From cc0c27a4e9cb04d590b8eeecd536e3f730038fd9 Mon Sep 17 00:00:00 2001 From: Timo Bergen Date: Mon, 7 Jul 2025 21:32:39 +0200 Subject: [PATCH] feat: STACKIT Secrets Manager Action --- .forgejo/workflows/release.yml | 52 +++++++++++++++++++++++ README.md | 51 +++++++++++++++++++++++ action.yml | 59 ++++++++++++++++++++++++++ config/config.go | 75 ++++++++++++++++++++++++++++++++++ go.mod | 21 ++++++++++ go.sum | 38 +++++++++++++++++ main.go | 27 ++++++++++++ secretsmanager/client.go | 41 +++++++++++++++++++ secretsmanager/secrets.go | 37 +++++++++++++++++ 9 files changed, 401 insertions(+) create mode 100644 .forgejo/workflows/release.yml create mode 100644 README.md create mode 100644 action.yml create mode 100644 config/config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 secretsmanager/client.go create mode 100644 secretsmanager/secrets.go diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml new file mode 100644 index 0000000..5de3e41 --- /dev/null +++ b/.forgejo/workflows/release.yml @@ -0,0 +1,52 @@ +name: Release Secrets Manager Action + +on: + push: + tags: + - v* + +env: + build_name: action-secretsmanager + +jobs: + build: + runs-on: docker + steps: + - name: 📤 Checkout source code + uses: actions/checkout@v4 + + - name: ⚙️ Set up Go + uses: actions/setup-go@v5 + with: + go-version: 1.24 + + - name: ⚙️ Install dependencies + run: | + apt-get -y -qq update && apt-get -qq -y install jq + + - name: 👨🏻‍🔧 Build app + run: | + go build -o ${{ env.build_name }} + + - name: 🤠 Create release + run: | + set -e + + echo "Creating release for ${{ env.GITHUB_REPOSITORY}} with tag ${{ env.GITHUB_REF_NAME }}" + + REQUEST=$(curl --request POST \ + --url ${{ env.GITHUB_API_URL }}/repos/${{ env.GITHUB_REPOSITORY }}/releases \ + --header 'Authorization: token ${{ secrets.GIT_TOKEN }}' \ + --header 'content-type: application/json' \ + --data '{ "tag_name": "${{ env.GITHUB_REF_NAME }}" }') + + ls -lh ${{ env.build_name }} + + RELEASE_ID=$(echo $REQUEST | jq .id) + + echo "Uploading release asset for Release ID ${RELEASE_ID}" + + curl --request POST \ + --url ${{ env.GITHUB_API_URL }}/repos/${{ env.GITHUB_REPOSITORY }}/releases/${RELEASE_ID}/assets?name=${{ env.build_name }} \ + --header 'Authorization: token ${{ secrets.GIT_TOKEN }}' \ + -F 'attachment=@${{ env.build_name}}' \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e2417de --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +# STACKIT Secrets Manager Action + +## parameters + +| parameter | description | default | +| --- | --- | --- | +| vault_addr | Secrets Manager Base URL | https://prod.sm.eu01.stackit.cloud | +| vault_id | Your Secrets Manager ID, looks something like this: 6d9060fd-59b4-4dda-9106-b2dbe88acf65 | - | +| vault_username | Your Secrets Manager Username, looks something like this: sms96o170771ttt6 | - | +| vault_password | Your Secrets Manager Password, a random generated password provided by the STACKIT Portal | - | +| vault_path | The Path to your Secret can be some like this: "test" or "folder/test" | - | +| debug | true or false, enable or disable Debug Logging | false | + +## usage + +In this example we assume that there is a Secret on Path "${{ secrets.VAULT_PATH}}" and there is a KVSecret named "test". +In the "Output secret" step we output above mentioned KVSecret "test". We access the outputs of the secrets step. + +Keep in mind to set an id on the actions step and use that to reference the outputted secrets. + +```yaml +name: Secrets Manager Action + +on: + push: + workflow_dispatch: + +jobs: + get-vault-secrets: + runs-on: docker + # here we can also define outputs for use in other stages + # keep in mind that other "stages" need to define a "needs" for this job + outputs: + # here i use our example secret "test" + test: ${{ steps.fetch-secrets.outputs.test }} + steps: + - name: Fetch secrets from STACKIT Secrets Manager + id: secrets + uses: https://stackit-solutions.git.onstackit.cloud/actions/secretsmanager@main + with: + # vault_addr: 'https://prod.sm.eu01.stackit.cloud' # Optional - uses default STACKIT endpoint + vault_id: ${{ secrets.VAULT_ID }} # Your Secrets Manager ID + vault_username: ${{ secrets.VAULT_USERNAME }} # Your STACKIT Secrets Manager username + vault_password: ${{ secrets.VAULT_PASSWORD }} # Your STACKIT Secrets Manager password + vault_path: ${{ secrets.VAULT_PATH }} # The secret key/path in your Secrets Manager + debug: false # Set to 'true' for debug logging + + - name: Output secret + run: | + echo ${{ steps.secrets.outputs.test}} +``` diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..b809502 --- /dev/null +++ b/action.yml @@ -0,0 +1,59 @@ +name: STACKIT Secrets Manager Secret Fetcher +description: Connects to Secrets Manager using a Go app, gets all secrets under a path, and outputs them. + +inputs: + go_version: + description: The version of Go to use for building the application. + required: false + default: 1.24.x + vault_addr: + description: You could optionally override the address. + required: false + vault_id: + description: The ID of your Secrets Manager Instance. + required: true + vault_username: + description: The Vault username to use for authentication. + required: true + vault_password: + description: The Vault password to use for authentication. + vault_path: + description: The path in Vault where the secrets are stored (e.g., secret/data/my-app). + required: true + debug: + description: Turn on debugging logs. + required: false + default: false + +outputs: + secrets: + description: A JSON object string containing all the fetched secrets. + +runs: + using: composite + steps: + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ inputs.go_version }} + + - name: Check out action code + uses: actions/checkout@v4 + with: + repository: actions/secretsmanager + ref: main + github-server-url: https://stackit-solutions.git.onstackit.cloud + + - name: Run Vault Fetcher and set output + id: secrets + run: | + go mod tidy + go run main.go >> $GITHUB_OUTPUT + shell: bash + env: + VAULT_ADDR: ${{ inputs.vault_addr }} + VAULT_ID: ${{ inputs.vault_id }} + VAULT_USERNAME: ${{ inputs.vault_username }} + VAULT_PASSWORD: ${{ inputs.vault_password }} + VAULT_PATH: ${{ inputs.vault_path }} + DEBUG: ${{ inputs.debug }} \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..90e0a0f --- /dev/null +++ b/config/config.go @@ -0,0 +1,75 @@ +package config + +import ( + "log" + "os" + + "github.com/creasty/defaults" +) + +type Config struct { + VaultAddr string `default:"https://prod.sm.eu01.stackit.cloud" env:"VAULT_ADDR"` + VaultUsername string `env:"VAULT_USERNAME"` + VaultPassword string `env:"VAULT_PASSWORD"` + VaultSecretsManagerID string `env:"VAULT_ID"` + VaultPath string `env:"VAULT_PATH"` + Debug bool `default:"false" env:"DEBUG"` +} + +// DebugLog prints debug messages only if DEBUG is enabled +func DebugLog(format string, args ...interface{}) { + if os.Getenv("DEBUG") == "true" { + log.Printf("DEBUG: "+format, args...) + } +} + +// InfoLog prints info messages only if DEBUG is enabled +func InfoLog(format string, args ...interface{}) { + if os.Getenv("DEBUG") == "true" { + log.Printf("INFO: "+format, args...) + } +} + +// ErrorLog prints error messages only if DEBUG is enabled +func ErrorLog(format string, args ...interface{}) { + if os.Getenv("DEBUG") == "true" { + log.Printf("ERROR: "+format, args...) + } +} + +// FatalLog always prints fatal messages and exits +func FatalLog(format string, args ...interface{}) { + log.Fatalf("FATAL: "+format, args...) +} + +func ValidateConfig( + cfg Config, +) Config { + defaults.Set(&cfg) + + if cfg.VaultAddr == "" { + FatalLog("VAULT_ADDR cannot be empty") + } + + if cfg.VaultUsername == "" { + FatalLog("VAULT_USERNAME cannot be empty") + } + + if cfg.VaultPassword == "" { + FatalLog("VAULT_PASSWORD cannot be empty") + } + + if cfg.VaultSecretsManagerID == "" { + FatalLog("VAULT_ID cannot be empty, in the Secrets Manager UI this is called 'Secrets Manager-ID'") + } + + if cfg.VaultPath == "" { + FatalLog("VAULT_SECRET cannot be empty, this is the key of your secret") + } + + InfoLog("Using Vault address: %s", cfg.VaultAddr) + InfoLog("Vault path: %s", cfg.VaultPath) + InfoLog("Mount: %s", cfg.VaultSecretsManagerID) + + return cfg +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..2838a56 --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module secretsmanager + +go 1.24.3 + +require ( + github.com/caarlos0/env/v11 v11.3.1 + github.com/creasty/defaults v1.8.0 + github.com/hashicorp/vault-client-go v0.4.3 +) + +require ( + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/stretchr/testify v1.10.0 // indirect + golang.org/x/sys v0.31.0 // indirect + golang.org/x/time v0.0.0-20220922220347-f3bd1da661af // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6e65122 --- /dev/null +++ b/go.sum @@ -0,0 +1,38 @@ +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/creasty/defaults v1.8.0 h1:z27FJxCAa0JKt3utc0sCImAEb+spPucmKoOdLHvHYKk= +github.com/creasty/defaults v1.8.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/vault-client-go v0.4.3 h1:zG7STGVgn/VK6rnZc0k8PGbfv2x/sJExRKHSUg3ljWc= +github.com/hashicorp/vault-client-go v0.4.3/go.mod h1:4tDw7Uhq5XOxS1fO+oMtotHL7j4sB9cp0T7U6m4FzDY= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +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/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= +golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/time v0.0.0-20220922220347-f3bd1da661af h1:Yx9k8YCG3dvF87UAn2tu2HQLf2dt/eR1bXxpLMWeH+Y= +golang.org/x/time v0.0.0-20220922220347-f3bd1da661af/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3073dbf --- /dev/null +++ b/main.go @@ -0,0 +1,27 @@ +package main + +import ( + "log" + "fmt" + + "secretsmanager/config" + "secretsmanager/secretsmanager" + + "github.com/caarlos0/env/v11" +) + +func main() { + var cfg config.Config + err := env.Parse(&cfg) + if err != nil { + log.Fatalf("Error parsing environment variables %s", err) + } + cfg = config.ValidateConfig(cfg) + + s := secretsmanager.InitializeClient(cfg) + data, _ := secretsmanager.GetSecrets(&s, cfg) + + for _, secret := range data { + fmt.Println(secret) + } +} \ No newline at end of file diff --git a/secretsmanager/client.go b/secretsmanager/client.go new file mode 100644 index 0000000..d2252c9 --- /dev/null +++ b/secretsmanager/client.go @@ -0,0 +1,41 @@ +package secretsmanager + +import ( + "context" + "time" + + "secretsmanager/config" + + "github.com/hashicorp/vault-client-go" + "github.com/hashicorp/vault-client-go/schema" +) + +type SecretsManager struct { + Ctx context.Context + Client *vault.Client +} + +func InitializeClient( + cfg config.Config, +) SecretsManager { + + s := SecretsManager{} + s.Ctx = context.Background() + s.Client, _ = vault.New( + vault.WithAddress(cfg.VaultAddr), + vault.WithRequestTimeout(30*time.Second), + vault.WithTLS(vault.TLSConfiguration{ + InsecureSkipVerify: false, + }), + ) + + config.InfoLog("Attempting to login with user %s", cfg.VaultUsername) + loginResp, err := s.Client.Auth.UserpassLogin(s.Ctx, cfg.VaultUsername, schema.UserpassLoginRequest{Password: cfg.VaultPassword}) + if err != nil { + config.FatalLog("Vault login request failed: %s", err) + } + config.InfoLog("Login successful. Token received.") + s.Client.SetToken(loginResp.Auth.ClientToken) + + return s +} diff --git a/secretsmanager/secrets.go b/secretsmanager/secrets.go new file mode 100644 index 0000000..4d0f65b --- /dev/null +++ b/secretsmanager/secrets.go @@ -0,0 +1,37 @@ +package secretsmanager + +import ( + "fmt" + "secretsmanager/config" + "log" + + "github.com/hashicorp/vault-client-go" +) + +func GetSecrets( + s *SecretsManager, + cfg config.Config, +) ([]string, error) { + + config.InfoLog("Attempting to read secret from mount '%s' at path '%s'", cfg.VaultSecretsManagerID, cfg.VaultPath) + secret, err := s.Client.Secrets.KvV2Read(s.Ctx, cfg.VaultPath, vault.WithMountPath(cfg.VaultSecretsManagerID)) + if err != nil { + log.Fatalf("Failed to read secret from vault: %v", err) + return nil, fmt.Errorf("failed to read secret from vault: %w", err) + } + + if secret == nil || secret.Data.Data == nil { + log.Fatal("No data found at the specified secret path.") + return []string{}, nil + } + + var secretsAsKeyValue []string + + for key, value := range secret.Data.Data { + secretsAsKeyValue = append(secretsAsKeyValue, fmt.Sprintf("%s=%v", key, value)) + } + + config.InfoLog("Successfully retrieved and formatted %d secrets.", len(secretsAsKeyValue)) + + return secretsAsKeyValue, nil +}