diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bedab41..d110c3e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -24,6 +24,8 @@ jobs: restore-keys: | ${{ runner.os }}-go- - name: Run unit tests + env: + TEST_GIT_CLIENT_WITH_AUTH: true run: make test-unit - name: Upload coverage reports uses: codecov/codecov-action@v3 diff --git a/Dockerfile b/Dockerfile index bd62b06..8533927 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,4 +59,4 @@ ENV XDG_CONFIG_HOME=/tmp/.config ENV XDG_CACHE_HOME=/tmp/.cache ENV XDG_DATA_HOME=/tmp/.local/share -CMD ["/usr/local/bin/kargo-render"] +ENTRYPOINT ["/usr/local/bin/kargo-render"] diff --git a/branches.go b/branches.go index 78636d7..303260c 100644 --- a/branches.go +++ b/branches.go @@ -84,10 +84,18 @@ func switchToTargetBranch(rc requestContext) error { if targetBranchExists { logger.Debug("target branch exists on remote") + if err = rc.repo.Fetch(); err != nil { + return fmt.Errorf("error fetching from remote: %w", err) + } + logger.Debug("fetched from remote") if err = rc.repo.Checkout(rc.request.TargetBranch); err != nil { return fmt.Errorf("error checking out target branch: %w", err) } logger.Debug("checked out target branch") + if err = rc.repo.Pull(rc.request.TargetBranch); err != nil { + return fmt.Errorf("error pulling from remote: %w", err) + } + logger.Debug("pulled from remote") return nil } diff --git a/cmd/action_cmd.go b/cmd/action_cmd.go index 8df40a2..bfe99a3 100644 --- a/cmd/action_cmd.go +++ b/cmd/action_cmd.go @@ -87,8 +87,8 @@ func (o *actionOptions) run(_ context.Context, out io.Writer) error { return nil } -func request() (render.Request, error) { - req := render.Request{ +func request() (*render.Request, error) { + req := &render.Request{ RepoCreds: render.RepoCredentials{ Username: "git", }, @@ -96,15 +96,15 @@ func request() (render.Request, error) { } repo, err := libOS.GetRequiredEnvVar("GITHUB_REPOSITORY") if err != nil { - return req, err + return nil, err } req.RepoURL = fmt.Sprintf("https://github.com/%s", repo) if req.RepoCreds.Password, err = libOS.GetRequiredEnvVar("INPUT_PERSONALACCESSTOKEN"); err != nil { - return req, err + return nil, err } if req.Ref, err = libOS.GetRequiredEnvVar("GITHUB_SHA"); err != nil { - return req, err + return nil, err } req.TargetBranch, err = libOS.GetRequiredEnvVar("INPUT_TARGETBRANCH") return req, err diff --git a/cmd/action_cmd_test.go b/cmd/action_cmd_test.go index b1b2e9c..fd3a19f 100644 --- a/cmd/action_cmd_test.go +++ b/cmd/action_cmd_test.go @@ -22,7 +22,7 @@ func TestRequest(t *testing.T) { testImage1 = "krancour/foo:blue" testImage2 = "krancour/foo:green" ) - testReq := render.Request{ + testReq := &render.Request{ RepoURL: fmt.Sprintf("https://github.com/%s", testRepo), RepoCreds: render.RepoCredentials{ Username: "git", @@ -35,11 +35,11 @@ func TestRequest(t *testing.T) { testCases := []struct { name string setup func() - assertions func(*testing.T, render.Request, error) + assertions func(*testing.T, *render.Request, error) }{ { name: "GITHUB_REPOSITORY not specified", - assertions: func(t *testing.T, _ render.Request, err error) { + assertions: func(t *testing.T, _ *render.Request, err error) { require.Error(t, err) require.Contains(t, err.Error(), "value not found for") require.Contains(t, err.Error(), "GITHUB_REPOSITORY") @@ -50,7 +50,7 @@ func TestRequest(t *testing.T) { setup: func() { t.Setenv("GITHUB_REPOSITORY", testRepo) }, - assertions: func(t *testing.T, _ render.Request, err error) { + assertions: func(t *testing.T, _ *render.Request, err error) { require.Error(t, err) require.Contains(t, err.Error(), "value not found for") require.Contains(t, err.Error(), "INPUT_PERSONALACCESSTOKEN") @@ -61,7 +61,7 @@ func TestRequest(t *testing.T) { setup: func() { t.Setenv("INPUT_PERSONALACCESSTOKEN", testReq.RepoCreds.Password) }, - assertions: func(t *testing.T, _ render.Request, err error) { + assertions: func(t *testing.T, _ *render.Request, err error) { require.Error(t, err) require.Contains(t, err.Error(), "value not found for") require.Contains(t, err.Error(), "GITHUB_SHA") @@ -72,7 +72,7 @@ func TestRequest(t *testing.T) { setup: func() { t.Setenv("GITHUB_SHA", testReq.Ref) }, - assertions: func(t *testing.T, _ render.Request, err error) { + assertions: func(t *testing.T, _ *render.Request, err error) { require.Error(t, err) require.Contains(t, err.Error(), "value not found for") require.Contains(t, err.Error(), "INPUT_TARGETBRANCH") @@ -86,7 +86,7 @@ func TestRequest(t *testing.T) { "INPUT_IMAGES", fmt.Sprintf("%s,%s", testImage1, testImage2)) }, - assertions: func(t *testing.T, req render.Request, err error) { + assertions: func(t *testing.T, req *render.Request, err error) { require.NoError(t, err) require.Equal(t, testReq, req) }, diff --git a/cmd/flags.go b/cmd/flags.go index 7ab4f55..c458ea4 100644 --- a/cmd/flags.go +++ b/cmd/flags.go @@ -5,6 +5,8 @@ const ( flagCommitMessage = "commit-message" flagDebug = "debug" flagImage = "image" + flagLocalInPath = "local-in-path" + flagLocalOutPath = "local-out-path" flagOutput = "output" flagOutputJSON = "json" flagOutputYAML = "yaml" @@ -12,5 +14,6 @@ const ( flagRepo = "repo" flagRepoPassword = "repo-password" flagRepoUsername = "repo-username" + flagStdout = "stdout" flagTargetBranch = "target-branch" ) diff --git a/cmd/root_cmd.go b/cmd/root_cmd.go index 6cbdd62..b530ef5 100644 --- a/cmd/root_cmd.go +++ b/cmd/root_cmd.go @@ -2,10 +2,10 @@ package main import ( "context" - "errors" "fmt" "io" "os" + "sort" "strings" "github.com/spf13/cobra" @@ -15,14 +15,16 @@ import ( ) type rootOptions struct { - render.Request + *render.Request commitMessage string debug bool outputFormat string } func newRootCommand() *cobra.Command { - cmdOpts := &rootOptions{} + cmdOpts := &rootOptions{ + Request: &render.Request{}, + } cmd := &cobra.Command{ Use: "kargo-render", @@ -36,9 +38,6 @@ func newRootCommand() *cobra.Command { Args: cobra.NoArgs, PreRun: cmdOpts.preRun, RunE: func(cmd *cobra.Command, _ []string) error { - if err := cmdOpts.validate(); err != nil { - return err - } return cmdOpts.run(cmd.Context(), cmd.OutOrStdout()) }, } @@ -88,6 +87,21 @@ func (o *rootOptions) addFlags(cmd *cobra.Command) { "used more than once.", ) + cmd.Flags().StringVar( + &o.LocalInPath, + flagLocalInPath, + "", + "Read input from the specified path instead of the remote gitops repository.", + ) + + cmd.Flags().StringVar( + &o.LocalOutPath, + flagLocalOutPath, + "", + "Write rendered manifests to the specified path instead of the remote "+ + "gitops repository. The path must NOT already exist.", + ) + cmd.Flags().StringVarP( &o.outputFormat, flagOutput, @@ -112,9 +126,6 @@ func (o *rootOptions) addFlags(cmd *cobra.Command) { "", "The URL of a remote gitops repository.", ) - if err := cmd.MarkFlagRequired(flagRepo); err != nil { - panic(fmt.Errorf("could not mark %s flag as required", flagRepo)) - } cmd.Flags().StringVarP( &o.RepoCreds.Password, @@ -125,9 +136,6 @@ func (o *rootOptions) addFlags(cmd *cobra.Command) { "repository. Can alternatively be specified using the "+ "KARGO_RENDER_REPO_PASSWORD environment variable.", ) - if err := cmd.MarkFlagRequired(flagRepoPassword); err != nil { - panic(fmt.Errorf("could not mark %s flag as required", flagRepoPassword)) - } cmd.Flags().StringVarP( &o.RepoCreds.Username, @@ -138,9 +146,13 @@ func (o *rootOptions) addFlags(cmd *cobra.Command) { "Can alternatively be specified using the KARGO_RENDER_REPO_USERNAME "+ "environment variable.", ) - if err := cmd.MarkFlagRequired(flagRepoUsername); err != nil { - panic(fmt.Errorf("could not mark %s flag as required", flagRepoUsername)) - } + + cmd.Flags().BoolVar( + &o.Stdout, + flagStdout, + false, + "Write rendered manifests to stdout instead of the remote gitops repo.", + ) cmd.Flags().StringVarP( &o.TargetBranch, @@ -152,28 +164,15 @@ func (o *rootOptions) addFlags(cmd *cobra.Command) { if err := cmd.MarkFlagRequired(flagTargetBranch); err != nil { panic(fmt.Errorf("could not mark %s flag as required", flagTargetBranch)) } -} -// validate performs validation of the options. If the options are invalid, an -// error is returned. -func (o *rootOptions) validate() error { - var errs []error - // While these flags are marked as required, a user could still provide an - // empty string for any of them. This is a check to ensure that required flags - // are not empty. - if o.RepoURL == "" { - errs = append(errs, fmt.Errorf("the --%s flag is required", flagRepo)) - } - if o.RepoCreds.Password == "" { - errs = append(errs, fmt.Errorf("the --%s flag is required", flagRepoPassword)) - } - if o.RepoCreds.Username == "" { - errs = append(errs, fmt.Errorf("the --%s flag is required", flagRepoUsername)) - } - if o.TargetBranch == "" { - errs = append(errs, fmt.Errorf("the --%s flag is required", flagTargetBranch)) - } - return errors.Join(errs...) + // Make sure input source is specified and unambiguous. + cmd.MarkFlagsOneRequired(flagRepo, flagLocalInPath) + cmd.MarkFlagsMutuallyExclusive(flagRepo, flagLocalInPath) + // And the ref flag cannot be combined with the local input path.. + cmd.MarkFlagsMutuallyExclusive(flagRef, flagLocalInPath) + + // Make sure output destination is unambiguous. + cmd.MarkFlagsMutuallyExclusive(flagCommitMessage, flagLocalOutPath, flagStdout) } func (o *rootOptions) preRun(cmd *cobra.Command, _ []string) { @@ -224,6 +223,9 @@ func (o *rootOptions) run(ctx context.Context, out io.Writer) error { if o.outputFormat == "" { switch res.ActionTaken { case render.ActionTakenNone: + if o.Stdout { + return manifestsToStdout(res.Manifests, out) + } fmt.Fprintln( out, "\nThis request would not change any state. No action was taken.", @@ -242,7 +244,17 @@ func (o *rootOptions) run(ctx context.Context, out io.Writer) error { o.TargetBranch, ) case render.ActionTakenUpdatedPR: - + fmt.Fprintf( + out, + "\nUpdated PR %s\n", + res.PullRequestURL, + ) + case render.ActionTakenWroteToLocalPath: + fmt.Fprintf( + out, + "\nWrote rendered manifests to %s\n", + o.LocalOutPath, + ) } } else { if err := output(res, out, o.outputFormat); err != nil { @@ -252,3 +264,19 @@ func (o *rootOptions) run(ctx context.Context, out io.Writer) error { return nil } + +func manifestsToStdout(manifests map[string][]byte, out io.Writer) error { + apps := make([]string, 0, len(manifests)) + for k := range manifests { + apps = append(apps, k) + } + sort.StringSlice(apps).Sort() + for _, app := range apps { + const sep = "--------------------------------------------------" + fmt.Fprintln(out, sep) + fmt.Fprintf(out, "App: %s\n", app) + fmt.Fprintln(out, sep) + fmt.Fprintln(out, string(manifests[app])) + } + return nil +} diff --git a/context.go b/context.go index 1833ee3..9846742 100644 --- a/context.go +++ b/context.go @@ -8,7 +8,7 @@ import ( type requestContext struct { logger *log.Entry - request Request + request *Request repo git.Repo source sourceContext intermediate intermediateContext diff --git a/docs/docs/30-how-to-guides/30-docker-image.md b/docs/docs/30-how-to-guides/30-docker-image.md index 2cc7295..f735f76 100644 --- a/docs/docs/30-how-to-guides/30-docker-image.md +++ b/docs/docs/30-how-to-guides/30-docker-image.md @@ -17,8 +17,7 @@ easiest option for experimenting locally with Kargo Render! Example usage: ```shell -docker run -it ghcr.io/akuity/kargo-render:v0.1.0-rc.34 \ - kargo-render render \ +docker run -it ghcr.io/akuity/kargo-render:v0.1.0-rc.36 \ --repo https://github.com//kargo-render-demo-deploy \ --repo-username \ --repo-password \ diff --git a/go.mod b/go.mod index 19c696c..10060e8 100644 --- a/go.mod +++ b/go.mod @@ -61,6 +61,7 @@ require k8s.io/apiserver v0.24.17 // indirect require ( github.com/argoproj/argo-cd/v2 v2.9.9 github.com/google/go-github/v47 v47.1.0 + github.com/sosedoff/gitkit v0.4.0 github.com/xeipuuv/gojsonschema v1.2.0 ) @@ -95,6 +96,7 @@ require ( github.com/go-openapi/jsonreference v0.20.0 // indirect github.com/go-openapi/swag v0.22.3 // indirect github.com/go-redis/cache/v9 v9.0.0 // indirect + github.com/gofrs/uuid v4.0.0+incompatible // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/btree v1.1.2 // indirect github.com/google/gnostic v0.6.9 // indirect diff --git a/go.sum b/go.sum index 63c053a..805a17b 100644 --- a/go.sum +++ b/go.sum @@ -265,6 +265,8 @@ github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg78 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= +github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.3.1/go.mod h1:SlYgWuQ5SjCEi6WLHjHCa1yvBfUnHcTbrrZtXPKa29o= @@ -621,6 +623,8 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/sosedoff/gitkit v0.4.0 h1:opyQJ/h9xMRLsz2ca/2CRXtstePcpldiZN8DpLLF8Os= +github.com/sosedoff/gitkit v0.4.0/go.mod h1:V3EpGZ0nvCBhXerPsbDeqtyReNb48cwP9KtkUYTKT5I= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= @@ -749,6 +753,7 @@ golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= diff --git a/pkg/git/git.go b/pkg/git/git.go index b010cdf..fb963ab 100644 --- a/pkg/git/git.go +++ b/pkg/git/git.go @@ -13,6 +13,8 @@ import ( libExec "github.com/akuity/kargo-render/internal/exec" ) +const RemoteOrigin = "origin" + // RepoCredentials represents the credentials for connecting to a private git // repository. type RepoCredentials struct { @@ -68,11 +70,20 @@ type Repo interface { // CommitMessages returns a slice of commit messages starting with id1 and // ending with id2. The results exclude id1, but include id2. CommitMessages(id1, id2 string) ([]string, error) + // Fetch fetches from the remote repository. + Fetch() error + // Pull fetches from the remote repository and merges the changes into the + // current branch. + Pull(branch string) error // Push pushes from the current branch to a remote branch by the same name. Push() error // RemoteBranchExists returns a bool indicating if the specified branch exists // in the remote repository. RemoteBranchExists(branch string) (bool, error) + // Remotes returns a slice of strings representing the names of the remotes. + Remotes() ([]string, error) + // RemoteURL returns the URL of the the specified remote. + RemoteURL(name string) (string, error) // ResetHard performs a hard reset. ResetHard() error // URL returns the remote URL of the repository. @@ -121,6 +132,79 @@ func Clone( return r, r.clone() } +// CopyRepo copies a git repository from the specified path to a temporary +// location. Repository credentials are required in order to authenticate to the +// remote repository, if any. +func CopyRepo(path string, repoCreds RepoCredentials) (Repo, error) { + // Validate path is absolute + if !filepath.IsAbs(path) { + return nil, fmt.Errorf("path %s is not absolute", path) + } + + // Validate path exists and is a directory + if fi, err := os.Stat(path); err != nil { + return nil, fmt.Errorf("error checking if path %s exists: %w", path, err) + } else if !fi.IsDir() { + return nil, fmt.Errorf("path %s is not a directory", path) + } + + // Validate path is a git repository + cmd := exec.Command("git", "rev-parse", "--is-inside-work-tree") + cmd.Dir = path + if _, err := libExec.Exec(cmd); err != nil { + return nil, fmt.Errorf("path %s is not a git repository: %w", path, err) + } + + homeDir, err := os.MkdirTemp("", "") + if err != nil { + return nil, fmt.Errorf( + "error creating directory for copy of repo at %s: %w", + path, + err, + ) + } + + r := &repo{ + homeDir: homeDir, + dir: filepath.Join(homeDir, "repo"), + } + + // Copy from path to r.dir. Note: This obviously only works on *nix systems, + // but we already advise that Kargo Render not be run outside of a Linux + // container since its dependent on compatible versions of git, helm, and + // kustomize binaries. + // nolint: gosec + if _, err = libExec.Exec(exec.Command("cp", "-r", path, r.dir)); err != nil { + return nil, fmt.Errorf( + "error copying repo from %s to %s: %w", + path, + r.dir, + err, + ) + } + + remotes, err := r.Remotes() + if err != nil { + return nil, err + } + if len(remotes) != 1 { + return nil, fmt.Errorf( + "expected exactly one remote in source repository; found %d", + len(remotes), + ) + } + r.url, err = r.RemoteURL(remotes[0]) + if err != nil { + return nil, err + } + + if err = r.setupAuth(repoCreds); err != nil { + return nil, err + } + + return r, nil +} + func (r *repo) AddAll() error { if _, err := libExec.Exec(r.buildCommand("add", ".")); err != nil { return fmt.Errorf("error staging changes for commit: %w", err) @@ -234,7 +318,7 @@ func (r *repo) CreateOrphanedBranch(branch string) error { func (r *repo) HasDiffs() (bool, error) { resBytes, err := libExec.Exec(r.buildCommand("status", "-s")) - if err == nil { + if err != nil { return false, fmt.Errorf("error checking status of branch %q: %w", r.currentBranch, err) } @@ -308,9 +392,29 @@ func (r *repo) CommitMessages(id1, id2 string) ([]string, error) { return msgs, nil } +func (r *repo) Fetch() error { + if _, err := libExec.Exec(r.buildCommand("fetch", RemoteOrigin)); err != nil { + return fmt.Errorf("error fetching from remote repo %q: %w", r.url, err) + } + return nil +} + +func (r *repo) Pull(branch string) error { + if _, err := + libExec.Exec(r.buildCommand("pull", RemoteOrigin, branch)); err != nil { + return fmt.Errorf( + "error pulling branch %q from remote repo %q: %w", + branch, + r.url, + err, + ) + } + return nil +} + func (r *repo) Push() error { if _, err := - libExec.Exec(r.buildCommand("push", "origin", r.currentBranch)); err != nil { + libExec.Exec(r.buildCommand("push", RemoteOrigin, r.currentBranch)); err != nil { return fmt.Errorf("error pushing branch %q: %w", r.currentBranch, err) } return nil @@ -321,7 +425,7 @@ func (r *repo) RemoteBranchExists(branch string) (bool, error) { "ls-remote", "--heads", "--exit-code", // Return 2 if not found - r.url, + RemoteOrigin, branch, )); err != nil { if exitErr, ok := err.(*libExec.ExitError); ok && exitErr.ExitCode == 2 { @@ -338,6 +442,27 @@ func (r *repo) RemoteBranchExists(branch string) (bool, error) { return true, nil } +func (r *repo) Remotes() ([]string, error) { + resBytes, err := libExec.Exec(r.buildCommand("remote")) + if err != nil { + return nil, fmt.Errorf("error listing remotes for repo %q: %w", r.url, err) + } + return strings.Fields(string(resBytes)), nil +} + +func (r *repo) RemoteURL(name string) (string, error) { + resBytes, err := libExec.Exec(r.buildCommand("remote", "get-url", name)) + if err != nil { + return "", fmt.Errorf( + "error obtaining URL for remote %q of repo %q: %w", + name, + r.url, + err, + ) + } + return strings.TrimSpace(string(resBytes)), nil +} + func (r *repo) ResetHard() error { if _, err := libExec.Exec(r.buildCommand("reset", "--hard")); err != nil { @@ -395,7 +520,10 @@ func (r *repo) setupAuth(repoCreds RepoCredentials) error { return nil // We're done } - // If we get to here, we're authenticating using a password + // If no password is specified, we don't need to authenticate at all. + if repoCreds.Password == "" { + return nil + } // Set up the credential helper cmd = r.buildCommand("config", "--global", "credential.helper", "store") diff --git a/pkg/git/git_test.go b/pkg/git/git_test.go new file mode 100644 index 0000000..7986f38 --- /dev/null +++ b/pkg/git/git_test.go @@ -0,0 +1,221 @@ +package git + +import ( + "fmt" + "net/http/httptest" + "os" + "testing" + + "github.com/google/uuid" + "github.com/sosedoff/gitkit" + "github.com/stretchr/testify/require" + + libOS "github.com/akuity/kargo-render/internal/os" +) + +func TestRepo(t *testing.T) { + testRepoCreds := RepoCredentials{ + Username: "fake-username", + Password: "fake-password", + } + + // This will be something to opt into because on some OSes, this will lead + // to keychain-related prompts. + useAuth, err := libOS.GetBoolFromEnvVar("TEST_GIT_CLIENT_WITH_AUTH", false) + require.NoError(t, err) + service := gitkit.New( + gitkit.Config{ + Dir: t.TempDir(), + AutoCreate: true, + Auth: useAuth, + }, + ) + require.NoError(t, service.Setup()) + service.AuthFunc = + func(cred gitkit.Credential, _ *gitkit.Request) (bool, error) { + return cred.Username == testRepoCreds.Username && + cred.Password == testRepoCreds.Password, nil + } + server := httptest.NewServer(service) + defer server.Close() + + testRepoURL := fmt.Sprintf("%s/test.git", server.URL) + + rep, err := Clone(testRepoURL, testRepoCreds) + require.NoError(t, err) + require.NotNil(t, rep) + r, ok := rep.(*repo) + require.True(t, ok) + + t.Run("can clone", func(t *testing.T) { + require.Equal(t, testRepoURL, r.url) + require.NotEmpty(t, r.homeDir) + var fi os.FileInfo + fi, err = os.Stat(r.homeDir) + require.NoError(t, err) + require.True(t, fi.IsDir()) + require.NotEmpty(t, r.dir) + fi, err = os.Stat(r.dir) + require.NoError(t, err) + require.True(t, fi.IsDir()) + require.Equal(t, "HEAD", r.currentBranch) + }) + + t.Run("can get the repo url", func(t *testing.T) { + require.Equal(t, r.url, r.URL()) + }) + + t.Run("can get the home dir", func(t *testing.T) { + require.Equal(t, r.homeDir, r.HomeDir()) + }) + + t.Run("can get the working dir", func(t *testing.T) { + require.Equal(t, r.dir, r.WorkingDir()) + }) + + t.Run("can list remotes", func(t *testing.T) { + var remotes []string + remotes, err = r.Remotes() + require.NoError(t, err) + require.Len(t, remotes, 1) + require.Equal(t, RemoteOrigin, remotes[0]) + }) + + t.Run("can get url of a remote", func(t *testing.T) { + var url string + url, err = r.RemoteURL(RemoteOrigin) + require.NoError(t, err) + require.Equal(t, r.url, url) + }) + + t.Run("can check for diffs -- negative result", func(t *testing.T) { + var hasDiffs bool + hasDiffs, err = r.HasDiffs() + require.NoError(t, err) + require.False(t, hasDiffs) + }) + + err = os.WriteFile(fmt.Sprintf("%s/%s", r.WorkingDir(), "test.txt"), []byte("foo"), 0600) + require.NoError(t, err) + + t.Run("can check for diffs -- positive result", func(t *testing.T) { + var hasDiffs bool + hasDiffs, err = r.HasDiffs() + require.NoError(t, err) + require.True(t, hasDiffs) + }) + + t.Run("can get diff paths", func(t *testing.T) { + var paths []string + paths, err = r.GetDiffPaths() + require.NoError(t, err) + require.Len(t, paths, 1) + }) + + testCommitMessage := fmt.Sprintf("test commit %s", uuid.NewString()) + err = r.AddAllAndCommit(testCommitMessage) + require.NoError(t, err) + + t.Run("can commit", func(t *testing.T) { + require.NoError(t, err) + }) + + lastCommitID, err := r.LastCommitID() + require.NoError(t, err) + + t.Run("can get last commit id", func(t *testing.T) { + require.NoError(t, err) + require.NotEmpty(t, lastCommitID) + }) + + t.Run("can get commit message by id", func(t *testing.T) { + var msg string + msg, err = r.CommitMessage(lastCommitID) + require.NoError(t, err) + require.Equal(t, testCommitMessage, msg) + }) + + t.Run("can check if remote branch exists -- negative result", func(t *testing.T) { + var exists bool + exists, err = r.RemoteBranchExists("main") // The remote repo is empty! + require.NoError(t, err) + require.False(t, exists) + }) + + err = r.Push() + require.NoError(t, err) + + t.Run("can push", func(t *testing.T) { + require.NoError(t, err) + }) + + t.Run("can check if remote branch exists -- positive result", func(t *testing.T) { + var exists bool + // "master" is still the default branch name for a new repository unless + // you configure it otherwise. + exists, err = r.RemoteBranchExists("master") + require.NoError(t, err) + require.True(t, exists) + }) + + t.Run("can fetch", func(t *testing.T) { + err = r.Fetch() + require.NoError(t, err) + }) + + t.Run("can pull", func(t *testing.T) { + err = r.Pull("master") + require.NoError(t, err) + }) + + t.Run("can create a child branch", func(t *testing.T) { + testBranch := fmt.Sprintf("test-branch-%s", uuid.NewString()) + err = r.CreateChildBranch(testBranch) + require.NoError(t, err) + }) + + err = os.WriteFile(fmt.Sprintf("%s/%s", r.WorkingDir(), "test.txt"), []byte("bar"), 0600) + require.NoError(t, err) + + t.Run("can hard reset", func(t *testing.T) { + var hasDiffs bool + hasDiffs, err = r.HasDiffs() + require.NoError(t, err) + require.True(t, hasDiffs) + err = r.ResetHard() + require.NoError(t, err) + hasDiffs, err = r.HasDiffs() + require.NoError(t, err) + require.False(t, hasDiffs) + }) + + t.Run("can create an orphaned branch", func(t *testing.T) { + testBranch := fmt.Sprintf("test-branch-%s", uuid.NewString()) + err = r.CreateOrphanedBranch(testBranch) + require.NoError(t, err) + }) + + t.Run("can copy an existing repo", func(t *testing.T) { + newRepo, err := CopyRepo(r.WorkingDir(), testRepoCreds) + require.NoError(t, err) + defer newRepo.Close() + require.NotNil(t, newRepo) + require.Equal(t, r.URL(), r.URL()) + require.NotEqual(t, r.HomeDir(), newRepo.HomeDir()) + fi, err := os.Stat(newRepo.HomeDir()) + require.NoError(t, err) + require.True(t, fi.IsDir()) + require.NotEqual(t, r.WorkingDir(), newRepo.WorkingDir()) + fi, err = os.Stat(newRepo.WorkingDir()) + require.NoError(t, err) + require.True(t, fi.IsDir()) + }) + + t.Run("can close repo", func(t *testing.T) { + require.NoError(t, r.Close()) + _, err := os.Stat(r.HomeDir()) + require.Error(t, err) + require.True(t, os.IsNotExist(err)) + }) + +} diff --git a/service.go b/service.go index b69979d..74676b5 100644 --- a/service.go +++ b/service.go @@ -2,6 +2,7 @@ package render import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -22,7 +23,7 @@ type ServiceOptions struct { // Implementations of this interface are transport-agnostic. type Service interface { // RenderManifests handles a rendering request. - RenderManifests(context.Context, Request) (Response, error) + RenderManifests(context.Context, *Request) (Response, error) } type service struct { @@ -54,7 +55,7 @@ func NewService(opts *ServiceOptions) Service { // nolint: gocyclo func (s *service) RenderManifests( ctx context.Context, - req Request, + req *Request, ) (Response, error) { req.id = uuid.NewString() @@ -69,7 +70,7 @@ func (s *service) RenderManifests( res := Response{} var err error - if req, err = validateAndCanonicalizeRequest(req); err != nil { + if err = req.canonicalizeAndValidate(); err != nil { return res, err } startEndLogger.Debug("validated rendering request") @@ -79,20 +80,63 @@ func (s *service) RenderManifests( request: req, } - if rc.repo, err = git.Clone( - rc.request.RepoURL, - git.RepoCredentials{ - SSHPrivateKey: rc.request.RepoCreds.SSHPrivateKey, - Username: rc.request.RepoCreds.Username, - Password: rc.request.RepoCreds.Password, - }, - ); err != nil { - return res, fmt.Errorf("error cloning remote repository: %w", err) + if rc.request.LocalInPath != "" { + + // We'll be taking our input from a local directory which is presumably + // a git repository with the desired source commit already checked out. + // + // This is mainly useful when Kargo proper wishes to handle the reading and + // writing to/from remote repositories itself, leaving Kargo Render to + // handle rendering only. + + if rc.repo, err = git.CopyRepo( + rc.request.LocalInPath, + git.RepoCredentials(rc.request.RepoCreds), + ); err != nil { + return res, fmt.Errorf("error copying local repository: %w", err) + } + // Check if the working tree is dirty + var isDirty bool + if isDirty, err = rc.repo.HasDiffs(); err != nil { + return res, fmt.Errorf("error checking for diffs: %w", err) + } + if isDirty { + return res, errors.New("working tree is dirty; refusing to proceed") + } + // Check that there is exactly one remote and it's named "origin" + var remotes []string + if remotes, err = rc.repo.Remotes(); err != nil { + return res, fmt.Errorf("error getting remotes: %w", err) + } + if len(remotes) != 1 || remotes[0] != git.RemoteOrigin { + return res, errors.New( + "local repository must have exactly one remote, which must be " + + "named \"origin\"; refusing to proceed", + ) + } + + } else { + + // Clone the remote repository ourselves + + if rc.repo, err = git.Clone( + rc.request.RepoURL, + git.RepoCredentials{ + SSHPrivateKey: rc.request.RepoCreds.SSHPrivateKey, + Username: rc.request.RepoCreds.Username, + Password: rc.request.RepoCreds.Password, + }, + ); err != nil { + return res, fmt.Errorf("error cloning remote repository: %w", err) + } + } defer rc.repo.Close() // TODO: Add some logging to this block - if rc.request.Ref == "" { + if rc.request.LocalInPath != "" || rc.request.Ref == "" { + // For either of these mutually exclusive cases, we don't know the source + // commit yet if rc.source.commit, err = rc.repo.LastCommitID(); err != nil { return res, fmt.Errorf("error getting last commit ID: %w", err) } @@ -192,22 +236,61 @@ func (s *service) RenderManifests( return res, fmt.Errorf("error in last-mile manifest rendering: %w", err) } + // If we're writing to stdout, we're done + if rc.request.Stdout { + res.ActionTaken = ActionTakenNone + res.Manifests = rc.target.renderedManifests + return res, nil + } + + // Figure out where we're writing to + outputDir := rc.repo.WorkingDir() + if rc.request.LocalOutPath != "" { + outputDir = rc.request.LocalOutPath + // Create a directory for the output + if err = os.MkdirAll(outputDir, 0755); err != nil { + return res, fmt.Errorf( + "error creating local output directory %q: %w", + outputDir, + err, + ) + } + defer func() { + if err != nil { + if rmErr := os.RemoveAll(outputDir); rmErr != nil { + logger.WithError(err).Error( + "error cleaning up local output directory", + ) + } + } + }() + } + // Write branch metadata if err = writeBranchMetadata( rc.target.newBranchMetadata, - rc.repo.WorkingDir(), + outputDir, ); err != nil { return res, fmt.Errorf("error writing branch metadata: %w", err) } logger.WithField("sourceCommit", rc.source.commit). Debug("wrote branch metadata") - // Write the new fully-rendered manifests to the root of the repo - if err = writeAllManifests(rc); err != nil { + // Write the fully-rendered manifests to the root of the repo + if err = writeAllManifests(rc, outputDir); err != nil { return res, err } logger.Debug("wrote all manifests") + // If we're writing to a local directory, we're done + if rc.request.LocalOutPath != "" { + res.ActionTaken = ActionTakenWroteToLocalPath + res.LocalPath = outputDir + return res, nil + } + + // If we get to here, we're writing to the remote repository + // Before committing, check if we actually have any diffs from the head of // this branch that are NOT just Kargo Render metadata. We'd have an error if // we tried to commit with no diffs! @@ -363,30 +446,30 @@ func buildCommitMessage(rc requestContext) (string, error) { return formattedCommitMsg, nil } -func writeAllManifests(rc requestContext) error { +func writeAllManifests(rc requestContext, outputDir string) error { for appName, appConfig := range rc.target.branchConfig.AppConfigs { appLogger := rc.logger.WithField("app", appName) - var outputDir string + var appOutputDir string if appConfig.OutputPath != "" { - outputDir = filepath.Join(rc.repo.WorkingDir(), appConfig.OutputPath) + appOutputDir = filepath.Join(outputDir, appConfig.OutputPath) } else { - outputDir = filepath.Join(rc.repo.WorkingDir(), appName) + appOutputDir = filepath.Join(outputDir, appName) } var err error if appConfig.CombineManifests { appLogger.Debug("manifests will be combined into a single file") err = - writeCombinedManifests(outputDir, rc.target.renderedManifests[appName]) + writeCombinedManifests(appOutputDir, rc.target.renderedManifests[appName]) } else { appLogger.Debug("manifests will NOT be combined into a single file") - err = writeManifests(outputDir, rc.target.renderedManifests[appName]) + err = writeManifests(appOutputDir, rc.target.renderedManifests[appName]) } appLogger.Debug("wrote manifests") if err != nil { return fmt.Errorf( "error writing manifests for app %q to %q: %w", appName, - outputDir, + appOutputDir, err, ) } diff --git a/types.go b/types.go index 07824a5..49367ca 100644 --- a/types.go +++ b/types.go @@ -20,6 +20,10 @@ const ( // 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 @@ -27,7 +31,8 @@ const ( // RepoURL. type Request struct { id string - // RepoURL is the URL of a remote GitOps repository. + // RepoURL is the URL of a remote GitOps repository. This field is mutually + // exclusive with the LocalInPath field. RepoURL string `json:"repoURL,omitempty"` // RepoCreds encapsulates read/write credentials for the remote GitOps // repository referenced by the RepoURL field. @@ -52,6 +57,20 @@ type Request struct { // against scenarios where a bug of any kind might otherwise cause Kargo // Render to wipe out the contents of the target branch in error. AllowEmpty bool `json:"allowEmpty,omitempty"` + // LocalInPath specifies a path to the repository's working tree with the + // desired source commit already checked out. The contents at this path will + // not be modified. This field is mutually exclusive with the Ref field. + LocalInPath string `json:"localInPath,omitempty"` + // LocalOutPath specifies a path where the rendered manifests should be + // written. The specified path must NOT exist already. When specified, the + // rendered manifests will not be written to the target branch of the + // repository specified by the RepoURL field. This field is mutually exclusive + // with the Stdout field. + LocalOutPath string `json:"localOutPath,omitempty"` + // Stdout specifies whether rendered manifests should be written to stdout + // instead of to the target branch of the repository specified by the RepoURL + // field. This field is mutually exclusive with the LocalOutPath field. + Stdout bool `json:"stdout,omitempty"` } // RepoCredentials represents the credentials for connecting to a private git @@ -82,4 +101,11 @@ type Response struct { // 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. This is only set when the LocalOutPath field of the + // corresponding RenderRequest was non-empty. + LocalPath string `json:"localPath,omitempty"` + // Manifests is the rendered environment-specific manifests. This is only set + // when the Stdout field of the corresponding RenderRequest was true. + Manifests map[string][]byte `json:"manifests,omitempty"` } diff --git a/validation.go b/validation.go index 084f7a8..8417843 100644 --- a/validation.go +++ b/validation.go @@ -3,64 +3,163 @@ package render import ( "errors" "fmt" + "os" + "path/filepath" "regexp" "strings" ) -func validateAndCanonicalizeRequest(req Request) (Request, error) { - req.RepoURL = strings.TrimSpace(req.RepoURL) - if req.RepoURL == "" { - return req, errors.New("validation failed: RepoURL is a required field") - } - repoURLRegex := - regexp.MustCompile(`^(?:(?:(?:https?://)|(?:git@))[\w:/\-\.\?=@&%]+)$`) - if !repoURLRegex.MatchString(req.RepoURL) { - return req, fmt.Errorf( - "validation failed: RepoURL %q does not appear to be a valid git "+ - "repository URL", - req.RepoURL, +var ( + repoURLRegex = regexp.MustCompile(`^(?:(?:(?:https?://)|(?:git@))[\w:/\-\.\?=@&%]+)$`) + targetBranchRegex = regexp.MustCompile(`^(?:[\w\.-]+\/?)*\w$`) +) + +func (r *Request) canonicalizeAndValidate() error { + var errs []error + + // First, canonicalize the input... + + r.RepoURL = strings.TrimSpace(r.RepoURL) + r.RepoCreds.Username = strings.TrimSpace(r.RepoCreds.Username) + r.RepoCreds.Password = strings.TrimSpace(r.RepoCreds.Password) + r.Ref = strings.TrimSpace(r.Ref) + r.TargetBranch = strings.TrimSpace(r.TargetBranch) + r.TargetBranch = strings.TrimPrefix(r.TargetBranch, "refs/heads/") + for i := range r.Images { + r.Images[i] = strings.TrimSpace(r.Images[i]) + } + r.CommitMessage = strings.TrimSpace(r.CommitMessage) + r.LocalInPath = strings.TrimSpace(r.LocalInPath) + if r.LocalInPath != "" { + r.LocalInPath = strings.TrimSuffix(r.LocalInPath, "/") + var err error + if r.LocalInPath, err = filepath.Abs(r.LocalInPath); err != nil { + errs = append( + errs, + fmt.Errorf("error canonicalizing path %s: %w", r.LocalInPath, err), + ) + } + } + + r.LocalOutPath = strings.TrimSpace(r.LocalOutPath) + if r.LocalOutPath != "" { + r.LocalOutPath = strings.TrimSuffix(r.LocalOutPath, "/") + var err error + if r.LocalOutPath, err = filepath.Abs(r.LocalOutPath); err != nil { + errs = append( + errs, + fmt.Errorf("error canonicalizing path %s: %w", r.LocalOutPath, err), + ) + } + } + + // Check for invalid combinations of input... + + // Input comes from the remote repository or from a local path, but not both. + if r.RepoURL == "" && r.LocalInPath == "" { + errs = append( + errs, + errors.New( + "no input source specified: at least one of RepoURL or LocalInPath is required ", + ), + ) + } + if r.RepoURL != "" && r.LocalInPath != "" { + errs = append( + errs, + errors.New( + "input source is ambiguous: RepoURL and LocalInPath are mutually exclusive", + ), ) } + if r.LocalInPath != "" && r.Ref != "" { + errs = append(errs, errors.New("LocalInPath and Ref are mutually exclusive")) + } - // TODO: Should this be required? I think some git providers don't require - // this if the password is a bearer token -- e.g. such as in the case of a - // GitHub personal access token. - req.RepoCreds.Username = strings.TrimSpace(req.RepoCreds.Username) - req.RepoCreds.Password = strings.TrimSpace(req.RepoCreds.Password) - if req.RepoCreds.Password == "" { - return req, errors.New( - "validation failed: RepoCreds.Password is a required field", + var count int + if r.CommitMessage != "" { + count++ + } + if r.LocalOutPath != "" { + count++ + } + if r.Stdout { + count++ + } + if count > 1 { + errs = append( + errs, + errors.New( + "output destination is ambiguous: CommitMessage, LocalOutPath, and "+ + "Stdout are mutually exclusive", + ), ) } - req.Ref = strings.TrimSpace(req.Ref) + // Now validate individual fields... + + if r.RepoURL != "" && !repoURLRegex.MatchString(r.RepoURL) { + errs = append( + errs, + fmt.Errorf( + "RepoURL %q does not appear to be a valid git repository URL", + r.RepoURL, + ), + ) + } - req.TargetBranch = strings.TrimSpace(req.TargetBranch) - if req.TargetBranch == "" { - return req, - errors.New("validation failed: TargetBranch is a required field") + if r.TargetBranch == "" { + errs = append(errs, errors.New("TargetBranch is a required field")) } - targetBranchRegex := regexp.MustCompile(`^(?:[\w\.-]+\/?)*\w$`) - if !targetBranchRegex.MatchString(req.TargetBranch) { - return req, fmt.Errorf( - "validation failed: TargetBranch %q is an invalid branch name", - req.TargetBranch, + if !targetBranchRegex.MatchString(r.TargetBranch) { + errs = append( + errs, + fmt.Errorf("TargetBranch %q is an invalid branch name", r.TargetBranch), ) } - req.TargetBranch = strings.TrimPrefix(req.TargetBranch, "refs/heads/") - if len(req.Images) > 0 { - for i := range req.Images { - req.Images[i] = strings.TrimSpace(req.Images[i]) - if req.Images[i] == "" { - return req, errors.New( - "validation failed: Images must not contain any empty strings", + if len(r.Images) > 0 { + for i := range r.Images { + r.Images[i] = strings.TrimSpace(r.Images[i]) + if r.Images[i] == "" { + errs = append(errs, errors.New("Images must not contain any empty strings")) + break + } + } + } + + if r.LocalInPath != "" { + if fi, err := os.Stat(r.LocalInPath); err != nil { + if os.IsNotExist(err) { + errs = append( + errs, + fmt.Errorf("path %s does not exist", r.LocalInPath), + ) + } else { + errs = append( + errs, + fmt.Errorf("error checking if path %s exists: %w", r.LocalInPath, err), ) } + } else if !fi.IsDir() { + errs = append(errs, fmt.Errorf("path %s is not a directory", r.LocalInPath)) } } - req.CommitMessage = strings.TrimSpace(req.CommitMessage) + if r.LocalOutPath != "" { + if _, err := os.Stat(r.LocalOutPath); err != nil && !os.IsNotExist(err) { + errs = append( + errs, + fmt.Errorf("error checking if path %s exists: %w", r.LocalOutPath, err), + ) + } else if err == nil { + // path exists + errs = append( + errs, + fmt.Errorf("path %q already exists; refusing to overwrite", r.LocalOutPath), + ) + } + } - return req, nil + return errors.Join(errs...) } diff --git a/validation_test.go b/validation_test.go index bba7b1b..3af9d1e 100644 --- a/validation_test.go +++ b/validation_test.go @@ -13,38 +13,61 @@ func TestValidateAndCanonicalizeRequest(t *testing.T) { assertions func(*testing.T, Request, error) }{ { - name: "missing RepoURL", + name: "no input source specified", req: Request{}, assertions: func(t *testing.T, _ Request, err error) { require.Error(t, err) - require.Contains(t, err.Error(), "RepoURL is a required field") + require.Contains(t, err.Error(), "no input source specified") }, }, { - name: "invalid RepoURL", + name: "input source is ambiguous", req: Request{ - RepoURL: "foobar", + RepoURL: "fake-url", + LocalInPath: "/some/path", + }, + assertions: func(t *testing.T, _ Request, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "input source is ambiguous") + }, + }, + { + name: "local input path and git ref incorrectly used together", + req: Request{ + LocalInPath: "/some/path", + Ref: "1abcdef2", }, assertions: func(t *testing.T, _ Request, err error) { require.Error(t, err) require.Contains( t, err.Error(), - "does not appear to be a valid git repository URL", + "LocalInPath and Ref are mutually exclusive", ) }, }, { - name: "missing Password", + name: "output destination is ambiguous", req: Request{ - RepoURL: "https://github.com/akuity/foobar", + LocalOutPath: "/some/path", + Stdout: true, + }, + assertions: func(t *testing.T, _ Request, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "output destination is ambiguous") + }, + }, + { + name: "invalid RepoURL", + req: Request{ + RepoURL: "fake-url", }, assertions: func(t *testing.T, _ Request, err error) { require.Error(t, err) require.Contains( t, err.Error(), - "RepoCreds.Password is a required field", + "does not appear to be a valid git repository URL", ) }, }, @@ -97,6 +120,26 @@ func TestValidateAndCanonicalizeRequest(t *testing.T) { ) }, }, + { + name: "LocalInPath does not exist", + req: Request{ + LocalInPath: "/some/path/that/does/not/exist", + }, + assertions: func(t *testing.T, _ Request, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + }, + }, + { + name: "LocalOutPath exists", + req: Request{ + LocalOutPath: t.TempDir(), + }, + assertions: func(t *testing.T, _ Request, err error) { + require.Error(t, err) + require.Contains(t, err.Error(), "already exists; refusing to overwrite") + }, + }, { name: "validation succeeds", req: Request{ @@ -120,8 +163,8 @@ func TestValidateAndCanonicalizeRequest(t *testing.T) { } for _, testCase := range testCases { t.Run(testCase.name, func(t *testing.T) { - req, err := validateAndCanonicalizeRequest(testCase.req) - testCase.assertions(t, req, err) + err := testCase.req.canonicalizeAndValidate() + testCase.assertions(t, testCase.req, err) }) } }