Skip to content

Commit aef5c22

Browse files
committed
feat: initial vx implementation — vault-backed secret manager for monorepos
Implements the full vx CLI tool addressing four fnox limitations: 1. Token auto-renewal via background daemon (50% TTL renewal) 2. Nested Vault keys via ${env} path templating (single base_path) 3. Workspace-scoped secret loading (bun workspace detection) 4. Parallel secret resolution via errgroup (65 seq → ~17 concurrent) Packages: - cmd/: cobra CLI (exec, login, daemon, token, list, validate, migrate, version) - internal/config: TOML parser, workspace detection, config merging - internal/resolver: parallel Vault reads, path grouping, template interpolation - internal/vault: Vault SDK client, KV v2 reads, OIDC + AppRole auth - internal/token: token sink, renewal daemon, PID management - internal/exec: child process runner, signal forwarding - internal/migrate: fnox.toml → vx.toml converter CI/CD: GitHub Actions (test + release), GoReleaser, multi-arch Docker images
0 parents  commit aef5c22

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

65 files changed

+6613
-0
lines changed

.github/workflows/ci.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
permissions:
10+
contents: read
11+
12+
jobs:
13+
test:
14+
name: Test
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- uses: actions/setup-go@v5
20+
with:
21+
go-version-file: go.mod
22+
23+
- name: Download dependencies
24+
run: go mod download
25+
26+
- name: Build
27+
run: go build ./...
28+
29+
- name: Vet
30+
run: go vet ./...
31+
32+
- name: Test
33+
run: go test -race -coverprofile=coverage.out ./...
34+
35+
- name: Upload coverage
36+
uses: actions/upload-artifact@v4
37+
with:
38+
name: coverage
39+
path: coverage.out

.github/workflows/release.yml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: Release
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
permissions:
9+
contents: write
10+
packages: write
11+
12+
jobs:
13+
release:
14+
name: Release
15+
runs-on: ubuntu-latest
16+
steps:
17+
- uses: actions/checkout@v4
18+
with:
19+
fetch-depth: 0
20+
21+
- uses: actions/setup-go@v5
22+
with:
23+
go-version-file: go.mod
24+
25+
- name: Login to GHCR
26+
uses: docker/login-action@v3
27+
with:
28+
registry: ghcr.io
29+
username: ${{ github.actor }}
30+
password: ${{ secrets.GITHUB_TOKEN }}
31+
32+
- name: Set up Docker Buildx
33+
uses: docker/setup-buildx-action@v3
34+
35+
- name: Run GoReleaser
36+
uses: goreleaser/goreleaser-action@v6
37+
with:
38+
version: latest
39+
args: release --clean
40+
env:
41+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

.gitignore

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# Binaries
2+
vx
3+
*.exe
4+
*.dll
5+
*.so
6+
*.dylib
7+
8+
# Build output
9+
dist/
10+
coverage.out
11+
12+
# IDE
13+
.idea/
14+
.vscode/
15+
*.swp
16+
*.swo
17+
18+
# OS
19+
.DS_Store
20+
Thumbs.db

.goreleaser.yml

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
project_name: vx
2+
3+
before:
4+
hooks:
5+
- go mod tidy
6+
- go test ./...
7+
8+
builds:
9+
- id: vx
10+
main: .
11+
binary: vx
12+
env:
13+
- CGO_ENABLED=0
14+
goos:
15+
- linux
16+
- darwin
17+
- windows
18+
goarch:
19+
- amd64
20+
- arm64
21+
ldflags:
22+
- -s -w
23+
- -X go.dot.industries/vx/cmd.version={{.Version}}
24+
- -X go.dot.industries/vx/cmd.commit={{.ShortCommit}}
25+
- -X go.dot.industries/vx/cmd.date={{.Date}}
26+
27+
archives:
28+
- id: default
29+
format: tar.gz
30+
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
31+
format_overrides:
32+
- goos: windows
33+
format: zip
34+
35+
checksum:
36+
name_template: "checksums.txt"
37+
38+
changelog:
39+
sort: asc
40+
filters:
41+
exclude:
42+
- "^docs:"
43+
- "^test:"
44+
- "^chore:"
45+
46+
dockers:
47+
- image_templates:
48+
- "ghcr.io/dotindustries/vx:{{ .Version }}-amd64"
49+
use: buildx
50+
build_flag_templates:
51+
- "--platform=linux/amd64"
52+
goarch: amd64
53+
dockerfile: Dockerfile.release
54+
55+
- image_templates:
56+
- "ghcr.io/dotindustries/vx:{{ .Version }}-arm64"
57+
use: buildx
58+
build_flag_templates:
59+
- "--platform=linux/arm64"
60+
goarch: arm64
61+
dockerfile: Dockerfile.release
62+
63+
docker_manifests:
64+
- name_template: "ghcr.io/dotindustries/vx:{{ .Version }}"
65+
image_templates:
66+
- "ghcr.io/dotindustries/vx:{{ .Version }}-amd64"
67+
- "ghcr.io/dotindustries/vx:{{ .Version }}-arm64"
68+
69+
- name_template: "ghcr.io/dotindustries/vx:latest"
70+
image_templates:
71+
- "ghcr.io/dotindustries/vx:{{ .Version }}-amd64"
72+
- "ghcr.io/dotindustries/vx:{{ .Version }}-arm64"

Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
FROM golang:1.25-alpine AS builder
2+
3+
WORKDIR /src
4+
COPY go.mod go.sum ./
5+
RUN go mod download
6+
7+
COPY . .
8+
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /vx .
9+
10+
FROM alpine:3.21
11+
RUN apk add --no-cache ca-certificates
12+
COPY --from=builder /vx /usr/local/bin/vx
13+
ENTRYPOINT ["vx"]

Dockerfile.release

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
FROM scratch
2+
COPY vx /vx
3+
ENTRYPOINT ["/vx"]

cmd/daemon.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os"
7+
"os/signal"
8+
"syscall"
9+
10+
"github.com/rs/zerolog/log"
11+
"github.com/spf13/cobra"
12+
13+
"go.dot.industries/vx/internal/token"
14+
)
15+
16+
func init() {
17+
rootCmd.AddCommand(daemonCmd)
18+
daemonCmd.AddCommand(daemonStartCmd)
19+
daemonCmd.AddCommand(daemonStopCmd)
20+
daemonCmd.AddCommand(daemonStatusCmd)
21+
}
22+
23+
var daemonCmd = &cobra.Command{
24+
Use: "daemon",
25+
Short: "Manage the token renewal daemon",
26+
Long: `The daemon automatically renews your Vault token before it expires.`,
27+
}
28+
29+
var daemonStartCmd = &cobra.Command{
30+
Use: "start",
31+
Short: "Start the token renewal daemon in the foreground",
32+
Args: cobra.NoArgs,
33+
RunE: runDaemonStart,
34+
}
35+
36+
var daemonStopCmd = &cobra.Command{
37+
Use: "stop",
38+
Short: "Stop the running token renewal daemon",
39+
Args: cobra.NoArgs,
40+
RunE: runDaemonStop,
41+
}
42+
43+
var daemonStatusCmd = &cobra.Command{
44+
Use: "status",
45+
Short: "Show the daemon status",
46+
Args: cobra.NoArgs,
47+
RunE: runDaemonStatus,
48+
}
49+
50+
func runDaemonStart(cmd *cobra.Command, args []string) error {
51+
cfg, _, err := loadConfig()
52+
if err != nil {
53+
return err
54+
}
55+
56+
renewer := token.NewTokenRenewer(cfg.Vault.Address)
57+
daemon := token.NewDaemon(renewer)
58+
59+
if daemon.IsRunning() {
60+
return fmt.Errorf("daemon is already running")
61+
}
62+
63+
ctx, cancel := context.WithCancel(context.Background())
64+
defer cancel()
65+
66+
if err := daemon.Start(ctx); err != nil {
67+
return fmt.Errorf("starting daemon: %w", err)
68+
}
69+
70+
log.Info().Msg("daemon started, press Ctrl+C to stop")
71+
72+
sigCh := make(chan os.Signal, 1)
73+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
74+
<-sigCh
75+
76+
log.Info().Msg("stopping daemon...")
77+
if err := daemon.Stop(); err != nil {
78+
log.Warn().Err(err).Msg("error stopping daemon")
79+
}
80+
81+
return nil
82+
}
83+
84+
func runDaemonStop(cmd *cobra.Command, args []string) error {
85+
pidPath := token.PIDPath()
86+
87+
data, err := os.ReadFile(pidPath)
88+
if err != nil {
89+
return fmt.Errorf("daemon is not running (no PID file)")
90+
}
91+
92+
pid := 0
93+
if _, err := fmt.Sscanf(string(data), "%d", &pid); err != nil {
94+
return fmt.Errorf("invalid PID file: %w", err)
95+
}
96+
97+
proc, err := os.FindProcess(pid)
98+
if err != nil {
99+
return fmt.Errorf("finding daemon process: %w", err)
100+
}
101+
102+
if err := proc.Signal(syscall.SIGTERM); err != nil {
103+
return fmt.Errorf("sending stop signal: %w", err)
104+
}
105+
106+
if err := os.Remove(pidPath); err != nil && !os.IsNotExist(err) {
107+
log.Warn().Err(err).Msg("removing PID file")
108+
}
109+
110+
log.Info().Int("pid", pid).Msg("daemon stopped")
111+
112+
return nil
113+
}
114+
115+
func runDaemonStatus(cmd *cobra.Command, args []string) error {
116+
cfg, _, err := loadConfig()
117+
if err != nil {
118+
return err
119+
}
120+
121+
renewer := token.NewTokenRenewer(cfg.Vault.Address)
122+
daemon := token.NewDaemon(renewer)
123+
124+
status, err := daemon.Status()
125+
if err != nil {
126+
return fmt.Errorf("checking daemon status: %w", err)
127+
}
128+
129+
if !status.Running {
130+
fmt.Println("Daemon: not running")
131+
return nil
132+
}
133+
134+
fmt.Printf("Daemon: running (PID %d)\n", status.PID)
135+
if !status.LastRenewal.IsZero() {
136+
fmt.Printf("Last renewal: %s\n", status.LastRenewal.Format("2006-01-02 15:04:05"))
137+
}
138+
139+
return nil
140+
}

0 commit comments

Comments
 (0)