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=