Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ require (
github.com/moby/moby/api v1.54.2
github.com/moby/moby/client v0.4.1
github.com/spf13/cobra v1.10.2
github.com/zalando/go-keyring v0.2.8
golang.org/x/mod v0.37.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.44.0
Expand All @@ -37,6 +38,7 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/danieljoos/wincred v1.2.3 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
Expand All @@ -48,6 +50,7 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ=
github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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=
Expand Down Expand Up @@ -75,6 +77,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
Expand Down Expand Up @@ -143,6 +147,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
Expand All @@ -151,6 +157,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
Expand Down
128 changes: 127 additions & 1 deletion internal/cli/secrets.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,137 @@ func newSecretsCmd(g *GlobalOpts) *cobra.Command {
}
cmd.AddCommand(
newSecretsKeygenCmd(g),
stub("login", "Authenticate a secrets provider (keyring) — S5", "M4"),
newSecretsLoginCmd(g),
newSecretsLogoutCmd(g),
newSecretsStatusCmd(g),
)
return cmd
}

// openKeyring builds the credential store: the OS keyring when usable, otherwise
// an ephemeral in-memory fallback (WSL2 without D-Bus). The bool reports whether
// the store persists across invocations. It's a package var so tests inject a
// deterministic store.
var openKeyring = func() (secrets.Keyring, bool) {
k := secrets.OSKeyring{}
if secrets.KeyringAvailable(k) {
return k, true
}
return secrets.NewMemKeyring(), false
}

// newSecretsLoginCmd wires `secrets login <provider>` (spec 04 §S5): store a
// provider credential in the OS keyring. On a keyring-less host (WSL2 without
// D-Bus) it degrades with a one-line warning naming the env var to set instead —
// the tool keeps working in env-var mode.
func newSecretsLoginCmd(g *GlobalOpts) *cobra.Command {
var token string
cmd := &cobra.Command{
Use: "login <provider>",
Short: "Store a secrets-provider credential in the OS keyring",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
provider := args[0]
if token == "" {
return fmt.Errorf("provide the credential via --token (avoid shell history: read it from a file or a pipe)")
}
k, persistent := openKeyring()
if !persistent {
// Degrade, never fail: tell the user the env-var path and exit 0.
msg := fmt.Sprintf("os keyring unavailable (no Secret Service / D-Bus — common on WSL2); not stored.\n"+
"set %s in your environment instead.", secrets.CredEnvVar(provider))
if g.JSON {
return writeJSON(cmd, map[string]any{"provider": provider, "stored": false, "envVar": secrets.CredEnvVar(provider), "warning": msg})
}
fmt.Fprintf(cmd.ErrOrStderr(), "[warn] %s\n", msg)
return nil
}
if err := k.Set(secrets.ProviderCredKey(provider), token); err != nil {
return fmt.Errorf("store credential for %q: %w", provider, err)
}
if g.JSON {
return writeJSON(cmd, map[string]any{"provider": provider, "stored": true})
}
fmt.Fprintf(cmd.OutOrStdout(), "stored credential for %q in the OS keyring\n", provider)
return nil
},
}
cmd.Flags().StringVar(&token, "token", "", "the credential value (required)")
return cmd
}

// newSecretsLogoutCmd wires `secrets logout <provider>` — remove a stored
// credential. A no-op (success) when none exists or the keyring is unavailable.
func newSecretsLogoutCmd(g *GlobalOpts) *cobra.Command {
cmd := &cobra.Command{
Use: "logout <provider>",
Short: "Remove a stored secrets-provider credential from the OS keyring",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
provider := args[0]
k, persistent := openKeyring()
if !persistent {
if g.JSON {
return writeJSON(cmd, map[string]any{"provider": provider, "removed": false, "reason": "keyring unavailable"})
}
fmt.Fprintf(cmd.ErrOrStderr(), "[warn] os keyring unavailable; nothing to remove\n")
return nil
}
if err := k.Delete(secrets.ProviderCredKey(provider)); err != nil {
return fmt.Errorf("remove credential for %q: %w", provider, err)
}
if g.JSON {
return writeJSON(cmd, map[string]any{"provider": provider, "removed": true})
}
fmt.Fprintf(cmd.OutOrStdout(), "removed credential for %q\n", provider)
return nil
},
}
return cmd
}

// newSecretsStatusCmd wires `secrets status` — report keyring availability and,
// for any providers named as args, whether a credential resolves (env or keyring).
func newSecretsStatusCmd(g *GlobalOpts) *cobra.Command {
cmd := &cobra.Command{
Use: "status [provider...]",
Short: "Report keyring availability and per-provider credential source",
RunE: func(cmd *cobra.Command, args []string) error {
k, persistent := openKeyring()
type provStatus struct {
Provider string `json:"provider"`
Source string `json:"source"` // env | keyring | none
}
var provs []provStatus
for _, p := range args {
src := "none"
if os.Getenv(secrets.CredEnvVar(p)) != "" {
src = "env"
} else if persistent {
if v, _ := k.Get(secrets.ProviderCredKey(p)); v != "" {
src = "keyring"
}
}
provs = append(provs, provStatus{Provider: p, Source: src})
}
if g.JSON {
return writeJSON(cmd, map[string]any{"keyringPersistent": persistent, "providers": provs})
}
w := cmd.OutOrStdout()
if persistent {
fmt.Fprintln(w, "os keyring: available (credentials persist across invocations)")
} else {
fmt.Fprintln(w, "os keyring: UNAVAILABLE (no Secret Service / D-Bus); use env vars (DEVSTACK_<PROVIDER>_TOKEN)")
}
for _, p := range provs {
fmt.Fprintf(w, " %-20s %s\n", p.Provider, p.Source)
}
return nil
},
}
return cmd
}

func newSecretsKeygenCmd(g *GlobalOpts) *cobra.Command {
var output string
cmd := &cobra.Command{
Expand Down
82 changes: 82 additions & 0 deletions internal/cli/secrets_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"path/filepath"
"strings"
"testing"

"github.com/open-source-cloud/devstack/internal/secrets"
)

func TestSecretsKeygenRegistered(t *testing.T) {
Expand Down Expand Up @@ -45,3 +47,83 @@ func TestSecretsKeygenWritesFile(t *testing.T) {
t.Errorf("output should hint SOPS_AGE_KEY_FILE:\n%s", buf.String())
}
}

func TestSecretsLoginLogoutStatusRegistered(t *testing.T) {
root := NewRootCmd(Options{})
for _, sub := range []string{"login", "logout", "status"} {
c, _, err := root.Find([]string{"secrets", sub})
if err != nil || c.Name() != sub || c.RunE == nil {
t.Fatalf("secrets %s not registered as a real command: %v", sub, err)
}
}
}

// withKeyring swaps the openKeyring seam for the duration of a test.
func withKeyring(t *testing.T, k secrets.Keyring, persistent bool) {
t.Helper()
prev := openKeyring
openKeyring = func() (secrets.Keyring, bool) { return k, persistent }
t.Cleanup(func() { openKeyring = prev })
}

func runSecrets(t *testing.T, args ...string) (string, error) {
t.Helper()
var out strings.Builder
root := NewRootCmd(Options{})
root.SetArgs(args)
root.SetOut(&out)
root.SetErr(&out)
err := root.Execute()
return out.String(), err
}

func TestSecretsLoginPersists(t *testing.T) {
k := secrets.NewMemKeyring()
withKeyring(t, k, true)

if _, err := runSecrets(t, "secrets", "login", "infisical", "--token", "tok-123"); err != nil {
t.Fatalf("login: %v", err)
}
if v, _ := k.Get(secrets.ProviderCredKey("infisical")); v != "tok-123" {
t.Errorf("stored token = %q, want tok-123", v)
}

out, err := runSecrets(t, "secrets", "status", "infisical")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(out, "available") || !strings.Contains(out, "keyring") {
t.Errorf("status should show available + keyring source:\n%s", out)
}

if _, err := runSecrets(t, "secrets", "logout", "infisical"); err != nil {
t.Fatalf("logout: %v", err)
}
if v, _ := k.Get(secrets.ProviderCredKey("infisical")); v != "" {
t.Errorf("after logout = %q, want empty", v)
}
}

func TestSecretsLoginDegradesWithoutKeyring(t *testing.T) {
// WSL2 path: keyring unavailable → warn, name the env var, exit 0, store nothing.
k := secrets.NewMemKeyring()
withKeyring(t, k, false)

out, err := runSecrets(t, "secrets", "login", "aws", "--token", "x")
if err != nil {
t.Fatalf("login must degrade (exit 0), got %v", err)
}
if !strings.Contains(out, "DEVSTACK_AWS_TOKEN") {
t.Errorf("degrade message must name the env var:\n%s", out)
}
if v, _ := k.Get(secrets.ProviderCredKey("aws")); v != "" {
t.Error("nothing should be stored when the keyring is unavailable")
}
}

func TestSecretsLoginRequiresToken(t *testing.T) {
withKeyring(t, secrets.NewMemKeyring(), true)
if _, err := runSecrets(t, "secrets", "login", "aws"); err == nil {
t.Fatal("login without --token must error")
}
}
Loading
Loading