github.com/freiheit-com/kuberpult@v1.24.2-0.20240328135542-315d5630abe6/services/cd-service/pkg/argocd/reposerver/reposerver_test.go (about)

     1  /*This file is part of kuberpult.
     2  
     3  Kuberpult is free software: you can redistribute it and/or modify
     4  it under the terms of the Expat(MIT) License as published by
     5  the Free Software Foundation.
     6  
     7  Kuberpult is distributed in the hope that it will be useful,
     8  but WITHOUT ANY WARRANTY; without even the implied warranty of
     9  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    10  MIT License for more details.
    11  
    12  You should have received a copy of the MIT License
    13  along with kuberpult. If not, see <https://directory.fsf.org/wiki/License:Expat>.
    14  
    15  Copyright 2023 freiheit.com*/
    16  
    17  package reposerver
    18  
    19  import (
    20  	"context"
    21  	"os"
    22  	"os/exec"
    23  	"path"
    24  	"path/filepath"
    25  	"regexp"
    26  	"strings"
    27  	"testing"
    28  	"time"
    29  
    30  	"github.com/freiheit-com/kuberpult/services/cd-service/pkg/repository/testutil"
    31  
    32  	v1alpha1 "github.com/argoproj/argo-cd/v2/pkg/apis/application/v1alpha1"
    33  	argorepo "github.com/argoproj/argo-cd/v2/reposerver/apiclient"
    34  	"github.com/argoproj/argo-cd/v2/reposerver/cache"
    35  	"github.com/argoproj/argo-cd/v2/reposerver/metrics"
    36  	argosrv "github.com/argoproj/argo-cd/v2/reposerver/repository"
    37  	"github.com/argoproj/argo-cd/v2/util/argo"
    38  	cacheutil "github.com/argoproj/argo-cd/v2/util/cache"
    39  	"github.com/argoproj/argo-cd/v2/util/git"
    40  	"github.com/freiheit-com/kuberpult/services/cd-service/pkg/config"
    41  	"github.com/freiheit-com/kuberpult/services/cd-service/pkg/repository"
    42  	"github.com/google/go-cmp/cmp"
    43  	"github.com/google/go-cmp/cmp/cmpopts"
    44  	"google.golang.org/protobuf/testing/protocmp"
    45  )
    46  
    47  // Used to compare two error message strings, needed because errors.Is(fmt.Errorf(text),fmt.Errorf(text)) == false
    48  type errMatcher struct {
    49  	msg string
    50  }
    51  
    52  func (e errMatcher) Error() string {
    53  	return e.msg
    54  }
    55  
    56  func (e errMatcher) Is(err error) bool {
    57  	return e.Error() == err.Error()
    58  }
    59  
    60  var createOneAppInDevelopment []repository.Transformer = []repository.Transformer{
    61  	&repository.CreateEnvironment{
    62  		Environment: "development",
    63  		Config: config.EnvironmentConfig{
    64  			Upstream: &config.EnvironmentConfigUpstream{
    65  				Latest: true,
    66  			},
    67  		},
    68  	},
    69  	&repository.CreateApplicationVersion{
    70  		Application: "app",
    71  		Manifests: map[string]string{
    72  			"development": `
    73  api: v1
    74  kind: ConfigMap
    75  metadata:
    76    name: something
    77    namespace: something
    78  data:
    79    key: value
    80  ---
    81  api: v1
    82  kind: ConfigMap
    83  metadata:
    84    name: somethingelse
    85    namespace: somethingelse
    86  data:
    87    key: value
    88  `,
    89  		},
    90  	},
    91  }
    92  
    93  var createOneAppInDevelopmentAndTesting []repository.Transformer = []repository.Transformer{
    94  	&repository.CreateEnvironment{
    95  		Environment: "development",
    96  		Config: config.EnvironmentConfig{
    97  			Upstream: &config.EnvironmentConfigUpstream{
    98  				Latest: true,
    99  			},
   100  			ArgoCd: &config.EnvironmentConfigArgoCd{
   101  				Destination: config.ArgoCdDestination{
   102  					Server: "development",
   103  				},
   104  			},
   105  		},
   106  	},
   107  	&repository.CreateEnvironment{
   108  		Environment: "testing",
   109  		Config: config.EnvironmentConfig{
   110  			Upstream: &config.EnvironmentConfigUpstream{
   111  				Latest: true,
   112  			},
   113  			ArgoCd: &config.EnvironmentConfigArgoCd{
   114  				Destination: config.ArgoCdDestination{
   115  					Server: "testing",
   116  				},
   117  			},
   118  		},
   119  	},
   120  	&repository.CreateApplicationVersion{
   121  		Application: "app",
   122  		Manifests: map[string]string{
   123  			"development": `
   124  api: v1
   125  kind: ConfigMap
   126  metadata:
   127    name: something
   128    namespace: something
   129  data:
   130    key: value`,
   131  			"testing": `
   132  api: v1
   133  kind: ConfigMap
   134  metadata:
   135    name: something
   136    namespace: something
   137  data:
   138    key: value`,
   139  		},
   140  	},
   141  }
   142  
   143  func TestGenerateManifest(t *testing.T) {
   144  	tcs := []struct {
   145  		Name              string
   146  		Setup             []repository.Transformer
   147  		Request           *argorepo.ManifestRequest
   148  		ExpectedResponse  *argorepo.ManifestResponse
   149  		ExpectedError     error
   150  		ExpectedArgoError *regexp.Regexp
   151  	}{
   152  		{
   153  			Name:  "generates a manifest for HEAD",
   154  			Setup: createOneAppInDevelopment,
   155  			Request: &argorepo.ManifestRequest{
   156  				Revision: "HEAD",
   157  				Repo: &v1alpha1.Repository{
   158  					Repo: "<the-repo-url>",
   159  				},
   160  				ApplicationSource: &v1alpha1.ApplicationSource{
   161  					Path: "environments/development/applications/app/manifests",
   162  				},
   163  			},
   164  
   165  			ExpectedResponse: &argorepo.ManifestResponse{
   166  				Manifests: []string{
   167  					`{"api":"v1","data":{"key":"value"},"kind":"ConfigMap","metadata":{"name":"something","namespace":"something"}}`,
   168  					`{"api":"v1","data":{"key":"value"},"kind":"ConfigMap","metadata":{"name":"somethingelse","namespace":"somethingelse"}}`,
   169  				},
   170  				SourceType: "Directory",
   171  			},
   172  		},
   173  		{
   174  			Name:  "generates a manifest for the branch itself",
   175  			Setup: createOneAppInDevelopment,
   176  			Request: &argorepo.ManifestRequest{
   177  				Revision: "master",
   178  				Repo: &v1alpha1.Repository{
   179  					Repo: "<the-repo-url>",
   180  				},
   181  				ApplicationSource: &v1alpha1.ApplicationSource{
   182  					Path: "environments/development/applications/app/manifests",
   183  				},
   184  			},
   185  
   186  			ExpectedResponse: &argorepo.ManifestResponse{
   187  				Manifests: []string{
   188  					`{"api":"v1","data":{"key":"value"},"kind":"ConfigMap","metadata":{"name":"something","namespace":"something"}}`,
   189  					`{"api":"v1","data":{"key":"value"},"kind":"ConfigMap","metadata":{"name":"somethingelse","namespace":"somethingelse"}}`,
   190  				},
   191  				SourceType: "Directory",
   192  			},
   193  		},
   194  		{
   195  			Name:  "supports the include filter",
   196  			Setup: createOneAppInDevelopmentAndTesting,
   197  			Request: &argorepo.ManifestRequest{
   198  				Revision: "master",
   199  				Repo: &v1alpha1.Repository{
   200  					Repo: "<the-repo-url>",
   201  				},
   202  				ApplicationSource: &v1alpha1.ApplicationSource{
   203  					Path: "argocd/v1alpha1",
   204  					Directory: &v1alpha1.ApplicationSourceDirectory{
   205  						Include: "development.yaml",
   206  					},
   207  				},
   208  			},
   209  
   210  			ExpectedResponse: &argorepo.ManifestResponse{
   211  				Manifests: []string{
   212  					`{"apiVersion":"argoproj.io/v1alpha1","kind":"AppProject","metadata":{"name":"development"},"spec":{"description":"development","destinations":[{"server":"development"}],"sourceRepos":["*"]}}`,
   213  					`{"apiVersion":"argoproj.io/v1alpha1","kind":"Application","metadata":{"annotations":{"argocd.argoproj.io/manifest-generate-paths":"/environments/development/applications/app/manifests","com.freiheit.kuberpult/application":"app","com.freiheit.kuberpult/environment":"development","com.freiheit.kuberpult/team":""},"finalizers":["resources-finalizer.argocd.argoproj.io"],"labels":{"com.freiheit.kuberpult/team":""},"name":"development-app"},"spec":{"destination":{"server":"development"},"project":"development","source":{"path":"environments/development/applications/app/manifests","repoURL":"<the-repo-url>","targetRevision":"master"},"syncPolicy":{"automated":{"allowEmpty":true,"prune":true,"selfHeal":true}}}}`,
   214  				},
   215  				SourceType: "Directory",
   216  			},
   217  		},
   218  		{
   219  			Name:  "generates a manifest for a fixed commit id",
   220  			Setup: createOneAppInDevelopment,
   221  			Request: &argorepo.ManifestRequest{
   222  				Revision: "<last-commit-id>",
   223  				Repo: &v1alpha1.Repository{
   224  					Repo: "<the-repo-url>",
   225  				},
   226  				ApplicationSource: &v1alpha1.ApplicationSource{
   227  					Path: "environments/development/applications/app/manifests",
   228  				},
   229  			},
   230  
   231  			ExpectedResponse: &argorepo.ManifestResponse{
   232  				Manifests: []string{
   233  					`{"api":"v1","data":{"key":"value"},"kind":"ConfigMap","metadata":{"name":"something","namespace":"something"}}`,
   234  					`{"api":"v1","data":{"key":"value"},"kind":"ConfigMap","metadata":{"name":"somethingelse","namespace":"somethingelse"}}`,
   235  				},
   236  				SourceType: "Directory",
   237  			},
   238  		},
   239  		{
   240  			Name:  "rejectes unknown refs",
   241  			Setup: createOneAppInDevelopment,
   242  			Request: &argorepo.ManifestRequest{
   243  				Revision: "not-our-branch",
   244  				Repo: &v1alpha1.Repository{
   245  					Repo: "<the-repo-url>",
   246  				},
   247  				ApplicationSource: &v1alpha1.ApplicationSource{
   248  					Path: "environments/development/applications/app/manifests",
   249  				},
   250  			},
   251  
   252  			ExpectedArgoError: regexp.MustCompile("\\AUnable to resolve 'not-our-branch' to a commit SHA\\z"),
   253  			ExpectedError:     errMatcher{"rpc error: code = NotFound desc = unknown revision \"not-our-branch\", I only know \"HEAD\", \"master\" and commit hashes"},
   254  		},
   255  		{
   256  			Name:  "rejectes unknown commit ids",
   257  			Setup: createOneAppInDevelopment,
   258  			Request: &argorepo.ManifestRequest{
   259  				Revision: "b551320bc327abfabf9df32ee5a830f8ccb1e88d",
   260  				Repo: &v1alpha1.Repository{
   261  					Repo: "<the-repo-url>",
   262  				},
   263  				ApplicationSource: &v1alpha1.ApplicationSource{
   264  					Path: "environments/development/applications/app/manifests",
   265  				},
   266  			},
   267  
   268  			// The error message from argo cd contains the output log of git which differs slightly with the git version. Therefore, we don't match on that.
   269  			ExpectedArgoError: regexp.MustCompile("\\A.*rpc error: code = Internal desc = Failed to checkout revision b551320bc327abfabf9df32ee5a830f8ccb1e88d:"),
   270  			ExpectedError:     errMatcher{"rpc error: code = NotFound desc = unknown revision \"b551320bc327abfabf9df32ee5a830f8ccb1e88d\", I only know \"HEAD\", \"master\" and commit hashes"},
   271  		},
   272  	}
   273  	for _, tc := range tcs {
   274  		tc := tc
   275  		t.Run(tc.Name, func(t *testing.T) {
   276  			repo, cfg := testRepository(t)
   277  			err := repo.Apply(testutil.MakeTestContext(), tc.Setup...)
   278  			if err != nil {
   279  				t.Fatalf("failed setup: %s", err)
   280  			}
   281  			// These two values change every run:
   282  			if tc.Request.Repo.Repo == "<the-repo-url>" {
   283  				tc.Request.Repo.Repo = cfg.URL
   284  			}
   285  			if tc.Request.Revision == "<last-commit-id>" {
   286  
   287  				tc.Request.Revision = repo.State().Commit.Id().String()
   288  			}
   289  			if tc.ExpectedResponse != nil {
   290  				tc.ExpectedResponse.Revision = repo.State().Commit.Id().String()
   291  				mn := make([]string, 0)
   292  				for _, m := range tc.ExpectedResponse.Manifests {
   293  					mn = append(mn, strings.ReplaceAll(m, "<the-repo-url>", cfg.URL))
   294  				}
   295  				tc.ExpectedResponse.Manifests = mn
   296  			}
   297  
   298  			srv := New(repo, cfg)
   299  			resp, err := srv.GenerateManifest(context.Background(), tc.Request)
   300  			if diff := cmp.Diff(tc.ExpectedError, err, cmpopts.EquateErrors()); diff != "" {
   301  				t.Fatalf("error mismatch (-want, +got):\n%s", diff)
   302  			}
   303  			if diff := cmp.Diff(tc.ExpectedResponse, resp, protocmp.Transform()); diff != "" {
   304  				t.Errorf("response mismatch (-want, +got):\n%s", diff)
   305  			}
   306  
   307  			asrv := testArgoServer(t)
   308  			aresp, err := asrv.GenerateManifest(context.Background(), tc.Request)
   309  			if tc.ExpectedError != nil {
   310  				if !tc.ExpectedArgoError.MatchString(err.Error()) {
   311  					t.Fatalf("got wrong error, expected to match %q but got %q", tc.ExpectedArgoError, err)
   312  				}
   313  			} else if err != nil {
   314  				t.Fatalf("unexpected error: %s", err.Error())
   315  			}
   316  			if diff := cmp.Diff(tc.ExpectedResponse, aresp, protocmp.Transform()); diff != "" {
   317  				t.Errorf("response mismatch (-want, +got):\n%s", diff)
   318  			}
   319  		})
   320  	}
   321  }
   322  
   323  func TestResolveRevision(t *testing.T) {
   324  	tcs := []struct {
   325  		Name              string
   326  		Setup             []repository.Transformer
   327  		Request           *argorepo.ResolveRevisionRequest
   328  		ExpectedError     error
   329  		ExpectedArgoError error
   330  	}{
   331  		{
   332  			Name:  "resolves HEAD",
   333  			Setup: createOneAppInDevelopment,
   334  			Request: &argorepo.ResolveRevisionRequest{
   335  				App: &v1alpha1.Application{
   336  					Spec: v1alpha1.ApplicationSpec{},
   337  				},
   338  				Repo: &v1alpha1.Repository{
   339  					Repo: "<the-repo-url>",
   340  				},
   341  				AmbiguousRevision: "HEAD",
   342  			},
   343  		},
   344  		{
   345  			Name:  "resolves master",
   346  			Setup: createOneAppInDevelopment,
   347  			Request: &argorepo.ResolveRevisionRequest{
   348  				App: &v1alpha1.Application{
   349  					Spec: v1alpha1.ApplicationSpec{},
   350  				},
   351  				Repo: &v1alpha1.Repository{
   352  					Repo: "<the-repo-url>",
   353  				},
   354  				AmbiguousRevision: "master",
   355  			},
   356  		},
   357  		{
   358  			Name:  "resolves a commit id",
   359  			Setup: createOneAppInDevelopment,
   360  			Request: &argorepo.ResolveRevisionRequest{
   361  				App: &v1alpha1.Application{
   362  					Spec: v1alpha1.ApplicationSpec{},
   363  				},
   364  				Repo: &v1alpha1.Repository{
   365  					Repo: "<the-repo-url>",
   366  				},
   367  				AmbiguousRevision: "<last-commit-id>",
   368  			},
   369  		},
   370  		{
   371  			Name:  "rejects an unknown branch",
   372  			Setup: createOneAppInDevelopment,
   373  			Request: &argorepo.ResolveRevisionRequest{
   374  				App: &v1alpha1.Application{
   375  					Spec: v1alpha1.ApplicationSpec{},
   376  				},
   377  				Repo: &v1alpha1.Repository{
   378  					Repo: "<the-repo-url>",
   379  				},
   380  				AmbiguousRevision: "not-our-branch",
   381  			},
   382  
   383  			ExpectedError: errMatcher{"rpc error: code = NotFound desc = unknown revision \"not-our-branch\", I only know \"HEAD\", \"master\" and commit hashes"},
   384  
   385  			ExpectedArgoError: errMatcher{"Unable to resolve 'not-our-branch' to a commit SHA"},
   386  		},
   387  		{
   388  			Name:  "accepts unknown commit ids",
   389  			Setup: createOneAppInDevelopment,
   390  			Request: &argorepo.ResolveRevisionRequest{
   391  				App: &v1alpha1.Application{
   392  					Spec: v1alpha1.ApplicationSpec{},
   393  				},
   394  				Repo: &v1alpha1.Repository{
   395  					Repo: "<the-repo-url>",
   396  				},
   397  				AmbiguousRevision: "b551320bc327abfabf9df32ee5a830f8ccb1e88d",
   398  			},
   399  		},
   400  	}
   401  	for _, tc := range tcs {
   402  		tc := tc
   403  		t.Run(tc.Name, func(t *testing.T) {
   404  			repo, cfg := testRepository(t)
   405  			err := repo.Apply(testutil.MakeTestContext(), tc.Setup...)
   406  			if err != nil {
   407  				t.Fatalf("failed setup: %s", err)
   408  			}
   409  			// These two values change every run:
   410  			if tc.Request.Repo.Repo == "<the-repo-url>" {
   411  				tc.Request.Repo.Repo = cfg.URL
   412  			}
   413  			if tc.Request.AmbiguousRevision == "<last-commit-id>" {
   414  				tc.Request.AmbiguousRevision = repo.State().Commit.Id().String()
   415  			}
   416  			asrv := testArgoServer(t)
   417  			aresp, err := asrv.ResolveRevision(context.Background(), tc.Request)
   418  			if diff := cmp.Diff(tc.ExpectedArgoError, err, cmpopts.EquateErrors()); diff != "" {
   419  				t.Errorf("error mismatch (-want, +got):\n%s", diff)
   420  			}
   421  
   422  			srv := New(repo, cfg)
   423  			resp, err := srv.ResolveRevision(context.Background(), tc.Request)
   424  			if diff := cmp.Diff(tc.ExpectedError, err, cmpopts.EquateErrors()); diff != "" {
   425  				t.Errorf("error mismatch (-want, +got):\n%s", diff)
   426  			}
   427  
   428  			if tc.ExpectedError == nil {
   429  				// We only need to check here if both are the same
   430  				if diff := cmp.Diff(aresp, resp, protocmp.Transform()); diff != "" {
   431  					t.Errorf("responses mismatch (-want, +got):\n%s", diff)
   432  				}
   433  			}
   434  		})
   435  	}
   436  }
   437  
   438  func TestGetRevisionMetadata(t *testing.T) {
   439  	tcs := []struct {
   440  		Name string
   441  	}{
   442  		{
   443  			Name: "returns a dummy",
   444  		},
   445  	}
   446  	for _, tc := range tcs {
   447  		tc := tc
   448  		t.Run(tc.Name, func(t *testing.T) {
   449  			srv := (*reposerver)(nil)
   450  			req := argorepo.RepoServerRevisionMetadataRequest{}
   451  			_, err := srv.GetRevisionMetadata(
   452  				context.Background(),
   453  				&req,
   454  			)
   455  			if err != nil {
   456  				t.Errorf("expected no error, but got %q", err)
   457  			}
   458  		})
   459  	}
   460  }
   461  
   462  func testRepository(t *testing.T) (repository.Repository, repository.RepositoryConfig) {
   463  	dir := t.TempDir()
   464  	remoteDir := path.Join(dir, "remote")
   465  	localDir := path.Join(dir, "local")
   466  	cmd := exec.Command("git", "init", "--bare", remoteDir)
   467  	cmd.Run()
   468  	cfg := repository.RepositoryConfig{
   469  		URL:    "file://" + remoteDir,
   470  		Path:   localDir,
   471  		Branch: "master",
   472  	}
   473  	repo, err := repository.New(
   474  		testutil.MakeTestContext(),
   475  		cfg,
   476  	)
   477  	if err != nil {
   478  		t.Fatalf("expected no error, got '%e'", err)
   479  	}
   480  	return repo, cfg
   481  }
   482  
   483  func testArgoServer(t *testing.T) argorepo.RepoServerServiceServer {
   484  	argoRoot := t.TempDir()
   485  	t.Cleanup(
   486  		func() {
   487  			// argocd chmods all its directories in such a way that they can't be listed.
   488  			// this makes a lot of sense until you actually want to remove them cleanly.
   489  			os.Chmod(argoRoot, 0700)
   490  			dirs, _ := os.ReadDir(argoRoot)
   491  			for _, dir := range dirs {
   492  				os.Chmod(filepath.Join(argoRoot, dir.Name()), 0700)
   493  			}
   494  		})
   495  	asrv := argosrv.NewService(
   496  		metrics.NewMetricsServer(),
   497  		cache.NewCache(cacheutil.NewCache(cacheutil.NewInMemoryCache(time.Hour)), time.Hour, time.Hour),
   498  		argosrv.RepoServerInitConstants{},
   499  		argo.NewResourceTracking(),
   500  		&git.NoopCredsStore{},
   501  		argoRoot,
   502  	)
   503  	err := asrv.Init()
   504  	if err != nil {
   505  		t.Fatal(err)
   506  	}
   507  	return asrv
   508  
   509  }