Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
adaa4599f1
commit
a3ffaa242b
|
@ -569,9 +569,6 @@ RSpec/ImplicitSubject:
|
|||
RSpec/ReceiveNever:
|
||||
Enabled: false
|
||||
|
||||
RSpec/MissingExampleGroupArgument:
|
||||
Enabled: false
|
||||
|
||||
RSpec/UnspecifiedException:
|
||||
Enabled: false
|
||||
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
---
|
||||
RSpec/MissingExampleGroupArgument:
|
||||
Exclude:
|
||||
- 'ee/spec/controllers/groups/audit_events_controller_spec.rb'
|
||||
- 'ee/spec/services/ee/notification_service_spec.rb'
|
||||
- 'ee/spec/support/shared_examples/controllers/concerns/description_diff_actions_shared_examples.rb'
|
||||
- 'spec/controllers/projects/issues_controller_spec.rb'
|
||||
- 'spec/controllers/projects/merge_requests_controller_spec.rb'
|
||||
- 'spec/factories/projects/ci_feature_usages.rb'
|
||||
- 'spec/lib/gitlab/background_migration/backfill_jira_tracker_deployment_type2_spec.rb'
|
||||
- 'spec/lib/gitlab/git_access_spec.rb'
|
||||
- 'spec/policies/award_emoji_policy_spec.rb'
|
||||
- 'spec/requests/api/graphql/mutations/notes/create/diff_note_spec.rb'
|
||||
- 'spec/requests/api/graphql/mutations/notes/create/image_diff_note_spec.rb'
|
||||
- 'spec/services/notification_service_spec.rb'
|
||||
- 'spec/support/shared_examples/graphql/sorted_paginated_query_shared_examples.rb'
|
|
@ -66,7 +66,7 @@ export default {
|
|||
<div v-if="loading && !error" class="text-center loading">
|
||||
<gl-loading-icon class="mt-5" size="lg" />
|
||||
</div>
|
||||
<notebook-lab v-if="!loading && !error" :notebook="json" code-css-class="code white" />
|
||||
<notebook-lab v-if="!loading && !error" :notebook="json" />
|
||||
<p v-if="error" class="text-center">
|
||||
<span v-if="loadError" ref="loadErrorMessage">{{
|
||||
__('An error occurred while loading the file. Please try again later.')
|
||||
|
|
|
@ -1,11 +0,0 @@
|
|||
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 }),
|
||||
});
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import Vue from 'vue';
|
||||
import Panel from './panel.vue';
|
||||
|
||||
export default () => {
|
||||
const element = document.querySelector('#js-google-cloud-databases');
|
||||
const attrs = JSON.parse(element.getAttribute('data'));
|
||||
return new Vue({
|
||||
el: element,
|
||||
render: (createElement) => createElement(Panel, { attrs }),
|
||||
});
|
||||
};
|
|
@ -0,0 +1,11 @@
|
|||
import Vue from 'vue';
|
||||
import Form from './cloudsql/create_instance_form.vue';
|
||||
|
||||
export default () => {
|
||||
const element = document.querySelector('#js-google-cloud-databases-cloudsql-form');
|
||||
const attrs = JSON.parse(element.getAttribute('data'));
|
||||
return new Vue({
|
||||
el: element,
|
||||
render: (createElement) => createElement(Form, { attrs }),
|
||||
});
|
||||
};
|
|
@ -1,11 +1,15 @@
|
|||
<script>
|
||||
import GoogleCloudMenu from '../components/google_cloud_menu.vue';
|
||||
import IncubationBanner from '../components/incubation_banner.vue';
|
||||
import InstanceTable from './cloudsql/instance_table.vue';
|
||||
import ServiceTable from './service_table.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
IncubationBanner,
|
||||
InstanceTable,
|
||||
GoogleCloudMenu,
|
||||
ServiceTable,
|
||||
},
|
||||
props: {
|
||||
configurationUrl: {
|
||||
|
@ -20,6 +24,26 @@ export default {
|
|||
type: String,
|
||||
required: true,
|
||||
},
|
||||
cloudsqlPostgresUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
cloudsqlMysqlUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
cloudsqlSqlserverUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
cloudsqlInstances: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
emptyIllustrationUrl: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -34,5 +58,19 @@ export default {
|
|||
:deployments-url="deploymentsUrl"
|
||||
:databases-url="databasesUrl"
|
||||
/>
|
||||
|
||||
<service-table
|
||||
alloydb-postgres-url="#"
|
||||
:cloudsql-mysql-url="cloudsqlMysqlUrl"
|
||||
:cloudsql-postgres-url="cloudsqlPostgresUrl"
|
||||
:cloudsql-sqlserver-url="cloudsqlSqlserverUrl"
|
||||
firestore-url="#"
|
||||
memorystore-redis-url="#"
|
||||
/>
|
||||
|
||||
<instance-table
|
||||
:cloudsql-instances="cloudsqlInstances"
|
||||
:empty-illustration-url="emptyIllustrationUrl"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -13,11 +13,6 @@ export default {
|
|||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
codeCssClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
rawInputCode() {
|
||||
|
@ -39,18 +34,12 @@ export default {
|
|||
|
||||
<template>
|
||||
<div class="cell">
|
||||
<code-output
|
||||
:raw-code="rawInputCode"
|
||||
:count="cell.execution_count"
|
||||
:code-css-class="codeCssClass"
|
||||
type="input"
|
||||
/>
|
||||
<code-output :raw-code="rawInputCode" :count="cell.execution_count" type="input" />
|
||||
<output-cell
|
||||
v-if="hasOutput"
|
||||
:count="cell.execution_count"
|
||||
:outputs="outputs"
|
||||
:metadata="cell.metadata"
|
||||
:code-css-class="codeCssClass"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
<script>
|
||||
import Prism from '../../lib/highlight';
|
||||
import CodeBlockHighlighted from '~/vue_shared/components/code_block_highlighted.vue';
|
||||
import Prompt from '../prompt.vue';
|
||||
|
||||
export default {
|
||||
name: 'CodeOutput',
|
||||
components: {
|
||||
CodeBlockHighlighted,
|
||||
Prompt,
|
||||
},
|
||||
props: {
|
||||
|
@ -13,11 +14,6 @@ export default {
|
|||
required: false,
|
||||
default: 0,
|
||||
},
|
||||
codeCssClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
required: true,
|
||||
|
@ -41,22 +37,21 @@ export default {
|
|||
|
||||
return type.charAt(0).toUpperCase() + type.slice(1);
|
||||
},
|
||||
cellCssClass() {
|
||||
return {
|
||||
[this.codeCssClass]: true,
|
||||
'jupyter-notebook-scrolled': this.metadata.scrolled,
|
||||
};
|
||||
maxHeight() {
|
||||
return this.metadata.scrolled ? '20rem' : 'initial';
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
Prism.highlightElement(this.$refs.code);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="type">
|
||||
<prompt :type="promptType" :count="count" />
|
||||
<pre ref="code" :class="cellCssClass" class="language-python" v-text="code"></pre>
|
||||
<code-block-highlighted
|
||||
language="python"
|
||||
:code="code"
|
||||
:max-height="maxHeight"
|
||||
class="gl-border"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -6,11 +6,6 @@ import LatexOutput from './latex.vue';
|
|||
|
||||
export default {
|
||||
props: {
|
||||
codeCssClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
count: {
|
||||
type: Number,
|
||||
required: false,
|
||||
|
@ -96,7 +91,6 @@ export default {
|
|||
:index="index"
|
||||
:raw-code="rawCode(output)"
|
||||
:metadata="metadata"
|
||||
:code-css-class="codeCssClass"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -11,11 +11,6 @@ export default {
|
|||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
codeCssClass: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
cells() {
|
||||
|
@ -52,7 +47,6 @@ export default {
|
|||
v-for="(cell, index) in cells"
|
||||
:key="index"
|
||||
:cell="cell"
|
||||
:code-css-class="codeCssClass"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
import Prism from 'prismjs';
|
||||
import 'prismjs/components/prism-python';
|
||||
import 'prismjs/themes/prism.css';
|
||||
|
||||
export default Prism;
|
|
@ -1,3 +0,0 @@
|
|||
import init from '~/google_cloud/databases/index';
|
||||
|
||||
init();
|
|
@ -0,0 +1,3 @@
|
|||
import init from '~/google_cloud/databases/init_index';
|
||||
|
||||
init();
|
|
@ -0,0 +1,3 @@
|
|||
import init from '~/google_cloud/databases/init_new';
|
||||
|
||||
init();
|
|
@ -1,12 +1,18 @@
|
|||
<script>
|
||||
import { GlAlert, GlSafeHtmlDirective } from '@gitlab/ui';
|
||||
import { GlAlert, GlSprintf, GlLink, GlSafeHtmlDirective } from '@gitlab/ui';
|
||||
import axios from '~/lib/utils/axios_utils';
|
||||
import { helpPagePath } from '~/helpers/help_page_helper';
|
||||
import { __, sprintf } from '~/locale';
|
||||
import ServiceDeskSetting from './service_desk_setting.vue';
|
||||
|
||||
export default {
|
||||
customEmailHelpPath: helpPagePath('/user/project/service_desk.html', {
|
||||
anchor: 'using-a-custom-email-address',
|
||||
}),
|
||||
components: {
|
||||
GlAlert,
|
||||
GlSprintf,
|
||||
GlLink,
|
||||
ServiceDeskSetting,
|
||||
},
|
||||
directives: {
|
||||
|
@ -43,6 +49,9 @@ export default {
|
|||
templates: {
|
||||
default: [],
|
||||
},
|
||||
publicProject: {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
@ -127,6 +136,27 @@ export default {
|
|||
|
||||
<template>
|
||||
<div>
|
||||
<gl-alert
|
||||
v-if="publicProject && isEnabled"
|
||||
class="mb-3"
|
||||
variant="warning"
|
||||
data-testid="public-project-alert"
|
||||
:dismissible="false"
|
||||
>
|
||||
<gl-sprintf
|
||||
:message="
|
||||
__(
|
||||
'This project is public. Non-members can guess the Service Desk email address, because it contains the group and project name. %{linkStart}How do I create a custom email address?%{linkEnd}',
|
||||
)
|
||||
"
|
||||
>
|
||||
<template #link="{ content }">
|
||||
<gl-link :href="$options.customEmailHelpPath" target="_blank">
|
||||
{{ content }}
|
||||
</gl-link>
|
||||
</template>
|
||||
</gl-sprintf>
|
||||
</gl-alert>
|
||||
<gl-alert v-if="isAlertShowing" class="mb-3" :variant="alertVariant" @dismiss="onDismiss">
|
||||
<span v-safe-html="alertMessage"></span>
|
||||
</gl-alert>
|
||||
|
|
|
@ -20,6 +20,7 @@ export default () => {
|
|||
selectedTemplate,
|
||||
selectedFileTemplateProjectId,
|
||||
templates,
|
||||
publicProject,
|
||||
} = el.dataset;
|
||||
|
||||
return new Vue({
|
||||
|
@ -35,6 +36,7 @@ export default () => {
|
|||
selectedTemplate,
|
||||
selectedFileTemplateProjectId: parseInt(selectedFileTemplateProjectId, 10) || null,
|
||||
templates: JSON.parse(templates),
|
||||
publicProject: parseBoolean(publicProject),
|
||||
},
|
||||
render: (createElement) => createElement(ServiceDeskRoot),
|
||||
});
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<script>
|
||||
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
|
||||
import { GlButton, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
import { normalizeHeaders } from '~/lib/utils/common_utils';
|
||||
import { sprintf, __ } from '~/locale';
|
||||
|
@ -8,11 +8,13 @@ import StatusIcon from '../extensions/status_icon.vue';
|
|||
import { EXTENSION_ICONS } from '../../constants';
|
||||
|
||||
const FETCH_TYPE_COLLAPSED = 'collapsed';
|
||||
const FETCH_TYPE_EXPANDED = 'expanded';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
StatusIcon,
|
||||
GlButton,
|
||||
GlLoadingIcon,
|
||||
},
|
||||
directives: {
|
||||
GlTooltip: GlTooltipDirective,
|
||||
|
@ -20,7 +22,7 @@ export default {
|
|||
props: {
|
||||
/**
|
||||
* @param {value.collapsed} Object
|
||||
* @param {value.extended} Object
|
||||
* @param {value.expanded} Object
|
||||
*/
|
||||
value: {
|
||||
type: Object,
|
||||
|
@ -40,7 +42,7 @@ export default {
|
|||
type: Function,
|
||||
required: true,
|
||||
},
|
||||
fetchExtendedData: {
|
||||
fetchExpandedData: {
|
||||
type: Function,
|
||||
required: false,
|
||||
default: undefined,
|
||||
|
@ -79,8 +81,10 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
isExpandedForTheFirstTime: true,
|
||||
isCollapsed: true,
|
||||
isLoading: false,
|
||||
isLoadingExpandedContent: false,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
|
@ -111,6 +115,22 @@ export default {
|
|||
methods: {
|
||||
toggleCollapsed() {
|
||||
this.isCollapsed = !this.isCollapsed;
|
||||
|
||||
if (this.isExpandedForTheFirstTime && typeof this.fetchExpandedData === 'function') {
|
||||
this.isExpandedForTheFirstTime = false;
|
||||
this.fetchExpandedContent();
|
||||
}
|
||||
},
|
||||
async fetchExpandedContent() {
|
||||
this.isLoadingExpandedContent = true;
|
||||
|
||||
try {
|
||||
await this.fetch(this.fetchExpandedData, FETCH_TYPE_EXPANDED);
|
||||
} catch {
|
||||
this.error = this.errorText;
|
||||
}
|
||||
|
||||
this.isLoadingExpandedContent = false;
|
||||
},
|
||||
fetch(handler, dataType) {
|
||||
const requests = this.multiPolling ? handler() : [handler];
|
||||
|
@ -161,7 +181,6 @@ export default {
|
|||
<slot v-if="!error" name="summary">{{ isLoading ? loadingText : summary }}</slot>
|
||||
<span v-else>{{ error }}</span>
|
||||
</div>
|
||||
<!-- actions will go here -->
|
||||
<div
|
||||
v-if="isCollapsible"
|
||||
class="gl-border-l-1 gl-border-l-solid gl-border-gray-100 gl-ml-3 gl-pl-3 gl-h-6"
|
||||
|
@ -185,7 +204,10 @@ export default {
|
|||
class="mr-widget-grouped-section gl-relative"
|
||||
data-testid="widget-extension-collapsed-section"
|
||||
>
|
||||
<slot name="content">{{ content }}</slot>
|
||||
<div v-if="isLoadingExpandedContent" class="report-block-container gl-text-center">
|
||||
<gl-loading-icon size="sm" inline /> {{ __('Loading...') }}
|
||||
</div>
|
||||
<slot v-else name="content">{{ content }}</slot>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import CodeBlock from './code_block.vue';
|
||||
|
||||
export default {
|
||||
component: CodeBlock,
|
||||
title: 'vue_shared/components/code_block',
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
components: { CodeBlock },
|
||||
props: Object.keys(argTypes),
|
||||
template: '<code-block v-bind="$props" />',
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
// eslint-disable-next-line @gitlab/require-i18n-strings
|
||||
code: `git commit -a "Message"\ngit push`,
|
||||
};
|
|
@ -4,7 +4,8 @@ export default {
|
|||
props: {
|
||||
code: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
maxHeight: {
|
||||
type: String,
|
||||
|
@ -32,5 +33,5 @@ export default {
|
|||
class="code-block rounded code"
|
||||
:class="$options.userColorScheme"
|
||||
:style="styleObject"
|
||||
><code class="d-block">{{ code }}</code></pre>
|
||||
><slot><code class="d-block">{{ code }}</code></slot></pre>
|
||||
</template>
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
import CodeBlockHighlighted from './code_block_highlighted.vue';
|
||||
|
||||
export default {
|
||||
component: CodeBlockHighlighted,
|
||||
title: 'vue_shared/components/code_block_highlighted',
|
||||
};
|
||||
|
||||
const Template = (args, { argTypes }) => ({
|
||||
components: { CodeBlockHighlighted },
|
||||
props: Object.keys(argTypes),
|
||||
template: '<code-block-highlighted v-bind="$props" />',
|
||||
});
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = {
|
||||
code: `const foo = 1;\nconsole.log(foo + ' yay')`,
|
||||
language: 'javascript',
|
||||
};
|
|
@ -0,0 +1,72 @@
|
|||
<script>
|
||||
import { GlSafeHtmlDirective } from '@gitlab/ui';
|
||||
|
||||
import languageLoader from '~/content_editor/services/highlight_js_language_loader';
|
||||
import CodeBlock from './code_block.vue';
|
||||
|
||||
export default {
|
||||
name: 'CodeBlockHighlighted',
|
||||
directives: {
|
||||
SafeHtml: GlSafeHtmlDirective,
|
||||
},
|
||||
components: {
|
||||
CodeBlock,
|
||||
},
|
||||
props: {
|
||||
code: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
language: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
maxHeight: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: 'initial',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hljs: null,
|
||||
languageLoaded: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
highlighted() {
|
||||
if (this.hljs && this.languageLoaded) {
|
||||
return this.hljs.highlight(this.code, { language: this.language }).value;
|
||||
}
|
||||
|
||||
return this.code;
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.hljs = await this.loadHighlightJS();
|
||||
if (this.language) {
|
||||
await this.loadLanguage();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async loadLanguage() {
|
||||
try {
|
||||
const { default: languageDefinition } = await languageLoader[this.language]();
|
||||
|
||||
this.hljs.registerLanguage(this.language, languageDefinition);
|
||||
this.languageLoaded = true;
|
||||
} catch (e) {
|
||||
this.$emit('error', e);
|
||||
}
|
||||
},
|
||||
loadHighlightJS() {
|
||||
return import('highlight.js/lib/core');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<template>
|
||||
<code-block :max-height="maxHeight" class="highlight">
|
||||
<span v-safe-html="highlighted"></span>
|
||||
</code-block>
|
||||
</template>
|
|
@ -471,11 +471,6 @@ span.idiff {
|
|||
}
|
||||
}
|
||||
|
||||
.jupyter-notebook-scrolled {
|
||||
overflow-y: auto;
|
||||
max-height: 20rem;
|
||||
}
|
||||
|
||||
#js-openapi-viewer {
|
||||
pre.version,
|
||||
code {
|
||||
|
|
|
@ -3,14 +3,138 @@
|
|||
module Projects
|
||||
module GoogleCloud
|
||||
class DatabasesController < Projects::GoogleCloud::BaseController
|
||||
before_action :validate_gcp_token!
|
||||
before_action :validate_product, only: :new
|
||||
|
||||
def index
|
||||
js_data = {
|
||||
configurationUrl: project_google_cloud_configuration_path(project),
|
||||
deploymentsUrl: project_google_cloud_deployments_path(project),
|
||||
databasesUrl: project_google_cloud_databases_path(project)
|
||||
databasesUrl: project_google_cloud_databases_path(project),
|
||||
cloudsqlPostgresUrl: new_project_google_cloud_database_path(project, :postgres),
|
||||
cloudsqlMysqlUrl: new_project_google_cloud_database_path(project, :mysql),
|
||||
cloudsqlSqlserverUrl: new_project_google_cloud_database_path(project, :sqlserver),
|
||||
cloudsqlInstances: ::GoogleCloud::GetCloudsqlInstancesService.new(project).execute,
|
||||
emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg')
|
||||
}
|
||||
@js_data = js_data.to_json
|
||||
track_event('databases#index', 'success', js_data)
|
||||
|
||||
track_event('databases#index', 'success', nil)
|
||||
end
|
||||
|
||||
def new
|
||||
product = permitted_params[:product].to_sym
|
||||
|
||||
@title = title(product)
|
||||
|
||||
@js_data = {
|
||||
gcpProjects: gcp_projects,
|
||||
refs: refs,
|
||||
cancelPath: project_google_cloud_databases_path(project),
|
||||
formTitle: form_title(product),
|
||||
formDescription: description(product),
|
||||
databaseVersions: Projects::GoogleCloud::CloudsqlHelper::VERSIONS[product],
|
||||
tiers: Projects::GoogleCloud::CloudsqlHelper::TIERS
|
||||
}.to_json
|
||||
|
||||
render template: 'projects/google_cloud/databases/cloudsql_form', formats: :html
|
||||
end
|
||||
|
||||
def create
|
||||
enable_response = ::GoogleCloud::EnableCloudsqlService
|
||||
.new(project, current_user, enable_service_params)
|
||||
.execute
|
||||
|
||||
if enable_response[:status] == :error
|
||||
track_event('databases#cloudsql_create', 'error_enable_cloudsql_service', enable_response)
|
||||
flash[:error] = error_message(enable_response[:message])
|
||||
else
|
||||
permitted_params = params.permit(:gcp_project, :ref, :database_version, :tier)
|
||||
create_response = ::GoogleCloud::CreateCloudsqlInstanceService
|
||||
.new(project, current_user, create_service_params(permitted_params))
|
||||
.execute
|
||||
|
||||
if create_response[:status] == :error
|
||||
track_event('databases#cloudsql_create', 'error_create_cloudsql_instance', create_response)
|
||||
flash[:warning] = error_message(create_response[:message])
|
||||
else
|
||||
track_event('databases#cloudsql_create', 'success', nil)
|
||||
flash[:notice] = success_message
|
||||
end
|
||||
end
|
||||
|
||||
redirect_to project_google_cloud_databases_path(project)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def enable_service_params
|
||||
{ google_oauth2_token: token_in_session }
|
||||
end
|
||||
|
||||
def create_service_params(permitted_params)
|
||||
{
|
||||
google_oauth2_token: token_in_session,
|
||||
gcp_project_id: permitted_params[:gcp_project],
|
||||
environment_name: permitted_params[:ref],
|
||||
database_version: permitted_params[:database_version],
|
||||
tier: permitted_params[:tier]
|
||||
}
|
||||
end
|
||||
|
||||
def error_message(message)
|
||||
format(s_("CloudSeed|Google Cloud Error - %{message}"), message: message)
|
||||
end
|
||||
|
||||
def success_message
|
||||
s_('CloudSeed|Cloud SQL instance creation request successful. Expected resolution time is ~5 minutes.')
|
||||
end
|
||||
|
||||
def validate_product
|
||||
not_found unless permitted_params[:product].in?(%w[postgres mysql sqlserver])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:product)
|
||||
end
|
||||
|
||||
def title(product)
|
||||
case product
|
||||
when :postgres
|
||||
s_('CloudSeed|Create Postgres Instance')
|
||||
when :mysql
|
||||
s_('CloudSeed|Create MySQL Instance')
|
||||
else
|
||||
s_('CloudSeed|Create MySQL Instance')
|
||||
end
|
||||
end
|
||||
|
||||
def form_title(product)
|
||||
case product
|
||||
when :postgres
|
||||
s_('CloudSeed|Cloud SQL for Postgres')
|
||||
when :mysql
|
||||
s_('CloudSeed|Cloud SQL for MySQL')
|
||||
else
|
||||
s_('CloudSeed|Cloud SQL for SQL Server')
|
||||
end
|
||||
end
|
||||
|
||||
def description(product)
|
||||
case product
|
||||
when :postgres
|
||||
s_('CloudSeed|Cloud SQL instances are fully managed, relational PostgreSQL databases. '\
|
||||
'Google handles replication, patch management, and database management '\
|
||||
'to ensure availability and performance.')
|
||||
when :mysql
|
||||
s_('Cloud SQL instances are fully managed, relational MySQL databases. '\
|
||||
'Google handles replication, patch management, and database management '\
|
||||
'to ensure availability and performance.')
|
||||
else
|
||||
s_('Cloud SQL instances are fully managed, relational SQL Server databases. ' \
|
||||
'Google handles replication, patch management, and database management ' \
|
||||
'to ensure availability and performance.')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -9,8 +9,8 @@
|
|||
# updated_before: DateTime
|
||||
# finished_after: DateTime
|
||||
# finished_before: DateTime
|
||||
# environment: String
|
||||
# status: String (see Deployment.statuses)
|
||||
# environment: String (name) or Integer (ID)
|
||||
# status: String or Array<String> (see Deployment.statuses)
|
||||
# order_by: String (see ALLOWED_SORT_VALUES constant)
|
||||
# sort: String (asc | desc)
|
||||
class DeploymentsFinder
|
||||
|
@ -33,6 +33,7 @@ class DeploymentsFinder
|
|||
|
||||
def initialize(params = {})
|
||||
@params = params
|
||||
@params[:status] = Array(@params[:status]).map(&:to_s) if @params[:status]
|
||||
|
||||
validate!
|
||||
end
|
||||
|
@ -68,16 +69,25 @@ class DeploymentsFinder
|
|||
raise error if raise_for_inefficient_updated_at_query?
|
||||
end
|
||||
|
||||
if (filter_by_finished_at? && !order_by_finished_at?) || (!filter_by_finished_at? && order_by_finished_at?)
|
||||
raise InefficientQueryError, '`finished_at` filter and `finished_at` sorting must be paired'
|
||||
if filter_by_finished_at? && !order_by_finished_at?
|
||||
raise InefficientQueryError, '`finished_at` filter requires `finished_at` sort.'
|
||||
end
|
||||
|
||||
if order_by_finished_at? && !(filter_by_finished_at? || filter_by_finished_statuses?)
|
||||
raise InefficientQueryError,
|
||||
'`finished_at` sort requires `finished_at` filter or a filter with at least one of the finished statuses.'
|
||||
end
|
||||
|
||||
if filter_by_finished_at? && !filter_by_successful_deployment?
|
||||
raise InefficientQueryError, '`finished_at` filter must be combined with `success` status filter.'
|
||||
end
|
||||
|
||||
if params[:environment].present? && !params[:project].present?
|
||||
raise InefficientQueryError, '`environment` filter must be combined with `project` scope.'
|
||||
if filter_by_environment_name? && !params[:project].present?
|
||||
raise InefficientQueryError, '`environment` name filter must be combined with `project` scope.'
|
||||
end
|
||||
|
||||
if filter_by_finished_statuses? && filter_by_upcoming_statuses?
|
||||
raise InefficientQueryError, 'finished statuses and upcoming statuses must be separately queried.'
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -86,6 +96,8 @@ class DeploymentsFinder
|
|||
params[:project].deployments
|
||||
elsif params[:group].present?
|
||||
::Deployment.for_projects(params[:group].all_projects)
|
||||
elsif filter_by_environment_id?
|
||||
::Deployment.for_environment(params[:environment])
|
||||
else
|
||||
::Deployment.none
|
||||
end
|
||||
|
@ -112,7 +124,7 @@ class DeploymentsFinder
|
|||
end
|
||||
|
||||
def by_environment(items)
|
||||
if params[:project].present? && params[:environment].present?
|
||||
if params[:project].present? && filter_by_environment_name?
|
||||
items.for_environment_name(params[:project], params[:environment])
|
||||
else
|
||||
items
|
||||
|
@ -122,7 +134,7 @@ class DeploymentsFinder
|
|||
def by_status(items)
|
||||
return items unless params[:status].present?
|
||||
|
||||
unless Deployment.statuses.key?(params[:status])
|
||||
unless Deployment.statuses.keys.intersection(params[:status]) == params[:status]
|
||||
raise ArgumentError, "The deployment status #{params[:status]} is invalid"
|
||||
end
|
||||
|
||||
|
@ -165,7 +177,23 @@ class DeploymentsFinder
|
|||
end
|
||||
|
||||
def filter_by_successful_deployment?
|
||||
params[:status].to_s == 'success'
|
||||
params[:status].present? && params[:status].count == 1 && params[:status].first.to_s == 'success'
|
||||
end
|
||||
|
||||
def filter_by_finished_statuses?
|
||||
params[:status].present? && Deployment::FINISHED_STATUSES.map(&:to_s).intersection(params[:status]).any?
|
||||
end
|
||||
|
||||
def filter_by_upcoming_statuses?
|
||||
params[:status].present? && Deployment::UPCOMING_STATUSES.map(&:to_s).intersection(params[:status]).any?
|
||||
end
|
||||
|
||||
def filter_by_environment_name?
|
||||
params[:environment].present? && params[:environment].is_a?(String)
|
||||
end
|
||||
|
||||
def filter_by_environment_id?
|
||||
params[:environment].present? && params[:environment].is_a?(Integer)
|
||||
end
|
||||
|
||||
def order_by_updated_at?
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Resolvers
|
||||
class DeploymentResolver < BaseResolver
|
||||
argument :iid,
|
||||
GraphQL::Types::ID,
|
||||
required: true,
|
||||
description: 'Project-level internal ID of the Deployment.'
|
||||
|
||||
type Types::DeploymentType, null: true
|
||||
|
||||
alias_method :project, :object
|
||||
|
||||
def resolve(iid:)
|
||||
return unless project.present? && project.is_a?(::Project)
|
||||
|
||||
Deployment.for_iid(project, iid)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,39 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Resolvers
|
||||
class DeploymentsResolver < BaseResolver
|
||||
argument :statuses, [Types::DeploymentStatusEnum],
|
||||
description: 'Statuses of the deployments.',
|
||||
required: false,
|
||||
as: :status
|
||||
|
||||
argument :order_by, Types::DeploymentsOrderByInputType,
|
||||
description: 'Order by a specified field.',
|
||||
required: false
|
||||
|
||||
type Types::DeploymentType, null: true
|
||||
|
||||
alias_method :environment, :object
|
||||
|
||||
def resolve(**args)
|
||||
return unless environment.present? && environment.is_a?(::Environment)
|
||||
|
||||
args = transform_args_for_finder(**args)
|
||||
|
||||
# GraphQL BatchLoader shouldn't be used here because pagination query will be inefficient
|
||||
# that fetches thousands of rows before limiting and offsetting.
|
||||
DeploymentsFinder.new(environment: environment.id, **args).execute
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def transform_args_for_finder(**args)
|
||||
if (order_by = args.delete(:order_by))
|
||||
order_by = order_by.to_h.map { |k, v| { order_by: k.to_s, sort: v } }.first
|
||||
args.merge!(order_by)
|
||||
end
|
||||
|
||||
args
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
class DeploymentDetailsType < DeploymentType
|
||||
graphql_name 'DeploymentDetails'
|
||||
description 'The details of the deployment'
|
||||
authorize :read_deployment
|
||||
end
|
||||
end
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
class DeploymentStatusEnum < BaseEnum
|
||||
graphql_name 'DeploymentStatus'
|
||||
description 'All deployment statuses.'
|
||||
|
||||
::Deployment.statuses.each_key do |status|
|
||||
value status.upcase,
|
||||
description: "A deployment that is #{status.tr('_', ' ')}.",
|
||||
value: status
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,54 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
# If you're considering to add a new field in DeploymentType, please follow this guideline:
|
||||
# - If the field is preloadable in batch, define it in DeploymentType.
|
||||
# In this case, you should extend DeploymentsResolver logic to preload the field. Also, add a new test that
|
||||
# fetching the specific field for multiple deployments doesn't cause N+1 query problem.
|
||||
# - If the field is NOT preloadable in batch, define it in DeploymentDetailsType.
|
||||
# This type can be only fetched for a single deployment, so you don't need to take care of the preloading.
|
||||
class DeploymentType < BaseObject
|
||||
graphql_name 'Deployment'
|
||||
description 'The deployment of an environment'
|
||||
|
||||
present_using Deployments::DeploymentPresenter
|
||||
|
||||
authorize :read_deployment
|
||||
|
||||
field :id,
|
||||
GraphQL::Types::ID,
|
||||
description: 'Global ID of the deployment.'
|
||||
|
||||
field :iid,
|
||||
GraphQL::Types::ID,
|
||||
description: 'Project-level internal ID of the deployment.'
|
||||
|
||||
field :ref,
|
||||
GraphQL::Types::String,
|
||||
description: 'Git-Ref that the deployment ran on.'
|
||||
|
||||
field :tag,
|
||||
GraphQL::Types::Boolean,
|
||||
description: 'True or false if the deployment ran on a Git-tag.'
|
||||
|
||||
field :sha,
|
||||
GraphQL::Types::String,
|
||||
description: 'Git-SHA that the deployment ran on.'
|
||||
|
||||
field :created_at,
|
||||
Types::TimeType,
|
||||
description: 'When the deployment record was created.'
|
||||
|
||||
field :updated_at,
|
||||
Types::TimeType,
|
||||
description: 'When the deployment record was updated.'
|
||||
|
||||
field :finished_at,
|
||||
Types::TimeType,
|
||||
description: 'When the deployment finished.'
|
||||
|
||||
field :status,
|
||||
Types::DeploymentStatusEnum,
|
||||
description: 'Status of the deployment.'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,24 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
class DeploymentsOrderByInputType < BaseInputObject
|
||||
graphql_name 'DeploymentsOrderByInput'
|
||||
description 'Values for ordering deployments by a specific field'
|
||||
|
||||
argument :created_at,
|
||||
Types::SortDirectionEnum,
|
||||
required: false,
|
||||
description: 'Order by Created time.'
|
||||
|
||||
argument :finished_at,
|
||||
Types::SortDirectionEnum,
|
||||
required: false,
|
||||
description: 'Order by Finished time.'
|
||||
|
||||
def prepare
|
||||
raise GraphQL::ExecutionError, 'orderBy parameter must contain one key-value pair.' unless to_h.size == 1
|
||||
|
||||
super
|
||||
end
|
||||
end
|
||||
end
|
|
@ -29,5 +29,14 @@ module Types
|
|||
Types::AlertManagement::AlertType,
|
||||
null: true,
|
||||
description: 'Most severe open alert for the environment. If multiple alerts have equal severity, the most recent is returned.'
|
||||
|
||||
# Setting high complexity for preventing users from querying deployments for multiple environments,
|
||||
# which could result in N+1 issue.
|
||||
field :deployments,
|
||||
Types::DeploymentType.connection_type,
|
||||
null: true,
|
||||
description: 'Deployments of the environment.',
|
||||
resolver: Resolvers::DeploymentsResolver,
|
||||
complexity: 150
|
||||
end
|
||||
end
|
||||
|
|
|
@ -179,6 +179,12 @@ module Types
|
|||
description: 'A single environment of the project.',
|
||||
resolver: Resolvers::EnvironmentsResolver.single
|
||||
|
||||
field :deployment,
|
||||
Types::DeploymentDetailsType,
|
||||
null: true,
|
||||
description: 'Details of the deployment of the project.',
|
||||
resolver: Resolvers::DeploymentResolver.single
|
||||
|
||||
field :issue,
|
||||
Types::IssueType,
|
||||
null: true,
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Types
|
||||
class SortDirectionEnum < BaseEnum
|
||||
graphql_name 'SortDirectionEnum'
|
||||
description 'Values for sort direction'
|
||||
|
||||
value 'ASC', 'Ascending order.', value: 'asc'
|
||||
value 'DESC', 'Descending order.', value: 'desc'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
module Projects
|
||||
module GoogleCloud
|
||||
module CloudsqlHelper
|
||||
# Sources:
|
||||
# - https://cloud.google.com/sql/docs/postgres/instance-settings
|
||||
# - https://cloud.google.com/sql/docs/mysql/instance-settings
|
||||
# - https://cloud.google.com/sql/docs/sqlserver/instance-settings
|
||||
|
||||
TIERS = [
|
||||
{ value: 'db-custom-1-3840', label: '1 vCPU, 3840 MB RAM - Standard' },
|
||||
{ value: 'db-custom-2-7680', label: '2 vCPU, 7680 MB RAM - Standard' },
|
||||
{ value: 'db-custom-2-13312', label: '2 vCPU, 13312 MB RAM - High memory' },
|
||||
{ value: 'db-custom-4-15360', label: '4 vCPU, 15360 MB RAM - Standard' },
|
||||
{ value: 'db-custom-4-26624', label: '4 vCPU, 26624 MB RAM - High memory' },
|
||||
{ value: 'db-custom-8-30720', label: '8 vCPU, 30720 MB RAM - Standard' },
|
||||
{ value: 'db-custom-8-53248', label: '8 vCPU, 53248 MB RAM - High memory' },
|
||||
{ value: 'db-custom-16-61440', label: '16 vCPU, 61440 MB RAM - Standard' },
|
||||
{ value: 'db-custom-16-106496', label: '16 vCPU, 106496 MB RAM - High memory' },
|
||||
{ value: 'db-custom-32-122880', label: '32 vCPU, 122880 MB RAM - Standard' },
|
||||
{ value: 'db-custom-32-212992', label: '32 vCPU, 212992 MB RAM - High memory' },
|
||||
{ value: 'db-custom-64-245760', label: '64 vCPU, 245760 MB RAM - Standard' },
|
||||
{ value: 'db-custom-64-425984', label: '64 vCPU, 425984 MB RAM - High memory' },
|
||||
{ value: 'db-custom-96-368640', label: '96 vCPU, 368640 MB RAM - Standard' },
|
||||
{ value: 'db-custom-96-638976', label: '96 vCPU, 638976 MB RAM - High memory' }
|
||||
].freeze
|
||||
|
||||
VERSIONS = {
|
||||
postgres: [
|
||||
{ value: 'POSTGRES_14', label: 'PostgreSQL 14' },
|
||||
{ value: 'POSTGRES_13', label: 'PostgreSQL 13' },
|
||||
{ value: 'POSTGRES_12', label: 'PostgreSQL 12' },
|
||||
{ value: 'POSTGRES_11', label: 'PostgreSQL 11' },
|
||||
{ value: 'POSTGRES_10', label: 'PostgreSQL 10' },
|
||||
{ value: 'POSTGRES_9_6', label: 'PostgreSQL 9.6' }
|
||||
],
|
||||
mysql: [
|
||||
{ value: 'MYSQL_8_0', label: 'MySQL 8' },
|
||||
{ value: 'MYSQL_5_7', label: 'MySQL 5.7' },
|
||||
{ value: 'MYSQL_5_6', label: 'MySQL 5.6' }
|
||||
],
|
||||
sqlserver: [
|
||||
{ value: 'SQLSERVER_2017_STANDARD', label: 'SQL Server 2017 Standard' },
|
||||
{ value: 'SQLSERVER_2017_ENTERPRISE', label: 'SQL Server 2017 Enterprise' },
|
||||
{ value: 'SQLSERVER_2017_EXPRESS', label: 'SQL Server 2017 Express' },
|
||||
{ value: 'SQLSERVER_2017_WEB', label: 'SQL Server 2017 Web' },
|
||||
{ value: 'SQLSERVER_2019_STANDARD', label: 'SQL Server 2019 Standard' },
|
||||
{ value: 'SQLSERVER_2019_ENTERPRISE', label: 'SQL Server 2019 Enterprise' },
|
||||
{ value: 'SQLSERVER_2019_EXPRESS', label: 'SQL Server 2019 Express' },
|
||||
{ value: 'SQLSERVER_2019_WEB', label: 'SQL Server 2019 Web' }
|
||||
]
|
||||
}.freeze
|
||||
end
|
||||
end
|
||||
end
|
|
@ -36,6 +36,7 @@ class Deployment < ApplicationRecord
|
|||
delegate :name, to: :environment, prefix: true
|
||||
delegate :kubernetes_namespace, to: :deployment_cluster, allow_nil: true
|
||||
|
||||
scope :for_iid, -> (project, iid) { where(project: project, iid: iid) }
|
||||
scope :for_environment, -> (environment) { where(environment_id: environment) }
|
||||
scope :for_environment_name, -> (project, name) do
|
||||
where('deployments.environment_id = (?)',
|
||||
|
@ -61,6 +62,7 @@ class Deployment < ApplicationRecord
|
|||
|
||||
VISIBLE_STATUSES = %i[running success failed canceled blocked].freeze
|
||||
FINISHED_STATUSES = %i[success failed canceled].freeze
|
||||
UPCOMING_STATUSES = %i[created blocked running].freeze
|
||||
|
||||
state_machine :status, initial: :created do
|
||||
event :run do
|
||||
|
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Deployments
|
||||
class DeploymentPresenter < Gitlab::View::Presenter::Delegated
|
||||
presents ::Deployment, as: :deployment
|
||||
end
|
||||
end
|
|
@ -11,7 +11,7 @@ module GoogleCloud
|
|||
trigger_instance_setup_worker
|
||||
success
|
||||
rescue Google::Apis::Error => err
|
||||
error(err.to_json)
|
||||
error(err.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -12,6 +12,8 @@ module GoogleCloud
|
|||
end
|
||||
|
||||
success({ gcp_project_ids: unique_gcp_project_ids })
|
||||
rescue Google::Apis::Error => err
|
||||
error(err.message)
|
||||
end
|
||||
|
||||
private
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}",
|
||||
outgoing_name: "#{@project.service_desk_setting&.outgoing_name}",
|
||||
project_key: "#{@project.service_desk_setting&.project_key}",
|
||||
templates: available_service_desk_templates_for(@project) } }
|
||||
templates: available_service_desk_templates_for(@project),
|
||||
public_project: "#{@project.public?}" } }
|
||||
- elsif show_callout?('promote_service_desk_dismissed')
|
||||
= render 'shared/promotions/promote_servicedesk'
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project)
|
||||
- add_to_breadcrumbs s_('CloudSeed|Databases'), project_google_cloud_databases_path(@project)
|
||||
- breadcrumb_title @title
|
||||
- page_title @title
|
||||
|
||||
- @content_class = "limit-container-width" unless fluid_layout
|
||||
|
||||
= form_tag project_google_cloud_databases_path(@project), method: 'post' do
|
||||
#js-google-cloud-databases-cloudsql-form{ data: @js_data }
|
|
@ -1,5 +1,5 @@
|
|||
- add_to_breadcrumbs _('Google Cloud'), project_google_cloud_path(@project)
|
||||
- breadcrumb_title _('CloudSeed|Regions')
|
||||
- breadcrumb_title s_('CloudSeed|Regions')
|
||||
- page_title s_('CloudSeed|Regions')
|
||||
|
||||
- @content_class = "limit-container-width" unless fluid_layout
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
- user = local_assigns.fetch(:user, current_user)
|
||||
- access = user&.max_member_access_for_group(group.id)
|
||||
|
||||
%li.group-row.py-3.gl-align-items-center{ class: "gl-display-flex!#{' no-description' if group.description.blank?}" }
|
||||
%li.group-row.py-3.gl-align-items-center{ class: "gl-display-flex!" }
|
||||
.avatar-container.rect-avatar.s40.gl-flex-shrink-0
|
||||
= link_to group do
|
||||
= group_icon(group, class: "avatar s40")
|
||||
|
|
|
@ -8,30 +8,15 @@ module GoogleCloud
|
|||
feature_category :not_owned # rubocop:disable Gitlab/AvoidFeatureCategoryNotOwned
|
||||
idempotent!
|
||||
|
||||
def perform(user_id, project_id, options = {})
|
||||
def perform(user_id, project_id, params = {})
|
||||
user = User.find(user_id)
|
||||
project = Project.find(project_id)
|
||||
params = params.with_indifferent_access
|
||||
|
||||
google_oauth2_token = options[:google_oauth2_token]
|
||||
gcp_project_id = options[:gcp_project_id]
|
||||
instance_name = options[:instance_name]
|
||||
database_version = options[:database_version]
|
||||
environment_name = options[:environment_name]
|
||||
is_protected = options[:is_protected]
|
||||
|
||||
params = {
|
||||
google_oauth2_token: google_oauth2_token,
|
||||
gcp_project_id: gcp_project_id,
|
||||
instance_name: instance_name,
|
||||
database_version: database_version,
|
||||
environment_name: environment_name,
|
||||
is_protected: is_protected
|
||||
}
|
||||
|
||||
response = GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute
|
||||
response = ::GoogleCloud::SetupCloudsqlInstanceService.new(project, user, params).execute
|
||||
|
||||
if response[:status] == :error
|
||||
raise response[:message]
|
||||
raise "Error SetupCloudsqlInstanceService: #{response.to_json}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
---
|
||||
name: ci_forked_source_public_cost_factor
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/94870
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/370147
|
||||
milestone: '15.4'
|
||||
type: development
|
||||
group: group::pipeline execution
|
||||
default_enabled: false
|
|
@ -1,7 +1,7 @@
|
|||
---
|
||||
name: incubation_5mp_google_cloud
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70715
|
||||
rollout_issue_url:
|
||||
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/371332
|
||||
milestone: '14.3'
|
||||
type: development
|
||||
group: group::incubation
|
||||
|
|
|
@ -312,7 +312,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
|
|||
get '/deployments/cloud_run', to: 'deployments#cloud_run'
|
||||
get '/deployments/cloud_storage', to: 'deployments#cloud_storage'
|
||||
|
||||
get '/databases', to: 'databases#index'
|
||||
resources :databases, only: [:index, :create, :new], path_names: { new: 'new/:product' }
|
||||
end
|
||||
|
||||
resources :environments, except: [:destroy] do
|
||||
|
|
|
@ -0,0 +1,70 @@
|
|||
- name: "Create tasks in issues"
|
||||
description: |
|
||||
Tasks provide a robust way to refine an issue into smaller, discrete work units. Previously in GitLab, you could break down an issue into smaller parts using markdown checklists within the description. However, these checklist items could not be easily assigned, labeled, or managed anywhere outside of the description field.
|
||||
|
||||
You can now create tasks within issues from the Child Items widget. Then, you can open the task directly within the issue to quickly update the title, set the weight, or add a description. Tasks break down work within projects for GitLab Free and increase the planning hierarchy for our GitLab Premium customers to three levels (epic, issue, and task). In our next iteration, you will be able to add labels, milestones, and iterations to each task.
|
||||
|
||||
Tasks represent our first step toward evolving issues, epics, incidents, requirements, and test cases to [work items](https://docs.gitlab.com/ee/development/work_items.html). If you have feedback or suggestions about tasks, please comment on [this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/363613).
|
||||
stage: plan
|
||||
self-managed: true
|
||||
gitlab-com: true
|
||||
available_in: [Free, Premium, Ultimate]
|
||||
documentation_link: https://docs.gitlab.com/ee/user/tasks.html
|
||||
image_url: https://about.gitlab.com/images/unreleased/create-tasks.gif
|
||||
published_at: 2022-08-22
|
||||
release: 15.3
|
||||
- name: "GitOps features are now free"
|
||||
description: |
|
||||
When you use GitOps to update a Kubernetes cluster, also called a pull-based deployment, you get an improved security model, better scalability and stability.
|
||||
|
||||
The GitLab agent for Kubernetes has supported [GitOps workflows](https://docs.gitlab.com/ee/user/clusters/agent/gitops.html) from its initial release, but until now, the functionality was available only if you had a GitLab Premium or Ultimate subscription. Now if you have a Free subscription, you also get pull-based deployment support. The features available in GitLab Free should serve small, high-trust teams or be suitable to test the agent before upgrading to a higher tier.
|
||||
|
||||
In the future, we plan to add [built-in multi-tenant support](https://gitlab.com/gitlab-org/gitlab/-/issues/337904) for Premium subscriptions. This feature would be similar to the impersonation feature already available for the [CI/CD workflow](https://docs.gitlab.com/ee/user/clusters/agent/ci_cd_workflow.html#restrict-project-and-group-access-by-using-impersonation).
|
||||
stage: configure
|
||||
self-managed: true
|
||||
gitlab-com: true
|
||||
available_in: [Free, Premium, Ultimate]
|
||||
documentation_link: https://docs.gitlab.com/ee/user/clusters/agent/gitops.html
|
||||
image_url: https://img.youtube.com/vi/jgVxOnMfOZA/hqdefault.jpg
|
||||
published_at: 2022-08-22
|
||||
release: 15.3
|
||||
- name: "Submit merge request review with summary comment"
|
||||
description: |
|
||||
When you finish reviewing a merge request, there are probably some common things that you do, like summarizing your review for others or approving the changes if they look good to you. Those common tasks are now quicker and easier: when you submit your review, you can add a summary comment along with any [quick actions](https://docs.gitlab.com/ee/user/project/quick_actions.html) like `/approve`.
|
||||
stage: create
|
||||
self-managed: true
|
||||
gitlab-com: true
|
||||
available_in: [Free, Premium, Ultimate]
|
||||
documentation_link: https://docs.gitlab.com/ee/user/project/merge_requests/reviews/#submit-a-review
|
||||
image_url: https://about.gitlab.com/images/unreleased/create-mr-review-summary.png
|
||||
published_at: 2022-08-22
|
||||
release: 15.3
|
||||
- name: "Define password complexity requirements"
|
||||
description: |
|
||||
GitLab administrators can now define password complexity requirements in addition to minimum password length. For new passwords, you can now require:
|
||||
|
||||
- Numbers.
|
||||
- Uppercase letters.
|
||||
- Lowercase letters.
|
||||
- Symbols.
|
||||
|
||||
Complex passwords are less likely to be compromised, and the ability to configure password complexity requirements helps administrators enforce their password policies.
|
||||
stage: manage
|
||||
self-managed: true
|
||||
gitlab-com: false
|
||||
available_in: [Premium, Ultimate]
|
||||
documentation_link: https://docs.gitlab.com/ee/user/admin_area/settings/sign_up_restrictions.html#password-complexity-requirements
|
||||
image_url: https://about.gitlab.com/images/unreleased/manage-password-complexity-policy.png
|
||||
published_at: 2022-08-22
|
||||
release: 15.3
|
||||
- name: "Maintain SAML Group Links with API"
|
||||
description: |
|
||||
Until now, SAML group links had to be configured in the UI. Now, you can manage SAML group links programmatically using the API so you can automate SAML groups management.
|
||||
stage: manage
|
||||
self-managed: true
|
||||
gitlab-com: true
|
||||
available_in: [Premium, Ultimate]
|
||||
documentation_link: https://docs.gitlab.com/ee/api/groups.html#saml-group-links
|
||||
image_url: https://img.youtube.com/vi/Pft61UFM5LM/hqdefault.jpg
|
||||
published_at: 2022-08-22
|
||||
release: 15.3
|
|
@ -0,0 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddAutoBanUserToApplicationSettings < Gitlab::Database::Migration[2.0]
|
||||
def change
|
||||
add_column :application_settings, :auto_ban_user_on_excessive_projects_download, :boolean,
|
||||
default: false, null: false
|
||||
end
|
||||
end
|
|
@ -0,0 +1 @@
|
|||
a669aca9370ecd086b582164e68366ca459754b26e096301c2dc7121a7e9ab58
|
|
@ -11458,6 +11458,7 @@ CREATE TABLE application_settings (
|
|||
error_tracking_access_token_encrypted text,
|
||||
package_registry_cleanup_policies_worker_capacity integer DEFAULT 2 NOT NULL,
|
||||
deactivate_dormant_users_period integer DEFAULT 90 NOT NULL,
|
||||
auto_ban_user_on_excessive_projects_download boolean DEFAULT false NOT NULL,
|
||||
CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)),
|
||||
CONSTRAINT app_settings_container_registry_pre_import_tags_rate_positive CHECK ((container_registry_pre_import_tags_rate >= (0)::numeric)),
|
||||
CONSTRAINT app_settings_dep_proxy_ttl_policies_worker_capacity_positive CHECK ((dependency_proxy_ttl_group_policy_worker_capacity >= 0)),
|
||||
|
|
|
@ -6984,6 +6984,29 @@ The edge type for [`DependencyProxyManifest`](#dependencyproxymanifest).
|
|||
| <a id="dependencyproxymanifestedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
|
||||
| <a id="dependencyproxymanifestedgenode"></a>`node` | [`DependencyProxyManifest`](#dependencyproxymanifest) | The item at the end of the edge. |
|
||||
|
||||
#### `DeploymentConnection`
|
||||
|
||||
The connection type for [`Deployment`](#deployment).
|
||||
|
||||
##### Fields
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="deploymentconnectionedges"></a>`edges` | [`[DeploymentEdge]`](#deploymentedge) | A list of edges. |
|
||||
| <a id="deploymentconnectionnodes"></a>`nodes` | [`[Deployment]`](#deployment) | A list of nodes. |
|
||||
| <a id="deploymentconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
|
||||
|
||||
#### `DeploymentEdge`
|
||||
|
||||
The edge type for [`Deployment`](#deployment).
|
||||
|
||||
##### Fields
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="deploymentedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
|
||||
| <a id="deploymentedgenode"></a>`node` | [`Deployment`](#deployment) | The item at the end of the edge. |
|
||||
|
||||
#### `DesignAtVersionConnection`
|
||||
|
||||
The connection type for [`DesignAtVersion`](#designatversion).
|
||||
|
@ -10975,6 +10998,42 @@ Group-level Dependency Proxy settings.
|
|||
| ---- | ---- | ----------- |
|
||||
| <a id="dependencyproxysettingenabled"></a>`enabled` | [`Boolean!`](#boolean) | Indicates whether the dependency proxy is enabled for the group. |
|
||||
|
||||
### `Deployment`
|
||||
|
||||
The deployment of an environment.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="deploymentcreatedat"></a>`createdAt` | [`Time`](#time) | When the deployment record was created. |
|
||||
| <a id="deploymentfinishedat"></a>`finishedAt` | [`Time`](#time) | When the deployment finished. |
|
||||
| <a id="deploymentid"></a>`id` | [`ID`](#id) | Global ID of the deployment. |
|
||||
| <a id="deploymentiid"></a>`iid` | [`ID`](#id) | Project-level internal ID of the deployment. |
|
||||
| <a id="deploymentref"></a>`ref` | [`String`](#string) | Git-Ref that the deployment ran on. |
|
||||
| <a id="deploymentsha"></a>`sha` | [`String`](#string) | Git-SHA that the deployment ran on. |
|
||||
| <a id="deploymentstatus"></a>`status` | [`DeploymentStatus`](#deploymentstatus) | Status of the deployment. |
|
||||
| <a id="deploymenttag"></a>`tag` | [`Boolean`](#boolean) | True or false if the deployment ran on a Git-tag. |
|
||||
| <a id="deploymentupdatedat"></a>`updatedAt` | [`Time`](#time) | When the deployment record was updated. |
|
||||
|
||||
### `DeploymentDetails`
|
||||
|
||||
The details of the deployment.
|
||||
|
||||
#### Fields
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="deploymentdetailscreatedat"></a>`createdAt` | [`Time`](#time) | When the deployment record was created. |
|
||||
| <a id="deploymentdetailsfinishedat"></a>`finishedAt` | [`Time`](#time) | When the deployment finished. |
|
||||
| <a id="deploymentdetailsid"></a>`id` | [`ID`](#id) | Global ID of the deployment. |
|
||||
| <a id="deploymentdetailsiid"></a>`iid` | [`ID`](#id) | Project-level internal ID of the deployment. |
|
||||
| <a id="deploymentdetailsref"></a>`ref` | [`String`](#string) | Git-Ref that the deployment ran on. |
|
||||
| <a id="deploymentdetailssha"></a>`sha` | [`String`](#string) | Git-SHA that the deployment ran on. |
|
||||
| <a id="deploymentdetailsstatus"></a>`status` | [`DeploymentStatus`](#deploymentstatus) | Status of the deployment. |
|
||||
| <a id="deploymentdetailstag"></a>`tag` | [`Boolean`](#boolean) | True or false if the deployment ran on a Git-tag. |
|
||||
| <a id="deploymentdetailsupdatedat"></a>`updatedAt` | [`Time`](#time) | When the deployment record was updated. |
|
||||
|
||||
### `Design`
|
||||
|
||||
A single design.
|
||||
|
@ -11406,6 +11465,23 @@ Describes where code is deployed for a project.
|
|||
|
||||
#### Fields with arguments
|
||||
|
||||
##### `Environment.deployments`
|
||||
|
||||
Deployments of the environment.
|
||||
|
||||
Returns [`DeploymentConnection`](#deploymentconnection).
|
||||
|
||||
This field returns a [connection](#connections). It accepts the
|
||||
four standard [pagination arguments](#connection-pagination-arguments):
|
||||
`before: String`, `after: String`, `first: Int`, `last: Int`.
|
||||
|
||||
###### Arguments
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="environmentdeploymentsorderby"></a>`orderBy` | [`DeploymentsOrderByInput`](#deploymentsorderbyinput) | Order by a specified field. |
|
||||
| <a id="environmentdeploymentsstatuses"></a>`statuses` | [`[DeploymentStatus!]`](#deploymentstatus) | Statuses of the deployments. |
|
||||
|
||||
##### `Environment.metricsDashboard`
|
||||
|
||||
Metrics dashboard schema for the environment.
|
||||
|
@ -15838,6 +15914,18 @@ four standard [pagination arguments](#connection-pagination-arguments):
|
|||
| <a id="projectdastsitevalidationsnormalizedtargeturls"></a>`normalizedTargetUrls` | [`[String!]`](#string) | Normalized URL of the target to be scanned. |
|
||||
| <a id="projectdastsitevalidationsstatus"></a>`status` | [`DastSiteValidationStatusEnum`](#dastsitevalidationstatusenum) | Status of the site validation. |
|
||||
|
||||
##### `Project.deployment`
|
||||
|
||||
Details of the deployment of the project.
|
||||
|
||||
Returns [`DeploymentDetails`](#deploymentdetails).
|
||||
|
||||
###### Arguments
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="projectdeploymentiid"></a>`iid` | [`ID!`](#id) | Project-level internal ID of the Deployment. |
|
||||
|
||||
##### `Project.environment`
|
||||
|
||||
A single environment of the project.
|
||||
|
@ -19605,6 +19693,20 @@ Weight of the data visualization palette.
|
|||
| <a id="dependencyproxymanifeststatuspending_destruction"></a>`PENDING_DESTRUCTION` | Dependency proxy manifest has a status of pending_destruction. |
|
||||
| <a id="dependencyproxymanifeststatusprocessing"></a>`PROCESSING` | Dependency proxy manifest has a status of processing. |
|
||||
|
||||
### `DeploymentStatus`
|
||||
|
||||
All deployment statuses.
|
||||
|
||||
| Value | Description |
|
||||
| ----- | ----------- |
|
||||
| <a id="deploymentstatusblocked"></a>`BLOCKED` | A deployment that is blocked. |
|
||||
| <a id="deploymentstatuscanceled"></a>`CANCELED` | A deployment that is canceled. |
|
||||
| <a id="deploymentstatuscreated"></a>`CREATED` | A deployment that is created. |
|
||||
| <a id="deploymentstatusfailed"></a>`FAILED` | A deployment that is failed. |
|
||||
| <a id="deploymentstatusrunning"></a>`RUNNING` | A deployment that is running. |
|
||||
| <a id="deploymentstatusskipped"></a>`SKIPPED` | A deployment that is skipped. |
|
||||
| <a id="deploymentstatussuccess"></a>`SUCCESS` | A deployment that is success. |
|
||||
|
||||
### `DeploymentTier`
|
||||
|
||||
All environment deployment tiers.
|
||||
|
@ -20621,6 +20723,15 @@ Common sort values.
|
|||
| <a id="sortupdated_asc"></a>`updated_asc` **{warning-solid}** | **Deprecated** in 13.5. This was renamed. Use: `UPDATED_ASC`. |
|
||||
| <a id="sortupdated_desc"></a>`updated_desc` **{warning-solid}** | **Deprecated** in 13.5. This was renamed. Use: `UPDATED_DESC`. |
|
||||
|
||||
### `SortDirectionEnum`
|
||||
|
||||
Values for sort direction.
|
||||
|
||||
| Value | Description |
|
||||
| ----- | ----------- |
|
||||
| <a id="sortdirectionenumasc"></a>`ASC` | Ascending order. |
|
||||
| <a id="sortdirectionenumdesc"></a>`DESC` | Descending order. |
|
||||
|
||||
### `TestCaseStatus`
|
||||
|
||||
| Value | Description |
|
||||
|
@ -22375,6 +22486,17 @@ Input type for DastSiteProfile authentication.
|
|||
| <a id="dastsiteprofileauthinputusername"></a>`username` | [`String`](#string) | Username to authenticate with on the target. |
|
||||
| <a id="dastsiteprofileauthinputusernamefield"></a>`usernameField` | [`String`](#string) | Name of username field at the sign-in HTML form. |
|
||||
|
||||
### `DeploymentsOrderByInput`
|
||||
|
||||
Values for ordering deployments by a specific field.
|
||||
|
||||
#### Arguments
|
||||
|
||||
| Name | Type | Description |
|
||||
| ---- | ---- | ----------- |
|
||||
| <a id="deploymentsorderbyinputcreatedat"></a>`createdAt` | [`SortDirectionEnum`](#sortdirectionenum) | Order by Created time. |
|
||||
| <a id="deploymentsorderbyinputfinishedat"></a>`finishedAt` | [`SortDirectionEnum`](#sortdirectionenum) | Order by Finished time. |
|
||||
|
||||
### `DiffImagePositionInput`
|
||||
|
||||
#### Arguments
|
||||
|
|
|
@ -816,7 +816,7 @@ You can't delete archived jobs with the API, but you can
|
|||
|
||||
## Run a job
|
||||
|
||||
Triggers a manual action to start a job.
|
||||
For a job in manual status, trigger an action to start the job.
|
||||
|
||||
```plaintext
|
||||
POST /projects/:id/jobs/:job_id/play
|
||||
|
|
|
@ -385,6 +385,7 @@ listed in the descriptions of the relevant settings.
|
|||
| `max_number_of_repository_downloads` **(ULTIMATE SELF)** | integer | no | Maximum number of unique repositories a user can download in the specified time period before they are banned. Default: 0, Maximum: 10,000 repositories. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87980) in GitLab 15.1. |
|
||||
| `max_number_of_repository_downloads_within_time_period` **(ULTIMATE SELF)** | integer | no | Reporting time period (in seconds). Default: 0, Maximum: 864000 seconds (10 days). [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/87980) in GitLab 15.1. |
|
||||
| `git_rate_limit_users_allowlist` **(ULTIMATE SELF)** | array of strings | no | List of usernames excluded from Git anti-abuse rate limits. Default: `[]`, Maximum: 100 usernames. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/90815) in GitLab 15.2. |
|
||||
| `auto_ban_user_on_excessive_projects_download` **(ULTIMATE SELF)** | boolean | no | When enabled, users will get automatically banned from the application when they download more than the maximum number of unique projects in the time period specified by `max_number_of_repository_downloads` and `max_number_of_repository_downloads_within_time_period` respectively. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/94153) in GitLab 15.4 |
|
||||
| `mirror_available` | boolean | no | Allow repository mirroring to configured by project Maintainers. If disabled, only Administrators can configure repository mirroring. |
|
||||
| `mirror_capacity_threshold` **(PREMIUM)** | integer | no | Minimum capacity to be available before scheduling more mirrors preemptively. |
|
||||
| `mirror_max_capacity` **(PREMIUM)** | integer | no | Maximum number of mirrors that can be synchronizing at the same time. |
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
---
|
||||
stage: Verify
|
||||
group: Pipeline Authoring
|
||||
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
|
||||
---
|
||||
|
||||
# Downstream pipelines **(FREE)**
|
||||
|
||||
A downstream pipeline is any GitLab CI/CD pipeline triggered by another pipeline.
|
||||
A downstream pipeline can be either:
|
||||
|
||||
- A [parent-child pipeline](parent_child_pipelines.md), which is a downstream pipeline triggered
|
||||
in the same project as the first pipeline.
|
||||
- A [multi-project pipeline](multi_project_pipelines.md), which is a downstream pipeline triggered
|
||||
in a different project than the first pipeline.
|
||||
|
||||
Parent-child pipelines and multi-project pipelines can sometimes be used for similar purposes,
|
||||
but there are some key differences.
|
||||
|
||||
Parent-child pipelines:
|
||||
|
||||
- Run under the same project, ref, and commit SHA as the parent pipeline.
|
||||
- Affect the overall status of the ref the pipeline runs against. For example,
|
||||
if a pipeline fails for the main branch, it's common to say that "main is broken".
|
||||
The status of child pipelines don't directly affect the status of the ref, unless the child
|
||||
pipeline is triggered with [`strategy:depend`](../yaml/index.md#triggerstrategy).
|
||||
- Are automatically canceled if the pipeline is configured with [`interruptible`](../yaml/index.md#interruptible)
|
||||
when a new pipeline is created for the same ref.
|
||||
- Display only the parent pipelines in the pipeline index page. Child pipelines are
|
||||
visible when visiting their parent pipeline's page.
|
||||
- Are limited to 2 levels of nesting. A parent pipeline can trigger multiple child pipelines,
|
||||
and those child pipeline can trigger multiple child pipelines (`A -> B -> C`).
|
||||
|
||||
Multi-project pipelines:
|
||||
|
||||
- Are triggered from another pipeline, but the upstream (triggering) pipeline does
|
||||
not have much control over the downstream (triggered) pipeline. However, it can
|
||||
choose the ref of the downstream pipeline, and pass CI/CD variables to it.
|
||||
- Affect the overall status of the ref of the project it runs in, but does not
|
||||
affect the status of the triggering pipeline's ref, unless it was triggered with
|
||||
[`strategy:depend`](../yaml/index.md#triggerstrategy).
|
||||
- Are not automatically canceled in the downstream project when using [`interruptible`](../yaml/index.md#interruptible)
|
||||
if a new pipeline runs for the same ref in the upstream pipeline. They can be
|
||||
automatically canceled if a new pipeline is triggered for the same ref on the downstream project.
|
||||
- Multi-project pipelines are standalone pipelines because they are normal pipelines
|
||||
that happened to be triggered by an external project. They are all visible on the pipeline index page.
|
||||
- Are independent, so there are no nesting limits.
|
||||
|
||||
## View a downstream pipeline
|
||||
|
||||
In the [pipeline graph view](index.md#view-full-pipeline-graph), downstream pipelines display
|
||||
as a list of cards on the right of the graph.
|
||||
|
||||
### Cancel or retry downstream pipelines from the graph view
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/354974) in GitLab 15.0 [with a flag](../../administration/feature_flags.md) named `downstream_retry_action`. Disabled by default.
|
||||
> - [Generally available and feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/357406) in GitLab 15.1.
|
||||
|
||||
To cancel a downstream pipeline that is still running, select **Cancel** (**{cancel}**)
|
||||
on the pipeline's card.
|
||||
|
||||
To retry a failed downstream pipeline, select **Retry** (**{retry}**)
|
||||
on the pipeline's card.
|
||||
|
||||
![downstream pipeline actions](img/downstream_pipeline_actions.png)
|
|
@ -62,40 +62,6 @@ Pipelines can be configured in many different ways:
|
|||
run in the same project and with the same SHA. This pipeline architecture is commonly used for mono-repos.
|
||||
- [Multi-project pipelines](multi_project_pipelines.md) combine pipelines for different projects together.
|
||||
|
||||
### How parent-child pipelines compare to multi-project pipelines
|
||||
|
||||
Parent-child pipelines and multi-project pipelines can sometimes be used for similar
|
||||
purposes, but there are some key differences:
|
||||
|
||||
Parent-child pipelines:
|
||||
|
||||
- Run under the same project, ref, and commit SHA as the parent pipeline.
|
||||
- Affect the overall status of the ref the pipeline runs against. For example,
|
||||
if a pipeline fails for the main branch, it's common to say that "main is broken".
|
||||
The status of child pipelines don't directly affect the status of the ref, unless the child
|
||||
pipeline is triggered with [`strategy:depend`](../yaml/index.md#triggerstrategy).
|
||||
- Are automatically canceled if the pipeline is configured with [`interruptible`](../yaml/index.md#interruptible)
|
||||
when a new pipeline is created for the same ref.
|
||||
- Display only the parent pipelines in the pipeline index page. Child pipelines are
|
||||
visible when visiting their parent pipeline's page.
|
||||
- Are limited to 2 levels of nesting. A parent pipeline can trigger multiple child pipelines,
|
||||
and those child pipeline can trigger multiple child pipelines (`A -> B -> C`).
|
||||
|
||||
Multi-project pipelines:
|
||||
|
||||
- Are triggered from another pipeline, but the upstream (triggering) pipeline does
|
||||
not have much control over the downstream (triggered) pipeline. However, it can
|
||||
choose the ref of the downstream pipeline, and pass CI/CD variables to it.
|
||||
- Affect the overall status of the ref of the project it runs in, but does not
|
||||
affect the status of the triggering pipeline's ref, unless it was triggered with
|
||||
[`strategy:depend`](../yaml/index.md#triggerstrategy).
|
||||
- Are not automatically canceled in the downstream project when using [`interruptible`](../yaml/index.md#interruptible)
|
||||
if a new pipeline runs for the same ref in the upstream pipeline. They can be
|
||||
automatically canceled if a new pipeline is triggered for the same ref on the downstream project.
|
||||
- Multi-project pipelines are standalone pipelines because they are normal pipelines
|
||||
that happened to be triggered by an external project. They are all visible on the pipeline index page.
|
||||
- Are independent, so there are no nesting limits.
|
||||
|
||||
## Configure a pipeline
|
||||
|
||||
Pipelines and their component jobs and stages are defined in the CI/CD pipeline configuration file for each project.
|
||||
|
@ -456,25 +422,6 @@ Pipeline analytics are available on the [**CI/CD Analytics** page](../../user/an
|
|||
Pipeline status and test coverage report badges are available and configurable for each project.
|
||||
For information on adding pipeline badges to projects, see [Pipeline badges](settings.md#pipeline-badges).
|
||||
|
||||
### Downstream pipelines
|
||||
|
||||
In the pipeline graph view, downstream pipelines ([Multi-project pipelines](multi_project_pipelines.md)
|
||||
and [Parent-child pipelines](parent_child_pipelines.md)) display as a list of cards
|
||||
on the right of the graph.
|
||||
|
||||
#### Cancel or retry downstream pipelines from the graph view
|
||||
|
||||
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/354974) in GitLab 15.0 [with a flag](../../administration/feature_flags.md) named `downstream_retry_action`. Disabled by default.
|
||||
> - [Generally available and feature flag removed](https://gitlab.com/gitlab-org/gitlab/-/issues/357406) in GitLab 15.1.
|
||||
|
||||
To cancel a downstream pipeline that is still running, select **Cancel** (**{cancel}**)
|
||||
on the pipeline's card.
|
||||
|
||||
To retry a failed downstream pipeline, select **Retry** (**{retry}**)
|
||||
on the pipeline's card.
|
||||
|
||||
![downstream pipeline actions](img/downstream_pipeline_actions.png)
|
||||
|
||||
## Pipelines API
|
||||
|
||||
GitLab provides API endpoints to:
|
||||
|
|
|
@ -415,5 +415,5 @@ displays to the right of the mini graph.
|
|||
If you have permission to trigger pipelines in the downstream project, you can
|
||||
retry or cancel multi-project pipelines:
|
||||
|
||||
- [In the main graph view](index.md#downstream-pipelines).
|
||||
- [In the main graph view](downstream_pipelines.md#view-a-downstream-pipeline).
|
||||
- From the downstream pipeline's details page.
|
||||
|
|
|
@ -224,5 +224,5 @@ multi-project pipelines:
|
|||
|
||||
You can retry or cancel child pipelines:
|
||||
|
||||
- [In the main graph view](index.md#downstream-pipelines).
|
||||
- [In the main graph view](downstream_pipelines.md#view-a-downstream-pipeline).
|
||||
- In the child pipeline's details page.
|
||||
|
|
|
@ -89,7 +89,7 @@ the paths for the apps that you would like to use in your cluster.
|
|||
By default, each `helmfile.yaml` in these sub-paths has the attribute `installed: true`. This means that every time
|
||||
the pipeline runs, Helmfile tries to either install or update your apps according to the current state of your
|
||||
cluster and Helm releases. If you change this attribute to `installed: false`, Helmfile tries try to uninstall this app
|
||||
from your cluster. [Read more](https://github.com/roboll/helmfile) about how Helmfile works.
|
||||
from your cluster. [Read more](https://helmfile.readthedocs.io/en/latest/) about how Helmfile works.
|
||||
|
||||
### Built-in applications
|
||||
|
||||
|
|
|
@ -8392,6 +8392,12 @@ msgstr ""
|
|||
msgid "Cloud Run"
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud SQL instances are fully managed, relational MySQL databases. Google handles replication, patch management, and database management to ensure availability and performance."
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud SQL instances are fully managed, relational SQL Server databases. Google handles replication, patch management, and database management to ensure availability and performance."
|
||||
msgstr ""
|
||||
|
||||
msgid "Cloud Storage"
|
||||
msgstr ""
|
||||
|
||||
|
@ -8419,12 +8425,24 @@ msgstr ""
|
|||
msgid "CloudSeed|Cloud SQL for SQL Server"
|
||||
msgstr ""
|
||||
|
||||
msgid "CloudSeed|Cloud SQL instance creation request successful. Expected resolution time is ~5 minutes."
|
||||
msgstr ""
|
||||
|
||||
msgid "CloudSeed|Cloud SQL instances are fully managed, relational PostgreSQL databases. Google handles replication, patch management, and database management to ensure availability and performance."
|
||||
msgstr ""
|
||||
|
||||
msgid "CloudSeed|CloudSQL Instance"
|
||||
msgstr ""
|
||||
|
||||
msgid "CloudSeed|Configuration"
|
||||
msgstr ""
|
||||
|
||||
msgid "CloudSeed|Create MySQL Instance"
|
||||
msgstr ""
|
||||
|
||||
msgid "CloudSeed|Create Postgres Instance"
|
||||
msgstr ""
|
||||
|
||||
msgid "CloudSeed|Create cluster"
|
||||
msgstr ""
|
||||
|
||||
|
@ -8479,6 +8497,9 @@ msgstr ""
|
|||
msgid "CloudSeed|Generated database instance is linked to the selected branch or tag"
|
||||
msgstr ""
|
||||
|
||||
msgid "CloudSeed|Google Cloud Error - %{message}"
|
||||
msgstr ""
|
||||
|
||||
msgid "CloudSeed|Google Cloud Project"
|
||||
msgstr ""
|
||||
|
||||
|
@ -40354,6 +40375,9 @@ msgstr ""
|
|||
msgid "This project is not subscribed to any project pipelines."
|
||||
msgstr ""
|
||||
|
||||
msgid "This project is public. Non-members can guess the Service Desk email address, because it contains the group and project name. %{linkStart}How do I create a custom email address?%{linkEnd}"
|
||||
msgstr ""
|
||||
|
||||
msgid "This project manages its dependencies using %{strong_start}%{manager_name}%{strong_end}"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -150,7 +150,6 @@
|
|||
"popper.js": "^1.16.1",
|
||||
"portal-vue": "^2.1.7",
|
||||
"postcss": "8.4.14",
|
||||
"prismjs": "^1.21.0",
|
||||
"prosemirror-markdown": "1.9.1",
|
||||
"prosemirror-model": "^1.18.1",
|
||||
"prosemirror-state": "^1.4.1",
|
||||
|
@ -178,13 +177,13 @@
|
|||
"url-loader": "^4.1.1",
|
||||
"uuid": "8.1.0",
|
||||
"visibilityjs": "^1.2.4",
|
||||
"vue": "^2.7.8",
|
||||
"vue": "^2.7.9",
|
||||
"vue-apollo": "^3.0.7",
|
||||
"vue-loader": "^15.10",
|
||||
"vue-observe-visibility": "^1.0.0",
|
||||
"vue-resize": "^1.0.1",
|
||||
"vue-router": "3.4.9",
|
||||
"vue-template-compiler": "^2.7.8",
|
||||
"vue-template-compiler": "^2.7.9",
|
||||
"vue-virtual-scroll-list": "^1.4.7",
|
||||
"vuedraggable": "^2.23.0",
|
||||
"vuex": "^3.6.2",
|
||||
|
|
|
@ -32,7 +32,17 @@ RSpec.describe DeploymentsFinder do
|
|||
it 'raises an error' do
|
||||
expect { subject }.to raise_error(
|
||||
described_class::InefficientQueryError,
|
||||
'`finished_at` filter and `finished_at` sorting must be paired')
|
||||
'`finished_at` filter requires `finished_at` sort.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when running status filter and finished_at sorting' do
|
||||
let(:params) { { status: :running, order_by: :finished_at } }
|
||||
|
||||
it 'raises an error' do
|
||||
expect { subject }.to raise_error(
|
||||
described_class::InefficientQueryError,
|
||||
'`finished_at` sort requires `finished_at` filter or a filter with at least one of the finished statuses.')
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -52,7 +62,17 @@ RSpec.describe DeploymentsFinder do
|
|||
it 'raises an error' do
|
||||
expect { subject }.to raise_error(
|
||||
described_class::InefficientQueryError,
|
||||
'`environment` filter must be combined with `project` scope.')
|
||||
'`environment` name filter must be combined with `project` scope.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when status filter with mixed finished and upcoming statuses' do
|
||||
let(:params) { { status: [:success, :running] } }
|
||||
|
||||
it 'raises an error' do
|
||||
expect { subject }.to raise_error(
|
||||
described_class::InefficientQueryError,
|
||||
'finished statuses and upcoming statuses must be separately queried.')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -103,6 +123,24 @@ RSpec.describe DeploymentsFinder do
|
|||
end
|
||||
end
|
||||
|
||||
context 'when the environment ID is specified' do
|
||||
let!(:environment1) { create(:environment, project: project) }
|
||||
let!(:environment2) { create(:environment, project: project) }
|
||||
let!(:deployment1) do
|
||||
create(:deployment, project: project, environment: environment1)
|
||||
end
|
||||
|
||||
let!(:deployment2) do
|
||||
create(:deployment, project: project, environment: environment2)
|
||||
end
|
||||
|
||||
let(:params) { { environment: environment1.id } }
|
||||
|
||||
it 'returns deployments for the given environment' do
|
||||
is_expected.to match_array([deployment1])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the deployment status is specified' do
|
||||
let!(:deployment1) { create(:deployment, :success, project: project) }
|
||||
let!(:deployment2) { create(:deployment, :failed, project: project) }
|
||||
|
|
|
@ -2,6 +2,8 @@ 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';
|
||||
import ServiceTable from '~/google_cloud/databases/service_table.vue';
|
||||
import InstanceTable from '~/google_cloud/databases/cloudsql/instance_table.vue';
|
||||
|
||||
describe('google_cloud/databases/panel', () => {
|
||||
let wrapper;
|
||||
|
@ -10,6 +12,11 @@ describe('google_cloud/databases/panel', () => {
|
|||
configurationUrl: 'configuration-url',
|
||||
deploymentsUrl: 'deployments-url',
|
||||
databasesUrl: 'databases-url',
|
||||
cloudsqlPostgresUrl: 'cloudsql-postgres-url',
|
||||
cloudsqlMysqlUrl: 'cloudsql-mysql-url',
|
||||
cloudsqlSqlserverUrl: 'cloudsql-sqlserver-url',
|
||||
cloudsqlInstances: [],
|
||||
emptyIllustrationUrl: 'empty-illustration-url',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -33,4 +40,14 @@ describe('google_cloud/databases/panel', () => {
|
|||
expect(target.props('deploymentsUrl')).toBe(props.deploymentsUrl);
|
||||
expect(target.props('databasesUrl')).toBe(props.databasesUrl);
|
||||
});
|
||||
|
||||
it('contains Databases service table', () => {
|
||||
const target = wrapper.findComponent(ServiceTable);
|
||||
expect(target.exists()).toBe(true);
|
||||
});
|
||||
|
||||
it('contains CloudSQL instance table', () => {
|
||||
const target = wrapper.findComponent(InstanceTable);
|
||||
expect(target.exists()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -11,7 +11,7 @@ describe('Notebook component', () => {
|
|||
|
||||
function buildComponent(notebook) {
|
||||
return mount(Component, {
|
||||
propsData: { notebook, codeCssClass: 'js-code-class' },
|
||||
propsData: { notebook },
|
||||
provide: { relativeRawPath: '' },
|
||||
}).vm;
|
||||
}
|
||||
|
@ -46,10 +46,6 @@ describe('Notebook component', () => {
|
|||
it('renders code cell', () => {
|
||||
expect(vm.$el.querySelector('pre')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('add code class to code blocks', () => {
|
||||
expect(vm.$el.querySelector('.js-code-class')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with worksheets', () => {
|
||||
|
@ -72,9 +68,5 @@ describe('Notebook component', () => {
|
|||
it('renders code cell', () => {
|
||||
expect(vm.$el.querySelector('pre')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('add code class to code blocks', () => {
|
||||
expect(vm.$el.querySelector('.js-code-class')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
import Prism from '~/notebook/lib/highlight';
|
||||
|
||||
describe('Highlight library', () => {
|
||||
it('imports python language', () => {
|
||||
expect(Prism.languages.python).toBeDefined();
|
||||
});
|
||||
|
||||
it('uses custom CSS classes', () => {
|
||||
const el = document.createElement('div');
|
||||
el.innerHTML = Prism.highlight('console.log("a");', Prism.languages.javascript);
|
||||
|
||||
expect(el.querySelector('.string')).not.toBeNull();
|
||||
expect(el.querySelector('.function')).not.toBeNull();
|
||||
});
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import { GlAlert } from '@gitlab/ui';
|
||||
import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import AxiosMockAdapter from 'axios-mock-adapter';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
|
@ -23,11 +23,16 @@ describe('ServiceDeskRoot', () => {
|
|||
selectedTemplate: 'Bug',
|
||||
selectedFileTemplateProjectId: 42,
|
||||
templates: ['Bug', 'Documentation'],
|
||||
publicProject: false,
|
||||
};
|
||||
|
||||
const getAlertText = () => wrapper.find(GlAlert).text();
|
||||
|
||||
const createComponent = () => shallowMount(ServiceDeskRoot, { provide: provideData });
|
||||
const createComponent = (customInject = {}) =>
|
||||
shallowMount(ServiceDeskRoot, {
|
||||
provide: { ...provideData, ...customInject },
|
||||
stubs: { GlSprintf },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
axiosMock = new AxiosMockAdapter(axios);
|
||||
|
@ -60,6 +65,25 @@ describe('ServiceDeskRoot', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows alert about email inference when current project is public', () => {
|
||||
wrapper = createComponent({
|
||||
publicProject: true,
|
||||
});
|
||||
|
||||
const alertEl = wrapper.find('[data-testid="public-project-alert"]');
|
||||
expect(alertEl.exists()).toBe(true);
|
||||
expect(alertEl.text()).toContain(
|
||||
'This project is public. Non-members can guess the Service Desk email address, because it contains the group and project name.',
|
||||
);
|
||||
|
||||
const alertBodyLink = alertEl.findComponent(GlLink);
|
||||
expect(alertBodyLink.exists()).toBe(true);
|
||||
expect(alertBodyLink.attributes('href')).toBe(
|
||||
'/help/user/project/service_desk.html#using-a-custom-email-address',
|
||||
);
|
||||
expect(alertBodyLink.text()).toBe('How do I create a custom email address?');
|
||||
});
|
||||
|
||||
describe('toggle event', () => {
|
||||
describe('when toggling service desk on', () => {
|
||||
beforeEach(async () => {
|
||||
|
|
|
@ -197,5 +197,43 @@ describe('MR Widget', () => {
|
|||
|
||||
expect(findToggleButton().exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('fetches expanded data when clicked for the first time', async () => {
|
||||
const mockDataCollapsed = {
|
||||
headers: {},
|
||||
status: 200,
|
||||
data: { vulnerabilities: [{ vuln: 1 }] },
|
||||
};
|
||||
|
||||
const mockDataExpanded = {
|
||||
headers: {},
|
||||
status: 200,
|
||||
data: { vulnerabilities: [{ vuln: 2 }] },
|
||||
};
|
||||
|
||||
createComponent({
|
||||
propsData: {
|
||||
isCollapsible: true,
|
||||
fetchCollapsedData: () => Promise.resolve(mockDataCollapsed),
|
||||
fetchExpandedData: () => Promise.resolve(mockDataExpanded),
|
||||
},
|
||||
});
|
||||
|
||||
findToggleButton().vm.$emit('click');
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
// First fetches the collapsed data
|
||||
expect(wrapper.emitted('input')[0][0]).toEqual({
|
||||
collapsed: mockDataCollapsed.data,
|
||||
expanded: null,
|
||||
});
|
||||
|
||||
// Then fetches the expanded data
|
||||
expect(wrapper.emitted('input')[1][0]).toEqual({
|
||||
collapsed: null,
|
||||
expanded: mockDataExpanded.data,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,26 +0,0 @@
|
|||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`Code Block with default props renders correctly 1`] = `
|
||||
<pre
|
||||
class="code-block rounded code"
|
||||
>
|
||||
<code
|
||||
class="d-block"
|
||||
>
|
||||
test-code
|
||||
</code>
|
||||
</pre>
|
||||
`;
|
||||
|
||||
exports[`Code Block with maxHeight set to "200px" renders correctly 1`] = `
|
||||
<pre
|
||||
class="code-block rounded code"
|
||||
style="max-height: 200px; overflow-y: auto;"
|
||||
>
|
||||
<code
|
||||
class="d-block"
|
||||
>
|
||||
test-code
|
||||
</code>
|
||||
</pre>
|
||||
`;
|
|
@ -0,0 +1,65 @@
|
|||
import { shallowMount } from '@vue/test-utils';
|
||||
import CodeBlock from '~/vue_shared/components/code_block_highlighted.vue';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
|
||||
describe('Code Block Highlighted', () => {
|
||||
let wrapper;
|
||||
|
||||
const code = 'const foo = 1;';
|
||||
|
||||
const createComponent = (propsData = {}) => {
|
||||
wrapper = shallowMount(CodeBlock, { propsData });
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
it('renders highlighted code if language is supported', async () => {
|
||||
createComponent({ code, language: 'javascript' });
|
||||
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.element).toMatchInlineSnapshot(`
|
||||
<code-block-stub
|
||||
class="highlight"
|
||||
code=""
|
||||
maxheight="initial"
|
||||
>
|
||||
<span>
|
||||
<span
|
||||
class="hljs-keyword"
|
||||
>
|
||||
const
|
||||
</span>
|
||||
foo =
|
||||
<span
|
||||
class="hljs-number"
|
||||
>
|
||||
1
|
||||
</span>
|
||||
;
|
||||
</span>
|
||||
</code-block-stub>
|
||||
`);
|
||||
});
|
||||
|
||||
it("renders plain text if language isn't supported", async () => {
|
||||
createComponent({ code, language: 'foobar' });
|
||||
await waitForPromises();
|
||||
|
||||
expect(wrapper.emitted('error')).toEqual([[expect.any(TypeError)]]);
|
||||
|
||||
expect(wrapper.element).toMatchInlineSnapshot(`
|
||||
<code-block-stub
|
||||
class="highlight"
|
||||
code=""
|
||||
maxheight="initial"
|
||||
>
|
||||
<span>
|
||||
const foo = 1;
|
||||
</span>
|
||||
</code-block-stub>
|
||||
`);
|
||||
});
|
||||
});
|
|
@ -4,41 +4,77 @@ import CodeBlock from '~/vue_shared/components/code_block.vue';
|
|||
describe('Code Block', () => {
|
||||
let wrapper;
|
||||
|
||||
const defaultProps = {
|
||||
code: 'test-code',
|
||||
};
|
||||
const code = 'test-code';
|
||||
|
||||
const createComponent = (props = {}) => {
|
||||
const createComponent = (propsData, slots = {}) => {
|
||||
wrapper = shallowMount(CodeBlock, {
|
||||
propsData: {
|
||||
...defaultProps,
|
||||
...props,
|
||||
},
|
||||
slots,
|
||||
propsData,
|
||||
});
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
wrapper = null;
|
||||
});
|
||||
|
||||
describe('with default props', () => {
|
||||
beforeEach(() => {
|
||||
createComponent();
|
||||
});
|
||||
it('overwrites the default slot', () => {
|
||||
createComponent({}, { default: 'DEFAULT SLOT' });
|
||||
|
||||
it('renders correctly', () => {
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
expect(wrapper.element).toMatchInlineSnapshot(`
|
||||
<pre
|
||||
class="code-block rounded code"
|
||||
>
|
||||
DEFAULT SLOT
|
||||
</pre>
|
||||
`);
|
||||
});
|
||||
|
||||
describe('with maxHeight set to "200px"', () => {
|
||||
beforeEach(() => {
|
||||
createComponent({ maxHeight: '200px' });
|
||||
});
|
||||
it('renders with empty code prop', () => {
|
||||
createComponent({});
|
||||
|
||||
it('renders correctly', () => {
|
||||
expect(wrapper.element).toMatchSnapshot();
|
||||
});
|
||||
expect(wrapper.element).toMatchInlineSnapshot(`
|
||||
<pre
|
||||
class="code-block rounded code"
|
||||
>
|
||||
<code
|
||||
class="d-block"
|
||||
>
|
||||
|
||||
</code>
|
||||
</pre>
|
||||
`);
|
||||
});
|
||||
|
||||
it('renders code prop when provided', () => {
|
||||
createComponent({ code });
|
||||
|
||||
expect(wrapper.element).toMatchInlineSnapshot(`
|
||||
<pre
|
||||
class="code-block rounded code"
|
||||
>
|
||||
<code
|
||||
class="d-block"
|
||||
>
|
||||
test-code
|
||||
</code>
|
||||
</pre>
|
||||
`);
|
||||
});
|
||||
|
||||
it('sets maxHeight properly when provided', () => {
|
||||
createComponent({ code, maxHeight: '200px' });
|
||||
|
||||
expect(wrapper.element).toMatchInlineSnapshot(`
|
||||
<pre
|
||||
class="code-block rounded code"
|
||||
style="max-height: 200px; overflow-y: auto;"
|
||||
>
|
||||
<code
|
||||
class="d-block"
|
||||
>
|
||||
test-code
|
||||
</code>
|
||||
</pre>
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,28 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Resolvers::DeploymentResolver do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:project) { create(:project, :repository, :private) }
|
||||
let_it_be(:environment) { create(:environment, project: project) }
|
||||
let_it_be(:deployment) { create(:deployment, :created, environment: environment, project: project) }
|
||||
let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
|
||||
|
||||
let(:current_user) { developer }
|
||||
|
||||
describe '#resolve' do
|
||||
it 'finds the deployment' do
|
||||
expect(resolve_deployments(iid: deployment.iid)).to contain_exactly(deployment)
|
||||
end
|
||||
|
||||
it 'does not find the deployment if the IID does not match' do
|
||||
expect(resolve_deployments(iid: non_existing_record_id)).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_deployments(args = {}, context = { current_user: current_user })
|
||||
resolve(described_class, obj: project, args: args, ctx: context)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,41 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Resolvers::DeploymentsResolver do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:project) { create(:project, :repository, :private) }
|
||||
let_it_be(:environment) { create(:environment, project: project) }
|
||||
let_it_be(:deployment) { create(:deployment, :created, environment: environment, project: project) }
|
||||
let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
|
||||
|
||||
let(:current_user) { developer }
|
||||
|
||||
describe '#resolve' do
|
||||
it 'finds the deployment' do
|
||||
expect(resolve_deployments).to contain_exactly(deployment)
|
||||
end
|
||||
|
||||
it 'finds the deployment when status matches' do
|
||||
expect(resolve_deployments(statuses: [:created])).to contain_exactly(deployment)
|
||||
end
|
||||
|
||||
it 'does not find the deployment when status does not match' do
|
||||
expect(resolve_deployments(statuses: [:success])).to be_empty
|
||||
end
|
||||
|
||||
it 'transforms order_by for finder' do
|
||||
expect(DeploymentsFinder)
|
||||
.to receive(:new)
|
||||
.with(environment: environment.id, status: ['success'], order_by: 'finished_at', sort: 'asc')
|
||||
.and_call_original
|
||||
|
||||
resolve_deployments(statuses: [:success], order_by: { finished_at: :asc })
|
||||
end
|
||||
end
|
||||
|
||||
def resolve_deployments(args = {}, context = { current_user: current_user })
|
||||
resolve(described_class, obj: environment, args: args, ctx: context)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe GitlabSchema.types['DeploymentDetails'] do
|
||||
specify { expect(described_class.graphql_name).to eq('DeploymentDetails') }
|
||||
|
||||
it 'has the expected fields' do
|
||||
expected_fields = %w[
|
||||
id iid ref tag sha created_at updated_at finished_at status
|
||||
]
|
||||
|
||||
expect(described_class).to have_graphql_fields(*expected_fields)
|
||||
end
|
||||
|
||||
specify { expect(described_class).to require_graphql_authorizations(:read_deployment) }
|
||||
end
|
|
@ -0,0 +1,17 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe GitlabSchema.types['Deployment'] do
|
||||
specify { expect(described_class.graphql_name).to eq('Deployment') }
|
||||
|
||||
it 'has the expected fields' do
|
||||
expected_fields = %w[
|
||||
id iid ref tag sha created_at updated_at finished_at status
|
||||
]
|
||||
|
||||
expect(described_class).to have_graphql_fields(*expected_fields)
|
||||
end
|
||||
|
||||
specify { expect(described_class).to require_graphql_authorizations(:read_deployment) }
|
||||
end
|
|
@ -7,7 +7,7 @@ RSpec.describe GitlabSchema.types['Environment'] do
|
|||
|
||||
it 'has the expected fields' do
|
||||
expected_fields = %w[
|
||||
name id state metrics_dashboard latest_opened_most_severe_alert path
|
||||
name id state metrics_dashboard latest_opened_most_severe_alert path deployments
|
||||
]
|
||||
|
||||
expect(described_class).to have_graphql_fields(*expected_fields)
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Projects::GoogleCloud::CloudsqlHelper do
|
||||
describe '#TIERS' do
|
||||
it 'is an array' do
|
||||
expect(described_class::TIERS).to be_an_instance_of(Array)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#VERSIONS' do
|
||||
it 'returns versions for :postgres' do
|
||||
expect(described_class::VERSIONS[:postgres]).to be_an_instance_of(Array)
|
||||
end
|
||||
|
||||
it 'returns versions for :mysql' do
|
||||
expect(described_class::VERSIONS[:mysql]).to be_an_instance_of(Array)
|
||||
end
|
||||
|
||||
it 'returns versions for :sqlserver' do
|
||||
expect(described_class::VERSIONS[:sqlserver]).to be_an_instance_of(Array)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -74,6 +74,27 @@ RSpec.describe Deployment do
|
|||
end
|
||||
end
|
||||
|
||||
describe '.for_iid' do
|
||||
subject { described_class.for_iid(project, iid) }
|
||||
|
||||
let_it_be(:project) { create(:project, :repository) }
|
||||
let_it_be(:deployment) { create(:deployment, project: project) }
|
||||
|
||||
let(:iid) { deployment.iid }
|
||||
|
||||
it 'finds the deployment' do
|
||||
is_expected.to contain_exactly(deployment)
|
||||
end
|
||||
|
||||
context 'when iid does not match' do
|
||||
let(:iid) { non_existing_record_id }
|
||||
|
||||
it 'does not find the deployment' do
|
||||
is_expected.to be_empty
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.for_environment_name' do
|
||||
subject { described_class.for_environment_name(project, environment_name) }
|
||||
|
||||
|
|
|
@ -0,0 +1,345 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Environments Deployments query' do
|
||||
include GraphqlHelpers
|
||||
|
||||
let_it_be(:project) { create(:project, :private, :repository) }
|
||||
let_it_be(:environment) { create(:environment, project: project) }
|
||||
let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
|
||||
let_it_be(:guest) { create(:user).tap { |u| project.add_guest(u) } }
|
||||
|
||||
let(:user) { developer }
|
||||
|
||||
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
|
||||
|
||||
context 'when there are deployments in the environment' do
|
||||
let_it_be(:finished_deployment_old) do
|
||||
create(:deployment, :success, environment: environment, project: project, finished_at: 2.days.ago)
|
||||
end
|
||||
|
||||
let_it_be(:finished_deployment_new) do
|
||||
create(:deployment, :success, environment: environment, project: project, finished_at: 1.day.ago)
|
||||
end
|
||||
|
||||
let_it_be(:upcoming_deployment_old) do
|
||||
create(:deployment, :created, environment: environment, project: project, created_at: 2.hours.ago)
|
||||
end
|
||||
|
||||
let_it_be(:upcoming_deployment_new) do
|
||||
create(:deployment, :created, environment: environment, project: project, created_at: 1.hour.ago)
|
||||
end
|
||||
|
||||
let_it_be(:other_environment) { create(:environment, project: project) }
|
||||
let_it_be(:other_deployment) { create(:deployment, :success, environment: other_environment, project: project) }
|
||||
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environment(name: "#{environment.name}") {
|
||||
deployments {
|
||||
nodes {
|
||||
id
|
||||
iid
|
||||
ref
|
||||
tag
|
||||
sha
|
||||
createdAt
|
||||
updatedAt
|
||||
finishedAt
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns all deployments of the environment' do
|
||||
deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes')
|
||||
|
||||
expect(deployments.count).to eq(4)
|
||||
end
|
||||
|
||||
context 'when query last deployment' do
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environment(name: "#{environment.name}") {
|
||||
deployments(statuses: [SUCCESS], orderBy: { finishedAt: DESC }, first: 1) {
|
||||
nodes {
|
||||
iid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns deployment' do
|
||||
deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes')
|
||||
|
||||
expect(deployments.count).to eq(1)
|
||||
expect(deployments[0]['iid']).to eq(finished_deployment_new.iid.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when query latest upcoming deployment' do
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environment(name: "#{environment.name}") {
|
||||
deployments(statuses: [CREATED RUNNING BLOCKED], orderBy: { createdAt: DESC }, first: 1) {
|
||||
nodes {
|
||||
iid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns deployment' do
|
||||
deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes')
|
||||
|
||||
expect(deployments.count).to eq(1)
|
||||
expect(deployments[0]['iid']).to eq(upcoming_deployment_new.iid.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when query finished deployments in descending order' do
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environment(name: "#{environment.name}") {
|
||||
deployments(statuses: [SUCCESS FAILED CANCELED], orderBy: { finishedAt: DESC }) {
|
||||
nodes {
|
||||
iid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns deployments' do
|
||||
deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes')
|
||||
|
||||
expect(deployments.count).to eq(2)
|
||||
expect(deployments[0]['iid']).to eq(finished_deployment_new.iid.to_s)
|
||||
expect(deployments[1]['iid']).to eq(finished_deployment_old.iid.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when query finished deployments in ascending order' do
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environment(name: "#{environment.name}") {
|
||||
deployments(statuses: [SUCCESS FAILED CANCELED], orderBy: { finishedAt: ASC }) {
|
||||
nodes {
|
||||
iid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns deployments' do
|
||||
deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes')
|
||||
|
||||
expect(deployments.count).to eq(2)
|
||||
expect(deployments[0]['iid']).to eq(finished_deployment_old.iid.to_s)
|
||||
expect(deployments[1]['iid']).to eq(finished_deployment_new.iid.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when query upcoming deployments in descending order' do
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environment(name: "#{environment.name}") {
|
||||
deployments(statuses: [CREATED RUNNING BLOCKED], orderBy: { createdAt: DESC }) {
|
||||
nodes {
|
||||
iid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns deployments' do
|
||||
deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes')
|
||||
|
||||
expect(deployments.count).to eq(2)
|
||||
expect(deployments[0]['iid']).to eq(upcoming_deployment_new.iid.to_s)
|
||||
expect(deployments[1]['iid']).to eq(upcoming_deployment_old.iid.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when query upcoming deployments in ascending order' do
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environment(name: "#{environment.name}") {
|
||||
deployments(statuses: [CREATED RUNNING BLOCKED], orderBy: { createdAt: ASC }) {
|
||||
nodes {
|
||||
iid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns deployments' do
|
||||
deployments = subject.dig('data', 'project', 'environment', 'deployments', 'nodes')
|
||||
|
||||
expect(deployments.count).to eq(2)
|
||||
expect(deployments[0]['iid']).to eq(upcoming_deployment_old.iid.to_s)
|
||||
expect(deployments[1]['iid']).to eq(upcoming_deployment_new.iid.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when query last deployments of multiple environments' do
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environments {
|
||||
nodes {
|
||||
name
|
||||
deployments(statuses: [SUCCESS], orderBy: { finishedAt: DESC }, first: 1) {
|
||||
nodes {
|
||||
iid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returnes an error for preventing N+1 queries' do
|
||||
expect(subject['errors'][0]['message']).to include('exceeds max complexity')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when query finished and upcoming deployments together' do
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environment(name: "#{environment.name}") {
|
||||
deployments(statuses: [CREATED SUCCESS]) {
|
||||
nodes {
|
||||
iid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect { subject }.to raise_error(DeploymentsFinder::InefficientQueryError)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when multiple orderBy input are specified' do
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environment(name: "#{environment.name}") {
|
||||
deployments(orderBy: { finishedAt: DESC, createdAt: ASC }) {
|
||||
nodes {
|
||||
iid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'raises an error' do
|
||||
expect(subject['errors'][0]['message']).to include('orderBy parameter must contain one key-value pair.')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is guest' do
|
||||
let(:user) { guest }
|
||||
|
||||
it 'returns nothing' do
|
||||
expect(subject['data']['project']['environment']).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe 'sorting and pagination' do
|
||||
let(:data_path) { [:project, :environment, :deployments] }
|
||||
let(:current_user) { user }
|
||||
|
||||
def pagination_query(params)
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
environment(name: "#{environment.name}") {
|
||||
deployments(statuses: [SUCCESS], #{params}) {
|
||||
nodes {
|
||||
iid
|
||||
}
|
||||
pageInfo {
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def pagination_results_data(nodes)
|
||||
nodes.map { |deployment| deployment['iid'].to_i }
|
||||
end
|
||||
|
||||
context 'when sorting by finished_at in ascending order' do
|
||||
it_behaves_like 'sorted paginated query' do
|
||||
let(:sort_argument) { graphql_args(orderBy: { finishedAt: :ASC }) }
|
||||
let(:first_param) { 2 }
|
||||
let(:all_records) { [finished_deployment_old.iid, finished_deployment_new.iid] }
|
||||
end
|
||||
end
|
||||
|
||||
context 'when sorting by finished_at in descending order' do
|
||||
it_behaves_like 'sorted paginated query' do
|
||||
let(:sort_argument) { graphql_args(orderBy: { finishedAt: :DESC }) }
|
||||
let(:first_param) { 2 }
|
||||
let(:all_records) { [finished_deployment_new.iid, finished_deployment_old.iid] }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,51 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe 'Project Deployment query' do
|
||||
let_it_be(:project) { create(:project, :private, :repository) }
|
||||
let_it_be(:developer) { create(:user).tap { |u| project.add_developer(u) } }
|
||||
let_it_be(:guest) { create(:user).tap { |u| project.add_guest(u) } }
|
||||
let_it_be(:environment) { create(:environment, project: project) }
|
||||
let_it_be(:deployment) { create(:deployment, environment: environment, project: project) }
|
||||
|
||||
subject { GitlabSchema.execute(query, context: { current_user: user }).as_json }
|
||||
|
||||
let(:user) { developer }
|
||||
|
||||
let(:query) do
|
||||
%(
|
||||
query {
|
||||
project(fullPath: "#{project.full_path}") {
|
||||
deployment(iid: #{deployment.iid}) {
|
||||
id
|
||||
iid
|
||||
ref
|
||||
tag
|
||||
sha
|
||||
createdAt
|
||||
updatedAt
|
||||
finishedAt
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns the deployment of the project' do
|
||||
deployment_data = subject.dig('data', 'project', 'deployment')
|
||||
|
||||
expect(deployment_data['iid']).to eq(deployment.iid.to_s)
|
||||
end
|
||||
|
||||
context 'when user is guest' do
|
||||
let(:user) { guest }
|
||||
|
||||
it 'returns nothing' do
|
||||
deployment_data = subject.dig('data', 'project', 'deployment')
|
||||
|
||||
expect(deployment_data).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2,9 +2,6 @@
|
|||
|
||||
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) }
|
||||
|
@ -56,7 +53,7 @@ RSpec.describe Projects::GoogleCloud::ConfigurationController do
|
|||
|
||||
context 'but gitlab instance is not configured for google oauth2' do
|
||||
it 'returns forbidden' do
|
||||
unconfigured_google_oauth2 = MockGoogleOAuth2Credentials.new('', '')
|
||||
unconfigured_google_oauth2 = Struct.new(:app_id, :app_secret).new('', '')
|
||||
allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for)
|
||||
.with('google_oauth2')
|
||||
.and_return(unconfigured_google_oauth2)
|
||||
|
|
|
@ -2,133 +2,169 @@
|
|||
|
||||
require 'spec_helper'
|
||||
|
||||
# Mock Types
|
||||
MockGoogleOAuth2Credentials = Struct.new(:app_id, :app_secret)
|
||||
RSpec.describe Projects::GoogleCloud::DatabasesController, :snowplow do
|
||||
shared_examples 'shared examples for database controller endpoints' do
|
||||
include_examples 'requires `admin_project_google_cloud` role'
|
||||
|
||||
RSpec.describe Projects::GoogleCloud::DatabasesController do
|
||||
let_it_be(:project) { create(:project, :public) }
|
||||
let_it_be(:url) { project_google_cloud_databases_path(project) }
|
||||
include_examples 'requires feature flag `incubation_5mp_google_cloud` enabled'
|
||||
|
||||
let_it_be(:user_guest) { create(:user) }
|
||||
let_it_be(:user_developer) { create(:user) }
|
||||
let_it_be(:user_maintainer) { create(:user) }
|
||||
include_examples 'requires valid Google OAuth2 configuration'
|
||||
|
||||
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)
|
||||
include_examples 'requires valid Google Oauth2 token' do
|
||||
let_it_be(:mock_gcp_projects) { [{}, {}, {}] }
|
||||
let_it_be(:mock_branches) { [] }
|
||||
let_it_be(:mock_tags) { [] }
|
||||
end
|
||||
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)
|
||||
context '-/google_cloud/databases' do
|
||||
let_it_be(:project) { create(:project) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:renders_template) { 'projects/google_cloud/databases/index' }
|
||||
let_it_be(:redirects_to) { nil }
|
||||
|
||||
subject { get project_google_cloud_databases_path(project) }
|
||||
|
||||
include_examples 'shared examples for database controller endpoints'
|
||||
end
|
||||
|
||||
context '-/google_cloud/databases/new/postgres' do
|
||||
let_it_be(:project) { create(:project) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:renders_template) { 'projects/google_cloud/databases/cloudsql_form' }
|
||||
let_it_be(:redirects_to) { nil }
|
||||
|
||||
subject { get new_project_google_cloud_database_path(project, :postgres) }
|
||||
|
||||
include_examples 'shared examples for database controller endpoints'
|
||||
end
|
||||
|
||||
context '-/google_cloud/databases/new/mysql' do
|
||||
let_it_be(:project) { create(:project) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:renders_template) { 'projects/google_cloud/databases/cloudsql_form' }
|
||||
let_it_be(:redirects_to) { nil }
|
||||
|
||||
subject { get new_project_google_cloud_database_path(project, :mysql) }
|
||||
|
||||
include_examples 'shared examples for database controller endpoints'
|
||||
end
|
||||
|
||||
context '-/google_cloud/databases/new/sqlserver' do
|
||||
let_it_be(:project) { create(:project) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:renders_template) { 'projects/google_cloud/databases/cloudsql_form' }
|
||||
let_it_be(:redirects_to) { nil }
|
||||
|
||||
subject { get new_project_google_cloud_database_path(project, :sqlserver) }
|
||||
|
||||
include_examples 'shared examples for database controller endpoints'
|
||||
end
|
||||
|
||||
context '-/google_cloud/databases/create' do
|
||||
let_it_be(:project) { create(:project) }
|
||||
let_it_be(:user) { create(:user) }
|
||||
let_it_be(:renders_template) { nil }
|
||||
let_it_be(:redirects_to) { project_google_cloud_databases_path(project) }
|
||||
|
||||
subject { post project_google_cloud_databases_path(project) }
|
||||
|
||||
include_examples 'shared examples for database controller endpoints'
|
||||
|
||||
context 'when the request is valid' do
|
||||
before do
|
||||
project.add_maintainer(user)
|
||||
sign_in(user)
|
||||
|
||||
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_return(mock_gcp_projects)
|
||||
end
|
||||
|
||||
allow_next_instance_of(BranchesFinder) do |finder|
|
||||
allow(finder).to receive(:execute).and_return(mock_branches)
|
||||
end
|
||||
|
||||
allow_next_instance_of(TagsFinder) do |finder|
|
||||
allow(finder).to receive(:execute).and_return(mock_branches)
|
||||
end
|
||||
end
|
||||
|
||||
subject do
|
||||
post project_google_cloud_databases_path(project)
|
||||
end
|
||||
|
||||
it 'calls EnableCloudsqlService and redirects on error' do
|
||||
expect_next_instance_of(::GoogleCloud::EnableCloudsqlService) do |service|
|
||||
expect(service).to receive(:execute)
|
||||
.and_return({ status: :error, message: 'error' })
|
||||
end
|
||||
|
||||
subject
|
||||
|
||||
expect(response).to redirect_to(project_google_cloud_databases_path(project))
|
||||
|
||||
get url
|
||||
expect_snowplow_event(
|
||||
category: 'Projects::GoogleCloud',
|
||||
action: 'admin_project_google_cloud!',
|
||||
label: 'error_access_denied',
|
||||
property: 'invalid_user',
|
||||
action: 'databases#cloudsql_create',
|
||||
label: 'error_enable_cloudsql_service',
|
||||
extra: { status: :error, message: 'error' },
|
||||
project: project,
|
||||
user: unauthorized_member
|
||||
user: user
|
||||
)
|
||||
|
||||
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)
|
||||
context 'when EnableCloudsqlService is successful' do
|
||||
before do
|
||||
allow_next_instance_of(::GoogleCloud::EnableCloudsqlService) do |service|
|
||||
allow(service).to receive(:execute)
|
||||
.and_return({ status: :success, message: 'success' })
|
||||
end
|
||||
end
|
||||
|
||||
authorized_members.each do |authorized_member|
|
||||
sign_in(authorized_member)
|
||||
it 'calls CreateCloudsqlInstanceService and redirects on error' do
|
||||
expect_next_instance_of(::GoogleCloud::CreateCloudsqlInstanceService) do |service|
|
||||
expect(service).to receive(:execute)
|
||||
.and_return({ status: :error, message: 'error' })
|
||||
end
|
||||
|
||||
get url
|
||||
subject
|
||||
|
||||
expect(response).to redirect_to(project_google_cloud_databases_path(project))
|
||||
|
||||
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)
|
||||
},
|
||||
action: 'databases#cloudsql_create',
|
||||
label: 'error_create_cloudsql_instance',
|
||||
extra: { status: :error, message: 'error' },
|
||||
project: project,
|
||||
user: authorized_member
|
||||
user: user
|
||||
)
|
||||
end
|
||||
|
||||
context 'when CreateCloudsqlInstanceService is successful' do
|
||||
before do
|
||||
allow_next_instance_of(::GoogleCloud::CreateCloudsqlInstanceService) do |service|
|
||||
allow(service).to receive(:execute)
|
||||
.and_return({ status: :success, message: 'success' })
|
||||
end
|
||||
end
|
||||
|
||||
it 'redirects as expected' do
|
||||
subject
|
||||
|
||||
expect(response).to redirect_to(project_google_cloud_databases_path(project))
|
||||
|
||||
expect_snowplow_event(
|
||||
category: 'Projects::GoogleCloud',
|
||||
action: 'databases#cloudsql_create',
|
||||
label: 'success',
|
||||
extra: nil,
|
||||
project: project,
|
||||
user: user
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -23,6 +23,11 @@ RSpec.describe GoogleCloud::EnableCloudsqlService do
|
|||
project.save!
|
||||
end
|
||||
|
||||
after do
|
||||
project.variables.destroy_all # rubocop:disable Cop/DestroyAll
|
||||
project.save!
|
||||
end
|
||||
|
||||
it 'enables cloudsql, compute and service networking Google APIs', :aggregate_failures do
|
||||
expect_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance|
|
||||
expect(instance).to receive(:enable_cloud_sql_admin).with('prj-prod')
|
||||
|
@ -35,5 +40,22 @@ RSpec.describe GoogleCloud::EnableCloudsqlService do
|
|||
|
||||
expect(result[:status]).to eq(:success)
|
||||
end
|
||||
|
||||
context 'when Google APIs raise an error' do
|
||||
it 'returns error result' do
|
||||
allow_next_instance_of(GoogleApi::CloudPlatform::Client) do |instance|
|
||||
allow(instance).to receive(:enable_cloud_sql_admin).with('prj-prod')
|
||||
allow(instance).to receive(:enable_compute).with('prj-prod')
|
||||
allow(instance).to receive(:enable_service_networking).with('prj-prod')
|
||||
allow(instance).to receive(:enable_cloud_sql_admin).with('prj-staging')
|
||||
allow(instance).to receive(:enable_compute).with('prj-staging')
|
||||
allow(instance).to receive(:enable_service_networking).with('prj-staging')
|
||||
.and_raise(Google::Apis::Error.new('error'))
|
||||
end
|
||||
|
||||
expect(result[:status]).to eq(:error)
|
||||
expect(result[:message]).to eq('error')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'requires feature flag `incubation_5mp_google_cloud` enabled' do
|
||||
context 'when feature flag is disabled' do
|
||||
before do
|
||||
project.add_maintainer(user)
|
||||
stub_feature_flags(incubation_5mp_google_cloud: false)
|
||||
end
|
||||
|
||||
it 'renders not found' do
|
||||
sign_in(user)
|
||||
|
||||
subject
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,55 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'requires `admin_project_google_cloud` role' do
|
||||
shared_examples 'returns not_found' do
|
||||
it 'returns not found' do
|
||||
sign_in(user)
|
||||
|
||||
subject
|
||||
|
||||
expect(response).to have_gitlab_http_status(:not_found)
|
||||
end
|
||||
end
|
||||
|
||||
shared_examples 'redirects to authorize url' do
|
||||
it 'redirects to authorize url' do
|
||||
sign_in(user)
|
||||
|
||||
subject
|
||||
|
||||
expect(response).to redirect_to(assigns(:authorize_url))
|
||||
end
|
||||
end
|
||||
|
||||
context 'when requested by users with different roles' do
|
||||
let_it_be(:guest) { create(:user) }
|
||||
let_it_be(:developer) { create(:user) }
|
||||
let_it_be(:maintainer) { create(:user) }
|
||||
|
||||
before do
|
||||
project.add_guest(guest)
|
||||
project.add_developer(developer)
|
||||
project.add_maintainer(maintainer)
|
||||
end
|
||||
|
||||
context 'for unauthorized users' do
|
||||
include_examples 'returns not_found' do
|
||||
let(:user) { guest }
|
||||
end
|
||||
|
||||
include_examples 'returns not_found' do
|
||||
let(:user) { developer }
|
||||
end
|
||||
end
|
||||
|
||||
context 'for authorized users' do
|
||||
include_examples 'redirects to authorize url' do
|
||||
let(:user) { maintainer }
|
||||
end
|
||||
|
||||
include_examples 'redirects to authorize url' do
|
||||
let(:user) { project.owner }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'requires valid Google OAuth2 configuration' do
|
||||
context 'when GitLab instance does not have valid Google OAuth2 configuration ' do
|
||||
before do
|
||||
project.add_maintainer(user)
|
||||
unconfigured_google_oauth2 = Struct.new(:app_id, :app_secret)
|
||||
.new('', '')
|
||||
allow(Gitlab::Auth::OAuth::Provider).to receive(:config_for)
|
||||
.with('google_oauth2')
|
||||
.and_return(unconfigured_google_oauth2)
|
||||
end
|
||||
|
||||
it 'renders forbidden' do
|
||||
sign_in(user)
|
||||
|
||||
subject
|
||||
|
||||
expect(response).to have_gitlab_http_status(:forbidden)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,47 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.shared_examples 'requires valid Google Oauth2 token' do
|
||||
context 'when a valid Google OAuth2 token does not exist' do
|
||||
before do
|
||||
project.add_maintainer(user)
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
it 'triggers Google OAuth2 flow on request' do
|
||||
subject
|
||||
|
||||
expect(response).to redirect_to(assigns(:authorize_url))
|
||||
end
|
||||
|
||||
context 'and a valid Google OAuth2 token gets created' 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_return(mock_gcp_projects) if mock_gcp_projects
|
||||
end
|
||||
|
||||
allow_next_instance_of(BranchesFinder) do |finder|
|
||||
allow(finder).to receive(:execute).and_return(mock_branches) if mock_branches
|
||||
end
|
||||
|
||||
allow_next_instance_of(TagsFinder) do |finder|
|
||||
allow(finder).to receive(:execute).and_return(mock_branches) if mock_branches
|
||||
end
|
||||
end
|
||||
|
||||
it 'renders template as expected' do
|
||||
if renders_template
|
||||
subject
|
||||
expect(response).to render_template(renders_template)
|
||||
end
|
||||
end
|
||||
|
||||
it 'redirects as expected' do
|
||||
if redirects_to
|
||||
subject
|
||||
expect(response).to redirect_to(redirects_to)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -6,13 +6,16 @@ import translateMixin from '~/vue_shared/translate';
|
|||
const stylesheetsRequireCtx = require.context(
|
||||
'../../app/assets/stylesheets',
|
||||
true,
|
||||
/(application|application_utilities)\.scss$/,
|
||||
/(application|application_utilities|highlight\/themes\/white)\.scss$/,
|
||||
);
|
||||
|
||||
window.gon = {};
|
||||
window.gon = {
|
||||
user_color_scheme: 'white',
|
||||
};
|
||||
translateMixin(Vue);
|
||||
|
||||
stylesheetsRequireCtx('./application.scss');
|
||||
stylesheetsRequireCtx('./application_utilities.scss');
|
||||
stylesheetsRequireCtx('./highlight/themes/white.scss');
|
||||
|
||||
export const decorators = [withServer(createMockServer)];
|
||||
|
|
35
yarn.lock
35
yarn.lock
|
@ -2214,10 +2214,10 @@
|
|||
semver "^6.3.0"
|
||||
tsutils "^3.17.1"
|
||||
|
||||
"@vue/compiler-sfc@2.7.8":
|
||||
version "2.7.8"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-2.7.8.tgz#731aadd6beafdb9c72fd8614ce189ac6cee87612"
|
||||
integrity sha512-2DK4YWKfgLnW9VDR9gnju1gcYRk3flKj8UNsms7fsRmFcg35slVTZEkqwBtX+wJBXaamFfn6NxSsZh3h12Ix/Q==
|
||||
"@vue/compiler-sfc@2.7.9":
|
||||
version "2.7.9"
|
||||
resolved "https://registry.yarnpkg.com/@vue/compiler-sfc/-/compiler-sfc-2.7.9.tgz#aa31813c94de39f4977e4b924eb261c2199218ad"
|
||||
integrity sha512-TD2FvT0fPUezw5RVP4tfwTZnKHP0QjeEUb39y7tORvOJQTjbOuHJEk4GPHUPsRaTeQ8rjuKjntyrYcEIx+ODxg==
|
||||
dependencies:
|
||||
"@babel/parser" "^7.18.4"
|
||||
postcss "^8.4.14"
|
||||
|
@ -3429,7 +3429,7 @@ clean-stack@^2.0.0:
|
|||
resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b"
|
||||
integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==
|
||||
|
||||
clipboard@^2.0.0, clipboard@^2.0.8:
|
||||
clipboard@^2.0.8:
|
||||
version "2.0.8"
|
||||
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-2.0.8.tgz#ffc6c103dd2967a83005f3f61976aa4655a4cdba"
|
||||
integrity sha512-Y6WO0unAIQp5bLmk1zdThRhgJt/x3ks6f30s3oE3H1mgIEU33XyQjEf8gsf6DxC7NPX8Y1SsNWjUjL/ywLnnbQ==
|
||||
|
@ -9731,13 +9731,6 @@ pretty@^2.0.0:
|
|||
extend-shallow "^2.0.1"
|
||||
js-beautify "^1.6.12"
|
||||
|
||||
prismjs@^1.21.0:
|
||||
version "1.21.0"
|
||||
resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.21.0.tgz#36c086ec36b45319ec4218ee164c110f9fc015a3"
|
||||
integrity sha512-uGdSIu1nk3kej2iZsLyDoJ7e9bnPzIgY0naW/HdknGj61zScaprVEVGHrPoXqI+M9sP0NDnTK2jpkvmldpuqDw==
|
||||
optionalDependencies:
|
||||
clipboard "^2.0.0"
|
||||
|
||||
process-nextick-args@~2.0.0:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
|
||||
|
@ -12023,10 +12016,10 @@ vue-style-loader@^4.1.0:
|
|||
hash-sum "^1.0.2"
|
||||
loader-utils "^1.0.2"
|
||||
|
||||
vue-template-compiler@^2.7.8:
|
||||
version "2.7.8"
|
||||
resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.7.8.tgz#eadd54ed8fbff55b7deb07093a976c07f451a1dc"
|
||||
integrity sha512-eQqdcUpJKJpBRPDdxCNsqUoT0edNvdt1jFjtVnVS/LPPmr0BU2jWzXlrf6BVMeODtdLewB3j8j3WjNiB+V+giw==
|
||||
vue-template-compiler@^2.7.9:
|
||||
version "2.7.9"
|
||||
resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.7.9.tgz#ffbeb1769ae6af65cd405a6513df6b1e20e33616"
|
||||
integrity sha512-NPJxt6OjVlzmkixYg0SdIN2Lw/rMryQskObY89uAMcM9flS/HrmLK5LaN1ReBTuhBgnYuagZZEkSS6FESATQUQ==
|
||||
dependencies:
|
||||
de-indent "^1.0.2"
|
||||
he "^1.2.0"
|
||||
|
@ -12041,12 +12034,12 @@ vue-virtual-scroll-list@^1.4.7:
|
|||
resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-1.4.7.tgz#12ee26833885f5bb4d37dc058085ccf3ce5b5a74"
|
||||
integrity sha512-R8bk+k7WMGGoFQ9xF0krGCAlZhQjbJOkDUX+YZD2J+sHQWTzDtmTLS6kiIJToOHK1d/8QPGiD8fd9w0lDP4arg==
|
||||
|
||||
vue@^2.7.8:
|
||||
version "2.7.8"
|
||||
resolved "https://registry.yarnpkg.com/vue/-/vue-2.7.8.tgz#34e06553137611d8cecc4b0cd78b7a59f80b1299"
|
||||
integrity sha512-ncwlZx5qOcn754bCu5/tS/IWPhXHopfit79cx+uIlLMyt3vCMGcXai5yCG5y+I6cDmEj4ukRYyZail9FTQh7lQ==
|
||||
vue@^2.7.9:
|
||||
version "2.7.9"
|
||||
resolved "https://registry.yarnpkg.com/vue/-/vue-2.7.9.tgz#ac3ccb4a4ac5dd31a8eec4c68a094efe7f83dfbb"
|
||||
integrity sha512-GeWCvAUkjzD5q4A3vgi8ka5r9bM6g8fmNmx/9VnHDKCaEzBcoVw+7UcQktZHrJ2jhlI+Zv8L57pMCIwM4h4MWg==
|
||||
dependencies:
|
||||
"@vue/compiler-sfc" "2.7.8"
|
||||
"@vue/compiler-sfc" "2.7.9"
|
||||
csstype "^3.1.0"
|
||||
|
||||
vuedraggable@^2.23.0:
|
||||
|
|
Loading…
Reference in New Issue