Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
8577a60062
commit
f133142aee
|
|
@ -52,6 +52,7 @@ docs-lint markdown:
|
|||
stage: lint
|
||||
needs: []
|
||||
script:
|
||||
- apk add libuuid
|
||||
- source ./scripts/utils.sh
|
||||
- yarn_install_script
|
||||
- install_gitlab_gem
|
||||
|
|
|
|||
|
|
@ -185,6 +185,51 @@ gitaly-transactions-selective-parallel:
|
|||
variables:
|
||||
QA_TESTS: ""
|
||||
|
||||
# ========== gitaly reftables backend ===========
|
||||
# Verifies that E2E tests that interact with the gitaly backend work when the reftables backend is in use
|
||||
# https://about.gitlab.com/blog/2024/05/30/a-beginners-guide-to-the-git-reftable-format/
|
||||
# https://gitlab.com/groups/gitlab-org/-/epics/14946
|
||||
gitaly-reftables-backend:
|
||||
extends:
|
||||
- .parallel
|
||||
- .qa
|
||||
parallel: 2
|
||||
variables:
|
||||
QA_SCENARIO: Test::Integration::Praefect
|
||||
QA_CAN_TEST_PRAEFECT: "true"
|
||||
KNAPSACK_TEST_FILE_PATTERN: "qa/specs/features/**/3_create/**/*_spec.rb"
|
||||
QA_FEATURE_FLAGS: "gitaly_new_repo_reftable_backend=enabled"
|
||||
rules:
|
||||
- !reference [.rules:test:qa-parallel, rules]
|
||||
- if: $QA_SUITES =~ /Test::Instance::All/
|
||||
|
||||
gitaly-reftables-backend-selective:
|
||||
extends: .qa
|
||||
variables:
|
||||
QA_SCENARIO: Test::Integration::Praefect
|
||||
QA_CAN_TEST_PRAEFECT: "true"
|
||||
QA_FEATURE_FLAGS: "gitaly_new_repo_reftable_backend=enabled"
|
||||
rules:
|
||||
- !reference [.rules:test:qa-selective, rules]
|
||||
- if: $QA_SUITES =~ /Test::Instance::All/
|
||||
|
||||
gitaly-reftables-enabled-backend-parallel:
|
||||
extends:
|
||||
- .qa
|
||||
- .parallel
|
||||
parallel: 2
|
||||
variables:
|
||||
QA_SCENARIO: Test::Integration::Praefect
|
||||
QA_CAN_TEST_PRAEFECT: "true"
|
||||
QA_GIT_DEFAULT_REF_FORMAT: 'reftable'
|
||||
KNAPSACK_TEST_FILE_PATTERN: "qa/specs/features/**/3_create/**/*_spec.rb"
|
||||
QA_FEATURE_FLAGS: "gitaly_new_repo_reftable_backend=enabled"
|
||||
rules:
|
||||
- !reference [.rules:test:qa-selective-parallel, rules]
|
||||
- if: $QA_SUITES =~ /Test::Instance::All/
|
||||
variables:
|
||||
QA_TESTS: ""
|
||||
|
||||
# ========== git sha256 enabled ===========
|
||||
git-sha256-repositories:
|
||||
when: manual
|
||||
|
|
|
|||
|
|
@ -1173,7 +1173,6 @@ RSpec/BeforeAllRoleAssignment:
|
|||
- 'spec/requests/api/rubygem_packages_spec.rb'
|
||||
- 'spec/requests/api/search_spec.rb'
|
||||
- 'spec/requests/api/wikis_spec.rb'
|
||||
- 'spec/requests/concerns/planning_hierarchy_spec.rb'
|
||||
- 'spec/requests/groups/deploy_tokens_controller_spec.rb'
|
||||
- 'spec/requests/groups/settings/access_tokens_controller_spec.rb'
|
||||
- 'spec/requests/groups/settings/applications_controller_spec.rb'
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ Style/InlineDisableAnnotation:
|
|||
- 'app/controllers/concerns/membership_actions.rb'
|
||||
- 'app/controllers/concerns/milestone_actions.rb'
|
||||
- 'app/controllers/concerns/notes_actions.rb'
|
||||
- 'app/controllers/concerns/planning_hierarchy.rb'
|
||||
- 'app/controllers/concerns/preferred_language_switcher.rb'
|
||||
- 'app/controllers/concerns/preview_markdown.rb'
|
||||
- 'app/controllers/concerns/registry/connection_errors_handler.rb'
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script>
|
||||
import { debounce, uniq } from 'lodash';
|
||||
import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox, GlSprintf } from '@gitlab/ui';
|
||||
import { GlDropdownDivider, GlDropdownItem, GlCollapsibleListbox } from '@gitlab/ui';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import { __, s__, sprintf } from '~/locale';
|
||||
import { convertEnvironmentScope } from './utils';
|
||||
|
|
@ -29,7 +29,6 @@ export default {
|
|||
GlCollapsibleListbox,
|
||||
GlDropdownDivider,
|
||||
GlDropdownItem,
|
||||
GlSprintf,
|
||||
},
|
||||
mixins: [glFeatureFlagsMixin()],
|
||||
props: {
|
||||
|
|
@ -112,7 +111,8 @@ export default {
|
|||
}
|
||||
|
||||
return (
|
||||
this.searchTerm && ![...this.environments, this.customEnvScope].includes(this.searchTerm)
|
||||
this.searchTerm?.includes('*') &&
|
||||
![...this.environments, this.customEnvScope].includes(this.searchTerm)
|
||||
);
|
||||
},
|
||||
shouldRenderDivider() {
|
||||
|
|
@ -145,8 +145,8 @@ export default {
|
|||
},
|
||||
ENVIRONMENT_QUERY_LIMIT,
|
||||
i18n: {
|
||||
maxEnvsNote: s__(
|
||||
'CiVariable|Maximum of %{limit} environments listed. For more environments, enter a search query.',
|
||||
searchQueryNote: s__(
|
||||
'CiVariable|Enter a search query to find more environments, or use * to create a wildcard.',
|
||||
),
|
||||
},
|
||||
};
|
||||
|
|
@ -167,15 +167,9 @@ export default {
|
|||
>
|
||||
<template #footer>
|
||||
<gl-dropdown-divider v-if="shouldRenderDivider" />
|
||||
<div data-testid="max-envs-notice">
|
||||
<gl-dropdown-item class="gl-list-none" disabled>
|
||||
<gl-sprintf :message="$options.i18n.maxEnvsNote" class="gl-text-sm">
|
||||
<template #limit>
|
||||
{{ $options.ENVIRONMENT_QUERY_LIMIT }}
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</gl-dropdown-item>
|
||||
</div>
|
||||
<gl-dropdown-item class="gl-list-none" disabled data-testid="search-query-note">
|
||||
{{ $options.i18n.searchQueryNote }}
|
||||
</gl-dropdown-item>
|
||||
<div v-if="shouldRenderCreateButton">
|
||||
<!-- TODO: Rethink create wildcard button. https://gitlab.com/gitlab-org/gitlab/-/issues/396928 -->
|
||||
<gl-dropdown-item
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
import { initWorkItemsHierarchy } from '~/work_items_hierarchy/work_items_hierarchy_bundle';
|
||||
|
||||
initWorkItemsHierarchy();
|
||||
|
|
@ -219,6 +219,7 @@ export default {
|
|||
<work-item-link-child-metadata
|
||||
:reference="displayReference"
|
||||
:iid="childItem.iid"
|
||||
:is-child-item-open="isChildItemOpen"
|
||||
:metadata-widgets="metadataWidgets"
|
||||
:show-weight="showWeight"
|
||||
:work-item-type="childItemType"
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
<script>
|
||||
import { GlBanner } from '@gitlab/ui';
|
||||
import Cookies from '~/lib/utils/cookies';
|
||||
import { parseBoolean } from '~/lib/utils/common_utils';
|
||||
import RESPONSE from '../static_response';
|
||||
import { WORK_ITEMS_SURVEY_COOKIE_NAME, workItemTypes } from '../constants';
|
||||
import Hierarchy from './hierarchy.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlBanner,
|
||||
Hierarchy,
|
||||
},
|
||||
inject: ['illustrationPath', 'licensePlan'],
|
||||
data() {
|
||||
return {
|
||||
bannerVisible: !parseBoolean(Cookies.get(WORK_ITEMS_SURVEY_COOKIE_NAME)),
|
||||
workItemHierarchy: RESPONSE[this.licensePlan],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
hasUnavailableStructure() {
|
||||
return this.workItemTypes.unavailable.length > 0;
|
||||
},
|
||||
workItemTypes() {
|
||||
return this.workItemHierarchy.reduce(
|
||||
(itemTypes, item) => {
|
||||
const skipItem = workItemTypes[item.type].isWorkItem;
|
||||
|
||||
if (skipItem) {
|
||||
return itemTypes;
|
||||
}
|
||||
const key = item.available ? 'available' : 'unavailable';
|
||||
const nestedTypes = item.nestedTypes?.map((type) => workItemTypes[type]);
|
||||
|
||||
itemTypes[key].push({
|
||||
...item,
|
||||
...workItemTypes[item.type],
|
||||
nestedTypes,
|
||||
});
|
||||
|
||||
return itemTypes;
|
||||
},
|
||||
{ available: [], unavailable: [] },
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClose() {
|
||||
Cookies.set(WORK_ITEMS_SURVEY_COOKIE_NAME, 'true', { expires: 365 * 10 });
|
||||
this.bannerVisible = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<gl-banner
|
||||
v-if="bannerVisible"
|
||||
class="gl-mt-4 !gl-px-5"
|
||||
:title="s__('Hierarchy|Help us improve work items in GitLab!')"
|
||||
:button-text="s__('Hierarchy|Take the work items survey')"
|
||||
button-link="https://forms.gle/u1BmRp8rTbwj52iq5"
|
||||
:svg-path="illustrationPath"
|
||||
@close="handleClose"
|
||||
>
|
||||
<p>
|
||||
{{
|
||||
s__(
|
||||
'Hierarchy|Is there a framework or type of work item you wish you had access to in GitLab? Give us your feedback and help us build the experiences valuable to you.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</gl-banner>
|
||||
<h3 class="!gl-mt-5">{{ s__('Hierarchy|Planning hierarchy') }}</h3>
|
||||
<p>
|
||||
{{
|
||||
s__(
|
||||
'Hierarchy|Deliver value more efficiently by breaking down necessary work into a hierarchical structure. This structure helps teams understand scope, priorities, and how work cascades up toward larger goals.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="gl-mb-2 gl-font-bold">{{ s__('Hierarchy|Current structure') }}</div>
|
||||
<p class="!gl-mb-3">{{ s__('Hierarchy|You can start using these items now.') }}</p>
|
||||
<hierarchy :work-item-types="workItemTypes.available" />
|
||||
|
||||
<div
|
||||
v-if="hasUnavailableStructure"
|
||||
data-testid="unavailable-structure"
|
||||
class="gl-mb-2 gl-mt-5 gl-font-bold"
|
||||
>
|
||||
{{ s__('Hierarchy|Unavailable structure') }}
|
||||
</div>
|
||||
<p v-if="hasUnavailableStructure" class="!gl-mb-3">
|
||||
{{ s__('Hierarchy|These items are unavailable in the current structure.') }}
|
||||
</p>
|
||||
<hierarchy :work-item-types="workItemTypes.unavailable" />
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,119 +0,0 @@
|
|||
<!-- eslint-disable vue/multi-word-component-names -->
|
||||
<script>
|
||||
import { GlIcon, GlBadge } from '@gitlab/ui';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
GlIcon,
|
||||
GlBadge,
|
||||
},
|
||||
props: {
|
||||
workItemTypes: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
isLastItem(index, workItem) {
|
||||
const hasMoreThanOneItem = workItem.nestedTypes.length > 1;
|
||||
const isLastItemInArray = index === workItem.nestedTypes.length - 1;
|
||||
|
||||
return isLastItemInArray && hasMoreThanOneItem;
|
||||
},
|
||||
nestedWorkItemTypeMargin(index, workItem) {
|
||||
const isLastItemInArray = index === workItem.nestedTypes.length - 1;
|
||||
const hasMoreThanOneItem = workItem.nestedTypes.length > 1;
|
||||
|
||||
if (isLastItemInArray && hasMoreThanOneItem) {
|
||||
return 'gl-ml-0';
|
||||
}
|
||||
|
||||
return 'gl-ml-6';
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<div>
|
||||
<div
|
||||
v-for="workItem in workItemTypes"
|
||||
:key="workItem.id"
|
||||
class="gl-mb-3"
|
||||
:class="{ flex: !workItem.available }"
|
||||
>
|
||||
<span
|
||||
class="gl-inline-flex gl-items-center gl-justify-center gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-pb-2 gl-pl-2 gl-pr-3 gl-pt-2 gl-leading-normal"
|
||||
data-testid="work-item-wrapper"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
backgroundColor: workItem.backgroundColor,
|
||||
color: workItem.color,
|
||||
}"
|
||||
class="justify-content-center hierarchy-icon-wrapper gl-mr-2 gl-inline-flex gl-items-center gl-rounded-base"
|
||||
>
|
||||
<gl-icon :size="workItem.iconSize || 12" :name="workItem.icon" />
|
||||
</span>
|
||||
|
||||
{{ workItem.title }}
|
||||
</span>
|
||||
|
||||
<gl-badge
|
||||
v-if="!workItem.available"
|
||||
variant="info"
|
||||
icon="license"
|
||||
class="gl-ml-3 gl-self-center"
|
||||
>{{ workItem.license }}</gl-badge
|
||||
>
|
||||
|
||||
<div v-if="workItem.nestedTypes" :class="{ 'gl-relative': workItem.nestedTypes.length > 1 }">
|
||||
<svg
|
||||
v-if="workItem.nestedTypes.length > 1"
|
||||
class="hierarchy-rounded-arrow-tail gl-text-gray-400"
|
||||
data-testid="hierarchy-rounded-arrow-tail"
|
||||
width="2"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line
|
||||
x1="0.75"
|
||||
y1="1"
|
||||
x2="0.75"
|
||||
y2="100%"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
</svg>
|
||||
<template v-for="(nestedWorkItem, index) in workItem.nestedTypes">
|
||||
<div :key="nestedWorkItem.id" class="gl-ml-6 gl-mt-2 gl-block">
|
||||
<gl-icon name="arrow-down" class="gl-text-gray-400" />
|
||||
</div>
|
||||
<gl-icon
|
||||
v-if="isLastItem(index, workItem)"
|
||||
:key="nestedWorkItem.id"
|
||||
name="level-up"
|
||||
class="hierarchy-rounded-arrow gl-ml-2 gl-text-gray-400"
|
||||
/>
|
||||
<span
|
||||
:key="nestedWorkItem.id"
|
||||
class="gl-mt-2 gl-inline-flex gl-items-center gl-justify-center gl-rounded-base gl-border-1 gl-border-solid gl-border-gray-100 gl-pb-2 gl-pl-2 gl-pr-3 gl-pt-2 gl-leading-normal"
|
||||
:class="nestedWorkItemTypeMargin(index, workItem)"
|
||||
>
|
||||
<span
|
||||
:style="{
|
||||
backgroundColor: nestedWorkItem.backgroundColor,
|
||||
color: nestedWorkItem.color,
|
||||
}"
|
||||
class="justify-content-center hierarchy-icon-wrapper gl-mr-2 gl-inline-flex gl-items-center gl-rounded-base"
|
||||
>
|
||||
<gl-icon :size="nestedWorkItem.iconSize || 12" :name="nestedWorkItem.icon" />
|
||||
</span>
|
||||
|
||||
{{ nestedWorkItem.title }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { __ } from '~/locale';
|
||||
|
||||
export const WORK_ITEMS_SURVEY_COOKIE_NAME = 'hide_work_items_hierarchy_survey';
|
||||
|
||||
/**
|
||||
* Hard-coded strings since we're rendering hierarchy
|
||||
* items from mock responses. Remove this when we
|
||||
* have a real hierarchy endpoint.
|
||||
*/
|
||||
export const LICENSE_PLAN = {
|
||||
FREE: 'free',
|
||||
PREMIUM: 'premium',
|
||||
ULTIMATE: 'ultimate',
|
||||
};
|
||||
|
||||
export const workItemTypes = {
|
||||
EPIC: {
|
||||
title: __('Epic'),
|
||||
icon: 'epic',
|
||||
color: '#694CC0',
|
||||
backgroundColor: '#E1D8F9',
|
||||
},
|
||||
ISSUE: {
|
||||
title: __('Issue'),
|
||||
icon: 'issues',
|
||||
color: '#1068BF',
|
||||
backgroundColor: '#CBE2F9',
|
||||
},
|
||||
TASK: {
|
||||
title: __('Task'),
|
||||
icon: 'todo-done',
|
||||
color: '#217645',
|
||||
backgroundColor: '#C3E6CD',
|
||||
isWorkItem: true,
|
||||
},
|
||||
INCIDENT: {
|
||||
title: __('Incident'),
|
||||
icon: 'issue-type-incident',
|
||||
backgroundColor: '#db2a0f',
|
||||
color: '#FDD4CD',
|
||||
iconSize: 16,
|
||||
},
|
||||
SUB_EPIC: {
|
||||
title: __('Child epic'),
|
||||
icon: 'epic',
|
||||
color: '#AB6100',
|
||||
backgroundColor: '#F5D9A8',
|
||||
},
|
||||
REQUIREMENT: {
|
||||
title: __('Requirement'),
|
||||
icon: 'requirements',
|
||||
color: '#0068c5',
|
||||
backgroundColor: '#c5e3fb',
|
||||
},
|
||||
TEST_CASE: {
|
||||
title: __('Test case'),
|
||||
icon: 'issue-type-test-case',
|
||||
backgroundColor: '#007a3f',
|
||||
color: '#bae8cb',
|
||||
iconSize: 16,
|
||||
},
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
import { LICENSE_PLAN } from './constants';
|
||||
|
||||
export function inferLicensePlan({ hasSubEpics, hasEpics }) {
|
||||
if (hasSubEpics) {
|
||||
return LICENSE_PLAN.ULTIMATE;
|
||||
}
|
||||
if (hasEpics) {
|
||||
return LICENSE_PLAN.PREMIUM;
|
||||
}
|
||||
return LICENSE_PLAN.FREE;
|
||||
}
|
||||
|
|
@ -1,142 +0,0 @@
|
|||
const FREE_TIER = 'free';
|
||||
const ULTIMATE_TIER = 'ultimate';
|
||||
const PREMIUM_TIER = 'premium';
|
||||
|
||||
const RESPONSE = {
|
||||
[FREE_TIER]: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'ISSUE',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'TASK',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'INCIDENT',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'EPIC',
|
||||
available: false,
|
||||
license: 'Premium', // eslint-disable-line @gitlab/require-i18n-strings
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'SUB_EPIC',
|
||||
available: false,
|
||||
license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'REQUIREMENT',
|
||||
available: false,
|
||||
license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
type: 'TEST_CASE',
|
||||
available: false,
|
||||
license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
|
||||
nestedTypes: null,
|
||||
},
|
||||
],
|
||||
|
||||
[PREMIUM_TIER]: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'EPIC',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: ['ISSUE'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'TASK',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'INCIDENT',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
type: 'SUB_EPIC',
|
||||
available: false,
|
||||
license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'REQUIREMENT',
|
||||
available: false,
|
||||
license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
type: 'TEST_CASE',
|
||||
available: false,
|
||||
license: 'Ultimate', // eslint-disable-line @gitlab/require-i18n-strings
|
||||
nestedTypes: null,
|
||||
},
|
||||
],
|
||||
|
||||
[ULTIMATE_TIER]: [
|
||||
{
|
||||
id: '1',
|
||||
type: 'EPIC',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: ['SUB_EPIC', 'ISSUE'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'TASK',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'INCIDENT',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'REQUIREMENT',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: null,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
type: 'TEST_CASE',
|
||||
available: true,
|
||||
license: null,
|
||||
nestedTypes: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default RESPONSE;
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
import Vue from 'vue';
|
||||
import { parseBoolean } from '~/lib/utils/common_utils';
|
||||
import App from './components/app.vue';
|
||||
import { inferLicensePlan } from './hierarchy_util';
|
||||
|
||||
export const initWorkItemsHierarchy = () => {
|
||||
const el = document.querySelector('#js-work-items-hierarchy');
|
||||
|
||||
const { illustrationPath, hasEpics, hasSubEpics } = el.dataset;
|
||||
|
||||
const licensePlan = inferLicensePlan({
|
||||
hasEpics: parseBoolean(hasEpics),
|
||||
hasSubEpics: parseBoolean(hasSubEpics),
|
||||
});
|
||||
|
||||
return new Vue({
|
||||
el,
|
||||
provide: {
|
||||
illustrationPath,
|
||||
licensePlan,
|
||||
},
|
||||
render(createElement) {
|
||||
return createElement(App);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
@import './pages/commits';
|
||||
@import './pages/events';
|
||||
@import './pages/groups';
|
||||
@import './pages/hierarchy';
|
||||
@import './pages/issues';
|
||||
@import './pages/note_form';
|
||||
@import './pages/notes';
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
.hierarchy-rounded-arrow-tail {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 5px;
|
||||
height: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.hierarchy-icon-wrapper {
|
||||
height: $default-icon-size;
|
||||
width: $default-icon-size;
|
||||
}
|
||||
|
||||
.hierarchy-rounded-arrow {
|
||||
transform: scale(1, -1) rotate(90deg);
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PlanningHierarchy
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# rubocop:disable Gitlab/ModuleWithInstanceVariables
|
||||
def planning_hierarchy
|
||||
return access_denied! unless can?(current_user, :read_planning_hierarchy, @project)
|
||||
|
||||
route_not_found
|
||||
end
|
||||
# rubocop:enable Gitlab/ModuleWithInstanceVariables
|
||||
end
|
||||
|
||||
PlanningHierarchy.prepend_mod_with('PlanningHierarchy')
|
||||
|
|
@ -9,7 +9,6 @@ class ProjectsController < Projects::ApplicationController
|
|||
include ImportUrlParams
|
||||
include FiltersEvents
|
||||
include SourcegraphDecorator
|
||||
include PlanningHierarchy
|
||||
|
||||
REFS_LIMIT = 100
|
||||
|
||||
|
|
@ -66,7 +65,6 @@ class ProjectsController < Projects::ApplicationController
|
|||
feature_category :team_planning, [:preview_markdown, :new_issuable_address]
|
||||
feature_category :importers, [:export, :remove_export, :generate_new_export, :download_export]
|
||||
feature_category :code_review_workflow, [:unfoldered_environment_names]
|
||||
feature_category :portfolio_management, [:planning_hierarchy]
|
||||
|
||||
urgency :low, [:export, :remove_export, :generate_new_export, :download_export]
|
||||
urgency :low, [:preview_markdown, :new_issuable_address]
|
||||
|
|
|
|||
|
|
@ -80,9 +80,7 @@ class SessionsController < Devise::SessionsController
|
|||
|
||||
accept_pending_invitations
|
||||
|
||||
if Feature.enabled?(:new_broadcast_message_dismissal, current_user, type: :gitlab_com_derisk)
|
||||
synchronize_broadcast_message_dismissals
|
||||
end
|
||||
synchronize_broadcast_message_dismissals
|
||||
|
||||
log_audit_event(current_user, resource, with: authentication_method)
|
||||
log_user_activity(current_user)
|
||||
|
|
|
|||
|
|
@ -362,7 +362,6 @@ class ProjectPolicy < BasePolicy
|
|||
enable :read_wiki
|
||||
enable :read_issue
|
||||
enable :read_label
|
||||
enable :read_planning_hierarchy
|
||||
enable :read_milestone
|
||||
enable :read_snippet
|
||||
enable :read_project_member
|
||||
|
|
@ -791,7 +790,6 @@ class ProjectPolicy < BasePolicy
|
|||
enable :read_issue_board_list
|
||||
enable :read_wiki
|
||||
enable :read_label
|
||||
enable :read_planning_hierarchy
|
||||
enable :read_milestone
|
||||
enable :read_snippet
|
||||
enable :read_project_member
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ module Ml
|
|||
)
|
||||
|
||||
add_metadata(model, @metadata)
|
||||
audit_creation_event(model)
|
||||
|
||||
success(model)
|
||||
end
|
||||
|
|
@ -69,5 +70,17 @@ module Ml
|
|||
def experiment_name
|
||||
Ml::Model.prefixed_experiment(@name)
|
||||
end
|
||||
|
||||
def audit_creation_event(model)
|
||||
audit_context = {
|
||||
name: 'ml_model_created',
|
||||
author: @user,
|
||||
scope: @project,
|
||||
target: model,
|
||||
message: "MlModel #{model.name} created"
|
||||
}
|
||||
|
||||
::Gitlab::Audit::Auditor.audit(audit_context)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ module Ml
|
|||
|
||||
return error unless @model.destroy
|
||||
|
||||
audit_destroy_event(@model)
|
||||
|
||||
success
|
||||
end
|
||||
|
||||
|
|
@ -37,5 +39,17 @@ module Ml
|
|||
def payload
|
||||
{ model: @model }
|
||||
end
|
||||
|
||||
def audit_destroy_event(model)
|
||||
audit_context = {
|
||||
name: 'ml_model_destroyed',
|
||||
author: @user,
|
||||
scope: model.project,
|
||||
target: model,
|
||||
message: "MlModel #{model.name} destroyed"
|
||||
}
|
||||
|
||||
::Gitlab::Audit::Auditor.audit(audit_context)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,16 +33,20 @@ module PagesDomains
|
|||
|
||||
api_order = ::Gitlab::LetsEncrypt::Client.new.load_order(acme_order.url)
|
||||
|
||||
# https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6 - statuses diagram
|
||||
case api_order.status
|
||||
when 'ready'
|
||||
api_order.request_certificate(private_key: acme_order.private_key, domain: pages_domain.domain)
|
||||
PagesDomainSslRenewalWorker.perform_in(CERTIFICATE_PROCESSING_DELAY, pages_domain.id)
|
||||
when 'valid'
|
||||
save_certificate(acme_order.private_key, api_order)
|
||||
acme_order.destroy!
|
||||
when 'invalid'
|
||||
save_order_error(acme_order, get_challenge_error(api_order))
|
||||
begin
|
||||
# https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6 - statuses diagram
|
||||
case api_order.status
|
||||
when 'ready'
|
||||
api_order.request_certificate(private_key: acme_order.private_key, domain: pages_domain.domain)
|
||||
PagesDomainSslRenewalWorker.perform_in(CERTIFICATE_PROCESSING_DELAY, pages_domain.id)
|
||||
when 'valid'
|
||||
save_certificate(acme_order.private_key, api_order)
|
||||
acme_order.destroy!
|
||||
when 'invalid'
|
||||
save_order_error(acme_order, get_challenge_error(api_order))
|
||||
end
|
||||
rescue Acme::Client::Error => e
|
||||
save_order_error(acme_order, e.message)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
- page_title _("Planning hierarchy")
|
||||
- has_sub_epics = @project&.licensed_feature_available?(:subepics)
|
||||
- has_epics = @project&.licensed_feature_available?(:epics)
|
||||
|
||||
#js-work-items-hierarchy{ data: { has_sub_epics: has_sub_epics.to_s, has_epics: has_epics.to_s, illustration_path: image_path('illustrations/rocket-launch-md.svg') } }
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
name: ml_model_created
|
||||
description: ML model is created
|
||||
introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/463215
|
||||
introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165011
|
||||
feature_category: mlops
|
||||
milestone: '17.4'
|
||||
saved_to_database: true
|
||||
scope: [Project]
|
||||
streamed: true
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
name: ml_model_destroyed
|
||||
description: ML model destroyed
|
||||
introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/463215
|
||||
introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165011
|
||||
feature_category: mlops
|
||||
milestone: '17.4'
|
||||
saved_to_database: true
|
||||
scope: [Project]
|
||||
streamed: true
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
---
|
||||
name: new_broadcast_message_dismissal
|
||||
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/438595
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151056
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/461564
|
||||
milestone: '17.1'
|
||||
type: gitlab_com_derisk
|
||||
group: group::activation
|
||||
default_enabled: false
|
||||
|
|
@ -459,8 +459,6 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
|
|||
end
|
||||
end
|
||||
|
||||
get :planning_hierarchy
|
||||
|
||||
resources :badges, only: [] do
|
||||
collection do
|
||||
constraints format: /svg/ do
|
||||
|
|
|
|||
|
|
@ -1,319 +1,11 @@
|
|||
---
|
||||
stage: Verify
|
||||
group: Pipeline Security
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
redirect_to: '../../secrets/hashicorp_vault.md'
|
||||
remove_date: '2024-12-05'
|
||||
---
|
||||
|
||||
# Authenticating and reading secrets with HashiCorp Vault
|
||||
This document was moved to [another location](../../secrets/hashicorp_vault.md).
|
||||
|
||||
DETAILS:
|
||||
**Tier:** Premium, Ultimate
|
||||
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
|
||||
|
||||
WARNING:
|
||||
Authenticating with `CI_JOB_JWT` was [deprecated in GitLab 15.9](../../../update/deprecations.md#old-versions-of-json-web-tokens-are-deprecated)
|
||||
and the token is scheduled to be removed in GitLab 17.0. Use
|
||||
[ID tokens to authenticate with HashiCorp Vault](../../secrets/id_token_authentication.md#automatic-id-token-authentication-with-hashicorp-vault)
|
||||
instead, as demonstrated on this page.
|
||||
|
||||
NOTE:
|
||||
Starting in Vault 1.17, [JWT auth login requires bound audiences on the role](https://developer.hashicorp.com/vault/docs/upgrading/upgrade-to-1.17.x#jwt-auth-login-requires-bound-audiences-on-the-role)
|
||||
when the JWT contains an `aud` claim. The `aud` claim can be a single string or a list of strings.
|
||||
|
||||
This tutorial demonstrates how to authenticate, configure, and read secrets with HashiCorp's Vault from GitLab CI/CD.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This tutorial assumes you are familiar with GitLab CI/CD and Vault.
|
||||
|
||||
To follow along, you must have:
|
||||
|
||||
- An account on GitLab.
|
||||
- Access to a running Vault server (at least v1.2.0) to configure authentication and to create roles and policies. For HashiCorp Vaults, this can be the Open Source or Enterprise version.
|
||||
|
||||
NOTE:
|
||||
You must replace the `vault.example.com` URL below with the URL of your Vault server, and `gitlab.example.com` with the URL of your GitLab instance.
|
||||
|
||||
## How it works
|
||||
|
||||
ID tokens are JSON Web Tokens (JWTs) used for OIDC authentication with third-party services. If a job has at least one ID token defined, the `secrets` keyword automatically uses that token to authenticate with Vault.
|
||||
|
||||
The following fields are included in the JWT:
|
||||
|
||||
| Field | When | Description |
|
||||
|-------------------------|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `jti` | Always | Unique identifier for this token |
|
||||
| `iss` | Always | Issuer, the domain of your GitLab instance |
|
||||
| `iat` | Always | Issued at |
|
||||
| `nbf` | Always | Not valid before |
|
||||
| `exp` | Always | Expires at |
|
||||
| `sub` | Always | Subject (job ID) |
|
||||
| `namespace_id` | Always | Use this to scope to group or user level namespace by ID |
|
||||
| `namespace_path` | Always | Use this to scope to group or user level namespace by path |
|
||||
| `project_id` | Always | Use this to scope to project by ID |
|
||||
| `project_path` | Always | Use this to scope to project by path |
|
||||
| `user_id` | Always | ID of the user executing the job |
|
||||
| `user_login` | Always | Username of the user executing the job |
|
||||
| `user_email` | Always | Email of the user executing the job |
|
||||
| `pipeline_id` | Always | ID of this pipeline |
|
||||
| `pipeline_source` | Always | [Pipeline source](../../jobs/job_rules.md#common-if-clauses-with-predefined-variables) |
|
||||
| `job_id` | Always | ID of this job |
|
||||
| `ref` | Always | Git ref for this job |
|
||||
| `ref_type` | Always | Git ref type, either `branch` or `tag` |
|
||||
| `ref_path` | Always | Fully qualified ref for the job. For example, `refs/heads/main`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/119075) in GitLab 16.0. |
|
||||
| `ref_protected` | Always | `true` if this Git ref is protected, `false` otherwise |
|
||||
| `environment` | Job specifies an environment | Environment this job specifies |
|
||||
| `groups_direct` | User is a direct member of 0 to 200 groups | The paths of the user's direct membership groups. Omitted if the user is a direct member of more than 200 groups. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/435848) in GitLab 16.11). |
|
||||
| `environment_protected` | Job specifies an environment | `true` if specified environment is protected, `false` otherwise |
|
||||
| `deployment_tier` | Job specifies an environment | [Deployment tier](../../environments/index.md#deployment-tier-of-environments) of environment this job specifies ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/363590) in GitLab 15.2) |
|
||||
| `environment_action` | Job specifies an environment | [Environment action (`environment:action`)](../../environments/index.md) specified in the job. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/) in GitLab 16.5) |
|
||||
|
||||
Example JWT payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"jti": "c82eeb0c-5c6f-4a33-abf5-4c474b92b558",
|
||||
"iss": "gitlab.example.com",
|
||||
"iat": 1585710286,
|
||||
"nbf": 1585798372,
|
||||
"exp": 1585713886,
|
||||
"sub": "job_1212",
|
||||
"namespace_id": "1",
|
||||
"namespace_path": "mygroup",
|
||||
"project_id": "22",
|
||||
"project_path": "mygroup/myproject",
|
||||
"user_id": "42",
|
||||
"user_login": "myuser",
|
||||
"user_email": "myuser@example.com",
|
||||
"pipeline_id": "1212",
|
||||
"pipeline_source": "web",
|
||||
"job_id": "1212",
|
||||
"ref": "auto-deploy-2020-04-01",
|
||||
"ref_type": "branch",
|
||||
"ref_path": "refs/heads/auto-deploy-2020-04-01",
|
||||
"ref_protected": "true",
|
||||
"groups_direct": ["mygroup/mysubgroup", "myothergroup/myothersubgroup"],
|
||||
"environment": "production",
|
||||
"environment_protected": "true",
|
||||
"environment_action": "start"
|
||||
}
|
||||
```
|
||||
|
||||
The JWT is encoded by using RS256 and signed with a dedicated private key. The expire time for the token is set to job's timeout, if specified, or 5 minutes if it is not. The key used to sign this token may change without any notice. In such case retrying the job generates new JWT using the current signing key.
|
||||
|
||||
You can use this JWT for authentication with a Vault server that is configured to allow
|
||||
the JWT authentication method. Provide your GitLab instance's base URL
|
||||
(for example `https://gitlab.example.com`) to your Vault server as the `oidc_discovery_url`.
|
||||
The server can then retrieve the keys for validating the token from your instance.
|
||||
|
||||
When configuring roles in Vault, you can use [bound claims](https://developer.hashicorp.com/vault/docs/auth/jwt#bound-claims) to match against the JWT claims and restrict which secrets each CI/CD job has access to.
|
||||
|
||||
To communicate with Vault, you can use either its CLI client or perform API requests (using `curl` or another client).
|
||||
|
||||
## Example
|
||||
|
||||
WARNING:
|
||||
JWTs are credentials, which can grant access to resources. Be careful where you paste them!
|
||||
|
||||
Let's say you have the passwords for your staging and production databases stored in a Vault server that is running on `http://vault.example.com:8200`. Your staging password is `pa$$w0rd` and your production password is `real-pa$$w0rd`.
|
||||
|
||||
```shell
|
||||
$ vault kv get -field=password secret/myproject/staging/db
|
||||
pa$$w0rd
|
||||
|
||||
$ vault kv get -field=password secret/myproject/production/db
|
||||
real-pa$$w0rd
|
||||
```
|
||||
|
||||
To configure your Vault server, start by enabling the [JWT Auth](https://developer.hashicorp.com/vault/docs/auth/jwt) method:
|
||||
|
||||
```shell
|
||||
$ vault auth enable jwt
|
||||
Success! Enabled jwt auth method at: jwt/
|
||||
```
|
||||
|
||||
Then create policies that allow you to read these secrets (one for each secret):
|
||||
|
||||
```shell
|
||||
$ vault policy write myproject-staging - <<EOF
|
||||
# Policy name: myproject-staging
|
||||
#
|
||||
# Read-only permission on 'secret/myproject/staging/*' path
|
||||
path "secret/myproject/staging/*" {
|
||||
capabilities = [ "read" ]
|
||||
}
|
||||
EOF
|
||||
Success! Uploaded policy: myproject-staging
|
||||
|
||||
$ vault policy write myproject-production - <<EOF
|
||||
# Policy name: myproject-production
|
||||
#
|
||||
# Read-only permission on 'secret/myproject/production/*' path
|
||||
path "secret/myproject/production/*" {
|
||||
capabilities = [ "read" ]
|
||||
}
|
||||
EOF
|
||||
Success! Uploaded policy: myproject-production
|
||||
```
|
||||
|
||||
You also need roles that link the JWT with these policies.
|
||||
|
||||
One for staging named `myproject-staging`:
|
||||
|
||||
```shell
|
||||
$ vault write auth/jwt/role/myproject-staging - <<EOF
|
||||
{
|
||||
"role_type": "jwt",
|
||||
"policies": ["myproject-staging"],
|
||||
"token_explicit_max_ttl": 60,
|
||||
"user_claim": "user_email",
|
||||
"bound_audiences": "https://vault.example.com",
|
||||
"bound_claims": {
|
||||
"project_id": "22",
|
||||
"ref": "master",
|
||||
"ref_type": "branch"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
And one for production named `myproject-production`:
|
||||
|
||||
```shell
|
||||
$ vault write auth/jwt/role/myproject-production - <<EOF
|
||||
{
|
||||
"role_type": "jwt",
|
||||
"policies": ["myproject-production"],
|
||||
"token_explicit_max_ttl": 60,
|
||||
"user_claim": "user_email",
|
||||
"bound_audiences": "https://vault.example.com",
|
||||
"bound_claims_type": "glob",
|
||||
"bound_claims": {
|
||||
"project_id": "22",
|
||||
"ref_protected": "true",
|
||||
"ref_type": "branch",
|
||||
"ref": "auto-deploy-*"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
This example uses [bound claims](https://developer.hashicorp.com/vault/api-docs/auth/jwt#bound_claims) to specify that only a JWT with matching values for the specified claims is allowed to authenticate.
|
||||
|
||||
Combined with [protected branches](../../../user/project/protected_branches.md), you can restrict who is able to authenticate and read the secrets.
|
||||
|
||||
Any of the claims [included in the JWT](#how-it-works) can be matched against a list of values
|
||||
in the bound claims. For example:
|
||||
|
||||
```json
|
||||
"bound_claims": {
|
||||
"user_login": ["alice", "bob", "mallory"]
|
||||
}
|
||||
|
||||
"bound_claims": {
|
||||
"ref": ["main", "develop", "test"]
|
||||
}
|
||||
|
||||
"bound_claims": {
|
||||
"namespace_id": ["10", "20", "30"]
|
||||
}
|
||||
|
||||
"bound_claims": {
|
||||
"project_id": ["12", "22", "37"]
|
||||
}
|
||||
```
|
||||
|
||||
- If only `namespace_id` is used, all projects in the namespace are allowed. Nested projects are not included, so their namespace IDs must also be added to the list if needed.
|
||||
- If both `namespace_id` and `project_id` are used, Vault first checks if the project's namespace is in `namespace_id` then checks if the project is in `project_id`.
|
||||
|
||||
[`token_explicit_max_ttl`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#token_explicit_max_ttl) specifies that the token issued by Vault, upon successful authentication, has a hard lifetime limit of 60 seconds.
|
||||
|
||||
[`user_claim`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#user_claim) specifies the name for the Identity alias created by Vault upon a successful login.
|
||||
|
||||
[`bound_claims_type`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#bound_claims_type) configures the interpretation of the `bound_claims` values. If set to `glob`, the values are interpreted as globs, with `*` matching any number of characters.
|
||||
|
||||
The claim fields listed in [the table above](#how-it-works) can also be accessed for [Vault's policy path templating](https://developer.hashicorp.com/vault/tutorials/policies/policy-templating?in=vault%2Fpolicies) purposes by using the accessor name of the JWT auth within Vault. The [mount accessor name](https://developer.hashicorp.com/vault/tutorials/auth-methods/identity#step-1-create-an-entity-with-alias) (`ACCESSOR_NAME` in the example below) can be retrieved by running `vault auth list`.
|
||||
|
||||
Policy template example making use of a named metadata field named `project_path`:
|
||||
|
||||
```plaintext
|
||||
path "secret/data/{{identity.entity.aliases.ACCESSOR_NAME.metadata.project_path}}/staging/*" {
|
||||
capabilities = [ "read" ]
|
||||
}
|
||||
```
|
||||
|
||||
Role example to support the templated policy above, mapping the claim field `project_path` as a metadata field through use of [`claim_mappings`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#claim_mappings) configuration:
|
||||
|
||||
```plaintext
|
||||
{
|
||||
"role_type": "jwt",
|
||||
...
|
||||
"claim_mappings": {
|
||||
"project_path": "project_path"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For the full list of options, see Vault's [Create Role documentation](https://developer.hashicorp.com/vault/api-docs/auth/jwt#create-role).
|
||||
|
||||
WARNING:
|
||||
Always restrict your roles to project or namespace by using one of the provided claims (for example, `project_id` or `namespace_id`). Otherwise any JWT generated by this instance may be allowed to authenticate using this role.
|
||||
|
||||
Now, configure the JWT Authentication method:
|
||||
|
||||
```shell
|
||||
$ vault write auth/jwt/config \
|
||||
oidc_discovery_url="https://gitlab.example.com" \
|
||||
bound_issuer="https://gitlab.example.com"
|
||||
```
|
||||
|
||||
[`bound_issuer`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#bound_issuer) specifies that only a JWT with the issuer (that is, the `iss` claim) set to `gitlab.example.com` can use this method to authenticate, and that the `oidc_discovery_url` (`https://gitlab.example.com`) should be used to validate the token.
|
||||
|
||||
For the full list of available configuration options, see Vault's [API documentation](https://developer.hashicorp.com/vault/api-docs/auth/jwt#configure).
|
||||
|
||||
In GitLab, create the following [CI/CD variables](../../variables/index.md#for-a-project) to provide details about your Vault server:
|
||||
|
||||
- `VAULT_SERVER_URL` - The URL of your Vault server, for example `https://vault.example.com:8200`.
|
||||
- `VAULT_AUTH_ROLE` - Optional. The role to use when attempting to authenticate. If no role is specified, Vault uses the [default role](https://developer.hashicorp.com/vault/api-docs/auth/jwt#default_role) specified when the authentication method was configured.
|
||||
- `VAULT_AUTH_PATH` - Optional. The path where the authentication method is mounted. Default is `jwt`.
|
||||
- `VAULT_NAMESPACE` - Optional. The [Vault Enterprise namespace](https://developer.hashicorp.com/vault/docs/enterprise/namespaces) to use for reading secrets and authentication. If no namespace is specified, Vault uses the root (`/`) namespace. The setting is ignored by Vault Open Source.
|
||||
|
||||
The following job, when run for the default branch, can read secrets under `secret/myproject/staging/`, but not the secrets under `secret/myproject/production/`:
|
||||
|
||||
```yaml
|
||||
job_with_secrets:
|
||||
id_tokens:
|
||||
VAULT_ID_TOKEN:
|
||||
aud: https://vault.example.com
|
||||
secrets:
|
||||
STAGING_DB_PASSWORD:
|
||||
vault: secret/myproject/staging/db/password@secrets # authenticates using $VAULT_ID_TOKEN
|
||||
script:
|
||||
- access-staging-db.sh --token $STAGING_DB_PASSWORD
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- `id_tokens` - The JSON Web Token (JWT) used for OIDC authentication. The `aud` claim
|
||||
is set to match the `bound_audiences` parameter of the Vault JWT authentication method.
|
||||
- `@secrets` - The vault name, where your Secrets Engines are enabled.
|
||||
- `secret/myproject/staging/db` - The path location of the secret in Vault.
|
||||
- `password` The field to be fetched within the referenced secret.
|
||||
|
||||
### Limit token access to Vault secrets
|
||||
|
||||
You can control ID token access to Vault secrets by using Vault protections
|
||||
and GitLab features. For example, restrict the token by:
|
||||
|
||||
- Using Vault [bound audiences](https://developer.hashicorp.com/vault/docs/auth/jwt#bound-audiences)
|
||||
for specific ID token `aud` claims.
|
||||
- Using Vault [bound claims](https://developer.hashicorp.com/vault/docs/auth/jwt#bound-claims)
|
||||
for specific groups using `group_claim`.
|
||||
- Hard coding values for Vault bound claims based on the `user_login` and `user_email`
|
||||
of specific users.
|
||||
- Setting Vault time limits for TTL of the token as specified in [`token_explicit_max_ttl`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#token_explicit_max_ttl),
|
||||
where the token expires after authentication.
|
||||
- Scoping the JWT to [GitLab protected branches](../../../user/project/protected_branches.md)
|
||||
that are restricted to a subset of project users.
|
||||
- Scoping the JWT to [GitLab protected tags](../../../user/project/protected_tags.md),
|
||||
that are restricted to a subset of project users.
|
||||
<!-- This redirect file can be deleted after <2024-12-05>. -->
|
||||
<!-- Redirects that point to other docs in the same project expire in three months. -->
|
||||
<!-- Redirects that point to docs in a different project or site (for example, link is not relative and starts with `https:`) expire in one year. -->
|
||||
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,355 @@
|
|||
---
|
||||
stage: Verify
|
||||
group: Pipeline Security
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
---
|
||||
|
||||
# Use HashiCorp Vault secrets in GitLab CI/CD
|
||||
|
||||
DETAILS:
|
||||
**Tier:** Premium, Ultimate
|
||||
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
|
||||
|
||||
WARNING:
|
||||
Authenticating with `CI_JOB_JWT` was [deprecated in GitLab 15.9](../../update/deprecations.md#old-versions-of-json-web-tokens-are-deprecated)
|
||||
and the token is scheduled to be removed in GitLab 18.0. Use
|
||||
[ID tokens to authenticate with HashiCorp Vault](id_token_authentication.md#automatic-id-token-authentication-with-hashicorp-vault)
|
||||
instead, as demonstrated on this page.
|
||||
|
||||
NOTE:
|
||||
Starting in Vault 1.17, [JWT auth login requires bound audiences on the role](https://developer.hashicorp.com/vault/docs/upgrading/upgrade-to-1.17.x#jwt-auth-login-requires-bound-audiences-on-the-role)
|
||||
when the JWT contains an `aud` claim. The `aud` claim can be a single string or a list of strings.
|
||||
|
||||
This tutorial demonstrates how to authenticate, configure, and read secrets with HashiCorp's Vault from GitLab CI/CD.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
This tutorial assumes you are familiar with GitLab CI/CD and Vault.
|
||||
|
||||
To follow along, you must have:
|
||||
|
||||
- An account on GitLab.
|
||||
- Access to a running Vault server (at least v1.2.0) to configure authentication and to create roles and policies.
|
||||
For HashiCorp Vaults, this can be the Open Source or Enterprise version.
|
||||
|
||||
NOTE:
|
||||
You must replace the `vault.example.com` URL below with the URL of your Vault server,
|
||||
and `gitlab.example.com` with the URL of your GitLab instance.
|
||||
|
||||
## How it works
|
||||
|
||||
ID tokens are JSON Web Tokens (JWTs) used for OIDC authentication with third-party services.
|
||||
If a job has at least one ID token defined, the `secrets` keyword automatically uses that token
|
||||
to authenticate with Vault.
|
||||
|
||||
The following fields are included in the JWT:
|
||||
|
||||
| Field | When | Description |
|
||||
|-------------------------|--------------------------------------------|-------------|
|
||||
| `jti` | Always | Unique identifier for this token |
|
||||
| `iss` | Always | Issuer, the domain of your GitLab instance |
|
||||
| `iat` | Always | Issued at |
|
||||
| `nbf` | Always | Not valid before |
|
||||
| `exp` | Always | Expires at |
|
||||
| `sub` | Always | Subject (job ID) |
|
||||
| `namespace_id` | Always | Use this to scope to group or user level namespace by ID |
|
||||
| `namespace_path` | Always | Use this to scope to group or user level namespace by path |
|
||||
| `project_id` | Always | Use this to scope to project by ID |
|
||||
| `project_path` | Always | Use this to scope to project by path |
|
||||
| `user_id` | Always | ID of the user executing the job |
|
||||
| `user_login` | Always | Username of the user executing the job |
|
||||
| `user_email` | Always | Email of the user executing the job |
|
||||
| `pipeline_id` | Always | ID of this pipeline |
|
||||
| `pipeline_source` | Always | [Pipeline source](../jobs/job_rules.md#common-if-clauses-with-predefined-variables) |
|
||||
| `job_id` | Always | ID of this job |
|
||||
| `ref` | Always | Git ref for this job |
|
||||
| `ref_type` | Always | Git ref type, either `branch` or `tag` |
|
||||
| `ref_path` | Always | Fully qualified ref for the job. For example, `refs/heads/main`. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/119075) in GitLab 16.0. |
|
||||
| `ref_protected` | Always | `true` if this Git ref is protected, `false` otherwise |
|
||||
| `environment` | Job specifies an environment | Environment this job specifies |
|
||||
| `groups_direct` | User is a direct member of 0 to 200 groups | The paths of the user's direct membership groups. Omitted if the user is a direct member of more than 200 groups. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/435848) in GitLab 16.11). |
|
||||
| `environment_protected` | Job specifies an environment | `true` if specified environment is protected, `false` otherwise |
|
||||
| `deployment_tier` | Job specifies an environment | [Deployment tier](../environments/index.md#deployment-tier-of-environments) of environment this job specifies ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/363590) in GitLab 15.2) |
|
||||
| `environment_action` | Job specifies an environment | [Environment action (`environment:action`)](../environments/index.md) specified in the job. ([Introduced](https://gitlab.com/gitlab-org/gitlab/-/) in GitLab 16.5) |
|
||||
|
||||
Example JWT payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"jti": "c82eeb0c-5c6f-4a33-abf5-4c474b92b558",
|
||||
"iss": "gitlab.example.com",
|
||||
"iat": 1585710286,
|
||||
"nbf": 1585798372,
|
||||
"exp": 1585713886,
|
||||
"sub": "job_1212",
|
||||
"namespace_id": "1",
|
||||
"namespace_path": "mygroup",
|
||||
"project_id": "22",
|
||||
"project_path": "mygroup/myproject",
|
||||
"user_id": "42",
|
||||
"user_login": "myuser",
|
||||
"user_email": "myuser@example.com",
|
||||
"pipeline_id": "1212",
|
||||
"pipeline_source": "web",
|
||||
"job_id": "1212",
|
||||
"ref": "auto-deploy-2020-04-01",
|
||||
"ref_type": "branch",
|
||||
"ref_path": "refs/heads/auto-deploy-2020-04-01",
|
||||
"ref_protected": "true",
|
||||
"groups_direct": ["mygroup/mysubgroup", "myothergroup/myothersubgroup"],
|
||||
"environment": "production",
|
||||
"environment_protected": "true",
|
||||
"environment_action": "start"
|
||||
}
|
||||
```
|
||||
|
||||
The JWT is encoded by using RS256 and signed with a dedicated private key. The expire time
|
||||
for the token is set to job's timeout, if specified, or 5 minutes if it is not.
|
||||
The key used to sign this token may change without any notice. In such case retrying the job
|
||||
generates new JWT using the current signing key.
|
||||
|
||||
You can use this JWT for authentication with a Vault server that is configured to allow
|
||||
the JWT authentication method. Provide your GitLab instance's base URL
|
||||
(for example `https://gitlab.example.com`) to your Vault server as the `oidc_discovery_url`.
|
||||
The server can then retrieve the keys for validating the token from your instance.
|
||||
|
||||
When configuring roles in Vault, you can use [bound claims](https://developer.hashicorp.com/vault/docs/auth/jwt#bound-claims)
|
||||
to match against the JWT claims and restrict which secrets each CI/CD job has access to.
|
||||
|
||||
To communicate with Vault, you can use either its CLI client or perform API requests (using `curl` or another client).
|
||||
|
||||
## Example
|
||||
|
||||
WARNING:
|
||||
JWTs are credentials, which can grant access to resources. Be careful where you paste them!
|
||||
|
||||
Let's say you have the passwords for your staging and production databases stored in a Vault server
|
||||
that is running on `http://vault.example.com:8200`. Your staging password is `pa$$w0rd`
|
||||
and your production password is `real-pa$$w0rd`.
|
||||
|
||||
```shell
|
||||
$ vault kv get -field=password secret/myproject/staging/db
|
||||
pa$$w0rd
|
||||
|
||||
$ vault kv get -field=password secret/myproject/production/db
|
||||
real-pa$$w0rd
|
||||
```
|
||||
|
||||
To configure your Vault server, start by enabling the [JWT Auth](https://developer.hashicorp.com/vault/docs/auth/jwt) method:
|
||||
|
||||
```shell
|
||||
$ vault auth enable jwt
|
||||
Success! Enabled jwt auth method at: jwt/
|
||||
```
|
||||
|
||||
Then create policies that allow you to read these secrets (one for each secret):
|
||||
|
||||
```shell
|
||||
$ vault policy write myproject-staging - <<EOF
|
||||
# Policy name: myproject-staging
|
||||
#
|
||||
# Read-only permission on 'secret/myproject/staging/*' path
|
||||
path "secret/myproject/staging/*" {
|
||||
capabilities = [ "read" ]
|
||||
}
|
||||
EOF
|
||||
Success! Uploaded policy: myproject-staging
|
||||
|
||||
$ vault policy write myproject-production - <<EOF
|
||||
# Policy name: myproject-production
|
||||
#
|
||||
# Read-only permission on 'secret/myproject/production/*' path
|
||||
path "secret/myproject/production/*" {
|
||||
capabilities = [ "read" ]
|
||||
}
|
||||
EOF
|
||||
Success! Uploaded policy: myproject-production
|
||||
```
|
||||
|
||||
You also need roles that link the JWT with these policies.
|
||||
|
||||
One for staging named `myproject-staging`:
|
||||
|
||||
```shell
|
||||
$ vault write auth/jwt/role/myproject-staging - <<EOF
|
||||
{
|
||||
"role_type": "jwt",
|
||||
"policies": ["myproject-staging"],
|
||||
"token_explicit_max_ttl": 60,
|
||||
"user_claim": "user_email",
|
||||
"bound_audiences": "https://vault.example.com",
|
||||
"bound_claims": {
|
||||
"project_id": "22",
|
||||
"ref": "master",
|
||||
"ref_type": "branch"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
And one for production named `myproject-production`:
|
||||
|
||||
```shell
|
||||
$ vault write auth/jwt/role/myproject-production - <<EOF
|
||||
{
|
||||
"role_type": "jwt",
|
||||
"policies": ["myproject-production"],
|
||||
"token_explicit_max_ttl": 60,
|
||||
"user_claim": "user_email",
|
||||
"bound_audiences": "https://vault.example.com",
|
||||
"bound_claims_type": "glob",
|
||||
"bound_claims": {
|
||||
"project_id": "22",
|
||||
"ref_protected": "true",
|
||||
"ref_type": "branch",
|
||||
"ref": "auto-deploy-*"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
This example uses [bound claims](https://developer.hashicorp.com/vault/api-docs/auth/jwt#bound_claims)
|
||||
to specify that only a JWT with matching values for the specified claims is allowed to authenticate.
|
||||
|
||||
Combined with [protected branches](../../user/project/protected_branches.md),
|
||||
you can restrict who is able to authenticate and read the secrets.
|
||||
|
||||
Any of the claims [included in the JWT](#how-it-works) can be matched against a list of values
|
||||
in the bound claims. For example:
|
||||
|
||||
```json
|
||||
"bound_claims": {
|
||||
"user_login": ["alice", "bob", "mallory"]
|
||||
}
|
||||
|
||||
"bound_claims": {
|
||||
"ref": ["main", "develop", "test"]
|
||||
}
|
||||
|
||||
"bound_claims": {
|
||||
"namespace_id": ["10", "20", "30"]
|
||||
}
|
||||
|
||||
"bound_claims": {
|
||||
"project_id": ["12", "22", "37"]
|
||||
}
|
||||
```
|
||||
|
||||
- If only `namespace_id` is used, all projects in the namespace are allowed. Nested projects are not included,
|
||||
so their namespace IDs must also be added to the list if needed.
|
||||
- If both `namespace_id` and `project_id` are used, Vault first checks if the project's namespace
|
||||
is in `namespace_id` then checks if the project is in `project_id`.
|
||||
|
||||
[`token_explicit_max_ttl`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#token_explicit_max_ttl)
|
||||
specifies that the token issued by Vault, upon successful authentication, has a hard lifetime limit of 60 seconds.
|
||||
|
||||
[`user_claim`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#user_claim)
|
||||
specifies the name for the Identity alias created by Vault upon a successful login.
|
||||
|
||||
[`bound_claims_type`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#bound_claims_type)
|
||||
configures the interpretation of the `bound_claims` values. If set to `glob`, the values are interpreted as globs,
|
||||
with `*` matching any number of characters.
|
||||
|
||||
The claim fields listed in [the table above](#how-it-works) can also be accessed for
|
||||
[Vault's policy path templating](https://developer.hashicorp.com/vault/tutorials/policies/policy-templating?in=vault%2Fpolicies)
|
||||
purposes by using the accessor name of the JWT auth in Vault.
|
||||
The [mount accessor name](https://developer.hashicorp.com/vault/tutorials/auth-methods/identity#step-1-create-an-entity-with-alias)
|
||||
(`ACCESSOR_NAME` in the example below) can be retrieved by running `vault auth list`.
|
||||
|
||||
Policy template example making use of a named metadata field named `project_path`:
|
||||
|
||||
```plaintext
|
||||
path "secret/data/{{identity.entity.aliases.ACCESSOR_NAME.metadata.project_path}}/staging/*" {
|
||||
capabilities = [ "read" ]
|
||||
}
|
||||
```
|
||||
|
||||
Role example to support the templated policy above, mapping the claim field `project_path`
|
||||
as a metadata field through use of [`claim_mappings`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#claim_mappings)
|
||||
configuration:
|
||||
|
||||
```plaintext
|
||||
{
|
||||
"role_type": "jwt",
|
||||
...
|
||||
"claim_mappings": {
|
||||
"project_path": "project_path"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For the full list of options, see Vault's [Create Role documentation](https://developer.hashicorp.com/vault/api-docs/auth/jwt#create-role).
|
||||
|
||||
WARNING:
|
||||
Always restrict your roles to project or namespace by using one of the provided claims
|
||||
(for example, `project_id` or `namespace_id`). Otherwise any JWT generated by this instance
|
||||
may be allowed to authenticate using this role.
|
||||
|
||||
Now, configure the JWT Authentication method:
|
||||
|
||||
```shell
|
||||
$ vault write auth/jwt/config \
|
||||
oidc_discovery_url="https://gitlab.example.com" \
|
||||
bound_issuer="https://gitlab.example.com"
|
||||
```
|
||||
|
||||
[`bound_issuer`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#bound_issuer)
|
||||
specifies that only a JWT with the issuer (that is, the `iss` claim) set to `gitlab.example.com`
|
||||
can use this method to authenticate, and that the `oidc_discovery_url` (`https://gitlab.example.com`)
|
||||
should be used to validate the token.
|
||||
|
||||
For the full list of available configuration options, see Vault's [API documentation](https://developer.hashicorp.com/vault/api-docs/auth/jwt#configure).
|
||||
|
||||
In GitLab, create the following [CI/CD variables](../variables/index.md#for-a-project)
|
||||
to provide details about your Vault server:
|
||||
|
||||
- `VAULT_SERVER_URL` - The URL of your Vault server, for example `https://vault.example.com:8200`.
|
||||
- `VAULT_AUTH_ROLE` - Optional. The role to use when attempting to authenticate. If no role is specified,
|
||||
Vault uses the [default role](https://developer.hashicorp.com/vault/api-docs/auth/jwt#default_role)
|
||||
specified when the authentication method was configured.
|
||||
- `VAULT_AUTH_PATH` - Optional. The path where the authentication method is mounted.
|
||||
Default is `jwt`.
|
||||
- `VAULT_NAMESPACE` - Optional. The [Vault Enterprise namespace](https://developer.hashicorp.com/vault/docs/enterprise/namespaces)
|
||||
to use for reading secrets and authentication. If no namespace is specified, Vault uses the root (`/`) namespace.
|
||||
The setting is ignored by Vault Open Source.
|
||||
|
||||
The following job, when run for the default branch, can read secrets under `secret/myproject/staging/`,
|
||||
but not the secrets under `secret/myproject/production/`:
|
||||
|
||||
```yaml
|
||||
job_with_secrets:
|
||||
id_tokens:
|
||||
VAULT_ID_TOKEN:
|
||||
aud: https://vault.example.com
|
||||
secrets:
|
||||
STAGING_DB_PASSWORD:
|
||||
vault: secret/myproject/staging/db/password@secrets # authenticates using $VAULT_ID_TOKEN
|
||||
script:
|
||||
- access-staging-db.sh --token $STAGING_DB_PASSWORD
|
||||
```
|
||||
|
||||
In this example:
|
||||
|
||||
- `id_tokens` - The JSON Web Token (JWT) used for OIDC authentication. The `aud` claim
|
||||
is set to match the `bound_audiences` parameter of the Vault JWT authentication method.
|
||||
- `@secrets` - The vault name, where your Secrets Engines are enabled.
|
||||
- `secret/myproject/staging/db` - The path location of the secret in Vault.
|
||||
- `password` The field to be fetched in the referenced secret.
|
||||
|
||||
### Limit token access to Vault secrets
|
||||
|
||||
You can control ID token access to Vault secrets by using Vault protections
|
||||
and GitLab features. For example, restrict the token by:
|
||||
|
||||
- Using Vault [bound audiences](https://developer.hashicorp.com/vault/docs/auth/jwt#bound-audiences)
|
||||
for specific ID token `aud` claims.
|
||||
- Using Vault [bound claims](https://developer.hashicorp.com/vault/docs/auth/jwt#bound-claims)
|
||||
for specific groups using `group_claim`.
|
||||
- Hard coding values for Vault bound claims based on the `user_login` and `user_email`
|
||||
of specific users.
|
||||
- Setting Vault time limits for TTL of the token as specified in [`token_explicit_max_ttl`](https://developer.hashicorp.com/vault/api-docs/auth/jwt#token_explicit_max_ttl),
|
||||
where the token expires after authentication.
|
||||
- Scoping the JWT to [GitLab protected branches](../../user/project/protected_branches.md)
|
||||
that are restricted to a subset of project users.
|
||||
- Scoping the JWT to [GitLab protected tags](../../user/project/protected_tags.md),
|
||||
that are restricted to a subset of project users.
|
||||
|
|
@ -29,7 +29,7 @@ first supported provider, and [KV-V2](https://developer.hashicorp.com/vault/docs
|
|||
as the first supported secrets engine.
|
||||
|
||||
Use [ID tokens](../yaml/index.md#id_tokens) to [authenticate with Vault](https://developer.hashicorp.com/vault/docs/auth/jwt#jwt-authentication).
|
||||
The [Authenticating and Reading Secrets With HashiCorp Vault](../examples/authenticating-with-hashicorp-vault/index.md)
|
||||
The [Authenticating and Reading Secrets With HashiCorp Vault](hashicorp_vault.md)
|
||||
tutorial has more details about authenticating with ID tokens.
|
||||
|
||||
You must [configure your Vault server](#configure-your-vault-server) before you
|
||||
|
|
@ -49,7 +49,7 @@ is summarized by this diagram:
|
|||
1. Runner reads secrets from the HashiCorp Vault.
|
||||
|
||||
NOTE:
|
||||
Read the [Authenticating and Reading Secrets With HashiCorp Vault](../examples/authenticating-with-hashicorp-vault/index.md)
|
||||
Read the [Authenticating and Reading Secrets With HashiCorp Vault](hashicorp_vault.md)
|
||||
tutorial for a version of this feature. It's available to all
|
||||
subscription levels, supports writing secrets to and deleting secrets from Vault,
|
||||
and supports multiple secrets engines.
|
||||
|
|
@ -236,8 +236,8 @@ claims like `project_id` or `namespace_id`. Without these restrictions, any JWT
|
|||
generated by this GitLab instance may be allowed to authenticate using this role.
|
||||
|
||||
For a full list of ID token JWT claims, read the
|
||||
[How It Works](../examples/authenticating-with-hashicorp-vault/index.md#how-it-works) section of the
|
||||
[Authenticating and Reading Secrets With HashiCorp Vault](../examples/authenticating-with-hashicorp-vault/index.md) tutorial.
|
||||
[How It Works](hashicorp_vault.md) section of the
|
||||
[Authenticating and Reading Secrets With HashiCorp Vault](hashicorp_vault.md) tutorial.
|
||||
|
||||
You can also specify some attributes for the resulting Vault tokens, such as time-to-live,
|
||||
IP address range, and number of uses. The full list of options is available in
|
||||
|
|
|
|||
|
|
@ -506,7 +506,7 @@ about the renewal.
|
|||
renew for any other reason, the email tells you to contact our Sales team or
|
||||
[manually renew in the Customers Portal](#renew-subscription-manually).
|
||||
- If there are no issues, the email specifies the:
|
||||
- Names and quantity of the products being renewed
|
||||
- Names and quantity of the products being renewed.
|
||||
- Total amount you owe. If your usage increases before renewal, this amount will change.
|
||||
|
||||
#### Enable or disable automatic subscription renewal
|
||||
|
|
|
|||
|
|
@ -29,6 +29,5 @@ with [Scaled Agile Framework (SAFe)](https://handbook.gitlab.com/handbook/market
|
|||
| [**Issues**](../user/project/issues/index.md)<br>Tasks, bug reports, feature requests, tracking. | [**Issue boards**](../user/project/issue_board.md)<br>Visualization, workflow, Kanban, prioritization. | [**Comments and threads**](../user/discussions/index.md)<br> Mentions, locked discussions, internal notes, thread resolution. |
|
||||
| [**Tasks**](../user/tasks.md)<br>Task labels, confidential tasks, linked items, task weights. | [**Requirements**](../user/project/requirements/index.md)<br>Acceptance criteria, requirements test reports, CSV import. | [**Time tracking**](../user/project/time_tracking.md)<br>Estimates, time spent, reporting. |
|
||||
| [**CRM**](../user/crm/index.md)<br>Customer management, organizations, contacts, permissions. | [**Wikis**](../user/project/wiki/index.md)<br>Documentation, external wikis, wiki events, history. | [**Epics**](../user/group/epics/index.md)<br>Roadmaps, hierarchies, planning, issue progress. |
|
||||
| [**Roadmaps**](../user/group/roadmap/index.md)<br>Epic progress, timelines, milestones, goals. | [**Planning hierarchies**](../user/group/planning_hierarchy/index.md)<br>Organization, structure, multi-level epics, nesting. | [**Objectives and key results**](../user/okrs.md)<br>Goal setting, performance tracking, child objectives, health status. |
|
||||
| [**Roadmaps**](../user/group/roadmap/index.md)<br>Epic progress, timelines, milestones, goals. | [**Objectives and key results**](../user/okrs.md)<br>Goal setting, performance tracking, child objectives, health status. | [**To-Do List**](../user/todos.md)<br>Task management, actions, access changes. |
|
||||
| [**Keyboard shortcuts**](../user/shortcuts.md)<br>Global shortcuts, navigation, quick access. | [**Quick actions**](../user/project/quick_actions.md)<br>Commands, shortcuts, inline actions. | [**Markdown**](../user/markdown.md)<br>Formatting, inline HTML, GitLab-specific references, diagrams and flowcharts. |
|
||||
| [**To-Do List**](../user/todos.md)<br>Task management, actions, access changes. | | |
|
||||
|
|
|
|||
|
|
@ -65,7 +65,11 @@ Note the following:
|
|||
|
||||
### Job naming best practice
|
||||
|
||||
> - Naming conflict handling was introduced in GitLab 17.4 with a flag named `pipeline_execution_policy_suffix`. Disabled by default.
|
||||
> - Naming conflict handling [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/473189) in GitLab 17.4 with a flag named `pipeline_execution_policy_suffix`. Disabled by default.
|
||||
|
||||
FLAG:
|
||||
The availability of job naming conflict handling is controlled by a feature flag.
|
||||
For more information, see the history.
|
||||
|
||||
There is no visible indicator for jobs coming from a security policy. Adding a unique prefix or suffix to job names makes it easier to identify them and avoid job name collisions.
|
||||
|
||||
|
|
|
|||
|
|
@ -379,6 +379,8 @@ Audit event types belong to the following product categories.
|
|||
| Name | Description | Saved to database | Streamed | Introduced in | Scope |
|
||||
|:------------|:------------|:------------------|:---------|:--------------|:--------------|
|
||||
| [`project_feature_model_experiments_access_level_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121027) | Model experiments access level was updated | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.1](https://gitlab.com/gitlab-org/gitlab/-/issues/412384) | Project |
|
||||
| [`ml_model_created`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165011) | ML model is created | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.4](https://gitlab.com/gitlab-org/gitlab/-/issues/463215) | Project |
|
||||
| [`ml_model_destroyed`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/165011) | ML model destroyed | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [17.4](https://gitlab.com/gitlab-org/gitlab/-/issues/463215) | Project |
|
||||
| [`project_feature_model_registry_access_level_updated`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/138399) | Model registry access level was updated | **{check-circle}** Yes | **{check-circle}** Yes | GitLab [16.7](https://gitlab.com/gitlab-org/gitlab/-/issues/412734) | Project |
|
||||
|
||||
### Not categorized
|
||||
|
|
|
|||
|
|
@ -64,8 +64,6 @@ accDescr: How issues and child epics relate to parent epics
|
|||
Child_epic --> Issue2
|
||||
```
|
||||
|
||||
Also, read more about possible [planning hierarchies](../planning_hierarchy/index.md).
|
||||
|
||||
### Child issues from different group hierarchies
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/371081) in GitLab 15.5 [with a flag](../../../administration/feature_flags.md) named `epic_issues_from_different_hierarchies`. Disabled by default.
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 4.9 KiB |
|
|
@ -1,78 +1,11 @@
|
|||
---
|
||||
stage: Plan
|
||||
group: Product Planning
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
|
||||
redirect_to: '../../../topics/plan_and_track.md'
|
||||
remove_date: '2024-11-26'
|
||||
---
|
||||
|
||||
# Planning hierarchies
|
||||
This document was moved to [another location](../../../topics/plan_and_track.md).
|
||||
|
||||
DETAILS:
|
||||
**Tier:** Premium, Ultimate
|
||||
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
|
||||
|
||||
Planning hierarchies are an integral part of breaking down your work in GitLab.
|
||||
To understand how you can use epics and issues together in hierarchies, remember the following:
|
||||
|
||||
- [Epics](../epics/index.md) exist in groups.
|
||||
- [Issues](../../project/issues/index.md) exist in projects.
|
||||
|
||||
GitLab is not opinionated on how you structure your work and the hierarchy you can build with multi-level
|
||||
epics. For example, you can use the hierarchy as a folder of issues for bigger initiatives.
|
||||
|
||||
To learn about hierarchies in general, common frameworks, and using GitLab for
|
||||
portfolio management, see
|
||||
[How to use GitLab for Agile portfolio planning and project management](https://about.gitlab.com/blog/2020/11/11/gitlab-for-agile-portfolio-planning-project-management/).
|
||||
|
||||
## Hierarchies with epics
|
||||
|
||||
With epics, you can achieve the following hierarchy:
|
||||
|
||||
```mermaid
|
||||
%%{init: { "fontFamily": "GitLab Sans" }}%%
|
||||
graph TD
|
||||
accTitle: Hierarchies with epics
|
||||
accDescr: Use epics to link projects, issues, and groups
|
||||
Group_epic --> Project1_Issue1
|
||||
Group_epic --> Project1_Issue2
|
||||
Group_epic --> Project2_Issue1
|
||||
```
|
||||
|
||||
### Hierarchies with multi-level epics
|
||||
|
||||
DETAILS:
|
||||
**Tier:** Ultimate
|
||||
**Offering:** GitLab.com, Self-managed, GitLab Dedicated
|
||||
|
||||
With the addition of [multi-level epics](../epics/manage_epics.md#multi-level-child-epics) and up to
|
||||
seven levels of nested epics, you can achieve the following hierarchy:
|
||||
|
||||
<!--
|
||||
Image below was generated with the following Mermaid code.
|
||||
Attached as an image because a rendered diagram doesn't look clear on the docs page.
|
||||
|
||||
```mermaid
|
||||
%%{init: { "fontFamily": "GitLab Sans" }}%%
|
||||
classDiagram
|
||||
direction TD
|
||||
class Epic
|
||||
class Issue
|
||||
|
||||
Epic *-- "0..7" Epic
|
||||
Epic "1"*-- "0..*" Issue
|
||||
```
|
||||
|
||||
-->
|
||||
|
||||

|
||||
|
||||
## View ancestry of an issue
|
||||
|
||||
In an issue, you can view the parented epic above the issue in the right sidebar under **Epic**.
|
||||
|
||||

|
||||
|
||||
## View ancestry of an epic
|
||||
|
||||
In an epic, you can view the ancestors as parents in the right sidebar under **Ancestors**.
|
||||
|
||||

|
||||
<!-- This redirect file can be deleted after <2024-11-26>. -->
|
||||
<!-- Redirects that point to other docs in the same project expire in three months. -->
|
||||
<!-- Redirects that point to docs in a different project or site (link is not relative and starts with `https:`) expire in one year. -->
|
||||
<!-- Before deletion, see: https://docs.gitlab.com/ee/development/documentation/redirects.html -->
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ module Gitlab
|
|||
}.freeze
|
||||
|
||||
DOC_TYPE_ONLY_SORT = {
|
||||
popularity_asc: %w[issue],
|
||||
popularity_desc: %w[issue]
|
||||
popularity_asc: %w[issue work_item],
|
||||
popularity_desc: %w[issue work_item]
|
||||
}.freeze
|
||||
|
||||
def sort_and_direction(order_by, sort)
|
||||
|
|
|
|||
|
|
@ -11408,9 +11408,6 @@ msgstr ""
|
|||
msgid "Child"
|
||||
msgstr ""
|
||||
|
||||
msgid "Child epic"
|
||||
msgstr ""
|
||||
|
||||
msgid "Child issues and epics"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -11935,10 +11932,10 @@ msgstr ""
|
|||
msgid "CiVariable|Define a CI/CD variable in the UI"
|
||||
msgstr ""
|
||||
|
||||
msgid "CiVariable|GitLab CI/CD supports OpenID Connect (OIDC) to give your build and deployment jobs access to cloud credentials and services. %{linkStart}How do I configure OIDC for my cloud provider?%{linkEnd}"
|
||||
msgid "CiVariable|Enter a search query to find more environments, or use * to create a wildcard."
|
||||
msgstr ""
|
||||
|
||||
msgid "CiVariable|Maximum of %{limit} environments listed. For more environments, enter a search query."
|
||||
msgid "CiVariable|GitLab CI/CD supports OpenID Connect (OIDC) to give your build and deployment jobs access to cloud credentials and services. %{linkStart}How do I configure OIDC for my cloud provider?%{linkEnd}"
|
||||
msgstr ""
|
||||
|
||||
msgid "CiVariable|New environment"
|
||||
|
|
@ -20937,6 +20934,9 @@ msgstr ""
|
|||
msgid "Enter a number from 0 to 100."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter a search query to find more branches, or use * to create a wildcard."
|
||||
msgstr ""
|
||||
|
||||
msgid "Enter a valid URL"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -27075,39 +27075,12 @@ msgstr ""
|
|||
msgid "Hide values"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|Current structure"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|Deliver value more efficiently by breaking down necessary work into a hierarchical structure. This structure helps teams understand scope, priorities, and how work cascades up toward larger goals."
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|Help us improve work items in GitLab!"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|Is there a framework or type of work item you wish you had access to in GitLab? Give us your feedback and help us build the experiences valuable to you."
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|Planning hierarchy"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|Something went wrong while fetching ancestors."
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|Something went wrong while fetching children."
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|Take the work items survey"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|These items are unavailable in the current structure."
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|Unavailable structure"
|
||||
msgstr ""
|
||||
|
||||
msgid "Hierarchy|You can start using these items now."
|
||||
msgstr ""
|
||||
|
||||
msgid "High - S2"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -32908,9 +32881,6 @@ msgstr ""
|
|||
msgid "Maximum number of variables loaded (2000)"
|
||||
msgstr ""
|
||||
|
||||
msgid "Maximum of %{limit} branches listed. For more branches, enter a search query."
|
||||
msgstr ""
|
||||
|
||||
msgid "Maximum of 255 characters"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -40557,9 +40527,6 @@ msgstr ""
|
|||
msgid "Plan:"
|
||||
msgstr ""
|
||||
|
||||
msgid "Planning hierarchy"
|
||||
msgstr ""
|
||||
|
||||
msgid "PlantUML"
|
||||
msgstr ""
|
||||
|
||||
|
|
@ -49582,6 +49549,9 @@ msgstr ""
|
|||
msgid "SecurityReports|%{count} Selected"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityReports|%{count}+"
|
||||
msgstr ""
|
||||
|
||||
msgid "SecurityReports|%{count}+ projects"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
|||
|
|
@ -276,8 +276,8 @@
|
|||
"custom-jquery-matchers": "^2.1.0",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-import-resolver-jest": "3.0.2",
|
||||
"eslint-import-resolver-webpack": "0.13.8",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-import-resolver-webpack": "0.13.9",
|
||||
"eslint-plugin-import": "^2.30.0",
|
||||
"eslint-plugin-local-rules": "^3.0.2",
|
||||
"eslint-plugin-no-jquery": "2.7.0",
|
||||
"eslint-plugin-no-unsanitized": "^4.1.0",
|
||||
|
|
|
|||
|
|
@ -246,56 +246,31 @@ RSpec.describe SessionsController, feature_category: :system_access do
|
|||
create(:broadcast_message_dismissal, broadcast_message: other_message, user: build(:user))
|
||||
end
|
||||
|
||||
context 'when new_broadcast_message_dismissal feature flag is not enabled' do
|
||||
before do
|
||||
stub_feature_flags(new_broadcast_message_dismissal: false)
|
||||
end
|
||||
it 'creates dismissed cookies based on db records' do
|
||||
expect(cookies["hide_broadcast_message_#{message_banner.id}"]).to be_nil
|
||||
expect(cookies["hide_broadcast_message_#{message_notification.id}"]).to be_nil
|
||||
expect(cookies["hide_broadcast_message_#{other_message.id}"]).to be_nil
|
||||
|
||||
it 'does not create dismissed cookies based on db records' do
|
||||
expect(cookies["hide_broadcast_message_#{message_banner.id}"]).to be_nil
|
||||
expect(cookies["hide_broadcast_message_#{message_notification.id}"]).to be_nil
|
||||
expect(cookies["hide_broadcast_message_#{other_message.id}"]).to be_nil
|
||||
post_action
|
||||
|
||||
post_action
|
||||
|
||||
expect(cookies["hide_broadcast_message_#{message_banner.id}"]).to be_nil
|
||||
expect(cookies["hide_broadcast_message_#{message_notification.id}"]).to be_nil
|
||||
expect(cookies["hide_broadcast_message_#{other_message.id}"]).to be_nil
|
||||
end
|
||||
expect(cookies["hide_broadcast_message_#{message_banner.id}"]).to be(true)
|
||||
expect(cookies["hide_broadcast_message_#{message_notification.id}"]).to be(true)
|
||||
expect(cookies["hide_broadcast_message_#{other_message.id}"]).to be_nil
|
||||
end
|
||||
|
||||
context 'when new_broadcast_message_dismissal feature flag is enabled' do
|
||||
context 'when dismissal is expired' do
|
||||
let_it_be(:message) { create(:broadcast_message, broadcast_type: :banner, message: 'banner') }
|
||||
|
||||
before do
|
||||
allow(Gitlab::AppLogger).to receive(:info).and_call_original
|
||||
stub_feature_flags(new_broadcast_message_dismissal: true)
|
||||
create(:broadcast_message_dismissal, :expired, broadcast_message: message, user: user)
|
||||
end
|
||||
|
||||
it 'creates dismissed cookies based on db records' do
|
||||
expect(cookies["hide_broadcast_message_#{message_banner.id}"]).to be_nil
|
||||
expect(cookies["hide_broadcast_message_#{message_notification.id}"]).to be_nil
|
||||
expect(cookies["hide_broadcast_message_#{other_message.id}"]).to be_nil
|
||||
it 'does not create cookie' do
|
||||
expect(cookies["hide_broadcast_message_#{message.id}"]).to be_nil
|
||||
|
||||
post_action
|
||||
|
||||
expect(cookies["hide_broadcast_message_#{message_banner.id}"]).to be(true)
|
||||
expect(cookies["hide_broadcast_message_#{message_notification.id}"]).to be(true)
|
||||
expect(cookies["hide_broadcast_message_#{other_message.id}"]).to be_nil
|
||||
end
|
||||
|
||||
context 'when dismissal is expired' do
|
||||
let_it_be(:message) { create(:broadcast_message, broadcast_type: :banner, message: 'banner') }
|
||||
|
||||
before do
|
||||
create(:broadcast_message_dismissal, :expired, broadcast_message: message, user: user)
|
||||
end
|
||||
|
||||
it 'does not create cookie' do
|
||||
expect(cookies["hide_broadcast_message_#{message.id}"]).to be_nil
|
||||
|
||||
post_action
|
||||
|
||||
expect(cookies["hide_broadcast_message_#{message.id}"]).to be_nil
|
||||
end
|
||||
expect(cookies["hide_broadcast_message_#{message.id}"]).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ describe('Ci environments dropdown', () => {
|
|||
const findListboxText = () => findListbox().props('toggleText');
|
||||
const findCreateWildcardButton = () => wrapper.findByTestId('create-wildcard-button');
|
||||
const findDropdownDivider = () => wrapper.findComponent(GlDropdownDivider);
|
||||
const findMaxEnvNote = () => wrapper.findByTestId('max-envs-notice');
|
||||
const findSearchQueryNote = () => wrapper.findByTestId('search-query-note');
|
||||
|
||||
const createComponent = ({ props = {}, searchTerm = '' } = {}) => {
|
||||
wrapper = mountExtended(CiEnvironmentsDropdown, {
|
||||
|
|
@ -34,10 +34,10 @@ describe('Ci environments dropdown', () => {
|
|||
findListbox().vm.$emit('search', searchTerm);
|
||||
};
|
||||
|
||||
describe('create wildcard button', () => {
|
||||
describe('when canCreateWildcard is true', () => {
|
||||
describe('create wildcard buttons', () => {
|
||||
describe('when canCreateWildcard is true and search has wildcard character', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ props: { canCreateWildcard: true }, searchTerm: 'stable' });
|
||||
createComponent({ props: { canCreateWildcard: true }, searchTerm: 'stable/*' });
|
||||
});
|
||||
|
||||
it('renders create button during search', () => {
|
||||
|
|
@ -45,9 +45,19 @@ describe('Ci environments dropdown', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('when canCreateWildcard is true and wildcard character is missing from search', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ props: { canCreateWildcard: true }, searchTerm: 'stable/' });
|
||||
});
|
||||
|
||||
it('does not render create button during search', () => {
|
||||
expect(findCreateWildcardButton().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when canCreateWildcard is false', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ props: { canCreateWildcard: false }, searchTerm: 'stable' });
|
||||
createComponent({ props: { canCreateWildcard: false }, searchTerm: 'stable/*' });
|
||||
});
|
||||
|
||||
it('does not render create button during search', () => {
|
||||
|
|
@ -59,7 +69,7 @@ describe('Ci environments dropdown', () => {
|
|||
describe('No environments found', () => {
|
||||
describe('default behavior', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ searchTerm: 'stable' });
|
||||
createComponent({ searchTerm: 'stable/*' });
|
||||
});
|
||||
|
||||
it('renders dropdown divider', () => {
|
||||
|
|
@ -69,7 +79,7 @@ describe('Ci environments dropdown', () => {
|
|||
it('renders create button with search term if environments do not contain search term', () => {
|
||||
const button = findCreateWildcardButton();
|
||||
expect(button.exists()).toBe(true);
|
||||
expect(button.text()).toBe('Create wildcard: stable');
|
||||
expect(button.text()).toBe('Create wildcard: stable/*');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -170,8 +180,9 @@ describe('Ci environments dropdown', () => {
|
|||
});
|
||||
|
||||
it('displays note about max environments', () => {
|
||||
expect(findMaxEnvNote().exists()).toBe(true);
|
||||
expect(findMaxEnvNote().text()).toContain('30');
|
||||
expect(findSearchQueryNote().text()).toBe(
|
||||
'Enter a search query to find more environments, or use * to create a wildcard.',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -191,7 +202,7 @@ describe('Ci environments dropdown', () => {
|
|||
});
|
||||
|
||||
describe('when creating a new environment scope from a search term', () => {
|
||||
const searchTerm = 'new-env';
|
||||
const searchTerm = 'new-env-*';
|
||||
beforeEach(() => {
|
||||
createComponent({ searchTerm });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
import Vue, { nextTick } from 'vue';
|
||||
import { mount } from '@vue/test-utils';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import { GlBanner } from '@gitlab/ui';
|
||||
import App from '~/work_items_hierarchy/components/app.vue';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
describe('WorkItemsHierarchy App', () => {
|
||||
let wrapper;
|
||||
const createComponent = (props = {}, data = {}) => {
|
||||
wrapper = extendedWrapper(
|
||||
mount(App, {
|
||||
provide: {
|
||||
illustrationPath: '/foo.svg',
|
||||
licensePlan: 'free',
|
||||
...props,
|
||||
},
|
||||
data() {
|
||||
return data;
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe('survey banner', () => {
|
||||
it('shows when the banner is visible', () => {
|
||||
createComponent({}, { bannerVisible: true });
|
||||
|
||||
expect(wrapper.findComponent(GlBanner).exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('hide when close is called', async () => {
|
||||
createComponent({}, { bannerVisible: true });
|
||||
|
||||
wrapper.findByTestId('close-icon').trigger('click');
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.findComponent(GlBanner).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unavailable structure', () => {
|
||||
it.each`
|
||||
licensePlan | visible
|
||||
${'free'} | ${true}
|
||||
${'premium'} | ${true}
|
||||
${'ultimate'} | ${false}
|
||||
`('visibility is $visible when plan is $licensePlan', ({ licensePlan, visible }) => {
|
||||
createComponent({ licensePlan });
|
||||
|
||||
expect(wrapper.findByTestId('unavailable-structure').exists()).toBe(visible);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
import { mount } from '@vue/test-utils';
|
||||
import Vue from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import { GlBadge } from '@gitlab/ui';
|
||||
import Hierarchy from '~/work_items_hierarchy/components/hierarchy.vue';
|
||||
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
|
||||
import RESPONSE from '~/work_items_hierarchy/static_response';
|
||||
import { workItemTypes } from '~/work_items_hierarchy/constants';
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
describe('WorkItemsHierarchy Hierarchy', () => {
|
||||
let wrapper;
|
||||
|
||||
const workItemsFromResponse = (response) => {
|
||||
return response.reduce(
|
||||
(itemTypes, item) => {
|
||||
const key = item.available ? 'available' : 'unavailable';
|
||||
itemTypes[key].push({
|
||||
...item,
|
||||
...workItemTypes[item.type],
|
||||
nestedTypes: item.nestedTypes
|
||||
? item.nestedTypes.map((type) => workItemTypes[type])
|
||||
: null,
|
||||
});
|
||||
return itemTypes;
|
||||
},
|
||||
{ available: [], unavailable: [] },
|
||||
);
|
||||
};
|
||||
|
||||
const createComponent = (props = {}) => {
|
||||
wrapper = extendedWrapper(
|
||||
mount(Hierarchy, {
|
||||
propsData: {
|
||||
workItemTypes: props.workItemTypes,
|
||||
...props,
|
||||
},
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
describe('available structure', () => {
|
||||
let items = [];
|
||||
|
||||
beforeEach(() => {
|
||||
items = workItemsFromResponse(RESPONSE.ultimate).available;
|
||||
createComponent({ workItemTypes: items });
|
||||
});
|
||||
|
||||
it('renders all work items', () => {
|
||||
expect(wrapper.findAllByTestId('work-item-wrapper')).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it('does not render badges', () => {
|
||||
expect(wrapper.findComponent(GlBadge).exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('unavailable structure', () => {
|
||||
let items = [];
|
||||
|
||||
beforeEach(() => {
|
||||
items = workItemsFromResponse(RESPONSE.premium).unavailable;
|
||||
createComponent({ workItemTypes: items });
|
||||
});
|
||||
|
||||
it('renders all work items', () => {
|
||||
expect(wrapper.findAllByTestId('work-item-wrapper')).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it('renders license badges for all work items', () => {
|
||||
expect(wrapper.findAllComponents(GlBadge)).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it('does not render svg icon for linking', () => {
|
||||
expect(wrapper.findByTestId('hierarchy-rounded-arrow-tail').exists()).toBe(false);
|
||||
expect(wrapper.findByTestId('level-up-icon').exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('nested work items', () => {
|
||||
describe.each`
|
||||
licensePlan | arrowTailVisible | levelUpIconVisible | arrowDownIconVisible
|
||||
${'ultimate'} | ${true} | ${true} | ${true}
|
||||
${'premium'} | ${false} | ${false} | ${true}
|
||||
${'free'} | ${false} | ${false} | ${false}
|
||||
`(
|
||||
'when $licensePlan license',
|
||||
({ licensePlan, arrowTailVisible, levelUpIconVisible, arrowDownIconVisible }) => {
|
||||
let items = [];
|
||||
beforeEach(() => {
|
||||
items = workItemsFromResponse(RESPONSE[licensePlan]).available;
|
||||
createComponent({ workItemTypes: items });
|
||||
});
|
||||
|
||||
it(`${arrowTailVisible ? 'render' : 'does not render'} arrow tail svg`, () => {
|
||||
expect(wrapper.findByTestId('hierarchy-rounded-arrow-tail').exists()).toBe(
|
||||
arrowTailVisible,
|
||||
);
|
||||
});
|
||||
|
||||
it(`${levelUpIconVisible ? 'render' : 'does not render'} arrow tail svg`, () => {
|
||||
expect(wrapper.findByTestId('level-up-icon').exists()).toBe(levelUpIconVisible);
|
||||
});
|
||||
|
||||
it(`${arrowDownIconVisible ? 'render' : 'does not render'} arrow tail svg`, () => {
|
||||
expect(wrapper.findByTestId('arrow-down-icon').exists()).toBe(arrowDownIconVisible);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
import { inferLicensePlan } from '~/work_items_hierarchy/hierarchy_util';
|
||||
import { LICENSE_PLAN } from '~/work_items_hierarchy/constants';
|
||||
|
||||
describe('inferLicensePlan', () => {
|
||||
it.each`
|
||||
epics | subEpics | licensePlan
|
||||
${true} | ${true} | ${LICENSE_PLAN.ULTIMATE}
|
||||
${true} | ${false} | ${LICENSE_PLAN.PREMIUM}
|
||||
${false} | ${false} | ${LICENSE_PLAN.FREE}
|
||||
`(
|
||||
'returns $licensePlan when epic is $epics and sub-epic is $subEpics',
|
||||
({ epics, subEpics, licensePlan }) => {
|
||||
expect(inferLicensePlan({ hasEpics: epics, hasSubEpics: subEpics })).toBe(licensePlan);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe PlanningHierarchy, type: :request, feature_category: :groups_and_projects do
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:group) { create(:group) }
|
||||
let_it_be(:project) { create(:project, group: group) }
|
||||
|
||||
before do
|
||||
project.add_maintainer(user)
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
describe 'GET #planning_hierarchy' do
|
||||
it 'renders planning hierarchy' do
|
||||
get project_planning_hierarchy_path(project)
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -9,24 +9,38 @@ RSpec.describe ::Ml::CreateModelService, feature_category: :mlops do
|
|||
let_it_be(:description) { 'description' }
|
||||
let_it_be(:metadata) { [] }
|
||||
|
||||
let(:audit_event) do
|
||||
{
|
||||
name: 'ml_model_created',
|
||||
author: user,
|
||||
scope: project
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(Gitlab::InternalEvents).to receive(:track_event)
|
||||
allow(Gitlab::Audit::Auditor).to receive(:audit).and_call_original
|
||||
end
|
||||
|
||||
subject(:create_model) { described_class.new(project, name, user, description, metadata).execute }
|
||||
|
||||
describe '#execute' do
|
||||
describe '#execute', :aggregate_failures do
|
||||
subject(:model_payload) { create_model.payload }
|
||||
|
||||
let(:audit_context) do
|
||||
audit_event.merge(target: model_payload, message: "MlModel #{name} created")
|
||||
end
|
||||
|
||||
context 'when model name is not supplied' do
|
||||
let(:name) { nil }
|
||||
let(:project) { existing_model.project }
|
||||
|
||||
it 'returns a model with errors', :aggregate_failures do
|
||||
it 'returns a model with errors' do
|
||||
expect { create_model }.not_to change { Ml::Model.count }
|
||||
expect(create_model).to be_error
|
||||
expect(Gitlab::InternalEvents).not_to have_received(:track_event)
|
||||
expect(create_model.message).to include("Name can't be blank")
|
||||
expect(Gitlab::Audit::Auditor).not_to have_received(:audit)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -34,13 +48,15 @@ RSpec.describe ::Ml::CreateModelService, feature_category: :mlops do
|
|||
let(:name) { 'new_model' }
|
||||
let(:project) { existing_model.project }
|
||||
|
||||
it 'creates a model', :aggregate_failures do
|
||||
it 'creates a model' do
|
||||
expect { create_model }.to change { Ml::Model.count }.by(1)
|
||||
expect(Gitlab::InternalEvents).to have_received(:track_event).with(
|
||||
'model_registry_ml_model_created',
|
||||
{ project: project, user: user }
|
||||
)
|
||||
|
||||
expect(Gitlab::Audit::Auditor).to have_received(:audit).with(audit_context)
|
||||
|
||||
expect(model_payload.name).to eq('new_model')
|
||||
expect(model_payload.default_experiment.name).to eq('[model]new_model')
|
||||
end
|
||||
|
|
@ -50,13 +66,14 @@ RSpec.describe ::Ml::CreateModelService, feature_category: :mlops do
|
|||
let(:name) { existing_model.name }
|
||||
let(:project) { another_project }
|
||||
|
||||
it 'creates a model', :aggregate_failures do
|
||||
it 'creates a model' do
|
||||
expect { create_model }.to change { Ml::Model.count }.by(1)
|
||||
expect(Gitlab::InternalEvents).to have_received(:track_event).with(
|
||||
'model_registry_ml_model_created',
|
||||
{ project: project, user: user }
|
||||
)
|
||||
|
||||
expect(Gitlab::Audit::Auditor).to have_received(:audit).with(audit_context)
|
||||
expect(model_payload.name).to eq(name)
|
||||
end
|
||||
end
|
||||
|
|
@ -65,11 +82,12 @@ RSpec.describe ::Ml::CreateModelService, feature_category: :mlops do
|
|||
let(:name) { existing_model.name }
|
||||
let(:project) { existing_model.project }
|
||||
|
||||
it 'returns a model with errors', :aggregate_failures do
|
||||
it 'returns a model with errors' do
|
||||
expect { create_model }.not_to change { Ml::Model.count }
|
||||
expect(create_model).to be_error
|
||||
expect(Gitlab::InternalEvents).not_to have_received(:track_event)
|
||||
expect(create_model.message).to eq(["Name should be unique in the project"])
|
||||
expect(Gitlab::Audit::Auditor).not_to have_received(:audit)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -78,10 +96,11 @@ RSpec.describe ::Ml::CreateModelService, feature_category: :mlops do
|
|||
let(:project) { existing_model.project }
|
||||
let(:metadata) { [{ key: 'key1', value: 'value1' }, { key: 'key2', value: 'value2' }] }
|
||||
|
||||
it 'creates metadata records', :aggregate_failures do
|
||||
it 'creates metadata records' do
|
||||
expect { create_model }.to change { Ml::Model.count }.by(1)
|
||||
|
||||
expect(model_payload.name).to eq(name)
|
||||
expect(Gitlab::Audit::Auditor).to have_received(:audit).with(audit_context)
|
||||
expect(model_payload.metadata.count).to be 2
|
||||
end
|
||||
end
|
||||
|
|
@ -92,8 +111,9 @@ RSpec.describe ::Ml::CreateModelService, feature_category: :mlops do
|
|||
let(:project) { existing_model.project }
|
||||
let(:metadata) { [{ key: 'key1', value: 'value1' }, { key: 'key1', value: 'value2' }] }
|
||||
|
||||
it 'raises an error', :aggregate_failures do
|
||||
it 'raises an error' do
|
||||
expect { create_model }.to raise_error(ActiveRecord::RecordInvalid)
|
||||
expect(Gitlab::Audit::Auditor).not_to have_received(:audit)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -103,8 +123,9 @@ RSpec.describe ::Ml::CreateModelService, feature_category: :mlops do
|
|||
let(:project) { existing_model.project }
|
||||
let(:metadata) { [{ key: 'key1', value: 'value1' }, { key: '', value: 'value2' }] }
|
||||
|
||||
it 'raises an error', :aggregate_failures do
|
||||
it 'raises an error' do
|
||||
expect { create_model }.to raise_error(ActiveRecord::RecordInvalid)
|
||||
expect(Gitlab::Audit::Auditor).not_to have_received(:audit)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,8 +9,21 @@ RSpec.describe ::Ml::DestroyModelService, feature_category: :mlops do
|
|||
|
||||
let(:model) { model0 }
|
||||
let(:service) { described_class.new(model, user) }
|
||||
let(:audit_event) do
|
||||
{
|
||||
name: 'ml_model_destroyed',
|
||||
author: user,
|
||||
scope: model.project,
|
||||
message: "MlModel #{model.name} destroyed",
|
||||
target: model
|
||||
}
|
||||
end
|
||||
|
||||
describe '#execute' do
|
||||
before do
|
||||
allow(Gitlab::Audit::Auditor).to receive(:audit).and_call_original
|
||||
end
|
||||
|
||||
describe '#execute', :aggregate_failures do
|
||||
subject(:service_result) { service.execute }
|
||||
|
||||
context 'when model fails to delete' do
|
||||
|
|
@ -18,17 +31,19 @@ RSpec.describe ::Ml::DestroyModelService, feature_category: :mlops do
|
|||
allow(model).to receive(:destroy).and_return(false)
|
||||
|
||||
expect(service_result).to be_error
|
||||
expect(Gitlab::Audit::Auditor).not_to have_received(:audit)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a model exists' do
|
||||
it 'destroys the model', :aggregate_failures do
|
||||
it 'destroys the model' do
|
||||
allow_next_instance_of(Packages::MarkPackagesForDestructionService) do |instance|
|
||||
allow(instance).to receive(:execute).and_return ServiceResponse.success(message: "")
|
||||
end
|
||||
|
||||
expect { service_result }.to change { Ml::Model.count }.by(-1).and change { Ml::ModelVersion.count }.by(-1)
|
||||
expect(service_result).to be_success
|
||||
expect(Gitlab::Audit::Auditor).to have_received(:audit).with(audit_event)
|
||||
end
|
||||
|
||||
context 'when a package cannot be marked for destruction' do
|
||||
|
|
@ -40,10 +55,11 @@ RSpec.describe ::Ml::DestroyModelService, feature_category: :mlops do
|
|||
end
|
||||
end
|
||||
|
||||
it 'returns success with warning', :aggregate_failures do
|
||||
it 'returns success with warning' do
|
||||
expect { service_result }.not_to change { Ml::Model.count }
|
||||
expect(service_result).to be_error
|
||||
expect(service_result.message).to eq("An error")
|
||||
expect(Gitlab::Audit::Auditor).not_to have_received(:audit)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -130,6 +130,17 @@ RSpec.describe PagesDomains::ObtainLetsEncryptCertificateService, feature_catego
|
|||
|
||||
service.execute
|
||||
end
|
||||
|
||||
describe 'when #request_certificate returns a client error' do
|
||||
before do
|
||||
allow(api_order).to receive(:request_certificate).and_raise(
|
||||
Acme::Client::Error::BadCSR,
|
||||
'Error finalizing order :: CN was longer than 64 bytes'
|
||||
)
|
||||
end
|
||||
|
||||
it_behaves_like 'saves error and sends notification'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when order is valid' do
|
||||
|
|
|
|||
|
|
@ -7206,7 +7206,6 @@
|
|||
- './spec/requests/api/users_preferences_spec.rb'
|
||||
- './spec/requests/api/users_spec.rb'
|
||||
- './spec/requests/api/wikis_spec.rb'
|
||||
- './spec/requests/concerns/planning_hierarchy_spec.rb'
|
||||
- './spec/requests/dashboard_controller_spec.rb'
|
||||
- './spec/requests/dashboard/projects_controller_spec.rb'
|
||||
- './spec/requests/git_http_spec.rb'
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ RSpec.shared_context 'ProjectPolicy context' do
|
|||
%i[
|
||||
award_emoji create_issue create_note
|
||||
create_project read_issue_board read_issue read_issue_iid read_issue_link
|
||||
read_label read_planning_hierarchy read_issue_board_list read_milestone read_note read_project
|
||||
read_label read_issue_board_list read_milestone read_note read_project
|
||||
read_project_for_iids read_project_member read_release read_snippet
|
||||
read_wiki upload_file
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'access restricted confidential issues' do
|
||||
RSpec.shared_examples 'access restricted confidential issues' do |document_type: :issue|
|
||||
let(:query) { 'issue' }
|
||||
let(:author) { create(:user) }
|
||||
let(:assignee) { create(:user) }
|
||||
let(:project) { create(:project, :internal) }
|
||||
|
||||
let!(:issue) { create(:issue, project: project, title: 'Issue 1') }
|
||||
let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) }
|
||||
let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) }
|
||||
let!(:issue) { create(document_type, project: project, title: 'Issue 1') }
|
||||
let!(:security_issue_1) { create(document_type, :confidential, project: project, title: 'Security issue 1', author: author) }
|
||||
let!(:security_issue_2) { create(document_type, :confidential, title: 'Security issue 2', project: project, assignees: [assignee]) }
|
||||
|
||||
subject(:objects) do
|
||||
described_class.new(user, query, project: project).objects('issues')
|
||||
|
|
|
|||
Loading…
Reference in New Issue