Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-09-01 18:09:55 +00:00
parent 7b197a72aa
commit 4316e6895a
83 changed files with 2511 additions and 897 deletions

View File

@ -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

View File

@ -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);
}

View File

@ -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'),
});

View File

@ -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,

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"
>

View File

@ -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>

View File

@ -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;

View File

@ -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 FETCH_ARTIFACT_LIST_ERROR_MESSAGE = s__(
'HarborRegistry|Something went wrong while fetching the artifact list.',
);
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 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');

View File

@ -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') },

View File

@ -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) {

View File

@ -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);
};

View File

@ -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>

View File

@ -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>

View File

@ -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,
},
},
],

View File

@ -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 */

View File

@ -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"

View File

@ -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.

View File

@ -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

View File

@ -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 } }

View File

@ -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'

View File

@ -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'

View File

@ -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, } }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1 @@
aa0b767ad0e38500e0eef83d5c8306054952363166f8cc2076ce48feeac1b0e1

View File

@ -0,0 +1 @@
badc3556e1dea545bbf8b55fb33065f45598df9b3fda74bffd28e89d7485e0b4

View File

@ -0,0 +1 @@
85e401f0920c6eb13b6756f191ccdf70494ca40f8133f05bbd5f23ba295b115d

View File

@ -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))

View File

@ -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> |

View File

@ -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
```

View File

@ -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" /}
```plaintext
::Tabs
<div class="js-tabs">
:::TabTitle Tab One
## This is the first tab
{: .no_toc}
Here's some content in tab one.
Here's some content in tab panel one.
:::TabTitle Tab Two
## Tab two
{: .no_toc}
Here's some other content in tab two.
Here's some content in tab panel 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`

View File

@ -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:

View File

@ -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.

View File

@ -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.

View File

@ -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)

View File

@ -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`).

View File

@ -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>
```

View File

@ -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
route_setting :authentication, authenticate_non_public: true
get 'Packages' do
relation = "::Packages::Debian::#{project_or_group.class.name}ComponentFile".constantize
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
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!
route_setting :authentication, authenticate_non_public: true
get 'Packages' do
present_index_file!(:di_packages)
end
end
present_carrierwave_file!(component_file.file)
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
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

View File

@ -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

View File

@ -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

View File

@ -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: {

View File

@ -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
if heap_fragmentation > @max_heap_fragmentation
@strikes += 1
@heap_frag_violations.increment
stat = @stats[stat_key]
ok, labels = yield(stat)
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
handler.on_high_heap_fragmentation(value)
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.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

View File

@ -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'),

View File

@ -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'),

View File

@ -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|%d artifact"
msgid_plural "HarborRegistry|%d artifacts"
msgstr[0] ""
msgstr[1] ""
msgid "HarborRegistry|%{count} Image repository"
msgid_plural "HarborRegistry|%{count} Image repositories"
msgstr[0] ""
msgstr[1] ""
msgid "HarborRegistry|%{count} Tag"
msgid_plural "HarborRegistry|%{count} Tags"
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}"
msgstr ""
msgid "HarborRegistry|Manifest digest: %{digest}"
msgstr ""
msgid "HarborRegistry|Please try different search criteria"
msgstr ""
msgid "HarborRegistry|Published %{timeInfo}"
msgstr ""
msgid "HarborRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}"
msgid "HarborRegistry|Root image"
msgstr ""
msgid "HarborRegistry|Root image"
msgid "HarborRegistry|Something went wrong while fetching the artifact list."
msgstr ""
msgid "HarborRegistry|Something went wrong while fetching the repository list."
msgstr ""
msgid "HarborRegistry|Something went wrong while fetching the tags."
msgstr ""
msgid "HarborRegistry|Sorry, your filter produced no results."
msgstr ""
msgid "HarborRegistry|Tag"
msgstr ""
msgid "HarborRegistry|The filter returned no results"
msgstr ""
msgid "HarborRegistry|The image repository could not be found."
msgid "HarborRegistry|There are no harbor images stored for this project"
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."
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."
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 ""

View File

@ -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)

View File

@ -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

View File

@ -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'))

View File

@ -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

View File

@ -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)

View File

@ -0,0 +1,2 @@
Package: example-package
Description: This is an incomplete D-I Packages file

View File

@ -0,0 +1,2 @@
Package: example-package
Description: This is an incomplete Sources file

View File

@ -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);
});
});
});
});

View File

@ -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', () => {

View File

@ -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 () => {

View File

@ -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 = () => {

View File

@ -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');

View File

@ -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', () => {

View File

@ -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');
});
});
});
});

View File

@ -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);
});
});
});

View File

@ -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');
});
});
});
});

View File

@ -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');
});
});
});

View File

@ -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', () => {

View File

@ -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 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 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 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',

View File

@ -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,
},
});
});
});
});

View File

@ -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,
},
});
});
});

View File

@ -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);

View File

@ -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
context 'when process exceeds the allowed number of strikes' do
let(:watchdog_iterations) { max_strikes + 1 }
it 'signals the handler and resets strike counter' do
expect(handler).to receive(:on_high_heap_fragmentation).and_return(true)
watchdog.call
expect(watchdog.strikes).to eq(0)
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
})
watchdog.call
end
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)
watchdog.call
end
context 'when enforce_memory_watchdog ops toggle is off' do
before do
stub_feature_flags(enforce_memory_watchdog: false)
end
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)
)
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'))
watchdog.call
end
end
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(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)
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 }
shared_examples 'no strikes left' do |stat|
it 'signals the handler and resets strike counter' do
expect(handler).to receive(:call).and_return(true)
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
)
watchdog.call
expect(watchdog.strikes(stat.to_sym)).to eq(0)
end
it 'does not signal the handler' do
expect(handler).not_to receive(:on_high_heap_fragmentation)
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 enforce_memory_watchdog ops toggle is off' do
before do
stub_feature_flags(enforce_memory_watchdog: false)
end
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
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
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 }
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 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_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
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 }
it_behaves_like 'has strikes left', 'heap_frag'
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
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
)
end
it 'does not signal the handler' do
expect(handler).not_to receive(:call)
watchdog.call
end
end
end
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

View File

@ -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) }

View File

@ -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) }

View File

@ -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

View File

@ -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])

View File

@ -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] }

View File

@ -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] }

View File

@ -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] }