diff --git a/cmd/commands/app.go b/cmd/commands/app.go index d321142f..bfe8b7da 100644 --- a/cmd/commands/app.go +++ b/cmd/commands/app.go @@ -7,10 +7,13 @@ import ( "fmt" "os" "text/tabwriter" + "time" "github.com/argoproj-labs/argocd-autopilot/pkg/application" + "github.com/argoproj-labs/argocd-autopilot/pkg/argocd" "github.com/argoproj-labs/argocd-autopilot/pkg/fs" "github.com/argoproj-labs/argocd-autopilot/pkg/git" + "github.com/argoproj-labs/argocd-autopilot/pkg/kube" "github.com/argoproj-labs/argocd-autopilot/pkg/log" "github.com/argoproj-labs/argocd-autopilot/pkg/store" "github.com/argoproj-labs/argocd-autopilot/pkg/util" @@ -28,6 +31,8 @@ type ( AppsCloneOpts *git.CloneOptions ProjectName string AppOpts *application.CreateOptions + KubeFactory kube.Factory + Timeout time.Duration } AppDeleteOptions struct { @@ -57,7 +62,6 @@ func NewAppCommand() *cobra.Command { } cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ FS: memfs.New(), - Required: true, }) cmd.AddCommand(NewAppCreateCommand(cloneOpts)) @@ -72,6 +76,8 @@ func NewAppCreateCommand(cloneOpts *git.CloneOptions) *cobra.Command { appsCloneOpts *git.CloneOptions appOpts *application.CreateOptions projectName string + timeout time.Duration + f kube.Factory ) cmd := &cobra.Command{ @@ -106,32 +112,42 @@ func NewAppCreateCommand(cloneOpts *git.CloneOptions) *cobra.Command { # Reference a specific git branch: app create --app github.com/some_org/some_repo/manifests?ref= --project project_name + +# Wait until the application is Synced in the cluster: + + app create --app github.com/some_org/some_repo/manifests --project project_name --wait-timeout 2m --context my_context `), PreRun: func(_ *cobra.Command, _ []string) { cloneOpts.Parse() appsCloneOpts.Parse() }, RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() if len(args) < 1 { - log.G().Fatal("must enter application name") + log.G(ctx).Fatal("must enter application name") } appOpts.AppName = args[0] - return RunAppCreate(cmd.Context(), &AppCreateOptions{ + return RunAppCreate(ctx, &AppCreateOptions{ CloneOpts: cloneOpts, AppsCloneOpts: appsCloneOpts, ProjectName: projectName, AppOpts: appOpts, + Timeout: timeout, + KubeFactory: f, }) }, } cmd.Flags().StringVarP(&projectName, "project", "p", "", "Project name") + cmd.Flags().DurationVar(&timeout, "wait-timeout", time.Duration(0), "If not '0s', will try to connect to the cluster and wait until the application is in 'Synced' status for the specified timeout period") appsCloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ - FS: memfs.New(), - Prefix: "apps", + FS: memfs.New(), + Prefix: "apps", + Optional: true, }) appOpts = application.AddFlags(cmd) + f = kube.AddFlags(cmd.Flags()) die(cmd.MarkFlagRequired("app")) die(cmd.MarkFlagRequired("project")) @@ -140,16 +156,16 @@ func NewAppCreateCommand(cloneOpts *git.CloneOptions) *cobra.Command { } func RunAppCreate(ctx context.Context, opts *AppCreateOptions) error { - r, repofs, err := prepareRepo(ctx, opts.CloneOpts, opts.ProjectName) - if err != nil { - return err - } - var ( appsRepo git.Repository appsfs fs.FS ) + r, repofs, err := prepareRepo(ctx, opts.CloneOpts, opts.ProjectName) + if err != nil { + return err + } + if opts.AppsCloneOpts.Repo != "" { if opts.AppsCloneOpts.Auth.Password == "" { opts.AppsCloneOpts.Auth.Password = opts.CloneOpts.Auth.Password @@ -182,19 +198,36 @@ func RunAppCreate(ctx context.Context, opts *AppCreateOptions) error { } if opts.AppsCloneOpts != opts.CloneOpts { - log.G().Info("committing changes to apps repo...") + log.G(ctx).Info("committing changes to apps repo...") if err = appsRepo.Persist(ctx, &git.PushOptions{CommitMsg: getCommitMsg(opts, appsfs)}); err != nil { return fmt.Errorf("failed to push to apps repo: %w", err) } } - log.G().Info("committing changes to gitops repo...") + log.G(ctx).Info("committing changes to gitops repo...") if err = r.Persist(ctx, &git.PushOptions{CommitMsg: getCommitMsg(opts, repofs)}); err != nil { return fmt.Errorf("failed to push to gitops repo: %w", err) } - log.G().Infof("installed application: %s", opts.AppOpts.AppName) + if opts.Timeout > 0 { + namespace, err := getInstallationNamespace(opts.CloneOpts.FS) + if err != nil { + return fmt.Errorf("failed to get application namespace: %w", err) + } + + log.G(ctx).WithField("timeout", opts.Timeout).Infof("Waiting for '%s' to finish syncing", opts.AppOpts.AppName) + fullName := fmt.Sprintf("%s-%s", opts.ProjectName, opts.AppOpts.AppName) + // wait for argocd to be ready before applying argocd-apps + stop := util.WithSpinner(ctx, fmt.Sprintf("waiting for '%s' to be ready", fullName)) + if err = waitAppSynced(ctx, opts.KubeFactory, opts.Timeout, fullName, namespace); err != nil { + stop() + return fmt.Errorf("failed waiting for application to sync: %w", err) + } + stop() + } + + log.G(ctx).Infof("installed application: %s", opts.AppOpts.AppName) return nil } @@ -223,7 +256,7 @@ var setAppOptsDefaults = func(ctx context.Context, repofs fs.FS, opts *AppCreate } else { host, orgRepo, p, _, _, suffix, _ := util.ParseGitUrl(opts.AppOpts.AppSpecifier) url := host + orgRepo + suffix - log.G().Infof("cloning repo: '%s', to infer app type from path '%s'", url, p) + log.G(ctx).Infof("cloning repo: '%s', to infer app type from path '%s'", url, p) cloneOpts := &git.CloneOptions{ Repo: opts.AppOpts.AppSpecifier, Auth: opts.CloneOpts.Auth, @@ -237,7 +270,7 @@ var setAppOptsDefaults = func(ctx context.Context, repofs fs.FS, opts *AppCreate } opts.AppOpts.AppType = application.InferAppType(fsys) - log.G().Infof("inferred application type: %s", opts.AppOpts.AppType) + log.G(ctx).Infof("inferred application type: %s", opts.AppOpts.AppType) return nil } @@ -265,6 +298,20 @@ func getCommitMsg(opts *AppCreateOptions, repofs fs.FS) string { return commitMsg } +func waitAppSynced(ctx context.Context, f kube.Factory, timeout time.Duration, appName, namespace string) error { + return f.Wait(ctx, &kube.WaitOptions{ + Interval: store.Default.WaitInterval, + Timeout: timeout, + Resources: []kube.Resource{ + { + Name: appName, + Namespace: namespace, + WaitFunc: argocd.CheckAppSynced, + }, + }, + }) +} + func NewAppListCommand(cloneOpts *git.CloneOptions) *cobra.Command { cmd := &cobra.Command{ Use: "list [PROJECT_NAME]", @@ -286,11 +333,12 @@ func NewAppListCommand(cloneOpts *git.CloneOptions) *cobra.Command { `), PreRun: func(_ *cobra.Command, _ []string) { cloneOpts.Parse() }, RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() if len(args) < 1 { - log.G().Fatal("must enter a project name") + log.G(ctx).Fatal("must enter a project name") } - return RunAppList(cmd.Context(), &AppListOptions{ + return RunAppList(ctx, &AppListOptions{ CloneOpts: cloneOpts, ProjectName: args[0], }) @@ -309,7 +357,7 @@ func RunAppList(ctx context.Context, opts *AppListOptions) error { // get all apps beneath apps/*/overlays/ matches, err := billyUtils.Glob(repofs, repofs.Join(store.Default.AppsDir, "*", store.Default.OverlaysDir, opts.ProjectName)) if err != nil { - log.G().Fatalf("failed to run glob on %s", opts.ProjectName) + log.G(ctx).Fatalf("failed to run glob on %s", opts.ProjectName) } w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) @@ -370,15 +418,16 @@ func NewAppDeleteCommand(cloneOpts *git.CloneOptions) *cobra.Command { `), PreRun: func(_ *cobra.Command, _ []string) { cloneOpts.Parse() }, RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() if len(args) < 1 { - log.G().Fatal("must enter application name") + log.G(ctx).Fatal("must enter application name") } if projectName == "" && !global { - log.G().Fatal("must enter project name OR use '--global' flag") + log.G(ctx).Fatal("must enter project name OR use '--global' flag") } - return RunAppDelete(cmd.Context(), &AppDeleteOptions{ + return RunAppDelete(ctx, &AppDeleteOptions{ CloneOpts: cloneOpts, ProjectName: projectName, AppName: args[0], @@ -440,7 +489,7 @@ func RunAppDelete(ctx context.Context, opts *AppDeleteOptions) error { return fmt.Errorf("failed to delete directory '%s': %w", dirToRemove, err) } - log.G().Info("committing changes to gitops repo...") + log.G(ctx).Info("committing changes to gitops repo...") if err = r.Persist(ctx, &git.PushOptions{CommitMsg: commitMsg}); err != nil { return fmt.Errorf("failed to push to repo: %w", err) } diff --git a/cmd/commands/app_test.go b/cmd/commands/app_test.go index eae20c72..dcdb7c58 100644 --- a/cmd/commands/app_test.go +++ b/cmd/commands/app_test.go @@ -2,10 +2,12 @@ package commands import ( "context" + "errors" "fmt" "path/filepath" "strings" "testing" + "time" "github.com/argoproj-labs/argocd-autopilot/pkg/application" appmocks "github.com/argoproj-labs/argocd-autopilot/pkg/application/mocks" @@ -13,6 +15,7 @@ import ( 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" + kubemocks "github.com/argoproj-labs/argocd-autopilot/pkg/kube/mocks" "github.com/argoproj-labs/argocd-autopilot/pkg/store" argocdv1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" @@ -25,13 +28,17 @@ import ( func TestRunAppCreate(t *testing.T) { tests := map[string]struct { - appsRepo string - wantErr string - prepareRepo func() (git.Repository, fs.FS, error) - clone func(t *testing.T, cloneOpts *git.CloneOptions) (git.Repository, fs.FS, error) - setAppOptsDefaultsErr error - parseApp func() (application.Application, error) - assertFn func(t *testing.T, gitopsRepo git.Repository, appsRepo git.Repository) + appsRepo string + timeout time.Duration + wantErr string + setAppOptsDefaultsErr error + parseAppErr error + createFilesErr error + beforeFn func(f *kubemocks.Factory) + prepareRepo func() (git.Repository, fs.FS, error) + getRepo func(*testing.T, *git.CloneOptions) (git.Repository, fs.FS, error) + getInstallationNamespace func(repofs fs.FS) (string, error) + assertFn func(*testing.T, git.Repository, git.Repository) }{ "Should fail when clone fails": { wantErr: "some error", @@ -45,7 +52,7 @@ func TestRunAppCreate(t *testing.T) { prepareRepo: func() (git.Repository, fs.FS, error) { return nil, nil, nil }, - clone: func(t *testing.T, cloneOpts *git.CloneOptions) (git.Repository, fs.FS, error) { + getRepo: func(_ *testing.T, _ *git.CloneOptions) (git.Repository, fs.FS, error) { return nil, nil, fmt.Errorf("some error") }, }, @@ -55,11 +62,11 @@ func TestRunAppCreate(t *testing.T) { prepareRepo: func() (git.Repository, fs.FS, error) { return nil, nil, nil }, - clone: func(t *testing.T, cloneOpts *git.CloneOptions) (git.Repository, fs.FS, error) { - assert.Equal(t, "https://github.com/owner/other_name.git", cloneOpts.URL()) - assert.Equal(t, "branch", cloneOpts.Revision()) - assert.Equal(t, "path", cloneOpts.Path()) - assert.Equal(t, "password", cloneOpts.Auth.Password) + getRepo: func(t *testing.T, opts *git.CloneOptions) (git.Repository, fs.FS, error) { + assert.Equal(t, "https://github.com/owner/other_name.git", opts.URL()) + assert.Equal(t, "branch", opts.Revision()) + assert.Equal(t, "path", opts.Path()) + assert.Equal(t, "password", opts.Auth.Password) return nil, nil, fmt.Errorf("some error") }, }, @@ -75,39 +82,27 @@ func TestRunAppCreate(t *testing.T) { prepareRepo: func() (git.Repository, fs.FS, error) { return nil, nil, nil }, - parseApp: func() (application.Application, error) { - return nil, fmt.Errorf("some error") - }, + parseAppErr: errors.New("some error"), }, "Should fail if app already exist in project": { - wantErr: fmt.Errorf("application 'app' already exists in project 'project': %w", application.ErrAppAlreadyInstalledOnProject).Error(), + wantErr: fmt.Errorf("application 'app' already exists in project 'project': %w", application.ErrAppAlreadyInstalledOnProject).Error(), + createFilesErr: application.ErrAppAlreadyInstalledOnProject, prepareRepo: func() (git.Repository, fs.FS, error) { memfs := memfs.New() _ = memfs.MkdirAll(filepath.Join(store.Default.AppsDir, "app", store.Default.OverlaysDir, "project"), 0666) mockRepo := &gitmocks.Repository{} return mockRepo, fs.Create(memfs), nil }, - parseApp: func() (application.Application, error) { - app := &appmocks.Application{} - app.On("Name").Return("app") - app.On("CreateFiles", mock.Anything, mock.Anything, "project").Return(application.ErrAppAlreadyInstalledOnProject) - return app, nil - }, }, "Should fail if file creation fails": { - wantErr: "some error", + wantErr: "some error", + createFilesErr: errors.New("some error"), prepareRepo: func() (git.Repository, fs.FS, error) { memfs := memfs.New() _ = memfs.MkdirAll(filepath.Join(store.Default.AppsDir, "app", store.Default.OverlaysDir, "project"), 0666) mockRepo := &gitmocks.Repository{} return mockRepo, fs.Create(memfs), nil }, - parseApp: func() (application.Application, error) { - app := &appmocks.Application{} - app.On("Name").Return("app") - app.On("CreateFiles", mock.Anything, mock.Anything, "project").Return(fmt.Errorf("some error")) - return app, nil - }, }, "Should fail if commiting to appsRepo fails": { appsRepo: "https://github.com/owner/other_name", @@ -118,19 +113,13 @@ func TestRunAppCreate(t *testing.T) { mockRepo := &gitmocks.Repository{} return mockRepo, fs.Create(memfs), nil }, - clone: func(_ *testing.T, _ *git.CloneOptions) (git.Repository, fs.FS, error) { + getRepo: func(_ *testing.T, _ *git.CloneOptions) (git.Repository, fs.FS, error) { mockRepo := &gitmocks.Repository{} mockRepo.On("Persist", mock.Anything, &git.PushOptions{ CommitMsg: "installed app 'app' on project 'project' installation-path: '/'", }).Return(fmt.Errorf("some error")) return mockRepo, fs.Create(memfs.New()), nil }, - parseApp: func() (application.Application, error) { - app := &appmocks.Application{} - app.On("Name").Return("app") - app.On("CreateFiles", mock.Anything, mock.Anything, "project").Return(nil) - return app, nil - }, }, "Should fail if commiting to gitops repo fails": { wantErr: "failed to push to gitops repo: some error", @@ -143,11 +132,40 @@ func TestRunAppCreate(t *testing.T) { }).Return(fmt.Errorf("some error")) return mockRepo, fs.Create(memfs), nil }, - parseApp: func() (application.Application, error) { - app := &appmocks.Application{} - app.On("Name").Return("app") - app.On("CreateFiles", mock.Anything, mock.Anything, "project").Return(nil) - return app, nil + }, + "Should fail if getInstallationNamespace fails": { + timeout: 1, + wantErr: "failed to get application namespace: some error", + prepareRepo: func() (git.Repository, fs.FS, error) { + memfs := memfs.New() + _ = memfs.MkdirAll(filepath.Join(store.Default.AppsDir, "app", store.Default.OverlaysDir, "project"), 0666) + mockRepo := &gitmocks.Repository{} + mockRepo.On("Persist", mock.Anything, &git.PushOptions{ + CommitMsg: "installed app 'app' on project 'project' installation-path: '/'", + }).Return(nil) + return mockRepo, fs.Create(memfs), nil + }, + getInstallationNamespace: func(repofs fs.FS) (string, error) { + return "", errors.New("some error") + }, + }, + "Should fail if waiting fails": { + timeout: 1, + wantErr: "failed waiting for application to sync: some error", + beforeFn: func(f *kubemocks.Factory) { + f.On("Wait", mock.Anything, mock.Anything).Return(errors.New("some error")) + }, + prepareRepo: func() (git.Repository, fs.FS, error) { + memfs := memfs.New() + _ = memfs.MkdirAll(filepath.Join(store.Default.AppsDir, "app", store.Default.OverlaysDir, "project"), 0666) + mockRepo := &gitmocks.Repository{} + mockRepo.On("Persist", mock.Anything, &git.PushOptions{ + CommitMsg: "installed app 'app' on project 'project' installation-path: '/'", + }).Return(nil) + return mockRepo, fs.Create(memfs), nil + }, + getInstallationNamespace: func(repofs fs.FS) (string, error) { + return "namespace", nil }, }, "Should Persist to both repos, if required": { @@ -162,19 +180,13 @@ func TestRunAppCreate(t *testing.T) { }).Return(nil) return mockRepo, fs.Create(memfs), nil }, - clone: func(_ *testing.T, _ *git.CloneOptions) (git.Repository, fs.FS, error) { + getRepo: func(_ *testing.T, _ *git.CloneOptions) (git.Repository, fs.FS, error) { mockRepo := &gitmocks.Repository{} mockRepo.On("Persist", mock.Anything, &git.PushOptions{ CommitMsg: "installed app 'app' on project 'project' installation-path: '/'", }).Return(nil) return mockRepo, fs.Create(memfs.New()), nil }, - parseApp: func() (application.Application, error) { - app := &appmocks.Application{} - app.On("Name").Return("app") - app.On("CreateFiles", mock.Anything, mock.Anything, "project").Return(nil) - return app, nil - }, assertFn: func(t *testing.T, gitopsRepo git.Repository, appsRepo git.Repository) { gitopsRepo.(*gitmocks.Repository).AssertExpectations(t) appsRepo.(*gitmocks.Repository).AssertExpectations(t) @@ -191,11 +203,27 @@ func TestRunAppCreate(t *testing.T) { }).Return(nil) return mockRepo, fs.Create(memfs), nil }, - parseApp: func() (application.Application, error) { - app := &appmocks.Application{} - app.On("Name").Return("app") - app.On("CreateFiles", mock.Anything, mock.Anything, "project").Return(nil) - return app, nil + assertFn: func(t *testing.T, gitopsRepo git.Repository, appsRepo git.Repository) { + assert.Nil(t, appsRepo) + gitopsRepo.(*gitmocks.Repository).AssertExpectations(t) + }, + }, + "Should wait succesfully and complete": { + timeout: 1, + beforeFn: func(f *kubemocks.Factory) { + f.On("Wait", mock.Anything, mock.Anything).Return(nil) + }, + prepareRepo: func() (git.Repository, fs.FS, error) { + memfs := memfs.New() + _ = memfs.MkdirAll(filepath.Join(store.Default.AppsDir, "app", store.Default.OverlaysDir, "project"), 0666) + mockRepo := &gitmocks.Repository{} + mockRepo.On("Persist", mock.Anything, &git.PushOptions{ + CommitMsg: "installed app 'app' on project 'project' installation-path: '/'", + }).Return(nil) + return mockRepo, fs.Create(memfs), nil + }, + getInstallationNamespace: func(repofs fs.FS) (string, error) { + return "namespace", nil }, assertFn: func(t *testing.T, gitopsRepo git.Repository, appsRepo git.Repository) { assert.Nil(t, appsRepo) @@ -203,14 +231,14 @@ func TestRunAppCreate(t *testing.T) { }, }, } - origPrepareRepo, origGetRepo, origSetAppOptsDefault, origAppParse := prepareRepo, getRepo, setAppOptsDefaults, parseApp + origPrepareRepo, origGetRepo, origSetAppOptsDefault, origAppParse, origGetInstallationNamespace := prepareRepo, getRepo, setAppOptsDefaults, parseApp, getInstallationNamespace defer func() { prepareRepo = origPrepareRepo getRepo = origGetRepo setAppOptsDefaults = origSetAppOptsDefault parseApp = origAppParse + getInstallationNamespace = origGetInstallationNamespace }() - for name, tt := range tests { t.Run(name, func(t *testing.T) { var ( @@ -218,6 +246,10 @@ func TestRunAppCreate(t *testing.T) { appsRepo git.Repository ) + f := &kubemocks.Factory{} + if tt.beforeFn != nil { + tt.beforeFn(f) + } prepareRepo = func(_ context.Context, _ *git.CloneOptions, _ string) (git.Repository, fs.FS, error) { var ( repofs fs.FS @@ -231,16 +263,25 @@ func TestRunAppCreate(t *testing.T) { repofs fs.FS err error ) - appsRepo, repofs, err = tt.clone(t, cloneOpts) + appsRepo, repofs, err = tt.getRepo(t, cloneOpts) return appsRepo, repofs, err } setAppOptsDefaults = func(_ context.Context, _ fs.FS, _ *AppCreateOptions) error { return tt.setAppOptsDefaultsErr } parseApp = func(_ *application.CreateOptions, _, _, _, _ string) (application.Application, error) { - return tt.parseApp() + if tt.parseAppErr != nil { + return nil, tt.parseAppErr + } + + app := &appmocks.Application{} + app.On("Name").Return("app") + app.On("CreateFiles", mock.Anything, mock.Anything, "project").Return(tt.createFilesErr) + return app, nil } + getInstallationNamespace = tt.getInstallationNamespace opts := &AppCreateOptions{ + Timeout: tt.timeout, CloneOpts: &git.CloneOptions{ Repo: "https://github.com/owner/name", Auth: git.Auth{ @@ -256,6 +297,7 @@ func TestRunAppCreate(t *testing.T) { AppType: application.AppTypeDirectory, AppSpecifier: "https://github.com/owner/name/manifests", }, + KubeFactory: f, } opts.CloneOpts.Parse() diff --git a/cmd/commands/common.go b/cmd/commands/common.go index 6b162e41..9f2e618f 100644 --- a/cmd/commands/common.go +++ b/cmd/commands/common.go @@ -231,3 +231,13 @@ func createAppSet(o *createAppSetOptions) ([]byte, error) { return yaml.Marshal(appSet) } + +var getInstallationNamespace = func(repofs fs.FS) (string, error) { + path := repofs.Join(store.Default.BootsrtrapDir, store.Default.ArgoCDName+".yaml") + a := &argocdv1alpha1.Application{} + if err := repofs.ReadYamls(path, a); err != nil { + return "", fmt.Errorf("failed to unmarshal namespace: %w", err) + } + + return a.Spec.Destination.Namespace, nil +} diff --git a/cmd/commands/project.go b/cmd/commands/project.go index 7b5e3e77..2ea9d1f1 100644 --- a/cmd/commands/project.go +++ b/cmd/commands/project.go @@ -72,7 +72,6 @@ func NewProjectCommand() *cobra.Command { } cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ FS: memfs.New(), - Required: true, }) cmd.AddCommand(NewProjectCreateCommand(cloneOpts)) @@ -315,16 +314,6 @@ func generateProjectManifests(o *GenerateProjectOptions) (projectYAML, appSetYAM return } -var getInstallationNamespace = func(repofs fs.FS) (string, error) { - path := repofs.Join(store.Default.BootsrtrapDir, store.Default.ArgoCDName+".yaml") - a := &argocdv1alpha1.Application{} - if err := repofs.ReadYamls(path, a); err != nil { - return "", fmt.Errorf("failed to unmarshal namespace: %w", err) - } - - return a.Spec.Destination.Namespace, nil -} - func NewProjectListCommand(cloneOpts *git.CloneOptions) *cobra.Command { cmd := &cobra.Command{ Use: "list ", diff --git a/cmd/commands/repo.go b/cmd/commands/repo.go index cf69c2b0..a74a487a 100644 --- a/cmd/commands/repo.go +++ b/cmd/commands/repo.go @@ -142,9 +142,8 @@ func NewRepoBootstrapCommand() *cobra.Command { "If flat, will commit the bootstrap manifests, otherwise will commit the bootstrap kustomization.yaml") cloneOpts = git.AddFlags(cmd, &git.AddFlagsOptions{ - FS: memfs.New(), + FS: memfs.New(), CreateIfNotExist: true, - Required: true, }) // add kubernetes flags @@ -221,8 +220,10 @@ func RunRepoBootstrap(ctx context.Context, opts *RepoBootstrapOptions) error { stop := util.WithSpinner(ctx, "waiting for argo-cd to be ready") if err = waitClusterReady(ctx, opts.KubeFactory, opts.Timeout, opts.Namespace); err != nil { + stop() return err } + stop() // push results to repo diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index c73bc99c..3e860edc 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -66,7 +66,7 @@ Execute the port forward command, and browse to http://localhost:8080. Log in us Execute the following commands to create a `testing` project, and add a example application to it: ``` argocd-autopilot project create testing -argocd-autopilot app create hello-world --app github.com/argoproj-labs/argocd-autopilot/examples/demo-app/ -p testing +argocd-autopilot app create hello-world --app github.com/argoproj-labs/argocd-autopilot/examples/demo-app/ -p testing --wait-timeout 2m ``` * notice the trailing slash in the URL diff --git a/docs/commands/argocd-autopilot_application_create.md b/docs/commands/argocd-autopilot_application_create.md index 70dbdc91..c105703f 100644 --- a/docs/commands/argocd-autopilot_application_create.md +++ b/docs/commands/argocd-autopilot_application_create.md @@ -39,20 +39,41 @@ argocd-autopilot application create [APP_NAME] [flags] argocd-autopilot app create --app github.com/some_org/some_repo/manifests?ref= --project project_name +# Wait until the application is Synced in the cluster: + + argocd-autopilot app create --app github.com/some_org/some_repo/manifests --project project_name --wait-timeout 2m --context my_context + ``` ### Options ``` - --app string The application specifier (e.g. github.com/argoproj/argo-workflows/manifests/cluster-install/?ref=v3.0.3) - --apps-git-token string Your git provider api token [APPS_GIT_TOKEN] - --apps-repo string Repository URL [APPS_GIT_REPO] - --dest-namespace string K8s target namespace (overrides the namespace specified in the kustomization.yaml) - --dest-server string K8s cluster URL (e.g. https://kubernetes.default.svc) (default "https://kubernetes.default.svc") - -h, --help help for create - --installation-mode string One of: normal|flat. If flat, will commit the application manifests (after running kustomize build), otherwise will commit the kustomization.yaml (default "normal") - -p, --project string Project name - --type string The application type (kustomize|dir) + --app string The application specifier (e.g. github.com/argoproj/argo-workflows/manifests/cluster-install/?ref=v3.0.3) + --apps-git-token string Your git provider api token [APPS_GIT_TOKEN] + --apps-repo string Repository URL [APPS_GIT_REPO] + --as string Username to impersonate for the operation + --as-group stringArray Group to impersonate for the operation, this flag can be repeated to specify multiple groups. + --cache-dir string Default cache directory (default "/home/user/.kube/cache") + --certificate-authority string Path to a cert file for the certificate authority + --client-certificate string Path to a client certificate file for TLS + --client-key string Path to a client key file for TLS + --cluster string The name of the kubeconfig cluster to use + --context string The name of the kubeconfig context to use + --dest-namespace string K8s target namespace (overrides the namespace specified in the kustomization.yaml) + --dest-server string K8s cluster URL (e.g. https://kubernetes.default.svc) (default "https://kubernetes.default.svc") + -h, --help help for create + --insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure + --installation-mode string One of: normal|flat. If flat, will commit the application manifests (after running kustomize build), otherwise will commit the kustomization.yaml (default "normal") + --kubeconfig string Path to the kubeconfig file to use for CLI requests. + -n, --namespace string If present, the namespace scope for this CLI request + -p, --project string Project name + --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 + --tls-server-name string Server name to use for server certificate validation. If it is not provided, the hostname used to contact the server is used + --token string Bearer token for authentication to the API server + --type string The application type (kustomize|dir) + --user string The name of the kubeconfig user to use + --wait-timeout duration If not '0s', will try to connect to the cluster and wait until the application is in 'Synced' status for the specified timeout period ``` ### Options inherited from parent commands diff --git a/pkg/argocd/argocd.go b/pkg/argocd/argocd.go index 489b2c76..5b640e38 100644 --- a/pkg/argocd/argocd.go +++ b/pkg/argocd/argocd.go @@ -3,13 +3,19 @@ package argocd import ( "context" + "github.com/argoproj-labs/argocd-autopilot/pkg/kube" + "github.com/argoproj-labs/argocd-autopilot/pkg/log" "github.com/argoproj-labs/argocd-autopilot/pkg/util" // used to solve this issue: https://github.com/argoproj/argo-cd/issues/2907 _ "github.com/argoproj-labs/argocd-autopilot/util/assets" "github.com/argoproj/argo-cd/v2/cmd/argocd/commands" + "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1" + argocdcd "github.com/argoproj/argo-cd/v2/pkg/client/clientset/versioned" "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) type ( @@ -18,6 +24,11 @@ type ( Execute(ctx context.Context, clusterName string) error } + addClusterImpl struct { + cmd *cobra.Command + args []string + } + LoginOptions struct { Namespace string Username string @@ -25,11 +36,6 @@ type ( } ) -type addClusterImpl struct { - cmd *cobra.Command - args []string -} - func AddClusterAddFlags(cmd *cobra.Command) (AddClusterCmd, error) { root := commands.NewCommand() args := []string{"cluster", "add"} @@ -48,6 +54,31 @@ func AddClusterAddFlags(cmd *cobra.Command) (AddClusterCmd, error) { return &addClusterImpl{root, args}, nil } +func CheckAppSynced(ctx context.Context, f kube.Factory, ns, name string) (bool, error) { + rc, err := f.ToRESTConfig() + if err != nil { + return false, err + } + + c, err := argocdcd.NewForConfig(rc) + if err != nil { + return false, err + } + + app, err := c.ArgoprojV1alpha1().Applications(ns).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + se, ok := err.(*errors.StatusError) + if !ok || se.ErrStatus.Reason != metav1.StatusReasonNotFound { + return false, err + } + + return false, nil + } + + log.G().Debugf("Application found, Sync Status = %s", app.Status.Sync.Status) + return app.Status.Sync.Status == v1alpha1.SyncStatusCodeSynced, nil +} + func (a *addClusterImpl) Execute(ctx context.Context, clusterName string) error { a.cmd.SetArgs(append(a.args, clusterName)) return a.cmd.ExecuteContext(ctx) diff --git a/pkg/git/repository.go b/pkg/git/repository.go index 8d602c6f..1a83a514 100644 --- a/pkg/git/repository.go +++ b/pkg/git/repository.go @@ -40,7 +40,7 @@ type ( FS billy.Filesystem Prefix string CreateIfNotExist bool - Required bool + Optional bool } CloneOptions struct { @@ -118,9 +118,9 @@ func AddFlags(cmd *cobra.Command, opts *AddFlagsOptions) *CloneOptions { 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")) + if !opts.Optional { + util.Die(cmd.MarkPersistentFlagRequired(opts.Prefix + "git-token")) + util.Die(cmd.MarkPersistentFlagRequired(opts.Prefix + "repo")) } return co diff --git a/pkg/git/repository_test.go b/pkg/git/repository_test.go index a47fd23e..27767f24 100644 --- a/pkg/git/repository_test.go +++ b/pkg/git/repository_test.go @@ -726,28 +726,28 @@ func TestAddFlags(t *testing.T) { 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 required": { + "Should create flags with optional": { opts: &AddFlagsOptions{ - Required: true, + Optional: 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, + name: "repo", + usage: "Repository URL [GIT_REPO]", }, }, }, @@ -757,12 +757,14 @@ func TestAddFlags(t *testing.T) { }, wantedFlags: []flag{ { - name: "prefix-git-token", - usage: "Your git provider api token [PREFIX_GIT_TOKEN]", + name: "prefix-git-token", + usage: "Your git provider api token [PREFIX_GIT_TOKEN]", + required: true, }, { - name: "prefix-repo", - usage: "Repository URL [PREFIX_GIT_REPO]", + name: "prefix-repo", + usage: "Repository URL [PREFIX_GIT_REPO]", + required: true, }, }, }, @@ -772,12 +774,14 @@ func TestAddFlags(t *testing.T) { }, wantedFlags: []flag{ { - name: "prefix-git-token", - usage: "Your git provider api token [PREFIX_GIT_TOKEN]", + name: "prefix-git-token", + usage: "Your git provider api token [PREFIX_GIT_TOKEN]", + required: true, }, { - name: "prefix-repo", - usage: "Repository URL [PREFIX_GIT_REPO]", + name: "prefix-repo", + usage: "Repository URL [PREFIX_GIT_REPO]", + required: true, }, }, }, diff --git a/pkg/kube/kube.go b/pkg/kube/kube.go index 45864d6f..2a21165d 100644 --- a/pkg/kube/kube.go +++ b/pkg/kube/kube.go @@ -15,6 +15,7 @@ import ( "k8s.io/apimachinery/pkg/util/wait" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/kubernetes" + restclient "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" "k8s.io/kubectl/pkg/cmd/apply" cmdutil "k8s.io/kubectl/pkg/cmd/util" @@ -49,6 +50,9 @@ type Factory interface { // KubernetesClientSetOrDie calls KubernetesClientSet() and panics if it returns an error KubernetesClientSetOrDie() kubernetes.Interface + // ToRESTConfig returns a rest Config object or error + ToRESTConfig() (*restclient.Config, error) + // Apply applies the provided manifests on the specified namespace Apply(ctx context.Context, namespace string, manifests []byte) error @@ -135,6 +139,10 @@ func (f *factory) KubernetesClientSet() (kubernetes.Interface, error) { return f.f.KubernetesClientSet() } +func (f *factory) ToRESTConfig() (*restclient.Config, error) { + return f.f.ToRESTConfig() +} + func (f *factory) Apply(ctx context.Context, namespace string, manifests []byte) error { reader, buf, err := os.Pipe() if err != nil { diff --git a/pkg/kube/mocks/kube.go b/pkg/kube/mocks/kube.go index f1189e05..5d5d293d 100644 --- a/pkg/kube/mocks/kube.go +++ b/pkg/kube/mocks/kube.go @@ -9,6 +9,8 @@ import ( kubernetes "k8s.io/client-go/kubernetes" mock "github.com/stretchr/testify/mock" + + rest "k8s.io/client-go/rest" ) // Factory is an autogenerated mock type for the Factory type @@ -69,6 +71,29 @@ func (_m *Factory) KubernetesClientSetOrDie() kubernetes.Interface { return r0 } +// ToRESTConfig provides a mock function with given fields: +func (_m *Factory) ToRESTConfig() (*rest.Config, error) { + ret := _m.Called() + + var r0 *rest.Config + if rf, ok := ret.Get(0).(func() *rest.Config); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*rest.Config) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Wait provides a mock function with given fields: _a0, _a1 func (_m *Factory) Wait(_a0 context.Context, _a1 *kube.WaitOptions) error { ret := _m.Called(_a0, _a1)