From 41048d544a68819cec3c3fa4474e5e075d17bbca Mon Sep 17 00:00:00 2001 From: Matt Calhoun Date: Tue, 20 Aug 2024 09:53:27 -0400 Subject: [PATCH] DEV-2465: add atmos pro stack locking (#678) --- cmd/pro.go | 17 ++ cmd/pro_lock.go | 33 +++ cmd/pro_unlock.go | 31 +++ demo/screengrabs/demo-stacks.txt | 2 + go.mod | 1 + internal/exec/describe_affected.go | 186 ++++++++-------- internal/exec/describe_affected_utils.go | 174 +++------------ internal/exec/pro.go | 203 ++++++++++++++++++ pkg/config/const.go | 2 +- pkg/git/git.go | 105 +++++++++ pkg/logger/logger.go | 149 +++++++++++++ pkg/logger/logger_test.go | 166 ++++++++++++++ pkg/pro/api_client.go | 191 ++++++++++++++++ pkg/pro/api_client_test.go | 144 +++++++++++++ pkg/pro/requests.go | 64 ++++++ website/docs/cli/commands/pro/_category_.json | 11 + website/docs/cli/commands/pro/pro-lock.mdx | 49 +++++ website/docs/cli/commands/pro/pro-unlock.mdx | 46 ++++ website/docs/cli/commands/pro/usage.mdx | 17 ++ 19 files changed, 1347 insertions(+), 244 deletions(-) create mode 100644 cmd/pro.go create mode 100644 cmd/pro_lock.go create mode 100644 cmd/pro_unlock.go create mode 100644 internal/exec/pro.go create mode 100644 pkg/git/git.go create mode 100644 pkg/logger/logger.go create mode 100644 pkg/logger/logger_test.go create mode 100644 pkg/pro/api_client.go create mode 100644 pkg/pro/api_client_test.go create mode 100644 pkg/pro/requests.go create mode 100644 website/docs/cli/commands/pro/_category_.json create mode 100644 website/docs/cli/commands/pro/pro-lock.mdx create mode 100644 website/docs/cli/commands/pro/pro-unlock.mdx create mode 100644 website/docs/cli/commands/pro/usage.mdx diff --git a/cmd/pro.go b/cmd/pro.go new file mode 100644 index 000000000..f619604d8 --- /dev/null +++ b/cmd/pro.go @@ -0,0 +1,17 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +// proCmd executes 'atmos pro' CLI commands +var proCmd = &cobra.Command{ + Use: "pro", + Short: "Execute 'pro' commands", + Long: `This command executes 'atmos pro' CLI commands`, + FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false}, +} + +func init() { + RootCmd.AddCommand(proCmd) +} diff --git a/cmd/pro_lock.go b/cmd/pro_lock.go new file mode 100644 index 000000000..b3eddc5a4 --- /dev/null +++ b/cmd/pro_lock.go @@ -0,0 +1,33 @@ +package cmd + +import ( + e "github.com/cloudposse/atmos/internal/exec" + u "github.com/cloudposse/atmos/pkg/utils" + "github.com/spf13/cobra" +) + +// proLockCmd executes 'pro lock' CLI command +var proLockCmd = &cobra.Command{ + Use: "lock", + Short: "Lock a stack", + Long: `This command calls the atmos pro API and locks a stack`, + FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false}, + Run: func(cmd *cobra.Command, args []string) { + // Check Atmos configuration + checkAtmosConfig() + + err := e.ExecuteProLockCommand(cmd, args) + if err != nil { + u.LogErrorAndExit(err) + } + }, +} + +func init() { + proLockCmd.PersistentFlags().StringP("component", "c", "", "Specify the Atmos component to lock") + proLockCmd.PersistentFlags().StringP("stack", "s", "", "Specify the Atmos stack to lock") + proLockCmd.PersistentFlags().StringP("message", "m", "", "The lock message to display if someone else tries to lock the stack. Defaults to 'Locked by Atmos'") + proLockCmd.PersistentFlags().Int32P("ttl", "t", 0, "The amount of time in seconds to lock the stack for. Defaults to 30") + + proCmd.AddCommand(proLockCmd) +} diff --git a/cmd/pro_unlock.go b/cmd/pro_unlock.go new file mode 100644 index 000000000..827c4808c --- /dev/null +++ b/cmd/pro_unlock.go @@ -0,0 +1,31 @@ +package cmd + +import ( + e "github.com/cloudposse/atmos/internal/exec" + u "github.com/cloudposse/atmos/pkg/utils" + "github.com/spf13/cobra" +) + +// proUnlockCmd executes 'pro unlock' CLI command +var proUnlockCmd = &cobra.Command{ + Use: "unlock", + Short: "Unlock a stack", + Long: `This command calls the atmos pro API and unlocks a stack`, + FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false}, + Run: func(cmd *cobra.Command, args []string) { + // Check Atmos configuration + checkAtmosConfig() + + err := e.ExecuteProUnlockCommand(cmd, args) + if err != nil { + u.LogErrorAndExit(err) + } + }, +} + +func init() { + proUnlockCmd.PersistentFlags().StringP("component", "c", "", "Specify the Atmos component to lock") + proUnlockCmd.PersistentFlags().StringP("stack", "s", "", "Specify the Atmos stack to lock") + + proCmd.AddCommand(proUnlockCmd) +} diff --git a/demo/screengrabs/demo-stacks.txt b/demo/screengrabs/demo-stacks.txt index 17396d7bd..c97165b45 100644 --- a/demo/screengrabs/demo-stacks.txt +++ b/demo/screengrabs/demo-stacks.txt @@ -24,6 +24,8 @@ atmos describe dependents --help atmos describe stacks --help atmos describe workflows --help atmos docs --help +atmos pro lock --help +atmos pro unlock --help atmos terraform --help atmos terraform clean --help atmos terraform deploy --help diff --git a/go.mod b/go.mod index ad2d695f1..1a8bbfa45 100644 --- a/go.mod +++ b/go.mod @@ -206,6 +206,7 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect github.com/tchap/go-patricia/v2 v2.3.1 // indirect github.com/ugorji/go/codec v1.2.7 // indirect diff --git a/internal/exec/describe_affected.go b/internal/exec/describe_affected.go index 817af4eb7..4e3f1a6bc 100644 --- a/internal/exec/describe_affected.go +++ b/internal/exec/describe_affected.go @@ -1,38 +1,56 @@ package exec import ( - "bytes" "errors" "fmt" - "io" - "net/http" - "os" - "time" "github.com/go-git/go-git/v5/plumbing" giturl "github.com/kubescape/go-git-url" "github.com/spf13/cobra" cfg "github.com/cloudposse/atmos/pkg/config" + l "github.com/cloudposse/atmos/pkg/logger" + "github.com/cloudposse/atmos/pkg/pro" "github.com/cloudposse/atmos/pkg/schema" u "github.com/cloudposse/atmos/pkg/utils" ) -// ExecuteDescribeAffectedCmd executes `describe affected` command -func ExecuteDescribeAffectedCmd(cmd *cobra.Command, args []string) error { +type DescribeAffectedCmdArgs struct { + CLIConfig schema.CliConfiguration + CloneTargetRef bool + Format string + IncludeDependents bool + IncludeSettings bool + IncludeSpaceliftAdminStacks bool + Logger *l.Logger + OutputFile string + Ref string + RepoPath string + SHA string + SSHKeyPath string + SSHKeyPassword string + Verbose bool + Upload bool +} + +func parseDescribeAffectedCliArgs(cmd *cobra.Command, args []string) (DescribeAffectedCmdArgs, error) { info, err := processCommandLineArgs("", cmd, args, nil) if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } cliConfig, err := cfg.InitCliConfig(info, true) if err != nil { - return err + return DescribeAffectedCmdArgs{}, err + } + logger, err := l.NewLoggerFromCliConfig(cliConfig) + if err != nil { + return DescribeAffectedCmdArgs{}, err } err = ValidateStacks(cliConfig) if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } // Process flags @@ -40,26 +58,26 @@ func ExecuteDescribeAffectedCmd(cmd *cobra.Command, args []string) error { ref, err := flags.GetString("ref") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } sha, err := flags.GetString("sha") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } repoPath, err := flags.GetString("repo-path") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } format, err := flags.GetString("format") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } if format != "" && format != "yaml" && format != "json" { - return fmt.Errorf("invalid '--format' flag '%s'. Valid values are 'json' (default) and 'yaml'", format) + return DescribeAffectedCmdArgs{}, fmt.Errorf("invalid '--format' flag '%s'. Valid values are 'json' (default) and 'yaml'", format) } if format == "" { @@ -68,51 +86,51 @@ func ExecuteDescribeAffectedCmd(cmd *cobra.Command, args []string) error { file, err := flags.GetString("file") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } verbose, err := flags.GetBool("verbose") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } sshKeyPath, err := flags.GetString("ssh-key") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } sshKeyPassword, err := flags.GetString("ssh-key-password") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } includeSpaceliftAdminStacks, err := flags.GetBool("include-spacelift-admin-stacks") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } includeDependents, err := flags.GetBool("include-dependents") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } includeSettings, err := flags.GetBool("include-settings") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } upload, err := flags.GetBool("upload") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } cloneTargetRef, err := flags.GetBool("clone-target-ref") if err != nil { - return err + return DescribeAffectedCmdArgs{}, err } if repoPath != "" && (ref != "" || sha != "" || sshKeyPath != "" || sshKeyPassword != "") { - return errors.New("if the '--repo-path' flag is specified, the '--ref', '--sha', '--ssh-key' and '--ssh-key-password' flags can't be used") + return DescribeAffectedCmdArgs{}, errors.New("if the '--repo-path' flag is specified, the '--ref', '--sha', '--ssh-key' and '--ssh-key-password' flags can't be used") } // When uploading, always include dependents and settings for all affected components @@ -123,18 +141,47 @@ func ExecuteDescribeAffectedCmd(cmd *cobra.Command, args []string) error { if verbose { cliConfig.Logs.Level = u.LogLevelTrace + logger.SetLogLevel(l.LogLevelTrace) + } + + result := DescribeAffectedCmdArgs{ + CLIConfig: cliConfig, + CloneTargetRef: cloneTargetRef, + Format: format, + IncludeDependents: includeDependents, + IncludeSettings: includeSettings, + IncludeSpaceliftAdminStacks: includeSpaceliftAdminStacks, + Logger: logger, + OutputFile: file, + Ref: ref, + RepoPath: repoPath, + SHA: sha, + SSHKeyPath: sshKeyPath, + SSHKeyPassword: sshKeyPassword, + Verbose: verbose, + Upload: upload, + } + + return result, nil +} + +// ExecuteDescribeAffectedCmd executes `describe affected` command +func ExecuteDescribeAffectedCmd(cmd *cobra.Command, args []string) error { + a, err := parseDescribeAffectedCliArgs(cmd, args) + if err != nil { + return err } var affected []schema.Affected var headHead, baseHead *plumbing.Reference var repoUrl string - if repoPath != "" { - affected, headHead, baseHead, repoUrl, err = ExecuteDescribeAffectedWithTargetRepoPath(cliConfig, repoPath, verbose, includeSpaceliftAdminStacks, includeSettings) - } else if cloneTargetRef { - affected, headHead, baseHead, repoUrl, err = ExecuteDescribeAffectedWithTargetRefClone(cliConfig, ref, sha, sshKeyPath, sshKeyPassword, verbose, includeSpaceliftAdminStacks, includeSettings) + if a.RepoPath != "" { + affected, headHead, baseHead, repoUrl, err = ExecuteDescribeAffectedWithTargetRepoPath(a.CLIConfig, a.RepoPath, a.Verbose, a.IncludeSpaceliftAdminStacks, a.IncludeSettings) + } else if a.CloneTargetRef { + affected, headHead, baseHead, repoUrl, err = ExecuteDescribeAffectedWithTargetRefClone(a.CLIConfig, a.Ref, a.SHA, a.SSHKeyPath, a.SSHKeyPassword, a.Verbose, a.IncludeSpaceliftAdminStacks, a.IncludeSettings) } else { - affected, headHead, baseHead, repoUrl, err = ExecuteDescribeAffectedWithTargetRefCheckout(cliConfig, ref, sha, verbose, includeSpaceliftAdminStacks, includeSettings) + affected, headHead, baseHead, repoUrl, err = ExecuteDescribeAffectedWithTargetRefCheckout(a.CLIConfig, a.Ref, a.SHA, a.Verbose, a.IncludeSpaceliftAdminStacks, a.IncludeSettings) } if err != nil { @@ -142,95 +189,46 @@ func ExecuteDescribeAffectedCmd(cmd *cobra.Command, args []string) error { } // Add dependent components and stacks for each affected component - if len(affected) > 0 && includeDependents { - err = addDependentsToAffected(cliConfig, &affected, includeSettings) + if len(affected) > 0 && a.IncludeDependents { + err = addDependentsToAffected(a.CLIConfig, &affected, a.IncludeSettings) if err != nil { return err } } - u.LogTrace(cliConfig, fmt.Sprintf("\nAffected components and stacks: \n")) + a.Logger.Trace(fmt.Sprintf("\nAffected components and stacks: \n")) - err = printOrWriteToFile(format, file, affected) + err = printOrWriteToFile(a.Format, a.OutputFile, affected) if err != nil { return err } - // Upload the affected components and stacks to a specified endpoint - // https://www.digitalocean.com/community/tutorials/how-to-make-http-requests-in-go - if upload { - baseUrl := os.Getenv(cfg.AtmosProBaseUrlEnvVarName) - if baseUrl == "" { - baseUrl = cfg.AtmosProDefaultBaseUrl - } - endpoint := os.Getenv(cfg.AtmosProEndpointEnvVarName) - if endpoint == "" { - endpoint = cfg.AtmosProDefaultEndpoint - } - url := fmt.Sprintf("%s/%s", baseUrl, endpoint) - + if a.Upload { // Parse the repo URL gitURL, err := giturl.NewGitURL(repoUrl) if err != nil { return err } - body := map[string]any{ - "head_sha": headHead.Hash().String(), - "base_sha": baseHead.Hash().String(), - "repo_url": repoUrl, - "repo_name": gitURL.GetRepoName(), - "repo_owner": gitURL.GetOwnerName(), - "repo_host": gitURL.GetHostName(), - "stacks": affected, - "config": cliConfig.Integrations.Pro, - } - - bodyJson, err := u.ConvertToJSON(body) - if err != nil { - return err - } - - u.LogTrace(cliConfig, fmt.Sprintf("\nUploading the affected components and stacks to %s", url)) - - bodyReader := bytes.NewReader([]byte(bodyJson)) - req, err := http.NewRequest(http.MethodPost, url, bodyReader) + apiClient, err := pro.NewAtmosProAPIClientFromEnv(a.Logger) if err != nil { return err } - req.Header.Set("Content-Type", "application/json") - - // Authorization header - // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Authorization - token := os.Getenv(cfg.AtmosProTokenEnvVarName) - if token != "" { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) - } - - client := http.Client{ - Timeout: 30 * time.Second, + req := pro.AffectedStacksUploadRequest{ + HeadSHA: headHead.Hash().String(), + BaseSHA: baseHead.Hash().String(), + RepoURL: repoUrl, + RepoName: gitURL.GetRepoName(), + RepoOwner: gitURL.GetOwnerName(), + RepoHost: gitURL.GetHostName(), + Stacks: affected, } - resp, err := client.Do(req) + err = apiClient.UploadAffectedStacks(req) if err != nil { return err } - - defer func(Body io.ReadCloser) { - err := Body.Close() - if err != nil { - u.LogError(err) - } - }(resp.Body) - - if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { - err = fmt.Errorf("\nError uploading the affected components and stacks to %s\nStatus: %s\n", url, resp.Status) - return err - } - - u.LogTrace(cliConfig, fmt.Sprintf("\nUploaded the affected components and stacks to %s\n", url)) } - return nil } diff --git a/internal/exec/describe_affected_utils.go b/internal/exec/describe_affected_utils.go index 3339f68e2..a2d0f2cf1 100644 --- a/internal/exec/describe_affected_utils.go +++ b/internal/exec/describe_affected_utils.go @@ -12,7 +12,6 @@ import ( "time" "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/transport/ssh" "github.com/hashicorp/terraform-config-inspect/tfconfig" @@ -20,6 +19,7 @@ import ( cp "github.com/otiai10/copy" cfg "github.com/cloudposse/atmos/pkg/config" + g "github.com/cloudposse/atmos/pkg/git" "github.com/cloudposse/atmos/pkg/schema" u "github.com/cloudposse/atmos/pkg/utils" ) @@ -46,48 +46,14 @@ func ExecuteDescribeAffectedWithTargetRefClone( cliConfig.Logs.Level = u.LogLevelTrace } - localPath := "." - - localRepo, err := git.PlainOpenWithOptions(localPath, &git.PlainOpenOptions{ - DetectDotGit: true, - EnableDotGitCommonDir: false, - }) - if err != nil { - return nil, nil, nil, "", errors.Join(err, localRepoIsNotGitRepoError) - } - - // Get the Git config of the local repo - localRepoConfig, err := getRepoConfig(localRepo) + localRepo, err := g.GetLocalRepo() if err != nil { - return nil, nil, nil, "", errors.Join(err, localRepoIsNotGitRepoError) + return nil, nil, nil, "", err } - localRepoWorktree, err := localRepo.Worktree() + localRepoInfo, err := g.GetRepoInfo(localRepo) if err != nil { - return nil, nil, nil, "", errors.Join(err, localRepoIsNotGitRepoError) - } - - localRepoPath := localRepoWorktree.Filesystem.Root() - - // Get the remotes of the local repo - keys := []string{} - for k := range localRepoConfig.Remotes { - keys = append(keys, k) - } - - if len(keys) == 0 { - return nil, nil, nil, "", localRepoIsNotGitRepoError - } - - // Get the URL of the repo - remoteUrls := localRepoConfig.Remotes[keys[0]].URLs - if len(remoteUrls) == 0 { - return nil, nil, nil, "", localRepoIsNotGitRepoError - } - - repoUrl := remoteUrls[0] - if repoUrl == "" { - return nil, nil, nil, "", localRepoIsNotGitRepoError + return nil, nil, nil, "", err } // Clone the remote repo @@ -107,10 +73,10 @@ func ExecuteDescribeAffectedWithTargetRefClone( defer removeTempDir(cliConfig, tempDir) - u.LogTrace(cliConfig, fmt.Sprintf("\nCloning repo '%s' into the temp dir '%s'", repoUrl, tempDir)) + u.LogTrace(cliConfig, fmt.Sprintf("\nCloning repo '%s' into the temp dir '%s'", localRepoInfo.RepoUrl, tempDir)) cloneOptions := git.CloneOptions{ - URL: repoUrl, + URL: localRepoInfo.RepoUrl, NoCheckout: false, SingleBranch: false, } @@ -146,7 +112,7 @@ func ExecuteDescribeAffectedWithTargetRefClone( // Update the repo URL to SSH format // https://mirrors.edge.kernel.org/pub/software/scm/git/docs/git-clone.html - cloneOptions.URL = strings.Replace(repoUrl, "https://", "ssh://", 1) + cloneOptions.URL = strings.Replace(localRepoInfo.RepoUrl, "https://", "ssh://", 1) } remoteRepo, err := git.PlainClone(tempDir, false, &cloneOptions) @@ -191,7 +157,7 @@ func ExecuteDescribeAffectedWithTargetRefClone( affected, localRepoHead, remoteRepoHead, err := executeDescribeAffected( cliConfig, - localRepoPath, + localRepoInfo.LocalWorktreePath, tempDir, localRepo, remoteRepo, @@ -203,7 +169,7 @@ func ExecuteDescribeAffectedWithTargetRefClone( return nil, nil, nil, "", err } - return affected, localRepoHead, remoteRepoHead, repoUrl, nil + return affected, localRepoHead, remoteRepoHead, localRepoInfo.RepoUrl, nil } // ExecuteDescribeAffectedWithTargetRefCheckout checks out the target reference, @@ -221,48 +187,14 @@ func ExecuteDescribeAffectedWithTargetRefCheckout( cliConfig.Logs.Level = u.LogLevelTrace } - localPath := "." - - localRepo, err := git.PlainOpenWithOptions(localPath, &git.PlainOpenOptions{ - DetectDotGit: true, - EnableDotGitCommonDir: false, - }) - if err != nil { - return nil, nil, nil, "", errors.Join(err, localRepoIsNotGitRepoError) - } - - // Check the Git config of the local repo - localRepoConfig, err := getRepoConfig(localRepo) + localRepo, err := g.GetLocalRepo() if err != nil { - return nil, nil, nil, "", errors.Join(err, localRepoIsNotGitRepoError) + return nil, nil, nil, "", err } - localRepoWorktree, err := localRepo.Worktree() + localRepoInfo, err := g.GetRepoInfo(localRepo) if err != nil { - return nil, nil, nil, "", errors.Join(err, localRepoIsNotGitRepoError) - } - - localRepoPath := localRepoWorktree.Filesystem.Root() - - // Get the remotes of the local repo - keys := []string{} - for k := range localRepoConfig.Remotes { - keys = append(keys, k) - } - - if len(keys) == 0 { - return nil, nil, nil, "", localRepoIsNotGitRepoError - } - - // Get the URL of the repo - remoteUrls := localRepoConfig.Remotes[keys[0]].URLs - if len(remoteUrls) == 0 { - return nil, nil, nil, "", localRepoIsNotGitRepoError - } - - repoUrl := remoteUrls[0] - if repoUrl == "" { - return nil, nil, nil, "", localRepoIsNotGitRepoError + return nil, nil, nil, "", err } // Create a temp dir for the target ref @@ -298,7 +230,7 @@ func ExecuteDescribeAffectedWithTargetRefCheckout( }, } - if err = cp.Copy(localRepoPath, tempDir, copyOptions); err != nil { + if err = cp.Copy(localRepoInfo.LocalWorktreePath, tempDir, copyOptions); err != nil { return nil, nil, nil, "", err } @@ -313,7 +245,7 @@ func ExecuteDescribeAffectedWithTargetRefCheckout( } // Check the Git config of the target ref - _, err = getRepoConfig(remoteRepo) + _, err = g.GetRepoConfig(remoteRepo) if err != nil { return nil, nil, nil, "", errors.Join(err, remoteRepoIsNotGitRepoError) } @@ -375,7 +307,7 @@ func ExecuteDescribeAffectedWithTargetRefCheckout( affected, localRepoHead, remoteRepoHead, err := executeDescribeAffected( cliConfig, - localRepoPath, + localRepoInfo.LocalWorktreePath, tempDir, localRepo, remoteRepo, @@ -387,7 +319,7 @@ func ExecuteDescribeAffectedWithTargetRefCheckout( return nil, nil, nil, "", err } - return affected, localRepoHead, remoteRepoHead, repoUrl, nil + return affected, localRepoHead, remoteRepoHead, localRepoInfo.LocalRepoPath, nil } // ExecuteDescribeAffectedWithTargetRepoPath uses `repo-path` to access the target repo, and processes stack configs @@ -400,48 +332,14 @@ func ExecuteDescribeAffectedWithTargetRepoPath( includeSettings bool, ) ([]schema.Affected, *plumbing.Reference, *plumbing.Reference, string, error) { - localPath := "." - - localRepo, err := git.PlainOpenWithOptions(localPath, &git.PlainOpenOptions{ - DetectDotGit: true, - EnableDotGitCommonDir: false, - }) - if err != nil { - return nil, nil, nil, "", errors.Join(err, localRepoIsNotGitRepoError) - } - - // Check the Git config of the local repo - localRepoConfig, err := getRepoConfig(localRepo) + localRepo, err := g.GetLocalRepo() if err != nil { - return nil, nil, nil, "", errors.Join(err, localRepoIsNotGitRepoError) + return nil, nil, nil, "", err } - localRepoWorktree, err := localRepo.Worktree() + localRepoInfo, err := g.GetRepoInfo(localRepo) if err != nil { - return nil, nil, nil, "", errors.Join(err, localRepoIsNotGitRepoError) - } - - localRepoPath := localRepoWorktree.Filesystem.Root() - - // Get the remotes of the local repo - keys := []string{} - for k := range localRepoConfig.Remotes { - keys = append(keys, k) - } - - if len(keys) == 0 { - return nil, nil, nil, "", localRepoIsNotGitRepoError - } - - // Get the URL of the repo - remoteUrls := localRepoConfig.Remotes[keys[0]].URLs - if len(remoteUrls) == 0 { - return nil, nil, nil, "", localRepoIsNotGitRepoError - } - - repoUrl := remoteUrls[0] - if repoUrl == "" { - return nil, nil, nil, "", localRepoIsNotGitRepoError + return nil, nil, nil, "", err } remoteRepo, err := git.PlainOpenWithOptions(targetRefPath, &git.PlainOpenOptions{ @@ -453,14 +351,14 @@ func ExecuteDescribeAffectedWithTargetRepoPath( } // Check the Git config of the remote target repo - _, err = getRepoConfig(remoteRepo) + _, err = g.GetRepoConfig(remoteRepo) if err != nil { return nil, nil, nil, "", errors.Join(err, remoteRepoIsNotGitRepoError) } affected, localRepoHead, remoteRepoHead, err := executeDescribeAffected( cliConfig, - localRepoPath, + localRepoInfo.LocalWorktreePath, targetRefPath, localRepo, remoteRepo, @@ -472,7 +370,7 @@ func ExecuteDescribeAffectedWithTargetRepoPath( return nil, nil, nil, "", err } - return affected, localRepoHead, remoteRepoHead, repoUrl, nil + return affected, localRepoHead, remoteRepoHead, localRepoInfo.RepoUrl, nil } func executeDescribeAffected( @@ -1601,25 +1499,3 @@ func processIncludedInDependenciesForDependents(dependents *[]schema.Dependent, } return false } - -func getRepoConfig(repo *git.Repository) (*config.Config, error) { - repoConfig, err := repo.Config() - if err != nil { - return nil, err - } - - core := repoConfig.Raw.Section("core") - - // Remove the untrackedCache option if it exists - if coreOption := core.Option("untrackedCache"); coreOption != "" { - core.RemoveOption("untrackedCache") - - // Save the updated configuration - err = repo.Storer.SetConfig(repoConfig) - if err != nil { - return nil, err - } - } - - return repoConfig, nil -} diff --git a/internal/exec/pro.go b/internal/exec/pro.go new file mode 100644 index 000000000..dd1ad9744 --- /dev/null +++ b/internal/exec/pro.go @@ -0,0 +1,203 @@ +package exec + +import ( + "fmt" + + cfg "github.com/cloudposse/atmos/pkg/config" + "github.com/cloudposse/atmos/pkg/git" + l "github.com/cloudposse/atmos/pkg/logger" + "github.com/cloudposse/atmos/pkg/pro" + "github.com/spf13/cobra" +) + +type ProLockUnlockCmdArgs struct { + Component string + Logger *l.Logger + Stack string +} + +type ProLockCmdArgs struct { + ProLockUnlockCmdArgs + LockMessage string + LockTTL int32 +} + +type ProUnlockCmdArgs struct { + ProLockUnlockCmdArgs +} + +func parseLockUnlockCliArgs(cmd *cobra.Command, args []string) (ProLockUnlockCmdArgs, error) { + info, err := processCommandLineArgs("terraform", cmd, args, nil) + if err != nil { + return ProLockUnlockCmdArgs{}, err + } + + // InitCliConfig finds and merges CLI configurations in the following order: + // system dir, home dir, current dir, ENV vars, command-line arguments + cliConfig, err := cfg.InitCliConfig(info, true) + if err != nil { + return ProLockUnlockCmdArgs{}, err + } + + logger, err := l.NewLoggerFromCliConfig(cliConfig) + if err != nil { + return ProLockUnlockCmdArgs{}, err + } + + flags := cmd.Flags() + + component, err := flags.GetString("component") + if err != nil { + return ProLockUnlockCmdArgs{}, err + } + + stack, err := flags.GetString("stack") + if err != nil { + return ProLockUnlockCmdArgs{}, err + } + + if component == "" || stack == "" { + return ProLockUnlockCmdArgs{}, fmt.Errorf("both '--component' and '--stack' flag must be provided") + } + + result := ProLockUnlockCmdArgs{ + Component: component, + Logger: logger, + Stack: stack, + } + + return result, nil +} + +func parseLockCliArgs(cmd *cobra.Command, args []string) (ProLockCmdArgs, error) { + commonArgs, err := parseLockUnlockCliArgs(cmd, args) + if err != nil { + return ProLockCmdArgs{}, err + } + + flags := cmd.Flags() + + ttl, err := flags.GetInt32("ttl") + if err != nil { + return ProLockCmdArgs{}, err + } + + if ttl == 0 { + ttl = 30 + } + + message, err := flags.GetString("message") + if err != nil { + return ProLockCmdArgs{}, err + } + + if message == "" { + message = "Locked by Atmos" + } + + result := ProLockCmdArgs{ + ProLockUnlockCmdArgs: commonArgs, + LockMessage: message, + LockTTL: ttl, + } + + return result, nil +} + +func parseUnlockCliArgs(cmd *cobra.Command, args []string) (ProUnlockCmdArgs, error) { + commonArgs, err := parseLockUnlockCliArgs(cmd, args) + if err != nil { + return ProUnlockCmdArgs{}, err + } + + result := ProUnlockCmdArgs{ + ProLockUnlockCmdArgs: commonArgs, + } + + return result, nil + +} + +// ExecuteProLockCommand executes `atmos pro lock` command +func ExecuteProLockCommand(cmd *cobra.Command, args []string) error { + a, err := parseLockCliArgs(cmd, args) + if err != nil { + return err + } + + repo, err := git.GetLocalRepo() + if err != nil { + return err + } + + repoInfo, err := git.GetRepoInfo(repo) + if err != nil { + return err + } + + owner := repoInfo.RepoOwner + repoName := repoInfo.RepoName + + dto := pro.LockStackRequest{ + Key: fmt.Sprintf("%s/%s/%s/%s", owner, repoName, a.Stack, a.Component), + TTL: a.LockTTL, + LockMessage: a.LockMessage, + Properties: nil, + } + + apiClient, err := pro.NewAtmosProAPIClientFromEnv(a.Logger) + if err != nil { + return err + } + + lock, err := apiClient.LockStack(dto) + if err != nil { + return err + } + + a.Logger.Info("Stack successfully locked.\n") + a.Logger.Info(fmt.Sprintf("Key: %s", lock.Data.Key)) + a.Logger.Info(fmt.Sprintf("LockID: %s", lock.Data.ID)) + a.Logger.Info(fmt.Sprintf("Expires %s", lock.Data.ExpiresAt)) + + return nil +} + +// ExecuteProUnlockCommand executes `atmos pro unlock` command +func ExecuteProUnlockCommand(cmd *cobra.Command, args []string) error { + a, err := parseUnlockCliArgs(cmd, args) + if err != nil { + return err + } + + repo, err := git.GetLocalRepo() + if err != nil { + return err + } + + repoInfo, err := git.GetRepoInfo(repo) + if err != nil { + return err + } + + owner := repoInfo.RepoOwner + repoName := repoInfo.RepoName + + dto := pro.UnlockStackRequest{ + Key: fmt.Sprintf("%s/%s/%s/%s", owner, repoName, a.Stack, a.Component), + } + + apiClient, err := pro.NewAtmosProAPIClientFromEnv(a.Logger) + if err != nil { + return err + } + + _, err = apiClient.UnlockStack(dto) + if err != nil { + return err + } + + a.Logger.Info(fmt.Sprintf("Key '%s' successfully unlocked.\n", dto.Key)) + + return nil +} diff --git a/pkg/config/const.go b/pkg/config/const.go index 98765bed2..bebfb1e32 100644 --- a/pkg/config/const.go +++ b/pkg/config/const.go @@ -66,5 +66,5 @@ const ( AtmosProEndpointEnvVarName = "ATMOS_PRO_ENDPOINT" AtmosProTokenEnvVarName = "ATMOS_PRO_TOKEN" AtmosProDefaultBaseUrl = "https://app.cloudposse.com" - AtmosProDefaultEndpoint = "api/affected-stacks" + AtmosProDefaultEndpoint = "api" ) diff --git a/pkg/git/git.go b/pkg/git/git.go new file mode 100644 index 000000000..85670cf26 --- /dev/null +++ b/pkg/git/git.go @@ -0,0 +1,105 @@ +package git + +import ( + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + giturl "github.com/kubescape/go-git-url" +) + +func GetLocalRepo() (*git.Repository, error) { + localPath := "." + + localRepo, err := git.PlainOpenWithOptions(localPath, &git.PlainOpenOptions{ + DetectDotGit: true, + EnableDotGitCommonDir: false, + }) + if err != nil { + return nil, err + } + + return localRepo, nil +} + +func GetRepoConfig(repo *git.Repository) (*config.Config, error) { + repoConfig, err := repo.Config() + if err != nil { + return nil, err + } + + core := repoConfig.Raw.Section("core") + + // Remove the untrackedCache option if it exists + if coreOption := core.Option("untrackedCache"); coreOption != "" { + core.RemoveOption("untrackedCache") + + // Save the updated configuration + err = repo.Storer.SetConfig(repoConfig) + if err != nil { + return nil, err + } + } + + return repoConfig, nil +} + +type RepoInfo struct { + LocalRepoPath string + LocalWorktree *git.Worktree + LocalWorktreePath string + RepoUrl string + RepoOwner string + RepoName string + RepoHost string +} + +func GetRepoInfo(localRepo *git.Repository) (RepoInfo, error) { + localRepoConfig, err := GetRepoConfig(localRepo) + if err != nil { + return RepoInfo{}, err + } + + localRepoWorktree, err := localRepo.Worktree() + if err != nil { + return RepoInfo{}, err + } + + localRepoPath := localRepoWorktree.Filesystem.Root() + + // Get the remotes of the local repo + keys := []string{} + for k := range localRepoConfig.Remotes { + keys = append(keys, k) + } + + if len(keys) == 0 { + return RepoInfo{}, nil + } + + // Get the URL of the repo + remoteUrls := localRepoConfig.Remotes[keys[0]].URLs + if len(remoteUrls) == 0 { + return RepoInfo{}, nil + } + + repoUrl := remoteUrls[0] + if repoUrl == "" { + return RepoInfo{}, nil + } + + gitURL, err := giturl.NewGitURL(repoUrl) + if err != nil { + return RepoInfo{}, err + } + + response := RepoInfo{ + LocalRepoPath: localRepoPath, + LocalWorktree: localRepoWorktree, + LocalWorktreePath: localRepoWorktree.Filesystem.Root(), + RepoUrl: repoUrl, + RepoOwner: gitURL.GetOwnerName(), + RepoName: gitURL.GetRepoName(), + RepoHost: gitURL.GetHostName(), + } + + return response, nil +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 000000000..4b539aa46 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,149 @@ +package logger + +import ( + "fmt" + "os" + + "github.com/cloudposse/atmos/pkg/schema" + "github.com/fatih/color" +) + +type LogLevel string + +const ( + LogLevelOff LogLevel = "Off" + LogLevelTrace LogLevel = "Trace" + LogLevelDebug LogLevel = "Debug" + LogLevelInfo LogLevel = "Info" + LogLevelWarning LogLevel = "Warning" +) + +type Logger struct { + LogLevel LogLevel + File string +} + +func NewLogger(logLevel LogLevel, file string) (*Logger, error) { + return &Logger{ + LogLevel: logLevel, + File: file, + }, nil +} + +func NewLoggerFromCliConfig(cfg schema.CliConfiguration) (*Logger, error) { + logLevel, err := ParseLogLevel(cfg.Logs.Level) + if err != nil { + return nil, err + } + return NewLogger(logLevel, cfg.Logs.File) +} + +func ParseLogLevel(logLevel string) (LogLevel, error) { + if logLevel == "" { + return LogLevelInfo, nil + } + + switch LogLevel(logLevel) { // Convert logLevel to type LogLevel + case LogLevelTrace: + return LogLevelTrace, nil + case LogLevelDebug: + return LogLevelDebug, nil + case LogLevelInfo: + return LogLevelInfo, nil + case LogLevelWarning: + return LogLevelWarning, nil + default: + return LogLevelInfo, fmt.Errorf("invalid log level '%s'. Supported log levels are Trace, Debug, Info, Warning, Off", logLevel) + } +} + +func (l *Logger) log(logColor *color.Color, message string) { + if l.File != "" { + if l.File == "/dev/stdout" { + _, err := logColor.Fprintln(os.Stdout, message) + if err != nil { + color.Red("%s\n", err) + } + } else if l.File == "/dev/stderr" { + _, err := logColor.Fprintln(os.Stderr, message) + if err != nil { + color.Red("%s\n", err) + } + } else { + f, err := os.OpenFile(l.File, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) + if err != nil { + color.Red("%s\n", err) + return + } + + defer func(f *os.File) { + err = f.Close() + if err != nil { + color.Red("%s\n", err) + } + }(f) + + _, err = f.Write([]byte(fmt.Sprintf("%s\n", message))) + if err != nil { + color.Red("%s\n", err) + } + } + } else { + _, err := logColor.Fprintln(os.Stdout, message) + if err != nil { + color.Red("%s\n", err) + } + } +} + +func (l *Logger) SetLogLevel(logLevel LogLevel) error { + l.LogLevel = logLevel + return nil +} + +func (l *Logger) Error(err error) { + if err != nil { + c := color.New(color.FgRed) + _, err2 := c.Fprintln(color.Error, err.Error()+"\n") + if err2 != nil { + color.Red("Error logging the error:") + color.Red("%s\n", err2) + color.Red("Original error:") + color.Red("%s\n", err) + } + } +} + +func (l *Logger) Trace(message string) { + if l.LogLevel == LogLevelTrace { + l.log(color.New(color.FgCyan), message) + } +} + +func (l *Logger) Debug(message string) { + if l.LogLevel == LogLevelTrace || + l.LogLevel == LogLevelDebug { + + l.log(color.New(color.FgCyan), message) + } +} + +func (l *Logger) Info(message string) { + + if l.LogLevel == LogLevelTrace || + l.LogLevel == LogLevelDebug || + l.LogLevel == LogLevelInfo { + + l.log(color.New(color.Reset), message) + } +} + +func (l *Logger) Warning(cliConfig schema.CliConfiguration, message string) { + if l.LogLevel == LogLevelTrace || + l.LogLevel == LogLevelDebug || + l.LogLevel == LogLevelInfo || + l.LogLevel == LogLevelWarning { + + l.log(color.New(color.FgYellow), message) + } +} diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go new file mode 100644 index 000000000..a5b84927e --- /dev/null +++ b/pkg/logger/logger_test.go @@ -0,0 +1,166 @@ +package logger + +import ( + "bytes" + "fmt" + "io" + "os" + "testing" + + "github.com/cloudposse/atmos/pkg/schema" + "github.com/fatih/color" + "github.com/stretchr/testify/assert" +) + +func captureOutput(f func()) string { + r, w, _ := os.Pipe() + stdout := os.Stdout + os.Stdout = w + + outC := make(chan string) + // Copy the output in a separate goroutine so printing can't block indefinitely + go func() { + var buf bytes.Buffer + io.Copy(&buf, r) + outC <- buf.String() + }() + + // Call the function which will use stdout + f() + + // Close the writer and restore the original stdout + w.Close() + os.Stdout = stdout + + // Read the output string + out := <-outC + + return out +} + +func TestNewLogger(t *testing.T) { + logger, err := NewLogger(LogLevelDebug, "/dev/stdout") + assert.NoError(t, err) + assert.NotNil(t, logger) + assert.Equal(t, LogLevelDebug, logger.LogLevel) + assert.Equal(t, "/dev/stdout", logger.File) +} + +func TestNewLoggerFromCliConfig(t *testing.T) { + cliConfig := schema.CliConfiguration{ + Logs: schema.Logs{ + Level: "Info", + File: "/dev/stdout", + }, + } + + logger, err := NewLoggerFromCliConfig(cliConfig) + assert.NoError(t, err) + assert.NotNil(t, logger) + assert.Equal(t, LogLevelInfo, logger.LogLevel) + assert.Equal(t, "/dev/stdout", logger.File) +} + +func TestParseLogLevel(t *testing.T) { + tests := []struct { + input string + expected LogLevel + err bool + }{ + {"Trace", LogLevelTrace, false}, + {"Debug", LogLevelDebug, false}, + {"Info", LogLevelInfo, false}, + {"Warning", LogLevelWarning, false}, + {"Invalid", LogLevelInfo, true}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("input=%s", test.input), func(t *testing.T) { + logLevel, err := ParseLogLevel(test.input) + if test.err { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, test.expected, logLevel) + } + }) + } +} + +func TestLogger_Trace(t *testing.T) { + logger, _ := NewLogger(LogLevelTrace, "/dev/stdout") + + output := captureOutput(func() { + logger.Trace("Trace message") + }) + + assert.Contains(t, output, "Trace message") +} + +func TestLogger_Debug(t *testing.T) { + logger, _ := NewLogger(LogLevelDebug, "/dev/stdout") + + output := captureOutput(func() { + logger.Debug("Debug message") + }) + + assert.Contains(t, output, "Debug message") + + logger, _ = NewLogger(LogLevelTrace, "/dev/stdout") + + output = captureOutput(func() { + logger.Debug("Trace message should appear") + }) + + assert.Contains(t, output, "Trace message should appear") +} + +func TestLogger_Info(t *testing.T) { + logger, _ := NewLogger(LogLevelInfo, "/dev/stdout") + + output := captureOutput(func() { + logger.Info("Info message") + }) + + assert.Contains(t, output, "Info message") +} + +func TestLogger_Warning(t *testing.T) { + logger, _ := NewLogger(LogLevelWarning, "/dev/stdout") + + output := captureOutput(func() { + logger.Warning(schema.CliConfiguration{}, "Warning message") + }) + + assert.Contains(t, output, "Warning message") +} + +func TestLogger_Error(t *testing.T) { + var buf bytes.Buffer + color.Error = &buf + logger, _ := NewLogger(LogLevelWarning, "/dev/stderr") + + err := fmt.Errorf("This is an error") + logger.Error(err) + assert.Contains(t, buf.String(), "This is an error") +} + +func TestLogger_FileLogging(t *testing.T) { + tempFile := "/tmp/test.log" + defer os.Remove(tempFile) + + logger, _ := NewLogger(LogLevelInfo, tempFile) + logger.Info("File logging test") + + data, err := os.ReadFile(tempFile) + assert.NoError(t, err) + assert.Contains(t, string(data), "File logging test") +} + +func TestLogger_SetLogLevel(t *testing.T) { + logger, _ := NewLogger(LogLevelInfo, "/dev/stdout") + + err := logger.SetLogLevel(LogLevelDebug) + assert.NoError(t, err) + assert.Equal(t, LogLevelDebug, logger.LogLevel) +} diff --git a/pkg/pro/api_client.go b/pkg/pro/api_client.go new file mode 100644 index 000000000..3ff4d12fe --- /dev/null +++ b/pkg/pro/api_client.go @@ -0,0 +1,191 @@ +package pro + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + cfg "github.com/cloudposse/atmos/pkg/config" + "github.com/cloudposse/atmos/pkg/logger" +) + +// AtmosProAPIClient represents the client to interact with the AtmosPro API +type AtmosProAPIClient struct { + APIToken string + BaseAPIEndpoint string + BaseURL string + HTTPClient *http.Client + Logger *logger.Logger +} + +// NewAtmosProAPIClient creates a new instance of AtmosProAPIClient +func NewAtmosProAPIClient(logger *logger.Logger, baseURL, baseAPIEndpoint, apiToken string) *AtmosProAPIClient { + return &AtmosProAPIClient{ + Logger: logger, + BaseURL: baseURL, + BaseAPIEndpoint: baseAPIEndpoint, + APIToken: apiToken, + HTTPClient: &http.Client{Timeout: 30 * time.Second}, + } +} + +// NewAtmosProAPIClientFromEnv creates a new AtmosProAPIClient from environment variables +func NewAtmosProAPIClientFromEnv(logger *logger.Logger) (*AtmosProAPIClient, error) { + baseURL := os.Getenv(cfg.AtmosProBaseUrlEnvVarName) + if baseURL == "" { + baseURL = cfg.AtmosProDefaultBaseUrl + } + + baseAPIEndpoint := os.Getenv(cfg.AtmosProEndpointEnvVarName) + if baseAPIEndpoint == "" { + baseAPIEndpoint = cfg.AtmosProDefaultEndpoint + } + + apiToken := os.Getenv(cfg.AtmosProTokenEnvVarName) + if apiToken == "" { + return nil, fmt.Errorf("%s is not set", cfg.AtmosProTokenEnvVarName) + } + + return NewAtmosProAPIClient(logger, baseURL, baseAPIEndpoint, apiToken), nil +} + +func getAuthenticatedRequest(c *AtmosProAPIClient, method, url string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", c.APIToken)) + req.Header.Set("Content-Type", "application/json") + + return req, nil +} + +// UploadAffectedStacks uploads information about affected stacks +func (c *AtmosProAPIClient) UploadAffectedStacks(dto AffectedStacksUploadRequest) error { + url := fmt.Sprintf("%s/%s/affected-stacks", c.BaseURL, c.BaseAPIEndpoint) + + data, err := json.Marshal(dto) + if err != nil { + return fmt.Errorf("failed to marshal payload: %w", err) + } + + req, err := getAuthenticatedRequest(c, "POST", url, bytes.NewBuffer(data)) + if err != nil { + return fmt.Errorf("failed to create authenticated request: %w", err) + } + + c.Logger.Trace(fmt.Sprintf("\nUploading the affected components and stacks to %s", url)) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf("failed to upload stacks, status: %s", resp.Status) + } + c.Logger.Trace(fmt.Sprintf("\nUploaded the affected components and stacks to %s", url)) + + return nil +} + +// LockStack locks a specific stack +func (c *AtmosProAPIClient) LockStack(dto LockStackRequest) (LockStackResponse, error) { + url := fmt.Sprintf("%s/%s/locks", c.BaseURL, c.BaseAPIEndpoint) + c.Logger.Trace(fmt.Sprintf("\nLocking stack at %s", url)) + + data, err := json.Marshal(dto) + if err != nil { + return LockStackResponse{}, fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := getAuthenticatedRequest(c, "POST", url, bytes.NewBuffer(data)) + if err != nil { + return LockStackResponse{}, fmt.Errorf("failed to create authenticated request: %w", err) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return LockStackResponse{}, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return LockStackResponse{}, fmt.Errorf("Error reading response body: %s", err) + } + + // Create an instance of the struct + var responseData LockStackResponse + + // Unmarshal the JSON response into the struct + err = json.Unmarshal(body, &responseData) + if err != nil { + return LockStackResponse{}, fmt.Errorf("Error unmarshaling JSON: %s", err) + } + + if !responseData.Success { + var context string + for key, value := range responseData.Context { + context += fmt.Sprintf(" %s: %v\n", key, value) + } + + return LockStackResponse{}, fmt.Errorf("An error occurred while attempting to lock stack.\n\nError: %s\nContext:\n%s", responseData.ErrorMessage, context) + } + + return responseData, nil +} + +// UnlockStack unlocks a specific stack +func (c *AtmosProAPIClient) UnlockStack(dto UnlockStackRequest) (UnlockStackResponse, error) { + url := fmt.Sprintf("%s/%s/locks", c.BaseURL, c.BaseAPIEndpoint) + c.Logger.Trace(fmt.Sprintf("\nLocking stack at %s", url)) + + data, err := json.Marshal(dto) + if err != nil { + return UnlockStackResponse{}, fmt.Errorf("failed to marshal request body: %w", err) + } + + req, err := getAuthenticatedRequest(c, "DELETE", url, bytes.NewBuffer(data)) + if err != nil { + return UnlockStackResponse{}, fmt.Errorf("failed to create authenticated request: %w", err) + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return UnlockStackResponse{}, fmt.Errorf("failed to make request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return UnlockStackResponse{}, fmt.Errorf("Error reading response body: %s", err) + } + + // Create an instance of the struct + var responseData UnlockStackResponse + + // Unmarshal the JSON response into the struct + err = json.Unmarshal(body, &responseData) + + if err != nil { + return UnlockStackResponse{}, fmt.Errorf("Error unmarshaling JSON: %s", err) + } + + if !responseData.Success { + var context string + for key, value := range responseData.Context { + context += fmt.Sprintf(" %s: %v\n", key, value) + } + + return UnlockStackResponse{}, fmt.Errorf("An error occurred while attempting to lock stack.\n\nError: %s\nContext:\n%s", responseData.ErrorMessage, context) + } + + return responseData, nil +} diff --git a/pkg/pro/api_client_test.go b/pkg/pro/api_client_test.go new file mode 100644 index 000000000..08126b2e8 --- /dev/null +++ b/pkg/pro/api_client_test.go @@ -0,0 +1,144 @@ +package pro + +import ( + "bytes" + "io" + "net/http" + "testing" + + "github.com/cloudposse/atmos/pkg/logger" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +// MockRoundTripper is an implementation of http.RoundTripper for testing purposes. +type MockRoundTripper struct { + mock.Mock +} + +func (m *MockRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + args := m.Called(req) + return args.Get(0).(*http.Response), args.Error(1) +} + +func TestLockStack(t *testing.T) { + mockLogger, err := logger.NewLogger("test", "/dev/stdout") + assert.Nil(t, err) + + mockRoundTripper := new(MockRoundTripper) + httpClient := &http.Client{Transport: mockRoundTripper} + apiClient := &AtmosProAPIClient{ + Logger: mockLogger, + BaseURL: "http://localhost", + BaseAPIEndpoint: "api", + APIToken: "test-token", + HTTPClient: httpClient, + } + + dto := LockStackRequest{ /* populate fields */ } + + mockResponse := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"success": true}`)), + } + + mockRoundTripper.On("RoundTrip", mock.Anything).Return(mockResponse, nil) + + response, err := apiClient.LockStack(dto) + assert.NoError(t, err) + assert.True(t, response.Success) + + mockRoundTripper.AssertExpectations(t) +} + +func TestLockStack_Error(t *testing.T) { + mockLogger, err := logger.NewLogger("test", "/dev/stdout") + assert.Nil(t, err) + + mockRoundTripper := new(MockRoundTripper) + httpClient := &http.Client{Transport: mockRoundTripper} + apiClient := &AtmosProAPIClient{ + Logger: mockLogger, + BaseURL: "http://localhost", + BaseAPIEndpoint: "api", + APIToken: "test-token", + HTTPClient: httpClient, + } + + dto := LockStackRequest{ /* populate fields */ } + + mockResponse := &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewBufferString(`{"success": false, "errorMessage": "Internal Server Error"}`)), + } + + mockRoundTripper.On("RoundTrip", mock.Anything).Return(mockResponse, nil) + + response, err := apiClient.LockStack(dto) + assert.Error(t, err) + assert.False(t, response.Success) + assert.Contains(t, err.Error(), "Internal Server Error") + + mockRoundTripper.AssertExpectations(t) +} + +func TestUnlockStack(t *testing.T) { + mockLogger, err := logger.NewLogger("test", "/dev/stdout") + assert.Nil(t, err) + + mockRoundTripper := new(MockRoundTripper) + httpClient := &http.Client{Transport: mockRoundTripper} + apiClient := &AtmosProAPIClient{ + Logger: mockLogger, + BaseURL: "http://localhost", + BaseAPIEndpoint: "api", + APIToken: "test-token", + HTTPClient: httpClient, + } + + dto := UnlockStackRequest{} + + mockResponse := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewBufferString(`{"success": true}`)), + } + + mockRoundTripper.On("RoundTrip", mock.Anything).Return(mockResponse, nil) + + response, err := apiClient.UnlockStack(dto) + assert.NoError(t, err) + assert.True(t, response.Success) + + mockRoundTripper.AssertExpectations(t) +} + +func TestUnlockStack_Error(t *testing.T) { + mockLogger, err := logger.NewLogger("test", "/dev/stdout") + assert.Nil(t, err) + + mockRoundTripper := new(MockRoundTripper) + httpClient := &http.Client{Transport: mockRoundTripper} + apiClient := &AtmosProAPIClient{ + Logger: mockLogger, + BaseURL: "http://localhost", + BaseAPIEndpoint: "api", + APIToken: "test-token", + HTTPClient: httpClient, + } + + dto := UnlockStackRequest{} + + mockResponse := &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(bytes.NewBufferString(`{"request":"1", "success": false, "errorMessage": "Internal Server Error"}`)), + } + + mockRoundTripper.On("RoundTrip", mock.Anything).Return(mockResponse, nil) + + response, err := apiClient.UnlockStack(dto) + assert.Error(t, err) + assert.Contains(t, err.Error(), "Internal Server Error") + assert.False(t, response.Success) + + mockRoundTripper.AssertExpectations(t) +} diff --git a/pkg/pro/requests.go b/pkg/pro/requests.go new file mode 100644 index 000000000..aa4761ef4 --- /dev/null +++ b/pkg/pro/requests.go @@ -0,0 +1,64 @@ +package pro + +import ( + "time" + + "github.com/cloudposse/atmos/pkg/schema" +) + +type LockStackRequest struct { + Key string `json:"key"` + TTL int32 `json:"ttl"` + LockMessage string `json:"lockMessage,omitempty"` + Properties map[string]interface{} `json:"properties,omitempty"` +} + +type UnlockStackRequest struct { + Key string `json:"key"` +} + +type Property struct { + ID string `json:"id,omitempty"` + LockID string `json:"lockId,omitempty"` + Key string `json:"key,omitempty"` + Value string `json:"value,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` + DeletedAt time.Time `json:"deletedAt,omitempty"` +} + +type AtmosApiResponse struct { + Request string `json:"request"` + Success bool `json:"success"` + ErrorMessage string `json:"errorMessage,omitempty"` + Context map[string]interface{} `json:"context,omitempty"` +} + +type LockStackResponse struct { + AtmosApiResponse + Data struct { + ID string `json:"id,omitempty"` + WorkspaceId string `json:"workspaceId,omitempty"` + Key string `json:"key,omitempty"` + LockMessage string `json:"lockMessage,omitempty"` + ExpiresAt time.Time `json:"expiresAt,omitempty"` + CreatedAt time.Time `json:"createdAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt,omitempty"` + DeletedAt time.Time `json:"deletedAt,omitempty"` + } `json:"data"` +} + +type UnlockStackResponse struct { + AtmosApiResponse + Data struct{} `json:"data"` +} + +type AffectedStacksUploadRequest struct { + HeadSHA string `json:"head_sha"` + BaseSHA string `json:"base_sha"` + RepoURL string `json:"repo_url"` + RepoName string `json:"repo_name"` + RepoOwner string `json:"repo_owner"` + RepoHost string `json:"repo_host"` + Stacks []schema.Affected `json:"stacks"` +} diff --git a/website/docs/cli/commands/pro/_category_.json b/website/docs/cli/commands/pro/_category_.json new file mode 100644 index 000000000..23a7e15c1 --- /dev/null +++ b/website/docs/cli/commands/pro/_category_.json @@ -0,0 +1,11 @@ +{ + "label": "pro", + "position": 5, + "className": "command", + "collapsible": true, + "collapsed": true, + "link": { + "type": "doc", + "id": "usage" + } +} diff --git a/website/docs/cli/commands/pro/pro-lock.mdx b/website/docs/cli/commands/pro/pro-lock.mdx new file mode 100644 index 000000000..ccb2284cd --- /dev/null +++ b/website/docs/cli/commands/pro/pro-lock.mdx @@ -0,0 +1,49 @@ +--- +title: atmos pro lock +sidebar_label: lock +sidebar_class_name: command +id: lock +description: Use this command to lock a stack in Atmos Pro so that it cannot be planned or applied by another process (pull request, CI/CD, etc.) +--- + +import Screengrab from "@site/src/components/Screengrab"; + +:::note Purpose +This command implements the locking feature of [Atmos Pro](https://app.cloudposse.com/docs). Use this command to lock +a stack in Atmos Pro so that it cannot be planned or applied by another process (pull request, CI/CD, etc.) +::: + +## Usage + +Execute the `pro lock` command like this: + +```shell +atmos pro lock --component --stack --ttl --message +``` + +## Description + +Atmos pro supports locking a stack in Atmos Pro so that it cannot be planned or applied by another process (pull +request, CI/CD, etc.). Your CI/CD pipeline can use the `atmos pro lock` command to ensure it is the exclusive process +interacting with a stack at the current time. Once your work is complete, you can unlock the stack by running the `atmos +pro unlock` command. + +:::tip +Run `atmos pro lock --help` to see all the available options +::: + +## Examples + +```shell +atmos pro lock --component vpc --stack plat-ue2-dev --ttl 300 --message "Locked by $GITHUB_RUN_ID" +atmos pro lock --component vpc --stack plat-ue2-dev --ttl 300 +``` + +## Flags + +| Flag | Description | Alias | Required | +| :------------ | :----------------------------------------------------------------------------------------- | :---- | :------- | +| `--component` | Atmos component to lock | `-c` | yes | +| `--stack` | Atmos stack to lock | `-s` | yes | +| `--ttl` | The time to live (TTL) for the lock, in seconds. Defaults to 30 | `-t` | no | +| `--message` | A message to display to other users who try to lock the stack. Defaults to "Locked by Atmos" | `-m` | no | diff --git a/website/docs/cli/commands/pro/pro-unlock.mdx b/website/docs/cli/commands/pro/pro-unlock.mdx new file mode 100644 index 000000000..79844349a --- /dev/null +++ b/website/docs/cli/commands/pro/pro-unlock.mdx @@ -0,0 +1,46 @@ +--- +title: atmos pro unlock +sidebar_label: unlock +sidebar_class_name: command +id: unlock +description: Use this command to unlock a stack in Atmos Pro that has previously been locked by the lock command. +--- + +import Screengrab from "@site/src/components/Screengrab"; + +:::note Purpose +This command implements the locking feature of [Atmos Pro](https://app.cloudposse.com/docs). Use this command to unlock +a stack in Atmos Pro that has previously been locked by the lock command. +::: + +## Usage + +Execute the `pro unlock` command like this: + +```shell +atmos pro unlock --component --stack +``` + +## Description + +Atmos pro supports locking a stack in Atmos Pro so that it cannot be planned or applied by another process (pull +request, CI/CD, etc.). Your CI/CD pipeline can use the `atmos pro lock` command to ensure it is the exclusive process +interacting with a stack at the current time. Once your work is complete, you can unlock the stack by running the `atmos +pro unlock` command. + +:::tip +Run `atmos pro unlock --help` to see all the available options +::: + +## Examples + +```shell +atmos pro unlock --component vpc --stack plat-ue2-dev +``` + +## Flags + +| Flag | Description | Alias | Required | +| :------------ | :----------------------------------------------------------------------------------------- | :---- | :------- | +| `--component` | Atmos component to unlock | `-c` | yes | +| `--stack` | Atmos stack to unlock | `-s` | yes | diff --git a/website/docs/cli/commands/pro/usage.mdx b/website/docs/cli/commands/pro/usage.mdx new file mode 100644 index 000000000..d843b0cca --- /dev/null +++ b/website/docs/cli/commands/pro/usage.mdx @@ -0,0 +1,17 @@ +--- +title: atmos pro +sidebar_label: pro +sidebar_class_name: command +description: "Atmos Pro Commands" +--- + +import Screengrab from "@site/src/components/Screengrab"; +import DocCardList from "@theme/DocCardList"; + +:::note Purpose +Use these subcommands to interact with Atmos Pro. +::: + +## Subcommands + +