From 8fd775b83837389f7710f1d9e4283d80695873cd Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Wed, 8 Jun 2022 20:01:58 -0700 Subject: [PATCH 01/11] incremental --- .../v1alpha1/chart_integration_test.go | 45 +++- .../packages/v1alpha1/global_vars_test.go | 80 ++++++- .../v1alpha1/integration_utils_test.go | 19 +- .../fluxv2/packages/v1alpha1/release.go | 5 - .../v1alpha1/release_integration_test.go | 9 +- .../plugins/fluxv2/packages/v1alpha1/repo.go | 196 +++++++++++++----- .../v1alpha1/repo_integration_test.go | 99 ++++++--- .../fluxv2/packages/v1alpha1/server.go | 16 +- .../core/packages/v1alpha1/repositories.proto | 6 +- script/makefiles/deploy-dev.mk | 2 +- 10 files changed, 355 insertions(+), 122 deletions(-) diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go index 988ba729eac..af2a17a0e6a 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go @@ -95,7 +95,7 @@ func TestKindClusterGetAvailablePackageSummariesForLargeReposAndTinyRedis(t *tes Namespace: "default", } // this is to make sure we allow enough time for repository to be created and come to ready state - if err = kubeAddHelmRepositoryAndCleanup(t, repo, in_cluster_bitnami_url, "", 0); err != nil { + if err = kubeAddHelmRepositoryAndCleanup(t, repo, "", in_cluster_bitnami_url, "", 0); err != nil { t.Fatalf("%v", err) } // wait until this repo have been indexed and cached up to 10 minutes @@ -188,7 +188,7 @@ func TestKindClusterGetAvailablePackageSummariesForLargeReposAndTinyRedis(t *tes Namespace: "default", } // this is to make sure we allow enough time for repository to be created and come to ready state - if err = kubeAddHelmRepositoryAndCleanup(t, repo, in_cluster_bitnami_url, "", 0); err != nil { + if err = kubeAddHelmRepositoryAndCleanup(t, repo, "", in_cluster_bitnami_url, "", 0); err != nil { t.Fatalf("%v", err) } // wait until this repo have been indexed and cached up to 10 minutes @@ -278,7 +278,7 @@ func TestKindClusterRepoAndChartRBAC(t *testing.T) { for _, n := range names { if err := kubeCreateNamespaceAndCleanup(t, n.Namespace); err != nil { t.Fatal(err) - } else if err = kubeAddHelmRepositoryAndCleanup(t, n, podinfo_repo_url, "", 0); err != nil { + } else if err = kubeAddHelmRepositoryAndCleanup(t, n, "", podinfo_repo_url, "", 0); err != nil { t.Fatal(err) } // wait until this repo reaches 'Ready' @@ -512,3 +512,42 @@ func TestKindClusterRepoAndChartRBAC(t *testing.T) { } } } + +func TestKindClusterGetAvailablePackageSummariesForOCI(t *testing.T) { + fluxPluginClient, _, err := checkEnv(t) + if err != nil { + t.Fatal(err) + } + + adminName := types.NamespacedName{ + Name: "test-admin-" + randSeq(4), + Namespace: "default", + } + grpcContext, err := newGrpcAdminContext(t, adminName) + if err != nil { + t.Fatal(err) + } + + repoName := types.NamespacedName{ + Name: "my-podinfo-" + randSeq(4), + Namespace: "default", + } + if err := kubeAddHelmRepositoryAndCleanup(t, repoName, "oci", podinfo_oci_repo_url, "", 0); err != nil { + t.Fatalf("%v", err) + } + // wait until this repo reaches 'Ready' + if err = kubeWaitUntilHelmRepositoryIsReady(t, repoName); err != nil { + t.Fatal(err) + } + + grpcContext, cancel := context.WithTimeout(grpcContext, 90*time.Second) + defer cancel() + resp, err := fluxPluginClient.GetAvailablePackageSummaries( + grpcContext, + &corev1.GetAvailablePackageSummariesRequest{}) + if err != nil { + t.Fatalf("%v", err) + } + + t.Logf("=======> %s", common.PrettyPrint(resp)) +} diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/global_vars_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/global_vars_test.go index ed3ef9a237d..df7a7262140 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/global_vars_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/global_vars_test.go @@ -14,6 +14,7 @@ import ( "helm.sh/helm/v3/pkg/release" v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ) // global vars @@ -1084,6 +1085,13 @@ var ( }, } + add_repo_req_21 = &corev1.AddPackageRepositoryRequest{ + Name: "my-podinfo-5", + Context: &corev1.Context{Namespace: "default"}, + Type: "oci", + Url: podinfo_oci_repo_url, + } + add_repo_expected_resp = &corev1.AddPackageRepositoryResponse{ PackageRepoRef: repoRef("bar", "foo"), } @@ -1105,7 +1113,7 @@ var ( } add_repo_expected_resp_6 = &corev1.AddPackageRepositoryResponse{ - PackageRepoRef: repoRef("my-podinfo-4", "default"), + PackageRepoRef: repoRef("my-podinfo-5", "default"), } status_installed = &corev1.InstalledPackageStatus{ @@ -2402,6 +2410,52 @@ var ( PackageRepoRef: repoRefInReq("my-podinfo-4", "TBD"), } + get_repo_detail_req_12 = &corev1.GetPackageRepositoryDetailRequest{ + // namespace will be set when test scenario is run + PackageRepoRef: repoRefInReq("my-podinfo-12", "TBD"), + } + + get_repo_detail_resp_15 = &corev1.GetPackageRepositoryDetailResponse{ + Detail: &corev1.PackageRepositoryDetail{ + PackageRepoRef: repoRefWithId("my-podinfo-12"), + Name: "my-podinfo-12", + Description: "", + NamespaceScoped: false, + Type: "helm", + Url: podinfo_oci_repo_url, + Interval: 600, + Auth: &corev1.PackageRepositoryAuth{}, + Status: &corev1.PackageRepositoryStatus{ + Ready: false, + Reason: corev1.PackageRepositoryStatus_STATUS_REASON_FAILED, + UserReason: "Failed: failed to fetch Helm repository index: failed to cache index to temporary file: object required", + }, + }, + } + + get_repo_detail_req_13 = &corev1.GetPackageRepositoryDetailRequest{ + // namespace will be set when test scenario is run + PackageRepoRef: repoRefInReq("my-podinfo-13", "TBD"), + } + + get_repo_detail_resp_16 = &corev1.GetPackageRepositoryDetailResponse{ + Detail: &corev1.PackageRepositoryDetail{ + PackageRepoRef: repoRefWithId("my-podinfo-13"), + Name: "my-podinfo-13", + Description: "", + NamespaceScoped: false, + Type: "oci", + Url: podinfo_oci_repo_url, + Interval: 600, + Auth: &corev1.PackageRepositoryAuth{}, + Status: &corev1.PackageRepositoryStatus{ + Ready: true, + Reason: corev1.PackageRepositoryStatus_STATUS_REASON_SUCCESS, + UserReason: "Succeeded: Helm repository is ready", + }, + }, + } + get_summaries_repo_1 = newRepo("bar", "foo", &sourcev1.HelmRepositorySpec{ URL: "http://example.com", @@ -2521,10 +2575,10 @@ var ( }, } - get_summaries_summary_5 = func(name, ns string) *corev1.PackageRepositorySummary { + get_summaries_summary_5 = func(name types.NamespacedName) *corev1.PackageRepositorySummary { return &corev1.PackageRepositorySummary{ - PackageRepoRef: repoRef(name, ns), - Name: name, + PackageRepoRef: repoRef(name.Name, name.Namespace), + Name: name.Name, Description: "", NamespaceScoped: false, Type: "helm", @@ -2533,6 +2587,18 @@ var ( } } + get_summaries_summary_6 = func(name types.NamespacedName) *corev1.PackageRepositorySummary { + return &corev1.PackageRepositorySummary{ + PackageRepoRef: repoRef(name.Name, name.Namespace), + Name: name.Name, + Description: "", + NamespaceScoped: false, + Type: "oci", + Url: podinfo_oci_repo_url, + Status: podinfo_repo_status_4, + } + } + update_repo_req_1 = &corev1.UpdatePackageRepositoryRequest{ PackageRepoRef: repoRefInReq("repo-1", "namespace-1"), Url: "http://newurl.com", @@ -2956,6 +3022,12 @@ var ( UserReason: "Succeeded: stored artifact for revision '2867920fb8f56575f4bc95ed878ee2a0c8ae79cdd2bca210a72aa3ff04defa1b'", } + podinfo_repo_status_4 = &corev1.PackageRepositoryStatus{ + Ready: true, + Reason: corev1.PackageRepositoryStatus_STATUS_REASON_SUCCESS, + UserReason: "Succeeded: Helm repository is ready", + } + repo_status_pending = &corev1.PackageRepositoryStatus{ Reason: corev1.PackageRepositoryStatus_STATUS_REASON_PENDING, } diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/integration_utils_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/integration_utils_test.go index efc72bd488c..f3aab79d3bd 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/integration_utils_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/integration_utils_test.go @@ -25,6 +25,7 @@ import ( "github.com/vmware-tanzu/kubeapps/cmd/apprepository-controller/pkg/client/clientset/versioned/scheme" plugins "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/core/plugins/v1alpha1" fluxplugin "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/plugins/fluxv2/packages/v1alpha1" + "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common" "github.com/vmware-tanzu/kubeapps/pkg/chart/models" "github.com/vmware-tanzu/kubeapps/pkg/helm" httpclient "github.com/vmware-tanzu/kubeapps/pkg/http-client" @@ -80,6 +81,8 @@ const ( // port forward is done programmatically outside_cluster_bitnami_url = "http://localhost:50057/bitnami" + + podinfo_oci_repo_url = "oci://ghcr.io/stefanprodan/charts" ) func checkEnv(t *testing.T) (fluxplugin.FluxV2PackagesServiceClient, fluxplugin.FluxV2RepositoriesServiceClient, error) { @@ -193,10 +196,9 @@ func getFluxPluginClients(t *testing.T) (fluxplugin.FluxV2PackagesServiceClient, return fluxplugin.NewFluxV2PackagesServiceClient(conn), fluxplugin.NewFluxV2RepositoriesServiceClient(conn), nil } -// This creates a flux helm repository CRD. The usage of this func should be minimized as much as -// possible in favor of flux Plugin's AddPackageRepository() call -func kubeAddHelmRepository(t *testing.T, name types.NamespacedName, url, secretName string, interval time.Duration) error { - t.Logf("+kubeAddHelmRepository(%s)", name) +// This creates a flux helm repository CRD +func kubeAddHelmRepository(t *testing.T, name types.NamespacedName, typ, url, secretName string, interval time.Duration) error { + t.Logf("+kubeAddHelmRepository(%s,%s,%s)", name, typ, url) if interval <= 0 { interval = time.Duration(10 * time.Minute) } @@ -211,6 +213,10 @@ func kubeAddHelmRepository(t *testing.T, name types.NamespacedName, url, secretN }, } + if typ != "" { + repo.Spec.Type = typ + } + if secretName != "" { repo.Spec.SecretRef = &meta.LocalObjectReference{ Name: secretName, @@ -222,13 +228,14 @@ func kubeAddHelmRepository(t *testing.T, name types.NamespacedName, url, secretN if ifc, err := kubeGetCtrlClient(); err != nil { return err } else { + t.Logf("Creating: %s\n...", common.PrettyPrint(repo)) return ifc.Create(ctx, &repo) } } -func kubeAddHelmRepositoryAndCleanup(t *testing.T, name types.NamespacedName, url, secretName string, interval time.Duration) error { +func kubeAddHelmRepositoryAndCleanup(t *testing.T, name types.NamespacedName, typ, url, secretName string, interval time.Duration) error { t.Logf("+kubeAddHelmRepositoryAndCleanup(%s)", name) - err := kubeAddHelmRepository(t, name, url, secretName, interval) + err := kubeAddHelmRepository(t, name, typ, url, secretName, interval) if err == nil { t.Cleanup(func() { err := kubeDeleteHelmRepository(t, name) diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/release.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/release.go index 0853a8cc4e9..c3dcffe0e04 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/release.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/release.go @@ -34,11 +34,6 @@ import ( "sigs.k8s.io/yaml" ) -const ( - fluxHelmReleases = "helmreleases" - fluxHelmReleaseList = "HelmReleaseList" -) - var ( // default reconcile interval is 1 min defaultReconcileInterval = metav1.Duration{Duration: 1 * time.Minute} diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/release_integration_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/release_integration_test.go index c360313295d..fead4da3674 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/release_integration_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/release_integration_test.go @@ -24,6 +24,11 @@ import ( "k8s.io/apimachinery/pkg/types" ) +const ( + fluxHelmReleases = "helmreleases" + fluxHelmReleaseList = "HelmReleaseList" +) + // This is an integration test: it tests the full integration of flux plugin with flux back-end // To run these tests, enable ENABLE_FLUX_INTEGRATION_TESTS variable // pre-requisites for these tests to run: @@ -940,7 +945,7 @@ func TestKindClusterRBAC_CreateRelease(t *testing.T) { Namespace: ns1, } - err = kubeAddHelmRepositoryAndCleanup(t, name, podinfo_repo_url, "", 0) + err = kubeAddHelmRepositoryAndCleanup(t, name, "", podinfo_repo_url, "", 0) if err != nil { t.Fatal(err) } @@ -1319,7 +1324,7 @@ func createAndWaitForHelmRelease(t *testing.T, tc integrationTestCreatePackageSp Name: idParts[0], Namespace: availablePackageRef.Context.Namespace, } - err := kubeAddHelmRepositoryAndCleanup(t, name, tc.repoUrl, "", tc.repoInterval) + err := kubeAddHelmRepositoryAndCleanup(t, name, "", tc.repoUrl, "", tc.repoInterval) if err != nil { t.Fatalf("%+v", err) } diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go index 0f8d5df5d36..194404f7a2a 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go @@ -8,11 +8,13 @@ import ( "context" "encoding/gob" "fmt" + "net/http" "reflect" "regexp" "strings" "time" + "github.com/containerd/containerd/remotes/docker" fluxmeta "github.com/fluxcd/pkg/apis/meta" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" corev1 "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/core/packages/v1alpha1" @@ -41,6 +43,7 @@ const ( fluxHelmRepositories = "helmrepositories" fluxHelmRepositoryList = "HelmRepositoryList" redactedString = "REDACTED" + additionalCAFile = "/usr/local/share/ca-certificates/ca.crt" ) var ( @@ -200,18 +203,34 @@ func (s *Server) clientOptionsForRepo(ctx context.Context, repoName types.Namesp return sink.clientOptionsForRepo(ctx, *repo) } -func (s *Server) newRepo(ctx context.Context, targetName types.NamespacedName, url string, interval uint32, - tlsConfig *corev1.PackageRepositoryTlsConfig, auth *corev1.PackageRepositoryAuth) (*corev1.PackageRepositoryReference, error) { +func (s *Server) newRepo(ctx context.Context, request *corev1.AddPackageRepositoryRequest) (*corev1.PackageRepositoryReference, error) { + if request.Name == "" { + return nil, status.Errorf(codes.InvalidArgument, "no request Name provided") + } + + if request.GetNamespaceScoped() { + return nil, status.Errorf(codes.Unimplemented, "namespaced-scoped repositories are not supported") + } + + typ := request.GetType() + if typ != "helm" && typ != "oci" { + return nil, status.Errorf(codes.Unimplemented, "repository type [%s] not supported", typ) + } + + url := request.GetUrl() + tlsConfig := request.GetTlsConfig() if url == "" { return nil, status.Errorf(codes.InvalidArgument, "repository url may not be empty") } else if tlsConfig != nil && tlsConfig.InsecureSkipVerify { return nil, status.Errorf(codes.InvalidArgument, "TLS flag insecureSkipVerify is not supported") } + name := types.NamespacedName{Name: request.Name, Namespace: request.Context.Namespace} + auth := request.GetAuth() var secret *apiv1.Secret var err error if s.pluginConfig.UserManagedSecrets { - if secret, err = s.validateUserManagedRepoSecret(ctx, targetName, tlsConfig, auth); err != nil { + if secret, err = s.validateUserManagedRepoSecret(ctx, name, tlsConfig, auth); err != nil { return nil, err } } else { @@ -219,19 +238,20 @@ func (s *Server) newRepo(ctx context.Context, targetName types.NamespacedName, u // but then I need to set the owner reference on this secret to the repo. In has to be done // in that order because to set an owner ref you need object (i.e. repo) UID, which you only get // once the object's been created - if secret, err = s.createKubeappsManagedRepoSecret(ctx, targetName, tlsConfig, auth); err != nil { + if secret, err = s.createKubeappsManagedRepoSecret(ctx, name, tlsConfig, auth); err != nil { return nil, err } } passCredentials := auth != nil && auth.PassCredentials + interval := request.GetInterval() - if fluxRepo, err := newFluxHelmRepo(targetName, url, interval, secret, passCredentials); err != nil { + if fluxRepo, err := newFluxHelmRepo(name, typ, url, interval, secret, passCredentials); err != nil { return nil, err - } else if client, err := s.getClient(ctx, targetName.Namespace); err != nil { + } else if client, err := s.getClient(ctx, name.Namespace); err != nil { return nil, err } else if err = client.Create(ctx, fluxRepo); err != nil { - return nil, statuserror.FromK8sError("create", "HelmRepository", targetName.String(), err) + return nil, statuserror.FromK8sError("create", "HelmRepository", name.String(), err) } else { if !s.pluginConfig.UserManagedSecrets { if err = s.setOwnerReferencesForRepoSecret(ctx, secret, fluxRepo); err != nil { @@ -254,6 +274,7 @@ func (s *Server) repoDetail(ctx context.Context, repoRef *corev1.PackageReposito repo, err := s.getRepoInCluster(ctx, key) if err != nil { + log.Infof("repoDetail :%v", err) return nil, err } @@ -288,6 +309,10 @@ func (s *Server) repoDetail(ctx context.Context, repoRef *corev1.PackageReposito } } auth.PassCredentials = repo.Spec.PassCredentials + typ := repo.Spec.Type + if typ == "" { + typ = "helm" + } return &corev1.PackageRepositoryDetail{ PackageRepoRef: &corev1.PackageRepositoryReference{ Context: &corev1.Context{ @@ -301,7 +326,7 @@ func (s *Server) repoDetail(ctx context.Context, repoRef *corev1.PackageReposito // TBD Flux HelmRepository CR doesn't have a designated field for description Description: "", NamespaceScoped: false, - Type: "helm", + Type: typ, Url: repo.Spec.URL, Interval: uint32(repo.Spec.Interval.Duration.Seconds()), TlsConfig: tlsConfig, @@ -335,6 +360,10 @@ func (s *Server) repoSummaries(ctx context.Context, namespace string) ([]*corev1 } } for _, repo := range repos { + typ := repo.Spec.Type + if typ == "" { + typ = "helm" + } summary := &corev1.PackageRepositorySummary{ PackageRepoRef: &corev1.PackageRepositoryReference{ Context: &corev1.Context{ @@ -348,7 +377,7 @@ func (s *Server) repoSummaries(ctx context.Context, namespace string) ([]*corev1 // TBD Flux HelmRepository CR doesn't have a designated field for description Description: "", NamespaceScoped: false, - Type: "helm", + Type: typ, Url: repo.Spec.URL, Status: repoStatus(repo), } @@ -526,12 +555,12 @@ func (s *Server) updateKubeappsManagedRepoSecret( // TODO (gfichtenholt) we should optimize this to somehow tell if the existing secret // is the same (data-wise) as the new one and if so skip all this if err = secretInterface.Delete(ctx, existingSecretRef.Name, metav1.DeleteOptions{}); err != nil { - return nil, false, statuserror.FromK8sError("get", "secret", existingSecretRef.Name, err) + return nil, false, statuserror.FromK8sError("delete", "secret", existingSecretRef.Name, err) } // create a new one newSecret, err := secretInterface.Create(ctx, secret, metav1.CreateOptions{}) if err != nil { - return nil, false, statuserror.FromK8sError("update", "secret", secret.GetGenerateName(), err) + return nil, false, statuserror.FromK8sError("create", "secret", secret.GetGenerateName(), err) } return newSecret, true, nil } @@ -674,21 +703,12 @@ func (s *repoEventSink) onAddRepo(key string, obj ctrlclient.Object) (interface{ if repo, ok := obj.(*sourcev1.HelmRepository); !ok { return nil, false, fmt.Errorf("expected an instance of *sourcev1.HelmRepository, got: %s", reflect.TypeOf(obj)) - } else if isRepoReady(*repo) { // first, check the repo is ready - // ref https://fluxcd.io/docs/components/source/helmrepositories/#status - if artifact := repo.GetArtifact(); artifact != nil { - if checksum := artifact.Checksum; checksum == "" { - return nil, false, status.Errorf(codes.Internal, - "expected field status.artifact.checksum not found on HelmRepository\n[%s]", - common.PrettyPrint(repo)) - } else { - return s.indexAndEncode(checksum, *repo) - } + } else if isRepoReady(*repo) { + if repo.Spec.Type == sourcev1.HelmRepositoryTypeOCI { + return s.onAddOciRepo(repo) } else { - return nil, false, status.Errorf(codes.Internal, - "expected field status.artifact not found on HelmRepository\n[%s]", - common.PrettyPrint(repo)) + return s.onAddHttpRepo(repo) } } else { // repo is not quite ready to be indexed - not really an error condition, @@ -698,6 +718,60 @@ func (s *repoEventSink) onAddRepo(key string, obj ctrlclient.Object) (interface{ } } +// OCI Helm repository, which defines a source, does not produce an Artifact +// ref https://fluxcd.io/docs/components/source/helmrepositories/#helm-oci-repository +func (s *repoEventSink) onAddOciRepo(repo *sourcev1.HelmRepository) ([]byte, bool, error) { + authorizationHeader := "" + // TODO look at repo's secretRef + /* + // The auth header may be a dockerconfig that we need to parse + if serveOpts.DockerConfigJson != "" { + dockerConfig := &credentialprovider.DockerConfigJSON{} + err := json.Unmarshal([]byte(serveOpts.DockerConfigJson), dockerConfig) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + authorizationHeader, err = kube.GetAuthHeaderFromDockerConfig(dockerConfig) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + } + */ + headers := http.Header{} + if authorizationHeader != "" { + headers["Authorization"] = []string{authorizationHeader} + } + // TODO look at repo's secretRef for TLS config + netClient, err := httpclient.NewWithCertFile(additionalCAFile, false) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + + ociResolver := docker.NewResolver( + docker.ResolverOptions{ + Headers: headers, + Client: netClient}) + + return nil, false, nil +} + +// ref https://fluxcd.io/docs/components/source/helmrepositories/#status +func (s *repoEventSink) onAddHttpRepo(repo *sourcev1.HelmRepository) ([]byte, bool, error) { + if artifact := repo.GetArtifact(); artifact != nil { + if checksum := artifact.Checksum; checksum == "" { + return nil, false, status.Errorf(codes.Internal, + "expected field status.artifact.checksum not found on HelmRepository\n[%s]", + common.PrettyPrint(repo)) + } else { + return s.indexAndEncode(checksum, *repo) + } + } else { + return nil, false, status.Errorf(codes.Internal, + "expected field status.artifact not found on HelmRepository\n[%s]", + common.PrettyPrint(repo)) + } +} + func (s *repoEventSink) indexAndEncode(checksum string, repo sourcev1.HelmRepository) ([]byte, bool, error) { charts, err := s.indexOneRepo(repo) if err != nil { @@ -794,43 +868,49 @@ func (s *repoEventSink) onModifyRepo(key string, obj ctrlclient.Object, oldValue if repo, ok := obj.(*sourcev1.HelmRepository); !ok { return nil, false, fmt.Errorf("expected an instance of *sourcev1.HelmRepository, got: %s", reflect.TypeOf(obj)) } else if isRepoReady(*repo) { - // first check the repo is ready - // We should to compare checksums on what's stored in the cache - // vs the modified object to see if the contents has really changed before embarking on - // expensive operation indexOneRepo() below. - // ref https://fluxcd.io/docs/components/source/helmrepositories/#status - // ref https://fluxcd.io/docs/components/source/helmrepositories/#status - var newChecksum string - if artifact := repo.GetArtifact(); artifact != nil { - if newChecksum = artifact.Checksum; newChecksum == "" { + if repo.Spec.Type == sourcev1.HelmRepositoryTypeOCI { + // OCI Helm repository, which defines a source, does not produce an Artifact + // ref https://fluxcd.io/docs/components/source/helmrepositories/#helm-oci-repository + return nil, false, nil + } else { + // first check the repo is ready + // We should to compare checksums on what's stored in the cache + // vs the modified object to see if the contents has really changed before embarking on + // expensive operation indexOneRepo() below. + // ref https://fluxcd.io/docs/components/source/helmrepositories/#status + // ref https://fluxcd.io/docs/components/source/helmrepositories/#status + var newChecksum string + if artifact := repo.GetArtifact(); artifact != nil { + if newChecksum = artifact.Checksum; newChecksum == "" { + return nil, false, status.Errorf(codes.Internal, + "expected field status.artifact.checksum not found on HelmRepository\n[%s]", + common.PrettyPrint(repo)) + } + } else { return nil, false, status.Errorf(codes.Internal, - "expected field status.artifact.checksum not found on HelmRepository\n[%s]", + "expected field status.artifact not found on HelmRepository\n[%s]", common.PrettyPrint(repo)) } - } else { - return nil, false, status.Errorf(codes.Internal, - "expected field status.artifact not found on HelmRepository\n[%s]", - common.PrettyPrint(repo)) - } - cacheEntryUntyped, err := s.onGetRepo(key, oldValue) - if err != nil { - return nil, false, err - } + cacheEntryUntyped, err := s.onGetRepo(key, oldValue) + if err != nil { + return nil, false, err + } - cacheEntry, ok := cacheEntryUntyped.(repoCacheEntryValue) - if !ok { - return nil, false, status.Errorf( - codes.Internal, - "unexpected value found in cache for key [%s]: %v", - key, cacheEntryUntyped) - } + cacheEntry, ok := cacheEntryUntyped.(repoCacheEntryValue) + if !ok { + return nil, false, status.Errorf( + codes.Internal, + "unexpected value found in cache for key [%s]: %v", + key, cacheEntryUntyped) + } - if cacheEntry.Checksum != newChecksum { - return s.indexAndEncode(newChecksum, *repo) - } else { - // skip because the content did not change - return nil, false, nil + if cacheEntry.Checksum != newChecksum { + return s.indexAndEncode(newChecksum, *repo) + } else { + // skip because the content did not change + return nil, false, nil + } } } else { // repo is not quite ready to be indexed - not really an error condition, @@ -990,6 +1070,7 @@ func checkRepoGeneration(repo sourcev1.HelmRepository) bool { // ref https://fluxcd.io/docs/components/source/helmrepositories/ func newFluxHelmRepo( targetName types.NamespacedName, + typ string, url string, interval uint32, secret *apiv1.Secret, @@ -1008,6 +1089,11 @@ func newFluxHelmRepo( Interval: pollInterval, }, } + if typ == "oci" { + fluxRepo.Spec.Type = sourcev1.HelmRepositoryTypeOCI + } else { + fluxRepo.Spec.Type = sourcev1.HelmRepositoryTypeDefault + } if secret != nil { fluxRepo.Spec.SecretRef = &fluxmeta.LocalObjectReference{ Name: secret.Name, diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo_integration_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo_integration_test.go index a3e2ac0474d..f911a22f1f9 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo_integration_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo_integration_test.go @@ -54,7 +54,7 @@ func TestKindClusterAddThenDeleteRepo(t *testing.T) { } if err = usesBitnamiCatalog(t); err != nil { t.Fatal(err) - } else if err = kubeAddHelmRepository(t, name, in_cluster_bitnami_url, "", 0); err != nil { + } else if err = kubeAddHelmRepository(t, name, "", in_cluster_bitnami_url, "", 0); err != nil { t.Fatal(err) } // wait until this repo reaches 'Ready' state so that long indexation process kicks in @@ -96,7 +96,7 @@ func TestKindClusterRepoWithBasicAuth(t *testing.T) { Name: "podinfo-basic-auth-" + randSeq(4), Namespace: "default", } - if err := kubeAddHelmRepositoryAndCleanup(t, repoName, podinfo_basic_auth_repo_url, secretName.Name, 0); err != nil { + if err := kubeAddHelmRepositoryAndCleanup(t, repoName, "", podinfo_basic_auth_repo_url, secretName.Name, 0); err != nil { t.Fatalf("%v", err) } @@ -250,7 +250,7 @@ func TestKindClusterAddPackageRepository(t *testing.T) { Name: "secret-2", Namespace: "default", }, pub, priv, ca), - expectedResponse: add_repo_expected_resp_6, + expectedResponse: add_repo_expected_resp_5, expectedStatusCode: codes.OK, userManagedSecrets: true, }, @@ -263,6 +263,12 @@ func TestKindClusterAddPackageRepository(t *testing.T) { }, pub, priv, ca), expectedStatusCode: codes.InvalidArgument, }, + { + testName: "add OCI repo test (simplest case)", + request: add_repo_req_21, + expectedResponse: add_repo_expected_resp_6, + expectedStatusCode: codes.OK, + }, } adminAcctName := types.NamespacedName{ @@ -326,14 +332,6 @@ func TestKindClusterAddPackageRepository(t *testing.T) { } } -func TestKindClusterAddOciPackageRepository(t *testing.T) { - _, _, err := checkEnv(t) - if err != nil { - t.Fatal(err) - } - // TODO -} - func TestKindClusterGetPackageRepositoryDetail(t *testing.T) { _, fluxPluginReposClient, err := checkEnv(t) if err != nil { @@ -344,6 +342,7 @@ func TestKindClusterGetPackageRepositoryDetail(t *testing.T) { testName string request *corev1.GetPackageRepositoryDetailRequest repoName string + repoType string repoUrl string unauthorized bool expectedResponse *corev1.GetPackageRepositoryDetailResponse @@ -415,6 +414,23 @@ func TestKindClusterGetPackageRepositoryDetail(t *testing.T) { expectedStatusCode: codes.PermissionDenied, unauthorized: true, }, + { + testName: "returns failed status for helm repository with OCI url", + request: get_repo_detail_req_12, + repoName: "my-podinfo-12", + repoUrl: podinfo_oci_repo_url, + expectedStatusCode: codes.OK, + expectedResponse: get_repo_detail_resp_15, + }, + { + testName: "get details for OCI repo", + request: get_repo_detail_req_13, + repoName: "my-podinfo-13", + repoType: "oci", + repoUrl: podinfo_oci_repo_url, + expectedStatusCode: codes.OK, + expectedResponse: get_repo_detail_resp_16, + }, } adminAcctName := types.NamespacedName{ @@ -454,7 +470,7 @@ func TestKindClusterGetPackageRepositoryDetail(t *testing.T) { if err = kubeAddHelmRepositoryAndCleanup(t, types.NamespacedName{ Name: tc.repoName, Namespace: repoNamespace, - }, tc.repoUrl, secretName, 0); err != nil { + }, tc.repoType, tc.repoUrl, secretName, 0); err != nil { t.Fatal(err) } @@ -480,7 +496,6 @@ func TestKindClusterGetPackageRepositoryDetail(t *testing.T) { defer cancel() resp, err = fluxPluginReposClient.GetPackageRepositoryDetail(grpcCtx, tc.request) - if got, want := status.Code(err), tc.expectedStatusCode; got != want { t.Fatalf("got: %v, want: %v, last repo detail: %v", err, want, resp) } @@ -510,6 +525,7 @@ func TestKindClusterGetPackageRepositorySummaries(t *testing.T) { type repoSpec struct { name string ns string + typ string url string } @@ -545,9 +561,9 @@ func TestKindClusterGetPackageRepositorySummaries(t *testing.T) { expectedStatusCode: codes.OK, expectedResponse: &corev1.GetPackageRepositorySummariesResponse{ PackageRepositorySummaries: []*corev1.PackageRepositorySummary{ - get_summaries_summary_5("podinfo-1", ns1), - get_summaries_summary_5("podinfo-2", ns2), - get_summaries_summary_5("podinfo-3", ns3), + get_summaries_summary_5(types.NamespacedName{Name: "podinfo-1", Namespace: ns1}), + get_summaries_summary_5(types.NamespacedName{Name: "podinfo-2", Namespace: ns2}), + get_summaries_summary_5(types.NamespacedName{Name: "podinfo-3", Namespace: ns3}), }, }, }, @@ -564,7 +580,7 @@ func TestKindClusterGetPackageRepositorySummaries(t *testing.T) { expectedStatusCode: codes.OK, expectedResponse: &corev1.GetPackageRepositorySummariesResponse{ PackageRepositorySummaries: []*corev1.PackageRepositorySummary{ - get_summaries_summary_5("podinfo-5", ns2), + get_summaries_summary_5(types.NamespacedName{Name: "podinfo-5", Namespace: ns2}), }, }, }, @@ -597,10 +613,32 @@ func TestKindClusterGetPackageRepositorySummaries(t *testing.T) { }, unauthorized: true, }, + { + testName: "summaries from OCI repo", + request: &corev1.GetPackageRepositorySummariesRequest{ + Context: &corev1.Context{}, + }, + existingRepos: []repoSpec{ + { + name: "podinfo-13", + ns: ns1, + typ: "oci", + url: podinfo_oci_repo_url, + }, + }, + expectedStatusCode: codes.OK, + expectedResponse: &corev1.GetPackageRepositorySummariesResponse{ + PackageRepositorySummaries: []*corev1.PackageRepositorySummary{ + get_summaries_summary_6(types.NamespacedName{ + Name: "podinfo-13", + Namespace: ns1}), + }, + }, + }, } adminAcctName := types.NamespacedName{ - Name: "test-get-repo-admin-" + randSeq(4), + Name: "test-get-summaries-admin-" + randSeq(4), Namespace: "default", } grpcAdmin, err := newGrpcAdminContext(t, adminAcctName) @@ -609,7 +647,7 @@ func TestKindClusterGetPackageRepositorySummaries(t *testing.T) { } loserAcctName := types.NamespacedName{ - Name: "test-get-repo-loser-" + randSeq(4), + Name: "test-get-summaries-loser-" + randSeq(4), Namespace: "default", } grpcLoser, err := newGrpcContextForServiceAccountWithoutAccessToAnyNamespace(t, loserAcctName) @@ -620,15 +658,16 @@ func TestKindClusterGetPackageRepositorySummaries(t *testing.T) { for _, tc := range testCases { t.Run(tc.testName, func(t *testing.T) { for _, repo := range tc.existingRepos { - name := types.NamespacedName{ - Name: repo.name, - Namespace: repo.ns, - } - if err = kubeAddHelmRepositoryAndCleanup(t, name, repo.url, "", 0); err != nil { + if err = kubeAddHelmRepositoryAndCleanup(t, + types.NamespacedName{ + Name: repo.name, + Namespace: repo.ns}, repo.typ, repo.url, "", 0); err != nil { t.Fatal(err) } // want to wait until all repos reach Ready state - err := kubeWaitUntilHelmRepositoryIsReady(t, name) + err := kubeWaitUntilHelmRepositoryIsReady(t, types.NamespacedName{ + Name: repo.name, + Namespace: repo.ns}) if err != nil { t.Fatal(err) } @@ -809,7 +848,7 @@ func TestKindClusterUpdatePackageRepository(t *testing.T) { Namespace: repoNamespace, } if err = kubeAddHelmRepositoryAndCleanup(t, name, - tc.repoUrl, oldSecretName, 0); err != nil { + "", tc.repoUrl, oldSecretName, 0); err != nil { t.Fatal(err) } // wait until this repo reaches 'Ready' state so that long indexation process kicks in @@ -932,7 +971,9 @@ func TestKindClusterDeletePackageRepository(t *testing.T) { expectedStatusCode: codes.PermissionDenied, unauthorized: true, }, - { + { //TODO rewrite this test to use AddPackageRepository + //Instead of kubeAddHelmRepository so we don't need to copy + //production code bizness logic here name: "delete repo also deletes the corresponding secret in kubeapps managed env", request: delete_repo_req_6, repoName: "my-podinfo-4", @@ -999,7 +1040,7 @@ func TestKindClusterDeletePackageRepository(t *testing.T) { Name: tc.repoName, Namespace: repoNamespace, } - if err = kubeAddHelmRepository(t, name, tc.repoUrl, oldSecretName, 0); err != nil { + if err = kubeAddHelmRepository(t, name, "", tc.repoUrl, oldSecretName, 0); err != nil { t.Fatal(err) // wait until this repo reaches 'Ready' state so that long indexation process kicks in } else if !tc.userManagedSecrets && tc.oldSecret != nil { @@ -1124,7 +1165,7 @@ func TestKindClusterUpdatePackageRepoSecretUnchanged(t *testing.T) { Name: repoName, Namespace: repoNamespace, } - if err = kubeAddHelmRepositoryAndCleanup(t, name, repoUrl, oldSecretName, 0); err != nil { + if err = kubeAddHelmRepositoryAndCleanup(t, name, "", repoUrl, oldSecretName, 0); err != nil { t.Fatal(err) } else if err = kubeWaitUntilHelmRepositoryIsReady(t, name); err != nil { t.Fatal(err) diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/server.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/server.go index 96c26711093..31b50b18fae 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/server.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/server.go @@ -514,20 +514,7 @@ func (s *Server) AddPackageRepository(ctx context.Context, request *corev1.AddPa request.Context.Cluster) } - if request.Name == "" { - return nil, status.Errorf(codes.InvalidArgument, "no request Name provided") - } - - name := types.NamespacedName{Name: request.Name, Namespace: request.Context.Namespace} - - if request.GetNamespaceScoped() { - return nil, status.Errorf(codes.Unimplemented, "namespaced-scoped repositories are not supported") - } else if request.GetType() != "helm" { - return nil, status.Errorf(codes.Unimplemented, "repository type [%s] not supported", request.GetType()) - } - - if repoRef, err := s.newRepo(ctx, name, request.GetUrl(), - request.GetInterval(), request.GetTlsConfig(), request.GetAuth()); err != nil { + if repoRef, err := s.newRepo(ctx, request); err != nil { return nil, err } else { return &corev1.AddPackageRepositoryResponse{PackageRepoRef: repoRef}, nil @@ -536,6 +523,7 @@ func (s *Server) AddPackageRepository(ctx context.Context, request *corev1.AddPa func (s *Server) GetPackageRepositoryDetail(ctx context.Context, request *corev1.GetPackageRepositoryDetailRequest) (*corev1.GetPackageRepositoryDetailResponse, error) { log.Infof("+fluxv2 GetPackageRepositoryDetail [%v]", request) + defer log.Infof("-fluxv2 GetPackageRepositoryDetail") if request == nil || request.PackageRepoRef == nil { return nil, status.Errorf(codes.InvalidArgument, "no request AvailablePackageRef provided") } diff --git a/cmd/kubeapps-apis/proto/kubeappsapis/core/packages/v1alpha1/repositories.proto b/cmd/kubeapps-apis/proto/kubeappsapis/core/packages/v1alpha1/repositories.proto index cc0a71f7646..e0ccc20626e 100644 --- a/cmd/kubeapps-apis/proto/kubeappsapis/core/packages/v1alpha1/repositories.proto +++ b/cmd/kubeapps-apis/proto/kubeappsapis/core/packages/v1alpha1/repositories.proto @@ -75,9 +75,9 @@ message AddPackageRepositoryRequest { // Package storage type // In general, each plug-in will define an acceptable set of valid types - // - for direct helm plug-in valid values are: helm, oci - // - for flux plug-in currently only supported value is helm. In the - // future, we may add support for git and/or AWS s3-style buckets + // - for direct helm plug-in valid values are: “helm” and “oci” + // - for flux plug-in valid values are: “helm” and “oci”. In the + // future, we may add support for git and/or AWS s3-style buckets string type = 5; // A URL identifying the package repository location. Must contain at diff --git a/script/makefiles/deploy-dev.mk b/script/makefiles/deploy-dev.mk index 11b80b4561b..e5181f0cf17 100644 --- a/script/makefiles/deploy-dev.mk +++ b/script/makefiles/deploy-dev.mk @@ -62,7 +62,7 @@ deploy-kapp-controller: # Add the flux controllers used for testing the kubeapps-apis integration. deploy-flux-controllers: - kubectl --kubeconfig=${CLUSTER_CONFIG} apply -f https://github.com/fluxcd/flux2/releases/download/v0.30.2/install.yaml + kubectl --kubeconfig=${CLUSTER_CONFIG} apply -f https://github.com/fluxcd/flux2/releases/download/v0.31.0/install.yaml reset-dev: helm --kubeconfig=${CLUSTER_CONFIG} -n kubeapps delete kubeapps || true From de4cf465444abbfd142f4161dad41c10ec7a57aa Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Thu, 16 Jun 2022 13:08:05 -0700 Subject: [PATCH 02/11] inremental --- .../packages/v1alpha1/cache/chart_cache.go | 8 +- .../v1alpha1/chart_integration_test.go | 21 +- .../fluxv2/packages/v1alpha1/chart_test.go | 10 +- .../fluxv2/packages/v1alpha1/common/utils.go | 32 +- .../fluxv2/packages/v1alpha1/oci_repo.go | 514 ++++++++++++++++++ .../plugins/fluxv2/packages/v1alpha1/repo.go | 80 +-- .../fluxv2/packages/v1alpha1/server_test.go | 2 +- .../v1alpha1/testdata/kind-cluster-setup.sh | 1 + go.mod | 1 + go.sum | 2 + .../latest/howto/private-app-repository.md | 2 +- 11 files changed, 592 insertions(+), 81 deletions(-) create mode 100644 cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/cache/chart_cache.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/cache/chart_cache.go index c07e26d7be4..35d11f7d31e 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/cache/chart_cache.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/cache/chart_cache.go @@ -83,7 +83,7 @@ type chartCacheStoreEntry struct { id string version string url string - clientOptions *common.ClientOptions + clientOptions *common.HttpClientOptions deleted bool } @@ -118,7 +118,7 @@ func NewChartCache(name string, redisCli *redis.Client, stopCh <-chan struct{}) // this func will enqueue work items into chart work queue and return. // the charts will be synced worker threads running in the background -func (c *ChartCache) SyncCharts(charts []models.Chart, clientOptions *common.ClientOptions) error { +func (c *ChartCache) SyncCharts(charts []models.Chart, clientOptions *common.HttpClientOptions) error { log.Infof("+SyncCharts()") totalToSync := 0 defer func() { @@ -454,7 +454,7 @@ func (c *ChartCache) FetchForOne(key string) ([]byte, error) { • otherwise return the bytes stored in the chart cache for the given entry */ -func (c *ChartCache) GetForOne(key string, chart *models.Chart, clientOptions *common.ClientOptions) ([]byte, error) { +func (c *ChartCache) GetForOne(key string, chart *models.Chart, clientOptions *common.HttpClientOptions) ([]byte, error) { // TODO (gfichtenholt) it'd be nice to get rid of all arguments except for the key, similar to that of // NamespacedResourceWatcherCache.GetForOne() log.Infof("+GetForOne(%s)", key) @@ -599,7 +599,7 @@ func chartCacheKeyFor(namespace, chartID, chartVersion string) (string, error) { } // FYI: The work queue is able to retry transient HTTP errors -func ChartCacheComputeValue(chartID, chartUrl, chartVersion string, clientOptions *common.ClientOptions) ([]byte, error) { +func ChartCacheComputeValue(chartID, chartUrl, chartVersion string, clientOptions *common.HttpClientOptions) ([]byte, error) { client, headers, err := common.NewHttpClientAndHeaders(clientOptions) if err != nil { return nil, err diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go index af2a17a0e6a..e2d3128362b 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go @@ -514,7 +514,7 @@ func TestKindClusterRepoAndChartRBAC(t *testing.T) { } func TestKindClusterGetAvailablePackageSummariesForOCI(t *testing.T) { - fluxPluginClient, _, err := checkEnv(t) + fluxPluginClient, fluxPluginReposClient, err := checkEnv(t) if err != nil { t.Fatal(err) } @@ -532,7 +532,22 @@ func TestKindClusterGetAvailablePackageSummariesForOCI(t *testing.T) { Name: "my-podinfo-" + randSeq(4), Namespace: "default", } - if err := kubeAddHelmRepositoryAndCleanup(t, repoName, "oci", podinfo_oci_repo_url, "", 0); err != nil { + + secret := newBasicAuthSecret(types.NamespacedName{ + Name: "secret-1", + Namespace: repoName.Namespace}, + "admin", "Harbor12345") + + if err := kubeCreateSecretAndCleanup(t, secret); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(grpcContext, defaultContextTimeout) + defer cancel() + setUserManagedSecretsAndCleanup(t, fluxPluginReposClient, ctx, true) + + // TODO: need to somehow pass repo = "podinfo" + if err := kubeAddHelmRepositoryAndCleanup( + t, repoName, "oci", "oci://ghcr.io/stefanprodan/charts", "", 0); err != nil { t.Fatalf("%v", err) } // wait until this repo reaches 'Ready' @@ -540,7 +555,7 @@ func TestKindClusterGetAvailablePackageSummariesForOCI(t *testing.T) { t.Fatal(err) } - grpcContext, cancel := context.WithTimeout(grpcContext, 90*time.Second) + grpcContext, cancel = context.WithTimeout(grpcContext, 90*time.Second) defer cancel() resp, err := fluxPluginClient.GetAvailablePackageSummaries( grpcContext, diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_test.go index 49ec7888a51..466db6a749f 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_test.go @@ -108,7 +108,7 @@ func TestGetAvailablePackageDetail(t *testing.T) { requestChartUrl := "" // these will be used later in a few places - opts := &common.ClientOptions{} + opts := &common.HttpClientOptions{} if tc.basicAuth { opts.Username = "foo" opts.Password = "bar" @@ -743,7 +743,7 @@ func TestChartCacheResyncNotIdle(t *testing.T) { redisMockSetValueForRepo(mock, repoKey, repoBytes, nil) } - opts := &common.ClientOptions{} + opts := &common.HttpClientOptions{} chartCacheKeys := []string{} var chartBytes []byte for i := 0; i < NUM_CHARTS; i++ { @@ -960,7 +960,7 @@ func newChart(name, namespace string, spec *sourcev1.HelmChartSpec, status *sour return helmChart } -func (s *Server) redisMockSetValueForChart(mock redismock.ClientMock, key, url string, opts *common.ClientOptions) error { +func (s *Server) redisMockSetValueForChart(mock redismock.ClientMock, key, url string, opts *common.HttpClientOptions) error { sink := repoEventSink{ clientGetter: s.newBackgroundClientGetter(), chartCache: s.chartCache, @@ -968,7 +968,7 @@ func (s *Server) redisMockSetValueForChart(mock redismock.ClientMock, key, url s return sink.redisMockSetValueForChart(mock, key, url, opts) } -func (cs *repoEventSink) redisMockSetValueForChart(mock redismock.ClientMock, key, url string, opts *common.ClientOptions) error { +func (cs *repoEventSink) redisMockSetValueForChart(mock redismock.ClientMock, key, url string, opts *common.HttpClientOptions) error { _, chartID, version, err := fromRedisKeyForChart(key) if err != nil { return err @@ -988,7 +988,7 @@ func redisMockSetValueForChart(mock redismock.ClientMock, key string, byteArray } // does a series of mock.ExpectGet(...) -func redisMockExpectGetFromChartCache(mock redismock.ClientMock, key, url string, opts *common.ClientOptions) error { +func redisMockExpectGetFromChartCache(mock redismock.ClientMock, key, url string, opts *common.HttpClientOptions) error { if url != "" { _, chartID, version, err := fromRedisKeyForChart(key) if err != nil { diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go index e9a2dd3c78d..be3d7738186 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go @@ -26,6 +26,7 @@ import ( "golang.org/x/net/http/httpproxy" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "helm.sh/helm/v3/pkg/getter" apiv1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -223,7 +224,7 @@ func RedisMemoryStats(redisCli *redis.Client) (used, total string) { } // options are generic parameters to be provided to the httpclient during instantiation. -type ClientOptions struct { +type HttpClientOptions struct { // for TLS connections CertBytes []byte KeyBytes []byte @@ -236,12 +237,9 @@ type ClientOptions struct { UserAgent string } -// inspired by https://github.com/fluxcd/source-controller/blob/main/internal/helm/getter/getter.go#L29 - -// ClientOptionsFromSecret constructs a getter.Option slice for the given secret. -// It returns the slice, or an error. -func ClientOptionsFromSecret(secret apiv1.Secret) (*ClientOptions, error) { - var opts ClientOptions +// HttpClientOptionsFromSecret constructs a getter.Option slice for the given secret. +func HttpClientOptionsFromSecret(secret apiv1.Secret) (*HttpClientOptions, error) { + var opts HttpClientOptions if err := basicAuthFromSecret(secret, &opts); err != nil { return nil, err } @@ -251,10 +249,24 @@ func ClientOptionsFromSecret(secret apiv1.Secret) (*ClientOptions, error) { return &opts, nil } +// HelmGetterOptionsFromSecret attempts to construct a basic auth getter.Option for the +// given v1.Secret and returns the result. +// It returns the slice, or an error. +func HelmGetterOptionsFromSecret(secret apiv1.Secret) ([]getter.Option, error) { + var opts HttpClientOptions + if err := basicAuthFromSecret(secret, &opts); err != nil { + return nil, err + } else { + return []getter.Option{ + getter.WithBasicAuth(opts.Username, opts.Password), + }, nil + } +} + // // Secrets with no username AND password are ignored, if only one is defined it // returns an error. -func basicAuthFromSecret(secret apiv1.Secret, options *ClientOptions) error { +func basicAuthFromSecret(secret apiv1.Secret, options *HttpClientOptions) error { username, password := string(secret.Data["username"]), string(secret.Data["password"]) switch { case username == "" && password == "": @@ -269,7 +281,7 @@ func basicAuthFromSecret(secret apiv1.Secret, options *ClientOptions) error { // Secrets with no certFile, keyFile, AND caFile are ignored, if only a // certBytes OR keyBytes is defined it returns an error. -func tlsClientConfigFromSecret(secret apiv1.Secret, options *ClientOptions) error { +func tlsClientConfigFromSecret(secret apiv1.Secret, options *HttpClientOptions) error { certBytes, keyBytes, caBytes := secret.Data["certFile"], secret.Data["keyFile"], secret.Data["caFile"] switch { case len(certBytes)+len(keyBytes)+len(caBytes) == 0: @@ -285,7 +297,7 @@ func tlsClientConfigFromSecret(secret apiv1.Secret, options *ClientOptions) erro return nil } -func NewHttpClientAndHeaders(clientOptions *ClientOptions) (*http.Client, map[string]string, error) { +func NewHttpClientAndHeaders(clientOptions *HttpClientOptions) (*http.Client, map[string]string, error) { // I wish I could have re-used the code in pkg/chart/chart.go and pkg/kube_utils/kube_utils.go // InitHTTPClient(), etc. but alas, it's all built around AppRepository CRD, which I don't have. headers := make(map[string]string) diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go new file mode 100644 index 00000000000..77429b8602c --- /dev/null +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go @@ -0,0 +1,514 @@ +// Copyright 2021-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Inspired by +// https://github.com/fluxcd/source-controller/blob/main/internal/helm/repository/ +// oci_chart_repository.go +// and adapted for kubeapps use +// OCI spec ref +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md + +package main + +import ( + "bytes" + "context" + "crypto/tls" + "fmt" + "io" + "net/url" + "os" + "sort" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/registry" + "helm.sh/helm/v3/pkg/repo" + + "github.com/Masterminds/semver/v3" + "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common" + log "k8s.io/klog/v2" + + "github.com/fluxcd/pkg/version" + sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" + // "github.com/fluxcd/source-controller/internal/transport" +) + +// RegistryClient is an interface for interacting with OCI registries +// It is used by the OCIChartRepository to retrieve chart versions +// from OCI registries +type RegistryClient interface { + Login(host string, opts ...registry.LoginOption) error + Logout(host string, opts ...registry.LogoutOption) error + Tags(url string) ([]string, error) +} + +// OCIChartRepository represents a Helm chart repository, and the configuration +// required to download the repository tags and charts from the repository. +// All methods are thread safe unless defined otherwise. +type OCIChartRepository struct { + // URL is the location of the repository. + URL url.URL + // Client to use while accessing the repository's contents. + Client getter.Getter + // Options to configure the Client with while downloading tags + // or a chart from the URL. + Options []getter.Option + + tlsConfig *tls.Config + + // RegistryClient is a client to use while downloading tags or charts from a registry. + RegistryClient RegistryClient +} + +// OCIChartRepositoryOption is a function that can be passed to NewOCIChartRepository +// to configure an OCIChartRepository. +type OCIChartRepositoryOption func(*OCIChartRepository) error + +// WithOCIRegistryClient returns a ChartRepositoryOption that will set the registry client +func WithOCIRegistryClient(client RegistryClient) OCIChartRepositoryOption { + return func(r *OCIChartRepository) error { + r.RegistryClient = client + return nil + } +} + +// WithOCIGetter returns a ChartRepositoryOption that will set the getter.Getter +func WithOCIGetter(providers getter.Providers) OCIChartRepositoryOption { + return func(r *OCIChartRepository) error { + c, err := providers.ByScheme(r.URL.Scheme) + if err != nil { + return err + } + r.Client = c + return nil + } +} + +// WithOCIGetterOptions returns a ChartRepositoryOption that will set the getter.Options +func WithOCIGetterOptions(getterOpts []getter.Option) OCIChartRepositoryOption { + return func(r *OCIChartRepository) error { + r.Options = getterOpts + return nil + } +} + +// NewOCIChartRepository constructs and returns a new ChartRepository with +// the ChartRepository.Client configured to the getter.Getter for the +// repository URL scheme. It returns an error on URL parsing failures. +// It assumes that the url scheme has been validated to be an OCI scheme. +func NewOCIChartRepository(repositoryURL string, chartRepoOpts ...OCIChartRepositoryOption) (*OCIChartRepository, error) { + u, err := url.Parse(repositoryURL) + if err != nil { + return nil, err + } + + r := &OCIChartRepository{} + r.URL = *u + for _, opt := range chartRepoOpts { + if err := opt(r); err != nil { + return nil, err + } + } + + return r, nil +} + +// Get returns the repo.ChartVersion for the given name, the version is expected +// to be a semver.Constraints compatible string. If version is empty, the latest +// stable version will be returned and prerelease versions will be ignored. +// adapted from https://github.com/helm/helm/blob/49819b4ef782e80b0c7f78c30bd76b51ebb56dc8/pkg/downloader/chart_downloader.go#L162 +func (r *OCIChartRepository) Get(name, ver string) (*repo.ChartVersion, error) { + // Find chart versions matching the given name. + // Either in an index file or from a registry. + ref := fmt.Sprintf("%s/%s", r.URL.String(), name) + log.Infof("about to call getTags(%s)", ref) + cvs, err := r.getTags(ref) + if err != nil { + return nil, err + } + + if len(cvs) == 0 { + return nil, fmt.Errorf("unable to locate any tags in provided repository: %s", name) + } + + // Determine if version provided + // If empty, try to get the highest available tag + // If exact version, try to find it + // If semver constraint string, try to find a match + tag, err := getLastMatchingVersionOrConstraint(cvs, ver) + return &repo.ChartVersion{ + URLs: []string{fmt.Sprintf("%s/%s:%s", r.URL.String(), name, tag)}, + Metadata: &chart.Metadata{ + Name: name, + Version: tag, + }, + }, err +} + +// This function shall be called for OCI registries only +// It assumes that the ref has been validated to be an OCI reference. +func (r *OCIChartRepository) getTags(ref string) ([]string, error) { + url := strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme)) + log.Infof("about to call RegistryClient.Tags(%s)", url) + // Retrieve list of repository tags + tags, err := r.RegistryClient.Tags(url) + log.Infof("done with call RegistryClient.Tags(%s): %s %v", url, tags, err) + if err != nil { + return nil, err + } + if len(tags) == 0 { + return nil, fmt.Errorf("unable to locate any tags in provided repository: %s", ref) + } + + return tags, nil +} + +// DownloadChart confirms the given repo.ChartVersion has a downloadable URL, +// and then attempts to download the chart using the Client and Options of the +// ChartRepository. It returns a bytes.Buffer containing the chart data. +// In case of an OCI hosted chart, this function assumes that the chartVersion url is valid. +func (r *OCIChartRepository) DownloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error) { + if len(chart.URLs) == 0 { + return nil, fmt.Errorf("chart '%s' has no downloadable URLs", chart.Name) + } + + ref := chart.URLs[0] + u, err := url.Parse(ref) + if err != nil { + err = fmt.Errorf("invalid chart URL format '%s': %w", ref, err) + return nil, err + } + + ustr := strings.TrimPrefix(u.String(), fmt.Sprintf("%s://", registry.OCIScheme)) + return nil, status.Errorf(codes.Unimplemented, "TODO %s", ustr) + + /* TODO: + t := transport.NewOrIdle(r.tlsConfig) + clientOpts := append(r.Options, getter.WithTransport(t)) + defer transport.Release(t) + + // trim the oci scheme prefix if needed + return r.Client.Get(ustr, clientOpts...) + */ +} + +// Login attempts to login to the OCI registry. +// It returns an error on failure. +func (r *OCIChartRepository) Login(opts ...registry.LoginOption) error { + err := r.RegistryClient.Login(r.URL.Host, opts...) + if err != nil { + return err + } + return nil +} + +// Logout attempts to logout from the OCI registry. +// It returns an error on failure. +func (r *OCIChartRepository) Logout() error { + err := r.RegistryClient.Logout(r.URL.Host) + if err != nil { + return err + } + return nil +} + +// getLastMatchingVersionOrConstraint returns the last version that matches the given version string. +// If the version string is empty, the highest available version is returned. +func getLastMatchingVersionOrConstraint(cvs []string, ver string) (string, error) { + // Check for exact matches first + if ver != "" { + for _, cv := range cvs { + if ver == cv { + return cv, nil + } + } + } + + // Continue to look for a (semantic) version match + verConstraint, err := semver.NewConstraint("*") + if err != nil { + return "", err + } + latestStable := ver == "" || ver == "*" + if !latestStable { + verConstraint, err = semver.NewConstraint(ver) + if err != nil { + return "", err + } + } + + matchingVersions := make([]*semver.Version, 0, len(cvs)) + for _, cv := range cvs { + v, err := version.ParseVersion(cv) + if err != nil { + continue + } + + if !verConstraint.Check(v) { + continue + } + + matchingVersions = append(matchingVersions, v) + } + if len(matchingVersions) == 0 { + return "", fmt.Errorf("could not locate a version matching provided version string %s", ver) + } + + // Sort versions + sort.Sort(sort.Reverse(semver.Collection(matchingVersions))) + + return matchingVersions[0].Original(), nil +} + +// NewRegistryClient generates a registry client and a temporary credential file. +// The client is meant to be used for a single reconciliation. +// The file is meant to be used for a single reconciliation and deleted after. +func NewRegistryClient(isLogin bool) (*registry.Client, string, error) { + if isLogin { + // create a temporary file to store the credentials + // this is needed because otherwise the credentials are stored in ~/.docker/config.json. + credentialFile, err := os.CreateTemp("", "credentials") + if err != nil { + return nil, "", err + } + + rClient, err := registry.NewClient(registry.ClientOptWriter(io.Discard), registry.ClientOptCredentialsFile(credentialFile.Name())) + if err != nil { + return nil, "", err + } + return rClient, credentialFile.Name(), nil + } + + rClient, err := registry.NewClient(registry.ClientOptWriter(io.Discard)) + if err != nil { + return nil, "", err + } + return rClient, "", nil +} + +// OCI Helm repository, which defines a source, does not produce an Artifact +// ref https://fluxcd.io/docs/components/source/helmrepositories/#helm-oci-repository +func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool, error) { + log.Infof("+onAddOciRepo(%s)", common.PrettyPrint(repo)) + defer log.Infof("-onAddOciRepo") + + // Construct the Getter options from the HelmRepository data + loginOpts, getterOpts, err := s.helmOptionsForRepo(repo) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "failed to create registry client options: %v", err) + } + log.Infof("=====================> loginOpts: [%v], getterOpts: [%v]", loginOpts, getterOpts) + + // Create registry client and login if needed. + registryClient, file, err := NewRegistryClient(loginOpts != nil) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "failed to create registry client: %v", err) + } + if file != "" { + defer func() { + if err := os.Remove(file); err != nil { + log.Infof("Failed to delete temporary credentials file: %v", err) + } + }() + } + + chartRepo, err := NewOCIChartRepository( + repo.Spec.URL, + WithOCIRegistryClient(registryClient)) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "failed to parse URL '%s': %v", repo.Spec.URL, err) + } + + // Attempt to login to the registry if credentials are provided. + if loginOpts != nil { + err = chartRepo.Login(loginOpts...) + if err != nil { + return nil, false, err + } + } + + repoURL, err := url.ParseRequestURI(strings.TrimSpace(repo.Spec.URL)) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + + // e.g. + log.Infof("==========>: url: [%s]", common.PrettyPrint(repoURL)) + + ref := "podinfo" + log.Infof("==========>: ref: [%s]", ref) + + chartVersion, err := chartRepo.Get(ref, "") + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + log.Infof("==========>: chart version: %s", common.PrettyPrint(chartVersion)) + + // later we'll do + // chartRepo.DownloadChart(chartVersion) + // to cache the charts' .tgz file + + return nil, false, nil + /* + authorizationHeader := "" + // TODO look at repo's secretRef + /* + // The auth header may be a dockerconfig that we need to parse + if serveOpts.DockerConfigJson != "" { + dockerConfig := &credentialprovider.DockerConfigJSON{} + err := json.Unmarshal([]byte(serveOpts.DockerConfigJson), dockerConfig) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + authorizationHeader, err = kube.GetAuthHeaderFromDockerConfig(dockerConfig) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + } + headers := http.Header{} + if authorizationHeader != "" { + headers["Authorization"] = []string{authorizationHeader} + } + // TODO look at repo's secretRef for TLS config + netClient, err := httpclient.NewWithCertFile(additionalCAFile, false) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + + ociResolver := + docker.NewResolver( + docker.ResolverOptions{ + Headers: headers, + + Client: netClient}) + // from cmd/asset-syncer/server/utils.go + //func pullAndExtract(repoURL *url.URL, appName, tag string, puller helm.ChartPuller, r *OCIRegistry) (*models.Chart, error) { + repoURL, err := url.ParseRequestURI(strings.TrimSpace(repo.Spec.URL)) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + repositories := []string{"test-oci-1/podinfo"} + + // find tags + // see func (r *OCIRegistry) FilterIndex() + // TagList represents a list of tags as specified at + // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery + type TagList struct { + Name string `json:"name"` + Tags []string `json:"tags"` + } + tags := map[string]TagList{} + + for _, appName := range repositories { + tag := tags[appName].Tags[0] + ref := path.Join(repoURL.Host, repoURL.Path, fmt.Sprintf("%s:%s", appName, tag)) + } + + chartBuffer, digest, err := puller.PullOCIChart(ref) + if err != nil { + return nil, err + } + + // Extract + files, err := extractFilesFromBuffer(chartBuffer) + if err != nil { + return nil, err + } + chartMetadata := chart.Metadata{} + err = yaml.Unmarshal([]byte(files.Metadata), &chartMetadata) + if err != nil { + return nil, err + } + + // Format Data + chartVersion := models.ChartVersion{ + Version: chartMetadata.Version, + AppVersion: chartMetadata.AppVersion, + Digest: digest, + URLs: chartMetadata.Sources, + Readme: files.Readme, + Values: files.Values, + Schema: files.Schema, + } + + maintainers := []chart.Maintainer{} + for _, m := range chartMetadata.Maintainers { + maintainers = append(maintainers, chart.Maintainer{ + Name: m.Name, + Email: m.Email, + URL: m.URL, + }) + } + + // Encode repository names to store them in the database. + encodedAppName := url.PathEscape(appName) + + &models.Chart{ + ID: path.Join(r.Name, encodedAppName), + Name: encodedAppName, + Repo: &models.Repo{Namespace: r.Namespace, Name: r.Name, URL: r.URL, Type: r.Type}, + Description: chartMetadata.Description, + Home: chartMetadata.Home, + Keywords: chartMetadata.Keywords, + Maintainers: maintainers, + Sources: chartMetadata.Sources, + Icon: chartMetadata.Icon, + Category: chartMetadata.Annotations["category"], + ChartVersions: []models.ChartVersion{chartVersion}, + }, nil + */ +} + +// +// misc OCI repo utilities +// +func (s *repoEventSink) helmOptionsForRepo(repo sourcev1.HelmRepository) ([]registry.LoginOption, []getter.Option, error) { + log.Infof("+helmOptionsForRepo()") + + getterOpts := []getter.Option{ + getter.WithURL(repo.Spec.URL), + getter.WithTimeout(repo.Spec.Timeout.Duration), + getter.WithPassCredentialsAll(repo.Spec.PassCredentials), + } + + secret, err := s.getRepoSecret(context.Background(), repo) + if err != nil { + return nil, nil, err + } else if secret != nil { + opts, err := common.HelmGetterOptionsFromSecret(*secret) + if err != nil { + return nil, nil, err + } + getterOpts = append(getterOpts, opts...) + } + + log.Infof("============> getter opts: [%v]", getterOpts) + + /* + ctx := + _, err := s.clientOptionsForRepo(ctx, repo) + if err != nil { + return nil, nil, err + } + + loginOpt := append(getterOpts, common.ConvertClientOptionsToHelmGetterOptions(c)...) + + tlsConfig, err = getter.TLSClientConfigFromSecret(*secret, repo.Spec.URL) + if err != nil { + return nil, err + } + + // Build registryClient options from secret + loginOpt, err := registry.LoginOptionFromSecret(repo.Spec.URL, *secret) + if err != nil { + return nil, err + } + + loginOpts := append([]registry.LoginOption{}, loginOpt) + */ + return nil, getterOpts, nil +} diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go index fa9ec36bb4e..ac15fa60e9e 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go @@ -8,13 +8,11 @@ import ( "context" "encoding/gob" "fmt" - "net/http" "reflect" "regexp" "strings" "time" - "github.com/containerd/containerd/remotes/docker" fluxmeta "github.com/fluxcd/pkg/apis/meta" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" corev1 "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/core/packages/v1alpha1" @@ -43,7 +41,6 @@ const ( fluxHelmRepositories = "helmrepositories" fluxHelmRepositoryList = "HelmRepositoryList" redactedString = "REDACTED" - additionalCAFile = "/usr/local/share/ca-certificates/ca.crt" ) var ( @@ -182,7 +179,7 @@ func (s *Server) getChartsForRepos(ctx context.Context, match []string) (map[str return chartsTyped, nil } -func (s *Server) clientOptionsForRepo(ctx context.Context, repoName types.NamespacedName) (*common.ClientOptions, error) { +func (s *Server) clientOptionsForRepo(ctx context.Context, repoName types.NamespacedName) (*common.HttpClientOptions, error) { repo, err := s.getRepoInCluster(ctx, repoName) if err != nil { return nil, err @@ -200,7 +197,7 @@ func (s *Server) clientOptionsForRepo(ctx context.Context, repoName types.Namesp clientGetter: s.newBackgroundClientGetter(), chartCache: s.chartCache, } - return sink.clientOptionsForRepo(ctx, *repo) + return sink.httpClientOptionsForRepo(ctx, *repo) } func (s *Server) newRepo(ctx context.Context, request *corev1.AddPackageRepositoryRequest) (*corev1.PackageRepositoryReference, error) { @@ -698,17 +695,17 @@ type repoCacheEntryValue struct { // onAddRepo essentially tells the cache whether to and what to store for a given key func (s *repoEventSink) onAddRepo(key string, obj ctrlclient.Object) (interface{}, bool, error) { - log.V(4).Info("+onAddRepo(%s)", key) - defer log.V(4).Info("-onAddRepo()") + log.Infof("+onAddRepo(%s)", key) + defer log.Infof("-onAddRepo()") if repo, ok := obj.(*sourcev1.HelmRepository); !ok { return nil, false, fmt.Errorf("expected an instance of *sourcev1.HelmRepository, got: %s", reflect.TypeOf(obj)) // first, check the repo is ready } else if isRepoReady(*repo) { if repo.Spec.Type == sourcev1.HelmRepositoryTypeOCI { - return s.onAddOciRepo(repo) + return s.onAddOciRepo(*repo) } else { - return s.onAddHttpRepo(repo) + return s.onAddHttpRepo(*repo) } } else { // repo is not quite ready to be indexed - not really an error condition, @@ -718,52 +715,15 @@ func (s *repoEventSink) onAddRepo(key string, obj ctrlclient.Object) (interface{ } } -// OCI Helm repository, which defines a source, does not produce an Artifact -// ref https://fluxcd.io/docs/components/source/helmrepositories/#helm-oci-repository -func (s *repoEventSink) onAddOciRepo(repo *sourcev1.HelmRepository) ([]byte, bool, error) { - authorizationHeader := "" - // TODO look at repo's secretRef - /* - // The auth header may be a dockerconfig that we need to parse - if serveOpts.DockerConfigJson != "" { - dockerConfig := &credentialprovider.DockerConfigJSON{} - err := json.Unmarshal([]byte(serveOpts.DockerConfigJson), dockerConfig) - if err != nil { - return nil, false, status.Errorf(codes.Internal, "%v", err) - } - authorizationHeader, err = kube.GetAuthHeaderFromDockerConfig(dockerConfig) - if err != nil { - return nil, false, status.Errorf(codes.Internal, "%v", err) - } - } - */ - headers := http.Header{} - if authorizationHeader != "" { - headers["Authorization"] = []string{authorizationHeader} - } - // TODO look at repo's secretRef for TLS config - netClient, err := httpclient.NewWithCertFile(additionalCAFile, false) - if err != nil { - return nil, false, status.Errorf(codes.Internal, "%v", err) - } - - ociResolver := docker.NewResolver( - docker.ResolverOptions{ - Headers: headers, - Client: netClient}) - - return nil, false, nil -} - // ref https://fluxcd.io/docs/components/source/helmrepositories/#status -func (s *repoEventSink) onAddHttpRepo(repo *sourcev1.HelmRepository) ([]byte, bool, error) { +func (s *repoEventSink) onAddHttpRepo(repo sourcev1.HelmRepository) ([]byte, bool, error) { if artifact := repo.GetArtifact(); artifact != nil { if checksum := artifact.Checksum; checksum == "" { return nil, false, status.Errorf(codes.Internal, "expected field status.artifact.checksum not found on HelmRepository\n[%s]", common.PrettyPrint(repo)) } else { - return s.indexAndEncode(checksum, *repo) + return s.indexAndEncode(checksum, repo) } } else { return nil, false, status.Errorf(codes.Internal, @@ -773,6 +733,7 @@ func (s *repoEventSink) onAddHttpRepo(repo *sourcev1.HelmRepository) ([]byte, bo } func (s *repoEventSink) indexAndEncode(checksum string, repo sourcev1.HelmRepository) ([]byte, bool, error) { + log.Infof("+indexAndEncode(%s)", common.PrettyPrint(repo)) charts, err := s.indexOneRepo(repo) if err != nil { return nil, false, err @@ -791,7 +752,7 @@ func (s *repoEventSink) indexAndEncode(checksum string, repo sourcev1.HelmReposi } if s.chartCache != nil { - if opts, err := s.clientOptionsForRepo(context.Background(), repo); err != nil { + if opts, err := s.httpClientOptionsForRepo(context.Background(), repo); err != nil { // ref: https://github.com/vmware-tanzu/kubeapps/pull/3899#issuecomment-990446931 // I don't want this func to fail onAdd/onModify() if we can't read // the corresponding secret due to something like default RBAC settings: @@ -964,12 +925,7 @@ func (s *repoEventSink) fromKey(key string) (*types.NamespacedName, error) { return &types.NamespacedName{Namespace: parts[1], Name: parts[2]}, nil } -// this is only until https://github.com/vmware-tanzu/kubeapps/issues/3496 -// "Investigate and propose package repositories API with similar core interface to packages API" -// gets implemented. After that, the auth should be part of some kind of packageRepositoryFromCtrlObject() -// The reason I do this here is to set up auth that may be needed to fetch chart tarballs by -// ChartCache -func (s *repoEventSink) clientOptionsForRepo(ctx context.Context, repo sourcev1.HelmRepository) (*common.ClientOptions, error) { +func (s *repoEventSink) getRepoSecret(ctx context.Context, repo sourcev1.HelmRepository) (*apiv1.Secret, error) { if repo.Spec.SecretRef == nil { return nil, nil } @@ -978,7 +934,7 @@ func (s *repoEventSink) clientOptionsForRepo(ctx context.Context, repo sourcev1. return nil, nil } if s == nil || s.clientGetter == nil { - return nil, status.Errorf(codes.Internal, "unexpected state in clientGetterHolder instance") + return nil, status.Errorf(codes.Internal, "unexpected state in clientGetter instance") } typedClient, err := s.clientGetter.Typed(ctx) if err != nil { @@ -992,7 +948,17 @@ func (s *repoEventSink) clientOptionsForRepo(ctx context.Context, repo sourcev1. if err != nil { return nil, statuserror.FromK8sError("get", "secret", secretName, err) } - return common.ClientOptionsFromSecret(*secret) + return secret, err +} + +// The reason I do this here is to set up auth that may be needed to fetch chart tarballs by +// ChartCache +func (s *repoEventSink) httpClientOptionsForRepo(ctx context.Context, repo sourcev1.HelmRepository) (*common.HttpClientOptions, error) { + if secret, err := s.getRepoSecret(ctx, repo); err == nil && secret != nil { + return common.HttpClientOptionsFromSecret(*secret) + } else { + return nil, err + } } // diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/server_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/server_test.go index 5f6fde80a56..520bc4b90d6 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/server_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/server_test.go @@ -196,7 +196,7 @@ type testSpecChartWithUrl struct { chartID string chartRevision string chartUrl string - opts *common.ClientOptions + opts *common.HttpClientOptions repoNamespace string // this is for a negative test TestTransientHttpFailuresAreRetriedForChartCache numRetries int diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/testdata/kind-cluster-setup.sh b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/testdata/kind-cluster-setup.sh index b30f7d9db51..714a3248705 100755 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/testdata/kind-cluster-setup.sh +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/testdata/kind-cluster-setup.sh @@ -27,6 +27,7 @@ function deploy { -v $(pwd)/bcrypt.htpasswd:/etc/docker/registry/auth.htpasswd \ -e REGISTRY_AUTH="{htpasswd: {realm: localhost, path: /etc/docker/registry/auth.htpasswd}}" \ registry + # TODO retries helm registry login -u foo localhost:5000 -p bar helm push charts/podinfo-6.0.3.tgz oci://localhost:5000/helm-charts helm show all oci://localhost:5000/helm-charts/podinfo | head -9 diff --git a/go.mod b/go.mod index c4fff83fd3f..0e4e8e5514c 100644 --- a/go.mod +++ b/go.mod @@ -130,6 +130,7 @@ require ( github.com/fatih/color v1.13.0 // indirect github.com/fluxcd/pkg/apis/acl v0.0.3 // indirect github.com/fluxcd/pkg/apis/kustomize v0.4.1 // indirect + github.com/fluxcd/pkg/version v0.1.0 github.com/fsnotify/fsnotify v1.5.4 // indirect github.com/fvbommel/sortorder v1.0.2 // indirect github.com/ghodss/yaml v1.0.0 // indirect diff --git a/go.sum b/go.sum index 239a89e56be..1d5ff777325 100644 --- a/go.sum +++ b/go.sum @@ -598,6 +598,8 @@ github.com/fluxcd/pkg/apis/kustomize v0.4.1/go.mod h1:U9rfSgDHaQd74PgPKt9DprtuzT github.com/fluxcd/pkg/apis/meta v0.14.1/go.mod h1:1uJkTJGSZWrZxL5PFpx1IxGLrFmT1Cd0C2fFWrbv77I= github.com/fluxcd/pkg/apis/meta v0.14.2 h1:/Hf7I/Vz01vv3m7Qx7DtQvrzAL1oVt0MJcLb/I1Y1HE= github.com/fluxcd/pkg/apis/meta v0.14.2/go.mod h1:ijZ61VG/8T3U17gj0aFL3fdtZL+mulD6V8VrLLUCAgM= +github.com/fluxcd/pkg/version v0.1.0 h1:v+SmCanmCB5Tj2Cx9TXlj+kNRfPGbAvirkeqsp7ZEAQ= +github.com/fluxcd/pkg/version v0.1.0/go.mod h1:V7Z/w8dxLQzv0FHqa5ox5TeyOd2zOd49EeuWFgnwyj4= github.com/fluxcd/source-controller/api v0.25.4 h1:ezKARCXsuHNIie+NxL5aVe+/UufefOI4J8GUtn/g0dQ= github.com/fluxcd/source-controller/api v0.25.4/go.mod h1:tuMrqHHpRt7mxdLeRXGIMtTKAMufLwLTm5uXkEOJWFw= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= diff --git a/site/content/docs/latest/howto/private-app-repository.md b/site/content/docs/latest/howto/private-app-repository.md index 4d902d528e1..84eba0478c4 100644 --- a/site/content/docs/latest/howto/private-app-repository.md +++ b/site/content/docs/latest/howto/private-app-repository.md @@ -198,7 +198,7 @@ There is one caveat though. It's necessary to specify the list of applications ( For example, for Harbor, it's possible to query its API to retrieve the list: ```console -curl -X GET "https://harbor.domain/api/v2.0/projects/my-oci-registry/repositories" -H "accept: applincation/json" | jq 'map(.name) | join(", ")' +curl -X GET "https://harbor.domain/api/v2.0/projects/my-oci-registry/repositories" -H "accept: application/json" | jq 'map(.name) | join(", ")' ``` > **Note**: Substitute the domain `harbor.domain` and the project name `my-oci-registry` with your own. From 42cc0cab63e026a0be333111abad9f38a39df21f Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Thu, 16 Jun 2022 15:46:57 -0700 Subject: [PATCH 03/11] incremental --- .../fluxv2/packages/v1alpha1/oci_repo.go | 105 ++++++++++-------- .../plugins/fluxv2/packages/v1alpha1/repo.go | 37 +++--- script/makefiles/deploy-dev.mk | 2 +- 3 files changed, 82 insertions(+), 62 deletions(-) diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go index 77429b8602c..01f4d0ad4fe 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go @@ -2,11 +2,10 @@ // SPDX-License-Identifier: Apache-2.0 // Inspired by -// https://github.com/fluxcd/source-controller/blob/main/internal/helm/repository/ -// oci_chart_repository.go -// and adapted for kubeapps use +// https://github.com/fluxcd/source-controller/blob/main/internal/helm/repository/oci_chart_repository.go +// and adapted for kubeapps use. // OCI spec ref -// https://github.com/opencontainers/distribution-spec/blob/main/spec.md +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md package main @@ -38,7 +37,7 @@ import ( ) // RegistryClient is an interface for interacting with OCI registries -// It is used by the OCIChartRepository to retrieve chart versions +// It is used by the OCIRegistry to retrieve chart versions // from OCI registries type RegistryClient interface { Login(host string, opts ...registry.LoginOption) error @@ -46,10 +45,10 @@ type RegistryClient interface { Tags(url string) ([]string, error) } -// OCIChartRepository represents a Helm chart repository, and the configuration +// OCIRegistry represents a Helm chart repository, and the configuration // required to download the repository tags and charts from the repository. // All methods are thread safe unless defined otherwise. -type OCIChartRepository struct { +type OCIRegistry struct { // URL is the location of the repository. URL url.URL // Client to use while accessing the repository's contents. @@ -64,21 +63,21 @@ type OCIChartRepository struct { RegistryClient RegistryClient } -// OCIChartRepositoryOption is a function that can be passed to NewOCIChartRepository -// to configure an OCIChartRepository. -type OCIChartRepositoryOption func(*OCIChartRepository) error +// OCIRegistryOption is a function that can be passed to NewOCIRegistry +// to configure an OCIRegistry. +type OCIRegistryOption func(*OCIRegistry) error // WithOCIRegistryClient returns a ChartRepositoryOption that will set the registry client -func WithOCIRegistryClient(client RegistryClient) OCIChartRepositoryOption { - return func(r *OCIChartRepository) error { +func WithOCIRegistryClient(client RegistryClient) OCIRegistryOption { + return func(r *OCIRegistry) error { r.RegistryClient = client return nil } } -// WithOCIGetter returns a ChartRepositoryOption that will set the getter.Getter -func WithOCIGetter(providers getter.Providers) OCIChartRepositoryOption { - return func(r *OCIChartRepository) error { +// WithOCIGetter returns a OCIRegistryOption that will set the getter.Getter +func WithOCIGetter(providers getter.Providers) OCIRegistryOption { + return func(r *OCIRegistry) error { c, err := providers.ByScheme(r.URL.Scheme) if err != nil { return err @@ -88,27 +87,27 @@ func WithOCIGetter(providers getter.Providers) OCIChartRepositoryOption { } } -// WithOCIGetterOptions returns a ChartRepositoryOption that will set the getter.Options -func WithOCIGetterOptions(getterOpts []getter.Option) OCIChartRepositoryOption { - return func(r *OCIChartRepository) error { +// WithOCIGetterOptions returns a OCIRegistryOption that will set the getter.Options +func WithOCIGetterOptions(getterOpts []getter.Option) OCIRegistryOption { + return func(r *OCIRegistry) error { r.Options = getterOpts return nil } } -// NewOCIChartRepository constructs and returns a new ChartRepository with -// the ChartRepository.Client configured to the getter.Getter for the -// repository URL scheme. It returns an error on URL parsing failures. +// NewOCIRegistry constructs and returns a new OCIRegistry with +// the RegistryClient configured to the getter.Getter for the +// registry URL scheme. It returns an error on URL parsing failures. // It assumes that the url scheme has been validated to be an OCI scheme. -func NewOCIChartRepository(repositoryURL string, chartRepoOpts ...OCIChartRepositoryOption) (*OCIChartRepository, error) { - u, err := url.Parse(repositoryURL) +func NewOCIRegistry(registryURL string, registryOpts ...OCIRegistryOption) (*OCIRegistry, error) { + u, err := url.Parse(registryURL) if err != nil { return nil, err } - r := &OCIChartRepository{} + r := &OCIRegistry{} r.URL = *u - for _, opt := range chartRepoOpts { + for _, opt := range registryOpts { if err := opt(r); err != nil { return nil, err } @@ -117,11 +116,19 @@ func NewOCIChartRepository(repositoryURL string, chartRepoOpts ...OCIChartReposi return r, nil } -// Get returns the repo.ChartVersion for the given name, the version is expected +func (r *OCIRegistry) listRepositoryNames() ([]string, error) { + // see OCI Registry section in private-app-repository.md + // It's necessary to specify the list of applications (repositories) that the registry contains. + // This is because the OCI specification doesn't have an endpoint to discover artifacts + // (unlike the index.yaml file of a Helm repository). + return []string{"podinfo"}, nil +} + +// Get returns the ChartVersion for the given name, the version is expected // to be a semver.Constraints compatible string. If version is empty, the latest // stable version will be returned and prerelease versions will be ignored. // adapted from https://github.com/helm/helm/blob/49819b4ef782e80b0c7f78c30bd76b51ebb56dc8/pkg/downloader/chart_downloader.go#L162 -func (r *OCIChartRepository) Get(name, ver string) (*repo.ChartVersion, error) { +func (r *OCIRegistry) getChartVersion(name, ver string) (*repo.ChartVersion, error) { // Find chart versions matching the given name. // Either in an index file or from a registry. ref := fmt.Sprintf("%s/%s", r.URL.String(), name) @@ -151,7 +158,7 @@ func (r *OCIChartRepository) Get(name, ver string) (*repo.ChartVersion, error) { // This function shall be called for OCI registries only // It assumes that the ref has been validated to be an OCI reference. -func (r *OCIChartRepository) getTags(ref string) ([]string, error) { +func (r *OCIRegistry) getTags(ref string) ([]string, error) { url := strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme)) log.Infof("about to call RegistryClient.Tags(%s)", url) // Retrieve list of repository tags @@ -167,11 +174,11 @@ func (r *OCIChartRepository) getTags(ref string) ([]string, error) { return tags, nil } -// DownloadChart confirms the given repo.ChartVersion has a downloadable URL, +// downloadChart confirms the given repo.ChartVersion has a downloadable URL, // and then attempts to download the chart using the Client and Options of the // ChartRepository. It returns a bytes.Buffer containing the chart data. // In case of an OCI hosted chart, this function assumes that the chartVersion url is valid. -func (r *OCIChartRepository) DownloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error) { +func (r *OCIRegistry) downloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error) { if len(chart.URLs) == 0 { return nil, fmt.Errorf("chart '%s' has no downloadable URLs", chart.Name) } @@ -196,9 +203,9 @@ func (r *OCIChartRepository) DownloadChart(chart *repo.ChartVersion) (*bytes.Buf */ } -// Login attempts to login to the OCI registry. +// login attempts to login to the OCI registry. // It returns an error on failure. -func (r *OCIChartRepository) Login(opts ...registry.LoginOption) error { +func (r *OCIRegistry) login(opts ...registry.LoginOption) error { err := r.RegistryClient.Login(r.URL.Host, opts...) if err != nil { return err @@ -206,9 +213,9 @@ func (r *OCIChartRepository) Login(opts ...registry.LoginOption) error { return nil } -// Logout attempts to logout from the OCI registry. +// logout attempts to logout from the OCI registry. // It returns an error on failure. -func (r *OCIChartRepository) Logout() error { +func (r *OCIRegistry) logout() error { err := r.RegistryClient.Logout(r.URL.Host) if err != nil { return err @@ -301,7 +308,7 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool if err != nil { return nil, false, status.Errorf(codes.Internal, "failed to create registry client options: %v", err) } - log.Infof("=====================> loginOpts: [%v], getterOpts: [%v]", loginOpts, getterOpts) + log.Infof("=====================> loginOpts: [%v], getterOpts: [%v]", len(loginOpts), len(getterOpts)) // Create registry client and login if needed. registryClient, file, err := NewRegistryClient(loginOpts != nil) @@ -316,7 +323,10 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool }() } - chartRepo, err := NewOCIChartRepository( + // a little bit misleading, repo.Spec.URL is really an OCI registry URL, + // which may contain zero or more "helm repositories", such as + // oci://demo.goharbor.io/test-oci-1 or + ociRegistry, err := NewOCIRegistry( repo.Spec.URL, WithOCIRegistryClient(registryClient)) if err != nil { @@ -325,7 +335,7 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool // Attempt to login to the registry if credentials are provided. if loginOpts != nil { - err = chartRepo.Login(loginOpts...) + err = ociRegistry.login(loginOpts...) if err != nil { return nil, false, err } @@ -337,16 +347,21 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool } // e.g. - log.Infof("==========>: url: [%s]", common.PrettyPrint(repoURL)) - - ref := "podinfo" - log.Infof("==========>: ref: [%s]", ref) + log.Infof("==========>: URL object: [%s]", common.PrettyPrint(repoURL)) - chartVersion, err := chartRepo.Get(ref, "") + repoNames, err := ociRegistry.listRepositoryNames() if err != nil { - return nil, false, status.Errorf(codes.Internal, "%v", err) + return nil, false, err + } + for _, name := range repoNames { + log.Infof("==========>: ref: [%s]", name) + + chartVersion, err := ociRegistry.getChartVersion(name, "") + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + log.Infof("==========>: chart version: %s", common.PrettyPrint(chartVersion)) } - log.Infof("==========>: chart version: %s", common.PrettyPrint(chartVersion)) // later we'll do // chartRepo.DownloadChart(chartVersion) @@ -486,8 +501,6 @@ func (s *repoEventSink) helmOptionsForRepo(repo sourcev1.HelmRepository) ([]regi getterOpts = append(getterOpts, opts...) } - log.Infof("============> getter opts: [%v]", getterOpts) - /* ctx := _, err := s.clientOptionsForRepo(ctx, repo) diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go index cc032129d2d..45fc7c02e10 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go @@ -544,12 +544,12 @@ func (s *Server) updateKubeappsManagedRepoSecret( // TODO (gfichtenholt) we should optimize this to somehow tell if the existing secret // is the same (data-wise) as the new one and if so skip all this if err = secretInterface.Delete(ctx, existingSecretRef.Name, metav1.DeleteOptions{}); err != nil { - return nil, false, statuserror.FromK8sError("get", "secret", existingSecretRef.Name, err) + return nil, false, statuserror.FromK8sError("delete", "secret", existingSecretRef.Name, err) } // create a new one newSecret, err := secretInterface.Create(ctx, secret, metav1.CreateOptions{}) if err != nil { - return nil, false, statuserror.FromK8sError("update", "secret", secret.GetGenerateName(), err) + return nil, false, statuserror.FromK8sError("create", "secret", secret.GetGenerateName(), err) } return newSecret, true, nil } @@ -697,20 +697,10 @@ func (s *repoEventSink) onAddRepo(key string, obj ctrlclient.Object) (interface{ if repo, ok := obj.(*sourcev1.HelmRepository); !ok { return nil, false, fmt.Errorf("expected an instance of *sourcev1.HelmRepository, got: %s", reflect.TypeOf(obj)) } else if isRepoReady(*repo) { - // first, check the repo is ready - // ref https://fluxcd.io/docs/components/source/helmrepositories/#status - if artifact := repo.GetArtifact(); artifact != nil { - if checksum := artifact.Checksum; checksum == "" { - return nil, false, status.Errorf(codes.Internal, - "expected field status.artifact.checksum not found on HelmRepository\n[%s]", - common.PrettyPrint(repo)) - } else { - return s.indexAndEncode(checksum, *repo) - } + if repo.Spec.Type == sourcev1.HelmRepositoryTypeOCI { + return s.onAddOciRepo(*repo) } else { - return nil, false, status.Errorf(codes.Internal, - "expected field status.artifact not found on HelmRepository\n[%s]", - common.PrettyPrint(repo)) + return s.onAddHttpRepo(*repo) } } else { // repo is not quite ready to be indexed - not really an error condition, @@ -720,6 +710,23 @@ func (s *repoEventSink) onAddRepo(key string, obj ctrlclient.Object) (interface{ } } +// ref https://fluxcd.io/docs/components/source/helmrepositories/#status +func (s *repoEventSink) onAddHttpRepo(repo sourcev1.HelmRepository) ([]byte, bool, error) { + if artifact := repo.GetArtifact(); artifact != nil { + if checksum := artifact.Checksum; checksum == "" { + return nil, false, status.Errorf(codes.Internal, + "expected field status.artifact.checksum not found on HelmRepository\n[%s]", + common.PrettyPrint(repo)) + } else { + return s.indexAndEncode(checksum, repo) + } + } else { + return nil, false, status.Errorf(codes.Internal, + "expected field status.artifact not found on HelmRepository\n[%s]", + common.PrettyPrint(repo)) + } +} + func (s *repoEventSink) indexAndEncode(checksum string, repo sourcev1.HelmRepository) ([]byte, bool, error) { charts, err := s.indexOneRepo(repo) if err != nil { diff --git a/script/makefiles/deploy-dev.mk b/script/makefiles/deploy-dev.mk index e5181f0cf17..b1ee3d2e26a 100644 --- a/script/makefiles/deploy-dev.mk +++ b/script/makefiles/deploy-dev.mk @@ -62,7 +62,7 @@ deploy-kapp-controller: # Add the flux controllers used for testing the kubeapps-apis integration. deploy-flux-controllers: - kubectl --kubeconfig=${CLUSTER_CONFIG} apply -f https://github.com/fluxcd/flux2/releases/download/v0.31.0/install.yaml + kubectl --kubeconfig=${CLUSTER_CONFIG} apply -f https://github.com/fluxcd/flux2/releases/download/v0.31.1/install.yaml reset-dev: helm --kubeconfig=${CLUSTER_CONFIG} -n kubeapps delete kubeapps || true From beaf62fc8f5e47b35797cd2e4d76c4fd7cf7f9e2 Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Thu, 16 Jun 2022 21:51:59 -0700 Subject: [PATCH 04/11] incremental --- .../v1alpha1/common/transport/transport.go | 104 ++++++++ .../common/transport/transport_test.go | 58 ++++ .../packages/v1alpha1/global_vars_test.go | 2 +- .../fluxv2/packages/v1alpha1/oci_repo.go | 248 +++++++++--------- .../plugins/fluxv2/packages/v1alpha1/repo.go | 18 +- 5 files changed, 300 insertions(+), 130 deletions(-) create mode 100644 cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport.go create mode 100644 cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport_test.go diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport.go new file mode 100644 index 00000000000..8cd4ecdef30 --- /dev/null +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport.go @@ -0,0 +1,104 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// a copy of fluxcd source-controller internal/transport/transport.go +package transport + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "sync" + "time" +) + +// TransportPool is a progressive and non-blocking pool +// for http.Transport objects, optimised for Gargabe Collection +// and without a hard limit on number of objects created. +// +// Its main purpose is to enable for transport objects to be +// used across helm chart download requests and helm/pkg/getter +// instances by leveraging the getter.WithTransport(t) construct. +// +// The use of this pool improves the default behaviour of helm getter +// which creates a new connection per request, or per getter instance, +// resulting on unnecessary TCP connections with the target. +// +// http.Transport objects may contain sensitive material and also have +// settings that may impact the security of HTTP operations using +// them (i.e. InsecureSkipVerify). Therefore, ensure that they are +// used in a thread-safe way, and also by reseting TLS specific state +// after each use. +// +// Calling the Release(t) function will reset TLS specific state whilst +// also releasing the transport back to the pool to be reused. +// +// xref: https://github.com/helm/helm/pull/10568 +// xref2: https://github.com/fluxcd/source-controller/issues/578 +type TransportPool struct { +} + +var pool = &sync.Pool{ + New: func() interface{} { + return &http.Transport{ + DisableCompression: true, + Proxy: http.ProxyFromEnvironment, + + // Due to the non blocking nature of this approach, + // at peak usage a higher number of transport objects + // may be created. sync.Pool will ensure they are + // gargage collected when/if needed. + // + // By setting a low value to IdleConnTimeout the connections + // will be closed after that period of inactivity, allowing the + // transport to be garbage collected. + IdleConnTimeout: 60 * time.Second, + + // use safe defaults based off http.DefaultTransport + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + } + }, +} + +// NewOrIdle tries to return an existing transport that is not currently being used. +// If none is found, creates a new Transport instead. +// +// tlsConfig can optionally set the TLSClientConfig for the transport. +func NewOrIdle(tlsConfig *tls.Config) *http.Transport { + t := pool.Get().(*http.Transport) + t.TLSClientConfig = tlsConfig + + return t +} + +// Release releases the transport back to the TransportPool after +// sanitising its sensitive fields. +func Release(transport *http.Transport) error { + if transport == nil { + return fmt.Errorf("cannot release nil transport") + } + + transport.TLSClientConfig = nil + + pool.Put(transport) + return nil +} diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport_test.go new file mode 100644 index 00000000000..f0bc387d6cd --- /dev/null +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport_test.go @@ -0,0 +1,58 @@ +/* +Copyright 2022 The Flux authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package transport + +import ( + "crypto/tls" + "testing" +) + +func Test_TransportReuse(t *testing.T) { + t1 := NewOrIdle(nil) + t2 := NewOrIdle(nil) + + if t1 == t2 { + t.Errorf("same transported returned twice") + } + + err := Release(t2) + if err != nil { + t.Errorf("error releasing transport t2: %v", err) + } + + t3 := NewOrIdle(&tls.Config{ + ServerName: "testing", + }) + if t3.TLSClientConfig == nil || t3.TLSClientConfig.ServerName != "testing" { + t.Errorf("TLSClientConfig not properly configured") + } + + err = Release(t3) + if err != nil { + t.Errorf("error releasing transport t3: %v", err) + } + if t3.TLSClientConfig != nil { + t.Errorf("TLSClientConfig not cleared after release") + } + + err = Release(nil) + if err == nil { + t.Errorf("should not allow release nil transport") + } else if err.Error() != "cannot release nil transport" { + t.Errorf("wanted error message: 'cannot release nil transport' got: %q", err.Error()) + } +} diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/global_vars_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/global_vars_test.go index 9d58547685e..273c3ae8dc7 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/global_vars_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/global_vars_test.go @@ -1113,7 +1113,7 @@ var ( } add_repo_expected_resp_6 = &corev1.AddPackageRepositoryResponse{ - PackageRepoRef: repoRef("my-podinfo-4", "default"), + PackageRepoRef: repoRef("my-podinfo-5", "default"), } status_installed = &corev1.InstalledPackageStatus{ diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go index 01f4d0ad4fe..9b62f450df9 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go @@ -13,10 +13,12 @@ import ( "bytes" "context" "crypto/tls" + "encoding/gob" "fmt" "io" "net/url" "os" + "path" "sort" "strings" @@ -26,9 +28,13 @@ import ( "helm.sh/helm/v3/pkg/getter" "helm.sh/helm/v3/pkg/registry" "helm.sh/helm/v3/pkg/repo" + "sigs.k8s.io/yaml" "github.com/Masterminds/semver/v3" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common" + "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport" + "github.com/vmware-tanzu/kubeapps/pkg/chart/models" + "github.com/vmware-tanzu/kubeapps/pkg/tarutil" log "k8s.io/klog/v2" "github.com/fluxcd/pkg/version" @@ -63,11 +69,31 @@ type OCIRegistry struct { RegistryClient RegistryClient } +type ArtifactFiles struct { + Metadata string + Readme string + Values string + Schema string +} + // OCIRegistryOption is a function that can be passed to NewOCIRegistry // to configure an OCIRegistry. type OCIRegistryOption func(*OCIRegistry) error -// WithOCIRegistryClient returns a ChartRepositoryOption that will set the registry client +var ( + getters = getter.Providers{ + getter.Provider{ + Schemes: []string{"http", "https"}, + New: getter.NewHTTPGetter, + }, + getter.Provider{ + Schemes: []string{"oci"}, + New: getter.NewOCIGetter, + }, + } +) + +// WithOCIRegistryClient returns a OCIRegistryOption that will set the registry client func WithOCIRegistryClient(client RegistryClient) OCIRegistryOption { return func(r *OCIRegistry) error { r.RegistryClient = client @@ -121,6 +147,8 @@ func (r *OCIRegistry) listRepositoryNames() ([]string, error) { // It's necessary to specify the list of applications (repositories) that the registry contains. // This is because the OCI specification doesn't have an endpoint to discover artifacts // (unlike the index.yaml file of a Helm repository). + + // TODO (gfichtenholt) fix me return []string{"podinfo"}, nil } @@ -139,7 +167,7 @@ func (r *OCIRegistry) getChartVersion(name, ver string) (*repo.ChartVersion, err } if len(cvs) == 0 { - return nil, fmt.Errorf("unable to locate any tags in provided repository: %s", name) + return nil, status.Errorf(codes.Internal, "unable to locate any tags in provided repository: %s", name) } // Determine if version provided @@ -176,7 +204,7 @@ func (r *OCIRegistry) getTags(ref string) ([]string, error) { // downloadChart confirms the given repo.ChartVersion has a downloadable URL, // and then attempts to download the chart using the Client and Options of the -// ChartRepository. It returns a bytes.Buffer containing the chart data. +// OCIRegistry. It returns a bytes.Buffer containing the chart data. // In case of an OCI hosted chart, this function assumes that the chartVersion url is valid. func (r *OCIRegistry) downloadChart(chart *repo.ChartVersion) (*bytes.Buffer, error) { if len(chart.URLs) == 0 { @@ -190,17 +218,13 @@ func (r *OCIRegistry) downloadChart(chart *repo.ChartVersion) (*bytes.Buffer, er return nil, err } - ustr := strings.TrimPrefix(u.String(), fmt.Sprintf("%s://", registry.OCIScheme)) - return nil, status.Errorf(codes.Unimplemented, "TODO %s", ustr) - - /* TODO: t := transport.NewOrIdle(r.tlsConfig) clientOpts := append(r.Options, getter.WithTransport(t)) defer transport.Release(t) // trim the oci scheme prefix if needed - return r.Client.Get(ustr, clientOpts...) - */ + getThis := strings.TrimPrefix(u.String(), fmt.Sprintf("%s://", registry.OCIScheme)) + return r.Client.Get(getThis, clientOpts...) } // login attempts to login to the OCI registry. @@ -323,11 +347,14 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool }() } - // a little bit misleading, repo.Spec.URL is really an OCI registry URL, + // a little bit misleading, since repo.Spec.URL is really an OCI Registry URL, // which may contain zero or more "helm repositories", such as - // oci://demo.goharbor.io/test-oci-1 or + // oci://demo.goharbor.io/test-oci-1, which may contain repositories "repo-1", "repo2", etc + ociRegistry, err := NewOCIRegistry( repo.Spec.URL, + WithOCIGetter(getters), + WithOCIGetterOptions(getterOpts), WithOCIRegistryClient(registryClient)) if err != nil { return nil, false, status.Errorf(codes.Internal, "failed to parse URL '%s': %v", repo.Spec.URL, err) @@ -346,136 +373,111 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool return nil, false, status.Errorf(codes.Internal, "%v", err) } - // e.g. log.Infof("==========>: URL object: [%s]", common.PrettyPrint(repoURL)) - repoNames, err := ociRegistry.listRepositoryNames() + chartRepo := &models.Repo{ + Namespace: repo.Namespace, + Name: repo.Name, + URL: repo.Spec.URL, + Type: repo.Spec.Type, + } + + // repository names aka application names + appNames, err := ociRegistry.listRepositoryNames() if err != nil { return nil, false, err } - for _, name := range repoNames { - log.Infof("==========>: ref: [%s]", name) - chartVersion, err := ociRegistry.getChartVersion(name, "") + charts := []models.Chart{} + for _, appName := range appNames { + log.Infof("==========>: app name: [%s]", appName) + + chartVersion, err := ociRegistry.getChartVersion(appName, "") if err != nil { return nil, false, status.Errorf(codes.Internal, "%v", err) } log.Infof("==========>: chart version: %s", common.PrettyPrint(chartVersion)) - } - - // later we'll do - // chartRepo.DownloadChart(chartVersion) - // to cache the charts' .tgz file - return nil, false, nil - /* - authorizationHeader := "" - // TODO look at repo's secretRef - /* - // The auth header may be a dockerconfig that we need to parse - if serveOpts.DockerConfigJson != "" { - dockerConfig := &credentialprovider.DockerConfigJSON{} - err := json.Unmarshal([]byte(serveOpts.DockerConfigJson), dockerConfig) - if err != nil { - return nil, false, status.Errorf(codes.Internal, "%v", err) - } - authorizationHeader, err = kube.GetAuthHeaderFromDockerConfig(dockerConfig) - if err != nil { - return nil, false, status.Errorf(codes.Internal, "%v", err) - } - } - headers := http.Header{} - if authorizationHeader != "" { - headers["Authorization"] = []string{authorizationHeader} + chartBuffer, err := ociRegistry.downloadChart(chartVersion) + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) } - // TODO look at repo's secretRef for TLS config - netClient, err := httpclient.NewWithCertFile(additionalCAFile, false) + log.Infof("==========>: chart buffer: [%d] bytes", chartBuffer.Len()) + + // Encode repository names to store them in the database. + encodedAppName := url.PathEscape(appName) + + // not sure yet why flux untars into a temp directory + chartID := path.Join(repo.Name, encodedAppName) + files, err := tarutil.FetchChartDetailFromTarball( + bytes.NewReader(chartBuffer.Bytes()), chartID) if err != nil { return nil, false, status.Errorf(codes.Internal, "%v", err) } - ociResolver := - docker.NewResolver( - docker.ResolverOptions{ - Headers: headers, - - Client: netClient}) - // from cmd/asset-syncer/server/utils.go - //func pullAndExtract(repoURL *url.URL, appName, tag string, puller helm.ChartPuller, r *OCIRegistry) (*models.Chart, error) { - repoURL, err := url.ParseRequestURI(strings.TrimSpace(repo.Spec.URL)) - if err != nil { - return nil, false, status.Errorf(codes.Internal, "%v", err) - } - repositories := []string{"test-oci-1/podinfo"} - - // find tags - // see func (r *OCIRegistry) FilterIndex() - // TagList represents a list of tags as specified at - // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery - type TagList struct { - Name string `json:"name"` - Tags []string `json:"tags"` - } - tags := map[string]TagList{} + log.Infof("==========>: files: [%d]", len(files)) - for _, appName := range repositories { - tag := tags[appName].Tags[0] - ref := path.Join(repoURL.Host, repoURL.Path, fmt.Sprintf("%s:%s", appName, tag)) - } + chartYaml, ok := files[models.ChartYamlKey] + // TODO (gfichtenholt): if there is no chart yaml (is that even possible?), + // fall back to chart info from repo index.yaml + if !ok || chartYaml == "" { + return nil, false, status.Errorf(codes.Internal, "No chart manifest found for chart [%s]", chartID) + } + var chartMetadata chart.Metadata + err = yaml.Unmarshal([]byte(chartYaml), &chartMetadata) + if err != nil { + return nil, false, err + } - chartBuffer, digest, err := puller.PullOCIChart(ref) - if err != nil { - return nil, err - } - - // Extract - files, err := extractFilesFromBuffer(chartBuffer) - if err != nil { - return nil, err - } - chartMetadata := chart.Metadata{} - err = yaml.Unmarshal([]byte(files.Metadata), &chartMetadata) - if err != nil { - return nil, err - } - - // Format Data - chartVersion := models.ChartVersion{ - Version: chartMetadata.Version, - AppVersion: chartMetadata.AppVersion, - Digest: digest, - URLs: chartMetadata.Sources, - Readme: files.Readme, - Values: files.Values, - Schema: files.Schema, - } - - maintainers := []chart.Maintainer{} - for _, m := range chartMetadata.Maintainers { - maintainers = append(maintainers, chart.Maintainer{ - Name: m.Name, - Email: m.Email, - URL: m.URL, - }) - } - - // Encode repository names to store them in the database. - encodedAppName := url.PathEscape(appName) - - &models.Chart{ - ID: path.Join(r.Name, encodedAppName), - Name: encodedAppName, - Repo: &models.Repo{Namespace: r.Namespace, Name: r.Name, URL: r.URL, Type: r.Type}, - Description: chartMetadata.Description, - Home: chartMetadata.Home, - Keywords: chartMetadata.Keywords, - Maintainers: maintainers, - Sources: chartMetadata.Sources, - Icon: chartMetadata.Icon, - Category: chartMetadata.Annotations["category"], - ChartVersions: []models.ChartVersion{chartVersion}, - }, nil - */ + log.Infof("==========>: chart metadata: %s", common.PrettyPrint(chartMetadata)) + + maintainers := []chart.Maintainer{} + for _, maintainer := range chartMetadata.Maintainers { + maintainers = append(maintainers, *maintainer) + } + + modelsChartVersion := models.ChartVersion{ + Version: chartVersion.Version, + AppVersion: chartVersion.AppVersion, + Created: chartVersion.Created, + Digest: chartVersion.Digest, + URLs: chartVersion.URLs, + Readme: files[models.ReadmeKey], + Values: files[models.ValuesKey], + Schema: files[models.SchemaKey], + } + + chart := models.Chart{ + ID: path.Join(repo.Name, encodedAppName), + Name: encodedAppName, + Repo: chartRepo, + Description: chartMetadata.Description, + Home: chartMetadata.Home, + Keywords: chartMetadata.Keywords, + Maintainers: maintainers, + Sources: chartMetadata.Sources, + Icon: chartMetadata.Icon, + Category: chartMetadata.Annotations["category"], + ChartVersions: []models.ChartVersion{ + modelsChartVersion, + }, + } + charts = append(charts, chart) + } + + // TODO: encode and return bytes + cacheEntryValue := repoCacheEntryValue{ + Checksum: "TODO", + Charts: charts, + } + + // use gob encoding instead of json, it peforms much better + var buf bytes.Buffer + enc := gob.NewEncoder(&buf) + if err = enc.Encode(cacheEntryValue); err != nil { + return nil, false, err + } + return buf.Bytes(), true, nil } // diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go index 45fc7c02e10..99ea395e59e 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go @@ -306,6 +306,10 @@ func (s *Server) repoDetail(ctx context.Context, repoRef *corev1.PackageReposito } } auth.PassCredentials = repo.Spec.PassCredentials + typ := repo.Spec.Type + if typ == "" { + typ = "helm" + } return &corev1.PackageRepositoryDetail{ PackageRepoRef: &corev1.PackageRepositoryReference{ Context: &corev1.Context{ @@ -316,10 +320,10 @@ func (s *Server) repoDetail(ctx context.Context, repoRef *corev1.PackageReposito Plugin: GetPluginDetail(), }, Name: repo.Name, - // TBD Flux HelmRepository CR doesn't have a designated field for description + // TODO (gfichtenholt) Flux HelmRepository CR doesn't have a designated field for description Description: "", NamespaceScoped: false, - Type: "helm", + Type: typ, Url: repo.Spec.URL, Interval: pkgutils.FromDuration(&repo.Spec.Interval), TlsConfig: tlsConfig, @@ -353,6 +357,10 @@ func (s *Server) repoSummaries(ctx context.Context, namespace string) ([]*corev1 } } for _, repo := range repos { + typ := repo.Spec.Type + if typ == "" { + typ = "helm" + } summary := &corev1.PackageRepositorySummary{ PackageRepoRef: &corev1.PackageRepositoryReference{ Context: &corev1.Context{ @@ -363,10 +371,10 @@ func (s *Server) repoSummaries(ctx context.Context, namespace string) ([]*corev1 Plugin: GetPluginDetail(), }, Name: repo.Name, - // TBD Flux HelmRepository CR doesn't have a designated field for description + // TODO (gfichtenholt) Flux HelmRepository CR doesn't have a designated field for description Description: "", NamespaceScoped: false, - Type: "helm", + Type: typ, Url: repo.Spec.URL, Status: repoStatus(repo), } @@ -1049,8 +1057,6 @@ func newFluxHelmRepo( } if typ == "oci" { fluxRepo.Spec.Type = sourcev1.HelmRepositoryTypeOCI - } else { - fluxRepo.Spec.Type = sourcev1.HelmRepositoryTypeDefault } if secret != nil { fluxRepo.Spec.SecretRef = &fluxmeta.LocalObjectReference{ From f55086674695f2506044400752c562495904f53d Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Fri, 17 Jun 2022 01:06:47 -0700 Subject: [PATCH 05/11] incremental --- .../fluxv2/packages/v1alpha1/common/utils.go | 12 ++ .../fluxv2/packages/v1alpha1/oci_repo.go | 165 +++++++++++++----- .../plugins/fluxv2/packages/v1alpha1/repo.go | 74 ++++---- 3 files changed, 178 insertions(+), 73 deletions(-) diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go index be3d7738186..9d3bc1a3c3e 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go @@ -4,9 +4,12 @@ package common import ( + "bytes" + "crypto/sha256" "encoding/base64" "encoding/json" "fmt" + "io" "io/ioutil" "net/http" "net/url" @@ -417,3 +420,12 @@ func GetChartsGvr() schema.GroupVersionResource { func GetReleasesGvr() schema.GroupVersionResource { return releasesGvr } + +func GetSha256(src []byte) (string, error) { + f := bytes.NewReader(src) + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return "", err + } + return fmt.Sprintf("%x", h.Sum(nil)), nil +} diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go index 9b62f450df9..a96cf337559 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go @@ -14,6 +14,7 @@ import ( "context" "crypto/tls" "encoding/gob" + "encoding/json" "fmt" "io" "net/url" @@ -327,45 +328,9 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool log.Infof("+onAddOciRepo(%s)", common.PrettyPrint(repo)) defer log.Infof("-onAddOciRepo") - // Construct the Getter options from the HelmRepository data - loginOpts, getterOpts, err := s.helmOptionsForRepo(repo) - if err != nil { - return nil, false, status.Errorf(codes.Internal, "failed to create registry client options: %v", err) - } - log.Infof("=====================> loginOpts: [%v], getterOpts: [%v]", len(loginOpts), len(getterOpts)) - - // Create registry client and login if needed. - registryClient, file, err := NewRegistryClient(loginOpts != nil) + ociRegistry, err := s.newOCIRegistry(repo) if err != nil { - return nil, false, status.Errorf(codes.Internal, "failed to create registry client: %v", err) - } - if file != "" { - defer func() { - if err := os.Remove(file); err != nil { - log.Infof("Failed to delete temporary credentials file: %v", err) - } - }() - } - - // a little bit misleading, since repo.Spec.URL is really an OCI Registry URL, - // which may contain zero or more "helm repositories", such as - // oci://demo.goharbor.io/test-oci-1, which may contain repositories "repo-1", "repo2", etc - - ociRegistry, err := NewOCIRegistry( - repo.Spec.URL, - WithOCIGetter(getters), - WithOCIGetterOptions(getterOpts), - WithOCIRegistryClient(registryClient)) - if err != nil { - return nil, false, status.Errorf(codes.Internal, "failed to parse URL '%s': %v", repo.Spec.URL, err) - } - - // Attempt to login to the registry if credentials are provided. - if loginOpts != nil { - err = ociRegistry.login(loginOpts...) - if err != nil { - return nil, false, err - } + return nil, false, err } repoURL, err := url.ParseRequestURI(strings.TrimSpace(repo.Spec.URL)) @@ -465,9 +430,13 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool charts = append(charts, chart) } - // TODO: encode and return bytes + checksum, err := ociRegistry.checksum() + if err != nil { + return nil, false, status.Errorf(codes.Internal, "%v", err) + } + cacheEntryValue := repoCacheEntryValue{ - Checksum: "TODO", + Checksum: checksum, Charts: charts, } @@ -480,9 +449,125 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool return buf.Bytes(), true, nil } +func (s *repoEventSink) onModifyOciRepo(key string, oldValue interface{}, repo sourcev1.HelmRepository) ([]byte, bool, error) { + log.Infof("+onModifyOciRepo(%s)", common.PrettyPrint(repo)) + defer log.Infof("-onModifyOciRepo") + + // We should to compare checksums on what's stored in the cache + // vs the modified object to see if the contents has really changed before embarking on + // an expensive operation + cacheEntryUntyped, err := s.onGetRepo(key, oldValue) + if err != nil { + return nil, false, err + } + + cacheEntry, ok := cacheEntryUntyped.(repoCacheEntryValue) + if !ok { + return nil, false, status.Errorf( + codes.Internal, + "unexpected value found in cache for key [%s]: %v", + key, cacheEntryUntyped) + } + + ociRegistry, err := s.newOCIRegistry(repo) + if err != nil { + return nil, false, err + } + + newChecksum, err := ociRegistry.checksum() + if err != nil { + return nil, false, err + } + + if cacheEntry.Checksum != newChecksum { + return nil, false, status.Errorf(codes.Unimplemented, "TODO") + } else { + // skip because the content did not change + return nil, false, nil + } +} + // // misc OCI repo utilities // + +// TagList represents a list of tags as specified at +// https://github.com/opencontainers/distribution-spec/blob/main/spec.md#content-discovery +type TagList struct { + Name string `json:"name"` + Tags []string `json:"tags"` +} + +// Checksum returns the sha256 of the repo by concatenating tags for +// all repositories within the registry and returning the sha256. +// Caveat: Mutated image tags won't be detected as new +func (r *OCIRegistry) checksum() (string, error) { + appNames, err := r.listRepositoryNames() + if err != nil { + return "", err + } + tags := map[string]TagList{} + for _, appName := range appNames { + ref := fmt.Sprintf("%s/%s", r.URL.String(), appName) + tagss, err := r.getTags(ref) + if err != nil { + return "", err + } + tags[appName] = TagList{Name: appName, Tags: tagss} + } + + content, err := json.Marshal(tags) + if err != nil { + return "", err + } + + return common.GetSha256(content) +} + +func (s *repoEventSink) newOCIRegistry(repo sourcev1.HelmRepository) (*OCIRegistry, error) { + // Construct the Getter options from the HelmRepository data + loginOpts, getterOpts, err := s.helmOptionsForRepo(repo) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create registry client options: %v", err) + } + log.Infof("=====================> loginOpts: [%v], getterOpts: [%v]", len(loginOpts), len(getterOpts)) + + // Create registry client and login if needed. + registryClient, file, err := NewRegistryClient(loginOpts != nil) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to create registry client: %v", err) + } + if file != "" { + defer func() { + if err := os.Remove(file); err != nil { + log.Infof("Failed to delete temporary credentials file: %v", err) + } + }() + } + + // a little bit misleading, since repo.Spec.URL is really an OCI Registry URL, + // which may contain zero or more "helm repositories", such as + // oci://demo.goharbor.io/test-oci-1, which may contain repositories "repo-1", "repo2", etc + + ociRegistry, err := NewOCIRegistry( + repo.Spec.URL, + WithOCIGetter(getters), + WithOCIGetterOptions(getterOpts), + WithOCIRegistryClient(registryClient)) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to parse URL '%s': %v", repo.Spec.URL, err) + } + + // Attempt to login to the registry if credentials are provided. + if loginOpts != nil { + err = ociRegistry.login(loginOpts...) + if err != nil { + return nil, err + } + } + return ociRegistry, nil +} + func (s *repoEventSink) helmOptionsForRepo(repo sourcev1.HelmRepository) ([]registry.LoginOption, []getter.Option, error) { log.Infof("+helmOptionsForRepo()") diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go index 99ea395e59e..ef9831d890c 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/repo.go @@ -832,47 +832,55 @@ func (s *repoEventSink) onModifyRepo(key string, obj ctrlclient.Object, oldValue return nil, false, fmt.Errorf("expected an instance of *sourcev1.HelmRepository, got: %s", reflect.TypeOf(obj)) } else if isRepoReady(*repo) { // first check the repo is ready - // We should to compare checksums on what's stored in the cache - // vs the modified object to see if the contents has really changed before embarking on - // expensive operation indexOneRepo() below. - // ref https://fluxcd.io/docs/components/source/helmrepositories/#status - // ref https://fluxcd.io/docs/components/source/helmrepositories/#status - var newChecksum string - if artifact := repo.GetArtifact(); artifact != nil { - if newChecksum = artifact.Checksum; newChecksum == "" { - return nil, false, status.Errorf(codes.Internal, - "expected field status.artifact.checksum not found on HelmRepository\n[%s]", - common.PrettyPrint(repo)) - } + + if repo.Spec.Type == sourcev1.HelmRepositoryTypeOCI { + return s.onModifyOciRepo(key, oldValue, *repo) } else { + return s.onModifyHttpRepo(key, oldValue, *repo) + } + } else { + // repo is not quite ready to be indexed - not really an error condition, + // just skip it eventually there will be another event when it is in ready state + log.V(4).Infof("Skipping packages for repository [%s] because it is not in 'Ready' state", key) + return nil, false, nil + } +} + +func (s *repoEventSink) onModifyHttpRepo(key string, oldValue interface{}, repo sourcev1.HelmRepository) ([]byte, bool, error) { + // We should to compare checksums on what's stored in the cache + // vs the modified object to see if the contents has really changed before embarking on + // expensive operation indexOneRepo() below. + // ref https://fluxcd.io/docs/components/source/helmrepositories/#status + var newChecksum string + if artifact := repo.GetArtifact(); artifact != nil { + if newChecksum = artifact.Checksum; newChecksum == "" { return nil, false, status.Errorf(codes.Internal, - "expected field status.artifact not found on HelmRepository\n[%s]", + "expected field status.artifact.checksum not found on HelmRepository\n[%s]", common.PrettyPrint(repo)) } + } else { + return nil, false, status.Errorf(codes.Internal, + "expected field status.artifact not found on HelmRepository\n[%s]", + common.PrettyPrint(repo)) + } - cacheEntryUntyped, err := s.onGetRepo(key, oldValue) - if err != nil { - return nil, false, err - } + cacheEntryUntyped, err := s.onGetRepo(key, oldValue) + if err != nil { + return nil, false, err + } - cacheEntry, ok := cacheEntryUntyped.(repoCacheEntryValue) - if !ok { - return nil, false, status.Errorf( - codes.Internal, - "unexpected value found in cache for key [%s]: %v", - key, cacheEntryUntyped) - } + cacheEntry, ok := cacheEntryUntyped.(repoCacheEntryValue) + if !ok { + return nil, false, status.Errorf( + codes.Internal, + "unexpected value found in cache for key [%s]: %v", + key, cacheEntryUntyped) + } - if cacheEntry.Checksum != newChecksum { - return s.indexAndEncode(newChecksum, *repo) - } else { - // skip because the content did not change - return nil, false, nil - } + if cacheEntry.Checksum != newChecksum { + return s.indexAndEncode(newChecksum, repo) } else { - // repo is not quite ready to be indexed - not really an error condition, - // just skip it eventually there will be another event when it is in ready state - log.V(4).Infof("Skipping packages for repository [%s] because it is not in 'Ready' state", key) + // skip because the content did not change return nil, false, nil } } From 944fca8bffbecdd6cc58b60d507b2fe4247fd1ac Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Sat, 18 Jun 2022 00:45:47 -0700 Subject: [PATCH 06/11] incremental --- .../v1alpha1/chart_integration_test.go | 6 + .../fluxv2/packages/v1alpha1/oci_repo.go | 33 ++-- .../packages/v1alpha1/oci_repo_debug.go | 164 ++++++++++++++++++ 3 files changed, 193 insertions(+), 10 deletions(-) create mode 100644 cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go index e2d3128362b..191fe02a2d9 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go @@ -519,6 +519,12 @@ func TestKindClusterGetAvailablePackageSummariesForOCI(t *testing.T) { t.Fatal(err) } + debugTags("ghcr.io/stefanprodan/charts/podinfo") + + if true { + return + } + adminName := types.NamespacedName{ Name: "test-admin-" + randSeq(4), Namespace: "default", diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go index a96cf337559..8529328d9c0 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go @@ -40,7 +40,6 @@ import ( "github.com/fluxcd/pkg/version" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" - // "github.com/fluxcd/source-controller/internal/transport" ) // RegistryClient is an interface for interacting with OCI registries @@ -158,10 +157,10 @@ func (r *OCIRegistry) listRepositoryNames() ([]string, error) { // stable version will be returned and prerelease versions will be ignored. // adapted from https://github.com/helm/helm/blob/49819b4ef782e80b0c7f78c30bd76b51ebb56dc8/pkg/downloader/chart_downloader.go#L162 func (r *OCIRegistry) getChartVersion(name, ver string) (*repo.ChartVersion, error) { + log.Infof("+getChartVersion(%s,%s", name, ver) // Find chart versions matching the given name. // Either in an index file or from a registry. ref := fmt.Sprintf("%s/%s", r.URL.String(), name) - log.Infof("about to call getTags(%s)", ref) cvs, err := r.getTags(ref) if err != nil { return nil, err @@ -188,18 +187,21 @@ func (r *OCIRegistry) getChartVersion(name, ver string) (*repo.ChartVersion, err // This function shall be called for OCI registries only // It assumes that the ref has been validated to be an OCI reference. func (r *OCIRegistry) getTags(ref string) ([]string, error) { - url := strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme)) - log.Infof("about to call RegistryClient.Tags(%s)", url) - // Retrieve list of repository tags - tags, err := r.RegistryClient.Tags(url) - log.Infof("done with call RegistryClient.Tags(%s): %s %v", url, tags, err) + ref = strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme)) + + if err := debugTags(ref); err != nil { + log.Infof("debugTags failed due to: %v", err) + } + + tags, err := r.RegistryClient.Tags(ref) if err != nil { return nil, err } + + log.Infof("getTags(%s): %s %v", ref, tags, err) if len(tags) == 0 { return nil, fmt.Errorf("unable to locate any tags in provided repository: %s", ref) } - return tags, nil } @@ -231,6 +233,7 @@ func (r *OCIRegistry) downloadChart(chart *repo.ChartVersion) (*bytes.Buffer, er // login attempts to login to the OCI registry. // It returns an error on failure. func (r *OCIRegistry) login(opts ...registry.LoginOption) error { + log.Infof("+login") err := r.RegistryClient.Login(r.URL.Host, opts...) if err != nil { return err @@ -241,6 +244,7 @@ func (r *OCIRegistry) login(opts ...registry.LoginOption) error { // logout attempts to logout from the OCI registry. // It returns an error on failure. func (r *OCIRegistry) logout() error { + log.Infof("+logout") err := r.RegistryClient.Logout(r.URL.Host) if err != nil { return err @@ -308,14 +312,23 @@ func NewRegistryClient(isLogin bool) (*registry.Client, string, error) { return nil, "", err } - rClient, err := registry.NewClient(registry.ClientOptWriter(io.Discard), registry.ClientOptCredentialsFile(credentialFile.Name())) + clientOpts := []registry.ClientOption{ + registry.ClientOptWriter(io.Discard), + registry.ClientOptCredentialsFile(credentialFile.Name()), + } + rClient, err := registry.NewClient(clientOpts...) if err != nil { return nil, "", err } return rClient, credentialFile.Name(), nil } - rClient, err := registry.NewClient(registry.ClientOptWriter(io.Discard)) + clientOpts := []registry.ClientOption{ + registry.ClientOptWriter(io.Discard), + registry.ClientOptDebug(true), + } + + rClient, err := registry.NewClient(clientOpts...) if err != nil { return nil, "", err } diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go new file mode 100644 index 00000000000..00feb4836bc --- /dev/null +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go @@ -0,0 +1,164 @@ +// Temporary file for debugging exactly what the exact HTTP requests to +// and responses from a remote OCI registry look like. +// Kinf of like a network sniffer. This file will eventually go away + +package main + +import ( + "context" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "strconv" + + "helm.sh/helm/v3/pkg/registry" + + "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common" + log "k8s.io/klog/v2" + + "github.com/sirupsen/logrus" + "helm.sh/helm/v3/pkg/helmpath" + dockerauth "oras.land/oras-go/pkg/auth/docker" + orascontext "oras.land/oras-go/pkg/context" + orasregistry "oras.land/oras-go/pkg/registry" + registryremote "oras.land/oras-go/pkg/registry/remote" + registryauth "oras.land/oras-go/pkg/registry/remote/auth" +) + +// Retrieve list of repository tags +// impl ref https://github.com/helm/helm/blob/657850e44b880cca43d0606ebf5a54eb75362c3f/pkg/registry/client.go#L579 +func debugTags(ref string) error { + log.Infof("+debugTags(%s)", ref) + + parsedReference, err := orasregistry.ParseReference(ref) + if err != nil { + return err + } + // given ref like this + // ghcr.io/stefanprodan/charts/podinfo + // will return + // { + // "Registry": "ghcr.io", + // "Repository": "stefanprodan/charts/podinfo", + // "Reference": "" + // } + log.Infof("debugTags parsedReference(%s): %s", ref, common.PrettyPrint(parsedReference)) + + credentialsFile := helmpath.ConfigPath(registry.CredentialsFileBasename) + log.Infof("debugTags credentials file: %s", credentialsFile) + + authClient, err := dockerauth.NewClientWithDockerFallback(credentialsFile) + if err != nil { + return err + } + + registryAuthorizer := ®istryauth.Client{ + Header: http.Header{"User-Agent": {"Helm/3.9.0"}}, + Cache: registryauth.DefaultCache, + Credential: func(ctx context.Context, reg string) (registryauth.Credential, error) { + dockerClient, ok := authClient.(*dockerauth.Client) + if !ok { + return registryauth.EmptyCredential, errors.New("unable to obtain docker client") + } + + username, password, err := dockerClient.Credential(reg) + if err != nil { + return registryauth.EmptyCredential, errors.New("unable to retrieve credentials") + } + + log.Infof("=======> debugTags: registryAuthorizer: [%s] [%s...]", username, password[0:3]) + + // A blank returned username and password value is a bearer token + if username == "" && password != "" { + log.Infof("debugTags: registryAuthorizer: [%s] [%s]", username, password) + return registryauth.Credential{ + RefreshToken: password, + }, nil + } + return registryauth.Credential{ + Username: username, + Password: password, + }, nil + }, + } + + repository := registryremote.Repository{ + Reference: parsedReference, + Client: registryAuthorizer, + } + + ctxFn := func(out io.Writer, debug bool) context.Context { + if !debug { + return orascontext.Background() + } + ctx := orascontext.WithLoggerFromWriter(context.Background(), out) + orascontext.GetLogger(ctx).Logger.SetLevel(logrus.DebugLevel) + return ctx + } + + // ref https://github.com/oras-project/oras-go/blob/main/registry/remote/url.go + // buildScheme returns HTTP scheme used to access the remote registry. + buildScheme := func(plainHTTP bool) string { + if plainHTTP { + return "http" + } + return "https" + } + + // buildRepositoryBaseURL builds the base endpoint of the remote repository. + // Format: :///v2/ + //buildRepositoryBaseURL := + _ = func(plainHTTP bool, ref orasregistry.Reference) string { + return fmt.Sprintf("%s://%s/v2/%s", buildScheme(plainHTTP), ref.Host(), ref.Repository) + } + + // buildRepositoryTagListURL builds the URL for accessing the tag list API. + // Format: :///v2//tags/list + // Reference: https://docs.docker.com/registry/spec/api/#tags + buildRepositoryTagListURL := func(plainHTTP bool, ref orasregistry.Reference) string { + //return buildRepositoryBaseURL(plainHTTP, ref) + "/tags/list" + //return buildRepositoryBaseURL(plainHTTP, ref) + return "https://ghcr.io/v2/_catalog" + } + + tags := func(ctx context.Context, url string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return "", err + } + if repository.TagListPageSize > 0 { + q := req.URL.Query() + q.Set("n", strconv.Itoa(repository.TagListPageSize)) + req.URL.RawQuery = q.Encode() + } + + log.Infof("debugTags: +HTTP GET request:\nURL:\n%s\npretty print:\n%s\nAuthorization:[%s]\n", + req.URL.String(), + common.PrettyPrint(req), + req.Header["Authorization"]) + resp, err := repository.Client.Do(req) + if err != nil { + log.Infof("debugTags: -HTTP GET response: raised err=%v", err) + return "", err + } + respBody, err := ioutil.ReadAll(resp.Body) + log.Infof("debugTags: -HTTP GET response: code:%s\nbody:\n%s\nheaders:\n%s\nerr=%v", + resp.Status, + respBody, + resp.Header, + err) + if err != nil { + return "", err + } + defer resp.Body.Close() + + return "", nil + } + + url := buildRepositoryTagListURL(repository.PlainHTTP, repository.Reference) + tags(ctxFn(io.Discard, true), url) + + return nil +} From 0ee44de8f703115185aeb72150e757b8248d4aba Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Sat, 18 Jun 2022 22:25:21 -0700 Subject: [PATCH 07/11] incremental --- .../v1alpha1/chart_integration_test.go | 12 +- .../v1alpha1/github_oci_repo_lister.go | 166 ++++++++++++++++++ .../fluxv2/packages/v1alpha1/oci_repo.go | 116 ++++++------ .../packages/v1alpha1/oci_repo_debug.go | 25 +-- 4 files changed, 259 insertions(+), 60 deletions(-) create mode 100644 cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go index 191fe02a2d9..0e09709ee34 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go @@ -519,7 +519,17 @@ func TestKindClusterGetAvailablePackageSummariesForOCI(t *testing.T) { t.Fatal(err) } - debugTags("ghcr.io/stefanprodan/charts/podinfo") + // appears to work on the client-side only after successful + // docker login ghcr.io -u gfichtenholt -p ghp_... + // personal access token ghp_... can be seen on https://github.com/settings/tokens + // and has "admin:repo_hook, delete_repo, repo" scopes + + // TODO on the server-side it doesn't work + // somehow dockerauth.NewClientWithDockerFallback() isn't finding any creds which causes + // -HTTP GET response: raised err=GET "https://ghcr.io/v2/": + // GET "https://ghcr.io/token?scope=repository%3Auser%2Fimage%3Apull&service=ghcr.io": + // unexpected status code 403: denied: requested access to the resource is denied + debugTagList("ghcr.io/stefanprodan/charts/podinfo") if true { return diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go new file mode 100644 index 00000000000..0bc06e8c3b6 --- /dev/null +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go @@ -0,0 +1,166 @@ +// Copyright 2021-2022 the Kubeapps contributors. +// SPDX-License-Identifier: Apache-2.0 +package main + +import ( + "context" + "errors" + "fmt" + "io" + "io/ioutil" + "net/http" + "strings" + + "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common" + httpclient "github.com/vmware-tanzu/kubeapps/pkg/http-client" + log "k8s.io/klog/v2" + + "github.com/sirupsen/logrus" + "helm.sh/helm/v3/pkg/helmpath" + "helm.sh/helm/v3/pkg/registry" + dockerauth "oras.land/oras-go/pkg/auth/docker" + orascontext "oras.land/oras-go/pkg/context" + orasregistry "oras.land/oras-go/pkg/registry" + registryremote "oras.land/oras-go/pkg/registry/remote" + registryauth "oras.land/oras-go/pkg/registry/remote/auth" +) + +func NewGitHubRepositoryLister() OCIRepositoryLister { + return &gitHubRepositoryLister{} +} + +type gitHubRepositoryLister struct { +} + +// ref https://github.com/distribution/distribution/blob/main/docs/spec/api.md#api-version-check +func (l *gitHubRepositoryLister) IsApplicableFor(ociRegistry *OCIRegistry) (bool, error) { + log.Infof("+IsApplicableFor()") + + ref := strings.TrimPrefix(ociRegistry.url.String(), fmt.Sprintf("%s://", registry.OCIScheme)) + log.Infof("ref: [%s]", ref) + + // given ref like this + // ghcr.io/stefanprodan/charts/podinfo + // will return + // { + // "Registry": "ghcr.io", + // "Repository": "stefanprodan/charts/podinfo", + // "Reference": "" + // } + parsedRef, err := orasregistry.ParseReference(ref) + if err != nil { + return false, err + } + log.Infof("parsed reference: [%s]", common.PrettyPrint(parsedRef)) + + credentialsFile := helmpath.ConfigPath(registry.CredentialsFileBasename) + + authClient, err := dockerauth.NewClientWithDockerFallback(credentialsFile) + if err != nil { + return false, err + } + + registryAuthorizer := ®istryauth.Client{ + Header: http.Header{"User-Agent": {"Helm/3.9.0"}}, + Cache: registryauth.DefaultCache, + Credential: func(ctx context.Context, reg string) (registryauth.Credential, error) { + dockerClient, ok := authClient.(*dockerauth.Client) + if !ok { + return registryauth.EmptyCredential, errors.New("unable to obtain docker client") + } + + username, password, err := dockerClient.Credential(reg) + if err != nil { + return registryauth.EmptyCredential, errors.New("unable to retrieve credentials") + } + + log.Infof("=======> IsApplicableFor: registryAuthorizer: [%s] [%s...]", username, password[0:3]) + + // A blank returned username and password value is a bearer token + if username == "" && password != "" { + log.Infof("IsApplicableFor: registryAuthorizer: [%s] [%s]", username, password) + return registryauth.Credential{ + RefreshToken: password, + }, nil + } + return registryauth.Credential{ + Username: username, + Password: password, + }, nil + }, + } + + ociRepo := registryremote.Repository{ + Reference: parsedRef, + Client: registryAuthorizer, + } + + // ref https://github.com/oras-project/oras-go/blob/main/registry/remote/url.go + // buildScheme returns HTTP scheme used to access the remote registry. + buildScheme := func(plainHTTP bool) string { + if plainHTTP { + return "http" + } + return "https" + } + + ctx := ctxFn(io.Discard, true) + // buildRepositoryBaseURL builds the base endpoint of the remote registry. + // Format: :///v2/ + url := fmt.Sprintf("%s://%s/v2/", buildScheme(ociRepo.PlainHTTP), ociRepo.Reference.Host()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err + } + + resp, err := httpclient.New().Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + _, err = ioutil.ReadAll(resp.Body) + if err != nil { + return false, err + } + + if resp.StatusCode == http.StatusOK { + // based on the presence of this here Docker-Distribution-Api-Version:[registry/2.0] + // conclude this is a case for GitHubRepositoryLister, e.g. + // +HTTP GET request: + // URL: https://ghcr.io/v2/ + // -HTTP GET response: code: 200 OK + // headers: + // map[ + // Content-Length:[0] Content-Type:[application/json] + // Date:[Sun, 19 Jun 2022 05:08:18 GMT] + // Docker-Distribution-Api-Version:[registry/2.0] + // X-Github-Request-Id:[C4E4:2F9A:3069FD:914D65:62AEAF42] + // ] + + val, ok := resp.Header["Docker-Distribution-Api-Version"] + if ok && len(val) == 1 && val[0] == "registry/2.0" { + return true, nil + } + } else { + log.Infof("isApplicableFor: HTTP GET (%s) returned status [%d]", url, resp.StatusCode) + } + + return false, nil +} + +// ref https://github.com/distribution/distribution/blob/main/docs/spec/api.md#listing-repositories +func (l *gitHubRepositoryLister) ListRepositoryNames() ([]string, error) { + log.Infof("+ListRepositoryNames()") + // TODO (gfichtenholt) fix me + return []string{"podinfo"}, nil +} + +func ctxFn(out io.Writer, debug bool) context.Context { + if !debug { + return orascontext.Background() + } + ctx := orascontext.WithLoggerFromWriter(context.Background(), out) + orascontext.GetLogger(ctx).Logger.SetLevel(logrus.DebugLevel) + return ctx +} diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go index 8529328d9c0..214567ed1ce 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go @@ -20,6 +20,7 @@ import ( "net/url" "os" "path" + "reflect" "sort" "strings" @@ -44,36 +45,39 @@ import ( // RegistryClient is an interface for interacting with OCI registries // It is used by the OCIRegistry to retrieve chart versions -// from OCI registries +// from OCI registries. These signatures are implemented by +// type RegistryClient interface { Login(host string, opts ...registry.LoginOption) error Logout(host string, opts ...registry.LogoutOption) error Tags(url string) ([]string, error) } +// an interface flux plugin uses to determine what kind of vendor-specific +// registry repository name lister applies, and then executes specific logic +type OCIRepositoryLister interface { + IsApplicableFor(*OCIRegistry) (bool, error) + ListRepositoryNames() ([]string, error) +} + // OCIRegistry represents a Helm chart repository, and the configuration // required to download the repository tags and charts from the repository. // All methods are thread safe unless defined otherwise. type OCIRegistry struct { - // URL is the location of the repository. - URL url.URL - // Client to use while accessing the repository's contents. - Client getter.Getter - // Options to configure the Client with while downloading tags + // url is the location of the repository. + url url.URL + // helmGetter to use while accessing the repository's contents. + helmGetter getter.Getter + // helmOptions to configure the Client with while downloading tags // or a chart from the URL. - Options []getter.Option + helmOptions []getter.Option tlsConfig *tls.Config - // RegistryClient is a client to use while downloading tags or charts from a registry. - RegistryClient RegistryClient -} + // registryClient is a client to use while downloading tags or charts from a registry. + registryClient RegistryClient -type ArtifactFiles struct { - Metadata string - Readme string - Values string - Schema string + repositoryLister OCIRepositoryLister } // OCIRegistryOption is a function that can be passed to NewOCIRegistry @@ -81,7 +85,7 @@ type ArtifactFiles struct { type OCIRegistryOption func(*OCIRegistry) error var ( - getters = getter.Providers{ + helmGetters = getter.Providers{ getter.Provider{ Schemes: []string{"http", "https"}, New: getter.NewHTTPGetter, @@ -91,65 +95,79 @@ var ( New: getter.NewOCIGetter, }, } + + // TODO: make this thing extensible so code coming from other plugs/modules + // can register new repository listers + builtInRepoListers = []OCIRepositoryLister{ + NewGitHubRepositoryLister(), + // TODO + } ) -// WithOCIRegistryClient returns a OCIRegistryOption that will set the registry client -func WithOCIRegistryClient(client RegistryClient) OCIRegistryOption { +// withOCIRegistryClient returns a OCIRegistryOption that will set the registry client +func withOCIRegistryClient(client RegistryClient) OCIRegistryOption { return func(r *OCIRegistry) error { - r.RegistryClient = client + r.registryClient = client return nil } } -// WithOCIGetter returns a OCIRegistryOption that will set the getter.Getter -func WithOCIGetter(providers getter.Providers) OCIRegistryOption { +// withOCIGetter returns a OCIRegistryOption that will set the getter.Getter +func withOCIGetter(providers getter.Providers) OCIRegistryOption { return func(r *OCIRegistry) error { - c, err := providers.ByScheme(r.URL.Scheme) + c, err := providers.ByScheme(r.url.Scheme) if err != nil { return err } - r.Client = c + r.helmGetter = c return nil } } -// WithOCIGetterOptions returns a OCIRegistryOption that will set the getter.Options -func WithOCIGetterOptions(getterOpts []getter.Option) OCIRegistryOption { +// withOCIGetterOptions returns a OCIRegistryOption that will set the getter.Options +func withOCIGetterOptions(getterOpts []getter.Option) OCIRegistryOption { return func(r *OCIRegistry) error { - r.Options = getterOpts + r.helmOptions = getterOpts return nil } } -// NewOCIRegistry constructs and returns a new OCIRegistry with +// newOCIRegistry constructs and returns a new OCIRegistry with // the RegistryClient configured to the getter.Getter for the // registry URL scheme. It returns an error on URL parsing failures. // It assumes that the url scheme has been validated to be an OCI scheme. -func NewOCIRegistry(registryURL string, registryOpts ...OCIRegistryOption) (*OCIRegistry, error) { +func newOCIRegistry(registryURL string, registryOpts ...OCIRegistryOption) (*OCIRegistry, error) { u, err := url.Parse(registryURL) if err != nil { return nil, err } r := &OCIRegistry{} - r.URL = *u + r.url = *u for _, opt := range registryOpts { if err := opt(r); err != nil { return nil, err } } + for _, lister := range builtInRepoListers { + if ok, err := lister.IsApplicableFor(r); ok && err == nil { + r.repositoryLister = lister + break + } else { + log.Infof("Lister [%v] not applicable for registry for URL: [%s] [%v]", reflect.TypeOf(lister), &r.url, err) + } + } + + if r.repositoryLister == nil { + return nil, status.Errorf(codes.Internal, "No repository lister found for OCI registry with url: [%s]", &r.url) + } + return r, nil } func (r *OCIRegistry) listRepositoryNames() ([]string, error) { - // see OCI Registry section in private-app-repository.md - // It's necessary to specify the list of applications (repositories) that the registry contains. - // This is because the OCI specification doesn't have an endpoint to discover artifacts - // (unlike the index.yaml file of a Helm repository). - - // TODO (gfichtenholt) fix me - return []string{"podinfo"}, nil + return r.repositoryLister.ListRepositoryNames() } // Get returns the ChartVersion for the given name, the version is expected @@ -160,7 +178,7 @@ func (r *OCIRegistry) getChartVersion(name, ver string) (*repo.ChartVersion, err log.Infof("+getChartVersion(%s,%s", name, ver) // Find chart versions matching the given name. // Either in an index file or from a registry. - ref := fmt.Sprintf("%s/%s", r.URL.String(), name) + ref := fmt.Sprintf("%s/%s", r.url.String(), name) cvs, err := r.getTags(ref) if err != nil { return nil, err @@ -176,7 +194,7 @@ func (r *OCIRegistry) getChartVersion(name, ver string) (*repo.ChartVersion, err // If semver constraint string, try to find a match tag, err := getLastMatchingVersionOrConstraint(cvs, ver) return &repo.ChartVersion{ - URLs: []string{fmt.Sprintf("%s/%s:%s", r.URL.String(), name, tag)}, + URLs: []string{fmt.Sprintf("%s/%s:%s", r.url.String(), name, tag)}, Metadata: &chart.Metadata{ Name: name, Version: tag, @@ -189,11 +207,11 @@ func (r *OCIRegistry) getChartVersion(name, ver string) (*repo.ChartVersion, err func (r *OCIRegistry) getTags(ref string) ([]string, error) { ref = strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme)) - if err := debugTags(ref); err != nil { + if err := debugTagList(ref); err != nil { log.Infof("debugTags failed due to: %v", err) } - tags, err := r.RegistryClient.Tags(ref) + tags, err := r.registryClient.Tags(ref) if err != nil { return nil, err } @@ -222,19 +240,19 @@ func (r *OCIRegistry) downloadChart(chart *repo.ChartVersion) (*bytes.Buffer, er } t := transport.NewOrIdle(r.tlsConfig) - clientOpts := append(r.Options, getter.WithTransport(t)) + clientOpts := append(r.helmOptions, getter.WithTransport(t)) defer transport.Release(t) // trim the oci scheme prefix if needed getThis := strings.TrimPrefix(u.String(), fmt.Sprintf("%s://", registry.OCIScheme)) - return r.Client.Get(getThis, clientOpts...) + return r.helmGetter.Get(getThis, clientOpts...) } // login attempts to login to the OCI registry. // It returns an error on failure. func (r *OCIRegistry) login(opts ...registry.LoginOption) error { log.Infof("+login") - err := r.RegistryClient.Login(r.URL.Host, opts...) + err := r.registryClient.Login(r.url.Host, opts...) if err != nil { return err } @@ -245,7 +263,7 @@ func (r *OCIRegistry) login(opts ...registry.LoginOption) error { // It returns an error on failure. func (r *OCIRegistry) logout() error { log.Infof("+logout") - err := r.RegistryClient.Logout(r.URL.Host) + err := r.registryClient.Logout(r.url.Host) if err != nil { return err } @@ -521,7 +539,7 @@ func (r *OCIRegistry) checksum() (string, error) { } tags := map[string]TagList{} for _, appName := range appNames { - ref := fmt.Sprintf("%s/%s", r.URL.String(), appName) + ref := fmt.Sprintf("%s/%s", r.url.String(), appName) tagss, err := r.getTags(ref) if err != nil { return "", err @@ -562,11 +580,11 @@ func (s *repoEventSink) newOCIRegistry(repo sourcev1.HelmRepository) (*OCIRegist // which may contain zero or more "helm repositories", such as // oci://demo.goharbor.io/test-oci-1, which may contain repositories "repo-1", "repo2", etc - ociRegistry, err := NewOCIRegistry( + ociRegistry, err := newOCIRegistry( repo.Spec.URL, - WithOCIGetter(getters), - WithOCIGetterOptions(getterOpts), - WithOCIRegistryClient(registryClient)) + withOCIGetter(helmGetters), + withOCIGetterOptions(getterOpts), + withOCIRegistryClient(registryClient)) if err != nil { return nil, status.Errorf(codes.Internal, "failed to parse URL '%s': %v", repo.Spec.URL, err) } diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go index 00feb4836bc..fa5828ab368 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go @@ -29,8 +29,8 @@ import ( // Retrieve list of repository tags // impl ref https://github.com/helm/helm/blob/657850e44b880cca43d0606ebf5a54eb75362c3f/pkg/registry/client.go#L579 -func debugTags(ref string) error { - log.Infof("+debugTags(%s)", ref) +func debugTagList(ref string) error { + log.Infof("+debugTagList(%s)", ref) parsedReference, err := orasregistry.ParseReference(ref) if err != nil { @@ -68,11 +68,15 @@ func debugTags(ref string) error { return registryauth.EmptyCredential, errors.New("unable to retrieve credentials") } - log.Infof("=======> debugTags: registryAuthorizer: [%s] [%s...]", username, password[0:3]) + printPwd := password + if len(printPwd) > 3 { + printPwd = printPwd[0:3] + "..." + } + log.Infof("=======> debugTags: registryAuthorizer: username: [%s] password: [%s]", + username, printPwd) // A blank returned username and password value is a bearer token if username == "" && password != "" { - log.Infof("debugTags: registryAuthorizer: [%s] [%s]", username, password) return registryauth.Credential{ RefreshToken: password, }, nil @@ -119,8 +123,8 @@ func debugTags(ref string) error { // Reference: https://docs.docker.com/registry/spec/api/#tags buildRepositoryTagListURL := func(plainHTTP bool, ref orasregistry.Reference) string { //return buildRepositoryBaseURL(plainHTTP, ref) + "/tags/list" - //return buildRepositoryBaseURL(plainHTTP, ref) - return "https://ghcr.io/v2/_catalog" + //return "https://ghcr.io/v2/_catalog" + return "https://ghcr.io/v2/" } tags := func(ctx context.Context, url string) (string, error) { @@ -134,17 +138,18 @@ func debugTags(ref string) error { req.URL.RawQuery = q.Encode() } - log.Infof("debugTags: +HTTP GET request:\nURL:\n%s\npretty print:\n%s\nAuthorization:[%s]\n", + log.Infof("debugTags: +HTTP %s request:\nURL:\n%s\npretty print:\n%s", + req.Method, req.URL.String(), - common.PrettyPrint(req), - req.Header["Authorization"]) + common.PrettyPrint(req)) resp, err := repository.Client.Do(req) if err != nil { log.Infof("debugTags: -HTTP GET response: raised err=%v", err) return "", err } respBody, err := ioutil.ReadAll(resp.Body) - log.Infof("debugTags: -HTTP GET response: code:%s\nbody:\n%s\nheaders:\n%s\nerr=%v", + log.Infof("debugTags: -HTTP %s response: code:%s\nbody:\n%s\nheaders:\n%s\nerr=%v", + req.Method, resp.Status, respBody, resp.Header, From 67dddb9be9297c921d06d7da2699145be0b26c24 Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Mon, 20 Jun 2022 01:23:55 -0700 Subject: [PATCH 08/11] incremental --- .../v1alpha1/chart_integration_test.go | 33 ++++-- .../fluxv2/packages/v1alpha1/common/utils.go | 52 +++++++++ .../v1alpha1/github_oci_repo_lister.go | 109 +++++++++++++++++- .../fluxv2/packages/v1alpha1/oci_repo.go | 88 ++++++-------- .../packages/v1alpha1/oci_repo_debug.go | 27 ++++- 5 files changed, 242 insertions(+), 67 deletions(-) diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go index 0e09709ee34..0e4b980c26a 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go @@ -6,6 +6,7 @@ package main import ( "context" "fmt" + "os" "strings" "testing" "time" @@ -519,6 +520,15 @@ func TestKindClusterGetAvailablePackageSummariesForOCI(t *testing.T) { t.Fatal(err) } + ghUser := os.Getenv("GITHUB_USER") + if ghUser == "" { + t.Fatalf("Environment variable GITHUB_USER needs to be set") + } + ghToken := os.Getenv("GITHUB_TOKEN") + if ghToken == "" { + t.Fatalf("Environment variable GITHUB_TOKEN needs to be set") + } + // appears to work on the client-side only after successful // docker login ghcr.io -u gfichtenholt -p ghp_... // personal access token ghp_... can be seen on https://github.com/settings/tokens @@ -529,11 +539,7 @@ func TestKindClusterGetAvailablePackageSummariesForOCI(t *testing.T) { // -HTTP GET response: raised err=GET "https://ghcr.io/v2/": // GET "https://ghcr.io/token?scope=repository%3Auser%2Fimage%3Apull&service=ghcr.io": // unexpected status code 403: denied: requested access to the resource is denied - debugTagList("ghcr.io/stefanprodan/charts/podinfo") - - if true { - return - } + // debugTagList("ghcr.io/stefanprodan/charts/podinfo") adminName := types.NamespacedName{ Name: "test-admin-" + randSeq(4), @@ -549,21 +555,26 @@ func TestKindClusterGetAvailablePackageSummariesForOCI(t *testing.T) { Namespace: "default", } - secret := newBasicAuthSecret(types.NamespacedName{ - Name: "secret-1", + // this is a secret for authentication with GitHub (ghcr.io) + // personal access token ghp_... can be seen on https://github.com/settings/tokens + // and has "admin:repo_hook, delete_repo, repo" scopes + // one should be able to login successfully like this: + // docker login ghcr.io -u $GITHUB_USER -p $GITHUB_TOKEN + + ghSecret := newBasicAuthSecret(types.NamespacedName{ + Name: "github-secret-1", Namespace: repoName.Namespace}, - "admin", "Harbor12345") + ghUser, ghToken) - if err := kubeCreateSecretAndCleanup(t, secret); err != nil { + if err := kubeCreateSecretAndCleanup(t, ghSecret); err != nil { t.Fatal(err) } ctx, cancel := context.WithTimeout(grpcContext, defaultContextTimeout) defer cancel() setUserManagedSecretsAndCleanup(t, fluxPluginReposClient, ctx, true) - // TODO: need to somehow pass repo = "podinfo" if err := kubeAddHelmRepositoryAndCleanup( - t, repoName, "oci", "oci://ghcr.io/stefanprodan/charts", "", 0); err != nil { + t, repoName, "oci", "oci://ghcr.io/stefanprodan/charts", ghSecret.Name, 0); err != nil { t.Fatalf("%v", err) } // wait until this repo reaches 'Ready' diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go index 9d3bc1a3c3e..43efa5896e0 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go @@ -20,6 +20,8 @@ import ( "sync" "time" + "github.com/docker/cli/cli/config" + "github.com/docker/cli/cli/config/credentials" helmv2 "github.com/fluxcd/helm-controller/api/v2beta1" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" "github.com/go-redis/redis/v8" @@ -30,6 +32,7 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "helm.sh/helm/v3/pkg/getter" + "helm.sh/helm/v3/pkg/registry" apiv1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" @@ -300,6 +303,55 @@ func tlsClientConfigFromSecret(secret apiv1.Secret, options *HttpClientOptions) return nil } +// LoginOptionFromSecret derives authentication data from a Secret to login to an OCI registry. +// This Secret may either hold "username" and "password" fields or be of the +// apiv1.SecretTypeDockerConfigJson type and hold a apiv1.DockerConfigJsonKey field with a +// complete Docker configuration. If both, "username" and "password" are +// empty, a nil LoginOption and a nil error will be returned. +// ref https://github.com/fluxcd/source-controller/blob/main/internal/helm/registry/auth.go +func LoginOptionFromSecret(registryURL string, secret apiv1.Secret) (registry.LoginOption, error) { + var username, password string + if secret.Type == apiv1.SecretTypeDockerConfigJson { + dockerCfg, err := config.LoadFromReader(bytes.NewReader(secret.Data[apiv1.DockerConfigJsonKey])) + if err != nil { + return nil, fmt.Errorf("unable to load Docker config from Secret '%s': %w", secret.Name, err) + } + parsedURL, err := url.Parse(registryURL) + if err != nil { + return nil, fmt.Errorf("unable to parse registry URL '%s' while reconciling Secret '%s': %w", + registryURL, secret.Name, err) + } + authConfig, err := dockerCfg.GetAuthConfig(parsedURL.Host) + if err != nil { + return nil, fmt.Errorf("unable to get authentication data from Secret '%s': %w", secret.Name, err) + } + + // Make sure that the obtained auth config is for the requested host. + // When the docker config does not contain the credentials for a host, + // the credential store returns an empty auth config. + // Refer: https://github.com/docker/cli/blob/v20.10.16/cli/config/credentials/file_store.go#L44 + if credentials.ConvertToHostname(authConfig.ServerAddress) != parsedURL.Host { + return nil, fmt.Errorf("no auth config for '%s' in the docker-registry Secret '%s'", parsedURL.Host, secret.Name) + } + username = authConfig.Username + password = authConfig.Password + } else { + username, password = string(secret.Data["username"]), string(secret.Data["password"]) + } + switch { + case username == "" && password == "": + return nil, nil + case username == "" || password == "": + return nil, fmt.Errorf("invalid '%s' secret data: required fields 'username' and 'password'", secret.Name) + } + pwdRedacted := password + if len(pwdRedacted) > 4 { + pwdRedacted = pwdRedacted[0:3] + "..." + } + log.Infof("-LoginOptionFromSecret: username: [%s], password: [%s]", username, pwdRedacted) + return registry.LoginOptBasicAuth(username, password), nil +} + func NewHttpClientAndHeaders(clientOptions *HttpClientOptions) (*http.Client, map[string]string, error) { // I wish I could have re-used the code in pkg/chart/chart.go and pkg/kube_utils/kube_utils.go // InitHTTPClient(), etc. but alas, it's all built around AppRepository CRD, which I don't have. diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go index 0bc06e8c3b6..acfd7c893eb 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go @@ -9,6 +9,7 @@ import ( "io" "io/ioutil" "net/http" + "reflect" "strings" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common" @@ -32,13 +33,113 @@ func NewGitHubRepositoryLister() OCIRepositoryLister { type gitHubRepositoryLister struct { } -// ref https://github.com/distribution/distribution/blob/main/docs/spec/api.md#api-version-check func (l *gitHubRepositoryLister) IsApplicableFor(ociRegistry *OCIRegistry) (bool, error) { log.Infof("+IsApplicableFor()") ref := strings.TrimPrefix(ociRegistry.url.String(), fmt.Sprintf("%s://", registry.OCIScheme)) log.Infof("ref: [%s]", ref) + // need to use + // https://github.com/helm/helm/blob/657850e44b880cca43d0606ebf5a54eb75362c3f/pkg/registry/client.go#L55 + //registry.Client. + log.Infof("ociRegistry.registryClient: %s", reflect.TypeOf(ociRegistry.registryClient)) + //origRegistryAuthorizer := ociRegistry.registryClient. + + // given ref like this + // ghcr.io/stefanprodan/charts/podinfo + // will return + // { + // "Registry": "ghcr.io", + // "Repository": "stefanprodan/charts/podinfo", + // "Reference": "" + // } + registryAuthorizer := ®istryauth.Client{ + Header: http.Header{"User-Agent": {"Helm/3.9.0"}}, + Cache: registryauth.DefaultCache, + Credential: func(ctx context.Context, reg string) (registryauth.Credential, error) { + username := "gfichtenholt" + password := "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" + + log.Infof("=======> IsApplicableFor: registryAuthorizer: [%s] [%s...]", username, password[0:3]) + return registryauth.Credential{ + Username: username, + Password: password, + }, nil + }, + } + + // given ref like this + // ghcr.io/stefanprodan/charts/podinfo + // will return + // { + // "Registry": "ghcr.io", + // "Repository": "stefanprodan/charts/podinfo", + // "Reference": "" + // } + parsedRef, err := orasregistry.ParseReference(ref) + if err != nil { + return false, err + } + log.Infof("parsed reference: [%s]", common.PrettyPrint(parsedRef)) + + ociRepo := registryremote.Repository{ + Reference: parsedRef, + Client: registryAuthorizer, + } + + ctx := ctxFn(io.Discard, true) + ctx = withScopeHint(ctx, parsedRef, registryauth.ActionPull) + // buildRepositoryBaseURL builds the base endpoint of the remote registry. + // Format: :///v2/ + url := fmt.Sprintf("%s://%s/v2/", "https", ociRepo.Reference.Host()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return false, err + } + + resp, err := ociRepo.Client.Do(req) + if err != nil { + return false, err + } + defer resp.Body.Close() + + _, err = ioutil.ReadAll(resp.Body) + if err != nil { + return false, err + } + + if resp.StatusCode == http.StatusOK { + // based on the presence of this here Docker-Distribution-Api-Version:[registry/2.0] + // conclude this is a case for GitHubRepositoryLister, e.g. + // +HTTP GET request: + // URL: https://ghcr.io/v2/ + // -HTTP GET response: code: 200 OK + // headers: + // map[ + // Content-Length:[0] Content-Type:[application/json] + // Date:[Sun, 19 Jun 2022 05:08:18 GMT] + // Docker-Distribution-Api-Version:[registry/2.0] + // X-Github-Request-Id:[C4E4:2F9A:3069FD:914D65:62AEAF42] + // ] + + val, ok := resp.Header["Docker-Distribution-Api-Version"] + if ok && len(val) == 1 && val[0] == "registry/2.0" { + return true, nil + } + } else { + log.Infof("isApplicableFor: HTTP GET (%s) returned status [%d]", url, resp.StatusCode) + } + + return false, nil +} + +// ref https://github.com/distribution/distribution/blob/main/docs/spec/api.md#api-version-check +func (l *gitHubRepositoryLister) IsApplicableFor2(ociRegistry *OCIRegistry) (bool, error) { + log.Infof("+IsApplicableFor()") + + ref := strings.TrimPrefix(ociRegistry.url.String(), fmt.Sprintf("%s://", registry.OCIScheme)) + log.Infof("ref: [%s]", ref) + // given ref like this // ghcr.io/stefanprodan/charts/podinfo // will return @@ -164,3 +265,9 @@ func ctxFn(out io.Writer, debug bool) context.Context { orascontext.GetLogger(ctx).Logger.SetLevel(logrus.DebugLevel) return ctx } + +// withScopeHint adds a hinted scope to the context. +func withScopeHint(ctx context.Context, ref orasregistry.Reference, actions ...string) context.Context { + scope := registryauth.ScopeRepository(ref.Repository, actions...) + return registryauth.AppendScopes(ctx, scope) +} diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go index 214567ed1ce..5023498d95c 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go @@ -46,7 +46,7 @@ import ( // RegistryClient is an interface for interacting with OCI registries // It is used by the OCIRegistry to retrieve chart versions // from OCI registries. These signatures are implemented by -// +// https://github.com/helm/helm/blob/main/pkg/registry/client.go type RegistryClient interface { Login(host string, opts ...registry.LoginOption) error Logout(host string, opts ...registry.LogoutOption) error @@ -149,7 +149,11 @@ func newOCIRegistry(registryURL string, registryOpts ...OCIRegistryOption) (*OCI return nil, err } } + return r, nil +} +func (r *OCIRegistry) listRepositoryNames() ([]string, error) { + // this needs to be done after a call to login() for _, lister := range builtInRepoListers { if ok, err := lister.IsApplicableFor(r); ok && err == nil { r.repositoryLister = lister @@ -163,10 +167,6 @@ func newOCIRegistry(registryURL string, registryOpts ...OCIRegistryOption) (*OCI return nil, status.Errorf(codes.Internal, "No repository lister found for OCI registry with url: [%s]", &r.url) } - return r, nil -} - -func (r *OCIRegistry) listRepositoryNames() ([]string, error) { return r.repositoryLister.ListRepositoryNames() } @@ -207,10 +207,6 @@ func (r *OCIRegistry) getChartVersion(name, ver string) (*repo.ChartVersion, err func (r *OCIRegistry) getTags(ref string) ([]string, error) { ref = strings.TrimPrefix(ref, fmt.Sprintf("%s://", registry.OCIScheme)) - if err := debugTagList(ref); err != nil { - log.Infof("debugTags failed due to: %v", err) - } - tags, err := r.registryClient.Tags(ref) if err != nil { return nil, err @@ -248,17 +244,6 @@ func (r *OCIRegistry) downloadChart(chart *repo.ChartVersion) (*bytes.Buffer, er return r.helmGetter.Get(getThis, clientOpts...) } -// login attempts to login to the OCI registry. -// It returns an error on failure. -func (r *OCIRegistry) login(opts ...registry.LoginOption) error { - log.Infof("+login") - err := r.registryClient.Login(r.url.Host, opts...) - if err != nil { - return err - } - return nil -} - // logout attempts to logout from the OCI registry. // It returns an error on failure. func (r *OCIRegistry) logout() error { @@ -359,17 +344,12 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool log.Infof("+onAddOciRepo(%s)", common.PrettyPrint(repo)) defer log.Infof("-onAddOciRepo") - ociRegistry, err := s.newOCIRegistry(repo) + ociRegistry, err := s.newOCIRegistryAndLogin(repo) if err != nil { return nil, false, err } - repoURL, err := url.ParseRequestURI(strings.TrimSpace(repo.Spec.URL)) - if err != nil { - return nil, false, status.Errorf(codes.Internal, "%v", err) - } - - log.Infof("==========>: URL object: [%s]", common.PrettyPrint(repoURL)) + // debugTagList("ghcr.io/stefanprodan/charts/podinfo") chartRepo := &models.Repo{ Namespace: repo.Namespace, @@ -500,7 +480,7 @@ func (s *repoEventSink) onModifyOciRepo(key string, oldValue interface{}, repo s key, cacheEntryUntyped) } - ociRegistry, err := s.newOCIRegistry(repo) + ociRegistry, err := s.newOCIRegistryAndLogin(repo) if err != nil { return nil, false, err } @@ -555,7 +535,7 @@ func (r *OCIRegistry) checksum() (string, error) { return common.GetSha256(content) } -func (s *repoEventSink) newOCIRegistry(repo sourcev1.HelmRepository) (*OCIRegistry, error) { +func (s *repoEventSink) newOCIRegistryAndLogin(repo sourcev1.HelmRepository) (*OCIRegistry, error) { // Construct the Getter options from the HelmRepository data loginOpts, getterOpts, err := s.helmOptionsForRepo(repo) if err != nil { @@ -569,11 +549,22 @@ func (s *repoEventSink) newOCIRegistry(repo sourcev1.HelmRepository) (*OCIRegist return nil, status.Errorf(codes.Internal, "failed to create registry client: %v", err) } if file != "" { - defer func() { - if err := os.Remove(file); err != nil { - log.Infof("Failed to delete temporary credentials file: %v", err) - } - }() + /* + defer func() { + byteArray, err := ioutil.ReadFile(file) + if err != nil { + log.Infof("Failed to read temporary credentials file [%s]: %v", file, err) + } + + // Convert []byte to string and print to screen + log.Infof("about to remove temporary credentials file [%s] content\n[%s]", + file, string(byteArray)) + + if err := os.Remove(file); err != nil { + log.Infof("Failed to delete temporary credentials file: %v", err) + } + }() + */ } // a little bit misleading, since repo.Spec.URL is really an OCI Registry URL, @@ -591,7 +582,8 @@ func (s *repoEventSink) newOCIRegistry(repo sourcev1.HelmRepository) (*OCIRegist // Attempt to login to the registry if credentials are provided. if loginOpts != nil { - err = ociRegistry.login(loginOpts...) + err := ociRegistry.registryClient.Login(ociRegistry.url.Host, loginOpts...) + log.Infof("login(%s): %v", ociRegistry.url.Host, err) if err != nil { return nil, err } @@ -602,6 +594,7 @@ func (s *repoEventSink) newOCIRegistry(repo sourcev1.HelmRepository) (*OCIRegist func (s *repoEventSink) helmOptionsForRepo(repo sourcev1.HelmRepository) ([]registry.LoginOption, []getter.Option, error) { log.Infof("+helmOptionsForRepo()") + var loginOpts []registry.LoginOption getterOpts := []getter.Option{ getter.WithURL(repo.Spec.URL), getter.WithTimeout(repo.Spec.Timeout.Duration), @@ -617,29 +610,16 @@ func (s *repoEventSink) helmOptionsForRepo(repo sourcev1.HelmRepository) ([]regi return nil, nil, err } getterOpts = append(getterOpts, opts...) - } - /* - ctx := - _, err := s.clientOptionsForRepo(ctx, repo) + loginOpt, err := common.LoginOptionFromSecret(repo.Spec.URL, *secret) if err != nil { return nil, nil, err } - loginOpt := append(getterOpts, common.ConvertClientOptionsToHelmGetterOptions(c)...) - - tlsConfig, err = getter.TLSClientConfigFromSecret(*secret, repo.Spec.URL) - if err != nil { - return nil, err - } - - // Build registryClient options from secret - loginOpt, err := registry.LoginOptionFromSecret(repo.Spec.URL, *secret) - if err != nil { - return nil, err - } + if loginOpt != nil { + loginOpts = append(loginOpts, loginOpt) + } + } - loginOpts := append([]registry.LoginOption{}, loginOpt) - */ - return nil, getterOpts, nil + return loginOpts, getterOpts, nil } diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go index fa5828ab368..479cc01aa7b 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go @@ -11,6 +11,7 @@ import ( "io" "io/ioutil" "net/http" + "os" "strconv" "helm.sh/helm/v3/pkg/registry" @@ -18,6 +19,9 @@ import ( "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common" log "k8s.io/klog/v2" + "github.com/docker/cli/cli/config/configfile" + "github.com/docker/cli/cli/config/credentials" + "github.com/sirupsen/logrus" "helm.sh/helm/v3/pkg/helmpath" dockerauth "oras.land/oras-go/pkg/auth/docker" @@ -58,11 +62,11 @@ func debugTagList(ref string) error { Header: http.Header{"User-Agent": {"Helm/3.9.0"}}, Cache: registryauth.DefaultCache, Credential: func(ctx context.Context, reg string) (registryauth.Credential, error) { + log.Infof("+ =======> debugTags: Credential fn(%s)", reg) dockerClient, ok := authClient.(*dockerauth.Client) if !ok { return registryauth.EmptyCredential, errors.New("unable to obtain docker client") } - username, password, err := dockerClient.Credential(reg) if err != nil { return registryauth.EmptyCredential, errors.New("unable to retrieve credentials") @@ -167,3 +171,24 @@ func debugTagList(ref string) error { return nil } + +// loadConfigFile reads the configuration files from the given path. +func loadConfigFile(path string) (*configfile.ConfigFile, error) { + cfg := configfile.New(path) + if _, err := os.Stat(path); err == nil { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + if err := cfg.LoadFromReader(file); err != nil { + return nil, err + } + } else if !os.IsNotExist(err) { + return nil, err + } + if !cfg.ContainsAuth() { + cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore) + } + return cfg, nil +} From 7658abdcd55a7d64abacca6fad1075b6e03c4bf2 Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Mon, 20 Jun 2022 16:39:48 -0700 Subject: [PATCH 09/11] incremental --- .../v1alpha1/chart_integration_test.go | 12 -- .../fluxv2/packages/v1alpha1/common/utils.go | 15 +- .../v1alpha1/github_oci_repo_lister.go | 159 +------------- .../fluxv2/packages/v1alpha1/oci_repo.go | 52 +++-- .../packages/v1alpha1/oci_repo_debug.go | 194 ------------------ 5 files changed, 55 insertions(+), 377 deletions(-) delete mode 100644 cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go index 0e4b980c26a..12edc35ceda 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/chart_integration_test.go @@ -529,18 +529,6 @@ func TestKindClusterGetAvailablePackageSummariesForOCI(t *testing.T) { t.Fatalf("Environment variable GITHUB_TOKEN needs to be set") } - // appears to work on the client-side only after successful - // docker login ghcr.io -u gfichtenholt -p ghp_... - // personal access token ghp_... can be seen on https://github.com/settings/tokens - // and has "admin:repo_hook, delete_repo, repo" scopes - - // TODO on the server-side it doesn't work - // somehow dockerauth.NewClientWithDockerFallback() isn't finding any creds which causes - // -HTTP GET response: raised err=GET "https://ghcr.io/v2/": - // GET "https://ghcr.io/token?scope=repository%3Auser%2Fimage%3Apull&service=ghcr.io": - // unexpected status code 403: denied: requested access to the resource is denied - // debugTagList("ghcr.io/stefanprodan/charts/podinfo") - adminName := types.NamespacedName{ Name: "test-admin-" + randSeq(4), Namespace: "default", diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go index 43efa5896e0..1b7b5122467 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go @@ -32,13 +32,13 @@ import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "helm.sh/helm/v3/pkg/getter" - "helm.sh/helm/v3/pkg/registry" apiv1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/wait" log "k8s.io/klog/v2" + registryauth "oras.land/oras-go/pkg/registry/remote/auth" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -303,13 +303,13 @@ func tlsClientConfigFromSecret(secret apiv1.Secret, options *HttpClientOptions) return nil } -// LoginOptionFromSecret derives authentication data from a Secret to login to an OCI registry. +// OCIRegistryCredentialFromSecret derives authentication data from a Secret to login to an OCI registry. // This Secret may either hold "username" and "password" fields or be of the // apiv1.SecretTypeDockerConfigJson type and hold a apiv1.DockerConfigJsonKey field with a // complete Docker configuration. If both, "username" and "password" are // empty, a nil LoginOption and a nil error will be returned. // ref https://github.com/fluxcd/source-controller/blob/main/internal/helm/registry/auth.go -func LoginOptionFromSecret(registryURL string, secret apiv1.Secret) (registry.LoginOption, error) { +func OCIRegistryCredentialFromSecret(registryURL string, secret apiv1.Secret) (*registryauth.Credential, error) { var username, password string if secret.Type == apiv1.SecretTypeDockerConfigJson { dockerCfg, err := config.LoadFromReader(bytes.NewReader(secret.Data[apiv1.DockerConfigJsonKey])) @@ -348,12 +348,15 @@ func LoginOptionFromSecret(registryURL string, secret apiv1.Secret) (registry.Lo if len(pwdRedacted) > 4 { pwdRedacted = pwdRedacted[0:3] + "..." } - log.Infof("-LoginOptionFromSecret: username: [%s], password: [%s]", username, pwdRedacted) - return registry.LoginOptBasicAuth(username, password), nil + log.Infof("-OCIRegistryCredentialFromSecret: username: [%s], password: [%s]", username, pwdRedacted) + return ®istryauth.Credential{ + Username: username, + Password: password, + }, nil } func NewHttpClientAndHeaders(clientOptions *HttpClientOptions) (*http.Client, map[string]string, error) { - // I wish I could have re-used the code in pkg/chart/chart.go and pkg/kube_utils/kube_utils.go + // I wish I could reuse the code in pkg/chart/chart.go and pkg/kube_utils/kube_utils.go // InitHTTPClient(), etc. but alas, it's all built around AppRepository CRD, which I don't have. headers := make(map[string]string) headers["User-Agent"] = userAgentString() diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go index acfd7c893eb..cf69c1c0d59 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go @@ -4,7 +4,6 @@ package main import ( "context" - "errors" "fmt" "io" "io/ioutil" @@ -13,13 +12,10 @@ import ( "strings" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common" - httpclient "github.com/vmware-tanzu/kubeapps/pkg/http-client" log "k8s.io/klog/v2" "github.com/sirupsen/logrus" - "helm.sh/helm/v3/pkg/helmpath" "helm.sh/helm/v3/pkg/registry" - dockerauth "oras.land/oras-go/pkg/auth/docker" orascontext "oras.land/oras-go/pkg/context" orasregistry "oras.land/oras-go/pkg/registry" registryremote "oras.land/oras-go/pkg/registry/remote" @@ -33,39 +29,18 @@ func NewGitHubRepositoryLister() OCIRepositoryLister { type gitHubRepositoryLister struct { } +// ref https://github.com/distribution/distribution/blob/main/docs/spec/api.md#api-version-check func (l *gitHubRepositoryLister) IsApplicableFor(ociRegistry *OCIRegistry) (bool, error) { - log.Infof("+IsApplicableFor()") - - ref := strings.TrimPrefix(ociRegistry.url.String(), fmt.Sprintf("%s://", registry.OCIScheme)) - log.Infof("ref: [%s]", ref) + log.Infof("+IsApplicableFor(%s)", ociRegistry.url.String()) // need to use // https://github.com/helm/helm/blob/657850e44b880cca43d0606ebf5a54eb75362c3f/pkg/registry/client.go#L55 //registry.Client. log.Infof("ociRegistry.registryClient: %s", reflect.TypeOf(ociRegistry.registryClient)) - //origRegistryAuthorizer := ociRegistry.registryClient. - - // given ref like this - // ghcr.io/stefanprodan/charts/podinfo - // will return - // { - // "Registry": "ghcr.io", - // "Repository": "stefanprodan/charts/podinfo", - // "Reference": "" - // } registryAuthorizer := ®istryauth.Client{ - Header: http.Header{"User-Agent": {"Helm/3.9.0"}}, - Cache: registryauth.DefaultCache, - Credential: func(ctx context.Context, reg string) (registryauth.Credential, error) { - username := "gfichtenholt" - password := "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" - - log.Infof("=======> IsApplicableFor: registryAuthorizer: [%s] [%s...]", username, password[0:3]) - return registryauth.Credential{ - Username: username, - Password: password, - }, nil - }, + Header: http.Header{"User-Agent": {"Helm/3.9.0"}}, + Cache: registryauth.DefaultCache, + Credential: ociRegistry.registryCredentialFn, } // given ref like this @@ -76,145 +51,31 @@ func (l *gitHubRepositoryLister) IsApplicableFor(ociRegistry *OCIRegistry) (bool // "Repository": "stefanprodan/charts/podinfo", // "Reference": "" // } - parsedRef, err := orasregistry.ParseReference(ref) - if err != nil { - return false, err - } - log.Infof("parsed reference: [%s]", common.PrettyPrint(parsedRef)) - - ociRepo := registryremote.Repository{ - Reference: parsedRef, - Client: registryAuthorizer, - } - - ctx := ctxFn(io.Discard, true) - ctx = withScopeHint(ctx, parsedRef, registryauth.ActionPull) - // buildRepositoryBaseURL builds the base endpoint of the remote registry. - // Format: :///v2/ - url := fmt.Sprintf("%s://%s/v2/", "https", ociRepo.Reference.Host()) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return false, err - } - - resp, err := ociRepo.Client.Do(req) - if err != nil { - return false, err - } - defer resp.Body.Close() - - _, err = ioutil.ReadAll(resp.Body) - if err != nil { - return false, err - } - - if resp.StatusCode == http.StatusOK { - // based on the presence of this here Docker-Distribution-Api-Version:[registry/2.0] - // conclude this is a case for GitHubRepositoryLister, e.g. - // +HTTP GET request: - // URL: https://ghcr.io/v2/ - // -HTTP GET response: code: 200 OK - // headers: - // map[ - // Content-Length:[0] Content-Type:[application/json] - // Date:[Sun, 19 Jun 2022 05:08:18 GMT] - // Docker-Distribution-Api-Version:[registry/2.0] - // X-Github-Request-Id:[C4E4:2F9A:3069FD:914D65:62AEAF42] - // ] - - val, ok := resp.Header["Docker-Distribution-Api-Version"] - if ok && len(val) == 1 && val[0] == "registry/2.0" { - return true, nil - } - } else { - log.Infof("isApplicableFor: HTTP GET (%s) returned status [%d]", url, resp.StatusCode) - } - - return false, nil -} - -// ref https://github.com/distribution/distribution/blob/main/docs/spec/api.md#api-version-check -func (l *gitHubRepositoryLister) IsApplicableFor2(ociRegistry *OCIRegistry) (bool, error) { - log.Infof("+IsApplicableFor()") - ref := strings.TrimPrefix(ociRegistry.url.String(), fmt.Sprintf("%s://", registry.OCIScheme)) log.Infof("ref: [%s]", ref) - // given ref like this - // ghcr.io/stefanprodan/charts/podinfo - // will return - // { - // "Registry": "ghcr.io", - // "Repository": "stefanprodan/charts/podinfo", - // "Reference": "" - // } parsedRef, err := orasregistry.ParseReference(ref) if err != nil { return false, err } log.Infof("parsed reference: [%s]", common.PrettyPrint(parsedRef)) - credentialsFile := helmpath.ConfigPath(registry.CredentialsFileBasename) - - authClient, err := dockerauth.NewClientWithDockerFallback(credentialsFile) - if err != nil { - return false, err - } - - registryAuthorizer := ®istryauth.Client{ - Header: http.Header{"User-Agent": {"Helm/3.9.0"}}, - Cache: registryauth.DefaultCache, - Credential: func(ctx context.Context, reg string) (registryauth.Credential, error) { - dockerClient, ok := authClient.(*dockerauth.Client) - if !ok { - return registryauth.EmptyCredential, errors.New("unable to obtain docker client") - } - - username, password, err := dockerClient.Credential(reg) - if err != nil { - return registryauth.EmptyCredential, errors.New("unable to retrieve credentials") - } - - log.Infof("=======> IsApplicableFor: registryAuthorizer: [%s] [%s...]", username, password[0:3]) - - // A blank returned username and password value is a bearer token - if username == "" && password != "" { - log.Infof("IsApplicableFor: registryAuthorizer: [%s] [%s]", username, password) - return registryauth.Credential{ - RefreshToken: password, - }, nil - } - return registryauth.Credential{ - Username: username, - Password: password, - }, nil - }, - } - ociRepo := registryremote.Repository{ Reference: parsedRef, Client: registryAuthorizer, } - // ref https://github.com/oras-project/oras-go/blob/main/registry/remote/url.go - // buildScheme returns HTTP scheme used to access the remote registry. - buildScheme := func(plainHTTP bool) string { - if plainHTTP { - return "http" - } - return "https" - } - ctx := ctxFn(io.Discard, true) + ctx = withScopeHint(ctx, parsedRef, registryauth.ActionPull) // buildRepositoryBaseURL builds the base endpoint of the remote registry. // Format: :///v2/ - url := fmt.Sprintf("%s://%s/v2/", buildScheme(ociRepo.PlainHTTP), ociRepo.Reference.Host()) + url := fmt.Sprintf("%s://%s/v2/", "https", ociRepo.Reference.Host()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return false, err } - resp, err := httpclient.New().Do(req) + resp, err := ociRepo.Client.Do(req) if err != nil { return false, err } @@ -241,12 +102,12 @@ func (l *gitHubRepositoryLister) IsApplicableFor2(ociRegistry *OCIRegistry) (boo val, ok := resp.Header["Docker-Distribution-Api-Version"] if ok && len(val) == 1 && val[0] == "registry/2.0" { + log.Infof("-isApplicableFor(): yes") return true, nil } } else { - log.Infof("isApplicableFor: HTTP GET (%s) returned status [%d]", url, resp.StatusCode) + log.Infof("isApplicableFor(): HTTP GET (%s) returned status [%d]", url, resp.StatusCode) } - return false, nil } diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go index 5023498d95c..a72147946d8 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go @@ -41,6 +41,7 @@ import ( "github.com/fluxcd/pkg/version" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" + registryauth "oras.land/oras-go/pkg/registry/remote/auth" ) // RegistryClient is an interface for interacting with OCI registries @@ -77,6 +78,9 @@ type OCIRegistry struct { // registryClient is a client to use while downloading tags or charts from a registry. registryClient RegistryClient + // a workaround for the fact that I don't have access to internals of RegistryClient + registryCredentialFn OCIRegistryCredentialFn + repositoryLister OCIRepositoryLister } @@ -84,6 +88,8 @@ type OCIRegistry struct { // to configure an OCIRegistry. type OCIRegistryOption func(*OCIRegistry) error +type OCIRegistryCredentialFn func(ctx context.Context, reg string) (registryauth.Credential, error) + var ( helmGetters = getter.Providers{ getter.Provider{ @@ -132,6 +138,13 @@ func withOCIGetterOptions(getterOpts []getter.Option) OCIRegistryOption { } } +func withRegistryCredentialFn(fn OCIRegistryCredentialFn) OCIRegistryOption { + return func(r *OCIRegistry) error { + r.registryCredentialFn = fn + return nil + } +} + // newOCIRegistry constructs and returns a new OCIRegistry with // the RegistryClient configured to the getter.Getter for the // registry URL scheme. It returns an error on URL parsing failures. @@ -303,10 +316,10 @@ func getLastMatchingVersionOrConstraint(cvs []string, ver string) (string, error return matchingVersions[0].Original(), nil } -// NewRegistryClient generates a registry client and a temporary credential file. +// newRegistryClient generates a registry client and a temporary credential file. // The client is meant to be used for a single reconciliation. // The file is meant to be used for a single reconciliation and deleted after. -func NewRegistryClient(isLogin bool) (*registry.Client, string, error) { +func newRegistryClient(isLogin bool) (*registry.Client, string, error) { if isLogin { // create a temporary file to store the credentials // this is needed because otherwise the credentials are stored in ~/.docker/config.json. @@ -349,8 +362,6 @@ func (s *repoEventSink) onAddOciRepo(repo sourcev1.HelmRepository) ([]byte, bool return nil, false, err } - // debugTagList("ghcr.io/stefanprodan/charts/podinfo") - chartRepo := &models.Repo{ Namespace: repo.Namespace, Name: repo.Name, @@ -537,14 +548,14 @@ func (r *OCIRegistry) checksum() (string, error) { func (s *repoEventSink) newOCIRegistryAndLogin(repo sourcev1.HelmRepository) (*OCIRegistry, error) { // Construct the Getter options from the HelmRepository data - loginOpts, getterOpts, err := s.helmOptionsForRepo(repo) + loginOpts, getterOpts, cred, err := s.clientOptionsForRepo(repo) if err != nil { return nil, status.Errorf(codes.Internal, "failed to create registry client options: %v", err) } - log.Infof("=====================> loginOpts: [%v], getterOpts: [%v]", len(loginOpts), len(getterOpts)) + log.Infof("=====================> loginOpts: [%v], getterOpts: [%v], cred: %t", len(loginOpts), len(getterOpts), cred != nil) // Create registry client and login if needed. - registryClient, file, err := NewRegistryClient(loginOpts != nil) + registryClient, file, err := newRegistryClient(loginOpts != nil) if err != nil { return nil, status.Errorf(codes.Internal, "failed to create registry client: %v", err) } @@ -567,6 +578,13 @@ func (s *repoEventSink) newOCIRegistryAndLogin(repo sourcev1.HelmRepository) (*O */ } + registryCredentialFn := func(ctx context.Context, reg string) (registryauth.Credential, error) { + if cred != nil { + return *cred, nil + } else { + return registryauth.EmptyCredential, nil + } + } // a little bit misleading, since repo.Spec.URL is really an OCI Registry URL, // which may contain zero or more "helm repositories", such as // oci://demo.goharbor.io/test-oci-1, which may contain repositories "repo-1", "repo2", etc @@ -575,7 +593,8 @@ func (s *repoEventSink) newOCIRegistryAndLogin(repo sourcev1.HelmRepository) (*O repo.Spec.URL, withOCIGetter(helmGetters), withOCIGetterOptions(getterOpts), - withOCIRegistryClient(registryClient)) + withOCIRegistryClient(registryClient), + withRegistryCredentialFn(registryCredentialFn)) if err != nil { return nil, status.Errorf(codes.Internal, "failed to parse URL '%s': %v", repo.Spec.URL, err) } @@ -591,10 +610,11 @@ func (s *repoEventSink) newOCIRegistryAndLogin(repo sourcev1.HelmRepository) (*O return ociRegistry, nil } -func (s *repoEventSink) helmOptionsForRepo(repo sourcev1.HelmRepository) ([]registry.LoginOption, []getter.Option, error) { - log.Infof("+helmOptionsForRepo()") +func (s *repoEventSink) clientOptionsForRepo(repo sourcev1.HelmRepository) ([]registry.LoginOption, []getter.Option, *registryauth.Credential, error) { + log.Infof("+clientOptionsForRepo()") var loginOpts []registry.LoginOption + var cred *registryauth.Credential getterOpts := []getter.Option{ getter.WithURL(repo.Spec.URL), getter.WithTimeout(repo.Spec.Timeout.Duration), @@ -603,23 +623,23 @@ func (s *repoEventSink) helmOptionsForRepo(repo sourcev1.HelmRepository) ([]regi secret, err := s.getRepoSecret(context.Background(), repo) if err != nil { - return nil, nil, err + return nil, nil, nil, err } else if secret != nil { opts, err := common.HelmGetterOptionsFromSecret(*secret) if err != nil { - return nil, nil, err + return nil, nil, nil, err } getterOpts = append(getterOpts, opts...) - loginOpt, err := common.LoginOptionFromSecret(repo.Spec.URL, *secret) + cred, err = common.OCIRegistryCredentialFromSecret(repo.Spec.URL, *secret) if err != nil { - return nil, nil, err + return nil, nil, nil, err } + loginOpt := registry.LoginOptBasicAuth(cred.Username, cred.Password) if loginOpt != nil { loginOpts = append(loginOpts, loginOpt) } } - - return loginOpts, getterOpts, nil + return loginOpts, getterOpts, cred, nil } diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go deleted file mode 100644 index 479cc01aa7b..00000000000 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo_debug.go +++ /dev/null @@ -1,194 +0,0 @@ -// Temporary file for debugging exactly what the exact HTTP requests to -// and responses from a remote OCI registry look like. -// Kinf of like a network sniffer. This file will eventually go away - -package main - -import ( - "context" - "errors" - "fmt" - "io" - "io/ioutil" - "net/http" - "os" - "strconv" - - "helm.sh/helm/v3/pkg/registry" - - "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common" - log "k8s.io/klog/v2" - - "github.com/docker/cli/cli/config/configfile" - "github.com/docker/cli/cli/config/credentials" - - "github.com/sirupsen/logrus" - "helm.sh/helm/v3/pkg/helmpath" - dockerauth "oras.land/oras-go/pkg/auth/docker" - orascontext "oras.land/oras-go/pkg/context" - orasregistry "oras.land/oras-go/pkg/registry" - registryremote "oras.land/oras-go/pkg/registry/remote" - registryauth "oras.land/oras-go/pkg/registry/remote/auth" -) - -// Retrieve list of repository tags -// impl ref https://github.com/helm/helm/blob/657850e44b880cca43d0606ebf5a54eb75362c3f/pkg/registry/client.go#L579 -func debugTagList(ref string) error { - log.Infof("+debugTagList(%s)", ref) - - parsedReference, err := orasregistry.ParseReference(ref) - if err != nil { - return err - } - // given ref like this - // ghcr.io/stefanprodan/charts/podinfo - // will return - // { - // "Registry": "ghcr.io", - // "Repository": "stefanprodan/charts/podinfo", - // "Reference": "" - // } - log.Infof("debugTags parsedReference(%s): %s", ref, common.PrettyPrint(parsedReference)) - - credentialsFile := helmpath.ConfigPath(registry.CredentialsFileBasename) - log.Infof("debugTags credentials file: %s", credentialsFile) - - authClient, err := dockerauth.NewClientWithDockerFallback(credentialsFile) - if err != nil { - return err - } - - registryAuthorizer := ®istryauth.Client{ - Header: http.Header{"User-Agent": {"Helm/3.9.0"}}, - Cache: registryauth.DefaultCache, - Credential: func(ctx context.Context, reg string) (registryauth.Credential, error) { - log.Infof("+ =======> debugTags: Credential fn(%s)", reg) - dockerClient, ok := authClient.(*dockerauth.Client) - if !ok { - return registryauth.EmptyCredential, errors.New("unable to obtain docker client") - } - username, password, err := dockerClient.Credential(reg) - if err != nil { - return registryauth.EmptyCredential, errors.New("unable to retrieve credentials") - } - - printPwd := password - if len(printPwd) > 3 { - printPwd = printPwd[0:3] + "..." - } - log.Infof("=======> debugTags: registryAuthorizer: username: [%s] password: [%s]", - username, printPwd) - - // A blank returned username and password value is a bearer token - if username == "" && password != "" { - return registryauth.Credential{ - RefreshToken: password, - }, nil - } - return registryauth.Credential{ - Username: username, - Password: password, - }, nil - }, - } - - repository := registryremote.Repository{ - Reference: parsedReference, - Client: registryAuthorizer, - } - - ctxFn := func(out io.Writer, debug bool) context.Context { - if !debug { - return orascontext.Background() - } - ctx := orascontext.WithLoggerFromWriter(context.Background(), out) - orascontext.GetLogger(ctx).Logger.SetLevel(logrus.DebugLevel) - return ctx - } - - // ref https://github.com/oras-project/oras-go/blob/main/registry/remote/url.go - // buildScheme returns HTTP scheme used to access the remote registry. - buildScheme := func(plainHTTP bool) string { - if plainHTTP { - return "http" - } - return "https" - } - - // buildRepositoryBaseURL builds the base endpoint of the remote repository. - // Format: :///v2/ - //buildRepositoryBaseURL := - _ = func(plainHTTP bool, ref orasregistry.Reference) string { - return fmt.Sprintf("%s://%s/v2/%s", buildScheme(plainHTTP), ref.Host(), ref.Repository) - } - - // buildRepositoryTagListURL builds the URL for accessing the tag list API. - // Format: :///v2//tags/list - // Reference: https://docs.docker.com/registry/spec/api/#tags - buildRepositoryTagListURL := func(plainHTTP bool, ref orasregistry.Reference) string { - //return buildRepositoryBaseURL(plainHTTP, ref) + "/tags/list" - //return "https://ghcr.io/v2/_catalog" - return "https://ghcr.io/v2/" - } - - tags := func(ctx context.Context, url string) (string, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) - if err != nil { - return "", err - } - if repository.TagListPageSize > 0 { - q := req.URL.Query() - q.Set("n", strconv.Itoa(repository.TagListPageSize)) - req.URL.RawQuery = q.Encode() - } - - log.Infof("debugTags: +HTTP %s request:\nURL:\n%s\npretty print:\n%s", - req.Method, - req.URL.String(), - common.PrettyPrint(req)) - resp, err := repository.Client.Do(req) - if err != nil { - log.Infof("debugTags: -HTTP GET response: raised err=%v", err) - return "", err - } - respBody, err := ioutil.ReadAll(resp.Body) - log.Infof("debugTags: -HTTP %s response: code:%s\nbody:\n%s\nheaders:\n%s\nerr=%v", - req.Method, - resp.Status, - respBody, - resp.Header, - err) - if err != nil { - return "", err - } - defer resp.Body.Close() - - return "", nil - } - - url := buildRepositoryTagListURL(repository.PlainHTTP, repository.Reference) - tags(ctxFn(io.Discard, true), url) - - return nil -} - -// loadConfigFile reads the configuration files from the given path. -func loadConfigFile(path string) (*configfile.ConfigFile, error) { - cfg := configfile.New(path) - if _, err := os.Stat(path); err == nil { - file, err := os.Open(path) - if err != nil { - return nil, err - } - defer file.Close() - if err := cfg.LoadFromReader(file); err != nil { - return nil, err - } - } else if !os.IsNotExist(err) { - return nil, err - } - if !cfg.ContainsAuth() { - cfg.CredentialsStore = credentials.DetectDefaultStore(cfg.CredentialsStore) - } - return cfg, nil -} From 7eb2bb2dc71975cd0810e724e71812b561cc2c61 Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Mon, 20 Jun 2022 17:49:29 -0700 Subject: [PATCH 10/11] incremental --- .../fluxv2/packages/v1alpha1/common/utils.go | 4 ++-- .../v1alpha1/github_oci_repo_lister.go | 24 ++++--------------- 2 files changed, 6 insertions(+), 22 deletions(-) diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go index 1b7b5122467..f0a0ee099f0 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/utils.go @@ -359,7 +359,7 @@ func NewHttpClientAndHeaders(clientOptions *HttpClientOptions) (*http.Client, ma // I wish I could reuse the code in pkg/chart/chart.go and pkg/kube_utils/kube_utils.go // InitHTTPClient(), etc. but alas, it's all built around AppRepository CRD, which I don't have. headers := make(map[string]string) - headers["User-Agent"] = userAgentString() + headers["User-Agent"] = UserAgentString() if clientOptions != nil { if clientOptions.Username != "" && clientOptions.Password != "" { auth := clientOptions.Username + ":" + clientOptions.Password @@ -395,7 +395,7 @@ func NewHttpClientAndHeaders(clientOptions *HttpClientOptions) (*http.Client, ma } // this string is the same for all outbound calls -func userAgentString() string { +func UserAgentString() string { return fmt.Sprintf("%s/%s/%s/%s", UserAgentPrefix, pluginDetail.Name, pluginDetail.Version, version) } diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go index cf69c1c0d59..9a4607af234 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go @@ -5,16 +5,13 @@ package main import ( "context" "fmt" - "io" "io/ioutil" "net/http" - "reflect" "strings" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common" log "k8s.io/klog/v2" - "github.com/sirupsen/logrus" "helm.sh/helm/v3/pkg/registry" orascontext "oras.land/oras-go/pkg/context" orasregistry "oras.land/oras-go/pkg/registry" @@ -33,12 +30,9 @@ type gitHubRepositoryLister struct { func (l *gitHubRepositoryLister) IsApplicableFor(ociRegistry *OCIRegistry) (bool, error) { log.Infof("+IsApplicableFor(%s)", ociRegistry.url.String()) - // need to use - // https://github.com/helm/helm/blob/657850e44b880cca43d0606ebf5a54eb75362c3f/pkg/registry/client.go#L55 - //registry.Client. - log.Infof("ociRegistry.registryClient: %s", reflect.TypeOf(ociRegistry.registryClient)) + // ref https://github.com/helm/helm/blob/657850e44b880cca43d0606ebf5a54eb75362c3f/pkg/registry/client.go#L55 registryAuthorizer := ®istryauth.Client{ - Header: http.Header{"User-Agent": {"Helm/3.9.0"}}, + Header: http.Header{"User-Agent": {common.UserAgentString()}}, Cache: registryauth.DefaultCache, Credential: ociRegistry.registryCredentialFn, } @@ -65,9 +59,8 @@ func (l *gitHubRepositoryLister) IsApplicableFor(ociRegistry *OCIRegistry) (bool Client: registryAuthorizer, } - ctx := ctxFn(io.Discard, true) - ctx = withScopeHint(ctx, parsedRef, registryauth.ActionPull) - // buildRepositoryBaseURL builds the base endpoint of the remote registry. + ctx := withScopeHint(orascontext.Background(), parsedRef, registryauth.ActionPull) + // build the base endpoint of the remote registry. // Format: :///v2/ url := fmt.Sprintf("%s://%s/v2/", "https", ociRepo.Reference.Host()) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) @@ -118,15 +111,6 @@ func (l *gitHubRepositoryLister) ListRepositoryNames() ([]string, error) { return []string{"podinfo"}, nil } -func ctxFn(out io.Writer, debug bool) context.Context { - if !debug { - return orascontext.Background() - } - ctx := orascontext.WithLoggerFromWriter(context.Background(), out) - orascontext.GetLogger(ctx).Logger.SetLevel(logrus.DebugLevel) - return ctx -} - // withScopeHint adds a hinted scope to the context. func withScopeHint(ctx context.Context, ref orasregistry.Reference, actions ...string) context.Context { scope := registryauth.ScopeRepository(ref.Repository, actions...) From d314d46c753fdb0e2d24b91bd814eaa621dcd3c4 Mon Sep 17 00:00:00 2001 From: gfichtenholt Date: Tue, 21 Jun 2022 18:18:49 -0700 Subject: [PATCH 11/11] Michael's comments --- .../packages/v1alpha1/common/transport/transport.go | 7 ++++++- .../packages/v1alpha1/github_oci_repo_lister.go | 12 ++---------- .../plugins/fluxv2/packages/v1alpha1/oci_repo.go | 7 ++++++- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport.go index 8cd4ecdef30..ceabbea812e 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/common/transport/transport.go @@ -14,7 +14,12 @@ See the License for the specific language governing permissions and limitations under the License. */ -// a copy of fluxcd source-controller internal/transport/transport.go +// this is a copy of fluxcd source-controller internal/transport/transport.go +// It implements a pool of TCP connections. Allows for re-use of TCP +// connections when pulling (downloading) helm charts. Per +// official Go documentation ref https://go.dev/src/net/http/transport.go, L#68-69: +// Transports should be reused instead of created as needed. +// Transports are safe for concurrent use by multiple goroutines. package transport import ( diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go index 9a4607af234..8534b20ca7b 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/github_oci_repo_lister.go @@ -3,7 +3,6 @@ package main import ( - "context" "fmt" "io/ioutil" "net/http" @@ -59,11 +58,10 @@ func (l *gitHubRepositoryLister) IsApplicableFor(ociRegistry *OCIRegistry) (bool Client: registryAuthorizer, } - ctx := withScopeHint(orascontext.Background(), parsedRef, registryauth.ActionPull) // build the base endpoint of the remote registry. // Format: :///v2/ url := fmt.Sprintf("%s://%s/v2/", "https", ociRepo.Reference.Host()) - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(orascontext.Background(), http.MethodGet, url, nil) if err != nil { return false, err } @@ -80,7 +78,7 @@ func (l *gitHubRepositoryLister) IsApplicableFor(ociRegistry *OCIRegistry) (bool } if resp.StatusCode == http.StatusOK { - // based on the presence of this here Docker-Distribution-Api-Version:[registry/2.0] + // based on the presence of this header Docker-Distribution-Api-Version:[registry/2.0] // conclude this is a case for GitHubRepositoryLister, e.g. // +HTTP GET request: // URL: https://ghcr.io/v2/ @@ -110,9 +108,3 @@ func (l *gitHubRepositoryLister) ListRepositoryNames() ([]string, error) { // TODO (gfichtenholt) fix me return []string{"podinfo"}, nil } - -// withScopeHint adds a hinted scope to the context. -func withScopeHint(ctx context.Context, ref orasregistry.Reference, actions ...string) context.Context { - scope := registryauth.ScopeRepository(ref.Repository, actions...) - return registryauth.AppendScopes(ctx, scope) -} diff --git a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go index a72147946d8..0232fe6832c 100644 --- a/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go +++ b/cmd/kubeapps-apis/plugins/fluxv2/packages/v1alpha1/oci_repo.go @@ -41,6 +41,8 @@ import ( "github.com/fluxcd/pkg/version" sourcev1 "github.com/fluxcd/source-controller/api/v1beta2" + + // OCI Registry As a Storage (ORAS) registryauth "oras.land/oras-go/pkg/registry/remote/auth" ) @@ -78,7 +80,10 @@ type OCIRegistry struct { // registryClient is a client to use while downloading tags or charts from a registry. registryClient RegistryClient - // a workaround for the fact that I don't have access to internals of RegistryClient + // The set of public operations one can use w.r.t. RegistryClient is very small + // (Login/Logout/Tags). I need to be able to query remote OCI repo for ListRepositoryNames(), + // which is not in the set and all fields of RegistryClient, + // including repositoryAuthorizer are internal, so this is a workaround registryCredentialFn OCIRegistryCredentialFn repositoryLister OCIRepositoryLister