diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-18 21:08:47 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-07-18 21:08:47 +0300 |
commit | 128d4d89e98177996d1ff6e0b3d7a8a0c9b35929 (patch) | |
tree | 88b02d3bf972bac281d673e99f854303e0dd13ed | |
parent | cc1066db64a2a283a3d229b9bbb67c01716ca871 (diff) |
Add latest changes from gitlab-org/gitlab@master
121 files changed, 2719 insertions, 967 deletions
diff --git a/.gitlab/ci/review-apps/main.gitlab-ci.yml b/.gitlab/ci/review-apps/main.gitlab-ci.yml index 45ada3fd09e..7f3ae70da8c 100644 --- a/.gitlab/ci/review-apps/main.gitlab-ci.yml +++ b/.gitlab/ci/review-apps/main.gitlab-ci.yml @@ -77,7 +77,7 @@ review-build-cng: variables: HOST_SUFFIX: "${CI_ENVIRONMENT_SLUG}" DOMAIN: "-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN}" - GITLAB_HELM_CHART_REF: "a6a609a19166f00b1a7774374041cd38a9f7e20d" + GITLAB_HELM_CHART_REF: "138c146a5ba787942f66d4c7d795d224d6ba206a" environment: name: review/${CI_COMMIT_REF_SLUG}${SCHEDULE_TYPE} # No separator for SCHEDULE_TYPE so it's compatible as before and looks nice without it url: https://gitlab-${CI_ENVIRONMENT_SLUG}.${REVIEW_APPS_DOMAIN} diff --git a/app/assets/javascripts/google_cloud/components/app.vue b/app/assets/javascripts/google_cloud/components/app.vue deleted file mode 100644 index b3d773e6bee..00000000000 --- a/app/assets/javascripts/google_cloud/components/app.vue +++ /dev/null @@ -1,63 +0,0 @@ -<script> -import { __ } from '~/locale'; - -import Home from './home.vue'; -import IncubationBanner from './incubation_banner.vue'; -import ServiceAccountsForm from './service_accounts_form.vue'; -import GcpRegionsForm from './gcp_regions_form.vue'; -import NoGcpProjects from './errors/no_gcp_projects.vue'; -import GcpError from './errors/gcp_error.vue'; - -const SCREEN_GCP_ERROR = 'gcp_error'; -const SCREEN_HOME = 'home'; -const SCREEN_NO_GCP_PROJECTS = 'no_gcp_projects'; -const SCREEN_SERVICE_ACCOUNTS_FORM = 'service_accounts_form'; -const SCREEN_GCP_REGIONS_FORM = 'gcp_regions_form'; - -export default { - components: { - IncubationBanner, - }, - inheritAttrs: false, - props: { - screen: { - required: true, - type: String, - }, - }, - computed: { - mainComponent() { - switch (this.screen) { - case SCREEN_HOME: - return Home; - case SCREEN_GCP_ERROR: - return GcpError; - case SCREEN_NO_GCP_PROJECTS: - return NoGcpProjects; - case SCREEN_SERVICE_ACCOUNTS_FORM: - return ServiceAccountsForm; - case SCREEN_GCP_REGIONS_FORM: - return GcpRegionsForm; - default: - throw new Error(__('Unknown screen')); - } - }, - }, - methods: { - feedbackUrl(template) { - return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=${template}`; - }, - }, -}; -</script> - -<template> - <div> - <incubation-banner - :share-feedback-url="feedbackUrl('general_feedback')" - :report-bug-url="feedbackUrl('report_bug')" - :feature-request-url="feedbackUrl('feature_request')" - /> - <component :is="mainComponent" v-bind="$attrs" /> - </div> -</template> diff --git a/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue b/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue deleted file mode 100644 index 90aa0e1ae68..00000000000 --- a/app/assets/javascripts/google_cloud/components/errors/gcp_error.vue +++ /dev/null @@ -1,29 +0,0 @@ -<script> -import { GlAlert } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { GlAlert }, - props: { - error: { - type: String, - required: true, - }, - }, - i18n: { - title: __('Google Cloud project misconfigured'), - description: __( - 'GitLab and Google Cloud configuration seems to be incomplete. This probably can be fixed by your GitLab administration team. You may share these logs with them:', - ), - }, -}; -</script> - -<template> - <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title"> - {{ $options.i18n.description }} - <blockquote> - <code>{{ error }}</code> - </blockquote> - </gl-alert> -</template> diff --git a/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue b/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue deleted file mode 100644 index da229ac3f0e..00000000000 --- a/app/assets/javascripts/google_cloud/components/errors/no_gcp_projects.vue +++ /dev/null @@ -1,26 +0,0 @@ -<script> -import { GlAlert, GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; - -export default { - components: { GlAlert, GlButton }, - i18n: { - title: __('Google Cloud project required'), - description: __( - 'You do not have any Google Cloud projects. Please create a Google Cloud project and then reload this page.', - ), - createLabel: __('Create Google Cloud project'), - }, -}; -</script> - -<template> - <gl-alert :dismissible="false" variant="warning" :title="$options.i18n.title"> - {{ $options.i18n.description }} - <template #actions> - <gl-button href="https://console.cloud.google.com/projectcreate" target="_blank"> - {{ $options.i18n.createLabel }} - </gl-button> - </template> - </gl-alert> -</template> diff --git a/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue new file mode 100644 index 00000000000..d6b7c702b54 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/google_cloud_menu.vue @@ -0,0 +1,85 @@ +<script> +import { s__ } from '~/locale'; + +const CONFIGURATION_KEY = 'configuration'; +const DEPLOYMENTS_KEY = 'deployments'; +const DATABASES_KEY = 'databases'; + +const i18n = { + configuration: { title: s__('CloudSeed|Configuration') }, + deployments: { title: s__('CloudSeed|Deployments') }, + databases: { title: s__('CloudSeed|Databases') }, +}; + +export default { + props: { + active: { + type: String, + required: true, + }, + configurationUrl: { + type: String, + required: true, + }, + deploymentsUrl: { + type: String, + required: true, + }, + databasesUrl: { + type: String, + required: true, + }, + }, + computed: { + isConfigurationActive() { + return this.active === CONFIGURATION_KEY; + }, + isDeploymentsActive() { + return this.active === DEPLOYMENTS_KEY; + }, + isDatabasesActive() { + return this.active === DATABASES_KEY; + }, + }, + i18n, +}; +</script> +<template> + <div class="tabs gl-tabs"> + <ul role="tablist" class="nav gl-tabs-nav"> + <li role="presentation" class="nav-item"> + <a + data-testid="configurationLink" + role="tab" + :href="configurationUrl" + class="nav-link gl-tab-nav-item" + :class="{ 'gl-tab-nav-item-active': isConfigurationActive }" + > + {{ $options.i18n.configuration.title }}</a + > + </li> + <li role="presentation" class="nav-item"> + <a + data-testid="deploymentsLink" + role="tab" + :href="deploymentsUrl" + class="nav-link gl-tab-nav-item" + :class="{ 'gl-tab-nav-item-active': isDeploymentsActive }" + > + {{ $options.i18n.deployments.title }} + </a> + </li> + <li role="presentation" class="nav-item"> + <a + data-testid="databasesLink" + role="tab" + :href="databasesUrl" + class="nav-link gl-tab-nav-item" + :class="{ 'gl-tab-nav-item-active': isDatabasesActive }" + > + {{ $options.i18n.databases.title }} + </a> + </li> + </ul> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue deleted file mode 100644 index e41337e2679..00000000000 --- a/app/assets/javascripts/google_cloud/components/home.vue +++ /dev/null @@ -1,81 +0,0 @@ -<script> -import { GlTabs, GlTab } from '@gitlab/ui'; -import DeploymentsServiceTable from './deployments_service_table.vue'; -import RevokeOauth from './revoke_oauth.vue'; -import ServiceAccountsList from './service_accounts_list.vue'; -import GcpRegionsList from './gcp_regions_list.vue'; - -export default { - components: { - GlTabs, - GlTab, - DeploymentsServiceTable, - RevokeOauth, - ServiceAccountsList, - GcpRegionsList, - }, - props: { - serviceAccounts: { - type: Array, - required: true, - }, - createServiceAccountUrl: { - type: String, - required: true, - }, - configureGcpRegionsUrl: { - type: String, - required: true, - }, - emptyIllustrationUrl: { - type: String, - required: true, - }, - enableCloudRunUrl: { - type: String, - required: true, - }, - enableCloudStorageUrl: { - type: String, - required: true, - }, - gcpRegions: { - type: Array, - required: true, - }, - revokeOauthUrl: { - type: String, - required: true, - }, - }, -}; -</script> - -<template> - <gl-tabs> - <gl-tab :title="__('Configuration')"> - <service-accounts-list - class="gl-mx-4" - :list="serviceAccounts" - :create-url="createServiceAccountUrl" - :empty-illustration-url="emptyIllustrationUrl" - /> - <hr /> - <gcp-regions-list - class="gl-mx-4" - :empty-illustration-url="emptyIllustrationUrl" - :create-url="configureGcpRegionsUrl" - :list="gcpRegions" - /> - <hr v-if="revokeOauthUrl" /> - <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" /> - </gl-tab> - <gl-tab :title="__('Deployments')"> - <deployments-service-table - :cloud-run-url="enableCloudRunUrl" - :cloud-storage-url="enableCloudStorageUrl" - /> - </gl-tab> - <gl-tab :title="__('Services')" disabled /> - </gl-tabs> -</template> diff --git a/app/assets/javascripts/google_cloud/components/incubation_banner.vue b/app/assets/javascripts/google_cloud/components/incubation_banner.vue index 652b8c1aecb..128b3dcb1d9 100644 --- a/app/assets/javascripts/google_cloud/components/incubation_banner.vue +++ b/app/assets/javascripts/google_cloud/components/incubation_banner.vue @@ -1,22 +1,20 @@ <script> import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +const FEATURE_REQUEST_KEY = 'feature_request'; +const REPORT_BUG_KEY = 'report_bug'; +const GENERAL_FEEDBACK_KEY = 'general_feedback'; + export default { components: { GlAlert, GlLink, GlSprintf }, - props: { - shareFeedbackUrl: { - required: true, - type: String, - }, - reportBugUrl: { - required: true, - type: String, - }, - featureRequestUrl: { - required: true, - type: String, + methods: { + feedbackUrl(template) { + return `https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=${template}`; }, }, + FEATURE_REQUEST_KEY, + REPORT_BUG_KEY, + GENERAL_FEEDBACK_KEY, }; </script> @@ -31,13 +29,13 @@ export default { " > <template #featureLink="{ content }"> - <gl-link :href="featureRequestUrl">{{ content }}</gl-link> + <gl-link :href="feedbackUrl($options.FEATURE_REQUEST_KEY)">{{ content }}</gl-link> </template> <template #bugLink="{ content }"> - <gl-link :href="reportBugUrl">{{ content }}</gl-link> + <gl-link :href="feedbackUrl($options.REPORT_BUG_KEY)">{{ content }}</gl-link> </template> <template #feedbackLink="{ content }"> - <gl-link :href="shareFeedbackUrl">{{ content }}</gl-link> + <gl-link :href="feedbackUrl($options.GENERAL_FEEDBACK_KEY)">{{ content }}</gl-link> </template> </gl-sprintf> </gl-alert> diff --git a/app/assets/javascripts/google_cloud/configuration/index.js b/app/assets/javascripts/google_cloud/configuration/index.js new file mode 100644 index 00000000000..580315588d0 --- /dev/null +++ b/app/assets/javascripts/google_cloud/configuration/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Panel from './panel.vue'; + +export default (containerId = '#js-google-cloud-configuration') => { + const element = document.querySelector(containerId); + const { ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Panel, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/configuration/panel.vue b/app/assets/javascripts/google_cloud/configuration/panel.vue new file mode 100644 index 00000000000..ee046eb1988 --- /dev/null +++ b/app/assets/javascripts/google_cloud/configuration/panel.vue @@ -0,0 +1,88 @@ +<script> +import GcpRegionsList from '../gcp_regions/list.vue'; +import GoogleCloudMenu from '../components/google_cloud_menu.vue'; +import IncubationBanner from '../components/incubation_banner.vue'; +import RevokeOauth from '../components/revoke_oauth.vue'; +import ServiceAccountsList from '../service_accounts/list.vue'; + +export default { + components: { + GcpRegionsList, + GoogleCloudMenu, + IncubationBanner, + RevokeOauth, + ServiceAccountsList, + }, + props: { + configurationUrl: { + type: String, + required: true, + }, + deploymentsUrl: { + type: String, + required: true, + }, + databasesUrl: { + type: String, + required: true, + }, + serviceAccounts: { + type: Array, + required: true, + }, + createServiceAccountUrl: { + type: String, + required: true, + }, + emptyIllustrationUrl: { + type: String, + required: true, + }, + configureGcpRegionsUrl: { + type: String, + required: true, + }, + gcpRegions: { + type: Array, + required: true, + }, + revokeOauthUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <incubation-banner /> + + <google-cloud-menu + active="configuration" + :configuration-url="configurationUrl" + :deployments-url="deploymentsUrl" + :databases-url="databasesUrl" + /> + + <service-accounts-list + class="gl-mx-4" + :list="serviceAccounts" + :create-url="createServiceAccountUrl" + :empty-illustration-url="emptyIllustrationUrl" + /> + + <hr /> + + <gcp-regions-list + class="gl-mx-4" + :empty-illustration-url="emptyIllustrationUrl" + :create-url="configureGcpRegionsUrl" + :list="gcpRegions" + /> + + <hr v-if="revokeOauthUrl" /> + + <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" /> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/cloudsql/create_instance_form.vue b/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue index 0ac561b6132..0ac561b6132 100644 --- a/app/assets/javascripts/google_cloud/components/cloudsql/create_instance_form.vue +++ b/app/assets/javascripts/google_cloud/databases/cloudsql/create_instance_form.vue diff --git a/app/assets/javascripts/google_cloud/components/cloudsql/instance_table.vue b/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue index 823895214df..823895214df 100644 --- a/app/assets/javascripts/google_cloud/components/cloudsql/instance_table.vue +++ b/app/assets/javascripts/google_cloud/databases/cloudsql/instance_table.vue diff --git a/app/assets/javascripts/google_cloud/databases/index.js b/app/assets/javascripts/google_cloud/databases/index.js new file mode 100644 index 00000000000..e240a1116e8 --- /dev/null +++ b/app/assets/javascripts/google_cloud/databases/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Panel from './panel.vue'; + +export default (containerId = '#js-google-cloud-databases') => { + const element = document.querySelector(containerId); + const { ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Panel, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/databases/panel.vue b/app/assets/javascripts/google_cloud/databases/panel.vue new file mode 100644 index 00000000000..e2f18c286a5 --- /dev/null +++ b/app/assets/javascripts/google_cloud/databases/panel.vue @@ -0,0 +1,38 @@ +<script> +import GoogleCloudMenu from '../components/google_cloud_menu.vue'; +import IncubationBanner from '../components/incubation_banner.vue'; + +export default { + components: { + IncubationBanner, + GoogleCloudMenu, + }, + props: { + configurationUrl: { + type: String, + required: true, + }, + deploymentsUrl: { + type: String, + required: true, + }, + databasesUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <incubation-banner /> + + <google-cloud-menu + active="databases" + :configuration-url="configurationUrl" + :deployments-url="deploymentsUrl" + :databases-url="databasesUrl" + /> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/databases/service_table.vue b/app/assets/javascripts/google_cloud/databases/service_table.vue index 80bd6ef28fb..80bd6ef28fb 100644 --- a/app/assets/javascripts/google_cloud/components/databases/service_table.vue +++ b/app/assets/javascripts/google_cloud/databases/service_table.vue diff --git a/app/assets/javascripts/google_cloud/deployments/index.js b/app/assets/javascripts/google_cloud/deployments/index.js new file mode 100644 index 00000000000..fcbb2209c40 --- /dev/null +++ b/app/assets/javascripts/google_cloud/deployments/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Panel from './panel.vue'; + +export default (containerId = '#js-google-cloud-deployments') => { + const element = document.querySelector(containerId); + const { ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Panel, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/deployments/panel.vue b/app/assets/javascripts/google_cloud/deployments/panel.vue new file mode 100644 index 00000000000..89db132ad5e --- /dev/null +++ b/app/assets/javascripts/google_cloud/deployments/panel.vue @@ -0,0 +1,50 @@ +<script> +import GoogleCloudMenu from '../components/google_cloud_menu.vue'; +import IncubationBanner from '../components/incubation_banner.vue'; +import ServiceTable from './service_table.vue'; + +export default { + components: { + ServiceTable, + IncubationBanner, + GoogleCloudMenu, + }, + props: { + configurationUrl: { + type: String, + required: true, + }, + deploymentsUrl: { + type: String, + required: true, + }, + databasesUrl: { + type: String, + required: true, + }, + enableCloudRunUrl: { + type: String, + required: true, + }, + enableCloudStorageUrl: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div> + <incubation-banner /> + + <google-cloud-menu + active="deployments" + :configuration-url="configurationUrl" + :deployments-url="deploymentsUrl" + :databases-url="databasesUrl" + /> + + <service-table :cloud-run-url="enableCloudRunUrl" :cloud-storage-url="enableCloudStorageUrl" /> + </div> +</template> diff --git a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue b/app/assets/javascripts/google_cloud/deployments/service_table.vue index 26c9fd14dc6..26c9fd14dc6 100644 --- a/app/assets/javascripts/google_cloud/components/deployments_service_table.vue +++ b/app/assets/javascripts/google_cloud/deployments/service_table.vue diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue b/app/assets/javascripts/google_cloud/gcp_regions/form.vue index 23011e5a5b0..23011e5a5b0 100644 --- a/app/assets/javascripts/google_cloud/components/gcp_regions_form.vue +++ b/app/assets/javascripts/google_cloud/gcp_regions/form.vue diff --git a/app/assets/javascripts/google_cloud/gcp_regions/index.js b/app/assets/javascripts/google_cloud/gcp_regions/index.js new file mode 100644 index 00000000000..da37c612805 --- /dev/null +++ b/app/assets/javascripts/google_cloud/gcp_regions/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Form from './form.vue'; + +export default (containerId = '#js-google-cloud-gcp-regions') => { + const element = document.querySelector(containerId); + const { ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Form, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue b/app/assets/javascripts/google_cloud/gcp_regions/list.vue index 5d403d5cd65..5d403d5cd65 100644 --- a/app/assets/javascripts/google_cloud/components/gcp_regions_list.vue +++ b/app/assets/javascripts/google_cloud/gcp_regions/list.vue diff --git a/app/assets/javascripts/google_cloud/index.js b/app/assets/javascripts/google_cloud/index.js deleted file mode 100644 index ab9e8227812..00000000000 --- a/app/assets/javascripts/google_cloud/index.js +++ /dev/null @@ -1,12 +0,0 @@ -import Vue from 'vue'; -import App from './components/app.vue'; - -export default () => { - const root = '#js-google-cloud'; - const element = document.querySelector(root); - const { screen, ...attrs } = JSON.parse(element.getAttribute('data')); - return new Vue({ - el: element, - render: (createElement) => createElement(App, { props: { screen }, attrs }), - }); -}; diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue b/app/assets/javascripts/google_cloud/service_accounts/form.vue index faec94e735b..faec94e735b 100644 --- a/app/assets/javascripts/google_cloud/components/service_accounts_form.vue +++ b/app/assets/javascripts/google_cloud/service_accounts/form.vue diff --git a/app/assets/javascripts/google_cloud/service_accounts/index.js b/app/assets/javascripts/google_cloud/service_accounts/index.js new file mode 100644 index 00000000000..5207b44deac --- /dev/null +++ b/app/assets/javascripts/google_cloud/service_accounts/index.js @@ -0,0 +1,11 @@ +import Vue from 'vue'; +import Form from './form.vue'; + +export default (containerId = '#js-google-cloud-service-accounts') => { + const element = document.querySelector(containerId); + const { ...attrs } = JSON.parse(element.getAttribute('data')); + return new Vue({ + el: element, + render: (createElement) => createElement(Form, { attrs }), + }); +}; diff --git a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue b/app/assets/javascripts/google_cloud/service_accounts/list.vue index 4b580c594f5..4b580c594f5 100644 --- a/app/assets/javascripts/google_cloud/components/service_accounts_list.vue +++ b/app/assets/javascripts/google_cloud/service_accounts/list.vue diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index c01db435f53..7345afb8545 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -5,13 +5,23 @@ import { GlBadge, GlIcon, GlLabel, + GlButton, + GlPopover, + GlLink, GlTooltipDirective, GlSafeHtmlDirective, } from '@gitlab/ui'; import { visitUrl } from '~/lib/utils/url_utility'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; -import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '../constants'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { __ } from '~/locale'; +import { + VISIBILITY_TYPE_ICON, + GROUP_VISIBILITY_TYPE, + ITEM_TYPE, + VISIBILITY_PRIVATE, +} from '../constants'; import eventHub from '../event_hub'; import itemActions from './item_actions.vue'; @@ -30,12 +40,16 @@ export default { GlLoadingIcon, GlIcon, GlLabel, + GlButton, + GlPopover, + GlLink, UserAccessRoleBadge, itemCaret, itemTypeIcon, itemActions, itemStats, }, + inject: ['currentGroupVisibility'], props: { parentGroup: { type: Object, @@ -56,6 +70,9 @@ export default { groupDomId() { return `group-${this.group.id}`; }, + itemTestId() { + return `group-overview-item-${this.group.id}`; + }, rowClass() { return { 'is-open': this.group.isOpen, @@ -74,10 +91,10 @@ export default { return Boolean(this.group.complianceFramework?.name); }, isGroup() { - return this.group.type === 'group'; + return this.group.type === ITEM_TYPE.GROUP; }, isGroupPendingRemoval() { - return this.group.type === 'group' && this.group.pendingRemoval; + return this.group.type === ITEM_TYPE.GROUP && this.group.pendingRemoval; }, visibilityIcon() { return VISIBILITY_TYPE_ICON[this.group.visibility]; @@ -94,6 +111,13 @@ export default { showActionsMenu() { return this.isGroup && (this.group.canEdit || this.group.canRemove || this.group.canLeave); }, + shouldShowVisibilityWarning() { + return ( + this.action === 'shared' && + this.currentGroupVisibility === VISIBILITY_PRIVATE && + this.group.visibility !== VISIBILITY_PRIVATE + ); + }, }, methods: { onClickRowGroup(e) { @@ -110,6 +134,17 @@ export default { } }, }, + i18n: { + popoverTitle: __('Less restrictive visibility'), + popoverBody: __('Project visibility level is less restrictive than the group settings.'), + learnMore: __('Learn more'), + }, + shareProjectsWithGroupsHelpPagePath: helpPagePath( + 'user/project/members/share_project_with_groups', + { + anchor: 'share-a-public-project-with-private-group', + }, + ), safeHtmlConfig: { ADD_TAGS: ['gl-emoji'] }, AVATAR_SHAPE_OPTION_RECT, }; @@ -118,6 +153,7 @@ export default { <template> <li :id="groupDomId" + :data-testid="itemTestId" :class="rowClass" class="group-row" :itemprop="microdata.itemprop" @@ -163,7 +199,7 @@ export default { data-testid="group-name" :href="group.relativePath" :title="group.fullName" - class="no-expand gl-mr-3 gl-mt-3 gl-text-gray-900!" + class="no-expand gl-mr-3 gl-text-gray-900!" :itemprop="microdata.nameItemprop" > {{ @@ -174,17 +210,40 @@ export default { </a> <gl-icon v-gl-tooltip.hover.bottom - class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-mt-3 gl-text-gray-500" + class="gl-display-inline-flex gl-align-items-center gl-mr-3 gl-text-gray-500" :name="visibilityIcon" :title="visibilityTooltip" data-testid="group-visibility-icon" /> - <user-access-role-badge v-if="group.permission" class="gl-mt-3"> + <template v-if="shouldShowVisibilityWarning"> + <gl-button + ref="visibilityWarningButton" + class="gl-p-1! gl-bg-transparent! gl-mr-3" + category="tertiary" + icon="warning" + :aria-label="$options.i18n.popoverTitle" + @click.stop + /> + <gl-popover + :target="() => $refs.visibilityWarningButton.$el" + :title="$options.i18n.popoverTitle" + triggers="hover focus" + > + {{ $options.i18n.popoverBody }} + <div class="gl-mt-3"> + <gl-link + class="gl-font-sm" + :href="$options.shareProjectsWithGroupsHelpPagePath" + >{{ $options.i18n.learnMore }}</gl-link + > + </div> + </gl-popover> + </template> + <user-access-role-badge v-if="group.permission" class="gl-mr-3"> {{ group.permission }} </user-access-role-badge> <gl-label v-if="hasComplianceFramework" - class="gl-mt-3" :title="complianceFramework.name" :background-color="complianceFramework.color" :description="complianceFramework.description" diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index cacba2dfd23..29981d09155 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -28,28 +28,32 @@ export const ITEM_TYPE = { GROUP: 'group', }; +export const VISIBILITY_PUBLIC = 'public'; +export const VISIBILITY_INTERNAL = 'internal'; +export const VISIBILITY_PRIVATE = 'private'; + export const GROUP_VISIBILITY_TYPE = { - public: __( + [VISIBILITY_PUBLIC]: __( 'Public - The group and any public projects can be viewed without any authentication.', ), - internal: __( + [VISIBILITY_INTERNAL]: __( 'Internal - The group and any internal projects can be viewed by any logged in user except external users.', ), - private: __('Private - The group and its projects can only be viewed by members.'), + [VISIBILITY_PRIVATE]: __('Private - The group and its projects can only be viewed by members.'), }; export const PROJECT_VISIBILITY_TYPE = { - public: __('Public - The project can be accessed without any authentication.'), - internal: __( + [VISIBILITY_PUBLIC]: __('Public - The project can be accessed without any authentication.'), + [VISIBILITY_INTERNAL]: __( 'Internal - The project can be accessed by any logged in user except external users.', ), - private: __( + [VISIBILITY_PRIVATE]: __( 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.', ), }; export const VISIBILITY_TYPE_ICON = { - public: 'earth', - internal: 'shield', - private: 'lock', + [VISIBILITY_PUBLIC]: 'earth', + [VISIBILITY_INTERNAL]: 'shield', + [VISIBILITY_PRIVATE]: 'lock', }; diff --git a/app/assets/javascripts/groups/index.js b/app/assets/javascripts/groups/index.js index dfcee80aec7..a502fcd31ad 100644 --- a/app/assets/javascripts/groups/index.js +++ b/app/assets/javascripts/groups/index.js @@ -55,6 +55,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { renderEmptyState, canCreateSubgroups, canCreateProjects, + currentGroupVisibility, }, } = this.$options.el; @@ -67,6 +68,7 @@ export default (containerId = 'js-groups-tree', endpoint, action = '') => { renderEmptyState: parseBoolean(renderEmptyState), canCreateSubgroups: parseBoolean(canCreateSubgroups), canCreateProjects: parseBoolean(canCreateProjects), + currentGroupVisibility, }; }, data() { diff --git a/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js b/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js new file mode 100644 index 00000000000..abececa44ee --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/configuration/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/configuration/index'; + +init(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/databases/index.js b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js new file mode 100644 index 00000000000..5482324f1cd --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/databases/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/databases/index'; + +init(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js b/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js new file mode 100644 index 00000000000..b5a29b3825b --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/deployments/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/deployments/index'; + +init(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js b/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js new file mode 100644 index 00000000000..fb66e2fa051 --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/gcp_regions/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/gcp_regions/index'; + +init(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/index.js b/app/assets/javascripts/pages/projects/google_cloud/index.js deleted file mode 100644 index 4506ea8efd1..00000000000 --- a/app/assets/javascripts/pages/projects/google_cloud/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initGoogleCloud from '~/google_cloud/index'; - -initGoogleCloud(); diff --git a/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js b/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js new file mode 100644 index 00000000000..8b644c2b324 --- /dev/null +++ b/app/assets/javascripts/pages/projects/google_cloud/service_accounts/index.js @@ -0,0 +1,3 @@ +import init from '~/google_cloud/service_accounts/index'; + +init(); diff --git a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue index 23f1592cac1..610a570c4ce 100644 --- a/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue +++ b/app/assets/javascripts/pipeline_editor/components/header/validation_segment.vue @@ -19,7 +19,7 @@ export const i18n = { invalid: s__('Pipelines|This GitLab CI configuration is invalid.'), invalidWithReason: s__('Pipelines|This GitLab CI configuration is invalid: %{reason}.'), unavailableValidation: s__('Pipelines|Configuration validation currently not available.'), - valid: s__('Pipelines|This GitLab CI configuration is valid.'), + valid: s__('Pipelines|Pipeline syntax is correct.'), }; export default { diff --git a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue index 9a789ccab4d..0f19b9386e6 100644 --- a/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue +++ b/app/assets/javascripts/pipeline_editor/components/lint/ci_lint_results.vue @@ -52,6 +52,11 @@ export default { required: false, default: false, }, + hideAlert: { + type: Boolean, + required: false, + default: false, + }, isValid: { type: Boolean, required: true, @@ -63,7 +68,8 @@ export default { }, lintHelpPagePath: { type: String, - required: true, + required: false, + default: '', }, warnings: { type: Array, @@ -96,6 +102,7 @@ export default { <template> <div> <gl-alert + v-if="!hideAlert" class="gl-mb-5" :variant="status.variant" :title="__('Status:')" diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index 4ebfec7a715..99ee244577e 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -219,8 +219,7 @@ export default { :title="$options.i18n.tabValidate" @click="setCurrentTab($options.tabConstants.VALIDATE_TAB)" > - <gl-loading-icon v-if="isLoading" size="lg" class="gl-m-3" /> - <ci-validate v-else /> + <ci-validate :ci-file-content="ciFileContent" /> </editor-tab> <editor-tab v-else diff --git a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue index 4453796a0e4..47673119db9 100644 --- a/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue +++ b/app/assets/javascripts/pipeline_editor/components/validate/ci_validate.vue @@ -1,10 +1,35 @@ <script> -import { GlButton, GlDropdown, GlIcon, GlTooltipDirective, GlSprintf } from '@gitlab/ui'; +import { + GlAlert, + GlButton, + GlDropdown, + GlIcon, + GlLoadingIcon, + GlLink, + GlTooltip, + GlTooltipDirective, + GlSprintf, +} from '@gitlab/ui'; import { s__, __ } from '~/locale'; import ValidatePipelinePopover from '../popovers/validate_pipeline_popover.vue'; +import CiLintResults from '../lint/ci_lint_results.vue'; +import getBlobContent from '../../graphql/queries/blob_content.query.graphql'; +import getCurrentBranch from '../../graphql/queries/client/current_branch.query.graphql'; +import lintCiMutation from '../../graphql/mutations/client/lint_ci.mutation.graphql'; export const i18n = { + alertDesc: s__( + 'PipelineEditor|Simulated a %{codeStart}git push%{codeEnd} event for a default branch. %{codeStart}Rules%{codeEnd}, %{codeStart}only%{codeEnd}, %{codeStart}except%{codeEnd}, and %{codeStart}needs%{codeEnd} job dependencies logic have been evaluated. %{linkStart}Learn more%{linkEnd}', + ), + cancelBtn: __('Cancel'), + contentChange: s__( + 'PipelineEditor|Configuration content has changed. Re-run validation for updated results.', + ), + cta: s__('PipelineEditor|Validate pipeline'), + ctaDisabledTooltip: s__('PipelineEditor|Waiting for CI content to load...'), + errorAlertTitle: s__('PipelineEditor|Pipeline simulation completed with errors'), help: __('Help'), + loading: s__('PipelineEditor|Validating pipeline... It can take up to a minute.'), pipelineSource: s__('PipelineEditor|Pipeline Source'), pipelineSourceDefault: s__('PipelineEditor|Git push event to the default branch'), pipelineSourceTooltip: s__('PipelineEditor|Other pipeline sources are not available yet.'), @@ -15,48 +40,179 @@ export const i18n = { simulationNote: s__( 'PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies.', ), - cta: s__('PipelineEditor|Validate pipeline'), + successAlertTitle: s__('PipelineEditor|Simulation completed successfully'), }; +export const VALIDATE_TAB_INIT = 'VALIDATE_TAB_INIT'; +export const VALIDATE_TAB_RESULTS = 'VALIDATE_TAB_RESULTS'; +export const VALIDATE_TAB_LOADING = 'VALIDATE_TAB_LOADING'; +const BASE_CLASSES = [ + 'gl-display-flex', + 'gl-flex-direction-column', + 'gl-align-items-center', + 'gl-mt-11', +]; + export default { name: 'CiValidateTab', components: { + CiLintResults, + GlAlert, GlButton, GlDropdown, GlIcon, + GlLoadingIcon, + GlLink, GlSprintf, + GlTooltip, ValidatePipelinePopover, }, directives: { GlTooltip: GlTooltipDirective, }, - inject: ['validateTabIllustrationPath'], + inject: ['ciConfigPath', 'ciLintPath', 'projectFullPath', 'validateTabIllustrationPath'], + props: { + ciFileContent: { + type: String, + required: true, + }, + }, + apollo: { + initialBlobContent: { + query: getBlobContent, + variables() { + return { + projectPath: this.projectFullPath, + path: this.ciConfigPath, + ref: this.currentBranch, + }; + }, + update(data) { + return data?.project?.repository?.blobs?.nodes[0]?.rawBlob; + }, + }, + currentBranch: { + query: getCurrentBranch, + update(data) { + return data.workBranches?.current?.name; + }, + }, + }, + data() { + return { + yaml: this.ciFileContent, + state: VALIDATE_TAB_INIT, + errors: [], + hasCiContentChanged: false, + isValid: false, + jobs: [], + warnings: [], + }; + }, + computed: { + isInitialCiContentLoading() { + return this.$apollo.queries.initialBlobContent.loading; + }, + isInitState() { + return this.state === VALIDATE_TAB_INIT; + }, + isSimulationLoading() { + return this.state === VALIDATE_TAB_LOADING; + }, + hasSimulationResults() { + return this.state === VALIDATE_TAB_RESULTS; + }, + resultStatus() { + return { + title: this.isValid ? i18n.successAlertTitle : i18n.errorAlertTitle, + variant: this.isValid ? 'success' : 'danger', + }; + }, + }, + watch: { + ciFileContent(value) { + this.yaml = value; + this.hasCiContentChanged = true; + }, + }, + methods: { + cancelSimulation() { + this.state = VALIDATE_TAB_INIT; + }, + async validateYaml() { + this.state = VALIDATE_TAB_LOADING; + + try { + const { + data: { + lintCI: { errors, jobs, valid, warnings }, + }, + } = await this.$apollo.mutate({ + mutation: lintCiMutation, + variables: { + dry_run: true, + content: this.yaml, + endpoint: this.ciLintPath, + }, + }); + + // only save the result if the user did not cancel the simulation + if (this.state === VALIDATE_TAB_LOADING) { + this.errors = errors; + this.jobs = jobs; + this.warnings = warnings; + this.isValid = valid; + this.state = VALIDATE_TAB_RESULTS; + this.hasCiContentChanged = false; + } + } catch (error) { + this.cancelSimulation(); + } + }, + }, i18n, + BASE_CLASSES, }; </script> <template> <div> - <div class="gl-mt-3"> - <label>{{ $options.i18n.pipelineSource }}</label> - <gl-dropdown - v-gl-tooltip.hover - :title="$options.i18n.pipelineSourceTooltip" - :text="$options.i18n.pipelineSourceDefault" - disabled - data-testid="pipeline-source" - /> - <validate-pipeline-popover /> - <gl-icon - id="validate-pipeline-help" - name="question-o" - class="gl-ml-1 gl-fill-blue-500" - category="secondary" - variant="confirm" - :aria-label="$options.i18n.help" - /> + <div class="gl-display-flex gl-justify-content-space-between gl-mt-3"> + <div> + <label>{{ $options.i18n.pipelineSource }}</label> + <gl-dropdown + v-gl-tooltip.hover + class="gl-ml-3" + :title="$options.i18n.pipelineSourceTooltip" + :text="$options.i18n.pipelineSourceDefault" + disabled + data-testid="pipeline-source" + /> + <validate-pipeline-popover /> + <gl-icon + id="validate-pipeline-help" + name="question-o" + class="gl-ml-1 gl-fill-blue-500" + category="secondary" + variant="confirm" + :aria-label="$options.i18n.help" + /> + </div> + <div v-if="hasSimulationResults && hasCiContentChanged"> + <span class="gl-text-gray-400" data-testid="content-status"> + {{ $options.i18n.contentChange }} + </span> + <gl-button + variant="confirm" + class="gl-ml-2 gl-mb-2" + data-testid="resimulate-pipeline-button" + @click="validateYaml" + > + {{ $options.i18n.cta }} + </gl-button> + </div> </div> - <div class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-mt-11"> + <div v-if="isInitState" :class="$options.BASE_CLASSES"> <img :src="validateTabIllustrationPath" /> <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.title }}</h1> <ul> @@ -69,9 +225,61 @@ export default { </gl-sprintf> </li> </ul> - <gl-button variant="confirm" class="gl-mt-3" data-qa-selector="simulate_pipeline"> - {{ $options.i18n.cta }} - </gl-button> + <div ref="simulatePipelineButton"> + <gl-button + ref="simulatePipelineButton" + variant="confirm" + class="gl-mt-3" + :disabled="isInitialCiContentLoading" + data-testid="simulate-pipeline-button" + @click="validateYaml" + > + {{ $options.i18n.cta }} + </gl-button> + </div> + <gl-tooltip + v-if="isInitialCiContentLoading" + :target="() => $refs.simulatePipelineButton" + :title="$options.i18n.ctaDisabledTooltip" + data-testid="cta-tooltip" + /> + </div> + <div v-else-if="isSimulationLoading" :class="$options.BASE_CLASSES"> + <gl-loading-icon size="lg" class="gl-m-3" /> + <h1 class="gl-font-size-h1 gl-mb-6">{{ $options.i18n.loading }}</h1> + <div> + <gl-button class="gl-mt-3" data-testid="cancel-simulation" @click="cancelSimulation"> + {{ $options.i18n.cancelBtn }} + </gl-button> + <gl-button class="gl-mt-3" loading data-testid="simulate-pipeline-button"> + {{ $options.i18n.cta }} + </gl-button> + </div> + </div> + <div v-else-if="hasSimulationResults" class="gl-mt-5"> + <gl-alert + class="gl-mb-5" + :dismissible="false" + :title="resultStatus.title" + :variant="resultStatus.variant" + > + <gl-sprintf :message="$options.i18n.alertDesc"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + <template #link="{ content }"> + <gl-link target="_blank" href="#">{{ content }}</gl-link> + </template> + </gl-sprintf> + </gl-alert> + <ci-lint-results + dry-run + hide-alert + :is-valid="isValid" + :jobs="jobs" + :errors="errors" + :warnings="warnings" + /> </div> </div> </template> diff --git a/app/assets/javascripts/pipeline_editor/index.js b/app/assets/javascripts/pipeline_editor/index.js index b056b086d57..4f5b69107bf 100644 --- a/app/assets/javascripts/pipeline_editor/index.js +++ b/app/assets/javascripts/pipeline_editor/index.js @@ -27,6 +27,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ciConfigPath, ciExamplesHelpPagePath, ciHelpPagePath, + ciLintPath, defaultBranch, emptyStateIllustrationPath, helpPaths, @@ -116,6 +117,7 @@ export const initPipelineEditor = (selector = '#js-pipeline-editor') => { ciConfigPath, ciExamplesHelpPagePath, ciHelpPagePath, + ciLintPath, configurationPaths, dataMethod: 'graphql', defaultBranch, diff --git a/app/assets/javascripts/pipelines/stores/test_reports/constants.js b/app/assets/javascripts/pipelines/stores/test_reports/constants.js index 8eebfb6b208..83d14e1a109 100644 --- a/app/assets/javascripts/pipelines/stores/test_reports/constants.js +++ b/app/assets/javascripts/pipelines/stores/test_reports/constants.js @@ -1 +1 @@ -export const ARTIFACTS_EXPIRED_ERROR_MESSAGE = 'Test report artifacts have expired'; +export const ARTIFACTS_EXPIRED_ERROR_MESSAGE = 'Test report artifacts not found'; diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index 96ca9fbcb43..2e1bb9b9eac 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -209,7 +209,6 @@ table.pipeline-project-metrics tr td { } .title { - margin-top: -$gl-padding-8; // negative margin required for flex-wrap font-size: $gl-font-size; } diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index c9c51289d3a..2e9fbb1d0d9 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -54,8 +54,6 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController # limit scopes when signing in with GitLab def downgrade_scopes! - return unless Feature.enabled?(:omniauth_login_minimal_scopes, current_user) - auth_type = params.delete('gl_auth_type') return unless auth_type == 'login' diff --git a/app/controllers/projects/google_cloud/base_controller.rb b/app/controllers/projects/google_cloud/base_controller.rb index 980e9bdcdad..050b26a40c7 100644 --- a/app/controllers/projects/google_cloud/base_controller.rb +++ b/app/controllers/projects/google_cloud/base_controller.rb @@ -12,7 +12,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController def admin_project_google_cloud! unless can?(current_user, :admin_project_google_cloud, project) - track_event('admin_project_google_cloud!', 'access_denied', 'invalid_user') + track_event('admin_project_google_cloud!', 'error_access_denied', 'invalid_user') access_denied! end end @@ -20,7 +20,11 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController def google_oauth2_enabled! config = Gitlab::Auth::OAuth::Provider.config_for('google_oauth2') if config.app_id.blank? || config.app_secret.blank? - track_event('google_oauth2_enabled!', 'access_denied', { reason: 'google_oauth2_not_configured', config: config }) + track_event( + 'google_oauth2_enabled!', + 'error_access_denied', + { reason: 'google_oauth2_not_configured', config: config } + ) access_denied! 'This GitLab instance not configured for Google Oauth2.' end end @@ -31,7 +35,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController enabled_for_project = Feature.enabled?(:incubation_5mp_google_cloud, project) feature_is_enabled = enabled_for_user || enabled_for_group || enabled_for_project unless feature_is_enabled - track_event('feature_flag_enabled!', 'access_denied', 'feature_flag_not_enabled') + track_event('feature_flag_enabled!', 'error_access_denied', 'feature_flag_not_enabled') access_denied! end end @@ -42,7 +46,7 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController return if is_token_valid - return_url = project_google_cloud_index_path(project) + return_url = project_google_cloud_configuration_path(project) state = generate_session_key_redirect(request.url, return_url) @authorize_url = GoogleApi::CloudPlatform::Client.new(nil, callback_google_api_auth_url, @@ -65,12 +69,6 @@ class Projects::GoogleCloud::BaseController < Projects::ApplicationController session[GoogleApi::CloudPlatform::Client.session_key_for_expires_at] end - def handle_gcp_error(action, error) - track_event(action, 'gcp_error', error) - @js_data = { screen: 'gcp_error', error: error.to_s }.to_json - render status: :unauthorized, template: 'projects/google_cloud/errors/gcp_error' - end - def track_event(action, label, property) options = { label: label, project: project, user: current_user } diff --git a/app/controllers/projects/google_cloud/configuration_controller.rb b/app/controllers/projects/google_cloud/configuration_controller.rb new file mode 100644 index 00000000000..fa672058247 --- /dev/null +++ b/app/controllers/projects/google_cloud/configuration_controller.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Projects + module GoogleCloud + class ConfigurationController < Projects::GoogleCloud::BaseController + def index + @google_cloud_path = project_google_cloud_configuration_path(project) + js_data = { + configurationUrl: project_google_cloud_configuration_path(project), + deploymentsUrl: project_google_cloud_deployments_path(project), + databasesUrl: project_google_cloud_databases_path(project), + serviceAccounts: ::GoogleCloud::ServiceAccountsService.new(project).find_for_project, + createServiceAccountUrl: project_google_cloud_service_accounts_path(project), + emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'), + configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project), + gcpRegions: gcp_regions, + revokeOauthUrl: revoke_oauth_url + } + @js_data = js_data.to_json + track_event('configuration#index', 'success', js_data) + end + + private + + def gcp_regions + params = { key: Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY } + list = ::Ci::VariablesFinder.new(project, params).execute + list.map { |variable| { gcp_region: variable.value, environment: variable.environment_scope } } + end + + def revoke_oauth_url + google_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + .validate_token(expires_at_in_session) + google_token_valid ? project_google_cloud_revoke_oauth_index_path(project) : nil + end + end + end +end diff --git a/app/controllers/projects/google_cloud/databases_controller.rb b/app/controllers/projects/google_cloud/databases_controller.rb new file mode 100644 index 00000000000..711409e7550 --- /dev/null +++ b/app/controllers/projects/google_cloud/databases_controller.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Projects + module GoogleCloud + class DatabasesController < Projects::GoogleCloud::BaseController + def index + @google_cloud_path = project_google_cloud_configuration_path(project) + js_data = { + configurationUrl: project_google_cloud_configuration_path(project), + deploymentsUrl: project_google_cloud_deployments_path(project), + databasesUrl: project_google_cloud_databases_path(project) + } + @js_data = js_data.to_json + track_event('databases#index', 'success', js_data) + end + end + end +end diff --git a/app/controllers/projects/google_cloud/deployments_controller.rb b/app/controllers/projects/google_cloud/deployments_controller.rb index 1ad3511fbc0..4aa17b36fad 100644 --- a/app/controllers/projects/google_cloud/deployments_controller.rb +++ b/app/controllers/projects/google_cloud/deployments_controller.rb @@ -3,32 +3,47 @@ class Projects::GoogleCloud::DeploymentsController < Projects::GoogleCloud::BaseController before_action :validate_gcp_token! + def index + @google_cloud_path = project_google_cloud_configuration_path(project) + js_data = { + configurationUrl: project_google_cloud_configuration_path(project), + deploymentsUrl: project_google_cloud_deployments_path(project), + databasesUrl: project_google_cloud_databases_path(project), + enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project), + enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project) + } + @js_data = js_data.to_json + track_event('deployments#index', 'success', js_data) + end + def cloud_run params = { google_oauth2_token: token_in_session } enable_cloud_run_response = GoogleCloud::EnableCloudRunService .new(project, current_user, params).execute if enable_cloud_run_response[:status] == :error - track_event('deployments#cloud_run', 'enable_cloud_run_error', enable_cloud_run_response) + track_event('deployments#cloud_run', 'error_enable_cloud_run', enable_cloud_run_response) flash[:error] = enable_cloud_run_response[:message] - redirect_to project_google_cloud_index_path(project) + redirect_to project_google_cloud_deployments_path(project) else params = { action: GoogleCloud::GeneratePipelineService::ACTION_DEPLOY_TO_CLOUD_RUN } generate_pipeline_response = GoogleCloud::GeneratePipelineService .new(project, current_user, params).execute if generate_pipeline_response[:status] == :error - track_event('deployments#cloud_run', 'generate_pipeline_error', generate_pipeline_response) + track_event('deployments#cloud_run', 'error_generate_pipeline', generate_pipeline_response) flash[:error] = 'Failed to generate pipeline' - redirect_to project_google_cloud_index_path(project) + redirect_to project_google_cloud_deployments_path(project) else cloud_run_mr_params = cloud_run_mr_params(generate_pipeline_response[:branch_name]) - track_event('deployments#cloud_run', 'cloud_run_success', cloud_run_mr_params) + track_event('deployments#cloud_run', 'success', cloud_run_mr_params) redirect_to project_new_merge_request_path(project, merge_request: cloud_run_mr_params) end end - rescue Google::Apis::ClientError => error - handle_gcp_error('deployments#cloud_run', error) + rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error + track_event('deployments#cloud_run', 'error_gcp', error) + flash[:warning] = _('Google Cloud Error - %{error}') % { error: error } + redirect_to project_google_cloud_deployments_path(project) end def cloud_storage diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb index beeb91cfd80..3fbe9a96284 100644 --- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb +++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb @@ -6,8 +6,10 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC # Source https://cloud.google.com/run/docs/locations 2022-01-30 AVAILABLE_REGIONS = %w[asia-east1 asia-northeast1 asia-southeast1 europe-north1 europe-west1 europe-west4 us-central1 us-east1 us-east4 us-west1].freeze + GCP_REGION_CI_VAR_KEY = 'GCP_REGION' + def index - @google_cloud_path = project_google_cloud_index_path(project) + @google_cloud_path = project_google_cloud_configuration_path(project) params = { per_page: 50 } branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true) @@ -16,16 +18,16 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC screen: 'gcp_regions_form', availableRegions: AVAILABLE_REGIONS, refs: refs, - cancelPath: project_google_cloud_index_path(project) + cancelPath: project_google_cloud_configuration_path(project) } @js_data = js_data.to_json - track_event('gcp_regions#index', 'form_render', js_data) + track_event('gcp_regions#index', 'success', js_data) end def create permitted_params = params.permit(:ref, :gcp_region) response = GoogleCloud::GcpRegionAddOrReplaceService.new(project).execute(permitted_params[:ref], permitted_params[:gcp_region]) - track_event('gcp_regions#create', 'form_submit', response) - redirect_to project_google_cloud_index_path(project), notice: _('GCP region configured') + track_event('gcp_regions#create', 'success', response) + redirect_to project_google_cloud_configuration_path(project), notice: _('GCP region configured') end end diff --git a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb index 03d1474707b..1a9a2daf4f2 100644 --- a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb +++ b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb @@ -8,16 +8,15 @@ class Projects::GoogleCloud::RevokeOauthController < Projects::GoogleCloud::Base response = google_api_client.revoke_authorizations if response.success? - status = 'success' redirect_message = { notice: s_('GoogleCloud|Google OAuth2 token revocation requested') } + track_event('revoke_oauth#create', 'success', response.to_json) else - status = 'failed' redirect_message = { alert: s_('GoogleCloud|Google OAuth2 token revocation request failed') } + track_event('revoke_oauth#create', 'error', response.to_json) end session.delete(GoogleApi::CloudPlatform::Client.session_key_for_token) - track_event('revoke_oauth#create', 'create', status) - redirect_to project_google_cloud_index_path(project), redirect_message + redirect_to project_google_cloud_configuration_path(project), redirect_message end end diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb index 5d8b2030d5c..dbd83be19db 100644 --- a/app/controllers/projects/google_cloud/service_accounts_controller.rb +++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb @@ -4,14 +4,15 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: before_action :validate_gcp_token! def index - @google_cloud_path = project_google_cloud_index_path(project) + @google_cloud_path = project_google_cloud_configuration_path(project) google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) gcp_projects = google_api_client.list_projects if gcp_projects.empty? @js_data = { screen: 'no_gcp_projects' }.to_json - track_event('service_accounts#index', 'form_error', 'no_gcp_projects') - render status: :unauthorized, template: 'projects/google_cloud/errors/no_gcp_projects' + track_event('service_accounts#index', 'error_form', 'no_gcp_projects') + flash[:warning] = _('No Google Cloud projects - You need at least one Google Cloud project') + redirect_to project_google_cloud_configuration_path(project) else params = { per_page: 50 } branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) @@ -21,14 +22,16 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: screen: 'service_accounts_form', gcpProjects: gcp_projects, refs: refs, - cancelPath: project_google_cloud_index_path(project) + cancelPath: project_google_cloud_configuration_path(project) } @js_data = js_data.to_json - track_event('service_accounts#index', 'form_success', js_data) + track_event('service_accounts#index', 'success', js_data) end - rescue Google::Apis::ClientError => error - handle_gcp_error('service_accounts#index', error) + rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error + track_event('service_accounts#index', 'error_gcp', error) + flash[:warning] = _('Google Cloud Error - %{error}') % { error: error } + redirect_to project_google_cloud_configuration_path(project) end def create @@ -42,9 +45,11 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: environment_name: permitted_params[:ref] ).execute - track_event('service_accounts#create', 'form_submit', response) - redirect_to project_google_cloud_index_path(project), notice: response.message + track_event('service_accounts#create', 'success', response) + redirect_to project_google_cloud_configuration_path(project), notice: response.message rescue Google::Apis::ClientError, Google::Apis::ServerError, Google::Apis::AuthorizationError => error - handle_gcp_error('service_accounts#create', error) + track_event('service_accounts#create', 'error_gcp', error) + flash[:warning] = _('Google Cloud Error - %{error}') % { error: error } + redirect_to project_google_cloud_configuration_path(project) end end diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb deleted file mode 100644 index 49bb4bec859..00000000000 --- a/app/controllers/projects/google_cloud_controller.rb +++ /dev/null @@ -1,34 +0,0 @@ -# frozen_string_literal: true - -class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController - GCP_REGION_CI_VAR_KEY = 'GCP_REGION' - - def index - js_data = { - screen: 'home', - serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project, - createServiceAccountUrl: project_google_cloud_service_accounts_path(project), - enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project), - enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project), - emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'), - configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project), - gcpRegions: gcp_regions, - revokeOauthUrl: revoke_oauth_url - } - @js_data = js_data.to_json - track_event('google_cloud#index', 'index', js_data) - end - - private - - def gcp_regions - list = ::Ci::VariablesFinder.new(project, { key: GCP_REGION_CI_VAR_KEY }).execute - list.map { |variable| { gcp_region: variable.value, environment: variable.environment_scope } } - end - - def revoke_oauth_url - google_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) - .validate_token(expires_at_in_session) - google_token_valid ? project_google_cloud_revoke_oauth_index_path(project) : nil - end -end diff --git a/app/controllers/projects/pipelines/tests_controller.rb b/app/controllers/projects/pipelines/tests_controller.rb index 20bdb4d3af1..e42cb9b8422 100644 --- a/app/controllers/projects/pipelines/tests_controller.rb +++ b/app/controllers/projects/pipelines/tests_controller.rb @@ -35,7 +35,7 @@ module Projects def validate_test_reports! unless pipeline.has_test_reports? - render json: { errors: 'Test report artifacts have expired' }, status: :not_found + render json: { errors: 'Test report artifacts not found' }, status: :not_found end end diff --git a/app/helpers/ci/pipeline_editor_helper.rb b/app/helpers/ci/pipeline_editor_helper.rb index a3f1a540581..d044a93213a 100644 --- a/app/helpers/ci/pipeline_editor_helper.rb +++ b/app/helpers/ci/pipeline_editor_helper.rb @@ -18,6 +18,7 @@ module Ci "ci-config-path": project.ci_config_path_or_default, "ci-examples-help-page-path" => help_page_path('ci/examples/index'), "ci-help-page-path" => help_page_path('ci/index'), + "ci-lint-path" => project_ci_lint_path(project), "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => image_path('illustrations/empty-state/empty-dag-md.svg'), "initial-branch-name" => initial_branch, diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb index ded6ab8687a..0f13c45b84d 100644 --- a/app/models/customer_relations/contact.rb +++ b/app/models/customer_relations/contact.rb @@ -57,7 +57,22 @@ class CustomerRelations::Contact < ApplicationRecord end def self.sort_by_name - order("last_name ASC, first_name ASC") + order(Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'last_name', + order_expression: arel_table[:last_name].asc, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'first_name', + order_expression: arel_table[:first_name].asc, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table[:id].asc + ) + ])) end def self.find_ids_by_emails(group, emails) diff --git a/app/models/issue.rb b/app/models/issue.rb index c529f41e8cd..cae42115bef 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -124,8 +124,24 @@ class Issue < ApplicationRecord scope :order_due_date_desc, -> { reorder(arel_table[:due_date].desc.nulls_last) } scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) } scope :order_created_at_desc, -> { reorder(created_at: :desc) } - scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') } - scope :order_severity_desc, -> { includes(:issuable_severity).order('issuable_severities.severity DESC NULLS LAST') } + scope :order_severity_asc, -> do + build_keyset_order_on_joined_column( + scope: includes(:issuable_severity), + attribute_name: 'issuable_severities_severity', + column: IssuableSeverity.arel_table[:severity], + direction: :asc, + nullable: :nulls_first + ) + end + scope :order_severity_desc, -> do + build_keyset_order_on_joined_column( + scope: includes(:issuable_severity), + attribute_name: 'issuable_severities_severity', + column: IssuableSeverity.arel_table[:severity], + direction: :desc, + nullable: :nulls_last + ) + end scope :order_escalation_status_asc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].asc.nulls_last).references(:incident_management_issuable_escalation_status) } scope :order_escalation_status_desc, -> { includes(:incident_management_issuable_escalation_status).order(IncidentManagement::IssuableEscalationStatus.arel_table[:status].desc.nulls_last).references(:incident_management_issuable_escalation_status) } scope :order_closed_at_asc, -> { reorder(arel_table[:closed_at].asc.nulls_last) } @@ -234,6 +250,31 @@ class Issue < ApplicationRecord alias_method :with_state, :with_state_id alias_method :with_states, :with_state_ids + def build_keyset_order_on_joined_column(scope:, attribute_name:, column:, direction:, nullable:) + reversed_direction = direction == :asc ? :desc : :asc + + # rubocop: disable GitlabSecurity/PublicSend + order = ::Gitlab::Pagination::Keyset::Order.build([ + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: attribute_name, + column_expression: column, + order_expression: column.send(direction).send(nullable), + reversed_order_expression: column.send(reversed_direction).send(nullable), + order_direction: direction, + distinct: false, + add_to_projections: true, + nullable: nullable + ), + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'id', + order_expression: arel_table['id'].desc + ) + ]) + # rubocop: enable GitlabSecurity/PublicSend + + order.apply_cursor_conditions(scope).order(order) + end + override :order_upvotes_desc def order_upvotes_desc reorder(upvotes_count: :desc) @@ -331,10 +372,10 @@ class Issue < ApplicationRecord when 'due_date', 'due_date_asc' then order_due_date_asc.with_order_id_desc when 'due_date_desc' then order_due_date_desc.with_order_id_desc when 'relative_position', 'relative_position_asc' then order_by_relative_position - when 'severity_asc' then order_severity_asc.with_order_id_desc - when 'severity_desc' then order_severity_desc.with_order_id_desc - when 'escalation_status_asc' then order_escalation_status_asc.with_order_id_desc - when 'escalation_status_desc' then order_escalation_status_desc.with_order_id_desc + when 'severity_asc' then order_severity_asc + when 'severity_desc' then order_severity_desc + when 'escalation_status_asc' then order_escalation_status_asc + when 'escalation_status_desc' then order_escalation_status_desc when 'closed_at', 'closed_at_asc' then order_closed_at_asc when 'closed_at_desc' then order_closed_at_desc else diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index e9fd7e4446c..59d2e3deb4f 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -5,6 +5,8 @@ class ProjectSetting < ApplicationRecord belongs_to :project, inverse_of: :project_setting + scope :for_projects, ->(projects) { where(project_id: projects) } + enum squash_option: { never: 0, always: 1, diff --git a/app/services/google_cloud/gcp_region_add_or_replace_service.rb b/app/services/google_cloud/gcp_region_add_or_replace_service.rb index 6edf7726719..f79df707a08 100644 --- a/app/services/google_cloud/gcp_region_add_or_replace_service.rb +++ b/app/services/google_cloud/gcp_region_add_or_replace_service.rb @@ -3,7 +3,7 @@ module GoogleCloud class GcpRegionAddOrReplaceService < ::GoogleCloud::BaseService def execute(environment, region) - gcp_region_key = Projects::GoogleCloudController::GCP_REGION_CI_VAR_KEY + gcp_region_key = Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY change_params = { variable_params: { key: gcp_region_key, value: region, environment_scope: environment } } filter_params = { key: gcp_region_key, filter: { environment_scope: environment } } diff --git a/app/services/groups/transfer_service.rb b/app/services/groups/transfer_service.rb index a0021ae2ccb..29e3a9473ab 100644 --- a/app/services/groups/transfer_service.rb +++ b/app/services/groups/transfer_service.rb @@ -162,6 +162,12 @@ module Groups projects_to_update .update_all(visibility_level: @new_parent_group.visibility_level) + + update_project_settings(@updated_project_ids) + end + + # Overridden in EE + def update_project_settings(updated_project_ids) end def update_two_factor_authentication diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml index ef6410ad439..6063d160fab 100644 --- a/app/views/groups/_shared_projects.html.haml +++ b/app/views/groups/_shared_projects.html.haml @@ -3,5 +3,5 @@ %p= _("There are no projects shared with this group yet") %ul.content-list{ data: { hide_projects: 'false', group_id: group.id, path: group_path(group) } } - .js-groups-list-holder + .js-groups-list-holder{ data: { current_group_visibility: group.visibility } } = gl_loading_icon diff --git a/app/views/projects/google_cloud/configuration/index.html.haml b/app/views/projects/google_cloud/configuration/index.html.haml new file mode 100644 index 00000000000..ec977898f47 --- /dev/null +++ b/app/views/projects/google_cloud/configuration/index.html.haml @@ -0,0 +1,7 @@ +- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- breadcrumb_title s_('CloudSeed|Configuration') +- page_title s_('CloudSeed|Configuration') + +- @content_class = "limit-container-width" unless fluid_layout + +#js-google-cloud-configuration{ data: @js_data } diff --git a/app/views/projects/google_cloud/databases/index.html.haml b/app/views/projects/google_cloud/databases/index.html.haml new file mode 100644 index 00000000000..ad732317d8d --- /dev/null +++ b/app/views/projects/google_cloud/databases/index.html.haml @@ -0,0 +1,7 @@ +- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- breadcrumb_title s_('CloudSeed|Databases') +- page_title s_('CloudSeed|Databases') + +- @content_class = "limit-container-width" unless fluid_layout + +#js-google-cloud-databases{ data: @js_data } diff --git a/app/views/projects/google_cloud/deployments/index.html.haml b/app/views/projects/google_cloud/deployments/index.html.haml new file mode 100644 index 00000000000..b140159a7f5 --- /dev/null +++ b/app/views/projects/google_cloud/deployments/index.html.haml @@ -0,0 +1,7 @@ +- add_to_breadcrumbs _('Google Cloud'), @google_cloud_path +- breadcrumb_title s_('CloudSeed|Deployments') +- page_title s_('CloudSeed|Deployments') + +- @content_class = "limit-container-width" unless fluid_layout + +#js-google-cloud-deployments{ data: @js_data } diff --git a/app/views/projects/google_cloud/errors/gcp_error.html.haml b/app/views/projects/google_cloud/errors/gcp_error.html.haml deleted file mode 100644 index 69e481501d5..00000000000 --- a/app/views/projects/google_cloud/errors/gcp_error.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- breadcrumb_title _('Google Cloud') -- page_title _('Google Cloud') - -- @content_class = "limit-container-width" unless fluid_layout - -#js-google-cloud{ data: @js_data } diff --git a/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml b/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml deleted file mode 100644 index 69e481501d5..00000000000 --- a/app/views/projects/google_cloud/errors/no_gcp_projects.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- breadcrumb_title _('Google Cloud') -- page_title _('Google Cloud') - -- @content_class = "limit-container-width" unless fluid_layout - -#js-google-cloud{ data: @js_data } diff --git a/app/views/projects/google_cloud/gcp_regions/index.html.haml b/app/views/projects/google_cloud/gcp_regions/index.html.haml index 3a6f8ca059d..d7cabaa029b 100644 --- a/app/views/projects/google_cloud/gcp_regions/index.html.haml +++ b/app/views/projects/google_cloud/gcp_regions/index.html.haml @@ -1,8 +1,8 @@ - add_to_breadcrumbs _('Google Cloud'), @google_cloud_path -- breadcrumb_title _('Regions') -- page_title _('Regions') +- breadcrumb_title _('CloudSeed|Regions') +- page_title s_('CloudSeed|Regions') - @content_class = "limit-container-width" unless fluid_layout = form_tag project_google_cloud_gcp_regions_path(@project), method: 'post' do - #js-google-cloud{ data: @js_data } + #js-google-cloud-gcp-regions{ data: @js_data } diff --git a/app/views/projects/google_cloud/index.html.haml b/app/views/projects/google_cloud/index.html.haml deleted file mode 100644 index 69e481501d5..00000000000 --- a/app/views/projects/google_cloud/index.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -- breadcrumb_title _('Google Cloud') -- page_title _('Google Cloud') - -- @content_class = "limit-container-width" unless fluid_layout - -#js-google-cloud{ data: @js_data } diff --git a/app/views/projects/google_cloud/service_accounts/index.html.haml b/app/views/projects/google_cloud/service_accounts/index.html.haml index 9b82bc0acb5..6191de577fe 100644 --- a/app/views/projects/google_cloud/service_accounts/index.html.haml +++ b/app/views/projects/google_cloud/service_accounts/index.html.haml @@ -1,8 +1,8 @@ - add_to_breadcrumbs _('Google Cloud'), @google_cloud_path -- breadcrumb_title _('Service Account') -- page_title _('Service Account') +- breadcrumb_title s_('CloudSeed|Service Account') +- page_title s_('CloudSeed|Service Account') - @content_class = "limit-container-width" unless fluid_layout = form_tag project_google_cloud_service_accounts_path(@project), method: 'post' do - #js-google-cloud{ data: @js_data } + #js-google-cloud-service-accounts{ data: @js_data } diff --git a/config/feature_flags/development/omniauth_login_minimal_scopes.yml b/config/feature_flags/ops/enforce_memory_watchdog.yml index b2ca3484a98..1d1f9a4eef0 100644 --- a/config/feature_flags/development/omniauth_login_minimal_scopes.yml +++ b/config/feature_flags/ops/enforce_memory_watchdog.yml @@ -1,8 +1,8 @@ --- -name: omniauth_login_minimal_scopes -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/78556 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/351331 -milestone: '14.8' -type: development -group: 'group::authentication and authorization' +name: enforce_memory_watchdog +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91910 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/367534 +milestone: '15.2' +type: ops +group: group::memory default_enabled: false diff --git a/config/feature_flags/ops/gitlab_memory_watchdog.yml b/config/feature_flags/ops/gitlab_memory_watchdog.yml new file mode 100644 index 00000000000..9b995ea607e --- /dev/null +++ b/config/feature_flags/ops/gitlab_memory_watchdog.yml @@ -0,0 +1,8 @@ +--- +name: gitlab_memory_watchdog +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/91910 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/367534 +milestone: '15.2' +type: ops +group: group::memory +default_enabled: false diff --git a/config/initializers/memory_watchdog.rb b/config/initializers/memory_watchdog.rb new file mode 100644 index 00000000000..72779a18b10 --- /dev/null +++ b/config/initializers/memory_watchdog.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +return unless Gitlab::Runtime.application? +return unless Gitlab::Utils.to_boolean(ENV['GITLAB_MEMORY_WATCHDOG_ENABLED']) + +Gitlab::Cluster::LifecycleEvents.on_worker_start do + handler = + if Gitlab::Runtime.puma? + Gitlab::Memory::Watchdog::PumaHandler.new + elsif Gitlab::Runtime.sidekiq? + Gitlab::Memory::Watchdog::TermProcessHandler.new + else + Gitlab::Memory::Watchdog::NullHandler.instance + end + + Gitlab::Memory::Watchdog.new( + handler: handler, logger: Gitlab::AppLogger + ).start +end diff --git a/config/routes/project.rb b/config/routes/project.rb index ba4dba47f4c..5eb0b9396c9 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -298,15 +298,18 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do resources :terraform, only: [:index] - resources :google_cloud, only: [:index] - namespace :google_cloud do + get '/configuration', to: 'configuration#index' + resources :revoke_oauth, only: [:create] resources :service_accounts, only: [:index, :create] resources :gcp_regions, only: [:index, :create] + get '/deployments', to: 'deployments#index' get '/deployments/cloud_run', to: 'deployments#cloud_run' get '/deployments/cloud_storage', to: 'deployments#cloud_storage' + + get '/databases', to: 'databases#index' end resources :environments, except: [:destroy] do diff --git a/doc/integration/gitlab.md b/doc/integration/gitlab.md index 68ba676b539..02705d9dec3 100644 --- a/doc/integration/gitlab.md +++ b/doc/integration/gitlab.md @@ -117,10 +117,9 @@ signed in. ## Reduce access privileges on sign in -> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/337663) in GitLab 14.8 [with a flag](../administration/feature_flags.md) named `omniauth_login_minimal_scopes`. Disabled by default. - -FLAG: -On self-managed GitLab, by default this feature is not available. To make it available, ask an administrator to [enable the feature flag](../administration/feature_flags.md) named `omniauth_login_minimal_scopes`. On GitLab.com, this feature is not available. +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/337663) in GitLab 14.8 [with a flag](../administration/feature_flags.md) named `omniauth_login_minimal_scopes`. Disabled by default. +> - [Enabled on GitLab.com](https://gitlab.com/gitlab-org/gitlab/-/issues/351331) in GitLab 14.9. +> - [Feature flag `omniauth_login_minimal_scopes`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/83453) removed in GitLab 15.2 If you use a GitLab instance for authentication, you can reduce access rights when an OAuth application is used for sign in. diff --git a/doc/user/infrastructure/clusters/connect/new_civo_cluster.md b/doc/user/infrastructure/clusters/connect/new_civo_cluster.md index d8401d5a286..fad75ca6cab 100644 --- a/doc/user/infrastructure/clusters/connect/new_civo_cluster.md +++ b/doc/user/infrastructure/clusters/connect/new_civo_cluster.md @@ -64,7 +64,7 @@ Use CI/CD environment variables to configure your project. 1. On the left sidebar, select **Settings > CI/CD**. 1. Expand **Variables**. -1. Set the variable `BASE64_CIVO_CREDENTIALS` to the [token](https://www.civo.com/account/security) from your Civo account. +1. Set the variable `BASE64_CIVO_TOKEN` to the [token](https://www.civo.com/account/security) from your Civo account. 1. Set the variable `TF_VAR_agent_token` to the agent token you received in the previous task. 1. Set the variable `TF_VAR_kas_address` to the agent server address in the previous task. @@ -78,8 +78,8 @@ contains other variables that you can override according to your needs: - `TF_VAR_civo_region`: Set your cluster's region. - `TF_VAR_cluster_name`: Set your cluster's name. - `TF_VAR_cluster_description`: Set a description for the cluster. To create a reference to your GitLab project on your Civo cluster detail page, set this value to `$CI_PROJECT_URL`. This value helps you determine which project was responsible for provisioning the cluster you see on the Civo dashboard. -- `TF_VAR_machine_type`: Set the machine type for the Kubernetes nodes. -- `TF_VAR_node_count`: Set the number of Kubernetes nodes. +- `TF_VAR_target_nodes_size`: Set the size of the nodes to use for the cluster +- `TF_VAR_num_target_nodes`: Set the number of Kubernetes nodes. - `TF_VAR_agent_version`: Set the version of the GitLab agent. - `TF_VAR_agent_namespace`: Set the Kubernetes namespace for the GitLab agent. diff --git a/doc/user/usage_quotas.md b/doc/user/usage_quotas.md index 84c98a60917..c863a9d8270 100644 --- a/doc/user/usage_quotas.md +++ b/doc/user/usage_quotas.md @@ -10,11 +10,57 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/13294) in GitLab 12.0. > - Moved to GitLab Free. -NOTE: -Free tier namespaces on GitLab SaaS have a 5GB storage limit. This limit is not visible on the storage quota page nor currently enforced for users who exceed the limit. To learn more, visit our [pricing page](https://about.gitlab.com/pricing/). +## Namespace storage limit -A project's repository has a free storage quota of 10 GB. When a project's repository reaches -the quota it is locked. You cannot push changes to a locked project. To monitor the size of each +Namespaces on a GitLab SaaS Free tier have a 5 GB storage limit. For more information, see our [pricing page](https://about.gitlab.com/pricing/). +This limit is not visible on the storage quota page, but we plan to make it visible and enforced starting October 19, 2022. + +Storage types that add to the total namespace storage are: + +- Git repository +- Git LFS +- Artifacts +- Container registry +- Package registry +- Dependecy proxy +- Wiki +- Snippets + +If your total namespace storage exceeds the available namespace storage quota, all projects under the namespace are locked. A locked project will not be able to push to the repository, run pipelines and jobs, or build and push packages. + +To prevent exceeding the namespace storage quota, you can: + +1. [Purchase more storage](../subscriptions/gitlab_com/index.md#purchase-more-storage-and-transfer). +1. [Upgrade to a paid tier](../subscriptions/gitlab_com/#upgrade-your-gitlab-saas-subscription-tier). +1. [Reduce storage usage](#manage-your-storage-usage). + +### Namespace storage limit enforcement schedule + +Starting October 19, 2022, a storage limit will be enforced on all GitLab Free namespaces. +We will start with a large limit enforcement and eventually reduce it to 5 GB. + +The following table describes the enforcement schedule, which is subject to change. + +| Target enforcement date | Limit | Expected Impact | Status | +| ------ | ------ | ------ | ------ | +| October 19, 2022 | 45,000 GB | LOW | Pending (**{hourglass}**)| +| October 20, 2022 | 7,500 GB | LOW | Pending (**{hourglass}**)| +| October 24, 2022 | 500 GB | MEDIUM | Pending (**{hourglass}**)| +| October 27, 2022 | 75 GB | MEDIUM HIGH | Pending (**{hourglass}**)| +| November 2, 2022 | 10 GB | HIGH | Pending (**{hourglass}**)| +| November 9, 2022 | 5 GB | VERY HIGH | Pending (**{hourglass}**)| + +Namespaces that reach the enforced limit will have their projects locked. To unlock your project, you will have to [manage its storage](#manage-your-storage-usage). + +### Project storage limit + +Namespaces on a GitLab SaaS **paid** tier (Premium and Ultimate) have a storage limit on their project repositories. +A project's repository has a storage quota of 10 GB. A namespace has either a namespace-level storage limit or a project-level storage limit, but not both. + +- Paid tier namespaces have project-level storage limits enforced. +- Free tier namespaces have namespace-level storage limits. + +When a project's repository reaches the quota, the project is locked. You cannot push changes to a locked project. To monitor the size of each repository in a namespace, including a breakdown for each project, you can [view storage usage](#view-storage-usage). To allow a project's repository to exceed the free quota you must purchase additional storage. For more details, see [Excess storage usage](#excess-storage-usage). diff --git a/lib/gitlab/memory/watchdog.rb b/lib/gitlab/memory/watchdog.rb new file mode 100644 index 00000000000..db75ba8a47d --- /dev/null +++ b/lib/gitlab/memory/watchdog.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +module Gitlab + module Memory + # A background thread that observes Ruby heap fragmentation and calls + # into a handler when the Ruby heap has been fragmented for an extended + # period of time. + # + # See Gitlab::Metrics::Memory for how heap fragmentation is defined. + # + # To decide whether a given fragmentation level is being exceeded, + # the watchdog regularly polls the GC. Whenever a violation occurs + # a strike is issued. If the maximum number of strikes are reached, + # a handler is invoked to deal with the situation. + # + # The duration for which a process may be above a given fragmentation + # threshold is computed as `max_strikes * sleep_time_seconds`. + class Watchdog < Daemon + DEFAULT_SLEEP_TIME_SECONDS = 60 + DEFAULT_HEAP_FRAG_THRESHOLD = 0.5 + DEFAULT_MAX_STRIKES = 5 + + # This handler does nothing. It returns `false` to indicate to the + # caller that the situation has not been dealt with so it will + # receive calls repeatedly if fragmentation remains high. + # + # This is useful for "dress rehearsals" in production since it allows + # us to observe how frequently the handler is invoked before taking action. + class NullHandler + include Singleton + + def on_high_heap_fragmentation(value) + # NOP + false + end + end + + # This handler sends SIGTERM and considers the situation handled. + class TermProcessHandler + def initialize(pid = $$) + @pid = pid + end + + def on_high_heap_fragmentation(value) + Process.kill(:TERM, @pid) + true + end + end + + # This handler invokes Puma's graceful termination handler, which takes + # into account a configurable grace period during which a process may + # remain unresponsive to a SIGTERM. + class PumaHandler + def initialize(puma_options = ::Puma.cli_config.options) + @worker = ::Puma::Cluster::WorkerHandle.new(0, $$, 0, puma_options) + end + + def on_high_heap_fragmentation(value) + @worker.term + true + end + end + + # max_heap_fragmentation: + # The degree to which the Ruby heap is allowed to be fragmented. Range [0,1]. + # max_strikes: + # How many times the process is allowed to be above max_heap_fragmentation before + # a handler is invoked. + # sleep_time_seconds: + # Used to control the frequency with which the watchdog will wake up and poll the GC. + def initialize( + handler: NullHandler.instance, + logger: Logger.new($stdout), + max_heap_fragmentation: ENV['GITLAB_MEMWD_MAX_HEAP_FRAG']&.to_f || DEFAULT_HEAP_FRAG_THRESHOLD, + max_strikes: ENV['GITLAB_MEMWD_MAX_STRIKES']&.to_i || DEFAULT_MAX_STRIKES, + sleep_time_seconds: ENV['GITLAB_MEMWD_SLEEP_TIME_SEC']&.to_i || DEFAULT_SLEEP_TIME_SECONDS, + **options) + super(**options) + + @handler = handler + @logger = logger + @max_heap_fragmentation = max_heap_fragmentation + @sleep_time_seconds = sleep_time_seconds + @max_strikes = max_strikes + + @alive = true + @strikes = 0 + + init_prometheus_metrics(max_heap_fragmentation) + end + + attr_reader :strikes, :max_heap_fragmentation, :max_strikes, :sleep_time_seconds + + def run_thread + @logger.info(log_labels.merge(message: 'started')) + + while @alive + sleep(@sleep_time_seconds) + + monitor_heap_fragmentation if Feature.enabled?(:gitlab_memory_watchdog, type: :ops) + end + + @logger.info(log_labels.merge(message: 'stopped')) + end + + private + + def monitor_heap_fragmentation + heap_fragmentation = Gitlab::Metrics::Memory.gc_heap_fragmentation + + if heap_fragmentation > @max_heap_fragmentation + @strikes += 1 + @heap_frag_violations.increment + else + @strikes = 0 + end + + if @strikes > @max_strikes + # If the handler returns true, it means the event is handled and we can shut down. + @alive = !handle_heap_fragmentation_limit_exceeded(heap_fragmentation) + @strikes = 0 + end + end + + def handle_heap_fragmentation_limit_exceeded(value) + @logger.warn( + log_labels.merge( + message: 'heap fragmentation limit exceeded', + memwd_cur_heap_frag: value + )) + @heap_frag_violations_handled.increment + + handler.on_high_heap_fragmentation(value) + end + + def handler + # This allows us to keep the watchdog running but turn it into "friendly mode" where + # all that happens is we collect logs and Prometheus events for fragmentation violations. + return NullHandler.instance unless Feature.enabled?(:enforce_memory_watchdog, type: :ops) + + @handler + end + + def stop_working + @alive = false + end + + def log_labels + { + pid: $$, + worker_id: worker_id, + memwd_handler_class: handler.class.name, + memwd_sleep_time_s: @sleep_time_seconds, + memwd_max_heap_frag: @max_heap_fragmentation, + memwd_max_strikes: @max_strikes, + memwd_cur_strikes: @strikes, + memwd_rss_bytes: process_rss_bytes + } + end + + def worker_id + ::Prometheus::PidProvider.worker_id + end + + def process_rss_bytes + Gitlab::Metrics::System.memory_usage_rss + end + + def init_prometheus_metrics(max_heap_fragmentation) + default_labels = { pid: worker_id } + + @heap_frag_limit = Gitlab::Metrics.gauge( + :gitlab_memwd_heap_frag_limit, + 'The configured limit for how fragmented the Ruby heap is allowed to be', + default_labels + ) + @heap_frag_limit.set({}, max_heap_fragmentation) + + @heap_frag_violations = Gitlab::Metrics.counter( + :gitlab_memwd_heap_frag_violations_total, + 'Total number of times heap fragmentation in a Ruby process exceeded its allowed maximum', + default_labels + ) + @heap_frag_violations_handled = Gitlab::Metrics.counter( + :gitlab_memwd_heap_frag_violations_handled_total, + 'Total number of times heap fragmentation violations in a Ruby process were handled', + default_labels + ) + end + end + end +end diff --git a/lib/sidebars/projects/menus/infrastructure_menu.rb b/lib/sidebars/projects/menus/infrastructure_menu.rb index a98cc20d51a..1c04a7b117d 100644 --- a/lib/sidebars/projects/menus/infrastructure_menu.rb +++ b/lib/sidebars/projects/menus/infrastructure_menu.rb @@ -88,8 +88,14 @@ module Sidebars ::Sidebars::MenuItem.new( title: _('Google Cloud'), - link: project_google_cloud_index_path(context.project), - active_routes: { controller: [:google_cloud, :service_accounts, :deployments, :gcp_regions] }, + link: project_google_cloud_configuration_path(context.project), + active_routes: { controller: [ + :configuration, + :service_accounts, + :databases, + :deployments, + :gcp_regions + ] }, item_id: :google_cloud ) end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index df30e10058f..035792e23df 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -8318,6 +8318,9 @@ msgstr "" msgid "CloudSeed|CloudSQL Instance" msgstr "" +msgid "CloudSeed|Configuration" +msgstr "" + msgid "CloudSeed|Create cluster" msgstr "" @@ -8336,6 +8339,12 @@ msgstr "" msgid "CloudSeed|Database version" msgstr "" +msgid "CloudSeed|Databases" +msgstr "" + +msgid "CloudSeed|Deployments" +msgstr "" + msgid "CloudSeed|Description" msgstr "" @@ -8393,12 +8402,18 @@ msgstr "" msgid "CloudSeed|Refs" msgstr "" +msgid "CloudSeed|Regions" +msgstr "" + msgid "CloudSeed|Scalable, secure, and highly available in-memory service for Redis" msgstr "" msgid "CloudSeed|Service" msgstr "" +msgid "CloudSeed|Service Account" +msgstr "" + msgid "CloudSeed|Services" msgstr "" @@ -10662,9 +10677,6 @@ msgstr "" msgid "Create %{workspace} label" msgstr "" -msgid "Create Google Cloud project" -msgstr "" - msgid "Create New Directory" msgstr "" @@ -17494,9 +17506,6 @@ msgstr "" msgid "GitLab account request rejected" msgstr "" -msgid "GitLab and Google Cloud configuration seems to be incomplete. This probably can be fixed by your GitLab administration team. You may share these logs with them:" -msgstr "" - msgid "GitLab commit" msgstr "" @@ -17983,16 +17992,13 @@ msgstr "" msgid "Google Cloud" msgstr "" -msgid "Google Cloud Project" -msgstr "" - -msgid "Google Cloud authorizations required" +msgid "Google Cloud Error - %{error}" msgstr "" -msgid "Google Cloud project misconfigured" +msgid "Google Cloud Project" msgstr "" -msgid "Google Cloud project required" +msgid "Google Cloud authorizations required" msgstr "" msgid "GoogleCloud|Cancel" @@ -23204,6 +23210,9 @@ msgstr "" msgid "Less Details" msgstr "" +msgid "Less restrictive visibility" +msgstr "" + msgid "Let's Encrypt does not accept emails on example.com" msgstr "" @@ -25982,6 +25991,9 @@ msgstr "" msgid "No Epic" msgstr "" +msgid "No Google Cloud projects - You need at least one Google Cloud project" +msgstr "" + msgid "No Matching Results" msgstr "" @@ -28378,6 +28390,9 @@ msgstr "" msgid "PipelineEditorTutorial|🚀 Run your first pipeline" msgstr "" +msgid "PipelineEditor|Configuration content has changed. Re-run validation for updated results." +msgstr "" + msgid "PipelineEditor|Current content in the Edit tab will be used for the simulation." msgstr "" @@ -28396,6 +28411,15 @@ msgstr "" msgid "PipelineEditor|Pipeline behavior will be simulated including the %{codeStart}rules%{codeEnd} %{codeStart}only%{codeEnd} %{codeStart}except%{codeEnd} and %{codeStart}needs%{codeEnd} job dependencies. %{linkStart}Learn more%{linkEnd}" msgstr "" +msgid "PipelineEditor|Pipeline simulation completed with errors" +msgstr "" + +msgid "PipelineEditor|Simulated a %{codeStart}git push%{codeEnd} event for a default branch. %{codeStart}Rules%{codeEnd}, %{codeStart}only%{codeEnd}, %{codeStart}except%{codeEnd}, and %{codeStart}needs%{codeEnd} job dependencies logic have been evaluated. %{linkStart}Learn more%{linkEnd}" +msgstr "" + +msgid "PipelineEditor|Simulation completed successfully" +msgstr "" + msgid "PipelineEditor|The CI/CD configuration is continuously validated. Errors and warnings are displayed when the CI/CD configuration file is not empty." msgstr "" @@ -28417,6 +28441,12 @@ msgstr "" msgid "PipelineEditor|Validate pipeline under simulated conditions" msgstr "" +msgid "PipelineEditor|Validating pipeline... It can take up to a minute." +msgstr "" + +msgid "PipelineEditor|Waiting for CI content to load..." +msgstr "" + msgid "PipelineScheduleIntervalPattern|Custom (%{linkStart}Cron syntax%{linkEnd})" msgstr "" @@ -28696,6 +28726,9 @@ msgstr "" msgid "Pipelines|Pipeline Editor" msgstr "" +msgid "Pipelines|Pipeline syntax is correct." +msgstr "" + msgid "Pipelines|Project cache successfully reset." msgstr "" @@ -30151,6 +30184,9 @@ msgstr "" msgid "Project uploads" msgstr "" +msgid "Project visibility level is less restrictive than the group settings." +msgstr "" + msgid "Project visibility level will be changed to match namespace rules when transferring to a group." msgstr "" @@ -35588,9 +35624,6 @@ msgstr "" msgid "ServicePing|Turn on service ping to review instance-level analytics." msgstr "" -msgid "Services" -msgstr "" - msgid "Session ID" msgstr "" @@ -41343,9 +41376,6 @@ msgstr "" msgid "Unknown response text" msgstr "" -msgid "Unknown screen" -msgstr "" - msgid "Unknown user" msgstr "" @@ -44417,9 +44447,6 @@ msgstr "" msgid "You currently have more than %{free_limit} members across all your personal projects. From June 22, 2022, the %{free_limit} most recently active members will remain active, and the remaining members will get a %{link_start}status of Over limit%{link_end} and lose access. To view and manage members, check the members page for each project in your namespace. We recommend you %{move_link_start}move your project to a group%{move_link_end} so you can easily manage users and features." msgstr "" -msgid "You do not have any Google Cloud projects. Please create a Google Cloud project and then reload this page." -msgstr "" - msgid "You do not have any subscriptions yet" msgstr "" diff --git a/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_editor_lint_spec.rb b/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_editor_lint_spec.rb index 23f212e110b..c58865fff03 100644 --- a/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_editor_lint_spec.rb +++ b/qa/qa/specs/features/browser_ui/4_verify/pipeline/pipeline_editor_lint_spec.rb @@ -50,7 +50,7 @@ module QA it 'shows valid validations', testcase: 'https://gitlab.com/gitlab-org/gitlab/-/quality/test_cases/349128' do Page::Project::PipelineEditor::Show.perform do |show| aggregate_failures do - expect(show.ci_syntax_validate_message).to have_content('CI configuration is valid') + expect(show.ci_syntax_validate_message).to have_content('Pipeline syntax is correct') show.go_to_visualize_tab { stage1: 'job1', stage2: 'job2' }.each_pair do |stage, job| diff --git a/scripts/review_apps/review-apps.sh b/scripts/review_apps/review-apps.sh index 8a868edce3a..b6ac7b4281b 100755 --- a/scripts/review_apps/review-apps.sh +++ b/scripts/review_apps/review-apps.sh @@ -248,6 +248,7 @@ function download_chart() { helm repo add gitlab https://charts.gitlab.io echoinfo "Building the gitlab chart's dependencies..." + helm dependency build "gitlab-${GITLAB_HELM_CHART_REF}" } function base_config_changed() { diff --git a/spec/controllers/projects/pipelines/tests_controller_spec.rb b/spec/controllers/projects/pipelines/tests_controller_spec.rb index deb96cc5bf9..ddcab8b048e 100644 --- a/spec/controllers/projects/pipelines/tests_controller_spec.rb +++ b/spec/controllers/projects/pipelines/tests_controller_spec.rb @@ -64,7 +64,7 @@ RSpec.describe Projects::Pipelines::TestsController do get_tests_show_json(build_ids) expect(response).to have_gitlab_http_status(:not_found) - expect(json_response['errors']).to eq('Test report artifacts have expired') + expect(json_response['errors']).to eq('Test report artifacts not found') end end diff --git a/spec/features/groups/show_spec.rb b/spec/features/groups/show_spec.rb index fa8db1befb5..9a1e216c6d2 100644 --- a/spec/features/groups/show_spec.rb +++ b/spec/features/groups/show_spec.rb @@ -97,6 +97,31 @@ RSpec.describe 'Group show page' do end end + context 'when a public project is shared with a private group' do + let_it_be(:private_group) { create(:group, :private) } + let_it_be(:public_project) { create(:project, :public) } + let_it_be(:project_group_link) { create(:project_group_link, group: private_group, project: public_project) } + + before do + private_group.add_owner(user) + sign_in(user) + end + + it 'shows warning popover', :js do + visit group_path(private_group) + + click_link _('Shared projects') + + wait_for_requests + + page.within("[data-testid=\"group-overview-item-#{public_project.id}\"]") do + click_button _('Less restrictive visibility') + end + + expect(page).to have_content _('Project visibility level is less restrictive than the group settings.') + end + end + context 'when user does not have permissions to create new subgroups or projects', :js do before do group.add_reporter(user) diff --git a/spec/frontend/ci_lint/mock_data.js b/spec/frontend/ci_lint/mock_data.js index 28ea0f55bf8..660b2ad6e8b 100644 --- a/spec/frontend/ci_lint/mock_data.js +++ b/spec/frontend/ci_lint/mock_data.js @@ -1,5 +1,16 @@ import { mockJobs } from 'jest/pipeline_editor/mock_data'; +export const mockLintDataError = { + data: { + lintCI: { + errors: ['Error message'], + warnings: ['Warning message'], + valid: false, + jobs: mockJobs, + }, + }, +}; + export const mockLintDataValid = { data: { lintCI: { diff --git a/spec/frontend/google_cloud/components/app_spec.js b/spec/frontend/google_cloud/components/app_spec.js deleted file mode 100644 index 0cafe6d3b9d..00000000000 --- a/spec/frontend/google_cloud/components/app_spec.js +++ /dev/null @@ -1,77 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { mapValues } from 'lodash'; -import App from '~/google_cloud/components/app.vue'; -import Home from '~/google_cloud/components/home.vue'; -import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; -import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue'; -import GcpError from '~/google_cloud/components/errors/gcp_error.vue'; -import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue'; - -const BASE_FEEDBACK_URL = - 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new'; -const SCREEN_COMPONENTS = { - Home, - ServiceAccountsForm, - GcpError, - NoGcpProjects, -}; -const SERVICE_ACCOUNTS_FORM_PROPS = { - gcpProjects: [1, 2, 3], - refs: [4, 5, 6], - cancelPath: '', -}; -const HOME_PROPS = { - serviceAccounts: [{}, {}], - gcpRegions: [{}, {}], - createServiceAccountUrl: '#url-create-service-account', - configureGcpRegionsUrl: '#url-configure-gcp-regions', - emptyIllustrationUrl: '#url-empty-illustration', - enableCloudRunUrl: '#url-enable-cloud-run', - enableCloudStorageUrl: '#enableCloudStorageUrl', - revokeOauthUrl: '#revokeOauthUrl', -}; - -describe('google_cloud App component', () => { - let wrapper; - - const findIncubationBanner = () => wrapper.findComponent(IncubationBanner); - - afterEach(() => { - wrapper.destroy(); - }); - - describe.each` - screen | extraProps | componentName - ${'gcp_error'} | ${{ error: 'mock_gcp_client_error' }} | ${'GcpError'} - ${'no_gcp_projects'} | ${{}} | ${'NoGcpProjects'} - ${'service_accounts_form'} | ${SERVICE_ACCOUNTS_FORM_PROPS} | ${'ServiceAccountsForm'} - ${'home'} | ${HOME_PROPS} | ${'Home'} - `('for screen=$screen', ({ screen, extraProps, componentName }) => { - const component = SCREEN_COMPONENTS[componentName]; - - beforeEach(() => { - wrapper = shallowMount(App, { propsData: { screen, ...extraProps } }); - }); - - it(`renders only ${componentName}`, () => { - const existences = mapValues(SCREEN_COMPONENTS, (x) => wrapper.findComponent(x).exists()); - - expect(existences).toEqual({ - ...mapValues(SCREEN_COMPONENTS, () => false), - [componentName]: true, - }); - }); - - it(`renders the ${componentName} with props`, () => { - expect(wrapper.findComponent(component).props()).toEqual(extraProps); - }); - - it('renders incubation banner', () => { - expect(findIncubationBanner().props()).toEqual({ - shareFeedbackUrl: `${BASE_FEEDBACK_URL}?issuable_template=general_feedback`, - reportBugUrl: `${BASE_FEEDBACK_URL}?issuable_template=report_bug`, - featureRequestUrl: `${BASE_FEEDBACK_URL}?issuable_template=feature_request`, - }); - }); - }); -}); diff --git a/spec/frontend/google_cloud/components/errors/gcp_error_spec.js b/spec/frontend/google_cloud/components/errors/gcp_error_spec.js deleted file mode 100644 index 4062a8b902a..00000000000 --- a/spec/frontend/google_cloud/components/errors/gcp_error_spec.js +++ /dev/null @@ -1,34 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlAlert } from '@gitlab/ui'; -import GcpError from '~/google_cloud/components/errors/gcp_error.vue'; - -describe('GcpError component', () => { - let wrapper; - - const findAlert = () => wrapper.findComponent(GlAlert); - const findBlockquote = () => wrapper.find('blockquote'); - - const propsData = { error: 'IAM and CloudResourceManager API disabled' }; - - beforeEach(() => { - wrapper = shallowMount(GcpError, { propsData }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('contains alert', () => { - expect(findAlert().exists()).toBe(true); - }); - - it('contains relevant text', () => { - const alertText = findAlert().text(); - expect(findAlert().props('title')).toBe(GcpError.i18n.title); - expect(alertText).toContain(GcpError.i18n.description); - }); - - it('contains error stacktrace', () => { - expect(findBlockquote().text()).toBe(propsData.error); - }); -}); diff --git a/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js b/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js deleted file mode 100644 index e1e20377880..00000000000 --- a/spec/frontend/google_cloud/components/errors/no_gcp_projects_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { GlAlert, GlButton } from '@gitlab/ui'; -import NoGcpProjects from '~/google_cloud/components/errors/no_gcp_projects.vue'; - -describe('NoGcpProjects component', () => { - let wrapper; - - const findAlert = () => wrapper.findComponent(GlAlert); - const findButton = () => wrapper.findComponent(GlButton); - - beforeEach(() => { - wrapper = mount(NoGcpProjects); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('contains alert', () => { - expect(findAlert().exists()).toBe(true); - }); - - it('contains relevant text', () => { - expect(findAlert().props('title')).toBe(NoGcpProjects.i18n.title); - expect(findAlert().text()).toContain(NoGcpProjects.i18n.description); - }); - - it('contains create gcp project button', () => { - const button = findButton(); - expect(button.text()).toBe(NoGcpProjects.i18n.createLabel); - expect(button.attributes('href')).toBe('https://console.cloud.google.com/projectcreate'); - }); -}); diff --git a/spec/frontend/google_cloud/components/google_cloud_menu_spec.js b/spec/frontend/google_cloud/components/google_cloud_menu_spec.js new file mode 100644 index 00000000000..4809ea37045 --- /dev/null +++ b/spec/frontend/google_cloud/components/google_cloud_menu_spec.js @@ -0,0 +1,40 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue'; + +describe('google_cloud/components/google_cloud_menu', () => { + let wrapper; + + const props = { + active: 'configuration', + configurationUrl: 'configuration-url', + deploymentsUrl: 'deployments-url', + databasesUrl: 'databases-url', + }; + + beforeEach(() => { + wrapper = mountExtended(GoogleCloudMenu, { propsData: props }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains active configuration link', () => { + const link = wrapper.findByTestId('configurationLink'); + expect(link.text()).toBe(GoogleCloudMenu.i18n.configuration.title); + expect(link.attributes('href')).toBe(props.configurationUrl); + expect(link.element.classList.contains('gl-tab-nav-item-active')).toBe(true); + }); + + it('contains deployments link', () => { + const link = wrapper.findByTestId('deploymentsLink'); + expect(link.text()).toBe(GoogleCloudMenu.i18n.deployments.title); + expect(link.attributes('href')).toBe(props.deploymentsUrl); + }); + + it('contains databases link', () => { + const link = wrapper.findByTestId('databasesLink'); + expect(link.text()).toBe(GoogleCloudMenu.i18n.databases.title); + expect(link.attributes('href')).toBe(props.databasesUrl); + }); +}); diff --git a/spec/frontend/google_cloud/components/home_spec.js b/spec/frontend/google_cloud/components/home_spec.js deleted file mode 100644 index 42e3d72577d..00000000000 --- a/spec/frontend/google_cloud/components/home_spec.js +++ /dev/null @@ -1,66 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlTab, GlTabs } from '@gitlab/ui'; -import Home from '~/google_cloud/components/home.vue'; -import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue'; - -describe('google_cloud Home component', () => { - let wrapper; - - const findTabs = () => wrapper.findComponent(GlTabs); - const findTabItems = () => findTabs().findAllComponents(GlTab); - const findTabItemsModel = () => - findTabs() - .findAllComponents(GlTab) - .wrappers.map((x) => ({ - title: x.attributes('title'), - disabled: x.attributes('disabled'), - })); - - const TEST_HOME_PROPS = { - serviceAccounts: [{}, {}], - gcpRegions: [{}, {}], - createServiceAccountUrl: '#url-create-service-account', - configureGcpRegionsUrl: '#url-configure-gcp-regions', - emptyIllustrationUrl: '#url-empty-illustration', - enableCloudRunUrl: '#url-enable-cloud-run', - enableCloudStorageUrl: '#enableCloudStorageUrl', - revokeOauthUrl: '#revokeOauthUrl', - }; - - beforeEach(() => { - const propsData = { - screen: 'home', - ...TEST_HOME_PROPS, - }; - wrapper = shallowMount(Home, { propsData }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('google_cloud App tabs', () => { - it('should contain tabs', () => { - expect(findTabs().exists()).toBe(true); - }); - - it('should contain three tab items', () => { - expect(findTabItemsModel()).toEqual([ - { title: 'Configuration', disabled: undefined }, - { title: 'Deployments', disabled: undefined }, - { title: 'Services', disabled: '' }, - ]); - }); - - describe('configuration tab', () => { - it('should contain service accounts component', () => { - const serviceAccounts = findTabItems().at(0).findComponent(ServiceAccountsList); - expect(serviceAccounts.props()).toEqual({ - list: TEST_HOME_PROPS.serviceAccounts, - createUrl: TEST_HOME_PROPS.createServiceAccountUrl, - emptyIllustrationUrl: TEST_HOME_PROPS.emptyIllustrationUrl, - }); - }); - }); - }); -}); diff --git a/spec/frontend/google_cloud/components/incubation_banner_spec.js b/spec/frontend/google_cloud/components/incubation_banner_spec.js index 89517be4ef1..09a4d92dca2 100644 --- a/spec/frontend/google_cloud/components/incubation_banner_spec.js +++ b/spec/frontend/google_cloud/components/incubation_banner_spec.js @@ -2,7 +2,7 @@ import { mount } from '@vue/test-utils'; import { GlAlert, GlLink } from '@gitlab/ui'; import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; -describe('IncubationBanner component', () => { +describe('google_cloud/components/incubation_banner', () => { let wrapper; const findAlert = () => wrapper.findComponent(GlAlert); @@ -12,12 +12,7 @@ describe('IncubationBanner component', () => { const findShareFeedbackLink = () => findLinks().at(2); beforeEach(() => { - const propsData = { - shareFeedbackUrl: 'url_general_feedback', - reportBugUrl: 'url_report_bug', - featureRequestUrl: 'url_feature_request', - }; - wrapper = mount(IncubationBanner, { propsData }); + wrapper = mount(IncubationBanner); }); afterEach(() => { @@ -41,20 +36,26 @@ describe('IncubationBanner component', () => { it('contains feature request link', () => { const link = findFeatureRequestLink(); + const expected = + 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=feature_request'; expect(link.text()).toBe('request a feature'); - expect(link.attributes('href')).toBe('url_feature_request'); + expect(link.attributes('href')).toBe(expected); }); it('contains report bug link', () => { const link = findReportBugLink(); + const expected = + 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=report_bug'; expect(link.text()).toBe('report a bug'); - expect(link.attributes('href')).toBe('url_report_bug'); + expect(link.attributes('href')).toBe(expected); }); it('contains share feedback link', () => { const link = findShareFeedbackLink(); + const expected = + 'https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/feedback/-/issues/new?issuable_template=general_feedback'; expect(link.text()).toBe('share feedback'); - expect(link.attributes('href')).toBe('url_general_feedback'); + expect(link.attributes('href')).toBe(expected); }); }); }); diff --git a/spec/frontend/google_cloud/components/revoke_oauth_spec.js b/spec/frontend/google_cloud/components/revoke_oauth_spec.js index 87580dbf6de..faaec07fc35 100644 --- a/spec/frontend/google_cloud/components/revoke_oauth_spec.js +++ b/spec/frontend/google_cloud/components/revoke_oauth_spec.js @@ -5,7 +5,7 @@ import RevokeOauth, { GOOGLE_CLOUD_REVOKE_DESCRIPTION, } from '~/google_cloud/components/revoke_oauth.vue'; -describe('RevokeOauth component', () => { +describe('google_cloud/components/revoke_oauth', () => { let wrapper; const findTitle = () => wrapper.find('h2'); diff --git a/spec/frontend/google_cloud/configuration/panel_spec.js b/spec/frontend/google_cloud/configuration/panel_spec.js new file mode 100644 index 00000000000..79eb4cb4918 --- /dev/null +++ b/spec/frontend/google_cloud/configuration/panel_spec.js @@ -0,0 +1,65 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Panel from '~/google_cloud/configuration/panel.vue'; +import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; +import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue'; +import ServiceAccountsList from '~/google_cloud/service_accounts/list.vue'; +import GcpRegionsList from '~/google_cloud/gcp_regions/list.vue'; +import RevokeOauth from '~/google_cloud/components/revoke_oauth.vue'; + +describe('google_cloud/configuration/panel', () => { + let wrapper; + + const props = { + configurationUrl: 'configuration-url', + deploymentsUrl: 'deployments-url', + databasesUrl: 'databases-url', + serviceAccounts: [], + createServiceAccountUrl: 'create-service-account-url', + emptyIllustrationUrl: 'empty-illustration-url', + gcpRegions: [], + configureGcpRegionsUrl: 'configure-gcp-regions-url', + revokeOauthUrl: 'revoke-oauth-url', + }; + + beforeEach(() => { + wrapper = shallowMountExtended(Panel, { propsData: props }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains incubation banner', () => { + const target = wrapper.findComponent(IncubationBanner); + expect(target.exists()).toBe(true); + }); + + it('contains google cloud menu with `configuration` active', () => { + const target = wrapper.findComponent(GoogleCloudMenu); + expect(target.exists()).toBe(true); + expect(target.props('active')).toBe('configuration'); + expect(target.props('configurationUrl')).toBe(props.configurationUrl); + expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl); + expect(target.props('databasesUrl')).toBe(props.databasesUrl); + }); + + it('contains service accounts list', () => { + const target = wrapper.findComponent(ServiceAccountsList); + expect(target.exists()).toBe(true); + expect(target.props('list')).toBe(props.serviceAccounts); + expect(target.props('createUrl')).toBe(props.createServiceAccountUrl); + expect(target.props('emptyIllustrationUrl')).toBe(props.emptyIllustrationUrl); + }); + + it('contains gcp regions list', () => { + const target = wrapper.findComponent(GcpRegionsList); + expect(target.props('list')).toBe(props.gcpRegions); + expect(target.props('createUrl')).toBe(props.configureGcpRegionsUrl); + expect(target.props('emptyIllustrationUrl')).toBe(props.emptyIllustrationUrl); + }); + + it('contains revoke oauth', () => { + const target = wrapper.findComponent(RevokeOauth); + expect(target.props('url')).toBe(props.revokeOauthUrl); + }); +}); diff --git a/spec/frontend/google_cloud/components/cloudsql/create_instance_form_spec.js b/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js index de644a33b50..48e4b0ca1ad 100644 --- a/spec/frontend/google_cloud/components/cloudsql/create_instance_form_spec.js +++ b/spec/frontend/google_cloud/databases/cloudsql/create_instance_form_spec.js @@ -1,8 +1,8 @@ import { GlFormCheckbox } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import InstanceForm from '~/google_cloud/components/cloudsql/create_instance_form.vue'; +import InstanceForm from '~/google_cloud/databases/cloudsql/create_instance_form.vue'; -describe('google_cloud::cloudsql::create_instance_form component', () => { +describe('google_cloud/databases/cloudsql/create_instance_form', () => { let wrapper; const findByTestId = (id) => wrapper.findByTestId(id); diff --git a/spec/frontend/google_cloud/components/cloudsql/instance_table_spec.js b/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js index 286f2b8e379..a5736d0a524 100644 --- a/spec/frontend/google_cloud/components/cloudsql/instance_table_spec.js +++ b/spec/frontend/google_cloud/databases/cloudsql/instance_table_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { GlEmptyState, GlTable } from '@gitlab/ui'; -import InstanceTable from '~/google_cloud/components/cloudsql/instance_table.vue'; +import InstanceTable from '~/google_cloud/databases/cloudsql/instance_table.vue'; -describe('google_cloud::databases::service_table component', () => { +describe('google_cloud/databases/cloudsql/instance_table', () => { let wrapper; const findEmptyState = () => wrapper.findComponent(GlEmptyState); diff --git a/spec/frontend/google_cloud/databases/panel_spec.js b/spec/frontend/google_cloud/databases/panel_spec.js new file mode 100644 index 00000000000..490c0136651 --- /dev/null +++ b/spec/frontend/google_cloud/databases/panel_spec.js @@ -0,0 +1,36 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Panel from '~/google_cloud/databases/panel.vue'; +import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; +import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue'; + +describe('google_cloud/databases/panel', () => { + let wrapper; + + const props = { + configurationUrl: 'configuration-url', + deploymentsUrl: 'deployments-url', + databasesUrl: 'databases-url', + }; + + beforeEach(() => { + wrapper = shallowMountExtended(Panel, { propsData: props }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains incubation banner', () => { + const target = wrapper.findComponent(IncubationBanner); + expect(target.exists()).toBe(true); + }); + + it('contains google cloud menu with `databases` active', () => { + const target = wrapper.findComponent(GoogleCloudMenu); + expect(target.exists()).toBe(true); + expect(target.props('active')).toBe('databases'); + expect(target.props('configurationUrl')).toBe(props.configurationUrl); + expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl); + expect(target.props('databasesUrl')).toBe(props.databasesUrl); + }); +}); diff --git a/spec/frontend/google_cloud/components/databases/service_table_spec.js b/spec/frontend/google_cloud/databases/service_table_spec.js index 142e32c1a4b..4a622e544e1 100644 --- a/spec/frontend/google_cloud/components/databases/service_table_spec.js +++ b/spec/frontend/google_cloud/databases/service_table_spec.js @@ -1,8 +1,8 @@ import { GlTable } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import ServiceTable from '~/google_cloud/components/databases/service_table.vue'; +import ServiceTable from '~/google_cloud/databases/service_table.vue'; -describe('google_cloud::databases::service_table component', () => { +describe('google_cloud/databases/service_table', () => { let wrapper; const findTable = () => wrapper.findComponent(GlTable); diff --git a/spec/frontend/google_cloud/deployments/panel_spec.js b/spec/frontend/google_cloud/deployments/panel_spec.js new file mode 100644 index 00000000000..729db1707a7 --- /dev/null +++ b/spec/frontend/google_cloud/deployments/panel_spec.js @@ -0,0 +1,46 @@ +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import Panel from '~/google_cloud/deployments/panel.vue'; +import IncubationBanner from '~/google_cloud/components/incubation_banner.vue'; +import GoogleCloudMenu from '~/google_cloud/components/google_cloud_menu.vue'; +import ServiceTable from '~/google_cloud/deployments/service_table.vue'; + +describe('google_cloud/deployments/panel', () => { + let wrapper; + + const props = { + configurationUrl: 'configuration-url', + deploymentsUrl: 'deployments-url', + databasesUrl: 'databases-url', + enableCloudRunUrl: 'cloud-run-url', + enableCloudStorageUrl: 'cloud-storage-url', + }; + + beforeEach(() => { + wrapper = shallowMountExtended(Panel, { propsData: props }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('contains incubation banner', () => { + const target = wrapper.findComponent(IncubationBanner); + expect(target.exists()).toBe(true); + }); + + it('contains google cloud menu with `deployments` active', () => { + const target = wrapper.findComponent(GoogleCloudMenu); + expect(target.exists()).toBe(true); + expect(target.props('active')).toBe('deployments'); + expect(target.props('configurationUrl')).toBe(props.configurationUrl); + expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl); + expect(target.props('databasesUrl')).toBe(props.databasesUrl); + }); + + it('contains service-table', () => { + const target = wrapper.findComponent(ServiceTable); + expect(target.exists()).toBe(true); + expect(target.props('cloudRunUrl')).toBe(props.enableCloudRunUrl); + expect(target.props('cloudStorageUrl')).toBe(props.enableCloudStorageUrl); + }); +}); diff --git a/spec/frontend/google_cloud/components/deployments_service_table_spec.js b/spec/frontend/google_cloud/deployments/service_table_spec.js index 882376547c4..8faad64e313 100644 --- a/spec/frontend/google_cloud/components/deployments_service_table_spec.js +++ b/spec/frontend/google_cloud/deployments/service_table_spec.js @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils'; import { GlButton, GlTable } from '@gitlab/ui'; -import DeploymentsServiceTable from '~/google_cloud/components/deployments_service_table.vue'; +import DeploymentsServiceTable from '~/google_cloud/deployments/service_table.vue'; -describe('google_cloud DeploymentsServiceTable component', () => { +describe('google_cloud/deployments/service_table', () => { let wrapper; const findTable = () => wrapper.findComponent(GlTable); diff --git a/spec/frontend/google_cloud/components/gcp_regions_form_spec.js b/spec/frontend/google_cloud/gcp_regions/form_spec.js index a8b7593e7c8..1030e9c8a18 100644 --- a/spec/frontend/google_cloud/components/gcp_regions_form_spec.js +++ b/spec/frontend/google_cloud/gcp_regions/form_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { GlButton, GlFormGroup, GlFormSelect } from '@gitlab/ui'; -import GcpRegionsForm from '~/google_cloud/components/gcp_regions_form.vue'; +import GcpRegionsForm from '~/google_cloud/gcp_regions/form.vue'; -describe('GcpRegionsForm component', () => { +describe('google_cloud/gcp_regions/form', () => { let wrapper; const findHeader = () => wrapper.find('header'); diff --git a/spec/frontend/google_cloud/components/gcp_regions_list_spec.js b/spec/frontend/google_cloud/gcp_regions/list_spec.js index ab0c17451e8..6d8c389e5a1 100644 --- a/spec/frontend/google_cloud/components/gcp_regions_list_spec.js +++ b/spec/frontend/google_cloud/gcp_regions/list_spec.js @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils'; import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; -import GcpRegionsList from '~/google_cloud/components/gcp_regions_list.vue'; +import GcpRegionsList from '~/google_cloud/gcp_regions/list.vue'; -describe('GcpRegions component', () => { +describe('google_cloud/gcp_regions/list', () => { describe('when the project does not have any configured regions', () => { let wrapper; diff --git a/spec/frontend/google_cloud/components/service_accounts_form_spec.js b/spec/frontend/google_cloud/service_accounts/form_spec.js index 38602d4e8cc..8be481774fa 100644 --- a/spec/frontend/google_cloud/components/service_accounts_form_spec.js +++ b/spec/frontend/google_cloud/service_accounts/form_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { GlButton, GlFormGroup, GlFormSelect, GlFormCheckbox } from '@gitlab/ui'; -import ServiceAccountsForm from '~/google_cloud/components/service_accounts_form.vue'; +import ServiceAccountsForm from '~/google_cloud/service_accounts/form.vue'; -describe('ServiceAccountsForm component', () => { +describe('google_cloud/service_accounts/form', () => { let wrapper; const findHeader = () => wrapper.find('header'); diff --git a/spec/frontend/google_cloud/components/service_accounts_list_spec.js b/spec/frontend/google_cloud/service_accounts/list_spec.js index f7051c8a53d..7a76a893757 100644 --- a/spec/frontend/google_cloud/components/service_accounts_list_spec.js +++ b/spec/frontend/google_cloud/service_accounts/list_spec.js @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils'; import { GlAlert, GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; -import ServiceAccountsList from '~/google_cloud/components/service_accounts_list.vue'; +import ServiceAccountsList from '~/google_cloud/service_accounts/list.vue'; -describe('ServiceAccounts component', () => { +describe('google_cloud/service_accounts/list', () => { describe('when the project does not have any service accounts', () => { let wrapper; diff --git a/spec/frontend/groups/components/group_item_spec.js b/spec/frontend/groups/components/group_item_spec.js index 8ea7e54aef4..0bc80df6535 100644 --- a/spec/frontend/groups/components/group_item_spec.js +++ b/spec/frontend/groups/components/group_item_spec.js @@ -1,4 +1,4 @@ -import { mount } from '@vue/test-utils'; +import { GlPopover } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import GroupFolder from '~/groups/components/group_folder.vue'; import GroupItem from '~/groups/components/group_item.vue'; @@ -6,14 +6,25 @@ import ItemActions from '~/groups/components/item_actions.vue'; import eventHub from '~/groups/event_hub'; import { getGroupItemMicrodata } from '~/groups/store/utils'; import * as urlUtilities from '~/lib/utils/url_utility'; +import { + ITEM_TYPE, + VISIBILITY_INTERNAL, + VISIBILITY_PRIVATE, + VISIBILITY_PUBLIC, +} from '~/groups/constants'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mockParentGroupItem, mockChildren } from '../mock_data'; const createComponent = ( propsData = { group: mockParentGroupItem, parentGroup: mockChildren[0] }, + provide = { + currentGroupVisibility: VISIBILITY_PRIVATE, + }, ) => { - return mount(GroupItem, { + return mountExtended(GroupItem, { propsData, components: { GroupFolder }, + provide, }); }; @@ -276,4 +287,90 @@ describe('GroupItemComponent', () => { }); }); }); + + describe('visibility warning popover', () => { + const findPopover = () => wrapper.findComponent(GlPopover); + + const itDoesNotRenderVisibilityWarningPopover = () => { + it('does not render visibility warning popover', () => { + expect(findPopover().exists()).toBe(false); + }); + }; + + describe('when showing groups', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + itDoesNotRenderVisibilityWarningPopover(); + }); + + describe('when `action` prop is not `shared`', () => { + beforeEach(() => { + wrapper = createComponent({ + group: mockParentGroupItem, + parentGroup: mockChildren[0], + action: 'subgroups_and_projects', + }); + }); + + itDoesNotRenderVisibilityWarningPopover(); + }); + + describe('when showing projects', () => { + describe.each` + itemVisibility | currentGroupVisibility | isPopoverShown + ${VISIBILITY_PRIVATE} | ${VISIBILITY_PUBLIC} | ${false} + ${VISIBILITY_INTERNAL} | ${VISIBILITY_PUBLIC} | ${false} + ${VISIBILITY_PUBLIC} | ${VISIBILITY_PUBLIC} | ${false} + ${VISIBILITY_PRIVATE} | ${VISIBILITY_PRIVATE} | ${false} + ${VISIBILITY_INTERNAL} | ${VISIBILITY_PRIVATE} | ${true} + ${VISIBILITY_PUBLIC} | ${VISIBILITY_PRIVATE} | ${true} + `( + 'when item visibility is $itemVisibility and parent group visibility is $currentGroupVisibility', + ({ itemVisibility, currentGroupVisibility, isPopoverShown }) => { + beforeEach(() => { + wrapper = createComponent( + { + group: { + ...mockParentGroupItem, + visibility: itemVisibility, + type: ITEM_TYPE.PROJECT, + }, + parentGroup: mockChildren[0], + action: 'shared', + }, + { + currentGroupVisibility, + }, + ); + }); + + if (isPopoverShown) { + it('renders visibility warning popover', () => { + expect(findPopover().exists()).toBe(true); + }); + } else { + itDoesNotRenderVisibilityWarningPopover(); + } + }, + ); + }); + + it('sets up popover `target` prop correctly', () => { + wrapper = createComponent({ + group: { + ...mockParentGroupItem, + visibility: VISIBILITY_PUBLIC, + type: ITEM_TYPE.PROJECT, + }, + parentGroup: mockChildren[0], + action: 'shared', + }); + + expect(findPopover().props('target')()).toEqual( + wrapper.findByRole('button', { name: GroupItem.i18n.popoverTitle }).element, + ); + }); + }); }); diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index 590b4fb3d57..48a2319cf96 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -1,45 +1,55 @@ -import Vue, { nextTick } from 'vue'; +import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import groupFolderComponent from '~/groups/components/group_folder.vue'; -import groupItemComponent from '~/groups/components/group_item.vue'; -import groupsComponent from '~/groups/components/groups.vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import GroupFolderComponent from '~/groups/components/group_folder.vue'; +import GroupItemComponent from '~/groups/components/group_item.vue'; +import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import GroupsComponent from '~/groups/components/groups.vue'; import eventHub from '~/groups/event_hub'; +import { VISIBILITY_PRIVATE } from '~/groups/constants'; import { mockGroups, mockPageInfo } from '../mock_data'; -const createComponent = (searchEmpty = false) => { - const Component = Vue.extend(groupsComponent); +describe('GroupsComponent', () => { + let wrapper; - return mountComponent(Component, { + const defaultPropsData = { groups: mockGroups, pageInfo: mockPageInfo, searchEmptyMessage: 'No matching results', - searchEmpty, - }); -}; + searchEmpty: false, + }; -describe('GroupsComponent', () => { - let vm; - - beforeEach(async () => { - Vue.component('GroupFolder', groupFolderComponent); - Vue.component('GroupItem', groupItemComponent); + const createComponent = ({ propsData } = {}) => { + wrapper = mountExtended(GroupsComponent, { + propsData: { + ...defaultPropsData, + ...propsData, + }, + provide: { + currentGroupVisibility: VISIBILITY_PRIVATE, + }, + }); + }; - vm = createComponent(); + const findPaginationLinks = () => wrapper.findComponent(PaginationLinks); - await nextTick(); + beforeEach(async () => { + Vue.component('GroupFolder', GroupFolderComponent); + Vue.component('GroupItem', GroupItemComponent); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); describe('methods', () => { describe('change', () => { it('should emit `fetchPage` event when page is changed via pagination', () => { + createComponent(); + jest.spyOn(eventHub, '$emit').mockImplementation(); - vm.change(2); + findPaginationLinks().props('change')(2); expect(eventHub.$emit).toHaveBeenCalledWith('fetchPage', { page: 2, @@ -52,18 +62,18 @@ describe('GroupsComponent', () => { }); describe('template', () => { - it('should render component template correctly', async () => { - await nextTick(); - expect(vm.$el.querySelector('.groups-list-tree-container')).toBeDefined(); - expect(vm.$el.querySelector('.group-list-tree')).toBeDefined(); - expect(vm.$el.querySelector('.gl-pagination')).toBeDefined(); - expect(vm.$el.querySelectorAll('.has-no-search-results').length).toBe(0); + it('should render component template correctly', () => { + createComponent(); + + expect(wrapper.findComponent(GroupFolderComponent).exists()).toBe(true); + expect(findPaginationLinks().exists()).toBe(true); + expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(false); }); - it('should render empty search message when `searchEmpty` is `true`', async () => { - vm.searchEmpty = true; - await nextTick(); - expect(vm.$el.querySelector('.has-no-search-results')).toBeDefined(); + it('should render empty search message when `searchEmpty` is `true`', () => { + createComponent({ propsData: { searchEmpty: true } }); + + expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/groups/mock_data.js b/spec/frontend/groups/mock_data.js index 65a62876893..9a325776374 100644 --- a/spec/frontend/groups/mock_data.js +++ b/spec/frontend/groups/mock_data.js @@ -29,6 +29,7 @@ export const mockParentGroupItem = { isChildrenLoading: false, isBeingRemoved: false, updatedAt: '2017-04-09T18:40:39.101Z', + lastActivityAt: '2017-04-09T18:40:39.101Z', }; export const mockRawChildren = [ diff --git a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js index ae19ed9ab02..82ac390971d 100644 --- a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js +++ b/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js @@ -152,4 +152,26 @@ describe('CI Lint Results', () => { expect(findAfterScripts()).toHaveLength(filterEmptyScripts('afterScript').length); }); }); + + describe('Hide Alert', () => { + it('hides alert on success if hide-alert prop is true', async () => { + await createComponent({ dryRun: true, hideAlert: true }, mount); + + expect(findStatus().exists()).toBe(false); + }); + + it('hides alert on error if hide-alert prop is true', async () => { + await createComponent( + { + hideAlert: true, + isValid: false, + errors: mockErrors, + warnings: mockWarnings, + }, + mount, + ); + + expect(findStatus().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js index af5029b7c3b..87a7f07f7d4 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -1,6 +1,8 @@ import { GlAlert, GlBadge, GlLoadingIcon, GlTabs } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; import Vue, { nextTick } from 'vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; import CiLint from '~/pipeline_editor/components/lint/ci_lint.vue'; @@ -19,7 +21,17 @@ import { VALIDATE_TAB_BADGE_DISMISSED_KEY, } from '~/pipeline_editor/constants'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; -import { mockLintResponse, mockLintResponseWithoutMerged, mockCiYml } from '../mock_data'; +import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql'; +import { + mockBlobContentQueryResponse, + mockCiLintPath, + mockCiYml, + mockLintResponse, + mockLintResponseWithoutMerged, +} from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); Vue.config.ignoredElements = ['gl-emoji']; @@ -35,6 +47,7 @@ describe('Pipeline editor tabs component', () => { provide = {}, appStatus = EDITOR_APP_STATUS_VALID, mountFn = shallowMount, + options = {}, } = {}) => { wrapper = mountFn(PipelineEditorTabs, { propsData: { @@ -50,12 +63,34 @@ describe('Pipeline editor tabs component', () => { appStatus, }; }, - provide: { ...provide }, + provide: { + ciLintPath: mockCiLintPath, + ...provide, + }, stubs: { TextEditor: MockTextEditor, EditorTab, }, listeners, + ...options, + }); + }; + + let mockBlobContentData; + let mockApollo; + + const createComponentWithApollo = ({ props, provide = {}, mountFn = shallowMount } = {}) => { + const handlers = [[getBlobContent, mockBlobContentData]]; + mockApollo = createMockApollo(handlers); + + createComponent({ + props, + provide, + mountFn, + options: { + localVue, + apolloProvider: mockApollo, + }, }); }; @@ -76,6 +111,10 @@ describe('Pipeline editor tabs component', () => { const findMergedPreview = () => wrapper.findComponent(CiConfigMergedPreview); const findWalkthroughPopover = () => wrapper.findComponent(WalkthroughPopover); + beforeEach(() => { + mockBlobContentData = jest.fn(); + }); + afterEach(() => { wrapper.destroy(); }); @@ -118,27 +157,6 @@ describe('Pipeline editor tabs component', () => { describe('validate tab', () => { describe('with simulatePipeline feature flag ON', () => { - describe('while loading', () => { - beforeEach(() => { - createComponent({ - appStatus: EDITOR_APP_STATUS_LOADING, - provide: { - glFeatures: { - simulatePipeline: true, - }, - }, - }); - }); - - it('displays a loading icon if the lint query is loading', () => { - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('does not display the validate component', () => { - expect(findCiValidate().exists()).toBe(false); - }); - }); - describe('after loading', () => { beforeEach(() => { createComponent({ @@ -155,13 +173,17 @@ describe('Pipeline editor tabs component', () => { describe('NEW badge', () => { describe('default', () => { beforeEach(() => { - createComponent({ + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + createComponentWithApollo({ mountFn: mount, props: { currentTab: VALIDATE_TAB, }, provide: { glFeatures: { simulatePipeline: true }, + ciConfigPath: '/path/to/ci-config', + currentBranch: 'main', + projectFullPath: '/path/to/project', simulatePipelineHelpPagePath: 'path/to/help/page', validateTabIllustrationPath: 'path/to/svg', }, @@ -185,10 +207,14 @@ describe('Pipeline editor tabs component', () => { describe('if badge has been dismissed before', () => { beforeEach(() => { localStorage.setItem(VALIDATE_TAB_BADGE_DISMISSED_KEY, 'true'); - createComponent({ + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + createComponentWithApollo({ mountFn: mount, provide: { glFeatures: { simulatePipeline: true }, + ciConfigPath: '/path/to/ci-config', + currentBranch: 'main', + projectFullPath: '/path/to/project', simulatePipelineHelpPagePath: 'path/to/help/page', validateTabIllustrationPath: 'path/to/svg', }, diff --git a/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js index e1fbcd34e7c..f5f01b675b2 100644 --- a/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js +++ b/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js @@ -1,45 +1,132 @@ -import { GlButton, GlDropdown, GlIcon, GlPopover } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlDropdown, GlIcon, GlLoadingIcon, GlPopover } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; import CiValidate, { i18n } from '~/pipeline_editor/components/validate/ci_validate.vue'; import ValidatePipelinePopover from '~/pipeline_editor/components/popovers/validate_pipeline_popover.vue'; -import { mockSimulatePipelineHelpPagePath } from '../../mock_data'; +import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql'; +import lintCIMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; +import { + mockBlobContentQueryResponse, + mockCiLintPath, + mockCiYml, + mockSimulatePipelineHelpPagePath, +} from '../../mock_data'; +import { mockLintDataError, mockLintDataValid } from '../../../ci_lint/mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); describe('Pipeline Editor Validate Tab', () => { let wrapper; + let mockApollo; + let mockBlobContentData; - const createComponent = ({ stubs } = {}) => { - wrapper = shallowMount(CiValidate, { + const createComponent = ({ + props, + stubs, + options, + isBlobLoading = false, + isSimulationLoading = false, + } = {}) => { + wrapper = shallowMountExtended(CiValidate, { + propsData: { + ciFileContent: mockCiYml, + ...props, + }, provide: { + ciConfigPath: '/path/to/ci-config', + ciLintPath: mockCiLintPath, + currentBranch: 'main', + projectFullPath: '/path/to/project', validateTabIllustrationPath: '/path/to/img', simulatePipelineHelpPagePath: mockSimulatePipelineHelpPagePath, }, stubs, + mocks: { + $apollo: { + queries: { + initialBlobContent: { + loading: isBlobLoading, + }, + }, + mutations: { + lintCiMutation: { + loading: isSimulationLoading, + }, + }, + }, + }, + ...options, + }); + }; + + const createComponentWithApollo = ({ props, stubs } = {}) => { + const handlers = [[getBlobContent, mockBlobContentData]]; + mockApollo = createMockApollo(handlers); + + createComponent({ + props, + stubs, + options: { + localVue, + apolloProvider: mockApollo, + mocks: {}, + }, }); }; - const findCta = () => wrapper.findComponent(GlButton); + const findAlert = () => wrapper.findComponent(GlAlert); + const findCancelBtn = () => wrapper.findByTestId('cancel-simulation'); + const findContentChangeStatus = () => wrapper.findByTestId('content-status'); + const findCta = () => wrapper.findByTestId('simulate-pipeline-button'); + const findDisabledCtaTooltip = () => wrapper.findByTestId('cta-tooltip'); const findHelpIcon = () => wrapper.findComponent(GlIcon); + const findIllustration = () => wrapper.findByRole('img'); + const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); const findPipelineSource = () => wrapper.findComponent(GlDropdown); const findPopover = () => wrapper.findComponent(GlPopover); + const findCiLintResults = () => wrapper.findComponent(CiLintResults); + const findResultsCta = () => wrapper.findByTestId('resimulate-pipeline-button'); + + beforeEach(() => { + mockBlobContentData = jest.fn(); + }); afterEach(() => { wrapper.destroy(); }); - describe('template', () => { + describe('while initial CI content is loading', () => { beforeEach(() => { - createComponent({ stubs: { GlPopover, ValidatePipelinePopover } }); + createComponent({ isBlobLoading: true }); + }); + + it('renders disabled CTA with tooltip', () => { + expect(findCta().props('disabled')).toBe(true); + expect(findDisabledCtaTooltip().exists()).toBe(true); + }); + }); + + describe('after initial CI content is loaded', () => { + beforeEach(async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + await createComponentWithApollo({ stubs: { GlPopover, ValidatePipelinePopover } }); }); it('renders disabled pipeline source dropdown', () => { expect(findPipelineSource().exists()).toBe(true); expect(findPipelineSource().attributes('text')).toBe(i18n.pipelineSourceDefault); - expect(findPipelineSource().attributes('disabled')).toBe('true'); + expect(findPipelineSource().props('disabled')).toBe(true); }); - it('renders CTA', () => { + it('renders enabled CTA without tooltip', () => { expect(findCta().exists()).toBe(true); - expect(findCta().text()).toBe(i18n.cta); + expect(findCta().props('disabled')).toBe(false); + expect(findDisabledCtaTooltip().exists()).toBe(false); }); it('popover is set to render when hovering over help icon', () => { @@ -47,4 +134,146 @@ describe('Pipeline Editor Validate Tab', () => { expect(findPopover().props('triggers')).toBe('hover focus'); }); }); + + describe('simulating the pipeline', () => { + beforeEach(async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + await createComponentWithApollo(); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); + }); + + it('renders loading state while simulation is ongoing', async () => { + findCta().vm.$emit('click'); + await nextTick(); + + expect(findLoadingIcon().exists()).toBe(true); + expect(findCancelBtn().exists()).toBe(true); + expect(findCta().props('loading')).toBe(true); + }); + + it('calls mutation with the correct input', async () => { + await findCta().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: lintCIMutation, + variables: { + dry_run: true, + content: mockCiYml, + endpoint: mockCiLintPath, + }, + }); + }); + + describe('when results are successful', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); + await findCta().vm.$emit('click'); + }); + + it('renders success alert', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().attributes('variant')).toBe('success'); + expect(findAlert().attributes('title')).toBe(i18n.successAlertTitle); + }); + + it('does not render content change status or CTA for results page', () => { + expect(findContentChangeStatus().exists()).toBe(false); + expect(findResultsCta().exists()).toBe(false); + }); + + it('renders CI lint results with correct props', () => { + expect(findCiLintResults().exists()).toBe(true); + expect(findCiLintResults().props()).toMatchObject({ + dryRun: true, + hideAlert: true, + isValid: true, + jobs: mockLintDataValid.data.lintCI.jobs, + }); + }); + }); + + describe('when results have errors', () => { + beforeEach(async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataError); + await findCta().vm.$emit('click'); + }); + + it('renders error alert', () => { + expect(findAlert().exists()).toBe(true); + expect(findAlert().attributes('variant')).toBe('danger'); + expect(findAlert().attributes('title')).toBe(i18n.errorAlertTitle); + }); + + it('renders CI lint results with correct props', () => { + expect(findCiLintResults().exists()).toBe(true); + expect(findCiLintResults().props()).toMatchObject({ + dryRun: true, + hideAlert: true, + isValid: false, + errors: mockLintDataError.data.lintCI.errors, + warnings: mockLintDataError.data.lintCI.warnings, + }); + }); + }); + }); + + describe('when CI content has changed after a simulation', () => { + beforeEach(async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + await createComponentWithApollo(); + + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); + await findCta().vm.$emit('click'); + }); + + it('renders content change status', async () => { + await wrapper.setProps({ ciFileContent: 'new yaml content' }); + + expect(findContentChangeStatus().exists()).toBe(true); + expect(findResultsCta().exists()).toBe(true); + }); + + it('calls mutation with new content', async () => { + await wrapper.setProps({ ciFileContent: 'new yaml content' }); + await findResultsCta().vm.$emit('click'); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(2); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: lintCIMutation, + variables: { + dry_run: true, + content: 'new yaml content', + endpoint: mockCiLintPath, + }, + }); + }); + }); + + describe('canceling a simulation', () => { + beforeEach(async () => { + mockBlobContentData.mockResolvedValue(mockBlobContentQueryResponse); + await createComponentWithApollo(); + }); + + it('returns to init state', async () => { + // init state + expect(findIllustration().exists()).toBe(true); + expect(findCiLintResults().exists()).toBe(false); + + // mutations should have successful results + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockLintDataValid); + findCta().vm.$emit('click'); + await nextTick(); + + // cancel before simulation succeeds + expect(findCancelBtn().exists()).toBe(true); + await findCancelBtn().vm.$emit('click'); + + // should still render init state + expect(findIllustration().exists()).toBe(true); + expect(findCiLintResults().exists()).toBe(false); + }); + }); }); diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/pipeline_editor/mock_data.js index f50ab1ab2a6..2ea580b7b53 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/pipeline_editor/mock_data.js @@ -7,6 +7,7 @@ export const mockProjectFullPath = `${mockProjectNamespace}/${mockProjectPath}`; export const mockDefaultBranch = 'main'; export const mockNewBranch = 'new-branch'; export const mockNewMergeRequestPath = '/-/merge_requests/new'; +export const mockCiLintPath = '/-/ci/lint'; export const mockCommitSha = 'aabbccdd'; export const mockCommitNextSha = 'eeffgghh'; export const mockIncludesHelpPagePath = '/-/includes/help'; diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index 6ab479a257c..f9b9da01a2b 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -49,7 +49,7 @@ describe('Mutations TestReports Store', () => { describe('set suite error', () => { it('should set the error message in state if provided', () => { - const message = 'Test report artifacts have expired'; + const message = 'Test report artifacts not found'; mutations[types.SET_SUITE_ERROR](mockState, { response: { data: { errors: message } }, diff --git a/spec/helpers/ci/pipeline_editor_helper_spec.rb b/spec/helpers/ci/pipeline_editor_helper_spec.rb index a895d3e4fd7..bc9e47a4ca1 100644 --- a/spec/helpers/ci/pipeline_editor_helper_spec.rb +++ b/spec/helpers/ci/pipeline_editor_helper_spec.rb @@ -48,6 +48,7 @@ RSpec.describe Ci::PipelineEditorHelper do "ci-config-path": project.ci_config_path_or_default, "ci-examples-help-page-path" => help_page_path('ci/examples/index'), "ci-help-page-path" => help_page_path('ci/index'), + "ci-lint-path" => project_ci_lint_path(project), "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => 'illustrations/empty.svg', "initial-branch-name" => nil, @@ -78,6 +79,7 @@ RSpec.describe Ci::PipelineEditorHelper do "ci-config-path": project.ci_config_path_or_default, "ci-examples-help-page-path" => help_page_path('ci/examples/index'), "ci-help-page-path" => help_page_path('ci/index'), + "ci-lint-path" => project_ci_lint_path(project), "default-branch" => project.default_branch_or_main, "empty-state-illustration-path" => 'illustrations/empty.svg', "initial-branch-name" => nil, diff --git a/spec/lib/gitlab/memory/watchdog_spec.rb b/spec/lib/gitlab/memory/watchdog_spec.rb new file mode 100644 index 00000000000..8b82078bcb9 --- /dev/null +++ b/spec/lib/gitlab/memory/watchdog_spec.rb @@ -0,0 +1,308 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Memory::Watchdog, :aggregate_failures, :prometheus do + context 'watchdog' do + let(:logger) { instance_double(::Logger) } + let(:handler) { instance_double(described_class::NullHandler) } + + let(:heap_frag_limit_gauge) { instance_double(::Prometheus::Client::Gauge) } + let(:heap_frag_violations_counter) { instance_double(::Prometheus::Client::Counter) } + let(:heap_frag_violations_handled_counter) { instance_double(::Prometheus::Client::Counter) } + + let(:sleep_time) { 0.1 } + let(:max_heap_fragmentation) { 0.2 } + + subject(:watchdog) do + described_class.new(handler: handler, logger: logger, sleep_time_seconds: sleep_time, + max_strikes: max_strikes, max_heap_fragmentation: max_heap_fragmentation) + end + + before do + allow(handler).to receive(:on_high_heap_fragmentation).and_return(true) + + allow(logger).to receive(:warn) + allow(logger).to receive(:info) + + allow(Gitlab::Metrics::Memory).to receive(:gc_heap_fragmentation).and_return(fragmentation) + end + + after do + watchdog.stop + end + + context 'when starting up' do + let(:fragmentation) { 0 } + let(:max_strikes) { 0 } + + it 'sets the heap fragmentation limit gauge' do + allow(Gitlab::Metrics).to receive(:gauge).and_return(heap_frag_limit_gauge) + + expect(heap_frag_limit_gauge).to receive(:set).with({}, max_heap_fragmentation) + end + + context 'when no settings are set in the environment' do + it 'initializes with defaults' do + watchdog = described_class.new(handler: handler, logger: logger) + + expect(watchdog.max_heap_fragmentation).to eq(described_class::DEFAULT_HEAP_FRAG_THRESHOLD) + expect(watchdog.max_strikes).to eq(described_class::DEFAULT_MAX_STRIKES) + expect(watchdog.sleep_time_seconds).to eq(described_class::DEFAULT_SLEEP_TIME_SECONDS) + end + end + + context 'when settings are passed through the environment' do + before do + stub_env('GITLAB_MEMWD_MAX_HEAP_FRAG', 1) + stub_env('GITLAB_MEMWD_MAX_STRIKES', 2) + stub_env('GITLAB_MEMWD_SLEEP_TIME_SEC', 3) + end + + it 'initializes with these settings' do + watchdog = described_class.new(handler: handler, logger: logger) + + expect(watchdog.max_heap_fragmentation).to eq(1) + expect(watchdog.max_strikes).to eq(2) + expect(watchdog.sleep_time_seconds).to eq(3) + end + end + end + + context 'when process does not exceed heap fragmentation threshold' do + let(:fragmentation) { max_heap_fragmentation - 0.1 } + let(:max_strikes) { 0 } # To rule out that we were granting too many strikes. + + it 'does not signal the handler' do + expect(handler).not_to receive(:on_high_heap_fragmentation) + + watchdog.start + + sleep sleep_time * 3 + end + end + + context 'when process exceeds heap fragmentation threshold permanently' do + let(:fragmentation) { max_heap_fragmentation + 0.1 } + + before do + allow(Gitlab::Metrics).to receive(:counter) + .with(:gitlab_memwd_heap_frag_violations_total, anything, anything) + .and_return(heap_frag_violations_counter) + allow(Gitlab::Metrics).to receive(:counter) + .with(:gitlab_memwd_heap_frag_violations_handled_total, anything, anything) + .and_return(heap_frag_violations_handled_counter) + allow(heap_frag_violations_counter).to receive(:increment) + allow(heap_frag_violations_handled_counter).to receive(:increment) + end + + context 'when process has not exceeded allowed number of strikes' do + let(:max_strikes) { 10 } + + it 'does not signal the handler' do + expect(handler).not_to receive(:on_high_heap_fragmentation) + + watchdog.start + + sleep sleep_time * 3 + end + + it 'does not log any events' do + expect(logger).not_to receive(:warn) + + watchdog.start + + sleep sleep_time * 3 + end + + it 'increments the violations counter' do + expect(heap_frag_violations_counter).to receive(:increment) + + watchdog.start + + sleep sleep_time * 3 + end + + it 'does not increment violations handled counter' do + expect(heap_frag_violations_handled_counter).not_to receive(:increment) + + watchdog.start + + sleep sleep_time * 3 + end + end + + context 'when process exceeds the allowed number of strikes' do + let(:max_strikes) { 1 } + + it 'signals the handler and resets strike counter' do + expect(handler).to receive(:on_high_heap_fragmentation).and_return(true) + + watchdog.start + + sleep sleep_time * 3 + + expect(watchdog.strikes).to eq(0) + end + + it 'logs the event' do + expect(::Prometheus::PidProvider).to receive(:worker_id).at_least(:once).and_return('worker_1') + expect(Gitlab::Metrics::System).to receive(:memory_usage_rss).at_least(:once).and_return(1024) + expect(logger).to receive(:warn).with({ + message: 'heap fragmentation limit exceeded', + pid: Process.pid, + worker_id: 'worker_1', + memwd_handler_class: 'RSpec::Mocks::InstanceVerifyingDouble', + memwd_sleep_time_s: sleep_time, + memwd_max_heap_frag: max_heap_fragmentation, + memwd_cur_heap_frag: fragmentation, + memwd_max_strikes: max_strikes, + memwd_cur_strikes: max_strikes + 1, + memwd_rss_bytes: 1024 + }) + + watchdog.start + + sleep sleep_time * 3 + end + + it 'increments both the violations and violations handled counters' do + expect(heap_frag_violations_counter).to receive(:increment) + expect(heap_frag_violations_handled_counter).to receive(:increment) + + watchdog.start + + sleep sleep_time * 3 + end + + context 'when enforce_memory_watchdog ops toggle is off' do + before do + stub_feature_flags(enforce_memory_watchdog: false) + end + + it 'always uses the NullHandler' do + expect(handler).not_to receive(:on_high_heap_fragmentation) + expect(described_class::NullHandler.instance).to( + receive(:on_high_heap_fragmentation).with(fragmentation).and_return(true) + ) + + watchdog.start + + sleep sleep_time * 3 + end + end + end + + context 'when handler result is true' do + let(:max_strikes) { 1 } + + it 'considers the event handled and stops itself' do + expect(handler).to receive(:on_high_heap_fragmentation).once.and_return(true) + + watchdog.start + + sleep sleep_time * 3 + end + end + + context 'when handler result is false' do + let(:max_strikes) { 1 } + + it 'keeps running' do + # Return true the third time to terminate the daemon. + expect(handler).to receive(:on_high_heap_fragmentation).and_return(false, false, true) + + watchdog.start + + sleep sleep_time * 4 + end + end + end + + context 'when process exceeds heap fragmentation threshold temporarily' do + let(:fragmentation) { max_heap_fragmentation } + let(:max_strikes) { 1 } + + before do + allow(Gitlab::Metrics::Memory).to receive(:gc_heap_fragmentation).and_return( + fragmentation - 0.1, + fragmentation + 0.2, + fragmentation - 0.1, + fragmentation + 0.1 + ) + end + + it 'does not signal the handler' do + expect(handler).not_to receive(:on_high_heap_fragmentation) + + watchdog.start + + sleep sleep_time * 4 + end + end + + context 'when gitlab_memory_watchdog ops toggle is off' do + let(:fragmentation) { 0 } + let(:max_strikes) { 0 } + + before do + stub_feature_flags(gitlab_memory_watchdog: false) + end + + it 'does not monitor heap fragmentation' do + expect(Gitlab::Metrics::Memory).not_to receive(:gc_heap_fragmentation) + + watchdog.start + + sleep sleep_time * 3 + end + end + end + + context 'handlers' do + context 'NullHandler' do + subject(:handler) { described_class::NullHandler.instance } + + describe '#on_high_heap_fragmentation' do + it 'does nothing' do + expect(handler.on_high_heap_fragmentation(1.0)).to be(false) + end + end + end + + context 'TermProcessHandler' do + subject(:handler) { described_class::TermProcessHandler.new(42) } + + describe '#on_high_heap_fragmentation' do + it 'sends SIGTERM to the current process' do + expect(Process).to receive(:kill).with(:TERM, 42) + + expect(handler.on_high_heap_fragmentation(1.0)).to be(true) + end + end + end + + context 'PumaHandler' do + # rubocop: disable RSpec/VerifiedDoubles + # In tests, the Puma constant is not loaded so we cannot make this an instance_double. + let(:puma_worker_handle_class) { double('Puma::Cluster::WorkerHandle') } + let(:puma_worker_handle) { double('worker') } + # rubocop: enable RSpec/VerifiedDoubles + + subject(:handler) { described_class::PumaHandler.new({}) } + + before do + stub_const('::Puma::Cluster::WorkerHandle', puma_worker_handle_class) + end + + describe '#on_high_heap_fragmentation' do + it 'invokes orderly termination via Puma API' do + expect(puma_worker_handle_class).to receive(:new).and_return(puma_worker_handle) + expect(puma_worker_handle).to receive(:term) + + expect(handler.on_high_heap_fragmentation(1.0)).to be(true) + end + end + end + end +end diff --git a/spec/models/project_setting_spec.rb b/spec/models/project_setting_spec.rb index 867ad843406..fb1601a5f9c 100644 --- a/spec/models/project_setting_spec.rb +++ b/spec/models/project_setting_spec.rb @@ -6,6 +6,17 @@ RSpec.describe ProjectSetting, type: :model do using RSpec::Parameterized::TableSyntax it { is_expected.to belong_to(:project) } + describe 'scopes' do + let_it_be(:project_1) { create(:project) } + let_it_be(:project_2) { create(:project) } + let_it_be(:project_setting_1) { create(:project_setting, project: project_1) } + let_it_be(:project_setting_2) { create(:project_setting, project: project_2) } + + it 'returns project setting for the given projects' do + expect(described_class.for_projects(project_1)).to contain_exactly(project_setting_1) + end + end + describe 'validations' do it { is_expected.not_to allow_value(nil).for(:target_platforms) } it { is_expected.to allow_value([]).for(:target_platforms) } diff --git a/spec/requests/api/graphql/crm/contacts_spec.rb b/spec/requests/api/graphql/crm/contacts_spec.rb new file mode 100644 index 00000000000..7e824140894 --- /dev/null +++ b/spec/requests/api/graphql/crm/contacts_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'getting CRM contacts' do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:group) { create(:group, :crm_enabled) } + + let_it_be(:contact_a) do + create( + :contact, + group: group, + first_name: "ABC", + last_name: "DEF", + email: "ghi@test.com", + description: "LMNO", + state: "inactive" + ) + end + + let_it_be(:contact_b) do + create( + :contact, + group: group, + first_name: "ABC", + last_name: "DEF", + email: "vwx@test.com", + description: "YZ", + state: "active" + ) + end + + let_it_be(:contact_c) do + create( + :contact, + group: group, + first_name: "PQR", + last_name: "STU", + email: "aaa@test.com", + description: "YZ", + state: "active" + ) + end + + before do + group.add_reporter(current_user) + end + + it_behaves_like 'sorted paginated query' do + let(:sort_argument) { {} } + let(:first_param) { 2 } + let(:all_records) { [contact_a, contact_b, contact_c] } + let(:data_path) { [:group, :contacts] } + + def pagination_query(params) + graphql_query_for( + :group, + { full_path: group.full_path }, + query_graphql_field(:contacts, params, "#{page_info} nodes { id }") + ) + end + + def pagination_results_data(nodes) + nodes.map { |item| GlobalID::Locator.locate(item['id']) } + end + end +end diff --git a/spec/requests/api/graphql/project/issues_spec.rb b/spec/requests/api/graphql/project/issues_spec.rb index 69e14eace66..596e023a027 100644 --- a/spec/requests/api/graphql/project/issues_spec.rb +++ b/spec/requests/api/graphql/project/issues_spec.rb @@ -223,6 +223,7 @@ RSpec.describe 'getting an issue list for a project' do end describe 'sorting and pagination' do + let_it_be(:sort_project) { create(:project, :public) } let_it_be(:data_path) { [:project, :issues] } def pagination_query(params) @@ -237,8 +238,38 @@ RSpec.describe 'getting an issue list for a project' do data.map { |issue| issue['iid'].to_i } end + context 'when sorting by severity' do + let_it_be(:severty_issue1) { create(:issue, project: sort_project) } + let_it_be(:severty_issue2) { create(:issue, project: sort_project) } + let_it_be(:severty_issue3) { create(:issue, project: sort_project) } + let_it_be(:severty_issue4) { create(:issue, project: sort_project) } + let_it_be(:severty_issue5) { create(:issue, project: sort_project) } + + before(:all) do + create(:issuable_severity, issue: severty_issue1, severity: :unknown) + create(:issuable_severity, issue: severty_issue2, severity: :low) + create(:issuable_severity, issue: severty_issue4, severity: :critical) + create(:issuable_severity, issue: severty_issue5, severity: :high) + end + + context 'when ascending' do + it_behaves_like 'sorted paginated query' do + let(:sort_param) { :SEVERITY_ASC } + let(:first_param) { 2 } + let(:all_records) { [severty_issue3.iid, severty_issue1.iid, severty_issue2.iid, severty_issue5.iid, severty_issue4.iid] } + end + end + + context 'when descending' do + it_behaves_like 'sorted paginated query' do + let(:sort_param) { :SEVERITY_DESC } + let(:first_param) { 2 } + let(:all_records) { [severty_issue4.iid, severty_issue5.iid, severty_issue2.iid, severty_issue1.iid, severty_issue3.iid] } + end + end + end + context 'when sorting by due date' do - let_it_be(:sort_project) { create(:project, :public) } let_it_be(:due_issue1) { create(:issue, project: sort_project, due_date: 3.days.from_now) } let_it_be(:due_issue2) { create(:issue, project: sort_project, due_date: nil) } let_it_be(:due_issue3) { create(:issue, project: sort_project, due_date: 2.days.ago) } @@ -263,7 +294,6 @@ RSpec.describe 'getting an issue list for a project' do end context 'when sorting by relative position' do - let_it_be(:sort_project) { create(:project, :public) } let_it_be(:relative_issue1) { create(:issue, project: sort_project, relative_position: 2000) } let_it_be(:relative_issue2) { create(:issue, project: sort_project, relative_position: nil) } let_it_be(:relative_issue3) { create(:issue, project: sort_project, relative_position: 1000) } @@ -285,7 +315,6 @@ RSpec.describe 'getting an issue list for a project' do end context 'when sorting by priority' do - let_it_be(:sort_project) { create(:project, :public) } let_it_be(:on_project) { { project: sort_project } } let_it_be(:early_milestone) { create(:milestone, **on_project, due_date: 10.days.from_now) } let_it_be(:late_milestone) { create(:milestone, **on_project, due_date: 30.days.from_now) } @@ -321,7 +350,6 @@ RSpec.describe 'getting an issue list for a project' do end context 'when sorting by label priority' do - let_it_be(:sort_project) { create(:project, :public) } let_it_be(:label1) { create(:label, project: sort_project, priority: 1) } let_it_be(:label2) { create(:label, project: sort_project, priority: 5) } let_it_be(:label3) { create(:label, project: sort_project, priority: 10) } @@ -348,7 +376,6 @@ RSpec.describe 'getting an issue list for a project' do end context 'when sorting by milestone due date' do - let_it_be(:sort_project) { create(:project, :public) } let_it_be(:early_milestone) { create(:milestone, project: sort_project, due_date: 10.days.from_now) } let_it_be(:late_milestone) { create(:milestone, project: sort_project, due_date: 30.days.from_now) } let_it_be(:milestone_issue1) { create(:issue, project: sort_project) } diff --git a/spec/requests/projects/google_cloud/configuration_controller_spec.rb b/spec/requests/projects/google_cloud/configuration_controller_spec.rb new file mode 100644 index 00000000000..08d4ad2f9ba --- /dev/null +++ b/spec/requests/projects/google_cloud/configuration_controller_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Mock Types +MockGoogleOAuth2Credentials = Struct.new(:app_id, :app_secret) + +RSpec.describe Projects::GoogleCloud::ConfigurationController do + let_it_be(:project) { create(:project, :public) } + let_it_be(:url) { project_google_cloud_configuration_path(project) } + + let_it_be(:user_guest) { create(:user) } + let_it_be(:user_developer) { create(:user) } + let_it_be(:user_maintainer) { create(:user) } + + let_it_be(:unauthorized_members) { [user_guest, user_developer] } + let_it_be(:authorized_members) { [user_maintainer] } + + before do + project.add_guest(user_guest) + project.add_developer(user_developer) + project.add_maintainer(user_maintainer) + end + + context 'when accessed by unauthorized members' do + it 'returns not found on GET request' do + unauthorized_members.each do |unauthorized_member| + sign_in(unauthorized_member) + + get url + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'admin_project_google_cloud!', + label: 'error_access_denied', + property: 'invalid_user', + project: project, + user: unauthorized_member + ) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when accessed by authorized members' do + it 'returns successful' do + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to be_successful + expect(response).to render_template('projects/google_cloud/configuration/index') + end + end + + context 'but gitlab instance is not configured for google oauth2' do + it 'returns forbidden' do + unconfigured_google_oauth2 = MockGoogleOAuth2Credentials.new('', '') + allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for) + .with('google_oauth2') + .and_return(unconfigured_google_oauth2) + + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to have_gitlab_http_status(:forbidden) + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'google_oauth2_enabled!', + label: 'error_access_denied', + extra: { reason: 'google_oauth2_not_configured', + config: unconfigured_google_oauth2 }, + project: project, + user: authorized_member + ) + end + end + end + + context 'but feature flag is disabled' do + before do + stub_feature_flags(incubation_5mp_google_cloud: false) + end + + it 'returns not found' do + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to have_gitlab_http_status(:not_found) + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'feature_flag_enabled!', + label: 'error_access_denied', + property: 'feature_flag_not_enabled', + project: project, + user: authorized_member + ) + end + end + end + + context 'but google oauth2 token is not valid' do + it 'does not return revoke oauth url' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| + allow(client).to receive(:validate_token).and_return(false) + end + + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to be_successful + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'configuration#index', + label: 'success', + extra: { + configurationUrl: project_google_cloud_configuration_path(project), + deploymentsUrl: project_google_cloud_deployments_path(project), + databasesUrl: project_google_cloud_databases_path(project), + serviceAccounts: [], + createServiceAccountUrl: project_google_cloud_service_accounts_path(project), + emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'), + configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project), + gcpRegions: [], + revokeOauthUrl: nil + }, + project: project, + user: authorized_member + ) + end + end + end + end +end diff --git a/spec/requests/projects/google_cloud/databases_controller_spec.rb b/spec/requests/projects/google_cloud/databases_controller_spec.rb new file mode 100644 index 00000000000..c9335f8f317 --- /dev/null +++ b/spec/requests/projects/google_cloud/databases_controller_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# Mock Types +MockGoogleOAuth2Credentials = Struct.new(:app_id, :app_secret) + +RSpec.describe Projects::GoogleCloud::DatabasesController do + let_it_be(:project) { create(:project, :public) } + let_it_be(:url) { project_google_cloud_databases_path(project) } + + let_it_be(:user_guest) { create(:user) } + let_it_be(:user_developer) { create(:user) } + let_it_be(:user_maintainer) { create(:user) } + + let_it_be(:unauthorized_members) { [user_guest, user_developer] } + let_it_be(:authorized_members) { [user_maintainer] } + + before do + project.add_guest(user_guest) + project.add_developer(user_developer) + project.add_maintainer(user_maintainer) + end + + context 'when accessed by unauthorized members' do + it 'returns not found on GET request' do + unauthorized_members.each do |unauthorized_member| + sign_in(unauthorized_member) + + get url + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'admin_project_google_cloud!', + label: 'error_access_denied', + property: 'invalid_user', + project: project, + user: unauthorized_member + ) + + expect(response).to have_gitlab_http_status(:not_found) + end + end + end + + context 'when accessed by authorized members' do + it 'returns successful' do + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to be_successful + expect(response).to render_template('projects/google_cloud/databases/index') + end + end + + context 'but gitlab instance is not configured for google oauth2' do + it 'returns forbidden' do + unconfigured_google_oauth2 = MockGoogleOAuth2Credentials.new('', '') + allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for) + .with('google_oauth2') + .and_return(unconfigured_google_oauth2) + + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to have_gitlab_http_status(:forbidden) + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'google_oauth2_enabled!', + label: 'error_access_denied', + extra: { reason: 'google_oauth2_not_configured', + config: unconfigured_google_oauth2 }, + project: project, + user: authorized_member + ) + end + end + end + + context 'but feature flag is disabled' do + before do + stub_feature_flags(incubation_5mp_google_cloud: false) + end + + it 'returns not found' do + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to have_gitlab_http_status(:not_found) + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'feature_flag_enabled!', + label: 'error_access_denied', + property: 'feature_flag_not_enabled', + project: project, + user: authorized_member + ) + end + end + end + + context 'but google oauth2 token is not valid' do + it 'does not return revoke oauth url' do + allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| + allow(client).to receive(:validate_token).and_return(false) + end + + authorized_members.each do |authorized_member| + sign_in(authorized_member) + + get url + + expect(response).to be_successful + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'databases#index', + label: 'success', + extra: { + configurationUrl: project_google_cloud_configuration_path(project), + deploymentsUrl: project_google_cloud_deployments_path(project), + databasesUrl: project_google_cloud_databases_path(project) + }, + project: project, + user: authorized_member + ) + end + end + end + end +end diff --git a/spec/requests/projects/google_cloud/deployments_controller_spec.rb b/spec/requests/projects/google_cloud/deployments_controller_spec.rb index 7bd9609a7dc..9e854e01516 100644 --- a/spec/requests/projects/google_cloud/deployments_controller_spec.rb +++ b/spec/requests/projects/google_cloud/deployments_controller_spec.rb @@ -9,10 +9,9 @@ RSpec.describe Projects::GoogleCloud::DeploymentsController do let_it_be(:user_guest) { create(:user) } let_it_be(:user_developer) { create(:user) } let_it_be(:user_maintainer) { create(:user) } - let_it_be(:user_creator) { project.creator } let_it_be(:unauthorized_members) { [user_guest, user_developer] } - let_it_be(:authorized_members) { [user_maintainer, user_creator] } + let_it_be(:authorized_members) { [user_maintainer] } let_it_be(:urls_list) { %W[#{project_google_cloud_deployments_cloud_run_path(project)} #{project_google_cloud_deployments_cloud_storage_path(project)}] } @@ -32,7 +31,7 @@ RSpec.describe Projects::GoogleCloud::DeploymentsController do expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'admin_project_google_cloud!', - label: 'access_denied', + label: 'error_access_denied', property: 'invalid_user', project: project, user: nil @@ -51,7 +50,7 @@ RSpec.describe Projects::GoogleCloud::DeploymentsController do expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'admin_project_google_cloud!', - label: 'access_denied', + label: 'error_access_denied', property: 'invalid_user', project: project, user: nil @@ -87,15 +86,15 @@ RSpec.describe Projects::GoogleCloud::DeploymentsController do end end - it 'redirects to google_cloud home on enable service error' do + it 'redirects to google cloud deployments on enable service error' do get url - expect(response).to redirect_to(project_google_cloud_index_path(project)) + expect(response).to redirect_to(project_google_cloud_deployments_path(project)) # since GPC_PROJECT_ID is not set, enable cloud run service should return an error expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'deployments#cloud_run', - label: 'enable_cloud_run_error', + label: 'error_enable_cloud_run', extra: { message: 'No GCP projects found. Configure a service account or GCP_PROJECT_ID ci variable.', status: :error }, project: project, @@ -103,7 +102,7 @@ RSpec.describe Projects::GoogleCloud::DeploymentsController do ) end - it 'redirects to gcp_error' do + it 'redirects to google cloud deployments with error' do mock_gcp_error = Google::Apis::ClientError.new('some_error') allow_next_instance_of(GoogleCloud::EnableCloudRunService) do |service| @@ -112,11 +111,11 @@ RSpec.describe Projects::GoogleCloud::DeploymentsController do get url - expect(response).to render_template(:gcp_error) + expect(response).to redirect_to(project_google_cloud_deployments_path(project)) expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'deployments#cloud_run', - label: 'gcp_error', + label: 'error_gcp', extra: mock_gcp_error, project: project, user: user_maintainer @@ -124,7 +123,7 @@ RSpec.describe Projects::GoogleCloud::DeploymentsController do end context 'GCP_PROJECT_IDs are defined' do - it 'redirects to google_cloud home on generate pipeline error' do + it 'redirects to google_cloud deployments on generate pipeline error' do allow_next_instance_of(GoogleCloud::EnableCloudRunService) do |enable_cloud_run_service| allow(enable_cloud_run_service).to receive(:execute).and_return({ status: :success }) end @@ -135,11 +134,11 @@ RSpec.describe Projects::GoogleCloud::DeploymentsController do get url - expect(response).to redirect_to(project_google_cloud_index_path(project)) + expect(response).to redirect_to(project_google_cloud_deployments_path(project)) expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'deployments#cloud_run', - label: 'generate_pipeline_error', + label: 'error_generate_pipeline', extra: { status: :error }, project: project, user: user_maintainer @@ -162,7 +161,7 @@ RSpec.describe Projects::GoogleCloud::DeploymentsController do expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'deployments#cloud_run', - label: 'cloud_run_success', + label: 'success', extra: { "title": "Enable deployments to Cloud Run", "description": "This merge request includes a Cloud Run deployment job in the pipeline definition (.gitlab-ci.yml).\n\nThe `deploy-to-cloud-run` job:\n* Requires the following environment variables\n * `GCP_PROJECT_ID`\n * `GCP_SERVICE_ACCOUNT_KEY`\n* Job definition can be found at: https://gitlab.com/gitlab-org/incubation-engineering/five-minute-production/library\n\nThis pipeline definition has been committed to the branch ``.\nYou may modify the pipeline definition further or accept the changes as-is if suitable.\n", "source_project_id": project.id, diff --git a/spec/requests/projects/google_cloud/gcp_regions_controller_spec.rb b/spec/requests/projects/google_cloud/gcp_regions_controller_spec.rb index 56474b6520d..f88273080d5 100644 --- a/spec/requests/projects/google_cloud/gcp_regions_controller_spec.rb +++ b/spec/requests/projects/google_cloud/gcp_regions_controller_spec.rb @@ -6,8 +6,8 @@ RSpec.describe Projects::GoogleCloud::GcpRegionsController do let_it_be(:project) { create(:project, :public, :repository) } let_it_be(:repository) { project.repository } - let(:user_guest) { create(:user) } - let(:user_maintainer) { create(:user) } + let_it_be(:user_guest) { create(:user) } + let_it_be(:user_maintainer) { create(:user) } RSpec.shared_examples "should track not_found event" do it "tracks event" do @@ -15,7 +15,7 @@ RSpec.describe Projects::GoogleCloud::GcpRegionsController do expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'admin_project_google_cloud!', - label: 'access_denied', + label: 'error_access_denied', property: 'invalid_user', project: project, user: nil @@ -29,7 +29,7 @@ RSpec.describe Projects::GoogleCloud::GcpRegionsController do expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'admin_project_google_cloud!', - label: 'access_denied', + label: 'error_access_denied', property: 'invalid_user', project: project, user: nil @@ -43,7 +43,7 @@ RSpec.describe Projects::GoogleCloud::GcpRegionsController do expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'feature_flag_enabled!', - label: 'access_denied', + label: 'error_access_denied', property: 'feature_flag_not_enabled', project: project, user: user_maintainer @@ -57,7 +57,7 @@ RSpec.describe Projects::GoogleCloud::GcpRegionsController do expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'google_oauth2_enabled!', - label: 'access_denied', + label: 'error_access_denied', extra: { reason: 'google_oauth2_not_configured', config: config }, project: project, user: user_maintainer @@ -144,8 +144,8 @@ RSpec.describe Projects::GoogleCloud::GcpRegionsController do sign_in(user_maintainer) end - it 'redirects to google cloud index' do - is_expected.to redirect_to(project_google_cloud_index_path(project)) + it 'redirects to google cloud configurations' do + is_expected.to redirect_to(project_google_cloud_configuration_path(project)) end end end diff --git a/spec/requests/projects/google_cloud/revoke_oauth_controller_spec.rb b/spec/requests/projects/google_cloud/revoke_oauth_controller_spec.rb index 07590d3710e..36441a184cb 100644 --- a/spec/requests/projects/google_cloud/revoke_oauth_controller_spec.rb +++ b/spec/requests/projects/google_cloud/revoke_oauth_controller_spec.rb @@ -47,13 +47,13 @@ RSpec.describe Projects::GoogleCloud::RevokeOauthController do post url expect(request.session[GoogleApi::CloudPlatform::Client.session_key_for_token]).to be_nil - expect(response).to redirect_to(project_google_cloud_index_path(project)) + expect(response).to redirect_to(project_google_cloud_configuration_path(project)) expect(flash[:notice]).to eq('Google OAuth2 token revocation requested') expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'revoke_oauth#create', - label: 'create', - property: 'success', + label: 'success', + property: '{}', project: project, user: user ) @@ -70,13 +70,13 @@ RSpec.describe Projects::GoogleCloud::RevokeOauthController do post url expect(request.session[GoogleApi::CloudPlatform::Client.session_key_for_token]).to be_nil - expect(response).to redirect_to(project_google_cloud_index_path(project)) + expect(response).to redirect_to(project_google_cloud_configuration_path(project)) expect(flash[:alert]).to eq('Google OAuth2 token revocation request failed') expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'revoke_oauth#create', - label: 'create', - property: 'failed', + label: 'error', + property: '{}', project: project, user: user ) diff --git a/spec/requests/projects/google_cloud/service_accounts_controller_spec.rb b/spec/requests/projects/google_cloud/service_accounts_controller_spec.rb index 4b32965e2b0..ae2519855db 100644 --- a/spec/requests/projects/google_cloud/service_accounts_controller_spec.rb +++ b/spec/requests/projects/google_cloud/service_accounts_controller_spec.rb @@ -8,13 +8,15 @@ RSpec.describe Projects::GoogleCloud::ServiceAccountsController do describe 'GET index', :snowplow do let_it_be(:url) { "#{project_google_cloud_service_accounts_path(project)}" } - let(:user_guest) { create(:user) } - let(:user_developer) { create(:user) } - let(:user_maintainer) { create(:user) } - let(:user_creator) { project.creator } + let_it_be(:user_guest) { create(:user) } + let_it_be(:user_developer) { create(:user) } + let_it_be(:user_maintainer) { create(:user) } + let_it_be(:user_creator) { project.creator } - let(:unauthorized_members) { [user_guest, user_developer] } - let(:authorized_members) { [user_maintainer, user_creator] } + let_it_be(:unauthorized_members) { [user_guest, user_developer] } + let_it_be(:authorized_members) { [user_maintainer, user_creator] } + + let_it_be(:google_client_error) { Google::Apis::ClientError.new('client-error') } before do project.add_guest(user_guest) @@ -30,7 +32,7 @@ RSpec.describe Projects::GoogleCloud::ServiceAccountsController do expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'admin_project_google_cloud!', - label: 'access_denied', + label: 'error_access_denied', property: 'invalid_user', project: project, user: nil @@ -53,7 +55,7 @@ RSpec.describe Projects::GoogleCloud::ServiceAccountsController do expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'admin_project_google_cloud!', - label: 'access_denied', + label: 'error_access_denied', property: 'invalid_user', project: project, user: unauthorized_member @@ -71,7 +73,7 @@ RSpec.describe Projects::GoogleCloud::ServiceAccountsController do expect_snowplow_event( category: 'Projects::GoogleCloud', action: 'admin_project_google_cloud!', - label: 'access_denied', + label: 'error_access_denied', property: 'invalid_user', project: project, user: unauthorized_member @@ -116,7 +118,7 @@ RSpec.describe Projects::GoogleCloud::ServiceAccountsController do end end - it 'renders no_gcp_projects' do + it 'flashes error and redirects to google cloud configurations' do authorized_members.each do |authorized_member| allow_next_instance_of(BranchesFinder) do |branches_finder| allow(branches_finder).to receive(:execute).and_return([]) @@ -130,7 +132,16 @@ RSpec.describe Projects::GoogleCloud::ServiceAccountsController do get url - expect(response).to render_template('projects/google_cloud/errors/no_gcp_projects') + expect(response).to redirect_to(project_google_cloud_configuration_path(project)) + expect(flash[:warning]).to eq('No Google Cloud projects - You need at least one Google Cloud project') + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'service_accounts#index', + label: 'error_form', + property: 'no_gcp_projects', + project: project, + user: authorized_member + ) end end end @@ -171,7 +182,7 @@ RSpec.describe Projects::GoogleCloud::ServiceAccountsController do post url, params: { gcp_project: 'prj1', ref: 'env1' } - expect(response).to redirect_to(project_google_cloud_index_path(project)) + expect(response).to redirect_to(project_google_cloud_configuration_path(project)) end end end @@ -181,29 +192,47 @@ RSpec.describe Projects::GoogleCloud::ServiceAccountsController do before do allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| allow(client).to receive(:validate_token).and_return(true) - allow(client).to receive(:list_projects).and_raise(Google::Apis::ClientError.new('')) - allow(client).to receive(:create_service_account).and_raise(Google::Apis::ClientError.new('')) - allow(client).to receive(:create_service_account_key).and_raise(Google::Apis::ClientError.new('')) + allow(client).to receive(:list_projects).and_raise(google_client_error) + allow(client).to receive(:create_service_account).and_raise(google_client_error) + allow(client).to receive(:create_service_account_key).and_raise(google_client_error) end end - it 'renders gcp_error template on GET' do + it 'GET flashes error and redirects to -/google_cloud/configurations' do authorized_members.each do |authorized_member| sign_in(authorized_member) get url - expect(response).to render_template(:gcp_error) + expect(response).to redirect_to(project_google_cloud_configuration_path(project)) + expect(flash[:warning]).to eq('Google Cloud Error - client-error') + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'service_accounts#index', + label: 'error_gcp', + extra: google_client_error, + project: project, + user: authorized_member + ) end end - it 'renders gcp_error template on POST' do + it 'POST flashes error and redirects to -/google_cloud/configurations' do authorized_members.each do |authorized_member| sign_in(authorized_member) post url, params: { gcp_project: 'prj1', environment: 'env1' } - expect(response).to render_template(:gcp_error) + expect(response).to redirect_to(project_google_cloud_configuration_path(project)) + expect(flash[:warning]).to eq('Google Cloud Error - client-error') + expect_snowplow_event( + category: 'Projects::GoogleCloud', + action: 'service_accounts#create', + label: 'error_gcp', + extra: google_client_error, + project: project, + user: authorized_member + ) end end end diff --git a/spec/requests/projects/google_cloud_controller_spec.rb b/spec/requests/projects/google_cloud_controller_spec.rb deleted file mode 100644 index d0814990989..00000000000 --- a/spec/requests/projects/google_cloud_controller_spec.rb +++ /dev/null @@ -1,178 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -# Mock Types -MockGoogleOAuth2Credentials = Struct.new(:app_id, :app_secret) - -RSpec.describe Projects::GoogleCloudController do - let_it_be(:project) { create(:project, :public) } - - describe 'GET index', :snowplow do - let_it_be(:url) { "#{project_google_cloud_index_path(project)}" } - - context 'when a public request is made' do - it 'returns not found' do - get url - - expect(response).to have_gitlab_http_status(:not_found) - expect_snowplow_event( - category: 'Projects::GoogleCloud', - action: 'admin_project_google_cloud!', - label: 'access_denied', - property: 'invalid_user', - project: project, - user: nil) - end - end - - context 'when a project.guest makes request' do - let(:user) { create(:user) } - - it 'returns not found' do - project.add_guest(user) - sign_in(user) - - get url - - expect(response).to have_gitlab_http_status(:not_found) - expect_snowplow_event( - category: 'Projects::GoogleCloud', - action: 'admin_project_google_cloud!', - label: 'access_denied', - property: 'invalid_user', - project: project, - user: user - ) - end - end - - context 'when project.developer makes request' do - let(:user) { create(:user) } - - it 'returns not found' do - project.add_developer(user) - sign_in(user) - - get url - - expect(response).to have_gitlab_http_status(:not_found) - expect_snowplow_event( - category: 'Projects::GoogleCloud', - action: 'admin_project_google_cloud!', - label: 'access_denied', - property: 'invalid_user', - project: project, - user: user - ) - end - end - - context 'when project.maintainer makes request' do - let(:user) { create(:user) } - - it 'returns successful' do - project.add_maintainer(user) - sign_in(user) - - get url - - expect(response).to be_successful - end - end - - context 'when project.creator makes request' do - let(:user) { project.creator } - - it 'returns successful' do - sign_in(user) - - get url - - expect(response).to be_successful - end - end - - describe 'when authorized user makes request' do - let(:user) { project.creator } - - context 'but gitlab instance is not configured for google oauth2' do - it 'returns forbidden' do - unconfigured_google_oauth2 = MockGoogleOAuth2Credentials.new('', '') - allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for) - .with('google_oauth2') - .and_return(unconfigured_google_oauth2) - - sign_in(user) - - get url - - expect(response).to have_gitlab_http_status(:forbidden) - expect_snowplow_event( - category: 'Projects::GoogleCloud', - action: 'google_oauth2_enabled!', - label: 'access_denied', - extra: { reason: 'google_oauth2_not_configured', - config: unconfigured_google_oauth2 }, - project: project, - user: user - ) - end - end - - context 'but feature flag is disabled' do - before do - stub_feature_flags(incubation_5mp_google_cloud: false) - end - - it 'returns not found' do - sign_in(user) - - get url - - expect(response).to have_gitlab_http_status(:not_found) - expect_snowplow_event( - category: 'Projects::GoogleCloud', - action: 'feature_flag_enabled!', - label: 'access_denied', - property: 'feature_flag_not_enabled', - project: project, - user: user - ) - end - end - - context 'but google oauth2 token is not valid' do - it 'does not return revoke oauth url' do - allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |client| - allow(client).to receive(:validate_token).and_return(false) - end - - sign_in(user) - - get url - - expect(response).to be_successful - expect_snowplow_event( - category: 'Projects::GoogleCloud', - action: 'google_cloud#index', - label: 'index', - extra: { - screen: 'home', - serviceAccounts: [], - createServiceAccountUrl: project_google_cloud_service_accounts_path(project), - enableCloudRunUrl: project_google_cloud_deployments_cloud_run_path(project), - enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project), - emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'), - configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project), - gcpRegions: [], - revokeOauthUrl: nil - }, - project: project, - user: user - ) - end - end - end - end -end diff --git a/spec/services/google_cloud/gcp_region_add_or_replace_service_spec.rb b/spec/services/google_cloud/gcp_region_add_or_replace_service_spec.rb index e2f5a2e719e..b2cd5632be0 100644 --- a/spec/services/google_cloud/gcp_region_add_or_replace_service_spec.rb +++ b/spec/services/google_cloud/gcp_region_add_or_replace_service_spec.rb @@ -11,7 +11,7 @@ RSpec.describe GoogleCloud::GcpRegionAddOrReplaceService do service.execute('env_2', 'loc_2') service.execute('env_1', 'loc_3') - list = project.variables.reload.filter { |variable| variable.key == Projects::GoogleCloudController::GCP_REGION_CI_VAR_KEY } + list = project.variables.reload.filter { |variable| variable.key == Projects::GoogleCloud::GcpRegionsController::GCP_REGION_CI_VAR_KEY } list = list.sort_by(&:environment_scope) aggregate_failures 'testing list of gcp regions' do diff --git a/spec/services/groups/transfer_service_spec.rb b/spec/services/groups/transfer_service_spec.rb index f0d7f570c19..fbcca215282 100644 --- a/spec/services/groups/transfer_service_spec.rb +++ b/spec/services/groups/transfer_service_spec.rb @@ -439,6 +439,7 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do before do TestEnv.clean_test_path create(:group_member, :owner, group: new_parent_group, user: user) + allow(transfer_service).to receive(:update_project_settings) transfer_service.execute(new_parent_group) end @@ -478,6 +479,11 @@ RSpec.describe Groups::TransferService, :sidekiq_inline do end end + it 'invokes #update_project_settings' do + expect(transfer_service).to have_received(:update_project_settings) + .with(group.projects.pluck(:id)) + end + it_behaves_like 'project namespace path is in sync with project path' do let(:group_full_path) { "#{new_parent_group.path}/#{group.path}" } let(:projects_with_project_namespace) { [project1, project2] } diff --git a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb index a479f3a82ff..e3c2466f807 100644 --- a/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb +++ b/spec/views/layouts/nav/sidebar/_project.html.haml_spec.rb @@ -532,7 +532,7 @@ RSpec.describe 'layouts/nav/sidebar/_project' do describe 'Google Cloud' do it 'has a link to the google cloud page' do render - expect(rendered).to have_link('Google Cloud', href: project_google_cloud_index_path(project)) + expect(rendered).to have_link('Google Cloud', href: project_google_cloud_configuration_path(project)) end describe 'when the user does not have access' do |