From ba1e134db8c41f120f3dfdb5ab4c287371f58728 Mon Sep 17 00:00:00 2001 From: Mathieu Frenette <1917993+silphid@users.noreply.github.com> Date: Fri, 27 Oct 2023 16:52:26 -0400 Subject: [PATCH] feat(devops-1938): add joy setup command (#33) Co-authored-by: Mathieu Frenette --- cmd/joy/root.go | 17 +- cmd/joy/setup.go | 22 ++ internal/config/config.go | 13 +- internal/dependencies/dependency.go | 59 +++++ internal/git/git.go | 11 + internal/git/pr/github/gh.go | 20 +- internal/jac/jac.go | 37 ++- .../promote/interactive_prompt_provider.go | 2 +- internal/secret/import.go | 28 +-- internal/setup/setup.go | 230 ++++++++++++++++++ internal/style/style.go | 12 +- 11 files changed, 395 insertions(+), 56 deletions(-) create mode 100644 cmd/joy/setup.go create mode 100644 internal/dependencies/dependency.go create mode 100644 internal/setup/setup.go diff --git a/cmd/joy/root.go b/cmd/joy/root.go index 4faf832..0734ce0 100644 --- a/cmd/joy/root.go +++ b/cmd/joy/root.go @@ -3,11 +3,13 @@ package main import ( "fmt" "github.com/nestoca/joy/internal/config" + "github.com/nestoca/joy/internal/dependencies" "github.com/spf13/cobra" "os" ) var cfg *config.Config +var configDir, catalogDir string func main() { rootCmd := NewRootCmd() @@ -18,17 +20,29 @@ func main() { } func NewRootCmd() *cobra.Command { - var configDir, catalogDir string + setupCmd := NewSetupCmd() + cmd := &cobra.Command{ Use: "joy", Short: "Manages project, environment and release resources as code", SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { var err error + if cmd != setupCmd { + dependencies.AllRequiredMustBeInstalled() + } + cfg, err = config.Load(configDir, catalogDir) if err != nil { return fmt.Errorf("loading config: %w", err) } + + if cmd != setupCmd { + err = config.CheckCatalogDir(cfg.CatalogDir) + if err != nil { + return err + } + } return nil }, } @@ -53,6 +67,7 @@ func NewRootCmd() *cobra.Command { // Additional commands cmd.AddCommand(NewSecretCmd()) cmd.AddCommand(NewVersionCmd()) + cmd.AddCommand(setupCmd) return cmd } diff --git a/cmd/joy/setup.go b/cmd/joy/setup.go new file mode 100644 index 0000000..98c4165 --- /dev/null +++ b/cmd/joy/setup.go @@ -0,0 +1,22 @@ +package main + +import ( + "github.com/nestoca/joy/internal/setup" + "github.com/spf13/cobra" +) + +func NewSetupCmd() *cobra.Command { + var catalogRepo string + cmd := &cobra.Command{ + Use: "setup", + Short: "Setup joy for first time use", + Long: `Setup joy for first time use. + +It prompts user for catalog directory, optionally cloning it if needed, creates config file and checks for required and optional dependencies.`, + RunE: func(cmd *cobra.Command, args []string) error { + return setup.Setup(configDir, catalogDir, catalogRepo) + }, + } + cmd.Flags().StringVar(&catalogRepo, "catalog-repo", "", "URL of catalog git repo (defaults to prompting user)") + return cmd +} diff --git a/internal/config/config.go b/internal/config/config.go index 841b2a2..9b9d0dc 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -70,16 +70,19 @@ func Load(configDir, catalogDir string) (*Config, error) { } cfg.FilePath = joyrcPath + return cfg, nil +} + +func CheckCatalogDir(catalogDir string) error { // Ensure that catalog's environments directory exists - environmentsDir := filepath.Join(cfg.CatalogDir, "environments") + environmentsDir := filepath.Join(catalogDir, "environments") if _, err := os.Stat(environmentsDir); err != nil { if os.IsNotExist(err) { - return nil, fmt.Errorf("no joy catalog found at %q", cfg.CatalogDir) + return fmt.Errorf("no joy catalog found at %q", catalogDir) } - return nil, fmt.Errorf("checking for catalog directory %s: %w", cfg.CatalogDir, err) + return fmt.Errorf("checking for catalog directory %s: %w", catalogDir, err) } - - return cfg, nil + return nil } func LoadFile(file string) (*Config, error) { diff --git a/internal/dependencies/dependency.go b/internal/dependencies/dependency.go new file mode 100644 index 0000000..4926c70 --- /dev/null +++ b/internal/dependencies/dependency.go @@ -0,0 +1,59 @@ +package dependencies + +import ( + "fmt" + "github.com/nestoca/joy/internal/style" + "os" + "os/exec" +) + +type Dependency struct { + // Command that should be found in PATH + Command string + + // Url to dependency's website + Url string + + // IsRequired indicates whether this is a core dependency required to run joy + IsRequired bool + + // RequiredBy lists which joy sub-commands require this dependency + RequiredBy []string +} + +func (d *Dependency) IsInstalled() bool { + cmd := exec.Command("command", "-v", d.Command) + return cmd.Run() == nil +} + +func (d *Dependency) MustBeInstalled() { + if !d.IsInstalled() { + fmt.Printf("😅 Oops! This command requires %s dependency (see: %s)\n", style.Code(d.Command), style.Link(d.Url)) + os.Exit(1) + } +} + +var AllRequired []*Dependency +var AllOptional []*Dependency + +func Add(dep *Dependency) { + if dep.IsRequired { + AllRequired = append(AllRequired, dep) + } else { + AllOptional = append(AllOptional, dep) + } +} + +func AllRequiredMustBeInstalled() { + missingRequired := false + for _, dep := range AllRequired { + if dep.IsRequired && !dep.IsInstalled() { + fmt.Printf("❌ The %s required dependency is missing (see %s).\n", style.Code(dep.Command), style.Link(dep.Url)) + missingRequired = true + } + } + if missingRequired { + fmt.Println("😅 Oops! Joy requires those dependencies to operate. Please install them and try again! 🙏") + os.Exit(1) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 5345963..6a26206 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -3,12 +3,23 @@ package git import ( "fmt" "github.com/AlecAivazis/survey/v2" + "github.com/nestoca/joy/internal/dependencies" "github.com/nestoca/joy/internal/style" "os" "os/exec" "strings" ) +var dependency = &dependencies.Dependency{ + Command: "git", + Url: "https://git-scm.com/downloads", + IsRequired: true, +} + +func init() { + dependencies.Add(dependency) +} + func Run(dir string, args []string) error { args = append([]string{"-C", dir}, args...) cmd := exec.Command("git", args...) diff --git a/internal/git/pr/github/gh.go b/internal/git/pr/github/gh.go index 79e2cdf..9f26bda 100644 --- a/internal/git/pr/github/gh.go +++ b/internal/git/pr/github/gh.go @@ -3,6 +3,7 @@ package github import ( "errors" "fmt" + "github.com/nestoca/joy/internal/dependencies" "github.com/nestoca/joy/internal/style" "os" "os/exec" @@ -10,6 +11,16 @@ import ( "strings" ) +var dependency = &dependencies.Dependency{ + Command: "gh", + Url: "/~https://github.com/cli/cli", + IsRequired: true, +} + +func init() { + dependencies.Add(dependency) +} + // executeInteractively runs gh command with given args with full forwarding of stdin, stdout and stderr. func executeInteractively(args ...string) error { err := EnsureInstalledAndAuthenticated() @@ -44,15 +55,10 @@ func executeAndGetOutput(args ...string) (string, error) { } func EnsureInstalledAndAuthenticated() error { - cmd := exec.Command("command", "-v", "gh") - err := cmd.Run() - if err != nil { - fmt.Println("🤓 This command requires the gh cli.\nSee: /~https://github.com/cli/cli") - return errors.New("missing gh cli dependency") - } + dependency.MustBeInstalled() // Check if user is logged in - cmd = exec.Command("gh", "auth", "status") + cmd := exec.Command("gh", "auth", "status") output, err := cmd.CombinedOutput() outputStr := string(output) if err != nil { diff --git a/internal/jac/jac.go b/internal/jac/jac.go index a796e02..04a709e 100644 --- a/internal/jac/jac.go +++ b/internal/jac/jac.go @@ -1,10 +1,10 @@ package jac import ( - "errors" "fmt" "github.com/AlecAivazis/survey/v2" "github.com/nestoca/joy/api/v1alpha1" + "github.com/nestoca/joy/internal/dependencies" "github.com/nestoca/joy/internal/git" "github.com/nestoca/joy/internal/release/cross" "github.com/nestoca/joy/internal/style" @@ -14,13 +14,21 @@ import ( "strings" ) +var dependency = &dependencies.Dependency{ + Command: "jac", + Url: "/~https://github.com/nestoca/jac", + IsRequired: false, + RequiredBy: []string{"project owners", "release owners"}, +} + +func init() { + dependencies.Add(dependency) +} + func ListProjectPeople(catalogDir string, extraArgs []string) error { - err := ensureJacCliInstalled() - if err != nil { - return err - } + dependency.MustBeInstalled() - err = git.EnsureCleanAndUpToDateWorkingCopy(catalogDir) + err := git.EnsureCleanAndUpToDateWorkingCopy(catalogDir) if err != nil { return err } @@ -45,12 +53,9 @@ func ListProjectPeople(catalogDir string, extraArgs []string) error { } func ListReleasePeople(catalogDir string, extraArgs []string) error { - err := ensureJacCliInstalled() - if err != nil { - return err - } + dependency.MustBeInstalled() - err = git.EnsureCleanAndUpToDateWorkingCopy(catalogDir) + err := git.EnsureCleanAndUpToDateWorkingCopy(catalogDir) if err != nil { return err } @@ -109,16 +114,6 @@ func listPeopleWithGroups(groups []string, extraArgs []string) error { return nil } -func ensureJacCliInstalled() error { - cmd := exec.Command("command", "-v", "jac") - err := cmd.Run() - if err != nil { - fmt.Println("🤓 This command requires the jac cli.\nSee: /~https://github.com/nestoca/jac") - return errors.New("missing jac cli dependency") - } - return nil -} - func selectProject(projects []*v1alpha1.Project) (*v1alpha1.Project, error) { var selectedIndex int err := survey.AskOne(&survey.Select{ diff --git a/internal/release/promote/interactive_prompt_provider.go b/internal/release/promote/interactive_prompt_provider.go index 1ca82b2..c09e067 100644 --- a/internal/release/promote/interactive_prompt_provider.go +++ b/internal/release/promote/interactive_prompt_provider.go @@ -259,7 +259,7 @@ func (i *InteractivePromptProvider) PrintBranchCreated(branchName, message strin } func (i *InteractivePromptProvider) PrintPullRequestCreated(url string) { - fmt.Printf("✅ Created pull request: %s\n", style.Hyperlink(url)) + fmt.Printf("✅ Created pull request: %s\n", style.Link(url)) } func (i *InteractivePromptProvider) PrintCanceled() { diff --git a/internal/secret/import.go b/internal/secret/import.go index a294e89..b865652 100644 --- a/internal/secret/import.go +++ b/internal/secret/import.go @@ -2,9 +2,9 @@ package secret import ( "encoding/base64" - "errors" "fmt" "github.com/AlecAivazis/survey/v2" + "github.com/nestoca/joy/internal/dependencies" "github.com/nestoca/joy/internal/environment" "github.com/nestoca/joy/internal/style" "github.com/nestoca/joy/internal/yml" @@ -13,11 +13,19 @@ import ( "strings" ) +var kubectlDependency = &dependencies.Dependency{ + Command: "kubectl", + Url: "https://kubernetes.io/docs/tasks/tools/#kubectl", + IsRequired: false, + RequiredBy: []string{"sealed-secret import"}, +} + +func init() { + dependencies.Add(kubectlDependency) +} + func ImportCert() error { - err := ensureKubectlInstalled() - if err != nil { - return err - } + kubectlDependency.MustBeInstalled() // Select kube context context, err := selectKubeContext() @@ -108,13 +116,3 @@ func selectKubeContext() (string, error) { } return contexts[selectedIndex], nil } - -func ensureKubectlInstalled() error { - cmd := exec.Command("command", "-v", "kubectl") - err := cmd.Run() - if err != nil { - fmt.Println("🤓 This command requires kubectl cli to be installed: https://kubernetes.io/docs/tasks/tools/#kubectl") - return errors.New("missing kubectl cli dependency") - } - return nil -} diff --git a/internal/setup/setup.go b/internal/setup/setup.go new file mode 100644 index 0000000..8e98f39 --- /dev/null +++ b/internal/setup/setup.go @@ -0,0 +1,230 @@ +package setup + +import ( + "fmt" + "github.com/AlecAivazis/survey/v2" + "github.com/nestoca/joy/internal/config" + "github.com/nestoca/joy/internal/dependencies" + "github.com/nestoca/joy/internal/style" + "github.com/nestoca/joy/pkg/catalog" + "os" + "os/exec" + "path" + "strings" +) + +const defaultCatalogDir = "~/.joy" +const separator = "————————————————————————————————————————————————————————————————————————————————" + +func Setup(configDir, catalogDir, catalogRepo string) error { + fmt.Println("👋 Hey there, let's kickstart your most joyful CD experience! ☀️") + fmt.Println(separator) + + // Setup catalog and config + fmt.Print("🛠️ Let's first set up your configuration and catalog repo...\n\n") + catalogDir, err := setupCatalog(configDir, catalogDir, catalogRepo) + if err != nil { + return err + } + err = setupConfig(configDir, catalogDir) + if err != nil { + return err + } + fmt.Println(separator) + + // Check dependencies + fmt.Print("🧐 Hmm, let's now see what dependencies you've got humming under the hood...\n\n") + checkDependencies() + fmt.Println(separator) + + fmt.Println("🚀 All systems nominal. Houston, we're cleared for launch!") + return nil +} + +func setupConfig(configDir string, catalogDir string) error { + // Try loading config file from given or default location + cfg, err := config.Load(configDir, catalogDir) + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + + // Save config file + cfg.CatalogDir = catalogDir + err = cfg.Save() + if err != nil { + return fmt.Errorf("saving config: %w", err) + } + fmt.Printf("✅ Saved config to file %s\n", style.Code(cfg.FilePath)) + return nil +} + +func setupCatalog(configDir string, catalogDir string, catalogRepo string) (string, error) { + var err error + catalogDir, err = getCatalogDir(configDir, catalogDir) + if err != nil { + return "", err + } + + // Check if catalog directory exists + if _, err := os.Stat(catalogDir); err != nil { + if os.IsNotExist(err) { + err := cloneCatalog(catalogRepo, catalogDir) + if err != nil { + return "", err + } + } else { + return "", fmt.Errorf("checking for catalog directory %s: %w", catalogDir, err) + } + } + + cat := loadCatalog(catalogDir) + printCatalogSummary(cat) + return catalogDir, nil +} + +func getCatalogDir(configDir string, catalogDir string) (string, error) { + if catalogDir == "" { + // Try loading catalog dir from config file to use as prompt default value + cfg, err := config.Load(configDir, catalogDir) + if err == nil { + catalogDir = cfg.CatalogDir + } else { + catalogDir = defaultCatalogDir + } + + // Prompt user for catalog directory using survey (defaults to $HOME/.joy) + err = survey.AskOne(&survey.Input{ + Message: "🎯 Where does (or should) your local catalog reside?", + Help: "This is where we will clone your catalog repo, but only if it's not already there.", + Default: catalogDir, + }, + &catalogDir, + survey.WithValidator(survey.Required), + ) + if err != nil { + return "", fmt.Errorf("prompting for catalog directory: %w", err) + } + } + + // Expand tilde to home directory + homePrefix := "~/" + if strings.HasPrefix(catalogDir, homePrefix) { + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("getting home directory: %w", err) + } + catalogDir = path.Join(homeDir, strings.TrimPrefix(catalogDir, homePrefix)) + } + return catalogDir, nil +} + +func loadCatalog(catalogDir string) *catalog.Catalog { + cat, err := catalog.Load(catalog.LoadOpts{ + Dir: catalogDir, + LoadEnvs: true, + LoadProjects: true, + LoadReleases: true, + ResolveRefs: true, + }) + if err != nil { + fmt.Printf("🤯 Whoa! Found the catalog, but failed to load it. Check this error and try again:\n%v\n", err) + os.Exit(1) + } + return cat +} + +func cloneCatalog(catalogRepo, catalogDir string) error { + shouldClone := true + if catalogRepo == "" { + // Prompt user whether to clone it + err := survey.AskOne(&survey.Confirm{ + Message: "🤷 No trace of catalog at given location, clone it?", + Default: true, + }, + &shouldClone, + ) + if err != nil { + return fmt.Errorf("prompting for catalog cloning: %w", err) + } + + // Prompt user for catalog repo + err = survey.AskOne(&survey.Input{ + Message: "📦 What's your catalog repo URL?", + }, + &catalogRepo, + survey.WithValidator(survey.Required), + ) + if err != nil { + return fmt.Errorf("prompting for catalog repo: %w", err) + } + } + + // Clone catalog + if shouldClone { + cmd := exec.Command("git", "clone", catalogRepo, catalogDir) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("cloning catalog: %s", output) + } + fmt.Printf("✅ Cloned catalog from %s to %s\n", style.Link(catalogRepo), style.Code(catalogDir)) + } else { + fmt.Println("😬 Sorry, cannot continue without catalog!") + os.Exit(1) + } + return nil +} + +func printCatalogSummary(cat *catalog.Catalog) { + envCount := len(cat.Environments) + projectCount := len(cat.Projects) + releaseCount := len(cat.Releases.Items) + if envCount == 0 || projectCount == 0 || releaseCount == 0 { + fmt.Print("🦗 Crickets... No ") + if envCount == 0 { + fmt.Print("environments") + } + if projectCount == 0 { + if envCount == 0 { + fmt.Print("/") + } + fmt.Print("projects") + } + if releaseCount == 0 { + if envCount == 0 || projectCount == 0 { + fmt.Print("/") + } + fmt.Print("releases") + } + fmt.Println(" found. Please add some to the catalog.") + } else { + fmt.Printf("✅ Catalog loaded! You've got %d environments, %d projects, and %d releases. Nice! 👍\n", envCount, projectCount, releaseCount) + } +} + +func checkDependencies() { + missingRequired := false + for _, dep := range dependencies.AllRequired { + if dep.IsInstalled() { + fmt.Printf("✅ Found %s required dependency.\n", style.Code(dep.Command)) + } else { + fmt.Printf("❌ The %s required dependency is missing (see %s).\n", style.Code(dep.Command), style.Link(dep.Url)) + missingRequired = true + } + } + for _, dep := range dependencies.AllOptional { + if dep.IsInstalled() { + fmt.Printf("✅ Found %s optional dependency.\n", style.Code(dep.Command)) + } else { + fmt.Printf(fmt.Sprintf("🤷 The %s optional dependency is missing (see: %s) but only required by those commands:\n", style.Code(dep.Command), style.Link(dep.Url))) + for _, cmd := range dep.RequiredBy { + fmt.Printf(" 🔹 %s\n", style.Code("joy "+cmd)) + } + } + } + + if missingRequired { + fmt.Println() + fmt.Println("😅 Oops! Joy requires those dependencies to operate. Please install them and try again! 🙏") + os.Exit(1) + } +} diff --git a/internal/style/style.go b/internal/style/style.go index 09c0aa0..7d8d349 100644 --- a/internal/style/style.go +++ b/internal/style/style.go @@ -20,11 +20,6 @@ func ResourceEnvPrefix(s any) string { return color.Colorize(darkYellow, s) } -// Hyperlink is for URLs and other clickable links -func Hyperlink(s any) string { - return color.InUnderline(color.InBlue(s)) -} - // DiffBefore is for text that is being removed in a diff func DiffBefore(s any) string { return color.InRed(s) @@ -47,7 +42,7 @@ func Warning(s any) string { // Code is for code snippets, commands, yaml properties, or any technical text that is not a resource name func Code(s any) string { - return color.InBold(color.InCyan(s)) + return color.InBold(color.InYellow(s)) } // Version is for release versions within messages (not tables) @@ -77,3 +72,8 @@ func ReleaseInSyncOrNot(s any, inSync bool) string { func ReleaseNotAvailable(s any) string { return color.InGray(s) } + +// Link is for URLs and other links +func Link(s any) string { + return color.InUnderline(color.InBlue(s)) +}