diff --git a/go.mod b/go.mod index 296832e67..b3881a184 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/bacongobbler/browser v1.1.0 github.com/bombsimon/logrusr/v4 v4.1.0 github.com/coreos/go-oidc/v3 v3.11.0 + github.com/cyphar/filepath-securejoin v0.3.1 github.com/evanphx/json-patch/v5 v5.9.0 github.com/fatih/structtag v1.2.0 github.com/gobwas/glob v0.2.3 diff --git a/go.sum b/go.sum index b1803648a..f2bcb55aa 100644 --- a/go.sum +++ b/go.sum @@ -105,6 +105,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/cyphar/filepath-securejoin v0.3.1 h1:1V7cHiaW+C+39wEfpH6XlLBQo3j/PciWFrgfCLS8XrE= +github.com/cyphar/filepath-securejoin v0.3.1/go.mod h1:F7i41x/9cBF7lzCrVsYs9fuzwRZm4NQsGTBdpp6mETc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= diff --git a/internal/directives/directive.go b/internal/directives/directive.go index c349088e7..5696e288b 100644 --- a/internal/directives/directive.go +++ b/internal/directives/directive.go @@ -25,6 +25,8 @@ type StepContext struct { Config Config // Project is the Project that the Promotion is associated with. Project string + // Stage is the Stage that the Promotion is targeting. + Stage string // FreightRequests is the list of Freight from various origins that is // requested by the Stage targeted by the Promotion. This information is // sometimes useful to Steps that reference a particular artifact and, in the diff --git a/internal/directives/git_clone_directive.go b/internal/directives/git_clone_directive.go index b8d29a919..7820f7509 100644 --- a/internal/directives/git_clone_directive.go +++ b/internal/directives/git_clone_directive.go @@ -4,7 +4,13 @@ import ( "context" "fmt" + securejoin "github.com/cyphar/filepath-securejoin" "github.com/xeipuuv/gojsonschema" + + kargoapi "github.com/akuity/kargo/api/v1alpha1" + "github.com/akuity/kargo/internal/controller/freight" + "github.com/akuity/kargo/internal/controller/git" + "github.com/akuity/kargo/internal/credentials" ) func init() { @@ -38,7 +44,7 @@ func (g *gitCloneDirective) Name() string { // Run implements the Directive interface. func (g *gitCloneDirective) Run( - _ context.Context, + ctx context.Context, stepCtx *StepContext, ) (Result, error) { failure := Result{Status: StatusFailure} @@ -50,10 +56,96 @@ func (g *gitCloneDirective) Run( ); err != nil { return failure, err } - if _, err := configToStruct[GitCloneConfig](stepCtx.Config); err != nil { + cfg, err := configToStruct[GitCloneConfig](stepCtx.Config) + if err != nil { return failure, fmt.Errorf("could not convert config into git-clone config: %w", err) } - // TODO: Add implementation here - return Result{Status: StatusSuccess}, nil + if err = g.run(ctx, stepCtx, cfg); err != nil { + return failure, err + } + return Result{ + Status: StatusSuccess, + }, nil +} + +func (g *gitCloneDirective) run( + ctx context.Context, + stepCtx *StepContext, + cfg GitCloneConfig, +) error { + var repoCreds *git.RepoCredentials + if creds, found, err := stepCtx.CredentialsDB.Get( + ctx, + stepCtx.Project, + credentials.TypeGit, + cfg.RepoURL, + ); err != nil { + return fmt.Errorf("error getting credentials for %s: %w", cfg.RepoURL, err) + } else if found { + repoCreds = &git.RepoCredentials{ + Username: creds.Username, + Password: creds.Password, + SSHPrivateKey: creds.SSHPrivateKey, + } + } + repo, err := git.CloneBare( + cfg.RepoURL, + &git.ClientOptions{ + Credentials: repoCreds, + }, + &git.BareCloneOptions{ + BaseDir: stepCtx.WorkDir, + InsecureSkipTLSVerify: cfg.InsecureSkipTLSVerify, + }, + ) + if err != nil { + return fmt.Errorf("error cloning %s: %w", cfg.RepoURL, err) + } + for _, checkout := range cfg.Checkout { + var ref string + switch { + case checkout.Branch != "": + ref = checkout.Branch + case checkout.FromFreight: + var desiredOrigin *kargoapi.FreightOrigin + if checkout.FromOrigin == nil { + desiredOrigin = &kargoapi.FreightOrigin{ + Kind: kargoapi.FreightOriginKind(checkout.FromOrigin.Kind), + } + } + var commit *kargoapi.GitCommit + if commit, err = freight.FindCommit( + ctx, + stepCtx.KargoClient, + stepCtx.Project, + stepCtx.FreightRequests, + desiredOrigin, + stepCtx.Freight.References(), + cfg.RepoURL, + ); err != nil { + return fmt.Errorf("error finding commit from repo %s: %w", cfg.RepoURL, err) + } + ref = commit.ID + case checkout.Tag != "": + ref = checkout.Tag + } + path, err := securejoin.SecureJoin(stepCtx.WorkDir, checkout.Path) + if err != nil { + return fmt.Errorf( + "error joining path %s with work dir %s: %w", + checkout.Path, stepCtx.WorkDir, err, + ) + } + if _, err = repo.AddWorkTree(path, ref); err != nil { + return fmt.Errorf( + "error adding work tree %s to repo %s: %w", + checkout.Path, cfg.RepoURL, err, + ) + } + } + // Note: We do NOT defer repo.Close() because we want to keep the repository + // around onf the FS for subsequent directives to use. The directive execution + // engine will handle all work dir cleanup. + return nil } diff --git a/internal/directives/git_commit_directive.go b/internal/directives/git_commit_directive.go index e4b7d2f7d..5bc997870 100644 --- a/internal/directives/git_commit_directive.go +++ b/internal/directives/git_commit_directive.go @@ -5,6 +5,9 @@ import ( "fmt" "github.com/xeipuuv/gojsonschema" + + "github.com/akuity/kargo/internal/controller/git" + "github.com/akuity/kargo/internal/credentials" ) func init() { @@ -32,7 +35,7 @@ func (g *gitCommitDirective) Name() string { // Run implements the Directive interface. func (g *gitCommitDirective) Run( - _ context.Context, + ctx context.Context, stepCtx *StepContext, ) (Result, error) { failure := Result{Status: StatusFailure} @@ -44,10 +47,68 @@ func (g *gitCommitDirective) Run( ); err != nil { return failure, err } - if _, err := configToStruct[GitCommitConfig](stepCtx.Config); err != nil { + cfg, err := configToStruct[GitCommitConfig](stepCtx.Config) + if err != nil { return failure, fmt.Errorf("could not convert config into git-commit config: %w", err) } - // TODO: Add implementation here - return Result{Status: StatusSuccess}, nil + if err = g.run(ctx, stepCtx, cfg); err != nil { + return failure, err + } + return Result{ + Status: StatusSuccess, + }, nil +} + +func (g *gitCommitDirective) run( + ctx context.Context, + stepCtx *StepContext, + cfg GitCommitConfig, +) error { + // This is kind of hacky, but we needed to load the working tree to get the + // URL of the repository. With that in hand, we can look for applicable + // credentials and, if found, reload the work tree with the credentials. + loadOpts := &git.LoadWorkTreeOptions{} + workTree, err := git.LoadWorkTree(cfg.Path, loadOpts) + if err != nil { + return fmt.Errorf("error loading working tree from %s: %w", cfg.Path, err) + } + var creds credentials.Credentials + var found bool + if creds, found, err = stepCtx.CredentialsDB.Get( + ctx, + stepCtx.Project, + credentials.TypeGit, + workTree.URL(), + ); err != nil { + return fmt.Errorf( + "error getting credentials for %s: %w", workTree.URL(), err, + ) + } else if found { + loadOpts.Credentials = &git.RepoCredentials{ + Username: creds.Username, + Password: creds.Password, + SSHPrivateKey: creds.SSHPrivateKey, + } + } + if workTree, err = git.LoadWorkTree(cfg.Path, nil); err != nil { + return fmt.Errorf("error loading working tree from %s: %w", cfg.Path, err) + } + if err = workTree.AddAll(); err != nil { + return fmt.Errorf("error adding all changes to working tree: %w", err) + } + commitOpts := &git.CommitOptions{} + if cfg.Author != nil { + commitOpts.Author = &git.User{} + if cfg.Author.Name != "" { + commitOpts.Author.Name = cfg.Author.Name + } + if cfg.Author.Email != "" { + commitOpts.Author.Email = cfg.Author.Email + } + } + if err = workTree.Commit(cfg.Message, commitOpts); err != nil { + return fmt.Errorf("error committing to working tree: %w", err) + } + return nil } diff --git a/internal/directives/git_push_directive.go b/internal/directives/git_push_directive.go index 1115fed91..576ffcf32 100644 --- a/internal/directives/git_push_directive.go +++ b/internal/directives/git_push_directive.go @@ -5,6 +5,9 @@ import ( "fmt" "github.com/xeipuuv/gojsonschema" + + "github.com/akuity/kargo/internal/controller/git" + "github.com/akuity/kargo/internal/credentials" ) func init() { @@ -32,7 +35,7 @@ func (g *gitPushDirective) Name() string { // Run implements the Directive interface. func (g *gitPushDirective) Run( - _ context.Context, + ctx context.Context, stepCtx *StepContext, ) (Result, error) { failure := Result{Status: StatusFailure} @@ -44,10 +47,77 @@ func (g *gitPushDirective) Run( ); err != nil { return failure, err } - if _, err := configToStruct[GitPushConfig](stepCtx.Config); err != nil { + cfg, err := configToStruct[GitPushConfig](stepCtx.Config) + if err != nil { return failure, fmt.Errorf("could not convert config into git-push config: %w", err) } - // TODO: Add implementation here - return Result{Status: StatusSuccess}, nil + targetBranch, err := g.run(ctx, stepCtx, cfg) + if err != nil { + return failure, err + } + return Result{ + Status: StatusSuccess, + Output: State{ + "branch": targetBranch, + }, + }, nil +} + +func (g *gitPushDirective) run( + ctx context.Context, + stepCtx *StepContext, + cfg GitPushConfig, +) (string, error) { + // This is kind of hacky, but we needed to load the working tree to get the + // URL of the repository. With that in hand, we can look for applicable + // credentials and, if found, reload the work tree with the credentials. + loadOpts := &git.LoadWorkTreeOptions{} + workTree, err := git.LoadWorkTree(cfg.Path, loadOpts) + if err != nil { + return "", fmt.Errorf("error loading working tree from %s: %w", cfg.Path, err) + } + var creds credentials.Credentials + var found bool + if creds, found, err = stepCtx.CredentialsDB.Get( + ctx, + stepCtx.Project, + credentials.TypeGit, + workTree.URL(), + ); err != nil { + return "", fmt.Errorf( + "error getting credentials for %s: %w", workTree.URL(), err, + ) + } else if found { + loadOpts.Credentials = &git.RepoCredentials{ + Username: creds.Username, + Password: creds.Password, + SSHPrivateKey: creds.SSHPrivateKey, + } + } + if workTree, err = git.LoadWorkTree(cfg.Path, loadOpts); err != nil { + return "", fmt.Errorf("error loading working tree from %s: %w", cfg.Path, err) + } + pushOpts := &git.PushOptions{ + // Start with whatever was specified in the config, which may be empty + TargetBranch: cfg.TargetBranch, + } + // If we're supposed to generate a target branch name, do so + if cfg.GenerateTargetBranch { + pushOpts.TargetBranch = fmt.Sprintf("kargo/%s/%s/promotion", stepCtx.Project, stepCtx.Stage) + pushOpts.Force = true + } + retBranch := pushOpts.TargetBranch + if retBranch == "" { + // If retBranch is still empty, we want to set it to the current branch + // because we will want to return the branch that was pushed to, but we + // don't want to mess with the options any further. + if retBranch, err = workTree.CurrentBranch(); err != nil { + return "", fmt.Errorf("error getting current branch: %w", err) + } + } + if err := workTree.Push(pushOpts); err != nil { + return "", fmt.Errorf("error pushing commits to remote: %w", err) + } + return retBranch, nil }