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/.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 }} 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/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"] 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. - 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/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.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/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.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/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/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) + } +} 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/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.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) } } 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) + } +}