Skip to content

Commit

Permalink
Add a tool to update mint leaf versions (#46)
Browse files Browse the repository at this point in the history
Co-authored-by: Pierre Beaucamp <pierre@rwx.com>
Co-authored-by: Pierre Beaucamp <pierrebeaucamp@users.noreply.github.com>
Co-authored-by: Tommy Graves <tommy@rwx.com>
  • Loading branch information
4 people committed Mar 21, 2024
1 parent 6f4c962 commit af2e3a6
Show file tree
Hide file tree
Showing 10 changed files with 657 additions and 6 deletions.
3 changes: 2 additions & 1 deletion .tool-versions
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
golang 1.20
golang 1.22.1
golangci-lint 1.57.1
47 changes: 47 additions & 0 deletions cmd/mint/leaves.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package main

import (
"os"

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

var leavesCmd = &cobra.Command{
Short: "Manage Mint leaves",
Use: "leaves",
}

var (
Files []string
AllowMajorVersionChange bool

leavesUpdateCmd = &cobra.Command{
PreRunE: func(cmd *cobra.Command, args []string) error {
return requireAccessToken()
},
RunE: func(cmd *cobra.Command, args []string) error {
replacementVersionPicker := cli.PickLatestMinorVersion
if AllowMajorVersionChange {
replacementVersionPicker = cli.PickLatestMajorVersion
}

return service.UpdateLeaves(cli.UpdateLeavesConfig{
Files: args,
DefaultDir: ".mint",
ReplacementVersionPicker: replacementVersionPicker,
Stdout: os.Stdout,
Stderr: os.Stderr,
})
},
Short: "Update all leaves to their latest (minor) version",
Long: "Update all leaves to their latest (minor) version.\n" +
"Takes a list of files as arguments, or updates all toplevel YAML files in .mint if no files are given.",
Use: "update [flags] [file...]",
}
)

func init() {
leavesUpdateCmd.Flags().BoolVar(&AllowMajorVersionChange, "allow-major-version-change", false, "update leaves to the latest major version")
leavesCmd.AddCommand(leavesUpdateCmd)
}
1 change: 1 addition & 0 deletions cmd/mint/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,5 @@ func init() {
rootCmd.AddCommand(loginCmd)
rootCmd.AddCommand(whoamiCmd)
rootCmd.AddCommand(vaultsCmd)
rootCmd.AddCommand(leavesCmd)
}
30 changes: 30 additions & 0 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,36 @@ func (c Client) SetSecretsInVault(cfg SetSecretsInVaultConfig) (*SetSecretsInVau
return &respBody, nil
}

func (c Client) GetLeafVersions() (*LeafVersionsResult, error) {
endpoint := "/mint/api/leaves"

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

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 := LeafVersionsResult{}
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
7 changes: 6 additions & 1 deletion internal/api/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ type WhoamiResult struct {

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

type Secret struct {
Expand All @@ -93,3 +93,8 @@ type Secret struct {
type SetSecretsInVaultResult struct {
SetSecrets []string `json:"set_secrets"`
}

type LeafVersionsResult struct {
LatestMajor map[string]string `json:"latest_major"`
LatestMinor map[string]map[string]string `json:"latest_minor"`
}
29 changes: 29 additions & 0 deletions internal/cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"io"

"github.com/rwx-research/mint-cli/internal/accesstoken"
"github.com/rwx-research/mint-cli/internal/api"
"github.com/rwx-research/mint-cli/internal/errors"
"github.com/rwx-research/mint-cli/internal/fs"
)
Expand Down Expand Up @@ -98,3 +99,31 @@ func (c SetSecretsInVaultConfig) Validate() error {

return nil
}

type UpdateLeavesConfig struct {
DefaultDir string
Files []string
ReplacementVersionPicker func(versions api.LeafVersionsResult, leaf string, major string) (string, error)
Stdout io.Writer
Stderr io.Writer
}

func (c UpdateLeavesConfig) Validate() error {
if len(c.Files) == 0 && c.DefaultDir == "" {
return errors.New("a default directory must be provided if not specifying files explicitly")
}

if c.ReplacementVersionPicker == nil {
return errors.New("a replacement version picker must be provided")
}

if c.Stdout == nil {
return errors.New("a stdout interface needs to be provided")
}

if c.Stdout == nil {
return errors.New("a stderr interface needs to 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 @@ -13,6 +13,7 @@ type APIClient interface {
AcquireToken(tokenUrl string) (*api.AcquireTokenResult, error)
Whoami() (*api.WhoamiResult, error)
SetSecretsInVault(api.SetSecretsInVaultConfig) (*api.SetSecretsInVaultResult, error)
GetLeafVersions() (*api.LeafVersionsResult, error)
}

type SSHClient interface {
Expand Down
170 changes: 168 additions & 2 deletions internal/cli/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"io"
"os"
"path/filepath"
"regexp"
"strings"
"time"

Expand Down Expand Up @@ -296,7 +297,6 @@ 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 {
Expand Down Expand Up @@ -358,6 +358,149 @@ func (s Service) SetSecretsInVault(cfg SetSecretsInVaultConfig) error {
return nil
}

func (s Service) UpdateLeaves(cfg UpdateLeavesConfig) error {
var files []string

err := cfg.Validate()
if err != nil {
return errors.Wrap(err, "validation failed")
}

if len(cfg.Files) > 0 {
files = cfg.Files
} else {
yamlFilePathsInDirectory, err := s.yamlFilePathsInDirectory(cfg.DefaultDir)
if err != nil {
return errors.Wrap(err, fmt.Sprintf("unable to find yaml files in directory %s", cfg.DefaultDir))
}
files = yamlFilePathsInDirectory
}

if len(files) == 0 {
return errors.New(fmt.Sprintf("no files provided, and no yaml files found in directory %s", cfg.DefaultDir))
}

leafReferences, err := s.findLeafReferences(files)
if err != nil {
return err
}

leafVersions, err := s.APIClient.GetLeafVersions()
if err != nil {
return errors.Wrap(err, "unable to fetch leaf versions")
}

replacements := make(map[string]string)
for leaf, majorVersions := range leafReferences {
for majorVersion, references := range majorVersions {
targetLeafVersion, err := cfg.ReplacementVersionPicker(*leafVersions, leaf, majorVersion)
if err != nil {
fmt.Fprintln(cfg.Stderr, err.Error())
continue
}

replacement := fmt.Sprintf("%s %s", leaf, targetLeafVersion)
for _, reference := range references {
if reference != replacement {
replacements[reference] = replacement
}
}
}
}

err = s.replaceInFiles(files, replacements)
if err != nil {
return errors.Wrap(err, "unable to replace leaf references")
}

if len(replacements) == 0 {
fmt.Fprintln(cfg.Stdout, "No leaves to update.")
} else {
fmt.Fprintln(cfg.Stdout, "Updated the following leaves:")
for original, replacement := range replacements {
fmt.Fprintf(cfg.Stdout, "\t%s -> %s\n", original, replacement)
}
}

return nil
}

var reLeaf = regexp.MustCompile(`([a-z0-9-]+\/[a-z0-9-]+) ([0-9]+)\.[0-9]+\.[0-9]+`)

// findLeafReferences returns a map indexed with the leaf names. Each key is another map, this time indexed by
// the major version number. Finally, the value is an array of version strings as they appeared in the source
// file
func (s Service) findLeafReferences(files []string) (map[string]map[string][]string, error) {
matches := make(map[string]map[string][]string)

for _, path := range files {
fd, err := s.FileSystem.Open(path)
if err != nil {
return nil, errors.Wrapf(err, "error while opening %q", path)
}
defer fd.Close()

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

for _, match := range reLeaf.FindAllSubmatch(fileContent, -1) {
fullMatch := string(match[0])
leaf := string(match[1])
majorVersion := string(match[2])

majorVersions, ok := matches[leaf]
if !ok {
majorVersions = make(map[string][]string)
}

if _, ok := majorVersions[majorVersion]; !ok {
majorVersions[majorVersion] = []string{fullMatch}
} else {
majorVersions[majorVersion] = append(majorVersions[majorVersion], fullMatch)
}

matches[leaf] = majorVersions
}
}

return matches, nil
}

func (s Service) replaceInFiles(files []string, replacements map[string]string) error {
for _, path := range files {
fd, err := s.FileSystem.Open(path)
if err != nil {
return errors.Wrapf(err, "error while opening %q", path)
}
defer fd.Close()

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

for old, new := range replacements {
fileContentStr = strings.ReplaceAll(fileContentStr, old, new)
}

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

_, err = io.WriteString(fd, fileContentStr)
if err != nil {
return errors.Wrapf(err, "error while writing %q", path)
}
}

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 Expand Up @@ -430,7 +573,7 @@ func (s Service) findMintDirectoryPath(configuredDirectory string) (string, erro
return filepath.Join(workingDirectory, ".mint"), nil
}

if (workingDirectory == string(os.PathSeparator)) {
if workingDirectory == string(os.PathSeparator) {
return "", nil
}

Expand All @@ -448,3 +591,26 @@ func validateYAML(body string) error {

return nil
}

func PickLatestMajorVersion(versions api.LeafVersionsResult, leaf string, _ string) (string, error) {
latestVersion, ok := versions.LatestMajor[leaf]
if !ok {
return "", fmt.Errorf("Unable to find the leaf %q; skipping it.", leaf)
}

return latestVersion, nil
}

func PickLatestMinorVersion(versions api.LeafVersionsResult, leaf string, major string) (string, error) {
majorVersions, ok := versions.LatestMinor[leaf]
if !ok {
return "", fmt.Errorf("Unable to find the leaf %q; skipping it.", leaf)
}

latestVersion, ok := majorVersions[major]
if !ok {
return "", fmt.Errorf("Unable to find major version %q for leaf %q; skipping it.", major, leaf)
}

return latestVersion, nil
}
Loading

0 comments on commit af2e3a6

Please sign in to comment.