diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml new file mode 100644 index 0000000..c633d1d --- /dev/null +++ b/.github/workflows/dependency-review.yml @@ -0,0 +1,39 @@ +# Dependency Review Action +# +# This Action will scan dependency manifest files that change as part of a Pull Request, +# surfacing known-vulnerable versions of the packages declared or updated in the PR. +# Once installed, if the workflow run is marked as required, PRs introducing known-vulnerable +# packages will be blocked from merging. +# +# Source repository: https://github.com/actions/dependency-review-action +# Public documentation: https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/about-dependency-review#dependency-review-enforcement +name: 'Dependency review' +on: + pull_request: + types: [opened, synchronize, reopened] + +# If using a dependency submission action in this workflow this permission will need to be set to: +# +# permissions: +# contents: write +# +# https://docs.github.com/en/enterprise-cloud@latest/code-security/supply-chain-security/understanding-your-software-supply-chain/using-the-dependency-submission-api +permissions: + contents: read + # Write permissions for pull-requests are required for using the `comment-summary-in-pr` option, comment out if you aren't using this option + pull-requests: write + +jobs: + dependency-review: + runs-on: ubuntu-latest + steps: + - name: 'Checkout repository' + uses: actions/checkout@v4 + - name: 'Dependency Review' + uses: actions/dependency-review-action@v4 + # Commonly enabled options, see https://github.com/actions/dependency-review-action#configuration-options for all available options. + with: + comment-summary-in-pr: always + # fail-on-severity: moderate + # deny-licenses: GPL-1.0-or-later, LGPL-2.0-or-later + # retry-on-snapshot-warnings: true diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml new file mode 100644 index 0000000..200672e --- /dev/null +++ b/.github/workflows/go.yml @@ -0,0 +1,45 @@ +# This workflow will build a golang project +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go + +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22.x' + + - name: Cache Go modules + uses: actions/cache@v4 + with: + path: | + ~/.cache/go-build + ~/go/pkg/mod + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go- + + - name: Lint + uses: golangci/golangci-lint-action@v6 + with: + version: latest + args: --timeout=5m --out-format=github-actions + + - name: Build + run: go build -v ./... + + - name: Test + run: go test -v ./... + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..da4bf75 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22.x' + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: v1.26.2 + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/slsa-goreleaser.yml b/.github/workflows/slsa-goreleaser.yml new file mode 100644 index 0000000..3b30264 --- /dev/null +++ b/.github/workflows/slsa-goreleaser.yml @@ -0,0 +1,37 @@ +name: Build binaries on master +on: + push: + branches: + - master + +permissions: read-all + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + goos: [linux, darwin] + goarch: [amd64, arm64] + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22.x' + - name: Build ${{ matrix.goos }}-${{ matrix.goarch }} + env: + CGO_ENABLED: 0 + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + run: | + mkdir -p dist + BIN_NAME=dcs + if [ "${{ matrix.goos }}" = "windows" ]; then BIN_NAME=dcs.exe; fi + go build -trimpath -ldflags "-s -w" -o dist/${BIN_NAME}-${{ matrix.goos }}-${{ matrix.goarch }} ./ + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: dcs-${{ matrix.goos }}-${{ matrix.goarch }} + path: dist/dcs-${{ matrix.goos }}-${{ matrix.goarch }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0e5f470 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# compiled app names: +/docker-compose-secrets +/dcs + +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk +dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d91acd2 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,34 @@ +run: + timeout: 5m + issues-exit-code: 1 + +linters: + enable: + - govet + - gosimple + - staticcheck + - unused + - ineffassign + - errcheck + - gocritic + - gosec + - misspell + - revive + +linters-settings: + gosec: + excludes: + - G204 # Subprocess launched with variable - safe in controlled context + revive: + rules: + - name: indent-error-flow + severity: warning + - name: exported + disabled: true + +issues: + exclude-use-default: false + exclude: + - "error return value not checked.*(Close|Log|Printf)" + - "should have comment or be unexported" + diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..2f7891c --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,31 @@ +project_name: dcs +before: + hooks: + - go mod download +builds: + - id: dcs + main: ./ + binary: dcs + env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + goarch: + - amd64 + - arm64 + flags: ["-trimpath"] + ldflags: + - -s -w +archives: + - id: archive + name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" + format: tar.gz + files: + - LICENSE + - README.md +checksum: + name_template: "checksums.txt" +changelog: + sort: desc + use: git \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 62ad517..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,27 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -/discord.xml - -# Editor-based HTTP Client requests -/httpRequests/ - -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml - -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ diff --git a/.idea/docker-compose-secrets.iml b/.idea/docker-compose-secrets.iml deleted file mode 100644 index 5e764c4..0000000 --- a/.idea/docker-compose-secrets.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index e96684d..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 94a25f7..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.slsa-goreleaser/darwin-amd64.yml b/.slsa-goreleaser/darwin-amd64.yml new file mode 100644 index 0000000..cbe8d8c --- /dev/null +++ b/.slsa-goreleaser/darwin-amd64.yml @@ -0,0 +1,36 @@ +# Version for this file. +version: 1 + +# (Optional) List of env variables used during compilation. +env: + - GO111MODULE=on + - CGO_ENABLED=0 + +# (Optional) Flags for the compiler. +flags: + - -trimpath + - -tags=netgo + +# The OS to compile for. `GOOS` env variable will be set to this value. +goos: darwin + +# The architecture to compile for. `GOARCH` env variable will be set to this value. +goarch: amd64 + +# (Optional) Entrypoint to compile. +# main: ./path/to/main.go + +# (Optional) Working directory. (default: root of the project) +# dir: ./relative/path/to/dir + +# Binary output name. +# {{ .Os }} will be replaced by goos field in the config file. +# {{ .Arch }} will be replaced by goarch field in the config file. +binary: dcs-{{ .Os }}-{{ .Arch }} + +# (Optional) ldflags generated dynamically in the workflow, and set as the `evaluated-envs` input variables in the workflow. +# ldflags: +# - "-X main.Version={{ .Env.VERSION }}" +# - "-X main.Commit={{ .Env.COMMIT }}" +# - "-X main.CommitDate={{ .Env.COMMIT_DATE }}" +# - "-X main.TreeState={{ .Env.TREE_STATE }}" \ No newline at end of file diff --git a/.slsa-goreleaser/darwin-arm64.yml b/.slsa-goreleaser/darwin-arm64.yml new file mode 100644 index 0000000..0de818e --- /dev/null +++ b/.slsa-goreleaser/darwin-arm64.yml @@ -0,0 +1,36 @@ +# Version for this file. +version: 1 + +# (Optional) List of env variables used during compilation. +env: + - GO111MODULE=on + - CGO_ENABLED=0 + +# (Optional) Flags for the compiler. +flags: + - -trimpath + - -tags=netgo + +# The OS to compile for. `GOOS` env variable will be set to this value. +goos: darwin + +# The architecture to compile for. `GOARCH` env variable will be set to this value. +goarch: arm64 + +# (Optional) Entrypoint to compile. +# main: ./path/to/main.go + +# (Optional) Working directory. (default: root of the project) +# dir: ./relative/path/to/dir + +# Binary output name. +# {{ .Os }} will be replaced by goos field in the config file. +# {{ .Arch }} will be replaced by goarch field in the config file. +binary: dcs-{{ .Os }}-{{ .Arch }} + +# (Optional) ldflags generated dynamically in the workflow, and set as the `evaluated-envs` input variables in the workflow. +# ldflags: +# - "-X main.Version={{ .Env.VERSION }}" +# - "-X main.Commit={{ .Env.COMMIT }}" +# - "-X main.CommitDate={{ .Env.COMMIT_DATE }}" +# - "-X main.TreeState={{ .Env.TREE_STATE }}" \ No newline at end of file diff --git a/.slsa-goreleaser/linux-amd64.yml b/.slsa-goreleaser/linux-amd64.yml new file mode 100644 index 0000000..3bc494b --- /dev/null +++ b/.slsa-goreleaser/linux-amd64.yml @@ -0,0 +1,36 @@ +# Version for this file. +version: 1 + +# (Optional) List of env variables used during compilation. +env: + - GO111MODULE=on + - CGO_ENABLED=0 + +# (Optional) Flags for the compiler. +flags: + - -trimpath + - -tags=netgo + +# The OS to compile for. `GOOS` env variable will be set to this value. +goos: linux + +# The architecture to compile for. `GOARCH` env variable will be set to this value. +goarch: amd64 + +# (Optional) Entrypoint to compile. +# main: ./path/to/main.go + +# (Optional) Working directory. (default: root of the project) +# dir: ./relative/path/to/dir + +# Binary output name. +# {{ .Os }} will be replaced by goos field in the config file. +# {{ .Arch }} will be replaced by goarch field in the config file. +binary: dcs-{{ .Os }}-{{ .Arch }} + +# (Optional) ldflags generated dynamically in the workflow, and set as the `evaluated-envs` input variables in the workflow. +# ldflags: +# - "-X main.Version={{ .Env.VERSION }}" +# - "-X main.Commit={{ .Env.COMMIT }}" +# - "-X main.CommitDate={{ .Env.COMMIT_DATE }}" +# - "-X main.TreeState={{ .Env.TREE_STATE }}" \ No newline at end of file diff --git a/.slsa-goreleaser/linux-arm64.yml b/.slsa-goreleaser/linux-arm64.yml new file mode 100644 index 0000000..d0937a4 --- /dev/null +++ b/.slsa-goreleaser/linux-arm64.yml @@ -0,0 +1,36 @@ +# Version for this file. +version: 1 + +# (Optional) List of env variables used during compilation. +env: + - GO111MODULE=on + - CGO_ENABLED=0 + +# (Optional) Flags for the compiler. +flags: + - -trimpath + - -tags=netgo + +# The OS to compile for. `GOOS` env variable will be set to this value. +goos: linux + +# The architecture to compile for. `GOARCH` env variable will be set to this value. +goarch: arm64 + +# (Optional) Entrypoint to compile. +# main: ./path/to/main.go + +# (Optional) Working directory. (default: root of the project) +# dir: ./relative/path/to/dir + +# Binary output name. +# {{ .Os }} will be replaced by goos field in the config file. +# {{ .Arch }} will be replaced by goarch field in the config file. +binary: dcs-{{ .Os }}-{{ .Arch }} + +# (Optional) ldflags generated dynamically in the workflow, and set as the `evaluated-envs` input variables in the workflow. +# ldflags: +# - "-X main.Version={{ .Env.VERSION }}" +# - "-X main.Commit={{ .Env.COMMIT }}" +# - "-X main.CommitDate={{ .Env.COMMIT_DATE }}" +# - "-X main.TreeState={{ .Env.TREE_STATE }}" \ No newline at end of file diff --git a/README.md b/README.md index 992a9f1..08af778 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,68 @@ The secrets are stored and managed securely in Vault and are injected into Docke With this setup, no more secrets can be leaked through insufficiently protected `.env` or `docker-compose.yml` files. +## Usage + +### Requirements + +- Docker CLI with Compose plugin (`docker compose`) +- HashiCorp Vault with KV v2 enabled at path `secret` + +### Environment variables (required) + +- `VAULT_ADDR`: Vault base URL, e.g. `http://127.0.0.1:8200` +- `VAULT_TOKEN`: Vault token with read access to the secret +- `VAULT_PATH`: Secret name under the KV v2 engine `secret` (e.g. `logto` if you wrote `secret/logto`) + +### Commands + +- `start`: runs `docker compose up -d` and injects secrets as environment variables +- `stop`: runs `docker compose down --remove-orphans` +- `restart`: runs `docker compose up -d --force-recreate` and injects secrets +- `update`: runs `docker compose pull`, and then automatically performs `restart` + +### CLI syntax + +``` +dcs + +Commands: + start | stop | restart | update +``` + +### Examples + +Set environment variables once in your shell and run a command: + +```bash +export VAULT_ADDR='http://127.0.0.1:8200' +export VAULT_TOKEN='s.xxxxxxxx' +export VAULT_PATH='logto' + +dcs start +``` + +One-liner without exporting variables globally: + +```bash +VAULT_ADDR='http://127.0.0.1:8200' \ +VAULT_TOKEN='s.xxxxxxxx' \ +VAULT_PATH='logto' \ +dcs restart +``` + +Stop and remove orphans: + +```bash +dcs stop +``` + +Pull latest images and redeploy: + +```bash +dcs update +``` + ## Demo In Vault, the secrets (environment variables) of an application (in this case: [Logto](https://logto.io)) are stored in a [KV Secrets Engine - Version 2](https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2), located at the default "secret" path. diff --git a/app/app.go b/app/app.go new file mode 100644 index 0000000..4b1f498 --- /dev/null +++ b/app/app.go @@ -0,0 +1,33 @@ +package app + +import ( + "docker-compose-secrets/app/commands" + "log" +) + +type Application struct { + commandService *commands.Service +} + +func NewApplication(commandService *commands.Service) *Application { + return &Application{commandService} +} + +func (app *Application) Run() { + currentCommand := app.commandService.GetCurrentCommand() + if currentCommand == "" { + log.Fatal("no command specified") + } + + switch currentCommand { + case commands.CommandStart: + app.commandService.ExecuteStart() + case commands.CommandStop: + app.commandService.ExecuteStop() + case commands.CommandRestart: + app.commandService.ExecuteRestart() + case commands.CommandUpdate: + app.commandService.ExecuteUpdate() + app.commandService.ExecuteRestart() + } +} diff --git a/app/client/http_client.go b/app/client/http_client.go new file mode 100644 index 0000000..fb366ab --- /dev/null +++ b/app/client/http_client.go @@ -0,0 +1,42 @@ +package client + +import ( + "github.com/go-resty/resty/v2" +) + +//nolint:gosec // Header name constant, not a credential +const HeaderVaultTokenName = "X-Vault-Token" + +type HttpClient struct { + client *resty.Client +} + +func NewHttpClient() *HttpClient { + return &HttpClient{ + client: resty.New(), + } +} + +func (h *HttpClient) Get(url string, headers map[string]string) ([]byte, error) { + resp, err := h.client.R(). + SetHeaders(headers). + Get(url) + if err != nil { + return nil, err + } + + return resp.Body(), nil +} + +func (h *HttpClient) BuildHeaders(customHeaders map[string]string) map[string]string { + headers := make(map[string]string) + + headers["Accept"] = "application/json" + headers["Content-Type"] = "application/json" + + for name, value := range customHeaders { + headers[name] = value + } + + return headers +} diff --git a/app/commands/commands.go b/app/commands/commands.go new file mode 100644 index 0000000..87f9569 --- /dev/null +++ b/app/commands/commands.go @@ -0,0 +1,126 @@ +package commands + +import ( + "docker-compose-secrets/app/services" + "fmt" + "log" + "os" + "os/exec" +) + +const ( + CommandStart = "start" + CommandStop = "stop" + CommandRestart = "restart" + CommandUpdate = "update" +) + +type Service struct { + secretService *services.SecretService + availableCommands []string + currentCommand string +} + +func NewService(secretService *services.SecretService) *Service { + return &Service{ + secretService: secretService, + availableCommands: []string{ + CommandStart, + CommandStop, + CommandRestart, + CommandUpdate, + }, + } +} + +func (c *Service) CheckCommandName(argCommand string) error { + for _, cmd := range c.availableCommands { + if cmd == argCommand { + return nil + } + } + + return fmt.Errorf("invalid command `%s`", argCommand) +} + +func (c *Service) SetCurrentCommand(argCommand string) { + c.currentCommand = argCommand +} + +func (c *Service) GetCurrentCommand() string { + return c.currentCommand +} + +func (c *Service) ExecuteStart() { + secrets, err := c.secretService.GetSecrets() + if err != nil { + log.Fatal(err) + } + + cmd := buildCommand("compose", "up", "-d") + addEnvironToCmd(cmd, secrets) + + println("Starting docker compose:") + if err := executeCommand(cmd); err != nil { + log.Fatal(err) + } +} + +func (c *Service) ExecuteStop() { + println("stopping docker compose:") + + if err := executeCommand( + buildCommand("compose", "down", "--remove-orphans"), + ); err != nil { + log.Fatal(err) + } +} + +func (c *Service) ExecuteRestart() { + secrets, err := c.secretService.GetSecrets() + if err != nil { + log.Fatal(err) + } + + cmd := buildCommand("compose", "up", "-d", "--force-recreate") + addEnvironToCmd(cmd, secrets) + + println("Starting docker compose:") + if err := executeCommand(cmd); err != nil { + log.Fatal(err) + } +} + +func (c *Service) ExecuteUpdate() { + println("pulling latest docker images:") + + if err := executeCommand( + buildCommand("compose", "pull"), + ); err != nil { + log.Fatal(err) + } +} + +func buildCommand(args ...string) *exec.Cmd { + cmd := exec.Command("docker", args...) + cmd.Env = os.Environ() + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd +} + +func addEnvironToCmd(cmd *exec.Cmd, env map[string]string) { + if len(env) == 0 { + println("No secrets found. Continuing without secrets") + return + } + + for key, value := range env { + cmd.Env = append(cmd.Environ(), fmt.Sprintf("%s=%s", key, value)) + } +} + +func executeCommand(cmd *exec.Cmd) error { + return cmd.Run() +} diff --git a/app/environment/environment.go b/app/environment/environment.go new file mode 100644 index 0000000..f99cd82 --- /dev/null +++ b/app/environment/environment.go @@ -0,0 +1,50 @@ +package environment + +import ( + "fmt" + "os" +) + +const ( + envVaultAddr = "VAULT_ADDR" + envVaultToken = "VAULT_TOKEN" + envVaultPath = "VAULT_PATH" +) + +type Service struct { + systemEnv map[string]string +} + +func NewService() *Service { + systemEnv := make(map[string]string) + systemEnv[envVaultAddr] = os.Getenv(envVaultAddr) + systemEnv[envVaultToken] = os.Getenv(envVaultToken) + systemEnv[envVaultPath] = os.Getenv(envVaultPath) + + return &Service{systemEnv} +} + +func (e *Service) CheckExistSystemEnv() error { + for envKey, envVal := range e.systemEnv { + if envVal == "" { + return fmt.Errorf( + "environment variable `%s` is empty", + envKey, + ) + } + } + + return nil +} + +func (e *Service) GetVaultAddr() string { + return e.systemEnv[envVaultAddr] +} + +func (e *Service) GetVaultToken() string { + return e.systemEnv[envVaultToken] +} + +func (e *Service) GetVaultPath() string { + return e.systemEnv[envVaultPath] +} diff --git a/app/models/get_secrets_result.go b/app/models/get_secrets_result.go new file mode 100644 index 0000000..bb6c09f --- /dev/null +++ b/app/models/get_secrets_result.go @@ -0,0 +1,10 @@ +package models + +type SecretData struct { + Data map[string]string `json:"data"` +} + +type GetSecretsResult struct { + Errors []string `json:"errors,omitempty"` + Data *SecretData `json:"data,omitempty"` +} diff --git a/app/services/secrets.go b/app/services/secrets.go new file mode 100644 index 0000000..22dcd62 --- /dev/null +++ b/app/services/secrets.go @@ -0,0 +1,75 @@ +package services + +import ( + "docker-compose-secrets/app/client" + "docker-compose-secrets/app/environment" + "docker-compose-secrets/app/models" + "encoding/json" + "errors" + "fmt" + "strings" +) + +const getSecretsDataPath = "v1/secret/data" + +type SecretService struct { + httpClient *client.HttpClient + environmentService *environment.Service +} + +func NewSecretService( + httpClient *client.HttpClient, environmentService *environment.Service, +) *SecretService { + return &SecretService{httpClient, environmentService} +} + +func (s *SecretService) GetSecrets() (map[string]string, error) { + headers := make(map[string]string) + headers[client.HeaderVaultTokenName] = s.environmentService.GetVaultToken() + + response, err := s.httpClient.Get( + s.buildUrl(), + s.httpClient.BuildHeaders(headers), + ) + if err != nil { + return nil, err + } + + result, err := s.parseResponse(response) + if err != nil { + return nil, err + } + + secrets := make(map[string]string) + for secretKey, secretValue := range result.Data.Data { + secrets[secretKey] = secretValue + } + + return secrets, nil +} + +func (s *SecretService) buildUrl() string { + return fmt.Sprintf( + "%s/%s/%s", + s.environmentService.GetVaultAddr(), + getSecretsDataPath, + s.environmentService.GetVaultPath(), + ) +} + +func (s *SecretService) parseResponse(response []byte) (*models.GetSecretsResult, error) { + var result models.GetSecretsResult + if err := json.Unmarshal(response, &result); err != nil { + return nil, err + } + + if len(result.Errors) > 0 { + return nil, errors.New(strings.Join(result.Errors, "; ")) + } + + if result.Data == nil { + return nil, errors.New("secrets not found") + } + + return &result, nil +} diff --git a/dcs.go b/dcs.go index cc7ffe9..58b156a 100644 --- a/dcs.go +++ b/dcs.go @@ -1,200 +1,54 @@ package main import ( - "encoding/json" - "fmt" - "io" + "docker-compose-secrets/app" + "docker-compose-secrets/app/client" + "docker-compose-secrets/app/commands" + "docker-compose-secrets/app/environment" + "docker-compose-secrets/app/services" "log" - "net/http" "os" - "os/exec" - "path/filepath" ) func main() { - // Get the arguments passed to the program - args := os.Args[1:] - - // Check if the user passed any arguments - if len(args) == 0 { - invalidCommand() - } - - // Check if the required environment variables are set - if os.Getenv("VAULT_ADDR") == "" || os.Getenv("VAULT_TOKEN") == "" { - log.Fatal("VAULT_ADDR and VAULT_TOKEN must be set in environment") - } - - // Check which command the user passed - switch args[0] { - case "start": - start(false) - case "stop": - stop() - case "restart": - start(true) - case "update": - update() - default: - invalidCommand() - } -} - -func invalidCommand() { - fmt.Println("Invalid command, please use one of the following:") - fmt.Println(" start") - fmt.Println(" stop") - fmt.Println(" restart") - fmt.Println(" update") - fmt.Println("\nExample: dcs start") - os.Exit(1) -} - -func start(restart bool) { - // Get the address of the Vault server from the environment - server := os.Getenv("VAULT_ADDR") - - // Get the folder of the current working directory - dir, err := filepath.Abs(filepath.Dir(os.Args[0])) - if err != nil { - log.Fatal(err) - } - path := filepath.Base(dir) - - // Get the token from the environment - token := os.Getenv("VAULT_TOKEN") - - // Print the settings to the user - fmt.Println("Retrieving secrets from Vault:") - fmt.Println(" Server:", server) - fmt.Println(" Path:", path) - fmt.Println(" Token:", token) - fmt.Println() - - // Initialize the request to the Vault server with the correct path - req, _ := http.NewRequest("GET", server+"/v1/secret/data/"+path, nil) - - // Add the authentication token header to the request - req.Header.Add("X-Vault-Token", token) - - // Send the request to the Vault server - res, _ := http.DefaultClient.Do(req) - - // Close the response body when we're done - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - log.Fatal(err) - } - }(res.Body) - - // Decode the response body - body, _ := io.ReadAll(res.Body) - - // Unmarshal and parse the JSON response into a map - var result map[string]interface{} - err = json.Unmarshal(body, &result) - if err != nil { + environmentService := environment.NewService() + if err := environmentService.CheckExistSystemEnv(); err != nil { log.Fatal(err) } - // Set the command to run depending on if we're restarting or not - var cmd *exec.Cmd - if restart { - cmd = exec.Command("docker", "compose", "up", "-d", "--force-recreate") - } else { - cmd = exec.Command("docker", "compose", "up", "-d") - } - - // secrets := result["data"].(map[string]interface{})["data"].(map[string]interface{}) - - // Extract the secrets from the response and handle errors - s1 := result["data"] - if s1 != nil { - s2 := s1.(map[string]interface{}) - if s2 != nil { - s3 := s2["data"] - if s3 != nil { - s4 := s3.(map[string]interface{}) - if s4 != nil { - fmt.Println("Injecting secrets into process:") - - // Pass all OS environment variables to the command - cmd.Env = os.Environ() - - // Inject all secrets into the command as environment variables and print them to the user - for k, v := range s4 { - cmd.Env = append(cmd.Environ(), fmt.Sprintf("%s=%s", k, v)) - fmt.Printf(" %s: %s\n", k, v) - } - } else { - fmt.Println("No secrets found for \"" + path + "\", continuing without secrets") - } - } else { - fmt.Println("No secrets found for \"" + path + "\", continuing without secrets") - } - } else { - fmt.Println("No secrets found for \"" + path + "\", continuing without secrets") - } - } else { - fmt.Println("No secrets found for \"" + path + "\", continuing without secrets") - } - - fmt.Println() - fmt.Println("Starting docker compose:") - - // Write the output of the command to the terminal - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr + args := os.Args[1:] - // Run the command - err = cmd.Run() - if err != nil { - log.Fatal(err) - } + commandService := commands.NewService( + services.NewSecretService( + client.NewHttpClient(), + environmentService, + ), + ) + checkArgument(args, commandService) + commandService.SetCurrentCommand(args[0]) + + application := app.NewApplication(commandService) + application.Run() } -func stop() { - // Set the command to run - cmd := exec.Command("docker", "compose", "down", "--remove-orphans") - - // Pass all OS environment variables to the command - cmd.Env = os.Environ() - - fmt.Println("Stopping docker compose:") - - // Write the output of the command to the terminal - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Run the command - err := cmd.Run() - if err != nil { - log.Fatal(err) +func checkArgument(args []string, commands *commands.Service) { + if len(args) == 0 { + showWrongCommandError("invalid command") } -} - -func update() { - // Set the command to run - cmd := exec.Command("docker", "compose", "pull") - // Pass all OS environment variables to the command - cmd.Env = os.Environ() - - fmt.Println("Pulling latest docker images:") - - // Write the output of the command to the terminal - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Run the command - err := cmd.Run() - if err != nil { - log.Fatal(err) + if err := commands.CheckCommandName(args[0]); err != nil { + showWrongCommandError(err.Error()) } +} - fmt.Println() - - // Restart the docker compose after updating the images - start(true) +func showWrongCommandError(title string) { + log.Fatalf( + "%s, please use one of the following:\n %s\n %s\n %s\n %s\n%s", + title, + "start", + "stop", + "restart", + "update", + "Example: dcs start", + ) } diff --git a/go.mod b/go.mod index 7651061..b06cf61 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module docker-compose-secrets -go 1.19 +go 1.22 + +require github.com/go-resty/resty/v2 v2.12.0 + +require golang.org/x/net v0.24.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7d15bcb --- /dev/null +++ b/go.sum @@ -0,0 +1,49 @@ +github.com/go-resty/resty/v2 v2.12.0 h1:rsVL8P90LFvkUYq/V5BTVe203WfRIU4gvcf+yfzJzGA= +github.com/go-resty/resty/v2 v2.12.0/go.mod h1:o0yGPrkS3lOe1+eFajk6kBW8ScXzwU3hD69/gt2yB/0= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w= +golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= +golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=