diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-09-01 21:09:55 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-09-01 21:09:55 +0300 |
commit | 4316e6895a9038cc21eeef847578e2918d5b5595 (patch) | |
tree | 738650d5359ff5c19f4f0b28468f23956ec4ca54 | |
parent | 7b197a72aac71c97cf234401a585aba58841ed64 (diff) |
Add latest changes from gitlab-org/gitlab@master
83 files changed, 2478 insertions, 864 deletions
diff --git a/.gitlab/ci/package-and-test/main.gitlab-ci.yml b/.gitlab/ci/package-and-test/main.gitlab-ci.yml index 117a78a7325..b4e6db74f7c 100644 --- a/.gitlab/ci/package-and-test/main.gitlab-ci.yml +++ b/.gitlab/ci/package-and-test/main.gitlab-ci.yml @@ -10,6 +10,11 @@ include: - /ci/allure-report.yml - /ci/knapsack-report.yml +stages: + - test + - report + - notify + # ========================================== # Templates # ========================================== @@ -72,8 +77,8 @@ trigger-omnibus: download-knapsack-report: extends: - - .ruby-image - .bundle-install + - .ruby-image - .rules:prepare stage: .pre script: @@ -87,8 +92,8 @@ download-knapsack-report: # e2e test jobs run on separate runner which has separate cache setup cache-gems: extends: - - .ruby-image - .bundle-install + - .ruby-image - .qa-cache-push - .rules:prepare stage: .pre @@ -441,28 +446,29 @@ allure-report: extends: - .generate-allure-report-base - .rules:report:allure-report - stage: .post + stage: report variables: GITLAB_AUTH_TOKEN: $GITLAB_QA_MR_ALLURE_REPORT_TOKEN ALLURE_PROJECT_PATH: $CI_PROJECT_PATH ALLURE_MERGE_REQUEST_IID: $CI_MERGE_REQUEST_IID ALLURE_JOB_NAME: e2e-package-and-test + GIT_STRATEGY: none upload-knapsack-report: extends: - .generate-knapsack-report-base - - .ruby-image - .bundle-install + - .ruby-image - .rules:report:process-results - stage: .post + stage: report when: always relate-test-failures: - stage: .post extends: - - .ruby-image - .bundle-install + - .ruby-image - .rules:report:process-results + stage: report variables: QA_FAILURES_REPORTING_PROJECT: gitlab-org/gitlab QA_FAILURES_MAX_DIFF_RATIO: "0.15" @@ -476,14 +482,15 @@ relate-test-failures: --max-diff-ratio "$QA_FAILURES_MAX_DIFF_RATIO" generate-test-session: - stage: .post extends: - - .ruby-image - .bundle-install + - .ruby-image - .rules:report:process-results + stage: report variables: QA_TESTCASE_SESSIONS_PROJECT: gitlab-org/quality/testcase-sessions GITLAB_QA_ACCESS_TOKEN: $QA_TEST_SESSION_TOKEN + GITLAB_CI_API_TOKEN: $QA_GITLAB_CI_TOKEN when: always script: - | @@ -499,12 +506,10 @@ generate-test-session: notify-slack: extends: - .notify-slack-qa - - .ruby-image - .bundle-install + - .ruby-image - .rules:report:process-results - stage: .post - needs: - - generate-test-session + stage: notify variables: ALLURE_JOB_NAME: e2e-package-and-test SLACK_ICON_EMOJI: ci_failing diff --git a/app/assets/javascripts/api/harbor_registry.js b/app/assets/javascripts/api/harbor_registry.js new file mode 100644 index 00000000000..1148c2c9657 --- /dev/null +++ b/app/assets/javascripts/api/harbor_registry.js @@ -0,0 +1,45 @@ +import axios from '~/lib/utils/axios_utils'; +import { buildApiUrl } from '~/api/api_utils'; + +// the :request_path is loading API-like resources, not part of our REST API. +// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82784#note_1077703806 +const HARBOR_REPOSITORIES_PATH = '/:request_path.json'; +const HARBOR_ARTIFACTS_PATH = '/:request_path/:repo_name/artifacts.json'; +const HARBOR_TAGS_PATH = '/:request_path/:repo_name/artifacts/:digest/tags.json'; + +export function getHarborRepositoriesList({ requestPath, limit, page, sort, search = '' }) { + const url = buildApiUrl(HARBOR_REPOSITORIES_PATH).replace('/:request_path', requestPath); + + return axios.get(url, { + params: { + limit, + page, + search, + sort, + }, + }); +} + +export function getHarborArtifacts({ requestPath, repoName, limit, page, sort, search = '' }) { + const url = buildApiUrl(HARBOR_ARTIFACTS_PATH) + .replace('/:request_path', requestPath) + .replace(':repo_name', repoName); + + return axios.get(url, { + params: { + limit, + page, + search, + sort, + }, + }); +} + +export function getHarborTags({ requestPath, repoName, digest }) { + const url = buildApiUrl(HARBOR_TAGS_PATH) + .replace('/:request_path', requestPath) + .replace(':repo_name', repoName) + .replace(':digest', digest); + + return axios.get(url); +} diff --git a/app/assets/javascripts/issues/show/components/incidents/constants.js b/app/assets/javascripts/issues/show/components/incidents/constants.js index b7f7e0e99ca..aa7b9805b5f 100644 --- a/app/assets/javascripts/issues/show/components/incidents/constants.js +++ b/app/assets/javascripts/issues/show/components/incidents/constants.js @@ -31,3 +31,10 @@ export const timelineListI18n = Object.freeze({ 'Incident|Something went wrong while updating the incident timeline event.', ), }); + +export const timelineItemI18n = Object.freeze({ + delete: __('Delete'), + edit: __('Edit'), + moreActions: __('More actions'), + timeUTC: __('%{time} UTC'), +}); diff --git a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue index c15e8bd0e59..866111981b2 100644 --- a/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue +++ b/app/assets/javascripts/issues/show/components/incidents/timeline_events_item.vue @@ -1,17 +1,12 @@ <script> import { GlDropdown, GlDropdownItem, GlIcon, GlSafeHtmlDirective, GlSprintf } from '@gitlab/ui'; import { formatDate } from '~/lib/utils/datetime_utility'; -import { __ } from '~/locale'; +import { timelineItemI18n } from './constants'; import { getEventIcon } from './utils'; export default { name: 'IncidentTimelineEventListItem', - i18n: { - delete: __('Delete'), - edit: __('Edit'), - moreActions: __('More actions'), - timeUTC: __('%{time} UTC'), - }, + i18n: timelineItemI18n, components: { GlDropdown, GlDropdownItem, diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue new file mode 100644 index 00000000000..b55204de875 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list.vue @@ -0,0 +1,95 @@ +<script> +import { GlEmptyState } from '@gitlab/ui'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import { + NO_ARTIFACTS_TITLE, + NO_TAGS_MATCHING_FILTERS_TITLE, + NO_TAGS_MATCHING_FILTERS_DESCRIPTION, +} from '~/packages_and_registries/harbor_registry/constants'; +import ArtifactsListRow from '~/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue'; + +export default { + name: 'TagsList', + components: { + GlEmptyState, + ArtifactsListRow, + TagsLoader, + RegistryList, + }, + inject: ['noContainersImage'], + props: { + artifacts: { + type: Array, + required: true, + }, + filter: { + type: String, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + isLoading: { + type: Boolean, + default: false, + required: false, + }, + }, + data() { + return { + tags: [], + tagsPageInfo: {}, + }; + }, + computed: { + hasNoTags() { + return this.artifacts.length === 0; + }, + emptyStateTitle() { + return this.filter ? NO_TAGS_MATCHING_FILTERS_TITLE : NO_ARTIFACTS_TITLE; + }, + emptyStateDescription() { + return this.filter ? NO_TAGS_MATCHING_FILTERS_DESCRIPTION : ''; + }, + }, + methods: { + fetchNextPage() { + this.$emit('next-page'); + }, + fetchPreviousPage() { + this.$emit('prev-page'); + }, + }, +}; +</script> + +<template> + <div> + <tags-loader v-if="isLoading" /> + <template v-else> + <gl-empty-state + v-if="hasNoTags" + :title="emptyStateTitle" + :svg-path="noContainersImage" + :description="emptyStateDescription" + class="gl-mx-auto gl-my-0" + /> + <template v-else> + <registry-list + :pagination="pageInfo" + :items="artifacts" + :hidden-delete="true" + id-property="name" + @prev-page="fetchPreviousPage" + @next-page="fetchNextPage" + > + <template #default="{ item }"> + <artifacts-list-row :artifact="item" /> + </template> + </registry-list> + </template> + </template> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue new file mode 100644 index 00000000000..4b459860346 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue @@ -0,0 +1,133 @@ +<script> +import { GlTooltipDirective, GlSprintf, GlIcon } from '@gitlab/ui'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { n__ } from '~/locale'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { + DIGEST_LABEL, + CREATED_AT_LABEL, + NOT_AVAILABLE_TEXT, + NOT_AVAILABLE_SIZE, +} from '~/packages_and_registries/harbor_registry/constants'; +import { artifactPullCommand } from '~/packages_and_registries/harbor_registry/utils'; + +export default { + name: 'TagsListRow', + components: { + GlSprintf, + GlIcon, + ListItem, + ClipboardButton, + TimeAgoTooltip, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + inject: ['repositoryUrl', 'harborIntegrationProjectName'], + props: { + artifact: { + type: Object, + required: true, + }, + }, + i18n: { + digestLabel: DIGEST_LABEL, + createdAtLabel: CREATED_AT_LABEL, + }, + computed: { + formattedSize() { + return this.artifact.size + ? numberToHumanSize(Number(this.artifact.size)) + : NOT_AVAILABLE_SIZE; + }, + tagsCountText() { + const count = this.artifact?.tags.length ? this.artifact?.tags.length : 0; + + return n__('%d tag', '%d tags', count); + }, + shortDigest() { + // remove sha256: from the string, and show only the first 7 char + const PREFIX_LENGTH = 'sha256:'.length; + const DIGEST_LENGTH = 7; + return ( + this.artifact.digest?.substring(PREFIX_LENGTH, PREFIX_LENGTH + DIGEST_LENGTH) ?? + NOT_AVAILABLE_TEXT + ); + }, + getPullCommand() { + if (this.artifact?.digest) { + const { image } = this.$route.params; + return artifactPullCommand({ + digest: this.artifact.digest, + imageName: image, + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + }); + } + + return ''; + }, + linkTo() { + const { project, image } = this.$route.params; + + return { name: 'details', params: { project, image, digest: this.artifact.digest } }; + }, + }, +}; +</script> + +<template> + <list-item v-bind="$attrs"> + <template #left-primary> + <div class="gl-display-flex gl-align-items-center"> + <router-link + class="gl-text-body gl-font-weight-bold gl-word-break-all" + data-testid="name" + :to="linkTo" + > + {{ artifact.digest }} + </router-link> + <clipboard-button + v-if="getPullCommand" + :title="getPullCommand" + :text="getPullCommand" + category="tertiary" + /> + </div> + </template> + + <template #left-secondary> + <span class="gl-mr-2" data-testid="size"> + {{ formattedSize }} + </span> + <span id="tagsCount" data-testid="tags-count"> + <gl-icon name="tag" class="gl-mr-2" /> + {{ tagsCountText }} + </span> + </template> + <template #right-primary> + <span data-testid="time"> + <gl-sprintf :message="$options.i18n.createdAtLabel"> + <template #timeInfo> + <time-ago-tooltip :time="artifact.pushTime" /> + </template> + </gl-sprintf> + </span> + </template> + <template #right-secondary> + <span data-testid="digest"> + <gl-sprintf :message="$options.i18n.digestLabel"> + <template #imageId>{{ shortDigest }}</template> + </gl-sprintf> + </span> + <clipboard-button + v-if="artifact.digest" + :title="artifact.digest" + :text="artifact.digest" + category="tertiary" + /> + </template> + </list-item> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue new file mode 100644 index 00000000000..cc5398ccf8f --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/details/details_header.vue @@ -0,0 +1,44 @@ +<script> +import { isEmpty } from 'lodash'; +import { n__, s__ } from '~/locale'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { ROOT_IMAGE_TEXT } from '~/packages_and_registries/harbor_registry/constants/index'; + +export default { + name: 'DetailsHeader', + components: { TitleArea, MetadataItem }, + mixins: [timeagoMixin], + props: { + imagesDetail: { + type: Object, + required: true, + }, + }, + computed: { + artifactCountText() { + if (isEmpty(this.imagesDetail)) { + return s__('HarborRegistry|-- artifacts'); + } + return n__('%d artifact', '%d artifacts', this.imagesDetail.artifactCount); + }, + repositoryFullName() { + return this.imagesDetail.name || ROOT_IMAGE_TEXT; + }, + }, +}; +</script> + +<template> + <title-area> + <template #title> + <span data-testid="title"> + {{ repositoryFullName }} + </span> + </template> + <template #metadata-tags-count> + <metadata-item icon="package" :text="artifactCountText" data-testid="artifacts-count" /> + </template> + </title-area> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue new file mode 100644 index 00000000000..72aaa762547 --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue @@ -0,0 +1,69 @@ +<script> +// We are using gl-breadcrumb only at the last child of the handwritten breadcrumb +// until this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079 +// +// See the CSS workaround in app/assets/stylesheets/pages/registry.scss when this file is changed. +import { GlBreadcrumb, GlIcon } from '@gitlab/ui'; +import { isArray, last } from 'lodash'; + +export default { + components: { + GlBreadcrumb, + GlIcon, + }, + computed: { + rootRoute() { + return this.$router.options.routes.find((r) => r.meta.root); + }, + isRootRoute() { + return this.$route.name === this.rootRoute.name; + }, + currentRoute() { + const currentName = this.$route.meta.nameGenerator(); + const currentHref = this.$route.meta.hrefGenerator(); + let routeInfoList = [ + { + text: currentName, + to: currentHref, + }, + ]; + + if (isArray(currentName) && isArray(currentHref)) { + routeInfoList = currentName.map((name, index) => { + return { + text: name, + to: currentHref[index], + }; + }); + } + + return routeInfoList; + }, + isLoaded() { + return this.isRootRoute || last(this.currentRoute).text; + }, + allCrumbs() { + let crumbs = [ + { + text: this.rootRoute.meta.nameGenerator(), + to: this.rootRoute.path, + }, + ]; + if (!this.isRootRoute) { + crumbs = crumbs.concat(this.currentRoute); + } + return crumbs; + }, + }, +}; +</script> + +<template> + <gl-breadcrumb :key="isLoaded" :items="allCrumbs"> + <template #separator> + <span class="gl-mx-n5"> + <gl-icon name="chevron-lg-right" :size="8" /> + </span> + </template> + </gl-breadcrumb> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue index 086b9c73d75..f9aefeeb46f 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue @@ -32,7 +32,7 @@ export default { }, }, i18n: { - HARBOR_REGISTRY_TITLE, + harborRegistryTitle: HARBOR_REGISTRY_TITLE, }, computed: { imagesCountText() { @@ -48,7 +48,7 @@ export default { <template> <title-area - :title="$options.i18n.HARBOR_REGISTRY_TITLE" + :title="$options.i18n.harborRegistryTitle" :info-messages="infoMessages" :metadata-loading="metadataLoading" > diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue index 258472fe16e..bfe0c250dd9 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue @@ -1,15 +1,14 @@ <script> -import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; +import { GlIcon, GlSkeletonLoader } from '@gitlab/ui'; import { n__ } from '~/locale'; - import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; +import { getNameFromParams } from '~/packages_and_registries/harbor_registry/utils'; export default { name: 'HarborListRow', components: { ClipboardButton, - GlSprintf, GlIcon, ListItem, GlSkeletonLoader, @@ -26,19 +25,18 @@ export default { }, }, computed: { - id() { - return this.item.id; + linkTo() { + const { projectName, imageName } = getNameFromParams(this.item.name); + + return { name: 'details', params: { project: projectName, image: imageName } }; }, artifactCountText() { return n__( - 'HarborRegistry|%{count} Tag', - 'HarborRegistry|%{count} Tags', + 'HarborRegistry|%d artifact', + 'HarborRegistry|%d artifacts', this.item.artifactCount, ); }, - imageName() { - return this.item.name; - }, }, }; </script> @@ -50,9 +48,9 @@ export default { class="gl-text-body gl-font-weight-bold" data-testid="details-link" data-qa-selector="registry_image_content" - :to="{ name: 'details', params: { id } }" + :to="linkTo" > - {{ imageName }} + {{ item.name }} </router-link> <clipboard-button v-if="item.location" @@ -63,13 +61,9 @@ export default { </template> <template #left-secondary> <template v-if="!metadataLoading"> - <span class="gl-display-flex gl-align-items-center" data-testid="tags-count"> - <gl-icon name="tag" class="gl-mr-2" /> - <gl-sprintf :message="artifactCountText"> - <template #count> - {{ item.artifactCount }} - </template> - </gl-sprintf> + <span class="gl-display-flex gl-align-items-center" data-testid="artifacts-count"> + <gl-icon name="package" class="gl-mr-2" /> + {{ artifactCountText }} </span> </template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js index a7891821755..89866320215 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/common.js @@ -16,14 +16,4 @@ export const SORT_FIELD_MAPPING = { CREATED: CREATED_SORT_FIELD_KEY, }; -/* eslint-disable @gitlab/require-i18n-strings */ -export const dockerBuildCommand = (repositoryUrl) => { - return `docker build -t ${repositoryUrl} .`; -}; -export const dockerPushCommand = (repositoryUrl) => { - return `docker push ${repositoryUrl}`; -}; -export const dockerLoginCommand = (registryHostUrlWithPort) => { - return `docker login ${registryHostUrlWithPort}`; -}; -/* eslint-enable @gitlab/require-i18n-strings */ +export const DEFAULT_PER_PAGE = 10; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js index b62c51bd208..e10a24315d8 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/details.js @@ -1,22 +1,10 @@ import { s__, __ } from '~/locale'; -export const UPDATED_AT = s__('HarborRegistry|Last updated %{time}'); - -export const MISSING_OR_DELETED_IMAGE_TITLE = s__( - 'HarborRegistry|The image repository could not be found.', -); - -export const MISSING_OR_DELETED_IMAGE_MESSAGE = s__( - 'HarborRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page.', +export const FETCH_ARTIFACT_LIST_ERROR_MESSAGE = s__( + 'HarborRegistry|Something went wrong while fetching the artifact list.', ); -export const NO_TAGS_TITLE = s__('HarborRegistry|This image has no active tags'); - -export const NO_TAGS_MESSAGE = s__( - `HarborRegistry|The last tag related to this image was recently removed. -This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. -If you have any questions, contact your administrator.`, -); +export const NO_ARTIFACTS_TITLE = s__('HarborRegistry|This image has no artifacts'); export const NO_TAGS_MATCHING_FILTERS_TITLE = s__('HarborRegistry|The filter returned no results'); @@ -26,14 +14,14 @@ export const NO_TAGS_MATCHING_FILTERS_DESCRIPTION = s__( export const DIGEST_LABEL = s__('HarborRegistry|Digest: %{imageId}'); export const CREATED_AT_LABEL = s__('HarborRegistry|Published %{timeInfo}'); -export const PUBLISHED_DETAILS_ROW_TEXT = s__( - 'HarborRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}', -); -export const MANIFEST_DETAILS_ROW_TEST = s__('HarborRegistry|Manifest digest: %{digest}'); -export const CONFIGURATION_DETAILS_ROW_TEST = s__('HarborRegistry|Configuration digest: %{digest}'); -export const MISSING_MANIFEST_WARNING_TOOLTIP = s__( - 'HarborRegistry|Invalid tag: missing manifest digest', -); export const NOT_AVAILABLE_TEXT = __('Not applicable.'); export const NOT_AVAILABLE_SIZE = __('0 bytes'); + +export const TOKEN_TYPE_TAG_NAME = 'tag_name'; + +export const FETCH_TAGS_ERROR_MESSAGE = s__( + 'HarborRegistry|Something went wrong while fetching the tags.', +); + +export const TAG_LABEL = s__('HarborRegistry|Tag'); diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js index a6cd59918ff..6a51d14d570 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/constants/list.js @@ -9,6 +9,11 @@ export const CONNECTION_ERROR_TITLE = s__('HarborRegistry|Harbor connection erro export const CONNECTION_ERROR_MESSAGE = s__( `HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}.`, ); + +export const FETCH_IMAGES_LIST_ERROR_MESSAGE = s__( + 'HarborRegistry|Something went wrong while fetching the repository list.', +); + export const LIST_INTRO_TEXT = s__( `HarborRegistry|With the Harbor Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}`, ); @@ -26,6 +31,13 @@ export const EMPTY_RESULT_MESSAGE = s__( 'HarborRegistry|To widen your search, change or remove the filters above.', ); +export const EMPTY_IMAGES_TITLE = s__( + 'HarborRegistry|There are no harbor images stored for this project', +); +export const EMPTY_IMAGES_MESSAGE = s__( + 'HarborRegistry|With the Harbor Registry, every project can connect to a harbor space to store its Docker images.', +); + export const SORT_FIELDS = [ { orderBy: 'UPDATED', label: __('Updated') }, { orderBy: 'CREATED', label: __('Created') }, diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js index ecfefead61a..70238e43ad6 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/index.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/index.js @@ -3,14 +3,8 @@ import Vue from 'vue'; import { parseBoolean } from '~/lib/utils/common_utils'; import PerformancePlugin from '~/performance/vue_performance_plugin'; import Translate from '~/vue_shared/translate'; -import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue'; +import RegistryBreadcrumb from '~/packages_and_registries/harbor_registry/components/harbor_registry_breadcrumb.vue'; import { renderBreadcrumb } from '~/packages_and_registries/shared/utils'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { - dockerBuildCommand, - dockerPushCommand, - dockerLoginCommand, -} from '~/packages_and_registries/harbor_registry/constants'; import createRouter from './router'; import HarborRegistryExplorer from './pages/index.vue'; @@ -35,13 +29,27 @@ export default (id) => { return null; } - const { endpoint, connectionError, invalidPathError, isGroupPage, ...config } = el.dataset; + const { + endpoint, + connectionError, + invalidPathError, + isGroupPage, + noContainersImage, + containersErrorImage, + repositoryUrl, + harborIntegrationProjectName, + projectName, + } = el.dataset; const breadCrumbState = Vue.observable({ name: '', + href: '', updateName(value) { this.name = value; }, + updateHref(value) { + this.href = value; + }, }); const router = createRouter(endpoint, breadCrumbState); @@ -53,16 +61,16 @@ export default (id) => { provide() { return { breadCrumbState, - config: { - ...config, - connectionError: parseBoolean(connectionError), - invalidPathError: parseBoolean(invalidPathError), - isGroupPage: parseBoolean(isGroupPage), - helpPagePath: helpPagePath('user/packages/container_registry/index'), - }, - dockerBuildCommand: dockerBuildCommand(config.repositoryUrl), - dockerPushCommand: dockerPushCommand(config.repositoryUrl), - dockerLoginCommand: dockerLoginCommand(config.registryHostUrlWithPort), + endpoint, + connectionError: parseBoolean(connectionError), + invalidPathError: parseBoolean(invalidPathError), + isGroupPage: parseBoolean(isGroupPage), + repositoryUrl, + harborIntegrationProjectName, + projectName, + containersErrorImage, + noContainersImage, + helpPagePath: '', }; }, render(createElement) { diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js b/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js deleted file mode 100644 index 50c7df1483c..00000000000 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/mock_api.js +++ /dev/null @@ -1,200 +0,0 @@ -const mockRequestFn = (mockData) => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(mockData); - }, 2000); - }); -}; -export const harborListResponse = () => { - const harborListResponseData = { - repositories: [ - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 25, - name: 'shao/flinkx', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 26, - name: 'shao/flinkx1', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 27, - name: 'shao/flinkx2', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - ], - totalCount: 3, - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - }, - }; - - return mockRequestFn(harborListResponseData); -}; - -export const getHarborRegistryImageDetail = () => { - const harborRegistryImageDetailData = { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 25, - name: 'shao/flinkx', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - tagsCount: 10, - }; - - return mockRequestFn(harborRegistryImageDetailData); -}; - -export const harborTagsResponse = () => { - const harborTagsResponseData = { - tags: [ - { - digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255', - shortRevision: 'f53bde3d4', - createdAt: '2022-03-02T23:59:05+00:00', - totalSize: '6623124', - }, - { - digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e', - shortRevision: 'e1fe52d8b', - createdAt: '2022-02-10T01:09:56+00:00', - totalSize: '920760', - }, - { - digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f', - shortRevision: 'c72770c6e', - createdAt: '2021-12-22T04:48:48+00:00', - totalSize: '48609053', - }, - { - digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a', - shortRevision: '1ac2a4319', - createdAt: '2022-03-09T11:02:27+00:00', - totalSize: '35141894', - }, - { - digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c', - shortRevision: 'cf8fee086', - createdAt: '2022-01-21T11:31:43+00:00', - totalSize: '48716070', - }, - { - digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15', - shortRevision: '1a4b48198', - createdAt: '2022-01-21T11:31:51+00:00', - totalSize: '6623127', - }, - { - digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61', - shortRevision: '03e2e2777', - createdAt: '2022-03-02T23:58:20+00:00', - totalSize: '911377', - }, - { - digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012', - shortRevision: '350e78d60', - createdAt: '2022-01-19T13:49:14+00:00', - totalSize: '48710241', - }, - { - digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18', - shortRevision: '76038370b', - createdAt: '2022-01-24T12:56:22+00:00', - totalSize: '280065', - }, - { - digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f', - shortRevision: '3d4b49a7b', - createdAt: '2022-02-17T17:37:52+00:00', - totalSize: '48655767', - }, - ], - totalCount: 10, - pageInfo: { - hasNextPage: false, - hasPreviousPage: true, - }, - }; - - return mockRequestFn(harborTagsResponseData); -}; diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue index e69de29bb2d..c6ab746b9f4 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue @@ -0,0 +1,156 @@ +<script> +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { + NAME_SORT_FIELD, + ROOT_IMAGE_TEXT, + DEFAULT_PER_PAGE, + FETCH_ARTIFACT_LIST_ERROR_MESSAGE, + TOKEN_TYPE_TAG_NAME, + TAG_LABEL, +} from '~/packages_and_registries/harbor_registry/constants/index'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { createAlert } from '~/flash'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import ArtifactsList from '~/packages_and_registries/harbor_registry/components/details/artifacts_list.vue'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import DetailsHeader from '~/packages_and_registries/harbor_registry/components/details/details_header.vue'; +import { + extractSortingDetail, + parseFilter, + formatPagination, +} from '~/packages_and_registries/harbor_registry/utils'; +import { getHarborArtifacts } from '~/rest_api'; + +export default { + name: 'HarborDetailsPage', + components: { + ArtifactsList, + TagsLoader, + DetailsHeader, + PersistedSearch, + }, + inject: ['endpoint', 'breadCrumbState'], + searchConfig: { nameSortFields: [NAME_SORT_FIELD] }, + tokens: [ + { + type: TOKEN_TYPE_TAG_NAME, + icon: 'tag', + title: TAG_LABEL, + unique: true, + token: GlFilteredSearchToken, + operators: OPERATOR_IS_ONLY, + }, + ], + data() { + return { + artifactsList: [], + pageInfo: {}, + mutationLoading: false, + deleteAlertType: null, + isLoading: true, + filterString: '', + sorting: null, + }; + }, + computed: { + currentPage() { + return this.pageInfo.page || 1; + }, + imagesDetail() { + return { + name: this.fullName, + artifactCount: this.pageInfo?.total || 0, + }; + }, + fullName() { + const { project, image } = this.$route.params; + + if (project && image) { + return `${project}/${image}`; + } + return ''; + }, + }, + mounted() { + this.updateBreadcrumb(); + }, + methods: { + updateBreadcrumb() { + const name = this.fullName || ROOT_IMAGE_TEXT; + this.breadCrumbState.updateName(name); + this.breadCrumbState.updateHref(this.$route.path); + }, + handleSearchUpdate({ sort, filters }) { + this.sorting = sort; + this.filterString = parseFilter(filters, 'digest'); + + this.fetchArtifacts(1); + }, + fetchPrevPage() { + const prevPageNum = this.currentPage - 1; + this.fetchArtifacts(prevPageNum); + }, + fetchNextPage() { + const nextPageNum = this.currentPage + 1; + this.fetchArtifacts(nextPageNum); + }, + fetchArtifacts(requestPage) { + this.isLoading = true; + + const { orderBy, sort } = extractSortingDetail(this.sorting); + const sortOptions = `${orderBy} ${sort}`; + + const { image } = this.$route.params; + + const params = { + requestPath: this.endpoint, + repoName: image, + limit: DEFAULT_PER_PAGE, + page: requestPage, + sort: sortOptions, + search: this.filterString, + }; + + getHarborArtifacts(params) + .then((res) => { + this.pageInfo = formatPagination(res.headers); + + this.artifactsList = (res?.data || []).map((artifact) => { + return convertObjectPropsToCamelCase(artifact); + }); + }) + .catch(() => { + createAlert({ message: FETCH_ARTIFACT_LIST_ERROR_MESSAGE }); + }) + .finally(() => { + this.isLoading = false; + }); + }, + }, +}; +</script> + +<template> + <div class="gl-my-3"> + <details-header :images-detail="imagesDetail" /> + <persisted-search + class="gl-mb-5" + :sortable-fields="$options.searchConfig.nameSortFields" + :default-order="$options.searchConfig.nameSortFields[0].orderBy" + default-sort="asc" + :tokens="$options.tokens" + @update="handleSearchUpdate" + /> + <tags-loader v-if="isLoading" /> + <artifacts-list + v-else + :filter="filterString" + :is-loading="isLoading" + :artifacts="artifactsList" + :page-info="pageInfo" + @prev-page="fetchPrevPage" + @next-page="fetchNextPage" + /> + </div> +</template> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue index 9c69059c968..b9ec625171e 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/list.vue @@ -1,19 +1,31 @@ <script> import { GlEmptyState, GlSprintf, GlLink, GlSkeletonLoader } from '@gitlab/ui'; -import { escape } from 'lodash'; import HarborListHeader from '~/packages_and_registries/harbor_registry/components/list/harbor_list_header.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import { + extractSortingDetail, + formatPagination, + parseFilter, + dockerBuildCommand, + dockerPushCommand, + dockerLoginCommand, +} from '~/packages_and_registries/harbor_registry/utils'; +import { createAlert } from '~/flash'; import { SORT_FIELDS, CONNECTION_ERROR_TITLE, CONNECTION_ERROR_MESSAGE, EMPTY_RESULT_TITLE, EMPTY_RESULT_MESSAGE, + DEFAULT_PER_PAGE, + FETCH_IMAGES_LIST_ERROR_MESSAGE, + EMPTY_IMAGES_TITLE, + EMPTY_IMAGES_MESSAGE, } from '~/packages_and_registries/harbor_registry/constants'; import Tracking from '~/tracking'; -import { harborListResponse } from '../mock_api'; +import { getHarborRepositoriesList } from '~/rest_api'; export default { name: 'HarborListPage', @@ -31,17 +43,26 @@ export default { ), }, mixins: [Tracking.mixin()], - inject: ['config', 'dockerBuildCommand', 'dockerPushCommand', 'dockerLoginCommand'], + inject: [ + 'endpoint', + 'repositoryUrl', + 'harborIntegrationProjectName', + 'projectName', + 'isGroupPage', + 'connectionError', + 'invalidPathError', + 'containersErrorImage', + 'helpPagePath', + 'noContainersImage', + ], loader: { repeat: 10, width: 1000, height: 40, }, i18n: { - CONNECTION_ERROR_TITLE, - CONNECTION_ERROR_MESSAGE, - EMPTY_RESULT_TITLE, - EMPTY_RESULT_MESSAGE, + connectionErrorTitle: CONNECTION_ERROR_TITLE, + connectionErrorMessage: CONNECTION_ERROR_MESSAGE, }, searchConfig: SORT_FIELDS, data() { @@ -56,42 +77,81 @@ export default { }; }, computed: { + dockerCommand() { + return { + build: dockerBuildCommand({ + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + projectName: this.projectName, + }), + push: dockerPushCommand({ + repositoryUrl: this.repositoryUrl, + harborProjectName: this.harborIntegrationProjectName, + projectName: this.projectName, + }), + login: dockerLoginCommand(this.repositoryUrl), + }; + }, showCommands() { - return !this.isLoading && !this.config?.isGroupPage && this.images?.length; + return !this.isLoading && !this.isGroupPage && this.images?.length; }, showConnectionError() { - return this.config.connectionError || this.config.invalidPathError; + return this.connectionError || this.invalidPathError; + }, + currentPage() { + return this.pageInfo.page || 1; + }, + emptyStateTexts() { + return { + title: this.name ? EMPTY_RESULT_TITLE : EMPTY_IMAGES_TITLE, + message: this.name ? EMPTY_RESULT_MESSAGE : EMPTY_IMAGES_MESSAGE, + }; }, }, methods: { - fetchHarborImages() { - // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 + fetchHarborImages(requestPage) { this.isLoading = true; - harborListResponse() + const { orderBy, sort } = extractSortingDetail(this.sorting); + const sortOptions = `${orderBy} ${sort}`; + + const params = { + requestPath: this.endpoint, + limit: DEFAULT_PER_PAGE, + search: this.name, + page: requestPage, + sort: sortOptions, + }; + + getHarborRepositoriesList(params) .then((res) => { - this.images = res?.repositories || []; - this.totalCount = res?.totalCount || 0; - this.pageInfo = res?.pageInfo || {}; + this.images = (res?.data || []).map((item) => { + return convertObjectPropsToCamelCase(item); + }); + const pagination = formatPagination(res.headers); + + this.totalCount = pagination?.total || 0; + this.pageInfo = pagination; + this.isLoading = false; }) - .catch(() => {}); + .catch(() => { + createAlert({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE }); + }); }, handleSearchUpdate({ sort, filters }) { this.sorting = sort; + this.name = parseFilter(filters, 'name'); - const search = filters.find((i) => i.type === FILTERED_SEARCH_TERM); - this.name = escape(search?.value?.data); - - this.fetchHarborImages(); + this.fetchHarborImages(1); }, fetchPrevPage() { - // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 - this.fetchHarborImages(); + const prevPageNum = this.currentPage - 1; + this.fetchHarborImages(prevPageNum); }, fetchNextPage() { - // TODO: Waiting for harbor api integration to finish: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82777 - this.fetchHarborImages(); + const nextPageNum = this.currentPage + 1; + this.fetchHarborImages(nextPageNum); }, }, }; @@ -101,14 +161,14 @@ export default { <div> <gl-empty-state v-if="showConnectionError" - :title="$options.i18n.CONNECTION_ERROR_TITLE" - :svg-path="config.containersErrorImage" + :title="$options.i18n.connectionErrorTitle" + :svg-path="containersErrorImage" > <template #description> <p> - <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE"> + <gl-sprintf :message="$options.i18n.connectionErrorMessage"> <template #docLink="{ content }"> - <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank"> + <gl-link :href="`${helpPagePath}#docker-connection-error`" target="_blank"> {{ content }} </gl-link> </template> @@ -120,14 +180,14 @@ export default { <harbor-list-header :metadata-loading="isLoading" :images-count="totalCount" - :help-page-path="config.helpPagePath" + :help-page-path="helpPagePath" > <template #commands> <cli-commands v-if="showCommands" - :docker-build-command="dockerBuildCommand" - :docker-push-command="dockerPushCommand" - :docker-login-command="dockerLoginCommand" + :docker-build-command="dockerCommand.build" + :docker-push-command="dockerCommand.push" + :docker-login-command="dockerCommand.login" /> </template> </harbor-list-header> @@ -152,26 +212,24 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <template v-if="images.length > 0 || name"> - <harbor-list - v-if="images.length" - :images="images" - :meta-data-loading="isLoading" - :page-info="pageInfo" - @prev-page="fetchPrevPage" - @next-page="fetchNextPage" - /> - <gl-empty-state - v-else - :svg-path="config.noContainersImage" - data-testid="emptySearch" - :title="$options.i18n.EMPTY_RESULT_TITLE" - > - <template #description> - {{ $options.i18n.EMPTY_RESULT_MESSAGE }} - </template> - </gl-empty-state> - </template> + <harbor-list + v-if="images.length" + :images="images" + :metadata-loading="isLoading" + :page-info="pageInfo" + @prev-page="fetchPrevPage" + @next-page="fetchNextPage" + /> + <gl-empty-state + v-else + :svg-path="noContainersImage" + data-testid="emptySearch" + :title="emptyStateTexts.title" + > + <template #description> + {{ emptyStateTexts.message }} + </template> + </gl-empty-state> </template> </template> </div> diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js index 572dd382be3..8744204b5c7 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/router.js +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/router.js @@ -22,10 +22,11 @@ export default function createRouter(base, breadCrumbState) { }, { name: 'details', - path: '/:id', + path: '/:project/:image', component: Details, meta: { nameGenerator: () => breadCrumbState.name, + hrefGenerator: () => breadCrumbState.href, }, }, ], diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js new file mode 100644 index 00000000000..13df303cffe --- /dev/null +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/utils.js @@ -0,0 +1,84 @@ +import { isFinite } from 'lodash'; +import { + SORT_FIELD_MAPPING, + TOKEN_TYPE_TAG_NAME, +} from '~/packages_and_registries/harbor_registry/constants'; +import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; + +export const extractSortingDetail = (parsedSorting = '') => { + const [orderBy, sortOrder] = parsedSorting.split('_'); + if (orderBy && sortOrder) { + return { + orderBy: SORT_FIELD_MAPPING[orderBy], + sort: sortOrder.toLowerCase(), + }; + } + + return { + orderBy: '', + sort: '', + }; +}; + +export const parseFilter = (filters = [], defaultPrefix = '') => { + /* eslint-disable @gitlab/require-i18n-strings */ + const prefixMap = { + [FILTERED_SEARCH_TERM]: `${defaultPrefix}=`, + [TOKEN_TYPE_TAG_NAME]: 'tags=', + }; + /* eslint-enable @gitlab/require-i18n-strings */ + const filterList = []; + filters.forEach((i) => { + if (i.value?.data) { + const filterVal = i.value?.data; + const prefix = prefixMap[i.type]; + const filterString = `${prefix}${filterVal}`; + + filterList.push(filterString); + } + }); + + return filterList.join(','); +}; + +export const getNameFromParams = (fullName) => { + const names = fullName.split('/'); + return { + projectName: names[0] || '', + imageName: names[1] || '', + }; +}; + +export const formatPagination = (headers) => { + const pagination = parseIntPagination(normalizeHeaders(headers)) || {}; + + if (pagination.nextPage || pagination.previousPage) { + pagination.hasNextPage = isFinite(pagination.nextPage); + pagination.hasPreviousPage = isFinite(pagination.previousPage); + } + + return pagination; +}; + +/* eslint-disable @gitlab/require-i18n-strings */ +export const dockerBuildCommand = ({ repositoryUrl, harborProjectName, projectName = '' }) => { + return `docker build -t ${repositoryUrl}/${harborProjectName}/${projectName} .`; +}; + +export const dockerPushCommand = ({ repositoryUrl, harborProjectName, projectName = '' }) => { + return `docker push ${repositoryUrl}/${harborProjectName}/${projectName}`; +}; + +export const dockerLoginCommand = (repositoryUrl) => { + return `docker login ${repositoryUrl}`; +}; + +export const artifactPullCommand = ({ repositoryUrl, harborProjectName, imageName, digest }) => { + return `docker pull ${repositoryUrl}/${harborProjectName}/${imageName}@${digest}`; +}; + +export const tagPullCommand = ({ repositoryUrl, harborProjectName, imageName, tag }) => { + return `docker pull ${repositoryUrl}/${harborProjectName}/${imageName}:${tag}`; +}; +/* eslint-enable @gitlab/require-i18n-strings */ diff --git a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue index b2b1d2c8212..363304c20ce 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/persisted_search.vue @@ -18,6 +18,11 @@ export default { type: String, required: true, }, + tokens: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -68,7 +73,7 @@ export default { v-if="mountRegistrySearch" :filters="filters" :sorting="sorting" - :tokens="$options.tokens" + :tokens="tokens" :sortable-fields="sortableFields" @sorting:changed="updateSortingAndEmitUpdate" @filter:changed="updateFilters" diff --git a/app/assets/javascripts/rest_api.js b/app/assets/javascripts/rest_api.js index 0b6c5063129..7b5babdd3a6 100644 --- a/app/assets/javascripts/rest_api.js +++ b/app/assets/javascripts/rest_api.js @@ -6,6 +6,7 @@ export * from './api/bulk_imports_api'; export * from './api/namespaces_api'; export * from './api/tags_api'; export * from './api/alert_management_alerts_api'; +export * from './api/harbor_registry'; // Note: It's not possible to spy on methods imported from this file in // Jest tests. diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index 03913a71d47..58eabcfd378 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -24,6 +24,10 @@ module Integrations s_("HarborIntegration|After the Harbor integration is activated, global variables '$HARBOR_USERNAME', '$HARBOR_HOST', '$HARBOR_OCI', '$HARBOR_PASSWORD', '$HARBOR_URL' and '$HARBOR_PROJECT' will be created for CI/CD use.") end + def hostname + Gitlab::Utils.parse_url(url).hostname + end + class << self def to_param name.demodulize.downcase diff --git a/app/views/groups/harbor/repositories/index.html.haml b/app/views/groups/harbor/repositories/index.html.haml index a8a52b2aba7..59ad29ccabd 100644 --- a/app/views/groups/harbor/repositories/index.html.haml +++ b/app/views/groups/harbor/repositories/index.html.haml @@ -4,8 +4,9 @@ #js-harbor-registry-list-group{ data: { endpoint: group_harbor_repositories_path(@group), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'), - "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - "registry_host_url_with_port" => 'demo.harbor.com', + "repository_url" => @group.harbor_integration.hostname, + "harbor_integration_project_name" => @group.harbor_integration.project_name, + full_path: @group.full_path, connection_error: (!!@connection_error).to_s, invalid_path_error: (!!@invalid_path_error).to_s, is_group_page: true.to_s } } diff --git a/app/views/notify/new_mention_in_merge_request_email.html.haml b/app/views/notify/new_mention_in_merge_request_email.html.haml index a28d944529f..e4588716d5c 100644 --- a/app/views/notify/new_mention_in_merge_request_email.html.haml +++ b/app/views/notify/new_mention_in_merge_request_email.html.haml @@ -1,4 +1,4 @@ %p - You have been mentioned in merge request #{merge_request_reference_link(@merge_request)} + = (s_("Notify|You have been mentioned in merge request %{mr_link}") % { mr_link: merge_request_reference_link(@merge_request) }).html_safe = render template: 'notify/new_merge_request_email' diff --git a/app/views/projects/buttons/_clone.html.haml b/app/views/projects/buttons/_clone.html.haml index 5fd1c5cd403..10a6bc6b524 100644 --- a/app/views/projects/buttons/_clone.html.haml +++ b/app/views/projects/buttons/_clone.html.haml @@ -25,7 +25,8 @@ .input-group-append = clipboard_button(target: '#http_project_clone', title: _("Copy URL"), class: "input-group-text gl-button btn btn-icon btn-default") = render_if_exists 'projects/buttons/geo' - %li.divider.mt-2 + = render_if_exists 'projects/buttons/kerberos_clone_field' + %li.divider.mt-2 %li.pt-2.gl-new-dropdown-item %label.label-bold{ class: 'gl-px-4!' } = _('Open in your IDE') @@ -51,4 +52,3 @@ %a.dropdown-item.open-with-link{ href: xcode_uri_to_repo(@project) } .gl-new-dropdown-item-text-wrapper = _("Xcode") - = render_if_exists 'projects/buttons/kerberos_clone_field' diff --git a/app/views/projects/harbor/repositories/index.html.haml b/app/views/projects/harbor/repositories/index.html.haml index 0fce3b7f8aa..e6f0e3e950c 100644 --- a/app/views/projects/harbor/repositories/index.html.haml +++ b/app/views/projects/harbor/repositories/index.html.haml @@ -4,8 +4,9 @@ #js-harbor-registry-list-project{ data: { endpoint: project_harbor_repositories_path(@project), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'), - "repository_url" => 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - "registry_host_url_with_port" => 'demo.harbor.com', + "repository_url" => @project.harbor_integration.hostname, + "harbor_integration_project_name" => @project.harbor_integration.project_name, + "project_name" => @project.name, connection_error: (!!@connection_error).to_s, invalid_path_error: (!!@invalid_path_error).to_s, is_group_page: false.to_s, } } diff --git a/config/feature_flags/development/skip_checking_namespace_in_query.yml b/config/feature_flags/development/skip_checking_namespace_in_query.yml new file mode 100644 index 00000000000..2b9e3cbfe0b --- /dev/null +++ b/config/feature_flags/development/skip_checking_namespace_in_query.yml @@ -0,0 +1,8 @@ +--- +name: skip_checking_namespace_in_query +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/96559 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/370742 +milestone: '15.4' +type: development +group: group::source code +default_enabled: false diff --git a/config/routes/group.rb b/config/routes/group.rb index 2a5931207b0..591a1983435 100644 --- a/config/routes/group.rb +++ b/config/routes/group.rb @@ -122,7 +122,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do resources :email_campaigns, only: :index namespace :harbor do - resources :repositories, only: [:index] do + resources :repositories, only: [:index, :show], constraints: { id: %r{[a-zA-Z./:0-9_\-]+} } do resources :artifacts, only: [:index] do resources :tags, only: [:index] end diff --git a/config/routes/project.rb b/config/routes/project.rb index 83a13c62458..e1ee0c8f28b 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -466,7 +466,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end namespace :harbor do - resources :repositories, only: [:index, :show] do + resources :repositories, only: [:index, :show], constraints: { id: %r{[a-zA-Z./:0-9_\-]+} } do resources :artifacts, only: [:index] do resources :tags, only: [:index] end diff --git a/db/post_migrate/20220830051704_add_temporary_index_for_orphaned_invited_members.rb b/db/post_migrate/20220830051704_add_temporary_index_for_orphaned_invited_members.rb new file mode 100644 index 00000000000..90254ac3d86 --- /dev/null +++ b/db/post_migrate/20220830051704_add_temporary_index_for_orphaned_invited_members.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class AddTemporaryIndexForOrphanedInvitedMembers < Gitlab::Database::Migration[2.0] + disable_ddl_transaction! + + TMP_INDEX_NAME = 'tmp_idx_orphaned_invited_members' + + def up + add_concurrent_index('members', :id, where: query_condition, name: TMP_INDEX_NAME) + end + + def down + remove_concurrent_index_by_name('members', TMP_INDEX_NAME) if index_exists_by_name?('members', TMP_INDEX_NAME) + end + + private + + def query_condition + 'invite_token IS NULL and invite_accepted_at IS NOT NULL AND user_id IS NULL' + end +end diff --git a/db/post_migrate/20220830061704_orphaned_invited_members_cleanup.rb b/db/post_migrate/20220830061704_orphaned_invited_members_cleanup.rb new file mode 100644 index 00000000000..c5249510164 --- /dev/null +++ b/db/post_migrate/20220830061704_orphaned_invited_members_cleanup.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class OrphanedInvitedMembersCleanup < Gitlab::Database::Migration[2.0] + disable_ddl_transaction! + + restrict_gitlab_migration gitlab_schema: :gitlab_main + + def up + # rubocop:disable Style/SymbolProc + membership.where(query_condition).each_batch(of: 100) do |relation| + relation.delete_all + end + # rubocop:enable Style/SymbolProc + end + + def down + # This migration is irreversible + end + + private + + def membership + @membership ||= define_batchable_model('members') + end + + def query_condition + 'invite_token IS NULL and invite_accepted_at IS NOT NULL AND user_id IS NULL' + end +end diff --git a/db/post_migrate/20220830071704_remove_temporary_index_for_orphaned_invited_members.rb b/db/post_migrate/20220830071704_remove_temporary_index_for_orphaned_invited_members.rb new file mode 100644 index 00000000000..c6b712da4c0 --- /dev/null +++ b/db/post_migrate/20220830071704_remove_temporary_index_for_orphaned_invited_members.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RemoveTemporaryIndexForOrphanedInvitedMembers < Gitlab::Database::Migration[2.0] + disable_ddl_transaction! + + TMP_INDEX_NAME = 'tmp_idx_orphaned_invited_members' + + def up + remove_concurrent_index_by_name('members', TMP_INDEX_NAME) if index_exists_by_name?('members', TMP_INDEX_NAME) + end + + def down + add_concurrent_index('members', :id, where: query_condition, name: TMP_INDEX_NAME) + end + + private + + def query_condition + 'invite_token IS NULL and invite_accepted_at IS NOT NULL AND user_id IS NULL' + end +end diff --git a/db/schema_migrations/20220830051704 b/db/schema_migrations/20220830051704 new file mode 100644 index 00000000000..5785862da4f --- /dev/null +++ b/db/schema_migrations/20220830051704 @@ -0,0 +1 @@ +aa0b767ad0e38500e0eef83d5c8306054952363166f8cc2076ce48feeac1b0e1
\ No newline at end of file diff --git a/db/schema_migrations/20220830061704 b/db/schema_migrations/20220830061704 new file mode 100644 index 00000000000..7a0db1acc65 --- /dev/null +++ b/db/schema_migrations/20220830061704 @@ -0,0 +1 @@ +badc3556e1dea545bbf8b55fb33065f45598df9b3fda74bffd28e89d7485e0b4
\ No newline at end of file diff --git a/db/schema_migrations/20220830071704 b/db/schema_migrations/20220830071704 new file mode 100644 index 00000000000..bc9d7fd0f8b --- /dev/null +++ b/db/schema_migrations/20220830071704 @@ -0,0 +1 @@ +85e401f0920c6eb13b6756f191ccdf70494ca40f8133f05bbd5f23ba295b115d
\ No newline at end of file diff --git a/doc/administration/feature_flags.md b/doc/administration/feature_flags.md index 7ad9220ffb3..75e65c2a9d6 100644 --- a/doc/administration/feature_flags.md +++ b/doc/administration/feature_flags.md @@ -117,7 +117,7 @@ Some feature flags can be enabled or disabled on a per project basis: Feature.enable(:<feature flag>, Project.find(<project id>)) ``` -For example, to enable the [`:product_analytics`](../operations/product_analytics.md#enable-or-disable-product-analytics) feature flag for project `1234`: +For example, to enable the [`:product_analytics`](../operations/product_analytics.md) feature flag for project `1234`: ```ruby Feature.enable(:product_analytics, Project.find(1234)) diff --git a/doc/administration/package_information/supported_os.md b/doc/administration/package_information/supported_os.md index e4d7ea72cdc..5ccabd66ed0 100644 --- a/doc/administration/package_information/supported_os.md +++ b/doc/administration/package_information/supported_os.md @@ -24,6 +24,7 @@ The following lists the currently supported OSs and their possible EOL dates. | OpenSUSE 15.3 | GitLab CE / GitLab EE 14.5.0 | x86_64, aarch64 | [OpenSUSE Install Documentation](https://about.gitlab.com/install/#opensuse-leap-15-3) | Nov 2022 | <https://en.opensuse.org/Lifetime> | | RHEL 8 | GitLab CE / GitLab EE 12.8.1 | x86_64, arm64 | [Use CentOS Install Documentation](https://about.gitlab.com/install/#centos-7) | May 2029 | [RHEL Details](https://access.redhat.com/support/policy/updates/errata/#Life_Cycle_Dates) | | SLES 12 | GitLab EE 9.0.0 | x86_64 | [Use OpenSUSE Install Documentation](https://about.gitlab.com/install/#opensuse-leap-15-3) | Oct 2027 | <https://www.suse.com/lifecycle/> | +| SLES 15 | GitLab EE 14.8.0 | x86_64 | [Use OpenSUSE Install Documentation](https://about.gitlab.com/install/#opensuse-leap-15-3) | Dec 2024 | <https://www.suse.com/lifecycle/> | | Oracle Linux | GitLab CE / GitLab EE 8.14.0 | x86_64 | [Use CentOS Install Documentation](https://about.gitlab.com/install/#centos-7) | Jul 2024 | <https://www.oracle.com/a/ocom/docs/elsp-lifetime-069338.pdf> | | Scientific Linux | GitLab CE / GitLab EE 8.14.0 | x86_64 | [Use CentOS Install Documentation](https://about.gitlab.com/install/#centos-7) | June 2024 | <https://scientificlinux.org/downloads/sl-versions/sl7/> | | Ubuntu 18.04 | GitLab CE / GitLab EE 10.7.0 | amd64 | [Ubuntu Install Documentation](https://about.gitlab.com/install/#ubuntu) | April 2023 | <https://wiki.ubuntu.com/Releases> | diff --git a/doc/api/packages/debian.md b/doc/api/packages/debian.md index 4abb7bc7112..598124ba2b9 100644 --- a/doc/api/packages/debian.md +++ b/doc/api/packages/debian.md @@ -212,11 +212,11 @@ curl --header "Private-Token: <personal_access_token>" \ This writes the downloaded file using the remote filename in the current directory. -## Download a binary file's index +## Download a packages index > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/64923) in GitLab 14.2. -Download a distribution index. +Download a packages index. ```plaintext GET <route-prefix>/dists/*distribution/:component/binary-:architecture/Packages @@ -229,14 +229,73 @@ GET <route-prefix>/dists/*distribution/:component/binary-:architecture/Packages | `architecture` | string | yes | The distribution architecture type. | ```shell -curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/main/amd64/Packages" +curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/main/binary-amd64/Packages" ``` Write the output to a file: ```shell curl --header "Private-Token: <personal_access_token>" \ - "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/main/amd64/Packages" \ + "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/main/binary-amd64/Packages" \ + --remote-name +``` + +This writes the downloaded file using the remote filename in the current directory. + +## Download a Debian Installer packages index + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71918) in GitLab 15.4. + +Download a Debian Installer packages index. + +```plaintext +GET <route-prefix>/dists/*distribution/:component/debian-installer/binary-:architecture/Packages +``` + +| Attribute | Type | Required | Description | +| ----------------- | ------ | -------- | ----------- | +| `distribution` | string | yes | The codename or suite of the Debian distribution. | +| `component` | string | yes | The distribution component name. | +| `architecture` | string | yes | The distribution architecture type. | + +```shell +curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/main/debian-installer/binary-amd64/Packages" +``` + +Write the output to a file: + +```shell +curl --header "Private-Token: <personal_access_token>" \ + "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/main/debian-installer/binary-amd64/Packages" \ + --remote-name +``` + +This writes the downloaded file using the remote filename in the current directory. + +## Download a source packages index + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71918) in GitLab 15.4. + +Download a source packages index. + +```plaintext +GET <route-prefix>/dists/*distribution/:component/source/Sources +``` + +| Attribute | Type | Required | Description | +| ----------------- | ------ | -------- | ----------- | +| `distribution` | string | yes | The codename or suite of the Debian distribution. | +| `component` | string | yes | The distribution component name. | + +```shell +curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/main/source/Sources" +``` + +Write the output to a file: + +```shell +curl --header "Private-Token: <personal_access_token>" \ + "https://gitlab.example.com/api/v4/projects/1/packages/debian/dists/my-distro/main/source/Sources" \ --remote-name ``` diff --git a/doc/development/documentation/styleguide/index.md b/doc/development/documentation/styleguide/index.md index 1256b2282c3..9773729fc54 100644 --- a/doc/development/documentation/styleguide/index.md +++ b/doc/development/documentation/styleguide/index.md @@ -1365,32 +1365,24 @@ It renders on the GitLab documentation site as: On the docs site, you can format text so it's displayed as tabs. -NOTE: -For now, tabs are for testing only. Do not use them on the production docs site. - To create a set of tabs, follow this example: -```markdown -{::options parse_block_html="true" /} - -<div class="js-tabs"> +```plaintext +::Tabs -## This is the first tab -{: .no_toc} +:::TabTitle Tab One -Here's some content in tab panel one. +Here's some content in tab one. -## Tab two -{: .no_toc} +:::TabTitle Tab Two -Here's some content in tab panel two. +Here's some other content in tab two. -</div> +::EndTabs ``` -The headings determine the tab titles. Each tab is populated with the content between the titles. - -Use brief words for the titles, ensure they are parallel, and start each with a capital letter. For example: +For tab titles, be brief and consistent. Ensure they are parallel, and start each with a capital letter. +For example: - `Omnibus package`, `Helm chart`, `Source` - `15.1 and earlier`, `15.2 and later` diff --git a/doc/development/documentation/styleguide/word_list.md b/doc/development/documentation/styleguide/word_list.md index e9dd5409700..94c12f80fda 100644 --- a/doc/development/documentation/styleguide/word_list.md +++ b/doc/development/documentation/styleguide/word_list.md @@ -8,8 +8,12 @@ description: 'Writing styles, markup, formatting, and other standards for GitLab # Recommended word list To help ensure consistency in the documentation, the Technical Writing team -recommends these wording choices. The GitLab handbook also maintains a list of -[top misused terms](https://about.gitlab.com/handbook/communication/top-misused-terms/). +recommends these word choices. In addition: + +- The GitLab handbook contains a list of + [top misused terms](https://about.gitlab.com/handbook/communication/top-misused-terms/). +- The documentation [style guide](../styleguide#language) includes details + about language and capitalization. For guidance not on this page, we defer to these style guides: diff --git a/doc/operations/product_analytics.md b/doc/operations/product_analytics.md index 98ba6a9203c..a897e1d26e8 100644 --- a/doc/operations/product_analytics.md +++ b/doc/operations/product_analytics.md @@ -1,81 +1,45 @@ --- stage: Analytics -group: Product Intelligence +group: Product Analytics info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments --- # Product Analytics **(FREE)** -> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/225167) in GitLab 13.3. -> - It's deployed behind a feature flag, disabled by default. -> - It's disabled on GitLab.com. -> - It's able to be enabled or disabled per-project. -> - It's not recommended for production use. -> - To use it in GitLab self-managed instances, ask a GitLab administrator to enable it. +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/225167) in GitLab 13.3 [with a flag](../administration/feature_flags.md) named `product_analytics`. Disabled by default. -GitLab allows you to go from planning an application to getting feedback. Feedback -is not just observability, but also knowing how people use your product. -Product Analytics uses events sent from your application to know how they are using it. -It's based on [Snowplow](https://github.com/snowplow/snowplow), the best open-source -event tracker. With Product Analytics, you can receive and analyze the Snowplow data -inside GitLab. +FLAG: +On self-managed GitLab, by default this feature is not available. To make it available per project or for your entire instance, ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `product_analytics`. On GitLab.com, this feature is not available. The feature is not ready for production use. -## Enable or disable Product Analytics +GitLab enables you to go from planning an application to getting feedback. You can use +Product Analytics to receive and analyze events sent from your application. This analysis +provides observability information and feedback on how people use your product. -Product Analytics is under development and not ready for production use. It's -deployed behind a feature flag that's **disabled by default**. -[GitLab administrators with access to the GitLab Rails console](../administration/feature_flags.md) -can enable it for your instance. Product Analytics can be enabled or disabled per-project. +Events are collected by a [Rails collector](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36443) and +then processed with [Snowplow](https://github.com/snowplow/snowplow). Events are stored in a GitLab database. -To enable it: +## View Product Analytics -```ruby -# Instance-wide -Feature.enable(:product_analytics) -# or by project -Feature.enable(:product_analytics, Project.find(<project ID>)) -``` +You can view the event data collected about your applications. -To disable it: +Prerequisite: -```ruby -# Instance-wide -Feature.disable(:product_analytics) -# or by project -Feature.disable(:product_analytics, Project.find(<project ID>)) -``` +- You must have at least the Reporter role. -## Access Product Analytics +To access Product Analytics: -After enabling the feature flag for Product Analytics, you can access the -user interface: +1. On the top bar, select **Menu > Projects** and find your project. +1. On the left sidebar, select **Monitor > Product Analytics**. -1. Sign in to GitLab as a user with at least the Reporter role. -1. Navigate to **Monitor > Product Analytics**. +The Product Analytics interface contains: -The user interface contains: +- An Events tab that shows the recent events and a total count. +- A Graph tab that shows graphs based on events of the last 30 days. +- A Test tab that sends a sample event payload. +- A Setup page containing the code to implement in your application. -- An Events page that shows the recent events and a total count. -- A test page that sends a sample event. -- A setup page containing the code to implement in your application. - -## Rate limits for Product Analytics +## Rate limits While Product Analytics is under development, it's rate-limited to **100 events per minute** per project. This limit prevents the events table in the database from growing too quickly. - -## Data storage for Product Analytics - -Product Analytics stores events are stored in GitLab database. - -WARNING: -This data storage is experimental, and GitLab is likely to remove this data during -future development. - -## Event collection - -Events are collected by [Rails collector](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/36443), -allowing GitLab to ship the feature fast. Due to scalability issue, GitLab plans -to switch to a separate application, such as -[snowplow-go-collector](https://gitlab.com/gitlab-org/snowplow-go-collector), for event collection. diff --git a/doc/user/application_security/vulnerability_report/index.md b/doc/user/application_security/vulnerability_report/index.md index 15a287356f8..24db6bfd6bd 100644 --- a/doc/user/application_security/vulnerability_report/index.md +++ b/doc/user/application_security/vulnerability_report/index.md @@ -57,6 +57,7 @@ From the Vulnerability Report you can: - [View an issue raised for a vulnerability](#view-issues-raised-for-a-vulnerability). - [Change the status of vulnerabilities](#change-status-of-vulnerabilities). - [Export details of vulnerabilities](#export-vulnerability-details). +- [Sort vulnerabilities by date](#sort-vulnerabilities-by-date-detected). - [Manually add a vulnerability finding](#manually-add-a-vulnerability-finding). ## Vulnerability Report filters @@ -186,6 +187,12 @@ Vulnerability records cannot be deleted, so a permanent record always remains. If a vulnerability is dismissed in error, reverse the dismissal by changing its status. +## Sort vulnerabilities by date detected + +By default, vulnerabilities are sorted by severity level, with the highest-severity vulnerabilities listed at the top. + +To sort vulnerabilities by the date each vulnerability was detected, click the "Detected" column header. + ## Export vulnerability details > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/213014) in the Security Center (previously known as the Instance Security Dashboard) and project-level Vulnerability Report (previously known as the Project Security Dashboard) in GitLab 13.0. diff --git a/doc/user/clusters/agent/gitops.md b/doc/user/clusters/agent/gitops.md index 4978b56917b..67439788ef7 100644 --- a/doc/user/clusters/agent/gitops.md +++ b/doc/user/clusters/agent/gitops.md @@ -49,7 +49,7 @@ For details, view the [architecture documentation](https://gitlab.com/gitlab-org To update a Kubernetes cluster by using GitOps, complete the following steps. -1. Ensure you have a working Kubernetes cluster, and that the manifests are in a GitLab project. +1. Ensure you have a working Kubernetes cluster, and that the manifests or [Helm charts](gitops/helm.md) are in a GitLab project. 1. In the same project, [register and install the GitLab agent](install/index.md). 1. Configure the agent configuration file so that the agent monitors the project for changes to the Kubernetes manifests. Use the [GitOps configuration reference](#gitops-configuration-reference) for guidance. @@ -112,12 +112,12 @@ a Kubernetes SIG project. You can read more about the available annotations in t ## Automatic drift remediation -Drift happens when the current configuration of an infrastructure resource differs from its expected configuration. -Typically, this is caused by manually editing resources directly through the service that created the resource. Minimizing the -risk of drift helps to ensure configuration consistency and successful operations. +Drift happens when the current configuration of an infrastructure resource differs from its desired configuration. +Typically, this is caused by manually editing resources directly rather than via the used infrastructure-as-code +mechanism. Minimizing the risk of drift helps to ensure configuration consistency and successful operations. -In GitLab, the agent for Kubernetes regularly compares the expected state from the `git` repository with -the known state from the `cluster`. Deviations from the `git` state are fixed at every check. These checks +In GitLab, the agent for Kubernetes regularly compares the desired state from the `git` repository with +the actual state from the Kubernetes cluster. Deviations from the `git` state are fixed at every check. These checks happen automatically every 5 minutes. They are not configurable. The agent uses [server-side applies](https://kubernetes.io/docs/reference/using-api/server-side-apply/). @@ -127,6 +127,7 @@ are checked for drift. This facilitates the use of in-cluster controllers to mod ## Related topics +- [Deploying Helm charts with the GitOps workflow](gitops/helm.md) - [GitOps working examples for training and demos](https://gitlab.com/groups/guided-explorations/gl-k8s-agent/gitops/-/wikis/home) - [Self-paced classroom workshop](https://gitlab-for-eks.awsworkshop.io) (Uses AWS EKS, but you can use for other Kubernetes clusters) - [Managing Kubernetes secrets in a GitOps workflow](gitops/secrets_management.md) diff --git a/doc/user/clusters/agent/gitops/helm.md b/doc/user/clusters/agent/gitops/helm.md new file mode 100644 index 00000000000..8626ddeb45f --- /dev/null +++ b/doc/user/clusters/agent/gitops/helm.md @@ -0,0 +1,112 @@ +--- +stage: Configure +group: Configure +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +--- + +# Using Helm charts to update a Kubernetes cluster (Alpha) **(FREE)** + +> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/371019) in GitLab 15.4. + +You can deploy Helm charts to your Kubernetes cluster and keep the resources in your cluster in sync +with your charts and values. To do this, you use the pull-based GitOps features of the agent for +Kubernetes. + +This feature is in Alpha and [an epic exists](https://gitlab.com/groups/gitlab-org/-/epics/7938) +to track future work. Please tell us about your use cases by leaving comments in the epic. + +NOTE: +This feature is Alpha. In future releases, to accommodate new features, the configuration format might change without notice. + +## GitOps workflow steps + +To update a Kubernetes cluster by using GitOps with charts, complete the following steps. + +1. Ensure you have a working Kubernetes cluster, and that the chart is in a GitLab project. +1. In the same project, [register and install the GitLab agent](../install/index.md). +1. Configure the agent configuration file so that the agent monitors the project for changes to the chart. + Use the [GitOps configuration reference](#helm-configuration-reference) for guidance. + +## Helm chart with GitOps workflow + +To update a Kubernetes cluster by using Helm charts: + +1. Ensure you have a working Kubernetes cluster. +1. In a GitLab project: + - Store your Helm charts. + - [Register and install the GitLab agent](../install/index.md). +1. Update the agent configuration file so that the agent monitors the project for changes to the chart. + Use the [configuration reference](#helm-configuration-reference) for guidance. + +Any time you commit updates to your chart repository, the agent applies the chart in the cluster. + +## Helm configuration reference + +The following snippet shows an example of the possible keys and values for the GitOps section of an [agent configuration file](../install/index.md#create-an-agent-configuration-file) (`config.yaml`). + +```yaml +gitops: + charts: + - release_name: my-application-release + source: + project: + id: my-group/my-project-with-chart + path: dir-in-project/with/charts + namespace: my-ns + max_history: 1 +``` + +| Keyword | Description | +|--|--| +| `charts` | List of charts you want to be applied in your cluster. Charts are applied concurrently. All charts must be in the same directory. | +| `release_name` | Required. Name of the release to use when applying the chart. | +| `id` | Required. ID of the project where Helm chart is committed. No authentication mechanisms are currently supported. | +| `path` | Optional. Path of the chart in the project repository. Root of the repository is used by default. This is the directory with the `Chart.yaml` file. | +| `namespace` | Optional. Namespace to use when applying the chart. Defaults to `default`. | +| `max_history` | Optional. Maximum number of release [revisions to store in the cluster](https://helm.sh/docs/helm/helm_history/). | + +## Automatic drift remediation + +Drift happens when the current configuration of an infrastructure resource differs from its desired configuration. +Typically, drift is caused by manually editing resources directly, rather than by editing the code that describes the desired state. Minimizing the risk of drift helps to ensure configuration consistency and successful operations. +mechanism. Minimizing the risk of drift helps to ensure configuration consistency and successful operations. + +In GitLab, the agent for Kubernetes regularly compares the desired state from the chart source with +the actual state from the Kubernetes cluster. Deviations from the desired state are fixed at every check. These checks +happen automatically every 5 minutes. They are not configurable. + +## Known issues + +The following are known issues: + +- Your chart must be in a GitLab project. The project must be an agent configuration project or a public + project. This known issue also exists for manifest-based GitOps and is tracked in + [this epic](https://gitlab.com/groups/gitlab-org/-/epics/7704). +- Values for the chart must be in a `values.yaml` file. This file must be with the chart, + in the same project and path. +- Because of drift detection and remediation, release history, stored in the cluster, is not useful. + A new release is created every five minutes and the oldest release is discarded. + Eventually history consists only of the same information. + View [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/372023) for details. + +## Example repository layout + +```plaintext +/my-chart + ├── templates + | └── ... + ├── charts + | └── ... + ├── Chart.yaml + ├── Chart.lock + ├── values.yaml + ├── values.schema.json + └── some-file-used-in-chart.txt +``` + +## Troubleshooting + +### Agent cannot find values for the chart + +Make sure values are in `values.yaml` and in the same directory as the `Chart.yaml` file. +The filename must be lowercase, with `.yaml` extension (not `.yml`). diff --git a/doc/user/packages/debian_repository/index.md b/doc/user/packages/debian_repository/index.md index 1d846a60281..4143ab0881f 100644 --- a/doc/user/packages/debian_repository/index.md +++ b/doc/user/packages/debian_repository/index.md @@ -175,3 +175,43 @@ To install a package: ```shell sudo apt-get -y install -t <codename> <package-name> ``` + +## Download a source package + +To download a source package: + +1. Configure the repository: + + If you are using a private project, add your [credentials](#authenticate-to-the-package-registry) to your apt configuration: + + ```shell + echo 'machine gitlab.example.com login <username> password <your_access_token>' \ + | sudo tee /etc/apt/auth.conf.d/gitlab_project.conf + ``` + + Download your distribution key: + + ```shell + sudo mkdir -p /usr/local/share/keyrings + curl --header "PRIVATE-TOKEN: <your_access_token>" \ + "https://gitlab.example.com/api/v4/projects/<project_id>/debian_distributions/<codename>/key.asc" \ + | \ + gpg --dearmor \ + | \ + sudo tee /usr/local/share/keyrings/<codename>-archive-keyring.gpg \ + > /dev/null + ``` + + Add your project as a source: + + ```shell + echo 'deb-src [ signed-by=/usr/local/share/keyrings/<codename>-archive-keyring.gpg ] https://gitlab.example.com/api/v4/projects/<project_id>/packages/debian <codename> <component1> <component2>' \ + | sudo tee /etc/apt/sources.list.d/gitlab_project-sources.list + sudo apt-get update + ``` + +1. Download the source package: + + ```shell + sudo apt-get source -t <codename> <package-name> + ``` diff --git a/lib/api/concerns/packages/debian_package_endpoints.rb b/lib/api/concerns/packages/debian_package_endpoints.rb index e8d27448f02..6b234b86154 100644 --- a/lib/api/concerns/packages/debian_package_endpoints.rb +++ b/lib/api/concerns/packages/debian_package_endpoints.rb @@ -42,6 +42,23 @@ module API present_carrierwave_file!(package_file.file) end + + def present_index_file!(file_type) + relation = "::Packages::Debian::#{project_or_group.class.name}ComponentFile".constantize + + component_file = relation + .preload_distribution + .with_container(project_or_group) + .with_codename_or_suite(params[:distribution]) + .with_component_name(params[:component]) + .with_file_type(file_type) + .with_architecture_name(params[:architecture]) + .with_compression_type(nil) + .order_created_asc + .last! + + present_carrierwave_file!(component_file.file) + end end rescue_from ArgumentError do |e| @@ -66,6 +83,7 @@ module API namespace 'dists/*distribution', requirements: DISTRIBUTION_REQUIREMENTS do # GET {projects|groups}/:id/packages/debian/dists/*distribution/Release.gpg + # https://wiki.debian.org/DebianRepository/Format#A.22Release.22_files desc 'The Release file signature' do detail 'This feature was introduced in GitLab 13.5' end @@ -76,6 +94,7 @@ module API end # GET {projects|groups}/:id/packages/debian/dists/*distribution/Release + # https://wiki.debian.org/DebianRepository/Format#A.22Release.22_files desc 'The unsigned Release file' do detail 'This feature was introduced in GitLab 13.5' end @@ -86,6 +105,7 @@ module API end # GET {projects|groups}/:id/packages/debian/dists/*distribution/InRelease + # https://wiki.debian.org/DebianRepository/Format#A.22Release.22_files desc 'The signed Release file' do detail 'This feature was introduced in GitLab 13.5' end @@ -97,31 +117,54 @@ module API params do requires :component, type: String, desc: 'The Debian Component', regexp: Gitlab::Regex.debian_component_regex - requires :architecture, type: String, desc: 'The Debian Architecture', regexp: Gitlab::Regex.debian_architecture_regex end - namespace ':component/binary-:architecture', requirements: COMPONENT_ARCHITECTURE_REQUIREMENTS do - # GET {projects|groups}/:id/packages/debian/dists/*distribution/:component/binary-:architecture/Packages - desc 'The binary files index' do - detail 'This feature was introduced in GitLab 13.5' + namespace ':component', requirements: COMPONENT_ARCHITECTURE_REQUIREMENTS do + params do + requires :architecture, type: String, desc: 'The Debian Architecture', regexp: Gitlab::Regex.debian_architecture_regex + end + + namespace 'debian-installer/binary-:architecture' do + # GET {projects|groups}/:id/packages/debian/dists/*distribution/:component/debian-installer/binary-:architecture/Packages + # https://wiki.debian.org/DebianRepository/Format#A.22Packages.22_Indices + desc 'The installer (udeb) binary files index' do + detail 'This feature was introduced in GitLab 15.4' + end + + route_setting :authentication, authenticate_non_public: true + get 'Packages' do + present_index_file!(:di_packages) + end + end + + namespace 'source', requirements: COMPONENT_ARCHITECTURE_REQUIREMENTS do + # GET {projects|groups}/:id/packages/debian/dists/*distribution/:component/source/Sources + # https://wiki.debian.org/DebianRepository/Format#A.22Sources.22_Indices + desc 'The source files index' do + detail 'This feature was introduced in GitLab 15.4' + end + + route_setting :authentication, authenticate_non_public: true + get 'Sources' do + present_index_file!(:sources) + end + end + + params do + requires :architecture, type: String, desc: 'The Debian Architecture', regexp: Gitlab::Regex.debian_architecture_regex end - route_setting :authentication, authenticate_non_public: true - get 'Packages' do - relation = "::Packages::Debian::#{project_or_group.class.name}ComponentFile".constantize - - component_file = relation - .preload_distribution - .with_container(project_or_group) - .with_codename_or_suite(params[:distribution]) - .with_component_name(params[:component]) - .with_file_type(:packages) - .with_architecture_name(params[:architecture]) - .with_compression_type(nil) - .order_created_asc - .last! - - present_carrierwave_file!(component_file.file) + namespace 'binary-:architecture', requirements: COMPONENT_ARCHITECTURE_REQUIREMENTS do + # GET {projects|groups}/:id/packages/debian/dists/*distribution/:component/binary-:architecture/Packages + # https://wiki.debian.org/DebianRepository/Format#A.22Packages.22_Indices + desc 'The binary files index' do + detail 'This feature was introduced in GitLab 13.5' + end + + route_setting :authentication, authenticate_non_public: true + get 'Packages' do + present_index_file!(:packages) + end end end end diff --git a/lib/gitlab/auth/o_auth/auth_hash.rb b/lib/gitlab/auth/o_auth/auth_hash.rb index 467a933c131..82a5aad360c 100644 --- a/lib/gitlab/auth/o_auth/auth_hash.rb +++ b/lib/gitlab/auth/o_auth/auth_hash.rb @@ -33,7 +33,7 @@ module Gitlab end def password - @password ||= Gitlab::Utils.force_utf8(::User.random_password.downcase) + @password ||= Gitlab::Utils.force_utf8(::User.random_password) end def location diff --git a/lib/gitlab/cluster/lifecycle_events.rb b/lib/gitlab/cluster/lifecycle_events.rb index e423d1f17da..be08ada9d2f 100644 --- a/lib/gitlab/cluster/lifecycle_events.rb +++ b/lib/gitlab/cluster/lifecycle_events.rb @@ -4,6 +4,11 @@ require_relative '../utils' # Gitlab::Utils module Gitlab module Cluster + # We take advantage of the fact that the application is pre-loaded in the primary + # process. If it's a pre-fork server like Puma, this will be the Puma master process. + # Otherwise it is the worker itself such as for Sidekiq. + PRIMARY_PID = $$ + # # LifecycleEvents lets Rails initializers register application startup hooks # that are sensitive to forking. For example, to defer the creation of diff --git a/lib/gitlab/harbor/query.rb b/lib/gitlab/harbor/query.rb index c120810ecf1..fcd984b01ce 100644 --- a/lib/gitlab/harbor/query.rb +++ b/lib/gitlab/harbor/query.rb @@ -17,7 +17,7 @@ module Gitlab message: 'Id invalid' }, allow_blank: true validates :artifact_id, format: { - with: /\A[a-zA-Z0-9\_\.\-$]+\z/, + with: /\A[a-zA-Z0-9\_\.\-$:]+\z/, message: 'Id invalid' }, allow_blank: true validates :sort, format: { diff --git a/lib/gitlab/memory/watchdog.rb b/lib/gitlab/memory/watchdog.rb index 91edb68ad66..38231fa933b 100644 --- a/lib/gitlab/memory/watchdog.rb +++ b/lib/gitlab/memory/watchdog.rb @@ -16,8 +16,9 @@ module Gitlab # The duration for which a process may be above a given fragmentation # threshold is computed as `max_strikes * sleep_time_seconds`. class Watchdog - DEFAULT_SLEEP_TIME_SECONDS = 60 - DEFAULT_HEAP_FRAG_THRESHOLD = 0.5 + DEFAULT_SLEEP_TIME_SECONDS = 60 * 5 + DEFAULT_MAX_HEAP_FRAG = 0.5 + DEFAULT_MAX_MEM_GROWTH = 3.0 DEFAULT_MAX_STRIKES = 5 # This handler does nothing. It returns `false` to indicate to the @@ -29,7 +30,7 @@ module Gitlab class NullHandler include Singleton - def on_high_heap_fragmentation(value) + def call # NOP false end @@ -41,7 +42,7 @@ module Gitlab @pid = pid end - def on_high_heap_fragmentation(value) + def call Process.kill(:TERM, @pid) true end @@ -55,7 +56,7 @@ module Gitlab @worker = ::Puma::Cluster::WorkerHandle.new(0, $$, 0, puma_options) end - def on_high_heap_fragmentation(value) + def call @worker.term true end @@ -63,6 +64,9 @@ module Gitlab # max_heap_fragmentation: # The degree to which the Ruby heap is allowed to be fragmented. Range [0,1]. + # max_mem_growth: + # A multiplier for how much excess private memory a worker can map compared to a reference process + # (itself or the primary in a pre-fork server.) # max_strikes: # How many times the process is allowed to be above max_heap_fragmentation before # a handler is invoked. @@ -71,7 +75,8 @@ module Gitlab def initialize( handler: NullHandler.instance, logger: Logger.new($stdout), - max_heap_fragmentation: ENV['GITLAB_MEMWD_MAX_HEAP_FRAG']&.to_f || DEFAULT_HEAP_FRAG_THRESHOLD, + max_heap_fragmentation: ENV['GITLAB_MEMWD_MAX_HEAP_FRAG']&.to_f || DEFAULT_MAX_HEAP_FRAG, + max_mem_growth: ENV['GITLAB_MEMWD_MAX_MEM_GROWTH']&.to_f || DEFAULT_MAX_MEM_GROWTH, max_strikes: ENV['GITLAB_MEMWD_MAX_STRIKES']&.to_i || DEFAULT_MAX_STRIKES, sleep_time_seconds: ENV['GITLAB_MEMWD_SLEEP_TIME_SEC']&.to_i || DEFAULT_SLEEP_TIME_SECONDS, **options) @@ -79,17 +84,37 @@ module Gitlab @handler = handler @logger = logger - @max_heap_fragmentation = max_heap_fragmentation @sleep_time_seconds = sleep_time_seconds @max_strikes = max_strikes + @stats = { + heap_frag: { + max: max_heap_fragmentation, + strikes: 0 + }, + mem_growth: { + max: max_mem_growth, + strikes: 0 + } + } @alive = true - @strikes = 0 init_prometheus_metrics(max_heap_fragmentation) end - attr_reader :strikes, :max_heap_fragmentation, :max_strikes, :sleep_time_seconds + attr_reader :max_strikes, :sleep_time_seconds + + def max_heap_fragmentation + @stats[:heap_frag][:max] + end + + def max_mem_growth + @stats[:mem_growth][:max] + end + + def strikes(stat) + @stats[stat][:strikes] + end def call @logger.info(log_labels.merge(message: 'started')) @@ -97,7 +122,10 @@ module Gitlab while @alive sleep(@sleep_time_seconds) - monitor_heap_fragmentation if Feature.enabled?(:gitlab_memory_watchdog, type: :ops) + next unless Feature.enabled?(:gitlab_memory_watchdog, type: :ops) + + monitor_heap_fragmentation + monitor_memory_growth end @logger.info(log_labels.merge(message: 'stopped')) @@ -109,32 +137,73 @@ module Gitlab private - def monitor_heap_fragmentation - heap_fragmentation = Gitlab::Metrics::Memory.gc_heap_fragmentation + def monitor_memory_condition(stat_key) + return unless @alive + + stat = @stats[stat_key] + + ok, labels = yield(stat) - if heap_fragmentation > @max_heap_fragmentation - @strikes += 1 - @heap_frag_violations.increment + if ok + stat[:strikes] = 0 else - @strikes = 0 + stat[:strikes] += 1 + @counter_violations.increment(reason: stat_key.to_s) end - if @strikes > @max_strikes - # If the handler returns true, it means the event is handled and we can shut down. - @alive = !handle_heap_fragmentation_limit_exceeded(heap_fragmentation) - @strikes = 0 + if stat[:strikes] > @max_strikes + @alive = !memory_limit_exceeded_callback(stat_key, labels) + stat[:strikes] = 0 end end - def handle_heap_fragmentation_limit_exceeded(value) - @logger.warn( - log_labels.merge( - message: 'heap fragmentation limit exceeded', - memwd_cur_heap_frag: value - )) - @heap_frag_violations_handled.increment + def monitor_heap_fragmentation + monitor_memory_condition(:heap_frag) do |stat| + heap_fragmentation = Gitlab::Metrics::Memory.gc_heap_fragmentation + [ + heap_fragmentation <= stat[:max], + { + message: 'heap fragmentation limit exceeded', + memwd_cur_heap_frag: heap_fragmentation, + memwd_max_heap_frag: stat[:max] + } + ] + end + end + + def monitor_memory_growth + monitor_memory_condition(:mem_growth) do |stat| + worker_uss = Gitlab::Metrics::System.memory_usage_uss_pss[:uss] + reference_uss = reference_mem[:uss] + memory_limit = stat[:max] * reference_uss + [ + worker_uss <= memory_limit, + { + message: 'memory limit exceeded', + memwd_uss_bytes: worker_uss, + memwd_ref_uss_bytes: reference_uss, + memwd_max_uss_bytes: memory_limit + } + ] + end + end + + # On pre-fork systems this would be the primary process memory from which workers fork. + # Otherwise it is the current process' memory. + # + # We initialize this lazily because in the initializer the application may not have + # finished booting yet, which would yield an incorrect baseline. + def reference_mem + @reference_mem ||= Gitlab::Metrics::System.memory_usage_uss_pss(pid: Gitlab::Cluster::PRIMARY_PID) + end + + def memory_limit_exceeded_callback(stat_key, handler_labels) + all_labels = log_labels.merge(handler_labels) + .merge(memwd_cur_strikes: strikes(stat_key)) + @logger.warn(all_labels) + @counter_violations_handled.increment(reason: stat_key.to_s) - handler.on_high_heap_fragmentation(value) + handler.call end def handler @@ -151,9 +220,7 @@ module Gitlab worker_id: worker_id, memwd_handler_class: handler.class.name, memwd_sleep_time_s: @sleep_time_seconds, - memwd_max_heap_frag: @max_heap_fragmentation, memwd_max_strikes: @max_strikes, - memwd_cur_strikes: @strikes, memwd_rss_bytes: process_rss_bytes } end @@ -174,14 +241,14 @@ module Gitlab @heap_frag_limit.set({}, max_heap_fragmentation) default_labels = { pid: worker_id } - @heap_frag_violations = Gitlab::Metrics.counter( - :gitlab_memwd_heap_frag_violations_total, - 'Total number of times heap fragmentation in a Ruby process exceeded its allowed maximum', + @counter_violations = Gitlab::Metrics.counter( + :gitlab_memwd_violations_total, + 'Total number of times a Ruby process violated a memory threshold', default_labels ) - @heap_frag_violations_handled = Gitlab::Metrics.counter( - :gitlab_memwd_heap_frag_violations_handled_total, - 'Total number of times heap fragmentation violations in a Ruby process were handled', + @counter_violations_handled = Gitlab::Metrics.counter( + :gitlab_memwd_violations_handled_total, + 'Total number of times Ruby process memory violations were handled', default_labels ) end diff --git a/lib/sidebars/groups/menus/packages_registries_menu.rb b/lib/sidebars/groups/menus/packages_registries_menu.rb index fda90406e0a..97cf4b9e85a 100644 --- a/lib/sidebars/groups/menus/packages_registries_menu.rb +++ b/lib/sidebars/groups/menus/packages_registries_menu.rb @@ -50,7 +50,9 @@ module Sidebars end def harbor_registry__menu_item - return nil_menu_item(:harbor_registry) if Feature.disabled?(:harbor_registry_integration) + if Feature.disabled?(:harbor_registry_integration) || context.group.harbor_integration.nil? + return nil_menu_item(:harbor_registry) + end ::Sidebars::MenuItem.new( title: _('Harbor Registry'), diff --git a/lib/sidebars/projects/menus/packages_registries_menu.rb b/lib/sidebars/projects/menus/packages_registries_menu.rb index e4d4441c687..4e2e6308b9a 100644 --- a/lib/sidebars/projects/menus/packages_registries_menu.rb +++ b/lib/sidebars/projects/menus/packages_registries_menu.rb @@ -66,7 +66,9 @@ module Sidebars end def harbor_registry__menu_item - return ::Sidebars::NilMenuItem.new(item_id: :harbor_registry) if Feature.disabled?(:harbor_registry_integration) + if Feature.disabled?(:harbor_registry_integration) || context.project.harbor_integration.nil? + return ::Sidebars::NilMenuItem.new(item_id: :harbor_registry) + end ::Sidebars::MenuItem.new( title: _('Harbor Registry'), diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5816584e38f..1eeeca97f69 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -148,6 +148,11 @@ msgid_plural "%d approvers (you've approved)" msgstr[0] "" msgstr[1] "" +msgid "%d artifact" +msgid_plural "%d artifacts" +msgstr[0] "" +msgstr[1] "" + msgid "%d assigned issue" msgid_plural "%d assigned issues" msgstr[0] "" @@ -19220,17 +19225,17 @@ msgstr "" msgid "HarborIntegration|Use Harbor as this project's container registry." msgstr "" -msgid "HarborRegistry|%{count} Image repository" -msgid_plural "HarborRegistry|%{count} Image repositories" +msgid "HarborRegistry|%d artifact" +msgid_plural "HarborRegistry|%d artifacts" msgstr[0] "" msgstr[1] "" -msgid "HarborRegistry|%{count} Tag" -msgid_plural "HarborRegistry|%{count} Tags" +msgid "HarborRegistry|%{count} Image repository" +msgid_plural "HarborRegistry|%{count} Image repositories" msgstr[0] "" msgstr[1] "" -msgid "HarborRegistry|Configuration digest: %{digest}" +msgid "HarborRegistry|-- artifacts" msgstr "" msgid "HarborRegistry|Digest: %{imageId}" @@ -19242,43 +19247,37 @@ msgstr "" msgid "HarborRegistry|Harbor connection error" msgstr "" -msgid "HarborRegistry|Invalid tag: missing manifest digest" -msgstr "" - -msgid "HarborRegistry|Last updated %{time}" +msgid "HarborRegistry|Please try different search criteria" msgstr "" -msgid "HarborRegistry|Manifest digest: %{digest}" +msgid "HarborRegistry|Published %{timeInfo}" msgstr "" -msgid "HarborRegistry|Please try different search criteria" +msgid "HarborRegistry|Root image" msgstr "" -msgid "HarborRegistry|Published %{timeInfo}" +msgid "HarborRegistry|Something went wrong while fetching the artifact list." msgstr "" -msgid "HarborRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}" +msgid "HarborRegistry|Something went wrong while fetching the repository list." msgstr "" -msgid "HarborRegistry|Root image" +msgid "HarborRegistry|Something went wrong while fetching the tags." msgstr "" msgid "HarborRegistry|Sorry, your filter produced no results." msgstr "" -msgid "HarborRegistry|The filter returned no results" -msgstr "" - -msgid "HarborRegistry|The image repository could not be found." +msgid "HarborRegistry|Tag" msgstr "" -msgid "HarborRegistry|The last tag related to this image was recently removed. This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process. If you have any questions, contact your administrator." +msgid "HarborRegistry|The filter returned no results" msgstr "" -msgid "HarborRegistry|The requested image repository does not exist or has been deleted. If you think this is an error, try refreshing the page." +msgid "HarborRegistry|There are no harbor images stored for this project" msgstr "" -msgid "HarborRegistry|This image has no active tags" +msgid "HarborRegistry|This image has no artifacts" msgstr "" msgid "HarborRegistry|To widen your search, change or remove the filters above." @@ -19287,6 +19286,9 @@ msgstr "" msgid "HarborRegistry|We are having trouble connecting to the Harbor Registry. Please try refreshing the page. If this error persists, please review %{docLinkStart}the troubleshooting documentation%{docLinkEnd}." msgstr "" +msgid "HarborRegistry|With the Harbor Registry, every project can connect to a harbor space to store its Docker images." +msgstr "" + msgid "HarborRegistry|With the Harbor Registry, every project can have its own space to store images. %{docLinkStart}More information%{docLinkEnd}" msgstr "" @@ -26962,6 +26964,9 @@ msgstr "" msgid "Notify|You have been mentioned in an issue." msgstr "" +msgid "Notify|You have been mentioned in merge request %{mr_link}" +msgstr "" + msgid "Notify|Your request to join the %{target_to_join} %{target_type} has been %{denied_tag}." msgstr "" diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 91b6f9214fc..741619a2532 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -11,8 +11,8 @@ GEM adamantium (0.2.0) ice_nine (~> 0.11.0) memoizable (~> 0.4.0) - addressable (2.8.0) - public_suffix (>= 2.0.2, < 5.0) + addressable (2.8.1) + public_suffix (>= 2.0.2, < 6.0) airborne (0.3.4) activesupport rack @@ -118,7 +118,7 @@ GEM gitlab (4.18.0) httparty (~> 0.18) terminal-table (>= 1.5.1) - gitlab-qa (8.4.0) + gitlab-qa (8.4.1) activesupport (~> 6.1) gitlab (~> 4.18.0) http (~> 5.0) @@ -223,7 +223,7 @@ GEM pry-byebug (3.5.1) byebug (~> 9.1) pry (~> 0.10) - public_suffix (4.0.7) + public_suffix (5.0.0) racc (1.6.0) rack (2.2.3.1) rack-test (1.1.0) diff --git a/spec/factories/packages/debian/component_file.rb b/spec/factories/packages/debian/component_file.rb index eeba64ba5d2..7d447d00e15 100644 --- a/spec/factories/packages/debian/component_file.rb +++ b/spec/factories/packages/debian/component_file.rb @@ -30,10 +30,12 @@ FactoryBot.define do trait(:sources) do file_type { :sources } architecture { nil } + file_fixture { 'spec/fixtures/packages/debian/distribution/Sources' } end trait(:di_packages) do file_type { :di_packages } + file_fixture { 'spec/fixtures/packages/debian/distribution/D-I-Packages' } end trait(:object_storage) do diff --git a/spec/features/groups/navbar_spec.rb b/spec/features/groups/navbar_spec.rb index c2dde1ba3fd..5cc8d1a9712 100644 --- a/spec/features/groups/navbar_spec.rb +++ b/spec/features/groups/navbar_spec.rb @@ -81,7 +81,11 @@ RSpec.describe 'Group navbar' do end context 'when harbor registry is available' do + let(:harbor_integration) { create(:harbor_integration, group: group, project: nil) } + before do + group.update!(harbor_integration: harbor_integration) + stub_feature_flags(harbor_registry_integration: true) insert_harbor_registry_nav(_('Package Registry')) diff --git a/spec/features/incidents/incident_timeline_events_spec.rb b/spec/features/incidents/incident_timeline_events_spec.rb index f78a49759df..6db9f87d6f2 100644 --- a/spec/features/incidents/incident_timeline_events_spec.rb +++ b/spec/features/incidents/incident_timeline_events_spec.rb @@ -17,20 +17,20 @@ RSpec.describe 'Incident timeline events', :js do visit project_issues_incident_path(project, incident) wait_for_requests - click_link 'Timeline' + click_link s_('Incident|Timeline') end context 'when add event is clicked' do it 'submits event data when save is clicked' do - click_button 'Add new timeline event' + click_button s_('Incident|Add new timeline event') expect(page).to have_selector('.common-note-form') - fill_in 'Description', with: 'Event note goes here' + fill_in _('Description'), with: 'Event note goes here' fill_in 'timeline-input-hours', with: '07' fill_in 'timeline-input-minutes', with: '25' - click_button 'Save' + click_button _('Save') expect(page).to have_selector('.incident-timeline-events') @@ -45,24 +45,24 @@ RSpec.describe 'Incident timeline events', :js do before do click_button 'Add new timeline event' fill_in 'Description', with: 'Event note to edit' - click_button 'Save' + click_button _('Save') end it 'shows the confirmation modal and edits the event' do - click_button 'More actions' + click_button _('More actions') page.within '.gl-new-dropdown-contents' do - expect(page).to have_content('Edit') - page.find('.gl-new-dropdown-item-text-primary', text: 'Edit').click + expect(page).to have_content(_('Edit')) + page.find('.gl-new-dropdown-item-text-primary', text: _('Edit')).click end expect(page).to have_selector('.common-note-form') - fill_in 'Description', with: 'Event note goes here' + fill_in _('Description'), with: 'Event note goes here' fill_in 'timeline-input-hours', with: '07' fill_in 'timeline-input-minutes', with: '25' - click_button 'Save' + click_button _('Save') wait_for_requests @@ -75,28 +75,28 @@ RSpec.describe 'Incident timeline events', :js do context 'when delete is clicked' do before do - click_button 'Add new timeline event' - fill_in 'Description', with: 'Event note to delete' - click_button 'Save' + click_button s_('Incident|Add new timeline event') + fill_in _('Description'), with: 'Event note to delete' + click_button _('Save') end it 'shows the confirmation modal and deletes the event' do - click_button 'More actions' + click_button _('More actions') page.within '.gl-new-dropdown-contents' do - expect(page).to have_content('Delete') + expect(page).to have_content(_('Delete')) page.find('.gl-new-dropdown-item-text-primary', text: 'Delete').click end page.within '.modal' do - expect(page).to have_content('Delete event') + expect(page).to have_content(s_('Incident|Delete event')) end - click_button 'Delete event' + click_button s_('Incident|Delete event') wait_for_requests - expect(page).to have_content('No timeline items have been added yet.') + expect(page).to have_content(s_('Incident|No timeline items have been added yet.')) end end end diff --git a/spec/features/projects/navbar_spec.rb b/spec/features/projects/navbar_spec.rb index e07a5d09405..ec1980193f6 100644 --- a/spec/features/projects/navbar_spec.rb +++ b/spec/features/projects/navbar_spec.rb @@ -83,6 +83,8 @@ RSpec.describe 'Project navbar' do end context 'when harbor registry is available' do + let_it_be(:harbor_integration) { create(:harbor_integration, project: project) } + before do stub_feature_flags(harbor_registry_integration: true) diff --git a/spec/fixtures/packages/debian/distribution/D-I-Packages b/spec/fixtures/packages/debian/distribution/D-I-Packages new file mode 100644 index 00000000000..80272e3a12c --- /dev/null +++ b/spec/fixtures/packages/debian/distribution/D-I-Packages @@ -0,0 +1,2 @@ +Package: example-package +Description: This is an incomplete D-I Packages file diff --git a/spec/fixtures/packages/debian/distribution/Sources b/spec/fixtures/packages/debian/distribution/Sources new file mode 100644 index 00000000000..1097f1b1aff --- /dev/null +++ b/spec/fixtures/packages/debian/distribution/Sources @@ -0,0 +1,2 @@ +Package: example-package +Description: This is an incomplete Sources file diff --git a/spec/frontend/api/harbor_registry_spec.js b/spec/frontend/api/harbor_registry_spec.js new file mode 100644 index 00000000000..8a4c377ebd1 --- /dev/null +++ b/spec/frontend/api/harbor_registry_spec.js @@ -0,0 +1,107 @@ +import MockAdapter from 'axios-mock-adapter'; +import * as harborRegistryApi from '~/api/harbor_registry'; +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; + +describe('~/api/harbor_registry', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(axios, 'get'); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('getHarborRepositoriesList', () => { + it('fetches the harbor repositories of the configured harbor project', () => { + const requestPath = '/flightjs/Flight/-/harbor/repositories'; + const expectedUrl = `${requestPath}.json`; + const expectedParams = { + limit: 10, + page: 1, + sort: 'update_time desc', + requestPath, + }; + const expectResponse = [ + { + harbor_id: 1, + name: 'test-project/image-1', + artifact_count: 1, + creation_time: '2022-07-16T08:20:34.851Z', + update_time: '2022-07-16T08:20:34.851Z', + harbor_project_id: 2, + pull_count: 0, + location: 'http://demo.harbor.com/harbor/projects/2/repositories/image-1', + }, + ]; + mock.onGet(expectedUrl).reply(httpStatus.OK, expectResponse); + + return harborRegistryApi.getHarborRepositoriesList(expectedParams).then(({ data }) => { + expect(data).toEqual(expectResponse); + }); + }); + }); + + describe('getHarborArtifacts', () => { + it('fetches the artifacts of a particular harbor repository', () => { + const requestPath = '/flightjs/Flight/-/harbor/repositories'; + const repoName = 'image-1'; + const expectedUrl = `${requestPath}/${repoName}/artifacts.json`; + const expectedParams = { + limit: 10, + page: 1, + sort: 'name asc', + repoName, + requestPath, + }; + const expectResponse = [ + { + harbor_id: 1, + digest: 'sha256:dcdf379c574e1773d703f0c0d56d67594e7a91d6b84d11ff46799f60fb081c52', + size: 775241, + push_time: '2022-07-16T08:20:34.867Z', + tags: ['v2', 'v1', 'latest'], + }, + ]; + mock.onGet(expectedUrl).reply(httpStatus.OK, expectResponse); + + return harborRegistryApi.getHarborArtifacts(expectedParams).then(({ data }) => { + expect(data).toEqual(expectResponse); + }); + }); + }); + + describe('getHarborTags', () => { + it('fetches the tags of a particular artifact', () => { + const requestPath = '/flightjs/Flight/-/harbor/repositories'; + const repoName = 'image-1'; + const digest = 'sha256:5d98daa36cdc8d6c7ed6579ce17230f0f9fd893a9012fc069cb7d714c0e3df35'; + const expectedUrl = `${requestPath}/${repoName}/artifacts/${digest}/tags.json`; + const expectedParams = { + requestPath, + digest, + repoName, + }; + const expectResponse = [ + { + repositoryId: 4, + artifactId: 5, + id: 4, + name: 'latest', + pullTime: '0001-01-01T00:00:00.000Z', + pushTime: '2022-05-27T18:21:27.903Z', + signed: false, + immutable: false, + }, + ]; + mock.onGet(expectedUrl).reply(httpStatus.OK, expectResponse); + + return harborRegistryApi.getHarborTags(expectedParams).then(({ data }) => { + expect(data).toEqual(expectResponse); + }); + }); + }); +}); diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index 6cd32ff6b40..e26c52f0bf7 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -36,7 +36,7 @@ describe('Flash', () => { hideFlash(el, false); expect(el.style.opacity).toBe(''); - expect(el.style.transition).toBeFalsy(); + expect(el.style.transition).toHaveLength(0); }); it('removes element after transitionend', () => { diff --git a/spec/frontend/ide/components/preview/navigator_spec.js b/spec/frontend/ide/components/preview/navigator_spec.js index 532cb6e795c..043dcade858 100644 --- a/spec/frontend/ide/components/preview/navigator_spec.js +++ b/spec/frontend/ide/components/preview/navigator_spec.js @@ -76,7 +76,7 @@ describe('IDE clientside preview navigator', () => { listenHandler({ type: 'urlchange', url: `${TEST_HOST}/url1` }); await nextTick(); findBackButton().trigger('click'); - expect(findBackButton().attributes('disabled')).toBeFalsy(); + expect(findBackButton().attributes()).not.toHaveProperty('disabled'); }); it('is disabled when there is no previous entry', async () => { @@ -117,7 +117,7 @@ describe('IDE clientside preview navigator', () => { findBackButton().trigger('click'); await nextTick(); - expect(findForwardButton().attributes('disabled')).toBeFalsy(); + expect(findForwardButton().attributes()).not.toHaveProperty('disabled'); }); it('is disabled when there is no next entry', async () => { diff --git a/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js index a277dd70764..1286617d64a 100644 --- a/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js +++ b/spec/frontend/issues/show/components/incidents/create_timeline_events_form_spec.js @@ -1,13 +1,13 @@ import VueApollo from 'vue-apollo'; import Vue from 'vue'; import { GlDatepicker } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import CreateTimelineEvent from '~/issues/show/components/incidents/create_timeline_event.vue'; import TimelineEventsForm from '~/issues/show/components/incidents/timeline_events_form.vue'; import createTimelineEventMutation from '~/issues/show/components/incidents/graphql/queries/create_timeline_event.mutation.graphql'; import getTimelineEvents from '~/issues/show/components/incidents/graphql/queries/get_timeline_events.query.graphql'; +import { timelineFormI18n } from '~/issues/show/components/incidents/constants'; import createMockApollo from 'helpers/mock_apollo_helper'; import { createAlert } from '~/flash'; import { useFakeDate } from 'helpers/fake_date'; @@ -35,10 +35,9 @@ describe('Create Timeline events', () => { let responseSpy; let apolloProvider; - const findSubmitButton = () => wrapper.findByText(__('Save')); - const findSubmitAndAddButton = () => - wrapper.findByText(s__('Incident|Save and add another event')); - const findCancelButton = () => wrapper.findByText(__('Cancel')); + const findSubmitButton = () => wrapper.findByText(timelineFormI18n.save); + const findSubmitAndAddButton = () => wrapper.findByText(timelineFormI18n.saveAndAdd); + const findCancelButton = () => wrapper.findByText(timelineFormI18n.cancel); const findDatePicker = () => wrapper.findComponent(GlDatepicker); const findNoteInput = () => wrapper.findByTestId('input-note'); const setNoteInput = () => { diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js index d04d2965401..8ff27e4c06e 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js @@ -4,6 +4,7 @@ import { GlDatepicker } from '@gitlab/ui'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import TimelineEventsForm from '~/issues/show/components/incidents/timeline_events_form.vue'; +import { timelineFormI18n } from '~/issues/show/components/incidents/constants'; import { createAlert } from '~/flash'; import { useFakeDate } from 'helpers/fake_date'; @@ -34,9 +35,9 @@ describe('Timeline events form', () => { wrapper.destroy(); }); - const findSubmitButton = () => wrapper.findByText('Save'); - const findSubmitAndAddButton = () => wrapper.findByText('Save and add another event'); - const findCancelButton = () => wrapper.findByText('Cancel'); + const findSubmitButton = () => wrapper.findByText(timelineFormI18n.save); + const findSubmitAndAddButton = () => wrapper.findByText(timelineFormI18n.saveAndAdd); + const findCancelButton = () => wrapper.findByText(timelineFormI18n.cancel); const findDatePicker = () => wrapper.findComponent(GlDatepicker); const findHourInput = () => wrapper.findByTestId('input-hours'); const findMinuteInput = () => wrapper.findByTestId('input-minutes'); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js index 9f1f6aff57e..edbbc9f8f4e 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js @@ -1,6 +1,7 @@ import timezoneMock from 'timezone-mock'; import { GlIcon, GlDropdown } from '@gitlab/ui'; import { nextTick } from 'vue'; +import { timelineItemI18n } from '~/issues/show/components/incidents/constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import IncidentTimelineEventItem from '~/issues/show/components/incidents/timeline_events_item.vue'; import { mockEvents } from './mock_data'; @@ -27,7 +28,7 @@ describe('IncidentTimelineEventList', () => { const findCommentIcon = () => wrapper.findComponent(GlIcon); const findEventTime = () => wrapper.findByTestId('event-time'); const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDeleteButton = () => wrapper.findByText('Delete'); + const findDeleteButton = () => wrapper.findByText(timelineItemI18n.delete); describe('template', () => { it('shows comment icon', () => { diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js new file mode 100644 index 00000000000..50210fd5943 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_row_spec.js @@ -0,0 +1,143 @@ +import { GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { n__ } from '~/locale'; +import ArtifactsListRow from '~/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue'; +import RealListItem from '~/vue_shared/components/registry/list_item.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { numberToHumanSize } from '~/lib/utils/number_utils'; +import { harborArtifactsList, defaultConfig } from '../../mock_data'; + +describe('Harbor artifact list row', () => { + let wrapper; + + const ListItem = { + ...RealListItem, + data() { + return { + detailsSlots: [], + isDetailsShown: true, + }; + }, + }; + + const RouterLinkStub = { + props: { + to: { + type: Object, + }, + }, + render(createElement) { + return createElement('a', {}, this.$slots.default); + }, + }; + + const findListItem = () => wrapper.findComponent(ListItem); + const findClipboardButton = () => wrapper.findAllComponents(ClipboardButton); + const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip); + const findByTestId = (testId) => wrapper.findByTestId(testId); + + const $route = { + params: { + project: defaultConfig.harborIntegrationProjectName, + image: 'test-repository', + }, + }; + + const mountComponent = ({ propsData, config = defaultConfig }) => { + wrapper = shallowMountExtended(ArtifactsListRow, { + stubs: { + GlSprintf, + ListItem, + 'router-link': RouterLinkStub, + }, + mocks: { + $route, + }, + propsData, + provide() { + return { + ...config, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('list item', () => { + beforeEach(() => { + mountComponent({ + propsData: { + artifact: harborArtifactsList[0], + }, + }); + }); + + it('exists', () => { + expect(findListItem().exists()).toBe(true); + }); + + it('has the correct artifact name', () => { + expect(findByTestId('name').text()).toBe(harborArtifactsList[0].digest); + }); + + it('has the correct tags count', () => { + const tagsCount = harborArtifactsList[0].tags.length; + expect(findByTestId('tags-count').text()).toBe(n__('%d tag', '%d tags', tagsCount)); + }); + + it('has correct digest', () => { + expect(findByTestId('digest').text()).toBe('Digest: 5d98daa'); + }); + describe('time', () => { + it('has the correct push time', () => { + expect(findByTestId('time').text()).toBe('Published'); + expect(findTimeAgoTooltip().attributes()).toMatchObject({ + time: harborArtifactsList[0].pushTime, + }); + }); + }); + + describe('clipboard button', () => { + it('exists', () => { + expect(findClipboardButton()).toHaveLength(2); + }); + + it('has the correct props', () => { + expect(findClipboardButton().at(0).attributes()).toMatchObject({ + text: `docker pull demo.harbor.com/test-project/test-repository@${harborArtifactsList[0].digest}`, + title: `docker pull demo.harbor.com/test-project/test-repository@${harborArtifactsList[0].digest}`, + }); + + expect(findClipboardButton().at(1).attributes()).toMatchObject({ + text: harborArtifactsList[0].digest, + title: harborArtifactsList[0].digest, + }); + }); + }); + + describe('size', () => { + it('calculated correctly', () => { + expect(findByTestId('size').text()).toBe( + numberToHumanSize(Number(harborArtifactsList[0].size)), + ); + }); + + it('when size is missing', () => { + const artifactInfo = harborArtifactsList[0]; + artifactInfo.size = null; + + mountComponent({ + propsData: { + artifact: artifactInfo, + }, + }); + + expect(findByTestId('size').text()).toBe('0 bytes'); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_spec.js new file mode 100644 index 00000000000..f6c4d94bb05 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/components/details/artifacts_list_spec.js @@ -0,0 +1,75 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState } from '@gitlab/ui'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; +import ArtifactsList from '~/packages_and_registries/harbor_registry/components/details/artifacts_list.vue'; +import ArtifactsListRow from '~/packages_and_registries/harbor_registry/components/details/artifacts_list_row.vue'; +import { defaultConfig, harborArtifactsList } from '../../mock_data'; + +describe('Harbor artifacts list', () => { + let wrapper; + + const findTagsLoader = () => wrapper.find(TagsLoader); + const findGlEmptyState = () => wrapper.find(GlEmptyState); + const findRegistryList = () => wrapper.find(RegistryList); + const findArtifactsListRow = () => wrapper.findAllComponents(ArtifactsListRow); + + const mountComponent = ({ propsData, config = defaultConfig }) => { + wrapper = shallowMount(ArtifactsList, { + propsData, + stubs: { RegistryList }, + provide() { + return { + ...config, + }; + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when isLoading is true', () => { + beforeEach(() => { + mountComponent({ + propsData: { + isLoading: true, + pageInfo: {}, + filter: '', + artifacts: [], + }, + }); + }); + + it('show the loader', () => { + expect(findTagsLoader().exists()).toBe(true); + }); + + it('does not show the list', () => { + expect(findGlEmptyState().exists()).toBe(false); + expect(findRegistryList().exists()).toBe(false); + }); + }); + + describe('registry list', () => { + beforeEach(() => { + mountComponent({ + propsData: { + isLoading: false, + pageInfo: {}, + filter: '', + artifacts: harborArtifactsList, + }, + }); + }); + + it('exists', () => { + expect(findRegistryList().exists()).toBe(true); + }); + + it('one artifact row exist', () => { + expect(findArtifactsListRow()).toHaveLength(harborArtifactsList.length); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/details/details_header_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/details/details_header_spec.js new file mode 100644 index 00000000000..710aac4e22a --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/components/details/details_header_spec.js @@ -0,0 +1,85 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import DetailsHeader from '~/packages_and_registries/harbor_registry/components/details/details_header.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import { ROOT_IMAGE_TEXT } from '~/packages_and_registries/harbor_registry/constants/index'; + +describe('Harbor Details Header', () => { + let wrapper; + + const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`); + const findTitle = () => findByTestId('title'); + const findArtifactsCount = () => findByTestId('artifacts-count'); + + const mountComponent = ({ propsData }) => { + wrapper = shallowMount(DetailsHeader, { + propsData, + stubs: { + TitleArea, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('artifact name', () => { + describe('missing image name', () => { + beforeEach(() => { + mountComponent({ propsData: { imagesDetail: { name: '', artifactCount: 1 } } }); + }); + + it('root image ', () => { + expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT); + }); + }); + + describe('with artifact name present', () => { + beforeEach(() => { + mountComponent({ propsData: { imagesDetail: { name: 'shao/flinkx', artifactCount: 1 } } }); + }); + + it('shows artifact.name ', () => { + expect(findTitle().text()).toContain('shao/flinkx'); + }); + }); + }); + + describe('metadata items', () => { + describe('artifacts count', () => { + it('displays "-- artifacts" while loading', async () => { + mountComponent({ propsData: { imagesDetail: {} } }); + await nextTick(); + + expect(findArtifactsCount().props('text')).toBe('-- artifacts'); + }); + + it('when there is more than one artifact has the correct text', async () => { + mountComponent({ propsData: { imagesDetail: { name: 'shao/flinkx', artifactCount: 10 } } }); + + await nextTick(); + + expect(findArtifactsCount().props('text')).toBe('10 artifacts'); + }); + + it('when there is one artifact has the correct text', async () => { + mountComponent({ + propsData: { imagesDetail: { name: 'shao/flinkx', artifactCount: 1 } }, + }); + await nextTick(); + + expect(findArtifactsCount().props('text')).toBe('1 artifact'); + }); + + it('has the correct icon', async () => { + mountComponent({ + propsData: { imagesDetail: { name: 'shao/flinkx', artifactCount: 1 } }, + }); + await nextTick(); + + expect(findArtifactsCount().props('icon')).toBe('package'); + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js index 8560c4f78f7..bd1abc7775d 100644 --- a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js +++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_row_spec.js @@ -1,25 +1,24 @@ import { shallowMount, RouterLinkStub as RouterLink } from '@vue/test-utils'; -import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui'; +import { GlIcon, GlSkeletonLoader } from '@gitlab/ui'; import HarborListRow from '~/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import { harborListResponse } from '../../mock_data'; +import { harborImagesList } from '../../mock_data'; describe('Harbor List Row', () => { let wrapper; - const [item] = harborListResponse.repositories; + const item = harborImagesList[0]; const findDetailsLink = () => wrapper.find(RouterLink); const findClipboardButton = () => wrapper.findComponent(ClipboardButton); - const findTagsCount = () => wrapper.find('[data-testid="tags-count"]'); + const findArtifactsCount = () => wrapper.find('[data-testid="artifacts-count"]'); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const mountComponent = (props) => { wrapper = shallowMount(HarborListRow, { stubs: { RouterLink, - GlSprintf, ListItem, }, propsData: { @@ -42,7 +41,8 @@ describe('Harbor List Row', () => { expect(findDetailsLink().props('to')).toMatchObject({ name: 'details', params: { - id: item.id, + image: 'nginx', + project: 'nginx', }, }); }); @@ -56,17 +56,17 @@ describe('Harbor List Row', () => { }); }); - describe('tags count', () => { + describe('artifacts count', () => { it('exists', () => { mountComponent(); - expect(findTagsCount().exists()).toBe(true); + expect(findArtifactsCount().exists()).toBe(true); }); - it('contains a tag icon', () => { + it('contains a package icon', () => { mountComponent(); - const icon = findTagsCount().find(GlIcon); + const icon = findArtifactsCount().find(GlIcon); expect(icon.exists()).toBe(true); - expect(icon.props('name')).toBe('tag'); + expect(icon.props('name')).toBe('package'); }); describe('loading state', () => { @@ -76,23 +76,23 @@ describe('Harbor List Row', () => { expect(findSkeletonLoader().exists()).toBe(true); }); - it('hides the tags count while loading', () => { + it('hides the artifacts count while loading', () => { mountComponent({ metadataLoading: true }); - expect(findTagsCount().exists()).toBe(false); + expect(findArtifactsCount().exists()).toBe(false); }); }); - describe('tags count text', () => { - it('with one tag in the image', () => { + describe('artifacts count text', () => { + it('with one artifact in the image', () => { mountComponent({ item: { ...item, artifactCount: 1 } }); - expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag'); + expect(findArtifactsCount().text()).toMatchInterpolatedText('1 artifact'); }); - it('with more than one tag in the image', () => { + it('with more than one artifact in the image', () => { mountComponent({ item: { ...item, artifactCount: 3 } }); - expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags'); + expect(findArtifactsCount().text()).toMatchInterpolatedText('3 artifacts'); }); }); }); diff --git a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js index f018eff58c9..304878fe5df 100644 --- a/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js +++ b/spec/frontend/packages_and_registries/harbor_registry/components/list/harbor_list_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue'; import HarborListRow from '~/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue'; import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue'; -import { harborListResponse } from '../../mock_data'; +import { harborImagesList } from '../../mock_data'; describe('Harbor List', () => { let wrapper; @@ -13,8 +13,8 @@ describe('Harbor List', () => { wrapper = shallowMount(HarborList, { stubs: { RegistryList }, propsData: { - images: harborListResponse.repositories, - pageInfo: harborListResponse.pageInfo, + images: harborImagesList, + pageInfo: {}, ...props, }, }); @@ -28,7 +28,7 @@ describe('Harbor List', () => { it('contains one list element for each image', () => { mountComponent(); - expect(findHarborListRow().length).toBe(harborListResponse.repositories.length); + expect(findHarborListRow().length).toBe(harborImagesList.length); }); it('passes down the metadataLoading prop', () => { diff --git a/spec/frontend/packages_and_registries/harbor_registry/mock_data.js b/spec/frontend/packages_and_registries/harbor_registry/mock_data.js index 85399c22e79..1bcfcef9102 100644 --- a/spec/frontend/packages_and_registries/harbor_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/harbor_registry/mock_data.js @@ -1,173 +1,105 @@ -export const harborListResponse = { - repositories: [ - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 25, - name: 'shao/flinkx', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 26, - name: 'shao/flinkx1', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - { - artifactCount: 1, - creationTime: '2022-03-02T06:35:53.205Z', - id: 27, - name: 'shao/flinkx2', - projectId: 21, - pullCount: 0, - updateTime: '2022-03-02T06:35:53.205Z', - location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', - }, - ], - totalCount: 3, - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, - }, +export const harborImageDetailEmptyResponse = { + data: null, +}; + +export const harborImageDetailResponse = { + artifactCount: 10, + creationTime: '2022-03-02T06:35:53.205Z', + id: 25, + name: 'shao/flinkx', + projectId: 21, + pullCount: 0, + updateTime: '2022-03-02T06:35:53.205Z', + location: 'demo.harbor.com/gitlab-cn/build/cng-images/gitlab-kas', }; -export const harborTagsResponse = { - tags: [ - { - digest: 'sha256:7f386a1844faf341353e1c20f2f39f11f397604fedc475435d13f756eeb235d1', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - name: '02310e655103823920157bc4410ea361dc638bc2cda59667d2cb1f2a988e264c', - revision: 'f53bde3d44699e04e11cf15fb415046a0913e2623d878d89bc21adb2cbda5255', - shortRevision: 'f53bde3d4', - createdAt: '2022-03-02T23:59:05+00:00', - totalSize: '6623124', - }, - { - digest: 'sha256:4554416b84c4568fe93086620b637064ed029737aabe7308b96d50e3d9d92ed7', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - name: '02deb4dddf177212b50e883d5e4f6c03731fad1a18cd27261736cd9dbba79160', - revision: 'e1fe52d8bab66d71bd54a6b8784d3b9edbc68adbd6ea87f5fa44d9974144ef9e', - shortRevision: 'e1fe52d8b', - createdAt: '2022-02-10T01:09:56+00:00', - totalSize: '920760', - }, - { - digest: 'sha256:14f37b60e52b9ce0e9f8f7094b311d265384798592f783487c30aaa3d58e6345', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - name: '03bc5971bab1e849ba52a20a31e7273053f22b2ddb1d04bd6b77d53a2635727a', - revision: 'c72770c6eb93c421bc496964b4bffc742b1ec2e642cdab876be7afda1856029f', - shortRevision: 'c72770c6e', - createdAt: '2021-12-22T04:48:48+00:00', - totalSize: '48609053', - }, - { - digest: 'sha256:e925e3b8277ea23f387ed5fba5e78280cfac7cfb261a78cf046becf7b6a3faae', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - name: '03f495bc5714bff78bb14293320d336afdf47fd47ddff0c3c5f09f8da86d5d19', - revision: '1ac2a43194f4e15166abdf3f26e6ec92215240490b9cac834d63de1a3d87494a', - shortRevision: '1ac2a4319', - createdAt: '2022-03-09T11:02:27+00:00', - totalSize: '35141894', - }, - { - digest: 'sha256:7d8303fd5c077787a8c879f8f66b69e2b5605f48ccd3f286e236fb0749fcc1ca', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - name: '05a4e58231e54b70aab2d6f22ba4fbe10e48aa4ddcbfef11c5662241c2ae4fda', - revision: 'cf8fee086701016e1a84e6824f0c896951fef4cce9d4745459558b87eec3232c', - shortRevision: 'cf8fee086', - createdAt: '2022-01-21T11:31:43+00:00', - totalSize: '48716070', - }, - { - digest: 'sha256:b33611cefe20e4a41a6e0dce356a5d7ef3c177ea7536a58652f5b3a4f2f83549', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - name: '093d2746876997723541aec8b88687a4cdb3b5bbb0279c5089b7891317741a9a', - revision: '1a4b48198b13d55242c5164e64d41c4e9f75b5d9506bc6e0efc1534dd0dd1f15', - shortRevision: '1a4b48198', - createdAt: '2022-01-21T11:31:51+00:00', - totalSize: '6623127', - }, - { - digest: 'sha256:d25c3c020e2dbd4711a67b9fe308f4cbb7b0bb21815e722a02f91c570dc5d519', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - name: '09698b3fae81dfd6e02554dbc82930f304a6356c8f541c80e8598a42aed985f7', - revision: '03e2e2777dde01c30469ee8c710973dd08a7a4f70494d7dc1583c24b525d7f61', - shortRevision: '03e2e2777', - createdAt: '2022-03-02T23:58:20+00:00', - totalSize: '911377', - }, - { - digest: 'sha256:fb760e4d2184e9e8e39d6917534d4610fe01009734698a5653b2de1391ba28f4', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - name: '09b830c3eaf80d547f3b523d8e242a2c411085c349dab86c520f36c7b7644f95', - revision: '350e78d60646bf6967244448c6aaa14d21ecb9a0c6cf87e9ff0361cbe59b9012', - shortRevision: '350e78d60', - createdAt: '2022-01-19T13:49:14+00:00', - totalSize: '48710241', - }, - { - digest: 'sha256:407250f380cea92729cbc038c420e74900f53b852e11edc6404fe75a0fd2c402', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - name: '0d03504a17b467eafc8c96bde70af26c74bd459a32b7eb2dd189dd6b3c121557', - revision: '76038370b7f3904364891457c4a6a234897255e6b9f45d0a852bf3a7e5257e18', - shortRevision: '76038370b', - createdAt: '2022-01-24T12:56:22+00:00', - totalSize: '280065', - }, - { - digest: 'sha256:ada87f25218542951ce6720c27f3d0758e90c2540bd129f5cfb9e15b31e07b07', - location: - 'registry.gitlab.com/gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - path: - 'gitlab-org/gitlab/gitlab-ee-qa/cache:0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - name: '0eb20a4a7cac2ebea821d420b3279654fe550fd8502f1785c1927aa84e5949eb', - revision: '3d4b49a7bbb36c48bb721f4d0e76e7950bec3878ee29cdfdd6da39f575d6d37f', - shortRevision: '3d4b49a7b', - createdAt: '2022-02-17T17:37:52+00:00', - totalSize: '48655767', - }, - ], - totalCount: 100, - pageInfo: { - hasNextPage: false, - hasPreviousPage: false, +export const harborArtifactsResponse = [ + { + id: 1, + digest: 'sha256:5d98daa36cdc8d6c7ed6579ce17230f0f9fd893a9012fc069cb7d714c0e3df35', + size: 773928, + push_time: '2022-05-19T15:54:47.821Z', + tags: ['latest'], + }, +]; + +export const harborArtifactsList = [ + { + id: 1, + digest: 'sha256:5d98daa36cdc8d6c7ed6579ce17230f0f9fd893a9012fc069cb7d714c0e3df35', + size: 773928, + pushTime: '2022-05-19T15:54:47.821Z', + tags: ['latest'], + }, +]; + +export const harborTagsResponse = [ + { + repository_id: 4, + artifact_id: 5, + id: 4, + name: 'latest', + pull_time: '0001-01-01T00:00:00.000Z', + push_time: '2022-05-27T18:21:27.903Z', + signed: false, + immutable: false, }, +]; + +export const harborTagsList = [ + { + repositoryId: 4, + artifactId: 5, + id: 4, + name: 'latest', + pullTime: '0001-01-01T00:00:00.000Z', + pushTime: '2022-05-27T18:21:27.903Z', + signed: false, + immutable: false, + }, +]; + +export const defaultConfig = { + noContainersImage: 'noContainersImage', + repositoryUrl: 'demo.harbor.com', + harborIntegrationProjectName: 'test-project', + projectName: 'Flight', + endpoint: '/flightjs/Flight/-/harbor/repositories', + connectionError: false, + invalidPathError: false, + isGroupPage: false, + helpPagePath: '', + containersErrorImage: 'containersErrorImage', }; +export const defaultFullPath = 'flightjs/Flight'; + +export const harborImagesResponse = [ + { + id: 1, + name: 'nginx/nginx', + artifact_count: 1, + creation_time: '2022-05-29T10:07:16.812Z', + update_time: '2022-05-29T10:07:16.812Z', + project_id: 4, + pull_count: 0, + location: 'https://demo.goharbor.io/harbor/projects/4/repositories/nginx', + }, +]; + +export const harborImagesList = [ + { + id: 1, + name: 'nginx/nginx', + artifactCount: 1, + creationTime: '2022-05-29T10:07:16.812Z', + updateTime: '2022-05-29T10:07:16.812Z', + projectId: 4, + pullCount: 0, + location: 'https://demo.goharbor.io/harbor/projects/4/repositories/nginx', + }, +]; + export const dockerCommands = { dockerBuildCommand: 'foofoo', dockerPushCommand: 'barbar', diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js new file mode 100644 index 00000000000..15e1130e058 --- /dev/null +++ b/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js @@ -0,0 +1,162 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { GlFilteredSearchToken } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import HarborDetailsPage from '~/packages_and_registries/harbor_registry/pages/details.vue'; +import TagsLoader from '~/packages_and_registries/shared/components/tags_loader.vue'; +import ArtifactsList from '~/packages_and_registries/harbor_registry/components/details/artifacts_list.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import DetailsHeader from '~/packages_and_registries/harbor_registry/components/details/details_header.vue'; +import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + NAME_SORT_FIELD, + TOKEN_TYPE_TAG_NAME, +} from '~/packages_and_registries/harbor_registry/constants/index'; +import { harborArtifactsResponse, harborArtifactsList, defaultConfig } from '../mock_data'; + +let mockHarborArtifactsResponse; + +jest.mock('~/rest_api', () => ({ + getHarborArtifacts: () => mockHarborArtifactsResponse, +})); + +describe('Harbor Details Page', () => { + let wrapper; + + const findTagsLoader = () => wrapper.find(TagsLoader); + const findArtifactsList = () => wrapper.find(ArtifactsList); + const findDetailsHeader = () => wrapper.find(DetailsHeader); + const findPersistedSearch = () => wrapper.find(PersistedSearch); + + const waitForHarborDetailRequest = async () => { + await waitForPromises(); + await nextTick(); + }; + + const $route = { + params: { + project: 'test-project', + image: 'test-repository', + }, + }; + + const breadCrumbState = { + updateName: jest.fn(), + updateHref: jest.fn(), + }; + + const defaultHeaders = { + 'x-page': '1', + 'X-Per-Page': '20', + 'X-TOTAL': '1', + 'X-Total-Pages': '1', + }; + + const mountComponent = ({ config = defaultConfig } = {}) => { + wrapper = shallowMount(HarborDetailsPage, { + mocks: { + $route, + }, + provide() { + return { + breadCrumbState, + ...config, + }; + }, + }); + }; + + beforeEach(() => { + mockHarborArtifactsResponse = Promise.resolve({ + data: harborArtifactsResponse, + headers: defaultHeaders, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when isLoading is true', () => { + it('shows the loader', () => { + mountComponent(); + + expect(findTagsLoader().exists()).toBe(true); + }); + + it('does not show the list', () => { + mountComponent(); + + expect(findArtifactsList().exists()).toBe(false); + }); + }); + + describe('artifacts list', () => { + it('exists', async () => { + mountComponent(); + + findPersistedSearch().vm.$emit('update', { sort: 'NAME_ASC', filters: [] }); + await waitForHarborDetailRequest(); + + expect(findArtifactsList().exists()).toBe(true); + }); + + it('has the correct props bound', async () => { + mountComponent(); + + findPersistedSearch().vm.$emit('update', { sort: 'NAME_ASC', filters: [] }); + await waitForHarborDetailRequest(); + + expect(findArtifactsList().props()).toMatchObject({ + isLoading: false, + filter: '', + artifacts: harborArtifactsList, + pageInfo: { + page: 1, + perPage: 20, + total: 1, + totalPages: 1, + }, + }); + }); + }); + + describe('persisted search', () => { + it('has the correct props', () => { + mountComponent(); + + expect(findPersistedSearch().props()).toMatchObject({ + sortableFields: [NAME_SORT_FIELD], + defaultOrder: NAME_SORT_FIELD.orderBy, + defaultSort: 'asc', + tokens: [ + { + type: TOKEN_TYPE_TAG_NAME, + icon: 'tag', + title: s__('HarborRegistry|Tag'), + unique: true, + token: GlFilteredSearchToken, + operators: OPERATOR_IS_ONLY, + }, + ], + }); + }); + }); + + describe('header', () => { + it('has the correct props', async () => { + mountComponent(); + + findPersistedSearch().vm.$emit('update', { sort: 'NAME_ASC', filters: [] }); + await waitForHarborDetailRequest(); + + expect(findDetailsHeader().props()).toMatchObject({ + imagesDetail: { + name: 'test-project/test-repository', + artifactCount: 1, + }, + }); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js index 61ee36a2794..97d30e6fe99 100644 --- a/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/harbor_registry/pages/list_spec.js @@ -5,15 +5,14 @@ import HarborListHeader from '~/packages_and_registries/harbor_registry/componen import HarborRegistryList from '~/packages_and_registries/harbor_registry/pages/list.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; import waitForPromises from 'helpers/wait_for_promises'; -// import { harborListResponse } from '~/packages_and_registries/harbor_registry/mock_api.js'; import HarborList from '~/packages_and_registries/harbor_registry/components/list/harbor_list.vue'; import CliCommands from '~/packages_and_registries/shared/components/cli_commands.vue'; import { SORT_FIELDS } from '~/packages_and_registries/harbor_registry/constants/index'; -import { harborListResponse, dockerCommands } from '../mock_data'; +import { harborImagesResponse, defaultConfig, harborImagesList } from '../mock_data'; let mockHarborListResponse; -jest.mock('~/packages_and_registries/harbor_registry/mock_api.js', () => ({ - harborListResponse: () => mockHarborListResponse, +jest.mock('~/rest_api', () => ({ + getHarborRepositoriesList: () => mockHarborListResponse, })); describe('Harbor List Page', () => { @@ -24,34 +23,43 @@ describe('Harbor List Page', () => { await nextTick(); }; - beforeEach(() => { - mockHarborListResponse = Promise.resolve(harborListResponse); - }); - const findHarborListHeader = () => wrapper.findComponent(HarborListHeader); const findPersistedSearch = () => wrapper.findComponent(PersistedSearch); const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); const findHarborList = () => wrapper.findComponent(HarborList); const findCliCommands = () => wrapper.findComponent(CliCommands); + const defaultHeaders = { + 'x-page': '1', + 'X-Per-Page': '20', + 'X-TOTAL': '1', + 'X-Total-Pages': '1', + }; + const fireFirstSortUpdate = () => { findPersistedSearch().vm.$emit('update', { sort: 'UPDATED_DESC', filters: [] }); }; - const mountComponent = ({ config = { isGroupPage: false } } = {}) => { + const mountComponent = ({ config = defaultConfig } = {}) => { wrapper = shallowMount(HarborRegistryList, { stubs: { HarborListHeader, }, provide() { return { - config, - ...dockerCommands, + ...config, }; }, }); }; + beforeEach(() => { + mockHarborListResponse = Promise.resolve({ + data: harborImagesResponse, + headers: defaultHeaders, + }); + }); + afterEach(() => { wrapper.destroy(); }); @@ -64,7 +72,7 @@ describe('Harbor List Page', () => { expect(findHarborListHeader().exists()).toBe(true); expect(findHarborListHeader().props()).toMatchObject({ - imagesCount: 3, + imagesCount: 1, metadataLoading: false, }); }); @@ -117,6 +125,16 @@ describe('Harbor List Page', () => { await nextTick(); expect(findHarborList().exists()).toBe(true); + expect(findHarborList().props()).toMatchObject({ + images: harborImagesList, + metadataLoading: false, + pageInfo: { + page: 1, + perPage: 20, + total: 1, + totalPages: 1, + }, + }); }); }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js b/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js index 7727bf167fe..22c2d0d3b1a 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js +++ b/spec/frontend/packages_and_registries/shared/components/cli_commands_spec.js @@ -15,7 +15,7 @@ import { import Tracking from '~/tracking'; import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; -import { dockerCommands } from '../../mock_data'; +import { dockerCommands } from 'jest/packages_and_registries/container_registry/explorer/mock_data'; Vue.use(Vuex); diff --git a/spec/lib/gitlab/memory/watchdog_spec.rb b/spec/lib/gitlab/memory/watchdog_spec.rb index 010f6884df3..beb49660022 100644 --- a/spec/lib/gitlab/memory/watchdog_spec.rb +++ b/spec/lib/gitlab/memory/watchdog_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'spec_helper' +require_relative '../../../../lib/gitlab/cluster/lifecycle_events' RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do context 'watchdog' do @@ -8,23 +9,31 @@ RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do let(:handler) { instance_double(described_class::NullHandler) } let(:heap_frag_limit_gauge) { instance_double(::Prometheus::Client::Gauge) } - let(:heap_frag_violations_counter) { instance_double(::Prometheus::Client::Counter) } - let(:heap_frag_violations_handled_counter) { instance_double(::Prometheus::Client::Counter) } + let(:violations_counter) { instance_double(::Prometheus::Client::Counter) } + let(:violations_handled_counter) { instance_double(::Prometheus::Client::Counter) } let(:sleep_time) { 0.1 } let(:max_heap_fragmentation) { 0.2 } + let(:max_mem_growth) { 2 } + + # Defaults that will not trigger any events. + let(:fragmentation) { 0 } + let(:worker_memory) { 0 } + let(:primary_memory) { 0 } + let(:max_strikes) { 0 } # Tests should set this to control the number of loop iterations in `call`. let(:watchdog_iterations) { 1 } subject(:watchdog) do described_class.new(handler: handler, logger: logger, sleep_time_seconds: sleep_time, - max_strikes: max_strikes, max_heap_fragmentation: max_heap_fragmentation).tap do |instance| + max_strikes: max_strikes, max_mem_growth: max_mem_growth, + max_heap_fragmentation: max_heap_fragmentation).tap do |instance| # We need to defuse `sleep` and stop the internal loop after N iterations. iterations = 0 - expect(instance).to receive(:sleep) do - instance.stop if (iterations += 1) >= watchdog_iterations - end.at_most(watchdog_iterations) + allow(instance).to receive(:sleep) do + instance.stop if (iterations += 1) > watchdog_iterations + end end end @@ -33,34 +42,35 @@ RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do .with(:gitlab_memwd_heap_frag_limit, anything) .and_return(heap_frag_limit_gauge) allow(Gitlab::Metrics).to receive(:counter) - .with(:gitlab_memwd_heap_frag_violations_total, anything, anything) - .and_return(heap_frag_violations_counter) + .with(:gitlab_memwd_violations_total, anything, anything) + .and_return(violations_counter) allow(Gitlab::Metrics).to receive(:counter) - .with(:gitlab_memwd_heap_frag_violations_handled_total, anything, anything) - .and_return(heap_frag_violations_handled_counter) + .with(:gitlab_memwd_violations_handled_total, anything, anything) + .and_return(violations_handled_counter) allow(heap_frag_limit_gauge).to receive(:set) - allow(heap_frag_violations_counter).to receive(:increment) - allow(heap_frag_violations_handled_counter).to receive(:increment) + allow(violations_counter).to receive(:increment) + allow(violations_handled_counter).to receive(:increment) end before do stub_prometheus_metrics - allow(handler).to receive(:on_high_heap_fragmentation).and_return(true) + allow(handler).to receive(:call).and_return(true) allow(logger).to receive(:warn) allow(logger).to receive(:info) allow(Gitlab::Metrics::Memory).to receive(:gc_heap_fragmentation).and_return(fragmentation) + allow(Gitlab::Metrics::System).to receive(:memory_usage_uss_pss).and_return({ uss: worker_memory }) + allow(Gitlab::Metrics::System).to receive(:memory_usage_uss_pss).with( + pid: Gitlab::Cluster::PRIMARY_PID + ).and_return({ uss: primary_memory }) allow(::Prometheus::PidProvider).to receive(:worker_id).and_return('worker_1') end context 'when created' do - let(:fragmentation) { 0 } - let(:max_strikes) { 0 } - it 'sets the heap fragmentation limit gauge' do expect(heap_frag_limit_gauge).to receive(:set).with({}, max_heap_fragmentation) @@ -71,7 +81,8 @@ RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do it 'initializes with defaults' do watchdog = described_class.new(handler: handler, logger: logger) - expect(watchdog.max_heap_fragmentation).to eq(described_class::DEFAULT_HEAP_FRAG_THRESHOLD) + expect(watchdog.max_heap_fragmentation).to eq(described_class::DEFAULT_MAX_HEAP_FRAG) + expect(watchdog.max_mem_growth).to eq(described_class::DEFAULT_MAX_MEM_GROWTH) expect(watchdog.max_strikes).to eq(described_class::DEFAULT_MAX_STRIKES) expect(watchdog.sleep_time_seconds).to eq(described_class::DEFAULT_SLEEP_TIME_SECONDS) end @@ -82,6 +93,7 @@ RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do stub_env('GITLAB_MEMWD_MAX_HEAP_FRAG', 1) stub_env('GITLAB_MEMWD_MAX_STRIKES', 2) stub_env('GITLAB_MEMWD_SLEEP_TIME_SEC', 3) + stub_env('GITLAB_MEMWD_MAX_MEM_GROWTH', 4) end it 'initializes with these settings' do @@ -90,30 +102,17 @@ RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do expect(watchdog.max_heap_fragmentation).to eq(1) expect(watchdog.max_strikes).to eq(2) expect(watchdog.sleep_time_seconds).to eq(3) + expect(watchdog.max_mem_growth).to eq(4) end end end - context 'when process does not exceed heap fragmentation threshold' do - let(:fragmentation) { max_heap_fragmentation - 0.1 } - let(:max_strikes) { 0 } # To rule out that we were granting too many strikes. - - it 'does not signal the handler' do - expect(handler).not_to receive(:on_high_heap_fragmentation) - - watchdog.call - end - end - - context 'when process exceeds heap fragmentation threshold permanently' do - let(:fragmentation) { max_heap_fragmentation + 0.1 } - let(:max_strikes) { 3 } - + shared_examples 'has strikes left' do |stat| context 'when process has not exceeded allowed number of strikes' do let(:watchdog_iterations) { max_strikes } it 'does not signal the handler' do - expect(handler).not_to receive(:on_high_heap_fragmentation) + expect(handler).not_to receive(:call) watchdog.call end @@ -125,119 +124,228 @@ RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do end it 'increments the violations counter' do - expect(heap_frag_violations_counter).to receive(:increment).exactly(watchdog_iterations) + expect(violations_counter).to receive(:increment).with(reason: stat).exactly(watchdog_iterations) watchdog.call end it 'does not increment violations handled counter' do - expect(heap_frag_violations_handled_counter).not_to receive(:increment) + expect(violations_handled_counter).not_to receive(:increment) watchdog.call end end + end + + shared_examples 'no strikes left' do |stat| + it 'signals the handler and resets strike counter' do + expect(handler).to receive(:call).and_return(true) + + watchdog.call + + expect(watchdog.strikes(stat.to_sym)).to eq(0) + end + + it 'increments both the violations and violations handled counters' do + expect(violations_counter).to receive(:increment).with(reason: stat).exactly(watchdog_iterations) + expect(violations_handled_counter).to receive(:increment).with(reason: stat) + + watchdog.call + end - context 'when process exceeds the allowed number of strikes' do - let(:watchdog_iterations) { max_strikes + 1 } + context 'when enforce_memory_watchdog ops toggle is off' do + before do + stub_feature_flags(enforce_memory_watchdog: false) + end - it 'signals the handler and resets strike counter' do - expect(handler).to receive(:on_high_heap_fragmentation).and_return(true) + it 'always uses the NullHandler' do + expect(handler).not_to receive(:call) + expect(described_class::NullHandler.instance).to receive(:call).and_return(true) watchdog.call + end + end - expect(watchdog.strikes).to eq(0) + context 'when handler result is true' do + it 'considers the event handled and stops itself' do + expect(handler).to receive(:call).once.and_return(true) + expect(logger).to receive(:info).with(hash_including(message: 'stopped')) + + watchdog.call end + end - it 'logs the event' do - expect(Gitlab::Metrics::System).to receive(:memory_usage_rss).at_least(:once).and_return(1024) - expect(logger).to receive(:warn).with({ - message: 'heap fragmentation limit exceeded', - pid: Process.pid, - worker_id: 'worker_1', - memwd_handler_class: 'RSpec::Mocks::InstanceVerifyingDouble', - memwd_sleep_time_s: sleep_time, - memwd_max_heap_frag: max_heap_fragmentation, - memwd_cur_heap_frag: fragmentation, - memwd_max_strikes: max_strikes, - memwd_cur_strikes: max_strikes + 1, - memwd_rss_bytes: 1024 - }) + context 'when handler result is false' do + let(:max_strikes) { 0 } # to make sure the handler fires each iteration + let(:watchdog_iterations) { 3 } + + it 'keeps running' do + expect(violations_counter).to receive(:increment).exactly(watchdog_iterations) + expect(violations_handled_counter).to receive(:increment).exactly(watchdog_iterations) + # Return true the third time to terminate the daemon. + expect(handler).to receive(:call).and_return(false, false, true) watchdog.call end + end + end + + context 'when monitoring memory growth' do + let(:primary_memory) { 2048 } - it 'increments both the violations and violations handled counters' do - expect(heap_frag_violations_counter).to receive(:increment).exactly(watchdog_iterations) - expect(heap_frag_violations_handled_counter).to receive(:increment) + context 'when process does not exceed threshold' do + let(:worker_memory) { max_mem_growth * primary_memory - 1 } + + it 'does not signal the handler' do + expect(handler).not_to receive(:call) watchdog.call end + end - context 'when enforce_memory_watchdog ops toggle is off' do - before do - stub_feature_flags(enforce_memory_watchdog: false) - end + context 'when process exceeds threshold permanently' do + let(:worker_memory) { max_mem_growth * primary_memory + 1 } + let(:max_strikes) { 3 } + + it_behaves_like 'has strikes left', 'mem_growth' + + context 'when process exceeds the allowed number of strikes' do + let(:watchdog_iterations) { max_strikes + 1 } - it 'always uses the NullHandler' do - expect(handler).not_to receive(:on_high_heap_fragmentation) - expect(described_class::NullHandler.instance).to( - receive(:on_high_heap_fragmentation).with(fragmentation).and_return(true) - ) + it_behaves_like 'no strikes left', 'mem_growth' + + it 'only reads reference memory once' do + expect(Gitlab::Metrics::System).to receive(:memory_usage_uss_pss) + .with(pid: Gitlab::Cluster::PRIMARY_PID) + .once watchdog.call end - end - context 'when handler result is true' do - it 'considers the event handled and stops itself' do - expect(handler).to receive(:on_high_heap_fragmentation).once.and_return(true) - expect(logger).to receive(:info).with(hash_including(message: 'stopped')) + it 'logs the event' do + expect(Gitlab::Metrics::System).to receive(:memory_usage_rss).at_least(:once).and_return(1024) + expect(logger).to receive(:warn).with({ + message: 'memory limit exceeded', + pid: Process.pid, + worker_id: 'worker_1', + memwd_handler_class: 'RSpec::Mocks::InstanceVerifyingDouble', + memwd_sleep_time_s: sleep_time, + memwd_max_uss_bytes: max_mem_growth * primary_memory, + memwd_ref_uss_bytes: primary_memory, + memwd_uss_bytes: worker_memory, + memwd_rss_bytes: 1024, + memwd_max_strikes: max_strikes, + memwd_cur_strikes: max_strikes + 1 + }) watchdog.call end end + end + + context 'when process exceeds threshold temporarily' do + let(:worker_memory) { max_mem_growth * primary_memory } + let(:max_strikes) { 1 } + let(:watchdog_iterations) { 4 } + + before do + allow(Gitlab::Metrics::System).to receive(:memory_usage_uss_pss).and_return( + { uss: worker_memory - 0.1 }, + { uss: worker_memory + 0.2 }, + { uss: worker_memory - 0.1 }, + { uss: worker_memory + 0.1 } + ) + allow(Gitlab::Metrics::System).to receive(:memory_usage_uss_pss).with( + pid: Gitlab::Cluster::PRIMARY_PID + ).and_return({ uss: primary_memory }) + end + + it 'does not signal the handler' do + expect(handler).not_to receive(:call) + + watchdog.call + end + end + end + + context 'when monitoring heap fragmentation' do + context 'when process does not exceed threshold' do + let(:fragmentation) { max_heap_fragmentation - 0.1 } + + it 'does not signal the handler' do + expect(handler).not_to receive(:call) + + watchdog.call + end + end + + context 'when process exceeds threshold permanently' do + let(:fragmentation) { max_heap_fragmentation + 0.1 } + let(:max_strikes) { 3 } - context 'when handler result is false' do - let(:max_strikes) { 0 } # to make sure the handler fires each iteration - let(:watchdog_iterations) { 3 } + it_behaves_like 'has strikes left', 'heap_frag' - it 'keeps running' do - expect(heap_frag_violations_counter).to receive(:increment).exactly(watchdog_iterations) - expect(heap_frag_violations_handled_counter).to receive(:increment).exactly(watchdog_iterations) - # Return true the third time to terminate the daemon. - expect(handler).to receive(:on_high_heap_fragmentation).and_return(false, false, true) + context 'when process exceeds the allowed number of strikes' do + let(:watchdog_iterations) { max_strikes + 1 } + + it_behaves_like 'no strikes left', 'heap_frag' + + it 'logs the event' do + expect(Gitlab::Metrics::System).to receive(:memory_usage_rss).at_least(:once).and_return(1024) + expect(logger).to receive(:warn).with({ + message: 'heap fragmentation limit exceeded', + pid: Process.pid, + worker_id: 'worker_1', + memwd_handler_class: 'RSpec::Mocks::InstanceVerifyingDouble', + memwd_sleep_time_s: sleep_time, + memwd_max_heap_frag: max_heap_fragmentation, + memwd_cur_heap_frag: fragmentation, + memwd_max_strikes: max_strikes, + memwd_cur_strikes: max_strikes + 1, + memwd_rss_bytes: 1024 + }) watchdog.call end end end - end - context 'when process exceeds heap fragmentation threshold temporarily' do - let(:fragmentation) { max_heap_fragmentation } - let(:max_strikes) { 1 } - let(:watchdog_iterations) { 4 } + context 'when process exceeds threshold temporarily' do + let(:fragmentation) { max_heap_fragmentation } + let(:max_strikes) { 1 } + let(:watchdog_iterations) { 4 } - before do - allow(Gitlab::Metrics::Memory).to receive(:gc_heap_fragmentation).and_return( - fragmentation - 0.1, - fragmentation + 0.2, - fragmentation - 0.1, - fragmentation + 0.1 - ) + before do + allow(Gitlab::Metrics::Memory).to receive(:gc_heap_fragmentation).and_return( + fragmentation - 0.1, + fragmentation + 0.2, + fragmentation - 0.1, + fragmentation + 0.1 + ) + end + + it 'does not signal the handler' do + expect(handler).not_to receive(:call) + + watchdog.call + end end + end - it 'does not signal the handler' do - expect(handler).not_to receive(:on_high_heap_fragmentation) + context 'when both memory fragmentation and growth exceed thresholds' do + let(:fragmentation) { max_heap_fragmentation + 0.1 } + let(:primary_memory) { 2048 } + let(:worker_memory) { max_mem_growth * primary_memory + 1 } + let(:watchdog_iterations) { max_strikes + 1 } + + it 'only calls the handler once' do + expect(handler).to receive(:call).once.and_return(true) watchdog.call end end context 'when gitlab_memory_watchdog ops toggle is off' do - let(:fragmentation) { 0 } - let(:max_strikes) { 0 } - before do stub_feature_flags(gitlab_memory_watchdog: false) end @@ -247,6 +355,12 @@ RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do watchdog.call end + + it 'does not monitor memory growth' do + expect(Gitlab::Metrics::System).not_to receive(:memory_usage_uss_pss) + + watchdog.call + end end end @@ -254,9 +368,9 @@ RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do context 'NullHandler' do subject(:handler) { described_class::NullHandler.instance } - describe '#on_high_heap_fragmentation' do + describe '#call' do it 'does nothing' do - expect(handler.on_high_heap_fragmentation(1.0)).to be(false) + expect(handler.call).to be(false) end end end @@ -264,11 +378,11 @@ RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do context 'TermProcessHandler' do subject(:handler) { described_class::TermProcessHandler.new(42) } - describe '#on_high_heap_fragmentation' do + describe '#call' do it 'sends SIGTERM to the current process' do expect(Process).to receive(:kill).with(:TERM, 42) - expect(handler.on_high_heap_fragmentation(1.0)).to be(true) + expect(handler.call).to be(true) end end end @@ -286,12 +400,12 @@ RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do stub_const('::Puma::Cluster::WorkerHandle', puma_worker_handle_class) end - describe '#on_high_heap_fragmentation' do + describe '#call' do it 'invokes orderly termination via Puma API' do expect(puma_worker_handle_class).to receive(:new).and_return(puma_worker_handle) expect(puma_worker_handle).to receive(:term) - expect(handler.on_high_heap_fragmentation(1.0)).to be(true) + expect(handler.call).to be(true) end end end diff --git a/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb b/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb index d3cb18222b5..c5666724acf 100644 --- a/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb +++ b/spec/lib/sidebars/groups/menus/packages_registries_menu_spec.rb @@ -10,6 +10,8 @@ RSpec.describe Sidebars::Groups::Menus::PackagesRegistriesMenu do end end + let_it_be(:harbor_integration) { create(:harbor_integration, group: group, project: nil) } + let(:user) { owner } let(:context) { Sidebars::Groups::Context.new(current_user: user, container: group) } let(:menu) { described_class.new(context) } diff --git a/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb b/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb index f24c40cfe89..6491ef823e9 100644 --- a/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/packages_registries_menu_spec.rb @@ -5,6 +5,8 @@ require 'spec_helper' RSpec.describe Sidebars::Projects::Menus::PackagesRegistriesMenu do let_it_be(:project) { create(:project) } + let_it_be(:harbor_integration) { create(:harbor_integration, project: project) } + let(:user) { project.first_owner } let(:context) { Sidebars::Projects::Context.new(current_user: user, container: project) } diff --git a/spec/migrations/orphaned_invited_members_cleanup_spec.rb b/spec/migrations/orphaned_invited_members_cleanup_spec.rb new file mode 100644 index 00000000000..4427e707f56 --- /dev/null +++ b/spec/migrations/orphaned_invited_members_cleanup_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe OrphanedInvitedMembersCleanup, :migration do + describe '#up', :aggregate_failures do + it 'removes accepted members with no associated user' do + user = create_user!('testuser1') + + create_member(invite_token: nil, invite_accepted_at: 1.day.ago) + record2 = create_member(invite_token: nil, invite_accepted_at: 1.day.ago, user_id: user.id) + record3 = create_member(invite_token: 'foo2', invite_accepted_at: nil) + record4 = create_member(invite_token: 'foo3', invite_accepted_at: 1.day.ago) + + migrate! + + expect(table(:members).all.pluck(:id)).to match_array([record2.id, record3.id, record4.id]) + end + end + + private + + def create_user!(name) + email = "#{name}@example.com" + + table(:users).create!( + name: name, + email: email, + username: name, + projects_limit: 0 + ) + end + + def create_member(**extra_attributes) + defaults = { + access_level: 10, + source_id: 1, + source_type: "Project", + notification_level: 0, + type: 'ProjectMember' + } + + table(:members).create!(defaults.merge(extra_attributes)) + end +end diff --git a/spec/models/integrations/harbor_spec.rb b/spec/models/integrations/harbor_spec.rb index 3952495119a..26b43fa3313 100644 --- a/spec/models/integrations/harbor_spec.rb +++ b/spec/models/integrations/harbor_spec.rb @@ -27,6 +27,12 @@ RSpec.describe Integrations::Harbor do it { is_expected.to allow_value('https://demo.goharbor.io').for(:url) } end + describe 'hostname' do + it 'returns the host of the integration url' do + expect(harbor_integration.hostname).to eq('demo.goharbor.io') + end + end + describe '#fields' do it 'returns custom fields' do expect(harbor_integration.fields.pluck(:name)).to eq(%w[url project_name username password]) diff --git a/spec/requests/api/debian_group_packages_spec.rb b/spec/requests/api/debian_group_packages_spec.rb index d881d4350fb..956de61bf07 100644 --- a/spec/requests/api/debian_group_packages_spec.rb +++ b/spec/requests/api/debian_group_packages_spec.rb @@ -36,6 +36,18 @@ RSpec.describe API::DebianGroupPackages do it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Packages file/ end + describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/source/Sources' do + let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/source/Sources" } + + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Sources file/ + end + + describe 'GET groups/:id/-/packages/debian/dists/*distribution/:component/debian-installer/binary-:architecture/Packages' do + let(:url) { "/groups/#{container.id}/-/packages/debian/dists/#{distribution.codename}/#{component.name}/debian-installer/binary-#{architecture.name}/Packages" } + + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete D-I Packages file/ + end + describe 'GET groups/:id/-/packages/debian/pool/:codename/:project_id/:letter/:package_name/:package_version/:file_name' do let(:url) { "/groups/#{container.id}/-/packages/debian/pool/#{package.debian_distribution.codename}/#{project.id}/#{letter}/#{package.name}/#{package.version}/#{file_name}" } let(:file_name) { params[:file_name] } diff --git a/spec/requests/api/debian_project_packages_spec.rb b/spec/requests/api/debian_project_packages_spec.rb index bd68bf912e1..122a3df5e67 100644 --- a/spec/requests/api/debian_project_packages_spec.rb +++ b/spec/requests/api/debian_project_packages_spec.rb @@ -36,6 +36,18 @@ RSpec.describe API::DebianProjectPackages do it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Packages file/ end + describe 'GET projects/:id/packages/debian/dists/*distribution/source/Sources' do + let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/source/Sources" } + + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete Sources file/ + end + + describe 'GET projects/:id/packages/debian/dists/*distribution/:component/debian-installer/binary-:architecture/Packages' do + let(:url) { "/projects/#{container.id}/packages/debian/dists/#{distribution.codename}/#{component.name}/debian-installer/binary-#{architecture.name}/Packages" } + + it_behaves_like 'Debian packages read endpoint', 'GET', :success, /Description: This is an incomplete D-I Packages file/ + end + describe 'GET projects/:id/packages/debian/pool/:codename/:letter/:package_name/:package_version/:file_name' do let(:url) { "/projects/#{container.id}/packages/debian/pool/#{package.debian_distribution.codename}/#{letter}/#{package.name}/#{package.version}/#{file_name}" } let(:file_name) { params[:file_name] } diff --git a/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb b/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb index 95b8b7ed9f8..e65e51eacc2 100644 --- a/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb +++ b/spec/support/shared_contexts/requests/api/debian_repository_shared_context.rb @@ -18,6 +18,8 @@ RSpec.shared_context 'Debian repository shared context' do |container_type, can_ let_it_be(:private_architecture_all, freeze: true) { create("debian_#{container_type}_architecture", distribution: private_distribution, name: 'all') } let_it_be(:private_architecture, freeze: true) { create("debian_#{container_type}_architecture", distribution: private_distribution, name: 'existing-arch') } let_it_be(:private_component_file) { create("debian_#{container_type}_component_file", component: private_component, architecture: private_architecture) } + let_it_be(:private_component_sources) { create("debian_#{container_type}_component_file", :sources, component: private_component) } + let_it_be(:private_component_file_di) { create("debian_#{container_type}_component_file", :di_packages, component: private_component, architecture: private_architecture) } let_it_be(:public_distribution, freeze: true) { create("debian_#{container_type}_distribution", :with_file, container: public_container, codename: 'existing-codename') } let_it_be(:public_distribution_key, freeze: true) { create("debian_#{container_type}_distribution_key", distribution: public_distribution) } @@ -25,6 +27,8 @@ RSpec.shared_context 'Debian repository shared context' do |container_type, can_ let_it_be(:public_architecture_all, freeze: true) { create("debian_#{container_type}_architecture", distribution: public_distribution, name: 'all') } let_it_be(:public_architecture, freeze: true) { create("debian_#{container_type}_architecture", distribution: public_distribution, name: 'existing-arch') } let_it_be(:public_component_file) { create("debian_#{container_type}_component_file", component: public_component, architecture: public_architecture) } + let_it_be(:public_component_file_sources) { create("debian_#{container_type}_component_file", :sources, component: public_component) } + let_it_be(:public_component_file_di) { create("debian_#{container_type}_component_file", :di_packages, component: public_component, architecture: public_architecture) } if container_type == :group let_it_be(:private_project) { create(:project, :private, group: private_container) } @@ -48,7 +52,6 @@ RSpec.shared_context 'Debian repository shared context' do |container_type, can_ let(:distribution) { { private: private_distribution, public: public_distribution }[visibility_level] } let(:architecture) { { private: private_architecture, public: public_architecture }[visibility_level] } let(:component) { { private: private_component, public: public_component }[visibility_level] } - let(:component_file) { { private: private_component_file, public: public_component_file }[visibility_level] } let(:package) { { private: private_package, public: public_package }[visibility_level] } let(:letter) { package.name[0..2] == 'lib' ? package.name[0..3] : package.name[0] } |