diff options
80 files changed, 1584 insertions, 957 deletions
diff --git a/.rubocop_todo/rspec/before_all_role_assignment.yml b/.rubocop_todo/rspec/before_all_role_assignment.yml index 9f85e031775..5ab492b4a29 100644 --- a/.rubocop_todo/rspec/before_all_role_assignment.yml +++ b/.rubocop_todo/rspec/before_all_role_assignment.yml @@ -223,7 +223,6 @@ RSpec/BeforeAllRoleAssignment: - 'ee/spec/graphql/resolvers/boards/epic_lists_resolvers_spec.rb' - 'ee/spec/graphql/resolvers/ci/catalog/resource_resolver_spec.rb' - 'ee/spec/graphql/resolvers/ci/catalog/resources_resolver_spec.rb' - - 'ee/spec/graphql/resolvers/ci/catalog/versions_resolver_spec.rb' - 'ee/spec/graphql/resolvers/clusters/agents_resolver_spec.rb' - 'ee/spec/graphql/resolvers/compliance_management/merge_requests/compliance_violation_resolver_spec.rb' - 'ee/spec/graphql/resolvers/dast_site_validation_resolver_spec.rb' diff --git a/.rubocop_todo/style/inline_disable_annotation.yml b/.rubocop_todo/style/inline_disable_annotation.yml index faa79f6aa3a..ae0d5084358 100644 --- a/.rubocop_todo/style/inline_disable_annotation.yml +++ b/.rubocop_todo/style/inline_disable_annotation.yml @@ -2938,7 +2938,6 @@ Style/InlineDisableAnnotation: - 'spec/graphql/mutations/design_management/delete_spec.rb' - 'spec/graphql/resolvers/board_resolver_spec.rb' - 'spec/graphql/resolvers/boards_resolver_spec.rb' - - 'spec/graphql/resolvers/ci/catalog/versions_resolver_spec.rb' - 'spec/haml_lint/linter/inline_javascript_spec.rb' - 'spec/haml_lint/linter/no_plain_nodes_spec.rb' - 'spec/helpers/admin/abuse_reports_helper_spec.rb' @@ -504,7 +504,7 @@ group :test do # Moved in `test` because https://gitlab.com/gitlab-org/gitlab/-/issues/217527 gem 'derailed_benchmarks', require: false # rubocop:todo Gemfile/MissingFeatureCategory - gem 'gitlab_quality-test_tooling', '~> 1.5.2', require: false, feature_category: :tooling + gem 'gitlab_quality-test_tooling', '~> 1.5.3', require: false, feature_category: :tooling end gem 'octokit', '~> 6.0' # rubocop:todo Gemfile/MissingFeatureCategory diff --git a/Gemfile.checksum b/Gemfile.checksum index c04ec4641c3..124843b3032 100644 --- a/Gemfile.checksum +++ b/Gemfile.checksum @@ -221,7 +221,7 @@ {"name":"gitlab-styles","version":"11.0.0","platform":"ruby","checksum":"0dd8ec066ce9955ac51d3616c6bfded30f75bb526f39ff392ece6f43d5b9406b"}, {"name":"gitlab_chronic_duration","version":"0.12.0","platform":"ruby","checksum":"0d766944d415b5c831f176871ee8625783fc0c5bfbef2d79a3a616f207ffc16d"}, {"name":"gitlab_omniauth-ldap","version":"2.2.0","platform":"ruby","checksum":"bb4d20acb3b123ed654a8f6a47d3fac673ece7ed0b6992edb92dca14bad2838c"}, -{"name":"gitlab_quality-test_tooling","version":"1.5.2","platform":"ruby","checksum":"1fe87d513f005fa2ad6c35ca2bef10a262e6ab3cca3754ff5ccf121622eb45f9"}, +{"name":"gitlab_quality-test_tooling","version":"1.5.3","platform":"ruby","checksum":"d53f3d6319d8948091caefa9cd0d1e71719df4f26a3a9d4936458ffcb72bba30"}, {"name":"globalid","version":"1.1.0","platform":"ruby","checksum":"b337e1746f0c8cb0a6c918234b03a1ddeb4966206ce288fbb57779f59b2d154f"}, {"name":"gon","version":"6.4.0","platform":"ruby","checksum":"e3a618d659392890f1aa7db420f17c75fd7d35aeb5f8fe003697d02c4b88d2f0"}, {"name":"google-apis-androidpublisher_v3","version":"0.34.0","platform":"ruby","checksum":"d7e1d7dd92f79c498fe2082222a1740d788e022e660c135564b3fd299cab5425"}, diff --git a/Gemfile.lock b/Gemfile.lock index 1b9ebfe61b9..e348606bf90 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -716,7 +716,7 @@ GEM omniauth (>= 1.3, < 3) pyu-ruby-sasl (>= 0.0.3.3, < 0.1) rubyntlm (~> 0.5) - gitlab_quality-test_tooling (1.5.2) + gitlab_quality-test_tooling (1.5.3) activesupport (>= 6.1, < 7.2) amatch (~> 0.4.1) gitlab (~> 4.19) @@ -1886,7 +1886,7 @@ DEPENDENCIES gitlab-utils! gitlab_chronic_duration (~> 0.12) gitlab_omniauth-ldap (~> 2.2.0) - gitlab_quality-test_tooling (~> 1.5.2) + gitlab_quality-test_tooling (~> 1.5.3) gon (~> 6.4.0) google-apis-androidpublisher_v3 (~> 0.34.0) google-apis-cloudbilling_v1 (~> 0.21.0) diff --git a/app/assets/javascripts/api/bulk_imports_api.js b/app/assets/javascripts/api/bulk_imports_api.js index 248f5601705..a5c9f904c1f 100644 --- a/app/assets/javascripts/api/bulk_imports_api.js +++ b/app/assets/javascripts/api/bulk_imports_api.js @@ -1,12 +1,22 @@ import { buildApiUrl } from '~/api/api_utils'; import axios from '~/lib/utils/axios_utils'; -const BULK_IMPORT_ENTITIES_PATH = '/api/:version/bulk_imports/entities'; +const BULK_IMPORT_ENTITIES_PATH = '/api/:version/bulk_imports/:id/entities'; +const BULK_IMPORTS_ENTITIES_PATH = '/api/:version/bulk_imports/entities'; const BULK_IMPORT_ENTITIES_FAILURES_PATH = '/api/:version/bulk_imports/:id/entities/:entity_id/failures'; +export const getBulkImportHistory = (id, params = {}) => { + const bulkImportHistoryUrl = buildApiUrl(BULK_IMPORT_ENTITIES_PATH).replace( + ':id', + encodeURIComponent(id), + ); + + return axios.get(bulkImportHistoryUrl, { params }); +}; + export const getBulkImportsHistory = (params) => - axios.get(buildApiUrl(BULK_IMPORT_ENTITIES_PATH), { params }); + axios.get(buildApiUrl(BULK_IMPORTS_ENTITIES_PATH), { params }); export const getBulkImportFailures = (id, entityId, { page, perPage }) => { const failuresPath = buildApiUrl(BULK_IMPORT_ENTITIES_FAILURES_PATH) diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue index 4fded3aec60..c7fe9724485 100644 --- a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue @@ -38,7 +38,7 @@ export default { }, tooltipConfig: { boundary: 'viewport', - placement: 'bottom', + placement: 'top', customClass: 'gl-pointer-events-none', }, components: { diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/actions.js b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js index 04aca11b945..5247faef363 100644 --- a/app/assets/javascripts/ci/reports/codequality_report/store/actions.js +++ b/app/assets/javascripts/ci/reports/codequality_report/store/actions.js @@ -1,7 +1,7 @@ import pollUntilComplete from '~/lib/utils/poll_until_complete'; import { STATUS_NOT_FOUND } from '../../constants'; +import { parseCodeclimateMetrics } from '../utils/codequality_parser'; import * as types from './mutation_types'; -import { parseCodeclimateMetrics } from './utils/codequality_parser'; export const setPaths = ({ commit }, paths) => commit(types.SET_PATHS, paths); diff --git a/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js b/app/assets/javascripts/ci/reports/codequality_report/utils/codequality_parser.js index 417297df43c..417297df43c 100644 --- a/app/assets/javascripts/ci/reports/codequality_report/store/utils/codequality_parser.js +++ b/app/assets/javascripts/ci/reports/codequality_report/utils/codequality_parser.js diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js deleted file mode 100644 index ab5f01227fb..00000000000 --- a/app/assets/javascripts/contextual_sidebar.js +++ /dev/null @@ -1,112 +0,0 @@ -import { GlBreakpointInstance as bp, breakpoints } from '@gitlab/ui/dist/utils'; -import $ from 'jquery'; -import { debounce } from 'lodash'; -import { getCookie, setCookie, parseBoolean } from '~/lib/utils/common_utils'; - -export const SIDEBAR_COLLAPSED_CLASS = 'js-sidebar-collapsed'; - -export default class ContextualSidebar { - constructor() { - this.initDomElements(); - this.render(); - } - - initDomElements() { - this.$page = $('.layout-page'); - this.$sidebar = $('.nav-sidebar'); - - if (!this.$sidebar.length) return; - - this.$innerScroll = $('.nav-sidebar-inner-scroll', this.$sidebar); - this.$overlay = $('.mobile-overlay'); - this.$openSidebar = $('.toggle-mobile-nav'); - this.$closeSidebar = $('.close-nav-button'); - this.$sidebarToggle = $('.js-toggle-sidebar'); - } - - bindEvents() { - if (!this.$sidebar.length) return; - - this.$openSidebar.on('click', () => this.toggleSidebarNav(true)); - this.$closeSidebar.on('click', () => this.toggleSidebarNav(false)); - this.$overlay.on('click', () => this.toggleSidebarNav(false)); - this.$sidebarToggle.on('click', () => { - if (!ContextualSidebar.isDesktopBreakpoint()) { - this.toggleSidebarNav(!this.$sidebar.hasClass('sidebar-expanded-mobile')); - } else { - const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop'); - this.toggleCollapsedSidebar(value, true); - } - }); - - $(window).on( - 'resize', - debounce(() => this.render(), 100), - ); - } - - // See documentation: https://design.gitlab.com/regions/navigation#contextual-navigation - // NOTE: at 1200px nav sidebar should not overlap the content - // https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/24555#note_134136110 - static isDesktopBreakpoint = () => bp.windowWidth() >= breakpoints.xl; - static setCollapsedCookie(value) { - if (!ContextualSidebar.isDesktopBreakpoint()) { - return; - } - setCookie('sidebar_collapsed', value, { expires: 365 * 10 }); - } - - toggleSidebarNav(show) { - const breakpoint = bp.getBreakpointSize(); - const dbp = ContextualSidebar.isDesktopBreakpoint(); - const supportedSizes = ['xs', 'sm', 'md']; - - this.$sidebar.toggleClass(SIDEBAR_COLLAPSED_CLASS, !show); - this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? show : false); - this.$overlay.toggleClass( - 'mobile-nav-open', - supportedSizes.includes(breakpoint) ? show : false, - ); - this.$sidebar.removeClass('sidebar-collapsed-desktop'); - } - - toggleCollapsedSidebar(collapsed, saveCookie) { - const breakpoint = bp.getBreakpointSize(); - const dbp = ContextualSidebar.isDesktopBreakpoint(); - const supportedSizes = ['xs', 'sm', 'md']; - - if (this.$sidebar.length) { - this.$sidebar.toggleClass(`sidebar-collapsed-desktop ${SIDEBAR_COLLAPSED_CLASS}`, collapsed); - this.$sidebar.toggleClass('sidebar-expanded-mobile', !dbp ? !collapsed : false); - this.$page.toggleClass( - 'page-with-icon-sidebar', - supportedSizes.includes(breakpoint) ? true : collapsed, - ); - } - - if (saveCookie) { - ContextualSidebar.setCollapsedCookie(collapsed); - } - - requestIdleCallback(() => this.toggleSidebarOverflow()); - } - - toggleSidebarOverflow() { - if (this.$innerScroll.prop('scrollHeight') > this.$innerScroll.prop('offsetHeight')) { - this.$innerScroll.css('overflow-y', 'scroll'); - } else { - this.$innerScroll.css('overflow-y', ''); - } - } - - render() { - if (!this.$sidebar.length) return; - - if (!ContextualSidebar.isDesktopBreakpoint()) { - this.toggleSidebarNav(false); - } else { - const collapse = parseBoolean(getCookie('sidebar_collapsed')); - this.toggleCollapsedSidebar(collapse, true); - } - } -} diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js deleted file mode 100644 index 0fb70fb831e..00000000000 --- a/app/assets/javascripts/fly_out_nav.js +++ /dev/null @@ -1,205 +0,0 @@ -import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; -import { SIDEBAR_COLLAPSED_CLASS } from './contextual_sidebar'; - -const HIDE_INTERVAL_TIMEOUT = 300; -const COLLAPSED_PANEL_WIDTH = 48; -const IS_OVER_CLASS = 'is-over'; -const IS_ABOVE_CLASS = 'is-above'; -const IS_SHOWING_FLY_OUT_CLASS = 'is-showing-fly-out'; -let currentOpenMenu = null; -let menuCornerLocs; -let timeoutId; -let sidebar; - -export const mousePos = []; - -export const setSidebar = (el) => { - sidebar = el; -}; -export const getOpenMenu = () => currentOpenMenu; -export const setOpenMenu = (menu = null) => { - currentOpenMenu = menu; -}; - -export const slope = (a, b) => (b.y - a.y) / (b.x - a.x); - -export const getHeaderHeight = () => sidebar?.offsetTop || 0; - -export const isSidebarCollapsed = () => - sidebar && sidebar.classList.contains(SIDEBAR_COLLAPSED_CLASS); - -export const canShowActiveSubItems = (el) => { - if (el.classList.contains('active') && !isSidebarCollapsed()) { - return false; - } - - return true; -}; - -export const canShowSubItems = () => ['md', 'lg', 'xl'].includes(bp.getBreakpointSize()); - -export const getHideSubItemsInterval = () => { - if (!currentOpenMenu || !mousePos.length) return 0; - - const currentMousePos = mousePos[mousePos.length - 1]; - const prevMousePos = mousePos[0]; - const currentMousePosY = currentMousePos.y; - const [menuTop, menuBottom] = menuCornerLocs; - - if (currentMousePosY < menuTop.y || currentMousePosY > menuBottom.y) return 0; - - if ( - slope(prevMousePos, menuBottom) < slope(currentMousePos, menuBottom) && - slope(prevMousePos, menuTop) > slope(currentMousePos, menuTop) - ) { - return HIDE_INTERVAL_TIMEOUT; - } - - return 0; -}; - -export const calculateTop = (boundingRect, outerHeight) => { - const windowHeight = window.innerHeight; - const bottomOverflow = windowHeight - (boundingRect.top + outerHeight); - - return bottomOverflow < 0 - ? boundingRect.top - outerHeight + boundingRect.height - : boundingRect.top; -}; - -export const hideMenu = (el) => { - if (!el) return; - - const parentEl = el.parentNode; - - el.style.display = ''; - el.style.transform = ''; - el.classList.remove(IS_ABOVE_CLASS); - el.classList.remove('fly-out-list'); - parentEl.classList.remove(IS_OVER_CLASS); - parentEl.classList.remove(IS_SHOWING_FLY_OUT_CLASS); - - setOpenMenu(); -}; - -export const moveSubItemsToPosition = (el, subItems) => { - const hasSubItems = subItems.parentNode.querySelector('.has-sub-items'); - const header = subItems.querySelector('.fly-out-top-item'); - const boundingRect = el.getBoundingClientRect(); - const left = sidebar ? sidebar.offsetWidth : COLLAPSED_PANEL_WIDTH; - let top = calculateTop(boundingRect, subItems.offsetHeight); - const isAbove = top < boundingRect.top; - if (hasSubItems) { - top = isAbove ? top : top - header.offsetHeight; - } else { - top = boundingRect.top; - } - - subItems.classList.add('fly-out-list'); - subItems.style.transform = `translate3d(${left}px, ${Math.floor(top) - getHeaderHeight()}px, 0)`; // eslint-disable-line no-param-reassign - const subItemsRect = subItems.getBoundingClientRect(); - - menuCornerLocs = [ - { - x: subItemsRect.left, // left position of the sub items - y: subItemsRect.top, // top position of the sub items - }, - { - x: subItemsRect.left, // left position of the sub items - y: subItemsRect.top + subItemsRect.height, // bottom position of the sub items - }, - ]; - - if (isAbove) { - subItems.classList.add(IS_ABOVE_CLASS); - } -}; - -export const showSubLevelItems = (el) => { - const subItems = el.querySelector('.sidebar-sub-level-items'); - const isIconOnly = subItems && subItems.classList.contains('is-fly-out-only'); - - if (!canShowSubItems() || !canShowActiveSubItems(el)) return; - - el.classList.add(IS_OVER_CLASS); - - if (!subItems || (!isSidebarCollapsed() && isIconOnly)) return; - - subItems.style.display = 'block'; - el.classList.add(IS_SHOWING_FLY_OUT_CLASS); - - setOpenMenu(subItems); - moveSubItemsToPosition(el, subItems); -}; - -export const mouseEnterTopItems = (el, timeout = getHideSubItemsInterval()) => { - clearTimeout(timeoutId); - - timeoutId = setTimeout(() => { - if (currentOpenMenu) hideMenu(currentOpenMenu); - - showSubLevelItems(el); - }, timeout); -}; - -export const mouseLeaveTopItem = (el) => { - const subItems = el.querySelector('.sidebar-sub-level-items'); - - if ( - !canShowSubItems() || - !canShowActiveSubItems(el) || - (subItems && subItems === currentOpenMenu) - ) - return; - - el.classList.remove(IS_OVER_CLASS); -}; - -export const documentMouseMove = (e) => { - mousePos.push({ - x: e.clientX, - y: e.clientY, - }); - - if (mousePos.length > 6) mousePos.shift(); -}; - -export const subItemsMouseLeave = (relatedTarget) => { - clearTimeout(timeoutId); - - if (relatedTarget && !relatedTarget.closest(`.${IS_OVER_CLASS}`)) { - hideMenu(currentOpenMenu); - } -}; - -export default () => { - sidebar = document.querySelector('.nav-sidebar'); - - if (!sidebar) return; - - const items = [...sidebar.querySelectorAll('.sidebar-top-level-items > li')]; - - const topItems = sidebar.querySelector('.sidebar-top-level-items'); - if (topItems) { - sidebar.querySelector('.sidebar-top-level-items').addEventListener('mouseleave', () => { - clearTimeout(timeoutId); - - timeoutId = setTimeout(() => { - if (currentOpenMenu) hideMenu(currentOpenMenu); - }, getHideSubItemsInterval()); - }); - } - - items.forEach((el) => { - const subItems = el.querySelector('.sidebar-sub-level-items'); - - if (subItems) { - subItems.addEventListener('mouseleave', (e) => subItemsMouseLeave(e.relatedTarget)); - } - - el.addEventListener('mouseenter', (e) => mouseEnterTopItems(e.currentTarget)); - el.addEventListener('mouseleave', (e) => mouseLeaveTopItem(e.currentTarget)); - }); - - document.addEventListener('mousemove', documentMouseMove); -}; diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_history_link.vue b/app/assets/javascripts/import_entities/import_groups/components/import_history_link.vue new file mode 100644 index 00000000000..218e7dee953 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_history_link.vue @@ -0,0 +1,31 @@ +<script> +import { GlLink } from '@gitlab/ui'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; + +export default { + components: { + GlLink, + }, + + props: { + id: { + type: Number, + required: false, + default: null, + }, + historyPath: { + type: String, + required: true, + }, + }, + + computed: { + historyPathWithId() { + return mergeUrlParams({ bulk_import_id: this.id }, this.historyPath); + }, + }, +}; +</script> +<template> + <gl-link :href="historyPathWithId">{{ __('View details') }}</gl-link> +</template> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index c5f83bf90da..82faf4fc110 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -13,7 +13,7 @@ import { GlFormCheckbox, GlTooltipDirective, } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, isNumber } from 'lodash'; import { createAlert } from '~/alert'; import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { s__, __, n__, sprintf } from '~/locale'; @@ -36,6 +36,7 @@ import { NEW_NAME_FIELD, ROOT_NAMESPACE, i18n } from '../constants'; import { StatusPoller } from '../services/status_poller'; import { isFinished, isAvailableForImport, isNameValid, isSameTarget } from '../utils'; import ImportActionsCell from './import_actions_cell.vue'; +import ImportHistoryLink from './import_history_link.vue'; import ImportSourceCell from './import_source_cell.vue'; import ImportStatusCell from './import_status.vue'; import ImportTargetCell from './import_target_cell.vue'; @@ -61,6 +62,7 @@ export default { ImportTargetCell, ImportStatusCell, ImportActionsCell, + ImportHistoryLink, PaginationBar, HelpPopover, }, @@ -352,6 +354,12 @@ export default { return group.progress?.hasFailures; }, + showHistoryLink(group) { + // We need to check for `isNumber` to make sure `id` is passed from the backend + // and not "LOCAL-PROGRESS-${id}" as defined by client_factory.js + return group.progress?.id && isNumber(group.progress.id); + }, + updateImportTarget(group, changes) { const newImportTarget = { ...group.importTarget, @@ -800,6 +808,12 @@ export default { </template> <template #cell(progress)="{ item: group }"> <import-status-cell :status="group.visibleStatus" :has-failures="hasFailures(group)" /> + <import-history-link + v-if="showHistoryLink(group)" + :id="group.progress.id" + :history-path="historyPath" + class="gl-display-inline-block gl-mt-2" + /> </template> <template #cell(actions)="{ item: group, index }"> <import-actions-cell diff --git a/app/assets/javascripts/layout_nav.js b/app/assets/javascripts/layout_nav.js index 670170ec9b9..2cb8c37f192 100644 --- a/app/assets/javascripts/layout_nav.js +++ b/app/assets/javascripts/layout_nav.js @@ -1,6 +1,4 @@ import $ from 'jquery'; -import ContextualSidebar from './contextual_sidebar'; -import initFlyOutNav from './fly_out_nav'; import { setNotification } from './whats_new/utils/notification'; function hideEndFade($scrollingTabs) { @@ -113,11 +111,5 @@ function initDeferred() { } export default function initLayoutNav() { - if (!gon.use_new_navigation) { - const contextualSidebar = new ContextualSidebar(); - contextualSidebar.bindEvents(); - initFlyOutNav(); - } - requestIdleCallback(initDeferred); } diff --git a/app/assets/javascripts/organizations/constants.js b/app/assets/javascripts/organizations/constants.js index 8ade37b169e..d3da072b38d 100644 --- a/app/assets/javascripts/organizations/constants.js +++ b/app/assets/javascripts/organizations/constants.js @@ -2,3 +2,5 @@ export const RESOURCE_TYPE_GROUPS = 'groups'; export const RESOURCE_TYPE_PROJECTS = 'projects'; export const ORGANIZATION_ROOT_ROUTE_NAME = 'root'; + +export const ORGANIZATION_USERS_PER_PAGE = 20; diff --git a/app/assets/javascripts/organizations/users/components/app.vue b/app/assets/javascripts/organizations/users/components/app.vue index 1d5ea895ff0..065a1e004f2 100644 --- a/app/assets/javascripts/organizations/users/components/app.vue +++ b/app/assets/javascripts/organizations/users/components/app.vue @@ -1,9 +1,17 @@ <script> import { __, s__ } from '~/locale'; import { createAlert } from '~/alert'; +import { ORGANIZATION_USERS_PER_PAGE } from '~/organizations/constants'; import organizationUsersQuery from '../graphql/organization_users.query.graphql'; import UsersView from './users_view.vue'; +const defaultPagination = { + first: ORGANIZATION_USERS_PER_PAGE, + last: null, + before: '', + after: '', +}; + export default { name: 'OrganizationsUsersApp', components: { @@ -20,16 +28,29 @@ export default { data() { return { users: [], + pagination: { + ...defaultPagination, + }, + pageInfo: {}, }; }, apollo: { users: { query: organizationUsersQuery, variables() { - return { id: this.organizationGid }; + return { + id: this.organizationGid, + first: this.pagination.first, + last: this.pagination.last, + before: this.pagination.before, + after: this.pagination.after, + }; }, update(data) { - return data.organization.organizationUsers.nodes.map(({ badges, user }) => { + const { nodes, pageInfo } = data.organization.organizationUsers; + this.pageInfo = pageInfo; + + return nodes.map(({ badges, user }) => { return { ...user, badges, email: user.publicEmail }; }); }, @@ -43,12 +64,28 @@ export default { return this.$apollo.queries.users.loading; }, }, + methods: { + handlePrevPage() { + this.pagination.before = this.pageInfo.startCursor; + this.pagination.after = ''; + }, + handleNextPage() { + this.pagination.before = ''; + this.pagination.after = this.pageInfo.endCursor; + }, + }, }; </script> <template> <section> <h1 class="gl-my-4 gl-font-size-h-display">{{ $options.i18n.users }}</h1> - <users-view :users="users" :loading="loading" /> + <users-view + :users="users" + :loading="loading" + :page-info="pageInfo" + @prev="handlePrevPage" + @next="handleNextPage" + /> </section> </template> diff --git a/app/assets/javascripts/organizations/users/components/users_view.vue b/app/assets/javascripts/organizations/users/components/users_view.vue index fac353bdaf6..c1f411fb958 100644 --- a/app/assets/javascripts/organizations/users/components/users_view.vue +++ b/app/assets/javascripts/organizations/users/components/users_view.vue @@ -1,11 +1,12 @@ <script> -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; import UsersTable from '~/vue_shared/components/users_table/users_table.vue'; export default { name: 'UsersView', components: { GlLoadingIcon, + GlKeysetPagination, UsersTable, }, inject: ['paths'], @@ -15,6 +16,10 @@ export default { required: false, default: () => [], }, + pageInfo: { + type: Object, + required: true, + }, loading: { type: Boolean, required: false, @@ -25,6 +30,19 @@ export default { </script> <template> - <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" /> - <users-table v-else :users="users" :admin-user-path="paths.adminUser" /> + <div> + <gl-loading-icon v-if="loading" class="gl-mt-5" size="md" /> + <template v-else> + <users-table :users="users" :admin-user-path="paths.adminUser" /> + <div class="gl-display-flex gl-justify-content-center"> + <gl-keyset-pagination + v-bind="pageInfo" + :prev-text="__('Previous')" + :next-text="__('Next')" + @prev="$emit('prev')" + @next="$emit('next')" + /> + </div> + </template> + </div> </template> diff --git a/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql b/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql index d98ebf9cd26..0b9b1314fa2 100644 --- a/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql +++ b/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql @@ -1,7 +1,15 @@ -query getOrganizationUsers($id: OrganizationsOrganizationID!) { +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getOrganizationUsers( + $id: OrganizationsOrganizationID! + $first: Int + $last: Int + $before: String! + $after: String! +) { organization(id: $id) { id - organizationUsers { + organizationUsers(first: $first, last: $last, before: $before, after: $after) { nodes { badges { text @@ -18,6 +26,9 @@ query getOrganizationUsers($id: OrganizationsOrganizationID!) { lastActivityOn } } + pageInfo { + ...PageInfo + } } } } diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index e912bfa4f92..23d6fa64c64 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -8,12 +8,13 @@ import { GlTableLite, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; +import { isEqual } from 'lodash'; import { s__, __ } from '~/locale'; import { createAlert } from '~/alert'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; -import { joinPaths } from '~/lib/utils/url_utility'; -import { getBulkImportsHistory } from '~/rest_api'; +import { joinPaths, getParameterValues } from '~/lib/utils/url_utility'; +import { getBulkImportHistory, getBulkImportsHistory } from '~/rest_api'; import ImportStatus from '~/import_entities/import_groups/components/import_status.vue'; import { StatusPoller } from '~/import_entities/import_groups/services/status_poller'; @@ -110,12 +111,18 @@ export default { showDetailsLink() { return this.glFeatures.bulkImportDetailsPage; }, + + paginationConfigCopy() { + return { ...this.paginationConfig }; + }, }, watch: { - paginationConfig: { - handler() { - this.loadHistoryItems(); + paginationConfigCopy: { + handler(newValue, oldValue) { + if (!isEqual(newValue, oldValue)) { + this.loadHistoryItems(); + } }, deep: true, }, @@ -159,10 +166,19 @@ export default { }, methods: { + fetchFn(params) { + const bulkImportId = getParameterValues('bulk_import_id')[0]; + + return bulkImportId + ? getBulkImportHistory(bulkImportId, params) + : getBulkImportsHistory(params); + }, + async loadHistoryItems() { try { this.loading = true; - const { data: historyItems, headers } = await getBulkImportsHistory({ + + const { data: historyItems, headers } = await this.fetchFn({ page: this.paginationConfig.page, per_page: this.paginationConfig.perPage, }); diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js index 3af984dcf6c..479f9eae820 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.js @@ -2,7 +2,7 @@ import axios from '~/lib/utils/axios_utils'; import { EXTENSION_ICONS } from '~/vue_merge_request_widget/constants'; import { SEVERITY_ICONS_MR_WIDGET } from '~/ci/reports/codequality_report/constants'; import { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; -import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser'; +import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/utils/codequality_parser'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; import { i18n, codeQualityPrefixes } from './constants'; diff --git a/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb b/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb new file mode 100644 index 00000000000..9332076a493 --- /dev/null +++ b/app/graphql/resolvers/ci/catalog/resources/versions_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + module Catalog + module Resources + class VersionsResolver < BaseResolver + type Types::Ci::Catalog::Resources::VersionType.connection_type, null: true + + # This allows a maximum of 1 call to the field that uses this resolver. If the + # field is evaluated on more than one node, it causes performance degradation. + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + + argument :sort, Types::Ci::Catalog::Resources::VersionSortEnum, + required: false, + description: 'Sort versions by given criteria.' + + def resolve(sort: nil) + ::Ci::Catalog::Resources::VersionsFinder.new(object, current_user, sort: sort).execute + end + end + end + end + end +end diff --git a/app/graphql/resolvers/ci/catalog/versions_resolver.rb b/app/graphql/resolvers/ci/catalog/versions_resolver.rb deleted file mode 100644 index 046adeb7a67..00000000000 --- a/app/graphql/resolvers/ci/catalog/versions_resolver.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module Resolvers - module Ci - module Catalog - class VersionsResolver < ::Resolvers::ReleasesResolver - type Types::ReleaseType.connection_type, null: true - - # This allows a maximum of 1 call to the field that uses this resolver. If the - # field is evaluated on more than one node, it causes performance degradation. - extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 - - private - - def get_project - object.respond_to?(:project) ? object.project : object - end - - # Override the aliased method in ReleasesResolver - alias_method :project, :get_project - end - end - end -end diff --git a/app/graphql/types/ci/catalog/resource_type.rb b/app/graphql/types/ci/catalog/resource_type.rb index 918f7067211..c20106ef7c4 100644 --- a/app/graphql/types/ci/catalog/resource_type.rb +++ b/app/graphql/types/ci/catalog/resource_type.rb @@ -32,13 +32,14 @@ module Types field :web_path, GraphQL::Types::String, null: true, description: 'Web path of the catalog resource.', alpha: { milestone: '16.1' } - field :versions, Types::ReleaseType.connection_type, null: true, + field :versions, Types::Ci::Catalog::Resources::VersionType.connection_type, null: true, description: 'Versions of the catalog resource. This field can only be ' \ 'resolved for one catalog resource in any single request.', - resolver: Resolvers::Ci::Catalog::VersionsResolver, + resolver: Resolvers::Ci::Catalog::Resources::VersionsResolver, alpha: { milestone: '16.2' } - field :latest_version, Types::ReleaseType, null: true, description: 'Latest version of the catalog resource.', + field :latest_version, Types::Ci::Catalog::Resources::VersionType, null: true, + description: 'Latest version of the catalog resource.', alpha: { milestone: '16.1' } field :latest_released_at, Types::TimeType, null: true, @@ -69,11 +70,12 @@ module Types end def latest_version - BatchLoader::GraphQL.for(object.project).batch do |projects, loader| - latest_releases = ReleasesFinder.new(projects, current_user, latest: true).execute + BatchLoader::GraphQL.for(object).batch do |catalog_resources, loader| + latest_versions = ::Ci::Catalog::Resources::VersionsFinder.new( + catalog_resources, current_user, latest: true).execute - latest_releases.index_by(&:project).each do |project, latest_release| - loader.call(project, latest_release) + latest_versions.index_by(&:catalog_resource).each do |catalog_resource, latest_version| + loader.call(catalog_resource, latest_version) end end end diff --git a/app/graphql/types/ci/catalog/resources/version_sort_enum.rb b/app/graphql/types/ci/catalog/resources/version_sort_enum.rb new file mode 100644 index 00000000000..c5a5f46605a --- /dev/null +++ b/app/graphql/types/ci/catalog/resources/version_sort_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Ci + module Catalog + module Resources + class VersionSortEnum < Types::ReleaseSortEnum + graphql_name 'CiCatalogResourceVersionSort' + description 'Values for sorting catalog resource versions' + end + end + end + end +end diff --git a/app/graphql/types/ci/catalog/resources/version_type.rb b/app/graphql/types/ci/catalog/resources/version_type.rb new file mode 100644 index 00000000000..8c073690eaf --- /dev/null +++ b/app/graphql/types/ci/catalog/resources/version_type.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Types + module Ci + module Catalog + module Resources + # rubocop: disable Graphql/AuthorizeTypes -- Authorization is handled by Ci::Catalog::Resources::VersionsFinder in the resolver. + class VersionType < BaseObject + graphql_name 'CiCatalogResourceVersion' + + connection_type_class Types::CountableConnectionType + + field :id, ::Types::GlobalIDType[::Ci::Catalog::Resources::Version], null: false, + description: 'Global ID of the version.', + alpha: { milestone: '16.7' } + + field :created_at, Types::TimeType, null: true, description: 'Timestamp of when the version was created.', + alpha: { milestone: '16.7' } + + field :released_at, Types::TimeType, null: true, description: 'Timestamp of when the version was released.', + alpha: { milestone: '16.7' } + + field :tag_name, GraphQL::Types::String, null: true, method: :name, + description: 'Name of the tag associated with the version.', + alpha: { milestone: '16.7' } + + field :tag_path, GraphQL::Types::String, null: true, + description: 'Relative web path to the tag associated with the version.', + alpha: { milestone: '16.7' } + + field :author, Types::UserType, null: true, description: 'User that created the version.', + alpha: { milestone: '16.7' } + + field :commit, Types::CommitType, null: true, complexity: 10, calls_gitaly: true, + description: 'Commit associated with the version.', + alpha: { milestone: '16.7' } + + def author + Gitlab::Graphql::Loaders::BatchModelLoader.new(User, object.author_id).find + end + + def tag_path + Gitlab::Routing.url_helpers.project_tag_path(object.project, object.name) + end + end + # rubocop: enable Graphql/AuthorizeTypes + end + end + end +end diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index 89f8520b331..564b10ea8e3 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -168,6 +168,18 @@ module Ci end # rubocop: enable CodeReuse/ServiceClass + def job_artifacts + Ci::JobArtifact.none + end + + def artifacts_expire_at; end + + def runner; end + + def tag_list + ActsAsTaggableOn::TagList.new + end + def artifacts? false end diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb index ab4d5c52526..4273c4515bc 100644 --- a/app/models/ci/catalog/resources/version.rb +++ b/app/models/ci/catalog/resources/version.rb @@ -26,7 +26,7 @@ module Ci scope :order_by_released_at_asc, -> { joins(:release).keyset_order_by_released_at_asc } scope :order_by_released_at_desc, -> { joins(:release).keyset_order_by_released_at_desc } - delegate :name, :description, :tag, :sha, :released_at, :author_id, to: :release + delegate :sha, :released_at, :author_id, to: :release after_destroy :update_catalog_resource after_save :update_catalog_resource @@ -114,6 +114,14 @@ module Ci end end + def name + release.tag + end + + def commit + project.commit_by(oid: sha) + end + private def update_catalog_resource diff --git a/app/services/ml/model_versions/delete_service.rb b/app/services/ml/model_versions/delete_service.rb new file mode 100644 index 00000000000..4eb8d367a19 --- /dev/null +++ b/app/services/ml/model_versions/delete_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ml + module ModelVersions + class DeleteService + def initialize(project, name, version, user) + @project = project + @name = name + @version = version + @user = user + end + + def execute + model_version = Ml::ModelVersion + .by_project_id_name_and_version(@project.id, @name, @version) + return ServiceResponse.error(message: 'Model not found') unless model_version + + if model_version.package.present? + result = ::Packages::MarkPackageForDestructionService + .new(container: model_version.package, current_user: @user) + .execute + + return ServiceResponse.error(message: result.message) unless result.success? + end + + return ServiceResponse.error(message: 'Could not destroy the model version') unless model_version.destroy + + ServiceResponse.success + end + end + end +end diff --git a/app/validators/json_schemas/scan_result_policy_project_approval_settings.json b/app/validators/json_schemas/scan_result_policy_project_approval_settings.json index 5a81c61ede4..4885e5266c1 100644 --- a/app/validators/json_schemas/scan_result_policy_project_approval_settings.json +++ b/app/validators/json_schemas/scan_result_policy_project_approval_settings.json @@ -15,7 +15,7 @@ "require_password_to_approve": { "type": "boolean" }, - "block_unprotecting_branches": { + "block_branch_modification": { "type": "boolean" } } diff --git a/config/metrics/counts_28d/20210216175055_merge_requests.yml b/config/metrics/counts_28d/20210216175055_merge_requests.yml index 9e3f64553f6..d46caf93675 100644 --- a/config/metrics/counts_28d/20210216175055_merge_requests.yml +++ b/config/metrics/counts_28d/20210216175055_merge_requests.yml @@ -17,6 +17,7 @@ tier: - free - premium - ultimate -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "<13.9" removed_by_url: diff --git a/config/metrics/counts_28d/20210216175542_ci_builds.yml b/config/metrics/counts_28d/20210216175542_ci_builds.yml index 3d2777aebea..c95a1e9fad5 100644 --- a/config/metrics/counts_28d/20210216175542_ci_builds.yml +++ b/config/metrics/counts_28d/20210216175542_ci_builds.yml @@ -16,6 +16,7 @@ tier: - free - premium - ultimate -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "12.9" introduced_by_url: "https://gitlab.com/gitlab-org/gitlab/-/merge_requests/26441" diff --git a/config/metrics/counts_28d/20210216181148_service_desk_issues.yml b/config/metrics/counts_28d/20210216181148_service_desk_issues.yml index 302dc04e16a..9a606ec802d 100644 --- a/config/metrics/counts_28d/20210216181148_service_desk_issues.yml +++ b/config/metrics/counts_28d/20210216181148_service_desk_issues.yml @@ -16,5 +16,6 @@ tier: - free - premium - ultimate -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "<13.9" diff --git a/config/metrics/counts_28d/20210216183730_jira.yml b/config/metrics/counts_28d/20210216183730_jira.yml index 37c9b9a7b66..590ca4e5f37 100644 --- a/config/metrics/counts_28d/20210216183730_jira.yml +++ b/config/metrics/counts_28d/20210216183730_jira.yml @@ -16,7 +16,8 @@ tier: - free - premium - ultimate -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "<13.9" removed_by_url: milestone_removed: "<16.4" diff --git a/config/metrics/counts_28d/20211126091206_p_analytics_ci_cd_lead_time_monthly.yml b/config/metrics/counts_28d/20211126091206_p_analytics_ci_cd_lead_time_monthly.yml index 63d1ed5d0c6..f5a4882aca5 100644 --- a/config/metrics/counts_28d/20211126091206_p_analytics_ci_cd_lead_time_monthly.yml +++ b/config/metrics/counts_28d/20211126091206_p_analytics_ci_cd_lead_time_monthly.yml @@ -12,7 +12,8 @@ time_frame: 28d data_source: redis_hll data_category: operational instrumentation_class: RedisHLLMetric -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score events: - name: p_analytics_ci_cd_lead_time unique: user.id diff --git a/config/metrics/counts_all/20210216175229_auto_devops_enabled.yml b/config/metrics/counts_all/20210216175229_auto_devops_enabled.yml index 9b192fa26a0..fe90390283f 100644 --- a/config/metrics/counts_all/20210216175229_auto_devops_enabled.yml +++ b/config/metrics/counts_all/20210216175229_auto_devops_enabled.yml @@ -17,5 +17,6 @@ tier: - free - premium - ultimate -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "<13.9" diff --git a/config/metrics/counts_all/20210216175514_ci_external_pipelines.yml b/config/metrics/counts_all/20210216175514_ci_external_pipelines.yml index b2c70204679..6b830d88d52 100644 --- a/config/metrics/counts_all/20210216175514_ci_external_pipelines.yml +++ b/config/metrics/counts_all/20210216175514_ci_external_pipelines.yml @@ -13,5 +13,6 @@ distribution: - ce tier: - free -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "<13.9" diff --git a/config/metrics/counts_all/20210216180232_projects_jira_dvcs_cloud_active.yml b/config/metrics/counts_all/20210216180232_projects_jira_dvcs_cloud_active.yml index c9c85bca415..907eed3b822 100644 --- a/config/metrics/counts_all/20210216180232_projects_jira_dvcs_cloud_active.yml +++ b/config/metrics/counts_all/20210216180232_projects_jira_dvcs_cloud_active.yml @@ -19,7 +19,8 @@ tier: - free - premium - ultimate -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "<13.9" removed_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/135755 milestone_removed: "<16.6" diff --git a/config/metrics/counts_all/20210216181011_projects_with_packages.yml b/config/metrics/counts_all/20210216181011_projects_with_packages.yml index 434d9989ed4..da869a35fa8 100644 --- a/config/metrics/counts_all/20210216181011_projects_with_packages.yml +++ b/config/metrics/counts_all/20210216181011_projects_with_packages.yml @@ -16,5 +16,6 @@ tier: - free - premium - ultimate -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "<13.9" diff --git a/config/metrics/counts_all/20210216181102_issues.yml b/config/metrics/counts_all/20210216181102_issues.yml index ec679b1089f..f10bbe69e95 100644 --- a/config/metrics/counts_all/20210216181102_issues.yml +++ b/config/metrics/counts_all/20210216181102_issues.yml @@ -17,5 +17,6 @@ tier: - free - premium - ultimate -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "<13.9" diff --git a/config/metrics/counts_all/20210216181254_projects.yml b/config/metrics/counts_all/20210216181254_projects.yml index 0bd0e783c0a..265f8f394a1 100644 --- a/config/metrics/counts_all/20210216181254_projects.yml +++ b/config/metrics/counts_all/20210216181254_projects.yml @@ -16,5 +16,6 @@ tier: - free - premium - ultimate -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "<13.9" diff --git a/config/metrics/counts_all/20210216182002_remote_mirrors.yml b/config/metrics/counts_all/20210216182002_remote_mirrors.yml index 0c8103284a3..7f06be1decb 100644 --- a/config/metrics/counts_all/20210216182002_remote_mirrors.yml +++ b/config/metrics/counts_all/20210216182002_remote_mirrors.yml @@ -16,5 +16,6 @@ tier: - free - premium - ultimate -performance_indicator_type: [] +performance_indicator_type: +- customer_health_score milestone: "<13.9" diff --git a/db/docs/audit_events_instance_amazon_s3_configurations.yml b/db/docs/audit_events_instance_amazon_s3_configurations.yml new file mode 100644 index 00000000000..5cb049342ee --- /dev/null +++ b/db/docs/audit_events_instance_amazon_s3_configurations.yml @@ -0,0 +1,10 @@ +--- +table_name: audit_events_instance_amazon_s3_configurations +classes: + - AuditEvents::Instance::AmazonS3Configuration +feature_categories: + - audit_events +description: Stores Amazon S3 configurations used for instance level audit event streaming. +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/136492 +milestone: '16.7' +gitlab_schema: gitlab_main diff --git a/db/migrate/20231107140642_create_audit_events_instance_amazon_s3_configurations.rb b/db/migrate/20231107140642_create_audit_events_instance_amazon_s3_configurations.rb new file mode 100644 index 00000000000..8051d23f6a1 --- /dev/null +++ b/db/migrate/20231107140642_create_audit_events_instance_amazon_s3_configurations.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class CreateAuditEventsInstanceAmazonS3Configurations < Gitlab::Database::Migration[2.2] + enable_lock_retries! + milestone '16.7' + + UNIQUE_NAME = "unique_instance_amazon_s3_configurations_name" + UNIQUE_BUCKET_NAME = "unique_instance_amazon_s3_configurations_bucket_name" + + def change + create_table :audit_events_instance_amazon_s3_configurations do |t| + t.timestamps_with_timezone null: false + t.text :access_key_xid, null: false, limit: 128 + t.text :name, null: false, limit: 72 + t.text :bucket_name, null: false, limit: 63 + t.text :aws_region, null: false, limit: 50 + t.binary :encrypted_secret_access_key, null: false + t.binary :encrypted_secret_access_key_iv, null: false + + t.index [:name], unique: true, name: UNIQUE_NAME + t.index [:bucket_name], unique: true, name: UNIQUE_BUCKET_NAME + end + end +end diff --git a/db/schema_migrations/20231107140642 b/db/schema_migrations/20231107140642 new file mode 100644 index 00000000000..e77a46970b3 --- /dev/null +++ b/db/schema_migrations/20231107140642 @@ -0,0 +1 @@ +7a2cd6460af9afcf6bcbb933854872d2be2b6d098f48383331c83d10c8f9ee73
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 353f009d07c..01eeff3dfa6 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -12713,6 +12713,31 @@ CREATE SEQUENCE audit_events_id_seq ALTER SEQUENCE audit_events_id_seq OWNED BY audit_events.id; +CREATE TABLE audit_events_instance_amazon_s3_configurations ( + id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + access_key_xid text NOT NULL, + name text NOT NULL, + bucket_name text NOT NULL, + aws_region text NOT NULL, + encrypted_secret_access_key bytea NOT NULL, + encrypted_secret_access_key_iv bytea NOT NULL, + CONSTRAINT check_1a908bd36f CHECK ((char_length(name) <= 72)), + CONSTRAINT check_8083750c42 CHECK ((char_length(bucket_name) <= 63)), + CONSTRAINT check_d2ca3eb90e CHECK ((char_length(aws_region) <= 50)), + CONSTRAINT check_d6d6bd8e8b CHECK ((char_length(access_key_xid) <= 128)) +); + +CREATE SEQUENCE audit_events_instance_amazon_s3_configurations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE audit_events_instance_amazon_s3_configurations_id_seq OWNED BY audit_events_instance_amazon_s3_configurations.id; + CREATE TABLE audit_events_instance_external_audit_event_destinations ( id bigint NOT NULL, created_at timestamp with time zone NOT NULL, @@ -26171,6 +26196,8 @@ ALTER TABLE ONLY audit_events_external_audit_event_destinations ALTER COLUMN id ALTER TABLE ONLY audit_events_google_cloud_logging_configurations ALTER COLUMN id SET DEFAULT nextval('audit_events_google_cloud_logging_configurations_id_seq'::regclass); +ALTER TABLE ONLY audit_events_instance_amazon_s3_configurations ALTER COLUMN id SET DEFAULT nextval('audit_events_instance_amazon_s3_configurations_id_seq'::regclass); + ALTER TABLE ONLY audit_events_instance_external_audit_event_destinations ALTER COLUMN id SET DEFAULT nextval('audit_events_instance_external_audit_event_destinations_id_seq'::regclass); ALTER TABLE ONLY audit_events_instance_google_cloud_logging_configurations ALTER COLUMN id SET DEFAULT nextval('audit_events_instance_google_cloud_logging_configuration_id_seq'::regclass); @@ -28014,6 +28041,9 @@ ALTER TABLE ONLY audit_events_external_audit_event_destinations ALTER TABLE ONLY audit_events_google_cloud_logging_configurations ADD CONSTRAINT audit_events_google_cloud_logging_configurations_pkey PRIMARY KEY (id); +ALTER TABLE ONLY audit_events_instance_amazon_s3_configurations + ADD CONSTRAINT audit_events_instance_amazon_s3_configurations_pkey PRIMARY KEY (id); + ALTER TABLE ONLY audit_events_instance_external_audit_event_destinations ADD CONSTRAINT audit_events_instance_external_audit_event_destinations_pkey PRIMARY KEY (id); @@ -35261,6 +35291,10 @@ CREATE UNIQUE INDEX unique_index_on_system_note_metadata_id ON resource_link_eve CREATE UNIQUE INDEX unique_index_sysaccess_ms_access_tokens_on_sysaccess_ms_app_id ON system_access_microsoft_graph_access_tokens USING btree (system_access_microsoft_application_id); +CREATE UNIQUE INDEX unique_instance_amazon_s3_configurations_bucket_name ON audit_events_instance_amazon_s3_configurations USING btree (bucket_name); + +CREATE UNIQUE INDEX unique_instance_amazon_s3_configurations_name ON audit_events_instance_amazon_s3_configurations USING btree (name); + CREATE UNIQUE INDEX unique_instance_audit_event_destination_name ON audit_events_instance_external_audit_event_destinations USING btree (name); CREATE UNIQUE INDEX unique_instance_google_cloud_logging_configurations ON audit_events_instance_google_cloud_logging_configurations USING btree (google_project_id_name, log_id_name); diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index a1fb13e846c..d2119e4434c 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -8925,6 +8925,30 @@ The edge type for [`CiCatalogResource`](#cicatalogresource). | <a id="cicatalogresourceedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="cicatalogresourceedgenode"></a>`node` | [`CiCatalogResource`](#cicatalogresource) | The item at the end of the edge. | +#### `CiCatalogResourceVersionConnection` + +The connection type for [`CiCatalogResourceVersion`](#cicatalogresourceversion). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cicatalogresourceversionconnectioncount"></a>`count` | [`Int!`](#int) | Total count of collection. | +| <a id="cicatalogresourceversionconnectionedges"></a>`edges` | [`[CiCatalogResourceVersionEdge]`](#cicatalogresourceversionedge) | A list of edges. | +| <a id="cicatalogresourceversionconnectionnodes"></a>`nodes` | [`[CiCatalogResourceVersion]`](#cicatalogresourceversion) | A list of nodes. | +| <a id="cicatalogresourceversionconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `CiCatalogResourceVersionEdge` + +The edge type for [`CiCatalogResourceVersion`](#cicatalogresourceversion). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cicatalogresourceversionedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="cicatalogresourceversionedgenode"></a>`node` | [`CiCatalogResourceVersion`](#cicatalogresourceversion) | The item at the end of the edge. | + #### `CiConfigGroupConnection` The connection type for [`CiConfigGroup`](#ciconfiggroup). @@ -15002,7 +15026,7 @@ Represents the total number of issues and their weights for a particular day. | <a id="cicatalogresourceicon"></a>`icon` **{warning-solid}** | [`String`](#string) | **Introduced** in 15.11. This feature is an Experiment. It can be changed or removed at any time. Icon for the catalog resource. | | <a id="cicatalogresourceid"></a>`id` **{warning-solid}** | [`ID!`](#id) | **Introduced** in 15.11. This feature is an Experiment. It can be changed or removed at any time. ID of the catalog resource. | | <a id="cicatalogresourcelatestreleasedat"></a>`latestReleasedAt` **{warning-solid}** | [`Time`](#time) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Release date of the catalog resource's latest version. | -| <a id="cicatalogresourcelatestversion"></a>`latestVersion` **{warning-solid}** | [`Release`](#release) | **Introduced** in 16.1. This feature is an Experiment. It can be changed or removed at any time. Latest version of the catalog resource. | +| <a id="cicatalogresourcelatestversion"></a>`latestVersion` **{warning-solid}** | [`CiCatalogResourceVersion`](#cicatalogresourceversion) | **Introduced** in 16.1. This feature is an Experiment. It can be changed or removed at any time. Latest version of the catalog resource. | | <a id="cicatalogresourcename"></a>`name` **{warning-solid}** | [`String`](#string) | **Introduced** in 15.11. This feature is an Experiment. It can be changed or removed at any time. Name of the catalog resource. | | <a id="cicatalogresourceopenissuescount"></a>`openIssuesCount` **{warning-solid}** | [`Int!`](#int) | **Introduced** in 16.3. This feature is an Experiment. It can be changed or removed at any time. Count of open issues that belong to the the catalog resource. | | <a id="cicatalogresourceopenmergerequestscount"></a>`openMergeRequestsCount` **{warning-solid}** | [`Int!`](#int) | **Introduced** in 16.3. This feature is an Experiment. It can be changed or removed at any time. Count of open merge requests that belong to the the catalog resource. | @@ -15021,7 +15045,7 @@ WARNING: **Introduced** in 16.2. This feature is an Experiment. It can be changed or removed at any time. -Returns [`ReleaseConnection`](#releaseconnection). +Returns [`CiCatalogResourceVersionConnection`](#cicatalogresourceversionconnection). This field returns a [connection](#connections). It accepts the four standard [pagination arguments](#connection-pagination-arguments): @@ -15031,7 +15055,21 @@ four standard [pagination arguments](#connection-pagination-arguments): | Name | Type | Description | | ---- | ---- | ----------- | -| <a id="cicatalogresourceversionssort"></a>`sort` | [`ReleaseSort`](#releasesort) | Sort releases by given criteria. | +| <a id="cicatalogresourceversionssort"></a>`sort` | [`CiCatalogResourceVersionSort`](#cicatalogresourceversionsort) | Sort versions by given criteria. | + +### `CiCatalogResourceVersion` + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="cicatalogresourceversionauthor"></a>`author` **{warning-solid}** | [`UserCore`](#usercore) | **Introduced** in 16.7. This feature is an Experiment. It can be changed or removed at any time. User that created the version. | +| <a id="cicatalogresourceversioncommit"></a>`commit` **{warning-solid}** | [`Commit`](#commit) | **Introduced** in 16.7. This feature is an Experiment. It can be changed or removed at any time. Commit associated with the version. | +| <a id="cicatalogresourceversioncreatedat"></a>`createdAt` **{warning-solid}** | [`Time`](#time) | **Introduced** in 16.7. This feature is an Experiment. It can be changed or removed at any time. Timestamp of when the version was created. | +| <a id="cicatalogresourceversionid"></a>`id` **{warning-solid}** | [`CiCatalogResourcesVersionID!`](#cicatalogresourcesversionid) | **Introduced** in 16.7. This feature is an Experiment. It can be changed or removed at any time. Global ID of the version. | +| <a id="cicatalogresourceversionreleasedat"></a>`releasedAt` **{warning-solid}** | [`Time`](#time) | **Introduced** in 16.7. This feature is an Experiment. It can be changed or removed at any time. Timestamp of when the version was released. | +| <a id="cicatalogresourceversiontagname"></a>`tagName` **{warning-solid}** | [`String`](#string) | **Introduced** in 16.7. This feature is an Experiment. It can be changed or removed at any time. Name of the tag associated with the version. | +| <a id="cicatalogresourceversiontagpath"></a>`tagPath` **{warning-solid}** | [`String`](#string) | **Introduced** in 16.7. This feature is an Experiment. It can be changed or removed at any time. Relative web path to the tag associated with the version. | ### `CiConfig` @@ -28391,6 +28429,17 @@ Values for sorting catalog resources. | <a id="cicatalogresourcesortname_asc"></a>`NAME_ASC` | Name by ascending order. | | <a id="cicatalogresourcesortname_desc"></a>`NAME_DESC` | Name by descending order. | +### `CiCatalogResourceVersionSort` + +Values for sorting catalog resource versions. + +| Value | Description | +| ----- | ----------- | +| <a id="cicatalogresourceversionsortcreated_asc"></a>`CREATED_ASC` | Created at ascending order. | +| <a id="cicatalogresourceversionsortcreated_desc"></a>`CREATED_DESC` | Created at descending order. | +| <a id="cicatalogresourceversionsortreleased_at_asc"></a>`RELEASED_AT_ASC` | Released at by ascending order. | +| <a id="cicatalogresourceversionsortreleased_at_desc"></a>`RELEASED_AT_DESC` | Released at by descending order. | + ### `CiConfigIncludeType` Include type. @@ -31007,6 +31056,12 @@ A `CiCatalogResourceID` is a global ID. It is encoded as a string. An example `CiCatalogResourceID` is: `"gid://gitlab/Ci::Catalog::Resource/1"`. +### `CiCatalogResourcesVersionID` + +A `CiCatalogResourcesVersionID` is a global ID. It is encoded as a string. + +An example `CiCatalogResourcesVersionID` is: `"gid://gitlab/Ci::Catalog::Resources::Version/1"`. + ### `CiJobArtifactID` A `CiJobArtifactID` is a global ID. It is encoded as a string. diff --git a/doc/architecture/blueprints/gitlab_steps/data.drawio.png b/doc/architecture/blueprints/gitlab_steps/data.drawio.png Binary files differnew file mode 100644 index 00000000000..59436093fb7 --- /dev/null +++ b/doc/architecture/blueprints/gitlab_steps/data.drawio.png diff --git a/doc/architecture/blueprints/gitlab_steps/implementation.md b/doc/architecture/blueprints/gitlab_steps/implementation.md new file mode 100644 index 00000000000..add6097b08f --- /dev/null +++ b/doc/architecture/blueprints/gitlab_steps/implementation.md @@ -0,0 +1,350 @@ +--- +owning-stage: "~devops::verify" +description: Implementation details for [CI Steps](index.md). +--- + +# Design and implementation details + +## Baseline Step Proto + +The internals of Step Runner operate on the baseline step definition +which is defined in Protocol Buffer. All GitLab CI steps (and other +supported formats such as GitHub Actions) compile / fold to baseline steps. +Both step invocations in `.gitlab-ci.yml` and step definitions +in `step.yml` files will be compiled to baseline structures. +The term "step" means "baseline step" for the remainder of this document. + +Each step includes a reference `ref` in the form of a URI. The method of +retrieval is determined by the protocol of the URI. + +Steps and step traces have fields for inputs, outputs, +environment variables and environment exports. +After steps are downloaded and the `step.yml` is parsed +a step definition `def` will be added. +If a step defines multiple additional steps then the +trace will include sub-traces for each sub-step. + +```protobuf +message Step { + string name = 1; + Reference ref = 2; + repeated EnvironmentVariable env = 3; + repeated Input inputs = 4; + message Reference { + string uri = 1; + string version = 2; + string hash = 3; + Definition def = 4; + } +} +message Definition { + Spec spec = 1; + enum Type { + type_unknown = 0; + type_steps = 1; + type_exec = 2; + } + Type type = 2; + oneof type_oneof { + DefinitionExec exec = 3; + DefinitionSteps steps = 4; + } + message Exec { + repeated string command = 1; + string work_dir = 2; + } + message Steps { + repeated Step children = 1; + } +} + +message Spec { + repeated Input inputs = 1; + message Input { + string key = 1; + string default_value = 2; + } +} + +message EnvironmentVariable { + string key = 1; + string value = 2; + bool masked = 3; + bool raw = 4; +} + +message Input { + string key = 1; + string value = 2; + bool masked = 3; + bool raw = 4; +} + +message Output { + string key = 1; + string value = 2; + bool masked = 3; +} + +message StepResult { + Step step = 1; + enum Status { + unknown_result = 0; + success = 1; + failure = 2; + running = 3; + } + Result result = 2; + repeated Output outputs = 3; + repeated EnvironmentVariable exports = 4; + int32 exit_code = 5; + repeated StepResult children_step_results = 6; +} +``` + +## Step Caching + +Steps are cached locally by a key comprised of `location` +(URL), `version` and `hash`. This prevents the exact same component +from being downloaded multiple times. The first time a step is +referenced it will be downloaded (unless local) and the cache will +return the path to the folder containing `step.yml` and the other +step files. If the same step is referenced again, the same folder +will be returned without downloading. + +If a step is referenced which differs by version or hash from another +cached step, it will be re-downloaded into a different folder and +cached separately. + +## Execution Context + +State is kept by Step Runner across all steps in the form of +an execution context. The context contains the output of each step, +environment variables and overall job and environment metadata. +The execution context can be referenced by expressions in +GitLab CI steps provided by the workflow author. + +Example of context available to expressions in `.gitlab-ci.yml`: + +```yaml +steps: + previous_step: + outputs: + name: "hello world" +env: + EXAMPLE_VAR: "bar" +job: + id: 1234 +``` + +Expressions in step definitions can also reference execution +context. However they can only access overall +job and environment metadata and the inputs defined in `step.yml`. +They cannot access the outputs of previous steps. In order to +provide the output of one step to the next, the step input +values should include an expression which references another +step's output. + +Example of context available to expressions in `step.yml`: + +```yaml +inputs: + name: "foo" +env: + EXAMPLE_VAR: "bar" +job: + id: 1234 +``` + +E.g. this is not allowed in a `step.yml file` because steps +should not couple to one another. + +```yaml +spec: + inputs: + name: +--- +type: exec +exec: + command: [echo, hello, ${{ steps.previous_step.outputs.name }}] +``` + +This is allowed because the GitLab CI steps syntax passes data +from one step to another: + +```yaml +spec: + inputs: + name: +--- +type: exec +exec: + command: [echo, hello, ${{ inputs.name }}] +``` + +```yaml +steps: +- name: previous_step + ... +- name: greeting + inputs: + name: ${{ steps.previous_step.outputs.name }} +``` + +Therefore evaluation of expressions will done in two different kinds +of context. One as a GitLab CI Step and one as a step definition. + +### Step Inputs + +Step inputs can be given in several ways. They can be embeded +directly into expressions in an `exec` command (as above). Or they +can be embedded in expressions for environment variables set during +exec: + +```yaml +spec: + inputs: + name: +--- +type: exec +exec: + command: [greeting.sh] +env: + NAME: ${{ inputs.name }} +``` + +### Input Types + +Input values are stored as strings. But they can also have a type +associated with them. Supported types are: + +- `string` +- `bool` +- `number` +- `object` + +String type values can be any string. Bool type values must be either `true` +or `false` when parsed as JSON. Number type values must a valid float64 +when parsed as JSON. Object types will be a JSON serialization of +the YAML input structure. + +For example, these would be valid inputs: + +```yaml +steps: +- name: my_step + inputs: + foo: bar + baz: true + bam: 1 +``` + +Given this step definition: + +```yaml +spec: + inputs: + foo: + type: string + baz: + type: bool + bam: + type: number +--- +type: exec +exec: + command: [echo, ${{ inputs.foo }}, ${{ inputs.baz }}, ${{ inputs.bam }}] +``` + +And it would output `bar true 1` + +For an object type, these would be valid inputs: + +```yaml +steps: + name: my_step + inputs: + foo: + steps: + - name: my_inner_step + inputs: + name: steppy +``` + +Given this step definition: + +```yaml +spec: + inputs: + foo: + type: object +--- +type: exec +exec: + command: [echo, ${{ inputs.foo }}] +``` + +And it would output `{"steps":[{"name":"my_inner_step","inputs":{"name":"steppy"}}]}` + +### Outputs + +Output files are created into which steps can write their +outputs and environment variable exports. The file locations are +provided in `OUTPUT_FILE` and `ENV_FILE` environment variables. + +After execution Step Runner will read the output and environment +variable files and populate the trace with their values. The +outputs will be stored under the context for the executed step. +And the exported environment variables will be merged with environment +provided to the next step. + +Some steps can be of type `steps` and be composed of a sequence +of GitLab CI steps. These will be compiled and executed in sequence. +Any environment variables exported by nested steps will be available +to subsequent steps. And will be available to high level steps +when the nested steps are complete. E.g. entering nested steps does +not create a new "scope" or context object. Environment variables +are global. + +## Containers + +We've tried a couple approaches to running steps in containers. +In end we've decided to delegate steps entirely to a step runner +in the container. + +Here are the options considered: + +### Delegation (chosen option) + +A provision is made for passing complex structures to steps, which +is to serialize them as JSON (see Inputs above). In this way the actual +step to be run can be merely a parameter to step running in container. +So the outer step is a `docker/run` step with a command that executes +`step-runner` with a `steps` input parameter. The `docker/run` step will +run the container and then extract the output files from the container +and re-emit them to the outer steps. + +This same technique will work for running steps in VMs or whatever. +Step Runner doesn't have to know anything about containerizing or +isolation steps. + +### Special Compilation (rejected option) + +When we see the `image` keyword in a GitLab CI step we would download +and compile the "target" step. Then manufacture a `docker/run` step +and pass the complied `exec` command as an input. Then we would compile +the `docker/run` step and execute it. + +However this requires Step Runner to know how to construct a `docker/run` +step. Which couples Step Runner with the method of isolation, making +isolation in VMs and other methods more complicated. + +### Native Docker (rejected option) + +The baseline step can include provisions for running a step in a +Docker container. For example the step could include a `ref` "target" +field and an `image` field. + +However this also couples Step Runner with Docker and expands the role +of Step Runner. It is preferable to make Docker an external step +that Step Runner execs in the same way as any other step. diff --git a/doc/architecture/blueprints/gitlab_steps/index.md b/doc/architecture/blueprints/gitlab_steps/index.md index 5e3becfec19..fb8b17e26a1 100644 --- a/doc/architecture/blueprints/gitlab_steps/index.md +++ b/doc/architecture/blueprints/gitlab_steps/index.md @@ -1,7 +1,7 @@ --- status: proposed creation-date: "2023-08-23" -authors: [ "@ayufan" ] +authors: [ "@ayufan", "@josephburnett" ] coach: "@grzegorz" approvers: [ "@dhershkovitch", "@DarrenEastman", "@cheryl.li" ] owning-stage: "~devops::verify" @@ -57,7 +57,7 @@ have to be part of CI syntax. Instead, they can be provided in the form of reusa that are configured in a generic way in the CI config, and later downloaded and executed according to inputs and parameters. -The GitLab Steps is meant to fill that product-gap by following similar model to competitors +GitLab Steps is meant to fill that product-gap by following similar model to competitors and to some extent staying compatible with them. The GitLab Steps is meant to replace all purpose-specific syntax to handle specific features. By providing and using reusable components, that are build outside of `.gitlab-ci.yml`, that are versioned, and requested when needed @@ -131,6 +131,80 @@ TBD ## Proposal +Step Runner will be a new go binary which lives at `https://gitlab.com/gitlab-org/step-runner`. +It will be able to accept a number of input formats which are compiled to a standard proto format. +Output will be a standard proto trace which will include details for debugging and reproducing the build. + +### Capabilities + +- Read steps + - from environment variable + - from `.gitlab-ci.yml` file + - from gRPC server in step-runner + - from commandline JSON input +- Compile GitLab Steps and GitHub Actions to a baseline step definition + - explicit inputs and outputs + - explicit environment and exports + - baseline steps can be type `exec` or more steps +- Download and run steps from: + - Git repos + - zip files + - locally provided +- A job can be composed of different kinds of steps + - steps can come from different sources and be run in different ways + - steps can access environment exports and output of previous steps +- Produce a step-by-step trace of execution + - including final inputs and outputs + - including final environment and exports + - including logs of each step + - each step specifies the exact runtime and component used (hash) + - (optional) masking sensitive inputs, outputs, environment and exports +- Replaying a trace + - reuses the exact runtimes and components from trace + - output of trace will be the same trace if build is deterministic + +### Example invocations + +#### Commandline + +- `STEPS=$(cat steps.yml) step-runner ci` +- `step-runner local .gitlab-ci.yml --format gitlab-ci --job-name hello-world --output-file trace.json` +- `step-runner replay trace.json` +- `step-runner ci --port 8080` + +#### GitLab CI + +```yaml +hello-world: + image: registry.gitlab.com/gitlab-org/step-runner + variables: + STEPS: | + - step: gitlab.com/josephburnett/component-hello-steppy@master + inputs: + greeting: "hello ${{ env.name }}" + env: + name: world + script: + - /step-runner ci + artifacts: + paths: + - trace.json +``` + +### Basic compilation and execution process + +Steps as expressed in GitLab CI are complied to the baseline step definition. +Referenced steps are loaded and compliled to produce an `exec` command, +or to produce an additional list of GitLab CI steps which are compiled recursively. +Each steps is executed immediately after compilation so its output will be available for subsequent compilations. + +![diagram of data during compilation](data.drawio.png) + +Steps return outputs and exports via files which are collected by Step Runner after each step. +Finally all the compiled inputs and outputs for each step are collected in a step trace. + +![sequenced diagram of step runner compilation and execution](step-runner-sequence.drawio.png) + ### GitLab Steps definition and syntax - [Step Definition](step-definition.md). @@ -142,8 +216,20 @@ TBD ## Design and implementation details -TBD +See [implementation.md](implementation.md) and [runner-integration.md](runner-integration.md). ## References +- [GitLab Issue #215511](https://gitlab.com/gitlab-org/gitlab/-/issues/215511) +- [Step Runner Code](https://gitlab.com/josephburnett/step-runner/-/tree/blueprint2). + This is the exploratory code created during the writing of this blueprint. + It shows the structure of the Step Runner binary and how the pieces fit together. + It runs but doesn't quite do the right thing (see all the TODOs). +- [CI Steps / CI Events / Executors / Taskonaut (video)](https://youtu.be/nZoO547IISM). + Some high-level discussion about how these 4 blueprints relate to each other. + And a good prequel to the video about this MR. +- [Steps in Runner (video)](https://youtu.be/82WLQ4zHYts). + A walk through of the Step Runner details from the code perspective. +- [CI YAML keywords](https://gitlab.com/gitlab-org/gitlab/-/issues/398129#note_1324467337). + An inventory of affected keywords. - [GitLab Epic 11535](https://gitlab.com/groups/gitlab-org/-/epics/11535) diff --git a/doc/architecture/blueprints/gitlab_steps/runner-integration.md b/doc/architecture/blueprints/gitlab_steps/runner-integration.md new file mode 100644 index 00000000000..e5a635908bc --- /dev/null +++ b/doc/architecture/blueprints/gitlab_steps/runner-integration.md @@ -0,0 +1,116 @@ +--- +owning-stage: "~devops::verify" +description: Runner integration for [CI Steps](index.md). +--- + +# Runner Integration + +Steps are delivered to Step Runner as a YAML blob in the GitLab CI syntax. +Runner interacts with Step Runner over a gRPC service `StepRunner` +which is started on a local socket in the execution environment. This +is the same way that Nesting serves a gRPC service in a dedicated +Mac instance. The service has three RPCs, `run`, `follow` and `cancel`. + +Run is the initial delivery of the steps. Follow requests a streaming +response to step traces. And Cancel stops execution and cleans up +resources as soon as possible. + +Step Runner operating in gRPC mode will be able to executed multiple +step payloads at once. That is each call to `run` will start a new +goroutine and execute the steps until completion. Multiple calls to `run` +may be made simultaneously. This is also why components are cached by +`location`, `version` and `hash`. Because we cannot be changing which +ref we are on while multiple, concurrent executions are using the +underlying files. + +```proto +service StepRunner { + rpc Run(RunRequest) returns (RunResponse); + rpc Follow(FollowRequest) returns (stream FollowResponse); + rpc Cancel(CancelRequest) returns (CancelResponse); +} + +message RunRequest { + string id = 1; + oneof job_oneof { + string ci_job = 2; + Steps steps = 3; + } +} + +message RunResponse { +} + +message FollowRequest { + string id = 1; +} + +message FollowResponse { + StepResult result = 1; +} + +message CancelRequest { + string id = 1; +} + +message CancelResponse { +} +``` + +As steps are executed, traces are streamed back to GitLab Runner. +So execution can be followed at least at the step level. If a more +granular follow is required, we can introduce a gRPC step type which +can stream back logs as they are produced. + +Here is how we will connect to Step Runner in each runner executor: + +## Instance + +The Instance executor is accessed via SSH, the same as today. However +instead of starting a bash shell and piping in commands, it connects +to the Step Runner socket in a known location and makes gRPC +calls. This is the same as how Runner calls the Nesting server in +dedicated Mac instances to make VMs. + +This requires that Step Runner is present and started in the job +execution environment. + +## Docker + +The same requirement that Step Runner is present and started is true +for the Docker executor (and `docker-autoscaler`). However in order to +connect to the socket inside the container, we must `exec` a bridge +process in the container. This will be another command on the Step +Runner binary which proxies STDIN and STDOUT to the local socket in a +known location, allowing the caller of exec to make gRPC calls inside +the container. + +## Kubernetes + +The Kubelet on Kubernetes Nodes exposes an exec API which will start a +process in a container of a running Pod. We will use this exec create +a bridge process that will allow the caller to make gRPC calls inside +the Pod. Same as the Docker executor. + +In order to access to this protected Kubelet API we must use the +Kubernetes API which provides an exec sub-resource on Pod. A caller +can POST to the URL of a pod suffixed with `/exec` and then negotiate +the connection up to a SPDY protocol for bidirectional byte +streaming. So GitLab Runner can use the Kubernetes API to connect to +the Step Runner service and deliver job payloads. + +This is the same way that `kubectl exec` works. In fact most of the +internals such as SPDY negotiation are provided as `client-go` +libraries. So Runner can call the Kubernetes API directly by +importing the necessary libraries rather than shelling out to +Kubectl. + +Historically one of the weaknesses of the Kubernetes Executor was +running a whole job through a single exec. To mitigate this Runner +uses the attach command instead, which can "re-attach" to an existing +shell process and pick up where it left off. + +This is not necessary for Step Runner however, because the exec is +just establishing a bridge to the long-running gRPC process. If the +connection drops, Runner will just "re-attach" by exec'ing another +connection and continuing to make RPC calls like `follow`. diff --git a/doc/architecture/blueprints/gitlab_steps/step-runner-sequence.drawio.png b/doc/architecture/blueprints/gitlab_steps/step-runner-sequence.drawio.png Binary files differnew file mode 100644 index 00000000000..9f6a6dcad9f --- /dev/null +++ b/doc/architecture/blueprints/gitlab_steps/step-runner-sequence.drawio.png diff --git a/doc/user/application_security/policies/scan-result-policies.md b/doc/user/application_security/policies/scan-result-policies.md index 91502fb202e..0e9a6ea1d72 100644 --- a/doc/user/application_security/policies/scan-result-policies.md +++ b/doc/user/application_security/policies/scan-result-policies.md @@ -181,11 +181,12 @@ the defined policy. > - The `block_unprotecting_branches` field was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/423101) in GitLab 16.4 [with flag](../../../administration/feature_flags.md) named `scan_result_policy_settings`. Disabled by default. > - The `scan_result_policy_settings` feature flag was replaced by the `scan_result_policies_block_unprotecting_branches` feature flag in 16.4. +> - The `block_unprotecting_branches` field was [replaced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/137153) by `block_branch_modification` field in GitLab 16.7. > - The `prevent_approval_by_author`, `prevent_approval_by_commit_author`, `remove_approvals_with_new_commit`, and `require_password_to_approve` fields were [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/418752) in GitLab 16.4 [with flag](../../../administration/feature_flags.md) named `scan_result_any_merge_request`. Enabled by default. > - The `prevent_pushing_and_force_pushing` field was [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/420629) in GitLab 16.4 [with flag](../../../administration/feature_flags.md) named `scan_result_policies_block_force_push`. Enabled by default. FLAG: -On self-managed GitLab, by default the `block_unprotecting_branches` field is unavailable. To show the feature, an administrator can [enable the feature flag](../../../administration/feature_flags.md) named `scan_result_policies_block_unprotecting_branches`. On GitLab.com, this feature is unavailable. +On self-managed GitLab, by default the `block_branch_modification` field is unavailable. To show the feature, an administrator can [enable the feature flag](../../../administration/feature_flags.md) named `scan_result_policies_block_unprotecting_branches`. On GitLab.com, this feature is unavailable. On self-managed GitLab, by default the `prevent_approval_by_author`, `prevent_approval_by_commit_author`, `remove_approvals_with_new_commit`, and `require_password_to_approve` fields are available. To hide the feature, an administrator can [disable the feature flag](../../../administration/feature_flags.md) named `scan_result_any_merge_request`. On GitLab.com, this feature is available. On self-managed GitLab, by default the `prevent_pushing_and_force_pushing` field is available. To hide the feature, an administrator can [disable the feature flag](../../../administration/feature_flags.md) named `scan_result_policies_block_force_push`. On GitLab.com, this feature is available. @@ -193,7 +194,7 @@ The settings set in the policy overwrite settings in the project. | Field | Type | Required | Possible values | Applicable rule type | Description | |-------------------------------------|-----------|----------|-----------------|----------------------|-------------| -| `block_unprotecting_branches` | `boolean` | false | `true`, `false` | All | When enabled, prevents a user from removing a branch from the protected branches list, deleting a protected branch, or changing the default branch if that branch is included in the security policy. This ensures users cannot remove protection status from a branch to merge vulnerable code. | +| `block_branch_modification` | `boolean` | false | `true`, `false` | All | When enabled, prevents a user from removing a branch from the protected branches list, deleting a protected branch, or changing the default branch if that branch is included in the security policy. This ensures users cannot remove protection status from a branch to merge vulnerable code. | | `prevent_approval_by_author` | `boolean` | false | `true`, `false` | `Any merge request` | When enabled, merge request authors cannot approve their own MRs. This ensures code authors cannot introduce vulnerabilities and approve code to merge. | | `prevent_approval_by_commit_author` | `boolean` | false | `true`, `false` | `Any merge request` | When enabled, users who have contributed code to the MR are ineligible for approval. This ensures code committers cannot introduce vulnerabilities and approve code to merge. | | `remove_approvals_with_new_commit` | `boolean` | false | `true`, `false` | `Any merge request` | When enabled, if an MR receives all necessary approvals to merge, but then a new commit is added, new approvals are required. This ensures new commits that may include vulnerabilities cannot be introduced. | diff --git a/doc/user/group/manage.md b/doc/user/group/manage.md index 2ccb42b93eb..d625f132ada 100644 --- a/doc/user/group/manage.md +++ b/doc/user/group/manage.md @@ -83,6 +83,11 @@ It is not possible to rename a namespace if it contains a project with [Container Registry](../packages/container_registry/index.md) tags, because the project cannot be moved. +WARNING: +To ensure that groups with thousands of subgroups get processed correctly, you should test the path change in a test environment. +Consider increasing the [Puma worker timeout](../../administration/operations/puma.md#change-the-worker-timeout) temporarily. +For more information about our solution to mitigate this timeout risk, see [issue 432065](https://gitlab.com/gitlab-org/gitlab/-/issues/432065). + ## Change the default branch protection of a group > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/7583) in GitLab 12.9. diff --git a/lib/gitlab/ci/pipeline/chain/create.rb b/lib/gitlab/ci/pipeline/chain/create.rb index d4c4f94c7d3..d1153a0990e 100644 --- a/lib/gitlab/ci/pipeline/chain/create.rb +++ b/lib/gitlab/ci/pipeline/chain/create.rb @@ -34,7 +34,7 @@ module Gitlab pipeline .stages .flat_map(&:statuses) - .select { |status| status.respond_to?(:tag_list) } + .select { |status| status.respond_to?(:tag_list=) } end end end diff --git a/lib/gitlab/redis/wrapper.rb b/lib/gitlab/redis/wrapper.rb index d3dc955d9e2..401ac50509d 100644 --- a/lib/gitlab/redis/wrapper.rb +++ b/lib/gitlab/redis/wrapper.rb @@ -109,6 +109,8 @@ module Gitlab end def secret_file + return unless defined?(Settings) + if raw_config_hash[:secret_file].blank? File.join(Settings.encrypted_settings['path'], 'redis.yaml.enc') else @@ -130,9 +132,11 @@ module Gitlab # the file. In other cases, like when being loaded as part of spinning # up test environment via `scripts/setup-test-env`, we should gate on # the presence of the specified secret file so that - # `Settings.encrypted`, which might not be loadable does not gets - # called. - Settings.encrypted(secret_file) if File.exist?(secret_file) || ::Gitlab::Runtime.rake? + # `Settings.encrypted`, which might not be loadable does not get + # called. Same is the case when this library gets called by Mailroom + # which does not have rails environment available. + Settings.encrypted(secret_file) if (secret_file && File.exist?(secret_file)) || + (defined?(Gitlab::Runtime) && Gitlab::Runtime.rake?) end private diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 4e2e4108c22..8f8c640e232 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -27875,7 +27875,7 @@ msgstr "" msgid "Last Accessed On" msgstr "" -msgid "Last Activity" +msgid "Last GitLab activity" msgstr "" msgid "Last Name" @@ -42328,7 +42328,7 @@ msgstr "" msgid "ScanResultPolicy|Prevent approval by merge request's author" msgstr "" -msgid "ScanResultPolicy|Prevent branch protection modification" +msgid "ScanResultPolicy|Prevent branch modification" msgstr "" msgid "ScanResultPolicy|Prevent pushing and force pushing" diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb index 7d80ab7b15d..db56b754623 100644 --- a/spec/factories/deployments.rb +++ b/spec/factories/deployments.rb @@ -69,5 +69,9 @@ FactoryBot.define do deployment.succeed! end end + + trait :with_bridge do + deployable { association :ci_bridge, environment: environment.name, pipeline: association(:ci_pipeline, project: environment.project) } + end end end diff --git a/spec/finders/ci/catalog/resources/versions_finder_spec.rb b/spec/finders/ci/catalog/resources/versions_finder_spec.rb index b2418aa45dd..b541b84f198 100644 --- a/spec/finders/ci/catalog/resources/versions_finder_spec.rb +++ b/spec/finders/ci/catalog/resources/versions_finder_spec.rb @@ -22,13 +22,13 @@ RSpec.describe Ci::Catalog::Resources::VersionsFinder, feature_category: :pipeli end.not_to exceed_query_limit(control_count) end - context 'when the user is not authorized for any catalog resource' do + context 'when the user is not authorized' do it 'returns empty response' do is_expected.to be_empty end end - describe 'versions' do + context 'when the user is authorized' do before_all do resource1.project.add_guest(current_user) end @@ -74,7 +74,7 @@ RSpec.describe Ci::Catalog::Resources::VersionsFinder, feature_category: :pipeli end end - describe 'latest versions' do + context 'when `latest` parameter is true' do before_all do resource1.project.add_guest(current_user) resource2.project.add_guest(current_user) @@ -85,22 +85,5 @@ RSpec.describe Ci::Catalog::Resources::VersionsFinder, feature_category: :pipeli it 'returns the latest version for each authorized catalog resource' do expect(execute).to match_array([v1_1, v2_1]) end - - context 'when one catalog resource does not have versions' do - it 'returns the latest version of only the catalog resource with versions' do - resource1.versions.delete_all(:delete_all) - - is_expected.to match_array([v2_1]) - end - end - - context 'when no catalog resource has versions' do - it 'returns empty response' do - resource1.versions.delete_all(:delete_all) - resource2.versions.delete_all(:delete_all) - - is_expected.to be_empty - end - end end end diff --git a/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js b/spec/frontend/ci/reports/codequality_report/utils/codequality_parser_spec.js index f7d82d2b662..953e6173662 100644 --- a/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js +++ b/spec/frontend/ci/reports/codequality_report/utils/codequality_parser_spec.js @@ -1,5 +1,5 @@ import { reportIssues, parsedReportIssues } from 'jest/ci/reports/codequality_report/mock_data'; -import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser'; +import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/utils/codequality_parser'; describe('Codequality report store utils', () => { let result; diff --git a/spec/frontend/import_entities/import_groups/components/import_history_link_spec.js b/spec/frontend/import_entities/import_groups/components/import_history_link_spec.js new file mode 100644 index 00000000000..5f530f2c3be --- /dev/null +++ b/spec/frontend/import_entities/import_groups/components/import_history_link_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; + +import ImportHistoryLink from '~/import_entities/import_groups/components/import_history_link.vue'; + +describe('import history link', () => { + let wrapper; + + const mockHistoryPath = '/import/history'; + + const createComponent = ({ props } = {}) => { + wrapper = shallowMount(ImportHistoryLink, { + propsData: { + historyPath: mockHistoryPath, + ...props, + }, + }); + }; + + const findGlLink = () => wrapper.findComponent(GlLink); + + it('renders link with href', () => { + const mockId = 174; + + createComponent({ + props: { + id: mockId, + }, + }); + + expect(findGlLink().text()).toBe('View details'); + expect(findGlLink().attributes('href')).toBe('/import/history?bulk_import_id=174'); + }); +}); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index 8cfc9d3ccd7..4141bded502 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -13,6 +13,7 @@ import { STATUSES } from '~/import_entities/constants'; import { i18n, ROOT_NAMESPACE } from '~/import_entities/import_groups/constants'; import ImportTable from '~/import_entities/import_groups/components/import_table.vue'; import ImportStatus from '~/import_entities/import_groups/components/import_status.vue'; +import ImportHistoryLink from '~/import_entities/import_groups/components//import_history_link.vue'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; @@ -179,6 +180,23 @@ describe('import table', () => { expect(findAllImportStatuses().wrappers.map((w) => w.text())).toEqual(expectedStatuses); }); + it('renders import history link for imports with id', async () => { + createComponent({ + bulkImportSourceGroups: () => ({ + nodes: FAKE_GROUPS, + pageInfo: FAKE_PAGE_INFO, + versionValidation: FAKE_VERSION_VALIDATION, + }), + }); + await waitForPromises(); + + const importHistoryLinks = wrapper.findAllComponents(ImportHistoryLink); + + expect(importHistoryLinks).toHaveLength(2); + expect(importHistoryLinks.at(0).props('id')).toBe(FAKE_GROUPS[1].id); + expect(importHistoryLinks.at(1).props('id')).toBe(FAKE_GROUPS[3].id); + }); + it('correctly maintains root namespace as last import target', async () => { createComponent({ bulkImportSourceGroups: () => ({ diff --git a/spec/frontend/organizations/users/components/app_spec.js b/spec/frontend/organizations/users/components/app_spec.js index e7ed712c309..30380bcf6a5 100644 --- a/spec/frontend/organizations/users/components/app_spec.js +++ b/spec/frontend/organizations/users/components/app_spec.js @@ -4,10 +4,16 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/alert'; +import { ORGANIZATION_USERS_PER_PAGE } from '~/organizations/constants'; import organizationUsersQuery from '~/organizations/users/graphql/organization_users.query.graphql'; import OrganizationsUsersApp from '~/organizations/users/components/app.vue'; import OrganizationsUsersView from '~/organizations/users/components/users_view.vue'; -import { MOCK_ORGANIZATION_GID, MOCK_USERS, MOCK_USERS_FORMATTED } from '../mock_data'; +import { + MOCK_ORGANIZATION_GID, + MOCK_USERS, + MOCK_USERS_FORMATTED, + MOCK_PAGE_INFO, +} from '../mock_data'; jest.mock('~/alert'); @@ -16,10 +22,11 @@ Vue.use(VueApollo); const mockError = new Error(); const loadingResolver = jest.fn().mockReturnValue(new Promise(() => {})); -const successfulResolver = (nodes) => - jest.fn().mockResolvedValue({ - data: { organization: { id: 1, organizationUsers: { nodes } } }, +const successfulResolver = (nodes, pageInfo = {}) => { + return jest.fn().mockResolvedValue({ + data: { organization: { id: 1, organizationUsers: { nodes, pageInfo } } }, }); +}; const errorResolver = jest.fn().mockRejectedValueOnce(mockError); describe('OrganizationsUsersApp', () => { @@ -44,12 +51,13 @@ describe('OrganizationsUsersApp', () => { const findOrganizationUsersView = () => wrapper.findComponent(OrganizationsUsersView); describe.each` - description | mockResolver | loading | userData | error - ${'when API call is loading'} | ${loadingResolver} | ${true} | ${[]} | ${false} - ${'when API returns successful with results'} | ${successfulResolver(MOCK_USERS)} | ${false} | ${MOCK_USERS_FORMATTED} | ${false} - ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${[]} | ${false} - ${'when API returns error'} | ${errorResolver} | ${false} | ${[]} | ${true} - `('$description', ({ mockResolver, loading, userData, error }) => { + description | mockResolver | loading | userData | pageInfo | error + ${'when API call is loading'} | ${loadingResolver} | ${true} | ${[]} | ${{}} | ${false} + ${'when API returns successful with one page of results'} | ${successfulResolver(MOCK_USERS)} | ${false} | ${MOCK_USERS_FORMATTED} | ${{}} | ${false} + ${'when API returns successful with multiple pages of results'} | ${successfulResolver(MOCK_USERS, MOCK_PAGE_INFO)} | ${false} | ${MOCK_USERS_FORMATTED} | ${MOCK_PAGE_INFO} | ${false} + ${'when API returns successful without results'} | ${successfulResolver([])} | ${false} | ${[]} | ${{}} | ${false} + ${'when API returns error'} | ${errorResolver} | ${false} | ${[]} | ${{}} | ${true} + `('$description', ({ mockResolver, loading, userData, pageInfo, error }) => { beforeEach(async () => { createComponent(mockResolver); await waitForPromises(); @@ -63,6 +71,10 @@ describe('OrganizationsUsersApp', () => { expect(findOrganizationUsersView().props('users')).toStrictEqual(userData); }); + it('renders OrganizationUsersView with correct pageInfo prop', () => { + expect(findOrganizationUsersView().props('pageInfo')).toStrictEqual(pageInfo); + }); + it(`does ${error ? '' : 'not '}render an error message`, () => { return error ? expect(createAlert).toHaveBeenCalledWith({ @@ -74,4 +86,40 @@ describe('OrganizationsUsersApp', () => { : expect(createAlert).not.toHaveBeenCalled(); }); }); + + describe('Pagination', () => { + const mockResolver = successfulResolver(MOCK_USERS, MOCK_PAGE_INFO); + + beforeEach(async () => { + createComponent(mockResolver); + await waitForPromises(); + mockResolver.mockClear(); + }); + + it('handleNextPage calls organizationUsersQuery with correct pagination data', async () => { + findOrganizationUsersView().vm.$emit('next'); + await waitForPromises(); + + expect(mockResolver).toHaveBeenCalledWith({ + id: MOCK_ORGANIZATION_GID, + before: '', + after: MOCK_PAGE_INFO.endCursor, + first: ORGANIZATION_USERS_PER_PAGE, + last: null, + }); + }); + + it('handlePrevPage calls organizationUsersQuery with correct pagination data', async () => { + findOrganizationUsersView().vm.$emit('prev'); + await waitForPromises(); + + expect(mockResolver).toHaveBeenCalledWith({ + id: MOCK_ORGANIZATION_GID, + before: MOCK_PAGE_INFO.startCursor, + after: '', + first: ORGANIZATION_USERS_PER_PAGE, + last: null, + }); + }); + }); }); diff --git a/spec/frontend/organizations/users/components/users_view_spec.js b/spec/frontend/organizations/users/components/users_view_spec.js index 5f47e18edd8..d665c60d425 100644 --- a/spec/frontend/organizations/users/components/users_view_spec.js +++ b/spec/frontend/organizations/users/components/users_view_spec.js @@ -1,8 +1,8 @@ -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlKeysetPagination } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import UsersView from '~/organizations/users/components/users_view.vue'; import UsersTable from '~/vue_shared/components/users_table/users_table.vue'; -import { MOCK_PATHS, MOCK_USERS_FORMATTED } from '../mock_data'; +import { MOCK_PATHS, MOCK_USERS_FORMATTED, MOCK_PAGE_INFO } from '../mock_data'; describe('UsersView', () => { let wrapper; @@ -12,6 +12,7 @@ describe('UsersView', () => { propsData: { loading: false, users: MOCK_USERS_FORMATTED, + pageInfo: MOCK_PAGE_INFO, ...props, }, provide: { @@ -22,6 +23,7 @@ describe('UsersView', () => { const findGlLoading = () => wrapper.findComponent(GlLoadingIcon); const findUsersTable = () => wrapper.findComponent(UsersTable); + const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination); describe.each` description | loading | usersData @@ -40,5 +42,27 @@ describe('UsersView', () => { it(`does ${!loading ? '' : 'not '}render users table`, () => { expect(findUsersTable().exists()).toBe(!loading); }); + + it(`does ${!loading ? '' : 'not '}render pagination`, () => { + expect(findGlKeysetPagination().exists()).toBe(Boolean(!loading)); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + createComponent(); + }); + + it('@next event forwards up to the parent component', () => { + findGlKeysetPagination().vm.$emit('next'); + + expect(wrapper.emitted('next')).toHaveLength(1); + }); + + it('@prev event forwards up to the parent component', () => { + findGlKeysetPagination().vm.$emit('prev'); + + expect(wrapper.emitted('prev')).toHaveLength(1); + }); }); }); diff --git a/spec/frontend/organizations/users/mock_data.js b/spec/frontend/organizations/users/mock_data.js index b6ca00bed79..16b3ec3bbcb 100644 --- a/spec/frontend/organizations/users/mock_data.js +++ b/spec/frontend/organizations/users/mock_data.js @@ -40,3 +40,11 @@ export const MOCK_USERS = [ export const MOCK_USERS_FORMATTED = MOCK_USERS.map(({ badges, user }) => { return { ...user, badges, email: user.publicEmail }; }); + +export const MOCK_PAGE_INFO = { + startCursor: 'aaaa', + endCursor: 'bbbb', + hasNextPage: true, + hasPreviousPage: true, + __typename: 'PageInfo', +}; diff --git a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js index be50858bc88..3db77469d6b 100644 --- a/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js +++ b/spec/frontend/pages/import/bulk_imports/history/components/bulk_imports_history_app_spec.js @@ -1,16 +1,23 @@ import { GlEmptyState, GlLoadingIcon, GlTableLite } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; +import { getParameterValues } from '~/lib/utils/url_utility'; + +import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import BulkImportsHistoryApp from '~/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +jest.mock('~/lib/utils/url_utility', () => ({ + ...jest.requireActual('~/lib/utils/url_utility'), + getParameterValues: jest.fn().mockReturnValue([]), +})); describe('BulkImportsHistoryApp', () => { - const API_URL = '/api/v4/bulk_imports/entities'; + const BULK_IMPORTS_API_URL = '/api/v4/bulk_imports/entities'; const DEFAULT_HEADERS = { 'x-page': 1, @@ -73,14 +80,14 @@ describe('BulkImportsHistoryApp', () => { } const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + const findPaginationBar = () => wrapper.findComponent(PaginationBar); beforeEach(() => { gon.api_version = 'v4'; - }); - beforeEach(() => { + getParameterValues.mockReturnValue([]); mock = new MockAdapter(axios); - mock.onGet(API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS); + mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS); }); afterEach(() => { @@ -94,9 +101,9 @@ describe('BulkImportsHistoryApp', () => { }); it('renders empty state when no data is available', async () => { - mock.onGet(API_URL).reply(HTTP_STATUS_OK, [], DEFAULT_HEADERS); + mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, [], DEFAULT_HEADERS); createComponent(); - await axios.waitForAll(); + await waitForPromises(); expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false); expect(wrapper.findComponent(GlEmptyState).exists()).toBe(true); @@ -104,7 +111,7 @@ describe('BulkImportsHistoryApp', () => { it('renders table with data when history is available', async () => { createComponent(); - await axios.waitForAll(); + await waitForPromises(); const table = wrapper.findComponent(GlTableLite); expect(table.exists()).toBe(true); @@ -116,26 +123,46 @@ describe('BulkImportsHistoryApp', () => { const NEW_PAGE = 4; createComponent(); - await axios.waitForAll(); + await waitForPromises(); mock.resetHistory(); - wrapper.findComponent(PaginationBar).vm.$emit('set-page', NEW_PAGE); - await axios.waitForAll(); + findPaginationBar().vm.$emit('set-page', NEW_PAGE); + await waitForPromises(); expect(mock.history.get.length).toBe(1); expect(mock.history.get[0].params).toStrictEqual(expect.objectContaining({ page: NEW_PAGE })); }); }); + describe('when filtering by bulk_import_id param', () => { + const mockId = 2; + + beforeEach(() => { + getParameterValues.mockReturnValue([mockId]); + }); + + it('makes a request to bulk_import_history endpoint', async () => { + createComponent(); + await waitForPromises(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].url).toBe(`/api/v4/bulk_imports/${mockId}/entities`); + expect(mock.history.get[0].params).toStrictEqual({ + page: 1, + per_page: 20, + }); + }); + }); + it('changes page size when requested by pagination bar', async () => { const NEW_PAGE_SIZE = 4; createComponent(); - await axios.waitForAll(); + await waitForPromises(); mock.resetHistory(); - wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); - await axios.waitForAll(); + findPaginationBar().vm.$emit('set-page-size', NEW_PAGE_SIZE); + await waitForPromises(); expect(mock.history.get.length).toBe(1); expect(mock.history.get[0].params).toStrictEqual( @@ -146,15 +173,14 @@ describe('BulkImportsHistoryApp', () => { it('resets page to 1 when page size is changed', async () => { const NEW_PAGE_SIZE = 4; - mock.onGet(API_URL).reply(200, DUMMY_RESPONSE, DEFAULT_HEADERS); createComponent(); - await axios.waitForAll(); - wrapper.findComponent(PaginationBar).vm.$emit('set-page', 2); - await axios.waitForAll(); + await waitForPromises(); + findPaginationBar().vm.$emit('set-page', 2); + await waitForPromises(); mock.resetHistory(); - wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); - await axios.waitForAll(); + findPaginationBar().vm.$emit('set-page-size', NEW_PAGE_SIZE); + await waitForPromises(); expect(mock.history.get.length).toBe(1); expect(mock.history.get[0].params).toStrictEqual( @@ -166,18 +192,18 @@ describe('BulkImportsHistoryApp', () => { const NEW_PAGE_SIZE = 4; createComponent(); - await axios.waitForAll(); + await waitForPromises(); mock.resetHistory(); - wrapper.findComponent(PaginationBar).vm.$emit('set-page-size', NEW_PAGE_SIZE); - await axios.waitForAll(); + findPaginationBar().vm.$emit('set-page-size', NEW_PAGE_SIZE); + await waitForPromises(); expect(findLocalStorageSync().props('value')).toBe(NEW_PAGE_SIZE); }); it('renders link to destination_full_path for destination group', async () => { createComponent({ shallow: false }); - await axios.waitForAll(); + await waitForPromises(); expect(wrapper.find('tbody tr a').attributes().href).toBe( `/${DUMMY_RESPONSE[0].destination_full_path}`, @@ -187,9 +213,9 @@ describe('BulkImportsHistoryApp', () => { it('renders destination as text when destination_full_path is not defined', async () => { const RESPONSE = [{ ...DUMMY_RESPONSE[0], destination_full_path: null }]; - mock.onGet(API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS); + mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS); createComponent({ shallow: false }); - await axios.waitForAll(); + await waitForPromises(); expect(wrapper.find('tbody tr a').exists()).toBe(false); expect(wrapper.find('tbody tr span').text()).toBe( @@ -199,14 +225,14 @@ describe('BulkImportsHistoryApp', () => { it('adds slash to group urls', async () => { createComponent({ shallow: false }); - await axios.waitForAll(); + await waitForPromises(); expect(wrapper.find('tbody tr a').text()).toBe(`${DUMMY_RESPONSE[0].destination_full_path}/`); }); it('does not prefixes project urls with slash', async () => { createComponent({ shallow: false }); - await axios.waitForAll(); + await waitForPromises(); expect(wrapper.findAll('tbody tr a').at(1).text()).toBe( DUMMY_RESPONSE[1].destination_full_path, @@ -215,9 +241,9 @@ describe('BulkImportsHistoryApp', () => { describe('details button', () => { beforeEach(() => { - mock.onGet(API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS); + mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, DUMMY_RESPONSE, DEFAULT_HEADERS); createComponent({ shallow: false }); - return axios.waitForAll(); + return waitForPromises(); }); it('renders details button if relevant item has failures', () => { @@ -255,7 +281,7 @@ describe('BulkImportsHistoryApp', () => { createComponent({ shallow: false }); await waitForPromises(); - expect(mock.history.get.map((x) => x.url)).toEqual([API_URL]); + expect(mock.history.get.map((x) => x.url)).toEqual([BULK_IMPORTS_API_URL]); }); }); @@ -279,7 +305,7 @@ describe('BulkImportsHistoryApp', () => { const RESPONSE = [mockCreatedImport, ...DUMMY_RESPONSE]; const POLL_HEADERS = { 'poll-interval': pollInterval }; - mock.onGet(API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS); + mock.onGet(BULK_IMPORTS_API_URL).reply(HTTP_STATUS_OK, RESPONSE, DEFAULT_HEADERS); mock.onGet(mockRealtimeChangesPath).replyOnce(HTTP_STATUS_OK, [], POLL_HEADERS); mock .onGet(mockRealtimeChangesPath) @@ -293,7 +319,10 @@ describe('BulkImportsHistoryApp', () => { it('starts polling for realtime changes', () => { jest.advanceTimersByTime(pollInterval); - expect(mock.history.get.map((x) => x.url)).toEqual([API_URL, mockRealtimeChangesPath]); + expect(mock.history.get.map((x) => x.url)).toEqual([ + BULK_IMPORTS_API_URL, + mockRealtimeChangesPath, + ]); expect(wrapper.findAll('tbody tr').at(0).text()).toContain('Pending'); }); @@ -305,7 +334,7 @@ describe('BulkImportsHistoryApp', () => { await waitForPromises(); expect(mock.history.get.map((x) => x.url)).toEqual([ - API_URL, + BULK_IMPORTS_API_URL, mockRealtimeChangesPath, mockRealtimeChangesPath, ]); diff --git a/spec/frontend_integration/fly_out_nav_browser_spec.js b/spec/frontend_integration/fly_out_nav_browser_spec.js deleted file mode 100644 index 07ddc0220e6..00000000000 --- a/spec/frontend_integration/fly_out_nav_browser_spec.js +++ /dev/null @@ -1,366 +0,0 @@ -import { GlBreakpointInstance } from '@gitlab/ui/dist/utils'; -import { SIDEBAR_COLLAPSED_CLASS } from '~/contextual_sidebar'; -import { - calculateTop, - showSubLevelItems, - canShowSubItems, - canShowActiveSubItems, - mouseEnterTopItems, - mouseLeaveTopItem, - getOpenMenu, - setOpenMenu, - mousePos, - getHideSubItemsInterval, - documentMouseMove, - getHeaderHeight, - setSidebar, - subItemsMouseLeave, -} from '~/fly_out_nav'; - -describe('Fly out sidebar navigation', () => { - let el; - let breakpointSize = 'lg'; - - const OLD_SIDEBAR_WIDTH = 200; - const CONTAINER_INITIAL_BOUNDING_RECT = { - x: 8, - y: 8, - width: 769, - height: 0, - top: 8, - right: 777, - bottom: 8, - left: 8, - }; - const SUB_ITEMS_INITIAL_BOUNDING_RECT = { - x: 148, - y: 8, - width: 0, - height: 150, - top: 8, - right: 148, - bottom: 158, - left: 148, - }; - const mockBoundingClientRect = (elem, rect) => { - jest.spyOn(elem, 'getBoundingClientRect').mockReturnValue(rect); - }; - - const findSubItems = () => document.querySelector('.sidebar-sub-level-items'); - const mockBoundingRects = () => { - const subItems = findSubItems(); - mockBoundingClientRect(el, CONTAINER_INITIAL_BOUNDING_RECT); - mockBoundingClientRect(subItems, SUB_ITEMS_INITIAL_BOUNDING_RECT); - }; - const mockSidebarFragment = (styleProps = '') => - `<div class="sidebar-sub-level-items" style="${styleProps}"></div>`; - - beforeEach(() => { - el = document.createElement('div'); - el.style.position = 'relative'; - document.body.appendChild(el); - - jest.spyOn(GlBreakpointInstance, 'getBreakpointSize').mockImplementation(() => breakpointSize); - }); - - afterEach(() => { - document.body.innerHTML = ''; - breakpointSize = 'lg'; - mousePos.length = 0; - - setSidebar(null); - }); - - describe('calculateTop', () => { - it('returns boundingRect top', () => { - const boundingRect = { - top: 100, - height: 100, - }; - - expect(calculateTop(boundingRect, 100)).toBe(100); - }); - }); - - describe('getHideSubItemsInterval', () => { - beforeEach(() => { - el.innerHTML = mockSidebarFragment('position: fixed; top: 0; left: 100px; height: 150px;'); - mockBoundingRects(); - }); - - it('returns 0 if currentOpenMenu is nil', () => { - setOpenMenu(null); - expect(getHideSubItemsInterval()).toBe(0); - }); - - it('returns 0 if mousePos is empty', () => { - expect(getHideSubItemsInterval()).toBe(0); - }); - - it('returns 0 when mouse above sub-items', () => { - showSubLevelItems(el); - documentMouseMove({ - clientX: el.getBoundingClientRect().left, - clientY: el.getBoundingClientRect().top, - }); - documentMouseMove({ - clientX: el.getBoundingClientRect().left, - clientY: el.getBoundingClientRect().top - 50, - }); - - expect(getHideSubItemsInterval()).toBe(0); - }); - - it('returns 0 when mouse is below sub-items', () => { - const subItems = findSubItems(); - - showSubLevelItems(el); - documentMouseMove({ - clientX: el.getBoundingClientRect().left, - clientY: el.getBoundingClientRect().top, - }); - documentMouseMove({ - clientX: el.getBoundingClientRect().left, - clientY: el.getBoundingClientRect().top - subItems.getBoundingClientRect().height + 50, - }); - - expect(getHideSubItemsInterval()).toBe(0); - }); - - it('returns 300 when mouse is moved towards sub-items', () => { - documentMouseMove({ - clientX: el.getBoundingClientRect().left, - clientY: el.getBoundingClientRect().top, - }); - - showSubLevelItems(el); - documentMouseMove({ - clientX: el.getBoundingClientRect().left + 20, - clientY: el.getBoundingClientRect().top + 10, - }); - - expect(getHideSubItemsInterval()).toBe(300); - }); - }); - - describe('mouseLeaveTopItem', () => { - beforeEach(() => { - jest.spyOn(el.classList, 'remove'); - }); - - it('removes is-over class if currentOpenMenu is null', () => { - setOpenMenu(null); - - mouseLeaveTopItem(el); - - expect(el.classList.remove).toHaveBeenCalledWith('is-over'); - }); - - it('removes is-over class if currentOpenMenu is null & there are sub-items', () => { - setOpenMenu(null); - el.innerHTML = mockSidebarFragment('position: absolute'); - - mouseLeaveTopItem(el); - - expect(el.classList.remove).toHaveBeenCalledWith('is-over'); - }); - - it('does not remove is-over class if currentOpenMenu is the passed in sub-items', () => { - setOpenMenu(null); - el.innerHTML = mockSidebarFragment('position: absolute'); - - setOpenMenu(findSubItems()); - mouseLeaveTopItem(el); - - expect(el.classList.remove).not.toHaveBeenCalled(); - }); - }); - - describe('mouseEnterTopItems', () => { - beforeEach(() => { - el.innerHTML = mockSidebarFragment( - `position: absolute; top: 0; left: 100px; height: ${OLD_SIDEBAR_WIDTH}px;`, - ); - mockBoundingRects(); - }); - - it('shows sub-items after 0ms if no menu is open', () => { - const subItems = findSubItems(); - mouseEnterTopItems(el); - - expect(getHideSubItemsInterval()).toBe(0); - - return new Promise((resolve) => { - setTimeout(() => { - expect(subItems.style.display).toBe('block'); - resolve(); - }); - }); - }); - - it('shows sub-items after 300ms if a menu is currently open', () => { - const subItems = findSubItems(); - - documentMouseMove({ - clientX: el.getBoundingClientRect().left, - clientY: el.getBoundingClientRect().top, - }); - - setOpenMenu(subItems); - - documentMouseMove({ - clientX: el.getBoundingClientRect().left + 20, - clientY: el.getBoundingClientRect().top + 10, - }); - - mouseEnterTopItems(el, 0); - - return new Promise((resolve) => { - setTimeout(() => { - expect(subItems.style.display).toBe('block'); - resolve(); - }); - }); - }); - }); - - describe('showSubLevelItems', () => { - beforeEach(() => { - el.innerHTML = mockSidebarFragment('position: absolute'); - }); - - it('adds is-over class to el', () => { - jest.spyOn(el.classList, 'add'); - - showSubLevelItems(el); - - expect(el.classList.add).toHaveBeenCalledWith('is-over'); - }); - - it('does not show sub-items on mobile', () => { - breakpointSize = 'xs'; - - showSubLevelItems(el); - - expect(findSubItems().style.display).not.toBe('block'); - }); - - it('shows sub-items', () => { - showSubLevelItems(el); - - expect(findSubItems().style.display).toBe('block'); - }); - - it('shows collapsed only sub-items if icon only sidebar', () => { - const subItems = findSubItems(); - const sidebar = document.createElement('div'); - sidebar.classList.add(SIDEBAR_COLLAPSED_CLASS); - subItems.classList.add('is-fly-out-only'); - - setSidebar(sidebar); - - showSubLevelItems(el); - - expect(findSubItems().style.display).toBe('block'); - }); - - it('does not show collapsed only sub-items if icon only sidebar', () => { - const subItems = findSubItems(); - subItems.classList.add('is-fly-out-only'); - - showSubLevelItems(el); - - expect(subItems.style.display).not.toBe('block'); - }); - - it('sets transform of sub-items', () => { - const sidebar = document.createElement('div'); - const subItems = findSubItems(); - - sidebar.style.width = `${OLD_SIDEBAR_WIDTH}px`; - - document.body.appendChild(sidebar); - - setSidebar(sidebar); - showSubLevelItems(el); - - expect(subItems.style.transform).toBe( - `translate3d(${OLD_SIDEBAR_WIDTH}px, ${ - Math.floor(el.getBoundingClientRect().top) - getHeaderHeight() - }px, 0)`, - ); - }); - - it('sets is-above when element is above', () => { - const subItems = findSubItems(); - mockBoundingRects(); - - subItems.style.height = `${window.innerHeight + el.offsetHeight}px`; - el.style.top = `${window.innerHeight - el.offsetHeight}px`; - - jest.spyOn(subItems.classList, 'add'); - - showSubLevelItems(el); - - expect(subItems.classList.add).toHaveBeenCalledWith('is-above'); - }); - }); - - describe('canShowSubItems', () => { - it('returns true if on desktop size', () => { - expect(canShowSubItems()).toBe(true); - }); - - it('returns false if on mobile size', () => { - breakpointSize = 'xs'; - - expect(canShowSubItems()).toBe(false); - }); - }); - - describe('canShowActiveSubItems', () => { - it('returns true by default', () => { - expect(canShowActiveSubItems(el)).toBe(true); - }); - - it('returns false when active & expanded sidebar', () => { - const sidebar = document.createElement('div'); - el.classList.add('active'); - - setSidebar(sidebar); - - expect(canShowActiveSubItems(el)).toBe(false); - }); - - it('returns true when active & collapsed sidebar', () => { - const sidebar = document.createElement('div'); - sidebar.classList.add(SIDEBAR_COLLAPSED_CLASS); - el.classList.add('active'); - - setSidebar(sidebar); - - expect(canShowActiveSubItems(el)).toBe(true); - }); - }); - - describe('subItemsMouseLeave', () => { - beforeEach(() => { - el.innerHTML = mockSidebarFragment('position: absolute'); - - setOpenMenu(findSubItems()); - }); - - it('hides subMenu if element is not hovered', () => { - subItemsMouseLeave(el); - - expect(getOpenMenu()).toBeNull(); - }); - - it('does not hide subMenu if element is hovered', () => { - el.classList.add('is-over'); - subItemsMouseLeave(el); - - expect(getOpenMenu()).not.toBeNull(); - }); - }); -}); diff --git a/spec/graphql/resolvers/ci/catalog/resources/versions_resolver_spec.rb b/spec/graphql/resolvers/ci/catalog/resources/versions_resolver_spec.rb new file mode 100644 index 00000000000..1ce0e91765f --- /dev/null +++ b/spec/graphql/resolvers/ci/catalog/resources/versions_resolver_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Ci::Catalog::Resources::VersionsResolver, feature_category: :pipeline_composition do + include GraphqlHelpers + + include_context 'when there are catalog resources with versions' + + let(:sort) { nil } + let(:args) { { sort: sort }.compact } + let(:ctx) { { current_user: current_user } } + + subject(:result) { resolve(described_class, ctx: ctx, obj: resource1, args: args) } + + describe '#resolve' do + context 'when the user is authorized to read project releases' do + before_all do + resource1.project.add_guest(current_user) + end + + context 'when sort argument is not provided' do + it 'returns versions ordered by released_at descending' do + expect(result.items).to eq([v1_1, v1_0]) + end + end + + context 'when sort argument is provided' do + context 'when sort is CREATED_ASC' do + let(:sort) { 'CREATED_ASC' } + + it 'returns versions ordered by created_at ascending' do + expect(result.items.to_a).to eq([v1_1, v1_0]) + end + end + + context 'when sort is CREATED_DESC' do + let(:sort) { 'CREATED_DESC' } + + it 'returns versions ordered by created_at descending' do + expect(result.items).to eq([v1_0, v1_1]) + end + end + + context 'when sort is RELEASED_AT_ASC' do + let(:sort) { 'RELEASED_AT_ASC' } + + it 'returns versions ordered by released_at ascending' do + expect(result.items).to eq([v1_0, v1_1]) + end + end + + context 'when sort is RELEASED_AT_DESC' do + let(:sort) { 'RELEASED_AT_DESC' } + + it 'returns versions ordered by released_at descending' do + expect(result.items).to eq([v1_1, v1_0]) + end + end + end + end + + context 'when the user is not authorized to read project releases' do + it 'returns empty response' do + expect(result).to be_empty + end + end + end +end diff --git a/spec/graphql/resolvers/ci/catalog/versions_resolver_spec.rb b/spec/graphql/resolvers/ci/catalog/versions_resolver_spec.rb deleted file mode 100644 index 02fb3dfaee4..00000000000 --- a/spec/graphql/resolvers/ci/catalog/versions_resolver_spec.rb +++ /dev/null @@ -1,66 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -# In this context, a `version` is equivalent to a `release` -RSpec.describe Resolvers::Ci::Catalog::VersionsResolver, feature_category: :pipeline_composition do - include GraphqlHelpers - - let_it_be(:today) { Time.now } - let_it_be(:yesterday) { today - 1.day } - let_it_be(:tomorrow) { today + 1.day } - - let_it_be(:project) { create(:project, :private) } - # rubocop: disable Layout/LineLength - let_it_be(:version1) { create(:release, project: project, tag: 'v1.0.0', released_at: yesterday, created_at: tomorrow) } - let_it_be(:version2) { create(:release, project: project, tag: 'v2.0.0', released_at: today, created_at: yesterday) } - let_it_be(:version3) { create(:release, project: project, tag: 'v3.0.0', released_at: tomorrow, created_at: today) } - # rubocop: enable Layout/LineLength - let_it_be(:developer) { create(:user) } - let_it_be(:public_user) { create(:user) } - - let(:args) { { sort: :released_at_desc } } - let(:all_releases) { [version1, version2, version3] } - - before_all do - project.add_developer(developer) - end - - describe '#resolve' do - it_behaves_like 'releases and group releases resolver' - - describe 'when order_by is created_at' do - let(:current_user) { developer } - - context 'with sort: desc' do - let(:args) { { sort: :created_desc } } - - it 'returns the releases ordered by created_at in descending order' do - expect(resolve_releases.to_a) - .to match_array(all_releases) - .and be_sorted(:created_at, :desc) - end - end - - context 'with sort: asc' do - let(:args) { { sort: :created_asc } } - - it 'returns the releases ordered by created_at in ascending order' do - expect(resolve_releases.to_a) - .to match_array(all_releases) - .and be_sorted(:created_at, :asc) - end - end - end - end - - private - - def resolve_versions - context = { current_user: current_user } - resolve(described_class, obj: project, args: args, ctx: context, arg_style: :internal) - end - - # Required for shared examples - alias_method :resolve_releases, :resolve_versions -end diff --git a/spec/graphql/types/ci/catalog/resources/version_sort_enum_spec.rb b/spec/graphql/types/ci/catalog/resources/version_sort_enum_spec.rb new file mode 100644 index 00000000000..fd0f1a1e553 --- /dev/null +++ b/spec/graphql/types/ci/catalog/resources/version_sort_enum_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CiCatalogResourceVersionSort'], feature_category: :pipeline_composition do + it { expect(described_class.graphql_name).to eq('CiCatalogResourceVersionSort') } + + it 'exposes all the existing catalog resource version sort options' do + expect(described_class.values.keys).to include( + *%w[RELEASED_AT_ASC RELEASED_AT_DESC CREATED_ASC CREATED_DESC] + ) + end +end diff --git a/spec/graphql/types/ci/catalog/resources/version_type_spec.rb b/spec/graphql/types/ci/catalog/resources/version_type_spec.rb new file mode 100644 index 00000000000..9faf3f16313 --- /dev/null +++ b/spec/graphql/types/ci/catalog/resources/version_type_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Types::Ci::Catalog::Resources::VersionType, feature_category: :pipeline_composition do + specify { expect(described_class.graphql_name).to eq('CiCatalogResourceVersion') } + + it 'exposes the expected fields' do + expected_fields = %i[ + id + created_at + released_at + tag_name + tag_path + author + commit + ] + + expect(described_class).to have_graphql_fields(*expected_fields) + end +end diff --git a/spec/lib/gitlab/database/no_new_tables_with_gitlab_main_schema_spec.rb b/spec/lib/gitlab/database/no_new_tables_with_gitlab_main_schema_spec.rb index 338475fa9c4..648213dc152 100644 --- a/spec/lib/gitlab/database/no_new_tables_with_gitlab_main_schema_spec.rb +++ b/spec/lib/gitlab/database/no_new_tables_with_gitlab_main_schema_spec.rb @@ -11,7 +11,9 @@ RSpec.describe 'new tables with gitlab_main schema', feature_category: :cell do # Specific tables can be exempted from this requirement, and such tables must be added to the `exempted_tables` list. let!(:exempted_tables) do - [] + [ + "audit_events_instance_amazon_s3_configurations" # https://gitlab.com/gitlab-org/gitlab/-/issues/431327 + ] end let!(:starting_from_milestone) { 16.7 } diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb index 775695b406c..53ad3a3a698 100644 --- a/spec/models/ci/bridge_spec.rb +++ b/spec/models/ci/bridge_spec.rb @@ -36,6 +36,24 @@ RSpec.describe Ci::Bridge, feature_category: :continuous_integration do expect(bridge).to have_one(:downstream_pipeline) end + describe 'no-op methods for compatibility with Ci::Build' do + it 'returns an empty array job_artifacts' do + expect(bridge.job_artifacts).to eq(Ci::JobArtifact.none) + end + + it 'return nil for artifacts_expire_at' do + expect(bridge.artifacts_expire_at).to be_nil + end + + it 'return nil for runner' do + expect(bridge.runner).to be_nil + end + + it 'returns an empty TagList for tag_list' do + expect(bridge.tag_list).to be_a(ActsAsTaggableOn::TagList) + end + end + describe '#retryable?' do let(:bridge) { create(:ci_bridge, :success) } diff --git a/spec/models/ci/catalog/resources/version_spec.rb b/spec/models/ci/catalog/resources/version_spec.rb index ccc729ee715..aafd51699b5 100644 --- a/spec/models/ci/catalog/resources/version_spec.rb +++ b/spec/models/ci/catalog/resources/version_spec.rb @@ -10,9 +10,6 @@ RSpec.describe Ci::Catalog::Resources::Version, type: :model, feature_category: it { is_expected.to belong_to(:project) } it { is_expected.to have_many(:components).class_name('Ci::Catalog::Resources::Component') } - it { is_expected.to delegate_method(:name).to(:release) } - it { is_expected.to delegate_method(:description).to(:release) } - it { is_expected.to delegate_method(:tag).to(:release) } it { is_expected.to delegate_method(:sha).to(:release) } it { is_expected.to delegate_method(:released_at).to(:release) } it { is_expected.to delegate_method(:author_id).to(:release) } @@ -127,4 +124,28 @@ RSpec.describe Ci::Catalog::Resources::Version, type: :model, feature_category: end end end + + describe '#name' do + it 'is equivalent to release.tag' do + release_v1_0.update!(name: 'Release v1.0') + + expect(v1_0.name).to eq(release_v1_0.tag) + end + end + + describe '#commit' do + subject(:commit) { v1_0.commit } + + it 'returns a commit' do + is_expected.to be_a(Commit) + end + + context 'when the sha is nil' do + it 'returns nil' do + release_v1_0.update!(sha: nil) + + is_expected.to be_nil + end + end + end end diff --git a/spec/requests/api/deployments_spec.rb b/spec/requests/api/deployments_spec.rb index 41c5847e940..d9248ad6855 100644 --- a/spec/requests/api/deployments_spec.rb +++ b/spec/requests/api/deployments_spec.rb @@ -161,24 +161,56 @@ RSpec.describe API::Deployments, feature_category: :continuous_delivery do end describe 'GET /projects/:id/deployments/:deployment_id' do - let(:project) { deployment.environment.project } - let!(:deployment) { create(:deployment, :success) } + let_it_be(:deployment_with_bridge) { create(:deployment, :with_bridge, :success) } + let_it_be(:deployment_with_build) { create(:deployment, :success) } context 'as a member of the project' do - it 'returns the projects deployment' do - get api("/projects/#{project.id}/deployments/#{deployment.id}", user) + shared_examples "returns project deployments" do + let(:project) { deployment.environment.project } - expect(response).to have_gitlab_http_status(:ok) - expect(json_response['sha']).to match /\A\h{40}\z/ - expect(json_response['id']).to eq(deployment.id) + it 'returns the expected response' do + get api("/projects/#{project.id}/deployments/#{deployment.id}", user) + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response['sha']).to match /\A\h{40}\z/ + expect(json_response['id']).to eq(deployment.id) + end + end + + context 'when the deployable is a build' do + it_behaves_like 'returns project deployments' do + let!(:deployment) { deployment_with_build } + end + end + + context 'when the deployable is a bridge' do + it_behaves_like 'returns project deployments' do + let!(:deployment) { deployment_with_bridge } + end end end context 'as non member' do - it 'returns a 404 status code' do - get api("/projects/#{project.id}/deployments/#{deployment.id}", non_member) + shared_examples 'deployment will not be found' do + let(:project) { deployment.environment.project } - expect(response).to have_gitlab_http_status(:not_found) + it 'returns a 404 status code' do + get api("/projects/#{project.id}/deployments/#{deployment.id}", non_member) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context 'when the deployable is a build' do + it_behaves_like 'deployment will not be found' do + let!(:deployment) { deployment_with_build } + end + end + + context 'when the deployable is a bridge' do + it_behaves_like 'deployment will not be found' do + let!(:deployment) { deployment_with_bridge } + end end end end diff --git a/spec/requests/api/environments_spec.rb b/spec/requests/api/environments_spec.rb index 498e030da0b..aed97bcfe7c 100644 --- a/spec/requests/api/environments_spec.rb +++ b/spec/requests/api/environments_spec.rb @@ -374,32 +374,71 @@ RSpec.describe API::Environments, feature_category: :continuous_delivery do end describe 'GET /projects/:id/environments/:environment_id' do + let_it_be(:bridge_job) { create(:ci_bridge, :running, project: project, user: user) } + let_it_be(:build_job) { create(:ci_build, :running, project: project, user: user) } + context 'as member of the project' do - it 'returns project environments' do - create(:deployment, :success, project: project, environment: environment) + shared_examples "returns project environments" do + it 'returns expected response' do + create( + :deployment, + :success, + project: project, + environment: environment, + deployable: job + ) + + get api("/projects/#{project.id}/environments/#{environment.id}", user) - get api("/projects/#{project.id}/environments/#{environment.id}", user) + expect(response).to have_gitlab_http_status(:ok) + expect(response).to match_response_schema('public_api/v4/environment') + expect(json_response['last_deployment']).to be_present + end + end - expect(response).to have_gitlab_http_status(:ok) - expect(response).to match_response_schema('public_api/v4/environment') - expect(json_response['last_deployment']).to be_present + context "when the deployable is a bridge" do + it_behaves_like "returns project environments" do + let(:job) { bridge_job } + end + + # No test for Ci::Bridge JOB-TOKEN auth because it doesn't implement the `.token` method. end - it 'returns 200 HTTP status when using JOB-TOKEN auth' do - job = create(:ci_build, :running, project: project, user: user) + context "when the deployable is a build" do + it_behaves_like "returns project environments" do + let(:job) { build_job } + end - get api("/projects/#{project.id}/environments/#{environment.id}"), - params: { job_token: job.token } + it 'returns 200 HTTP status when using JOB-TOKEN auth' do + get( + api("/projects/#{project.id}/environments/#{environment.id}"), + params: { job_token: build_job.token } + ) - expect(response).to have_gitlab_http_status(:ok) + expect(response).to have_gitlab_http_status(:ok) + end end end context 'as non member' do - it 'returns a 404 status code' do - get api("/projects/#{project.id}/environments/#{environment.id}", non_member) + shared_examples 'environment will not be found' do + it 'returns a 404 status code' do + get api("/projects/#{project.id}/environments/#{environment.id}", non_member) - expect(response).to have_gitlab_http_status(:not_found) + expect(response).to have_gitlab_http_status(:not_found) + end + end + + context "when the deployable is a bridge" do + it_behaves_like "environment will not be found" do + let(:job) { bridge_job } + end + end + + context "when the deployable is a build" do + it_behaves_like "environment will not be found" do + let(:job) { build_job } + end end end end diff --git a/spec/requests/api/graphql/ci/catalog/resource_spec.rb b/spec/requests/api/graphql/ci/catalog/resource_spec.rb index 56c6c341647..e9610e3c435 100644 --- a/spec/requests/api/graphql/ci/catalog/resource_spec.rb +++ b/spec/requests/api/graphql/ci/catalog/resource_spec.rb @@ -81,6 +81,7 @@ RSpec.describe 'Query.ciCatalogResource', feature_category: :pipeline_compositio nodes { id tagName + tagPath releasedAt author { id @@ -98,11 +99,13 @@ RSpec.describe 'Query.ciCatalogResource', feature_category: :pipeline_compositio let_it_be(:author) { create(:user, name: 'author') } let_it_be(:version1) do - create(:release, project: project, released_at: '2023-01-01T00:00:00Z', author: author) + create(:release, :with_catalog_resource_version, project: project, released_at: '2023-01-01T00:00:00Z', + author: author).catalog_resource_version end let_it_be(:version2) do - create(:release, project: project, released_at: '2023-02-01T00:00:00Z', author: author) + create(:release, :with_catalog_resource_version, project: project, released_at: '2023-02-01T00:00:00Z', + author: author).catalog_resource_version end it 'returns the resource with the versions data' do @@ -115,13 +118,15 @@ RSpec.describe 'Query.ciCatalogResource', feature_category: :pipeline_compositio expect(graphql_data_at(:ciCatalogResource, :versions, :nodes)).to contain_exactly( a_graphql_entity_for( version1, - tagName: version1.tag, + tagName: version1.name, + tagPath: project_tag_path(project, version1.name), releasedAt: version1.released_at, author: a_graphql_entity_for(author, :name) ), a_graphql_entity_for( version2, - tagName: version2.tag, + tagName: version2.name, + tagPath: project_tag_path(project, version2.name), releasedAt: version2.released_at, author: a_graphql_entity_for(author, :name) ) @@ -157,6 +162,7 @@ RSpec.describe 'Query.ciCatalogResource', feature_category: :pipeline_compositio latestVersion { id tagName + tagPath releasedAt author { id @@ -173,12 +179,14 @@ RSpec.describe 'Query.ciCatalogResource', feature_category: :pipeline_compositio let_it_be(:author) { create(:user, name: 'author') } let_it_be(:latest_version) do - create(:release, project: project, released_at: '2023-02-01T00:00:00Z', author: author) + create(:release, :with_catalog_resource_version, project: project, released_at: '2023-02-01T00:00:00Z', + author: author).catalog_resource_version end before_all do - # Previous version of the project - create(:release, project: project, released_at: '2023-01-01T00:00:00Z', author: author) + # Previous version of the catalog resource + create(:release, :with_catalog_resource_version, project: project, released_at: '2023-01-01T00:00:00Z', + author: author) end it 'returns the resource with the latest version data' do @@ -189,7 +197,8 @@ RSpec.describe 'Query.ciCatalogResource', feature_category: :pipeline_compositio resource, latestVersion: a_graphql_entity_for( latest_version, - tagName: latest_version.tag, + tagName: latest_version.name, + tagPath: project_tag_path(project, latest_version.name), releasedAt: latest_version.released_at, author: a_graphql_entity_for(author, :name) ) diff --git a/spec/requests/api/graphql/ci/catalog/resources_spec.rb b/spec/requests/api/graphql/ci/catalog/resources_spec.rb index 35f72f8b10f..dee841898bb 100644 --- a/spec/requests/api/graphql/ci/catalog/resources_spec.rb +++ b/spec/requests/api/graphql/ci/catalog/resources_spec.rb @@ -133,11 +133,13 @@ RSpec.describe 'Query.ciCatalogResources', feature_category: :pipeline_compositi let_it_be(:author2) { create(:user, name: 'author2') } let_it_be(:latest_version1) do - create(:release, project: project1, released_at: '2023-02-01T00:00:00Z', author: author1) + create(:release, :with_catalog_resource_version, project: project1, released_at: '2023-02-01T00:00:00Z', + author: author1).catalog_resource_version end let_it_be(:latest_version2) do - create(:release, project: public_project, released_at: '2023-02-01T00:00:00Z', author: author2) + create(:release, :with_catalog_resource_version, project: public_project, released_at: '2023-02-01T00:00:00Z', + author: author2).catalog_resource_version end let(:query) do @@ -165,9 +167,11 @@ RSpec.describe 'Query.ciCatalogResources', feature_category: :pipeline_compositi before_all do namespace.add_developer(user) - # Previous versions of the projects - create(:release, project: project1, released_at: '2023-01-01T00:00:00Z', author: author1) - create(:release, project: public_project, released_at: '2023-01-01T00:00:00Z', author: author2) + # Previous versions of the catalog resources + create(:release, :with_catalog_resource_version, project: project1, released_at: '2023-01-01T00:00:00Z', + author: author1) + create(:release, :with_catalog_resource_version, project: public_project, released_at: '2023-01-01T00:00:00Z', + author: author2) end it 'returns all resources with the latest version data' do @@ -178,7 +182,7 @@ RSpec.describe 'Query.ciCatalogResources', feature_category: :pipeline_compositi resource1, latestVersion: a_graphql_entity_for( latest_version1, - tagName: latest_version1.tag, + tagName: latest_version1.name, releasedAt: latest_version1.released_at, author: a_graphql_entity_for(author1, :name) ) @@ -187,7 +191,7 @@ RSpec.describe 'Query.ciCatalogResources', feature_category: :pipeline_compositi public_resource, latestVersion: a_graphql_entity_for( latest_version2, - tagName: latest_version2.tag, + tagName: latest_version2.name, releasedAt: latest_version2.released_at, author: a_graphql_entity_for(author2, :name) ) @@ -195,8 +199,7 @@ RSpec.describe 'Query.ciCatalogResources', feature_category: :pipeline_compositi ) end - # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/430350 - # it_behaves_like 'avoids N+1 queries' + it_behaves_like 'avoids N+1 queries' end describe 'rootNamespace' do diff --git a/spec/services/ml/model_versions/delete_service_spec.rb b/spec/services/ml/model_versions/delete_service_spec.rb new file mode 100644 index 00000000000..1cc5a2f85a5 --- /dev/null +++ b/spec/services/ml/model_versions/delete_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ml::ModelVersions::DeleteService, feature_category: :mlops do + let_it_be(:valid_model_version) do + create(:ml_model_versions, :with_package) + end + + let(:project) { valid_model_version.project } + let(:user) { valid_model_version.project.owner } + let(:name) { valid_model_version.name } + let(:version) { valid_model_version.version } + + subject(:execute_service) { described_class.new(project, name, version, user).execute } + + describe '#execute' do + context 'when model version exists' do + it 'deletes the model version', :aggregate_failures do + expect(execute_service).to be_success + expect(Ml::ModelVersion.find_by(id: valid_model_version.id)).to be_nil + end + end + + context 'when model version does not exist' do + let(:version) { 'wrong-version' } + + it { is_expected.to be_error.and have_attributes(message: 'Model not found') } + end + + context 'when model version has no package' do + before do + valid_model_version.update!(package: nil) + end + + it 'does not trigger destroy package service', :aggregate_failures do + expect(Packages::MarkPackageForDestructionService).not_to receive(:new) + expect(execute_service).to be_success + end + end + + context 'when package cannot be marked for destruction' do + before do + allow_next_instance_of(Packages::MarkPackageForDestructionService) do |service| + allow(service).to receive(:execute).and_return(ServiceResponse.error(message: 'error')) + end + end + + it 'does not delete the model version', :aggregate_failures do + is_expected.to be_error.and have_attributes(message: 'error') + expect(Ml::ModelVersion.find_by(id: valid_model_version.id)).to eq(valid_model_version) + end + end + end +end diff --git a/spec/support/shared_contexts/ci/catalog/resources/version_shared_context.rb b/spec/support/shared_contexts/ci/catalog/resources/version_shared_context.rb index ac2db624a38..3eeaa52d221 100644 --- a/spec/support/shared_contexts/ci/catalog/resources/version_shared_context.rb +++ b/spec/support/shared_contexts/ci/catalog/resources/version_shared_context.rb @@ -10,7 +10,7 @@ RSpec.shared_context 'when there are catalog resources with versions' do let_it_be_with_reload(:resource2) { create(:ci_catalog_resource, project: project2) } let_it_be(:resource3) { create(:ci_catalog_resource, project: project3) } - let_it_be(:release_v1_0) { create(:release, project: project1, tag: 'v1.0', released_at: 4.days.ago) } + let_it_be_with_reload(:release_v1_0) { create(:release, project: project1, tag: 'v1.0', released_at: 4.days.ago) } let_it_be(:release_v1_1) { create(:release, project: project1, tag: 'v1.1', released_at: 3.days.ago) } let_it_be(:release_v2_0) { create(:release, project: project2, tag: 'v2.0', released_at: 2.days.ago) } let_it_be(:release_v2_1) { create(:release, project: project2, tag: 'v2.1', released_at: 1.day.ago) } diff --git a/spec/support/shared_examples/services/protected_branches_shared_examples.rb b/spec/support/shared_examples/services/protected_branches_shared_examples.rb index 80e2f09ed44..6d4b82730da 100644 --- a/spec/support/shared_examples/services/protected_branches_shared_examples.rb +++ b/spec/support/shared_examples/services/protected_branches_shared_examples.rb @@ -12,7 +12,7 @@ RSpec.shared_context 'with scan result policy blocking protected branches' do end let(:scan_result_policy) do - build(:scan_result_policy, branches: [branch_name], approval_settings: { block_unprotecting_branches: true }) + build(:scan_result_policy, branches: [branch_name], approval_settings: { block_branch_modification: true }) end before do |