Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-01-14 12:18:55 +00:00
parent 7bd8f9822b
commit 8a70817cd9
113 changed files with 1729 additions and 1819 deletions

View File

@ -1,14 +1,12 @@
---
Database/MultipleDatabases:
Exclude:
- ee/lib/ee/gitlab/database.rb
- ee/lib/gitlab/geo/database_tasks.rb
- ee/lib/gitlab/geo/geo_tasks.rb
- ee/lib/gitlab/geo/health_check.rb
- ee/lib/gitlab/geo/log_cursor/daemon.rb
- ee/lib/pseudonymizer/dumper.rb
- ee/lib/pseudonymizer/pager.rb
- ee/lib/system_check/geo/geo_database_configured_check.rb
- ee/spec/lib/pseudonymizer/dumper_spec.rb
- ee/spec/services/ee/merge_requests/update_service_spec.rb
- lib/backup/database.rb
@ -21,14 +19,12 @@ Database/MultipleDatabases:
- lib/gitlab/database/migrations/observers/query_log.rb
- lib/gitlab/database/partitioning_migration_helpers/backfill_partitioned_table.rb
- lib/gitlab/database.rb
- lib/gitlab/database/schema_cache_with_renamed_table.rb
- lib/gitlab/database/with_lock_retries.rb
- lib/gitlab/gitlab_import/importer.rb
- lib/gitlab/health_checks/db_check.rb
- lib/gitlab/import_export/base/relation_factory.rb
- lib/gitlab/import_export/group/relation_tree_restorer.rb
- lib/gitlab/legacy_github_import/importer.rb
- lib/gitlab/metrics/samplers/database_sampler.rb
- lib/gitlab/seeder.rb
- lib/gitlab/sherlock/query.rb
- lib/system_check/orphans/repository_check.rb
@ -39,15 +35,8 @@ Database/MultipleDatabases:
- spec/lib/gitlab/database_spec.rb
- spec/lib/gitlab/metrics/subscribers/active_record_spec.rb
- spec/lib/gitlab/profiler_spec.rb
- spec/lib/gitlab/usage_data_metrics_spec.rb
- spec/lib/gitlab/usage_data_queries_spec.rb
- spec/lib/gitlab/usage/metrics/names_suggestions/relation_parsers/constraints_spec.rb
- spec/lib/gitlab/usage/metrics/names_suggestions/relation_parsers/joins_spec.rb
- spec/lib/gitlab/usage/metrics/instrumentations/database_metric_spec.rb
- spec/lib/gitlab/utils/usage_data_spec.rb
- spec/models/project_feature_usage_spec.rb
- spec/models/users_statistics_spec.rb
- spec/services/users/activity_service_spec.rb
- spec/support/caching.rb
- spec/support/gitlab/usage/metrics_instrumentation_shared_examples.rb
- spec/support/helpers/database_connection_helpers.rb

View File

@ -0,0 +1,61 @@
<script>
import { GlButton, GlTable } from '@gitlab/ui';
import { __ } from '~/locale';
const i18n = {
cloudRun: __('Cloud Run'),
cloudRunDescription: __('Deploy container based web apps on Google managed clusters'),
cloudStorage: __('Cloud Storage'),
cloudStorageDescription: __('Deploy static assets and resources to Google managed CDN'),
deployments: __('Deployments'),
deploymentsDescription: __(
'Configure pipelines to deploy web apps, backend services, APIs and static resources to Google Cloud',
),
configureViaMergeRequest: __('Configure via Merge Request'),
service: __('Service'),
description: __('Description'),
};
export default {
components: { GlButton, GlTable },
props: {
cloudRunUrl: {
type: String,
required: true,
},
cloudStorageUrl: {
type: String,
required: true,
},
},
fields: [
{ key: 'title', label: i18n.service },
{ key: 'description', label: i18n.description },
{ key: 'action', label: '' },
],
items: [
{
title: i18n.cloudRun,
description: i18n.cloudRunDescription,
action: { title: i18n.configureViaMergeRequest, disabled: true },
},
{
title: i18n.cloudStorage,
description: i18n.cloudStorageDescription,
action: { title: i18n.configureViaMergeRequest, disabled: true },
},
],
i18n,
};
</script>
<template>
<div class="gl-mx-3">
<h2 class="gl-font-size-h2">{{ $options.i18n.deployments }}</h2>
<p>{{ $options.i18n.deploymentsDescription }}</p>
<gl-table :fields="$options.fields" :items="$options.items">
<template #cell(action)="{ value }">
<gl-button :disabled="value.disabled">{{ value.title }}</gl-button>
</template>
</gl-table>
</div>
</template>

View File

@ -1,11 +1,13 @@
<script>
import { GlTabs, GlTab } from '@gitlab/ui';
import DeploymentsServiceTable from './deployments_service_table.vue';
import ServiceAccountsList from './service_accounts_list.vue';
export default {
components: {
GlTabs,
GlTab,
DeploymentsServiceTable,
ServiceAccountsList,
},
props: {
@ -21,6 +23,14 @@ export default {
type: String,
required: true,
},
deploymentsCloudRunUrl: {
type: String,
required: true,
},
deploymentsCloudStorageUrl: {
type: String,
required: true,
},
},
};
</script>
@ -35,7 +45,12 @@ export default {
:empty-illustration-url="emptyIllustrationUrl"
/>
</gl-tab>
<gl-tab :title="__('Deployments')" disabled />
<gl-tab :title="__('Deployments')">
<deployments-service-table
:cloud-run-url="deploymentsCloudRunUrl"
:cloud-storage-url="deploymentsCloudStorageUrl"
/>
</gl-tab>
<gl-tab :title="__('Services')" disabled />
</gl-tabs>
</template>

View File

@ -13,11 +13,8 @@ export default class Group {
this.updateGroupPathSlugHandler = this.updateGroupPathSlug.bind(this);
this.groupNames.forEach((groupName) => {
if (groupName.value === '') {
groupName.addEventListener('keyup', this.updateHandler);
groupName.addEventListener('keyup', this.updateGroupPathSlugHandler);
}
groupName.addEventListener('keyup', this.updateHandler);
groupName.addEventListener('keyup', this.updateGroupPathSlugHandler);
});
this.groupPaths.forEach((groupPath) => {

View File

@ -4,6 +4,7 @@ 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 { renderBreadcrumb } from '~/packages_and_registries/shared/utils';
import { apolloProvider } from './graphql/index';
import RegistryExplorer from './pages/index.vue';
import createRouter from './router';
@ -84,38 +85,8 @@ export default () => {
},
});
const attachBreadcrumb = () => {
const breadCrumbEls = document.querySelectorAll('nav .js-breadcrumbs-list li');
const breadCrumbEl = breadCrumbEls[breadCrumbEls.length - 1];
const crumbs = [breadCrumbEl.querySelector('h2')];
const nestedBreadcrumbEl = document.createElement('div');
breadCrumbEl.replaceChild(nestedBreadcrumbEl, breadCrumbEl.querySelector('h2'));
return new Vue({
el: nestedBreadcrumbEl,
router,
apolloProvider,
components: {
RegistryBreadcrumb,
},
render(createElement) {
// FIXME(@tnir): this is a workaround until the MR gets merged:
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115
const parentEl = breadCrumbEl.parentElement.parentElement;
if (parentEl) {
parentEl.classList.remove('breadcrumbs-container');
parentEl.classList.add('gl-display-flex');
parentEl.classList.add('w-100');
}
// End of FIXME(@tnir)
return createElement('registry-breadcrumb', {
class: breadCrumbEl.className,
props: {
crumbs,
},
});
},
});
return {
attachBreadcrumb: renderBreadcrumb(router, apolloProvider, RegistryBreadcrumb),
attachMainComponent,
};
return { attachBreadcrumb, attachMainComponent };
};

View File

@ -26,7 +26,7 @@ export default {
GlSprintf,
GlFormRadioGroup,
},
inject: ['npmPath'],
inject: ['npmInstanceUrl'],
props: {
packageEntity: {
type: Object,
@ -66,7 +66,9 @@ export default {
npmSetupCommand(type, endpointType) {
const scope = this.packageEntity.name.substring(0, this.packageEntity.name.indexOf('/'));
const npmPathForEndpoint =
endpointType === INSTANCE_PACKAGE_ENDPOINT_TYPE ? this.npmPath : this.packageEntity.npmUrl;
endpointType === INSTANCE_PACKAGE_ENDPOINT_TYPE
? this.npmInstanceUrl
: this.packageEntity.npmUrl;
if (type === NPM_PACKAGE_MANAGER) {
return `echo ${scope}:registry=${npmPathForEndpoint}/ >> .npmrc`;

View File

@ -1,5 +1,5 @@
<script>
import { GlButton, GlLink, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
import { GlButton, GlSprintf, GlTooltipDirective, GlTruncate } from '@gitlab/ui';
import { s__, __ } from '~/locale';
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import {
@ -18,7 +18,6 @@ export default {
name: 'PackageListRow',
components: {
GlButton,
GlLink,
GlSprintf,
GlTruncate,
PackageTags,
@ -42,9 +41,8 @@ export default {
packageType() {
return getPackageTypeLabel(this.packageEntity.packageType);
},
packageLink() {
const { project, id } = this.packageEntity;
return `${project?.webUrl}/-/packages/${getIdFromGraphQLId(id)}`;
packageId() {
return getIdFromGraphQLId(this.packageEntity.id);
},
pipeline() {
return this.packageEntity?.pipelines?.nodes[0];
@ -61,6 +59,9 @@ export default {
disabledRow() {
return this.packageEntity.status && this.packageEntity.status !== PACKAGE_DEFAULT_STATUS;
},
routerLinkEvent() {
return this.disabledRow ? '' : 'click';
},
},
i18n: {
erroredPackageText: s__('PackageRegistry|Invalid Package: failed metadata extraction'),
@ -73,14 +74,15 @@ export default {
<list-item data-qa-selector="package_row" :disabled="disabledRow">
<template #left-primary>
<div class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0">
<gl-link
:href="packageLink"
<router-link
class="gl-text-body gl-min-w-0"
data-testid="details-link"
data-qa-selector="package_link"
:disabled="disabledRow"
:event="routerLinkEvent"
:to="{ name: 'details', params: { id: packageId } }"
>
<gl-truncate :text="packageEntity.name" />
</gl-link>
</router-link>
<gl-button
v-if="showWarningIcon"

View File

@ -74,6 +74,7 @@ export const FETCH_PACKAGE_DETAILS_ERROR_MESSAGE = s__(
);
export const DELETE_PACKAGE_SUCCESS_MESSAGE = s__('PackageRegistry|Package deleted successfully');
export const PACKAGE_REGISTRY_TITLE = __('Package Registry');
export const PACKAGE_ERROR_STATUS = 'ERROR';
export const PACKAGE_DEFAULT_STATUS = 'DEFAULT';

View File

@ -2,29 +2,59 @@ import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index';
import PackageRegistry from '~/packages_and_registries/package_registry/pages/index.vue';
import RegistryBreadcrumb from '~/packages_and_registries/shared/components/registry_breadcrumb.vue';
import { renderBreadcrumb } from '~/packages_and_registries/shared/utils';
import createRouter from './router';
Vue.use(Translate);
export default () => {
const el = document.getElementById('js-vue-packages-list');
const { endpoint, resourceId, fullPath, pageType, emptyListIllustration } = el.dataset;
const router = createRouter(endpoint);
const {
endpoint,
resourceId,
fullPath,
pageType,
emptyListIllustration,
npmInstanceUrl,
projectListUrl,
groupListUrl,
} = el.dataset;
const isGroupPage = pageType === 'groups';
return new Vue({
el,
router,
apolloProvider,
provide: {
resourceId,
fullPath,
emptyListIllustration,
isGroupPage,
},
render(createElement) {
return createElement(PackageRegistry);
// This is a mini state to help the breadcrumb have the correct name in the details page
const breadCrumbState = Vue.observable({
name: '',
updateName(value) {
this.name = value;
},
});
const router = createRouter(endpoint, breadCrumbState);
const attachMainComponent = () =>
new Vue({
el,
router,
apolloProvider,
provide: {
resourceId,
fullPath,
emptyListIllustration,
isGroupPage,
npmInstanceUrl,
projectListUrl,
groupListUrl,
breadCrumbState,
},
render(createElement) {
return createElement(PackageRegistry);
},
});
return {
attachBreadcrumb: renderBreadcrumb(router, apolloProvider, RegistryBreadcrumb),
attachMainComponent,
};
};

View File

@ -1,27 +0,0 @@
import Vue from 'vue';
import { parseBoolean } from '~/lib/utils/common_utils';
import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue';
import { apolloProvider } from '~/packages_and_registries/package_registry/graphql/index';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
export default () => {
const el = document.getElementById('js-vue-packages-detail-new');
if (!el) {
return null;
}
const { canDelete, ...datasetOptions } = el.dataset;
return new Vue({
el,
apolloProvider,
provide: {
canDelete: parseBoolean(canDelete),
...datasetOptions,
},
render(createElement) {
return createElement(PackagesApp);
},
});
};

View File

@ -68,7 +68,7 @@ export default {
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin()],
inject: ['packageId', 'svgPath', 'projectListUrl', 'groupListUrl'],
inject: ['emptyListIllustration', 'projectListUrl', 'groupListUrl', 'breadCrumbState'],
trackingActions: {
DELETE_PACKAGE_TRACKING_ACTION,
REQUEST_DELETE_PACKAGE_TRACKING_ACTION,
@ -100,12 +100,20 @@ export default {
error,
});
},
result() {
this.breadCrumbState.updateName(
`${this.packageEntity?.name} v ${this.packageEntity?.version}`,
);
},
},
},
computed: {
projectName() {
return this.packageEntity.project?.name;
},
packageId() {
return this.$route.params.id;
},
queryVariables() {
return {
id: convertToGraphQLId('Packages::Package', this.packageId),
@ -229,7 +237,7 @@ export default {
v-if="!isValidPackage"
:title="s__('PackageRegistry|Unable to load package')"
:description="s__('PackageRegistry|There was a problem fetching the details for this package.')"
:svg-path="svgPath"
:svg-path="emptyListIllustration"
/>
<div v-else-if="!isLoading" class="packages-app">
<package-title :package-entity="packageEntity">

View File

@ -1,10 +1,12 @@
import Vue from 'vue';
import VueRouter from 'vue-router';
import List from '~/packages_and_registries/package_registry/pages/list.vue';
import Details from '~/packages_and_registries/package_registry/pages/details.vue';
import { PACKAGE_REGISTRY_TITLE } from '~/packages_and_registries/package_registry/constants';
Vue.use(VueRouter);
export default function createRouter(base) {
export default function createRouter(base, breadCrumbState) {
const router = new VueRouter({
base,
mode: 'history',
@ -13,9 +15,25 @@ export default function createRouter(base) {
name: 'list',
path: '/',
component: List,
meta: {
nameGenerator: () => PACKAGE_REGISTRY_TITLE,
root: true,
},
},
{
name: 'details',
path: '/:id',
component: Details,
meta: {
nameGenerator: () => breadCrumbState.name,
},
},
],
});
router.afterEach(() => {
breadCrumbState.updateName('');
});
return router;
}

View File

@ -20,8 +20,11 @@ export default {
isRootRoute() {
return this.$route.name === this.rootRoute.name;
},
detailsRouteName() {
return this.detailsRoute.meta.nameGenerator();
},
isLoaded() {
return this.isRootRoute || this.$store?.state.imageDetails?.name;
return this.isRootRoute || this.detailsRouteName;
},
allCrumbs() {
const crumbs = [
@ -32,7 +35,7 @@ export default {
];
if (!this.isRootRoute) {
crumbs.push({
text: this.detailsRoute.meta.nameGenerator(),
text: this.detailsRouteName,
href: this.detailsRoute.meta.path,
});
}
@ -45,7 +48,9 @@ export default {
<template>
<gl-breadcrumb :key="isLoaded" :items="allCrumbs">
<template #separator>
<gl-icon name="angle-right" :size="8" />
<span class="gl-mx-n5">
<gl-icon name="angle-right" :size="8" />
</span>
</template>
</gl-breadcrumb>
</template>

View File

@ -1,3 +1,4 @@
import Vue from 'vue';
import { queryToObject } from '~/lib/utils/url_utility';
import { FILTERED_SEARCH_TERM } from './constants';
@ -38,3 +39,37 @@ export const getCommitLink = ({ project_path: projectPath, pipeline = {} }, isGr
return `../commit/${pipeline.sha}`;
};
export const renderBreadcrumb = (router, apolloProvider, RegistryBreadcrumb) => () => {
const breadCrumbEls = document.querySelectorAll('nav .js-breadcrumbs-list li');
const breadCrumbEl = breadCrumbEls[breadCrumbEls.length - 1];
const lastCrumb = breadCrumbEl.children[0];
const crumbs = [lastCrumb];
const nestedBreadcrumbEl = document.createElement('div');
breadCrumbEl.replaceChild(nestedBreadcrumbEl, lastCrumb);
return new Vue({
el: nestedBreadcrumbEl,
router,
apolloProvider,
components: {
RegistryBreadcrumb,
},
render(createElement) {
// FIXME(@tnir): this is a workaround until the MR gets merged:
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115
const parentEl = breadCrumbEl.parentElement.parentElement;
if (parentEl) {
parentEl.classList.remove('breadcrumbs-container');
parentEl.classList.add('gl-display-flex');
parentEl.classList.add('w-100');
}
// End of FIXME(@tnir)
return createElement('registry-breadcrumb', {
class: breadCrumbEl.className,
props: {
crumbs,
},
});
},
});
};

View File

@ -0,0 +1,8 @@
import packageApp from '~/packages_and_registries/package_registry/index';
const app = packageApp();
if (app) {
app.attachBreadcrumb();
app.attachMainComponent();
}

View File

@ -1,3 +0,0 @@
import packageApp from '~/packages_and_registries/package_registry/index';
packageApp();

View File

@ -0,0 +1,8 @@
import packageApp from '~/packages_and_registries/package_registry/index';
const app = packageApp();
if (app) {
app.attachBreadcrumb();
app.attachMainComponent();
}

View File

@ -1,3 +0,0 @@
import packageApp from '~/packages_and_registries/package_registry/index';
packageApp();

View File

@ -1,3 +0,0 @@
import initPackageDetails from '~/packages_and_registries/package_registry/pages/details';
initPackageDetails();

View File

@ -9,7 +9,7 @@ import RegistrationDropdown from '../components/registration/registration_dropdo
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerName from '../components/runner_name.vue';
import RunnerOnlineStat from '../components/stat/runner_online_stat.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
@ -20,6 +20,9 @@ import {
INSTANCE_TYPE,
GROUP_TYPE,
PROJECT_TYPE,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_STALE,
I18N_FETCH_ERROR,
} from '../constants';
import getRunnersQuery from '../graphql/get_runners.query.graphql';
@ -51,7 +54,7 @@ export default {
RunnerFilteredSearchBar,
RunnerList,
RunnerName,
RunnerOnlineStat,
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
},
@ -60,10 +63,6 @@ export default {
type: String,
required: true,
},
activeRunnersCount: {
type: String,
required: true,
},
},
data() {
return {
@ -130,6 +129,30 @@ export default {
};
},
},
onlineRunnersTotal: {
...runnersCountSmartQuery,
variables() {
return {
status: STATUS_ONLINE,
};
},
},
offlineRunnersTotal: {
...runnersCountSmartQuery,
variables() {
return {
status: STATUS_OFFLINE,
};
},
},
staleRunnersTotal: {
...runnersCountSmartQuery,
variables() {
return {
status: STATUS_STALE,
};
},
},
},
computed: {
variables() {
@ -205,7 +228,11 @@ export default {
</script>
<template>
<div>
<runner-online-stat class="gl-py-6 gl-px-5" :value="activeRunnersCount" />
<runner-stats
:online-runners-count="onlineRunnersTotal"
:offline-runners-count="offlineRunnersTotal"
:stale-runners-count="staleRunnersTotal"
/>
<div
class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0"

View File

@ -25,9 +25,7 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
return null;
}
// TODO `activeRunnersCount` should be implemented using a GraphQL API
// https://gitlab.com/gitlab-org/gitlab/-/issues/333806
const { runnerInstallHelpPage, registrationToken, activeRunnersCount } = el.dataset;
const { runnerInstallHelpPage, registrationToken } = el.dataset;
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
@ -43,10 +41,6 @@ export const initAdminRunners = (selector = '#js-admin-runners') => {
return h(AdminRunnersApp, {
props: {
registrationToken,
// Runner counts are returned as formatted
// strings, we do not use `parseInt`.
activeRunnersCount,
},
});
},

View File

@ -1,5 +1,5 @@
<script>
import { GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
import { GlDropdownItem, GlLoadingIcon, GlModal, GlModalDirective } from '@gitlab/ui';
import { createAlert } from '~/flash';
import { TYPE_GROUP, TYPE_PROJECT } from '~/graphql_shared/constants';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@ -10,9 +10,17 @@ import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '../../constants';
export default {
name: 'RunnerRegistrationTokenReset',
i18n: {
modalTitle: __('Reset registration token'),
modalCopy: __('Are you sure you want to reset the registration token?'),
},
components: {
GlDropdownItem,
GlLoadingIcon,
GlModal,
},
directives: {
GlModal: GlModalDirective,
},
inject: {
groupId: {
@ -22,6 +30,7 @@ export default {
default: null,
},
},
modalID: 'token-reset-modal',
props: {
type: {
type: String,
@ -59,14 +68,10 @@ export default {
},
},
methods: {
handleModalPrimary() {
this.resetToken();
},
async resetToken() {
// TODO Replace confirmation with gl-modal
// See: https://gitlab.com/gitlab-org/gitlab/-/issues/333810
// eslint-disable-next-line no-alert
if (!window.confirm(__('Are you sure you want to reset the registration token?'))) {
return;
}
this.loading = true;
try {
const {
@ -106,8 +111,15 @@ export default {
};
</script>
<template>
<gl-dropdown-item @click.capture.native.stop="resetToken">
<gl-dropdown-item v-gl-modal="$options.modalID">
{{ __('Reset registration token') }}
<gl-modal
:modal-id="$options.modalID"
:title="$options.i18n.modalTitle"
@primary="handleModalPrimary"
>
<p>{{ $options.i18n.modalCopy }}</p>
</gl-modal>
<gl-loading-icon v-if="loading" inline />
</gl-dropdown-item>
</template>

View File

@ -1,17 +0,0 @@
<script>
import { GlSingleStat } from '@gitlab/ui/dist/charts';
export default {
components: {
GlSingleStat,
},
};
</script>
<template>
<gl-single-stat
v-bind="$attrs"
variant="success"
:title="s__('Runners|Online Runners')"
:meta-text="s__('Runners|online')"
/>
</template>

View File

@ -0,0 +1,49 @@
<script>
import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants';
import RunnerStatusStat from './runner_status_stat.vue';
export default {
components: {
RunnerStatusStat,
},
props: {
onlineRunnersCount: {
type: Number,
required: false,
default: null,
},
offlineRunnersCount: {
type: Number,
required: false,
default: null,
},
staleRunnersCount: {
type: Number,
required: false,
default: null,
},
},
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_STALE,
};
</script>
<template>
<div class="gl-display-flex gl-py-6">
<runner-status-stat
class="gl-px-5"
:status="$options.STATUS_ONLINE"
:value="onlineRunnersCount"
/>
<runner-status-stat
class="gl-px-5"
:status="$options.STATUS_OFFLINE"
:value="offlineRunnersCount"
/>
<runner-status-stat
class="gl-px-5"
:status="$options.STATUS_STALE"
:value="staleRunnersCount"
/>
</div>
</template>

View File

@ -0,0 +1,65 @@
<script>
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { s__, formatNumber } from '~/locale';
import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '../../constants';
export default {
components: {
GlSingleStat,
},
props: {
value: {
type: Number,
required: false,
default: null,
},
status: {
type: String,
required: true,
},
},
computed: {
formattedValue() {
if (typeof this.value === 'number') {
return formatNumber(this.value);
}
return '-';
},
stat() {
switch (this.status) {
case STATUS_ONLINE:
return {
variant: 'success',
title: s__('Runners|Online runners'),
metaText: s__('Runners|online'),
};
case STATUS_OFFLINE:
return {
variant: 'muted',
title: s__('Runners|Offline runners'),
metaText: s__('Runners|offline'),
};
case STATUS_STALE:
return {
variant: 'warning',
title: s__('Runners|Stale runners'),
metaText: s__('Runners|stale'),
};
default:
return {
title: s__('Runners|Runners'),
};
}
},
},
};
</script>
<template>
<gl-single-stat
v-if="stat"
:value="formattedValue"
:variant="stat.variant"
:title="stat.title"
:meta-text="stat.metaText"
/>
</template>

View File

@ -13,7 +13,7 @@ query getGroupRunners(
$sort: CiRunnerSort
) {
group(fullPath: $groupFullPath) {
id
id # Apollo required
runners(
membership: DESCENDANTS
before: $before

View File

@ -0,0 +1,20 @@
query getGroupRunnersCount(
$groupFullPath: ID!
$status: CiRunnerStatus
$type: CiRunnerType
$tagList: [String!]
$search: String
) {
group(fullPath: $groupFullPath) {
id # Apollo required
runners(
membership: DESCENDANTS
status: $status
type: $type
tagList: $tagList
search: $search
) {
count
}
}
}

View File

@ -9,7 +9,7 @@ import RegistrationDropdown from '../components/registration/registration_dropdo
import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue';
import RunnerList from '../components/runner_list.vue';
import RunnerName from '../components/runner_name.vue';
import RunnerOnlineStat from '../components/stat/runner_online_stat.vue';
import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue';
@ -19,8 +19,12 @@ import {
GROUP_FILTERED_SEARCH_NAMESPACE,
GROUP_TYPE,
GROUP_RUNNER_COUNT_LIMIT,
STATUS_ONLINE,
STATUS_OFFLINE,
STATUS_STALE,
} from '../constants';
import getGroupRunnersQuery from '../graphql/get_group_runners.query.graphql';
import getGroupRunnersCountQuery from '../graphql/get_group_runners_count.query.graphql';
import {
fromUrlQueryToSearch,
fromSearchToUrl,
@ -28,6 +32,17 @@ import {
} from '../runner_search_utils';
import { captureException } from '../sentry_utils';
const runnersCountSmartQuery = {
query: getGroupRunnersCountQuery,
fetchPolicy: fetchPolicies.CACHE_AND_NETWORK,
update(data) {
return data?.group?.runners?.count;
},
error(error) {
this.reportToSentry(error);
},
};
export default {
name: 'GroupRunnersApp',
components: {
@ -36,7 +51,7 @@ export default {
RunnerFilteredSearchBar,
RunnerList,
RunnerName,
RunnerOnlineStat,
RunnerStats,
RunnerPagination,
RunnerTypeTabs,
},
@ -89,6 +104,33 @@ export default {
this.reportToSentry(error);
},
},
onlineRunnersTotal: {
...runnersCountSmartQuery,
variables() {
return {
groupFullPath: this.groupFullPath,
status: STATUS_ONLINE,
};
},
},
offlineRunnersTotal: {
...runnersCountSmartQuery,
variables() {
return {
groupFullPath: this.groupFullPath,
status: STATUS_OFFLINE,
};
},
},
staleRunnersTotal: {
...runnersCountSmartQuery,
variables() {
return {
groupFullPath: this.groupFullPath,
status: STATUS_STALE,
};
},
},
},
computed: {
variables() {
@ -147,7 +189,11 @@ export default {
<template>
<div>
<runner-online-stat class="gl-py-6 gl-px-5" :value="groupRunnersCount" />
<runner-stats
:online-runners-count="onlineRunnersTotal"
:offline-runners-count="offlineRunnersTotal"
:stale-runners-count="staleRunnersTotal"
/>
<div class="gl-display-flex gl-align-items-center">
<runner-type-tabs

View File

@ -6,6 +6,11 @@ module Groups
feature_category :package_registry
# The show action renders index to allow frontend routing to work on page refresh
def show
render :index
end
private
def verify_packages_enabled!

View File

@ -7,8 +7,9 @@ module Projects
feature_category :package_registry
# The show action renders index to allow frontend routing to work on page refresh
def show
@package = project.packages.find(params[:id])
render :index
end
end
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
module Mutations
module Issues
class SetEscalationStatus < Base
graphql_name 'IssueSetEscalationStatus'
argument :status, Types::IncidentManagement::EscalationStatusEnum,
required: true,
description: 'Set the escalation status.'
def resolve(project_path:, iid:, status:)
issue = authorized_find!(project_path: project_path, iid: iid)
project = issue.project
authorize_escalation_status!(project)
check_feature_availability!(project, issue)
::Issues::UpdateService.new(
project: project,
current_user: current_user,
params: { escalation_status: { status: status } }
).execute(issue)
{
issue: issue,
errors: errors_on_object(issue)
}
end
private
def authorize_escalation_status!(project)
return if Ability.allowed?(current_user, :update_escalation_status, project)
raise_resource_not_available_error!
end
def check_feature_availability!(project, issue)
return if Feature.enabled?(:incident_escalations, project) && issue.supports_escalation?
raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature unavailable for provided issue'
end
end
end
end

View File

@ -48,7 +48,8 @@ module Resolvers
labels: [:labels],
assignees: [:assignees],
timelogs: [:timelogs],
customer_relations_contacts: { customer_relations_contacts: [:group] }
customer_relations_contacts: { customer_relations_contacts: [:group] },
escalation_status: [:incident_management_issuable_escalation_status]
}
end

View File

@ -0,0 +1,14 @@
# frozen_string_literal: true
module Types
module IncidentManagement
class EscalationStatusEnum < BaseEnum
graphql_name 'IssueEscalationStatus'
description 'Issue escalation status values'
::IncidentManagement::IssuableEscalationStatus.status_names.each do |status|
value status.to_s.upcase, value: status, description: "#{::IncidentManagement::IssuableEscalationStatus::STATUS_DESCRIPTIONS[status]}."
end
end
end
end

View File

@ -140,6 +140,9 @@ module Types
field :customer_relations_contacts, Types::CustomerRelations::ContactType.connection_type, null: true,
description: 'Customer relations contacts of the issue.'
field :escalation_status, Types::IncidentManagement::EscalationStatusEnum, null: true,
description: 'Escalation status of the issue.'
def author
Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find
end
@ -167,6 +170,12 @@ module Types
def hidden?
object.hidden? if Feature.enabled?(:ban_user_feature_flag)
end
def escalation_status
return unless Feature.enabled?(:incident_escalations, object.project) && object.supports_escalation?
object.escalation_status&.status_name
end
end
end

View File

@ -55,6 +55,7 @@ module Types
mount_mutation Mutations::Issues::SetDueDate
mount_mutation Mutations::Issues::SetSeverity
mount_mutation Mutations::Issues::SetSubscription
mount_mutation Mutations::Issues::SetEscalationStatus
mount_mutation Mutations::Issues::Update
mount_mutation Mutations::Issues::Move
mount_mutation Mutations::Labels::Create

View File

@ -65,10 +65,7 @@ module Ci
# Runner install help page is external, located at
# https://gitlab.com/gitlab-org/gitlab-runner
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
registration_token: Gitlab::CurrentSettings.runners_registration_token,
# Runner counts are returned as formatted strings
active_runners_count: Ci::Runner.online.count.to_s
registration_token: Gitlab::CurrentSettings.runners_registration_token
}
end

View File

@ -78,7 +78,6 @@ module AlertManagement
scope :for_environment, -> (environment) { where(environment: environment) }
scope :for_assignee_username, -> (assignee_username) { joins(:assignees).merge(User.by_username(assignee_username)) }
scope :search, -> (query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) }
scope :open, -> { with_status(open_statuses) }
scope :not_resolved, -> { without_status(:resolved) }
scope :with_prometheus_alert, -> { includes(:prometheus_alert) }
scope :with_threat_monitoring_alerts, -> { where(domain: :threat_monitoring ) }
@ -143,18 +142,6 @@ module AlertManagement
reference.to_i > 0 && reference.to_i <= Gitlab::Database::MAX_INT_VALUE
end
def self.open_statuses
[:triggered, :acknowledged]
end
def self.open_status?(status)
open_statuses.include?(status)
end
def open?
self.class.open_status?(status_name)
end
def prometheus?
monitoring_tool == Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus]
end

View File

@ -763,9 +763,7 @@ module Ci
def any_runners_available?
cache_for_available_runners do
::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339937') do
project.active_runners.exists?
end
project.active_runners.exists?
end
end

View File

@ -141,16 +141,9 @@ module Ci
project_groups = ::Group.joins(:projects).where(projects: { id: project_id })
if Feature.enabled?(:ci_decompose_belonging_to_parent_group_of_project_query, default_enabled: :yaml)
belonging_to_group(project_groups.self_and_ancestors.pluck(:id))
else
joins(:groups)
.where(namespaces: { id: project_groups.self_and_ancestors.as_ids })
.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433')
end
belonging_to_group(project_groups.self_and_ancestors.pluck(:id))
}
# deprecated
scope :owned_or_instance_wide, -> (project_id) do
from_union(
[
@ -159,7 +152,7 @@ module Ci
instance_type
],
remove_duplicates: false
).allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/336433')
)
end
scope :assignable_for, ->(project) do

View File

@ -27,6 +27,8 @@ module IncidentManagement
ignored: 'No action will be taken'
}.freeze
OPEN_STATUSES = [:triggered, :acknowledged].freeze
included do
validates :status, presence: true
@ -34,6 +36,7 @@ module IncidentManagement
# Descending sort order sorts statuses: Triggered > Acknowledged > Resolved > Ignored
# https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior
scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) }
scope :open, -> { with_status(OPEN_STATUSES) }
state_machine :status, initial: :triggered do
state :triggered, value: STATUSES[:triggered]
@ -89,6 +92,10 @@ module IncidentManagement
@status_names ||= state_machine_statuses.keys
end
def open_status?(status)
OPEN_STATUSES.include?(status)
end
private
def state_machine_statuses
@ -99,6 +106,10 @@ module IncidentManagement
def status_event_for(status)
self.class.state_machines[:status].events.transitions_for(self, to: status.to_s.to_sym).first&.event
end
def open?
self.class.open_status?(status_name)
end
end
end
end

View File

@ -1775,17 +1775,12 @@ class Project < ApplicationRecord
def all_runners
Ci::Runner.from_union([runners, group_runners, shared_runners])
.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339937')
end
def all_available_runners
Ci::Runner.from_union([runners, group_runners, available_shared_runners])
.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339937')
end
# Once issue 339937 is fixed, please search for all mentioned of
# https://gitlab.com/gitlab-org/gitlab/-/issues/339937,
# and remove the allow_cross_joins_across_databases.
def active_runners
strong_memoize(:active_runners) do
all_available_runners.active
@ -1793,9 +1788,7 @@ class Project < ApplicationRecord
end
def any_online_runners?(&block)
::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339937') do
online_runners_with_tags.any?(&block)
end
online_runners_with_tags.any?(&block)
end
def valid_runners_token?(token)

View File

@ -251,7 +251,7 @@ class User < ApplicationRecord
validate :notification_email_verified, if: :notification_email_changed?
validate :public_email_verified, if: :public_email_changed?
validate :commit_email_verified, if: :commit_email_changed?
validate :signup_email_valid?, on: :create, if: ->(user) { !user.created_by_id }
validate :email_allowed_by_restrictions?, if: ->(user) { user.new_record? ? !user.created_by_id : user.email_changed? }
validate :check_username_format, if: :username_changed?
validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids,
@ -2145,14 +2145,14 @@ class User < ApplicationRecord
end
end
def signup_email_valid?
def email_allowed_by_restrictions?
error = validate_admin_signup_restrictions(email)
errors.add(:email, error) if error
end
def signup_email_invalid_message
_('is not allowed for sign-up.')
self.new_record? ? _('is not allowed for sign-up.') : _('is not allowed.')
end
def check_username_format

View File

@ -95,13 +95,9 @@ module Ci
.build!
if pipeline.persisted?
if Feature.enabled?(:ci_publish_pipeline_events, pipeline.project, default_enabled: :yaml)
Gitlab::EventStore.publish(
Ci::PipelineCreatedEvent.new(data: { pipeline_id: pipeline.id })
)
else
schedule_head_pipeline_update
end
Gitlab::EventStore.publish(
Ci::PipelineCreatedEvent.new(data: { pipeline_id: pipeline.id })
)
create_namespace_onboarding_action
else
@ -141,12 +137,6 @@ module Ci
commit.try(:id)
end
def schedule_head_pipeline_update
pipeline.all_merge_requests.opened.each do |merge_request|
UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id)
end
end
def create_namespace_onboarding_action
Namespaces::OnboardingPipelineCreatedWorker.perform_async(project.namespace_id)
end

View File

@ -99,17 +99,15 @@ module Ci
private
def tick_for(build, runners)
::Gitlab::Database.allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/339937') do
runners = runners.with_recent_runner_queue
runners = runners.with_tags if Feature.enabled?(:ci_preload_runner_tags, default_enabled: :yaml)
runners = runners.with_recent_runner_queue
runners = runners.with_tags if Feature.enabled?(:ci_preload_runner_tags, default_enabled: :yaml)
metrics.observe_active_runners(-> { runners.to_a.size })
metrics.observe_active_runners(-> { runners.to_a.size })
runners.each do |runner|
metrics.increment_runner_tick(runner)
runners.each do |runner|
metrics.increment_runner_tick(runner)
runner.pick_build!(build)
end
runner.pick_build!(build)
end
end

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
module IncidentManagement
module IssuableEscalationStatuses
class AfterUpdateService < ::BaseProjectService
def initialize(issuable, current_user)
@issuable = issuable
@escalation_status = issuable.escalation_status
@alert = issuable.alert_management_alert
super(project: issuable.project, current_user: current_user)
end
def execute
after_update
ServiceResponse.success(payload: { escalation_status: escalation_status })
end
private
attr_reader :issuable, :escalation_status, :alert
def after_update
sync_to_alert
end
def sync_to_alert
return unless alert
return unless escalation_status.status_previously_changed?
::AlertManagement::Alerts::UpdateService.new(
alert,
current_user,
status: escalation_status.status_name
).execute
end
end
end
end
::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.prepend_mod

View File

@ -213,13 +213,8 @@ module Issues
def handle_escalation_status_change(issue, old_escalation_status)
return unless old_escalation_status.present?
return if issue.escalation_status&.slice(:status, :policy_id) == old_escalation_status
return unless issue.alert_management_alert
::AlertManagement::Alerts::UpdateService.new(
issue.alert_management_alert,
current_user,
status: issue.escalation_status.status_name
).execute
::IncidentManagement::IssuableEscalationStatuses::AfterUpdateService.new(issue, current_user).execute
end
# rubocop: disable CodeReuse/ActiveRecord

View File

@ -1,44 +1,3 @@
- page_title _('Deploy Keys')
- if Feature.enabled?(:admin_deploy_keys_vue, default_enabled: :yaml)
#js-admin-deploy-keys-table{ data: admin_deploy_keys_data }
- else
- if @deploy_keys.any?
%h3.page-title.deploy-keys-title
= _('Public deploy keys (%{deploy_keys_count})') % { deploy_keys_count: @deploy_keys.load.size }
= link_to _('New deploy key'), new_admin_deploy_key_path, class: 'float-right btn gl-button btn-confirm btn-md gl-button'
%table.table.b-table.gl-table.b-table-stacked-lg{ data: { testid: 'deploy-keys-list' } }
%thead
%tr
%th= _('Title')
%th= _('Fingerprint')
%th= _('Projects with write access')
%th= _('Created')
%th.gl-lg-w-1px.gl-white-space-nowrap
%span.gl-sr-only
= _('Actions')
%tbody
- @deploy_keys.each do |deploy_key|
%tr
%td{ data: { label: _('Title') } }
%div
= deploy_key.title
%td{ data: { label: _('Fingerprint') } }
%div
%code= deploy_key.fingerprint
%td{ data: { label: _('Projects with write access') } }
%div
- deploy_key.projects_with_write_access.each do |project|
= link_to project.full_name, admin_project_path(project), class: 'gl-display-block'
%td{ data: { label: _('Created') } }
%div
= time_ago_with_tooltip(deploy_key.created_at)
%td.gl-lg-w-1px.gl-white-space-nowrap{ data: { label: _('Actions') } }
%div
= link_to edit_admin_deploy_key_path(deploy_key), class: 'btn btn-default btn-md gl-button btn-icon gl-mr-3', aria: { label: _('Edit deploy key') } do
= sprite_icon('pencil', css_class: 'gl-button-icon')
= link_to admin_deploy_key_path(deploy_key), data: { confirm: _('Are you sure?') }, method: :delete, class: 'btn btn-danger btn-md gl-button btn-icon', aria: { label: _('Remove deploy key') } do
= sprite_icon('remove', css_class: 'gl-button-icon')
- else
= render 'shared/empty_states/deploy_keys'
#js-admin-deploy-keys-table{ data: admin_deploy_keys_data }

View File

@ -7,4 +7,7 @@
full_path: @group.full_path,
endpoint: group_packages_path(@group),
page_type: 'groups',
empty_list_illustration: image_path('illustrations/no-packages.svg'), } }
empty_list_illustration: image_path('illustrations/no-packages.svg'),
npm_instance_url: package_registry_instance_url(:npm),
project_list_url: '',
group_list_url: group_packages_path(@group) } }

View File

@ -7,4 +7,7 @@
full_path: @project.full_path,
endpoint: project_packages_path(@project),
page_type: 'projects',
empty_list_illustration: image_path('illustrations/no-packages.svg'), } }
empty_list_illustration: image_path('illustrations/no-packages.svg'),
npm_instance_url: package_registry_instance_url(:npm),
project_list_url: project_packages_path(@project),
group_list_url: '' } }

View File

@ -1,9 +0,0 @@
- add_to_breadcrumbs _("Package Registry"), project_packages_path(@project)
- add_to_breadcrumbs @package.name, project_packages_path(@project)
- breadcrumb_title @package.version
- page_title _("Package Registry")
- @content_class = "limit-container-width" unless fluid_layout
.row
.col-12
#js-vue-packages-detail-new{ data: package_details_data(@project, @package) }

View File

@ -1,9 +0,0 @@
.empty-state.gl-display-flex.gl-flex-direction-column.gl-flex-wrap.gl-text-center
.gl-flex-grow-0.gl-flex-shrink-0
.svg-250.svg-content
= image_tag 'illustrations/empty-state/empty-deploy-keys-lg.svg'
.gl-flex-grow-0.gl-flex-shrink-0
.text-content.gl-mx-auto.gl-my-0.gl-p-5
%h4.h4= _('Deploy Keys')
%p= _('Deploy keys grant read/write access to all repositories in your instance')
= link_to _('New deploy key'), new_admin_deploy_key_path, class: 'gl-button btn btn-confirm btn-md'

View File

@ -1,8 +0,0 @@
---
name: admin_deploy_keys_vue
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73580
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/344855
milestone: '14.5'
type: development
group: group::access
default_enabled: true

View File

@ -1,8 +0,0 @@
---
name: ci_decompose_belonging_to_parent_group_of_project_query
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/76454
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/348560
milestone: '14.7'
type: development
group: group::pipeline execution
default_enabled: false

View File

@ -1,8 +0,0 @@
---
name: ci_publish_pipeline_events
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/34042
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/336752
milestone: '14.3'
type: development
group: group::pipeline execution
default_enabled: false

View File

@ -1,8 +0,0 @@
---
name: use_primary_and_secondary_stores_for_sessions
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/73660
rollout_issue_url: https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1429
milestone: '14.6'
type: development
group: group::memory
default_enabled: false

View File

@ -1,8 +0,0 @@
---
name: use_primary_store_as_default_for_sessions
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/75258
rollout_issue_url:
milestone: '14.6'
type: development
group: group::memory
default_enabled: false

View File

@ -30,7 +30,7 @@
},
"status": {
"type": ["string"],
"enum": ["active", "deprecated", "removed", "broken"]
"enum": ["active", "removed", "broken"]
},
"milestone": {
"type": ["string"],

View File

@ -64,7 +64,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
post :toggle_subscription, on: :member
end
resources :packages, only: [:index]
resources :packages, only: [:index, :show]
resources :milestones, constraints: { id: %r{[^/]+} } do
member do

View File

@ -211,6 +211,8 @@
- 1
- - incident_management_pending_escalations_issue_check
- 1
- - incident_management_pending_escalations_issue_create
- 1
- - integrations_create_external_cross_reference
- 1
- - invalid_gpg_signature_update

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class MarkRecalculateFindingSignaturesAsCompleted < Gitlab::Database::Migration[1.0]
MIGRATION = 'RecalculateVulnerabilitiesOccurrencesUuid'
def up
# Only run migration for Gitlab.com
return unless ::Gitlab.com?
# In previous migration marking jobs as successful was missed
Gitlab::Database::BackgroundMigrationJob
.for_migration_class(MIGRATION)
.pending
.update_all(status: :succeeded)
end
def down
# no-op
end
end

View File

@ -0,0 +1 @@
649360f4069aac4784f4d039015f8dda3f4bae28e8132f841e25b48f034a392e

View File

@ -2962,6 +2962,27 @@ Input type: `IssueSetEscalationPolicyInput`
| <a id="mutationissuesetescalationpolicyerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationissuesetescalationpolicyissue"></a>`issue` | [`Issue`](#issue) | Issue after mutation. |
### `Mutation.issueSetEscalationStatus`
Input type: `IssueSetEscalationStatusInput`
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationissuesetescalationstatusclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationissuesetescalationstatusiid"></a>`iid` | [`String!`](#string) | IID of the issue to mutate. |
| <a id="mutationissuesetescalationstatusprojectpath"></a>`projectPath` | [`ID!`](#id) | Project the issue to mutate is in. |
| <a id="mutationissuesetescalationstatusstatus"></a>`status` | [`IssueEscalationStatus!`](#issueescalationstatus) | Set the escalation status. |
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="mutationissuesetescalationstatusclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationissuesetescalationstatuserrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. |
| <a id="mutationissuesetescalationstatusissue"></a>`issue` | [`Issue`](#issue) | Issue after mutation. |
### `Mutation.issueSetIteration`
Input type: `IssueSetIterationInput`
@ -10317,6 +10338,7 @@ Relationship between an epic and an issue.
| <a id="epicissueepic"></a>`epic` | [`Epic`](#epic) | Epic to which this issue belongs. |
| <a id="epicissueepicissueid"></a>`epicIssueId` | [`ID!`](#id) | ID of the epic-issue relation. |
| <a id="epicissueescalationpolicy"></a>`escalationPolicy` | [`EscalationPolicyType`](#escalationpolicytype) | Escalation policy associated with the issue. Available for issues which support escalation. |
| <a id="epicissueescalationstatus"></a>`escalationStatus` | [`IssueEscalationStatus`](#issueescalationstatus) | Escalation status of the issue. |
| <a id="epicissuehealthstatus"></a>`healthStatus` | [`HealthStatus`](#healthstatus) | Current health status. |
| <a id="epicissuehidden"></a>`hidden` | [`Boolean`](#boolean) | Indicates the issue is hidden because the author has been banned. Will always return `null` if `ban_user_feature_flag` feature flag is disabled. |
| <a id="epicissuehumantimeestimate"></a>`humanTimeEstimate` | [`String`](#string) | Human-readable time estimate of the issue. |
@ -11500,6 +11522,7 @@ Returns [`VulnerabilitySeveritiesCount`](#vulnerabilityseveritiescount).
| <a id="issueemailsdisabled"></a>`emailsDisabled` | [`Boolean!`](#boolean) | Indicates if a project has email notifications disabled: `true` if email notifications are disabled. |
| <a id="issueepic"></a>`epic` | [`Epic`](#epic) | Epic to which this issue belongs. |
| <a id="issueescalationpolicy"></a>`escalationPolicy` | [`EscalationPolicyType`](#escalationpolicytype) | Escalation policy associated with the issue. Available for issues which support escalation. |
| <a id="issueescalationstatus"></a>`escalationStatus` | [`IssueEscalationStatus`](#issueescalationstatus) | Escalation status of the issue. |
| <a id="issuehealthstatus"></a>`healthStatus` | [`HealthStatus`](#healthstatus) | Current health status. |
| <a id="issuehidden"></a>`hidden` | [`Boolean`](#boolean) | Indicates the issue is hidden because the author has been banned. Will always return `null` if `ban_user_feature_flag` feature flag is disabled. |
| <a id="issuehumantimeestimate"></a>`humanTimeEstimate` | [`String`](#string) | Human-readable time estimate of the issue. |
@ -16821,6 +16844,17 @@ Iteration ID wildcard values for issue creation.
| ----- | ----------- |
| <a id="issuecreationiterationwildcardidcurrent"></a>`CURRENT` | Current iteration. |
### `IssueEscalationStatus`
Issue escalation status values.
| Value | Description |
| ----- | ----------- |
| <a id="issueescalationstatusacknowledged"></a>`ACKNOWLEDGED` | Someone is actively investigating the problem. |
| <a id="issueescalationstatusignored"></a>`IGNORED` | No action will be taken. |
| <a id="issueescalationstatusresolved"></a>`RESOLVED` | The problem has been addressed. |
| <a id="issueescalationstatustriggered"></a>`TRIGGERED` | Investigation has not started. |
### `IssueSort`
Values for sorting issues.

View File

@ -94,7 +94,7 @@ a license, upload the license in the **Admin Area** in the web user interface.
## What happens when your license expires
One month before the license expires, a message with the upcoming expiration
Fifteen days before the license expires, a notification banner with the upcoming expiration
date displays to GitLab administrators.
When your license expires, GitLab locks features, like Git pushes

View File

@ -1,232 +0,0 @@
# frozen_string_literal: true
module Gitlab
module Redis
class MultiStore
include Gitlab::Utils::StrongMemoize
class ReadFromPrimaryError < StandardError
def message
'Value not found on the redis primary store. Read from the redis secondary store successful.'
end
end
class MethodMissingError < StandardError
def message
'Method missing. Falling back to execute method on the redis secondary store.'
end
end
attr_reader :primary_store, :secondary_store, :instance_name
FAILED_TO_READ_ERROR_MESSAGE = 'Failed to read from the redis primary_store.'
FAILED_TO_WRITE_ERROR_MESSAGE = 'Failed to write to the redis primary_store.'
SKIP_LOG_METHOD_MISSING_FOR_COMMANDS = %i(info).freeze
READ_COMMANDS = %i(
get
mget
smembers
scard
).freeze
WRITE_COMMANDS = %i(
set
setnx
setex
sadd
srem
del
pipelined
flushdb
).freeze
def initialize(primary_store, secondary_store, instance_name)
@primary_store = primary_store
@secondary_store = secondary_store
@instance_name = instance_name
validate_stores!
end
# rubocop:disable GitlabSecurity/PublicSend
READ_COMMANDS.each do |name|
define_method(name) do |*args, &block|
if use_primary_and_secondary_stores?
read_command(name, *args, &block)
else
default_store.send(name, *args, &block)
end
end
end
WRITE_COMMANDS.each do |name|
define_method(name) do |*args, &block|
if use_primary_and_secondary_stores?
write_command(name, *args, &block)
else
default_store.send(name, *args, &block)
end
end
end
def method_missing(...)
return @instance.send(...) if @instance
log_method_missing(...)
default_store.send(...)
end
# rubocop:enable GitlabSecurity/PublicSend
def respond_to_missing?(command_name, include_private = false)
true
end
# This is needed because of Redis::Rack::Connection is requiring Redis::Store
# https://github.com/redis-store/redis-rack/blob/a833086ba494083b6a384a1a4e58b36573a9165d/lib/redis/rack/connection.rb#L15
# Done similarly in https://github.com/lsegal/yard/blob/main/lib/yard/templates/template.rb#L122
def is_a?(klass)
return true if klass == default_store.class
super(klass)
end
alias_method :kind_of?, :is_a?
def to_s
use_primary_and_secondary_stores? ? primary_store.to_s : default_store.to_s
end
def use_primary_and_secondary_stores?
feature_table_exists? && Feature.enabled?("use_primary_and_secondary_stores_for_#{instance_name.underscore}", default_enabled: :yaml) && !same_redis_store?
end
def use_primary_store_as_default?
feature_table_exists? && Feature.enabled?("use_primary_store_as_default_for_#{instance_name.underscore}", default_enabled: :yaml) && !same_redis_store?
end
private
# @return [Boolean]
def feature_table_exists?
Feature::FlipperFeature.table_exists?
rescue StandardError
false
end
def default_store
use_primary_store_as_default? ? primary_store : secondary_store
end
def log_method_missing(command_name, *_args)
return if SKIP_LOG_METHOD_MISSING_FOR_COMMANDS.include?(command_name)
log_error(MethodMissingError.new, command_name)
increment_method_missing_count(command_name)
end
def read_command(command_name, *args, &block)
if @instance
send_command(@instance, command_name, *args, &block)
else
read_one_with_fallback(command_name, *args, &block)
end
end
def write_command(command_name, *args, &block)
if @instance
send_command(@instance, command_name, *args, &block)
else
write_both(command_name, *args, &block)
end
end
def read_one_with_fallback(command_name, *args, &block)
begin
value = send_command(primary_store, command_name, *args, &block)
rescue StandardError => e
log_error(e, command_name,
multi_store_error_message: FAILED_TO_READ_ERROR_MESSAGE)
end
value ||= fallback_read(command_name, *args, &block)
value
end
def fallback_read(command_name, *args, &block)
value = send_command(secondary_store, command_name, *args, &block)
if value
log_error(ReadFromPrimaryError.new, command_name)
increment_read_fallback_count(command_name)
end
value
end
def write_both(command_name, *args, &block)
begin
send_command(primary_store, command_name, *args, &block)
rescue StandardError => e
log_error(e, command_name,
multi_store_error_message: FAILED_TO_WRITE_ERROR_MESSAGE)
end
send_command(secondary_store, command_name, *args, &block)
end
def same_redis_store?
strong_memoize(:same_redis_store) do
# <Redis client v4.4.0 for redis:///path_to/redis/redis.socket/5>"
primary_store.inspect == secondary_store.inspect
end
end
# rubocop:disable GitlabSecurity/PublicSend
def send_command(redis_instance, command_name, *args, &block)
if block_given?
# Make sure that block is wrapped and executed only on the redis instance that is executing the block
redis_instance.send(command_name, *args) do |*params|
with_instance(redis_instance, *params, &block)
end
else
redis_instance.send(command_name, *args)
end
end
# rubocop:enable GitlabSecurity/PublicSend
def with_instance(instance, *params)
@instance = instance
yield(*params)
ensure
@instance = nil
end
def increment_read_fallback_count(command_name)
@read_fallback_counter ||= Gitlab::Metrics.counter(:gitlab_redis_multi_store_read_fallback_total, 'Client side Redis MultiStore reading fallback')
@read_fallback_counter.increment(command: command_name, instance_name: instance_name)
end
def increment_method_missing_count(command_name)
@method_missing_counter ||= Gitlab::Metrics.counter(:gitlab_redis_multi_store_method_missing_total, 'Client side Redis MultiStore method missing')
@method_missing_counter.increment(command: command_name, instance_name: instance_name)
end
def validate_stores!
raise ArgumentError, 'primary_store is required' unless primary_store
raise ArgumentError, 'secondary_store is required' unless secondary_store
raise ArgumentError, 'instance_name is required' unless instance_name
raise ArgumentError, 'invalid primary_store' unless primary_store.is_a?(::Redis)
raise ArgumentError, 'invalid secondary_store' unless secondary_store.is_a?(::Redis)
end
def log_error(exception, command_name, extra = {})
Gitlab::ErrorTracking.log_exception(
exception,
command_name: command_name,
extra: extra.merge(instance_name: instance_name))
end
end
end
end

View File

@ -9,39 +9,9 @@ module Gitlab
IP_SESSIONS_LOOKUP_NAMESPACE = 'session:lookup:ip:gitlab2'
OTP_SESSIONS_NAMESPACE = 'session:otp'
class << self
# The data we store on Sessions used to be stored on SharedState.
def config_fallback
SharedState
end
private
def redis
# Don't use multistore if redis.sessions configuration is not provided
return super if config_fallback?
primary_store = ::Redis.new(params)
secondary_store = ::Redis.new(config_fallback.params)
MultiStore.new(primary_store, secondary_store, store_name)
end
end
def store(extras = {})
# Don't use multistore if redis.sessions configuration is not provided
return super if self.class.config_fallback?
primary_store = create_redis_store(redis_store_options, extras)
secondary_store = create_redis_store(self.class.config_fallback.params, extras)
MultiStore.new(primary_store, secondary_store, self.class.store_name)
end
private
def create_redis_store(options, extras)
::Redis::Store.new(options.merge(extras))
# The data we store on Sessions used to be stored on SharedState.
def self.config_fallback
SharedState
end
end
end

View File

@ -7485,6 +7485,12 @@ msgstr ""
msgid "Closes this %{quick_action_target}."
msgstr ""
msgid "Cloud Run"
msgstr ""
msgid "Cloud Storage"
msgstr ""
msgid "Cluster"
msgstr ""
@ -9034,6 +9040,9 @@ msgstr ""
msgid "Configure existing installation"
msgstr ""
msgid "Configure pipelines to deploy web apps, backend services, APIs and static resources to Google Cloud"
msgstr ""
msgid "Configure repository mirroring."
msgstr ""
@ -9061,6 +9070,9 @@ msgstr ""
msgid "Configure the way a user creates a new account."
msgstr ""
msgid "Configure via Merge Request"
msgstr ""
msgid "Configure which lists are shown for anyone who visits this board"
msgstr ""
@ -11737,6 +11749,9 @@ msgstr[1] ""
msgid "Deploy Keys"
msgstr ""
msgid "Deploy container based web apps on Google managed clusters"
msgstr ""
msgid "Deploy freezes"
msgstr ""
@ -11752,6 +11767,9 @@ msgstr ""
msgid "Deploy progress not found. To see pods, ensure your environment matches %{linkStart}deploy board criteria%{linkEnd}."
msgstr ""
msgid "Deploy static assets and resources to Google managed CDN"
msgstr ""
msgid "Deploy to..."
msgstr ""
@ -28930,9 +28948,6 @@ msgstr ""
msgid "Public deploy keys"
msgstr ""
msgid "Public deploy keys (%{deploy_keys_count})"
msgstr ""
msgid "Public pipelines"
msgstr ""
@ -30726,10 +30741,13 @@ msgstr ""
msgid "Runners|Offline"
msgstr ""
msgid "Runners|Offline runners"
msgstr ""
msgid "Runners|Online"
msgstr ""
msgid "Runners|Online Runners"
msgid "Runners|Online runners"
msgstr ""
msgid "Runners|Paused"
@ -30825,6 +30843,9 @@ msgstr ""
msgid "Runners|Stale"
msgstr ""
msgid "Runners|Stale runners"
msgstr ""
msgid "Runners|Status"
msgstr ""
@ -42268,6 +42289,9 @@ msgstr ""
msgid "is not allowed since the group is not top-level group."
msgstr ""
msgid "is not allowed."
msgstr ""
msgid "is not allowed. We do not currently support project-level iterations"
msgstr ""

View File

@ -5,7 +5,7 @@ module QA
module Project
module Packages
class Show < QA::Page::Base
view 'app/assets/javascripts/packages_and_registries/package_registry/components/details/app.vue' do
view 'app/assets/javascripts/packages_and_registries/package_registry/pages/details.vue' do
element :delete_button
element :delete_modal_button
element :package_information_content

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Groups::PackagesController do
let_it_be(:group) { create(:group) }
let(:page) { :index }
let(:additional_parameters) { {} }
subject do
get page, params: additional_parameters.merge({
group_id: group
})
end
context 'GET #index' do
it_behaves_like 'returning response status', :ok
end
context 'GET #show' do
let(:page) { :show }
let(:additional_parameters) { { id: 1 } }
it_behaves_like 'returning response status', :ok
end
end

View File

@ -0,0 +1,28 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Projects::Packages::PackagesController do
let_it_be(:project) { create(:project, :public) }
let(:page) { :index }
let(:additional_parameters) { {} }
subject do
get page, params: additional_parameters.merge({
project_id: project,
namespace_id: project.namespace
})
end
context 'GET #index' do
it_behaves_like 'returning response status', :ok
end
context 'GET #show' do
let(:page) { :show }
let(:additional_parameters) { { id: 1 } }
it_behaves_like 'returning response status', :ok
end
end

View File

@ -2,7 +2,7 @@
require 'spec_helper'
RSpec.describe 'admin deploy keys' do
RSpec.describe 'admin deploy keys', :js do
include Spec::Support::Helpers::ModalHelpers
let_it_be(:admin) { create(:admin) }
@ -15,112 +15,81 @@ RSpec.describe 'admin deploy keys' do
gitlab_enable_admin_mode_sign_in(admin)
end
shared_examples 'renders deploy keys correctly' do
it 'show all public deploy keys' do
visit admin_deploy_keys_path
it 'show all public deploy keys' do
visit admin_deploy_keys_path
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).to have_content(deploy_key.title)
expect(page).to have_content(another_deploy_key.title)
end
end
it 'shows all the projects the deploy key has write access' do
write_key = create(:deploy_keys_project, :write_access, deploy_key: deploy_key)
visit admin_deploy_keys_path
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).to have_content(write_key.project.full_name)
end
end
describe 'create a new deploy key' do
let(:new_ssh_key) { attributes_for(:key)[:key] }
before do
visit admin_deploy_keys_path
click_link 'New deploy key'
end
it 'creates a new deploy key' do
fill_in 'deploy_key_title', with: 'laptop'
fill_in 'deploy_key_key', with: new_ssh_key
click_button 'Create'
expect(current_path).to eq admin_deploy_keys_path
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).to have_content('laptop')
end
end
end
describe 'update an existing deploy key' do
before do
visit admin_deploy_keys_path
page.within('tr', text: deploy_key.title) do
click_link(_('Edit deploy key'))
end
end
it 'updates an existing deploy key' do
fill_in 'deploy_key_title', with: 'new-title'
click_button 'Save changes'
expect(current_path).to eq admin_deploy_keys_path
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).to have_content('new-title')
end
end
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).to have_content(deploy_key.title)
expect(page).to have_content(another_deploy_key.title)
end
end
context 'when `admin_deploy_keys_vue` feature flag is enabled', :js do
it_behaves_like 'renders deploy keys correctly'
it 'shows all the projects the deploy key has write access' do
write_key = create(:deploy_keys_project, :write_access, deploy_key: deploy_key)
describe 'remove an existing deploy key' do
before do
visit admin_deploy_keys_path
end
visit admin_deploy_keys_path
it 'removes an existing deploy key' do
accept_gl_confirm('Are you sure you want to delete this deploy key?', button_text: 'Delete') do
page.within('tr', text: deploy_key.title) do
click_button _('Delete deploy key')
end
end
expect(current_path).to eq admin_deploy_keys_path
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).not_to have_content(deploy_key.title)
end
end
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).to have_content(write_key.project.full_name)
end
end
context 'when `admin_deploy_keys_vue` feature flag is disabled' do
describe 'create a new deploy key' do
let(:new_ssh_key) { attributes_for(:key)[:key] }
before do
stub_feature_flags(admin_deploy_keys_vue: false)
visit admin_deploy_keys_path
click_link 'New deploy key'
end
it_behaves_like 'renders deploy keys correctly'
it 'creates a new deploy key' do
fill_in 'deploy_key_title', with: 'laptop'
fill_in 'deploy_key_key', with: new_ssh_key
click_button 'Create'
describe 'remove an existing deploy key' do
before do
visit admin_deploy_keys_path
expect(current_path).to eq admin_deploy_keys_path
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).to have_content('laptop')
end
end
end
describe 'update an existing deploy key' do
before do
visit admin_deploy_keys_path
page.within('tr', text: deploy_key.title) do
click_link(_('Edit deploy key'))
end
end
it 'updates an existing deploy key' do
fill_in 'deploy_key_title', with: 'new-title'
click_button 'Save changes'
expect(current_path).to eq admin_deploy_keys_path
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).to have_content('new-title')
end
end
end
describe 'remove an existing deploy key' do
before do
visit admin_deploy_keys_path
end
it 'removes an existing deploy key' do
accept_gl_confirm('Are you sure you want to delete this deploy key?', button_text: 'Delete') do
page.within('tr', text: deploy_key.title) do
click_button _('Delete deploy key')
end
end
it 'removes an existing deploy key' do
page.within('tr', text: deploy_key.title) do
click_link _('Remove deploy key')
end
expect(current_path).to eq admin_deploy_keys_path
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).not_to have_content(deploy_key.title)
end
expect(current_path).to eq admin_deploy_keys_path
page.within(find('[data-testid="deploy-keys-list"]', match: :first)) do
expect(page).not_to have_content(deploy_key.title)
end
end
end

View File

@ -21,12 +21,16 @@ RSpec.describe "Admin Runners" do
context "when there are runners" do
it 'has all necessary texts' do
create(:ci_runner, :instance, contacted_at: Time.now)
create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: Time.now)
create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.week.ago)
create(:ci_runner, :instance, created_at: 1.year.ago, contacted_at: 1.year.ago)
visit admin_runners_path
expect(page).to have_text "Register an instance runner"
expect(page).to have_text "Online Runners 1"
expect(page).to have_text "Online runners 1"
expect(page).to have_text "Offline runners 2"
expect(page).to have_text "Stale runners 1"
end
it 'with an instance runner shows an instance badge' do
@ -387,7 +391,11 @@ RSpec.describe "Admin Runners" do
it 'has all necessary texts including no runner message' do
expect(page).to have_text "Register an instance runner"
expect(page).to have_text "Online Runners 0"
expect(page).to have_text "Online runners 0"
expect(page).to have_text "Offline runners 0"
expect(page).to have_text "Stale runners 0"
expect(page).to have_text 'No runners found'
end
@ -451,7 +459,9 @@ RSpec.describe "Admin Runners" do
before do
click_on 'Reset registration token'
page.accept_alert
within_modal do
click_button('OK', match: :first)
end
wait_for_requests
end

View File

@ -42,6 +42,9 @@ RSpec.describe 'Group Packages' do
let_it_be(:maven_package) { create(:maven_package, project: second_project, name: 'aaa', created_at: 2.days.ago, version: '2.0.0') }
let_it_be(:packages) { [npm_package, maven_package] }
let(:package) { packages.first }
let(:package_details_path) { group_package_path(group, package) }
it_behaves_like 'packages list', check_project_name: true
it_behaves_like 'package details link'

View File

@ -35,6 +35,9 @@ RSpec.describe 'Packages' do
let_it_be(:maven_package) { create(:maven_package, project: project, name: 'aaa', created_at: 2.days.ago, version: '2.0.0') }
let_it_be(:packages) { [npm_package, maven_package] }
let(:package) { packages.first }
let(:package_details_path) { project_package_path(project, package) }
it_behaves_like 'packages list'
it_behaves_like 'package details link'

View File

@ -24,99 +24,109 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
remove_repository(project)
end
describe GraphQL::Query, type: :request do
get_runners_query_name = 'get_runners.query.graphql'
describe do
before do
sign_in(admin)
enable_admin_mode!(admin)
end
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runners_query_name}")
describe GraphQL::Query, type: :request do
get_runners_query_name = 'get_runners.query.graphql'
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runners_query_name}")
end
it "#{fixtures_path}#{get_runners_query_name}.json" do
post_graphql(query, current_user: admin, variables: {})
expect_graphql_errors_to_be_empty
end
it "#{fixtures_path}#{get_runners_query_name}.paginated.json" do
post_graphql(query, current_user: admin, variables: { first: 2 })
expect_graphql_errors_to_be_empty
end
end
it "#{fixtures_path}#{get_runners_query_name}.json" do
post_graphql(query, current_user: admin, variables: {})
describe GraphQL::Query, type: :request do
get_runners_count_query_name = 'get_runners_count.query.graphql'
expect_graphql_errors_to_be_empty
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runners_count_query_name}")
end
it "#{fixtures_path}#{get_runners_count_query_name}.json" do
post_graphql(query, current_user: admin, variables: {})
expect_graphql_errors_to_be_empty
end
end
it "#{fixtures_path}#{get_runners_query_name}.paginated.json" do
post_graphql(query, current_user: admin, variables: { first: 2 })
describe GraphQL::Query, type: :request do
get_runner_query_name = 'get_runner.query.graphql'
expect_graphql_errors_to_be_empty
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runner_query_name}")
end
it "#{fixtures_path}#{get_runner_query_name}.json" do
post_graphql(query, current_user: admin, variables: {
id: instance_runner.to_global_id.to_s
})
expect_graphql_errors_to_be_empty
end
end
end
describe GraphQL::Query, type: :request do
get_runners_count_query_name = 'get_runners_count.query.graphql'
before do
sign_in(admin)
enable_admin_mode!(admin)
end
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runners_count_query_name}")
end
it "#{fixtures_path}#{get_runners_count_query_name}.json" do
post_graphql(query, current_user: admin, variables: {})
expect_graphql_errors_to_be_empty
end
end
describe GraphQL::Query, type: :request do
get_runner_query_name = 'get_runner.query.graphql'
before do
sign_in(admin)
enable_admin_mode!(admin)
end
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_runner_query_name}")
end
it "#{fixtures_path}#{get_runner_query_name}.json" do
post_graphql(query, current_user: admin, variables: {
id: instance_runner.to_global_id.to_s
})
expect_graphql_errors_to_be_empty
end
end
describe GraphQL::Query, type: :request do
get_group_runners_query_name = 'get_group_runners.query.graphql'
describe do
let_it_be(:group_owner) { create(:user) }
before do
group.add_owner(group_owner)
end
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}")
describe GraphQL::Query, type: :request do
get_group_runners_query_name = 'get_group_runners.query.graphql'
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_group_runners_query_name}")
end
it "#{fixtures_path}#{get_group_runners_query_name}.json" do
post_graphql(query, current_user: group_owner, variables: {
groupFullPath: group.full_path
})
expect_graphql_errors_to_be_empty
end
it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do
post_graphql(query, current_user: group_owner, variables: {
groupFullPath: group.full_path,
first: 1
})
expect_graphql_errors_to_be_empty
end
end
it "#{fixtures_path}#{get_group_runners_query_name}.json" do
post_graphql(query, current_user: group_owner, variables: {
groupFullPath: group.full_path
})
describe GraphQL::Query, type: :request do
get_group_runners_count_query_name = 'get_group_runners_count.query.graphql'
expect_graphql_errors_to_be_empty
end
let_it_be(:query) do
get_graphql_query_as_string("#{query_path}#{get_group_runners_count_query_name}")
end
it "#{fixtures_path}#{get_group_runners_query_name}.paginated.json" do
post_graphql(query, current_user: group_owner, variables: {
groupFullPath: group.full_path,
first: 1
})
it "#{fixtures_path}#{get_group_runners_count_query_name}.json" do
post_graphql(query, current_user: group_owner, variables: {
groupFullPath: group.full_path
})
expect_graphql_errors_to_be_empty
expect_graphql_errors_to_be_empty
end
end
end
end

View File

@ -24,6 +24,8 @@ const HOME_PROPS = {
serviceAccounts: [{}, {}],
createServiceAccountUrl: '#url-create-service-account',
emptyIllustrationUrl: '#url-empty-illustration',
deploymentsCloudRunUrl: '#url-deployments-cloud-run',
deploymentsCloudStorageUrl: '#deploymentsCloudStorageUrl',
};
describe('google_cloud App component', () => {

View File

@ -0,0 +1,40 @@
import { mount } from '@vue/test-utils';
import { GlButton, GlTable } from '@gitlab/ui';
import DeploymentsServiceTable from '~/google_cloud/components/deployments_service_table.vue';
describe('google_cloud DeploymentsServiceTable component', () => {
let wrapper;
const findTable = () => wrapper.findComponent(GlTable);
const findButtons = () => findTable().findAllComponents(GlButton);
const findCloudRunButton = () => findButtons().at(0);
const findCloudStorageButton = () => findButtons().at(1);
beforeEach(() => {
const propsData = {
cloudRunUrl: '#url-deployments-cloud-run',
cloudStorageUrl: '#url-deployments-cloud-storage',
};
wrapper = mount(DeploymentsServiceTable, { propsData });
});
afterEach(() => {
wrapper.destroy();
});
it('should contain a table', () => {
expect(findTable().exists()).toBe(true);
});
it('should contain configure cloud run button', () => {
const cloudRunButton = findCloudRunButton();
expect(cloudRunButton.exists()).toBe(true);
expect(cloudRunButton.props().disabled).toBe(true);
});
it('should contain configure cloud storage button', () => {
const cloudStorageButton = findCloudStorageButton();
expect(cloudStorageButton.exists()).toBe(true);
expect(cloudStorageButton.props().disabled).toBe(true);
});
});

View File

@ -20,6 +20,8 @@ describe('google_cloud Home component', () => {
serviceAccounts: [{}, {}],
createServiceAccountUrl: '#url-create-service-account',
emptyIllustrationUrl: '#url-empty-illustration',
deploymentsCloudRunUrl: '#url-deployments-cloud-run',
deploymentsCloudStorageUrl: '#deploymentsCloudStorageUrl',
};
beforeEach(() => {
@ -42,7 +44,7 @@ describe('google_cloud Home component', () => {
it('should contain three tab items', () => {
expect(findTabItemsModel()).toEqual([
{ title: 'Configuration', disabled: undefined },
{ title: 'Deployments', disabled: '' },
{ title: 'Deployments', disabled: undefined },
{ title: 'Services', disabled: '' },
]);
});

View File

@ -32,7 +32,7 @@ exports[`NpmInstallation renders all the messages 1`] = `
<code-instruction-stub
copytext="Copy npm setup command"
instruction="echo @gitlab-org:registry=npmPath/ >> .npmrc"
instruction="echo @gitlab-org:registry=npmInstanceUrl/ >> .npmrc"
label=""
trackingaction="copy_npm_setup_command"
trackinglabel="code_instruction"

View File

@ -35,7 +35,7 @@ describe('NpmInstallation', () => {
function createComponent({ data = {} } = {}) {
wrapper = shallowMountExtended(NpmInstallation, {
provide: {
npmPath: 'npmPath',
npmInstanceUrl: 'npmInstanceUrl',
},
propsData: {
packageEntity,
@ -117,7 +117,7 @@ describe('NpmInstallation', () => {
it('renders the correct setup command', () => {
expect(findCodeInstructions().at(1).props()).toMatchObject({
instruction: 'echo @gitlab-org:registry=npmPath/ >> .npmrc',
instruction: 'echo @gitlab-org:registry=npmInstanceUrl/ >> .npmrc',
multiline: false,
trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND,
});
@ -139,7 +139,7 @@ describe('NpmInstallation', () => {
await nextTick();
expect(findCodeInstructions().at(1).props()).toMatchObject({
instruction: `echo @gitlab-org:registry=npmPath/ >> .npmrc`,
instruction: `echo @gitlab-org:registry=npmInstanceUrl/ >> .npmrc`,
multiline: false,
trackingAction: TRACKING_ACTION_COPY_NPM_SETUP_COMMAND,
});
@ -161,7 +161,7 @@ describe('NpmInstallation', () => {
it('renders the correct registry command', () => {
expect(findCodeInstructions().at(1).props()).toMatchObject({
instruction: 'echo \\"@gitlab-org:registry\\" \\"npmPath/\\" >> .yarnrc',
instruction: 'echo \\"@gitlab-org:registry\\" \\"npmInstanceUrl/\\" >> .yarnrc',
multiline: false,
trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND,
});
@ -183,7 +183,7 @@ describe('NpmInstallation', () => {
await nextTick();
expect(findCodeInstructions().at(1).props()).toMatchObject({
instruction: 'echo \\"@gitlab-org:registry\\" \\"npmPath/\\" >> .yarnrc',
instruction: 'echo \\"@gitlab-org:registry\\" \\"npmInstanceUrl/\\" >> .yarnrc',
multiline: false,
trackingAction: TRACKING_ACTION_COPY_YARN_SETUP_COMMAND,
});

View File

@ -22,16 +22,20 @@ exports[`packages_list_row renders 1`] = `
<div
class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0"
>
<gl-link-stub
<router-link-stub
ariacurrentvalue="page"
class="gl-text-body gl-min-w-0"
data-qa-selector="package_link"
href="http://gdk.test:3000/gitlab-org/gitlab-test/-/packages/111"
data-testid="details-link"
event="click"
tag="a"
to="[object Object]"
>
<gl-truncate-stub
position="end"
text="@gitlab-org/package-15"
/>
</gl-link-stub>
</router-link-stub>
<!---->

View File

@ -1,7 +1,11 @@
import { GlLink, GlSprintf } from '@gitlab/ui';
import { GlSprintf } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueRouter from 'vue-router';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import PackagesListRow from '~/packages_and_registries/package_registry/components/list/package_list_row.vue';
import PackagePath from '~/packages_and_registries/shared/components/package_path.vue';
import PackageTags from '~/packages_and_registries/shared/components/package_tags.vue';
@ -13,6 +17,9 @@ import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/package_registry
import ListItem from '~/vue_shared/components/registry/list_item.vue';
import { packageData, packagePipelines, packageProject, packageTags } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(VueRouter);
describe('packages_list_row', () => {
let wrapper;
@ -28,7 +35,7 @@ describe('packages_list_row', () => {
const findDeleteButton = () => wrapper.findByTestId('action-delete');
const findPackageIconAndName = () => wrapper.find(PackageIconAndName);
const findListItem = () => wrapper.findComponent(ListItem);
const findPackageLink = () => wrapper.findComponent(GlLink);
const findPackageLink = () => wrapper.findByTestId('details-link');
const findWarningIcon = () => wrapper.findByTestId('warning-icon');
const findLeftSecondaryInfos = () => wrapper.findByTestId('left-secondary-infos');
const findPublishMethod = () => wrapper.findComponent(PublishMethod);
@ -40,6 +47,7 @@ describe('packages_list_row', () => {
provide = defaultProvide,
} = {}) => {
wrapper = shallowMountExtended(PackagesListRow, {
localVue,
provide,
stubs: {
ListItem,
@ -63,6 +71,15 @@ describe('packages_list_row', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('has a link to navigate to the details page', () => {
mountComponent();
expect(findPackageLink().props()).toMatchObject({
event: 'click',
to: { name: 'details', params: { id: getIdFromGraphQLId(packageWithoutTags.id) } },
});
});
describe('tags', () => {
it('renders package tags when a package has tags', () => {
mountComponent({ packageEntity: packageWithTags });
@ -120,7 +137,7 @@ describe('packages_list_row', () => {
});
it('details link is disabled', () => {
expect(findPackageLink().attributes('disabled')).toBe('true');
expect(findPackageLink().props('event')).toBe('');
});
it('has a warning icon', () => {

View File

@ -9,7 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import AdditionalMetadata from '~/packages_and_registries/package_registry/components/details/additional_metadata.vue';
import PackagesApp from '~/packages_and_registries/package_registry/components/details/app.vue';
import PackagesApp from '~/packages_and_registries/package_registry/pages/details.vue';
import DependencyRow from '~/packages_and_registries/package_registry/components/details/dependency_row.vue';
import InstallationCommands from '~/packages_and_registries/package_registry/components/details/installation_commands.vue';
import PackageFiles from '~/packages_and_registries/package_registry/components/details/package_files.vue';
@ -36,7 +36,7 @@ import {
packageFiles,
packageDestroyFileMutation,
packageDestroyFileMutationError,
} from '../../mock_data';
} from '../mock_data';
jest.mock('~/flash');
useMockLocationHelper();
@ -47,18 +47,22 @@ describe('PackagesApp', () => {
let wrapper;
let apolloProvider;
const breadCrumbState = {
updateName: jest.fn(),
};
const provide = {
packageId: '111',
svgPath: 'svgPath',
npmPath: 'npmPath',
npmHelpPath: 'npmHelpPath',
emptyListIllustration: 'svgPath',
projectListUrl: 'projectListUrl',
groupListUrl: 'groupListUrl',
breadCrumbState,
};
function createComponent({
resolver = jest.fn().mockResolvedValue(packageDetailsQuery()),
fileDeleteMutationResolver = jest.fn().mockResolvedValue(packageDestroyFileMutation()),
routeId = '1',
} = {}) {
localVue.use(VueApollo);
@ -84,6 +88,13 @@ describe('PackagesApp', () => {
GlTabs,
GlTab,
},
mocks: {
$route: {
params: {
id: routeId,
},
},
},
});
}
@ -172,6 +183,15 @@ describe('PackagesApp', () => {
});
});
it('calls the appropriate function to set the breadcrumbState', async () => {
const { name, version } = packageData();
createComponent();
await waitForPromises();
expect(breadCrumbState.updateName).toHaveBeenCalledWith(`${name} v ${version}`);
});
describe('delete package', () => {
const originalReferrer = document.referrer;
const setReferrer = (value = packageDetailsQuery().data.package.project.name) => {

View File

@ -24,16 +24,20 @@ exports[`Registry Breadcrumb when is not rootRoute renders 1`] = `
class="gl-breadcrumb-separator"
data-testid="separator"
>
<svg
aria-hidden="true"
class="gl-icon s8"
data-testid="angle-right-icon"
role="img"
<span
class="gl-mx-n5"
>
<use
href="#angle-right"
/>
</svg>
<svg
aria-hidden="true"
class="gl-icon s8"
data-testid="angle-right-icon"
role="img"
>
<use
href="#angle-right"
/>
</svg>
</span>
</span>
</a>
</li>

View File

@ -1,11 +1,14 @@
import VueApollo from 'vue-apollo';
import { GlIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { escape } from 'lodash';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import { sprintf } from '~/locale';
import ValidationSegment, {
i18n,
} from '~/pipeline_editor/components/header/validation_segment.vue';
import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql';
import {
CI_CONFIG_STATUS_INVALID,
EDITOR_APP_STATUS_EMPTY,
@ -21,12 +24,29 @@ import {
mockYmlHelpPagePath,
} from '../../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('Validation segment component', () => {
let wrapper;
const createComponent = ({ props = {}, appStatus }) => {
const mockApollo = createMockApollo();
const createComponent = ({ props = {}, appStatus = EDITOR_APP_STATUS_INVALID }) => {
mockApollo.clients.defaultClient.cache.writeQuery({
query: getAppStatus,
data: {
app: {
__typename: 'PipelineEditorApp',
status: appStatus,
},
},
});
wrapper = extendedWrapper(
shallowMount(ValidationSegment, {
localVue,
apolloProvider: mockApollo,
provide: {
ymlHelpPagePath: mockYmlHelpPagePath,
lintUnavailableHelpPagePath: mockLintUnavailableHelpPagePath,
@ -36,12 +56,6 @@ describe('Validation segment component', () => {
ciFileContent: mockCiYml,
...props,
},
// Simulate graphQL client query result
data() {
return {
appStatus,
};
},
}),
);
};
@ -99,6 +113,7 @@ describe('Validation segment component', () => {
appStatus: EDITOR_APP_STATUS_INVALID,
});
});
it('has warning icon', () => {
expect(findIcon().props('name')).toBe('warning-solid');
});

View File

@ -13,6 +13,7 @@ import AdminRunnersApp from '~/runner/admin_runners/admin_runners_app.vue';
import RunnerTypeTabs from '~/runner/components/runner_type_tabs.vue';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
@ -37,7 +38,6 @@ import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered
import { runnersData, runnersCountData, runnersDataPaginated } from '../mock_data';
const mockRegistrationToken = 'MOCK_REGISTRATION_TOKEN';
const mockActiveRunnersCount = '2';
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
@ -54,6 +54,7 @@ describe('AdminRunnersApp', () => {
let mockRunnersQuery;
let mockRunnersCountQuery;
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList);
@ -70,15 +71,16 @@ describe('AdminRunnersApp', () => {
[getRunnersCountQuery, mockRunnersCountQuery],
];
wrapper = mountFn(AdminRunnersApp, {
localVue,
apolloProvider: createMockApollo(handlers),
propsData: {
registrationToken: mockRegistrationToken,
activeRunnersCount: mockActiveRunnersCount,
...props,
},
});
wrapper = extendedWrapper(
mountFn(AdminRunnersApp, {
localVue,
apolloProvider: createMockApollo(handlers),
propsData: {
registrationToken: mockRegistrationToken,
...props,
},
}),
);
};
beforeEach(async () => {
@ -95,6 +97,18 @@ describe('AdminRunnersApp', () => {
wrapper.destroy();
});
it('shows total runner counts', async () => {
createComponent({ mountFn: mount });
await waitForPromises();
const stats = findRunnerStats().text();
expect(stats).toMatch('Online runners 4');
expect(stats).toMatch('Offline runners 4');
expect(stats).toMatch('Stale runners 4');
});
it('shows the runner tabs with a runner count for each type', async () => {
mockRunnersCountQuery.mockImplementation(({ type }) => {
let count;
@ -198,12 +212,6 @@ describe('AdminRunnersApp', () => {
]);
});
it('shows the active runner count', () => {
createComponent({ mountFn: mount });
expect(wrapper.text()).toMatch(new RegExp(`Online Runners ${mockActiveRunnersCount}`));
});
describe('when a filter is preselected', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}&tag[]=tag1`);

View File

@ -1,4 +1,4 @@
import { GlDropdownItem, GlLoadingIcon, GlToast } from '@gitlab/ui';
import { GlDropdownItem, GlLoadingIcon, GlToast, GlModal } from '@gitlab/ui';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@ -9,6 +9,7 @@ import RegistrationTokenResetDropdownItem from '~/runner/components/registration
import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
import { captureException } from '~/runner/sentry_utils';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
jest.mock('~/flash');
jest.mock('~/runner/sentry_utils');
@ -18,14 +19,18 @@ localVue.use(VueApollo);
localVue.use(GlToast);
const mockNewToken = 'NEW_TOKEN';
const modalID = 'token-reset-modal';
describe('RegistrationTokenResetDropdownItem', () => {
let wrapper;
let runnersRegistrationTokenResetMutationHandler;
let showToast;
const mockEvent = { preventDefault: jest.fn() };
const findDropdownItem = () => wrapper.findComponent(GlDropdownItem);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findModal = () => wrapper.findComponent(GlModal);
const clickSubmit = () => findModal().vm.$emit('primary', mockEvent);
const createComponent = ({ props, provide = {} } = {}) => {
wrapper = shallowMount(RegistrationTokenResetDropdownItem, {
@ -38,6 +43,9 @@ describe('RegistrationTokenResetDropdownItem', () => {
apolloProvider: createMockApollo([
[runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler],
]),
directives: {
GlModal: createMockDirective(),
},
});
showToast = wrapper.vm.$toast ? jest.spyOn(wrapper.vm.$toast, 'show') : null;
@ -54,8 +62,6 @@ describe('RegistrationTokenResetDropdownItem', () => {
});
createComponent();
jest.spyOn(window, 'confirm');
});
afterEach(() => {
@ -66,6 +72,18 @@ describe('RegistrationTokenResetDropdownItem', () => {
expect(findDropdownItem().exists()).toBe(true);
});
describe('modal directive integration', () => {
it('has the correct ID on the dropdown', () => {
const binding = getBinding(findDropdownItem().element, 'gl-modal');
expect(binding.value).toBe(modalID);
});
it('has the correct ID on the modal', () => {
expect(findModal().props('modalId')).toBe(modalID);
});
});
describe('On click and confirmation', () => {
const mockGroupId = '11';
const mockProjectId = '22';
@ -82,9 +100,8 @@ describe('RegistrationTokenResetDropdownItem', () => {
props: { type },
});
window.confirm.mockReturnValueOnce(true);
findDropdownItem().trigger('click');
clickSubmit();
await waitForPromises();
});
@ -114,7 +131,6 @@ describe('RegistrationTokenResetDropdownItem', () => {
describe('On click without confirmation', () => {
beforeEach(async () => {
window.confirm.mockReturnValueOnce(false);
findDropdownItem().vm.$emit('click');
await waitForPromises();
});
@ -142,8 +158,8 @@ describe('RegistrationTokenResetDropdownItem', () => {
runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(new Error(mockErrorMsg));
window.confirm.mockReturnValueOnce(true);
findDropdownItem().trigger('click');
clickSubmit();
await waitForPromises();
expect(createAlert).toHaveBeenLastCalledWith({
@ -168,8 +184,8 @@ describe('RegistrationTokenResetDropdownItem', () => {
},
});
window.confirm.mockReturnValueOnce(true);
findDropdownItem().trigger('click');
clickSubmit();
await waitForPromises();
expect(createAlert).toHaveBeenLastCalledWith({
@ -184,8 +200,8 @@ describe('RegistrationTokenResetDropdownItem', () => {
describe('Immediately after click', () => {
it('shows loading state', async () => {
window.confirm.mockReturnValue(true);
findDropdownItem().trigger('click');
clickSubmit();
await nextTick();
expect(findLoadingIcon().exists()).toBe(true);

View File

@ -1,34 +0,0 @@
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount, mount } from '@vue/test-utils';
import RunnerOnlineBadge from '~/runner/components/stat/runner_online_stat.vue';
describe('RunnerOnlineBadge', () => {
let wrapper;
const findSingleStat = () => wrapper.findComponent(GlSingleStat);
const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
wrapper = mountFn(RunnerOnlineBadge, {
propsData: {
value: '99',
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('Uses a success appearance', () => {
createComponent({}, shallowMount);
expect(findSingleStat().props('variant')).toBe('success');
});
it('Renders a value', () => {
createComponent({}, mount);
expect(wrapper.text()).toMatch(new RegExp(`Online Runners 99\\s+online`));
});
});

View File

@ -0,0 +1,46 @@
import { shallowMount, mount } from '@vue/test-utils';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
import RunnerStatusStat from '~/runner/components/stat/runner_status_stat.vue';
import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants';
describe('RunnerStats', () => {
let wrapper;
const findRunnerStatusStatAt = (i) => wrapper.findAllComponents(RunnerStatusStat).at(i);
const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
wrapper = mountFn(RunnerStats, {
propsData: {
onlineRunnersCount: 3,
offlineRunnersCount: 2,
staleRunnersCount: 1,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('Displays all the stats', () => {
createComponent({ mountFn: mount });
const stats = wrapper.text();
expect(stats).toMatch('Online runners 3');
expect(stats).toMatch('Offline runners 2');
expect(stats).toMatch('Stale runners 1');
});
it.each`
i | status
${0} | ${STATUS_ONLINE}
${1} | ${STATUS_OFFLINE}
${2} | ${STATUS_STALE}
`('Displays status types at index $i', ({ i, status }) => {
createComponent();
expect(findRunnerStatusStatAt(i).props('status')).toBe(status);
});
});

View File

@ -0,0 +1,67 @@
import { GlSingleStat } from '@gitlab/ui/dist/charts';
import { shallowMount, mount } from '@vue/test-utils';
import RunnerStatusStat from '~/runner/components/stat/runner_status_stat.vue';
import { STATUS_ONLINE, STATUS_OFFLINE, STATUS_STALE } from '~/runner/constants';
describe('RunnerStatusStat', () => {
let wrapper;
const findSingleStat = () => wrapper.findComponent(GlSingleStat);
const createComponent = ({ props = {} } = {}, mountFn = shallowMount) => {
wrapper = mountFn(RunnerStatusStat, {
propsData: {
status: STATUS_ONLINE,
value: 99,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
});
describe.each`
status | variant | title | badge
${STATUS_ONLINE} | ${'success'} | ${'Online runners'} | ${'online'}
${STATUS_OFFLINE} | ${'muted'} | ${'Offline runners'} | ${'offline'}
${STATUS_STALE} | ${'warning'} | ${'Stale runners'} | ${'stale'}
`('Renders a stat for status "$status"', ({ status, variant, title, badge }) => {
beforeEach(() => {
createComponent({ props: { status } }, mount);
});
it('Renders text', () => {
expect(wrapper.text()).toMatch(new RegExp(`${title} 99\\s+${badge}`));
});
it(`Uses variant ${variant}`, () => {
expect(findSingleStat().props('variant')).toBe(variant);
});
});
it('Formats stat number', () => {
createComponent({ props: { value: 1000 } }, mount);
expect(wrapper.text()).toMatch('Online runners 1,000');
});
it('Shows a null result', () => {
createComponent({ props: { value: null } }, mount);
expect(wrapper.text()).toMatch('Online runners -');
});
it('Shows an undefined result', () => {
createComponent({ props: { value: undefined } }, mount);
expect(wrapper.text()).toMatch('Online runners -');
});
it('Shows result for an unknown status', () => {
createComponent({ props: { status: 'UNKNOWN' } }, mount);
expect(wrapper.text()).toMatch('Runners 99');
});
});

View File

@ -12,6 +12,7 @@ import { updateHistory } from '~/lib/utils/url_utility';
import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_bar.vue';
import RunnerList from '~/runner/components/runner_list.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue';
@ -26,10 +27,11 @@ import {
RUNNER_PAGE_SIZE,
} from '~/runner/constants';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import getGroupRunnersCountQuery from '~/runner/graphql/get_group_runners_count.query.graphql';
import GroupRunnersApp from '~/runner/group_runners/group_runners_app.vue';
import { captureException } from '~/runner/sentry_utils';
import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import { groupRunnersData, groupRunnersDataPaginated } from '../mock_data';
import { groupRunnersData, groupRunnersDataPaginated, groupRunnersCountData } from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
@ -48,7 +50,9 @@ jest.mock('~/lib/utils/url_utility', () => ({
describe('GroupRunnersApp', () => {
let wrapper;
let mockGroupRunnersQuery;
let mockGroupRunnersCountQuery;
const findRunnerStats = () => wrapper.findComponent(RunnerStats);
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
@ -59,7 +63,10 @@ describe('GroupRunnersApp', () => {
const findFilteredSearch = () => wrapper.findComponent(FilteredSearch);
const createComponent = ({ props = {}, mountFn = shallowMount } = {}) => {
const handlers = [[getGroupRunnersQuery, mockGroupRunnersQuery]];
const handlers = [
[getGroupRunnersQuery, mockGroupRunnersQuery],
[getGroupRunnersCountQuery, mockGroupRunnersCountQuery],
];
wrapper = mountFn(GroupRunnersApp, {
localVue,
@ -77,11 +84,24 @@ describe('GroupRunnersApp', () => {
setWindowLocation(`/groups/${mockGroupFullPath}/-/runners`);
mockGroupRunnersQuery = jest.fn().mockResolvedValue(groupRunnersData);
mockGroupRunnersCountQuery = jest.fn().mockResolvedValue(groupRunnersCountData);
createComponent();
await waitForPromises();
});
it('shows total runner counts', async () => {
createComponent({ mountFn: mount });
await waitForPromises();
const stats = findRunnerStats().text();
expect(stats).toMatch('Online runners 2');
expect(stats).toMatch('Offline runners 2');
expect(stats).toMatch('Stale runners 2');
});
it('shows the runner setup instructions', () => {
expect(findRegistrationDropdown().props('registrationToken')).toBe(mockRegistrationToken);
expect(findRegistrationDropdown().props('type')).toBe(GROUP_TYPE);
@ -129,28 +149,6 @@ describe('GroupRunnersApp', () => {
);
});
describe('shows the active runner count', () => {
const expectedOnlineCount = (count) => new RegExp(`Online Runners ${count}`);
it('with a regular value', () => {
createComponent({ mountFn: mount });
expect(wrapper.text()).toMatch(expectedOnlineCount(mockGroupRunnersLimitedCount));
});
it('at the limit', () => {
createComponent({ props: { groupRunnersLimitedCount: 1000 }, mountFn: mount });
expect(wrapper.text()).toMatch(expectedOnlineCount('1,000'));
});
it('over the limit', () => {
createComponent({ props: { groupRunnersLimitedCount: 1001 }, mountFn: mount });
expect(wrapper.text()).toMatch(expectedOnlineCount('1,000\\+'));
});
});
describe('when a filter is preselected', () => {
beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`);

View File

@ -8,6 +8,7 @@ import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.js
// Group queries
import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json';
import groupRunnersCountData from 'test_fixtures/graphql/runner/get_group_runners_count.query.graphql.json';
import groupRunnersDataPaginated from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.paginated.json';
export {
@ -16,5 +17,6 @@ export {
runnersDataPaginated,
runnersData,
groupRunnersData,
groupRunnersCountData,
groupRunnersDataPaginated,
};

View File

@ -5,16 +5,16 @@ export const textProviderIds = [101, 102];
export const securityTrainingProviders = [
{
id: textProviderIds[0],
name: 'Kontra',
description: 'Interactive developer security education.',
url: 'https://application.security/',
name: 'Vendor Name 1',
description: 'Interactive developer security education',
url: 'https://www.example.org/security/training',
isEnabled: false,
},
{
id: textProviderIds[1],
name: 'SecureCodeWarrior',
name: 'Vendor Name 2',
description: 'Security training with guide and learning pathways.',
url: 'https://www.securecodewarrior.com/',
url: 'https://www.vendornametwo.com/',
isEnabled: true,
},
];

View File

@ -0,0 +1,66 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Mutations::Issues::SetEscalationStatus do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:issue, reload: true) { create(:incident, project: project) }
let_it_be(:escalation_status, reload: true) { create(:incident_management_issuable_escalation_status, issue: issue) }
let(:status) { :acknowledged }
let(:mutation) { described_class.new(object: nil, context: { current_user: user }, field: nil) }
describe '#resolve' do
let(:args) { { status: status } }
let(:mutated_issue) { result[:issue] }
subject(:result) { mutation.resolve(project_path: issue.project.full_path, iid: issue.iid, **args) }
it_behaves_like 'permission level for issue mutation is correctly verified', true
context 'when the user can update the issue' do
before_all do
project.add_reporter(user)
end
it_behaves_like 'permission level for issue mutation is correctly verified', true
context 'when the user can update the escalation status' do
before_all do
project.add_developer(user)
end
it 'returns the issue with the escalation policy' do
expect(mutated_issue).to eq(issue)
expect(mutated_issue.escalation_status.status_name).to eq(status)
expect(result[:errors]).to be_empty
end
it 'returns errors when issue update fails' do
issue.update_column(:author_id, nil)
expect(result[:errors]).not_to be_empty
end
context 'with non-incident issue is provided' do
let_it_be(:issue) { create(:issue, project: project) }
it 'raises an error' do
expect { result }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature unavailable for provided issue')
end
end
context 'with feature disabled' do
before do
stub_feature_flags(incident_escalations: false)
end
it 'raises an error' do
expect { result }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature unavailable for provided issue')
end
end
end
end
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['IssueEscalationStatus'] do
specify { expect(described_class.graphql_name).to eq('IssueEscalationStatus') }
describe 'statuses' do
using RSpec::Parameterized::TableSyntax
where(:status_name, :status_value) do
'TRIGGERED' | :triggered
'ACKNOWLEDGED' | :acknowledged
'RESOLVED' | :resolved
'IGNORED' | :ignored
'INVALID' | nil
end
with_them do
it 'exposes a status with the correct value' do
expect(described_class.values[status_name]&.value).to eq(status_value)
end
end
end
end

View File

@ -18,7 +18,7 @@ RSpec.describe GitlabSchema.types['Issue'] do
confidential hidden discussion_locked upvotes downvotes merge_requests_count user_notes_count user_discussions_count web_path web_url relative_position
emails_disabled subscribed time_estimate total_time_spent human_time_estimate human_total_time_spent closed_at created_at updated_at task_completion_status
design_collection alert_management_alert severity current_user_todos moved moved_to
create_note_email timelogs project_id customer_relations_contacts]
create_note_email timelogs project_id customer_relations_contacts escalation_status]
fields.each do |field_name|
expect(described_class).to have_graphql_field(field_name)
@ -257,4 +257,49 @@ RSpec.describe GitlabSchema.types['Issue'] do
end
end
end
describe 'escalation_status' do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :public) }
let_it_be(:issue, reload: true) { create(:issue, project: project) }
let(:execute) { GitlabSchema.execute(query, context: { current_user: user }).as_json }
let(:query) do
%(
query {
project(fullPath: "#{project.full_path}") {
issue(iid: "#{issue.iid}") {
escalationStatus
}
}
}
)
end
subject(:status) { execute.dig('data', 'project', 'issue', 'escalationStatus') }
it { is_expected.to be_nil }
context 'for an incident' do
before do
issue.update!(issue_type: Issue.issue_types[:incident])
end
it { is_expected.to be_nil }
context 'with an escalation status record' do
let!(:escalation_status) { create(:incident_management_issuable_escalation_status, issue: issue) }
it { is_expected.to eq(escalation_status.status_name.to_s.upcase) }
context 'with feature disabled' do
before do
stub_feature_flags(incident_escalations: false)
end
it { is_expected.to be_nil }
end
end
end
end
end

View File

@ -79,8 +79,7 @@ RSpec.describe Ci::RunnersHelper do
it 'returns the data in format' do
expect(helper.admin_runners_data_attributes).to eq({
runner_install_help_page: 'https://docs.gitlab.com/runner/install/',
registration_token: Gitlab::CurrentSettings.runners_registration_token,
active_runners_count: '0'
registration_token: Gitlab::CurrentSettings.runners_registration_token
})
end
end

View File

@ -1,716 +0,0 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Redis::MultiStore do
using RSpec::Parameterized::TableSyntax
let_it_be(:redis_store_class) do
Class.new(Gitlab::Redis::Wrapper) do
def config_file_name
config_file_name = "spec/fixtures/config/redis_new_format_host.yml"
Rails.root.join(config_file_name).to_s
end
def self.name
'Sessions'
end
end
end
let_it_be(:primary_db) { 1 }
let_it_be(:secondary_db) { 2 }
let_it_be(:primary_store) { create_redis_store(redis_store_class.params, db: primary_db, serializer: nil) }
let_it_be(:secondary_store) { create_redis_store(redis_store_class.params, db: secondary_db, serializer: nil) }
let_it_be(:instance_name) { 'TestStore' }
let_it_be(:multi_store) { described_class.new(primary_store, secondary_store, instance_name)}
subject { multi_store.send(name, *args) }
before do
skip_feature_flags_yaml_validation
skip_default_enabled_yaml_check
end
after(:all) do
primary_store.flushdb
secondary_store.flushdb
end
context 'when primary_store is nil' do
let(:multi_store) { described_class.new(nil, secondary_store, instance_name)}
it 'fails with exception' do
expect { multi_store }.to raise_error(ArgumentError, /primary_store is required/)
end
end
context 'when secondary_store is nil' do
let(:multi_store) { described_class.new(primary_store, nil, instance_name)}
it 'fails with exception' do
expect { multi_store }.to raise_error(ArgumentError, /secondary_store is required/)
end
end
context 'when instance_name is nil' do
let(:instance_name) { nil }
let(:multi_store) { described_class.new(primary_store, secondary_store, instance_name)}
it 'fails with exception' do
expect { multi_store }.to raise_error(ArgumentError, /instance_name is required/)
end
end
context 'when primary_store is not a ::Redis instance' do
before do
allow(primary_store).to receive(:is_a?).with(::Redis).and_return(false)
end
it 'fails with exception' do
expect { described_class.new(primary_store, secondary_store, instance_name) }.to raise_error(ArgumentError, /invalid primary_store/)
end
end
context 'when secondary_store is not a ::Redis instance' do
before do
allow(secondary_store).to receive(:is_a?).with(::Redis).and_return(false)
end
it 'fails with exception' do
expect { described_class.new(primary_store, secondary_store, instance_name) }.to raise_error(ArgumentError, /invalid secondary_store/)
end
end
context 'with READ redis commands' do
let_it_be(:key1) { "redis:{1}:key_a" }
let_it_be(:key2) { "redis:{1}:key_b" }
let_it_be(:value1) { "redis_value1"}
let_it_be(:value2) { "redis_value2"}
let_it_be(:skey) { "redis:set:key" }
let_it_be(:keys) { [key1, key2] }
let_it_be(:values) { [value1, value2] }
let_it_be(:svalues) { [value2, value1] }
where(:case_name, :name, :args, :value, :block) do
'execute :get command' | :get | ref(:key1) | ref(:value1) | nil
'execute :mget command' | :mget | ref(:keys) | ref(:values) | nil
'execute :mget with block' | :mget | ref(:keys) | ref(:values) | ->(value) { value }
'execute :smembers command' | :smembers | ref(:skey) | ref(:svalues) | nil
'execute :scard command' | :scard | ref(:skey) | 2 | nil
end
before(:all) do
primary_store.multi do |multi|
multi.set(key1, value1)
multi.set(key2, value2)
multi.sadd(skey, value1)
multi.sadd(skey, value2)
end
secondary_store.multi do |multi|
multi.set(key1, value1)
multi.set(key2, value2)
multi.sadd(skey, value1)
multi.sadd(skey, value2)
end
end
RSpec.shared_examples_for 'reads correct value' do
it 'returns the correct value' do
if value.is_a?(Array)
# :smembers does not guarantee the order it will return the values (unsorted set)
is_expected.to match_array(value)
else
is_expected.to eq(value)
end
end
end
RSpec.shared_examples_for 'fallback read from the secondary store' do
let(:counter) { Gitlab::Metrics::NullMetric.instance }
before do
allow(Gitlab::Metrics).to receive(:counter).and_return(counter)
end
it 'fallback and execute on secondary instance' do
expect(secondary_store).to receive(name).with(*args).and_call_original
subject
end
it 'logs the ReadFromPrimaryError' do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(Gitlab::Redis::MultiStore::ReadFromPrimaryError),
hash_including(command_name: name, extra: hash_including(instance_name: instance_name)))
subject
end
it 'increment read fallback count metrics' do
expect(counter).to receive(:increment).with(command: name, instance_name: instance_name)
subject
end
include_examples 'reads correct value'
context 'when fallback read from the secondary instance raises an exception' do
before do
allow(secondary_store).to receive(name).with(*args).and_raise(StandardError)
end
it 'fails with exception' do
expect { subject }.to raise_error(StandardError)
end
end
end
RSpec.shared_examples_for 'secondary store' do
it 'execute on the secondary instance' do
expect(secondary_store).to receive(name).with(*args).and_call_original
subject
end
include_examples 'reads correct value'
it 'does not execute on the primary store' do
expect(primary_store).not_to receive(name)
subject
end
end
with_them do
describe "#{name}" do
before do
allow(primary_store).to receive(name).and_call_original
allow(secondary_store).to receive(name).and_call_original
end
context 'with feature flag :use_primary_and_secondary_stores_for_test_store' do
before do
stub_feature_flags(use_primary_and_secondary_stores_for_test_store: true)
end
context 'when reading from the primary is successful' do
it 'returns the correct value' do
expect(primary_store).to receive(name).with(*args).and_call_original
subject
end
it 'does not execute on the secondary store' do
expect(secondary_store).not_to receive(name)
subject
end
include_examples 'reads correct value'
end
context 'when reading from primary instance is raising an exception' do
before do
allow(primary_store).to receive(name).with(*args).and_raise(StandardError)
allow(Gitlab::ErrorTracking).to receive(:log_exception)
end
it 'logs the exception' do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError),
hash_including(extra: hash_including(:multi_store_error_message, instance_name: instance_name),
command_name: name))
subject
end
include_examples 'fallback read from the secondary store'
end
context 'when reading from primary instance return no value' do
before do
allow(primary_store).to receive(name).and_return(nil)
end
include_examples 'fallback read from the secondary store'
end
context 'when the command is executed within pipelined block' do
subject do
multi_store.pipelined do
multi_store.send(name, *args)
end
end
it 'is executed only 1 time on primary instance' do
expect(primary_store).to receive(name).with(*args).once
subject
end
end
if params[:block]
subject do
multi_store.send(name, *args, &block)
end
context 'when block is provided' do
it 'yields to the block' do
expect(primary_store).to receive(name).and_yield(value)
subject
end
include_examples 'reads correct value'
end
end
end
context 'with feature flag :use_primary_and_secondary_stores_for_test_store' do
before do
stub_feature_flags(use_primary_and_secondary_stores_for_test_store: false)
end
context 'with feature flag :use_primary_store_as_default_for_test_store is disabled' do
before do
stub_feature_flags(use_primary_store_as_default_for_test_store: false)
end
it_behaves_like 'secondary store'
end
context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do
before do
stub_feature_flags(use_primary_store_as_default_for_test_store: true)
end
it 'execute on the primary instance' do
expect(primary_store).to receive(name).with(*args).and_call_original
subject
end
include_examples 'reads correct value'
it 'does not execute on the secondary store' do
expect(secondary_store).not_to receive(name)
subject
end
end
end
context 'with both primary and secondary store using same redis instance' do
let(:primary_store) { create_redis_store(redis_store_class.params, db: primary_db, serializer: nil) }
let(:secondary_store) { create_redis_store(redis_store_class.params, db: primary_db, serializer: nil) }
let(:multi_store) { described_class.new(primary_store, secondary_store, instance_name)}
it_behaves_like 'secondary store'
end
end
end
end
context 'with WRITE redis commands' do
let_it_be(:key1) { "redis:{1}:key_a" }
let_it_be(:key2) { "redis:{1}:key_b" }
let_it_be(:value1) { "redis_value1"}
let_it_be(:value2) { "redis_value2"}
let_it_be(:key1_value1) { [key1, value1] }
let_it_be(:key1_value2) { [key1, value2] }
let_it_be(:ttl) { 10 }
let_it_be(:key1_ttl_value1) { [key1, ttl, value1] }
let_it_be(:skey) { "redis:set:key" }
let_it_be(:svalues1) { [value2, value1] }
let_it_be(:svalues2) { [value1] }
let_it_be(:skey_value1) { [skey, value1] }
let_it_be(:skey_value2) { [skey, value2] }
where(:case_name, :name, :args, :expected_value, :verification_name, :verification_args) do
'execute :set command' | :set | ref(:key1_value1) | ref(:value1) | :get | ref(:key1)
'execute :setnx command' | :setnx | ref(:key1_value2) | ref(:value1) | :get | ref(:key2)
'execute :setex command' | :setex | ref(:key1_ttl_value1) | ref(:ttl) | :ttl | ref(:key1)
'execute :sadd command' | :sadd | ref(:skey_value2) | ref(:svalues1) | :smembers | ref(:skey)
'execute :srem command' | :srem | ref(:skey_value1) | [] | :smembers | ref(:skey)
'execute :del command' | :del | ref(:key2) | nil | :get | ref(:key2)
'execute :flushdb command' | :flushdb | nil | 0 | :dbsize | nil
end
before do
primary_store.flushdb
secondary_store.flushdb
primary_store.multi do |multi|
multi.set(key2, value1)
multi.sadd(skey, value1)
end
secondary_store.multi do |multi|
multi.set(key2, value1)
multi.sadd(skey, value1)
end
end
RSpec.shared_examples_for 'verify that store contains values' do |store|
it "#{store} redis store contains correct values", :aggregate_errors do
subject
redis_store = multi_store.send(store)
if expected_value.is_a?(Array)
# :smembers does not guarantee the order it will return the values
expect(redis_store.send(verification_name, *verification_args)).to match_array(expected_value)
else
expect(redis_store.send(verification_name, *verification_args)).to eq(expected_value)
end
end
end
with_them do
describe "#{name}" do
let(:expected_args) {args || no_args }
before do
allow(primary_store).to receive(name).and_call_original
allow(secondary_store).to receive(name).and_call_original
end
context 'with feature flag :use_primary_and_secondary_stores_for_test_store' do
before do
stub_feature_flags(use_primary_and_secondary_stores_for_test_store: true)
end
context 'when executing on primary instance is successful' do
it 'executes on both primary and secondary redis store', :aggregate_errors do
expect(primary_store).to receive(name).with(*expected_args).and_call_original
expect(secondary_store).to receive(name).with(*expected_args).and_call_original
subject
end
include_examples 'verify that store contains values', :primary_store
include_examples 'verify that store contains values', :secondary_store
end
context 'when executing on the primary instance is raising an exception' do
before do
allow(primary_store).to receive(name).with(*expected_args).and_raise(StandardError)
allow(Gitlab::ErrorTracking).to receive(:log_exception)
end
it 'logs the exception and execute on secondary instance', :aggregate_errors do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(StandardError),
hash_including(extra: hash_including(:multi_store_error_message), command_name: name))
expect(secondary_store).to receive(name).with(*expected_args).and_call_original
subject
end
include_examples 'verify that store contains values', :secondary_store
end
context 'when the command is executed within pipelined block' do
subject do
multi_store.pipelined do
multi_store.send(name, *args)
end
end
it 'is executed only 1 time on each instance', :aggregate_errors do
expect(primary_store).to receive(name).with(*expected_args).once
expect(secondary_store).to receive(name).with(*expected_args).once
subject
end
include_examples 'verify that store contains values', :primary_store
include_examples 'verify that store contains values', :secondary_store
end
end
context 'with feature flag :use_primary_and_secondary_stores_for_test_store is disabled' do
before do
stub_feature_flags(use_primary_and_secondary_stores_for_test_store: false)
end
context 'with feature flag :use_primary_store_as_default_for_test_store is disabled' do
before do
stub_feature_flags(use_primary_store_as_default_for_test_store: false)
end
it 'executes only on the secondary redis store', :aggregate_errors do
expect(secondary_store).to receive(name).with(*expected_args)
expect(primary_store).not_to receive(name).with(*expected_args)
subject
end
include_examples 'verify that store contains values', :secondary_store
end
context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do
before do
stub_feature_flags(use_primary_store_as_default_for_test_store: true)
end
it 'executes only on the primary_redis redis store', :aggregate_errors do
expect(primary_store).to receive(name).with(*expected_args)
expect(secondary_store).not_to receive(name).with(*expected_args)
subject
end
include_examples 'verify that store contains values', :primary_store
end
end
end
end
end
context 'with unsupported command' do
let(:counter) { Gitlab::Metrics::NullMetric.instance }
before do
primary_store.flushdb
secondary_store.flushdb
allow(Gitlab::Metrics).to receive(:counter).and_return(counter)
end
let_it_be(:key) { "redis:counter" }
subject { multi_store.incr(key) }
it 'executes method missing' do
expect(multi_store).to receive(:method_missing)
subject
end
context 'when command is not in SKIP_LOG_METHOD_MISSING_FOR_COMMANDS' do
it 'logs MethodMissingError' do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(an_instance_of(Gitlab::Redis::MultiStore::MethodMissingError),
hash_including(command_name: :incr, extra: hash_including(instance_name: instance_name)))
subject
end
it 'increments method missing counter' do
expect(counter).to receive(:increment).with(command: :incr, instance_name: instance_name)
subject
end
end
context 'when command is in SKIP_LOG_METHOD_MISSING_FOR_COMMANDS' do
subject { multi_store.info }
it 'does not log MethodMissingError' do
expect(Gitlab::ErrorTracking).not_to receive(:log_exception)
subject
end
it 'does not increment method missing counter' do
expect(counter).not_to receive(:increment)
subject
end
end
context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do
before do
stub_feature_flags(use_primary_store_as_default_for_test_store: true)
end
it 'fallback and executes only on the secondary store', :aggregate_errors do
expect(primary_store).to receive(:incr).with(key).and_call_original
expect(secondary_store).not_to receive(:incr)
subject
end
it 'correct value is stored on the secondary store', :aggregate_errors do
subject
expect(secondary_store.get(key)).to be_nil
expect(primary_store.get(key)).to eq('1')
end
end
context 'with feature flag :use_primary_store_as_default_for_test_store is disabled' do
before do
stub_feature_flags(use_primary_store_as_default_for_test_store: false)
end
it 'fallback and executes only on the secondary store', :aggregate_errors do
expect(secondary_store).to receive(:incr).with(key).and_call_original
expect(primary_store).not_to receive(:incr)
subject
end
it 'correct value is stored on the secondary store', :aggregate_errors do
subject
expect(primary_store.get(key)).to be_nil
expect(secondary_store.get(key)).to eq('1')
end
end
context 'when the command is executed within pipelined block' do
subject do
multi_store.pipelined do
multi_store.incr(key)
end
end
it 'is executed only 1 time on each instance', :aggregate_errors do
expect(primary_store).to receive(:incr).with(key).once
expect(secondary_store).to receive(:incr).with(key).once
subject
end
it "both redis stores are containing correct values", :aggregate_errors do
subject
expect(primary_store.get(key)).to eq('1')
expect(secondary_store.get(key)).to eq('1')
end
end
end
describe '#to_s' do
subject { multi_store.to_s }
context 'with feature flag :use_primary_and_secondary_stores_for_test_store is enabled' do
before do
stub_feature_flags(use_primary_and_secondary_stores_for_test_store: true)
end
it 'returns same value as primary_store' do
is_expected.to eq(primary_store.to_s)
end
end
context 'with feature flag :use_primary_and_secondary_stores_for_test_store is disabled' do
before do
stub_feature_flags(use_primary_and_secondary_stores_for_test_store: false)
end
context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do
before do
stub_feature_flags(use_primary_store_as_default_for_test_store: true)
end
it 'returns same value as primary_store' do
is_expected.to eq(primary_store.to_s)
end
end
context 'with feature flag :use_primary_store_as_default_for_test_store is disabled' do
before do
stub_feature_flags(use_primary_store_as_default_for_test_store: false)
end
it 'returns same value as primary_store' do
is_expected.to eq(secondary_store.to_s)
end
end
end
end
describe '#is_a?' do
it 'returns true for ::Redis::Store' do
expect(multi_store.is_a?(::Redis::Store)).to be true
end
end
describe '#use_primary_and_secondary_stores?' do
context 'with feature flag :use_primary_and_secondary_stores_for_test_store is enabled' do
before do
stub_feature_flags(use_primary_and_secondary_stores_for_test_store: true)
end
it 'multi store is disabled' do
expect(multi_store.use_primary_and_secondary_stores?).to be true
end
end
context 'with feature flag :use_primary_and_secondary_stores_for_test_store is disabled' do
before do
stub_feature_flags(use_primary_and_secondary_stores_for_test_store: false)
end
it 'multi store is disabled' do
expect(multi_store.use_primary_and_secondary_stores?).to be false
end
end
context 'with empty DB' do
before do
allow(Feature::FlipperFeature).to receive(:table_exists?).and_return(false)
end
it 'multi store is disabled' do
expect(multi_store.use_primary_and_secondary_stores?).to be false
end
end
context 'when FF table guard raises' do
before do
allow(Feature::FlipperFeature).to receive(:table_exists?).and_raise
end
it 'multi store is disabled' do
expect(multi_store.use_primary_and_secondary_stores?).to be false
end
end
end
describe '#use_primary_store_as_default?' do
context 'with feature flag :use_primary_store_as_default_for_test_store is enabled' do
before do
stub_feature_flags(use_primary_store_as_default_for_test_store: true)
end
it 'multi store is disabled' do
expect(multi_store.use_primary_store_as_default?).to be true
end
end
context 'with feature flag :use_primary_store_as_default_for_test_store is disabled' do
before do
stub_feature_flags(use_primary_store_as_default_for_test_store: false)
end
it 'multi store is disabled' do
expect(multi_store.use_primary_store_as_default?).to be false
end
end
context 'with empty DB' do
before do
allow(Feature::FlipperFeature).to receive(:table_exists?).and_return(false)
end
it 'multi store is disabled' do
expect(multi_store.use_primary_and_secondary_stores?).to be false
end
end
context 'when FF table guard raises' do
before do
allow(Feature::FlipperFeature).to receive(:table_exists?).and_raise
end
it 'multi store is disabled' do
expect(multi_store.use_primary_and_secondary_stores?).to be false
end
end
end
def create_redis_store(options, extras = {})
::Redis::Store.new(options.merge(extras))
end
end

View File

@ -6,31 +6,16 @@ RSpec.describe Gitlab::Redis::Sessions do
it_behaves_like "redis_new_instance_shared_examples", 'sessions', Gitlab::Redis::SharedState
describe 'redis instance used in connection pool' do
before do
around do |example|
clear_pool
example.run
ensure
clear_pool
end
after do
clear_pool
end
context 'when redis.sessions configuration is not provided' do
it 'uses ::Redis instance' do
expect(described_class).to receive(:config_fallback?).and_return(true)
described_class.pool.with do |redis_instance|
expect(redis_instance).to be_instance_of(::Redis)
end
end
end
context 'when redis.sessions configuration is provided' do
it 'instantiates an instance of MultiStore' do
expect(described_class).to receive(:config_fallback?).and_return(false)
described_class.pool.with do |redis_instance|
expect(redis_instance).to be_instance_of(::Gitlab::Redis::MultiStore)
end
it 'uses ::Redis instance' do
described_class.pool.with do |redis_instance|
expect(redis_instance).to be_instance_of(::Redis)
end
end
@ -44,49 +29,9 @@ RSpec.describe Gitlab::Redis::Sessions do
describe '#store' do
subject(:store) { described_class.store(namespace: described_class::SESSION_NAMESPACE) }
context 'when redis.sessions configuration is NOT provided' do
it 'instantiates ::Redis instance' do
expect(described_class).to receive(:config_fallback?).and_return(true)
expect(store).to be_instance_of(::Redis::Store)
end
end
context 'when redis.sessions configuration is provided' do
let(:config_new_format_host) { "spec/fixtures/config/redis_new_format_host.yml" }
let(:config_new_format_socket) { "spec/fixtures/config/redis_new_format_socket.yml" }
before do
redis_clear_raw_config!(Gitlab::Redis::Sessions)
redis_clear_raw_config!(Gitlab::Redis::SharedState)
allow(described_class).to receive(:config_fallback?).and_return(false)
end
after do
redis_clear_raw_config!(Gitlab::Redis::Sessions)
redis_clear_raw_config!(Gitlab::Redis::SharedState)
end
# Check that Gitlab::Redis::Sessions is configured as MultiStore with proper attrs.
it 'instantiates an instance of MultiStore', :aggregate_failures do
expect(described_class).to receive(:config_file_name).and_return(config_new_format_host)
expect(::Gitlab::Redis::SharedState).to receive(:config_file_name).and_return(config_new_format_socket)
expect(store).to be_instance_of(::Gitlab::Redis::MultiStore)
expect(store.primary_store.to_s).to eq("Redis Client connected to test-host:6379 against DB 99 with namespace session:gitlab")
expect(store.secondary_store.to_s).to eq("Redis Client connected to /path/to/redis.sock against DB 0 with namespace session:gitlab")
expect(store.instance_name).to eq('Sessions')
end
context 'when MultiStore correctly configured' do
before do
allow(described_class).to receive(:config_file_name).and_return(config_new_format_host)
allow(::Gitlab::Redis::SharedState).to receive(:config_file_name).and_return(config_new_format_socket)
end
it_behaves_like 'multi store feature flags', :use_primary_and_secondary_stores_for_sessions, :use_primary_store_as_default_for_sessions
end
# Check that Gitlab::Redis::Sessions is configured as RedisStore.
it 'instantiates an instance of Redis::Store' do
expect(store).to be_instance_of(::Redis::Store)
end
end
end

View File

@ -19,7 +19,7 @@ RSpec.describe Gitlab::Usage::Metrics::Instrumentations::DatabaseMetric do
let_it_be(:issues) { Issue.all }
before do
allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
allow(Issue.connection).to receive(:transaction_open?).and_return(false)
end
it 'calculates a correct result' do

View File

@ -3,10 +3,6 @@
require 'spec_helper'
RSpec.describe Gitlab::UsageDataQueries do
before do
allow(ActiveRecord::Base.connection).to receive(:transaction_open?).and_return(false)
end
describe '#add_metric' do
let(:metric) { 'CountBoardsMetric' }

Some files were not shown because too many files have changed in this diff Show More