Skip to content

Commit

Permalink
Adds mint vaults set-secrets (#42)
Browse files Browse the repository at this point in the history
  • Loading branch information
TAGraves authored Dec 20, 2023
1 parent af85551 commit 12ba7a6
Show file tree
Hide file tree
Showing 10 changed files with 316 additions and 0 deletions.
1 change: 1 addition & 0 deletions cmd/mint/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,5 @@ func init() {
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(loginCmd)
rootCmd.AddCommand(whoamiCmd)
rootCmd.AddCommand(vaultsCmd)
}
45 changes: 45 additions & 0 deletions cmd/mint/vaults.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package main

import (
"os"

"github.com/rwx-research/mint-cli/internal/cli"
"github.com/spf13/cobra"
)

var vaultsCmd = &cobra.Command{
Short: "Manage Mint vaults and secrets",
Use: "vaults",
}

var (
Vault string
File string

vaultsSetSecretsCmd = &cobra.Command{
PreRunE: func(cmd *cobra.Command, args []string) error {
return requireAccessToken()
},
RunE: func(cmd *cobra.Command, args []string) error {
var secrets []string
if len(args) >= 0 {
secrets = args
}

return service.SetSecretsInVault(cli.SetSecretsInVaultConfig{
Vault: Vault,
File: File,
Secrets: secrets,
Stdout: os.Stdout,
})
},
Short: "Set secrets in a vault",
Use: "set-secrets [flags] [SECRETNAME=secretvalue]",
}
)

func init() {
vaultsSetSecretsCmd.Flags().StringVar(&Vault, "vault", "default", "the name of the vault to set the secrets in")
vaultsSetSecretsCmd.Flags().StringVar(&File, "file", "", "the path to a file in dotenv format to read the secrets from")
vaultsCmd.AddCommand(vaultsSetSecretsCmd);
}
38 changes: 38 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,44 @@ func (c Client) Whoami() (*WhoamiResult, error) {
return &respBody, nil
}

func (c Client) SetSecretsInVault(cfg SetSecretsInVaultConfig) (*SetSecretsInVaultResult, error) {
endpoint := "/mint/api/vaults/secrets"

encodedBody, err := json.Marshal(cfg)
if err != nil {
return nil, errors.Wrap(err, "unable to encode as JSON")
}

req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewBuffer(encodedBody))
if err != nil {
return nil, errors.Wrap(err, "unable to create new HTTP request")
}

req.Header.Set("Content-Type", "application/json")

resp, err := c.RoundTrip(req)
if err != nil {
return nil, errors.Wrap(err, "HTTP request failed")
}
defer resp.Body.Close()

if resp.StatusCode != 200 {
msg := extractErrorMessage(resp.Body)
if msg == "" {
msg = fmt.Sprintf("Unable to call Mint API - %s", resp.Status)
}

return nil, errors.New(msg)
}

respBody := SetSecretsInVaultResult{}
if err := json.NewDecoder(resp.Body).Decode(&respBody); err != nil {
return nil, errors.Wrap(err, "unable to parse API response")
}

return &respBody, nil
}

// extractErrorMessage is a small helper function for parsing an API error message
func extractErrorMessage(reader io.Reader) string {
errorStruct := struct {
Expand Down
24 changes: 24 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,4 +186,28 @@ var _ = Describe("API Client", func() {
Expect(err).To(BeNil())
})
})

Describe("SetSecretsInVault", func() {
It("makes the request", func() {
body := api.SetSecretsInVaultConfig{
VaultName: "default",
Secrets: []api.Secret{{Name: "ABC", Secret: "123"}},
}
bodyBytes, _ := json.Marshal(body)

roundTrip := func(req *http.Request) (*http.Response, error) {
Expect(req.URL.Path).To(Equal("/mint/api/vaults/secrets"))
return &http.Response{
Status: "200 OK",
StatusCode: 200,
Body: io.NopCloser(bytes.NewReader(bodyBytes)),
}, nil
}

c := api.Client{roundTrip}

_, err := c.SetSecretsInVault(body)
Expect(err).To(BeNil())
})
})
})
14 changes: 14 additions & 0 deletions internal/api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,17 @@ type WhoamiResult struct {
TokenKind string `json:"token_kind"` // organization_access_token, personal_access_token
UserEmail *string `json:"user_email,omitempty"`
}

type SetSecretsInVaultConfig struct {
Secrets []Secret `json:"secrets"`
VaultName string `json:"vault_name"`
}

type Secret struct {
Name string `json:"name"`
Secret string `json:"secret"`
}

type SetSecretsInVaultResult struct {
SetSecrets []string `json:"set_secrets"`
}
20 changes: 20 additions & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,23 @@ type WhoamiConfig struct {
func (c WhoamiConfig) Validate() error {
return nil
}

type SetSecretsInVaultConfig struct {
Secrets []string
Vault string
File string
Stdout io.Writer
}

func (c SetSecretsInVaultConfig) Validate() error {
if c.Vault == "" {
return errors.New("the vault name must be provided")
}

if len(c.Secrets) == 0 && c.File == "" {
return errors.New("the secrets to set must be provided")
}

return nil
}

1 change: 1 addition & 0 deletions internal/cli/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ type APIClient interface {
ObtainAuthCode(api.ObtainAuthCodeConfig) (*api.ObtainAuthCodeResult, error)
AcquireToken(tokenUrl string) (*api.AcquireTokenResult, error)
Whoami() (*api.WhoamiResult, error)
SetSecretsInVault(api.SetSecretsInVaultConfig) (*api.SetSecretsInVaultResult, error)
}

type SSHClient interface {
Expand Down
69 changes: 69 additions & 0 deletions internal/cli/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,75 @@ func (s Service) Whoami(cfg WhoamiConfig) error {
return nil
}

// DebugRunConfig will connect to a running task over SSH. Key exchange is facilitated over the Cloud API.
func (s Service) SetSecretsInVault(cfg SetSecretsInVaultConfig) error {
err := cfg.Validate()
if err != nil {
return errors.Wrap(err, "validation failed")
}

secrets := []api.Secret{}
for i := range cfg.Secrets {
key, value, found := strings.Cut(cfg.Secrets[i], "=")
if !found {
return errors.New(fmt.Sprintf("Invalid secret '%s'. Secrets must be specified in the form 'KEY=value'.", cfg.Secrets[i]))
}
secrets = append(secrets, api.Secret{
Name: key,
Secret: value,
})
}

if cfg.File != "" {
fd, err := s.FileSystem.Open(cfg.File)
if err != nil {
return errors.Wrapf(err, "error while opening %q", cfg.File)
}
defer fd.Close()

fileContent, err := io.ReadAll(fd)
if err != nil {
return errors.Wrapf(err, "error while reading %q", cfg.File)
}

fileLines := strings.Split(string(fileContent), "\n")

for i := range fileLines {
if fileLines[i] == "" {
continue
}
key, value, found := strings.Cut(fileLines[i], "=")
if !found {
return errors.New(fmt.Sprintf("Invalid secret '%s' in file %s. Secrets must be specified in the form 'KEY=value'.", cfg.Secrets[i], cfg.File))
}
// If a line is like ABC="def", we need to strip off the leading and trailing quotation mark
if strings.HasPrefix(value, "\"") && strings.HasSuffix(value, "\"") {
value = value[1 : len(value)-1]
}
secrets = append(secrets, api.Secret{
Name: key,
Secret: value,
})
}
}

result, err := s.APIClient.SetSecretsInVault(api.SetSecretsInVaultConfig{
VaultName: cfg.Vault,
Secrets: secrets,
})

if result != nil && len(result.SetSecrets) > 0 {
fmt.Fprintln(cfg.Stdout)
fmt.Fprintf(cfg.Stdout, "Successfully set the following secrets: %s", strings.Join(result.SetSecrets, ", "))
}

if err != nil {
return errors.Wrap(err, "unable to set secrets")
}

return nil
}

// taskDefinitionsFromPaths opens each file specified in `paths` and reads their content as a string.
// No validation takes place here.
func (s Service) taskDefinitionsFromPaths(paths []string) ([]api.TaskDefinition, error) {
Expand Down
94 changes: 94 additions & 0 deletions internal/cli/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -778,4 +778,98 @@ AAAEC6442PQKevgYgeT0SIu9zwlnEMl6MF59ZgM+i0ByMv4eLJPqG3xnZcEQmktHj/GY2i
})
})
})

Describe("setting secrets", func() {
var (
stdout strings.Builder
)

BeforeEach(func() {
var err error
Expect(err).NotTo(HaveOccurred())

stdout = strings.Builder{}
})

Context("when unable to set secrets", func() {
BeforeEach(func() {
mockAPI.MockSetSecretsInVault = func(ssivc api.SetSecretsInVaultConfig) (*api.SetSecretsInVaultResult, error) {
Expect(ssivc.VaultName).To(Equal("default"))
Expect(ssivc.Secrets[0].Name).To(Equal("ABC"))
Expect(ssivc.Secrets[0].Secret).To(Equal("123"))
return nil, errors.New("error setting secret")
}
})

It("returns an error", func() {
err := service.SetSecretsInVault(cli.SetSecretsInVaultConfig{
Vault: "default",
Secrets: []string{"ABC=123"},
Stdout: &stdout,
})

Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("error setting secret"))
})
})

Context("with secrets set", func() {
BeforeEach(func() {
mockAPI.MockSetSecretsInVault = func(ssivc api.SetSecretsInVaultConfig) (*api.SetSecretsInVaultResult, error) {
Expect(ssivc.VaultName).To(Equal("default"))
Expect(ssivc.Secrets[0].Name).To(Equal("ABC"))
Expect(ssivc.Secrets[0].Secret).To(Equal("123"))
Expect(ssivc.Secrets[1].Name).To(Equal("DEF"))
Expect(ssivc.Secrets[1].Secret).To(Equal("\"xyz\""))
return &api.SetSecretsInVaultResult{
SetSecrets: []string{"ABC","DEF"},
}, nil
}
})

It("is successful", func() {
err := service.SetSecretsInVault(cli.SetSecretsInVaultConfig{
Vault: "default",
Secrets: []string{"ABC=123", "DEF=\"xyz\""},
Stdout: &stdout,
})

Expect(err).NotTo(HaveOccurred())
Expect(stdout.String()).To(Equal("\nSuccessfully set the following secrets: ABC, DEF"))
})
})

Context("when reading secrets from a file", func() {
BeforeEach(func() {
mockAPI.MockSetSecretsInVault = func(ssivc api.SetSecretsInVaultConfig) (*api.SetSecretsInVaultResult, error) {
Expect(ssivc.VaultName).To(Equal("default"))
Expect(ssivc.Secrets[0].Name).To(Equal("ABC"))
Expect(ssivc.Secrets[0].Secret).To(Equal("123"))
Expect(ssivc.Secrets[1].Name).To(Equal("DEF"))
Expect(ssivc.Secrets[1].Secret).To(Equal("xyz"))
return &api.SetSecretsInVaultResult{
SetSecrets: []string{"ABC","DEF"},
}, nil
}

mockFS.MockOpen = func(name string) (fs.File, error) {
Expect(name).To(Equal("secrets.txt"))
file := mocks.NewFile("ABC=123\nDEF=\"xyz\"\n")
return file, nil
}
})

It("is successful", func() {
err := service.SetSecretsInVault(cli.SetSecretsInVaultConfig{
Vault: "default",
Secrets: []string{},
File: "secrets.txt",
Stdout: &stdout,
})

Expect(err).NotTo(HaveOccurred())
Expect(stdout.String()).To(Equal("\nSuccessfully set the following secrets: ABC, DEF"))
})
})
})
})
10 changes: 10 additions & 0 deletions internal/mocks/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ type API struct {
MockObtainAuthCode func(api.ObtainAuthCodeConfig) (*api.ObtainAuthCodeResult, error)
MockAcquireToken func(tokenUrl string) (*api.AcquireTokenResult, error)
MockWhoami func() (*api.WhoamiResult, error)
MockSetSecretsInVault func(api.SetSecretsInVaultConfig) (*api.SetSecretsInVaultResult, error)
}

func (c *API) InitiateRun(cfg api.InitiateRunConfig) (*api.InitiateRunResult, error) {
Expand Down Expand Up @@ -52,3 +53,12 @@ func (c *API) Whoami() (*api.WhoamiResult, error) {

return nil, errors.New("MockWhoami was not configured")
}

func (c *API) SetSecretsInVault(cfg api.SetSecretsInVaultConfig) (*api.SetSecretsInVaultResult, error) {
if c.MockSetSecretsInVault != nil {
return c.MockSetSecretsInVault(cfg)
}

return nil, errors.New("MockSetSecretsInVault was not configured")
}

0 comments on commit 12ba7a6

Please sign in to comment.