diff --git a/chart/kubeapps/README.md b/chart/kubeapps/README.md index 786c3824a95..2a708403ef6 100644 --- a/chart/kubeapps/README.md +++ b/chart/kubeapps/README.md @@ -1,4 +1,3 @@ - # Bitnami package for Kubeapps Kubeapps is a web-based UI for launching and managing applications on Kubernetes. It allows users to deploy trusted applications and operators to control users access to the cluster. @@ -1144,4 +1143,4 @@ 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. \ No newline at end of file +limitations under the License. diff --git a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/repositories_test.go b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/repositories_test.go index 072c5fbd317..82ebbb1aad8 100644 --- a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/repositories_test.go +++ b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/repositories_test.go @@ -808,7 +808,7 @@ func TestAddPackageRepository(t *testing.T) { if tc.existingDockerSecret != nil { secrets = append(secrets, tc.existingDockerSecret) } - s := newServerWithSecretsAndRepos(t, secrets, nil, nil) + s := newServerWithSecretsAndRepos(t, secrets, nil) if tc.testRepoServer != nil { defer tc.testRepoServer.Close() s.repoClientGetter = func(_ *appRepov1.AppRepository, _ *apiv1.Secret) (*http.Client, error) { @@ -1194,22 +1194,12 @@ func TestGetPackageRepositoryDetail(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - var unstructuredObjects []k8sruntime.Object - for _, obj := range []*appRepov1alpha1.AppRepository{repo1, repo2, repo3, repo4, repo5} { - repository := obj - if tc.repositoryCustomizer != nil { - repository = tc.repositoryCustomizer(obj) - } - unstructuredContent, _ := k8sruntime.DefaultUnstructuredConverter.ToUnstructured(repository) - unstructuredObjects = append(unstructuredObjects, &unstructured.Unstructured{Object: unstructuredContent}) - } - var secrets []k8sruntime.Object if tc.existingSecret != nil { secrets = append(secrets, tc.existingSecret) } - s := newServerWithSecretsAndRepos(t, secrets, unstructuredObjects, nil) + s := newServerWithSecretsAndRepos(t, secrets, []*appRepov1alpha1.AppRepository{repo1, repo2, repo3, repo4, repo5}) actualResponse, err := s.GetPackageRepositoryDetail(context.Background(), connect.NewRequest(tc.request)) @@ -1935,12 +1925,6 @@ func TestUpdatePackageRepository(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - var unstructuredObjects []k8sruntime.Object - for _, obj := range repos { - unstructuredContent, _ := k8sruntime.DefaultUnstructuredConverter.ToUnstructured(obj) - unstructuredObjects = append(unstructuredObjects, &unstructured.Unstructured{Object: unstructuredContent}) - } - var secrets []k8sruntime.Object if tc.existingAuthSecret != nil { secrets = append(secrets, tc.existingAuthSecret) @@ -1949,7 +1933,7 @@ func TestUpdatePackageRepository(t *testing.T) { secrets = append(secrets, tc.existingDockerSecret) } - s := newServerWithSecretsAndRepos(t, secrets, unstructuredObjects, repos) + s := newServerWithSecretsAndRepos(t, secrets, repos) request := tc.requestCustomizer(commonRequest()) response, err := s.UpdatePackageRepository(context.Background(), connect.NewRequest(request)) @@ -2067,13 +2051,7 @@ func TestDeletePackageRepository(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - var unstructuredObjects []k8sruntime.Object - for _, obj := range repos { - unstructuredContent, _ := k8sruntime.DefaultUnstructuredConverter.ToUnstructured(obj) - unstructuredObjects = append(unstructuredObjects, &unstructured.Unstructured{Object: unstructuredContent}) - } - - s := newServerWithSecretsAndRepos(t, nil, unstructuredObjects, repos) + s := newServerWithSecretsAndRepos(t, nil, repos) _, err := s.DeletePackageRepository(context.Background(), connect.NewRequest(tc.request)) diff --git a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go index f72aba435c4..afb52de720e 100644 --- a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go +++ b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server.go @@ -15,6 +15,7 @@ import ( "strings" "github.com/bufbuild/connect-go" + imageSpecv1 "github.com/opencontainers/image-spec/specs-go/v1" appRepov1 "github.com/vmware-tanzu/kubeapps/cmd/apprepository-controller/pkg/apis/apprepository/v1alpha1" "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/core" corev1 "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/core/packages/v1alpha1" @@ -44,6 +45,7 @@ import ( "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/kubernetes" log "k8s.io/klog/v2" + "oras.land/oras-go/v2/registry/remote" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -1268,5 +1270,67 @@ func (s *Server) DeletePackageRepository(ctx context.Context, request *connect.R } func (s *Server) GetAvailablePackageMetadatas(ctx context.Context, request *connect.Request[corev1.GetAvailablePackageMetadatasRequest]) (*connect.Response[corev1.GetAvailablePackageMetadatasResponse], error) { - return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("Unimplemented")) + chartID := request.Msg.GetAvailablePackageRef().GetIdentifier() + repoNamespace := request.Msg.GetAvailablePackageRef().GetContext().GetNamespace() + repoName, chartName, err := pkgutils.SplitPackageIdentifier(chartID) + if err != nil { + return nil, connect.NewError(connect.CodeInvalidArgument, err) + } + + // TODO: Ignoring secrets for now, just demoing with public OCI repo + appRepo, _, _, _, err := s.getAppRepoAndRelatedSecrets(ctx, request.Header(), s.globalPackagingCluster, repoName, repoNamespace) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Unable to fetch app repo %q from namespace %q: %v", repoName, repoNamespace, err)) + } + if appRepo.Spec.Type != OCIRepoType { + return nil, connect.NewError(connect.CodeUnimplemented, fmt.Errorf("Unimplemented for non-OCI repositories")) + } + + repoURL := appRepo.Spec.URL + repoURL = strings.TrimPrefix(repoURL, "oci://") + repoURL = strings.TrimPrefix(repoURL, "https://") + plainHTTP := false + if strings.HasPrefix(repoURL, "http://") { + plainHTTP = true + repoURL = strings.TrimPrefix(repoURL, "http://") + } + + tag := path.Join(repoURL, chartName) + fmt.Printf("\ntag: %+v", tag) + repo, err := remote.NewRepository(tag) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Unable to call NewRepository with %q: %v", tag, err)) + } + repo.PlainHTTP = plainHTTP + + // Create the ocispec.Descriptor, first need to get the manifest of the chart. + descriptor, _, err := repo.Manifests().FetchReference(ctx, request.Msg.GetPkgVersion()) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Unable to fetch manifest for %v with ref %q: %v", repo, request.Msg.GetPkgVersion(), err)) + } + + referrers := []imageSpecv1.Descriptor{} + err = repo.Referrers(ctx, descriptor, "", func(r []imageSpecv1.Descriptor) error { + referrers = append(referrers, r...) + return nil + }) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("Unable to fetch referrers for %v with ref %q: %v", repo, request.Msg.GetPkgVersion(), err)) + } + + package_metadatas := []*corev1.PackageMetadata{} + for _, referrer := range referrers { + package_metadatas = append(package_metadatas, &corev1.PackageMetadata{ + Name: referrer.Annotations["org.opencontainers.image.title"], + Description: referrer.Annotations["org.opencontainers.image.description"], + MediaType: referrer.MediaType, + ArtifactType: referrer.ArtifactType, + Digest: referrer.Digest.String(), + }) + } + + return connect.NewResponse(&corev1.GetAvailablePackageMetadatasResponse{ + AvailablePackageRef: request.Msg.AvailablePackageRef, + PackageMetadata: package_metadatas, + }), nil } diff --git a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server_test.go b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server_test.go index 4d75eaef31c..e90f2240e98 100644 --- a/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server_test.go +++ b/cmd/kubeapps-apis/plugins/helm/packages/v1alpha1/server_test.go @@ -9,8 +9,10 @@ import ( "fmt" "io" "net/http" + "net/http/httptest" "net/url" "os" + "path" "regexp" "runtime" "sort" @@ -25,6 +27,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/vmware-tanzu/kubeapps/cmd/apprepository-controller/pkg/apis/apprepository/v1alpha1" + appRepov1alpha1 "github.com/vmware-tanzu/kubeapps/cmd/apprepository-controller/pkg/apis/apprepository/v1alpha1" corev1 "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/core/packages/v1alpha1" plugins "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/core/plugins/v1alpha1" helmv1 "github.com/vmware-tanzu/kubeapps/cmd/kubeapps-apis/gen/plugins/helm/packages/v1alpha1" @@ -49,6 +52,7 @@ import ( authorizationv1 "k8s.io/api/authorization/v1" apiextfake "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset/fake" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" k8sruntime "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apiserver/pkg/storage/names" @@ -268,9 +272,15 @@ func makeServer(t *testing.T, authorized bool, actionConfig *action.Configuratio }, mock, cleanup } -func newServerWithSecretsAndRepos(t *testing.T, secrets []k8sruntime.Object, unstructuredObjs []k8sruntime.Object, repos []*v1alpha1.AppRepository) *Server { +func newServerWithSecretsAndRepos(t *testing.T, secrets []k8sruntime.Object, repos []*v1alpha1.AppRepository) *Server { typedClient := typfake.NewSimpleClientset(secrets...) + var unstructuredObjs []k8sruntime.Object + for _, obj := range repos { + unstructuredContent, _ := k8sruntime.DefaultUnstructuredConverter.ToUnstructured(obj) + unstructuredObjs = append(unstructuredObjs, &unstructured.Unstructured{Object: unstructuredContent}) + } + // ref https://stackoverflow.com/questions/68794562/kubernetes-fake-client-doesnt-handle-generatename-in-objectmeta/68794563#68794563 typedClient.PrependReactor( "create", "*", @@ -2240,3 +2250,204 @@ type releaseStub struct { status release.Status manifest string } + +func newFakeOCIServer(t *testing.T, responses map[string]*http.Response) *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + for path, response := range responses { + if path == r.URL.Path { + if response.Header != nil { + for k, vs := range response.Header { + for _, v := range vs { + w.Header().Set(k, v) + } + } + } + w.WriteHeader(response.StatusCode) + body := []byte{} + if response.Body != nil { + var err error + body, err = io.ReadAll(response.Body) + if err != nil { + t.Fatalf("%+v", err) + } + } + _, err := w.Write(body) + if err != nil { + t.Fatalf("%+v", err) + } + return + } + } + t.Errorf("unhandled request: %+v", r) + w.WriteHeader(404) + })) +} + +const CHART_MANIFEST = `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","artifactType":"application/vnd.cncf.helm.config.v1","config":{"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2,"data":"e30="},"layers":[{"mediaType":"application/vnd.oci.image.manifest.v1","digest":"sha256:04e8ea5960143960a55e3cdaf968689593069d726cd1c4bc22e3cc0c40e6a20f","size":1790,"annotations":{"org.opencontainers.image.title":"simplechart-0.1.0.tgz"}}],"annotations":{"org.opencontainers.image.created":"2023-11-24T00:22:24Z"}}` + +const CHART_MANIFEST_SHA256 = "sha256:d7e6636cbed61ef760c404e089d21e766b519355b5cb20bd3bd888d2719e5943" + +const CHART_REFERERRS_CONTENT = `{"schemaVersion":2,"mediaType":"application/vnd.oci.image.index.v1+json","manifests":[{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:c3d036b5b676fc150a7f079030aa7aa4b0a4ed218354dbf1236e792f9e56e6af","size":827,"annotations":{"org.opencontainers.image.created":"2023-11-24T00:22:30Z","org.opencontainers.image.description":"A description of the scan results"},"artifactType":"application/vnd.oci.empty.v1+json"},{"mediaType":"application/vnd.oci.image.manifest.v1+json","digest":"sha256:07b56b5474aebda7af44d379d5c61123906efb2faa62e4ac173abdab8be019bc","size":842,"annotations":{"org.opencontainers.image.created":"2023-11-24T00:22:35Z","org.opencontainers.image.title":"SBOM stuff"},"artifactType":"application/vnd.oci.empty.v1+json"}]}` + +func TestGetAvailablePackageMetadatas(t *testing.T) { + fakeServer := newFakeOCIServer(t, map[string]*http.Response{ + // The ORAS client needs to be able to get the manifest + // based on the (version) tag: + "/v2/chart-name/manifests/1.2.3": { + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(CHART_MANIFEST)), + }, + // The ORAS client also needs to get the referrers based + // on the manifest's sha256 + path.Join("/v2/chart-name/referrers", CHART_MANIFEST_SHA256): { + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(CHART_REFERERRS_CONTENT)), + }, + }) + defer fakeServer.Close() + + var repoNonOCI = &appRepov1alpha1.AppRepository{ + TypeMeta: metav1.TypeMeta{ + APIVersion: appReposAPIVersion, + Kind: AppRepositoryKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "repo-non-oci", + Namespace: "kubeapps", + ResourceVersion: "1", + }, + Spec: appRepov1alpha1.AppRepositorySpec{ + URL: "https://test-repo", + Type: "helm", + Description: "description 1", + }, + } + var repoOCI = &appRepov1alpha1.AppRepository{ + TypeMeta: metav1.TypeMeta{ + APIVersion: appReposAPIVersion, + Kind: AppRepositoryKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "repo-oci", + Namespace: "kubeapps", + ResourceVersion: "1", + }, + Spec: appRepov1alpha1.AppRepositorySpec{ + URL: fakeServer.URL, + Type: "oci", + Description: "description 1", + }, + } + + opts := cmpopts.IgnoreUnexported( + corev1.Context{}, + corev1.GetAvailablePackageMetadatasResponse{}, + corev1.AvailablePackageReference{}, + corev1.PackageMetadata{}, + ) + + testCases := []struct { + name string + repos []*appRepov1alpha1.AppRepository + request *corev1.GetAvailablePackageMetadatasRequest + expectedResponse *corev1.GetAvailablePackageMetadatasResponse + expectedResponseCode connect.Code + }{ + { + name: "it returns invalid for an invalid identifier", + repos: []*appRepov1alpha1.AppRepository{ + repoNonOCI, + }, + request: &corev1.GetAvailablePackageMetadatasRequest{ + AvailablePackageRef: &corev1.AvailablePackageReference{ + Context: &corev1.Context{ + Cluster: "default", + Namespace: "kubeapps", + }, + Identifier: "not-a-valid-identifier", + }, + }, + expectedResponseCode: connect.CodeInvalidArgument, + expectedResponse: nil, + }, + { + name: "it returns unimplemented for non-OCI repositories", + repos: []*appRepov1alpha1.AppRepository{ + repoNonOCI, + }, + request: &corev1.GetAvailablePackageMetadatasRequest{ + AvailablePackageRef: &corev1.AvailablePackageReference{ + Context: &corev1.Context{ + Cluster: "default", + Namespace: "kubeapps", + }, + Identifier: "repo-non-oci/chart-name", + }, + }, + expectedResponseCode: connect.CodeUnimplemented, + expectedResponse: nil, + }, + { + name: "it returns metadata for OCI repositories", + repos: []*appRepov1alpha1.AppRepository{ + repoOCI, + }, + request: &corev1.GetAvailablePackageMetadatasRequest{ + AvailablePackageRef: &corev1.AvailablePackageReference{ + Context: &corev1.Context{ + Cluster: "default", + Namespace: "kubeapps", + }, + Identifier: "repo-oci/chart-name", + }, + PkgVersion: "1.2.3", + }, + expectedResponseCode: 0, + expectedResponse: &corev1.GetAvailablePackageMetadatasResponse{ + AvailablePackageRef: &corev1.AvailablePackageReference{ + Context: &corev1.Context{ + Cluster: "default", + Namespace: "kubeapps", + }, + Identifier: "repo-oci/chart-name", + }, + PackageMetadata: []*corev1.PackageMetadata{ + { + MediaType: "application/vnd.oci.image.manifest.v1+json", + ArtifactType: "application/vnd.oci.empty.v1+json", + Digest: "sha256:c3d036b5b676fc150a7f079030aa7aa4b0a4ed218354dbf1236e792f9e56e6af", + Description: "A description of the scan results", + }, + { + Name: "SBOM stuff", + MediaType: "application/vnd.oci.image.manifest.v1+json", + ArtifactType: "application/vnd.oci.empty.v1+json", + Digest: "sha256:07b56b5474aebda7af44d379d5c61123906efb2faa62e4ac173abdab8be019bc", + }, + }, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + server := newServerWithSecretsAndRepos(t, nil, tc.repos) + + response, err := server.GetAvailablePackageMetadatas(context.Background(), connect.NewRequest(tc.request)) + + if tc.expectedResponseCode != 0 { + if got, want := connect.CodeOf(err), tc.expectedResponseCode; got != want { + t.Fatalf("got: %+v, want: %+v. Err: %+v", got, want, err) + } + return + } + if err != nil { + t.Fatalf("got: %+v, want: nil", err) + } + + if got, want := response.Msg, tc.expectedResponse; !cmp.Equal(want, got, opts) { + t.Errorf("response mismatch (-want +got):\n%s", cmp.Diff(want, got, opts)) + } + }) + } +} diff --git a/site/content/docs/latest/reference/scripts/setup-demo-harbor-oci-metadata.sh b/site/content/docs/latest/reference/scripts/setup-demo-harbor-oci-metadata.sh new file mode 100755 index 00000000000..acd8bec59b5 --- /dev/null +++ b/site/content/docs/latest/reference/scripts/setup-demo-harbor-oci-metadata.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash + +# Copyright 2023 the Kubeapps contributors. +# SPDX-License-Identifier: Apache-2.0 + +set -o errexit +set -o nounset +set -o pipefail + +# Constants +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../../../../.." >/dev/null && pwd)" +RESET='\033[0m' +GREEN='\033[38;5;2m' +RED='\033[38;5;1m' +YELLOW='\033[38;5;3m' + +VERSION="0.1.0" +IMAGE="demo.goharbor.io/kubeapps-test/simplechart:${VERSION}" + +# shellcheck disable=SC1090 +. "${ROOT_DIR}/script/lib/liblog.sh" + +info "Setting up kubeapps project on demo.goharbor with metadata referers..." + +# Check if project exists - if not, create it. +http_code=$(curl --head https://demo.goharbor.io/api/v2.0/projects\?project_name\=kubeapps-test -u "${DEMO_USERNAME}:${DEMO_PASSWORD}" -o /dev/null -sSLw "%{http_code}") + +if [[ "${http_code}" -eq 404 ]] ; then + info "'kubeapps-test' project does not exist yet, creating..." + curl \ + 'https://demo.goharbor.io/api/v2.0/projects' \ + -u "${DEMO_USERNAME}:${DEMO_PASSWORD}" \ + -H 'accept: application/json' \ + -H 'X-Resource-Name-In-Location: false' \ + -H 'Content-Type: application/json' \ + -d '{ + "project_name": "kubeapps-test", + "public": true, + "metadata": { + "public": "true", + "enable_content_trust": "string", + "enable_content_trust_cosign": "string", + "prevent_vul": "string", + "severity": "string", + "auto_scan": "string", + "reuse_sys_cve_allowlist": "string", + "retention_id": "" + } + }' +elif [[ "${http_code}" -eq 200 ]] ; then + info "Project kubeapps-test already exists." +else + error "Unexpected http code: ${http_code}. Exiting" +fi + +info "Creating and pushing chart to kubeapps-test project..." +helm package ./integration/charts/simplechart +echo ${DEMO_PASSWORD} | oras login --username ${DEMO_USERNAME} --password-stdin demo.goharbor.io + +oras push --artifact-type "application/vnd.cncf.helm.config.v1" --export-manifest "./chart-manifest.json" ${IMAGE} "simplechart-0.1.0.tgz:application/vnd.oci.image.manifest.v1" +# helm push ./simplechart-${VERSION}.tgz oci://demo.goharbor.io/kubeapps-test + +info "Attaching super-important-meta with chart..." +echo '{"artifact": "'${IMAGE}'", "signature": "trust me"}' > signature.json +oras attach --export-manifest "./signature-manifest.json" ${IMAGE} --artifact-type "application/vnd.example.signature.v1" "signature.json:application/json" + +echo '{"artifact": "'${IMAGE}'", "sbom": "lots of materials"}' > sbom.json +oras attach --export-manifest "./sbom-manifest.json" --artifact-type "application/vnd.example.sbom.v1" ${IMAGE} "sbom.json:application/vnd.oci.image.manifest.v1" + + +info "Listing referrers of chart..." +oras discover -o tree ${IMAGE}