From f210b05df5ce903855ce46c201fe62def7860e43 Mon Sep 17 00:00:00 2001 From: leneffets <74921589+leneffets@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:46:09 +0100 Subject: [PATCH 1/6] feat!: migrate from AWS SDK v1 to v2 BREAKING CHANGE: handler functions now accept service interfaces instead of *session.Session. Config uses config.LoadDefaultConfig instead of session.NewSession. All packages define their own interfaces for testability. --- cmd/server/main.go | 125 +++++++++++++++++++++++++++++++++++++-------- go.mod | 29 +++++++++-- go.sum | 56 +++++++++++++++----- pkg/ecr/ecr.go | 34 ++++++------ pkg/s3/s3.go | 70 +++++++++++++------------ pkg/ssm/ssm.go | 79 ++++++++++++++++------------ pkg/sts/sts.go | 28 +++++----- 7 files changed, 289 insertions(+), 132 deletions(-) diff --git a/cmd/server/main.go b/cmd/server/main.go index 1a57847..13b21c5 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -1,36 +1,91 @@ package main import ( - "log" + "context" + "log/slog" "net/http" "os" + "os/signal" + "syscall" + "time" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/leneffets/awsserver/pkg/ecr" - "github.com/leneffets/awsserver/pkg/s3" - "github.com/leneffets/awsserver/pkg/ssm" - "github.com/leneffets/awsserver/pkg/sts" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/sts" + ecrpkg "github.com/leneffets/awsserver/pkg/ecr" + s3pkg "github.com/leneffets/awsserver/pkg/s3" + smpkg "github.com/leneffets/awsserver/pkg/secretsmanager" + ssmpkg "github.com/leneffets/awsserver/pkg/ssm" + stspkg "github.com/leneffets/awsserver/pkg/sts" ) +type statusRecorder struct { + http.ResponseWriter + status int +} + +func (r *statusRecorder) WriteHeader(code int) { + r.status = code + r.ResponseWriter.WriteHeader(code) +} + +func loggingMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + slog.Info("request", + "method", r.Method, + "path", r.URL.Path, + "status", rec.status, + "duration", time.Since(start).String(), + ) + }) +} + func main() { - sess := session.Must(session.NewSessionWithOptions(session.Options{ - SharedConfigState: session.SharedConfigEnable, - })) + slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) + + cfg, err := config.LoadDefaultConfig(context.Background()) + if err != nil { + slog.Error("failed to load AWS config", "error", err) + os.Exit(1) + } + + ssmSvc := ssm.NewFromConfig(cfg) + s3Svc := s3.NewFromConfig(cfg) + ecrSvc := ecr.NewFromConfig(cfg) + stsSvc := sts.NewFromConfig(cfg) + smSvc := secretsmanager.NewFromConfig(cfg) + + mux := http.NewServeMux() - http.HandleFunc("/ssm", func(w http.ResponseWriter, r *http.Request) { - ssm.HandleSSM(w, r, sess) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) }) - http.HandleFunc("/s3", func(w http.ResponseWriter, r *http.Request) { - s3.HandleS3(w, r, sess) + mux.HandleFunc("/ssm", func(w http.ResponseWriter, r *http.Request) { + ssmpkg.HandleSSM(w, r, ssmSvc) }) - http.HandleFunc("/ecr/login", func(w http.ResponseWriter, r *http.Request) { - ecr.HandleECRLogin(w, r, sess) + mux.HandleFunc("/s3", func(w http.ResponseWriter, r *http.Request) { + s3pkg.HandleS3(w, r, s3Svc) }) - http.HandleFunc("/sts", func(w http.ResponseWriter, r *http.Request) { - sts.HandleSTS(w, r, sess) + mux.HandleFunc("/ecr/login", func(w http.ResponseWriter, r *http.Request) { + ecrpkg.HandleECRLogin(w, r, ecrSvc) + }) + + mux.HandleFunc("/sts", func(w http.ResponseWriter, r *http.Request) { + stspkg.HandleSTS(w, r, stsSvc) + }) + + mux.HandleFunc("/secrets", func(w http.ResponseWriter, r *http.Request) { + smpkg.HandleSecrets(w, r, smSvc) }) port := os.Getenv("PORT") @@ -38,8 +93,38 @@ func main() { port = "3000" } - log.Printf("Server running on port %s", port) - if err := http.ListenAndServe(":"+port, nil); err != nil { - log.Fatalf("Error starting server: %v", err) + bind := os.Getenv("BIND_ADDRESS") + if bind == "" { + bind = "0.0.0.0" + } + + addr := bind + ":" + port + srv := &http.Server{ + Addr: addr, + Handler: loggingMiddleware(mux), + ReadTimeout: 15 * time.Second, + WriteTimeout: 60 * time.Second, + IdleTimeout: 120 * time.Second, + } + + go func() { + slog.Info("server starting", "addr", addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slog.Error("server failed", "error", err) + os.Exit(1) + } + }() + + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + slog.Info("shutting down server") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := srv.Shutdown(ctx); err != nil { + slog.Error("server forced to shutdown", "error", err) + os.Exit(1) } + slog.Info("server stopped") } diff --git a/go.mod b/go.mod index fffa766..c9845df 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,30 @@ module github.com/leneffets/awsserver -go 1.22.7 +go 1.26 -require github.com/aws/aws-sdk-go v1.55.8 +require ( + github.com/aws/aws-sdk-go-v2 v1.41.5 + github.com/aws/aws-sdk-go-v2/config v1.29.14 + github.com/aws/aws-sdk-go-v2/service/ecr v1.44.1 + github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3 + github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 + github.com/aws/aws-sdk-go-v2/service/ssm v1.58.1 + github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 +) -require github.com/jmespath/go-jmespath v0.4.0 // indirect +require ( + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.17.67 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 // indirect + github.com/aws/smithy-go v1.24.2 // indirect +) diff --git a/go.sum b/go.sum index 4412e1d..c62000a 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,42 @@ -github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= -github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= -gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= +github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10 h1:zAybnyUQXIZ5mok5Jqwlf58/TFE7uvd3IAsa1aF9cXs= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.10/go.mod h1:qqvMj6gHLR/EXWZw4ZbqlPbQUyenf4h82UQUlKc+l14= +github.com/aws/aws-sdk-go-v2/config v1.29.14 h1:f+eEi/2cKCg9pqKBoAIwRGzVb70MRKqWX4dg1BDcSJM= +github.com/aws/aws-sdk-go-v2/config v1.29.14/go.mod h1:wVPHWcIFv3WO89w0rE10gzf17ZYy+UVS1Geq8Iei34g= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67 h1:9KxtdcIA/5xPNQyZRgUSpYOE6j9Bc4+D7nZua0KGYOM= +github.com/aws/aws-sdk-go-v2/credentials v1.17.67/go.mod h1:p3C44m+cfnbv763s52gCqrjaqyPikj9Sg47kUVaNZQQ= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30 h1:x793wxmUWVDhshP8WW2mlnXuFrO4cOd3HLBroh1paFw= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.30/go.mod h1:Jpne2tDnYiFascUEs2AWHJL9Yp7A5ZVy3TNyxaAjD6M= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 h1:bIqFDwgGXXN1Kpp99pDOdKMTTb5d2KyU5X/BZxjOkRo= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3/go.mod h1:H5O/EsxDWyU+LP/V8i5sm8cxoZgc2fdNR9bxlOFrQTo= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34 h1:ZNTqv4nIdE/DiBfUUfXcLZ/Spcuz+RjeziUtNJackkM= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.3.34/go.mod h1:zf7Vcd1ViW7cPqYWEHLHJkS50X0JS2IKz9Cgaj6ugrs= +github.com/aws/aws-sdk-go-v2/service/ecr v1.44.1 h1:tvGdftJBAi5sos34vphJ2EAbelTOyHMojnMlcTGi0Xw= +github.com/aws/aws-sdk-go-v2/service/ecr v1.44.1/go.mod h1:iQ1skgw1XRK+6Lgkb0I9ODatAP72WoTILh0zXQ5DtbU= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3 h1:eAh2A4b5IzM/lum78bZ590jy36+d/aFLgKF/4Vd1xPE= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.3/go.mod h1:0yKJC/kb8sAnmlYa6Zs3QVYqaC8ug2AbnNChv5Ox3uA= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1 h1:4nm2G6A4pV9rdlWzGMPv4BNtQp22v1hg3yrtkYpeLl8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.7.1/go.mod h1:iu6FSzgt+M2/x3Dk8zhycdIcHjEFb36IS8HVUVFoMg0= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15 h1:dM9/92u2F1JbDaGooxTq18wmmFzbJRfXfVfy96/1CXM= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.15/go.mod h1:SwFBy2vjtA0vZbjjaFtfN045boopadnoVPhu4Fv66vY= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15 h1:moLQUoVq91LiqT1nbvzDukyqAlCv89ZmwaHw/ZFlFZg= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.18.15/go.mod h1:ZH34PJUc8ApjBIfgQCFvkWcUDBtl/WTD+uiYHjd8igA= +github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3 h1:BRXS0U76Z8wfF+bnkilA2QwpIch6URlm++yPUt9QPmQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.79.3/go.mod h1:bNXKFFyaiVvWuR6O16h/I1724+aXe/tAkA9/QS01t5k= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5 h1:z2ayoK3pOvf8ODj/vPR0FgAS5ONruBq0F94SRoW/BIU= +github.com/aws/aws-sdk-go-v2/service/secretsmanager v1.41.5/go.mod h1:mpZB5HAl4ZIISod9qCi12xZ170TbHX9CCJV5y7nb7QU= +github.com/aws/aws-sdk-go-v2/service/ssm v1.58.1 h1:GLyAQEth2SljkC2DP5iK2GMkzgrGvURD+NEBVgQer3I= +github.com/aws/aws-sdk-go-v2/service/ssm v1.58.1/go.mod h1:PUWUl5MDiYNQkUHN9Pyd9kgtA/YhbxnSnHP+yQqzrM8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3 h1:1Gw+9ajCV1jogloEv1RRnvfRFia2cL6c9cuKV2Ps+G8= +github.com/aws/aws-sdk-go-v2/service/sso v1.25.3/go.mod h1:qs4a9T5EMLl/Cajiw2TcbNt2UNo/Hqlyp+GiuG4CFDI= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1 h1:hXmVKytPfTy5axZ+fYbR5d0cFmC3JvwLm5kM83luako= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.1/go.mod h1:MlYRNmYu/fGPoxBQVvBYr9nyr948aY/WLUvwBMBJubs= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19 h1:1XuUZ8mYJw9B6lzAkXhqHlJd/XvaX32evhproijJEZY= +github.com/aws/aws-sdk-go-v2/service/sts v1.33.19/go.mod h1:cQnB8CUnxbMU82JvlqjKR2HBOm3fe9pWorWBza6MBJ4= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= diff --git a/pkg/ecr/ecr.go b/pkg/ecr/ecr.go index c50ee98..d19c04e 100644 --- a/pkg/ecr/ecr.go +++ b/pkg/ecr/ecr.go @@ -3,32 +3,35 @@ package ecr import ( "context" "encoding/base64" - "log" + "log/slog" "net/http" "strings" "time" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/ecr" ) -func GetECRCredentials(ctx context.Context, sess *session.Session) (*ecr.GetAuthorizationTokenOutput, error) { - svc := ecr.New(sess) - return svc.GetAuthorizationTokenWithContext(ctx, &ecr.GetAuthorizationTokenInput{}) +// ECRAPI defines the interface for ECR operations used by this package. +type ECRAPI interface { + GetAuthorizationToken(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) } -func HandleECRLogin(w http.ResponseWriter, r *http.Request, sess *session.Session) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() +func GetECRCredentials(ctx context.Context, svc ECRAPI) (*ecr.GetAuthorizationTokenOutput, error) { + return svc.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{}) +} +func HandleECRLogin(w http.ResponseWriter, r *http.Request, svc ECRAPI) { if r.Method != http.MethodGet { http.Error(w, "Invalid method", http.StatusMethodNotAllowed) return } - results, err := GetECRCredentials(ctx, sess) + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + results, err := GetECRCredentials(ctx, svc) if err != nil { - log.Printf("Error fetching ECR credentials: %v", err) + slog.Error("failed to fetch ECR credentials", "error", err) http.Error(w, "Error fetching ECR credentials", http.StatusInternalServerError) return } @@ -41,7 +44,7 @@ func HandleECRLogin(w http.ResponseWriter, r *http.Request, sess *session.Sessio authData := results.AuthorizationData[0] decodedToken, err := base64.StdEncoding.DecodeString(*authData.AuthorizationToken) if err != nil { - log.Printf("Error decoding authorization token: %v", err) + slog.Error("failed to decode authorization token", "error", err) http.Error(w, "Error decoding authorization token", http.StatusInternalServerError) return } @@ -52,12 +55,9 @@ func HandleECRLogin(w http.ResponseWriter, r *http.Request, sess *session.Sessio return } - password := tokenParts[1] - w.Header().Set("Content-Type", "text/plain") w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte(password)); err != nil { - http.Error(w, "Error writing response", http.StatusInternalServerError) - log.Printf("Error writing response: %v", err) + if _, err := w.Write([]byte(tokenParts[1])); err != nil { + slog.Error("failed to write response", "error", err) } } diff --git a/pkg/s3/s3.go b/pkg/s3/s3.go index 58a0cad..5afa94a 100644 --- a/pkg/s3/s3.go +++ b/pkg/s3/s3.go @@ -3,19 +3,23 @@ package s3 import ( "context" "io" - "log" + "log/slog" "net/http" "os" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/s3" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" ) -func GetFromS3(ctx context.Context, sess *session.Session, bucket, key string) (io.ReadCloser, error) { - svc := s3.New(sess) - output, err := svc.GetObjectWithContext(ctx, &s3.GetObjectInput{ +// S3API defines the interface for S3 operations used by this package. +type S3API interface { + GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) + PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) +} + +func GetFromS3(ctx context.Context, svc S3API, bucket, key string) (io.ReadCloser, error) { + output, err := svc.GetObject(ctx, &s3.GetObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), }) @@ -25,9 +29,8 @@ func GetFromS3(ctx context.Context, sess *session.Session, bucket, key string) ( return output.Body, nil } -func PutToS3(ctx context.Context, sess *session.Session, bucket, key string, body io.ReadSeeker) error { - svc := s3.New(sess) - _, err := svc.PutObjectWithContext(ctx, &s3.PutObjectInput{ +func PutToS3(ctx context.Context, svc S3API, bucket, key string, body io.Reader) error { + _, err := svc.PutObject(ctx, &s3.PutObjectInput{ Bucket: aws.String(bucket), Key: aws.String(key), Body: body, @@ -35,7 +38,7 @@ func PutToS3(ctx context.Context, sess *session.Session, bucket, key string, bod return err } -func HandleS3(w http.ResponseWriter, r *http.Request, sess *session.Session) { +func HandleS3(w http.ResponseWriter, r *http.Request, svc S3API) { bucket := r.URL.Query().Get("bucket") key := r.URL.Query().Get("key") if bucket == "" || key == "" { @@ -43,40 +46,42 @@ func HandleS3(w http.ResponseWriter, r *http.Request, sess *session.Session) { return } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() - - if r.Method == http.MethodGet { - handleGetS3(w, r, sess, bucket, key, ctx) - } else if r.Method == http.MethodPost { - handlePostS3(w, r, sess, bucket, key, ctx) - } else { + switch r.Method { + case http.MethodGet: + handleGetS3(w, r, svc, bucket, key) + case http.MethodPost: + handlePostS3(w, r, svc, bucket, key) + default: http.Error(w, "Invalid method", http.StatusMethodNotAllowed) } } -func handleGetS3(w http.ResponseWriter, r *http.Request, sess *session.Session, bucket, key string, ctx context.Context) { - body, err := GetFromS3(ctx, sess, bucket, key) +func handleGetS3(w http.ResponseWriter, r *http.Request, svc S3API, bucket, key string) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + body, err := GetFromS3(ctx, svc, bucket, key) if err != nil { http.Error(w, "Error fetching file from S3", http.StatusInternalServerError) - log.Printf("Error fetching file from S3: %v", err) + slog.Error("failed to fetch file from S3", "error", err) return } defer body.Close() w.Header().Set("Content-Type", "application/octet-stream") if _, err := io.Copy(w, body); err != nil { - http.Error(w, "Error sending file", http.StatusInternalServerError) - log.Printf("Error sending file: %v", err) - return + slog.Error("failed to send file", "error", err) } } -func handlePostS3(w http.ResponseWriter, r *http.Request, sess *session.Session, bucket, key string, ctx context.Context) { +func handlePostS3(w http.ResponseWriter, r *http.Request, svc S3API, bucket, key string) { + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + file, _, err := r.FormFile("file") if err != nil { http.Error(w, "Error reading uploaded file", http.StatusBadRequest) - log.Printf("Error reading uploaded file: %v", err) + slog.Error("failed to read uploaded file", "error", err) return } defer file.Close() @@ -84,25 +89,26 @@ func handlePostS3(w http.ResponseWriter, r *http.Request, sess *session.Session, tempFile, err := os.CreateTemp("", "upload-*.tmp") if err != nil { http.Error(w, "Error creating temporary file", http.StatusInternalServerError) - log.Printf("Error creating temporary file: %v", err) + slog.Error("failed to create temp file", "error", err) return } + defer tempFile.Close() defer os.Remove(tempFile.Name()) if _, err := io.Copy(tempFile, file); err != nil { http.Error(w, "Error saving file", http.StatusInternalServerError) - log.Printf("Error saving file: %v", err) + slog.Error("failed to save file", "error", err) return } tempFile.Seek(0, 0) - if err := PutToS3(ctx, sess, bucket, key, tempFile); err != nil { + if err := PutToS3(ctx, svc, bucket, key, tempFile); err != nil { http.Error(w, "Error uploading file to S3", http.StatusInternalServerError) - log.Printf("Error uploading file to S3: %v", err) + slog.Error("failed to upload file to S3", "error", err) return } - log.Printf("File uploaded successfully to bucket %s with key %s", bucket, key) + slog.Info("file uploaded", "bucket", bucket, "key", key) w.WriteHeader(http.StatusOK) } diff --git a/pkg/ssm/ssm.go b/pkg/ssm/ssm.go index 2a9626c..e881300 100644 --- a/pkg/ssm/ssm.go +++ b/pkg/ssm/ssm.go @@ -2,58 +2,60 @@ package ssm import ( "context" - "log" + "log/slog" "net/http" "time" - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/ssm" - "github.com/aws/aws-sdk-go/service/ssm/ssmiface" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" ) -func GetParameter(ctx context.Context, svc ssmiface.SSMAPI, name *string) (*ssm.GetParameterOutput, error) { - results, err := svc.GetParameterWithContext(ctx, &ssm.GetParameterInput{ - Name: name, +// SSMAPI defines the interface for SSM operations used by this package. +type SSMAPI interface { + GetParameter(ctx context.Context, params *ssm.GetParameterInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) + PutParameter(ctx context.Context, params *ssm.PutParameterInput, optFns ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) +} + +func GetParameter(ctx context.Context, svc SSMAPI, name string) (*ssm.GetParameterOutput, error) { + return svc.GetParameter(ctx, &ssm.GetParameterInput{ + Name: aws.String(name), WithDecryption: aws.Bool(true), }) - return results, err } -func PutParameter(ctx context.Context, svc ssmiface.SSMAPI, name *string, value *string, typeStr *string) (*ssm.PutParameterOutput, error) { - results, err := svc.PutParameterWithContext(ctx, &ssm.PutParameterInput{ - Name: name, - Value: value, - Type: aws.String(*typeStr), +func PutParameter(ctx context.Context, svc SSMAPI, name, value string, paramType types.ParameterType) (*ssm.PutParameterOutput, error) { + return svc.PutParameter(ctx, &ssm.PutParameterInput{ + Name: aws.String(name), + Value: aws.String(value), + Type: paramType, }) - return results, err } -func HandleSSM(w http.ResponseWriter, r *http.Request, sess *session.Session) { - svc := ssm.New(sess) - - if r.Method == http.MethodGet { +func HandleSSM(w http.ResponseWriter, r *http.Request, svc SSMAPI) { + switch r.Method { + case http.MethodGet: handleGetSSM(w, r, svc) - } else if r.Method == http.MethodPost { + case http.MethodPost: HandlePostSSM(w, r, svc) - } else { + default: http.Error(w, "Invalid method", http.StatusMethodNotAllowed) } } -func handleGetSSM(w http.ResponseWriter, r *http.Request, svc ssmiface.SSMAPI) { - id := r.URL.Query().Get("name") - if id == "" { +func handleGetSSM(w http.ResponseWriter, r *http.Request, svc SSMAPI) { + name := r.URL.Query().Get("name") + if name == "" { http.Error(w, "Parameter 'name' is required", http.StatusBadRequest) return } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() - results, err := GetParameter(ctx, svc, &id) + results, err := GetParameter(ctx, svc, name) if err != nil { - log.Printf("Error fetching parameter: %v", err) + slog.Error("failed to fetch parameter", "error", err) http.Error(w, "Error fetching parameter", http.StatusInternalServerError) return } @@ -62,10 +64,10 @@ func handleGetSSM(w http.ResponseWriter, r *http.Request, svc ssmiface.SSMAPI) { w.Write([]byte(*results.Parameter.Value)) } -func HandlePostSSM(w http.ResponseWriter, r *http.Request, svc ssmiface.SSMAPI) { +func HandlePostSSM(w http.ResponseWriter, r *http.Request, svc SSMAPI) { if err := r.ParseForm(); err != nil { http.Error(w, "Invalid form data", http.StatusBadRequest) - log.Printf("Invalid form data: %v", err) + slog.Error("invalid form data", "error", err) return } @@ -78,16 +80,27 @@ func HandlePostSSM(w http.ResponseWriter, r *http.Request, svc ssmiface.SSMAPI) return } - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + var paramType types.ParameterType + switch typeStr { + case "String": + paramType = types.ParameterTypeString + case "SecureString": + paramType = types.ParameterTypeSecureString + default: + http.Error(w, "Parameter 'type' must be 'String' or 'SecureString'", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() - _, err := PutParameter(ctx, svc, &name, &value, &typeStr) + _, err := PutParameter(ctx, svc, name, value, paramType) if err != nil { http.Error(w, "Error putting parameter", http.StatusInternalServerError) - log.Printf("Error putting parameter: %v", err) + slog.Error("failed to put parameter", "error", err) return } - log.Printf("Parameter %s uploaded successfully", name) + slog.Info("parameter uploaded", "name", name) w.WriteHeader(http.StatusOK) } diff --git a/pkg/sts/sts.go b/pkg/sts/sts.go index 013b6a2..75ea21d 100644 --- a/pkg/sts/sts.go +++ b/pkg/sts/sts.go @@ -3,31 +3,34 @@ package sts import ( "context" "encoding/json" - "log" + "log/slog" "net/http" "time" - "github.com/aws/aws-sdk-go/aws/session" - "github.com/aws/aws-sdk-go/service/sts" + "github.com/aws/aws-sdk-go-v2/service/sts" ) -func GetCallerIdentity(ctx context.Context, sess *session.Session) (*sts.GetCallerIdentityOutput, error) { - svc := sts.New(sess) - return svc.GetCallerIdentityWithContext(ctx, &sts.GetCallerIdentityInput{}) +// STSAPI defines the interface for STS operations used by this package. +type STSAPI interface { + GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) } -func HandleSTS(w http.ResponseWriter, r *http.Request, sess *session.Session) { - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - defer cancel() +func GetCallerIdentity(ctx context.Context, svc STSAPI) (*sts.GetCallerIdentityOutput, error) { + return svc.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{}) +} +func HandleSTS(w http.ResponseWriter, r *http.Request, svc STSAPI) { if r.Method != http.MethodGet { http.Error(w, "Invalid method", http.StatusMethodNotAllowed) return } - results, err := GetCallerIdentity(ctx, sess) + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + results, err := GetCallerIdentity(ctx, svc) if err != nil { - log.Printf("Error fetching caller identity: %v", err) + slog.Error("failed to fetch caller identity", "error", err) http.Error(w, "Error fetching caller identity", http.StatusInternalServerError) return } @@ -35,7 +38,6 @@ func HandleSTS(w http.ResponseWriter, r *http.Request, sess *session.Session) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) if err := json.NewEncoder(w).Encode(results); err != nil { - http.Error(w, "Error encoding response", http.StatusInternalServerError) - log.Printf("Error encoding response: %v", err) + slog.Error("failed to encode response", "error", err) } } From a6e6044d15fe35264588b6757ed6fd7e92516a73 Mon Sep 17 00:00:00 2001 From: leneffets <74921589+leneffets@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:46:14 +0100 Subject: [PATCH 2/6] feat: add AWS Secrets Manager support New GET /secrets endpoint to fetch secrets by name or ARN. Supports both SecretString and SecretBinary responses. --- pkg/secretsmanager/secretsmanager.go | 55 ++++++++++++++ pkg/secretsmanager/secretsmanager_test.go | 87 +++++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 pkg/secretsmanager/secretsmanager.go create mode 100644 pkg/secretsmanager/secretsmanager_test.go diff --git a/pkg/secretsmanager/secretsmanager.go b/pkg/secretsmanager/secretsmanager.go new file mode 100644 index 0000000..75ae105 --- /dev/null +++ b/pkg/secretsmanager/secretsmanager.go @@ -0,0 +1,55 @@ +package secretsmanager + +import ( + "context" + "log/slog" + "net/http" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +// SecretsManagerAPI defines the interface for Secrets Manager operations. +type SecretsManagerAPI interface { + GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) +} + +func GetSecret(ctx context.Context, svc SecretsManagerAPI, name string) (*secretsmanager.GetSecretValueOutput, error) { + return svc.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(name), + }) +} + +func HandleSecrets(w http.ResponseWriter, r *http.Request, svc SecretsManagerAPI) { + if r.Method != http.MethodGet { + http.Error(w, "Invalid method", http.StatusMethodNotAllowed) + return + } + + name := r.URL.Query().Get("name") + if name == "" { + http.Error(w, "Parameter 'name' is required", http.StatusBadRequest) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) + defer cancel() + + result, err := GetSecret(ctx, svc, name) + if err != nil { + slog.Error("failed to fetch secret", "error", err) + http.Error(w, "Error fetching secret", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/plain") + if result.SecretString != nil { + w.Write([]byte(*result.SecretString)) + } else if result.SecretBinary != nil { + w.Header().Set("Content-Type", "application/octet-stream") + w.Write(result.SecretBinary) + } else { + http.Error(w, "Secret has no value", http.StatusInternalServerError) + } +} diff --git a/pkg/secretsmanager/secretsmanager_test.go b/pkg/secretsmanager/secretsmanager_test.go new file mode 100644 index 0000000..29b9af5 --- /dev/null +++ b/pkg/secretsmanager/secretsmanager_test.go @@ -0,0 +1,87 @@ +package secretsmanager + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" +) + +type mockSM struct { + resp secretsmanager.GetSecretValueOutput + err error +} + +func (m *mockSM) GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + return &m.resp, m.err +} + +func TestHandleSecrets(t *testing.T) { + mock := &mockSM{resp: secretsmanager.GetSecretValueOutput{ + SecretString: aws.String(`{"user":"admin","pass":"secret"}`), + }} + + req := httptest.NewRequest("GET", "/secrets?name=my-secret", nil) + rr := httptest.NewRecorder() + HandleSecrets(rr, req, mock) + + if rr.Code != http.StatusOK { + t.Errorf("got %d want %d", rr.Code, http.StatusOK) + } + if rr.Body.String() != `{"user":"admin","pass":"secret"}` { + t.Errorf("got %q want JSON secret", rr.Body.String()) + } +} + +func TestHandleSecrets_Binary(t *testing.T) { + mock := &mockSM{resp: secretsmanager.GetSecretValueOutput{ + SecretBinary: []byte{0x01, 0x02, 0x03}, + }} + + req := httptest.NewRequest("GET", "/secrets?name=my-binary-secret", nil) + rr := httptest.NewRecorder() + HandleSecrets(rr, req, mock) + + if rr.Code != http.StatusOK { + t.Errorf("got %d want %d", rr.Code, http.StatusOK) + } + if rr.Header().Get("Content-Type") != "application/octet-stream" { + t.Errorf("got Content-Type %q want application/octet-stream", rr.Header().Get("Content-Type")) + } +} + +func TestHandleSecrets_MissingName(t *testing.T) { + req := httptest.NewRequest("GET", "/secrets", nil) + rr := httptest.NewRecorder() + HandleSecrets(rr, req, &mockSM{}) + + if rr.Code != http.StatusBadRequest { + t.Errorf("got %d want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestHandleSecrets_AWSError(t *testing.T) { + mock := &mockSM{err: fmt.Errorf("aws error")} + + req := httptest.NewRequest("GET", "/secrets?name=bad", nil) + rr := httptest.NewRecorder() + HandleSecrets(rr, req, mock) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("got %d want %d", rr.Code, http.StatusInternalServerError) + } +} + +func TestHandleSecrets_MethodNotAllowed(t *testing.T) { + req := httptest.NewRequest("POST", "/secrets", nil) + rr := httptest.NewRecorder() + HandleSecrets(rr, req, &mockSM{}) + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("got %d want %d", rr.Code, http.StatusMethodNotAllowed) + } +} From 6279ec6e269652903440420f7d90f6590ddc87c5 Mon Sep 17 00:00:00 2001 From: leneffets <74921589+leneffets@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:46:21 +0100 Subject: [PATCH 3/6] test: add package-level tests and improve coverage Add per-package tests for SSM, S3, ECR, STS, and Secrets Manager. Tests cover happy path, error cases, missing params, and invalid methods. Integration tests now call real handlers instead of reimplementing logic. --- main_test.go | 427 +++++++++++++++++++------------------------- pkg/ecr/ecr_test.go | 75 ++++++++ pkg/s3/s3_test.go | 105 +++++++++++ pkg/ssm/ssm_test.go | 118 ++++++++++++ pkg/sts/sts_test.go | 68 +++++++ 5 files changed, 549 insertions(+), 244 deletions(-) create mode 100644 pkg/ecr/ecr_test.go create mode 100644 pkg/s3/s3_test.go create mode 100644 pkg/ssm/ssm_test.go create mode 100644 pkg/sts/sts_test.go diff --git a/main_test.go b/main_test.go index e93a974..0da57f7 100644 --- a/main_test.go +++ b/main_test.go @@ -5,366 +5,305 @@ import ( "context" "encoding/json" "io" - "io/ioutil" + "mime/multipart" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" - "time" - - "reflect" - - "github.com/aws/aws-sdk-go/aws" - "github.com/aws/aws-sdk-go/aws/request" - "github.com/aws/aws-sdk-go/service/ecr" - "github.com/aws/aws-sdk-go/service/ecr/ecriface" - "github.com/aws/aws-sdk-go/service/s3" - "github.com/aws/aws-sdk-go/service/s3/s3iface" - "github.com/aws/aws-sdk-go/service/ssm" - "github.com/aws/aws-sdk-go/service/ssm/ssmiface" - "github.com/aws/aws-sdk-go/service/sts" - "github.com/aws/aws-sdk-go/service/sts/stsiface" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr" + ecrtypes "github.com/aws/aws-sdk-go-v2/service/ecr/types" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + "github.com/aws/aws-sdk-go-v2/service/ssm" + ssmtypes "github.com/aws/aws-sdk-go-v2/service/ssm/types" + "github.com/aws/aws-sdk-go-v2/service/sts" + ecrpkg "github.com/leneffets/awsserver/pkg/ecr" + s3pkg "github.com/leneffets/awsserver/pkg/s3" + smpkg "github.com/leneffets/awsserver/pkg/secretsmanager" ssmpkg "github.com/leneffets/awsserver/pkg/ssm" + stspkg "github.com/leneffets/awsserver/pkg/sts" ) -// MockSSMAPI for testing +// Mock SSM type MockSSMAPI struct { - ssmiface.SSMAPI - Response ssm.GetParameterOutput - PutResponse ssm.PutParameterOutput - Err error + GetResp ssm.GetParameterOutput + PutResp ssm.PutParameterOutput + Err error } -func (m *MockSSMAPI) GetParameterWithContext(ctx context.Context, input *ssm.GetParameterInput, opts ...request.Option) (*ssm.GetParameterOutput, error) { - return &m.Response, m.Err +func (m *MockSSMAPI) GetParameter(ctx context.Context, params *ssm.GetParameterInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) { + return &m.GetResp, m.Err } -func (m *MockSSMAPI) PutParameterWithContext(ctx context.Context, input *ssm.PutParameterInput, opts ...request.Option) (*ssm.PutParameterOutput, error) { - return &m.PutResponse, m.Err +func (m *MockSSMAPI) PutParameter(ctx context.Context, params *ssm.PutParameterInput, optFns ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) { + return &m.PutResp, m.Err } -// MockS3API for testing +// Mock S3 type MockS3API struct { - s3iface.S3API - Response s3.GetObjectOutput - Err error + GetResp s3.GetObjectOutput + Err error } -func (m *MockS3API) GetObjectWithContext(ctx context.Context, input *s3.GetObjectInput, opts ...request.Option) (*s3.GetObjectOutput, error) { - return &m.Response, m.Err +func (m *MockS3API) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + return &m.GetResp, m.Err } -func (m *MockS3API) PutObjectWithContext(ctx context.Context, input *s3.PutObjectInput, opts ...request.Option) (*s3.PutObjectOutput, error) { +func (m *MockS3API) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { return &s3.PutObjectOutput{}, m.Err } -// MockECRAPI for testing +// Mock ECR type MockECRAPI struct { - ecriface.ECRAPI - Response ecr.GetAuthorizationTokenOutput - Err error + Resp ecr.GetAuthorizationTokenOutput + Err error } -func (m *MockECRAPI) GetAuthorizationTokenWithContext(ctx context.Context, input *ecr.GetAuthorizationTokenInput, opts ...request.Option) (*ecr.GetAuthorizationTokenOutput, error) { - return &m.Response, m.Err +func (m *MockECRAPI) GetAuthorizationToken(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { + return &m.Resp, m.Err } -// MockSTSAPI for testing +// Mock STS type MockSTSAPI struct { - stsiface.STSAPI - Response sts.GetCallerIdentityOutput - Err error + Resp sts.GetCallerIdentityOutput + Err error } -func (m *MockSTSAPI) GetCallerIdentityWithContext(ctx context.Context, input *sts.GetCallerIdentityInput, opts ...request.Option) (*sts.GetCallerIdentityOutput, error) { - return &m.Response, m.Err +func (m *MockSTSAPI) GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return &m.Resp, m.Err } -func TestGetParameter(t *testing.T) { - mockSvc := &MockSSMAPI{ - Response: ssm.GetParameterOutput{ - Parameter: &ssm.Parameter{ - Value: aws.String("mock_value"), - }, - }, - } +// Mock Secrets Manager +type MockSMAPI struct { + Resp secretsmanager.GetSecretValueOutput + Err error +} - req, err := http.NewRequest("GET", "/ssm?name=mock_name", nil) - if err != nil { - t.Fatal(err) - } +func (m *MockSMAPI) GetSecretValue(ctx context.Context, params *secretsmanager.GetSecretValueInput, optFns ...func(*secretsmanager.Options)) (*secretsmanager.GetSecretValueOutput, error) { + return &m.Resp, m.Err +} +func TestHealthz(t *testing.T) { + req := httptest.NewRequest("GET", "/healthz", nil) rr := httptest.NewRecorder() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - id := r.URL.Query().Get("name") - if id == "" { - http.Error(w, "Parameter 'name' is required", http.StatusBadRequest) - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - results, err := ssmpkg.GetParameter(ctx, mockSvc, &id) - if err != nil { - http.Error(w, "Error fetching parameter", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "text/plain") - w.Write([]byte(*results.Parameter.Value)) + w.WriteHeader(http.StatusOK) + w.Write([]byte("ok")) }) - handler.ServeHTTP(rr, req) - if status := rr.Code; status != http.StatusOK { - t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK) + if rr.Code != http.StatusOK { + t.Errorf("got %v want %v", rr.Code, http.StatusOK) + } + if rr.Body.String() != "ok" { + t.Errorf("got %v want ok", rr.Body.String()) + } +} + +func TestGetParameter(t *testing.T) { + mock := &MockSSMAPI{ + GetResp: ssm.GetParameterOutput{ + Parameter: &ssmtypes.Parameter{Value: aws.String("mock_value")}, + }, } - expected := "mock_value" - if rr.Body.String() != expected { - t.Errorf("Handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + req := httptest.NewRequest("GET", "/ssm?name=mock_name", nil) + rr := httptest.NewRecorder() + ssmpkg.HandleSSM(rr, req, mock) + + if rr.Code != http.StatusOK { + t.Errorf("got %v want %v", rr.Code, http.StatusOK) + } + if rr.Body.String() != "mock_value" { + t.Errorf("got %v want mock_value", rr.Body.String()) } } func TestPutParameter(t *testing.T) { - mockSvc := &MockSSMAPI{} + mock := &MockSSMAPI{} form := url.Values{} form.Add("name", "mock_name") form.Add("value", "mock_value") form.Add("type", "String") - req, err := http.NewRequest("POST", "/ssm", strings.NewReader(form.Encode())) - if err != nil { - t.Fatal(err) - } + req := httptest.NewRequest("POST", "/ssm", strings.NewReader(form.Encode())) req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - rr := httptest.NewRecorder() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ssmpkg.HandlePostSSM(w, r, mockSvc) - }) + ssmpkg.HandleSSM(rr, req, mock) - handler.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Errorf("got %v want %v", rr.Code, http.StatusOK) + } +} + +func TestPutParameterInvalidType(t *testing.T) { + mock := &MockSSMAPI{} + + form := url.Values{} + form.Add("name", "mock_name") + form.Add("value", "mock_value") + form.Add("type", "InvalidType") + + req := httptest.NewRequest("POST", "/ssm", strings.NewReader(form.Encode())) + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + ssmpkg.HandleSSM(rr, req, mock) - if status := rr.Code; status != http.StatusOK { - t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK) + if rr.Code != http.StatusBadRequest { + t.Errorf("got %v want %v", rr.Code, http.StatusBadRequest) } } func TestGetFromS3(t *testing.T) { - mockS3 := &MockS3API{ - Response: s3.GetObjectOutput{ - Body: ioutil.NopCloser(bytes.NewReader([]byte("mock_file_content"))), + mock := &MockS3API{ + GetResp: s3.GetObjectOutput{ + Body: io.NopCloser(bytes.NewReader([]byte("mock_file_content"))), }, } - req, err := http.NewRequest("GET", "/s3?bucket=mock_bucket&key=mock_key", nil) - if err != nil { - t.Fatal(err) - } - + req := httptest.NewRequest("GET", "/s3?bucket=mock_bucket&key=mock_key", nil) rr := httptest.NewRecorder() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - bucket := r.URL.Query().Get("bucket") - key := r.URL.Query().Get("key") - if bucket == "" || key == "" { - http.Error(w, "Parameters 'bucket' and 'key' are required", http.StatusBadRequest) - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - body, err := mockS3.GetObjectWithContext(ctx, &s3.GetObjectInput{ - Bucket: aws.String(bucket), - Key: aws.String(key), - }) - if err != nil { - http.Error(w, "Error fetching file from S3", http.StatusInternalServerError) - return - } - defer body.Body.Close() - - w.Header().Set("Content-Type", "application/octet-stream") - if _, err := io.Copy(w, body.Body); err != nil { - http.Error(w, "Error sending file", http.StatusInternalServerError) - return - } - }) - - handler.ServeHTTP(rr, req) + s3pkg.HandleS3(rr, req, mock) - if status := rr.Code; status != http.StatusOK { - t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK) + if rr.Code != http.StatusOK { + t.Errorf("got %v want %v", rr.Code, http.StatusOK) } - - expected := "mock_file_content" - if rr.Body.String() != expected { - t.Errorf("Handler returned unexpected body: got %v want %v", rr.Body.String(), expected) + if rr.Body.String() != "mock_file_content" { + t.Errorf("got %v want mock_file_content", rr.Body.String()) } } func TestPutToS3(t *testing.T) { - mockS3 := &MockS3API{} - - fileContent := "mock_file_content" - file := ioutil.NopCloser(bytes.NewReader([]byte(fileContent))) + mock := &MockS3API{} - req, err := http.NewRequest("POST", "/s3?bucket=mock_bucket&key=mock_key", file) + var buf bytes.Buffer + writer := multipart.NewWriter(&buf) + part, err := writer.CreateFormFile("file", "test.txt") if err != nil { t.Fatal(err) } + part.Write([]byte("mock_file_content")) + writer.Close() + req := httptest.NewRequest("POST", "/s3?bucket=mock_bucket&key=mock_key", &buf) + req.Header.Set("Content-Type", writer.FormDataContentType()) rr := httptest.NewRecorder() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - bucket := r.URL.Query().Get("bucket") - key := r.URL.Query().Get("key") - if bucket == "" || key == "" { - http.Error(w, "Parameters 'bucket' and 'key' are required", http.StatusBadRequest) - return - } - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Convert io.ReadCloser to io.ReadSeeker - readSeeker := bytes.NewReader([]byte(fileContent)) - - _, err = mockS3.PutObjectWithContext(ctx, &s3.PutObjectInput{ - Bucket: aws.String(bucket), - Key: aws.String(key), - Body: readSeeker, - }) - if err != nil { - http.Error(w, "Error uploading file to S3", http.StatusInternalServerError) - return - } - - w.WriteHeader(http.StatusOK) - }) - - handler.ServeHTTP(rr, req) + s3pkg.HandleS3(rr, req, mock) - if status := rr.Code; status != http.StatusOK { - t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK) + if rr.Code != http.StatusOK { + t.Errorf("got %v want %v", rr.Code, http.StatusOK) } } func TestGetECRLogin(t *testing.T) { - mockECR := &MockECRAPI{ - Response: ecr.GetAuthorizationTokenOutput{ - AuthorizationData: []*ecr.AuthorizationData{ + mock := &MockECRAPI{ + Resp: ecr.GetAuthorizationTokenOutput{ + AuthorizationData: []ecrtypes.AuthorizationData{ { - AuthorizationToken: aws.String("mock_token"), + AuthorizationToken: aws.String("QVdTOm1vY2tfcGFzc3dvcmQ="), ProxyEndpoint: aws.String("https://mock_endpoint"), }, }, }, } - req, err := http.NewRequest("GET", "/ecr/login", nil) - if err != nil { - t.Fatal(err) - } - + req := httptest.NewRequest("GET", "/ecr/login", nil) rr := httptest.NewRecorder() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - result, err := mockECR.GetAuthorizationTokenWithContext(ctx, &ecr.GetAuthorizationTokenInput{}) - if err != nil { - http.Error(w, "Error fetching ECR authorization token", http.StatusInternalServerError) - return - } + ecrpkg.HandleECRLogin(rr, req, mock) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) - }) - - handler.ServeHTTP(rr, req) - - if status := rr.Code; status != http.StatusOK { - t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK) - } - - expected := ecr.GetAuthorizationTokenOutput{ - AuthorizationData: []*ecr.AuthorizationData{ - { - AuthorizationToken: aws.String("mock_token"), - ProxyEndpoint: aws.String("https://mock_endpoint"), - ExpiresAt: nil, - }, - }, - } - - var actual ecr.GetAuthorizationTokenOutput - if err := json.Unmarshal(rr.Body.Bytes(), &actual); err != nil { - t.Fatalf("Failed to unmarshal response body: %v", err) + if rr.Code != http.StatusOK { + t.Errorf("got %v want %v", rr.Code, http.StatusOK) } - - if !reflect.DeepEqual(actual, expected) { - t.Errorf("Handler returned unexpected body: got %v want %v", actual, expected) + if rr.Body.String() != "mock_password" { + t.Errorf("got %v want mock_password", rr.Body.String()) } } func TestGetCallerIdentity(t *testing.T) { - mockSTS := &MockSTSAPI{ - Response: sts.GetCallerIdentityOutput{ + mock := &MockSTSAPI{ + Resp: sts.GetCallerIdentityOutput{ Account: aws.String("mock_account"), Arn: aws.String("mock_arn"), UserId: aws.String("mock_user_id"), }, } - req, err := http.NewRequest("GET", "/sts", nil) - if err != nil { - t.Fatal(err) - } - + req := httptest.NewRequest("GET", "/sts", nil) rr := httptest.NewRecorder() - handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() + stspkg.HandleSTS(rr, req, mock) - result, err := mockSTS.GetCallerIdentityWithContext(ctx, &sts.GetCallerIdentityInput{}) - if err != nil { - http.Error(w, "Error fetching caller identity", http.StatusInternalServerError) - return - } + if rr.Code != http.StatusOK { + t.Errorf("got %v want %v", rr.Code, http.StatusOK) + } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(result) - }) + var result map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + if result["Account"] != "mock_account" { + t.Errorf("got Account=%v want mock_account", result["Account"]) + } +} - handler.ServeHTTP(rr, req) +func TestGetSecret(t *testing.T) { + mock := &MockSMAPI{ + Resp: secretsmanager.GetSecretValueOutput{ + SecretString: aws.String("super_secret"), + }, + } - if status := rr.Code; status != http.StatusOK { - t.Errorf("Handler returned wrong status code: got %v want %v", status, http.StatusOK) + req := httptest.NewRequest("GET", "/secrets?name=my-secret", nil) + rr := httptest.NewRecorder() + smpkg.HandleSecrets(rr, req, mock) + + if rr.Code != http.StatusOK { + t.Errorf("got %v want %v", rr.Code, http.StatusOK) + } + if rr.Body.String() != "super_secret" { + t.Errorf("got %v want super_secret", rr.Body.String()) } +} + +func TestMethodNotAllowed(t *testing.T) { + mock := &MockSSMAPI{} + req := httptest.NewRequest("DELETE", "/ssm", nil) + rr := httptest.NewRecorder() + ssmpkg.HandleSSM(rr, req, mock) - expected := sts.GetCallerIdentityOutput{ - Account: aws.String("mock_account"), - Arn: aws.String("mock_arn"), - UserId: aws.String("mock_user_id"), + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("got %v want %v", rr.Code, http.StatusMethodNotAllowed) } +} + +func TestMissingParameters(t *testing.T) { + mock := &MockSSMAPI{} + req := httptest.NewRequest("GET", "/ssm", nil) + rr := httptest.NewRecorder() + ssmpkg.HandleSSM(rr, req, mock) - var actual sts.GetCallerIdentityOutput - if err := json.Unmarshal(rr.Body.Bytes(), &actual); err != nil { - t.Fatalf("Failed to unmarshal response body: %v", err) + if rr.Code != http.StatusBadRequest { + t.Errorf("got %v want %v", rr.Code, http.StatusBadRequest) } +} + +func TestS3MissingParameters(t *testing.T) { + mock := &MockS3API{} + req := httptest.NewRequest("GET", "/s3", nil) + rr := httptest.NewRecorder() + s3pkg.HandleS3(rr, req, mock) - if !reflect.DeepEqual(actual, expected) { - t.Errorf("Handler returned unexpected body: got %v want %v", actual, expected) + if rr.Code != http.StatusBadRequest { + t.Errorf("got %v want %v", rr.Code, http.StatusBadRequest) } } func TestMain(m *testing.M) { os.Setenv("PORT", "3000") - code := m.Run() - os.Exit(code) + os.Exit(m.Run()) } diff --git a/pkg/ecr/ecr_test.go b/pkg/ecr/ecr_test.go new file mode 100644 index 0000000..d549885 --- /dev/null +++ b/pkg/ecr/ecr_test.go @@ -0,0 +1,75 @@ +package ecr + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ecr" + "github.com/aws/aws-sdk-go-v2/service/ecr/types" +) + +type mockECR struct { + resp ecr.GetAuthorizationTokenOutput + err error +} + +func (m *mockECR) GetAuthorizationToken(ctx context.Context, params *ecr.GetAuthorizationTokenInput, optFns ...func(*ecr.Options)) (*ecr.GetAuthorizationTokenOutput, error) { + return &m.resp, m.err +} + +func TestHandleECRLogin(t *testing.T) { + mock := &mockECR{resp: ecr.GetAuthorizationTokenOutput{ + AuthorizationData: []types.AuthorizationData{ + {AuthorizationToken: aws.String("QVdTOnRlc3RfcGFzcw==")}, // base64("AWS:test_pass") + }, + }} + + req := httptest.NewRequest("GET", "/ecr/login", nil) + rr := httptest.NewRecorder() + HandleECRLogin(rr, req, mock) + + if rr.Code != http.StatusOK { + t.Errorf("got %d want %d", rr.Code, http.StatusOK) + } + if rr.Body.String() != "test_pass" { + t.Errorf("got %q want test_pass", rr.Body.String()) + } +} + +func TestHandleECRLogin_AWSError(t *testing.T) { + mock := &mockECR{err: fmt.Errorf("aws error")} + + req := httptest.NewRequest("GET", "/ecr/login", nil) + rr := httptest.NewRecorder() + HandleECRLogin(rr, req, mock) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("got %d want %d", rr.Code, http.StatusInternalServerError) + } +} + +func TestHandleECRLogin_NoAuthData(t *testing.T) { + mock := &mockECR{resp: ecr.GetAuthorizationTokenOutput{}} + + req := httptest.NewRequest("GET", "/ecr/login", nil) + rr := httptest.NewRecorder() + HandleECRLogin(rr, req, mock) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("got %d want %d", rr.Code, http.StatusInternalServerError) + } +} + +func TestHandleECRLogin_MethodNotAllowed(t *testing.T) { + req := httptest.NewRequest("POST", "/ecr/login", nil) + rr := httptest.NewRecorder() + HandleECRLogin(rr, req, &mockECR{}) + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("got %d want %d", rr.Code, http.StatusMethodNotAllowed) + } +} diff --git a/pkg/s3/s3_test.go b/pkg/s3/s3_test.go new file mode 100644 index 0000000..75c8d17 --- /dev/null +++ b/pkg/s3/s3_test.go @@ -0,0 +1,105 @@ +package s3 + +import ( + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/service/s3" +) + +type mockS3 struct { + getResp s3.GetObjectOutput + err error +} + +func (m *mockS3) GetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.Options)) (*s3.GetObjectOutput, error) { + return &m.getResp, m.err +} + +func (m *mockS3) PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error) { + return &s3.PutObjectOutput{}, m.err +} + +func TestHandleGetS3(t *testing.T) { + mock := &mockS3{getResp: s3.GetObjectOutput{ + Body: io.NopCloser(bytes.NewReader([]byte("file_content"))), + }} + + req := httptest.NewRequest("GET", "/s3?bucket=b&key=k", nil) + rr := httptest.NewRecorder() + HandleS3(rr, req, mock) + + if rr.Code != http.StatusOK { + t.Errorf("got %d want %d", rr.Code, http.StatusOK) + } + if rr.Body.String() != "file_content" { + t.Errorf("got %q want file_content", rr.Body.String()) + } +} + +func TestHandleGetS3_MissingParams(t *testing.T) { + req := httptest.NewRequest("GET", "/s3?bucket=b", nil) + rr := httptest.NewRecorder() + HandleS3(rr, req, &mockS3{}) + + if rr.Code != http.StatusBadRequest { + t.Errorf("got %d want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestHandleGetS3_AWSError(t *testing.T) { + mock := &mockS3{err: fmt.Errorf("aws error")} + + req := httptest.NewRequest("GET", "/s3?bucket=b&key=k", nil) + rr := httptest.NewRecorder() + HandleS3(rr, req, mock) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("got %d want %d", rr.Code, http.StatusInternalServerError) + } +} + +func TestHandlePostS3(t *testing.T) { + mock := &mockS3{} + + var buf bytes.Buffer + w := multipart.NewWriter(&buf) + part, _ := w.CreateFormFile("file", "test.txt") + part.Write([]byte("content")) + w.Close() + + req := httptest.NewRequest("POST", "/s3?bucket=b&key=k", &buf) + req.Header.Set("Content-Type", w.FormDataContentType()) + rr := httptest.NewRecorder() + HandleS3(rr, req, mock) + + if rr.Code != http.StatusOK { + t.Errorf("got %d want %d", rr.Code, http.StatusOK) + } +} + +func TestHandlePostS3_NoFile(t *testing.T) { + req := httptest.NewRequest("POST", "/s3?bucket=b&key=k", nil) + rr := httptest.NewRecorder() + HandleS3(rr, req, &mockS3{}) + + if rr.Code != http.StatusBadRequest { + t.Errorf("got %d want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestHandleS3_MethodNotAllowed(t *testing.T) { + req := httptest.NewRequest("DELETE", "/s3?bucket=b&key=k", nil) + rr := httptest.NewRecorder() + HandleS3(rr, req, &mockS3{}) + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("got %d want %d", rr.Code, http.StatusMethodNotAllowed) + } +} diff --git a/pkg/ssm/ssm_test.go b/pkg/ssm/ssm_test.go new file mode 100644 index 0000000..1052cd3 --- /dev/null +++ b/pkg/ssm/ssm_test.go @@ -0,0 +1,118 @@ +package ssm + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/ssm" + "github.com/aws/aws-sdk-go-v2/service/ssm/types" +) + +type mockSSM struct { + getResp ssm.GetParameterOutput + putResp ssm.PutParameterOutput + err error +} + +func (m *mockSSM) GetParameter(ctx context.Context, params *ssm.GetParameterInput, optFns ...func(*ssm.Options)) (*ssm.GetParameterOutput, error) { + return &m.getResp, m.err +} + +func (m *mockSSM) PutParameter(ctx context.Context, params *ssm.PutParameterInput, optFns ...func(*ssm.Options)) (*ssm.PutParameterOutput, error) { + return &m.putResp, m.err +} + +func TestHandleGetSSM(t *testing.T) { + mock := &mockSSM{getResp: ssm.GetParameterOutput{ + Parameter: &types.Parameter{Value: aws.String("test_value")}, + }} + + req := httptest.NewRequest("GET", "/ssm?name=test", nil) + rr := httptest.NewRecorder() + HandleSSM(rr, req, mock) + + if rr.Code != http.StatusOK { + t.Errorf("got %d want %d", rr.Code, http.StatusOK) + } + if rr.Body.String() != "test_value" { + t.Errorf("got %q want test_value", rr.Body.String()) + } +} + +func TestHandleGetSSM_MissingName(t *testing.T) { + req := httptest.NewRequest("GET", "/ssm", nil) + rr := httptest.NewRecorder() + HandleSSM(rr, req, &mockSSM{}) + + if rr.Code != http.StatusBadRequest { + t.Errorf("got %d want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestHandleGetSSM_AWSError(t *testing.T) { + mock := &mockSSM{err: fmt.Errorf("aws error")} + + req := httptest.NewRequest("GET", "/ssm?name=test", nil) + rr := httptest.NewRecorder() + HandleSSM(rr, req, mock) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("got %d want %d", rr.Code, http.StatusInternalServerError) + } +} + +func TestHandlePostSSM(t *testing.T) { + mock := &mockSSM{} + form := url.Values{"name": {"p"}, "value": {"v"}, "type": {"SecureString"}} + + req := httptest.NewRequest("POST", "/ssm", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + HandleSSM(rr, req, mock) + + if rr.Code != http.StatusOK { + t.Errorf("got %d want %d", rr.Code, http.StatusOK) + } +} + +func TestHandlePostSSM_InvalidType(t *testing.T) { + form := url.Values{"name": {"p"}, "value": {"v"}, "type": {"BadType"}} + + req := httptest.NewRequest("POST", "/ssm", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + HandleSSM(rr, req, &mockSSM{}) + + if rr.Code != http.StatusBadRequest { + t.Errorf("got %d want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestHandlePostSSM_MissingFields(t *testing.T) { + form := url.Values{"name": {"p"}} + + req := httptest.NewRequest("POST", "/ssm", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + HandleSSM(rr, req, &mockSSM{}) + + if rr.Code != http.StatusBadRequest { + t.Errorf("got %d want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestHandleSSM_MethodNotAllowed(t *testing.T) { + req := httptest.NewRequest("DELETE", "/ssm", nil) + rr := httptest.NewRecorder() + HandleSSM(rr, req, &mockSSM{}) + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("got %d want %d", rr.Code, http.StatusMethodNotAllowed) + } +} diff --git a/pkg/sts/sts_test.go b/pkg/sts/sts_test.go new file mode 100644 index 0000000..7f47b2a --- /dev/null +++ b/pkg/sts/sts_test.go @@ -0,0 +1,68 @@ +package sts + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/sts" +) + +type mockSTS struct { + resp sts.GetCallerIdentityOutput + err error +} + +func (m *mockSTS) GetCallerIdentity(ctx context.Context, params *sts.GetCallerIdentityInput, optFns ...func(*sts.Options)) (*sts.GetCallerIdentityOutput, error) { + return &m.resp, m.err +} + +func TestHandleSTS(t *testing.T) { + mock := &mockSTS{resp: sts.GetCallerIdentityOutput{ + Account: aws.String("123456789012"), + Arn: aws.String("arn:aws:iam::123456789012:user/test"), + UserId: aws.String("AIDEXAMPLE"), + }} + + req := httptest.NewRequest("GET", "/sts", nil) + rr := httptest.NewRecorder() + HandleSTS(rr, req, mock) + + if rr.Code != http.StatusOK { + t.Errorf("got %d want %d", rr.Code, http.StatusOK) + } + + var result map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &result); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if result["Account"] != "123456789012" { + t.Errorf("got Account=%v want 123456789012", result["Account"]) + } +} + +func TestHandleSTS_AWSError(t *testing.T) { + mock := &mockSTS{err: fmt.Errorf("aws error")} + + req := httptest.NewRequest("GET", "/sts", nil) + rr := httptest.NewRecorder() + HandleSTS(rr, req, mock) + + if rr.Code != http.StatusInternalServerError { + t.Errorf("got %d want %d", rr.Code, http.StatusInternalServerError) + } +} + +func TestHandleSTS_MethodNotAllowed(t *testing.T) { + req := httptest.NewRequest("POST", "/sts", nil) + rr := httptest.NewRecorder() + HandleSTS(rr, req, &mockSTS{}) + + if rr.Code != http.StatusMethodNotAllowed { + t.Errorf("got %d want %d", rr.Code, http.StatusMethodNotAllowed) + } +} From 74cce923030ebdf9f3d9d49162030060427b559d Mon Sep 17 00:00:00 2001 From: leneffets <74921589+leneffets@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:46:27 +0100 Subject: [PATCH 4/6] build: use scratch base image with non-root user Pin golang:1.26-alpine builder, add go mod download layer caching, copy CA certificates from builder, run as UID 65534 (nobody). --- Dockerfile | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 26f65c0..0a72014 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,13 @@ -FROM golang AS builder +FROM golang:1.26-alpine AS builder WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download COPY . . RUN CGO_ENABLED=0 go build -ldflags '-extldflags "-static"' -o ./output/awsserver ./cmd/server -FROM alpine +FROM scratch +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /app/output/awsserver /usr/local/bin/awsserver EXPOSE 3000 +USER 65534:65534 ENTRYPOINT ["/usr/local/bin/awsserver"] From 51f757e0496d34b523f6f5071c780aff8d345db0 Mon Sep 17 00:00:00 2001 From: leneffets <74921589+leneffets@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:46:36 +0100 Subject: [PATCH 5/6] ci: update pipelines with multi-arch builds and test steps - Update actions to v4, Go version to 1.26 - Add test step before build in both pipelines - CI: run on all branches, Docker build-only on feature branches, push on main - CI: multi-arch Docker (amd64+arm64) with QEMU - Release: build binaries for linux/darwin (amd64/arm64), attach to release - Release: multi-arch Docker image tagged with release version --- .github/workflows/ci.yml | 33 +++++++++++++++++++------- .github/workflows/release.yml | 44 +++++++++++++++++++++-------------- 2 files changed, 50 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96a9b7c..7153bab 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,26 +2,28 @@ name: CI Pipeline on: push: - branches: [ main ] pull_request: branches: [ main ] jobs: - build-and-docker: + test-and-build: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.25 + go-version: '1.26' - name: Install dependencies run: go mod tidy + - name: Run tests + run: go test -v ./... + - name: Build static Linux binary run: | mkdir -p output @@ -33,20 +35,33 @@ jobs: name: awsserver-linux-amd64 path: ./output/awsserver + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image (test only) + if: github.ref != 'refs/heads/main' + uses: docker/build-push-action@v6 + with: + context: . + push: false + platforms: linux/amd64 + - name: Log in to GitHub Container Registry + if: github.ref == 'refs/heads/main' uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - name: Build and push Docker image + if: github.ref == 'refs/heads/main' uses: docker/build-push-action@v6 with: context: . push: true + platforms: linux/amd64,linux/arm64 tags: ghcr.io/${{ github.repository_owner }}/awsserver:latest - diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ff77ba..e88aa56 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,40 +10,44 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v2 + uses: actions/setup-go@v4 with: - go-version: 1.25 + go-version: '1.26' - name: Install dependencies run: go mod tidy - - name: Build Binary - run: CGO_ENABLED=0 go build -ldflags '-extldflags "-static"' -o awsserver ./cmd/server + - name: Run tests + run: go test -v ./... - - name: Get build architecture - id: arch - run: echo "ARCH=$(uname -m)" >> $GITHUB_ENV + - name: Build binaries + run: | + mkdir -p dist + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '-extldflags "-static"' -o dist/awsserver-linux-amd64 ./cmd/server + CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -ldflags '-extldflags "-static"' -o dist/awsserver-linux-arm64 ./cmd/server + CGO_ENABLED=0 GOOS=darwin GOARCH=amd64 go build -ldflags '-extldflags "-static"' -o dist/awsserver-darwin-amd64 ./cmd/server + CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -ldflags '-extldflags "-static"' -o dist/awsserver-darwin-arm64 ./cmd/server - - name: Package Binary + - name: Package binaries run: | - ARCH=${{ env.ARCH }} - TAR_NAME="awsserver-linux-${ARCH}.tar.gz" - tar -czvf ${TAR_NAME} awsserver - echo "RELEASE_FILE=${TAR_NAME}" >> $GITHUB_ENV + cd dist + for f in awsserver-*; do + tar -czvf "${f}.tar.gz" "$f" + done - - name: Upload release artifact + - name: Upload release artifacts uses: actions/upload-artifact@v4 with: - name: awsserver-linux-${{ env.ARCH }} - path: ${{ env.RELEASE_FILE }} + name: awsserver-binaries + path: dist/*.tar.gz - - name: Upload artifact to GitHub Release + - name: Upload binaries to GitHub Release uses: softprops/action-gh-release@v2 with: - files: ${{ env.RELEASE_FILE }} + files: dist/*.tar.gz env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -54,6 +58,9 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -62,4 +69,5 @@ jobs: with: context: . push: true + platforms: linux/amd64,linux/arm64 tags: ghcr.io/${{ github.repository_owner }}/awsserver:${{ github.ref_name }} From a619a04980077bf718c310ce3e61de4f6e48955e Mon Sep 17 00:00:00 2001 From: leneffets <74921589+leneffets@users.noreply.github.com> Date: Sat, 28 Mar 2026 17:46:43 +0100 Subject: [PATCH 6/6] docs: update README and fix project config - Update Go requirement to 1.26, container image to v2.0.0 - Document all endpoints including healthz and secrets manager - Add BIND_ADDRESS env var, graceful shutdown, GitLab CI sidecar example - Document multi-arch builds in CI/CD section - Fix trailing comma in devcontainer.json - Add dist/ to .gitignore --- .devcontainer/devcontainer.json | 2 +- .gitignore | 3 +- README.md | 124 +++++++++++++++++++------------- 3 files changed, 78 insertions(+), 51 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 24dc44d..fb36f5d 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -13,5 +13,5 @@ ] } }, - "forwardPorts": [3000], + "forwardPorts": [3000] } diff --git a/.gitignore b/.gitignore index 6caf68a..182ca65 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -output \ No newline at end of file +output +dist diff --git a/README.md b/README.md index 5e2fd6c..4ab8be3 100644 --- a/README.md +++ b/README.md @@ -5,60 +5,73 @@ This project provides an HTTP server using Go, which interacts with AWS Services ## Features - Fetch decrypted parameters from AWS SSM. +- Put parameters to AWS SSM. +- Fetch secrets from AWS Secrets Manager. - Fetch and serve files from AWS S3. - Upload files to AWS S3. - Fetch ECR authorization token. - Fetch caller identity from AWS STS. -- Basic CI/CD pipeline using GitHub Actions for automatic builds and tests. +- CI/CD pipeline using GitHub Actions for automatic builds, tests, and container image publishing. ## Requirements -- Go 1.17 or later -- AWS CLI configured with necessary permissions +- Go 1.26 or later +- AWS credentials configured (e.g. `~/.aws/credentials`, environment variables, or IAM role) - Git installed ## Container Image -Get the Container Image +```sh +docker pull ghcr.io/leneffets/awsserver:v2.0.0 +docker pull ghcr.io/leneffets/awsserver:latest +``` - ```sh - docker pull ghcr.io/leneffets/awsserver:v1.0.0 - docker pull ghcr.io/leneffets/awsserver:latest - ``` +The container image uses a `scratch` base (zero OS packages), runs as a non-root user, and is available for `linux/amd64` and `linux/arm64`. ## Setup 1. **Clone the repository:** ```sh - git clone git@github.com:USERNAME/REPO_NAME.git - cd REPO_NAME + git clone git@github.com:leneffets/awsserver.git + cd awsserver ``` -2. **Initialize the Go module:** +2. **Install dependencies:** ```sh go mod tidy ``` -3. **Configure AWS credentials:** +## Running the Server - Ensure you have AWS credentials configured, typically in `~/.aws/credentials`. +The server binds to `0.0.0.0` by default. To start it: -## Running the Server +```sh +# Port may be changed via environment variable, default 3000 +export PORT=3000 -To start the HTTP server locally on port 3000, run the following command: - - # Port may be changed via Environment, default 3000 - export PORT=3000 - go run cmd/server/main.go +# Bind address may be changed, default 0.0.0.0 +export BIND_ADDRESS=127.0.0.1 +go run cmd/server/main.go +``` + +The server shuts down gracefully on SIGINT/SIGTERM, finishing in-flight requests before stopping. ## Endpoints -### Fetch SSM Parameter +### Health Check -Fetch a decrypted parameter from AWS SSM. +- **URL:** `/healthz` +- **Method:** `GET` +- **Example:** + + ```sh + curl "http://localhost:3000/healthz" + ``` + +### Fetch SSM Parameter - **URL:** `/ssm` - **Method:** `GET` @@ -72,23 +85,31 @@ Fetch a decrypted parameter from AWS SSM. ### Put SSM Parameter -Put a parameter to AWS SSM. - - **URL:** `/ssm` - **Method:** `POST` -- **Query Parameters:** - - `name`: Name of the SSM parameter to put. - - `value`: Value of the SSM parameter to put. - - `type`: Type of the SSM parameter to put. (String, SecureString) +- **Form Parameters:** + - `name`: Name of the SSM parameter. + - `value`: Value of the SSM parameter. + - `type`: Type of the SSM parameter (`String` or `SecureString`). - **Example:** ```sh curl -X POST -d "name=/path/to/parameter&value=somevalue&type=String" http://localhost:3000/ssm ``` -### Fetch S3 File +### Fetch Secret from Secrets Manager -Fetch a file from an S3 bucket. +- **URL:** `/secrets` +- **Method:** `GET` +- **Query Parameters:** + - `name`: Name or ARN of the secret to fetch. +- **Example:** + + ```sh + curl "http://localhost:3000/secrets?name=my-app/db-credentials" + ``` + +### Fetch S3 File - **URL:** `/s3` - **Method:** `GET` @@ -103,8 +124,6 @@ Fetch a file from an S3 bucket. ### Upload S3 File -Upload a file to an S3 bucket. - - **URL:** `/s3` - **Method:** `POST` - **Query Parameters:** @@ -118,20 +137,16 @@ Upload a file to an S3 bucket. ### Get ECR Login -Fetch an authorization token for ECR. - - **URL:** `/ecr/login` - **Method:** `GET` - **Example:** ```sh - curl "http://localhost:3000/ecr/login" | docker login --username AWS --password-stdin aws-account-id.dkr.ecr.eu-central-1.amazonaws.com + curl "http://localhost:3000/ecr/login" | docker login --username AWS --password-stdin .dkr.ecr..amazonaws.com ``` ### Get Caller Identity -Fetch the caller identity from AWS STS. - - **URL:** `/sts` - **Method:** `GET` - **Example:** @@ -140,25 +155,37 @@ Fetch the caller identity from AWS STS. curl "http://localhost:3000/sts" ``` -## Running Tests +## Usage as a GitLab CI Sidecar -To run the tests: +This server is designed to run as a sidecar service in GitLab CI, giving your jobs easy access to AWS services without installing the AWS CLI. - go test -v ./... +```yaml +my-job: + image: alpine:latest + services: + - name: ghcr.io/leneffets/awsserver:latest + alias: awsserver + variables: + AWS_REGION: eu-central-1 + script: + - SECRET=$(curl -s "http://awsserver:3000/ssm?name=/my-app/db-password") + - echo "Fetched secret successfully" +``` -## CI/CD Pipeline with GitHub Actions +> **Note:** When running as a GitLab CI service, the server is reachable via the `alias` hostname (here `awsserver`) on port 3000. Make sure your AWS credentials are set as [CI/CD variables](https://docs.gitlab.com/ee/ci/variables/) in your project or group settings. -This project uses GitHub Actions for continuous integration. The pipeline is defined in `.github/workflows/ci.yml` and performs the following actions on each push or pull request to the `main` branch: +## Running Tests + +```sh +go test -v ./... +``` -- Checks out the code. -- Sets up the Go environment. -- Installs dependencies. -- Builds the project. -- Runs tests. +## CI/CD Pipeline -3. **Review pipeline runs:** +This project uses GitHub Actions with two workflows: - Go to the `Actions` tab in your GitHub repository to view the status of the workflow runs. +- **CI Pipeline** (`.github/workflows/ci.yml`): Runs on pushes and PRs to `main`. Checks out code, runs tests, builds a static binary, and pushes a Docker image (`:latest`) on pushes to `main`. +- **Release Pipeline** (`.github/workflows/release.yml`): Runs on published releases. Builds static binaries for linux/darwin (amd64/arm64), uploads them as release artifacts (`.tar.gz`), and pushes a multi-arch Docker image tagged with the release version. ## Contribution @@ -167,4 +194,3 @@ Feel free to fork this repository and create pull requests. For major changes, p ## License This project is licensed under the MIT License. -