From 3c8be611e962c29e940f402a3b6caa7f61f497f0 Mon Sep 17 00:00:00 2001 From: Noam Gal Date: Mon, 28 Jun 2021 12:26:36 +0300 Subject: [PATCH] CR-5155 - gracefully handle repo-not-exist during clone (#116) --- README.md | 22 +- cmd/commands/app.go | 16 +- cmd/commands/app_test.go | 14 +- cmd/commands/common.go | 13 +- cmd/commands/common_test.go | 109 +++-- cmd/commands/project.go | 5 +- cmd/commands/repo.go | 131 +----- cmd/commands/repo_test.go | 95 +---- docs/Getting-Started.md | 10 +- docs/commands/argocd-autopilot_repo.md | 1 - .../argocd-autopilot_repo_bootstrap.md | 1 + docs/commands/argocd-autopilot_repo_create.md | 47 --- pkg/application/application.go | 2 +- pkg/application/application_test.go | 2 - pkg/git/provider.go | 9 +- pkg/git/provider_github.go | 6 +- pkg/git/repository.go | 128 ++++-- pkg/git/repository_test.go | 384 +++++++++++++++--- 18 files changed, 529 insertions(+), 466 deletions(-) delete mode 100644 docs/commands/argocd-autopilot_repo_create.md diff --git a/README.md b/README.md index c5faf5ec..b2e0353b 100644 --- a/README.md +++ b/README.md @@ -70,30 +70,30 @@ docker run \ ## Getting Started ```bash -# Most of the commands need your git token, you can provide with --git-token to each command -# or export it beforehand: +# All of the commands need your git token with the --git-token flag, +# or the GIT_TOKEN env variable: export GIT_TOKEN= -# 1. Create a new git repository - - argocd-autopilot repo create --owner --name - -# At this point you can specify the gitops repo in each command with --repo -# or you can export it as well: +# The commands will also need your repo clone URL with the --repo flag, +# or the GIT_REPO env variable: export GIT_REPO= -# 2. Run the bootstrap installation on your current kubernetes context. +# 1. Run the bootstrap installation on your current kubernetes context. # This will install argo-cd as well as the application-set controller. argocd-autopilot repo bootstrap -# 3. Create your first project +# Please note that this will automatically attempt to create a private repository, +# if the clone URL references a non-existing one. If the repository already exists, +# the command will just clone it. + +# 2. Create your first project argocd-autopilot project create my-project -# 4. Install your first application on your project +# 3. Install your first application on your project argocd-autopilot app create demoapp --app github.com/argoproj-labs/argocd-autopilot/examples/demo-app/ -p my-project ``` diff --git a/cmd/commands/app.go b/cmd/commands/app.go index 7a4d3b4b..d321142f 100644 --- a/cmd/commands/app.go +++ b/cmd/commands/app.go @@ -55,7 +55,10 @@ func NewAppCommand() *cobra.Command { exit(1) }, } - cloneOpts = git.AddFlags(cmd, memfs.New(), "") + cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ + FS: memfs.New(), + Required: true, + }) cmd.AddCommand(NewAppCreateCommand(cloneOpts)) cmd.AddCommand(NewAppListCommand(cloneOpts)) @@ -124,7 +127,10 @@ func NewAppCreateCommand(cloneOpts *git.CloneOptions) *cobra.Command { } cmd.Flags().StringVarP(&projectName, "project", "p", "", "Project name") - appsCloneOpts = git.AddFlags(cmd, memfs.New(), "apps") + appsCloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ + FS: memfs.New(), + Prefix: "apps", + }) appOpts = application.AddFlags(cmd) die(cmd.MarkFlagRequired("app")) @@ -149,7 +155,7 @@ func RunAppCreate(ctx context.Context, opts *AppCreateOptions) error { opts.AppsCloneOpts.Auth.Password = opts.CloneOpts.Auth.Password } - appsRepo, appsfs, err = clone(ctx, opts.AppsCloneOpts) + appsRepo, appsfs, err = getRepo(ctx, opts.AppsCloneOpts) if err != nil { return err } @@ -164,7 +170,7 @@ func RunAppCreate(ctx context.Context, opts *AppCreateOptions) error { app, err := parseApp(opts.AppOpts, opts.ProjectName, opts.CloneOpts.URL(), opts.CloneOpts.Revision(), opts.CloneOpts.Path()) if err != nil { - return fmt.Errorf("failed to parse application from flags: %v", err) + return fmt.Errorf("failed to parse application from flags: %w", err) } if err = app.CreateFiles(repofs, appsfs, opts.ProjectName); err != nil { @@ -224,7 +230,7 @@ var setAppOptsDefaults = func(ctx context.Context, repofs fs.FS, opts *AppCreate FS: fs.Create(memfs.New()), } cloneOpts.Parse() - _, fsys, err = clone(ctx, cloneOpts) + _, fsys, err = getRepo(ctx, cloneOpts) if err != nil { return err } diff --git a/cmd/commands/app_test.go b/cmd/commands/app_test.go index ac5986d7..eae20c72 100644 --- a/cmd/commands/app_test.go +++ b/cmd/commands/app_test.go @@ -203,10 +203,10 @@ func TestRunAppCreate(t *testing.T) { }, }, } - origPrepareRepo, origClone, origSetAppOptsDefault, origAppParse := prepareRepo, clone, setAppOptsDefaults, parseApp + origPrepareRepo, origGetRepo, origSetAppOptsDefault, origAppParse := prepareRepo, getRepo, setAppOptsDefaults, parseApp defer func() { prepareRepo = origPrepareRepo - clone = origClone + getRepo = origGetRepo setAppOptsDefaults = origSetAppOptsDefault parseApp = origAppParse }() @@ -226,7 +226,7 @@ func TestRunAppCreate(t *testing.T) { gitopsRepo, repofs, err = tt.prepareRepo() return gitopsRepo, repofs, err } - clone = func(_ context.Context, cloneOpts *git.CloneOptions) (git.Repository, fs.FS, error) { + getRepo = func(_ context.Context, cloneOpts *git.CloneOptions) (git.Repository, fs.FS, error) { var ( repofs fs.FS err error @@ -739,7 +739,7 @@ func Test_setAppOptsDefaults(t *testing.T) { }, }, beforeFn: func() fs.FS { - clone = func(_ context.Context, _ *git.CloneOptions) (git.Repository, fs.FS, error) { + getRepo = func(_ context.Context, _ *git.CloneOptions) (git.Repository, fs.FS, error) { return nil, fs.Create(memfs.New()), nil } @@ -775,7 +775,7 @@ func Test_setAppOptsDefaults(t *testing.T) { }, wantErr: "some error", beforeFn: func() fs.FS { - clone = func(_ context.Context, _ *git.CloneOptions) (git.Repository, fs.FS, error) { + getRepo = func(_ context.Context, _ *git.CloneOptions) (git.Repository, fs.FS, error) { return nil, nil, fmt.Errorf("some error") } @@ -783,8 +783,8 @@ func Test_setAppOptsDefaults(t *testing.T) { }, }, } - origClone := clone - defer func() { clone = origClone }() + origGetRepo := getRepo + defer func() { getRepo = origGetRepo }() for name, tt := range tests { t.Run(name, func(t *testing.T) { var repofs fs.FS diff --git a/cmd/commands/common.go b/cmd/commands/common.go index dab5c210..6b162e41 100644 --- a/cmd/commands/common.go +++ b/cmd/commands/common.go @@ -35,8 +35,8 @@ var ( //go:embed assets/apps_readme.md appsReadme []byte - clone = func(ctx context.Context, cloneOpts *git.CloneOptions) (git.Repository, fs.FS, error) { - return cloneOpts.Clone(ctx) + getRepo = func(ctx context.Context, cloneOpts *git.CloneOptions) (git.Repository, fs.FS, error) { + return cloneOpts.GetRepo(ctx) } prepareRepo = func(ctx context.Context, cloneOpts *git.CloneOptions, projectName string) (git.Repository, fs.FS, error) { @@ -47,7 +47,7 @@ var ( // clone repo log.G().Infof("cloning git repository: %s", cloneOpts.URL()) - r, repofs, err := clone(ctx, cloneOpts) + r, repofs, err := getRepo(ctx, cloneOpts) if err != nil { return nil, nil, fmt.Errorf("Failed cloning the repository: %w", err) } @@ -55,12 +55,7 @@ var ( root := repofs.Root() log.G().Infof("using revision: \"%s\", installation path: \"%s\"", cloneOpts.Revision(), root) if !repofs.ExistsOrDie(store.Default.BootsrtrapDir) { - cmd := "repo bootstrap" - if root != "/" { - cmd += " --installation-path " + root - } - - return nil, nil, fmt.Errorf("Bootstrap directory not found, please execute `%s` command", cmd) + return nil, nil, fmt.Errorf("Bootstrap directory not found, please execute `repo bootstrap` command") } if projectName != "" { diff --git a/cmd/commands/common_test.go b/cmd/commands/common_test.go index 83252aef..fed53524 100644 --- a/cmd/commands/common_test.go +++ b/cmd/commands/common_test.go @@ -2,95 +2,84 @@ package commands import ( "context" - "fmt" - "reflect" + "errors" "testing" "github.com/argoproj-labs/argocd-autopilot/pkg/fs" - fsmocks "github.com/argoproj-labs/argocd-autopilot/pkg/fs/mocks" "github.com/argoproj-labs/argocd-autopilot/pkg/git" gitmocks "github.com/argoproj-labs/argocd-autopilot/pkg/git/mocks" + "github.com/argoproj-labs/argocd-autopilot/pkg/store" + "github.com/go-git/go-billy/v5/memfs" + billyUtils "github.com/go-git/go-billy/v5/util" "github.com/stretchr/testify/assert" ) -func TestBaseOptions_prepareRepo(t *testing.T) { +func Test_prepareRepo(t *testing.T) { tests := map[string]struct { projectName string - cloneErr string wantErr string - beforeFn func(m *fsmocks.FS) + getRepo func() (git.Repository, fs.FS, error) + assertFn func(*testing.T, git.Repository, fs.FS) }{ "Should complete when no errors are returned": { - projectName: "", - cloneErr: "", - wantErr: "", - beforeFn: func(m *fsmocks.FS) { - m.On("Root").Return("/") - m.On("ExistsOrDie", "bootstrap").Return(true) + getRepo: func() (git.Repository, fs.FS, error) { + repofs := fs.Create(memfs.New()) + _ = repofs.MkdirAll(store.Default.BootsrtrapDir, 0666) + return &gitmocks.Repository{}, repofs, nil + }, + assertFn: func(t *testing.T, r git.Repository, fs fs.FS) { + assert.NotNil(t, r) + assert.NotNil(t, fs) }, }, "Should fail when clone fails": { - projectName: "project", - cloneErr: "some error", - wantErr: "Failed cloning the repository: some error", - beforeFn: func(m *fsmocks.FS) {}, + wantErr: "Failed cloning the repository: some error", + getRepo: func() (git.Repository, fs.FS, error) { + return nil, nil, errors.New("some error") + }, }, "Should fail when there is no bootstrap at repo root": { - projectName: "", - cloneErr: "", - wantErr: "Bootstrap directory not found, please execute `repo bootstrap` command", - beforeFn: func(m *fsmocks.FS) { - m.On("Root").Return("/") - m.On("ExistsOrDie", "bootstrap").Return(false) + wantErr: "Bootstrap directory not found, please execute `repo bootstrap` command", + getRepo: func() (git.Repository, fs.FS, error) { + return &gitmocks.Repository{}, fs.Create(memfs.New()), nil }, - }, - "Should fail when there is no bootstrap at instllation path": { - projectName: "", - cloneErr: "", - wantErr: "Bootstrap directory not found, please execute `repo bootstrap --installation-path /some/path` command", - beforeFn: func(m *fsmocks.FS) { - m.On("Root").Return("/some/path") - m.On("ExistsOrDie", "bootstrap").Return(false) + assertFn: func(t *testing.T, r git.Repository, fs fs.FS) { + assert.NotNil(t, r) + assert.NotNil(t, fs) }, }, - "Should not validate project existence, if no projectName is supplied": { - projectName: "", - cloneErr: "", - wantErr: "", - beforeFn: func(m *fsmocks.FS) { - m.On("Root").Return("/") - m.On("ExistsOrDie", "bootstrap").Return(true) + "Should validate project existence if a projectName is supplied": { + projectName: "project", + getRepo: func() (git.Repository, fs.FS, error) { + repofs := fs.Create(memfs.New()) + _ = repofs.MkdirAll(store.Default.BootsrtrapDir, 0666) + _ = billyUtils.WriteFile(repofs, repofs.Join(store.Default.ProjectsDir, "project.yaml"), []byte{}, 0666) + return &gitmocks.Repository{}, repofs, nil + }, + assertFn: func(t *testing.T, r git.Repository, fs fs.FS) { + assert.NotNil(t, r) + assert.NotNil(t, fs) }, }, "Should fail when project does not exist": { projectName: "project", - cloneErr: "", wantErr: "project 'project' not found, please execute `argocd-autopilot project create project`", - beforeFn: func(m *fsmocks.FS) { - m.On("Root").Return("/") - m.On("ExistsOrDie", "bootstrap").Return(true) - m.On("Join", "projects", "project.yaml").Return("projects/project.yaml") - m.On("ExistsOrDie", "projects/project.yaml").Return(false) + getRepo: func() (git.Repository, fs.FS, error) { + repofs := fs.Create(memfs.New()) + _ = repofs.MkdirAll(store.Default.BootsrtrapDir, 0666) + return &gitmocks.Repository{}, repofs, nil }, }, } - origClone := clone - defer func() { clone = origClone }() + origGetRepo := getRepo + defer func() { getRepo = origGetRepo }() for name, tt := range tests { t.Run(name, func(t *testing.T) { - mockRepo := &gitmocks.Repository{} - mockFS := &fsmocks.FS{} - tt.beforeFn(mockFS) - clone = func(_ context.Context, _ *git.CloneOptions) (git.Repository, fs.FS, error) { - var err error - if tt.cloneErr != "" { - err = fmt.Errorf(tt.cloneErr) - } - - return mockRepo, mockFS, err + getRepo = func(_ context.Context, _ *git.CloneOptions) (git.Repository, fs.FS, error) { + return tt.getRepo() } - gotRepo, gotFS, err := prepareRepo(context.Background(), &git.CloneOptions{}, tt.projectName) + r, fs, err := prepareRepo(context.Background(), &git.CloneOptions{}, tt.projectName) if err != nil { if tt.wantErr != "" { assert.EqualError(t, err, tt.wantErr) @@ -101,13 +90,7 @@ func TestBaseOptions_prepareRepo(t *testing.T) { return } - if !reflect.DeepEqual(gotRepo, mockRepo) { - t.Errorf("BaseOptions.clone() got = %v, want %v", gotRepo, mockRepo) - } - - if !reflect.DeepEqual(gotFS, mockFS) { - t.Errorf("BaseOptions.clone() got1 = %v, want %v", gotFS, mockFS) - } + tt.assertFn(t, r, fs) }) } } diff --git a/cmd/commands/project.go b/cmd/commands/project.go index a8da017f..7b5e3e77 100644 --- a/cmd/commands/project.go +++ b/cmd/commands/project.go @@ -70,7 +70,10 @@ func NewProjectCommand() *cobra.Command { exit(1) }, } - cloneOpts = git.AddFlags(cmd, memfs.New(), "") + cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ + FS: memfs.New(), + Required: true, + }) cmd.AddCommand(NewProjectCreateCommand(cloneOpts)) cmd.AddCommand(NewProjectListCommand(cloneOpts)) diff --git a/cmd/commands/repo.go b/cmd/commands/repo.go index fa0b3f9c..cf69c2b0 100644 --- a/cmd/commands/repo.go +++ b/cmd/commands/repo.go @@ -24,7 +24,6 @@ import ( "github.com/ghodss/yaml" "github.com/go-git/go-billy/v5/memfs" "github.com/spf13/cobra" - "github.com/spf13/viper" v1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" kusttypes "sigs.k8s.io/kustomize/api/types" @@ -38,21 +37,11 @@ const ( // used for mocking var ( argocdLogin = argocd.Login - getGitProvider = git.NewProvider currentKubeContext = kube.CurrentContext runKustomizeBuild = application.GenerateManifests ) type ( - RepoCreateOptions struct { - Provider string - Owner string - Repo string - Token string - Public bool - Host string - } - RepoBootstrapOptions struct { AppSpecifier string InstallationMode string @@ -62,9 +51,8 @@ type ( DryRun bool HidePassword bool Timeout time.Duration - // FS fs.FS - KubeFactory kube.Factory - CloneOptions *git.CloneOptions + KubeFactory kube.Factory + CloneOptions *git.CloneOptions } bootstrapManifests struct { @@ -90,47 +78,11 @@ func NewRepoCommand() *cobra.Command { }, } - cmd.AddCommand(NewRepoCreateCommand()) cmd.AddCommand(NewRepoBootstrapCommand()) return cmd } -func NewRepoCreateCommand() *cobra.Command { - var opts *RepoCreateOptions - - cmd := &cobra.Command{ - Use: "create", - Short: "Create a new gitops repository", - Example: util.Doc(` -# To run this command you need to create a personal access token for your git provider -# and provide it using: - - export GIT_TOKEN= - -# or with the flag: - - --git-token - -# Create a new gitops repository on github - - repo create --owner foo --name bar --git-token abc123 - -# Create a public gitops repository on github - - repo create --owner foo --name bar --git-token abc123 --public -`), - RunE: func(cmd *cobra.Command, _ []string) error { - _, err := RunRepoCreate(cmd.Context(), opts) - return err - }, - } - - opts = AddRepoCreateFlags(cmd, "") - - return cmd -} - func NewRepoBootstrapCommand() *cobra.Command { var ( appSpecifier string @@ -189,7 +141,11 @@ func NewRepoBootstrapCommand() *cobra.Command { cmd.Flags().StringVar(&installationMode, "installation-mode", "normal", "One of: normal|flat. "+ "If flat, will commit the bootstrap manifests, otherwise will commit the bootstrap kustomization.yaml") - cloneOpts = git.AddFlags(cmd, memfs.New(), "") + cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ + FS: memfs.New(), + CreateIfNotExist: true, + Required: true, + }) // add kubernetes flags f = kube.AddFlags(cmd.Flags()) @@ -197,77 +153,6 @@ func NewRepoBootstrapCommand() *cobra.Command { return cmd } -func AddRepoCreateFlags(cmd *cobra.Command, prefix string) *RepoCreateOptions { - opts := &RepoCreateOptions{} - - if prefix != "" { - if !strings.HasSuffix(prefix, "-") { - prefix += "-" - } - - envPrefix := strings.ReplaceAll(strings.ToUpper(prefix), "-", "_") - - cmd.Flags().StringVar(&opts.Owner, prefix+"owner", "", "The name of the owner or organization") - cmd.Flags().StringVar(&opts.Repo, prefix+"name", "", "The name of the repository") - cmd.Flags().StringVar(&opts.Token, prefix+"git-token", "", fmt.Sprintf("Your git provider api token [%sGIT_TOKEN]", envPrefix)) - cmd.Flags().StringVar(&opts.Provider, prefix+"provider", "github", fmt.Sprintf("The git provider, one of: %v", strings.Join(git.Providers(), "|"))) - cmd.Flags().StringVar(&opts.Host, prefix+"host", "", "The git provider address (for on-premise git providers)") - cmd.Flags().BoolVar(&opts.Public, prefix+"public", false, "If true, will create the repository as public (default is false)") - - die(viper.BindEnv(prefix+"git-token", envPrefix+"GIT_TOKEN")) - } else { - cmd.Flags().StringVarP(&opts.Owner, "owner", "o", "", "The name of the owner or organization") - cmd.Flags().StringVarP(&opts.Repo, "name", "n", "", "The name of the repository") - cmd.Flags().StringVarP(&opts.Token, "git-token", "t", "", "Your git provider api token [GIT_TOKEN]") - cmd.Flags().StringVarP(&opts.Provider, "provider", "p", "github", fmt.Sprintf("The git provider, one of: %v", strings.Join(git.Providers(), "|"))) - cmd.Flags().StringVar(&opts.Host, "host", "", "The git provider address (for on-premise git providers)") - cmd.Flags().BoolVar(&opts.Public, "public", false, "If true, will create the repository as public (default is false)") - - die(viper.BindEnv("git-token", "GIT_TOKEN")) - die(cmd.MarkFlagRequired("owner")) - die(cmd.MarkFlagRequired("name")) - die(cmd.MarkFlagRequired("git-token")) - } - - return opts -} - -func RunRepoCreate(ctx context.Context, opts *RepoCreateOptions) (*git.CloneOptions, error) { - p, err := getGitProvider(&git.ProviderOptions{ - Type: opts.Provider, - Auth: &git.Auth{ - Username: "git", - Password: opts.Token, - }, - Host: opts.Host, - }) - if err != nil { - return nil, err - } - - log.G().Infof("creating repo: %s/%s", opts.Owner, opts.Repo) - repoUrl, err := p.CreateRepository(ctx, &git.CreateRepoOptions{ - Owner: opts.Owner, - Name: opts.Repo, - Private: !opts.Public, - }) - if err != nil { - return nil, err - } - - log.G().Infof("repo created at: %s", repoUrl) - - co := &git.CloneOptions{ - Repo: repoUrl, - FS: fs.Create(memfs.New()), - Auth: git.Auth{ - Password: opts.Token, - }, - } - co.Parse() - return co, nil -} - func RunRepoBootstrap(ctx context.Context, opts *RepoBootstrapOptions) error { var err error @@ -308,7 +193,7 @@ func RunRepoBootstrap(ctx context.Context, opts *RepoBootstrapOptions) error { log.G().Infof("cloning repo: %s", opts.CloneOptions.URL()) // clone GitOps repo - r, repofs, err := clone(ctx, opts.CloneOptions) + r, repofs, err := getRepo(ctx, opts.CloneOptions) if err != nil { return err } diff --git a/cmd/commands/repo_test.go b/cmd/commands/repo_test.go index 8fb1bbb7..177bdb35 100644 --- a/cmd/commands/repo_test.go +++ b/cmd/commands/repo_test.go @@ -24,86 +24,6 @@ import ( kusttypes "sigs.k8s.io/kustomize/api/types" ) -func TestRunRepoCreate(t *testing.T) { - tests := map[string]struct { - opts *RepoCreateOptions - preFn func(*gitmocks.Provider) - assertFn func(t *testing.T, mp *gitmocks.Provider, opts *RepoCreateOptions, cloneOpts *git.CloneOptions, err error) - }{ - "Invalid provider": { - opts: &RepoCreateOptions{ - Provider: "foobar", - }, - assertFn: func(t *testing.T, _ *gitmocks.Provider, _ *RepoCreateOptions, cloneOpts *git.CloneOptions, err error) { - assert.Nil(t, cloneOpts) - assert.ErrorIs(t, err, git.ErrProviderNotSupported) - }, - }, - "Should call CreateRepository": { - opts: &RepoCreateOptions{ - Provider: "github", - Owner: "foo", - Repo: "bar", - Token: "test", - Public: false, - Host: "", - }, - preFn: func(mp *gitmocks.Provider) { - mp.On("CreateRepository", mock.Anything, mock.Anything).Return("https://github.com/owner/name/path?ref=revision", nil) - }, - assertFn: func(t *testing.T, mp *gitmocks.Provider, opts *RepoCreateOptions, cloneOpts *git.CloneOptions, _ error) { - expected := &git.CloneOptions{ - Repo: "https://github.com/owner/name/path?ref=revision", - } - expected.Parse() - assert.Equal(t, "https://github.com/owner/name.git", cloneOpts.URL()) - assert.Equal(t, "revision", cloneOpts.Revision()) - assert.Equal(t, "path", cloneOpts.Path()) - mp.AssertCalled(t, "CreateRepository", mock.Anything, mock.Anything) - o := mp.Calls[0].Arguments[1].(*git.CreateRepoOptions) - assert.NotNil(t, o) - assert.Equal(t, opts.Public, !o.Private) - }, - }, - "Should fail to CreateRepository": { - opts: &RepoCreateOptions{ - Provider: "github", - Owner: "foo", - Repo: "bar", - Token: "test", - Public: false, - Host: "", - }, - preFn: func(mp *gitmocks.Provider) { - mp.On("CreateRepository", mock.Anything, mock.Anything).Return("", fmt.Errorf("error")) - }, - assertFn: func(t *testing.T, mp *gitmocks.Provider, _ *RepoCreateOptions, cloneOpts *git.CloneOptions, err error) { - assert.Nil(t, cloneOpts) - mp.AssertCalled(t, "CreateRepository", mock.Anything, mock.Anything) - assert.EqualError(t, err, "error") - }, - }, - } - - orgGetProvider := getGitProvider - for tname, tt := range tests { - t.Run(tname, func(t *testing.T) { - mp := &gitmocks.Provider{} - - if tt.preFn != nil { - tt.preFn(mp) - getGitProvider = func(opts *git.ProviderOptions) (git.Provider, error) { - return mp, nil - } - defer func() { getGitProvider = orgGetProvider }() - } - - gotCloneOpts, gotErr := RunRepoCreate(context.Background(), tt.opts) - tt.assertFn(t, mp, tt.opts, gotCloneOpts, gotErr) - }) - } -} - func Test_setBootstrapOptsDefaults(t *testing.T) { tests := map[string]struct { opts *RepoBootstrapOptions @@ -112,6 +32,7 @@ func Test_setBootstrapOptsDefaults(t *testing.T) { }{ "Bad installation mode": { opts: &RepoBootstrapOptions{ + CloneOptions: &git.CloneOptions{}, InstallationMode: "foo", }, assertFn: func(t *testing.T, _ *RepoBootstrapOptions, ret error) { @@ -119,7 +40,9 @@ func Test_setBootstrapOptsDefaults(t *testing.T) { }, }, "Basic": { - opts: &RepoBootstrapOptions{}, + opts: &RepoBootstrapOptions{ + CloneOptions: &git.CloneOptions{}, + }, preFn: func() { currentKubeContext = func() (string, error) { return "fooctx", nil @@ -135,6 +58,7 @@ func Test_setBootstrapOptsDefaults(t *testing.T) { }, "With App specifier": { opts: &RepoBootstrapOptions{ + CloneOptions: &git.CloneOptions{}, AppSpecifier: "https://github.com/foo/bar", KubeContext: "fooctx", }, @@ -149,6 +73,7 @@ func Test_setBootstrapOptsDefaults(t *testing.T) { }, "Namespaced": { opts: &RepoBootstrapOptions{ + CloneOptions: &git.CloneOptions{}, InstallationMode: installationModeFlat, KubeContext: "fooctx", Namespaced: true, @@ -418,9 +343,7 @@ func TestRunRepoBootstrap(t *testing.T) { f.On("Apply", mock.Anything, mock.Anything, mock.Anything).Return(nil) f.On("Wait", mock.Anything, mock.Anything).Return(nil) f.On("KubernetesClientSetOrDie").Return(mockCS) - r.On("Persist", mock.Anything, mock.Anything).Return(nil) - }, assertFn: func(t *testing.T, r *gitmocks.Repository, repofs fs.FS, f *kubemocks.Factory, ret error) { assert.NoError(t, ret) @@ -462,7 +385,7 @@ func TestRunRepoBootstrap(t *testing.T) { } orgExit := exit - orgClone := clone + orgClone := getRepo orgRunKustomizeBuild := runKustomizeBuild orgArgoLogin := argocdLogin @@ -480,7 +403,7 @@ func TestRunRepoBootstrap(t *testing.T) { tt.opts.KubeFactory = mockFactory exit = func(_ int) { exitCalled = true } - clone = func(ctx context.Context, cloneOpts *git.CloneOptions) (git.Repository, fs.FS, error) { + getRepo = func(ctx context.Context, cloneOpts *git.CloneOptions) (git.Repository, fs.FS, error) { return mockRepo, repofs, nil } runKustomizeBuild = func(k *kusttypes.Kustomization) ([]byte, error) { return []byte("test"), nil } @@ -488,7 +411,7 @@ func TestRunRepoBootstrap(t *testing.T) { defer func() { exit = orgExit - clone = orgClone + getRepo = orgClone runKustomizeBuild = orgRunKustomizeBuild argocdLogin = orgArgoLogin }() diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 05fa128b..c73bc99c 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -15,14 +15,9 @@ Make sure to have a [valid token](https://docs.github.com/en/github/authenticati export GIT_TOKEN=ghp_PcZ...IP0 ``` -If you have already created your GitOps Repository, you can skip the following step -### Create a new GitOps Repository -``` -argocd-autopilot repo create --owner --name -``` - ### Export Clone URL You can use any clone URL to a valid git repo, provided that the token you supplied earlier will allow cloning from, and pushing to it. +If the repository does not exist, bootstrapping it will also create it as a private repository. ``` export GIT_REPO=https://github.com/owner/name ``` @@ -39,6 +34,9 @@ If you want to use a specific branch for your GitOps repository operations, you export GIT_REPO=https://github.com/owner/name?ref=gitops_branch ``` +#### Using a Specific git Provider +You can add the `--provider` flag to the `repo bootstrap` command, to enforce using a specific provider when creating a new repository. If the value is not supplied, the code will attempt to infer it from the clone URL. + All the following commands will use the variables you supplied in order to manage your GitOps repository. ## Set up the GitOps Repository diff --git a/docs/commands/argocd-autopilot_repo.md b/docs/commands/argocd-autopilot_repo.md index 1abe7df5..735d8247 100644 --- a/docs/commands/argocd-autopilot_repo.md +++ b/docs/commands/argocd-autopilot_repo.md @@ -17,5 +17,4 @@ argocd-autopilot repo [flags] * [argocd-autopilot](argocd-autopilot.md) - argocd-autopilot is used for installing and managing argo-cd installations and argo-cd applications using gitops * [argocd-autopilot repo bootstrap](argocd-autopilot_repo_bootstrap.md) - Bootstrap a new installation -* [argocd-autopilot repo create](argocd-autopilot_repo_create.md) - Create a new gitops repository diff --git a/docs/commands/argocd-autopilot_repo_bootstrap.md b/docs/commands/argocd-autopilot_repo_bootstrap.md index 874b8d0b..2e9eb61e 100644 --- a/docs/commands/argocd-autopilot_repo_bootstrap.md +++ b/docs/commands/argocd-autopilot_repo_bootstrap.md @@ -52,6 +52,7 @@ argocd-autopilot repo bootstrap [flags] --kubeconfig string Path to the kubeconfig file to use for CLI requests. -n, --namespace string If present, the namespace scope for this CLI request --namespaced If true, install a namespaced version of argo-cd (no need for cluster-role) + --provider string The git provider, one of: github --repo string Repository URL [GIT_REPO] --request-timeout string The length of time to wait before giving up on a single server request. Non-zero values should contain a corresponding time unit (e.g. 1s, 2m, 3h). A value of zero means don't timeout requests. (default "0") -s, --server string The address and port of the Kubernetes API server diff --git a/docs/commands/argocd-autopilot_repo_create.md b/docs/commands/argocd-autopilot_repo_create.md deleted file mode 100644 index 27667551..00000000 --- a/docs/commands/argocd-autopilot_repo_create.md +++ /dev/null @@ -1,47 +0,0 @@ -## argocd-autopilot repo create - -Create a new gitops repository - -``` -argocd-autopilot repo create [flags] -``` - -### Examples - -``` - -# To run this command you need to create a personal access token for your git provider -# and provide it using: - - export GIT_TOKEN= - -# or with the flag: - - --git-token - -# Create a new gitops repository on github - - argocd-autopilot repo create --owner foo --name bar --git-token abc123 - -# Create a public gitops repository on github - - argocd-autopilot repo create --owner foo --name bar --git-token abc123 --public - -``` - -### Options - -``` - -t, --git-token string Your git provider api token [GIT_TOKEN] - -h, --help help for create - --host string The git provider address (for on-premise git providers) - -n, --name string The name of the repository - -o, --owner string The name of the owner or organization - -p, --provider string The git provider, one of: github (default "github") - --public If true, will create the repository as public (default is false) -``` - -### SEE ALSO - -* [argocd-autopilot repo](argocd-autopilot_repo.md) - Manage gitops repositories - diff --git a/pkg/application/application.go b/pkg/application/application.go index 0db184fd..becb8c86 100644 --- a/pkg/application/application.go +++ b/pkg/application/application.go @@ -312,7 +312,7 @@ func kustCreateFiles(app *kustApp, repofs fs.FS, appsfs fs.FS, projectName strin } else if appsfs != repofs && repofs.ExistsOrDie(appPath) { appRepo, err := getAppRepo(repofs, app.Name()) if err != nil { - return fmt.Errorf("Failed getting app repo: %v", err) + return fmt.Errorf("Failed getting app repo: %w", err) } return fmt.Errorf("an application with the same name already exists in '%s', consider choosing a different name", appRepo) diff --git a/pkg/application/application_test.go b/pkg/application/application_test.go index f87a8f40..b6774152 100644 --- a/pkg/application/application_test.go +++ b/pkg/application/application_test.go @@ -42,7 +42,6 @@ func Test_newKustApp(t *testing.T) { } tests := map[string]struct { - run bool opts *CreateOptions srcRepoURL string srcTargetRevision string @@ -102,7 +101,6 @@ func Test_newKustApp(t *testing.T) { }, }, "Flat installation mode with namespace": { - run: true, opts: &CreateOptions{ AppSpecifier: "app", AppName: "name", diff --git a/pkg/git/provider.go b/pkg/git/provider.go index 117a02a5..5e950d82 100644 --- a/pkg/git/provider.go +++ b/pkg/git/provider.go @@ -2,7 +2,6 @@ package git import ( "context" - "errors" "fmt" ) @@ -42,7 +41,9 @@ type ( // Errors var ( - ErrProviderNotSupported = errors.New("git provider not supported") + ErrProviderNotSupported = func(providerType string) error { + return fmt.Errorf("git provider '%s' not supported", providerType) + } ErrAuthenticationFailed = func(err error) error { return fmt.Errorf("authentication failed, make sure credentials are correct: %w", err) } @@ -53,10 +54,10 @@ var supportedProviders = map[string]func(*ProviderOptions) (Provider, error){ } // New creates a new git provider -func NewProvider(opts *ProviderOptions) (Provider, error) { +func newProvider(opts *ProviderOptions) (Provider, error) { cons, exists := supportedProviders[opts.Type] if !exists { - return nil, ErrProviderNotSupported + return nil, ErrProviderNotSupported(opts.Type) } return cons(opts) diff --git a/pkg/git/provider_github.go b/pkg/git/provider_github.go index ff645ac9..736bb34e 100644 --- a/pkg/git/provider_github.go +++ b/pkg/git/provider_github.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/http" + "strings" g "github.com/argoproj-labs/argocd-autopilot/pkg/git/github" @@ -19,12 +20,11 @@ type github struct { func newGithub(opts *ProviderOptions) (Provider, error) { var ( - c *gh.Client + c *gh.Client err error ) hc := &http.Client{} - if opts.Auth != nil { hc.Transport = &gh.BasicAuthTransport{ Username: opts.Auth.Username, @@ -32,7 +32,7 @@ func newGithub(opts *ProviderOptions) (Provider, error) { } } - if opts.Host != "" { + if opts.Host != "" && !strings.Contains(opts.Host, "github.com") { c, err = gh.NewEnterpriseClient(opts.Host, opts.Host, hc) if err != nil { return nil, err diff --git a/pkg/git/repository.go b/pkg/git/repository.go index 950d43b6..8d602c6f 100644 --- a/pkg/git/repository.go +++ b/pkg/git/repository.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "net/url" "os" "strings" @@ -35,15 +36,23 @@ type ( Persist(ctx context.Context, opts *PushOptions) error } + AddFlagsOptions struct { + FS billy.Filesystem + Prefix string + CreateIfNotExist bool + Required bool + } + CloneOptions struct { - // URL clone url - Repo string - Auth Auth - FS fs.FS - Progress io.Writer - url string - revision string - path string + Provider string + Repo string + Auth Auth + FS fs.FS + Progress io.Writer + url string + revision string + path string + createIfNotExist bool } PushOptions struct { @@ -84,31 +93,34 @@ var ( } ) -func AddFlags(cmd *cobra.Command, bfs billy.Filesystem, prefix string) *CloneOptions { +func AddFlags(cmd *cobra.Command, opts *AddFlagsOptions) *CloneOptions { co := &CloneOptions{ - FS: fs.Create(bfs), + FS: fs.Create(opts.FS), + createIfNotExist: opts.CreateIfNotExist, } - if prefix == "" { - cmd.PersistentFlags().StringVarP(&co.Auth.Password, "git-token", "t", "", "Your git provider api token [GIT_TOKEN]") - cmd.PersistentFlags().StringVar(&co.Repo, "repo", "", "Repository URL [GIT_REPO]") + if opts.Prefix != "" && !strings.HasSuffix(opts.Prefix, "-") { + opts.Prefix += "-" + } - util.Die(cmd.MarkPersistentFlagRequired("git-token")) - util.Die(cmd.MarkPersistentFlagRequired("repo")) + envPrefix := strings.ReplaceAll(strings.ToUpper(opts.Prefix), "-", "_") + cmd.PersistentFlags().StringVar(&co.Auth.Password, opts.Prefix+"git-token", "", fmt.Sprintf("Your git provider api token [%sGIT_TOKEN]", envPrefix)) + cmd.PersistentFlags().StringVar(&co.Repo, opts.Prefix+"repo", "", fmt.Sprintf("Repository URL [%sGIT_REPO]", envPrefix)) - util.Die(viper.BindEnv("git-token", "GIT_TOKEN")) - util.Die(viper.BindEnv("repo", "GIT_REPO")) - } else { - if !strings.HasSuffix(prefix, "-") { - prefix += "-" - } + util.Die(viper.BindEnv(opts.Prefix+"git-token", envPrefix+"GIT_TOKEN")) + util.Die(viper.BindEnv(opts.Prefix+"repo", envPrefix+"GIT_REPO")) - envPrefix := strings.ReplaceAll(strings.ToUpper(prefix), "-", "_") - cmd.PersistentFlags().StringVar(&co.Auth.Password, prefix+"git-token", "", fmt.Sprintf("Your git provider api token [%sGIT_TOKEN]", envPrefix)) - cmd.PersistentFlags().StringVar(&co.Repo, prefix+"repo", "", fmt.Sprintf("Repository URL [%sGIT_REPO]", envPrefix)) + if opts.Prefix == "" { + cmd.Flag("git-token").Shorthand = "t" + } - util.Die(viper.BindEnv(prefix+"git-token", envPrefix+"GIT_TOKEN")) - util.Die(viper.BindEnv(prefix+"repo", envPrefix+"GIT_REPO")) + if opts.CreateIfNotExist { + cmd.PersistentFlags().StringVar(&co.Provider, opts.Prefix+"provider", "", fmt.Sprintf("The git provider, one of: %v", strings.Join(Providers(), "|"))) + } + + if opts.Required { + util.Die(cmd.MarkPersistentFlagRequired("git-token")) + util.Die(cmd.MarkPersistentFlagRequired("repo")) } return co @@ -125,7 +137,7 @@ func (o *CloneOptions) Parse() { o.url = host + orgRepo + suffix } -func (o *CloneOptions) Clone(ctx context.Context) (Repository, fs.FS, error) { +func (o *CloneOptions) GetRepo(ctx context.Context) (Repository, fs.FS, error) { if o == nil { return nil, nil, ErrNilOpts } @@ -136,16 +148,30 @@ func (o *CloneOptions) Clone(ctx context.Context) (Repository, fs.FS, error) { r, err := clone(ctx, o) if err != nil { - if err == transport.ErrEmptyRemoteRepository { - log.G(ctx).Debug("empty repository, initializing new one with specified remote") + switch err { + case transport.ErrRepositoryNotFound: + if !o.createIfNotExist { + return nil, nil, err + } + + log.G(ctx).Infof("repository '%s' was not found, trying to create it...", o.Repo) + _, err = createRepo(ctx, o) + if err != nil { + return nil, nil, err + } + + fallthrough // a new repo will always start as empty - we need to init it locally + case transport.ErrEmptyRemoteRepository: + log.G(ctx).Info("empty repository, initializing a new one with specified remote") r, err = initRepo(ctx, o) + if err != nil { + return nil, nil, err + } + default: + return nil, nil, err } } - if err != nil { - return nil, nil, err - } - bootstrapFS, err := o.FS.Chroot(o.path) if err != nil { return nil, nil, err @@ -229,6 +255,42 @@ var clone = func(ctx context.Context, opts *CloneOptions) (*repo, error) { return repo, nil } +var createRepo = func(ctx context.Context, opts *CloneOptions) (string, error) { + host, orgRepo, _, _, _, _, _ := util.ParseGitUrl(opts.Repo) + providerType := opts.Provider + if providerType == "" { + u, err := url.Parse(host) + if err != nil { + return "", err + } + + providerType = strings.TrimSuffix(u.Hostname(), ".com") + log.G(ctx).Warnf("--provider not specified, assuming provider from url: %s", providerType) + } + + p, err := newProvider(&ProviderOptions{ + Type: providerType, + Auth: &opts.Auth, + Host: host, + }) + if err != nil { + return "", fmt.Errorf("failed to create the repository, you can try to manually create it before trying again: %w", err) + } + + s := strings.Split(orgRepo, "/") + if len(s) < 2 { + return "", fmt.Errorf("Failed parsing organization and repo from '%s'", orgRepo) + } + + owner := strings.Join(s[:len(s)-1], "/") + name := s[len(s)-1] + return p.CreateRepository(ctx, &CreateRepoOptions{ + Owner: owner, + Name: name, + Private: true, + }) +} + var initRepo = func(ctx context.Context, opts *CloneOptions) (*repo, error) { ggr, err := ggInitRepo(memory.NewStorage(), opts.FS) if err != nil { diff --git a/pkg/git/repository_test.go b/pkg/git/repository_test.go index 2f475126..a47fd23e 100644 --- a/pkg/git/repository_test.go +++ b/pkg/git/repository_test.go @@ -20,6 +20,8 @@ import ( "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage" + "github.com/spf13/cobra" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -373,103 +375,139 @@ func Test_clone(t *testing.T) { } } -func TestClone(t *testing.T) { +func TestGetRepo(t *testing.T) { tests := map[string]struct { - opts *CloneOptions - wantErr bool - cloneErr error - initErr error - expectInitCalled bool - assertFn func(*testing.T, Repository, fs.FS) + opts *CloneOptions + wantErr string + cloneFn func(context.Context, *CloneOptions) (*repo, error) + createRepoFn func(context.Context, *CloneOptions) (string, error) + initRepoFn func(context.Context, *CloneOptions) (*repo, error) + assertFn func(*testing.T, Repository, fs.FS, error) }{ - "No error": { + "Should get a repo": { opts: &CloneOptions{ Repo: "https://github.com/owner/name", + FS: fs.Create(memfs.New()), + }, + cloneFn: func(_ context.Context, opts *CloneOptions) (*repo, error) { + return &repo{}, nil }, - assertFn: func(t *testing.T, r Repository, _ fs.FS) { + assertFn: func(t *testing.T, r Repository, f fs.FS, e error) { assert.NotNil(t, r) + assert.NotNil(t, f) + assert.Nil(t, e) }, - expectInitCalled: false, }, - "NilOpts": { - opts: nil, - assertFn: func(t *testing.T, r Repository, repofs fs.FS) { + "Should fail when no CloneOptions": { + opts: nil, + wantErr: ErrNilOpts.Error(), + assertFn: func(t *testing.T, r Repository, f fs.FS, e error) { assert.Nil(t, r) - assert.Nil(t, repofs) + assert.Nil(t, f) + assert.Error(t, ErrNilOpts, e) }, - wantErr: true, }, - "EmptyRepo": { + "Should fail when clone fails": { opts: &CloneOptions{ Repo: "https://github.com/owner/name", }, - assertFn: func(t *testing.T, r Repository, repofs fs.FS) { - assert.NotNil(t, r) - assert.NotNil(t, repofs) + cloneFn: func(_ context.Context, opts *CloneOptions) (*repo, error) { + return nil, errors.New("some error") + }, + assertFn: func(t *testing.T, r Repository, f fs.FS, e error) { + assert.Nil(t, r) + assert.Nil(t, f) + assert.EqualError(t, e, "some error") }, - cloneErr: transport.ErrEmptyRemoteRepository, - wantErr: false, - expectInitCalled: true, }, - "AnotherErr": { + "Should fail when repo not found": { opts: &CloneOptions{ Repo: "https://github.com/owner/name", }, - assertFn: func(t *testing.T, r Repository, repofs fs.FS) { + cloneFn: func(_ context.Context, opts *CloneOptions) (*repo, error) { + return nil, transport.ErrRepositoryNotFound + }, + assertFn: func(t *testing.T, r Repository, f fs.FS, e error) { assert.Nil(t, r) - assert.Nil(t, repofs) + assert.Nil(t, f) + assert.Error(t, transport.ErrRepositoryNotFound, e) }, - cloneErr: fmt.Errorf("error"), - wantErr: true, - expectInitCalled: false, }, - "Use chroot": { + "Should fail when AutoCreate is true and create fails": { opts: &CloneOptions{ - Repo: "https://github.com/owner/name/some/folder", + Repo: "https://github.com/owner/name", + createIfNotExist: true, + }, + wantErr: "some error", + cloneFn: func(_ context.Context, opts *CloneOptions) (*repo, error) { + return nil, transport.ErrRepositoryNotFound + }, + createRepoFn: func(c context.Context, co *CloneOptions) (string, error) { + return "", errors.New("some error") }, - assertFn: func(t *testing.T, _ Repository, repofs fs.FS) { - assert.Equal(t, "/some/folder", repofs.Root()) + assertFn: func(t *testing.T, r Repository, f fs.FS, e error) { + assert.Nil(t, r) + assert.Nil(t, f) + assert.EqualError(t, e, "some error") + }, + }, + "Should fail when repo is empty and init fails": { + opts: &CloneOptions{ + Repo: "https://github.com/owner/name", + }, + wantErr: "some error", + cloneFn: func(_ context.Context, opts *CloneOptions) (*repo, error) { + return nil, transport.ErrEmptyRemoteRepository + }, + initRepoFn: func(c context.Context, co *CloneOptions) (*repo, error) { + return nil, errors.New("some error") + }, + assertFn: func(t *testing.T, r Repository, f fs.FS, e error) { + assert.Nil(t, r) + assert.Nil(t, f) + assert.EqualError(t, e, "some error") + }, + }, + "Should create and init repo when AutoCreate is true": { + opts: &CloneOptions{ + Repo: "https://github.com/owner/name", + createIfNotExist: true, + FS: fs.Create(memfs.New()), + }, + wantErr: "some error", + cloneFn: func(_ context.Context, opts *CloneOptions) (*repo, error) { + return nil, transport.ErrRepositoryNotFound + }, + createRepoFn: func(c context.Context, co *CloneOptions) (string, error) { + return "", nil + }, + initRepoFn: func(c context.Context, co *CloneOptions) (*repo, error) { + return &repo{}, nil + }, + assertFn: func(t *testing.T, r Repository, f fs.FS, e error) { + assert.NotNil(t, r) + assert.NotNil(t, f) + assert.Nil(t, e) }, - expectInitCalled: false, }, } - - orgClone := clone - orgInit := initRepo - defer func() { clone = orgClone }() - defer func() { initRepo = orgInit }() - + origClone, origCreateRepo, origInitRepo := clone, createRepo, initRepo + defer func() { + clone = origClone + createRepo = origCreateRepo + initRepo = origInitRepo + }() for tname, tt := range tests { t.Run(tname, func(t *testing.T) { - r := &repo{} - clone = func(_ context.Context, _ *CloneOptions) (*repo, error) { - if tt.cloneErr != nil { - return nil, tt.cloneErr - } - return r, nil - } - initRepo = func(_ context.Context, _ *CloneOptions) (*repo, error) { - if !tt.expectInitCalled { - t.Errorf("expectInitCalled = false, but it was called") - } - if tt.initErr != nil { - return nil, tt.initErr - } - return r, nil - } - + clone = tt.cloneFn + createRepo = tt.createRepoFn + initRepo = tt.initRepoFn if tt.opts != nil { tt.opts.Parse() - tt.opts.FS = fs.Create(memfs.New()) } - gotRepo, gotFS, err := tt.opts.Clone(context.Background()) - if (err != nil) != tt.wantErr { - t.Errorf("Clone() error = %v, wantErr %v", err, tt.wantErr) - return - } - - tt.assertFn(t, gotRepo, gotFS) + r, fs, err := tt.opts.GetRepo(context.Background()) + tt.assertFn(t, r, fs, err) }) } } @@ -668,3 +706,221 @@ func Test_repo_checkoutRef(t *testing.T) { }) } } + +func TestAddFlags(t *testing.T) { + type flag struct { + name string + shorthand string + value string + usage string + required bool + } + tests := map[string]struct { + opts *AddFlagsOptions + wantedFlags []flag + }{ + "Should create flags without a prefix": { + opts: &AddFlagsOptions{}, + wantedFlags: []flag{ + { + name: "git-token", + shorthand: "t", + usage: "Your git provider api token [GIT_TOKEN]", + }, + { + name: "repo", + usage: "Repository URL [GIT_REPO]", + }, + }, + }, + "Should create flags with required": { + opts: &AddFlagsOptions{ + Required: true, + }, + wantedFlags: []flag{ + { + name: "git-token", + shorthand: "t", + usage: "Your git provider api token [GIT_TOKEN]", + required: true, + }, + { + name: "repo", + usage: "Repository URL [GIT_REPO]", + required: true, + }, + }, + }, + "Should create flags with a prefix": { + opts: &AddFlagsOptions{ + Prefix: "prefix-", + }, + wantedFlags: []flag{ + { + name: "prefix-git-token", + usage: "Your git provider api token [PREFIX_GIT_TOKEN]", + }, + { + name: "prefix-repo", + usage: "Repository URL [PREFIX_GIT_REPO]", + }, + }, + }, + "Should automatically add a dash to prefix": { + opts: &AddFlagsOptions{ + Prefix: "prefix", + }, + wantedFlags: []flag{ + { + name: "prefix-git-token", + usage: "Your git provider api token [PREFIX_GIT_TOKEN]", + }, + { + name: "prefix-repo", + usage: "Repository URL [PREFIX_GIT_REPO]", + }, + }, + }, + "Should add provider flag when needed": { + opts: &AddFlagsOptions{ + CreateIfNotExist: true, + }, + wantedFlags: []flag{ + { + name: "provider", + usage: "The git provider, one of: github", + }, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + viper.Reset() + cmd := &cobra.Command{} + tt.opts.FS = memfs.New() + _ = AddFlags(cmd, tt.opts) + fs := cmd.PersistentFlags() + for _, expected := range tt.wantedFlags { + actual := fs.Lookup(expected.name) + assert.NotNil(t, actual, "missing "+expected.name+" flag") + assert.Equal(t, expected.shorthand, actual.Shorthand, "wrong shorthand for "+expected.name) + assert.Equal(t, expected.value, actual.DefValue, "wrong default value for "+expected.name) + assert.Equal(t, expected.usage, actual.Usage, "wrong usage for "+expected.name) + if expected.required { + assert.NotEmpty(t, actual.Annotations[cobra.BashCompOneRequiredFlag], expected.name+" was supposed to be required") + assert.Equal(t, "true", actual.Annotations[cobra.BashCompOneRequiredFlag][0], expected.name+" was supposed to be required") + } else { + assert.Nil(t, actual.Annotations[cobra.BashCompOneRequiredFlag], expected.name+" was not supposed to be required") + } + } + }) + } +} + +type mockProvider struct { + createRepository func(opts *CreateRepoOptions) (string, error) +} + +func (p *mockProvider) CreateRepository(_ context.Context, opts *CreateRepoOptions) (string, error) { + return p.createRepository(opts) +} + +func Test_createRepo(t *testing.T) { + tests := map[string]struct { + opts *CloneOptions + want string + wantErr string + newProvider func(*testing.T, *ProviderOptions) (Provider, error) + }{ + "Should create new repository": { + opts: &CloneOptions{ + Repo: "https://github.com/owner/name.git", + Provider: "github", + Auth: Auth{ + Username: "username", + Password: "password", + }, + }, + want: "https://github.com/owner/name.git", + newProvider: func(t *testing.T, opts *ProviderOptions) (Provider, error) { + assert.Equal(t, "username", opts.Auth.Username) + assert.Equal(t, "password", opts.Auth.Password) + assert.Equal(t, "https://github.com/", opts.Host) + assert.Equal(t, "github", opts.Type) + return &mockProvider{func(opts *CreateRepoOptions) (string, error) { + assert.Equal(t, "owner", opts.Owner) + assert.Equal(t, "name", opts.Name) + assert.Equal(t, true, opts.Private) + return "https://github.com/owner/name.git", nil + }}, nil + }, + }, + "Should infer correct provider type from repo url": { + opts: &CloneOptions{ + Repo: "https://github.com/owner/name.git", + }, + want: "https://github.com/owner/name.git", + newProvider: func(t *testing.T, opts *ProviderOptions) (Provider, error) { + assert.Equal(t, "github", opts.Type) + return &mockProvider{func(opts *CreateRepoOptions) (string, error) { + return "https://github.com/owner/name.git", nil + }}, nil + }, + }, + "Should fail if provider type is unknown": { + opts: &CloneOptions{ + Repo: "https://unkown.com/owner/name", + }, + wantErr: "failed to create the repository, you can try to manually create it before trying again: git provider 'unkown' not supported", + }, + "Should fail if url doesn't contain orgRepo parts": { + opts: &CloneOptions{ + Repo: "https://github.com/owner.git", + }, + wantErr: "Failed parsing organization and repo from 'owner'", + }, + "Should succesfully parse owner and name for long orgRepos": { + opts: &CloneOptions{ + Repo: "https://github.com/foo22/bar/fizz.git", + }, + want: "https://github.com/foo22/bar/fizz.git", + newProvider: func(t *testing.T, opts *ProviderOptions) (Provider, error) { + assert.Equal(t, "https://github.com/", opts.Host) + assert.Equal(t, "github", opts.Type) + return &mockProvider{func(opts *CreateRepoOptions) (string, error) { + assert.Equal(t, "foo22/bar", opts.Owner) + assert.Equal(t, "fizz", opts.Name) + return "https://github.com/foo22/bar/fizz.git", nil + }}, nil + }, + }, + } + origNewProvider := supportedProviders["github"] + defer func() { supportedProviders["github"] = origNewProvider }() + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + if tt.newProvider != nil { + supportedProviders["github"] = func(opts *ProviderOptions) (Provider, error) { + return tt.newProvider(t, opts) + } + } else { + supportedProviders["github"] = origNewProvider + } + + got, err := createRepo(context.Background(), tt.opts) + if err != nil { + if tt.wantErr != "" { + assert.EqualError(t, err, tt.wantErr) + } else { + t.Errorf("createRepo() error = %v, wantErr %v", err, tt.wantErr) + } + + return + } + + if got != tt.want { + t.Errorf("createRepo() = %v, want %v", got, tt.want) + } + }) + } +}