From 4a48d1e0e02cfa1a4d5a4c318addb7941d07061f Mon Sep 17 00:00:00 2001 From: Niki Date: Wed, 16 Feb 2022 16:57:12 +0100 Subject: [PATCH] Implement `update profile` to patch a profile's HelmRelease (#1445) * Add 'update profile' command - Add cobra CLI command func and required flags - Refactor Add Profile func to re-use some functionality for Update Profile - Add acceptance test * Add validation for updating profile - Refactor profiles funcs to fail if profiles.yaml contains no HelmReleases - Refactor func that appends HelmRelease to file so that update fails if HelmRelease does not exist * Updating an installed profile's version patches it in a list of HelmReleases * Update acceptance tests for updating profile * Address 'update profile' review * Remove sleep from profiles acceptance test * Address review feedback * Pend acceptance test 'SmokeTestLong - Verify that gitops can deploy an app with long name' --- cmd/gitops/add/profiles/cmd.go | 8 +- cmd/gitops/add/profiles/cmd_test.go | 14 +- cmd/gitops/root/cmd.go | 2 + cmd/gitops/update/cmd.go | 23 ++ cmd/gitops/update/profiles/profiles.go | 104 +++++++ .../update/profiles/profiles_suite_test.go | 13 + cmd/gitops/update/profiles/profiles_test.go | 71 +++++ pkg/helm/releases.go | 64 +++++ pkg/helm/releases_test.go | 91 ++++++- pkg/services/profiles/add.go | 120 +++------ pkg/services/profiles/add_test.go | 242 ++++++----------- pkg/services/profiles/get.go | 4 + pkg/services/profiles/get_test.go | 26 +- pkg/services/profiles/profiles.go | 32 +++ pkg/services/profiles/update.go | 145 ++++++++++ pkg/services/profiles/update_test.go | 255 ++++++++++++++++++ test/acceptance/test/add_tests.go | 2 +- test/acceptance/test/profiles_test.go | 22 ++ 18 files changed, 964 insertions(+), 274 deletions(-) create mode 100644 cmd/gitops/update/cmd.go create mode 100644 cmd/gitops/update/profiles/profiles.go create mode 100644 cmd/gitops/update/profiles/profiles_suite_test.go create mode 100644 cmd/gitops/update/profiles/profiles_test.go create mode 100644 pkg/services/profiles/update.go create mode 100644 pkg/services/profiles/update_test.go diff --git a/cmd/gitops/add/profiles/cmd.go b/cmd/gitops/add/profiles/cmd.go index 88721fe13d..4c66e5ff56 100644 --- a/cmd/gitops/add/profiles/cmd.go +++ b/cmd/gitops/add/profiles/cmd.go @@ -3,10 +3,8 @@ package profiles import ( "context" "fmt" - "math/rand" "os" "path/filepath" - "time" "github.com/Masterminds/semver/v3" "github.com/spf13/cobra" @@ -43,10 +41,10 @@ func AddCommand() *cobra.Command { cmd.Flags().StringVar(&opts.Name, "name", "", "Name of the profile") cmd.Flags().StringVar(&opts.Version, "version", "latest", "Version of the profile specified as semver (e.g.: 0.1.0) or as 'latest'") - cmd.Flags().StringVar(&opts.ConfigRepo, "config-repo", "", "URL of external repository (if any) which will hold automation manifests") + cmd.Flags().StringVar(&opts.ConfigRepo, "config-repo", "", "URL of the external repository that contains the automation manifests") cmd.Flags().StringVar(&opts.Cluster, "cluster", "", "Name of the cluster to add the profile to") cmd.Flags().StringVar(&opts.ProfilesPort, "profiles-port", server.DefaultPort, "Port the Profiles API is running on") - cmd.Flags().BoolVar(&opts.AutoMerge, "auto-merge", false, "If set, 'gitops add profile' will merge automatically into the repository's default branch") + cmd.Flags().BoolVar(&opts.AutoMerge, "auto-merge", false, "If set, 'gitops add profile' will merge automatically into the repository's branch") cmd.Flags().StringVar(&opts.Kubeconfig, "kubeconfig", filepath.Join(homedir.HomeDir(), ".kube", "config"), "Absolute path to the kubeconfig file") requiredFlags := []string{"name", "config-repo", "cluster"} @@ -61,8 +59,6 @@ func AddCommand() *cobra.Command { func addProfileCmdRunE() func(*cobra.Command, []string) error { return func(cmd *cobra.Command, args []string) error { - rand.Seed(time.Now().UnixNano()) - log := internal.NewCLILogger(os.Stdout) fluxClient := flux.New(osys.New(), &runner.CLIRunner{}) factory := services.NewFactory(fluxClient, log) diff --git a/cmd/gitops/add/profiles/cmd_test.go b/cmd/gitops/add/profiles/cmd_test.go index 57755f4c4c..1c58f2efb3 100644 --- a/cmd/gitops/add/profiles/cmd_test.go +++ b/cmd/gitops/add/profiles/cmd_test.go @@ -2,28 +2,20 @@ package profiles_test import ( "github.com/go-resty/resty/v2" - "github.com/jarcoal/httpmock" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "github.com/spf13/cobra" "github.com/weaveworks/weave-gitops/cmd/gitops/root" ) -var _ = Describe("Add Profiles", func() { - var ( - cmd *cobra.Command - ) +var _ = Describe("Add a Profile", func() { + var cmd *cobra.Command BeforeEach(func() { client := resty.New() - httpmock.ActivateNonDefault(client.GetClient()) cmd = root.RootCmd(client) }) - AfterEach(func() { - httpmock.DeactivateAndReset() - }) - When("the flags are valid", func() { It("accepts all known flags for adding a profile", func() { cmd.SetArgs([]string{ @@ -42,7 +34,7 @@ var _ = Describe("Add Profiles", func() { }) When("flags are not valid", func() { - It("fails if --name, --cluster, and --config-repo are not provided", func() { + It("fails if --name, --cluster, or --config-repo are not provided", func() { cmd.SetArgs([]string{ "add", "profile", }) diff --git a/cmd/gitops/root/cmd.go b/cmd/gitops/root/cmd.go index 0bc6af69dc..dca910f797 100644 --- a/cmd/gitops/root/cmd.go +++ b/cmd/gitops/root/cmd.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/weaveworks/weave-gitops/cmd/gitops/check" + "github.com/weaveworks/weave-gitops/cmd/gitops/update" "github.com/go-resty/resty/v2" log "github.com/sirupsen/logrus" @@ -140,6 +141,7 @@ func RootCmd(client *resty.Client) *cobra.Command { rootCmd.AddCommand(ui.NewCommand()) rootCmd.AddCommand(get.GetCommand(&options.endpoint, client)) rootCmd.AddCommand(add.GetCommand(&options.endpoint, client)) + rootCmd.AddCommand(update.UpdateCommand(&options.endpoint, client)) rootCmd.AddCommand(delete.DeleteCommand(&options.endpoint, client)) rootCmd.AddCommand(resume.GetCommand()) rootCmd.AddCommand(suspend.GetCommand()) diff --git a/cmd/gitops/update/cmd.go b/cmd/gitops/update/cmd.go new file mode 100644 index 0000000000..09401706e2 --- /dev/null +++ b/cmd/gitops/update/cmd.go @@ -0,0 +1,23 @@ +package update + +import ( + "github.com/weaveworks/weave-gitops/cmd/gitops/update/profiles" + + "github.com/go-resty/resty/v2" + "github.com/spf13/cobra" +) + +func UpdateCommand(endpoint *string, client *resty.Client) *cobra.Command { + cmd := &cobra.Command{ + Use: "update", + Short: "Update a Weave GitOps resource", + Example: ` + # Update a profile that is installed on a cluster + gitops update profile --name=podinfo --cluster=prod --config-repo=ssh://git@github.com/owner/config-repo.git --version=1.0.0 + `, + } + + cmd.AddCommand(profiles.UpdateCommand()) + + return cmd +} diff --git a/cmd/gitops/update/profiles/profiles.go b/cmd/gitops/update/profiles/profiles.go new file mode 100644 index 0000000000..d7bd1a6311 --- /dev/null +++ b/cmd/gitops/update/profiles/profiles.go @@ -0,0 +1,104 @@ +package profiles + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/Masterminds/semver/v3" + "github.com/spf13/cobra" + "github.com/weaveworks/weave-gitops/cmd/internal" + "github.com/weaveworks/weave-gitops/pkg/flux" + "github.com/weaveworks/weave-gitops/pkg/kube" + "github.com/weaveworks/weave-gitops/pkg/osys" + "github.com/weaveworks/weave-gitops/pkg/runner" + "github.com/weaveworks/weave-gitops/pkg/server" + "github.com/weaveworks/weave-gitops/pkg/services" + "github.com/weaveworks/weave-gitops/pkg/services/auth" + "github.com/weaveworks/weave-gitops/pkg/services/profiles" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/util/homedir" +) + +var opts profiles.UpdateOptions + +// UpdateCommand provides support for updating a profile that is installed on a cluster. +func UpdateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "profile", + Short: "Update a profile installation", + SilenceUsage: true, + SilenceErrors: true, + Example: ` + # Update a profile that is installed on a cluster + gitops update profile --name=podinfo --cluster=prod --config-repo=ssh://git@github.com/owner/config-repo.git --version=1.0.0 + `, + RunE: updateProfileCmdRunE(), + } + + cmd.Flags().StringVar(&opts.Name, "name", "", "Name of the profile") + cmd.Flags().StringVar(&opts.Version, "version", "latest", "Version of the profile specified as semver (e.g.: 0.1.0) or as 'latest'") + cmd.Flags().StringVar(&opts.ConfigRepo, "config-repo", "", "URL of the external repository that contains the automation manifests") + cmd.Flags().StringVar(&opts.Cluster, "cluster", "", "Name of the cluster where the profile is installed") + cmd.Flags().StringVar(&opts.ProfilesPort, "profiles-port", server.DefaultPort, "Port the Profiles API is running on") + cmd.Flags().BoolVar(&opts.AutoMerge, "auto-merge", false, "If set, 'gitops update profile' will merge automatically into the repository's branch") + cmd.Flags().StringVar(&opts.Kubeconfig, "kubeconfig", filepath.Join(homedir.HomeDir(), ".kube", "config"), "Absolute path to the kubeconfig file") + + requiredFlags := []string{"name", "config-repo", "cluster", "version"} + for _, f := range requiredFlags { + if err := cobra.MarkFlagRequired(cmd.Flags(), f); err != nil { + panic(fmt.Errorf("unexpected error: %w", err)) + } + } + + return cmd +} + +func updateProfileCmdRunE() func(*cobra.Command, []string) error { + return func(cmd *cobra.Command, args []string) error { + log := internal.NewCLILogger(os.Stdout) + fluxClient := flux.New(osys.New(), &runner.CLIRunner{}) + factory := services.NewFactory(fluxClient, log) + providerClient := internal.NewGitProviderClient(os.Stdout, os.LookupEnv, auth.NewAuthCLIHandler, log) + + if opts.Version != "latest" { + if _, err := semver.StrictNewVersion(opts.Version); err != nil { + return fmt.Errorf("error parsing --version=%s: %w", opts.Version, err) + } + } + + var err error + if opts.Namespace, err = cmd.Flags().GetString("namespace"); err != nil { + return err + } + + config, err := clientcmd.BuildConfigFromFlags("", opts.Kubeconfig) + if err != nil { + return fmt.Errorf("error initializing kubernetes config: %w", err) + } + + clientSet, err := kubernetes.NewForConfig(config) + if err != nil { + return fmt.Errorf("error initializing kubernetes client: %w", err) + } + + kubeClient, _, err := kube.NewKubeHTTPClient() + if err != nil { + return fmt.Errorf("failed to create kube client: %w", err) + } + + _, gitProvider, err := factory.GetGitClients(context.Background(), kubeClient, providerClient, services.GitConfigParams{ + ConfigRepo: opts.ConfigRepo, + Namespace: opts.Namespace, + IsHelmRepository: true, + DryRun: false, + }) + if err != nil { + return fmt.Errorf("failed to get git clients: %w", err) + } + + return profiles.NewService(clientSet, log).Update(context.Background(), gitProvider, opts) + } +} diff --git a/cmd/gitops/update/profiles/profiles_suite_test.go b/cmd/gitops/update/profiles/profiles_suite_test.go new file mode 100644 index 0000000000..f522ba3a04 --- /dev/null +++ b/cmd/gitops/update/profiles/profiles_suite_test.go @@ -0,0 +1,13 @@ +package profiles_test + +import ( + "testing" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" +) + +func TestProfiles(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Profiles Suite") +} diff --git a/cmd/gitops/update/profiles/profiles_test.go b/cmd/gitops/update/profiles/profiles_test.go new file mode 100644 index 0000000000..376e67bbe7 --- /dev/null +++ b/cmd/gitops/update/profiles/profiles_test.go @@ -0,0 +1,71 @@ +package profiles_test + +import ( + "github.com/weaveworks/weave-gitops/cmd/gitops/root" + + "github.com/go-resty/resty/v2" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" +) + +var _ = Describe("Update Profile(s)", func() { + var cmd *cobra.Command + + BeforeEach(func() { + cmd = root.RootCmd(resty.New()) + }) + + When("the flags are valid", func() { + It("accepts all known flags for updating a profile", func() { + cmd.SetArgs([]string{ + "update", "profile", + "--name", "podinfo", + "--version", "0.0.1", + "--cluster", "prod", + "--namespace", "test-namespace", + "--config-repo", "https://ssh@github:test/test.git", + "--auto-merge", "true", + }) + + err := cmd.Execute() + Expect(err.Error()).NotTo(ContainSubstring("unknown flag")) + }) + }) + + When("flags are not valid", func() { + It("fails if --name, --cluster, --version or --config-repo are not provided", func() { + cmd.SetArgs([]string{ + "update", "profile", + }) + + err := cmd.Execute() + Expect(err).To(MatchError("required flag(s) \"cluster\", \"config-repo\", \"name\", \"version\" not set")) + }) + + It("fails if given version is not valid semver", func() { + cmd.SetArgs([]string{ + "update", "profile", + "--name", "podinfo", + "--config-repo", "ssh://git@github.com/owner/config-repo.git", + "--cluster", "prod", + "--version", "&%*/v", + }) + + err := cmd.Execute() + Expect(err).To(MatchError(ContainSubstring("error parsing --version=&%*/v"))) + }) + }) + + When("a flag is unknown", func() { + It("fails", func() { + cmd.SetArgs([]string{ + "update", "profile", + "--unknown", "param", + }) + + err := cmd.Execute() + Expect(err).To(MatchError("unknown flag: --unknown")) + }) + }) +}) diff --git a/pkg/helm/releases.go b/pkg/helm/releases.go index a70b110780..30827be185 100644 --- a/pkg/helm/releases.go +++ b/pkg/helm/releases.go @@ -1,14 +1,22 @@ package helm import ( + "bytes" + "fmt" + "io" + "strings" "time" helmv2beta1 "github.com/fluxcd/helm-controller/api/v2beta1" sourcev1beta1 "github.com/fluxcd/source-controller/api/v1beta1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + k8syaml "k8s.io/apimachinery/pkg/util/yaml" + kyaml "sigs.k8s.io/yaml" ) +const DefaultBufferSize = 2048 + // MakeHelmRelease returns a HelmRelease object given a name, version, cluster, namespace, and HelmRepository's name and namespace. func MakeHelmRelease(name, version, cluster, namespace string, helmRepository types.NamespacedName) *helmv2beta1.HelmRelease { return &helmv2beta1.HelmRelease{ @@ -37,3 +45,59 @@ func MakeHelmRelease(name, version, cluster, namespace string, helmRepository ty }, } } + +// AppendHelmReleaseToString appends "---" and a HelmRelease to string that may or may not be empty. +// This creates the content of a manifest that contains HelmReleases separated by "---". +func AppendHelmReleaseToString(content string, newRelease *helmv2beta1.HelmRelease) (string, error) { + var sb strings.Builder + if content != "" { + sb.WriteString(content + "\n") + } + + helmReleaseManifest, err := kyaml.Marshal(newRelease) + if err != nil { + return "", fmt.Errorf("failed to marshal HelmRelease: %w", err) + } + + sb.WriteString("---\n" + string(helmReleaseManifest)) + + return sb.String(), nil +} + +// SplitHelmReleaseYAML splits a manifest file that contains one or more Helm Releases that may be separated by '---'. +func SplitHelmReleaseYAML(resources []byte) ([]*helmv2beta1.HelmRelease, error) { + var helmReleaseList []*helmv2beta1.HelmRelease + + decoder := k8syaml.NewYAMLOrJSONDecoder(bytes.NewReader(resources), DefaultBufferSize) + + for { + var value helmv2beta1.HelmRelease + if err := decoder.Decode(&value); err != nil { + if err == io.EOF { + break + } + + return nil, err + } + + helmReleaseList = append(helmReleaseList, &value) + } + + return helmReleaseList, nil +} + +// MarshalHelmReleases marshals a list of HelmReleases. +func MarshalHelmReleases(existingReleases []*helmv2beta1.HelmRelease) (string, error) { + var sb strings.Builder + + for _, r := range existingReleases { + b, err := kyaml.Marshal(r) + if err != nil { + return "", fmt.Errorf("failed to marshal: %w", err) + } + + sb.WriteString("---\n" + string(b)) + } + + return sb.String(), nil +} diff --git a/pkg/helm/releases_test.go b/pkg/helm/releases_test.go index 85c2ba7388..afd6814784 100644 --- a/pkg/helm/releases_test.go +++ b/pkg/helm/releases_test.go @@ -3,14 +3,17 @@ package helm_test import ( "time" + "github.com/weaveworks/weave-gitops/pkg/helm" + helmv2beta1 "github.com/fluxcd/helm-controller/api/v2beta1" sourcev1beta1 "github.com/fluxcd/source-controller/api/v1beta1" "github.com/google/go-cmp/cmp" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" - "github.com/weaveworks/weave-gitops/pkg/helm" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/yaml" + kyaml "sigs.k8s.io/yaml" ) var _ = Describe("MakeHelmRelease", func() { @@ -57,6 +60,90 @@ var _ = Describe("MakeHelmRelease", func() { Interval: metav1.Duration{Duration: time.Minute}, }, } - Expect(cmp.Diff(actualHelmRelease, expectedHelmRelease)).To(BeEmpty()) + Expect(cmp.Diff(&actualHelmRelease, &expectedHelmRelease)).To(BeEmpty()) + }) +}) + +var _ = Describe("AppendHelmReleaseToString", func() { + var newRelease *helmv2beta1.HelmRelease + + BeforeEach(func() { + newRelease = helm.MakeHelmRelease( + "podinfo", "6.0.0", "prod", "weave-system", + types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, + ) + }) + + When("the given string is empty", func() { + It("appends a HelmRelease to it", func() { + s, err := helm.AppendHelmReleaseToString("", newRelease) + Expect(err).NotTo(HaveOccurred()) + r, err := yaml.Marshal(newRelease) + Expect(err).NotTo(HaveOccurred()) + Expect(s).To(ContainSubstring(string(r))) + }) + }) + + When("the given string is not empty", func() { + It("appends a HelmRelease to it", func() { + b, _ := kyaml.Marshal(helm.MakeHelmRelease( + "another-profile", "7.0.0", "prod", "test-namespace", + types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, + )) + s, err := helm.AppendHelmReleaseToString(string(b), newRelease) + Expect(err).NotTo(HaveOccurred()) + r, err := yaml.Marshal(newRelease) + Expect(err).NotTo(HaveOccurred()) + Expect(s).To(ContainSubstring(string(r))) + }) + }) +}) + +var _ = Describe("MarshalHelmRelease", func() { + It("returns a string with an updated list of HelmReleases", func() { + release1 := helm.MakeHelmRelease( + "random-profile", "7.0.0", "prod", "weave-system", + types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, + ) + releaseBytes1, _ := kyaml.Marshal(release1) + + release2 := helm.MakeHelmRelease( + "podinfo", "6.0.0", "prod", "weave-system", + types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, + ) + releaseBytes2, _ := kyaml.Marshal(release2) + + patchedContent, err := helm.MarshalHelmReleases([]*helmv2beta1.HelmRelease{release1, release2}) + Expect(err).NotTo(HaveOccurred()) + Expect(cmp.Diff(patchedContent, "---\n"+string(releaseBytes1)+"---\n"+string(releaseBytes2))).To(BeEmpty()) + }) +}) + +var _ = Describe("SplitHelmReleaseYAML", func() { + When("the resource contains only HelmRelease", func() { + It("returns a slice of HelmReleases", func() { + r1 := helm.MakeHelmRelease( + "podinfo", "6.0.0", "prod", "weave-system", + types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, + ) + r2 := helm.MakeHelmRelease( + "profile", "6.0.1", "prod", "test-namespace", + types.NamespacedName{Name: "helm-repo-name", Namespace: "test-namespace"}, + ) + b1, _ := kyaml.Marshal(r1) + bytes := append(b1, []byte("\n---\n")...) + b2, _ := kyaml.Marshal(r2) + list, err := helm.SplitHelmReleaseYAML(append(bytes, b2...)) + Expect(err).NotTo(HaveOccurred()) + Expect(list).To(ContainElements(r1, r2)) + }) + }) + + When("the resource contains any resource other than a HelmRelease", func() { + It("returns an error", func() { + b, _ := kyaml.Marshal("content") + _, err := helm.SplitHelmReleaseYAML(b) + Expect(err).To(MatchError(ContainSubstring("error unmarshaling JSON"))) + }) }) }) diff --git a/pkg/services/profiles/add.go b/pkg/services/profiles/add.go index d58dcd95e8..8e7e82c86d 100644 --- a/pkg/services/profiles/add.go +++ b/pkg/services/profiles/add.go @@ -1,22 +1,18 @@ package profiles import ( - "bytes" "context" "fmt" - "io" - "github.com/google/uuid" "github.com/weaveworks/weave-gitops/pkg/git" "github.com/weaveworks/weave-gitops/pkg/gitproviders" "github.com/weaveworks/weave-gitops/pkg/helm" "github.com/weaveworks/weave-gitops/pkg/models" - "k8s.io/apimachinery/pkg/types" "github.com/fluxcd/go-git-providers/gitprovider" - "github.com/fluxcd/helm-controller/api/v2beta1" - "gopkg.in/yaml.v2" - kyaml "sigs.k8s.io/yaml" + helmv2beta1 "github.com/fluxcd/helm-controller/api/v2beta1" + "github.com/google/uuid" + "k8s.io/apimachinery/pkg/types" ) const AddCommitMessage = "Add Profile manifests" @@ -52,7 +48,7 @@ func (s *ProfilesSvc) Add(ctx context.Context, gitProvider gitproviders.GitProvi return fmt.Errorf("failed to get default branch: %w", err) } - availableProfile, version, err := s.GetProfile(ctx, GetOptions{ + helmRepo, version, err := s.discoverHelmRepository(ctx, GetOptions{ Name: opts.Name, Version: opts.Version, Cluster: opts.Cluster, @@ -60,43 +56,41 @@ func (s *ProfilesSvc) Add(ctx context.Context, gitProvider gitproviders.GitProvi Port: opts.ProfilesPort, }) if err != nil { - return fmt.Errorf("failed to get profiles from cluster: %w", err) - } - - if availableProfile.GetHelmRepository().GetName() == "" || availableProfile.GetHelmRepository().GetNamespace() == "" { - return fmt.Errorf("failed to discover HelmRepository's name and namespace") - } - - helmRepo := types.NamespacedName{ - Name: availableProfile.HelmRepository.Name, - Namespace: availableProfile.HelmRepository.Namespace, + return fmt.Errorf("failed to discover HelmRepository: %w", err) } - newRelease := helm.MakeHelmRelease(opts.Name, version, opts.Cluster, opts.Namespace, helmRepo) + opts.Version = version files, err := gitProvider.GetRepoDirFiles(ctx, configRepoURL, git.GetSystemPath(opts.Cluster), defaultBranch) if err != nil { return fmt.Errorf("failed to get files in '%s' for config repository %q: %s", git.GetSystemPath(opts.Cluster), configRepoURL, err) } - file, err := AppendProfileToFile(files, newRelease, git.GetProfilesPath(opts.Cluster, models.WegoProfilesPath)) + fileContent := getGitCommitFileContent(files, git.GetProfilesPath(opts.Cluster, models.WegoProfilesPath)) + + content, err := addHelmRelease(helmRepo, fileContent, opts.Name, opts.Version, opts.Cluster, opts.Namespace) if err != nil { - return fmt.Errorf("failed to append HelmRelease to profiles file: %w", err) + return fmt.Errorf("failed to add HelmRelease for profile '%s' to %s: %w", opts.Name, models.WegoProfilesPath, err) } + path := git.GetProfilesPath(opts.Cluster, models.WegoProfilesPath) + pr, err := gitProvider.CreatePullRequest(ctx, configRepoURL, gitproviders.PullRequestInfo{ Title: fmt.Sprintf("GitOps add %s", opts.Name), Description: fmt.Sprintf("Add manifest for %s profile", opts.Name), CommitMessage: AddCommitMessage, TargetBranch: defaultBranch, NewBranch: uuid.New().String(), - Files: []gitprovider.CommitFile{file}, + Files: []gitprovider.CommitFile{{ + Path: &path, + Content: &content, + }}, }) if err != nil { return fmt.Errorf("failed to create pull request: %s", err) } - s.Logger.Actionf("Pull Request created: %s", pr.Get().WebURL) + s.Logger.Actionf("created Pull Request: %s", pr.Get().WebURL) if opts.AutoMerge { s.Logger.Actionf("auto-merge=true; merging PR number %v", pr.Get().Number) @@ -119,79 +113,27 @@ func (s *ProfilesSvc) printAddSummary(opts AddOptions) { s.Logger.Println("Namespace: %s\n", opts.Namespace) } -// AppendProfileToFile appends a HelmRelease to profiles.yaml if file does not contain other HelmRelease with the same name and namespace. -func AppendProfileToFile(files []*gitprovider.CommitFile, newRelease *v2beta1.HelmRelease, path string) (gitprovider.CommitFile, error) { - var content string - - for _, f := range files { - if f.Path != nil && *f.Path == path { - if f.Content == nil || *f.Content == "" { - break - } - - manifestByteSlice, err := splitYAML([]byte(*f.Content)) - if err != nil { - return gitprovider.CommitFile{}, fmt.Errorf("error splitting %s: %w", models.WegoProfilesPath, err) - } - - for _, manifestBytes := range manifestByteSlice { - var r v2beta1.HelmRelease - if err := kyaml.Unmarshal(manifestBytes, &r); err != nil { - return gitprovider.CommitFile{}, fmt.Errorf("error unmarshaling %s: %w", models.WegoProfilesPath, err) - } - - if profileIsInstalled(r, *newRelease) { - return gitprovider.CommitFile{}, fmt.Errorf("version %s of profile '%s' already exists in namespace %s", r.Spec.Chart.Spec.Version, r.Name, r.Namespace) - } - } - - content = *f.Content - - break - } - } - - helmReleaseManifest, err := kyaml.Marshal(newRelease) +func addHelmRelease(helmRepo types.NamespacedName, fileContent, name, version, cluster, ns string) (string, error) { + existingReleases, err := helm.SplitHelmReleaseYAML([]byte(fileContent)) if err != nil { - return gitprovider.CommitFile{}, fmt.Errorf("failed to marshal new HelmRelease: %w", err) + return "", fmt.Errorf("error splitting into YAML: %w", err) } - content += "\n---\n" + string(helmReleaseManifest) - - return gitprovider.CommitFile{ - Path: &path, - Content: &content, - }, nil -} - -// splitYAML splits a manifest file that may contain multiple YAML resources separated by '---' -// and validates that each element is YAML. -func splitYAML(resources []byte) ([][]byte, error) { - var splitResources [][]byte + newRelease := helm.MakeHelmRelease(name, version, cluster, ns, helmRepo) - decoder := yaml.NewDecoder(bytes.NewReader(resources)) - - for { - var value interface{} - if err := decoder.Decode(&value); err != nil { - if err == io.EOF { - break - } + if releaseIsInNamespace(existingReleases, newRelease.Name, ns) { + return "", fmt.Errorf("found another HelmRelease for profile '%s' in namespace %s", name, ns) + } - return nil, err - } + return helm.AppendHelmReleaseToString(fileContent, newRelease) +} - valueBytes, err := yaml.Marshal(value) - if err != nil { - return nil, err +func releaseIsInNamespace(existingReleases []*helmv2beta1.HelmRelease, name, ns string) bool { + for _, r := range existingReleases { + if r.Name == name && r.Namespace == ns { + return true } - - splitResources = append(splitResources, valueBytes) } - return splitResources, nil -} - -func profileIsInstalled(r, newRelease v2beta1.HelmRelease) bool { - return r.Name == newRelease.Name && r.Namespace == newRelease.Namespace && r.Spec.Chart.Spec.Version == newRelease.Spec.Chart.Spec.Version + return false } diff --git a/pkg/services/profiles/add_test.go b/pkg/services/profiles/add_test.go index 318ea5b836..a4b6a2066c 100644 --- a/pkg/services/profiles/add_test.go +++ b/pkg/services/profiles/add_test.go @@ -13,7 +13,6 @@ import ( "github.com/weaveworks/weave-gitops/pkg/vendorfakes/fakegitprovider" "github.com/fluxcd/go-git-providers/gitprovider" - "github.com/fluxcd/helm-controller/api/v2beta1" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" "k8s.io/apimachinery/pkg/types" @@ -52,118 +51,134 @@ var _ = Describe("Add", func() { When("the config repository exists", func() { When("the version and HelmRepository name and namespace were discovered", func() { - JustBeforeEach(func() { - gitProviders.RepositoryExistsReturns(true, nil) - gitProviders.GetDefaultBranchReturns("main", nil) - gitProviders.GetRepoDirFilesReturns(makeTestFiles(), nil) - clientSet.AddProxyReactor("services", func(action testing.Action) (handled bool, ret restclient.ResponseWrapper, err error) { - return true, newFakeResponseWrapper(getProfilesResp), nil + When("the HelmRelease was appended to profiles.yaml", func() { + BeforeEach(func() { + gitProviders.RepositoryExistsReturns(true, nil) + gitProviders.GetDefaultBranchReturns("main", nil) + gitProviders.GetRepoDirFilesReturns(makeTestFiles(), nil) + clientSet.AddProxyReactor("services", func(action testing.Action) (handled bool, ret restclient.ResponseWrapper, err error) { + return true, newFakeResponseWrapper(getProfilesResp), nil + }) }) - }) - - JustAfterEach(func() { - Expect(gitProviders.RepositoryExistsCallCount()).To(Equal(1)) - Expect(gitProviders.GetRepoDirFilesCallCount()).To(Equal(1)) - Expect(gitProviders.CreatePullRequestCallCount()).To(Equal(1)) - }) - It("creates a helm release with the latest available version of the profile", func() { - fakePR.GetReturns(gitprovider.PullRequestInfo{ - WebURL: "url", + AfterEach(func() { + Expect(gitProviders.RepositoryExistsCallCount()).To(Equal(1)) + Expect(gitProviders.GetRepoDirFilesCallCount()).To(Equal(1)) + Expect(gitProviders.CreatePullRequestCallCount()).To(Equal(1)) }) - gitProviders.CreatePullRequestReturns(fakePR, nil) - Expect(profilesSvc.Add(context.TODO(), gitProviders, addOptions)).Should(Succeed()) - }) - When("auto-merge is enabled", func() { - It("merges the PR that was created", func() { + It("creates a helm release with the latest available version of the profile", func() { fakePR.GetReturns(gitprovider.PullRequestInfo{ WebURL: "url", - Number: 42, }) gitProviders.CreatePullRequestReturns(fakePR, nil) - addOptions.AutoMerge = true Expect(profilesSvc.Add(context.TODO(), gitProviders, addOptions)).Should(Succeed()) }) - When("the PR fails to be merged", func() { - It("returns an error", func() { + When("auto-merge is enabled", func() { + It("merges the PR that was created", func() { fakePR.GetReturns(gitprovider.PullRequestInfo{ WebURL: "url", + Number: 42, }) gitProviders.CreatePullRequestReturns(fakePR, nil) - gitProviders.MergePullRequestReturns(fmt.Errorf("err")) addOptions.AutoMerge = true - err := profilesSvc.Add(context.TODO(), gitProviders, addOptions) - Expect(err).To(MatchError("error auto-merging PR: err")) + Expect(profilesSvc.Add(context.TODO(), gitProviders, addOptions)).Should(Succeed()) }) - }) - }) - When("an existing version other than 'latest' is specified", func() { - It("creates a helm release with that version", func() { - addOptions.Version = "6.0.0" - fakePR.GetReturns(gitprovider.PullRequestInfo{ - WebURL: "url", + When("the PR fails to be merged", func() { + It("returns an error", func() { + fakePR.GetReturns(gitprovider.PullRequestInfo{ + WebURL: "url", + }) + gitProviders.CreatePullRequestReturns(fakePR, nil) + gitProviders.MergePullRequestReturns(fmt.Errorf("err")) + addOptions.AutoMerge = true + err := profilesSvc.Add(context.TODO(), gitProviders, addOptions) + Expect(err).To(MatchError("error auto-merging PR: err")) + }) }) - fakePR.GetReturns(gitprovider.PullRequestInfo{ - WebURL: "url", + }) + + When("an existing version other than 'latest' is specified", func() { + It("creates a helm release with that version", func() { + addOptions.Version = "6.0.0" + fakePR.GetReturns(gitprovider.PullRequestInfo{ + WebURL: "url", + }) + gitProviders.CreatePullRequestReturns(fakePR, nil) + err := profilesSvc.Add(context.TODO(), gitProviders, addOptions) + Expect(err).To(BeNil()) }) - gitProviders.CreatePullRequestReturns(fakePR, nil) - err := profilesSvc.Add(context.TODO(), gitProviders, addOptions) - Expect(err).To(BeNil()) }) - }) - When("it fails to create a pull request to write the helm release to the config repo", func() { - It("returns an error when ", func() { - gitProviders.CreatePullRequestReturns(nil, fmt.Errorf("err")) - err := profilesSvc.Add(context.TODO(), gitProviders, addOptions) - Expect(err).To(MatchError("failed to create pull request: err")) + When("it fails to create a pull request to write the helm release to the config repo", func() { + It("returns an error", func() { + gitProviders.CreatePullRequestReturns(nil, fmt.Errorf("err")) + err := profilesSvc.Add(context.TODO(), gitProviders, addOptions) + Expect(err).To(MatchError("failed to create pull request: err")) + }) }) }) }) - When("it fails to get a list of available profiles from the cluster", func() { - JustBeforeEach(func() { + When("profiles.yaml contains a HelmRelease with the same name in that namespace", func() { + BeforeEach(func() { gitProviders.RepositoryExistsReturns(true, nil) - gitProviders.GetRepoDirFilesReturns(makeTestFiles(), nil) - }) + gitProviders.GetDefaultBranchReturns("main", nil) - It("fails if it's unable to get a matching available profile from the cluster", func() { + existingRelease := helm.MakeHelmRelease( + "podinfo", "6.0.1", "prod", "weave-system", + types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, + ) + r, _ := yaml.Marshal(existingRelease) + content := string(r) + path := git.GetProfilesPath("prod", models.WegoProfilesPath) + gitProviders.GetRepoDirFilesReturns([]*gitprovider.CommitFile{{ + Path: &path, + Content: &content, + }}, nil) clientSet.AddProxyReactor("services", func(action testing.Action) (handled bool, ret restclient.ResponseWrapper, err error) { - return true, newFakeResponseWrapperWithErr("nope"), nil + return true, newFakeResponseWrapper(getProfilesResp), nil }) - err := profilesSvc.Add(context.TODO(), gitProviders, addOptions) - Expect(err).To(MatchError("failed to get profiles from cluster: failed to make GET request to service weave-system/wego-app path \"/v1/profiles\": nope")) + }) + + AfterEach(func() { Expect(gitProviders.RepositoryExistsCallCount()).To(Equal(1)) + Expect(gitProviders.GetRepoDirFilesCallCount()).To(Equal(1)) }) - It("fails if it's unable to discover the HelmRepository's name and namespace values", func() { + It("fails to append the new HelmRelease to profiles.yaml", func() { + err := profilesSvc.Add(context.TODO(), gitProviders, addOptions) + Expect(err).To(MatchError("failed to add HelmRelease for profile 'podinfo' to profiles.yaml: found another HelmRelease for profile 'podinfo' in namespace weave-system")) + }) + }) + + Context("it fails to discover the HelmRepository name and namespace", func() { + It("fails if it's unable to list available profiles from the cluster", func() { + gitProviders.RepositoryExistsReturns(true, nil) clientSet.AddProxyReactor("services", func(action testing.Action) (handled bool, ret restclient.ResponseWrapper, err error) { - return true, newFakeResponseWrapper(getRespWithoutHelmRepo()), nil + return true, newFakeResponseWrapperWithErr("nope"), nil }) err := profilesSvc.Add(context.TODO(), gitProviders, addOptions) - Expect(err).To(MatchError("failed to discover HelmRepository's name and namespace")) + Expect(err).To(MatchError("failed to discover HelmRepository: failed to get profiles from cluster: failed to make GET request to service weave-system/wego-app path \"/v1/profiles\": nope")) Expect(gitProviders.RepositoryExistsCallCount()).To(Equal(1)) }) - }) - When("it fails to find a matching version", func() { - It("returns an error", func() { + It("fails to find an available profile with the given version", func() { gitProviders.RepositoryExistsReturns(true, nil) clientSet.AddProxyReactor("services", func(action testing.Action) (handled bool, ret restclient.ResponseWrapper, err error) { return true, newFakeResponseWrapper(getProfilesResp), nil }) addOptions.Version = "7.0.0" err := profilesSvc.Add(context.TODO(), gitProviders, addOptions) - Expect(err).To(MatchError("failed to get profiles from cluster: version '7.0.0' not found for profile 'podinfo' in prod/weave-system")) + Expect(err).To(MatchError("failed to discover HelmRepository: failed to get profiles from cluster: version '7.0.0' not found for profile 'podinfo' in prod/weave-system")) Expect(gitProviders.RepositoryExistsCallCount()).To(Equal(1)) }) }) }) - When("the config repository exists", func() { + When("the config repository does not exist", func() { It("fails if the --config-repo url format is wrong", func() { addOptions = profiles.AddOptions{ Name: "foo", @@ -184,76 +199,6 @@ var _ = Describe("Add", func() { }) }) -var _ = Describe("AppendProfileToFile", func() { - var ( - newRelease *v2beta1.HelmRelease - existingFile *gitprovider.CommitFile - path string - content string - ) - - BeforeEach(func() { - newRelease = helm.MakeHelmRelease( - "podinfo", "6.0.0", "prod", "weave-system", - types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, - ) - path = git.GetProfilesPath("prod", models.WegoProfilesPath) - }) - - When("profiles.yaml does not exist", func() { - It("creates one with the new helm release", func() { - file, err := profiles.AppendProfileToFile(makeTestFiles(), newRelease, path) - Expect(err).NotTo(HaveOccurred()) - r, err := yaml.Marshal(newRelease) - Expect(err).NotTo(HaveOccurred()) - Expect(*file.Content).To(ContainSubstring(string(r))) - }) - }) - - When("profiles.yaml exists", func() { - When("the manifest contain a release with the same name in that namespace", func() { - When("the version is different", func() { - It("appends the release to the manifest", func() { - existingRelease := helm.MakeHelmRelease( - "podinfo", "6.0.1", "prod", "weave-system", - types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, - ) - r, _ := yaml.Marshal(existingRelease) - content = string(r) - file, err := profiles.AppendProfileToFile([]*gitprovider.CommitFile{{ - Path: &path, - Content: &content, - }}, newRelease, path) - Expect(err).NotTo(HaveOccurred()) - Expect(*file.Content).To(ContainSubstring(string(r))) - }) - }) - - When("the version is the same", func() { - It("fails to add the profile", func() { - existingRelease, _ := yaml.Marshal(newRelease) - content = string(existingRelease) - existingFile = &gitprovider.CommitFile{ - Path: &path, - Content: &content, - } - _, err := profiles.AppendProfileToFile([]*gitprovider.CommitFile{existingFile}, newRelease, path) - Expect(err).To(MatchError("version 6.0.0 of profile 'prod-podinfo' already exists in namespace weave-system")) - }) - }) - }) - - It("fails if the manifest contains a resource that is not a HelmRelease", func() { - content = "content" - _, err := profiles.AppendProfileToFile([]*gitprovider.CommitFile{{ - Path: &path, - Content: &content, - }}, newRelease, path) - Expect(err).To(MatchError("error unmarshaling profiles.yaml: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type v2beta1.HelmRelease")) - }) - }) -}) - func makeTestFiles() []*gitprovider.CommitFile { path0 := ".weave-gitops/clusters/prod/system/wego-system.yaml" content0 := "machine1 yaml content" @@ -281,34 +226,3 @@ func makeTestFiles() []*gitprovider.CommitFile { return commitFiles } - -func getRespWithoutHelmRepo() string { - return `{ - "profiles": [ - { - "name": "podinfo", - "home": "https://github.com/stefanprodan/podinfo", - "sources": [ - "https://github.com/stefanprodan/podinfo" - ], - "description": "Podinfo Helm chart for Kubernetes", - "keywords": [], - "maintainers": [ - { - "name": "stefanprodan", - "email": "stefanprodan@users.noreply.github.com", - "url": "" - } - ], - "icon": "", - "annotations": {}, - "kubeVersion": ">=1.19.0-0", - "availableVersions": [ - "6.0.0", - "6.0.1" - ] - } - ] - } - ` -} diff --git a/pkg/services/profiles/get.go b/pkg/services/profiles/get.go index 62e8e6c12f..fa21f17fa2 100644 --- a/pkg/services/profiles/get.go +++ b/pkg/services/profiles/get.go @@ -86,6 +86,10 @@ func (s *ProfilesSvc) GetProfile(ctx context.Context, opts GetOptions) (*pb.Prof version = opts.Version } + if p.GetHelmRepository().GetName() == "" || p.GetHelmRepository().GetNamespace() == "" { + return nil, "", fmt.Errorf("HelmRepository's name or namespace is empty") + } + return p, version, nil } } diff --git a/pkg/services/profiles/get_test.go b/pkg/services/profiles/get_test.go index b494b4ff14..281a917ed2 100644 --- a/pkg/services/profiles/get_test.go +++ b/pkg/services/profiles/get_test.go @@ -154,7 +154,7 @@ podinfo Podinfo Helm chart for Kubernetes 6.0.0,6.0.1 Expect(version).To(Equal("6.0.1")) }) - It("it fails to return a list of available profiles from the cluster", func() { + It("fails to return a list of available profiles from the cluster", func() { clientSet.AddProxyReactor("services", func(action testing.Action) (handled bool, ret restclient.ResponseWrapper, err error) { return true, newFakeResponseWrapperWithErr("nope"), nil }) @@ -195,6 +195,30 @@ podinfo Podinfo Helm chart for Kubernetes 6.0.0,6.0.1 _, _, err := profilesSvc.GetProfile(context.TODO(), opts) Expect(err).To(MatchError("no version found for profile 'podinfo' in prod/test-namespace")) }) + + It("fails if the available profile's HelmRepository name or namespace are empty", func() { + badProfileResp := `{ + "profiles": [ + { + "name": "podinfo", + "helmRepository": { + "name": "", + "namespace": "" + }, + "availableVersions": [ + "6.0.0", + "6.0.1" + ] + } + ] + } + ` + clientSet.AddProxyReactor("services", func(action testing.Action) (handled bool, ret restclient.ResponseWrapper, err error) { + return true, newFakeResponseWrapper(badProfileResp), nil + }) + _, _, err := profilesSvc.GetProfile(context.TODO(), opts) + Expect(err).To(MatchError("HelmRepository's name or namespace is empty")) + }) }) }) diff --git a/pkg/services/profiles/profiles.go b/pkg/services/profiles/profiles.go index fde640411b..18bb975cb6 100644 --- a/pkg/services/profiles/profiles.go +++ b/pkg/services/profiles/profiles.go @@ -2,9 +2,13 @@ package profiles import ( "context" + "fmt" + "github.com/fluxcd/go-git-providers/gitprovider" "github.com/weaveworks/weave-gitops/pkg/gitproviders" "github.com/weaveworks/weave-gitops/pkg/logger" + + "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" ) @@ -21,6 +25,8 @@ type ProfilesService interface { Add(ctx context.Context, gitProvider gitproviders.GitProvider, opts AddOptions) error // Get lists all the available profiles in a cluster Get(ctx context.Context, opts GetOptions) error + // Update updates a profile + Update(ctx context.Context, gitProvider gitproviders.GitProvider, opts UpdateOptions) error } type ProfilesSvc struct { @@ -34,3 +40,29 @@ func NewService(clientSet kubernetes.Interface, log logger.Logger) *ProfilesSvc Logger: log, } } + +func (s *ProfilesSvc) discoverHelmRepository(ctx context.Context, opts GetOptions) (types.NamespacedName, string, error) { + availableProfile, version, err := s.GetProfile(ctx, opts) + if err != nil { + return types.NamespacedName{}, "", fmt.Errorf("failed to get profiles from cluster: %w", err) + } + + return types.NamespacedName{ + Name: availableProfile.HelmRepository.Name, + Namespace: availableProfile.HelmRepository.Namespace, + }, version, nil +} + +func getGitCommitFileContent(files []*gitprovider.CommitFile, filePath string) string { + for _, f := range files { + if f.Path != nil && *f.Path == filePath { + if f.Content == nil || *f.Content == "" { + return "" + } + + return *f.Content + } + } + + return "" +} diff --git a/pkg/services/profiles/update.go b/pkg/services/profiles/update.go new file mode 100644 index 0000000000..61093b80eb --- /dev/null +++ b/pkg/services/profiles/update.go @@ -0,0 +1,145 @@ +package profiles + +import ( + "context" + "fmt" + + "github.com/weaveworks/weave-gitops/pkg/git" + "github.com/weaveworks/weave-gitops/pkg/gitproviders" + "github.com/weaveworks/weave-gitops/pkg/helm" + "github.com/weaveworks/weave-gitops/pkg/models" + + "github.com/fluxcd/go-git-providers/gitprovider" + helmv2beta1 "github.com/fluxcd/helm-controller/api/v2beta1" + "github.com/google/uuid" +) + +const UpdateCommitMessage = "Update Profile manifests" + +type UpdateOptions struct { + Name string + Cluster string + ConfigRepo string + Version string + ProfilesPort string + Namespace string + Kubeconfig string + AutoMerge bool +} + +// Update updates an installed profile +func (s *ProfilesSvc) Update(ctx context.Context, gitProvider gitproviders.GitProvider, opts UpdateOptions) error { + configRepoURL, err := gitproviders.NewRepoURL(opts.ConfigRepo) + if err != nil { + return fmt.Errorf("failed to parse url: %w", err) + } + + repoExists, err := gitProvider.RepositoryExists(ctx, configRepoURL) + if err != nil { + return fmt.Errorf("failed to check whether repository exists: %w", err) + } else if !repoExists { + return fmt.Errorf("repository %q could not be found", configRepoURL) + } + + defaultBranch, err := gitProvider.GetDefaultBranch(ctx, configRepoURL) + if err != nil { + return fmt.Errorf("failed to get default branch: %w", err) + } + + _, version, err := s.discoverHelmRepository(ctx, GetOptions{ + Name: opts.Name, + Version: opts.Version, + Cluster: opts.Cluster, + Namespace: opts.Namespace, + Port: opts.ProfilesPort, + }) + if err != nil { + return fmt.Errorf("failed to discover HelmRepository: %w", err) + } + + opts.Version = version + + files, err := gitProvider.GetRepoDirFiles(ctx, configRepoURL, git.GetSystemPath(opts.Cluster), defaultBranch) + if err != nil { + return fmt.Errorf("failed to get files in '%s' of config repository %q: %s", git.GetSystemPath(opts.Cluster), configRepoURL, err) + } + + content, err := updateHelmRelease(files, opts.Name, opts.Version, opts.Cluster, opts.Namespace) + if err != nil { + return fmt.Errorf("failed to update HelmRelease for profile '%s' in %s: %w", opts.Name, models.WegoProfilesPath, err) + } + + path := git.GetProfilesPath(opts.Cluster, models.WegoProfilesPath) + + pr, err := gitProvider.CreatePullRequest(ctx, configRepoURL, gitproviders.PullRequestInfo{ + Title: fmt.Sprintf("GitOps update %s", opts.Name), + Description: fmt.Sprintf("Update manifest for %s profile", opts.Name), + CommitMessage: UpdateCommitMessage, + TargetBranch: defaultBranch, + NewBranch: uuid.New().String(), + Files: []gitprovider.CommitFile{{ + Path: &path, + Content: &content, + }}, + }) + if err != nil { + return fmt.Errorf("failed to create pull request: %s", err) + } + + s.Logger.Actionf("created Pull Request: %s", pr.Get().WebURL) + + if opts.AutoMerge { + s.Logger.Actionf("auto-merge=true; merging PR number %v", pr.Get().Number) + + if err := gitProvider.MergePullRequest(ctx, configRepoURL, pr.Get().Number, AddCommitMessage); err != nil { + return fmt.Errorf("error auto-merging PR: %w", err) + } + } + + s.printUpdateSummary(opts) + + return nil +} + +func (s *ProfilesSvc) printUpdateSummary(opts UpdateOptions) { + s.Logger.Println("Updating profile:\n") + s.Logger.Println("Name: %s", opts.Name) + s.Logger.Println("Version: %s", opts.Version) + s.Logger.Println("Cluster: %s", opts.Cluster) + s.Logger.Println("Namespace: %s\n", opts.Namespace) +} + +func updateHelmRelease(files []*gitprovider.CommitFile, name, version, cluster, ns string) (string, error) { + fileContent := getGitCommitFileContent(files, git.GetProfilesPath(cluster, models.WegoProfilesPath)) + if fileContent == "" { + return "", fmt.Errorf("failed to find installed profiles in '%s'", git.GetProfilesPath(cluster, models.WegoProfilesPath)) + } + + existingReleases, err := helm.SplitHelmReleaseYAML([]byte(fileContent)) + if err != nil { + return "", fmt.Errorf("error splitting into YAML: %w", err) + } + + updatedReleases, err := patchRelease(existingReleases, cluster+"-"+name, ns, version) + if err != nil { + return "", err + } + + return helm.MarshalHelmReleases(updatedReleases) +} + +func patchRelease(existingReleases []*helmv2beta1.HelmRelease, name, ns, version string) ([]*helmv2beta1.HelmRelease, error) { + for _, r := range existingReleases { + if r.Name == name && r.Namespace == ns { + if r.Spec.Chart.Spec.Version == version { + return nil, fmt.Errorf("version %s of HelmRelease '%s' already installed in namespace '%s'", version, name, ns) + } + + r.Spec.Chart.Spec.Version = version + + return existingReleases, nil + } + } + + return nil, fmt.Errorf("failed to find HelmRelease '%s' in namespace '%s'", name, ns) +} diff --git a/pkg/services/profiles/update_test.go b/pkg/services/profiles/update_test.go new file mode 100644 index 0000000000..816c6e2a58 --- /dev/null +++ b/pkg/services/profiles/update_test.go @@ -0,0 +1,255 @@ +package profiles_test + +import ( + "context" + "fmt" + + "github.com/fluxcd/go-git-providers/gitprovider" + "github.com/weaveworks/weave-gitops/pkg/git" + "github.com/weaveworks/weave-gitops/pkg/gitproviders/gitprovidersfakes" + "github.com/weaveworks/weave-gitops/pkg/helm" + "github.com/weaveworks/weave-gitops/pkg/logger/loggerfakes" + "github.com/weaveworks/weave-gitops/pkg/models" + "github.com/weaveworks/weave-gitops/pkg/services/profiles" + "github.com/weaveworks/weave-gitops/pkg/vendorfakes/fakegitprovider" + "sigs.k8s.io/yaml" + + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/fake" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/testing" +) + +var updateOptions profiles.UpdateOptions + +var _ = Describe("Update Profile(s)", func() { + var ( + gitProviders *gitprovidersfakes.FakeGitProvider + profilesSvc *profiles.ProfilesSvc + clientSet *fake.Clientset + fakeLogger *loggerfakes.FakeLogger + fakePR *fakegitprovider.PullRequest + ) + + BeforeEach(func() { + gitProviders = &gitprovidersfakes.FakeGitProvider{} + clientSet = fake.NewSimpleClientset() + fakeLogger = &loggerfakes.FakeLogger{} + fakePR = &fakegitprovider.PullRequest{} + profilesSvc = profiles.NewService(clientSet, fakeLogger) + + updateOptions = profiles.UpdateOptions{ + ConfigRepo: "ssh://git@github.com/owner/config-repo.git", + Name: "podinfo", + Cluster: "prod", + Namespace: "weave-system", + Version: "latest", + } + }) + + When("the config repository exists", func() { + BeforeEach(func() { + gitProviders.RepositoryExistsReturns(true, nil) + gitProviders.GetDefaultBranchReturns("main", nil) + }) + + AfterEach(func() { + Expect(gitProviders.RepositoryExistsCallCount()).To(Equal(1)) + }) + + When("the version and HelmRepository name and namespace were discovered", func() { + BeforeEach(func() { + clientSet.AddProxyReactor("services", func(action testing.Action) (handled bool, ret restclient.ResponseWrapper, err error) { + return true, newFakeResponseWrapper(getProfilesResp), nil + }) + }) + + When("the file containing the HelmReleases is not empty", func() { + AfterEach(func() { + Expect(gitProviders.GetRepoDirFilesCallCount()).To(Equal(1)) + }) + + When("a matching HelmRelease is found", func() { + When("the existing HelmRelease is a different version than the want to update to", func() { + BeforeEach(func() { + existingRelease := helm.MakeHelmRelease( + "podinfo", "6.0.0", "prod", "weave-system", + types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, + ) + r, _ := yaml.Marshal(existingRelease) + content := string(r) + path := git.GetProfilesPath("prod", models.WegoProfilesPath) + gitProviders.GetRepoDirFilesReturns([]*gitprovider.CommitFile{{ + Path: &path, + Content: &content, + }}, nil) + }) + + AfterEach(func() { + Expect(gitProviders.CreatePullRequestCallCount()).To(Equal(1)) + }) + + When("it opens a PR", func() { + It("updates the HelmRelease version", func() { + fakePR.GetReturns(gitprovider.PullRequestInfo{ + WebURL: "url", + }) + gitProviders.CreatePullRequestReturns(fakePR, nil) + Expect(profilesSvc.Update(context.TODO(), gitProviders, updateOptions)).Should(Succeed()) + }) + }) + + When("auto-merge is enabled", func() { + It("merges the PR that was created", func() { + fakePR.GetReturns(gitprovider.PullRequestInfo{ + WebURL: "url", + Number: 42, + }) + gitProviders.CreatePullRequestReturns(fakePR, nil) + updateOptions.AutoMerge = true + Expect(profilesSvc.Update(context.TODO(), gitProviders, updateOptions)).Should(Succeed()) + }) + + When("the PR fails to be merged", func() { + It("returns an error", func() { + fakePR.GetReturns(gitprovider.PullRequestInfo{ + WebURL: "url", + }) + gitProviders.CreatePullRequestReturns(fakePR, nil) + gitProviders.MergePullRequestReturns(fmt.Errorf("err")) + updateOptions.AutoMerge = true + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(MatchError("error auto-merging PR: err")) + }) + }) + }) + + When("a version other than 'latest' is specified", func() { + It("creates a helm release with that version", func() { + updateOptions.Version = "6.0.1" + fakePR.GetReturns(gitprovider.PullRequestInfo{ + WebURL: "url", + }) + gitProviders.CreatePullRequestReturns(fakePR, nil) + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(BeNil()) + }) + }) + + When("the PR fails to be merged", func() { + It("returns an error", func() { + gitProviders.CreatePullRequestReturns(nil, fmt.Errorf("err")) + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(MatchError("failed to create pull request: err")) + }) + }) + }) + + When("an existing HelmRelease is the same version as the one to update to", func() { + It("returns an error", func() { + existingRelease := helm.MakeHelmRelease( + "podinfo", "6.0.1", "prod", "weave-system", + types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, + ) + r, _ := yaml.Marshal(existingRelease) + content := string(r) + path := git.GetProfilesPath("prod", models.WegoProfilesPath) + gitProviders.GetRepoDirFilesReturns([]*gitprovider.CommitFile{{ + Path: &path, + Content: &content, + }}, nil) + + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(MatchError("failed to update HelmRelease for profile 'podinfo' in profiles.yaml: version 6.0.1 of HelmRelease 'prod-podinfo' already installed in namespace 'weave-system'")) + }) + }) + }) + + When("the file containing the HelmReleases does not contain a HelmRelease with the given name and namespace", func() { + It("returns an error", func() { + existingRelease := helm.MakeHelmRelease( + "random-profile", "6.0.1", "prod", "weave-system", + types.NamespacedName{Name: "helm-repo-name", Namespace: "helm-repo-namespace"}, + ) + r, _ := yaml.Marshal(existingRelease) + content := string(r) + path := git.GetProfilesPath("prod", models.WegoProfilesPath) + gitProviders.GetRepoDirFilesReturns([]*gitprovider.CommitFile{{ + Path: &path, + Content: &content, + }}, nil) + + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(MatchError("failed to update HelmRelease for profile 'podinfo' in profiles.yaml: failed to find HelmRelease 'prod-podinfo' in namespace 'weave-system'")) + }) + }) + + When("the file containing the HelmRelease contains something other than a HelmRelease", func() { + It("returns an error", func() { + content := "content" + path := git.GetProfilesPath("prod", models.WegoProfilesPath) + gitProviders.GetRepoDirFilesReturns([]*gitprovider.CommitFile{{ + Path: &path, + Content: &content, + }}, nil) + + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(MatchError("failed to update HelmRelease for profile 'podinfo' in profiles.yaml: error splitting into YAML: error unmarshaling JSON: while decoding JSON: json: cannot unmarshal string into Go value of type v2beta1.HelmRelease")) + }) + }) + + When("the file containing the HelmReleases is empty", func() { + It("returns an error", func() { + gitProviders.GetRepoDirFilesReturns(makeTestFiles(), nil) + + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(MatchError(ContainSubstring("failed to find installed profiles in '.weave-gitops/clusters/prod/system/profiles.yaml'"))) + + Expect(gitProviders.GetRepoDirFilesCallCount()).To(Equal(1)) + }) + }) + }) + }) + + Context("it fails to discover the HelmRepository name and namespace", func() { + It("fails if it's unable to list available profiles from the cluster", func() { + clientSet.AddProxyReactor("services", func(action testing.Action) (handled bool, ret restclient.ResponseWrapper, err error) { + return true, newFakeResponseWrapperWithErr("nope"), nil + }) + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(MatchError("failed to discover HelmRepository: failed to get profiles from cluster: failed to make GET request to service weave-system/wego-app path \"/v1/profiles\": nope")) + }) + + It("fails to find an available profile with the given version", func() { + clientSet.AddProxyReactor("services", func(action testing.Action) (handled bool, ret restclient.ResponseWrapper, err error) { + return true, newFakeResponseWrapper(getProfilesResp), nil + }) + updateOptions.Version = "7.0.0" + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(MatchError("failed to discover HelmRepository: failed to get profiles from cluster: version '7.0.0' not found for profile 'podinfo' in prod/weave-system")) + }) + }) + }) + + When("the config repository does not exist", func() { + It("fails if the --config-repo url format is wrong", func() { + updateOptions = profiles.UpdateOptions{ + Name: "foo", + ConfigRepo: "{http:/-*wrong-url-827", + Cluster: "prod", + } + + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(MatchError(ContainSubstring("failed to parse url: could not get provider name from URL {http:/-*wrong-url-827"))) + }) + + It("fails if the config repo does not exist", func() { + gitProviders.RepositoryExistsReturns(false, nil) + err := profilesSvc.Update(context.TODO(), gitProviders, updateOptions) + Expect(err).To(MatchError("repository \"ssh://git@github.com/owner/config-repo.git\" could not be found")) + Expect(gitProviders.RepositoryExistsCallCount()).To(Equal(1)) + }) + }) +}) diff --git a/test/acceptance/test/add_tests.go b/test/acceptance/test/add_tests.go index 9c144e8e37..e06e8afc70 100644 --- a/test/acceptance/test/add_tests.go +++ b/test/acceptance/test/add_tests.go @@ -1551,7 +1551,7 @@ var _ = Describe("Weave GitOps Add Tests With Long Cluster Name", func() { }) }) - It("SmokeTestLong - Verify that gitops can deploy an app with long name", func() { + PIt("SmokeTestLong - Verify that gitops can deploy an app with long name", func() { var configRepoRemoteURL string var listOutput string var appStatus string diff --git a/test/acceptance/test/profiles_test.go b/test/acceptance/test/profiles_test.go index 667cae16aa..be7df52196 100644 --- a/test/acceptance/test/profiles_test.go +++ b/test/acceptance/test/profiles_test.go @@ -129,6 +129,28 @@ Namespace: %s`, clusterName, namespace))) resp, statusCode, err = kubernetesDoRequest(namespace, clusterName+"-"+profileName, "9898", "/healthz", clientSet) return statusCode }, "120s", "1s").Should(Equal(http.StatusOK)) + + By("Updating the version of the installed profile") + Eventually(func() string { + stdOut, stdErr = runCommandAndReturnStringOutput(fmt.Sprintf("%s update profile --name %s --version 6.0.0 --namespace %s --cluster %s --config-repo %s --auto-merge", gitopsBinaryPath, profileName, namespace, clusterName, appRepoRemoteURL)) + return stdErr + }, "240s", "10s").Should(BeEmpty()) + + Expect(stdOut).To(ContainSubstring( + fmt.Sprintf( + `Updating profile: + +Name: podinfo +Version: 6.0.0 +Cluster: %s +Namespace: %s`, clusterName, namespace))) + + By("Verifying that the profile installed in the cluster's namespace was updated to the correct version") + Eventually(func() string { + stdOut, stdErr = runCommandAndReturnStringOutput(fmt.Sprintf("kubectl get pods -n %s --selector=app.kubernetes.io/name=%s-%s -o jsonpath='{.items[*].spec.containers[*].image}'", namespace, clusterName, profileName)) + Expect(stdErr).Should(BeEmpty()) + return stdOut + }, "240s", "5s").Should(ContainSubstring("ghcr.io/stefanprodan/podinfo:6.0.0")) }) It("@skipOnNightly profiles are installed into a different namespace", func() {