Skip to content

Commit 070a117

Browse files
committed
Fix bootstrap SOPS age key path resolution on macOS
- Bootstrap now resolves SOPS age key paths to platform-specific locations - macOS without XDG_CONFIG_HOME: ~/Library/Application Support/sops/age/keys.txt - macOS with XDG_CONFIG_HOME: $XDG_CONFIG_HOME/sops/age/keys.txt - Linux: ~/.config/sops/age/keys.txt (or $XDG_CONFIG_HOME if set) - Fixes issue where SOPS couldn't find bootstrapped keys on macOS - Added comprehensive unit tests for path resolution logic
1 parent cec7bad commit 070a117

4 files changed

Lines changed: 192 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.6.5] - 2025-10-23
11+
12+
### Fixed
13+
- **Bootstrap SOPS age key path resolution on macOS** - Bootstrap now correctly saves age keys to the platform-specific path that SOPS expects:
14+
- macOS without `XDG_CONFIG_HOME`: `~/Library/Application Support/sops/age/keys.txt`
15+
- macOS with `XDG_CONFIG_HOME`: `$XDG_CONFIG_HOME/sops/age/keys.txt`
16+
- Linux: `~/.config/sops/age/keys.txt` (or `$XDG_CONFIG_HOME/sops/age/keys.txt`)
17+
- Previously, bootstrap always saved to `~/.config/sops/age/keys.txt` on all platforms, causing SOPS to fail finding keys on macOS
18+
1019
## [0.6.4] - 2025-01-07
1120

1221
### Fixed
@@ -144,7 +153,9 @@ bootstrap:
144153
- Support for Terraform and OpenTofu
145154
- CLI commands: plan, apply, destroy, list, output, clean
146155

147-
[Unreleased]: https://github.com/moonwalker/comet/compare/v0.6.3...HEAD
156+
[Unreleased]: https://github.com/moonwalker/comet/compare/v0.6.5...HEAD
157+
[0.6.5]: https://github.com/moonwalker/comet/releases/tag/v0.6.5
158+
[0.6.4]: https://github.com/moonwalker/comet/releases/tag/v0.6.4
148159
[0.6.3]: https://github.com/moonwalker/comet/releases/tag/v0.6.3
149160
[0.6.2]: https://github.com/moonwalker/comet/releases/tag/v0.6.2
150161
[0.6.1]: https://github.com/moonwalker/comet/releases/tag/v0.6.1

comet.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,14 @@ generate_backend: false
1616
# - name: sops-age-key
1717
# type: secret
1818
# source: op://vault/infrastructure/sops-age-key # Your 1Password path
19-
# target: ~/.config/sops/age/keys.txt
19+
# target: ~/.config/sops/age/keys.txt # Automatically resolves to platform-specific path
2020
# mode: "0600"
2121
#
22+
# # Note: For SOPS age keys, bootstrap automatically resolves the target path:
23+
# # - macOS without XDG_CONFIG_HOME: ~/Library/Application Support/sops/age/keys.txt
24+
# # - macOS with XDG_CONFIG_HOME: $XDG_CONFIG_HOME/sops/age/keys.txt
25+
# # - Linux: ~/.config/sops/age/keys.txt (or $XDG_CONFIG_HOME if set)
26+
#
2227
# # Optional: Check required tools
2328
# - name: check-tools
2429
# type: check

internal/bootstrap/runner.go

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"os/exec"
77
"path/filepath"
8+
"runtime"
89
"strconv"
910
"strings"
1011
"time"
@@ -224,15 +225,60 @@ func (r *Runner) runCheckStep(step *schema.BootstrapStep) error {
224225
return nil
225226
}
226227

227-
// expandPath expands ~ and environment variables in paths
228+
// expandPath expands ~ and environment variables in paths.
229+
// It also handles SOPS age key path resolution on macOS.
228230
func expandPath(path string) string {
231+
// Handle special case for SOPS age keys on macOS
232+
// SOPS uses different default paths depending on XDG_CONFIG_HOME:
233+
// - If XDG_CONFIG_HOME is set: $XDG_CONFIG_HOME/sops/age/keys.txt
234+
// - On macOS without XDG_CONFIG_HOME: ~/Library/Application Support/sops/age/keys.txt
235+
// - On Linux without XDG_CONFIG_HOME: ~/.config/sops/age/keys.txt
236+
if strings.Contains(path, "sops/age/keys.txt") {
237+
resolvedPath := resolveSopsAgePath(path)
238+
if resolvedPath != "" {
239+
return resolvedPath
240+
}
241+
}
242+
229243
if strings.HasPrefix(path, "~/") {
230244
home, _ := os.UserHomeDir()
231245
path = filepath.Join(home, path[2:])
232246
}
233247
return os.ExpandEnv(path)
234248
}
235249

250+
// resolveSopsAgePath resolves the SOPS age key path to match what the SOPS library expects.
251+
// This ensures bootstrap saves the key where SOPS will actually look for it.
252+
func resolveSopsAgePath(path string) string {
253+
const sopsAgeKeyPath = "sops/age/keys.txt"
254+
255+
// If path doesn't contain the SOPS age key path, don't modify it
256+
if !strings.Contains(path, sopsAgeKeyPath) {
257+
return ""
258+
}
259+
260+
// Check if XDG_CONFIG_HOME is set
261+
if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" {
262+
// Use XDG_CONFIG_HOME if set (works on all platforms)
263+
return filepath.Join(xdgConfigHome, sopsAgeKeyPath)
264+
}
265+
266+
// Platform-specific defaults when XDG_CONFIG_HOME is not set
267+
home, err := os.UserHomeDir()
268+
if err != nil {
269+
return ""
270+
}
271+
272+
if runtime.GOOS == "darwin" {
273+
// macOS: ~/Library/Application Support/sops/age/keys.txt
274+
// This matches what os.UserConfigDir() returns on macOS
275+
return filepath.Join(home, "Library", "Application Support", sopsAgeKeyPath)
276+
}
277+
278+
// Linux/others: ~/.config/sops/age/keys.txt
279+
return filepath.Join(home, ".config", sopsAgeKeyPath)
280+
}
281+
236282
// NeedsBootstrap checks if any bootstrap steps need to be run
237283
func NeedsBootstrap(config *schema.Config) bool {
238284
if len(config.Bootstrap) == 0 {

internal/bootstrap/runner_test.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package bootstrap
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
)
9+
10+
func TestExpandPath(t *testing.T) {
11+
home, _ := os.UserHomeDir()
12+
13+
tests := []struct {
14+
name string
15+
input string
16+
xdgHome string
17+
expected string
18+
}{
19+
{
20+
name: "regular tilde expansion",
21+
input: "~/test/file.txt",
22+
xdgHome: "",
23+
expected: filepath.Join(home, "test", "file.txt"),
24+
},
25+
{
26+
name: "sops age key on macOS without XDG_CONFIG_HOME",
27+
input: "~/.config/sops/age/keys.txt",
28+
xdgHome: "",
29+
expected: filepath.Join(home, "Library", "Application Support", "sops", "age", "keys.txt"),
30+
},
31+
{
32+
name: "sops age key with XDG_CONFIG_HOME set",
33+
input: "~/.config/sops/age/keys.txt",
34+
xdgHome: filepath.Join(home, ".config"),
35+
expected: filepath.Join(home, ".config", "sops", "age", "keys.txt"),
36+
},
37+
{
38+
name: "non-sops path is not affected",
39+
input: "~/.config/other/file.txt",
40+
xdgHome: "",
41+
expected: filepath.Join(home, ".config", "other", "file.txt"),
42+
},
43+
}
44+
45+
for _, tt := range tests {
46+
t.Run(tt.name, func(t *testing.T) {
47+
// Set up XDG_CONFIG_HOME environment variable
48+
if tt.xdgHome != "" {
49+
os.Setenv("XDG_CONFIG_HOME", tt.xdgHome)
50+
defer os.Unsetenv("XDG_CONFIG_HOME")
51+
} else {
52+
os.Unsetenv("XDG_CONFIG_HOME")
53+
}
54+
55+
result := expandPath(tt.input)
56+
57+
// Skip macOS-specific test on non-macOS systems
58+
if runtime.GOOS != "darwin" && tt.name == "sops age key on macOS without XDG_CONFIG_HOME" {
59+
// On Linux, expect ~/.config path
60+
expectedLinux := filepath.Join(home, ".config", "sops", "age", "keys.txt")
61+
if result != expectedLinux {
62+
t.Errorf("expandPath() = %v, want %v (Linux)", result, expectedLinux)
63+
}
64+
return
65+
}
66+
67+
if result != tt.expected {
68+
t.Errorf("expandPath() = %v, want %v", result, tt.expected)
69+
}
70+
})
71+
}
72+
}
73+
74+
func TestResolveSopsAgePath(t *testing.T) {
75+
home, _ := os.UserHomeDir()
76+
77+
tests := []struct {
78+
name string
79+
input string
80+
xdgHome string
81+
expected string
82+
}{
83+
{
84+
name: "returns empty for non-sops path",
85+
input: "~/.config/other/file.txt",
86+
xdgHome: "",
87+
expected: "",
88+
},
89+
{
90+
name: "resolves with XDG_CONFIG_HOME set",
91+
input: "sops/age/keys.txt",
92+
xdgHome: filepath.Join(home, "custom-config"),
93+
expected: filepath.Join(home, "custom-config", "sops", "age", "keys.txt"),
94+
},
95+
{
96+
name: "resolves macOS default without XDG_CONFIG_HOME",
97+
input: "sops/age/keys.txt",
98+
xdgHome: "",
99+
expected: getPlatformSopsPath(home),
100+
},
101+
}
102+
103+
for _, tt := range tests {
104+
t.Run(tt.name, func(t *testing.T) {
105+
// Set up XDG_CONFIG_HOME environment variable
106+
if tt.xdgHome != "" {
107+
os.Setenv("XDG_CONFIG_HOME", tt.xdgHome)
108+
defer os.Unsetenv("XDG_CONFIG_HOME")
109+
} else {
110+
os.Unsetenv("XDG_CONFIG_HOME")
111+
}
112+
113+
result := resolveSopsAgePath(tt.input)
114+
if result != tt.expected {
115+
t.Errorf("resolveSopsAgePath() = %v, want %v", result, tt.expected)
116+
}
117+
})
118+
}
119+
}
120+
121+
// getPlatformSopsPath returns the expected SOPS age key path for the current platform
122+
func getPlatformSopsPath(home string) string {
123+
if runtime.GOOS == "darwin" {
124+
return filepath.Join(home, "Library", "Application Support", "sops", "age", "keys.txt")
125+
}
126+
return filepath.Join(home, ".config", "sops", "age", "keys.txt")
127+
}

0 commit comments

Comments
 (0)