Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-08-12 18:11:09 +00:00
parent 1c8734ca5c
commit 60eaf3d906
53 changed files with 1300 additions and 117 deletions

View File

@ -1 +1 @@
15.2.0
15.3.0

View File

@ -495,9 +495,9 @@ gem 'google-protobuf', '~> 3.21'
gem 'toml-rb', '~> 2.0'
# Feature toggles
gem 'flipper', '~> 0.21.0'
gem 'flipper-active_record', '~> 0.21.0'
gem 'flipper-active_support_cache_store', '~> 0.21.0'
gem 'flipper', '~> 0.25.0'
gem 'flipper-active_record', '~> 0.25.0'
gem 'flipper-active_support_cache_store', '~> 0.25.0'
gem 'unleash', '~> 3.2.2'
gem 'gitlab-experiment', '~> 0.7.1'

View File

@ -438,13 +438,13 @@ GEM
libyajl2 (~> 1.2)
filelock (1.1.1)
find_a_port (1.0.1)
flipper (0.21.0)
flipper-active_record (0.21.0)
activerecord (>= 5.0, < 7)
flipper (~> 0.21.0)
flipper-active_support_cache_store (0.21.0)
activesupport (>= 5.0, < 7)
flipper (~> 0.21.0)
flipper (0.25.0)
flipper-active_record (0.25.0)
activerecord (>= 4.2, < 8)
flipper (~> 0.25.0)
flipper-active_support_cache_store (0.25.0)
activesupport (>= 4.2, < 8)
flipper (~> 0.25.0)
flowdock (0.7.1)
httparty (~> 0.7)
multi_json
@ -1557,9 +1557,9 @@ DEPENDENCIES
faraday_middleware-aws-sigv4 (~> 0.3.0)
fast_blank
ffaker (~> 2.10)
flipper (~> 0.21.0)
flipper-active_record (~> 0.21.0)
flipper-active_support_cache_store (~> 0.21.0)
flipper (~> 0.25.0)
flipper-active_record (~> 0.25.0)
flipper-active_support_cache_store (~> 0.25.0)
flowdock (~> 0.7)
fog-aliyun (~> 0.3)
fog-aws (~> 3.14)

View File

@ -1,15 +1,24 @@
<script>
import { GlDropdown, GlDropdownItem, GlModal, GlModalDirective } from '@gitlab/ui';
import {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlModal,
GlModalDirective,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
export default {
i18n: {
deleteTask: s__('WorkItem|Delete task'),
enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'),
disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'),
},
components: {
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlModal,
},
directives: {
@ -22,14 +31,33 @@ export default {
required: false,
default: null,
},
canUpdate: {
type: Boolean,
required: false,
default: false,
},
canDelete: {
type: Boolean,
required: false,
default: false,
},
isConfidential: {
type: Boolean,
required: false,
default: false,
},
isParentConfidential: {
type: Boolean,
required: false,
default: false,
},
},
emits: ['deleteWorkItem'],
emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
methods: {
handleToggleWorkItemConfidentiality() {
this.track('click_toggle_work_item_confidentiality');
this.$emit('toggleWorkItemConfidentiality', !this.isConfidential);
},
handleDeleteWorkItem() {
this.track('click_delete_work_item');
this.$emit('deleteWorkItem');
@ -44,7 +72,7 @@ export default {
</script>
<template>
<div v-if="canDelete">
<div>
<gl-dropdown
icon="ellipsis_v"
text-sr-only
@ -53,9 +81,24 @@ export default {
no-caret
right
>
<gl-dropdown-item v-gl-modal="'work-item-confirm-delete'">{{
$options.i18n.deleteTask
}}</gl-dropdown-item>
<template v-if="canUpdate && !isParentConfidential">
<gl-dropdown-item
data-testid="confidentiality-toggle-action"
@click="handleToggleWorkItemConfidentiality"
>{{
isConfidential
? $options.i18n.disableTaskConfidentiality
: $options.i18n.enableTaskConfidentiality
}}</gl-dropdown-item
>
<gl-dropdown-divider />
</template>
<gl-dropdown-item
v-if="canDelete"
v-gl-modal="'work-item-confirm-delete'"
data-testid="delete-action"
>{{ $options.i18n.deleteTask }}</gl-dropdown-item
>
</gl-dropdown>
<gl-modal
modal-id="work-item-confirm-delete"

View File

@ -1,5 +1,13 @@
<script>
import { GlAlert, GlSkeletonLoader, GlIcon, GlButton, GlTooltipDirective } from '@gitlab/ui';
import {
GlAlert,
GlSkeletonLoader,
GlLoadingIcon,
GlIcon,
GlBadge,
GlButton,
GlTooltipDirective,
} from '@gitlab/ui';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import {
@ -11,8 +19,12 @@ import {
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_VIEWED_STORAGE_KEY,
} from '../constants';
import workItemQuery from '../graphql/work_item.query.graphql';
import workItemTitleSubscription from '../graphql/work_item_title.subscription.graphql';
import updateWorkItemMutation from '../graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '../graphql/update_work_item_task.mutation.graphql';
import WorkItemActions from './work_item_actions.vue';
import WorkItemState from './work_item_state.vue';
import WorkItemTitle from './work_item_title.vue';
@ -29,7 +41,9 @@ export default {
},
components: {
GlAlert,
GlBadge,
GlButton,
GlLoadingIcon,
GlSkeletonLoader,
GlIcon,
WorkItemAssignees,
@ -65,6 +79,7 @@ export default {
error: undefined,
workItem: {},
showInfoBanner: true,
updateInProgress: false,
};
},
apollo: {
@ -125,6 +140,9 @@ export default {
parentWorkItem() {
return this.workItemHierarchy?.parent;
},
parentWorkItemConfidentiality() {
return this.parentWorkItem?.confidential;
},
parentUrl() {
return `../../issues/${this.parentWorkItem?.iid}`;
},
@ -138,6 +156,54 @@ export default {
dismissBanner() {
this.showInfoBanner = false;
},
toggleConfidentiality(confidentialStatus) {
this.updateInProgress = true;
let updateMutation = updateWorkItemMutation;
let inputVariables = {
id: this.workItemId,
confidential: confidentialStatus,
};
if (this.parentWorkItem) {
updateMutation = updateWorkItemTaskMutation;
inputVariables = {
id: this.parentWorkItem.id,
taskData: {
id: this.workItemId,
confidential: confidentialStatus,
},
};
}
this.$apollo
.mutate({
mutation: updateMutation,
variables: {
input: inputVariables,
},
})
.then(
({
data: {
workItemUpdate: { errors, workItem, task },
},
}) => {
if (errors?.length) {
throw new Error(errors[0]);
}
this.$emit('workItemUpdated', {
confidential: workItem?.confidential || task?.confidential,
});
},
)
.catch((error) => {
this.error = error.message;
})
.finally(() => {
this.updateInProgress = false;
});
},
},
WORK_ITEM_VIEWED_STORAGE_KEY,
};
@ -156,7 +222,7 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
<div class="gl-display-flex gl-align-items-center">
<div class="gl-display-flex gl-align-items-center" data-testid="work-item-body">
<ul
v-if="parentWorkItem"
class="list-unstyled gl-display-flex gl-mr-auto gl-max-w-26 gl-md-max-w-50p gl-min-w-0 gl-mb-0"
@ -187,10 +253,19 @@ export default {
data-testid="work-item-type"
>{{ workItemType }}</span
>
<gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
<gl-badge v-if="workItem.confidential" variant="warning" icon="eye-slash" class="gl-mr-3">{{
__('Confidential')
}}</gl-badge>
<work-item-actions
v-if="canUpdate || canDelete"
:work-item-id="workItem.id"
:can-delete="canDelete"
:can-update="canUpdate"
:is-confidential="workItem.confidential"
:is-parent-confidential="parentWorkItemConfidentiality"
@deleteWorkItem="$emit('deleteWorkItem')"
@toggleWorkItemConfidentiality="toggleConfidentiality"
@error="error = $event"
/>
<gl-button

View File

@ -1,5 +1,5 @@
<script>
import { GlButton, GlBadge, GlIcon, GlLoadingIcon } from '@gitlab/ui';
import { GlButton, GlBadge, GlIcon, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
import { produce } from 'immer';
import { s__ } from '~/locale';
import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils';
@ -30,6 +30,9 @@ export default {
WorkItemLinksMenu,
WorkItemDetailModal,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['projectPath'],
props: {
workItemId: {
@ -284,7 +287,15 @@ export default {
class="gl-relative gl-display-flex gl-flex-direction-column gl-sm-flex-direction-row gl-overflow-break-word gl-min-w-0 gl-bg-white gl-mb-3 gl-py-3 gl-px-4 gl-border gl-border-gray-100 gl-rounded-base gl-line-height-32"
data-testid="links-child"
>
<div class="gl-overflow-hidden">
<div class="gl-overflow-hidden gl-display-flex gl-align-items-center">
<gl-icon
v-if="child.confidential"
v-gl-tooltip.top
name="eye-slash"
class="gl-mr-3 gl-text-orange-500"
data-testid="confidential-icon"
:title="__('Confidential')"
/>
<gl-icon :name="$options.WIDGET_TYPE_TASK_ICON" class="gl-mr-3 gl-text-gray-700" />
<gl-button
:href="childPath(child.id)"

View File

@ -5,6 +5,7 @@ fragment WorkItem on WorkItem {
title
state
description
confidential
workItemType {
id
name
@ -34,6 +35,7 @@ fragment WorkItem on WorkItem {
id
iid
title
confidential
}
children {
nodes {

View File

@ -20,6 +20,7 @@ query workItemQuery($id: WorkItemID!) {
children {
nodes {
id
confidential
workItemType {
id
}

View File

@ -11,13 +11,20 @@ module Mutations
null: true,
description: 'Job after the mutation.'
argument :variables, [::Types::Ci::VariableInputType],
required: false,
default_value: [],
replace_null_with_default: true,
description: 'Variables to use when retrying a manual job.'
authorize :update_build
def resolve(id:)
def resolve(id:, variables:)
job = authorized_find!(id: id)
project = job.project
variables = variables.map(&:to_h)
response = ::Ci::RetryJobService.new(project, current_user).execute(job)
response = ::Ci::RetryJobService.new(project, current_user).execute(job, variables: variables)
if response.success?
{

View File

@ -194,7 +194,7 @@ module Types
end
def manual_variables
if object.manual? && object.respond_to?(:job_variables)
if object.action? && object.respond_to?(:job_variables)
object.job_variables
else
[]

View File

@ -0,0 +1,13 @@
# frozen_string_literal: true
module Types
module Ci
class VariableInputType < BaseInputObject
graphql_name 'CiVariableInput'
description 'Attributes for defining a CI/CD variable.'
argument :key, GraphQL::Types::String, description: 'Name of the variable.'
argument :value, GraphQL::Types::String, description: 'Value of the variable.'
end
end
end

View File

@ -1161,6 +1161,17 @@ module Ci
end
end
def clone(current_user:, new_job_variables_attributes: [])
new_build = super
if action? && new_job_variables_attributes.any?
new_build.job_variables = []
new_build.job_variables_attributes = new_job_variables_attributes
end
new_build
end
protected
def run_status_commit_hooks!

View File

@ -22,7 +22,8 @@ module Ci
accessibility: %w[accessibility],
coverage: %w[cobertura],
codequality: %w[codequality],
terraform: %w[terraform]
terraform: %w[terraform],
sbom: %w[cyclonedx]
}.freeze
DEFAULT_FILE_NAMES = {

View File

@ -101,7 +101,7 @@ module Ci
:merge_train_pipeline?,
to: :pipeline
def clone(current_user:)
def clone(current_user:, new_job_variables_attributes: [])
new_attributes = self.class.clone_accessors.to_h do |attribute|
[attribute, public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend
end

View File

@ -19,6 +19,8 @@ class ContainerRepository < ApplicationRecord
MIGRATION_PHASE_1_STARTED_AT = Date.new(2021, 11, 4).freeze
MIGRATION_PHASE_1_ENDED_AT = Date.new(2022, 01, 23).freeze
MAX_TAGS_PAGES = 2000
TooManyImportsError = Class.new(StandardError)
belongs_to :project
@ -377,6 +379,10 @@ class ContainerRepository < ApplicationRecord
migration_retries_count >= ContainerRegistry::Migration.max_retries - 1
end
def migrated?
MIGRATION_PHASE_1_ENDED_AT < self.created_at || import_done?
end
def last_import_step_done_at
[migration_pre_import_done_at, migration_import_done_at, migration_aborted_at, migration_skipped_at].compact.max
end
@ -427,6 +433,32 @@ class ContainerRepository < ApplicationRecord
end
end
def each_tags_page(page_size: 100, &block)
raise ArgumentError, 'not a migrated repository' unless migrated?
raise ArgumentError, 'block not given' unless block
# dummy uri to initialize the loop
next_page_uri = URI('')
page_count = 0
while next_page_uri && page_count < MAX_TAGS_PAGES
last = Rack::Utils.parse_nested_query(next_page_uri.query)['last']
current_page = gitlab_api_client.tags(self.path, page_size: page_size, last: last)
if current_page&.key?(:response_body)
yield transform_tags_page(current_page[:response_body])
next_page_uri = current_page.dig(:pagination, :next, :uri)
else
# no current page. Break the loop
next_page_uri = nil
end
page_count += 1
end
raise 'too many pages requested' if page_count >= MAX_TAGS_PAGES
end
def tags_count
return 0 unless manifest && manifest['tags']
@ -559,6 +591,16 @@ class ContainerRepository < ApplicationRecord
self.migration_skipped_reason = reason
finish_import
end
def transform_tags_page(tags_response_body)
return [] unless tags_response_body
tags_response_body.map do |raw_tag|
tag = ContainerRegistry::Tag.new(self, raw_tag['name'])
tag.force_created_at_from_iso8601(raw_tag['created_at'])
tag
end
end
end
ContainerRepository.prepend_mod_with('ContainerRepository')

View File

@ -4,10 +4,10 @@ module Ci
class RetryJobService < ::BaseService
include Gitlab::Utils::StrongMemoize
def execute(job)
def execute(job, variables: [])
if job.retryable?
job.ensure_scheduling_type!
new_job = retry_job(job)
new_job = retry_job(job, variables: variables)
ServiceResponse.success(payload: { job: new_job })
else
@ -19,7 +19,7 @@ module Ci
end
# rubocop: disable CodeReuse/ActiveRecord
def clone!(job)
def clone!(job, variables: [])
# Cloning a job requires a strict type check to ensure
# the attributes being used for the clone are taken straight
# from the model and not overridden by other abstractions.
@ -27,7 +27,7 @@ module Ci
check_access!(job)
new_job = job.clone(current_user: current_user)
new_job = job.clone(current_user: current_user, new_job_variables_attributes: variables)
new_job.run_after_commit do
::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job)
@ -55,8 +55,8 @@ module Ci
def check_assignable_runners!(job); end
def retry_job(job)
clone!(job).tap do |new_job|
def retry_job(job, variables: [])
clone!(job, variables: variables).tap do |new_job|
check_assignable_runners!(new_job) if new_job.is_a?(Ci::Build)
next if new_job.failed?

View File

@ -1,5 +1,5 @@
---
# Warning: gitlab.Admin
# Suggestion: gitlab.Admin
#
# Checks for "admin" and recommends using the full word instead. "Admin Area" is OK.
#

View File

@ -1,5 +1,5 @@
---
# Suggestion: gitlab.BadPlurals
# Warning: gitlab.BadPlurals
#
# Don't write plural words with the '(s)' construction. "HTTP(S)" is acceptable.
#

View File

@ -1,5 +1,5 @@
---
# Error: gitlab.EOLWhitespace
# Warning: gitlab.EOLWhitespace
#
# Checks that there is no useless whitespace at the end of lines.
#

View File

@ -1,5 +1,5 @@
---
# Suggestion: gitlab.FutureTense
# Warning: gitlab.FutureTense
#
# Checks for use of future tense in sentences. Present tense is strongly preferred.
#

View File

@ -1,5 +1,5 @@
---
# Error: gitlab.HeadingContent
# Warning: gitlab.HeadingContent
#
# Checks for generic, unhelpful subheadings.
#

View File

@ -1,5 +1,5 @@
---
# Warning: gitlab.OutdatedVersions
# Suggestion: gitlab.OutdatedVersions
#
# Checks for references to versions of GitLab that are no longer supported.
#

View File

@ -1,5 +1,5 @@
---
# Warning: gitlab.Possessive
# Error: gitlab.Possessive
#
# The word GitLab should not be used in the possessive form.
#

View File

@ -1549,7 +1549,7 @@ Input type: `CreateIssueInput`
WARNING:
**Deprecated** in 14.0.
Manual iteration management is deprecated. Only automatic iteration cadences will be supported in the future.
Use iterationCreate.
Input type: `CreateIterationInput`
@ -1561,7 +1561,7 @@ Input type: `CreateIterationInput`
| <a id="mutationcreateiterationdescription"></a>`description` | [`String`](#string) | Description of the iteration. |
| <a id="mutationcreateiterationduedate"></a>`dueDate` | [`String`](#string) | End date of the iteration. |
| <a id="mutationcreateiterationgrouppath"></a>`groupPath` | [`ID`](#id) | Full path of the group with which the resource is associated. |
| <a id="mutationcreateiterationiterationscadenceid"></a>`iterationsCadenceId` **{warning-solid}** | [`IterationsCadenceID`](#iterationscadenceid) | **Deprecated:** `iterationCadenceId` is deprecated and will be removed in the future. This argument is ignored, because you can't create an iteration in a specific cadence. In the future only automatic iteration cadences will be allowed. Deprecated in 14.10. |
| <a id="mutationcreateiterationiterationscadenceid"></a>`iterationsCadenceId` | [`IterationsCadenceID`](#iterationscadenceid) | Global ID of the iteration cadence to be assigned to the new iteration. |
| <a id="mutationcreateiterationprojectpath"></a>`projectPath` | [`ID`](#id) | Full path of the project with which the resource is associated. |
| <a id="mutationcreateiterationstartdate"></a>`startDate` | [`String`](#string) | Start date of the iteration. |
| <a id="mutationcreateiterationtitle"></a>`title` | [`String`](#string) | Title of the iteration. |
@ -3370,10 +3370,6 @@ Input type: `IterationCadenceUpdateInput`
### `Mutation.iterationCreate`
WARNING:
**Deprecated** in 14.10.
Manual iteration management is deprecated. Only automatic iteration cadences will be supported in the future.
Input type: `iterationCreateInput`
#### Arguments
@ -3384,7 +3380,7 @@ Input type: `iterationCreateInput`
| <a id="mutationiterationcreatedescription"></a>`description` | [`String`](#string) | Description of the iteration. |
| <a id="mutationiterationcreateduedate"></a>`dueDate` | [`String`](#string) | End date of the iteration. |
| <a id="mutationiterationcreategrouppath"></a>`groupPath` | [`ID`](#id) | Full path of the group with which the resource is associated. |
| <a id="mutationiterationcreateiterationscadenceid"></a>`iterationsCadenceId` **{warning-solid}** | [`IterationsCadenceID`](#iterationscadenceid) | **Deprecated:** `iterationCadenceId` is deprecated and will be removed in the future. This argument is ignored, because you can't create an iteration in a specific cadence. In the future only automatic iteration cadences will be allowed. Deprecated in 14.10. |
| <a id="mutationiterationcreateiterationscadenceid"></a>`iterationsCadenceId` | [`IterationsCadenceID`](#iterationscadenceid) | Global ID of the iteration cadence to be assigned to the new iteration. |
| <a id="mutationiterationcreateprojectpath"></a>`projectPath` | [`ID`](#id) | Full path of the project with which the resource is associated. |
| <a id="mutationiterationcreatestartdate"></a>`startDate` | [`String`](#string) | Start date of the iteration. |
| <a id="mutationiterationcreatetitle"></a>`title` | [`String`](#string) | Title of the iteration. |
@ -3399,10 +3395,6 @@ Input type: `iterationCreateInput`
### `Mutation.iterationDelete`
WARNING:
**Deprecated** in 14.10.
Manual iteration management is deprecated. Only automatic iteration cadences will be supported in the future.
Input type: `IterationDeleteInput`
#### Arguments
@ -3510,6 +3502,7 @@ Input type: `JobRetryInput`
| ---- | ---- | ----------- |
| <a id="mutationjobretryclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationjobretryid"></a>`id` | [`CiBuildID!`](#cibuildid) | ID of the job to mutate. |
| <a id="mutationjobretryvariables"></a>`variables` | [`[CiVariableInput!]`](#civariableinput) | Variables to use when retrying a manual job. |
#### Fields
@ -5218,11 +5211,11 @@ Input type: `UpdateIterationInput`
| ---- | ---- | ----------- |
| <a id="mutationupdateiterationclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationupdateiterationdescription"></a>`description` | [`String`](#string) | Description of the iteration. |
| <a id="mutationupdateiterationduedate"></a>`dueDate` **{warning-solid}** | [`String`](#string) | **Deprecated:** Manual iteration updates are deprecated, only `description` updates will be allowed in the future. Deprecated in 14.10. |
| <a id="mutationupdateiterationduedate"></a>`dueDate` | [`String`](#string) | End date of the iteration. |
| <a id="mutationupdateiterationgrouppath"></a>`groupPath` | [`ID!`](#id) | Group of the iteration. |
| <a id="mutationupdateiterationid"></a>`id` | [`ID!`](#id) | Global ID of the iteration. |
| <a id="mutationupdateiterationstartdate"></a>`startDate` **{warning-solid}** | [`String`](#string) | **Deprecated:** Manual iteration updates are deprecated, only `description` updates will be allowed in the future. Deprecated in 14.10. |
| <a id="mutationupdateiterationtitle"></a>`title` **{warning-solid}** | [`String`](#string) | **Deprecated:** Manual iteration updates are deprecated, only `description` updates will be allowed in the future. Deprecated in 14.10. |
| <a id="mutationupdateiterationstartdate"></a>`startDate` | [`String`](#string) | Start date of the iteration. |
| <a id="mutationupdateiterationtitle"></a>`title` | [`String`](#string) | Title of the iteration. |
#### Fields
@ -22103,6 +22096,17 @@ Field that are available while modifying the custom mapping attributes for an HT
| <a id="boardissueinputweight"></a>`weight` | [`String`](#string) | Filter by weight. |
| <a id="boardissueinputweightwildcardid"></a>`weightWildcardId` | [`WeightWildcardId`](#weightwildcardid) | Filter by weight ID wildcard. Incompatible with weight. |
### `CiVariableInput`
Attributes for defining a CI/CD variable.
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="civariableinputkey"></a>`key` | [`String!`](#string) | Name of the variable. |
| <a id="civariableinputvalue"></a>`value` | [`String!`](#string) | Value of the variable. |
### `CommitAction`
#### Arguments

View File

@ -62,16 +62,14 @@ After you configure the OIDC and role, the GitLab CI/CD job can retrieve a tempo
assume role:
script:
- >
STS=($(aws sts assume-role-with-web-identity
export $(printf "AWS_ACCESS_KEY_ID=%s AWS_SECRET_ACCESS_KEY=%s AWS_SESSION_TOKEN=%s"
$(aws sts assume-role-with-web-identity
--role-arn ${ROLE_ARN}
--role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}"
--web-identity-token $CI_JOB_JWT_V2
--duration-seconds 3600
--query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]'
--output text))
- export AWS_ACCESS_KEY_ID="${STS[0]}"
- export AWS_SECRET_ACCESS_KEY="${STS[1]}"
- export AWS_SESSION_TOKEN="${STS[2]}"
- aws sts get-caller-identity
```

View File

@ -889,3 +889,23 @@ Filter entries where stale runners were removed:
}
}
```
## Determine which runners need to be upgraded **(ULTIMATE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/365078) in GitLab 15.3.
The version of GitLab Runner used by your runners should be
[kept up-to-date](https://docs.gitlab.com/runner/index.html#gitlab-runner-versions).
To determine which runners need to be upgraded:
1. View the list of runners:
- For a group, on the top bar, select **Menu > Groups** and on the left sidebar, select **CI/CD > Runners**.
- For the instance, select **Menu > Admin** and on the left sidebar, select **Runners**.
1. Above the list of runners, view the status:
- **Outdated - recommended**: The runner does not have the latest `PATCH` version, which may make it vulnerable
to security or high severity bugs. Or, the runner is one or more `MAJOR` versions behind your GitLab instance, so some features may not be available or work properly.
- **Outdated - available**: Newer versions are available but upgrading is not critical.
1. Filter the list by status to view which individual runners need to be upgraded.

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -18,5 +18,25 @@ The number of minutes you can use on these runners depends on the
[maximum number of CI/CD minutes](../pipelines/cicd_minutes.md)
in your [subscription plan](https://about.gitlab.com/pricing/).
If you use self-managed GitLab or you use GitLab.com but want to use your own runners, you can
[install and configure your own runners](https://docs.gitlab.com/runner/install/).
## Security for GitLab SaaS runners
GitLab SaaS runners on Linux and Windows run on Google Compute Platform. The [Google Infrastructure Security Design Overview whitepaper](https://cloud.google.com/docs/security/infrastructure/design/resources/google_infrastructure_whitepaper_fa.pdf) provides an overview of how Google designs security into its technical infrastructure. The GitLab [Trust Center](https://about.gitlab.com/security/) and [GitLab Security Compliance Controls](https://about.staging.gitlab.com/handbook/engineering/security/security-assurance/security-compliance/sec-controls.html) pages provide an overview of the Security and Compliance controls that govern the GitLab SaaS runners.
The runner that serves as a Runner Manager automatically initiates the creation and deletion of the virtual machines (VMs) used for CI jobs. When the Runner Manager picks up a GitLab SaaS CI job, it automatically executes that job on a new VM. There is no human or manual intervention in this process. The following section provides an overview of the additional built-in layers that harden the security of the GitLab Runner SaaS CI build environment.
### Security of CI job execution on GitLab Runner SaaS (Linux, Windows)
A dedicated temporary runner VM hosts and runs each CI job. On GitLab SaaS, two CI jobs never run on the same VM.
![Job isolation](img/build_isolation.png)
In this example, there are three jobs in the project's pipeline. Therefore, there are three temporary VMs used to run that pipeline, or one VM per job.
GitLab sends the command to remove the temporary runner VM to the Google Compute API immediately after the CI job completes. The [Google Compute Engine hypervisor](https://cloud.google.com/blog/products/gcp/7-ways-we-harden-our-kvm-hypervisor-at-google-cloud-security-in-plaintext) takes over the task of securely deleting the virtual machine and associated data.
### Network security of CI job virtual machines on GitLab Runner SaaS (Linux, Windows)
- Firewall rules only allow outbound communication from the temporary VM to the public internet.
- Inbound communication from the public internet to the temporary VM is not allowed.
- Firewall rules do not permit communication between VMs.
- The only internal communication allowed to the temporary VMs is from the Runner Manager.

View File

@ -21,6 +21,8 @@ module ContainerRegistry
REGISTRY_GITLAB_V1_API_FEATURE = 'gitlab_v1_api'
MAX_TAGS_PAGE_SIZE = 1000
def self.supports_gitlab_api?
with_dummy_client(return_value_if_disabled: false) do |client|
client.supports_gitlab_api?
@ -86,6 +88,7 @@ module ContainerRegistry
end
end
# https://gitlab.com/gitlab-org/container-registry/-/blob/master/docs-gitlab/api.md#get-repository-details
def repository_details(path, sizing: nil)
with_token_faraday do |faraday_client|
req = faraday_client.get("/gitlab/v1/repositories/#{path}/") do |req|
@ -98,6 +101,26 @@ module ContainerRegistry
end
end
# https://gitlab.com/gitlab-org/container-registry/-/blob/master/docs-gitlab/api.md#list-repository-tags
def tags(path, page_size: 100, last: nil)
limited_page_size = [page_size, MAX_TAGS_PAGE_SIZE].min
with_token_faraday do |faraday_client|
response = faraday_client.get("/gitlab/v1/repositories/#{path}/tags/list/") do |req|
req.params['n'] = limited_page_size
req.params['last'] = last if last
end
break {} unless response.success?
link_parser = Gitlab::Utils::LinkHeaderParser.new(response.headers['link'])
{
pagination: link_parser.parse,
response_body: response_body(response)
}
end
end
private
def start_import_for(path, pre:)

View File

@ -75,15 +75,28 @@ module ContainerRegistry
def created_at
return @created_at if @created_at
return unless config
strong_memoize(:memoized_created_at) do
next unless config
DateTime.rfc3339(config['created'])
rescue ArgumentError
nil
end
end
# this function will set and memoize a created_at
# to avoid a #config_blob call.
def force_created_at_from_iso8601(string_value)
date =
begin
DateTime.iso8601(string_value)
rescue ArgumentError
nil
end
instance_variable_set(ivar(:memoized_created_at), date)
end
def layers
return unless manifest

View File

@ -13,7 +13,8 @@ module Gitlab
accessibility: ::Gitlab::Ci::Parsers::Accessibility::Pa11y,
codequality: ::Gitlab::Ci::Parsers::Codequality::CodeClimate,
sast: ::Gitlab::Ci::Parsers::Security::Sast,
secret_detection: ::Gitlab::Ci::Parsers::Security::SecretDetection
secret_detection: ::Gitlab::Ci::Parsers::Security::SecretDetection,
cyclonedx: ::Gitlab::Ci::Parsers::Sbom::Cyclonedx
}
end

View File

@ -8,13 +8,9 @@ module Gitlab
SUPPORTED_SPEC_VERSIONS = %w[1.4].freeze
COMPONENT_ATTRIBUTES = %w[type name version].freeze
def initialize(json_data, report)
@json_data = json_data
@report = report
end
def parse!
@data = Gitlab::Json.parse(json_data)
def parse!(blob, sbom_report)
@report = sbom_report
@data = Gitlab::Json.parse(blob)
return unless valid?

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Gitlab
module Ci
module Reports
module Sbom
class Reports
attr_reader :reports
def initialize
@reports = []
end
def add_report(report)
@reports << report
end
end
end
end
end
end

View File

@ -0,0 +1,46 @@
# frozen_string_literal: true
module Gitlab
module Utils
# Parses Link http headers (as defined in https://www.rfc-editor.org/rfc/rfc5988.txt)
#
# The URI-references with their relation type are extracted and returned as a hash
# Example:
#
# header = '<http://test.org/TheBook/chapter2>; rel="previous", <http://test.org/TheBook/chapter4>; rel="next"'
#
# Gitlab::Utils::LinkHeaderParser.new(header).parse
# {
# previous: {
# uri: #<URI::HTTP http://test.org/TheBook/chapter2>
# },
# next: {
# uri: #<URI::HTTP http://test.org/TheBook/chapter4>
# }
# }
class LinkHeaderParser
REL_PATTERN = %r{rel="(\w+)"}.freeze
# to avoid parse really long URIs we limit the amount of characters allowed
URI_PATTERN = %r{<(.{1,500})>}.freeze
def initialize(header)
@header = header
end
def parse
return {} if @header.blank?
links = @header.split(',')
result = {}
links.each do |link|
direction = link[REL_PATTERN, 1]&.to_sym
uri = link[URI_PATTERN, 1]
result[direction] = { uri: URI(uri) } if direction && uri
end
result
end
end
end
end

View File

@ -22006,6 +22006,9 @@ msgstr ""
msgid "Iteration"
msgstr ""
msgid "Iteration cannot be created for cadence"
msgstr ""
msgid "Iteration changed to"
msgstr ""
@ -22021,7 +22024,10 @@ msgstr ""
msgid "Iterations"
msgstr ""
msgid "IterationsCadence|Manual iteration cadences are deprecated. Only automatic iteration cadences are allowed."
msgid "Iterations cadence not found"
msgstr ""
msgid "Iterations cannot be manually added to cadences that use automatic scheduling"
msgstr ""
msgid "IterationsCadence|The automation start date must come after the active iteration %{iteration_dates}."
@ -23999,9 +24005,6 @@ msgstr ""
msgid "Manual"
msgstr ""
msgid "Manual iteration cadences are deprecated. Only automatic iteration cadences are allowed."
msgstr ""
msgid "ManualOrdering|Couldn't save the order of the issues"
msgstr ""
@ -44398,6 +44401,12 @@ msgstr ""
msgid "WorkItem|Task deleted"
msgstr ""
msgid "WorkItem|Turn off confidentiality"
msgstr ""
msgid "WorkItem|Turn on confidentiality"
msgstr ""
msgid "WorkItem|Type"
msgstr ""

0
results.txt Normal file
View File

View File

@ -84,8 +84,10 @@ RSpec.describe 'Issue Sidebar' do
click_link user2.name
end
find('.js-right-sidebar').click
find('.block.assignee .edit-link').click
within '.js-right-sidebar' do
find('.block.assignee').click(x: 0, y: 0)
find('.block.assignee .edit-link').click
end
expect(page.all('.dropdown-menu-user li').length).to eq(1)
expect(find('.dropdown-input-field').value).to eq(user2.name)

View File

@ -155,7 +155,7 @@ RSpec.describe "Issues > User edits issue", :js do
page.within '.block.labels' do
# Remove `verisimilitude` label
within '.gl-label' do
within '.gl-label', text: 'verisimilitude' do
click_button 'Remove label'
end

View File

@ -1,5 +1,5 @@
import { GlDropdownItem, GlModal } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { GlModal } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
describe('WorkItemActions component', () => {
@ -7,12 +7,19 @@ describe('WorkItemActions component', () => {
let glModalDirective;
const findModal = () => wrapper.findComponent(GlModal);
const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
const findConfidentialityToggleButton = () =>
wrapper.findByTestId('confidentiality-toggle-action');
const findDeleteButton = () => wrapper.findByTestId('delete-action');
const createComponent = ({ canDelete = true } = {}) => {
const createComponent = ({
canUpdate = true,
canDelete = true,
isConfidential = false,
isParentConfidential = false,
} = {}) => {
glModalDirective = jest.fn();
wrapper = shallowMount(WorkItemActions, {
propsData: { workItemId: '123', canDelete },
wrapper = shallowMountExtended(WorkItemActions, {
propsData: { workItemId: '123', canUpdate, canDelete, isConfidential, isParentConfidential },
directives: {
glModal: {
bind(_, { value }) {
@ -34,27 +41,69 @@ describe('WorkItemActions component', () => {
expect(findModal().props('visible')).toBe(false);
});
it('shows confirm modal when clicking Delete work item', () => {
it('renders dropdown actions', () => {
createComponent();
findDeleteButton().vm.$emit('click');
expect(glModalDirective).toHaveBeenCalled();
expect(findConfidentialityToggleButton().exists()).toBe(true);
expect(findDeleteButton().exists()).toBe(true);
});
it('emits event when clicking OK button', () => {
createComponent();
describe('toggle confidentiality action', () => {
it.each`
isConfidential | buttonText
${true} | ${'Turn off confidentiality'}
${false} | ${'Turn on confidentiality'}
`(
'renders confidentiality toggle button with text "$buttonText"',
({ isConfidential, buttonText }) => {
createComponent({ isConfidential });
findModal().vm.$emit('ok');
expect(findConfidentialityToggleButton().text()).toBe(buttonText);
},
);
expect(wrapper.emitted('deleteWorkItem')).toEqual([[]]);
});
it('emits `toggleWorkItemConfidentiality` event when clicked', () => {
createComponent();
it('does not render when canDelete is false', () => {
createComponent({
canDelete: false,
findConfidentialityToggleButton().vm.$emit('click');
expect(wrapper.emitted('toggleWorkItemConfidentiality')[0]).toEqual([true]);
});
expect(wrapper.html()).toBe('');
it.each`
props | propName | value
${{ isParentConfidential: true }} | ${'isParentConfidential'} | ${true}
${{ canUpdate: false }} | ${'canUpdate'} | ${false}
`('does not render when $propName is $value', ({ props }) => {
createComponent(props);
expect(findConfidentialityToggleButton().exists()).toBe(false);
});
});
describe('delete action', () => {
it('shows confirm modal when clicked', () => {
createComponent();
findDeleteButton().vm.$emit('click');
expect(glModalDirective).toHaveBeenCalled();
});
it('emits event when clicking OK button', () => {
createComponent();
findModal().vm.$emit('ok');
expect(wrapper.emitted('deleteWorkItem')).toEqual([[]]);
});
it('does not render when canDelete is false', () => {
createComponent({
canDelete: false,
});
expect(wrapper.findByTestId('delete-action').exists()).toBe(false);
});
});
});

View File

@ -132,6 +132,14 @@ describe('WorkItemLinks', () => {
expect(findFirstLinksMenu().exists()).toBe(true);
});
it('renders confidentiality icon when child item is confidential', () => {
const children = wrapper.findAll('[data-testid="links-child"]');
const confidentialIcon = children.at(0).find('[data-testid="confidential-icon"]');
expect(confidentialIcon.exists()).toBe(true);
expect(confidentialIcon.props('name')).toBe('eye-slash');
});
describe('when no permission to update', () => {
beforeEach(async () => {
await createComponent({ response: workItemHierarchyNoUpdatePermissionResponse });

View File

@ -25,6 +25,7 @@ export const workItemQueryResponse = {
title: 'Test',
state: 'OPEN',
description: 'description',
confidential: false,
workItemType: {
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
@ -57,6 +58,7 @@ export const workItemQueryResponse = {
id: 'gid://gitlab/Issue/1',
iid: '5',
title: 'Parent title',
confidential: false,
},
children: {
nodes: [
@ -82,6 +84,7 @@ export const updateWorkItemMutationResponse = {
title: 'Updated title',
state: 'OPEN',
description: 'description',
confidential: false,
workItemType: {
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
@ -115,12 +118,23 @@ export const updateWorkItemMutationResponse = {
},
};
export const mockParent = {
parent: {
id: 'gid://gitlab/Issue/1',
iid: '5',
title: 'Parent title',
confidential: false,
},
};
export const workItemResponseFactory = ({
canUpdate = false,
canDelete = false,
allowsMultipleAssignees = true,
assigneesWidgetPresent = true,
weightWidgetPresent = true,
parent = null,
confidential = false,
parent = mockParent.parent,
} = {}) => ({
data: {
workItem: {
@ -129,13 +143,14 @@ export const workItemResponseFactory = ({
title: 'Updated title',
state: 'OPEN',
description: 'description',
confidential,
workItemType: {
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
name: 'Task',
},
userPermissions: {
deleteWorkItem: false,
deleteWorkItem: canDelete,
updateWorkItem: canUpdate,
},
widgets: [
@ -216,6 +231,7 @@ export const createWorkItemMutationResponse = {
title: 'Updated title',
state: 'OPEN',
description: 'description',
confidential: false,
workItemType: {
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
@ -243,6 +259,7 @@ export const createWorkItemFromTaskMutationResponse = {
id: 'gid://gitlab/WorkItem/1',
title: 'Updated title',
state: 'OPEN',
confidential: false,
workItemType: {
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
@ -267,6 +284,7 @@ export const createWorkItemFromTaskMutationResponse = {
title: 'Updated title',
state: 'OPEN',
description: '',
confidential: false,
workItemType: {
__typename: 'WorkItemType',
id: 'gid://gitlab/WorkItems::Type/5',
@ -399,6 +417,7 @@ export const workItemHierarchyNoUpdatePermissionResponse = {
},
title: 'xyz',
state: 'OPEN',
confidential: false,
__typename: 'WorkItem',
},
],
@ -444,6 +463,7 @@ export const workItemHierarchyResponse = {
},
title: 'xyz',
state: 'OPEN',
confidential: true,
__typename: 'WorkItem',
},
{
@ -454,6 +474,7 @@ export const workItemHierarchyResponse = {
},
title: 'abc',
state: 'CLOSED',
confidential: false,
__typename: 'WorkItem',
},
{
@ -464,6 +485,7 @@ export const workItemHierarchyResponse = {
},
title: 'bar',
state: 'OPEN',
confidential: false,
__typename: 'WorkItem',
},
{
@ -474,6 +496,7 @@ export const workItemHierarchyResponse = {
},
title: 'foobar',
state: 'OPEN',
confidential: false,
__typename: 'WorkItem',
},
],
@ -505,6 +528,7 @@ export const changeWorkItemParentMutationResponse = {
id: 'gid://gitlab/WorkItem/2',
state: 'OPEN',
title: 'Foo',
confidential: false,
widgets: [
{
__typename: 'WorkItemWidgetHierarchy',
@ -662,11 +686,3 @@ export const projectLabelsResponse = {
},
},
};
export const mockParent = {
parent: {
id: 'gid://gitlab/Issue/1',
iid: '5',
title: 'Parent title',
},
};

View File

@ -1,11 +1,12 @@
import { GlAlert, GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { GlAlert, GlBadge, GlLoadingIcon, GlSkeletonLoader, GlButton } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import WorkItemDetail from '~/work_items/components/work_item_detail.vue';
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
import WorkItemDescription from '~/work_items/components/work_item_description.vue';
import WorkItemState from '~/work_items/components/work_item_state.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
@ -16,6 +17,8 @@ import WorkItemInformation from '~/work_items/components/work_item_information.v
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subscription.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql';
import { temporaryConfig } from '~/work_items/graphql/provider';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import {
@ -30,12 +33,19 @@ describe('WorkItemDetail component', () => {
Vue.use(VueApollo);
const workItemQueryResponse = workItemResponseFactory();
const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true });
const workItemQueryResponseWithoutParent = workItemResponseFactory({
parent: null,
canUpdate: true,
canDelete: true,
});
const successHandler = jest.fn().mockResolvedValue(workItemQueryResponse);
const initialSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse);
const findAlert = () => wrapper.findComponent(GlAlert);
const findSkeleton = () => wrapper.findComponent(GlSkeletonLoader);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findWorkItemActions = () => wrapper.findComponent(WorkItemActions);
const findWorkItemTitle = () => wrapper.findComponent(WorkItemTitle);
const findWorkItemState = () => wrapper.findComponent(WorkItemState);
const findWorkItemDescription = () => wrapper.findComponent(WorkItemDescription);
@ -51,17 +61,21 @@ describe('WorkItemDetail component', () => {
const createComponent = ({
isModal = false,
updateInProgress = false,
workItemId = workItemQueryResponse.data.workItem.id,
handler = successHandler,
subscriptionHandler = initialSubscriptionHandler,
confidentialityMock = [updateWorkItemMutation, jest.fn()],
workItemsMvc2Enabled = false,
includeWidgets = false,
error = undefined,
} = {}) => {
wrapper = shallowMount(WorkItemDetail, {
apolloProvider: createMockApollo(
[
[workItemQuery, handler],
[workItemTitleSubscription, subscriptionHandler],
confidentialityMock,
],
{},
{
@ -69,6 +83,12 @@ describe('WorkItemDetail component', () => {
},
),
propsData: { isModal, workItemId },
data() {
return {
updateInProgress,
error,
};
},
provide: {
glFeatures: {
workItemsMvc2: workItemsMvc2Enabled,
@ -146,6 +166,145 @@ describe('WorkItemDetail component', () => {
});
});
describe('confidentiality', () => {
const errorMessage = 'Mutation failed';
const confidentialWorkItem = workItemResponseFactory({
confidential: true,
});
// Mocks for work item without parent
const withoutParentExpectedInputVars = {
id: workItemQueryResponse.data.workItem.id,
confidential: true,
};
const toggleConfidentialityWithoutParentHandler = jest.fn().mockResolvedValue({
data: {
workItemUpdate: {
workItem: confidentialWorkItem.data.workItem,
errors: [],
},
},
});
const withoutParentHandlerMock = jest
.fn()
.mockResolvedValue(workItemQueryResponseWithoutParent);
const confidentialityWithoutParentMock = [
updateWorkItemMutation,
toggleConfidentialityWithoutParentHandler,
];
const confidentialityWithoutParentFailureMock = [
updateWorkItemMutation,
jest.fn().mockRejectedValue(new Error(errorMessage)),
];
// Mocks for work item with parent
const withParentExpectedInputVars = {
id: mockParent.parent.id,
taskData: { id: workItemQueryResponse.data.workItem.id, confidential: true },
};
const toggleConfidentialityWithParentHandler = jest.fn().mockResolvedValue({
data: {
workItemUpdate: {
workItem: {
id: confidentialWorkItem.data.workItem.id,
descriptionHtml: confidentialWorkItem.data.workItem.description,
},
task: {
workItem: confidentialWorkItem.data.workItem,
confidential: true,
},
errors: [],
},
},
});
const confidentialityWithParentMock = [
updateWorkItemTaskMutation,
toggleConfidentialityWithParentHandler,
];
const confidentialityWithParentFailureMock = [
updateWorkItemTaskMutation,
jest.fn().mockRejectedValue(new Error(errorMessage)),
];
describe.each`
context | handlerMock | confidentialityMock | confidentialityFailureMock | inputVariables
${'no parent'} | ${withoutParentHandlerMock} | ${confidentialityWithoutParentMock} | ${confidentialityWithoutParentFailureMock} | ${withoutParentExpectedInputVars}
${'parent'} | ${successHandler} | ${confidentialityWithParentMock} | ${confidentialityWithParentFailureMock} | ${withParentExpectedInputVars}
`(
'when work item has $context',
({ handlerMock, confidentialityMock, confidentialityFailureMock, inputVariables }) => {
it('renders confidential badge when work item is confidential', async () => {
createComponent({
handler: jest.fn().mockResolvedValue(confidentialWorkItem),
confidentialityMock,
});
await waitForPromises();
const confidentialBadge = wrapper.findComponent(GlBadge);
expect(confidentialBadge.exists()).toBe(true);
expect(confidentialBadge.props()).toMatchObject({
variant: 'warning',
icon: 'eye-slash',
});
expect(confidentialBadge.text()).toBe('Confidential');
});
it('renders gl-loading-icon while update mutation is in progress', async () => {
createComponent({
handler: handlerMock,
confidentialityMock,
});
await waitForPromises();
findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
await nextTick();
expect(findLoadingIcon().exists()).toBe(true);
});
it('emits workItemUpdated and shows confidentiality badge when mutation is successful', async () => {
createComponent({
handler: handlerMock,
confidentialityMock,
});
await waitForPromises();
findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
await waitForPromises();
expect(wrapper.emitted('workItemUpdated')).toEqual([[{ confidential: true }]]);
expect(confidentialityMock[1]).toHaveBeenCalledWith({
input: inputVariables,
});
expect(findLoadingIcon().exists()).toBe(false);
});
it('shows alert message when mutation fails', async () => {
createComponent({
handler: handlerMock,
confidentialityMock: confidentialityFailureMock,
});
await waitForPromises();
findWorkItemActions().vm.$emit('toggleWorkItemConfidentiality', true);
await waitForPromises();
expect(wrapper.emitted('workItemUpdated')).toBeFalsy();
await nextTick();
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(errorMessage);
expect(findLoadingIcon().exists()).toBe(false);
});
},
);
});
describe('description', () => {
it('does not show description widget if loading description fails', () => {
createComponent();
@ -169,7 +328,7 @@ describe('WorkItemDetail component', () => {
});
it('does not show secondary breadcrumbs if there is not a parent', async () => {
createComponent();
createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) });
await waitForPromises();
@ -177,7 +336,7 @@ describe('WorkItemDetail component', () => {
});
it('shows work item type if there is not a parent', async () => {
createComponent();
createComponent({ handler: jest.fn().mockResolvedValue(workItemQueryResponseWithoutParent) });
await waitForPromises();
expect(findWorkItemType().exists()).toBe(true);

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['CiVariableInput'] do
include GraphqlHelpers
it 'has the correct arguments' do
expect(described_class.arguments.keys).to match_array(%w[key value])
end
end

View File

@ -212,6 +212,105 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
end
end
describe '#tags' do
let(:path) { 'namespace/path/to/repository' }
let(:page_size) { 100 }
let(:last) { nil }
let(:response) do
[
{
name: '0.1.0',
digest: 'sha256:1234567890',
media_type: 'application/vnd.oci.image.manifest.v1+json',
size_bytes: 1234567890,
created_at: 5.minutes.ago
},
{
name: 'latest',
digest: 'sha256:1234567892',
media_type: 'application/vnd.oci.image.manifest.v1+json',
size_bytes: 1234567892,
created_at: 10.minutes.ago
}
]
end
subject { client.tags(path, page_size: page_size, last: last) }
context 'with valid parameters' do
let(:expected) do
{
pagination: {},
response_body: ::Gitlab::Json.parse(response.to_json)
}
end
before do
stub_tags(path, page_size: page_size, respond_with: response)
end
it { is_expected.to eq(expected) }
end
context 'with a response with a link header' do
let(:next_page_url) { 'http://sandbox.org/test?last=b' }
let(:expected) do
{
pagination: { next: { uri: URI(next_page_url) } },
response_body: ::Gitlab::Json.parse(response.to_json)
}
end
before do
stub_tags(path, page_size: page_size, next_page_url: next_page_url, respond_with: response)
end
it { is_expected.to eq(expected) }
end
context 'with a large page size set' do
let(:page_size) { described_class::MAX_TAGS_PAGE_SIZE + 1000 }
let(:expected) do
{
pagination: {},
response_body: ::Gitlab::Json.parse(response.to_json)
}
end
before do
stub_tags(path, page_size: described_class::MAX_TAGS_PAGE_SIZE, respond_with: response)
end
it { is_expected.to eq(expected) }
end
context 'with a last parameter set' do
let(:last) { 'test' }
let(:expected) do
{
pagination: {},
response_body: ::Gitlab::Json.parse(response.to_json)
}
end
before do
stub_tags(path, page_size: page_size, last: last, respond_with: response)
end
it { is_expected.to eq(expected) }
end
context 'with non successful response' do
before do
stub_tags(path, page_size: page_size, status_code: 404)
end
it { is_expected.to eq({}) }
end
end
describe '.supports_gitlab_api?' do
subject { described_class.supports_gitlab_api? }
@ -389,4 +488,30 @@ RSpec.describe ContainerRegistry::GitlabApiClient do
.with(headers: headers)
.to_return(status: status_code, body: respond_with.to_json, headers: { 'Content-Type' => described_class::JSON_TYPE })
end
def stub_tags(path, page_size: nil, last: nil, next_page_url: nil, status_code: 200, respond_with: {})
params = { n: page_size, last: last }.compact
url = "#{registry_api_url}/gitlab/v1/repositories/#{path}/tags/list/"
if params.present?
url += "?#{params.map { |param, val| "#{param}=#{val}" }.join('&')}"
end
request_headers = { 'Accept' => described_class::JSON_TYPE }
request_headers['Authorization'] = "bearer #{token}" if token
response_headers = { 'Content-Type' => described_class::JSON_TYPE }
if next_page_url
response_headers['Link'] = "<#{next_page_url}>; rel=\"next\""
end
stub_request(:get, url)
.with(headers: request_headers)
.to_return(
status: status_code,
body: respond_with.to_json,
headers: response_headers
)
end
end

View File

@ -205,6 +205,41 @@ RSpec.describe ContainerRegistry::Tag do
it_behaves_like 'a processable'
end
describe '#force_created_at_from_iso8601' do
subject { tag.force_created_at_from_iso8601(input) }
shared_examples 'setting and caching the created_at value' do
it 'sets and caches the created_at value' do
expect(tag).not_to receive(:config)
subject
expect(tag.created_at).to eq(expected_value)
end
end
context 'with a valid input' do
let(:input) { 2.days.ago.iso8601 }
let(:expected_value) { DateTime.iso8601(input) }
it_behaves_like 'setting and caching the created_at value'
end
context 'with a nil input' do
let(:input) { nil }
let(:expected_value) { nil }
it_behaves_like 'setting and caching the created_at value'
end
context 'with an invalid input' do
let(:input) { 'not a timestamp' }
let(:expected_value) { nil }
it_behaves_like 'setting and caching the created_at value'
end
end
end
end
end

View File

@ -18,7 +18,7 @@ RSpec.describe Gitlab::Ci::Parsers::Sbom::Cyclonedx do
}
end
subject(:parse!) { described_class.new(raw_report_data, report).parse! }
subject(:parse!) { described_class.new.parse!(raw_report_data, report) }
before do
allow_next_instance_of(Gitlab::Ci::Parsers::Sbom::Validators::CyclonedxSchemaValidator) do |validator|

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::Ci::Reports::Sbom::Reports do
subject(:reports_list) { described_class.new }
describe '#add_report' do
let(:rep1) { Gitlab::Ci::Reports::Sbom::Report.new }
let(:rep2) { Gitlab::Ci::Reports::Sbom::Report.new }
it 'appends the report to the report list' do
reports_list.add_report(rep1)
reports_list.add_report(rep2)
expect(reports_list.reports.length).to eq(2)
expect(reports_list.reports.first).to eq(rep1)
expect(reports_list.reports.last).to eq(rep2)
end
end
end

View File

@ -0,0 +1,75 @@
# frozen_string_literal: true
require 'fast_spec_helper'
RSpec.describe Gitlab::Utils::LinkHeaderParser do
let(:parser) { described_class.new(header) }
describe '#parse' do
subject { parser.parse }
context 'with a valid header' do
let(:header) { generate_header(next: 'http://sandbox.org/next') }
let(:expected) { { next: { uri: URI('http://sandbox.org/next') } } }
it { is_expected.to eq(expected) }
context 'with multiple links' do
let(:header) { generate_header(next: 'http://sandbox.org/next', previous: 'http://sandbox.org/previous') }
let(:expected) do
{
next: { uri: URI('http://sandbox.org/next') },
previous: { uri: URI('http://sandbox.org/previous') }
}
end
it { is_expected.to eq(expected) }
end
context 'with an incomplete uri' do
let(:header) { '<http://sandbox.org/next; rel="next"' }
it { is_expected.to eq({}) }
end
context 'with no rel' do
let(:header) { '<http://sandbox.org/next>; direction="next"' }
it { is_expected.to eq({}) }
end
context 'with multiple rel elements' do
# check https://datatracker.ietf.org/doc/html/rfc5988#section-5.3:
# occurrences after the first MUST be ignored by parsers
let(:header) { '<http://sandbox.org/next>; rel="next"; rel="dummy"' }
it { is_expected.to eq(expected) }
end
context 'when the url is too long' do
let(:header) { "<http://sandbox.org/#{'a' * 500}>; rel=\"next\"" }
it { is_expected.to eq({}) }
end
end
context 'with nil header' do
let(:header) { nil }
it { is_expected.to eq({}) }
end
context 'with empty header' do
let(:header) { '' }
it { is_expected.to eq({}) }
end
def generate_header(links)
stringified_links = links.map do |rel, url|
"<#{url}>; rel=\"#{rel}\""
end
stringified_links.join(', ')
end
end
end

View File

@ -5609,4 +5609,58 @@ RSpec.describe Ci::Build do
let!(:model) { create(:ci_build, user: create(:user)) }
let!(:parent) { model.user }
end
describe '#clone' do
let_it_be(:user) { FactoryBot.build(:user) }
context 'when given new job variables' do
context 'when the cloned build has an action' do
it 'applies the new job variables' do
build = create(:ci_build, :actionable)
create(:ci_job_variable, job: build, key: 'TEST_KEY', value: 'old value')
create(:ci_job_variable, job: build, key: 'OLD_KEY', value: 'i will not live for long')
new_build = build.clone(current_user: user, new_job_variables_attributes: [
{ key: 'TEST_KEY', value: 'new value' },
{ key: 'NEW_KEY', value: 'exciting new value' }
])
new_build.save!
expect(new_build.job_variables.count).to be(2)
expect(new_build.job_variables.pluck(:key)).to contain_exactly('TEST_KEY', 'NEW_KEY')
expect(new_build.job_variables.map(&:value)).to contain_exactly('new value', 'exciting new value')
end
end
context 'when the cloned build does not have an action' do
it 'applies the old job variables' do
build = create(:ci_build)
create(:ci_job_variable, job: build, key: 'TEST_KEY', value: 'old value')
new_build = build.clone(current_user: user, new_job_variables_attributes: [
{ key: 'TEST_KEY', value: 'new value' }
])
new_build.save!
expect(new_build.job_variables.count).to be(1)
expect(new_build.job_variables.pluck(:key)).to contain_exactly('TEST_KEY')
expect(new_build.job_variables.map(&:value)).to contain_exactly('old value')
end
end
end
context 'when not given new job variables' do
it 'applies the old job variables' do
build = create(:ci_build)
create(:ci_job_variable, job: build, key: 'TEST_KEY', value: 'old value')
new_build = build.clone(current_user: user)
new_build.save!
expect(new_build.job_variables.count).to be(1)
expect(new_build.job_variables.pluck(:key)).to contain_exactly('TEST_KEY')
expect(new_build.job_variables.map(&:value)).to contain_exactly('old value')
end
end
end
end

View File

@ -525,6 +525,162 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
describe '#each_tags_page' do
let(:page_size) { 100 }
shared_examples 'iterating through a page' do |expected_tags: true|
it 'iterates through one page' do
expect(repository.gitlab_api_client).to receive(:tags)
.with(repository.path, page_size: page_size, last: nil)
.and_return(client_response)
expect { |b| repository.each_tags_page(page_size: page_size, &b) }
.to yield_with_args(expected_tags ? expected_tags_from(client_response_tags) : [])
end
end
context 'with an empty page' do
let(:client_response) { { pagination: {}, response_body: [] } }
it_behaves_like 'iterating through a page', expected_tags: false
end
context 'with one page' do
let(:client_response) { { pagination: {}, response_body: client_response_tags } }
let(:client_response_tags) do
[
{
'name' => '0.1.0',
'created_at' => '2022-06-07T12:10:12.412+00:00'
},
{
'name' => 'latest',
'created_at' => '2022-06-07T12:11:13.633+00:00'
}
]
end
context 'with a nil created_at' do
let(:client_response_tags) { ['name' => '0.1.0', 'created_at' => nil] }
it_behaves_like 'iterating through a page'
end
context 'with an invalid created_at' do
let(:client_response_tags) { ['name' => '0.1.0', 'created_at' => 'not_a_timestamp'] }
it_behaves_like 'iterating through a page'
end
end
context 'with two pages' do
let(:client_response1) { { pagination: { next: { uri: URI('http://localhost/next?last=latest') } }, response_body: client_response_tags1 } }
let(:client_response_tags1) do
[
{
'name' => '0.1.0',
'created_at' => '2022-06-07T12:10:12.412+00:00'
},
{
'name' => 'latest',
'created_at' => '2022-06-07T12:11:13.633+00:00'
}
]
end
let(:client_response2) { { pagination: {}, response_body: client_response_tags2 } }
let(:client_response_tags2) do
[
{
'name' => '1.2.3',
'created_at' => '2022-06-10T12:10:15.412+00:00'
},
{
'name' => '2.3.4',
'created_at' => '2022-06-11T12:11:17.633+00:00'
}
]
end
it 'iterates through two pages' do
expect(repository.gitlab_api_client).to receive(:tags)
.with(repository.path, page_size: page_size, last: nil)
.and_return(client_response1)
expect(repository.gitlab_api_client).to receive(:tags)
.with(repository.path, page_size: page_size, last: 'latest')
.and_return(client_response2)
expect { |b| repository.each_tags_page(page_size: page_size, &b) }
.to yield_successive_args(expected_tags_from(client_response_tags1), expected_tags_from(client_response_tags2))
end
end
context 'when max pages is reached' do
before do
stub_const('ContainerRepository::MAX_TAGS_PAGES', 0)
end
it 'raises an error' do
expect { repository.each_tags_page(page_size: page_size) {} }
.to raise_error(StandardError, 'too many pages requested')
end
end
context 'without a block set' do
it 'raises an Argument error' do
expect { repository.each_tags_page(page_size: page_size) }.to raise_error(ArgumentError, 'block not given')
end
end
context 'without a page size set' do
let(:client_response) { { pagination: {}, response_body: [] } }
it 'uses a default size' do
expect(repository.gitlab_api_client).to receive(:tags)
.with(repository.path, page_size: 100, last: nil)
.and_return(client_response)
expect { |b| repository.each_tags_page(&b) }.to yield_with_args([])
end
end
context 'with an empty client response' do
let(:client_response) { {} }
it 'breaks the loop' do
expect(repository.gitlab_api_client).to receive(:tags)
.with(repository.path, page_size: page_size, last: nil)
.and_return(client_response)
expect { |b| repository.each_tags_page(page_size: page_size, &b) }.not_to yield_control
end
end
context 'with a nil page' do
let(:client_response) { { pagination: {}, response_body: nil } }
it_behaves_like 'iterating through a page', expected_tags: false
end
context 'calling on a non migrated repository' do
before do
repository.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.days)
end
it 'raises an Argument error' do
expect { repository.each_tags_page }.to raise_error(ArgumentError, 'not a migrated repository')
end
end
def expected_tags_from(client_tags)
client_tags.map do |tag|
created_at =
begin
DateTime.iso8601(tag['created_at'])
rescue ArgumentError
nil
end
an_object_having_attributes(name: tag['name'], created_at: created_at)
end
end
end
describe '#tags_count' do
it 'returns the count of tags' do
expect(repository.tags_count).to eq(1)
@ -1348,6 +1504,28 @@ RSpec.describe ContainerRepository, :aggregate_failures do
end
end
describe '#migrated?' do
subject { repository.migrated? }
it { is_expected.to eq(true) }
context 'with a created_at older than phase 1 ends' do
before do
repository.update!(created_at: described_class::MIGRATION_PHASE_1_ENDED_AT - 3.days)
end
it { is_expected.to eq(false) }
context 'with migration state set to import_done' do
before do
repository.update!(migration_state: 'import_done')
end
it { is_expected.to eq(true) }
end
end
end
context 'with repositories' do
let_it_be_with_reload(:repository) { create(:container_repository, :cleanup_unscheduled) }
let_it_be(:other_repository) { create(:container_repository, :cleanup_unscheduled) }

View File

@ -35,8 +35,8 @@ RSpec.describe 'Query.project(fullPath).pipelines.jobs.manualVariables' do
project.add_maintainer(user)
end
it 'returns the manual variables for the jobs' do
job = create(:ci_build, :manual, pipeline: pipeline)
it 'returns the manual variables for actionable jobs' do
job = create(:ci_build, :actionable, pipeline: pipeline)
create(:ci_job_variable, key: 'MANUAL_TEST_VAR', job: job)
post_graphql(query, current_user: user)
@ -46,8 +46,8 @@ RSpec.describe 'Query.project(fullPath).pipelines.jobs.manualVariables' do
expect(variables_data.map { |var| var['key'] }).to match_array(['MANUAL_TEST_VAR'])
end
it 'does not fetch job variables for jobs that are not manual' do
job = create(:ci_build, pipeline: pipeline)
it 'does not fetch job variables for jobs that are not actionable' do
job = create(:ci_build, pipeline: pipeline, status: :manual)
create(:ci_job_variable, key: 'THIS_VAR_WOULD_SHOULD_NEVER_EXIST', job: job)
post_graphql(query, current_user: user)

View File

@ -47,6 +47,38 @@ RSpec.describe 'JobRetry' do
expect(new_job).not_to be_retried
end
context 'when given CI variables' do
let(:job) { create(:ci_build, :success, :actionable, pipeline: pipeline, name: 'build') }
let(:mutation) do
variables = {
id: job.to_global_id.to_s,
variables: { key: 'MANUAL_VAR', value: 'test manual var' }
}
graphql_mutation(:job_retry, variables,
<<-QL
errors
job {
id
}
QL
)
end
it 'applies them to a retried manual job' do
post_graphql_mutation(mutation, current_user: user)
expect(response).to have_gitlab_http_status(:success)
new_job_id = GitlabSchema.object_from_id(mutation_response['job']['id']).sync.id
new_job = ::Ci::Build.find(new_job_id)
expect(new_job.job_variables.count).to be(1)
expect(new_job.job_variables.first.key).to eq('MANUAL_VAR')
expect(new_job.job_variables.first.value).to eq('test manual var')
end
end
context 'when the job is not retryable' do
let(:job) { create(:ci_build, :retried, pipeline: pipeline) }

View File

@ -17,6 +17,7 @@ RSpec.describe Ci::RetryJobService do
name: 'test')
end
let(:job_variables_attributes) { [{ key: 'MANUAL_VAR', value: 'manual test var' }] }
let(:user) { developer }
let(:service) { described_class.new(project, user) }
@ -206,6 +207,14 @@ RSpec.describe Ci::RetryJobService do
include_context 'retryable bridge'
it_behaves_like 'clones the job'
context 'when given variables' do
let(:new_job) { service.clone!(job, variables: job_variables_attributes) }
it 'does not give variables to the new bridge' do
expect { new_job }.not_to raise_error
end
end
end
context 'when the job to be cloned is a build' do
@ -250,6 +259,28 @@ RSpec.describe Ci::RetryJobService do
expect { new_job }.not_to change { Environment.count }
end
end
context 'when given variables' do
let(:new_job) { service.clone!(job, variables: job_variables_attributes) }
context 'when the build is actionable' do
let_it_be_with_refind(:job) { create(:ci_build, :actionable, pipeline: pipeline) }
it 'gives variables to the new build' do
expect(new_job.job_variables.count).to be(1)
expect(new_job.job_variables.first.key).to eq('MANUAL_VAR')
expect(new_job.job_variables.first.value).to eq('manual test var')
end
end
context 'when the build is not actionable' do
let_it_be_with_refind(:job) { create(:ci_build, pipeline: pipeline) }
it 'does not give variables to the new build' do
expect(new_job.job_variables.count).to be_zero
end
end
end
end
end
@ -260,6 +291,14 @@ RSpec.describe Ci::RetryJobService do
include_context 'retryable bridge'
it_behaves_like 'retries the job'
context 'when given variables' do
let(:new_job) { service.clone!(job, variables: job_variables_attributes) }
it 'does not give variables to the new bridge' do
expect { new_job }.not_to raise_error
end
end
end
context 'when the job to be retried is a build' do
@ -288,6 +327,28 @@ RSpec.describe Ci::RetryJobService do
expect { service.execute(job) }.not_to exceed_all_query_limit(control_count)
end
end
context 'when given variables' do
let(:new_job) { service.clone!(job, variables: job_variables_attributes) }
context 'when the build is actionable' do
let_it_be_with_refind(:job) { create(:ci_build, :actionable, pipeline: pipeline) }
it 'gives variables to the new build' do
expect(new_job.job_variables.count).to be(1)
expect(new_job.job_variables.first.key).to eq('MANUAL_VAR')
expect(new_job.job_variables.first.value).to eq('manual test var')
end
end
context 'when the build is not actionable' do
let_it_be_with_refind(:job) { create(:ci_build, pipeline: pipeline) }
it 'does not give variables to the new build' do
expect(new_job.job_variables.count).to be_zero
end
end
end
end
end
end