From 14b5d2b1a0a246b4306406c23fc665e1fdc5221d Mon Sep 17 00:00:00 2001 From: "Sergio C. Arteaga" Date: Wed, 12 Jan 2022 10:28:47 +0100 Subject: [PATCH] Add experimental support for containers images (#1777) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #1685 Signed-off-by: Sergio Castaño Arteaga Signed-off-by: Cintia Sanchez Garcia Co-authored-by: Sergio Castaño Arteaga Co-authored-by: Cintia Sanchez Garcia --- .goreleaser.yml | 12 + README.md | 1 + charts/artifact-hub/Chart.yaml | 3 +- .../templates/tracker_secret.yaml | 3 + charts/artifact-hub/values.schema.json | 2 +- configs/tracker.yaml | 3 + .../functions/001_load_functions.sql | 1 + .../functions/packages/get_package.sql | 8 +- .../functions/packages/is_latest.sql | 21 + .../functions/packages/register_package.sql | 65 ++- .../functions/repositories/add_repository.sql | 2 + .../repositories/get_repository_by_id.sql | 1 + .../repositories/update_repository.sql | 3 +- .../schema/035_containers_images.sql | 9 + .../tests/functions/packages/get_package.sql | 36 +- .../tests/functions/packages/is_latest.sql | 29 ++ .../functions/repositories/add_repository.sql | 6 + .../repositories/get_repository_by_id.sql | 12 +- .../repositories/update_repository.sql | 26 +- database/tests/schema/schema.sql | 8 +- docs/api/openapi.yaml | 86 ++++ docs/repositories.md | 75 +++ internal/handlers/handlers.go | 4 +- internal/handlers/pkg/handlers.go | 12 +- internal/handlers/pkg/handlers_test.go | 19 +- internal/hub/external.go | 2 +- internal/hub/repo.go | 63 ++- internal/hub/tracker.go | 6 + .../template/new_release_email.tmpl | 8 +- internal/oci/mock.go | 4 +- internal/oci/oci.go | 25 +- internal/pkg/manager.go | 22 +- internal/pkg/manager_test.go | 31 +- internal/repo/manager.go | 39 +- internal/repo/manager_test.go | 93 +++- internal/tracker/helpers.go | 3 + .../tracker/source/container/container.go | 415 +++++++++++++++ .../source/container/container_test.go | 9 + internal/tracker/source/helm/helm.go | 2 +- internal/tracker/source/helm/helm_test.go | 8 +- internal/tracker/tracker.go | 8 +- internal/tracker/tracker_test.go | 25 +- scripts/docker-build.sh | 57 ++- web/package.json | 1 + web/public/static/media/container-light.svg | 10 + web/public/static/media/container.svg | 10 + web/public/static/media/container_icon.png | Bin 0 -> 47926 bytes .../media/placeholder_pkg_container.png | Bin 0 -> 127002 bytes web/public/static/media/registries/amazon.svg | 38 ++ web/public/static/media/registries/azure.svg | 1 + .../static/media/registries/bundlebar.svg | 18 + web/public/static/media/registries/docker.svg | 9 + web/public/static/media/registries/github.svg | 3 + web/public/static/media/registries/google.svg | 11 + web/public/static/media/registries/quay.svg | 11 + .../static/media/registries/unknown.svg | 2 + web/src/layout/common/Image.tsx | 2 + web/src/layout/common/InputField.tsx | 3 +- web/src/layout/common/PackageInfo.tsx | 5 +- web/src/layout/common/RepositoryIcon.test.tsx | 7 + web/src/layout/common/RepositoryIcon.tsx | 4 + .../__snapshots__/InputField.test.tsx.snap | 2 +- .../members/__snapshots__/Modal.test.tsx.snap | 2 +- .../__snapshots__/Form.test.tsx.snap | 6 +- .../__snapshots__/Modal.test.tsx.snap | 6 +- .../repositories/Modal.module.css | 8 + .../controlPanel/repositories/Modal.test.tsx | 2 +- .../controlPanel/repositories/Modal.tsx | 174 ++++++- .../repositories/TagsList.module.css | 36 ++ .../controlPanel/repositories/TagsList.tsx | 141 ++++++ .../__snapshots__/DeletionModal.test.tsx.snap | 2 +- .../__snapshots__/Modal.test.tsx.snap | 14 +- .../__snapshots__/UpdateOrg.test.tsx.snap | 6 +- .../apiKeys/__snapshots__/Modal.test.tsx.snap | 2 +- .../UpdatePassword.test.tsx.snap | 6 +- .../__snapshots__/UpdateProfile.test.tsx.snap | 8 +- .../profile/__snapshots__/index.test.tsx.snap | 8 +- .../webhooks/__snapshots__/Form.test.tsx.snap | 10 +- .../home/__snapshots__/index.test.tsx.snap | 36 +- web/src/layout/home/index.test.tsx | 5 +- web/src/layout/home/index.tsx | 18 +- .../CreateAnAccount.test.tsx.snap | 12 +- .../__snapshots__/LogIn.test.tsx.snap | 4 +- .../__snapshots__/ResetPassword.test.tsx.snap | 2 +- .../ContainerAlternativeLocations.test.tsx | 55 ++ .../package/ContainerAlternativeLocations.tsx | 27 + .../package/ContainerRegistry.module.css | 13 + .../layout/package/ContainerRegistry.test.tsx | 26 + web/src/layout/package/ContainerRegistry.tsx | 115 +++++ .../layout/package/ContainersImages.test.tsx | 11 +- web/src/layout/package/ContainersImages.tsx | 13 +- web/src/layout/package/Details.tsx | 52 +- .../layout/package/Last30DaysViews.test.tsx | 27 +- web/src/layout/package/Last30DaysViews.tsx | 17 +- .../layout/package/PackageViewsStats.test.tsx | 2 + web/src/layout/package/PackageViewsStats.tsx | 11 +- web/src/layout/package/Platforms.test.tsx | 5 +- web/src/layout/package/Platforms.tsx | 3 +- .../__fixtures__/ContainersImages/4.json | 202 ++++++++ .../layout/package/__fixtures__/index/21.json | 22 + ...ontainerAlternativeLocations.test.tsx.snap | 474 ++++++++++++++++++ .../ContainerRegistry.test.tsx.snap | 58 +++ .../layout/package/changelog/Modal.test.tsx | 2 +- web/src/layout/package/changelog/Modal.tsx | 6 +- web/src/layout/package/index.tsx | 6 +- .../layout/package/securityReport/Modal.tsx | 9 +- .../package/securityReport/Summary.test.tsx | 24 +- .../layout/package/securityReport/Summary.tsx | 7 +- .../__snapshots__/Summary.test.tsx.snap | 6 +- .../package/securityReport/index.test.tsx | 2 + .../layout/package/securityReport/index.tsx | 10 +- web/src/themes/dark.scss | 4 + web/src/types.ts | 11 + web/src/utils/data.tsx | 12 + web/src/utils/history.ts | 7 +- web/src/utils/repoKind.test.tsx | 8 + web/src/utils/repoKind.ts | 4 + web/src/utils/userNotificationsDispatcher.ts | 6 +- widget/src/layout/Widget.tsx | 6 +- widget/src/layout/common/Image.test.tsx | 7 + widget/src/layout/common/Image.tsx | 2 + widget/src/layout/common/RepositoryIcon.tsx | 1 + .../src/layout/common/RepositoryIconLabel.tsx | 4 + widget/src/layout/common/SVGIcons.test.tsx | 5 + widget/src/layout/common/SVGIcons.tsx | 12 + widget/src/types.ts | 1 + 126 files changed, 2934 insertions(+), 273 deletions(-) create mode 100644 database/migrations/functions/packages/is_latest.sql create mode 100644 database/migrations/schema/035_containers_images.sql create mode 100644 database/tests/functions/packages/is_latest.sql create mode 100644 internal/tracker/source/container/container.go create mode 100644 internal/tracker/source/container/container_test.go create mode 100644 web/public/static/media/container-light.svg create mode 100644 web/public/static/media/container.svg create mode 100644 web/public/static/media/container_icon.png create mode 100644 web/public/static/media/placeholder_pkg_container.png create mode 100644 web/public/static/media/registries/amazon.svg create mode 100644 web/public/static/media/registries/azure.svg create mode 100644 web/public/static/media/registries/bundlebar.svg create mode 100644 web/public/static/media/registries/docker.svg create mode 100644 web/public/static/media/registries/github.svg create mode 100644 web/public/static/media/registries/google.svg create mode 100644 web/public/static/media/registries/quay.svg create mode 100644 web/public/static/media/registries/unknown.svg create mode 100644 web/src/layout/controlPanel/repositories/TagsList.module.css create mode 100644 web/src/layout/controlPanel/repositories/TagsList.tsx create mode 100644 web/src/layout/package/ContainerAlternativeLocations.test.tsx create mode 100644 web/src/layout/package/ContainerAlternativeLocations.tsx create mode 100644 web/src/layout/package/ContainerRegistry.module.css create mode 100644 web/src/layout/package/ContainerRegistry.test.tsx create mode 100644 web/src/layout/package/ContainerRegistry.tsx create mode 100644 web/src/layout/package/__fixtures__/ContainersImages/4.json create mode 100644 web/src/layout/package/__fixtures__/index/21.json create mode 100644 web/src/layout/package/__snapshots__/ContainerAlternativeLocations.test.tsx.snap create mode 100644 web/src/layout/package/__snapshots__/ContainerRegistry.test.tsx.snap diff --git a/.goreleaser.yml b/.goreleaser.yml index a06804360..f8b4aa460 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -49,6 +49,18 @@ dockers: build_flag_templates: - "--build-arg=VERSION={{ .Version }}" - "--build-arg=GIT_COMMIT={{ .FullCommit }}" + - "--label=org.opencontainers.image.description='Artifact Hub command line tool'" + - "--label=org.opencontainers.image.version='{{ .Version }}'" + - "--label=org.opencontainers.image.created='{{ .CommitDate }}'" + - "--label=org.opencontainers.image.documentation='https://artifacthub.io/docs/topics/cli'" + - "--label=org.opencontainers.image.source='https://github.com/artifacthub/hub/tree/{{ .FullCommit }}/cmd/ah'" + - "--label=org.opencontainers.image.vendor='Artifact Hub'" + - "--label=io.artifacthub.package.readmeURL='https://raw.githubusercontent.com/artifacthub/hub/{{ .FullCommit }}/docs/cli.md'" + - '--label=io.artifacthub.package.maintainers=''[{"name":"Artifact Hub maintainers","email":"cncf-artifacthub-maintainers@lists.cncf.io"}]''' + - "--label=io.artifacthub.package.logoURL='https://raw.githubusercontent.com/artifacthub/hub/master/docs/logo/logo.svg'" + - "--label=io.artifacthub.package.keywords='artifact hub,cli,lint'" + - "--label=io.artifacthub.package.license='Apache-2.0'" + - "--label=io.artifacthub.package.alternativeLocations='public.ecr.aws/artifacthub/ah:{{ .Tag }}'" extra_files: - go.mod - go.sum diff --git a/README.md b/README.md index b063aad2f..a6e840789 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ Discovering artifacts to use with CNCF projects can be difficult. If every CNCF At the moment, the following artifacts kinds are supported *(with plans to support more projects to follow)*: +- [Containers images](https://opencontainers.org) - [CoreDNS plugins](https://coredns.io/) - [Falco configurations](https://falco.org/) - [Helm charts](https://helm.sh/) diff --git a/charts/artifact-hub/Chart.yaml b/charts/artifact-hub/Chart.yaml index 0f9c8489f..b51d689ef 100644 --- a/charts/artifact-hub/Chart.yaml +++ b/charts/artifact-hub/Chart.yaml @@ -2,7 +2,7 @@ apiVersion: v2 name: artifact-hub description: Artifact Hub is a web-based application that enables finding, installing, and publishing Kubernetes packages. type: application -version: 1.5.1-1 +version: 1.5.1-3 appVersion: 1.5.0 kubeVersion: ">= 1.19.0-0" home: https://artifacthub.io @@ -19,6 +19,7 @@ keywords: - keda scalers - coredns - keptn + - containers images maintainers: - name: Sergio email: tegioz@icloud.com diff --git a/charts/artifact-hub/templates/tracker_secret.yaml b/charts/artifact-hub/templates/tracker_secret.yaml index 5e85f68b5..6e1418128 100644 --- a/charts/artifact-hub/templates/tracker_secret.yaml +++ b/charts/artifact-hub/templates/tracker_secret.yaml @@ -15,6 +15,9 @@ stringData: database: {{ .Values.db.database }} user: {{ .Values.db.user }} password: {{ .Values.db.password }} + creds: + dockerUsername: {{ .Values.creds.dockerUsername }} + dockerPassword: {{ .Values.creds.dockerPassword }} images: store: {{ .Values.images.store }} events: diff --git a/charts/artifact-hub/values.schema.json b/charts/artifact-hub/values.schema.json index 000b7a898..31f0dfa3b 100644 --- a/charts/artifact-hub/values.schema.json +++ b/charts/artifact-hub/values.schema.json @@ -775,7 +775,7 @@ }, "repositoriesKinds": { "title": "Repositories kinds to process ([] = all)", - "description": "The following kinds are supported at the moment: falco, helm, olm, opa, tbaction, krew, helm-plugin, tekton-task, keda-scaler, coredns, keptn, tekton-pipeline", + "description": "The following kinds are supported at the moment: falco, helm, olm, opa, tbaction, krew, helm-plugin, tekton-task, keda-scaler, coredns, keptn, tekton-pipeline, container", "type": "array", "items": { "type": "string" diff --git a/configs/tracker.yaml b/configs/tracker.yaml index 0ea8df605..cade0b137 100644 --- a/configs/tracker.yaml +++ b/configs/tracker.yaml @@ -7,6 +7,9 @@ db: port: "5432" database: hub user: postgres +creds: + dockerUsername: "" + dockerPassword: "" tracker: concurrency: 10 repositoriesNames: [] diff --git a/database/migrations/functions/001_load_functions.sql b/database/migrations/functions/001_load_functions.sql index c17e4ef49..650b608b3 100644 --- a/database/migrations/functions/001_load_functions.sql +++ b/database/migrations/functions/001_load_functions.sql @@ -47,6 +47,7 @@ {{ template "packages/get_production_usage.sql" }} {{ template "packages/get_random_packages.sql" }} {{ template "packages/get_snapshots_to_scan.sql" }} +{{ template "packages/is_latest.sql" }} {{ template "packages/register_package.sql" }} {{ template "packages/search_packages.sql" }} {{ template "packages/search_packages_monocular.sql" }} diff --git a/database/migrations/functions/packages/get_package.sql b/database/migrations/functions/packages/get_package.sql index e487eb04e..8737f9170 100644 --- a/database/migrations/functions/packages/get_package.sql +++ b/database/migrations/functions/packages/get_package.sql @@ -55,8 +55,12 @@ begin 'prerelease', prerelease, 'ts', floor(extract(epoch from ts)) )) - from snapshot - where package_id = v_package_id + from ( + select * + from snapshot + where package_id = v_package_id + order by ts desc + ) s ), 'app_version', s.app_version, 'digest', s.digest, diff --git a/database/migrations/functions/packages/is_latest.sql b/database/migrations/functions/packages/is_latest.sql new file mode 100644 index 000000000..30fd7de67 --- /dev/null +++ b/database/migrations/functions/packages/is_latest.sql @@ -0,0 +1,21 @@ +-- is_latest checks if the package version we are trying to register is the +-- latest or not. For repositories of container image kind, we check the latest +-- version timestamp. For the other kinds, we check the latest version, which +-- must be a valid semver. +create or replace function is_latest( + p_kind integer, + p_version text, + p_previous_latest_version text, + p_ts timestamptz, + p_previous_latest_version_ts timestamptz +) +returns boolean as $$ +begin + case p_kind + when 12 then -- Container image + return p_ts >= p_previous_latest_version_ts; + else -- Any other kind + return semver_gte(p_version, p_previous_latest_version); + end case; +end +$$ language plpgsql; diff --git a/database/migrations/functions/packages/register_package.sql b/database/migrations/functions/packages/register_package.sql index 23b2fd099..703b7ce5a 100644 --- a/database/migrations/functions/packages/register_package.sql +++ b/database/migrations/functions/packages/register_package.sql @@ -6,26 +6,36 @@ create or replace function register_package(p_pkg jsonb) returns void as $$ declare - v_previous_latest_version text; - v_package_id uuid; v_name text := p_pkg->>'name'; v_display_name text := nullif(p_pkg->>'display_name', ''); v_description text := nullif(p_pkg->>'description', ''); v_keywords text[] := (select nullif(array(select jsonb_array_elements_text(nullif(p_pkg->'keywords', 'null'::jsonb))), '{}')); v_version text := p_pkg->>'version'; v_repository_id uuid := ((p_pkg->'repository')->>'repository_id')::uuid; + v_provider text := nullif(p_pkg->>'provider', ''); + v_signatures text[] := (select nullif(array(select jsonb_array_elements_text(nullif(p_pkg->'signatures', 'null'::jsonb))), '{}')); + + v_latest_version_updated boolean; v_maintainer jsonb; v_maintainer_id uuid; + v_package_id uuid; + v_previous_latest_version text; + v_previous_latest_version_ts timestamptz; + v_repository_disabled boolean; + v_repository_kind_id integer; v_ts timestamptz; - v_provider text := nullif(p_pkg->>'provider', ''); - v_ts_repository text[]; v_ts_publisher text[]; - v_repository_disabled boolean; - v_signatures text[] := (select nullif(array(select jsonb_array_elements_text(nullif(p_pkg->'signatures', 'null'::jsonb))), '{}')); + v_ts_repository text[]; begin + -- Convert package version ts to timestamptz when available, otherwise use current + v_ts := to_timestamp((p_pkg->>'ts')::int); + if v_ts is null then + v_ts = current_timestamp; + end if; + -- Get some repository information (some of it for tsdoc) - select r.disabled, array[r.name, r.display_name], array[u.alias, o.name, o.display_name, v_provider] - into v_repository_disabled, v_ts_repository, v_ts_publisher + select r.disabled, array[r.name, r.display_name], array[u.alias, o.name, o.display_name, v_provider], repository_kind_id + into v_repository_disabled, v_ts_repository, v_ts_publisher, v_repository_kind_id from repository r left join "user" u using (user_id) left join organization o using (organization_id) @@ -38,13 +48,15 @@ begin raise 'repository is disabled'; end if; - -- Get package's latest version before registration, if available - select latest_version into v_previous_latest_version - from package - where name = v_name - and repository_id = v_repository_id; + -- Get package's latest version info before registration, if available + select p.latest_version, s.ts into v_previous_latest_version, v_previous_latest_version_ts + from package p + join snapshot s using (package_id) + where p.name = v_name + and p.repository_id = v_repository_id + and s.version = p.latest_version; - -- Package + -- Package (insert or update if latest has changed) insert into package ( name, latest_version, @@ -70,7 +82,13 @@ begin is_operator = excluded.is_operator, channels = excluded.channels, default_channel = excluded.default_channel - where semver_gte(v_version, package.latest_version) = true + where is_latest( + v_repository_kind_id, + v_version, + v_previous_latest_version, + v_ts, + v_previous_latest_version_ts + ) = true returning package_id into v_package_id; -- If package record has been created or updated @@ -122,10 +140,6 @@ begin end if; -- Package snapshot - v_ts := to_timestamp((p_pkg->>'ts')::int); - if v_ts is null then - v_ts = current_timestamp; - end if; insert into snapshot ( package_id, version, @@ -227,7 +241,18 @@ begin ts = v_ts; -- Register new release event if package's latest version has been updated - if semver_gt(v_version, v_previous_latest_version) then + v_latest_version_updated := false; + case v_repository_kind_id + when 12 then -- Container image + if v_ts > v_previous_latest_version_ts then + v_latest_version_updated := true; + end if; + else -- Any other kind + if semver_gt(v_version, v_previous_latest_version) then + v_latest_version_updated := true; + end if; + end case; + if v_latest_version_updated then insert into event (package_id, package_version, event_kind_id) values (v_package_id, v_version, 0); end if; diff --git a/database/migrations/functions/repositories/add_repository.sql b/database/migrations/functions/repositories/add_repository.sql index 2b7f3729e..b5b0d0129 100644 --- a/database/migrations/functions/repositories/add_repository.sql +++ b/database/migrations/functions/repositories/add_repository.sql @@ -26,6 +26,7 @@ begin auth_pass, disabled, scanner_disabled, + data, repository_kind_id, user_id, organization_id @@ -38,6 +39,7 @@ begin nullif(p_repository->>'auth_pass', ''), (p_repository->>'disabled')::boolean, (p_repository->>'scanner_disabled')::boolean, + nullif(p_repository->'data', 'null'), (p_repository->>'kind')::int, v_owner_user_id, v_owner_organization_id diff --git a/database/migrations/functions/repositories/get_repository_by_id.sql b/database/migrations/functions/repositories/get_repository_by_id.sql index 3318d41a6..eb3932dde 100644 --- a/database/migrations/functions/repositories/get_repository_by_id.sql +++ b/database/migrations/functions/repositories/get_repository_by_id.sql @@ -21,6 +21,7 @@ returns setof json as $$ 'last_scanning_errors', r.last_scanning_errors, 'last_tracking_ts', floor(extract(epoch from last_tracking_ts)), 'last_tracking_errors', r.last_tracking_errors, + 'data', r.data, 'user_alias', u.alias, 'organization_name', o.name, 'organization_display_name', o.display_name diff --git a/database/migrations/functions/repositories/update_repository.sql b/database/migrations/functions/repositories/update_repository.sql index ad758ed02..d75e05b21 100644 --- a/database/migrations/functions/repositories/update_repository.sql +++ b/database/migrations/functions/repositories/update_repository.sql @@ -61,7 +61,8 @@ begin end ), disabled = (p_repository->>'disabled')::boolean, - scanner_disabled = (p_repository->>'scanner_disabled')::boolean + scanner_disabled = (p_repository->>'scanner_disabled')::boolean, + data = nullif(p_repository->'data', 'null') where repository_id = v_repository_id; -- If the repository has been disabled, remove packages belonging to it and diff --git a/database/migrations/schema/035_containers_images.sql b/database/migrations/schema/035_containers_images.sql new file mode 100644 index 000000000..ab526704d --- /dev/null +++ b/database/migrations/schema/035_containers_images.sql @@ -0,0 +1,9 @@ +insert into repository_kind values (12, 'Containers images'); +alter table snapshot drop constraint snapshot_package_id_digest_key; +alter table repository add column data jsonb; + +---- create above / drop below ---- + +delete from repository_kind where repository_kind_id = 12; +alter table snapshot add constraint snapshot_package_id_digest_key unique (package_id, digest); +alter table repository drop column data; diff --git a/database/tests/functions/packages/get_package.sql b/database/tests/functions/packages/get_package.sql index 3aaf43620..d8796d320 100644 --- a/database/tests/functions/packages/get_package.sql +++ b/database/tests/functions/packages/get_package.sql @@ -291,17 +291,17 @@ select is( }, "version": "1.0.0", "available_versions": [ - { - "version": "0.0.9", - "contains_security_updates": false, - "prerelease": false, - "ts": 1592299233 - }, { "version": "1.0.0", "contains_security_updates": true, "prerelease": true, "ts": 1592299234 + }, + { + "version": "0.0.9", + "contains_security_updates": false, + "prerelease": false, + "ts": 1592299233 } ], "app_version": "12.1.0", @@ -444,17 +444,17 @@ select is( }, "version": "1.0.0", "available_versions": [ - { - "version": "0.0.9", - "contains_security_updates": false, - "prerelease": false, - "ts": 1592299233 - }, { "version": "1.0.0", "contains_security_updates": true, "prerelease": true, "ts": 1592299234 + }, + { + "version": "0.0.9", + "contains_security_updates": false, + "prerelease": false, + "ts": 1592299233 } ], "app_version": "12.1.0", @@ -587,17 +587,17 @@ select is( }, "version": "0.0.9", "available_versions": [ - { - "version": "0.0.9", - "contains_security_updates": false, - "prerelease": false, - "ts": 1592299233 - }, { "version": "1.0.0", "contains_security_updates": true, "prerelease": true, "ts": 1592299234 + }, + { + "version": "0.0.9", + "contains_security_updates": false, + "prerelease": false, + "ts": 1592299233 } ], "app_version": "12.0.0", diff --git a/database/tests/functions/packages/is_latest.sql b/database/tests/functions/packages/is_latest.sql new file mode 100644 index 000000000..21220c751 --- /dev/null +++ b/database/tests/functions/packages/is_latest.sql @@ -0,0 +1,29 @@ +-- Start transaction and plan tests +begin; +select plan(2); + +-- Test function +select is( + is_latest( + 0, + '1.0.1', + '1.0.0', + current_timestamp - '2 days'::interval, + current_timestamp - '1 day'::interval + ), + true +); +select is( + is_latest( + 12, + '1.0.1', + '1.0.0', + current_timestamp - '2 days'::interval, + current_timestamp - '1 day'::interval + ), + false +); + +-- Finish tests and rollback transaction +select * from finish(); +rollback; diff --git a/database/tests/functions/repositories/add_repository.sql b/database/tests/functions/repositories/add_repository.sql index d76d42104..1df650b89 100644 --- a/database/tests/functions/repositories/add_repository.sql +++ b/database/tests/functions/repositories/add_repository.sql @@ -24,6 +24,7 @@ select add_repository(:'user1ID', null, ' "auth_pass": "pass1", "disabled": false, "scanner_disabled": false, + "data": {"k1": "v1"}, "kind": 0 } '::jsonb); @@ -38,6 +39,7 @@ select results_eq( auth_pass, disabled, scanner_disabled, + data, repository_kind_id, user_id, organization_id @@ -54,6 +56,7 @@ select results_eq( 'pass1', false, false, + '{"k1": "v1"}'::jsonb, 0, '00000000-0000-0000-0000-000000000001'::uuid, null::uuid @@ -73,6 +76,7 @@ select add_repository(:'user1ID', 'org1', ' "auth_pass": "pass1", "disabled": true, "scanner_disabled": true, + "data": {"k1": "v1"}, "kind": 0 } '::jsonb); @@ -87,6 +91,7 @@ select results_eq( auth_pass, disabled, scanner_disabled, + data, repository_kind_id, user_id, organization_id @@ -103,6 +108,7 @@ select results_eq( 'pass1', true, true, + '{"k1": "v1"}'::jsonb, 0, null::uuid, '00000000-0000-0000-0000-000000000001'::uuid diff --git a/database/tests/functions/repositories/get_repository_by_id.sql b/database/tests/functions/repositories/get_repository_by_id.sql index aaf469d9e..f126340c4 100644 --- a/database/tests/functions/repositories/get_repository_by_id.sql +++ b/database/tests/functions/repositories/get_repository_by_id.sql @@ -30,7 +30,8 @@ insert into repository ( last_scanning_ts, last_scanning_errors, last_tracking_ts, - last_tracking_errors + last_tracking_errors, + data ) values ( :'repo1ID', @@ -46,7 +47,8 @@ values ( '2020-06-16 11:20:34+02', 'error1\nerror2\n', '2020-06-16 11:20:34+02', - 'error1\nerror2\n' + 'error1\nerror2\n', + '{"k1": "v1"}' ); insert into repository ( repository_id, @@ -97,7 +99,8 @@ select is( "last_scanning_errors": "error1\\nerror2\\n", "last_tracking_ts": 1592299234, "last_tracking_errors": "error1\\nerror2\\n", - "user_alias": "user1" + "user_alias": "user1", + "data": {"k1": "v1"} }'::jsonb, 'Repository 1 returned as a json object (without credentials)' ); @@ -122,7 +125,8 @@ select is( "last_scanning_errors": "error1\\nerror2\\n", "last_tracking_ts": 1592299234, "last_tracking_errors": "error1\\nerror2\\n", - "user_alias": "user1" + "user_alias": "user1", + "data": {"k1": "v1"} }'::jsonb, 'Repository 1 is returned as a json object (with credentials)' ); diff --git a/database/tests/functions/repositories/update_repository.sql b/database/tests/functions/repositories/update_repository.sql index 31cbd05ff..af86b9314 100644 --- a/database/tests/functions/repositories/update_repository.sql +++ b/database/tests/functions/repositories/update_repository.sql @@ -109,17 +109,37 @@ select update_repository(:'user1ID', ' "auth_user": "user1", "auth_pass": "pass1", "disabled": true, - "scanner_disabled": false + "scanner_disabled": false, + "data": {"k1": "v1"} } '::jsonb); select results_eq( $$ - select name, display_name, url, branch, auth_user, auth_pass, disabled, digest + select + name, + display_name, + url, + branch, + auth_user, + auth_pass, + disabled, + digest, + data from repository where name = 'repo1' $$, $$ - values ('repo1', 'Repo 1 updated', 'https://repo1.com/updated', 'main', 'user1', 'pass1', true, null) + values ( + 'repo1', + 'Repo 1 updated', + 'https://repo1.com/updated', + 'main', + 'user1', + 'pass1', + true, + null, + '{"k1": "v1"}'::jsonb + ) $$, 'Repository should have been updated by user who owns it' ); diff --git a/database/tests/schema/schema.sql b/database/tests/schema/schema.sql index 10f0a91aa..eb13c3282 100644 --- a/database/tests/schema/schema.sql +++ b/database/tests/schema/schema.sql @@ -1,6 +1,6 @@ -- Start transaction and plan tests begin; -select plan(185); +select plan(186); -- Check default_text_search_config is correct select results_eq( @@ -173,6 +173,7 @@ select columns_are('repository', array[ 'scanner_disabled', 'digest', 'created_at', + 'data', 'repository_kind_id', 'user_id', 'organization_id' @@ -363,7 +364,6 @@ select indexes_are('session', array[ ]); select indexes_are('snapshot', array[ 'snapshot_pkey', - 'snapshot_package_id_digest_key', 'snapshot_not_deprecated_with_readme_idx' ]); select indexes_are('subscription', array[ @@ -444,6 +444,7 @@ select has_function('get_packages_stats'); select has_function('get_production_usage'); select has_function('get_random_packages'); select has_function('get_snapshots_to_scan'); +select has_function('is_latest'); select has_function('register_package'); select has_function('search_packages'); select has_function('search_packages_monocular'); @@ -517,7 +518,8 @@ select results_eq( (8, 'KEDA scalers'), (9, 'CoreDNS plugins'), (10, 'Keptn integrations'), - (11, 'Tekton pipelines') + (11, 'Tekton pipelines'), + (12, 'Containers images') $$, 'Repository kinds should exist' ); diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ce9add3ef..e98bc3f64 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1111,6 +1111,29 @@ paths: $ref: "#/components/responses/TooManyRequests" "500": $ref: "#/components/responses/InternalServerError" + "/packages/container/{repoName}/{packageName}": + get: + tags: + - Packages + summary: Get container image details + description: Get container image details + operationId: getContainerImageDetails + parameters: + - $ref: "#/components/parameters/RepoNameParam" + - $ref: "#/components/parameters/PackageNameParam" + responses: + "200": + description: "" + content: + application/json: + schema: + $ref: "#/components/schemas/ContainerImage" + "404": + $ref: "#/components/responses/NotFoundResponse" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalServerError" "/packages/coredns/{repoName}/{packageName}": get: tags: @@ -1387,6 +1410,30 @@ paths: $ref: "#/components/responses/TooManyRequests" "500": $ref: "#/components/responses/InternalServerError" + "/packages/container/{repoName}/{packageName}/{version}": + get: + tags: + - Packages + summary: Get container image details + description: Get container image details + operationId: getContainerImageVersionDetails + parameters: + - $ref: "#/components/parameters/RepoNameParam" + - $ref: "#/components/parameters/PackageNameParam" + - $ref: "#/components/parameters/VersionParam" + responses: + "200": + description: "" + content: + application/json: + schema: + $ref: "#/components/schemas/ContainerImage" + "404": + $ref: "#/components/responses/NotFoundResponse" + "429": + $ref: "#/components/responses/TooManyRequests" + "500": + $ref: "#/components/responses/InternalServerError" "/packages/coredns/{repoName}/{packageName}/{version}": get: tags: @@ -3193,6 +3240,27 @@ components: name: user1 total: 3 filter_key: user + ContainerImage: + allOf: + - $ref: "#/components/schemas/Package" + - type: object + properties: + data: + type: object + nullable: false + properties: + platforms: + type: array + items: + type: string + nullable: false + example: darwin/amd64 + alternative_locations: + type: array + items: + type: string + nullable: false + example: public.ecr.aws/artifacthub/ah CoreDNSPackage: $ref: "#/components/schemas/Package" FalcoPackage: @@ -3868,6 +3936,24 @@ components: branch: type: string nullable: false + data: + type: object + nullable: false + properties: + tags: + type: array + items: + type: object + required: + - name + properties: + name: + type: string + nullable: false + example: stable + mutable: + type: boolean + nullable: false RepositoryKind: type: integer enum: diff --git a/docs/repositories.md b/docs/repositories.md index 6d9a034b2..05198eaa3 100644 --- a/docs/repositories.md +++ b/docs/repositories.md @@ -4,6 +4,7 @@ Artifact Hub allows publishers to list their content in an automated way. Publis The following repositories kinds are supported at the moment: +- [Containers images repositories](#container-images-repositories) - [CoreDNS plugins repositories](#coredns-plugins-repositories) - [Falco rules repositories](#falco-rules-repositories) - [Helm charts repositories](#helm-charts-repositories) @@ -24,6 +25,80 @@ This guide also contains additional information about the following repositories - [Ownership claim](#ownership-claim) - [Private repositories](#private-repositories) +## Container images repositories + +*This feature is experimental and it's subject to change.* + +Container images repositories are expected to be hosted in OCI registries. Each repository represents one package in Artifact Hub, and multiple versions of that package will be created from each of the tags configured when the repository is added. The repository name in the url will be used as the package name. At the moment tags have to be configured manually from the control panel, and they can be marked as `mutable` or `immutable`. Immutable tags will be only processed once, whereas mutable ones will be processed periodically and reindexed when they change. A repository can have a **maximum of 10 tags** listed. In some cases, adding a single mutable tag like `latest` will be enough to have presence on Artifact Hub. We have plans to add a new API endpoint that will allow publishers to push tags programatically as needed replacing old ones. + +To add a container image repository, the url used **must** follow the following format: + +- `oci://registry/[namespace]/repository` (example: oci://index.docker.io/artifacthub/ah) + +The registry host is required, please use `index.docker.io` when referring to repositories hosted in the Docker Hub. The url should not contain any tag. + +### Image metadata + +For an image tag to be listed on Artifact Hub, it **must** contain some metadata. Depending on the image manifest format, metadata must be provided one way or another: images using OCI manifests must use [annotations](https://github.com/opencontainers/image-spec/blob/main/annotations.md), whereas images using Docker V2 manifests must use [config labels](https://docs.docker.com/engine/reference/builder/#label). Docker V1 manifests are not supported. + +The following annotations/labels are supported at the moment: + +(all must be provided as strings) + +| key | required | description | +| -------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| **io.artifacthub.package.readmeURL** | **yes** | url of the readme file (in markdown format) for this package version. Please make sure it points to a raw markdown document, not HTML | +| **org.opencontainers.image.created** | **yes** | date and time on which the image was built (RFC3339) | +| **org.opencontainers.image.description** | **yes** | a short description of the package | +| **org.opencontainers.image.documentation** | no | url to get documentation on the image | +| **org.opencontainers.image.source** | no | url to get source code for building the image | +| **org.opencontainers.image.title** | no | name of the package nicely formatted | +| **org.opencontainers.image.url** | no | url to find more information on the image | +| **org.opencontainers.image.vendor** | no | name of the distributing entity, organization or individual | +| **org.opencontainers.image.version** | no | version of the packaged software | +| **io.artifacthub.package.alternativeLocations** | no | alternative locations where this image is hosted. They can be provided as a comma separated list of images urls | +| **io.artifacthub.package.containsSecurityUpdates** | no | boolean that indicates if this image version contains security updates | +| **io.artifacthub.package.deprecated** | no | boolean that indicates if this image version is deprecated | +| **io.artifacthub.package.keywords** | no | a list of comma separated keywords about this image | +| **io.artifacthub.package.license** | no | SPDX identifier of the package license | +| **io.artifacthub.package.logoURL** | no | url of the logo image | +| **io.artifacthub.package.maintainers** | no | json string with an array of maintainers. Example: `[{"name":"maintainer","email":"maintainer@email.com"}]` | +| **io.artifacthub.package.prerelease** | no | boolean that indicates if this image version is a pre-release | + +You can add annotations and labels to your images at build time (by using `podman`, `buildah` or `docker`), or later at any time by mutating the image with tools like [crane](https://github.com/google/go-containerregistry/tree/main/cmd/crane): + +```sh +crane mutate \ + --label org.opencontainers.image.description='Artifact Hub command line tool' \ + --label org.opencontainers.image.version='1.5.0' \ + --label org.opencontainers.image.created='2021-12-15T10:00:00.00Z' \ + --label org.opencontainers.image.documentation='https://artifacthub.io/docs/topics/cli' \ + --label org.opencontainers.image.source='https://github.com/artifacthub/hub/tree/0c0e789ab6f4e74dfa59a4e7c1ece4788881e279/cmd/ah' \ + --label org.opencontainers.image.vendor='Artifact Hub' \ + --label io.artifacthub.package.readmeURL='https://raw.githubusercontent.com/artifacthub/hub/0c0e789ab6f4e74dfa59a4e7c1ece4788881e279/docs/cli.md' \ + --label io.artifacthub.package.maintainers='[{"name":"Artifact Hub maintainers","email":"cncf-artifacthub-maintainers@lists.cncf.io"}]' \ + --label io.artifacthub.package.logoURL='https://raw.githubusercontent.com/artifacthub/hub/master/docs/logo/logo.svg' \ + --label io.artifacthub.package.keywords='artifact hub,cli,lint' \ + --label io.artifacthub.package.license='Apache-2.0' \ + --label io.artifacthub.package.alternativeLocations='public.ecr.aws/artifacthub/ah:v1.5.0' \ +artifacthub/ah:latest +``` + +### Repository metadata + +There is an Artifact Hub repository metadata file named [artifacthub-repo.yml](https://github.com/artifacthub/hub/blob/master/docs/metadata/artifacthub-repo.yml), which can be used to setup features like [Verified Publisher](#verified-publisher) or [Ownership claim](#ownership-claim). Once your repository metadata file is ready, you can push it to the OCI registry using [oras](https://oras.land/cli/): + +```bash +oras push \ + registry/namespace/repository:artifacthub.io \ + --manifest-config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml \ + artifacthub-repo.yml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml +``` + +The repository metadata file is pushed to the registry using a special tag named `artifacthub.io`. Artifact Hub will pull that artifact looking for the `application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml` layer when the repository metadata is needed. + +*Please note that publishing an Artifact Hub repository metadata file requires that the registry supports [OCI artifacts](https://oras.land/implementors/). At the time of writing this, the Docker Hub [does not support them yet](https://github.com/docker/roadmap/issues/135).* + ## CoreDNS plugins repositories CoreDNS plugins repositories are expected to be hosted in Github, Gitlab or Bitbucket repos. When adding your repository to Artifact Hub, the url used **must** follow the following format: diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 166aa362e..647254848 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -264,7 +264,7 @@ func (h *Handlers) setupRouter() { r.Get("/stats", h.Packages.GetStats) r.With(corsMW).Get("/search", h.Packages.Search) r.With(h.Users.RequireLogin).Get("/starred", h.Packages.GetStarredByUser) - r.Route("/{^helm$|^falco$|^opa$|^olm|^tbaction|^krew|^helm-plugin|^tekton-task|^keda-scaler|^coredns|^keptn|^tekton-pipeline$}/{repoName}/{packageName}", func(r chi.Router) { + r.Route("/{^helm$|^falco$|^opa$|^olm|^tbaction|^krew|^helm-plugin|^tekton-task|^keda-scaler|^coredns|^keptn|^tekton-pipeline|^container$}/{repoName}/{packageName}", func(r chi.Router) { r.Get("/feed/rss", h.Packages.RssFeed) r.With(corsMW).Get("/summary", h.Packages.GetSummary) r.Get("/{version}", h.Packages.Get) @@ -421,7 +421,7 @@ func (h *Handlers) setupRouter() { // Index special entry points r.Route("/packages", func(r chi.Router) { - r.Route("/{^helm$|^falco$|^opa$|^olm|^tbaction|^krew|^helm-plugin|^tekton-task|^keda-scaler|^coredns|^keptn|^tekton-pipeline$}/{repoName}/{packageName}", func(r chi.Router) { + r.Route("/{^helm$|^falco$|^opa$|^olm|^tbaction|^krew|^helm-plugin|^tekton-task|^keda-scaler|^coredns|^keptn|^tekton-pipeline|^container$}/{repoName}/{packageName}", func(r chi.Router) { r.With(h.Packages.InjectIndexMeta).Get("/{version}", h.Static.Index) r.With(h.Packages.InjectIndexMeta).Get("/", h.Static.Index) }) diff --git a/internal/handlers/pkg/handlers.go b/internal/handlers/pkg/handlers.go index cd81d2ed7..e78a70c21 100644 --- a/internal/handlers/pkg/handlers.go +++ b/internal/handlers/pkg/handlers.go @@ -492,11 +492,13 @@ func (h *Handlers) RssFeed(w http.ResponseWriter, r *http.Request) { Link: &feeds.Link{Href: BuildURL(baseURL, p, s.Version)}, }) } - sort.Slice(feed.Items, func(i, j int) bool { - vi, _ := semver.NewVersion(feed.Items[i].Title) - vj, _ := semver.NewVersion(feed.Items[j].Title) - return vj.LessThan(vi) - }) + if p.Repository.Kind != hub.Container { + sort.Slice(feed.Items, func(i, j int) bool { + vi, _ := semver.NewVersion(feed.Items[i].Title) + vj, _ := semver.NewVersion(feed.Items[j].Title) + return vj.LessThan(vi) + }) + } w.Header().Set("Cache-Control", helpers.BuildCacheControlHeader(helpers.DefaultAPICacheMaxAge)) _ = feed.WriteRss(w) diff --git a/internal/handlers/pkg/handlers_test.go b/internal/handlers/pkg/handlers_test.go index 6533c9e62..b06cd668d 100644 --- a/internal/handlers/pkg/handlers_test.go +++ b/internal/handlers/pkg/handlers_test.go @@ -1307,14 +1307,14 @@ func TestRssFeed(t *testing.T) { LogoImageID: "0001", TS: 1592299234, AvailableVersions: []*hub.Version{ - { - Version: "1.0.0", - TS: 1592299234, - }, { Version: "0.0.9", TS: 1592299233, }, + { + Version: "1.0.0", + TS: 1592299234, + }, }, Maintainers: []*hub.Maintainer{ { @@ -2014,6 +2014,17 @@ func TestBuildURL(t *testing.T) { "2.0.0", baseURL + "/packages/tekton-pipeline/repo1/pkg1/2.0.0", }, + { + &hub.Package{ + NormalizedName: "pkg1", + Repository: &hub.Repository{ + Kind: hub.Container, + Name: "repo1", + }, + }, + "2.0.0", + baseURL + "/packages/container/repo1/pkg1/2.0.0", + }, } for _, tc := range testCases { tc := tc diff --git a/internal/hub/external.go b/internal/hub/external.go index b5c5f9ac6..23e4f5a8a 100644 --- a/internal/hub/external.go +++ b/internal/hub/external.go @@ -63,5 +63,5 @@ type OCISignatureChecker interface { // OCITagsGetter is the interface that wraps the Tags method, used to get all // the tags available for a given repository in a OCI registry. type OCITagsGetter interface { - Tags(ctx context.Context, r *Repository) ([]string, error) + Tags(ctx context.Context, r *Repository, onlySemver bool) ([]string, error) } diff --git a/internal/hub/repo.go b/internal/hub/repo.go index 11715b809..78121a2b7 100644 --- a/internal/hub/repo.go +++ b/internal/hub/repo.go @@ -2,6 +2,7 @@ package hub import ( "context" + "encoding/json" "errors" helmrepo "helm.sh/helm/v3/pkg/repo" @@ -17,6 +18,18 @@ const ( RepositoryOCIPrefix = "oci://" ) +// ContainerImageData represents some data specific to repositories of the +// container image kind. +type ContainerImageData struct { + Tags []ContainerImageTag `json:"tags"` +} + +// ContainerImageTag represents some information about a container image tag. +type ContainerImageTag struct { + Name string `json:"name"` + Mutable bool `json:"mutable"` +} + // RepositoryKind represents the kind of a given repository. type RepositoryKind int64 @@ -57,6 +70,9 @@ const ( // TektonPipeline represents a repository with Tekton pipelines. TektonPipeline RepositoryKind = 11 + + // Container represents a repository with containers images. + Container RepositoryKind = 12 ) // GetKindName returns the name of the provided repository kind. @@ -86,6 +102,8 @@ func GetKindName(kind RepositoryKind) string { return "tekton-task" case TektonPipeline: return "tekton-pipeline" + case Container: + return "container" default: return "" } @@ -119,6 +137,8 @@ func GetKindFromName(kind string) (RepositoryKind, error) { return TektonTask, nil case "tekton-pipeline": return TektonPipeline, nil + case "container": + return Container, nil default: return -1, errors.New("invalid kind name") } @@ -144,27 +164,28 @@ type Owner struct { // Repository represents a packages repository. type Repository struct { - RepositoryID string `json:"repository_id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - URL string `json:"url"` - Branch string `json:"branch"` - Private bool `json:"private"` - AuthUser string `json:"auth_user"` - AuthPass string `json:"auth_pass"` - Digest string `json:"digest"` - Kind RepositoryKind `json:"kind"` - UserID string `json:"user_id"` - UserAlias string `json:"user_alias"` - OrganizationID string `json:"organization_id"` - OrganizationName string `json:"organization_name"` - OrganizationDisplayName string `json:"organization_display_name"` - LastScanningErrors string `json:"last_scanning_errors"` - LastTrackingErrors string `json:"last_tracking_errors"` - VerifiedPublisher bool `json:"verified_publisher"` - Official bool `json:"official"` - Disabled bool `json:"disabled"` - ScannerDisabled bool `json:"scanner_disabled"` + RepositoryID string `json:"repository_id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + URL string `json:"url"` + Branch string `json:"branch"` + Private bool `json:"private"` + AuthUser string `json:"auth_user"` + AuthPass string `json:"auth_pass"` + Digest string `json:"digest"` + Kind RepositoryKind `json:"kind"` + UserID string `json:"user_id"` + UserAlias string `json:"user_alias"` + OrganizationID string `json:"organization_id"` + OrganizationName string `json:"organization_name"` + OrganizationDisplayName string `json:"organization_display_name"` + LastScanningErrors string `json:"last_scanning_errors"` + LastTrackingErrors string `json:"last_tracking_errors"` + VerifiedPublisher bool `json:"verified_publisher"` + Official bool `json:"official"` + Disabled bool `json:"disabled"` + ScannerDisabled bool `json:"scanner_disabled"` + Data json.RawMessage `json:"data,omitempty"` } // RepositoryCloner describes the methods a RepositoryCloner implementation diff --git a/internal/hub/tracker.go b/internal/hub/tracker.go index 353eafed0..93dfcdf78 100644 --- a/internal/hub/tracker.go +++ b/internal/hub/tracker.go @@ -8,6 +8,12 @@ import ( "github.com/spf13/viper" ) +const ( + // HasNotChanged is a snapshot digest value that indicates that the digest + // has not changed. + HasNotChanged = "has-not-changed" +) + // TrackerServices represents a set of services that must be provided to a // Tracker instance so that it can perform its tasks. type TrackerServices struct { diff --git a/internal/notification/template/new_release_email.tmpl b/internal/notification/template/new_release_email.tmpl index 97492a14a..9a2e24455 100644 --- a/internal/notification/template/new_release_email.tmpl +++ b/internal/notification/template/new_release_email.tmpl @@ -2,7 +2,7 @@ {{ define "content" }}
- + @@ -13,7 +13,7 @@

{{ .Package.Name }}

{{ .Package.repository.publisher }}

-

Version {{ .Package.Version }} has been released

+

{{ if eq .Package.Repository.Kind "container" }}Tag{{ else }}Version{{ end }} {{ .Package.Version }} has been released

@@ -23,7 +23,7 @@ @@ -38,7 +38,7 @@ diff --git a/internal/oci/mock.go b/internal/oci/mock.go index 740b0d1ed..f1e57cea1 100644 --- a/internal/oci/mock.go +++ b/internal/oci/mock.go @@ -50,8 +50,8 @@ type TagsGetterMock struct { } // Tags implements the OCITagsGetter interface. -func (m *TagsGetterMock) Tags(ctx context.Context, r *hub.Repository) ([]string, error) { - args := m.Called(ctx, r) +func (m *TagsGetterMock) Tags(ctx context.Context, r *hub.Repository, onlySemver bool) ([]string, error) { + args := m.Called(ctx, r, onlySemver) tags, _ := args.Get(0).([]string) return tags, args.Error(1) } diff --git a/internal/oci/oci.go b/internal/oci/oci.go index 093778c7f..98f497cb3 100644 --- a/internal/oci/oci.go +++ b/internal/oci/oci.go @@ -122,7 +122,7 @@ func (c *SignatureChecker) HasCosignSignature( type TagsGetter struct{} // Tags returns a list with the tags available for the provided repository. -func (tg *TagsGetter) Tags(ctx context.Context, r *hub.Repository) ([]string, error) { +func (tg *TagsGetter) Tags(ctx context.Context, r *hub.Repository, onlySemver bool) ([]string, error) { u := strings.TrimPrefix(r.URL, hub.RepositoryOCIPrefix) ociRepo, err := name.NewRepository(u) if err != nil { @@ -141,16 +141,19 @@ func (tg *TagsGetter) Tags(ctx context.Context, r *hub.Repository) ([]string, er if err != nil { return nil, err } - var tagsFiltered []string - for _, tag := range tags { - if _, err := semver.NewVersion(tag); err == nil { - tagsFiltered = append(tagsFiltered, tag) + if onlySemver { + var semverTags []string + for _, tag := range tags { + if _, err := semver.NewVersion(tag); err == nil { + semverTags = append(semverTags, tag) + } } + sort.Slice(semverTags, func(i, j int) bool { + vi, _ := semver.NewVersion(semverTags[i]) + vj, _ := semver.NewVersion(semverTags[j]) + return vj.LessThan(vi) + }) + tags = semverTags } - sort.Slice(tagsFiltered, func(i, j int) bool { - vi, _ := semver.NewVersion(tagsFiltered[i]) - vj, _ := semver.NewVersion(tagsFiltered[j]) - return vj.LessThan(vi) - }) - return tagsFiltered, nil + return tags, nil } diff --git a/internal/pkg/manager.go b/internal/pkg/manager.go index 78ec4335e..4db0d3105 100644 --- a/internal/pkg/manager.go +++ b/internal/pkg/manager.go @@ -235,20 +235,22 @@ func (m *Manager) Register(ctx context.Context, pkg *hub.Package) error { if pkg.Version == "" { return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "version not provided") } - sv, err := semver.NewVersion(pkg.Version) - if err != nil { - return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid version (semver expected)") + if pkg.Repository == nil { + return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "repository not provided") + } + if pkg.Repository.Kind != hub.Container { + sv, err := semver.NewVersion(pkg.Version) + if err != nil { + return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid version (semver expected)") + } + pkg.Version = sv.String() } - pkg.Version = sv.String() if pkg.ContentURL != "" { u, err := url.Parse(pkg.ContentURL) if err != nil || u.Scheme == "" || u.Host == "" { return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid content url") } } - if pkg.Repository == nil { - return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "repository not provided") - } if pkg.Repository.RepositoryID == "" { return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "repository id not provided") } @@ -380,8 +382,10 @@ func (m *Manager) Unregister(ctx context.Context, pkg *hub.Package) error { if pkg.Version == "" { return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "version not provided") } - if _, err := semver.StrictNewVersion(pkg.Version); err != nil { - return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid version (semantic version expected)") + if pkg.Repository.Kind != hub.Container { + if _, err := semver.StrictNewVersion(pkg.Version); err != nil { + return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "invalid version (semantic version expected)") + } } // Unregister package from database diff --git a/internal/pkg/manager_test.go b/internal/pkg/manager_test.go index 74b44ed3e..9f45bcbe0 100644 --- a/internal/pkg/manager_test.go +++ b/internal/pkg/manager_test.go @@ -992,35 +992,35 @@ func TestRegister(t *testing.T) { }, }, { - "invalid version (semver expected)", + "repository not provided", &hub.Package{ Name: "package1", - Version: "invalid", + Version: "1.0.0", }, }, { - "invalid content url", + "invalid version (semver expected)", &hub.Package{ Name: "package1", - Version: "1.0.0", - ContentURL: "invalid", + Version: "invalid", + Repository: &hub.Repository{}, }, }, { - "repository not provided", + "invalid content url", &hub.Package{ - Name: "package1", - Version: "1.0.0", + Name: "package1", + Version: "1.0.0", + ContentURL: "invalid", + Repository: &hub.Repository{}, }, }, { "repository id not provided", &hub.Package{ - Name: "package1", - Version: "1.0.0", - Repository: &hub.Repository{ - RepositoryID: "", - }, + Name: "package1", + Version: "1.0.0", + Repository: &hub.Repository{}, }, }, { @@ -1433,8 +1433,9 @@ func TestUnregister(t *testing.T) { { "invalid version (semantic version expected)", &hub.Package{ - Name: "package1", - Version: "1.0", + Name: "package1", + Version: "1.0", + Repository: &hub.Repository{}, }, }, } diff --git a/internal/repo/manager.go b/internal/repo/manager.go index 3e5d1ce3d..87ab63aaf 100644 --- a/internal/repo/manager.go +++ b/internal/repo/manager.go @@ -54,7 +54,8 @@ const ( // contains the repository metadata in an OCI image. MetadataLayerMediaType = "application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml" - artifacthubTag = "artifacthub.io" + artifacthubTag = "artifacthub.io" + maxContainerImageTags = 10 ) var ( @@ -77,6 +78,7 @@ var ( // validRepositoryKinds contains the repository kinds supported. validRepositoryKinds = []hub.RepositoryKind{ + hub.Container, hub.CoreDNS, hub.Falco, hub.Helm, @@ -178,6 +180,9 @@ func (m *Manager) Add(ctx context.Context, orgName string, r *hub.Repository) er if err := m.validateCredentials(r); err != nil { return fmt.Errorf("%w: %s", hub.ErrInvalidInput, err.Error()) } + if err := validateData(r); err != nil { + return fmt.Errorf("%w: %s", hub.ErrInvalidInput, err.Error()) + } // Authorize action if the repository will be added to an organization if orgName != "" { @@ -258,7 +263,7 @@ func (m *Manager) ClaimOwnership(ctx context.Context, repoName, orgName string) // Some extra validation u, _ := url.Parse(r.URL) if r.Kind == hub.OLM && SchemeIsOCI(u) { - return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "ownership claim not available for olm oci repos") + return fmt.Errorf("%w: %s", hub.ErrInvalidInput, "ownership claim not available for this repo kind") } // Get repository metadata @@ -424,6 +429,8 @@ func (m *Manager) locateMetadataFile(r *hub.Repository, basePath string) string var mdFile string u, _ := url.Parse(r.URL) switch r.Kind { + case hub.Container: + mdFile = r.URL case hub.Helm: switch u.Scheme { case "http", "https": @@ -520,7 +527,7 @@ func (m *Manager) GetRemoteDigest(ctx context.Context, r *hub.Repository) (strin } case SchemeIsOCI(u): // Digest is obtained by hashing the list of versions available - versions, err := m.tg.Tags(ctx, r) + versions, err := m.tg.Tags(ctx, r, true) if err != nil { return digest, err } @@ -715,6 +722,9 @@ func (m *Manager) Update(ctx context.Context, r *hub.Repository) error { if err := m.validateCredentials(r); err != nil { return fmt.Errorf("%w: %s", hub.ErrInvalidInput, err.Error()) } + if err := validateData(r); err != nil { + return fmt.Errorf("%w: %s", hub.ErrInvalidInput, err.Error()) + } // Authorize action if the repository is owned by an organization rBefore, err := m.GetByName(ctx, r.Name, false) @@ -762,6 +772,10 @@ func (m *Manager) validateURL(r *hub.Repository) error { return errors.New("urls with credentials not allowed") } switch r.Kind { + case hub.Container: + if !SchemeIsOCI(u) { + return errors.New("invalid url format") + } case hub.Helm: if SchemeIsHTTP(u) { if _, _, err := m.il.LoadIndex(r); err != nil { @@ -796,6 +810,25 @@ func (m *Manager) validateCredentials(r *hub.Repository) error { return nil } +// validateData checks the kind specific data provided. +func validateData(r *hub.Repository) error { + switch r.Kind { + case hub.Container: + if r.Data != nil { + var data *hub.ContainerImageData + if err := json.Unmarshal(r.Data, &data); err != nil { + return fmt.Errorf("invalid container image data: %w", err) + } + if len(data.Tags) > maxContainerImageTags { + return fmt.Errorf("too many tags (max allowed: %d)", maxContainerImageTags) + } + } + return nil + default: + return nil + } +} + // validateSearchInput validates the search input provided, returning an error // in case it's invalid. func validateSearchInput(input *hub.SearchRepositoryInput) error { diff --git a/internal/repo/manager_test.go b/internal/repo/manager_test.go index 968f0f20d..0e7bc87f5 100644 --- a/internal/repo/manager_test.go +++ b/internal/repo/manager_test.go @@ -155,6 +155,16 @@ func TestAdd(t *testing.T) { }, nil, }, + { + "invalid url format", + "org1", + &hub.Repository{ + Kind: hub.Container, + Name: "repo1", + URL: "https://repo1.url", + }, + nil, + }, { "the url provided does not point to a valid Helm repository", "org1", @@ -177,6 +187,42 @@ func TestAdd(t *testing.T) { }, nil, }, + { + "invalid container image data", + "org1", + &hub.Repository{ + Kind: hub.Container, + Name: "repo1", + URL: "oci://registry.io/namespace/repo", + Data: json.RawMessage("{["), + }, + nil, + }, + { + "too many tags", + "org1", + &hub.Repository{ + Kind: hub.Container, + Name: "repo1", + URL: "oci://registry.io/namespace/repo", + Data: json.RawMessage(`{ + "tags": [ + {"name": "tag1"}, + {"name": "tag2"}, + {"name": "tag3"}, + {"name": "tag4"}, + {"name": "tag5"}, + {"name": "tag6"}, + {"name": "tag7"}, + {"name": "tag8"}, + {"name": "tag9"}, + {"name": "tag10"}, + {"name": "tag11"} + ] + }`), + }, + nil, + }, } for _, tc := range testCases { tc := tc @@ -1193,7 +1239,7 @@ func TestGetRemoteDigest(t *testing.T) { t.Run("helm-oci: error getting tags", func(t *testing.T) { t.Parallel() tg := &oci.TagsGetterMock{} - tg.On("Tags", ctx, helmOCI).Return(nil, tests.ErrFake) + tg.On("Tags", ctx, helmOCI, true).Return(nil, tests.ErrFake) m := NewManager(cfg, nil, nil, nil, WithOCITagsGetter(tg)) digest, err := m.GetRemoteDigest(ctx, helmOCI) @@ -1205,7 +1251,7 @@ func TestGetRemoteDigest(t *testing.T) { t.Run("helm-oci: success", func(t *testing.T) { t.Parallel() tg := &oci.TagsGetterMock{} - tg.On("Tags", ctx, helmOCI).Return([]string{"2.0.0", "1.0.0"}, nil) + tg.On("Tags", ctx, helmOCI, true).Return([]string{"2.0.0", "1.0.0"}, nil) m := NewManager(cfg, nil, nil, nil, WithOCITagsGetter(tg)) digest, err := m.GetRemoteDigest(ctx, helmOCI) @@ -1681,6 +1727,15 @@ func TestUpdate(t *testing.T) { }, nil, }, + { + "invalid url format", + &hub.Repository{ + Kind: hub.Container, + Name: "repo1", + URL: "https://repo1.url", + }, + nil, + }, { "the url provided does not point to a valid Helm repository", &hub.Repository{ @@ -1701,6 +1756,40 @@ func TestUpdate(t *testing.T) { }, nil, }, + { + "invalid container image data", + &hub.Repository{ + Kind: hub.Container, + Name: "repo1", + URL: "oci://registry.io/namespace/repo", + Data: json.RawMessage("{["), + }, + nil, + }, + { + "too many tags", + &hub.Repository{ + Kind: hub.Container, + Name: "repo1", + URL: "oci://registry.io/namespace/repo", + Data: json.RawMessage(`{ + "tags": [ + {"name": "tag1"}, + {"name": "tag2"}, + {"name": "tag3"}, + {"name": "tag4"}, + {"name": "tag5"}, + {"name": "tag6"}, + {"name": "tag7"}, + {"name": "tag8"}, + {"name": "tag9"}, + {"name": "tag10"}, + {"name": "tag11"} + ] + }`), + }, + nil, + }, } for _, tc := range testCases { tc := tc diff --git a/internal/tracker/helpers.go b/internal/tracker/helpers.go index 4f3de47d2..47695a39f 100644 --- a/internal/tracker/helpers.go +++ b/internal/tracker/helpers.go @@ -6,6 +6,7 @@ import ( "regexp" "github.com/artifacthub/hub/internal/hub" + "github.com/artifacthub/hub/internal/tracker/source/container" "github.com/artifacthub/hub/internal/tracker/source/falco" "github.com/artifacthub/hub/internal/tracker/source/generic" "github.com/artifacthub/hub/internal/tracker/source/helm" @@ -91,6 +92,8 @@ func GetRepositories( func SetupSource(i *hub.TrackerSourceInput) hub.TrackerSource { var source hub.TrackerSource switch i.Repository.Kind { + case hub.Container: + source = container.NewTrackerSource(i) case hub.Falco: // Temporary solution to maintain backwards compatibility with // the only Falco rules repository registered at the moment in diff --git a/internal/tracker/source/container/container.go b/internal/tracker/source/container/container.go new file mode 100644 index 000000000..f9262e2f6 --- /dev/null +++ b/internal/tracker/source/container/container.go @@ -0,0 +1,415 @@ +package container + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "sync" + "time" + + "github.com/artifacthub/hub/internal/hub" + "github.com/artifacthub/hub/internal/img" + "github.com/artifacthub/hub/internal/pkg" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/hashicorp/go-multierror" + "github.com/spf13/viper" +) + +const ( + // Number of tags processed concurrently + concurrency = 10 + + // Annotations based on OCI pre-defined annotation keys + // https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys + appVersionAnnotation = "org.opencontainers.image.version" + createdAnnotation = "org.opencontainers.image.created" + descriptionAnnotation = "org.opencontainers.image.description" + displayNameAnnotation = "org.opencontainers.image.title" + documentationURLAnnotation = "org.opencontainers.image.documentation" + homeURLAnnotation = "org.opencontainers.image.url" + sourceURLAnnotation = "org.opencontainers.image.source" + vendorAnnotation = "org.opencontainers.image.vendor" + + // Artifact Hub specific annotations + alternativeLocationsAnnotation = "io.artifacthub.package.alternativeLocations" + deprecatedAnnotation = "io.artifacthub.package.deprecated" + digestAnnotation = "io.artifacthub.package.digest" // Populated internally in getMetadata + keywordsAnnotation = "io.artifacthub.package.keywords" + licenseAnnotation = "io.artifacthub.package.license" + logoURLAnnotation = "io.artifacthub.package.logoURL" + maintainersAnnotation = "io.artifacthub.package.maintainers" + platformsAnnotation = "io.artifacthub.package.platforms" // Populated internally in getMetadata + prereleaseAnnotation = "io.artifacthub.package.prerelease" + readmeURLAnnotation = "io.artifacthub.package.readmeURL" + securityUpdatesAnnotation = "io.artifacthub.package.containsSecurityUpdates" +) + +var ( + // errUnsupportedMediaType indicates that the image media type is not + // supported and should not be processed. + errUnsupportedMediaType = errors.New("image media type not supported") + + // errInvalidAnnotation indicates that the annotation provided is not valid. + errInvalidAnnotation = errors.New("invalid annotation") + + // requiredMetadata represents the fields that must be present in the image + // metadata. + requiredMetadata = []string{ + createdAnnotation, + descriptionAnnotation, + readmeURLAnnotation, + } +) + +// TrackerSource is a hub.TrackerSource implementation for containers images +// repositories. +type TrackerSource struct { + i *hub.TrackerSourceInput +} + +// NewTrackerSource creates a new TrackerSource instance. +func NewTrackerSource(i *hub.TrackerSourceInput) *TrackerSource { + return &TrackerSource{i: i} +} + +// GetPackagesAvailable implements the TrackerSource interface. +func (s *TrackerSource) GetPackagesAvailable() (map[string]*hub.Package, error) { + var mu sync.Mutex + packagesAvailable := make(map[string]*hub.Package) + + // Prepare tags to process based on already processed ones and mutability config + if s.i.Repository.Data == nil { + // No tags have been set up yet, nothing to do + return packagesAvailable, nil + } + var data *hub.ContainerImageData + if err := json.Unmarshal(s.i.Repository.Data, &data); err != nil { + return nil, fmt.Errorf("invalid container image data: %w", err) + } + var tagsToProcess []string + for _, tag := range data.Tags { + p := &hub.Package{ + Name: path.Base(s.i.Repository.URL), + Version: tag.Name, + } + key := pkg.BuildKey(p) + if _, ok := s.i.PackagesRegistered[key]; !ok || tag.Mutable { + tagsToProcess = append(tagsToProcess, tag.Name) + } else { + p.Digest = hub.HasNotChanged + packagesAvailable[key] = p + } + } + + // Iterate over tags to process and prepare a package version for each + limiter := make(chan struct{}, concurrency) + var wg sync.WaitGroup + for _, tag := range tagsToProcess { + // Return ASAP if context is cancelled + select { + case <-s.i.Svc.Ctx.Done(): + wg.Wait() + return nil, s.i.Svc.Ctx.Err() + default: + } + + // Prepare and store package version + limiter <- struct{}{} + wg.Add(1) + go func(tag string) { + defer func() { + <-limiter + wg.Done() + }() + p, err := PreparePackage(s.i.Svc.Ctx, s.i.Svc.Cfg, s.i.Svc.Hc, s.i.Svc.Is, s.i.Repository, tag) + if err != nil { + s.warn(fmt.Errorf("error preparing package (tag: %s): %w", tag, err)) + return + } + mu.Lock() + packagesAvailable[pkg.BuildKey(p)] = p + mu.Unlock() + }(tag) + } + wg.Wait() + + return packagesAvailable, nil +} + +// warn is a helper that sends the error provided to the errors collector and +// logs it as a warning. +func (s *TrackerSource) warn(err error) { + s.i.Svc.Logger.Warn().Err(err).Send() + s.i.Svc.Ec.Append(s.i.Repository.RepositoryID, err.Error()) +} + +// PreparePackage prepares a package version from the metadata available in the +// container image identified by the tag provided. +func PreparePackage( + ctx context.Context, + cfg *viper.Viper, + hc hub.HTTPClient, + is img.Store, + r *hub.Repository, + tag string, +) (*hub.Package, error) { + // Get container image metadata + md, err := getMetadata(ctx, cfg, r, tag) + if err != nil { + return nil, fmt.Errorf("error getting metadata: %w", err) + } + + // Check required metadata fields are present + var errs *multierror.Error + for _, key := range requiredMetadata { + if _, ok := md[key]; !ok { + errs = multierror.Append(errs, fmt.Errorf("required metadata field not provided: %s", key)) + } + } + + // Prepare package from metadata + p := &hub.Package{ + Name: path.Base(r.URL), + Version: tag, + DisplayName: md[displayNameAnnotation], + Description: md[descriptionAnnotation], + HomeURL: md[homeURLAnnotation], + Digest: md[digestAnnotation], + AppVersion: md[appVersionAnnotation], + License: md[licenseAnnotation], + Provider: md[vendorAnnotation], + ContainersImages: []*hub.ContainerImage{ + { + Image: fmt.Sprintf("%s:%s", strings.TrimPrefix(r.URL, hub.RepositoryOCIPrefix), tag), + }, + }, + Data: make(map[string]interface{}), + Repository: r, + } + + // Created timestamp + if v, ok := md[createdAnnotation]; ok { + ts, err := time.Parse(time.RFC3339, v) + if err != nil { + errs = multierror.Append(errs, err) + } else { + p.TS = ts.Unix() + } + } + + // Readme + if v, ok := md[readmeURLAnnotation]; ok { + data, err := getContent(ctx, hc, v) + if err != nil { + errs = multierror.Append(errs, fmt.Errorf("error getting readme file content: %w", err)) + } else { + p.Readme = string(data) + } + } + + // Keywords + if v, ok := md[keywordsAnnotation]; ok && v != "" { + var keywords []string + for _, keyword := range strings.Split(v, ",") { + keywords = append(keywords, strings.TrimSpace(keyword)) + } + p.Keywords = keywords + } + + // Store logo when available if requested + if v, ok := md[logoURLAnnotation]; ok { + logoImageID, err := is.DownloadAndSaveImage(ctx, v) + if err != nil { + errs = multierror.Append(errs, fmt.Errorf("error downloading logo image: %w", err)) + } else { + p.LogoURL = v + p.LogoImageID = logoImageID + } + } + + // Links + var links []*hub.Link + if documentationURLAnnotation != "" { + links = append(links, &hub.Link{ + Name: "documentation", + URL: documentationURLAnnotation, + }) + } + if sourceURLAnnotation != "" { + links = append(links, &hub.Link{ + Name: "source", + URL: sourceURLAnnotation, + }) + } + p.Links = links + + // Maintainers + if v, ok := md[maintainersAnnotation]; ok { + var maintainers []*hub.Maintainer + if err := json.Unmarshal([]byte(v), &maintainers); err != nil { + errs = multierror.Append(errs, fmt.Errorf("%w: invalid maintainers value", errInvalidAnnotation)) + } else { + p.Maintainers = maintainers + } + } + + // Security updates + if v, ok := md[securityUpdatesAnnotation]; ok { + containsSecurityUpdates, err := strconv.ParseBool(v) + if err != nil { + errs = multierror.Append(errs, fmt.Errorf("%w: invalid containsSecurityUpdates value", errInvalidAnnotation)) + } else { + p.ContainsSecurityUpdates = containsSecurityUpdates + } + } + + // Pre-release + if v, ok := md[prereleaseAnnotation]; ok { + prerelease, err := strconv.ParseBool(v) + if err != nil { + errs = multierror.Append(errs, fmt.Errorf("%w: invalid prerelease value", errInvalidAnnotation)) + } else { + p.Prerelease = prerelease + } + } + + // Deprecated + if v, ok := md[deprecatedAnnotation]; ok { + deprecated, err := strconv.ParseBool(v) + if err != nil { + errs = multierror.Append(errs, fmt.Errorf("%w: invalid deprecated value", errInvalidAnnotation)) + } else { + p.Deprecated = deprecated + } + } + + // Platforms + if v, ok := md[platformsAnnotation]; ok && v != "" { + p.Data["platforms"] = strings.Split(v, ",") + } + + // Alternative locations + if v, ok := md[alternativeLocationsAnnotation]; ok && v != "" { + var alternativeLocations []string + for _, l := range strings.Split(v, ",") { + alternativeLocations = append(alternativeLocations, strings.TrimSpace(l)) + } + p.Data["alternativeLocations"] = alternativeLocations + } + + if errs.ErrorOrNil() != nil { + return nil, errs + } + return p, nil +} + +// getMetadata returns the metadata available in annotations and labels in the +// container image identified by the reference provided. Depending on the image +// media type the metadata will be obtained from annotations or labels. +func getMetadata(ctx context.Context, cfg *viper.Viper, r *hub.Repository, tag string) (map[string]string, error) { + // Prepare options for remote operations + ref, err := name.ParseReference(fmt.Sprintf("%s:%s", strings.TrimPrefix(r.URL, hub.RepositoryOCIPrefix), tag)) + if err != nil { + return nil, err + } + options := []remote.Option{ + remote.WithContext(ctx), + } + if r.AuthUser != "" || r.AuthPass != "" { + options = append(options, remote.WithAuth(&authn.Basic{ + Username: r.AuthUser, + Password: r.AuthPass, + })) + } else if strings.HasSuffix(ref.Context().Registry.Name(), "docker.io") { + options = append(options, remote.WithAuth(&authn.Basic{ + Username: cfg.GetString("creds.dockerUsername"), + Password: cfg.GetString("creds.dockerPassword"), + })) + } + + // Get image manifest + desc, err := remote.Get(ref, options...) + if err != nil { + return nil, err + } + image, err := desc.Image() + if err != nil { + return nil, err + } + manifest, err := image.Manifest() + if err != nil { + return nil, err + } + + // Prepare metadata from manifest annotations or config labels, based on media type + md := make(map[string]string) + switch manifest.MediaType { + case types.OCIManifestSchema1, "": + for k, v := range manifest.Annotations { + md[k] = v + } + case types.DockerManifestSchema2: + configFile, err := image.ConfigFile() + if err != nil { + return nil, err + } + for k, v := range configFile.Config.Labels { + md[k] = v + } + default: + return nil, errUnsupportedMediaType + } + + // Get image digest + digest, err := image.Digest() + if err == nil { + md[digestAnnotation] = digest.String() + } + + // Get supported platform from images index / manifest list when available + index, err := desc.ImageIndex() + if err == nil { + indexManifest, err := index.IndexManifest() + if err == nil { + var platforms []string + for _, m := range indexManifest.Manifests { + platforms = append(platforms, fmt.Sprintf("%s/%s", m.Platform.OS, m.Platform.Architecture)) + } + md[platformsAnnotation] = strings.Join(platforms, ",") + } + } + + return md, nil +} + +// getContent returns the content of the url provided. +func getContent( + ctx context.Context, + hc hub.HTTPClient, + u string, +) ([]byte, error) { + req, _ := http.NewRequest("GET", u, nil) + req = req.WithContext(ctx) + if _, err := url.Parse(u); err != nil { + return nil, err + } + resp, err := hc.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusOK { + return ioutil.ReadAll(resp.Body) + } + return nil, fmt.Errorf("unexpected status code received: %d", resp.StatusCode) +} diff --git a/internal/tracker/source/container/container_test.go b/internal/tracker/source/container/container_test.go new file mode 100644 index 000000000..a754ff8bf --- /dev/null +++ b/internal/tracker/source/container/container_test.go @@ -0,0 +1,9 @@ +package container + +import ( + "testing" +) + +func TestTrackerSource(t *testing.T) { + // TODO(tegioz) +} diff --git a/internal/tracker/source/helm/helm.go b/internal/tracker/source/helm/helm.go index 5cecc3cf9..97147182b 100644 --- a/internal/tracker/source/helm/helm.go +++ b/internal/tracker/source/helm/helm.go @@ -176,7 +176,7 @@ func (s *TrackerSource) getCharts() (map[string][]*helmrepo.ChartVersion, error) } case "oci": // Get versions (tags) available in the repository - versions, err := s.tg.Tags(s.i.Svc.Ctx, s.i.Repository) + versions, err := s.tg.Tags(s.i.Svc.Ctx, s.i.Repository, true) if err != nil { return nil, fmt.Errorf("error getting repository available versions: %w", err) } diff --git a/internal/tracker/source/helm/helm_test.go b/internal/tracker/source/helm/helm_test.go index 628e97ce7..247bbfd83 100644 --- a/internal/tracker/source/helm/helm_test.go +++ b/internal/tracker/source/helm/helm_test.go @@ -152,7 +152,7 @@ func TestTrackerSource(t *testing.T) { Svc: sw.Svc, } tg := &oci.TagsGetterMock{} - tg.On("Tags", i.Svc.Ctx, i.Repository).Return(nil, tests.ErrFake) + tg.On("Tags", i.Svc.Ctx, i.Repository, true).Return(nil, tests.ErrFake) // Run test and check expectations packages, err := NewTrackerSource(i, withOCITagsGetter(tg)).GetPackagesAvailable() @@ -333,7 +333,7 @@ func TestTrackerSource(t *testing.T) { Svc: sw.Svc, } tg := &oci.TagsGetterMock{} - tg.On("Tags", i.Svc.Ctx, i.Repository).Return([]string{"1.0.0"}, nil) + tg.On("Tags", i.Svc.Ctx, i.Repository, true).Return([]string{"1.0.0"}, nil) ref := strings.TrimPrefix(i.Repository.URL, hub.RepositoryOCIPrefix) + ":1.0.0" sw.Op.On("PullLayer", mock.Anything, ref, ChartContentLayerMediaType, "", ""). Return(ocispec.Descriptor{}, nil, oci.ErrArtifactNotFound) @@ -475,7 +475,7 @@ func TestTrackerSource(t *testing.T) { } ref := strings.TrimPrefix(i.Repository.URL, hub.RepositoryOCIPrefix) + ":1.0.0" tg := &oci.TagsGetterMock{} - tg.On("Tags", i.Svc.Ctx, i.Repository).Return([]string{"1.0.0"}, nil) + tg.On("Tags", i.Svc.Ctx, i.Repository, true).Return([]string{"1.0.0"}, nil) sc := &oci.SignatureCheckerMock{} sc.On("HasCosignSignature", i.Svc.Ctx, ref, "", "").Return(true, nil) data, _ := os.ReadFile("testdata/pkg1-1.0.0.tgz") @@ -513,7 +513,7 @@ func TestExtractContainersImages(t *testing.T) { chrt, err := loader.LoadArchive(f) require.NoError(t, err) - // Extract container images and check expectations + // Extract containers images and check expectations containersImages, err := extractContainersImages(chrt) require.NoError(t, err) assert.Equal(t, []string{ diff --git a/internal/tracker/tracker.go b/internal/tracker/tracker.go index b6b22c721..8eb9633c7 100644 --- a/internal/tracker/tracker.go +++ b/internal/tracker/tracker.go @@ -86,7 +86,7 @@ func (t *Tracker) Run() error { // Check if this package version is already registered digest, ok := t.packagesRegistered[pkg.BuildKey(p)] - if ok && p.Digest == digest && !bypassDigestCheck { + if ok && (p.Digest == digest || p.Digest == hub.HasNotChanged) && !bypassDigestCheck { continue } @@ -151,8 +151,10 @@ func (t *Tracker) cloneRepository() (string, string, error) { var err error switch t.r.Kind { - case hub.Helm: - // Helm repositories are not cloned + case + hub.Container, + hub.Helm: + // These repositories are not cloned case hub.OLM: if strings.HasPrefix(t.r.URL, hub.RepositoryOCIPrefix) { tmpDir, err = t.svc.Oe.ExportRepository(t.svc.Ctx, t.r) diff --git a/internal/tracker/tracker_test.go b/internal/tracker/tracker_test.go index 7d828dee5..5cf587688 100644 --- a/internal/tracker/tracker_test.go +++ b/internal/tracker/tracker_test.go @@ -209,7 +209,7 @@ func TestTracker(t *testing.T) { sw.assertExpectations(t) }) - t.Run("package available but not registered because it already was", func(t *testing.T) { + t.Run("package available but not registered because it already was (same digest)", func(t *testing.T) { t.Parallel() // Setup services and expectations @@ -230,6 +230,29 @@ func TestTracker(t *testing.T) { sw.assertExpectations(t) }) + t.Run("package available but not registered because it already was (digest has not changed)", func(t *testing.T) { + t.Parallel() + + // Setup services and expectations + sw := newServicesWrapper() + sw.rm.On("GetRemoteDigest", sw.svc.Ctx, r1).Return("", nil) + sw.ec.On("Init", r1.RepositoryID) + sw.rm.On("GetMetadata", r1, "").Return(nil, nil) + sw.rm.On("GetPackagesDigest", sw.svc.Ctx, r1.RepositoryID).Return(map[string]string{ + pkg.BuildKey(p1v1): "", + }, nil) + p := source.ClonePackage(p1v1) + p.Digest = hub.HasNotChanged + sw.src.On("GetPackagesAvailable").Return(map[string]*hub.Package{ + pkg.BuildKey(p1v1): p, + }, nil) + + // Run test and check expectations + err := New(sw.svc, r1, zerolog.Nop()).Run() + assert.Nil(t, err) + sw.assertExpectations(t) + }) + t.Run("package available but not registered because it should be ignored", func(t *testing.T) { t.Parallel() diff --git a/scripts/docker-build.sh b/scripts/docker-build.sh index 91598b8e0..31a445800 100755 --- a/scripts/docker-build.sh +++ b/scripts/docker-build.sh @@ -2,8 +2,55 @@ # Build docker images GIT_SHA=$(git rev-parse HEAD) -docker build -f cmd/ah/Dockerfile -t artifacthub/ah -t artifacthub/ah:$GIT_SHA --build-arg VERSION=devel --build-arg GIT_COMMIT=$GIT_SHA . -docker build -f cmd/hub/Dockerfile -t artifacthub/hub -t artifacthub/hub:$GIT_SHA . -docker build -f database/migrations/Dockerfile -t artifacthub/db-migrator -t artifacthub/db-migrator:$GIT_SHA . -docker build -f cmd/scanner/Dockerfile -t artifacthub/scanner -t artifacthub/scanner:$GIT_SHA . -docker build -f cmd/tracker/Dockerfile -t artifacthub/tracker -t artifacthub/tracker:$GIT_SHA . +TS=$(date -u +"%Y-%m-%dT%H:%M:%SZ") +VERSION=devel + +# ah +docker build \ + -f cmd/ah/Dockerfile \ + -t artifacthub/ah \ + -t artifacthub/ah:$GIT_SHA \ + -t localhost:5000/artifacthub/ah:$VERSION \ + --build-arg VERSION=$VERSION \ + --build-arg GIT_COMMIT=$GIT_SHA \ + --label org.opencontainers.image.description='Artifact Hub command line tool' \ + --label org.opencontainers.image.version=$VERSION \ + --label org.opencontainers.image.created=$TS \ + --label org.opencontainers.image.documentation='https://artifacthub.io/docs/topics/cli' \ + --label org.opencontainers.image.source='https://github.com/artifacthub/hub/tree/${GIT_SHA}/cmd/ah' \ + --label org.opencontainers.image.vendor='Artifact Hub' \ + --label io.artifacthub.package.readmeURL='https://raw.githubusercontent.com/artifacthub/hub/${GIT_SHA}/docs/cli.md' \ + --label io.artifacthub.package.maintainers='[{"name":"Artifact Hub maintainers","email":"cncf-artifacthub-maintainers@lists.cncf.io"}]' \ + --label io.artifacthub.package.logoURL='https://raw.githubusercontent.com/artifacthub/hub/master/docs/logo/logo.svg' \ + --label io.artifacthub.package.keywords='artifact hub,cli,lint' \ + --label io.artifacthub.package.license='Apache-2.0' \ + --label io.artifacthub.package.alternativeLocations='public.ecr.aws/artifacthub/ah' \ +. + +# hub +docker build \ + -f cmd/hub/Dockerfile \ + -t artifacthub/hub \ + -t artifacthub/hub:$GIT_SHA \ +. + +# db-migrator +docker build \ + -f database/migrations/Dockerfile \ + -t artifacthub/db-migrator \ + -t artifacthub/db-migrator:$GIT_SHA \ +. + +# scanner +docker build \ + -f cmd/scanner/Dockerfile \ + -t artifacthub/scanner \ + -t artifacthub/scanner:$GIT_SHA \ +. + +# tracker +docker build \ + -f cmd/tracker/Dockerfile \ + -t artifacthub/tracker \ + -t artifacthub/tracker:$GIT_SHA \ +. diff --git a/web/package.json b/web/package.json index aef3e14a0..ea1afe28a 100644 --- a/web/package.json +++ b/web/package.json @@ -15,6 +15,7 @@ "json-schema-merge-allof": "^0.7.0", "lodash": "^4.17.21", "moment": "^2.29.1", + "nanoid": "^3.1.30", "react": "^17.0.2", "react-apexcharts": "^1.3.9", "react-codemirror2": "^7.2.1", diff --git a/web/public/static/media/container-light.svg b/web/public/static/media/container-light.svg new file mode 100644 index 000000000..113514dee --- /dev/null +++ b/web/public/static/media/container-light.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/web/public/static/media/container.svg b/web/public/static/media/container.svg new file mode 100644 index 000000000..72edf253e --- /dev/null +++ b/web/public/static/media/container.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/web/public/static/media/container_icon.png b/web/public/static/media/container_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d4f4e7a352f2dbba4bc8683b85db63480c7fcece GIT binary patch literal 47926 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 literal 0 HcmV?d00001 diff --git a/web/public/static/media/placeholder_pkg_container.png b/web/public/static/media/placeholder_pkg_container.png new file mode 100644 index 0000000000000000000000000000000000000000..86bcb95caba7a646bc40dc050e19606a0e4faf4b GIT binary patch literal 127002 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%0A?&L*+goSDXukb6zP~FOd*@37lTUg$-wy?8B7z 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 literal 0 HcmV?d00001 diff --git a/web/public/static/media/registries/amazon.svg b/web/public/static/media/registries/amazon.svg new file mode 100644 index 000000000..4715937ff --- /dev/null +++ b/web/public/static/media/registries/amazon.svg @@ -0,0 +1,38 @@ + + + + + + + + + + + + diff --git a/web/public/static/media/registries/azure.svg b/web/public/static/media/registries/azure.svg new file mode 100644 index 000000000..5334aa7ca --- /dev/null +++ b/web/public/static/media/registries/azure.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/web/public/static/media/registries/bundlebar.svg b/web/public/static/media/registries/bundlebar.svg new file mode 100644 index 000000000..5ae720459 --- /dev/null +++ b/web/public/static/media/registries/bundlebar.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/web/public/static/media/registries/docker.svg b/web/public/static/media/registries/docker.svg new file mode 100644 index 000000000..828ed809c --- /dev/null +++ b/web/public/static/media/registries/docker.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/web/public/static/media/registries/github.svg b/web/public/static/media/registries/github.svg new file mode 100644 index 000000000..a8d117404 --- /dev/null +++ b/web/public/static/media/registries/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/web/public/static/media/registries/google.svg b/web/public/static/media/registries/google.svg new file mode 100644 index 000000000..4d734f169 --- /dev/null +++ b/web/public/static/media/registries/google.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/public/static/media/registries/quay.svg b/web/public/static/media/registries/quay.svg new file mode 100644 index 000000000..a9b4a83ac --- /dev/null +++ b/web/public/static/media/registries/quay.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/web/public/static/media/registries/unknown.svg b/web/public/static/media/registries/unknown.svg new file mode 100644 index 000000000..38a599cf6 --- /dev/null +++ b/web/public/static/media/registries/unknown.svg @@ -0,0 +1,2 @@ + + diff --git a/web/src/layout/common/Image.tsx b/web/src/layout/common/Image.tsx index ab0fca013..de1348f63 100644 --- a/web/src/layout/common/Image.tsx +++ b/web/src/layout/common/Image.tsx @@ -52,6 +52,8 @@ const Image = (props: Props) => { return '/static/media/placeholder_pkg_coredns.png'; case RepositoryKind.Keptn: return '/static/media/placeholder_pkg_keptn.png'; + case RepositoryKind.Container: + return '/static/media/placeholder_pkg_container.png'; default: return PLACEHOLDER_SRC; } diff --git a/web/src/layout/common/InputField.tsx b/web/src/layout/common/InputField.tsx index e6d95bb10..6904643fd 100644 --- a/web/src/layout/common/InputField.tsx +++ b/web/src/layout/common/InputField.tsx @@ -43,6 +43,7 @@ export interface Props { disabled?: boolean; visiblePassword?: boolean; excludedValues?: string[]; + smallBottomMargin?: boolean; } const VALIDATION_DELAY = 3 * 100; // 300ms @@ -205,7 +206,7 @@ const InputField = forwardRef((props: Props, ref: Ref) => { }, [inputValue]); /* eslint-disable-line react-hooks/exhaustive-deps */ return ( -
+
{!isUndefined(props.label) && (
- Version: + + {props.package.repository.kind === RepositoryKind.Container ? 'Tag' : 'Version'}:{' '} + {cutString(props.package.version || '-')} {(() => { switch (props.package.repository.kind) { case RepositoryKind.Helm: + case RepositoryKind.Container: return ( <> {props.package.appVersion && ( diff --git a/web/src/layout/common/RepositoryIcon.test.tsx b/web/src/layout/common/RepositoryIcon.test.tsx index 46eb5bebe..08ae0a574 100644 --- a/web/src/layout/common/RepositoryIcon.test.tsx +++ b/web/src/layout/common/RepositoryIcon.test.tsx @@ -93,6 +93,13 @@ describe('RepositoryIcon', () => { expect(icon).toHaveProperty('src', 'http://localhost/static/media/tekton-pkg-light.svg'); }); + it('renders Container icon', () => { + render(); + const icon = screen.getByAltText('Icon'); + expect(icon).toBeInTheDocument(); + expect(icon).toHaveProperty('src', 'http://localhost/static/media/container-light.svg'); + }); + it('renders Chart icon - default type', () => { render(); const icons = screen.getAllByAltText('Icon'); diff --git a/web/src/layout/common/RepositoryIcon.tsx b/web/src/layout/common/RepositoryIcon.tsx index 2af1716ad..3204a9301 100644 --- a/web/src/layout/common/RepositoryIcon.tsx +++ b/web/src/layout/common/RepositoryIcon.tsx @@ -57,6 +57,10 @@ const ICONS = { default: '/static/media/tekton-pkg.svg', white: '/static/media/tekton-pkg-light.svg', }, + [RepositoryKind.Container]: { + default: '/static/media/container.svg', + white: '/static/media/container-light.svg', + }, }; const RepositoryIcon = (props: Props) => { diff --git a/web/src/layout/common/__snapshots__/InputField.test.tsx.snap b/web/src/layout/common/__snapshots__/InputField.test.tsx.snap index b9a5e79eb..0bad03e72 100644 --- a/web/src/layout/common/__snapshots__/InputField.test.tsx.snap +++ b/web/src/layout/common/__snapshots__/InputField.test.tsx.snap @@ -3,7 +3,7 @@ exports[`InputField creates snapshot 1`] = `
{ autoComplete="on" noValidate > -
+
@@ -620,6 +741,7 @@ const RepositoryModal = (props: Props) => { resourceKind: ResourceKind.repositoryURL, excluded: props.repository ? [props.repository.url] : [], }} + placeholder={selectedKind === RepositoryKind.Container ? OCI_PREFIX : ''} pattern={getURLPattern()} onChange={onUrlInputChange} required @@ -627,6 +749,15 @@ const RepositoryModal = (props: Props) => { {getAdditionalInfo()} + {selectedKind === RepositoryKind.Container && ( + + )} + {[ RepositoryKind.Falco, RepositoryKind.OLM, @@ -731,6 +862,7 @@ const RepositoryModal = (props: Props) => { RepositoryKind.CoreDNS, RepositoryKind.Keptn, RepositoryKind.TektonPipeline, + RepositoryKind.Container, ].includes(selectedKind) && (
diff --git a/web/src/layout/controlPanel/repositories/TagsList.module.css b/web/src/layout/controlPanel/repositories/TagsList.module.css new file mode 100644 index 000000000..d4378b88e --- /dev/null +++ b/web/src/layout/controlPanel/repositories/TagsList.module.css @@ -0,0 +1,36 @@ +.label { + font-size: 0.875rem; +} + +.btn { + width: 1.25rem; + height: 1.25rem; + padding: 2px; +} + +.inTitle { + top: -2px; +} + +.btnWrapper { + width: 2rem; + top: -2px; +} + +.inputWrapper { + top: 3px; +} + +.checkbox, +.checkbox + label { + cursor: pointer; +} + +.checkbox:disabled, +.checkbox:disabled + label { + cursor: auto; +} + +.cellWrapper { + table-layout: fixed; +} diff --git a/web/src/layout/controlPanel/repositories/TagsList.tsx b/web/src/layout/controlPanel/repositories/TagsList.tsx new file mode 100644 index 000000000..fad1f6e34 --- /dev/null +++ b/web/src/layout/controlPanel/repositories/TagsList.tsx @@ -0,0 +1,141 @@ +import { isEmpty } from 'lodash'; +import { nanoid } from 'nanoid'; +import { ChangeEvent, Dispatch, MouseEvent as ReactMouseEvent, SetStateAction, useEffect } from 'react'; +import { HiPlus } from 'react-icons/hi'; +import { MdClose } from 'react-icons/md'; + +import { ContainerTag } from '../../../types'; +import InputField from '../../common/InputField'; +import styles from './TagsList.module.css'; + +interface Props { + tags: ContainerTag[]; + setContainerTags: Dispatch>; + repeatedTagNames: boolean; + setRepeatedTagNames: Dispatch>; +} + +const EMPTY_TAG = { name: '', mutable: false }; + +const TagsList = (props: Props) => { + const cleanRepeatedError = () => { + if (props.repeatedTagNames) { + props.setRepeatedTagNames(false); + } + }; + + const deleteTag = (index: number) => { + cleanRepeatedError(); + let updatedTags = [...props.tags]; + updatedTags.splice(index, 1); + props.setContainerTags(updatedTags); + }; + + const addTag = () => { + props.setContainerTags([...props.tags, { ...EMPTY_TAG, id: nanoid() }]); + }; + + const onUpdateTag = (index: number, field: 'name' | 'mutable', value?: string) => { + cleanRepeatedError(); + let tagToUpdate: ContainerTag = props.tags[index]; + if (field === 'name') { + tagToUpdate[field] = value as string; + } else { + tagToUpdate[field] = !tagToUpdate.mutable; + } + let updatedTags = [...props.tags]; + updatedTags[index] = tagToUpdate; + props.setContainerTags(updatedTags); + }; + + useEffect(() => { + if (isEmpty(props.tags)) { + props.setContainerTags([{ ...EMPTY_TAG, id: nanoid() }]); + } + }, [props.tags]); /* eslint-disable-line react-hooks/exhaustive-deps */ + + return ( +
+ + + {props.tags.length > 0 && ( + <> + {props.tags.map((item: ContainerTag, idx: number) => { + return ( +
+ ) => { + onUpdateTag(idx, 'name', e.target.value); + }} + smallBottomMargin + /> + +
+
+
+ + { + onUpdateTag(idx, 'mutable'); + }} + /> +
+
+ +
+ +
+
+
+ ); + })} + + )} + + {props.repeatedTagNames &&
Tags names must be unique.
} + +
+ The tags you'd like to list on Artifact Hub must be explicitly added. You can add up to{' '} + 10 (they can be edited later though). Mutable tags will be processed + periodically, whereas immutable tags will be only processed once. +
+
+ ); +}; + +export default TagsList; diff --git a/web/src/layout/controlPanel/repositories/__snapshots__/DeletionModal.test.tsx.snap b/web/src/layout/controlPanel/repositories/__snapshots__/DeletionModal.test.tsx.snap index 5db0d97c0..21a8b95a7 100644 --- a/web/src/layout/controlPanel/repositories/__snapshots__/DeletionModal.test.tsx.snap +++ b/web/src/layout/controlPanel/repositories/__snapshots__/DeletionModal.test.tsx.snap @@ -74,7 +74,7 @@ exports[`Deletion modal Modal - packages section creates snapshot 1`] = ` to confirm:

diff --git a/web/src/layout/home/index.test.tsx b/web/src/layout/home/index.test.tsx index 0bb38a332..eb6f1f264 100644 --- a/web/src/layout/home/index.test.tsx +++ b/web/src/layout/home/index.test.tsx @@ -124,7 +124,7 @@ describe('Home index', () => { await waitFor(() => expect(API.getStats).toHaveBeenCalledTimes(1)); const links = screen.getAllByRole('button'); - expect(links).toHaveLength(17); + expect(links).toHaveLength(18); expect(links[2]).toHaveProperty('href', 'https://github.com/cncf/hub'); expect(links[3]).toHaveProperty('href', 'https://cloud-native.slack.com/channels/artifact-hub'); @@ -144,8 +144,9 @@ describe('Home index', () => { expect(links[13]).toHaveProperty('href', 'https://keda.sh/'); expect(links[14]).toHaveProperty('href', 'https://coredns.io/'); expect(links[15]).toHaveProperty('href', 'https://keptn.sh/'); + expect(links[16]).toHaveProperty('href', 'https://opencontainers.org/'); - expect(links[16]).toHaveProperty('href', 'https://www.cncf.io/sandbox-projects/'); + expect(links[17]).toHaveProperty('href', 'https://www.cncf.io/sandbox-projects/'); }); }); }); diff --git a/web/src/layout/home/index.tsx b/web/src/layout/home/index.tsx index 927098e9a..10f357321 100644 --- a/web/src/layout/home/index.tsx +++ b/web/src/layout/home/index.tsx @@ -204,7 +204,7 @@ const HomeView = (props: Props) => { Artifact Hub is a web-based application that enables finding, installing, and publishing packages and configurations for CNCF projects. For example, this could include Helm charts and plugins, Falco configurations, Open Policy Agent (OPA) policies, OLM operators, Tinkerbell actions, kubectl plugins, - Tekton tasks and pipelines, KEDA scalers, CoreDNS plugins and Keptn integrations. + Tekton tasks and pipelines, KEDA scalers, CoreDNS plugins, Keptn integrations and container images.
@@ -258,8 +258,6 @@ const HomeView = (props: Props) => {
-
-
{
+
+
@@ -309,6 +309,18 @@ const HomeView = (props: Props) => {
+ +
+ +
+ Container images +
+
+
Discovering artifacts to use with CNCF projects can be difficult. If every CNCF project that needs to share artifacts creates its own Hub this creates a fair amount of repeat work for each project and a diff --git a/web/src/layout/navigation/__snapshots__/CreateAnAccount.test.tsx.snap b/web/src/layout/navigation/__snapshots__/CreateAnAccount.test.tsx.snap index c573eb329..4a1e7afd1 100644 --- a/web/src/layout/navigation/__snapshots__/CreateAnAccount.test.tsx.snap +++ b/web/src/layout/navigation/__snapshots__/CreateAnAccount.test.tsx.snap @@ -9,7 +9,7 @@ exports[`CreateAnAccount creates snapshot 1`] = ` novalidate="" >
-
This package version is a pre-release and it is not ready for production use.
+
This package {{ if eq .Package.Repository.Kind "container" }}tag{{ else }}version{{ end }} is a pre-release and it is not ready for production use.
-
This package version contains security updates.
+
This package {{ if eq .Package.Repository.Kind "container" }}tag{{ else }}version{{ end }} contains security updates.
), }; - }, [props.containers]); + }, [props.containers, props.kind]); const [containers, setContainers] = useState(getAllContainers()); diff --git a/web/src/layout/package/Details.tsx b/web/src/layout/package/Details.tsx index a367a9540..c1112e36a 100644 --- a/web/src/layout/package/Details.tsx +++ b/web/src/layout/package/Details.tsx @@ -17,6 +17,8 @@ import RSSLinkTitle from '../common/RSSLinkTitle'; import SeeAllModal from '../common/SeeAllModal'; import SmallTitle from '../common/SmallTitle'; import CapabilityLevel from './CapabilityLevel'; +import ContainerAlternativeLocations from './ContainerAlternativeLocations'; +import ContainerRegistry from './ContainerRegistry'; import ContainersImages from './ContainersImages'; import Dependencies from './Dependencies'; import styles from './Details.module.css'; @@ -52,6 +54,17 @@ interface VersionsProps { itemsForModal: JSX.Element[] | JSX.Element; } +const getVersionsTitle = (repoKind: RepositoryKind): string => { + switch (repoKind) { + case RepositoryKind.Helm: + return 'Chart versions'; + case RepositoryKind.Container: + return 'Tags'; + default: + return 'Versions'; + } +}; + const Details = (props: Props) => { const getAllVersions = useCallback((): VersionsProps => { let items: JSX.Element[] = []; @@ -149,6 +162,26 @@ const Details = (props: Props) => { )} ); + case RepositoryKind.Container: + return ( + <> +
+ + +
+ {props.package.data && props.package.data.alternativeLocations && ( + + )} + {props.package.appVersion && ( +
+ +

+ {props.package.appVersion} +

+
+ )} + + ); default: return null; } @@ -156,7 +189,7 @@ const Details = (props: Props) => {
{ ) : (
{
{ })()} { )} - + {props.package.repository.kind === RepositoryKind.Helm && !isUndefined(props.package.data) && @@ -330,10 +369,9 @@ const Details = (props: Props) => { )} - {props.package.repository.kind === RepositoryKind.Krew && - !isUndefined(props.package.data) && - !isNull(props.package.data) && - !isUndefined(props.package.data.platforms) && } + {props.package.data && props.package.data.platforms && ( + + )} diff --git a/web/src/layout/package/Last30DaysViews.test.tsx b/web/src/layout/package/Last30DaysViews.test.tsx index 598bdd979..b397df9ef 100644 --- a/web/src/layout/package/Last30DaysViews.test.tsx +++ b/web/src/layout/package/Last30DaysViews.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { BrowserRouter as Router } from 'react-router-dom'; +import { RepositoryKind } from '../../types'; import Last30DaysViews from './Last30DaysViews'; jest.mock('react-apexcharts', () => () =>
Chart
); @@ -91,6 +92,11 @@ const stats = { }, }; +const defaultProps = { + repoKind: RepositoryKind.Helm, + stats: stats, +}; + describe('Last30DaysViews', () => { let dateNowSpy: any; @@ -107,13 +113,13 @@ describe('Last30DaysViews', () => { }); it('creates snapshot', () => { - const { asFragment } = render(); + const { asFragment } = render(); expect(asFragment()).toMatchSnapshot(); }); describe('Render', () => { it('renders component', () => { - render(); + render(); expect(screen.getByText('Last 30 days views')).toBeInTheDocument(); expect(screen.getByText('Chart')).toBeInTheDocument(); @@ -122,8 +128,15 @@ describe('Last30DaysViews', () => { expect(screen.getByText('(all versions)')).toBeInTheDocument(); }); - it('renders component', () => { - render(); + it('displays correct legend', () => { + render(); + + expect(screen.getByText('(all tags)')).toBeInTheDocument(); + expect(screen.queryByText('(all versions)')).toBeNull(); + }); + + it('renders component when version is defined', () => { + render(); expect(screen.getByText('Last 30 days views')).toBeInTheDocument(); expect(screen.getByText('Chart')).toBeInTheDocument(); @@ -135,7 +148,7 @@ describe('Last30DaysViews', () => { it('goes to Views chart section', () => { render( - + ); @@ -154,10 +167,10 @@ describe('Last30DaysViews', () => { }); it('when stats are empty', () => { - const { rerender } = render(); + const { rerender } = render(); expect(screen.getByText('Loading...')).toBeInTheDocument(); - rerender(); + rerender(); expect(screen.getByText('Last 30 days views')).toBeInTheDocument(); expect(screen.getByText('No views yet')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'See views chart' })).toBeDisabled(); diff --git a/web/src/layout/package/Last30DaysViews.tsx b/web/src/layout/package/Last30DaysViews.tsx index 7d101bef8..4168e0c5f 100644 --- a/web/src/layout/package/Last30DaysViews.tsx +++ b/web/src/layout/package/Last30DaysViews.tsx @@ -5,12 +5,13 @@ import ReactApexChart from 'react-apexcharts'; import { HiPlusCircle } from 'react-icons/hi'; import { useHistory } from 'react-router-dom'; -import { PackageViewsStats, SearchFiltersURL } from '../../types'; +import { PackageViewsStats, RepositoryKind, SearchFiltersURL } from '../../types'; import { getSeriesDataPerPkgVersionViews, sumViewsPerVersions } from '../../utils/viewsStats'; import SmallTitle from '../common/SmallTitle'; import styles from './Last30DaysViews.module.css'; interface Props { + repoKind: RepositoryKind; stats?: PackageViewsStats; version?: string; searchUrlReferer?: SearchFiltersURL; @@ -38,6 +39,18 @@ const Last30DaysViews = (props: Props) => { const history = useHistory(); const [series, setSeries] = useState([]); + const getLegend = (): string => { + if (props.version) { + return props.version; + } else { + if (props.repoKind === RepositoryKind.Container) { + return 'all tags'; + } else { + return 'all versions'; + } + } + }; + const getSparkLineConfig = (): ApexCharts.ApexOptions => { return { chart: { @@ -193,7 +206,7 @@ const Last30DaysViews = (props: Props) => {
- ({props.version || 'all versions'}) + ({getLegend()})
diff --git a/web/src/layout/package/PackageViewsStats.test.tsx b/web/src/layout/package/PackageViewsStats.test.tsx index 46ada3439..ea2dc1a3e 100644 --- a/web/src/layout/package/PackageViewsStats.test.tsx +++ b/web/src/layout/package/PackageViewsStats.test.tsx @@ -1,10 +1,12 @@ import { render, screen } from '@testing-library/react'; +import { RepositoryKind } from '../../types'; import PackageViewsStats from './PackageViewsStats'; jest.mock('react-apexcharts', () => () =>
Chart
); const defaultProps = { title: Views over the last 30 days, + repoKind: RepositoryKind.Helm, }; const stats = { '19.0.1': { diff --git a/web/src/layout/package/PackageViewsStats.tsx b/web/src/layout/package/PackageViewsStats.tsx index 55e91e704..a9c203b2a 100644 --- a/web/src/layout/package/PackageViewsStats.tsx +++ b/web/src/layout/package/PackageViewsStats.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import ReactApexChart from 'react-apexcharts'; import semver from 'semver'; -import { PackageViewsStats } from '../../types'; +import { PackageViewsStats, RepositoryKind } from '../../types'; import { getSeriesDataPerPkgVersionViews, sumViewsPerVersions } from '../../utils/viewsStats'; import Loading from '../common/Loading'; import styles from './PackageViewsStats.module.css'; @@ -12,6 +12,7 @@ import styles from './PackageViewsStats.module.css'; interface Props { stats?: PackageViewsStats; version?: string; + repoKind: RepositoryKind; title: JSX.Element; } @@ -41,7 +42,7 @@ const getMostRecentVersions = (stats: PackageViewsStats): string[] => { return sortedVersions.slice(0, MAX_VISIBLE_VERSIONS); }; -const prepareChartsSeries = (stats: PackageViewsStats, version?: string): Series[] => { +const prepareChartsSeries = (repoKind: RepositoryKind, stats: PackageViewsStats, version?: string): Series[] => { if (isEmpty(stats)) return []; let series: Series[] = []; @@ -53,7 +54,7 @@ const prepareChartsSeries = (stats: PackageViewsStats, version?: string): Series return []; } } else { - visibleVersions = getMostRecentVersions(stats); + visibleVersions = repoKind === RepositoryKind.Container ? [] : getMostRecentVersions(stats); } visibleVersions.forEach((version: string) => { @@ -67,7 +68,7 @@ const prepareChartsSeries = (stats: PackageViewsStats, version?: string): Series if (statsVersions.length > visibleVersions.length && isUndefined(version)) { series.push({ - name: 'Other', + name: repoKind === RepositoryKind.Container ? 'All tags' : 'Other', data: sumViewsPerVersions(stats, visibleVersions), }); } @@ -167,7 +168,7 @@ const PackagesViewsStats = (props: Props) => { useEffect(() => { if (!isUndefined(props.stats)) { - setSeries(prepareChartsSeries(props.stats, props.version)); + setSeries(prepareChartsSeries(props.repoKind, props.stats, props.version)); } }, [props.version, props.stats]); /* eslint-disable-line react-hooks/exhaustive-deps */ diff --git a/web/src/layout/package/Platforms.test.tsx b/web/src/layout/package/Platforms.test.tsx index 2af96de4e..56dcaaf23 100644 --- a/web/src/layout/package/Platforms.test.tsx +++ b/web/src/layout/package/Platforms.test.tsx @@ -4,6 +4,7 @@ import Platforms from './Platforms'; const defaultProps = { platforms: ['darwin', 'linux', 'windows'], + title: 'Supported platforms', }; describe('Platforms', () => { @@ -26,14 +27,14 @@ describe('Platforms', () => { }); it('renders only uniq platfoms', () => { - render(); + render(); const platforms = screen.getAllByTestId('platformBadge'); expect(platforms).toHaveLength(3); }); it('does not render component if platforms is undefined', () => { - const { container } = render(); + const { container } = render(); expect(container).toBeEmptyDOMElement(); }); }); diff --git a/web/src/layout/package/Platforms.tsx b/web/src/layout/package/Platforms.tsx index b0479124c..5e41e323e 100644 --- a/web/src/layout/package/Platforms.tsx +++ b/web/src/layout/package/Platforms.tsx @@ -6,6 +6,7 @@ import SmallTitle from '../common/SmallTitle'; import styles from './Platforms.module.css'; interface Props { + title: string; platforms?: string[] | null; } @@ -29,7 +30,7 @@ const Platforms = (props: Props) => { return ( <> - +
{platforms.map((platform: string) => (
\n", + "repository": { + "repositoryid": "id", + "kind": 0, + "name": "incubator", + "organizationName": "helm", + "organizationDisplayName": "Helm", + "url": "https://repo.url", + "private": false + } +} diff --git a/web/src/layout/package/__snapshots__/ContainerAlternativeLocations.test.tsx.snap b/web/src/layout/package/__snapshots__/ContainerAlternativeLocations.test.tsx.snap new file mode 100644 index 000000000..f4fc6ee03 --- /dev/null +++ b/web/src/layout/package/__snapshots__/ContainerAlternativeLocations.test.tsx.snap @@ -0,0 +1,474 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContainerAlternativeLocations creates snapshot 1`] = ` + +
+ + + Alternative Locations + + +
+
+
+
+ Registry icon +
+ GitHub Packages CR +
+
+ +
+
+
+
+
+ Registry icon +
+ Google CR +
+
+ +
+
+
+
+
+ Registry icon +
+ Docker Hub +
+
+ +
+
+
+
+
+ Registry icon +
+ Amazon ECR +
+
+ +
+
+
+
+
+ Registry icon +
+ Quay +
+
+ +
+
+
+
+
+ Registry icon +
+ Azure CR +
+
+ +
+
+
+
+
+ Registry icon +
+ Bundle Bar +
+
+ +
+
+
+
+
+ Registry icon +
+ localhost:5000 +
+
+ +
+
+
+
+
+`; diff --git a/web/src/layout/package/__snapshots__/ContainerRegistry.test.tsx.snap b/web/src/layout/package/__snapshots__/ContainerRegistry.test.tsx.snap new file mode 100644 index 000000000..0f8e91b4f --- /dev/null +++ b/web/src/layout/package/__snapshots__/ContainerRegistry.test.tsx.snap @@ -0,0 +1,58 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ContainerRegistry creates snapshot 1`] = ` + +
+ Registry icon +
+ Google CR +
+
+ +
+
+
+`; diff --git a/web/src/layout/package/changelog/Modal.test.tsx b/web/src/layout/package/changelog/Modal.test.tsx index d58cac1f5..fbb8876ca 100644 --- a/web/src/layout/package/changelog/Modal.test.tsx +++ b/web/src/layout/package/changelog/Modal.test.tsx @@ -107,7 +107,7 @@ describe('ChangelogModal', () => { expect(screen.getByRole('button', { name: 'Get changelog markdown' })).toBeInTheDocument(); }); - it('does not render component when repo kind is Krew, Falco or Helm plugin', async () => { + it('does not render component when repo kind is Krew, Falco, Helm plugin or Container', async () => { const props = { ...defaultProps, repository: { diff --git a/web/src/layout/package/changelog/Modal.tsx b/web/src/layout/package/changelog/Modal.tsx index b76221b14..0d63211bd 100644 --- a/web/src/layout/package/changelog/Modal.tsx +++ b/web/src/layout/package/changelog/Modal.tsx @@ -97,7 +97,11 @@ const ChangelogModal = (props: Props) => { } }, [openStatus, changelog]); /* eslint-disable-line react-hooks/exhaustive-deps */ - if ([RepositoryKind.Falco, RepositoryKind.Krew, RepositoryKind.HelmPlugin].includes(props.repository.kind)) + if ( + [RepositoryKind.Falco, RepositoryKind.Krew, RepositoryKind.HelmPlugin, RepositoryKind.Container].includes( + props.repository.kind + ) + ) return null; async function getChangelog() { diff --git a/web/src/layout/package/index.tsx b/web/src/layout/package/index.tsx index 2de38d6fd..e971a7f79 100644 --- a/web/src/layout/package/index.tsx +++ b/web/src/layout/package/index.tsx @@ -272,7 +272,10 @@ const PackageView = (props: Props) => { let sortedVersions: Version[] = []; if (detail && detail.availableVersions) { - sortedVersions = sortPackageVersions(detail.availableVersions); + sortedVersions = + detail.repository.kind === RepositoryKind.Container + ? detail.availableVersions + : sortPackageVersions(detail.availableVersions); } // Section for recommended packages and in production (orgs) @@ -1073,6 +1076,7 @@ const PackageView = (props: Props) => { { )} - + {/* We wait until contentHeight is defined to be sure the scroll goes to the correct position */} {!isEmpty(report) && !isUndefined(contentHeight) && ( diff --git a/web/src/layout/package/securityReport/Summary.test.tsx b/web/src/layout/package/securityReport/Summary.test.tsx index 49d151c61..24b0151e2 100644 --- a/web/src/layout/package/securityReport/Summary.test.tsx +++ b/web/src/layout/package/securityReport/Summary.test.tsx @@ -1,8 +1,10 @@ import { render, screen } from '@testing-library/react'; +import { RepositoryKind } from '../../../types'; import SecuritySummary from './Summary'; const defaultProps = { + repoKind: RepositoryKind.Helm, totalVulnerabilities: 170, summary: { critical: 2, @@ -27,7 +29,7 @@ describe('SecuritySummary', () => { it('renders component', () => { render(); expect(screen.getByText('170')).toBeInTheDocument(); - expect(screen.getByText(/vulnerabilities have been detected in the/g)).toBeInTheDocument(); + expect(screen.getByText(/vulnerabilities have been detected in this package's/g)).toBeInTheDocument(); expect(screen.getByText('2')).toBeInTheDocument(); expect(screen.getByText('10')).toBeInTheDocument(); expect(screen.getByText('53')).toBeInTheDocument(); @@ -38,6 +40,7 @@ describe('SecuritySummary', () => { it('renders component with 0 vulnerabilities', () => { render( { }} /> ); - expect(screen.getByText(/No vulnerabilities have been detected in the/g)).toBeInTheDocument(); + expect(screen.getByText(/No vulnerabilities have been detected in this package's/g)).toBeInTheDocument(); + expect(screen.getByText('images')).toBeInTheDocument(); + }); + + it('renders component with 0 vulnerabilities for Container type', () => { + render( + + ); + expect(screen.getByText(/No vulnerabilities have been detected in this package's/g)).toBeInTheDocument(); + expect(screen.getByText('image')).toBeInTheDocument(); }); }); }); diff --git a/web/src/layout/package/securityReport/Summary.tsx b/web/src/layout/package/securityReport/Summary.tsx index 4680f5622..d57bba620 100644 --- a/web/src/layout/package/securityReport/Summary.tsx +++ b/web/src/layout/package/securityReport/Summary.tsx @@ -1,10 +1,11 @@ import { isUndefined } from 'lodash'; -import { VulnerabilitySeverity } from '../../../types'; +import { RepositoryKind, VulnerabilitySeverity } from '../../../types'; import { SEVERITY_ORDER, SEVERITY_RATING } from '../../../utils/data'; import styles from './Summary.module.css'; interface Props { + repoKind: RepositoryKind; totalVulnerabilities: number; summary: { [key in VulnerabilitySeverity]?: number; @@ -23,8 +24,8 @@ const SecuritySummary = (props: Props) => { return (
- {getVulnerabilitiesNumber()} vulnerabilities have been detected in the{' '} - default images used by this package. + {getVulnerabilitiesNumber()} vulnerabilities have been detected in this package's{' '} + {props.repoKind === RepositoryKind.Container ? 'image' : 'images'}.
{props.totalVulnerabilities > 0 && ( diff --git a/web/src/layout/package/securityReport/__snapshots__/Summary.test.tsx.snap b/web/src/layout/package/securityReport/__snapshots__/Summary.test.tsx.snap index 794abc550..ba5141d90 100644 --- a/web/src/layout/package/securityReport/__snapshots__/Summary.test.tsx.snap +++ b/web/src/layout/package/securityReport/__snapshots__/Summary.test.tsx.snap @@ -13,13 +13,13 @@ exports[`SecuritySummary creates snapshot 1`] = ` > 170 - vulnerabilities have been detected in the + vulnerabilities have been detected in this package's - default images + images - used by this package. + .
{
, + active: true, + }, { kind: RepositoryKind.CoreDNS, label: 'coredns', @@ -748,3 +757,6 @@ export const CVSS_V3_VECTORS: { [key: string]: CVSSVectorMetric[] } = { }; export const OCI_PREFIX = 'oci://'; + +export const PKG_DETAIL_PATH = + /^\/packages\/(helm|falco|opa|olm|tbaction|krew|helm-plugin|tekton-task|keda-scaler|coredns|keptn|tekton-pipeline|container)\//; diff --git a/web/src/utils/history.ts b/web/src/utils/history.ts index f37f02d8d..21ccc91c4 100644 --- a/web/src/utils/history.ts +++ b/web/src/utils/history.ts @@ -1,16 +1,15 @@ import { createBrowserHistory } from 'history'; import analytics from '../analytics/analytics'; +import { PKG_DETAIL_PATH } from './data'; import updateMetaIndex from './updateMetaIndex'; import notificationsDispatcher from './userNotificationsDispatcher'; -const history = createBrowserHistory(); -const detailPath = - /^\/packages\/(helm|falco|opa|olm|tbaction|krew|helm-plugin|tekton-task|keda-scaler|coredns|keptn|tekton-pipeline)\//; +const history = createBrowserHistory(); history.listen((location) => { // Updates meta tags every time that history is called for all locations except for package detail page - if (!detailPath.test(location.pathname)) { + if (!PKG_DETAIL_PATH.test(location.pathname)) { updateMetaIndex(); } analytics.page(); diff --git a/web/src/utils/repoKind.test.tsx b/web/src/utils/repoKind.test.tsx index 63e7c4fea..086b1a14e 100644 --- a/web/src/utils/repoKind.test.tsx +++ b/web/src/utils/repoKind.test.tsx @@ -51,6 +51,10 @@ describe('repoKind', () => { expect(methods.getRepoKind('tekton-pipeline')).toBe(RepositoryKind.TektonPipeline); }); + it('container', () => { + expect(methods.getRepoKind('container')).toBe(RepositoryKind.Container); + }); + it('unknown', () => { expect(methods.getRepoKind('unknown')).toBeNull(); }); @@ -105,6 +109,10 @@ describe('repoKind', () => { expect(methods.getRepoKindName(RepositoryKind.TektonPipeline)).toBe('tekton-pipeline'); }); + it('container kind', () => { + expect(methods.getRepoKindName(RepositoryKind.Container)).toBe('container'); + }); + it('unknown kind', () => { expect(methods.getRepoKindName(20)).toBeNull(); }); diff --git a/web/src/utils/repoKind.ts b/web/src/utils/repoKind.ts index 86ddbc019..4f55d9fcb 100644 --- a/web/src/utils/repoKind.ts +++ b/web/src/utils/repoKind.ts @@ -26,6 +26,8 @@ const getRepoKind = (repoName: string): RepositoryKind | null => { return RepositoryKind.Keptn; case 'tekton-pipeline': return RepositoryKind.TektonPipeline; + case 'container': + return RepositoryKind.Container; default: return null; } @@ -57,6 +59,8 @@ const getRepoKindName = (repoKind: RepositoryKind): string | null => { return 'keptn'; case RepositoryKind.TektonPipeline: return 'tekton-pipeline'; + case RepositoryKind.Container: + return 'container'; default: return null; } diff --git a/web/src/utils/userNotificationsDispatcher.ts b/web/src/utils/userNotificationsDispatcher.ts index 29705b701..76bb4d509 100644 --- a/web/src/utils/userNotificationsDispatcher.ts +++ b/web/src/utils/userNotificationsDispatcher.ts @@ -2,6 +2,7 @@ import md5 from 'crypto-js/md5'; import { groupBy, isNull, isUndefined } from 'lodash'; import { NotificationsPrefs, PathTips, UserNotification } from '../types'; +import { PKG_DETAIL_PATH } from './data'; import hasToBeDisplayedNewNotification from './hasToBeDisplayedNewNotification'; const DEFAULT_START_TIME = 3 * 1000; //3s @@ -11,9 +12,6 @@ export interface UserNotificationsUpdatesHandler { updateUserNotificationsWrapper(notification: UserNotification | null): void; } -const detailPkgPath = - /^\/packages\/(helm|falco|opa|olm|tbaction|krew|helm-plugin|tekton-task|keda-scaler|coredns|keptn)\//; - const getNotifications = (): UserNotification[] => { const list = require('./notifications.json').notifications; return list.map((notif: any) => { @@ -29,7 +27,7 @@ const getCurrentLocationPath = (location?: string): PathTips | undefined => { const currentLocation = location || window.location.pathname; if (currentLocation.startsWith('/control-panel')) { return PathTips.ControlPanel; - } else if (detailPkgPath.test(currentLocation)) { + } else if (PKG_DETAIL_PATH.test(currentLocation)) { return PathTips.Package; } else { switch (currentLocation) { diff --git a/widget/src/layout/Widget.tsx b/widget/src/layout/Widget.tsx index 7ce00bb89..c1f16e487 100644 --- a/widget/src/layout/Widget.tsx +++ b/widget/src/layout/Widget.tsx @@ -73,6 +73,8 @@ const getRepoKindName = (repoKind: RepositoryKind): string | null => { return 'keptn'; case RepositoryKind.TektonPipeline: return 'tekton-pipeline'; + case RepositoryKind.Container: + return 'container'; default: return null; } @@ -422,7 +424,9 @@ export default function Widget(props: Props) {
- Version: + + {packageSummary.repository.kind === RepositoryKind.Container ? 'Tag' : 'Version'}: + {packageSummary.version}
diff --git a/widget/src/layout/common/Image.test.tsx b/widget/src/layout/common/Image.test.tsx index 6e837bca9..8307b94f9 100644 --- a/widget/src/layout/common/Image.test.tsx +++ b/widget/src/layout/common/Image.test.tsx @@ -109,6 +109,13 @@ describe('Image', () => { expect(image).toHaveProperty('src', 'https://localhost:8000/static/media/placeholder_pkg_tekton-task.png'); }); + it('renders Container icon', () => { + render(); + const image = screen.getByAltText('alt image'); + expect(image).toBeInTheDocument(); + expect(image).toHaveProperty('src', 'https://localhost:8000/static/media/placeholder_pkg_container.png'); + }); + it('renders placeholder icon', () => { render(icon} />); expect(screen.getByText('icon')).toBeInTheDocument(); diff --git a/widget/src/layout/common/Image.tsx b/widget/src/layout/common/Image.tsx index acb699a7d..c6c45fc40 100644 --- a/widget/src/layout/common/Image.tsx +++ b/widget/src/layout/common/Image.tsx @@ -53,6 +53,8 @@ const Image = (props: Props) => { return '/static/media/placeholder_pkg_coredns.png'; case RepositoryKind.Keptn: return '/static/media/placeholder_pkg_keptn.png'; + case RepositoryKind.Container: + return '/static/media/placeholder_pkg_container.png'; default: return PLACEHOLDER_SRC; } diff --git a/widget/src/layout/common/RepositoryIcon.tsx b/widget/src/layout/common/RepositoryIcon.tsx index 53983bf15..712908658 100644 --- a/widget/src/layout/common/RepositoryIcon.tsx +++ b/widget/src/layout/common/RepositoryIcon.tsx @@ -24,6 +24,7 @@ const ICONS: IconsList = { [RepositoryKind.CoreDNS]: , [RepositoryKind.Keptn]: , [RepositoryKind.TektonPipeline]: , + [RepositoryKind.Container]: , }; const RepositoryIcon = (props: Props) => ( diff --git a/widget/src/layout/common/RepositoryIconLabel.tsx b/widget/src/layout/common/RepositoryIconLabel.tsx index 5bb9c5c67..2a71bf926 100644 --- a/widget/src/layout/common/RepositoryIconLabel.tsx +++ b/widget/src/layout/common/RepositoryIconLabel.tsx @@ -64,6 +64,10 @@ const REPOSITORY_KINDS: RepoKindDef[] = [ kind: RepositoryKind.TektonPipeline, name: 'Tekton pipeline', }, + { + kind: RepositoryKind.Container, + name: 'Container image', + }, ]; const Wrapper = styled('span')` diff --git a/widget/src/layout/common/SVGIcons.test.tsx b/widget/src/layout/common/SVGIcons.test.tsx index 97cc694c0..9c4a65d9b 100644 --- a/widget/src/layout/common/SVGIcons.test.tsx +++ b/widget/src/layout/common/SVGIcons.test.tsx @@ -83,6 +83,11 @@ describe('SVGIcons', () => { expect(screen.getByTitle('keptn')); }); + it('renders container icon', () => { + render(); + expect(screen.getByTitle('container')); + }); + it('does not render when name is not in the list', () => { render(); expect(screen.getByTestId('iconWrapper')).toBeEmptyDOMElement(); diff --git a/widget/src/layout/common/SVGIcons.tsx b/widget/src/layout/common/SVGIcons.tsx index 833732c76..520d86195 100644 --- a/widget/src/layout/common/SVGIcons.tsx +++ b/widget/src/layout/common/SVGIcons.tsx @@ -824,6 +824,18 @@ const SVGIcons = (props: Props) => ( ); + case 'container': + return ( + + {props.name} + + + + ); + default: return null; } diff --git a/widget/src/types.ts b/widget/src/types.ts index 4569246dd..c6583c22f 100644 --- a/widget/src/types.ts +++ b/widget/src/types.ts @@ -35,6 +35,7 @@ export enum RepositoryKind { CoreDNS, Keptn, TektonPipeline, + Container, } export interface SearchResults {