From 0b43b26e0d3c5e01b57b1db2914cf9e67e5408d7 Mon Sep 17 00:00:00 2001 From: Kent Rancourt Date: Tue, 26 Mar 2024 08:24:12 -0400 Subject: [PATCH] feat: make pr-based promotions work with kargo render (#1674) Signed-off-by: Kent Rancourt --- Dockerfile | 4 +- internal/controller/git/git.go | 24 +- internal/controller/promotion/git.go | 12 +- internal/controller/promotion/git_test.go | 6 +- internal/controller/promotion/helm.go | 1 + internal/controller/promotion/helm_test.go | 1 + internal/controller/promotion/kustomize.go | 1 + .../controller/promotion/kustomize_test.go | 1 + internal/controller/promotion/pullrequest.go | 22 +- internal/controller/promotion/render.go | 204 +++----- internal/controller/promotion/render_test.go | 465 +++++------------- internal/kargo-render/render.go | 94 ++-- 12 files changed, 285 insertions(+), 550 deletions(-) diff --git a/Dockerfile b/Dockerfile index 41adf0597..dea157c36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -68,7 +68,7 @@ RUN GRPC_HEALTH_PROBE_VERSION=v0.4.15 && \ # - supports development # - not used for official image builds #################################################################################################### -FROM ghcr.io/akuity/kargo-render:v0.1.0-rc.35 as back-end-dev +FROM ghcr.io/akuity/kargo-render:v0.1.0-rc.38 as back-end-dev USER root @@ -103,7 +103,7 @@ CMD ["pnpm", "dev"] # - the official image we publish # - purposefully last so that it is the default target when building #################################################################################################### -FROM ghcr.io/akuity/kargo-render:v0.1.0-rc.35 as final +FROM ghcr.io/akuity/kargo-render:v0.1.0-rc.38 as final USER root diff --git a/internal/controller/git/git.go b/internal/controller/git/git.go index 731ee4f7c..3fce045b6 100644 --- a/internal/controller/git/git.go +++ b/internal/controller/git/git.go @@ -30,6 +30,12 @@ type RepoCredentials struct { Password string `json:"password,omitempty"` } +// CommitOptions represents options for committing changes to a git repository. +type CommitOptions struct { + // AllowEmpty indicates whether an empty commit should be allowed. + AllowEmpty bool +} + // Repo is an interface for interacting with a git repository. type Repo interface { // AddAll stages pending changes for commit. @@ -46,7 +52,7 @@ type Repo interface { // Checkout checks out the specified branch. Checkout(branch string) error // Commit commits staged changes to the current branch. - Commit(message string) error + Commit(message string, opts *CommitOptions) error // CreateChildBranch creates a new branch that is a child of the current // branch. CreateChildBranch(branch string) error @@ -135,7 +141,7 @@ func Clone( repoCreds RepoCredentials, opts *CloneOptions, ) (Repo, error) { - homeDir, err := os.MkdirTemp("", "") + homeDir, err := os.MkdirTemp("", "repo-") if err != nil { return nil, fmt.Errorf("error creating home directory for repo %q: %w", repoURL, err) } @@ -162,7 +168,7 @@ func (r *repo) AddAllAndCommit(message string) error { if err := r.AddAll(); err != nil { return err } - return r.Commit(message) + return r.Commit(message, nil) } func (r *repo) Clean() error { @@ -226,8 +232,16 @@ func (r *repo) Checkout(branch string) error { return nil } -func (r *repo) Commit(message string) error { - if _, err := libExec.Exec(r.buildCommand("commit", "-m", message)); err != nil { +func (r *repo) Commit(message string, opts *CommitOptions) error { + if opts == nil { + opts = &CommitOptions{} + } + cmdTokens := []string{"commit", "-m", message} + if opts.AllowEmpty { + cmdTokens = append(cmdTokens, "--allow-empty") + } + + if _, err := libExec.Exec(r.buildCommand(cmdTokens...)); err != nil { return fmt.Errorf("error committing changes to branch %q: %w", r.currentBranch, err) } return nil diff --git a/internal/controller/promotion/git.go b/internal/controller/promotion/git.go index f17d07b79..4db907fc6 100644 --- a/internal/controller/promotion/git.go +++ b/internal/controller/promotion/git.go @@ -13,6 +13,8 @@ import ( "github.com/akuity/kargo/internal/logging" ) +const tmpPrefix = "repo-scrap-" + // gitMechanism is an implementation of the Mechanism interface that uses Git to // update configuration in a repository. It is easily configured to support // different types of configuration management tools. @@ -45,6 +47,7 @@ type gitMechanism struct { applyConfigManagementFn func( update kargoapi.GitRepoUpdate, newFreight kargoapi.FreightReference, + sourceCommit string, homeDir string, workingDir string, ) ([]string, error) @@ -61,6 +64,7 @@ func newGitMechanism( applyConfigManagementFn func( update kargoapi.GitRepoUpdate, newFreight kargoapi.FreightReference, + sourceCommit string, homeDir string, workingDir string, ) ([]string, error), @@ -294,11 +298,17 @@ func (g *gitMechanism) gitCommit( } } + sourceCommitID, err := repo.LastCommitID() + if err != nil { + return "", err // TODO: Wrap this + } + var changes []string if g.applyConfigManagementFn != nil { if changes, err = g.applyConfigManagementFn( update, newFreight, + sourceCommitID, repo.HomeDir(), repo.WorkingDir(), ); err != nil { @@ -310,7 +320,7 @@ func (g *gitMechanism) gitCommit( // Sometimes we don't write to the same branch we read from... if readRef != writeBranch { var tempDir string - tempDir, err = os.MkdirTemp("", "") + tempDir, err = os.MkdirTemp("", tmpPrefix) if err != nil { return "", fmt.Errorf("error creating temp directory for pending changes: %w", err) } diff --git a/internal/controller/promotion/git_test.go b/internal/controller/promotion/git_test.go index ba2b8439d..6371b497e 100644 --- a/internal/controller/promotion/git_test.go +++ b/internal/controller/promotion/git_test.go @@ -24,7 +24,11 @@ func TestNewGitMechanism(t *testing.T) { func([]kargoapi.GitRepoUpdate) []kargoapi.GitRepoUpdate { return nil }, - func(kargoapi.GitRepoUpdate, kargoapi.FreightReference, string, string) ([]string, error) { + func( + kargoapi.GitRepoUpdate, + kargoapi.FreightReference, + string, string, string, + ) ([]string, error) { return nil, nil }, ) diff --git a/internal/controller/promotion/helm.go b/internal/controller/promotion/helm.go index c1c4110f2..f8c924e15 100644 --- a/internal/controller/promotion/helm.go +++ b/internal/controller/promotion/helm.go @@ -64,6 +64,7 @@ type helmer struct { func (h *helmer) apply( update kargoapi.GitRepoUpdate, newFreight kargoapi.FreightReference, + _ string, // TODO: sourceCommit would be a nice addition to the commit message homeDir string, workingDir string, ) ([]string, error) { diff --git a/internal/controller/promotion/helm_test.go b/internal/controller/promotion/helm_test.go index faf483eff..10949b302 100644 --- a/internal/controller/promotion/helm_test.go +++ b/internal/controller/promotion/helm_test.go @@ -254,6 +254,7 @@ func TestHelmerApply(t *testing.T) { kargoapi.FreightReference{}, // The way the tests are structured, this value doesn't matter "", "", + "", ) testCase.assertions(t, changes, err) }) diff --git a/internal/controller/promotion/kustomize.go b/internal/controller/promotion/kustomize.go index 1dfd8395c..95f9922b4 100644 --- a/internal/controller/promotion/kustomize.go +++ b/internal/controller/promotion/kustomize.go @@ -47,6 +47,7 @@ type kustomizer struct { func (k *kustomizer) apply( update kargoapi.GitRepoUpdate, newFreight kargoapi.FreightReference, + _ string, // TODO: sourceCommit would be a nice addition to the commit message _ string, workingDir string, ) ([]string, error) { diff --git a/internal/controller/promotion/kustomize_test.go b/internal/controller/promotion/kustomize_test.go index c9e88dca0..ec650babb 100644 --- a/internal/controller/promotion/kustomize_test.go +++ b/internal/controller/promotion/kustomize_test.go @@ -168,6 +168,7 @@ func TestKustomizerApply(t *testing.T) { }, "", "", + "", ) testCase.assertions(t, changes, err) }) diff --git a/internal/controller/promotion/pullrequest.go b/internal/controller/promotion/pullrequest.go index c990ec327..75b0c7ba0 100644 --- a/internal/controller/promotion/pullrequest.go +++ b/internal/controller/promotion/pullrequest.go @@ -20,7 +20,27 @@ func pullRequestBranchName(project, stage string) string { // we like (i.e. not a descendant of base), recreate it. func preparePullRequestBranch(repo git.Repo, prBranch string, base string) error { origBranch := repo.CurrentBranch() - if err := repo.Checkout(base); err != nil { + baseBranchExists, err := repo.RemoteBranchExists(base) + if err != nil { + return err + } + if !baseBranchExists { + // Base branch doesn't exist. Create it! + if err = repo.CreateOrphanedBranch(base); err != nil { + return err + } + if err = repo.Commit( + "Initial commit", + &git.CommitOptions{ + AllowEmpty: true, + }, + ); err != nil { + return err + } + if err = repo.Push(false); err != nil { + return err + } + } else if err = repo.Checkout(base); err != nil { return err } prBranchExists, err := repo.RemoteBranchExists(prBranch) diff --git a/internal/controller/promotion/render.go b/internal/controller/promotion/render.go index 7b19d57ee..18ad33516 100644 --- a/internal/controller/promotion/render.go +++ b/internal/controller/promotion/render.go @@ -1,136 +1,58 @@ package promotion import ( - "context" "fmt" + "os" + "path/filepath" + "sort" kargoapi "github.com/akuity/kargo/api/v1alpha1" - "github.com/akuity/kargo/internal/controller/git" "github.com/akuity/kargo/internal/credentials" render "github.com/akuity/kargo/internal/kargo-render" - "github.com/akuity/kargo/internal/logging" ) -// kargoRenderMechanism is an implementation of the Mechanism interface that -// uses Kargo Render to update configuration in a Git repository. -type kargoRenderMechanism struct { - // Overridable behaviors: - doSingleUpdateFn func( - ctx context.Context, - promo *kargoapi.Promotion, - update kargoapi.GitRepoUpdate, - newFreight kargoapi.FreightReference, - ) (kargoapi.FreightReference, error) - getReadRefFn func( - update kargoapi.GitRepoUpdate, - commits []kargoapi.GitCommit, - ) (string, int, error) - getCredentialsFn func( - ctx context.Context, - namespace string, - credType credentials.Type, - repo string, - ) (credentials.Credentials, bool, error) - renderManifestsFn func(render.Request) (render.Response, error) -} - -// newKargoRenderMechanism returns an implementation of the Mechanism interface -// that uses Kargo Render to update configuration in a Git repository. +// newKargoRenderMechanism returns a gitMechanism that only only selects and +// performs updates that involve Kargo Render. func newKargoRenderMechanism( credentialsDB credentials.Database, ) Mechanism { - b := &kargoRenderMechanism{} - b.doSingleUpdateFn = b.doSingleUpdate - b.getReadRefFn = getReadRef - b.getCredentialsFn = credentialsDB.Get - // TODO: KR: Refactor this - b.renderManifestsFn = render.RenderManifests - return b -} - -// GetName implements the Mechanism interface. -func (*kargoRenderMechanism) GetName() string { - return "Kargo Render promotion mechanisms" + return newGitMechanism( + "Kargo Render promotion mechanism", + credentialsDB, + selectKargoRenderUpdates, + (&renderer{ + renderManifestsFn: render.RenderManifests, + }).apply, + ) } -// Promote implements the Mechanism interface. -func (b *kargoRenderMechanism) Promote( - ctx context.Context, - stage *kargoapi.Stage, - promo *kargoapi.Promotion, - newFreight kargoapi.FreightReference, -) (*kargoapi.PromotionStatus, kargoapi.FreightReference, error) { - updates := make([]kargoapi.GitRepoUpdate, 0, len(stage.Spec.PromotionMechanisms.GitRepoUpdates)) - for _, update := range stage.Spec.PromotionMechanisms.GitRepoUpdates { - if update.Render != nil { - updates = append(updates, update) - } - } - - if len(updates) == 0 { - return promo.Status.WithPhase(kargoapi.PromotionPhaseSucceeded), newFreight, nil - } - - newFreight = *newFreight.DeepCopy() - - logger := logging.LoggerFromContext(ctx) - logger.Debug("executing Kargo Render-based promotion mechanisms") - +// selectKargoRenderUpdates returns a subset of the given updates that involve +// Kargo Render. +func selectKargoRenderUpdates(updates []kargoapi.GitRepoUpdate) []kargoapi.GitRepoUpdate { + selectedUpdates := make([]kargoapi.GitRepoUpdate, 0, len(updates)) for _, update := range updates { - var err error - if newFreight, err = b.doSingleUpdateFn( - ctx, - promo, - update, - newFreight, - ); err != nil { - return nil, newFreight, err + if update.Render != nil { + selectedUpdates = append(selectedUpdates, update) } } + return selectedUpdates +} - logger.Debug("done executing Kargo Render-based promotion mechanisms") - - return promo.Status.WithPhase(kargoapi.PromotionPhaseSucceeded), newFreight, nil +// renderer is a helper struct whose sole purpose is to close over several +// other functions that are used in the implementation of the apply() function. +type renderer struct { + renderManifestsFn func(req render.Request) error } -// doSingleUpdateFn updates configuration in a single Git repository using -// Kargo Render. -func (b *kargoRenderMechanism) doSingleUpdate( - ctx context.Context, - promo *kargoapi.Promotion, +// apply uses Kargo Render to carry out the provided update in the specified +// working directory. +func (r *renderer) apply( update kargoapi.GitRepoUpdate, newFreight kargoapi.FreightReference, -) (kargoapi.FreightReference, error) { - logger := logging.LoggerFromContext(ctx).WithField("repo", update.RepoURL) - - readRef, commitIndex, err := b.getReadRefFn(update, newFreight.Commits) - if err != nil { - return newFreight, err - } - - creds, ok, err := b.getCredentialsFn( - ctx, - promo.Namespace, - credentials.TypeGit, - update.RepoURL, - ) - if err != nil { - return newFreight, fmt.Errorf( - "error obtaining credentials for git repo %q: %w", - update.RepoURL, - err, - ) - } - repoCreds := git.RepoCredentials{} - if ok { - repoCreds.Username = creds.Username - repoCreds.Password = creds.Password - repoCreds.SSHPrivateKey = creds.SSHPrivateKey - logger.Debug("obtained credentials for git repo") - } else { - logger.Debug("found no credentials for git repo") - } - + sourceCommit string, + _ string, + workingDir string, +) ([]string, error) { images := make([]string, 0, len(newFreight.Images)) if len(update.Render.Images) == 0 { // When no explicit image updates are specified, we will pass all images @@ -161,37 +83,47 @@ func (b *kargoRenderMechanism) doSingleUpdate( } } + sort.StringSlice(images).Sort() + + tempDir, err := os.MkdirTemp("", tmpPrefix) + if err != nil { + return nil, fmt.Errorf("error creating temporary directory: %w", err) + } + defer os.RemoveAll(tempDir) + writeDir := filepath.Join(tempDir, "rendered-manifests") + req := render.Request{ - RepoURL: update.RepoURL, - RepoCreds: repoCreds, - Ref: readRef, - Images: images, TargetBranch: update.WriteBranch, + Images: images, + LocalInPath: workingDir, + LocalOutPath: writeDir, } - res, err := b.renderManifestsFn(req) - if err != nil { - return newFreight, fmt.Errorf( - "error rendering manifests for git repo %q via Kargo Render: %w", - update.RepoURL, - err, - ) + if err = r.renderManifestsFn(req); err != nil { + return nil, fmt.Errorf("error rendering manifests via Kargo Render: %w", err) } - switch res.ActionTaken { - case render.ActionTakenPushedDirectly: - logger.WithField("commit", res.CommitID). - Debug("pushed new commit to repo via Kargo Render") - if commitIndex > -1 { - newFreight.Commits[commitIndex].HealthCheckCommit = res.CommitID - } - case render.ActionTakenNone: - logger.Debug("Kargo Render made no changes to repo") - if commitIndex > -1 { - newFreight.Commits[commitIndex].HealthCheckCommit = res.CommitID - } - default: - // TODO: Not sure yet how to handle PRs. + + if err = deleteRepoContents(workingDir); err != nil { + return nil, + fmt.Errorf("error overwriting working directory with rendered manifests: %w", err) + } + + if err = moveRepoContents(writeDir, workingDir); err != nil { + return nil, + fmt.Errorf("error overwriting working directory with rendered manifests: %w", err) + } + + changeSummary := make([]string, 0, len(images)+1) + changeSummary = append( + changeSummary, + fmt.Sprintf("rendered manifests from commit %s", sourceCommit[:7]), + ) + for _, image := range images { + changeSummary = append( + changeSummary, + fmt.Sprintf("updated manifests to use image %s", image), + ) } - return newFreight, nil + return changeSummary, nil } diff --git a/internal/controller/promotion/render_test.go b/internal/controller/promotion/render_test.go index aa10f0953..f40096a41 100644 --- a/internal/controller/promotion/render_test.go +++ b/internal/controller/promotion/render_test.go @@ -1,12 +1,13 @@ package promotion import ( - "context" "errors" + "fmt" + "os" + "path/filepath" "testing" "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kargoapi "github.com/akuity/kargo/api/v1alpha1" "github.com/akuity/kargo/internal/credentials" @@ -15,435 +16,211 @@ import ( func TestNewKargoRenderMechanism(t *testing.T) { pm := newKargoRenderMechanism(&credentials.FakeDB{}) - krpm, ok := pm.(*kargoRenderMechanism) + kpm, ok := pm.(*gitMechanism) require.True(t, ok) - require.NotNil(t, krpm.doSingleUpdateFn) - require.NotNil(t, krpm.getReadRefFn) - require.NotNil(t, krpm.getCredentialsFn) - require.NotNil(t, krpm.renderManifestsFn) + require.NotNil(t, kpm.selectUpdatesFn) + require.NotNil(t, kpm.applyConfigManagementFn) } -func TestKargoRenderGetName(t *testing.T) { - require.NotEmpty(t, (&kargoRenderMechanism{}).GetName()) -} - -func TestKargoRenderPromote(t *testing.T) { +func TestSelectKargoRenderUpdates(t *testing.T) { testCases := []struct { name string - promoMech *kargoRenderMechanism - stage *kargoapi.Stage - newFreight kargoapi.FreightReference - assertions func( - t *testing.T, - status *kargoapi.PromotionStatus, - newFreightIn kargoapi.FreightReference, - newFreightOut kargoapi.FreightReference, - err error, - ) + updates []kargoapi.GitRepoUpdate + assertions func(*testing.T, []kargoapi.GitRepoUpdate) }{ { - name: "no updates", - promoMech: &kargoRenderMechanism{}, - stage: &kargoapi.Stage{ - Spec: &kargoapi.StageSpec{ - PromotionMechanisms: &kargoapi.PromotionMechanisms{}, - }, - }, - assertions: func( - t *testing.T, - _ *kargoapi.PromotionStatus, - newFreightIn kargoapi.FreightReference, - newFreightOut kargoapi.FreightReference, - err error, - ) { - require.NoError(t, err) - require.Equal(t, newFreightIn, newFreightOut) + name: "no updates", + assertions: func(t *testing.T, selectedUpdates []kargoapi.GitRepoUpdate) { + require.Empty(t, selectedUpdates) }, }, { - name: "error applying update", - promoMech: &kargoRenderMechanism{ - doSingleUpdateFn: func( - _ context.Context, - _ *kargoapi.Promotion, - _ kargoapi.GitRepoUpdate, - newFreight kargoapi.FreightReference, - ) (kargoapi.FreightReference, error) { - return newFreight, errors.New("something went wrong") - }, - }, - stage: &kargoapi.Stage{ - Spec: &kargoapi.StageSpec{ - PromotionMechanisms: &kargoapi.PromotionMechanisms{ - GitRepoUpdates: []kargoapi.GitRepoUpdate{ - { - Render: &kargoapi.KargoRenderPromotionMechanism{}, - }, - }, - }, - }, - }, - newFreight: kargoapi.FreightReference{ - Images: []kargoapi.Image{ - { - RepoURL: "fake-url", - Tag: "fake-tag", - }, + name: "no kargo render updates", + updates: []kargoapi.GitRepoUpdate{ + { + RepoURL: "fake-url", }, }, - assertions: func( - t *testing.T, - _ *kargoapi.PromotionStatus, - newFreightIn kargoapi.FreightReference, - newFreightOut kargoapi.FreightReference, - err error, - ) { - require.Error(t, err) - require.Equal(t, "something went wrong", err.Error()) - require.Equal(t, newFreightIn, newFreightOut) + assertions: func(t *testing.T, selectedUpdates []kargoapi.GitRepoUpdate) { + require.Empty(t, selectedUpdates) }, }, { - name: "success", - promoMech: &kargoRenderMechanism{ - doSingleUpdateFn: func( - _ context.Context, - _ *kargoapi.Promotion, - _ kargoapi.GitRepoUpdate, - newFreight kargoapi.FreightReference, - ) (kargoapi.FreightReference, error) { - return newFreight, nil + name: "some kargo render updates", + updates: []kargoapi.GitRepoUpdate{ + { + RepoURL: "fake-url", + Render: &kargoapi.KargoRenderPromotionMechanism{}, }, - }, - stage: &kargoapi.Stage{ - Spec: &kargoapi.StageSpec{ - PromotionMechanisms: &kargoapi.PromotionMechanisms{ - GitRepoUpdates: []kargoapi.GitRepoUpdate{ - { - Render: &kargoapi.KargoRenderPromotionMechanism{}, - }, - }, - }, + { + RepoURL: "fake-url", + Helm: &kargoapi.HelmPromotionMechanism{}, }, - }, - newFreight: kargoapi.FreightReference{ - Images: []kargoapi.Image{ - { - RepoURL: "fake-url", - Tag: "fake-tag", - }, + { + RepoURL: "fake-url", }, }, - assertions: func( - t *testing.T, - _ *kargoapi.PromotionStatus, - newFreightIn kargoapi.FreightReference, - newFreightOut kargoapi.FreightReference, - err error, - ) { - require.NoError(t, err) - require.Equal(t, newFreightIn, newFreightOut) + assertions: func(t *testing.T, selectedUpdates []kargoapi.GitRepoUpdate) { + require.Len(t, selectedUpdates, 1) }, }, } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - status, newFreightOut, err := testCase.promoMech.Promote( - context.Background(), - testCase.stage, - &kargoapi.Promotion{}, - testCase.newFreight, - ) - testCase.assertions(t, status, testCase.newFreight, newFreightOut, err) + testCase.assertions(t, selectKargoRenderUpdates(testCase.updates)) }) } } -func TestKargoRenderDoSingleUpdate(t *testing.T) { - const testRef = "fake-ref" +func TestKargoRenderApply(t *testing.T) { + testRenderedManifestName := "fake-filename" + testRenderedManifest := []byte("fake-rendered-manifest") + testSourceCommitID := "fake-commit-id" testCases := []struct { name string - freight kargoapi.FreightReference - promoMech *kargoRenderMechanism update kargoapi.GitRepoUpdate - assertions func( - t *testing.T, - newFreightIn kargoapi.FreightReference, - newFreightOut kargoapi.FreightReference, - err error, - ) + newFreight kargoapi.FreightReference + renderer *renderer + assertions func(t *testing.T, changes []string, workDir string, err error) }{ { - name: "error getting readref", - promoMech: &kargoRenderMechanism{ - getReadRefFn: func( - kargoapi.GitRepoUpdate, - []kargoapi.GitCommit, - ) (string, int, error) { - return "", 0, errors.New("something went wrong") - }, - }, - assertions: func( - t *testing.T, - newFreightIn kargoapi.FreightReference, - newFreightOut kargoapi.FreightReference, - err error, - ) { - require.Error(t, err) - require.Equal(t, "something went wrong", err.Error()) - require.Equal(t, newFreightIn, newFreightOut) + name: "error running Kargo Render", + update: kargoapi.GitRepoUpdate{ + Render: &kargoapi.KargoRenderPromotionMechanism{}, }, - }, - { - name: "error getting repo credentials", - promoMech: &kargoRenderMechanism{ - getReadRefFn: func( - kargoapi.GitRepoUpdate, - []kargoapi.GitCommit, - ) (string, int, error) { - return testRef, 0, nil - }, - getCredentialsFn: func( - context.Context, - string, - credentials.Type, - string, - ) (credentials.Credentials, bool, error) { - return credentials.Credentials{}, - false, - errors.New("something went wrong") + renderer: &renderer{ + renderManifestsFn: func(render.Request) error { + return errors.New("something went wrong") }, }, - assertions: func( - t *testing.T, - newFreightIn kargoapi.FreightReference, - newFreightOut kargoapi.FreightReference, - err error, - ) { + assertions: func(t *testing.T, _ []string, _ string, err error) { require.Error(t, err) - require.Contains( - t, - err.Error(), - "error obtaining credentials for git repo", - ) + require.Contains(t, err.Error(), "error rendering manifests via Kargo Render") require.Contains(t, err.Error(), "something went wrong") - require.Equal(t, newFreightIn, newFreightOut) }, }, { - name: "error rendering manifests", + name: "update doesn't specify images", update: kargoapi.GitRepoUpdate{ - RepoURL: "fake-url", - Render: &kargoapi.KargoRenderPromotionMechanism{}, + Render: &kargoapi.KargoRenderPromotionMechanism{}, }, - promoMech: &kargoRenderMechanism{ - getReadRefFn: func( - kargoapi.GitRepoUpdate, - []kargoapi.GitCommit, - ) (string, int, error) { - return testRef, 0, nil - }, - getCredentialsFn: func( - context.Context, - string, - credentials.Type, - string, - ) (credentials.Credentials, bool, error) { - return credentials.Credentials{ - Username: "fake-username", - Password: "fake-personal-access-token", - }, true, nil - }, - renderManifestsFn: func(render.Request) (render.Response, error) { - return render.Response{}, errors.New("something went wrong") - }, - }, - assertions: func( - t *testing.T, - newFreightIn kargoapi.FreightReference, - newFreightOut kargoapi.FreightReference, - err error, - ) { - require.Error(t, err) - require.Contains( - t, - err.Error(), - "error rendering manifests for git repo", - ) - require.Contains(t, err.Error(), "something went wrong") - require.Equal(t, newFreightIn, newFreightOut) - }, - }, - { - name: "success -- all images -- no action", - freight: kargoapi.FreightReference{ - Commits: []kargoapi.GitCommit{{}}, + newFreight: kargoapi.FreightReference{ Images: []kargoapi.Image{ { RepoURL: "fake-url", Tag: "fake-tag", - }, - { - RepoURL: "second-fake-url", - Tag: "second-fake-tag", - }, - { - RepoURL: "third-fake-url", - Tag: "third-fake-tag", + Digest: "fake-digest", }, }, }, - update: kargoapi.GitRepoUpdate{ - RepoURL: "fake-url", - Render: &kargoapi.KargoRenderPromotionMechanism{}, - }, - promoMech: &kargoRenderMechanism{ - getReadRefFn: func( - kargoapi.GitRepoUpdate, - []kargoapi.GitCommit, - ) (string, int, error) { - return testRef, 0, nil - }, - getCredentialsFn: func( - context.Context, - string, - credentials.Type, - string, - ) (credentials.Credentials, bool, error) { - return credentials.Credentials{ - Username: "fake-username", - Password: "fake-personal-access-token", - }, true, nil - }, - renderManifestsFn: func(req render.Request) (render.Response, error) { - require.Equal( - t, - []string{ - "fake-url:fake-tag", - "second-fake-url:second-fake-tag", - "third-fake-url:third-fake-tag", - }, - req.Images, + renderer: &renderer{ + renderManifestsFn: func(req render.Request) error { + if err := os.MkdirAll(req.LocalOutPath, 0755); err != nil { + return err + } + return os.WriteFile( + filepath.Join(req.LocalOutPath, testRenderedManifestName), + testRenderedManifest, + 0600, ) - return render.Response{ - ActionTaken: render.ActionTakenNone, - CommitID: "fake-commit-id", - }, nil }, }, - assertions: func( - t *testing.T, - newFreightIn kargoapi.FreightReference, - newFreightOut kargoapi.FreightReference, - err error, - ) { + assertions: func(t *testing.T, changeSummary []string, workDir string, err error) { + require.NoError(t, err) + // The work directory should contain the rendered manifest + files, err := os.ReadDir(workDir) + require.NoError(t, err) + require.Len(t, files, 1) + require.Equal(t, testRenderedManifestName, files[0].Name()) + contents, err := os.ReadFile(filepath.Join(workDir, testRenderedManifestName)) require.NoError(t, err) + require.Equal(t, testRenderedManifest, contents) + // Inspect the change summary require.Equal( t, - "fake-commit-id", - newFreightOut.Commits[0].HealthCheckCommit, + []string{ + fmt.Sprintf("rendered manifests from commit %s", testSourceCommitID[:7]), + "updated manifests to use image fake-url:fake-tag", + }, + changeSummary, ) - // The newFreight is otherwise unaltered - newFreightIn.Commits[0].HealthCheckCommit = "" - require.Equal(t, newFreightIn, newFreightOut) }, }, { - name: "success -- some images -- commit", - freight: kargoapi.FreightReference{ - Commits: []kargoapi.GitCommit{{}}, - Images: []kargoapi.Image{ - { - RepoURL: "fake-url", - Tag: "fake-tag", - Digest: "fake-digest", - }, - { - RepoURL: "second-fake-url", - Tag: "second-fake-tag", - Digest: "second-fake-digest", - }, - { - RepoURL: "third-fake-url", - Tag: "third-fake-tag", - Digest: "third-fake-digest", - }, - }, - }, + name: "update specifies images", update: kargoapi.GitRepoUpdate{ - RepoURL: "fake-url", Render: &kargoapi.KargoRenderPromotionMechanism{ Images: []kargoapi.KargoRenderImageUpdate{ { Image: "fake-url", }, { - Image: "second-fake-url", + Image: "another-fake-url", UseDigest: true, }, }, }, }, - promoMech: &kargoRenderMechanism{ - getReadRefFn: func( - kargoapi.GitRepoUpdate, - []kargoapi.GitCommit, - ) (string, int, error) { - return testRef, 0, nil - }, - getCredentialsFn: func( - context.Context, - string, - credentials.Type, - string, - ) (credentials.Credentials, bool, error) { - return credentials.Credentials{ - Username: "fake-username", - Password: "fake-personal-access-token", - }, true, nil + newFreight: kargoapi.FreightReference{ + Images: []kargoapi.Image{ + { + RepoURL: "fake-url", + Tag: "fake-tag", + Digest: "fake-digest", + }, + { + RepoURL: "another-fake-url", + Tag: "another-fake-tag", + Digest: "another-fake-digest", + }, }, - renderManifestsFn: func(req render.Request) (render.Response, error) { - require.Equal( - t, - []string{ - "fake-url:fake-tag", - "second-fake-url@second-fake-digest", - }, - req.Images, + }, + renderer: &renderer{ + renderManifestsFn: func(req render.Request) error { + if err := os.MkdirAll(req.LocalOutPath, 0755); err != nil { + return err + } + return os.WriteFile( + filepath.Join(req.LocalOutPath, testRenderedManifestName), + testRenderedManifest, + 0600, ) - return render.Response{ - ActionTaken: render.ActionTakenPushedDirectly, - CommitID: "fake-commit-id", - }, nil }, }, - assertions: func( - t *testing.T, - newFreightIn kargoapi.FreightReference, - newFreightOut kargoapi.FreightReference, - err error, - ) { + assertions: func(t *testing.T, changeSummary []string, workDir string, err error) { + require.NoError(t, err) + // The work directory should contain the rendered manifest + files, err := os.ReadDir(workDir) + require.NoError(t, err) + require.Len(t, files, 1) + require.Equal(t, testRenderedManifestName, files[0].Name()) + contents, err := os.ReadFile(filepath.Join(workDir, testRenderedManifestName)) require.NoError(t, err) + require.Equal(t, testRenderedManifest, contents) + // Inspect the change summary require.Equal( t, - "fake-commit-id", - newFreightOut.Commits[0].HealthCheckCommit, + []string{ + fmt.Sprintf("rendered manifests from commit %s", testSourceCommitID[:7]), + "updated manifests to use image another-fake-url@another-fake-digest", + "updated manifests to use image fake-url:fake-tag", + }, + changeSummary, ) - // The newFreight is otherwise unaltered - newFreightIn.Commits[0].HealthCheckCommit = "" - require.Equal(t, newFreightIn, newFreightOut) }, }, } for _, testCase := range testCases { + testWorkDir := t.TempDir() t.Run(testCase.name, func(t *testing.T) { - res, err := testCase.promoMech.doSingleUpdate( - context.Background(), - &kargoapi.Promotion{ObjectMeta: metav1.ObjectMeta{Namespace: "fake-namespace"}}, + changes, err := testCase.renderer.apply( testCase.update, - testCase.freight, + testCase.newFreight, + testSourceCommitID, + "", // Home directory is not used by this implementation + testWorkDir, ) - testCase.assertions(t, testCase.freight, res, err) + testCase.assertions(t, changes, testWorkDir, err) }) } } diff --git a/internal/kargo-render/render.go b/internal/kargo-render/render.go index 5992f9701..9dba7129f 100644 --- a/internal/kargo-render/render.go +++ b/internal/kargo-render/render.go @@ -3,10 +3,8 @@ package render import ( "encoding/json" "fmt" - "os" "os/exec" - "github.com/akuity/kargo/internal/controller/git" libExec "github.com/akuity/kargo/internal/exec" ) @@ -15,40 +13,26 @@ import ( type ActionTaken string const ( - // ActionTakenNone represents the case where Kargo Render responded - // to a RenderRequest by, effectively, doing nothing. This occurs in cases - // where the fully rendered manifests that would have been written to the - // target branch do not differ from what is already present at the head of - // that branch. - ActionTakenNone ActionTaken = "NONE" - // ActionTakenOpenedPR represents the case where Kargo Render responded to a - // RenderRequest by opening a new pull request against the target branch. - ActionTakenOpenedPR ActionTaken = "OPENED_PR" - // ActionTakenPushedDirectly represents the case where Kargo Render responded - // to a RenderRequest by pushing a new commit directly to the target branch. - ActionTakenPushedDirectly ActionTaken = "PUSHED_DIRECTLY" - // ActionTakenUpdatedPR represents the case where Kargo Render responded to a - // RenderRequest by updating an existing PR. - ActionTakenUpdatedPR ActionTaken = "UPDATED_PR" + // ActionTakenWroteToLocalPath represents the case where Kargo Render + // responded to a RenderRequest by writing the rendered manifests to a local + // path. + ActionTakenWroteToLocalPath ActionTaken = "WROTE_TO_LOCAL_PATH" ) // Request is a request for Kargo Render to render environment-specific // manifests from input in the default branch of the repository specified by // RepoURL. type Request struct { - // RepoURL is the URL of a remote GitOps repository. - RepoURL string `json:"repoURL,omitempty"` - // RepoCreds encapsulates read/write credentials for the remote GitOps - // repository referenced by the RepoURL field. - RepoCreds git.RepoCredentials `json:"repoCreds,omitempty"` - // Ref specifies either a branch or a precise commit to render manifests from. - // When this is omitted, the request is assumed to be one to render from the - // head of the default branch. - Ref string `json:"ref,omitempty"` // TargetBranch is the name of an environment-specific branch in the GitOps // repository referenced by the RepoURL field into which plain YAML should be // rendered. TargetBranch string `json:"targetBranch,omitempty"` + // LocalInPath specifies a path to the repository's working tree with the + // desired source commit already checked out. + LocalInPath string `json:"localInPath,omitempty"` + // LocalOutPath specifies a path where the rendered manifests should be + // written. The specified path must NOT exist already. + LocalOutPath string `json:"localOutPath,omitempty"` // Images specifies images to incorporate into environment-specific // manifests. Images []string `json:"images,omitempty"` @@ -58,41 +42,21 @@ type Request struct { // environment-specific manifests into an environment-specific branch. type Response struct { ActionTaken ActionTaken `json:"actionTaken,omitempty"` - // CommitID is the ID (sha) of the commit to the environment-specific branch - // containing the rendered manifests. This is only set when the OpenPR field - // of the corresponding RenderRequest was false. - CommitID string `json:"commitID,omitempty"` - // PullRequestURL is a URL for a pull request containing the rendered - // manifests. This is only set when the OpenPR field of the corresponding - // RenderRequest was true. - PullRequestURL string `json:"pullRequestURL,omitempty"` + // LocalPath is the path to the directory where the rendered manifests + // were written. + LocalPath string `json:"localPath,omitempty"` } -// Execute a `kargo-render render` command and return the response. -func RenderManifests(req Request) (Response, error) { // nolint: revive - res := Response{} - resBytes, err := libExec.Exec(buildRenderCmd(req)) - if err != nil { - return res, fmt.Errorf("error rendering manifests: %w", err) - } - if err = json.Unmarshal(resBytes, &res); err != nil { - err = fmt.Errorf("error unmarshalling response: %w", err) - } - return res, err -} - -func buildRenderCmd(req Request) *exec.Cmd { +// Execute a `kargo-render` command and return the response. +func RenderManifests(req Request) error { // nolint: revive cmdTokens := []string{ "kargo-render", - "render", - "--repo", - req.RepoURL, - "--ref", - req.Ref, "--target-branch", req.TargetBranch, - "--repo-username", - req.RepoCreds.Username, + "--local-in-path", + req.LocalInPath, + "--local-out-path", + req.LocalOutPath, "--output", "json", } @@ -100,9 +64,19 @@ func buildRenderCmd(req Request) *exec.Cmd { cmdTokens = append(cmdTokens, "--image", image) } cmd := exec.Command(cmdTokens[0], cmdTokens[1:]...) // nolint: gosec - cmd.Env = append( - os.Environ(), - fmt.Sprintf("KARGO_RENDER_REPO_PASSWORD=%s", req.RepoCreds.Password), - ) - return cmd + + res := Response{} + resBytes, err := libExec.Exec(cmd) + if err != nil { + return fmt.Errorf("error rendering manifests: %w", err) + } + if err = json.Unmarshal(resBytes, &res); err != nil { + return fmt.Errorf("error unmarshaling response: %w", err) + } + + // TODO: Make some assertions about the response. It should have written the + // rendered manifests to a directory. If anything other than that happened, + // something went very wrong. + + return nil }