Commit 238c8c3e authored by Clement Ho's avatar Clement Ho

Merge branch 'master' into ce-to-ee-2018-07-26

parents 0030c4c3 8cb9f02e
/* eslint-disable quote-props, comma-dangle */
import $ from 'jquery'; import $ from 'jquery';
import _ from 'underscore'; import _ from 'underscore';
import Vue from 'vue'; import Vue from 'vue';
...@@ -56,7 +54,7 @@ export default () => { ...@@ -56,7 +54,7 @@ export default () => {
gl.IssueBoardsApp = new Vue({ gl.IssueBoardsApp = new Vue({
el: $boardApp, el: $boardApp,
components: { components: {
'board': gl.issueBoards.Board, board: gl.issueBoards.Board,
'board-sidebar': gl.issueBoards.BoardSidebar, 'board-sidebar': gl.issueBoards.BoardSidebar,
BoardAddIssuesModal, BoardAddIssuesModal,
}, },
...@@ -74,11 +72,11 @@ export default () => { ...@@ -74,11 +72,11 @@ export default () => {
defaultAvatar: $boardApp.dataset.defaultAvatar, defaultAvatar: $boardApp.dataset.defaultAvatar,
}, },
computed: { computed: {
detailIssueVisible () { detailIssueVisible() {
return Object.keys(this.detailIssue.issue).length; return Object.keys(this.detailIssue.issue).length;
}, },
}, },
created () { created() {
gl.boardService = new BoardService({ gl.boardService = new BoardService({
boardsEndpoint: this.boardsEndpoint, boardsEndpoint: this.boardsEndpoint,
listsEndpoint: this.listsEndpoint, listsEndpoint: this.listsEndpoint,
...@@ -100,15 +98,16 @@ export default () => { ...@@ -100,15 +98,16 @@ export default () => {
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription); sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
sidebarEventHub.$off('updateWeight', this.updateWeight); sidebarEventHub.$off('updateWeight', this.updateWeight);
}, },
mounted () { mounted() {
this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit); this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit);
this.filterManager.setup(); this.filterManager.setup();
Store.disabled = this.disabled; Store.disabled = this.disabled;
gl.boardService.all() gl.boardService
.all()
.then(res => res.data) .then(res => res.data)
.then((data) => { .then(data => {
data.forEach((board) => { data.forEach(board => {
const list = Store.addList(board, this.defaultAvatar); const list = Store.addList(board, this.defaultAvatar);
if (list.type === 'closed') { if (list.type === 'closed') {
...@@ -140,7 +139,7 @@ export default () => { ...@@ -140,7 +139,7 @@ export default () => {
newIssue.setFetchingState('epic', true); newIssue.setFetchingState('epic', true);
BoardService.getIssueInfo(sidebarInfoEndpoint) BoardService.getIssueInfo(sidebarInfoEndpoint)
.then(res => res.data) .then(res => res.data)
.then((data) => { .then(data => {
newIssue.setFetchingState('subscriptions', false); newIssue.setFetchingState('subscriptions', false);
newIssue.setFetchingState('weight', false); newIssue.setFetchingState('weight', false);
newIssue.setFetchingState('epic', false); newIssue.setFetchingState('epic', false);
...@@ -185,7 +184,7 @@ export default () => { ...@@ -185,7 +184,7 @@ export default () => {
issue.setLoadingState('weight', true); issue.setLoadingState('weight', true);
BoardService.updateWeight(issue.sidebarInfoEndpoint, newWeight) BoardService.updateWeight(issue.sidebarInfoEndpoint, newWeight)
.then(res => res.data) .then(res => res.data)
.then((data) => { .then(data => {
issue.setLoadingState('weight', false); issue.setLoadingState('weight', false);
issue.updateData({ issue.updateData({
weight: data.weight, weight: data.weight,
...@@ -196,7 +195,7 @@ export default () => { ...@@ -196,7 +195,7 @@ export default () => {
Flash(__('An error occurred when updating the issue weight')); Flash(__('An error occurred when updating the issue weight'));
}); });
} }
} },
}, },
}); });
...@@ -206,7 +205,7 @@ export default () => { ...@@ -206,7 +205,7 @@ export default () => {
filters: Store.state.filters, filters: Store.state.filters,
milestoneTitle: $boardApp.dataset.boardMilestoneTitle, milestoneTitle: $boardApp.dataset.boardMilestoneTitle,
}, },
mounted () { mounted() {
gl.issueBoards.newListDropdownInit(); gl.issueBoards.newListDropdownInit();
}, },
}); });
...@@ -231,8 +230,8 @@ export default () => { ...@@ -231,8 +230,8 @@ export default () => {
return this.canAdminList ? 'Edit board' : 'View scope'; return this.canAdminList ? 'Edit board' : 'View scope';
}, },
tooltipTitle() { tooltipTitle() {
return this.hasScope ? __('This board\'s scope is reduced') : ''; return this.hasScope ? __("This board's scope is reduced") : '';
} },
}, },
methods: { methods: {
showPage: page => gl.issueBoards.BoardsStore.showPage(page), showPage: page => gl.issueBoards.BoardsStore.showPage(page),
...@@ -254,76 +253,80 @@ export default () => { ...@@ -254,76 +253,80 @@ export default () => {
}); });
} }
gl.IssueBoardsModalAddBtn = new Vue({ const issueBoardsModal = document.getElementById('js-add-issues-btn');
el: document.getElementById('js-add-issues-btn'),
mixins: [modalMixin], if (issueBoardsModal) {
data() { gl.IssueBoardsModalAddBtn = new Vue({
return { el: issueBoardsModal,
modal: ModalStore.store, mixins: [modalMixin],
store: Store.state, data() {
isFullscreen: false, return {
focusModeAvailable: $boardApp.hasAttribute('data-focus-mode-available'), modal: ModalStore.store,
canAdminList: this.$options.el.hasAttribute('data-can-admin-list'), store: Store.state,
}; isFullscreen: false,
}, focusModeAvailable: $boardApp.hasAttribute('data-focus-mode-available'),
computed: { canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
disabled() { };
if (!this.store) {
return true;
}
return !this.store.lists.filter(list => !list.preset).length;
}, },
tooltipTitle() { computed: {
if (this.disabled) { disabled() {
return 'Please add a list to your board first'; if (!this.store) {
} return true;
}
return !this.store.lists.filter(list => !list.preset).length;
},
tooltipTitle() {
if (this.disabled) {
return 'Please add a list to your board first';
}
return ''; return '';
},
}, },
}, watch: {
watch: { disabled() {
disabled() { this.updateTooltip();
},
},
mounted() {
this.updateTooltip(); this.updateTooltip();
}, },
}, methods: {
mounted() { updateTooltip() {
this.updateTooltip(); const $tooltip = $(this.$refs.addIssuesButton);
},
methods: {
updateTooltip() {
const $tooltip = $(this.$refs.addIssuesButton);
this.$nextTick(() => { this.$nextTick(() => {
if (this.disabled) { if (this.disabled) {
$tooltip.tooltip(); $tooltip.tooltip();
} else { } else {
$tooltip.tooltip('dispose'); $tooltip.tooltip('dispose');
}
});
},
openModal() {
if (!this.disabled) {
this.toggleModal(true);
} }
}); },
},
openModal() {
if (!this.disabled) {
this.toggleModal(true);
}
}, },
}, template: `
template: ` <div class="board-extra-actions">
<div class="board-extra-actions"> <button
<button class="btn btn-create prepend-left-10"
class="btn btn-create prepend-left-10" type="button"
type="button" data-placement="bottom"
data-placement="bottom" ref="addIssuesButton"
ref="addIssuesButton" :class="{ 'disabled': disabled }"
:class="{ 'disabled': disabled }" :title="tooltipTitle"
:title="tooltipTitle" :aria-disabled="disabled"
:aria-disabled="disabled" v-if="canAdminList"
v-if="canAdminList" @click="openModal">
@click="openModal"> Add issues
Add issues </button>
</button> </div>
</div> `,
`, });
}); }
gl.IssueBoardsToggleFocusBtn = new Vue({ gl.IssueBoardsToggleFocusBtn = new Vue({
el: document.getElementById('js-toggle-focus-btn'), el: document.getElementById('js-toggle-focus-btn'),
...@@ -335,7 +338,9 @@ export default () => { ...@@ -335,7 +338,9 @@ export default () => {
}, },
methods: { methods: {
toggleFocusMode() { toggleFocusMode() {
if (!this.focusModeAvailable) { return; } if (!this.focusModeAvailable) {
return;
}
$(this.$refs.toggleFocusModeButton).tooltip('hide'); $(this.$refs.toggleFocusModeButton).tooltip('hide');
issueBoardsContent.classList.toggle('is-focused'); issueBoardsContent.classList.toggle('is-focused');
...@@ -369,6 +374,6 @@ export default () => { ...@@ -369,6 +374,6 @@ export default () => {
el: '#js-multiple-boards-switcher', el: '#js-multiple-boards-switcher',
components: { components: {
'boards-selector': gl.issueBoards.BoardsSelector, 'boards-selector': gl.issueBoards.BoardsSelector,
} },
}); });
}; };
<script> <script>
// ee-only
import DashboardMixin from 'ee/monitoring/components/dashboard_mixin';
import _ from 'underscore'; import _ from 'underscore';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
...@@ -17,6 +20,10 @@ export default { ...@@ -17,6 +20,10 @@ export default {
EmptyState, EmptyState,
Icon, Icon,
}, },
// ee-only
mixins: [DashboardMixin],
props: { props: {
hasMetrics: { hasMetrics: {
type: Boolean, type: Boolean,
...@@ -137,7 +144,7 @@ export default { ...@@ -137,7 +144,7 @@ export default {
.catch(() => Flash(s__('Metrics|There was an error getting deployment information.'))), .catch(() => Flash(s__('Metrics|There was an error getting deployment information.'))),
this.service this.service
.getEnvironmentsData() .getEnvironmentsData()
.then((data) => this.store.storeEnvironmentsData(data)) .then(data => this.store.storeEnvironmentsData(data))
.catch(() => Flash(s__('Metrics|There was an error getting environments information.'))), .catch(() => Flash(s__('Metrics|There was an error getting environments information.'))),
]) ])
.then(() => { .then(() => {
...@@ -225,7 +232,13 @@ export default { ...@@ -225,7 +232,13 @@ export default {
:small-graph="forceSmallGraph" :small-graph="forceSmallGraph"
> >
<!-- EE content --> <!-- EE content -->
{{ null }} <alert-widget
v-if="alertsEndpoint && graphData.id"
:alerts-endpoint="alertsEndpoint"
:label="getGraphLabel(graphData)"
:current-alerts="getQueryAlerts(graphData)"
:custom-metric-id="graphData.id"
/>
</graph> </graph>
</graph-group> </graph-group>
</div> </div>
......
export const STATUS_FAILED = 'failed';
export const STATUS_SUCCESS = 'success';
export const STATUS_NEUTRAL = 'neutral';
export const components = {};
export const componentNames = {};
<script>
import Icon from '~/vue_shared/components/icon.vue';
import {
STATUS_FAILED,
STATUS_NEUTRAL,
STATUS_SUCCESS,
} from '~/vue_shared/components/reports/constants';
export default {
name: 'IssueStatusIcon',
components: {
Icon,
},
props: {
// failed || success
status: {
type: String,
required: true,
},
},
computed: {
iconName() {
if (this.isStatusFailed) {
return 'status_failed_borderless';
} else if (this.isStatusSuccess) {
return 'status_success_borderless';
}
return 'status_created_borderless';
},
isStatusFailed() {
return this.status === STATUS_FAILED;
},
isStatusSuccess() {
return this.status === STATUS_SUCCESS;
},
isStatusNeutral() {
return this.status === STATUS_NEUTRAL;
},
},
};
</script>
<template>
<div
:class="{
failed: isStatusFailed,
success: isStatusSuccess,
neutral: isStatusNeutral,
}"
class="report-block-list-icon"
>
<icon
:name="iconName"
:size="32"
/>
</div>
</template>
<script> <script>
import IssuesBlock from '~/vue_shared/components/reports/report_issues.vue'; import IssuesBlock from '~/vue_shared/components/reports/report_issues.vue';
import {
STATUS_SUCCESS,
STATUS_FAILED,
STATUS_NEUTRAL,
} from '~/vue_shared/components/reports/constants';
import { componentNames } from 'ee/vue_shared/components/reports/issue_body';
import SastContainerInfo from 'ee/vue_shared/security_reports/components/sast_container_info.vue'; import SastContainerInfo from 'ee/vue_shared/security_reports/components/sast_container_info.vue';
import { SAST_CONTAINER } from 'ee/vue_shared/security_reports/store/constants';
/** /**
* Renders block of issues * Renders block of issues
...@@ -13,7 +18,10 @@ export default { ...@@ -13,7 +18,10 @@ export default {
IssuesBlock, IssuesBlock,
SastContainerInfo, SastContainerInfo,
}, },
sastContainer: SAST_CONTAINER, componentNames,
success: STATUS_SUCCESS,
failed: STATUS_FAILED,
neutral: STATUS_NEUTRAL,
props: { props: {
unresolvedIssues: { unresolvedIssues: {
type: Array, type: Array,
...@@ -35,9 +43,10 @@ export default { ...@@ -35,9 +43,10 @@ export default {
required: false, required: false,
default: () => [], default: () => [],
}, },
type: { component: {
type: String, type: String,
required: true, required: false,
default: '',
}, },
}, },
data() { data() {
...@@ -45,11 +54,6 @@ export default { ...@@ -45,11 +54,6 @@ export default {
isFullReportVisible: false, isFullReportVisible: false,
}; };
}, },
computed: {
unresolvedIssuesStatus() {
return this.type === 'license' ? 'neutral' : 'failed';
},
},
methods: { methods: {
openFullReport() { openFullReport() {
this.isFullReportVisible = true; this.isFullReportVisible = true;
...@@ -59,38 +63,37 @@ export default { ...@@ -59,38 +63,37 @@ export default {
</script> </script>
<template> <template>
<div class="report-block-container"> <div class="report-block-container">
<sast-container-info v-if="type === $options.sastContainer" /> <sast-container-info v-if="component === $options.componentNames.SastContainerIssueBody" />
<issues-block <issues-block
v-if="unresolvedIssues.length" v-if="unresolvedIssues.length"
:type="type" :component="component"
:status="unresolvedIssuesStatus"
:issues="unresolvedIssues" :issues="unresolvedIssues"
:status="$options.failed"
class="js-mr-code-new-issues" class="js-mr-code-new-issues"
/> />
<issues-block <issues-block
v-if="isFullReportVisible" v-if="isFullReportVisible"
:type="type" :component="component"
:issues="allIssues" :issues="allIssues"
:status="$options.failed"
class="js-mr-code-all-issues" class="js-mr-code-all-issues"
status="failed"
/> />
<issues-block <issues-block
v-if="neutralIssues.length" v-if="neutralIssues.length"
:type="type" :component="component"
:issues="neutralIssues" :issues="neutralIssues"
:status="$options.neutral"
class="js-mr-code-non-issues" class="js-mr-code-non-issues"
status="neutral"
/> />
<issues-block <issues-block
v-if="resolvedIssues.length" v-if="resolvedIssues.length"
:type="type" :component="component"
:issues="resolvedIssues" :issues="resolvedIssues"
:status="$options.success"
class="js-mr-code-resolved-issues" class="js-mr-code-resolved-issues"
status="success"
/> />
<button <button
......
<script> <script>
import Icon from '~/vue_shared/components/icon.vue'; import IssueStatusIcon from '~/vue_shared/components/reports/issue_status_icon.vue';
import { components, componentNames } from 'ee/vue_shared/components/reports/issue_body';
import PerformanceIssue from 'ee/vue_merge_request_widget/components/performance_issue_body.vue';
import CodequalityIssue from 'ee/vue_merge_request_widget/components/codequality_issue_body.vue';
import LicenseIssue from 'ee/vue_merge_request_widget/components/license_issue_body.vue';
import SastIssue from 'ee/vue_shared/security_reports/components/sast_issue_body.vue';
import SastContainerIssue from 'ee/vue_shared/security_reports/components/sast_container_issue_body.vue';
import DastIssue from 'ee/vue_shared/security_reports/components/dast_issue_body.vue';
import { SAST, DAST, SAST_CONTAINER } from 'ee/vue_shared/security_reports/store/constants';
export default { export default {
name: 'ReportIssues', name: 'ReportIssues',
components: { components: {
Icon, IssueStatusIcon,
SastIssue, ...components,
SastContainerIssue,
DastIssue,
PerformanceIssue,
CodequalityIssue,
LicenseIssue,
}, },
props: { props: {
issues: { issues: {
type: Array, type: Array,
required: true, required: true,
}, },
// security || codequality || performance || docker || dast || license component: {
type: {
type: String, type: String,
required: true, required: false,
default: '',
validator: value => value === '' || Object.values(componentNames).includes(value),
}, },
// failed || success // failed || success
status: { status: {
...@@ -36,44 +25,6 @@ export default { ...@@ -36,44 +25,6 @@ export default {
required: true, required: true,
}, },
}, },
computed: {
iconName() {
if (this.isStatusFailed) {
return 'status_failed_borderless';
} else if (this.isStatusSuccess) {
return 'status_success_borderless';
}
return 'status_created_borderless';
},
isStatusFailed() {
return this.status === 'failed';
},
isStatusSuccess() {
return this.status === 'success';
},
isStatusNeutral() {
return this.status === 'neutral';
},
isTypeCodequality() {
return this.type === 'codequality';
},
isTypePerformance() {
return this.type === 'performance';
},
isTypeLicense() {
return this.type === 'license';
},
isTypeSast() {
return this.type === SAST;
},
isTypeSastContainer() {
return this.type === SAST_CONTAINER;
},
isTypeDast() {
return this.type === DAST;
},
},
}; };
</script> </script>
<template> <template>
...@@ -85,60 +36,16 @@ export default { ...@@ -85,60 +36,16 @@ export default {
:key="index" :key="index"
class="report-block-list-issue" class="report-block-list-issue"
> >
<div <issue-status-icon
:class="{ :status="issue.status || status"
failed: isStatusFailed, class="append-right-5"
success: isStatusSuccess,
neutral: isStatusNeutral,
}"
class="report-block-list-icon append-right-5"
>
<icon
v-if="isTypeLicense"
:size="24"
name="status_created_borderless"
css-classes="prepend-left-4"
/>
<icon
v-else
:name="iconName"
:size="32"
/>
</div>
<sast-issue
v-if="isTypeSast"
:issue="issue"
:status="status"
/>
<dast-issue
v-else-if="isTypeDast"
:issue="issue"
:issue-index="index"
:status="status"
/>
<sast-container-issue
v-else-if="isTypeSastContainer"
:issue="issue"
:status="status"
/>
<codequality-issue
v-else-if="isTypeCodequality"
:is-status-success="isStatusSuccess"
:issue="issue"
/>
<performance-issue
v-else-if="isTypePerformance"
:issue="issue"
/> />
<license-issue <component
v-else-if="isTypeLicense" v-if="component"
:is="component"
:issue="issue" :issue="issue"
:status="issue.status || status"
/> />
</li> </li>
</ul> </ul>
......
...@@ -21,7 +21,7 @@ export default { ...@@ -21,7 +21,7 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
type: { component: {
type: String, type: String,
required: false, required: false,
default: '', default: '',
...@@ -183,8 +183,9 @@ export default { ...@@ -183,8 +183,9 @@ export default {
<issues-list <issues-list
:unresolved-issues="unresolvedIssues" :unresolved-issues="unresolvedIssues"
:resolved-issues="resolvedIssues" :resolved-issues="resolvedIssues"
:neutral-issues="neutralIssues"
:all-issues="allIssues" :all-issues="allIssues"
:type="type" :component="component"
/> />
</slot> </slot>
</div> </div>
......
...@@ -267,7 +267,7 @@ ...@@ -267,7 +267,7 @@
border: 1px solid $white-light; border: 1px solid $white-light;
background-color: $orange-300; background-color: $orange-300;
border-radius: 50%; border-radius: 50%;
content: ""; content: '';
} }
} }
} }
...@@ -287,8 +287,6 @@ ...@@ -287,8 +287,6 @@
} }
} }
.gl-responsive-table-row { .gl-responsive-table-row {
.branch-commit { .branch-commit {
max-width: 100%; max-width: 100%;
...@@ -679,3 +677,66 @@ ...@@ -679,3 +677,66 @@
} }
} }
} }
.alert-dropdown-button {
margin-left: $btn-side-margin;
.dropdown.open & {
background: $white-normal;
outline: 0;
}
svg {
margin: 0;
+ svg {
margin-left: -$gl-padding-4;
}
&.chevron {
color: $gl-text-color-secondary;
}
}
}
.alert-dropdown-menu {
right: 0;
left: auto;
z-index: $zindex-popover + 5; // must be higher than graph flag popover
.dropdown-title {
margin: 0;
}
}
.alert-error-message {
color: $gl-danger;
vertical-align: middle;
}
.alert-current-setting {
color: $gl-text-color-disabled;
vertical-align: middle;
}
.alert-form {
padding: $gl-padding $gl-padding $gl-padding-8;
label {
font-weight: normal;
}
.btn-group,
.action-group {
display: flex;
.btn {
flex: 1 auto;
box-shadow: none;
}
}
.action-group .btn + .btn {
margin-left: $gl-padding-8;
}
}
module EnvironmentsHelper module EnvironmentsHelper
prepend ::EE::EnvironmentsHelper
def environments_list_data def environments_list_data
{ {
endpoint: project_environments_path(@project, format: :json) endpoint: project_environments_path(@project, format: :json)
} }
end end
def metrics_data(project, environment)
{
"settings-path" => edit_project_service_path(project, 'prometheus'),
"clusters-path" => project_clusters_path(project),
"documentation-path" => help_page_path('administration/monitoring/prometheus/index.md'),
"empty-getting-started-svg-path" => image_path('illustrations/monitoring/getting_started.svg'),
"empty-loading-svg-path" => image_path('illustrations/monitoring/loading.svg'),
"empty-no-data-svg-path" => image_path('illustrations/monitoring/no_data.svg'),
"empty-unable-to-connect-svg-path" => image_path('illustrations/monitoring/unable_to_connect.svg'),
"metrics-endpoint" => additional_metrics_project_environment_path(project, environment, format: :json),
"deployment-endpoint" => project_environment_deployments_path(project, environment, format: :json),
"environments-endpoint": project_environments_path(project, format: :json),
"project-path" => project_path(project),
"tags-path" => project_tags_path(project),
"has-metrics" => "#{environment.has_metrics?}"
}
end
end end
...@@ -11,6 +11,8 @@ module Clusters ...@@ -11,6 +11,8 @@ module Clusters
include ::Clusters::Concerns::ApplicationStatus include ::Clusters::Concerns::ApplicationStatus
include ::Clusters::Concerns::ApplicationData include ::Clusters::Concerns::ApplicationData
prepend EE::Clusters::Applications::Prometheus
default_value_for :version, VERSION default_value_for :version, VERSION
state_machine :status do state_machine :status do
...@@ -21,6 +23,14 @@ module Clusters ...@@ -21,6 +23,14 @@ module Clusters
end end
end end
def ready_status
[:installed]
end
def ready?
ready_status.include?(status_name)
end
def chart def chart
'stable/prometheus' 'stable/prometheus'
end end
......
...@@ -4,6 +4,8 @@ module Clusters ...@@ -4,6 +4,8 @@ module Clusters
extend ActiveSupport::Concern extend ActiveSupport::Concern
included do included do
prepend ::EE::Clusters::ApplicationStatus
scope :installed, -> { where(status: self.state_machines[:status].states[:installed].value) } scope :installed, -> { where(status: self.state_machines[:status].states[:installed].value) }
state_machine :status, initial: :not_installable do state_machine :status, initial: :not_installable do
......
...@@ -3,6 +3,7 @@ module PrometheusAdapter ...@@ -3,6 +3,7 @@ module PrometheusAdapter
included do included do
include ReactiveCaching include ReactiveCaching
prepend EE::PrometheusAdapter
self.reactive_cache_key = ->(adapter) { [adapter.class.model_name.singular, adapter.id] } self.reactive_cache_key = ->(adapter) { [adapter.class.model_name.singular, adapter.id] }
self.reactive_cache_lease_timeout = 30.seconds self.reactive_cache_lease_timeout = 30.seconds
...@@ -24,17 +25,10 @@ module PrometheusAdapter ...@@ -24,17 +25,10 @@ module PrometheusAdapter
def query(query_name, *args) def query(query_name, *args)
return unless can_query? return unless can_query?
query_class = Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query") query_class = query_klass_for(query_name)
query_args = build_query_args(*args)
args.map! do |arg| with_reactive_cache(query_class.name, *query_args, &query_class.method(:transform_reactive_result))
if arg.respond_to?(:id)
arg.id
else
arg
end
end
with_reactive_cache(query_class.name, *args, &query_class.method(:transform_reactive_result))
end end
# Cache metrics for specific environment # Cache metrics for specific environment
...@@ -50,5 +44,13 @@ module PrometheusAdapter ...@@ -50,5 +44,13 @@ module PrometheusAdapter
rescue Gitlab::PrometheusClient::Error => err rescue Gitlab::PrometheusClient::Error => err
{ success: false, result: err.message } { success: false, result: err.message }
end end
def query_klass_for(query_name)
Gitlab::Prometheus::Queries.const_get("#{query_name.to_s.classify}Query")
end
def build_query_args(*args)
args.map(&:id)
end
end end
end end
...@@ -5,6 +5,8 @@ module Clusters ...@@ -5,6 +5,8 @@ module Clusters
class BaseHelmService class BaseHelmService
attr_accessor :app attr_accessor :app
prepend EE::Clusters::Applications::BaseHelmService
def initialize(app) def initialize(app)
@app = app @app = app
end end
......
...@@ -50,17 +50,17 @@ module Clusters ...@@ -50,17 +50,17 @@ module Clusters
end end
def remove_installation_pod def remove_installation_pod
helm_api.delete_installation_pod!(install_command.pod_name) helm_api.delete_pod!(install_command.pod_name)
rescue rescue
# no-op # no-op
end end
def installation_phase def installation_phase
helm_api.installation_status(install_command.pod_name) helm_api.status(install_command.pod_name)
end end
def installation_errors def installation_errors
helm_api.installation_log(install_command.pod_name) helm_api.log(install_command.pod_name)
end end
end end
end end
......
...@@ -30,7 +30,7 @@ module Prometheus ...@@ -30,7 +30,7 @@ module Prometheus
return unless deployment_platform.respond_to?(:cluster) return unless deployment_platform.respond_to?(:cluster)
cluster = deployment_platform.cluster cluster = deployment_platform.cluster
return unless cluster.application_prometheus&.installed? return unless cluster.application_prometheus&.ready?
cluster.application_prometheus cluster.application_prometheus
end end
......
...@@ -2,17 +2,11 @@ ...@@ -2,17 +2,11 @@
- page_title "Metrics for environment", @environment.name - page_title "Metrics for environment", @environment.name
.prometheus-container{ class: container_class } .prometheus-container{ class: container_class }
#prometheus-graphs{ data: { "settings-path": edit_project_service_path(@project, 'prometheus'), .top-area
"clusters-path": project_clusters_path(@project), .row
"current-environment-name": @environment.name, .col-sm-6
"documentation-path": help_page_path('administration/monitoring/prometheus/index.md'), %h3
"empty-getting-started-svg-path": image_path('illustrations/monitoring/getting_started.svg'), Environment:
"empty-loading-svg-path": image_path('illustrations/monitoring/loading.svg'), = link_to @environment.name, environment_path(@environment)
"empty-no-data-svg-path": image_path('illustrations/monitoring/no_data.svg'),
"empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect.svg'), #prometheus-graphs{ data: metrics_data(@project, @environment) }
"metrics-endpoint": additional_metrics_project_environment_path(@project, @environment, format: :json),
"deployment-endpoint": project_environment_deployments_path(@project, @environment, format: :json),
"environments-endpoint": project_environments_path(@project, format: :json),
"project-path": project_path(@project),
"tags-path": project_tags_path(@project),
"has-metrics": "#{@environment.has_metrics?}" } }
...@@ -22,8 +22,10 @@ ...@@ -22,8 +22,10 @@
- cronjob:prune_web_hook_logs - cronjob:prune_web_hook_logs
- gcp_cluster:cluster_install_app - gcp_cluster:cluster_install_app
- gcp_cluster:cluster_update_app
- gcp_cluster:cluster_provision - gcp_cluster:cluster_provision
- gcp_cluster:cluster_wait_for_app_installation - gcp_cluster:cluster_wait_for_app_installation
- gcp_cluster:cluster_wait_for_app_update
- gcp_cluster:wait_for_cluster_creation - gcp_cluster:wait_for_cluster_creation
- gcp_cluster:cluster_wait_for_ingress_ip_address - gcp_cluster:cluster_wait_for_ingress_ip_address
......
...@@ -80,6 +80,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -80,6 +80,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
post :validate_query, on: :collection post :validate_query, on: :collection
get :active_common, on: :collection get :active_common, on: :collection
end end
# EE-specific
resources :alerts, constraints: { id: /\d+/ }, only: [:index, :create, :show, :update, :destroy] do
post :notify, on: :collection
end
# EE-specific
end end
resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create, :edit, :update] do resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create, :edit, :update] do
......
...@@ -778,6 +778,7 @@ ActiveRecord::Schema.define(version: 20180722103201) do ...@@ -778,6 +778,7 @@ ActiveRecord::Schema.define(version: 20180722103201) do
t.text "status_reason" t.text "status_reason"
t.datetime_with_timezone "created_at", null: false t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false t.datetime_with_timezone "updated_at", null: false
t.datetime_with_timezone "last_update_started_at"
end end
create_table "clusters_applications_runners", force: :cascade do |t| create_table "clusters_applications_runners", force: :cascade do |t|
...@@ -2180,6 +2181,19 @@ ActiveRecord::Schema.define(version: 20180722103201) do ...@@ -2180,6 +2181,19 @@ ActiveRecord::Schema.define(version: 20180722103201) do
add_index "projects", ["star_count"], name: "index_projects_on_star_count", using: :btree add_index "projects", ["star_count"], name: "index_projects_on_star_count", using: :btree
add_index "projects", ["visibility_level"], name: "index_projects_on_visibility_level", using: :btree add_index "projects", ["visibility_level"], name: "index_projects_on_visibility_level", using: :btree
create_table "prometheus_alerts", force: :cascade do |t|
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.float "threshold", null: false
t.integer "operator", null: false
t.integer "environment_id", null: false
t.integer "project_id", null: false
t.integer "prometheus_metric_id", null: false
end
add_index "prometheus_alerts", ["environment_id"], name: "index_prometheus_alerts_on_environment_id", using: :btree
add_index "prometheus_alerts", ["prometheus_metric_id"], name: "index_prometheus_alerts_on_prometheus_metric_id", unique: true, using: :btree
create_table "prometheus_metrics", force: :cascade do |t| create_table "prometheus_metrics", force: :cascade do |t|
t.integer "project_id" t.integer "project_id"
t.string "title", null: false t.string "title", null: false
...@@ -2980,6 +2994,9 @@ ActiveRecord::Schema.define(version: 20180722103201) do ...@@ -2980,6 +2994,9 @@ ActiveRecord::Schema.define(version: 20180722103201) do
add_foreign_key "project_mirror_data", "projects", name: "fk_d1aad367d7", on_delete: :cascade add_foreign_key "project_mirror_data", "projects", name: "fk_d1aad367d7", on_delete: :cascade
add_foreign_key "project_repository_states", "projects", on_delete: :cascade add_foreign_key "project_repository_states", "projects", on_delete: :cascade
add_foreign_key "project_statistics", "projects", on_delete: :cascade add_foreign_key "project_statistics", "projects", on_delete: :cascade
add_foreign_key "prometheus_alerts", "environments", on_delete: :cascade
add_foreign_key "prometheus_alerts", "projects", on_delete: :cascade
add_foreign_key "prometheus_alerts", "prometheus_metrics", on_delete: :cascade
add_foreign_key "prometheus_metrics", "projects", on_delete: :cascade add_foreign_key "prometheus_metrics", "projects", on_delete: :cascade
add_foreign_key "protected_branch_merge_access_levels", "namespaces", column: "group_id", name: "fk_98f3d044fe", on_delete: :cascade add_foreign_key "protected_branch_merge_access_levels", "namespaces", column: "group_id", name: "fk_98f3d044fe", on_delete: :cascade
add_foreign_key "protected_branch_merge_access_levels", "protected_branches", name: "fk_8a3072ccb3", on_delete: :cascade add_foreign_key "protected_branch_merge_access_levels", "protected_branches", name: "fk_8a3072ccb3", on_delete: :cascade
......
...@@ -83,7 +83,7 @@ export default { ...@@ -83,7 +83,7 @@ export default {
}); });
}, },
fetchGeoNodes() { fetchGeoNodes() {
this.service return this.service
.getGeoNodes() .getGeoNodes()
.then(res => res.data) .then(res => res.data)
.then(nodes => { .then(nodes => {
...@@ -92,9 +92,7 @@ export default { ...@@ -92,9 +92,7 @@ export default {
}) })
.catch(() => { .catch(() => {
this.isLoading = false; this.isLoading = false;
Flash( Flash(s__('GeoNodes|Something went wrong while fetching nodes'));
s__('GeoNodes|Something went wrong while fetching nodes'),
);
}); });
}, },
fetchNodeDetails(node) { fetchNodeDetails(node) {
...@@ -109,10 +107,7 @@ export default { ...@@ -109,10 +107,7 @@ export default {
primaryRevision: primaryNodeVersion.revision, primaryRevision: primaryNodeVersion.revision,
}); });
this.store.setNodeDetails(nodeId, updatedNodeDetails); this.store.setNodeDetails(nodeId, updatedNodeDetails);
eventHub.$emit( eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
'nodeDetailsLoaded',
this.store.getNodeDetails(nodeId),
);
}) })
.catch(err => { .catch(err => {
if (err.response && err.response.data) { if (err.response && err.response.data) {
...@@ -124,10 +119,7 @@ export default { ...@@ -124,10 +119,7 @@ export default {
sync_status_unavailable: true, sync_status_unavailable: true,
storage_shards_match: null, storage_shards_match: null,
}); });
eventHub.$emit( eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
'nodeDetailsLoaded',
this.store.getNodeDetails(nodeId),
);
} else { } else {
eventHub.$emit('nodeDetailsLoadFailed', nodeId, err); eventHub.$emit('nodeDetailsLoadFailed', nodeId, err);
} }
...@@ -135,14 +127,11 @@ export default { ...@@ -135,14 +127,11 @@ export default {
}, },
repairNode(targetNode) { repairNode(targetNode) {
this.setNodeActionStatus(targetNode, true); this.setNodeActionStatus(targetNode, true);
this.service return this.service
.repairNode(targetNode) .repairNode(targetNode)
.then(() => { .then(() => {
this.setNodeActionStatus(targetNode, false); this.setNodeActionStatus(targetNode, false);
Flash( Flash(s__('GeoNodes|Node Authentication was successfully repaired.'), 'notice');
s__('GeoNodes|Node Authentication was successfully repaired.'),
'notice',
);
}) })
.catch(() => { .catch(() => {
this.setNodeActionStatus(targetNode, false); this.setNodeActionStatus(targetNode, false);
...@@ -151,7 +140,7 @@ export default { ...@@ -151,7 +140,7 @@ export default {
}, },
toggleNode(targetNode) { toggleNode(targetNode) {
this.setNodeActionStatus(targetNode, true); this.setNodeActionStatus(targetNode, true);
this.service return this.service
.toggleNode(targetNode) .toggleNode(targetNode)
.then(res => res.data) .then(res => res.data)
.then(node => { .then(node => {
...@@ -162,14 +151,12 @@ export default { ...@@ -162,14 +151,12 @@ export default {
}) })
.catch(() => { .catch(() => {
this.setNodeActionStatus(targetNode, false); this.setNodeActionStatus(targetNode, false);
Flash( Flash(s__('GeoNodes|Something went wrong while changing node status'));
s__('GeoNodes|Something went wrong while changing node status'),
);
}); });
}, },
removeNode(targetNode) { removeNode(targetNode) {
this.setNodeActionStatus(targetNode, true); this.setNodeActionStatus(targetNode, true);
this.service return this.service
.removeNode(targetNode) .removeNode(targetNode)
.then(() => { .then(() => {
this.store.removeNode(targetNode); this.store.removeNode(targetNode);
......
<script>
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import AlertWidgetForm from './alert_widget_form.vue';
import AlertsService from '../services/alerts_service';
export default {
components: {
Icon,
LoadingIcon,
AlertWidgetForm,
},
props: {
alertsEndpoint: {
type: String,
required: true,
},
label: {
type: String,
required: true,
},
currentAlerts: {
type: Array,
require: false,
default: () => [],
},
customMetricId: {
type: Number,
require: false,
default: null,
},
},
data() {
return {
service: null,
errorMessage: null,
isLoading: false,
isOpen: false,
alerts: this.currentAlerts,
alertData: {},
};
},
computed: {
alertSummary() {
const data = this.firstAlertData;
if (!data) return null;
return `${this.label} ${data.operator} ${data.threshold}`;
},
alertIcon() {
return this.hasAlerts ? 'notifications' : 'notifications-off';
},
alertStatus() {
return this.hasAlerts
? s__('PrometheusAlerts|Alert set')
: s__('PrometheusAlerts|No alert set');
},
dropdownTitle() {
return this.hasAlerts
? s__('PrometheusAlerts|Edit alert')
: s__('PrometheusAlerts|Add alert');
},
hasAlerts() {
return this.alerts.length > 0;
},
firstAlert() {
return this.hasAlerts ? this.alerts[0] : undefined;
},
firstAlertData() {
return this.hasAlerts ? this.alertData[this.alerts[0]] : undefined;
},
formDisabled() {
return !!(this.errorMessage || this.isLoading);
},
},
watch: {
isOpen(open) {
if (open) {
document.addEventListener('click', this.handleOutsideClick);
} else {
document.removeEventListener('click', this.handleOutsideClick);
}
},
},
created() {
this.service = new AlertsService({ alertsEndpoint: this.alertsEndpoint });
this.fetchAlertData();
},
beforeDestroy() {
// clean up external event listeners
document.removeEventListener('click', this.handleOutsideClick);
},
methods: {
fetchAlertData() {
this.isLoading = true;
return Promise.all(
this.alerts.map(alertPath =>
this.service
.readAlert(alertPath)
.then(alertData => this.$set(this.alertData, alertPath, alertData)),
),
)
.then(() => {
this.isLoading = false;
})
.catch(() => {
this.errorMessage = s__('PrometheusAlerts|Error fetching alert');
this.isLoading = false;
});
},
handleDropdownToggle() {
this.isOpen = !this.isOpen;
},
handleDropdownClose() {
this.isOpen = false;
},
handleOutsideClick(event) {
if (!this.$refs.dropdownMenu.contains(event.target)) {
this.isOpen = false;
}
},
handleCreate({ operator, threshold }) {
const newAlert = { operator, threshold, prometheus_metric_id: this.customMetricId };
this.isLoading = true;
this.service
.createAlert(newAlert)
.then(response => {
const alertPath = response.alert_path;
this.alerts.unshift(alertPath);
this.$set(this.alertData, alertPath, newAlert);
this.isLoading = false;
this.handleDropdownClose();
})
.catch(() => {
this.errorMessage = s__('PrometheusAlerts|Error creating alert');
this.isLoading = false;
});
},
handleUpdate({ alert, operator, threshold }) {
const updatedAlert = { operator, threshold };
this.isLoading = true;
this.service
.updateAlert(alert, updatedAlert)
.then(() => {
this.$set(this.alertData, alert, updatedAlert);
this.isLoading = false;
this.handleDropdownClose();
})
.catch(() => {
this.errorMessage = s__('PrometheusAlerts|Error saving alert');
this.isLoading = false;
});
},
handleDelete({ alert }) {
this.isLoading = true;
this.service
.deleteAlert(alert)
.then(() => {
this.$delete(this.alertData, alert);
this.alerts = this.alerts.filter(alertPath => alert !== alertPath);
this.isLoading = false;
this.handleDropdownClose();
})
.catch(() => {
this.errorMessage = s__('PrometheusAlerts|Error deleting alert');
this.isLoading = false;
});
},
},
};
</script>
<template>
<div
:class="{ show: isOpen }"
class="prometheus-alert-widget dropdown"
>
<span
v-if="errorMessage"
class="alert-error-message"
>
{{ errorMessage }}
</span>
<span
v-else
class="alert-current-setting"
>
<loading-icon
v-show="isLoading"
:inline="true"
/>
{{ alertSummary }}
</span>
<button
:aria-label="alertStatus"
class="btn btn-sm alert-dropdown-button"
type="button"
@click="handleDropdownToggle"
>
<icon
:name="alertIcon"
:size="16"
aria-hidden="true"
/>
<icon
:size="16"
name="arrow-down"
aria-hidden="true"
class="chevron"
/>
</button>
<div
ref="dropdownMenu"
class="dropdown-menu alert-dropdown-menu"
>
<div class="dropdown-title">
<span>{{ dropdownTitle }}</span>
<button
class="dropdown-title-button dropdown-menu-close"
type="button"
aria-label="Close"
@click="handleDropdownClose"
>
<icon
:size="12"
name="close"
aria-hidden="true"
/>
</button>
</div>
<div class="dropdown-content">
<alert-widget-form
ref="widgetForm"
:disabled="formDisabled"
:alert="firstAlert"
:alert-data="firstAlertData"
@create="handleCreate"
@update="handleUpdate"
@delete="handleDelete"
@cancel="handleDropdownClose"
/>
</div>
</div>
</div>
</template>
<script>
import { __ } from '~/locale';
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
Vue.use(Translate);
const SUBMIT_ACTION_TEXT = {
create: __('Add'),
update: __('Save'),
delete: __('Delete'),
};
const SUBMIT_BUTTON_CLASS = {
create: 'btn-create',
update: 'btn-save',
delete: 'btn-remove',
};
const OPERATORS = {
greaterThan: '>',
equalTo: '=',
lessThan: '<',
};
export default {
props: {
disabled: {
type: Boolean,
required: true,
},
alert: {
type: String,
required: false,
default: null,
},
alertData: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
operators: OPERATORS,
operator: this.alertData.operator,
threshold: this.alertData.threshold,
};
},
computed: {
haveValuesChanged() {
return (
this.operator &&
this.threshold === Number(this.threshold) &&
(this.operator !== this.alertData.operator || this.threshold !== this.alertData.threshold)
);
},
submitAction() {
if (!this.alert) return 'create';
if (this.haveValuesChanged) return 'update';
return 'delete';
},
submitActionText() {
return SUBMIT_ACTION_TEXT[this.submitAction];
},
submitButtonClass() {
return SUBMIT_BUTTON_CLASS[this.submitAction];
},
isSubmitDisabled() {
return this.disabled || (this.submitAction === 'create' && !this.haveValuesChanged);
},
},
watch: {
alertData() {
this.resetAlertData();
},
},
methods: {
handleCancel() {
this.resetAlertData();
this.$emit('cancel');
},
handleSubmit() {
this.$refs.submitButton.blur();
this.$emit(this.submitAction, {
alert: this.alert,
operator: this.operator,
threshold: this.threshold,
});
},
resetAlertData() {
this.operator = this.alertData.operator;
this.threshold = this.alertData.threshold;
},
},
};
</script>
<template>
<div class="alert-form">
<div
:aria-label="s__('PrometheusAlerts|Operator')"
class="form-group btn-group"
role="group"
>
<button
:class="{ active: operator === operators.greaterThan }"
:disabled="disabled"
type="button"
class="btn btn-default"
@click="operator = operators.greaterThan"
>
{{ operators.greaterThan }}
</button>
<button
:class="{ active: operator === operators.equalTo }"
:disabled="disabled"
type="button"
class="btn btn-default"
@click="operator = operators.equalTo"
>
{{ operators.equalTo }}
</button>
<button
:class="{ active: operator === operators.lessThan }"
:disabled="disabled"
type="button"
class="btn btn-default"
@click="operator = operators.lessThan"
>
{{ operators.lessThan }}
</button>
</div>
<div class="form-group">
<label>{{ s__('PrometheusAlerts|Threshold') }}</label>
<input
v-model.number="threshold"
:disabled="disabled"
type="number"
class="form-control"
/>
</div>
<div class="action-group">
<button
ref="cancelButton"
:disabled="disabled"
type="button"
class="btn btn-default"
@click="handleCancel"
>
{{ __('Cancel') }}
</button>
<button
ref="submitButton"
:class="submitButtonClass"
:disabled="isSubmitDisabled"
type="button"
class="btn btn-inverted"
@click="handleSubmit"
>
{{ submitActionText }}
</button>
</div>
</div>
</template>
import AlertWidget from './alert_widget.vue';
export default {
components: {
AlertWidget,
},
props: {
alertsEndpoint: {
type: String,
required: false,
default: null,
},
},
methods: {
getGraphLabel(graphData) {
if (!graphData.queries || !graphData.queries[0]) return undefined;
return graphData.queries[0].label || graphData.y_label || 'Average';
},
getQueryAlerts(graphData) {
if (!graphData.queries) return [];
return graphData.queries.map(query => query.alert_path).filter(Boolean);
},
},
};
import axios from '~/lib/utils/axios_utils';
export default class AlertsService {
constructor({ alertsEndpoint }) {
this.alertsEndpoint = alertsEndpoint;
}
getAlerts() {
return axios.get(this.alertsEndpoint).then(resp => resp.data);
}
createAlert({ prometheus_metric_id, operator, threshold }) {
return axios
.post(this.alertsEndpoint, { prometheus_metric_id, operator, threshold })
.then(resp => resp.data);
}
// eslint-disable-next-line class-methods-use-this
readAlert(alertPath) {
return axios.get(alertPath).then(resp => resp.data);
}
// eslint-disable-next-line class-methods-use-this
updateAlert(alertPath, { operator, threshold }) {
return axios.put(alertPath, { operator, threshold }).then(resp => resp.data);
}
// eslint-disable-next-line class-methods-use-this
deleteAlert(alertPath) {
return axios.delete(alertPath).then(resp => resp.data);
}
}
...@@ -4,6 +4,7 @@ ...@@ -4,6 +4,7 @@
* Fixed: [name] in [link]:[line] * Fixed: [name] in [link]:[line]
*/ */
import ReportLink from '~/vue_shared/components/reports/report_link.vue'; import ReportLink from '~/vue_shared/components/reports/report_link.vue';
import { STATUS_SUCCESS } from '~/vue_shared/components/reports/constants';
export default { export default {
name: 'CodequalityIssueBody', name: 'CodequalityIssueBody',
...@@ -11,10 +12,9 @@ export default { ...@@ -11,10 +12,9 @@ export default {
components: { components: {
ReportLink, ReportLink,
}, },
props: { props: {
isStatusSuccess: { status: {
type: Boolean, type: String,
required: true, required: true,
}, },
issue: { issue: {
...@@ -22,6 +22,11 @@ export default { ...@@ -22,6 +22,11 @@ export default {
required: true, required: true,
}, },
}, },
computed: {
isStatusSuccess() {
return this.status === STATUS_SUCCESS;
},
},
}; };
</script> </script>
<template> <template>
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { s__, sprintf } from '~/locale'; import { s__, sprintf } from '~/locale';
export default { export default {
name: 'LicenseIssueBody',
props: { props: {
issue: { issue: {
type: Object, type: Object,
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import ReportSection from '~/vue_shared/components/reports/report_section.vue'; import ReportSection from '~/vue_shared/components/reports/report_section.vue';
import GroupedSecurityReportsApp from 'ee/vue_shared/security_reports/grouped_security_reports_app.vue'; import GroupedSecurityReportsApp from 'ee/vue_shared/security_reports/grouped_security_reports_app.vue';
import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin'; import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin';
import { componentNames } from 'ee/vue_shared/components/reports/issue_body';
import { n__, s__, __, sprintf } from '~/locale'; import { n__, s__, __, sprintf } from '~/locale';
import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
...@@ -17,6 +18,7 @@ export default { ...@@ -17,6 +18,7 @@ export default {
}, },
extends: CEWidgetOptions, extends: CEWidgetOptions,
mixins: [reportsMixin], mixins: [reportsMixin],
componentNames,
data() { data() {
return { return {
isLoadingCodequality: false, isLoadingCodequality: false,
...@@ -255,8 +257,8 @@ export default { ...@@ -255,8 +257,8 @@ export default {
:unresolved-issues="mr.codeclimateMetrics.newIssues" :unresolved-issues="mr.codeclimateMetrics.newIssues"
:resolved-issues="mr.codeclimateMetrics.resolvedIssues" :resolved-issues="mr.codeclimateMetrics.resolvedIssues"
:has-issues="hasCodequalityIssues" :has-issues="hasCodequalityIssues"
:component="$options.componentNames.CodequalityIssueBody"
class="js-codequality-widget mr-widget-border-top" class="js-codequality-widget mr-widget-border-top"
type="codequality"
/> />
<report-section <report-section
v-if="shouldRenderPerformance" v-if="shouldRenderPerformance"
...@@ -268,8 +270,8 @@ export default { ...@@ -268,8 +270,8 @@ export default {
:resolved-issues="mr.performanceMetrics.improved" :resolved-issues="mr.performanceMetrics.improved"
:neutral-issues="mr.performanceMetrics.neutral" :neutral-issues="mr.performanceMetrics.neutral"
:has-issues="hasPerformanceMetrics" :has-issues="hasPerformanceMetrics"
:component="$options.componentNames.PerformanceIssueBody"
class="js-performance-widget mr-widget-border-top" class="js-performance-widget mr-widget-border-top"
type="performance"
/> />
<grouped-security-reports-app <grouped-security-reports-app
v-if="shouldRenderSecurityReport" v-if="shouldRenderSecurityReport"
...@@ -299,10 +301,10 @@ export default { ...@@ -299,10 +301,10 @@ export default {
:loading-text="translateText('license management').loading" :loading-text="translateText('license management').loading"
:error-text="translateText('license management').error" :error-text="translateText('license management').error"
:success-text="licenseReportText" :success-text="licenseReportText"
:unresolved-issues="mr.licenseReport" :neutral-issues="mr.licenseReport"
:has-issues="hasLicenseReportIssues" :has-issues="hasLicenseReportIssues"
:component="$options.componentNames.LicenseIssueBody"
class="js-license-report-widget mr-widget-border-top" class="js-license-report-widget mr-widget-border-top"
type="license"
/> />
<div class="mr-section-container"> <div class="mr-section-container">
<div class="mr-widget-section"> <div class="mr-widget-section">
......
import {
components as componentsCE,
componentNames as componentNamesCE,
} from '~/vue_shared/components/reports/issue_body';
import PerformanceIssueBody from 'ee/vue_merge_request_widget/components/performance_issue_body.vue';
import CodequalityIssueBody from 'ee/vue_merge_request_widget/components/codequality_issue_body.vue';
import LicenseIssueBody from 'ee/vue_merge_request_widget/components/license_issue_body.vue';
import SastIssueBody from 'ee/vue_shared/security_reports/components/sast_issue_body.vue';
import SastContainerIssueBody from 'ee/vue_shared/security_reports/components/sast_container_issue_body.vue';
import DastIssueBody from 'ee/vue_shared/security_reports/components/dast_issue_body.vue';
export const components = {
...componentsCE,
PerformanceIssueBody,
CodequalityIssueBody,
LicenseIssueBody,
SastContainerIssueBody,
SastIssueBody,
DastIssueBody,
};
export const componentNames = {
...componentNamesCE,
PerformanceIssueBody: PerformanceIssueBody.name,
CodequalityIssueBody: CodequalityIssueBody.name,
LicenseIssueBody: LicenseIssueBody.name,
SastContainerIssueBody: SastContainerIssueBody.name,
SastIssueBody: SastIssueBody.name,
DastIssueBody: DastIssueBody.name,
};
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
import ModalOpenName from '~/vue_shared/components/reports/modal_open_name.vue'; import ModalOpenName from '~/vue_shared/components/reports/modal_open_name.vue';
export default { export default {
name: 'SastIssueBody', name: 'DastIssueBody',
components: { components: {
ModalOpenName, ModalOpenName,
}, },
...@@ -16,11 +16,6 @@ export default { ...@@ -16,11 +16,6 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
issueIndex: {
type: Number,
required: true,
},
// failed || success // failed || success
status: { status: {
type: String, type: String,
......
...@@ -3,8 +3,8 @@ import { mapActions, mapState, mapGetters } from 'vuex'; ...@@ -3,8 +3,8 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import ReportSection from '~/vue_shared/components/reports/report_section.vue'; import ReportSection from '~/vue_shared/components/reports/report_section.vue';
import SummaryRow from '~/vue_shared/components/reports/summary_row.vue'; import SummaryRow from '~/vue_shared/components/reports/summary_row.vue';
import IssuesList from '~/vue_shared/components/reports/issues_list.vue'; import IssuesList from '~/vue_shared/components/reports/issues_list.vue';
import { componentNames } from 'ee/vue_shared/components/reports/issue_body';
import IssueModal from './components/modal.vue'; import IssueModal from './components/modal.vue';
import { SAST, DAST, SAST_CONTAINER } from './store/constants';
import securityReportsMixin from './mixins/security_report_mixin'; import securityReportsMixin from './mixins/security_report_mixin';
import createStore from './store'; import createStore from './store';
...@@ -111,9 +111,7 @@ export default { ...@@ -111,9 +111,7 @@ export default {
required: true, required: true,
}, },
}, },
sast: SAST, componentNames,
dast: DAST,
sastContainer: SAST_CONTAINER,
computed: { computed: {
...mapState(['sast', 'sastContainer', 'dast', 'dependencyScanning', 'summaryCounts']), ...mapState(['sast', 'sastContainer', 'dast', 'dependencyScanning', 'summaryCounts']),
...mapGetters([ ...mapGetters([
...@@ -229,7 +227,7 @@ export default { ...@@ -229,7 +227,7 @@ export default {
:unresolved-issues="sast.newIssues" :unresolved-issues="sast.newIssues"
:resolved-issues="sast.resolvedIssues" :resolved-issues="sast.resolvedIssues"
:all-issues="sast.allIssues" :all-issues="sast.allIssues"
:type="$options.sast" :component="$options.componentNames.SastIssueBody"
class="js-sast-issue-list report-block-group-list" class="js-sast-issue-list report-block-group-list"
/> />
</template> </template>
...@@ -248,7 +246,7 @@ export default { ...@@ -248,7 +246,7 @@ export default {
:unresolved-issues="dependencyScanning.newIssues" :unresolved-issues="dependencyScanning.newIssues"
:resolved-issues="dependencyScanning.resolvedIssues" :resolved-issues="dependencyScanning.resolvedIssues"
:all-issues="dependencyScanning.allIssues" :all-issues="dependencyScanning.allIssues"
:type="$options.sast" :component="$options.componentNames.SastIssueBody"
class="js-dss-issue-list report-block-group-list" class="js-dss-issue-list report-block-group-list"
/> />
</template> </template>
...@@ -265,7 +263,7 @@ export default { ...@@ -265,7 +263,7 @@ export default {
v-if="sastContainer.newIssues.length || sastContainer.resolvedIssues.length" v-if="sastContainer.newIssues.length || sastContainer.resolvedIssues.length"
:unresolved-issues="sastContainer.newIssues" :unresolved-issues="sastContainer.newIssues"
:neutral-issues="sastContainer.resolvedIssues" :neutral-issues="sastContainer.resolvedIssues"
:type="$options.sastContainer" :component="$options.componentNames.SastContainerIssueBody"
class="report-block-group-list" class="report-block-group-list"
/> />
</template> </template>
...@@ -282,7 +280,7 @@ export default { ...@@ -282,7 +280,7 @@ export default {
v-if="dast.newIssues.length || dast.resolvedIssues.length" v-if="dast.newIssues.length || dast.resolvedIssues.length"
:unresolved-issues="dast.newIssues" :unresolved-issues="dast.newIssues"
:resolved-issues="dast.resolvedIssues" :resolved-issues="dast.resolvedIssues"
:type="$options.dast" :component="$options.componentNames.DastIssueBody"
class="report-block-group-list" class="report-block-group-list"
/> />
</template> </template>
......
...@@ -3,7 +3,7 @@ import { mapActions, mapState } from 'vuex'; ...@@ -3,7 +3,7 @@ import { mapActions, mapState } from 'vuex';
import { s__, sprintf, n__ } from '~/locale'; import { s__, sprintf, n__ } from '~/locale';
import createFlash from '~/flash'; import createFlash from '~/flash';
import ReportSection from '~/vue_shared/components/reports/report_section.vue'; import ReportSection from '~/vue_shared/components/reports/report_section.vue';
import { SAST, DAST, SAST_CONTAINER } from './store/constants'; import { componentNames } from 'ee/vue_shared/components/reports/issue_body';
import IssueModal from './components/modal.vue'; import IssueModal from './components/modal.vue';
import mixin from './mixins/security_report_mixin'; import mixin from './mixins/security_report_mixin';
import reportsMixin from './mixins/reports_mixin'; import reportsMixin from './mixins/reports_mixin';
...@@ -88,9 +88,7 @@ export default { ...@@ -88,9 +88,7 @@ export default {
required: true, required: true,
}, },
}, },
sast: SAST, componentNames,
dast: DAST,
sastContainer: SAST_CONTAINER,
computed: { computed: {
...mapState(['sast', 'dependencyScanning', 'sastContainer', 'dast']), ...mapState(['sast', 'dependencyScanning', 'sastContainer', 'dast']),
...@@ -216,7 +214,7 @@ export default { ...@@ -216,7 +214,7 @@ export default {
<report-section <report-section
v-if="sastHeadPath" v-if="sastHeadPath"
:always-open="alwaysOpen" :always-open="alwaysOpen"
:type="$options.sast" :component="$options.componentNames.SastIssueBody"
:status="checkReportStatus(sast.isLoading, sast.hasError)" :status="checkReportStatus(sast.isLoading, sast.hasError)"
:loading-text="translateText('SAST').loading" :loading-text="translateText('SAST').loading"
:error-text="translateText('SAST').error" :error-text="translateText('SAST').error"
...@@ -230,7 +228,7 @@ export default { ...@@ -230,7 +228,7 @@ export default {
<report-section <report-section
v-if="dependencyScanningHeadPath" v-if="dependencyScanningHeadPath"
:always-open="alwaysOpen" :always-open="alwaysOpen"
:type="$options.sast" :component="$options.componentNames.SastIssueBody"
:status="checkReportStatus(dependencyScanning.isLoading, dependencyScanning.hasError)" :status="checkReportStatus(dependencyScanning.isLoading, dependencyScanning.hasError)"
:loading-text="translateText('Dependency scanning').loading" :loading-text="translateText('Dependency scanning').loading"
:error-text="translateText('Dependency scanning').error" :error-text="translateText('Dependency scanning').error"
...@@ -244,7 +242,7 @@ export default { ...@@ -244,7 +242,7 @@ export default {
<report-section <report-section
v-if="sastContainerHeadPath" v-if="sastContainerHeadPath"
:always-open="alwaysOpen" :always-open="alwaysOpen"
:type="$options.sastContainer" :component="$options.componentNames.SastContainerIssueBody"
:status="checkReportStatus(sastContainer.isLoading, sastContainer.hasError)" :status="checkReportStatus(sastContainer.isLoading, sastContainer.hasError)"
:loading-text="translateText('Container scanning').loading" :loading-text="translateText('Container scanning').loading"
:error-text="translateText('Container scanning').error" :error-text="translateText('Container scanning').error"
...@@ -258,7 +256,7 @@ export default { ...@@ -258,7 +256,7 @@ export default {
<report-section <report-section
v-if="dastHeadPath" v-if="dastHeadPath"
:always-open="alwaysOpen" :always-open="alwaysOpen"
:type="$options.dast" :component="$options.componentNames.DastIssueBody"
:status="checkReportStatus(dast.isLoading, dast.hasError)" :status="checkReportStatus(dast.isLoading, dast.hasError)"
:loading-text="translateText('DAST').loading" :loading-text="translateText('DAST').loading"
:error-text="translateText('DAST').error" :error-text="translateText('DAST').error"
......
...@@ -47,7 +47,7 @@ module EE ...@@ -47,7 +47,7 @@ module EE
def update def update
@metric = project.prometheus_metrics.find(params[:id]) # rubocop:disable Gitlab/ModuleWithInstanceVariables @metric = project.prometheus_metrics.find(params[:id]) # rubocop:disable Gitlab/ModuleWithInstanceVariables
@metric.update(metrics_params) # rubocop:disable Gitlab/ModuleWithInstanceVariables @metric = update_metrics_service(@metric).execute # rubocop:disable Gitlab/ModuleWithInstanceVariables
if @metric.persisted? # rubocop:disable Gitlab/ModuleWithInstanceVariables if @metric.persisted? # rubocop:disable Gitlab/ModuleWithInstanceVariables
redirect_to edit_project_service_path(project, ::PrometheusService), redirect_to edit_project_service_path(project, ::PrometheusService),
...@@ -63,7 +63,7 @@ module EE ...@@ -63,7 +63,7 @@ module EE
def destroy def destroy
metric = project.prometheus_metrics.find(params[:id]) metric = project.prometheus_metrics.find(params[:id])
metric.destroy destroy_metrics_service(metric).execute
respond_to do |format| respond_to do |format|
format.html do format.html do
...@@ -77,6 +77,14 @@ module EE ...@@ -77,6 +77,14 @@ module EE
private private
def update_metrics_service(metric)
::Projects::Prometheus::Metrics::UpdateService.new(metric, metrics_params)
end
def destroy_metrics_service(metric)
::Projects::Prometheus::Metrics::DestroyService.new(metric)
end
def metrics_params def metrics_params
params.require(:prometheus_metric).permit(:title, :query, :y_label, :unit, :legend, :group) params.require(:prometheus_metric).permit(:title, :query, :y_label, :unit, :legend, :group)
end end
......
module Projects
module Prometheus
class AlertsController < Projects::ApplicationController
respond_to :json
protect_from_forgery except: [:notify]
before_action :authorize_read_prometheus_alerts!, except: [:notify]
before_action :authorize_admin_project!, except: [:notify]
before_action :alert, only: [:update, :show, :destroy]
def index
alerts = project.prometheus_alerts.reorder(id: :asc)
render json: serialize_as_json(alerts)
end
def show
render json: serialize_as_json(alert)
end
def notify
NotificationService.new.async.prometheus_alerts_fired(project, params["alerts"])
head :ok
end
def create
@alert = project.prometheus_alerts.create(alerts_params)
if @alert
schedule_prometheus_update!
render json: serialize_as_json(@alert)
else
head :no_content
end
end
def update
if alert.update(alerts_params)
schedule_prometheus_update!
render json: serialize_as_json(alert)
else
head :no_content
end
end
def destroy
if alert.destroy
schedule_prometheus_update!
head :ok
else
head :no_content
end
end
private
def alerts_params
alerts_params = params.permit(:operator, :threshold, :environment_id, :prometheus_metric_id)
if alerts_params[:operator].present?
alerts_params[:operator] = PrometheusAlert.operator_to_enum(alerts_params[:operator])
end
alerts_params
end
def schedule_prometheus_update!
::Clusters::Applications::ScheduleUpdateService.new(application, project).execute
end
def serialize_as_json(alert_obj)
serializer.represent(alert_obj)
end
def serializer
PrometheusAlertSerializer.new(project: project, current_user: current_user)
end
def alert
@alert ||= project.prometheus_alerts.find_by(prometheus_metric: params[:id]) || render_404
end
def application
@application ||= alert.environment.cluster_prometheus_adapter
end
end
end
end
module EE
module EnvironmentsHelper
def metrics_data(project, environment)
ee_metrics_data = {
"alerts-endpoint" => project_prometheus_alerts_path(project, environment_id: environment.id, format: :json),
"prometheus-alerts-available" => "#{can?(current_user, :read_prometheus_alerts, project)}"
}
super.merge(ee_metrics_data)
end
end
end
...@@ -17,6 +17,25 @@ module Emails ...@@ -17,6 +17,25 @@ module Emails
mail(to: new_mirror_user.notification_email, mail(to: new_mirror_user.notification_email,
subject: subject('Mirror user changed')) subject: subject('Mirror user changed'))
end end
def prometheus_alert_fired_email(project_id, user_id, alert_params)
alert_metric_id = alert_params["labels"]["gitlab_alert_id"]
@project = Project.find_by(id: project_id)
return unless @project
@alert = @project.prometheus_alerts.find_by(prometheus_metric: alert_metric_id)
return unless @alert
@environment = @alert.environment
user = User.find_by(id: user_id)
return unless user
subject_text = "Alert: #{@environment.name} - #{@alert.title} #{@alert.computed_operator} #{@alert.threshold} for 5 minutes"
mail(to: user.notification_email, subject: subject(subject_text))
end
end end
end end
end end
module EE
module Clusters
module ApplicationStatus
extend ActiveSupport::Concern
prepended do
state_machine :status, initial: :not_installable do
state :updating, value: 4
state :updated, value: 5
state :update_errored, value: 6
event :make_updating do
transition [:installed, :updated, :update_errored] => :updating
end
event :make_updated do
transition [:updating] => :updated
end
event :make_update_errored do
transition any => :update_errored
end
before_transition any => [:updating] do |app_status, _|
app_status.status_reason = nil
end
before_transition any => [:update_errored] do |app_status, transition|
status_reason = transition.args.first
app_status.status_reason = status_reason if status_reason
end
end
end
end
end
end
module EE
module PrometheusAdapter
extend ::Gitlab::Utils::Override
def clear_prometheus_reactive_cache!(query_name, *args)
query_class = query_klass_for(query_name)
query_args = build_query_args(*args)
clear_reactive_cache!(query_class.name, *query_args)
end
private
override :build_query_args
def build_query_args(*args)
args.map do |arg|
arg.respond_to?(:id) ? arg.id : arg
end
end
end
end
module EE
module Clusters
module Applications
module Prometheus
extend ActiveSupport::Concern
prepended do
state_machine :status do
after_transition any => :updating do |application|
application.update(last_update_started_at: Time.now)
end
end
end
def ready_status
super + [:updating, :updated, :update_errored]
end
def updated_since?(timestamp)
last_update_started_at &&
last_update_started_at > timestamp &&
!update_errored?
end
def update_in_progress?
status_name == :updating
end
def update_errored?
status_name == :update_errored
end
def get_command
::Gitlab::Kubernetes::Helm::GetCommand.new(name)
end
def upgrade_command(values)
::Gitlab::Kubernetes::Helm::UpgradeCommand.new(
name,
chart: chart,
version: version,
values: values
)
end
end
end
end
end
module EE module EE
module Environment module Environment
extend ActiveSupport::Concern
prepended do
has_many :prometheus_alerts, inverse_of: :environment
end
def pod_names def pod_names
return [] unless rollout_status return [] unless rollout_status
...@@ -7,5 +13,13 @@ module EE ...@@ -7,5 +13,13 @@ module EE
instance[:pod_name] instance[:pod_name]
end end
end end
def clear_prometheus_reactive_cache!(query_name)
cluster_prometheus_adapter&.clear_prometheus_reactive_cache!(query_name, self)
end
def cluster_prometheus_adapter
@cluster_prometheus_adapter ||= Prometheus::AdapterService.new(project, deployment_platform).cluster_prometheus_adapter
end
end end
end end
...@@ -39,6 +39,8 @@ module EE ...@@ -39,6 +39,8 @@ module EE
has_many :source_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :project_id has_many :source_pipelines, class_name: 'Ci::Sources::Pipeline', foreign_key: :project_id
has_many :prometheus_alerts, inverse_of: :project
scope :with_shared_runners_limit_enabled, -> { with_shared_runners.non_public_only } scope :with_shared_runners_limit_enabled, -> { with_shared_runners.non_public_only }
scope :mirror, -> { where(mirror: true) } scope :mirror, -> { where(mirror: true) }
...@@ -99,6 +101,12 @@ module EE ...@@ -99,6 +101,12 @@ module EE
pipelines.newest_first(default_branch).with_security_reports.first pipelines.newest_first(default_branch).with_security_reports.first
end end
def environments_for_scope(scope)
quoted_scope = ::Gitlab::SQL::Glob.q(scope)
environments.where("name LIKE (#{::Gitlab::SQL::Glob.to_like(quoted_scope)})") # rubocop:disable GitlabSecurity/SqlInjection
end
def ensure_external_webhook_token def ensure_external_webhook_token
return if external_webhook_token.present? return if external_webhook_token.present?
......
...@@ -75,6 +75,7 @@ class License < ActiveRecord::Base ...@@ -75,6 +75,7 @@ class License < ActiveRecord::Base
chatops chatops
pod_logs pod_logs
pseudonymizer pseudonymizer
prometheus_alerts
].freeze ].freeze
# List all features available for early adopters, # List all features available for early adopters,
......
class PrometheusAlert < ActiveRecord::Base
OPERATORS_MAP = {
lt: "<",
eq: "=",
gt: ">"
}.freeze
belongs_to :environment, required: true, validate: true, inverse_of: :prometheus_alerts
belongs_to :project, required: true, validate: true, inverse_of: :prometheus_alerts
belongs_to :prometheus_metric, required: true, validate: true, inverse_of: :prometheus_alert
after_save :clear_prometheus_adapter_cache!
after_destroy :clear_prometheus_adapter_cache!
enum operator: [:lt, :eq, :gt]
delegate :title, :query, to: :prometheus_metric
def self.operator_to_enum(op)
OPERATORS_MAP.invert.fetch(op)
end
def full_query
"#{query} #{computed_operator} #{threshold}"
end
def computed_operator
OPERATORS_MAP.fetch(operator.to_sym)
end
def to_param
{
"alert" => title,
"expr" => full_query,
"for" => "5m",
"labels" => {
"gitlab" => "hook",
"gitlab_alert_id" => prometheus_metric_id
}
}
end
private
def clear_prometheus_adapter_cache!
environment.clear_prometheus_reactive_cache!(:additional_metrics_environment)
end
end
class PrometheusMetric < ActiveRecord::Base class PrometheusMetric < ActiveRecord::Base
belongs_to :project, required: true, validate: true, inverse_of: :prometheus_metrics belongs_to :project, required: true, validate: true, inverse_of: :prometheus_metrics
has_one :prometheus_alert, inverse_of: :prometheus_metric
enum group: [:business, :response, :system] enum group: [:business, :response, :system]
validates :title, presence: true validates :title, presence: true
...@@ -19,7 +22,7 @@ class PrometheusMetric < ActiveRecord::Base ...@@ -19,7 +22,7 @@ class PrometheusMetric < ActiveRecord::Base
end end
def to_query_metric def to_query_metric
Gitlab::Prometheus::Metric.new(title: title, required_metrics: [], weight: 0, y_label: y_label, queries: build_queries) Gitlab::Prometheus::Metric.new(id: id, title: title, required_metrics: [], weight: 0, y_label: y_label, queries: build_queries)
end end
private private
......
...@@ -49,6 +49,10 @@ module EE ...@@ -49,6 +49,10 @@ module EE
@subject.feature_available?(:pod_logs, @user) @subject.feature_available?(:pod_logs, @user)
end end
condition(:prometheus_alerts_enabled) do
@subject.feature_available?(:prometheus_alerts, @user)
end
rule { admin }.enable :change_repository_storage rule { admin }.enable :change_repository_storage
rule { support_bot }.enable :guest_access rule { support_bot }.enable :guest_access
...@@ -97,6 +101,7 @@ module EE ...@@ -97,6 +101,7 @@ module EE
end end
rule { pod_logs_enabled & can?(:maintainer_access) }.enable :read_pod_logs rule { pod_logs_enabled & can?(:maintainer_access) }.enable :read_pod_logs
rule { prometheus_alerts_enabled & can?(:maintainer_access) }.enable :read_prometheus_alerts
rule { auditor }.policy do rule { auditor }.policy do
enable :public_user_access enable :public_user_access
......
class PrometheusAlertEntity < Grape::Entity
include RequestAwareEntity
expose :id
expose :title
expose :query
expose :threshold
expose :operator do |prometheus_alert|
prometheus_alert.computed_operator
end
expose :alert_path do |prometheus_alert|
project_prometheus_alert_path(prometheus_alert.project, prometheus_alert.prometheus_metric_id, environment_id: prometheus_alert.environment.id, format: :json)
end
private
alias_method :prometheus_alert, :object
def can_read_prometheus_alerts?
can?(request.current_user, :read_prometheus_alerts, prometheus_alert.project)
end
end
class PrometheusAlertSerializer < BaseSerializer
entity PrometheusAlertEntity
end
module Clusters
module Applications
class CheckUpgradeProgressService < BaseHelmService
def execute
return unless app.updating?
case phase
when ::Gitlab::Kubernetes::Pod::SUCCEEDED
on_success
when ::Gitlab::Kubernetes::Pod::FAILED
on_failed
else
check_timeout
end
rescue ::Kubeclient::HttpError => e
app.make_update_errored!("Kubernetes error: #{e.message}") unless app.update_errored?
end
private
def on_success
app.make_updated!
ensure
remove_pod
end
def on_failed
app.make_update_errored!(errors || 'Update silently failed')
ensure
remove_pod
end
def check_timeout
if timeouted?
begin
app.make_update_errored!('Update timed out')
ensure
remove_pod
end
else
::ClusterWaitForAppUpdateWorker.perform_in(
::ClusterWaitForAppUpdateWorker::INTERVAL, app.name, app.id)
end
end
def timeouted?
Time.now.utc - app.updated_at.to_time.utc > ::ClusterWaitForAppUpdateWorker::TIMEOUT
end
def remove_pod
helm_api.delete_pod!(upgrade_command.pod_name)
rescue
# no-op
end
def phase
helm_api.status(upgrade_command.pod_name)
end
def errors
helm_api.log(upgrade_command.pod_name)
end
end
end
end
module Clusters
module Applications
class PrometheusUpdateService < BaseHelmService
attr_accessor :project
def initialize(app, project)
super(app)
@project = project
end
def execute
app.make_updating!
response = helm_api.get_config_map(app.get_command)
config = extract_config(response)
data =
if has_alerts?
generate_alert_manager(config)
else
reset_alert_manager(config)
end
helm_api.update(upgrade_command(data.to_yaml))
::ClusterWaitForAppUpdateWorker.perform_in(::ClusterWaitForAppUpdateWorker::INTERVAL, app.name, app.id)
rescue ::Kubeclient::HttpError => ke
app.make_update_errored!("Kubernetes error: #{ke.message}")
rescue StandardError => e
app.make_update_errored!(e.message)
end
private
def reset_alert_manager(config)
config = set_alert_manager_enabled(config, false)
config.delete("alertmanagerFiles")
config["serverFiles"]["alerts"] = {}
config
end
def generate_alert_manager(config)
config = set_alert_manager_enabled(config, true)
config = set_alert_manager_files(config)
set_alert_manager_groups(config)
end
def set_alert_manager_enabled(config, enabled)
config["alertmanager"]["enabled"] = enabled
config
end
def set_alert_manager_files(config)
config["alertmanagerFiles"] = {
"alertmanager.yml" => {
"receivers" => alert_manager_receivers_params,
"route" => alert_manager_route_params
}
}
config
end
def set_alert_manager_groups(config)
config["serverFiles"]["alerts"]["groups"] ||= []
environments_with_alerts.each do |env_name, alerts|
index = config["serverFiles"]["alerts"]["groups"].find_index do |group|
group["name"] == env_name
end
if index
config["serverFiles"]["alerts"]["groups"][index]["rules"] = alerts
else
config["serverFiles"]["alerts"]["groups"] << {
"name" => env_name,
"rules" => alerts
}
end
end
config
end
def alert_manager_receivers_params
[
{
"name" => "gitlab",
"webhook_configs" => [
{
"url" => notify_url,
"send_resolved" => false
}
]
}
]
end
def alert_manager_route_params
{
"receiver" => "gitlab",
"group_wait" => "30s",
"group_interval" => "5m",
"repeat_interval" => "4h"
}
end
def notify_url
::Gitlab::Routing.url_helpers.notify_namespace_project_prometheus_alerts_url(
namespace_id: project.namespace.path,
project_id: project.path,
format: :json
)
end
def extract_config(response)
YAML.safe_load(response.data.values)
end
def has_alerts?
environments_with_alerts.values.flatten.any?
end
def environments_with_alerts
@environments_with_alerts ||=
environments.each_with_object({}) do |environment, hsh|
name = rule_name(environment)
hsh[name] = environment.prometheus_alerts.map(&:to_param)
end
end
def rule_name(environment)
"#{environment.name}.rules"
end
def environments
project.environments_for_scope(cluster.environment_scope)
end
end
end
end
module Clusters
module Applications
class ScheduleUpdateService
BACKOFF_DELAY = 2.minutes
attr_accessor :application, :project
def initialize(application, project)
@application = application
@project = project
end
def execute
return unless application
if recently_scheduled?
worker_class.perform_in(BACKOFF_DELAY, application.name, application.id, project.id, Time.now)
else
worker_class.perform_async(application.name, application.id, project.id, Time.now)
end
end
private
def worker_class
::ClusterUpdateAppWorker
end
def recently_scheduled?
return false unless application.last_update_started_at
application.last_update_started_at.utc >= Time.now.utc - BACKOFF_DELAY
end
end
end
end
module EE
module Clusters
module Applications
module BaseHelmService
protected
def upgrade_command(new_values = "")
@upgrade_command ||= app.upgrade_command(new_values)
end
end
end
end
end
...@@ -42,6 +42,18 @@ module EE ...@@ -42,6 +42,18 @@ module EE
mailer.project_mirror_user_changed_email(new_mirror_user.id, deleted_user_name, project.id).deliver_later mailer.project_mirror_user_changed_email(new_mirror_user.id, deleted_user_name, project.id).deliver_later
end end
def prometheus_alerts_fired(project, alerts)
recipients = project.members.active_without_invites_and_requests.owners_and_masters
if recipients.empty? && project.group
recipients = project.group.members.active_without_invites_and_requests.owners_and_masters
end
recipients.product(alerts).each do |recipient, alert|
mailer.prometheus_alert_fired_email(project.id, recipient.user.id, alert).deliver_later
end
end
private private
def add_mr_approvers_email(merge_request, approvers, current_user) def add_mr_approvers_email(merge_request, approvers, current_user)
......
module Projects
module Prometheus
module Metrics
class BaseService
def initialize(metric, params = {})
@metric = metric
@project = metric.project
@params = params.dup
end
protected
attr_reader :metric, :project, :params
def application
metric.prometheus_alert.environment.cluster_prometheus_adapter
end
def schedule_alert_update
::Clusters::Applications::ScheduleUpdateService.new(application, project).execute
end
def has_alert?
metric.prometheus_alert.present?
end
end
end
end
end
module Projects
module Prometheus
module Metrics
class DestroyService < Metrics::BaseService
def execute
schedule_alert_update if has_alert?
metric.destroy
end
end
end
end
end
module Projects
module Prometheus
module Metrics
class UpdateService < Metrics::BaseService
def execute
metric.update!(params)
schedule_alert_update if requires_alert_update?
metric
end
private
def requires_alert_update?
has_alert? && (changing_title? || changing_query?)
end
def changing_title?
metric.previous_changes.include?(:title)
end
def changing_query?
metric.previous_changes.include?(:query)
end
end
end
end
end
%p
An alert has been triggered in #{@project.full_path}.
%p
Environment: #{@environment.name}
%p
Metric:
%pre
= @alert.full_query
%p
= link_to("View #{@environment.name} performance dashboard.", metrics_project_environment_url(@environment.project, @environment))
An alert has been triggered in <%= @project.full_path %>.
Environment: <%= @environment.name %>
Metric: <%= @alert.full_query %>
You can view the <%= @environment.name %> performance dashboard at <%= metrics_project_environment_url(@environment.project, @environment) %>.
class ClusterUpdateAppWorker
UpdateAlreadyInProgressError = Class.new(StandardError)
include ApplicationWorker
include ClusterQueue
include ClusterApplications
sidekiq_options retry: 3, dead: false
def perform(app_name, app_id, project_id, scheduled_time)
project = Project.find_by(id: project_id)
return unless project
find_application(app_name, app_id) do |app|
break if app.updated_since?(scheduled_time)
break if app.update_in_progress?
Clusters::Applications::PrometheusUpdateService.new(app, project).execute
end
end
end
class ClusterWaitForAppUpdateWorker
include ApplicationWorker
include ClusterQueue
include ClusterApplications
INTERVAL = 10.seconds
TIMEOUT = 20.minutes
def perform(app_name, app_id)
find_application(app_name, app_id) do |app|
::Clusters::Applications::CheckUpgradeProgressService.new(app).execute
end
end
end
---
title: Adds SLI alerts to custom prometheus metrics
merge_request: 6590
author:
type: added
class CreatePrometheusAlerts < ActiveRecord::Migration
DOWNTIME = false
def up
create_table :prometheus_alerts do |t|
t.datetime_with_timezone :created_at, null: false
t.datetime_with_timezone :updated_at, null: false
t.float :threshold, null: false
t.integer :operator, null: false
t.references :environment, index: true, null: false, foreign_key: { on_delete: :cascade }
t.references :project, null: false, foreign_key: { on_delete: :cascade }
t.references :prometheus_metric, null: false, index: { unique: true }, foreign_key: { on_delete: :cascade }
end
end
def down
remove_foreign_key :prometheus_alerts, column: :project_id
drop_table :prometheus_alerts
end
end
class AddLastUpdateStartedAtToApplicationsPrometheus < ActiveRecord::Migration
DOWNTIME = false
def change
add_column :clusters_applications_prometheus, :last_update_started_at, :datetime_with_timezone
end
end
module EE
module Gitlab
module Kubernetes
module Helm
module Api
def get_config_map(command)
namespace.ensure_exists!
return unless command.config_map?
kubeclient.get_config_map(command.config_map_name, namespace.name)
end
def update(command)
namespace.ensure_exists!
update_config_map(command) if command.config_map?
kubeclient.create_pod(command.pod_resource)
end
private
def update_config_map(command)
command.config_map_resource.tap do |config_map_resource|
kubeclient.update_config_map(config_map_resource)
end
end
end
end
end
end
end
module EE
module Gitlab
module Prometheus
module Queries
module QueryAdditionalMetrics
def query_metrics(project, environment, query_context)
super.map(&query_with_alert(project, environment))
end
protected
def query_with_alert(project, environment)
alerts_map =
project.prometheus_alerts.each_with_object({}) do |alert, hsh|
hsh[alert[:prometheus_metric_id]] = alert.prometheus_metric_id
end
proc do |group|
group[:metrics] = group[:metrics]&.map do |metric|
key = metric[:id]
if key && alerts_map[key]
metric[:queries] = metric[:queries]&.map do |item|
item[:alert_path] = alert_path(alerts_map, key, project, environment)
item
end
end
metric
end
group
end
end
private
def alert_path(alerts_map, key, project, environment)
::Gitlab::Routing.url_helpers.project_prometheus_alert_path(project, alerts_map[key], environment_id: environment.id, format: :json)
end
end
end
end
end
end
require_dependency 'lib/gitlab/kubernetes/helm.rb'
module Gitlab
module Kubernetes
module Helm
class GetCommand < BaseCommand
def config_map?
true
end
def config_map_name
::Gitlab::Kubernetes::ConfigMap.new(name).config_map_name
end
end
end
end
end
require_dependency 'lib/gitlab/kubernetes/helm.rb'
module Gitlab
module Kubernetes
module Helm
class UpgradeCommand < BaseCommand
attr_reader :chart, :version, :repository, :values
def initialize(name, chart:, values:, version: nil, repository: nil)
super(name)
@chart = chart
@version = version
@values = values
@repository = repository
end
def generate_script
super + [
init_command,
repository_command,
script_command
].compact.join("\n")
end
def config_map?
true
end
def config_map_resource
::Gitlab::Kubernetes::ConfigMap.new(name, values).generate
end
def pod_name
"upgrade-#{name}"
end
private
def init_command
'helm init --client-only >/dev/null'
end
def repository_command
"helm repo add #{name} #{repository}" if repository
end
def script_command
<<~HEREDOC
helm upgrade #{name}#{optional_version_flag} #{chart} --reset-values --install --namespace #{::Gitlab::Kubernetes::Helm::NAMESPACE} -f /data/helm/#{name}/config/values.yaml >/dev/null
HEREDOC
end
def optional_version_flag
" --version #{version}" if version
end
end
end
end
end
require 'spec_helper'
describe Projects::Prometheus::AlertsController do
let(:user) { create(:user) }
let(:project) { create(:project) }
let(:environment) { create(:environment, project: project) }
let(:metric) { create(:prometheus_metric, project: project) }
before do
stub_licensed_features(prometheus_alerts: true)
project.add_master(user)
sign_in(user)
end
describe 'GET #index' do
context 'when project has no prometheus alert' do
it 'renders forbidden when unlicensed' do
stub_licensed_features(prometheus_alerts: false)
get :index, project_params
expect(response).to have_gitlab_http_status(:not_found)
end
it 'returns an empty response' do
get :index, project_params
expect(response).to have_gitlab_http_status(200)
expect(JSON.parse(response.body)).to be_empty
end
end
context 'when project has prometheus alerts' do
before do
create_list(:prometheus_alert, 3, project: project, environment: environment)
end
it 'contains prometheus alerts' do
get :index, project_params
expect(response).to have_gitlab_http_status(200)
expect(JSON.parse(response.body).count).to eq(3)
end
end
end
describe 'GET #show' do
context 'when alert does not exist' do
it 'renders 404' do
get :show, project_params(id: PrometheusAlert.all.maximum(:prometheus_metric_id).to_i + 1)
expect(response).to have_gitlab_http_status(404)
end
end
context 'when alert exists' do
let(:alert) { create(:prometheus_alert, project: project, environment: environment, prometheus_metric: metric) }
it 'renders forbidden when unlicensed' do
stub_licensed_features(prometheus_alerts: false)
get :show, project_params(id: alert.prometheus_metric_id)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'renders the alert' do
alert_params = {
"id" => alert.id,
"title" => alert.title,
"query" => alert.query,
"operator" => alert.computed_operator,
"threshold" => alert.threshold,
"alert_path" => Gitlab::Routing.url_helpers.project_prometheus_alert_path(project, alert.prometheus_metric_id, environment_id: alert.environment.id, format: :json)
}
get :show, project_params(id: alert.prometheus_metric_id)
expect(response).to have_gitlab_http_status(200)
expect(JSON.parse(response.body)).to include(alert_params)
end
end
end
describe 'POST #notify' do
it 'sends a notification' do
alert = create(:prometheus_alert, project: project, environment: environment, prometheus_metric: metric)
notification_service = spy
alert_params = {
"alert" => alert.title,
"expr" => "#{alert.query} #{alert.computed_operator} #{alert.threshold}",
"for" => "5m",
"labels" => {
"gitlab" => "hook",
"gitlab_alert_id" => alert.prometheus_metric_id
}
}
allow(NotificationService).to receive(:new).and_return(notification_service)
expect(notification_service).to receive_message_chain(:async, :prometheus_alerts_fired).with(project, [alert_params])
post :notify, project_params(alerts: [alert])
expect(response).to have_gitlab_http_status(200)
end
end
describe 'POST #create' do
it 'renders forbidden when unlicensed' do
stub_licensed_features(prometheus_alerts: false)
post :create, project_params(
operator: ">",
threshold: "1",
environment_id: environment.id,
prometheus_metric_id: metric.id
)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'creates a new prometheus alert' do
schedule_update_service = spy
alert_params = {
"title" => metric.title,
"query" => metric.query,
"operator" => ">",
"threshold" => 1.0
}
allow(::Clusters::Applications::ScheduleUpdateService).to receive(:new).and_return(schedule_update_service)
post :create, project_params(
operator: ">",
threshold: "1",
environment_id: environment.id,
prometheus_metric_id: metric.id
)
expect(schedule_update_service).to have_received(:execute)
expect(response).to have_gitlab_http_status(200)
expect(JSON.parse(response.body)).to include(alert_params)
end
end
describe 'POST #update' do
let(:schedule_update_service) { spy }
let(:alert) { create(:prometheus_alert, project: project, environment: environment, prometheus_metric: metric) }
before do
allow(::Clusters::Applications::ScheduleUpdateService).to receive(:new).and_return(schedule_update_service)
end
it 'renders forbidden when unlicensed' do
stub_licensed_features(prometheus_alerts: false)
put :update, project_params(id: alert.prometheus_metric_id, operator: "<")
expect(response).to have_gitlab_http_status(:not_found)
end
it 'updates an already existing prometheus alert' do
alert_params = {
"id" => alert.id,
"title" => alert.title,
"query" => alert.query,
"operator" => "<",
"threshold" => alert.threshold,
"alert_path" => Gitlab::Routing.url_helpers.project_prometheus_alert_path(project, alert.prometheus_metric_id, environment_id: alert.environment.id, format: :json)
}
expect do
put :update, project_params(id: alert.prometheus_metric_id, operator: "<")
end.to change { alert.reload.operator }.to("lt")
expect(schedule_update_service).to have_received(:execute)
expect(response).to have_gitlab_http_status(200)
expect(JSON.parse(response.body)).to include(alert_params)
end
end
describe 'DELETE #destroy' do
let(:schedule_update_service) { spy }
let!(:alert) { create(:prometheus_alert, project: project, prometheus_metric: metric) }
before do
allow(::Clusters::Applications::ScheduleUpdateService).to receive(:new).and_return(schedule_update_service)
end
it 'renders forbidden when unlicensed' do
stub_licensed_features(prometheus_alerts: false)
delete :destroy, project_params(id: alert.prometheus_metric_id)
expect(response).to have_gitlab_http_status(:not_found)
end
it 'destroys the specified prometheus alert' do
expect do
delete :destroy, project_params(id: alert.prometheus_metric_id)
end.to change { PrometheusAlert.count }.from(1).to(0)
expect(schedule_update_service).to have_received(:execute)
end
end
def project_params(opts = {})
opts.reverse_merge(namespace_id: project.namespace, project_id: project)
end
end
FactoryBot.define do
factory :prometheus_alert do
project
environment
prometheus_metric
operator :gt
threshold 1
end
end
import Vue from 'vue'; import Vue from 'vue';
import component from 'ee/vue_merge_request_widget/components/codequality_issue_body.vue'; import component from 'ee/vue_merge_request_widget/components/codequality_issue_body.vue';
import mountComponent from '../../helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import {
STATUS_FAILED,
STATUS_NEUTRAL,
STATUS_SUCCESS,
} from '~/vue_shared/components/reports/constants';
describe('sast issue body', () => { describe('code quality issue body issue body', () => {
let vm; let vm;
const Component = Vue.extend(component); const Component = Vue.extend(component);
...@@ -22,7 +27,7 @@ describe('sast issue body', () => { ...@@ -22,7 +27,7 @@ describe('sast issue body', () => {
it('renders fixed label', () => { it('renders fixed label', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
issue: codequalityIssue, issue: codequalityIssue,
isStatusSuccess: true, status: STATUS_SUCCESS,
}); });
expect(vm.$el.textContent.trim()).toContain('Fixed'); expect(vm.$el.textContent.trim()).toContain('Fixed');
...@@ -33,7 +38,7 @@ describe('sast issue body', () => { ...@@ -33,7 +38,7 @@ describe('sast issue body', () => {
it('renders fixed label', () => { it('renders fixed label', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
issue: codequalityIssue, issue: codequalityIssue,
isStatusSuccess: false, status: STATUS_FAILED,
}); });
expect(vm.$el.textContent.trim()).not.toContain('Fixed'); expect(vm.$el.textContent.trim()).not.toContain('Fixed');
...@@ -44,7 +49,7 @@ describe('sast issue body', () => { ...@@ -44,7 +49,7 @@ describe('sast issue body', () => {
it('renders name', () => { it('renders name', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
issue: codequalityIssue, issue: codequalityIssue,
isStatusSuccess: false, status: STATUS_NEUTRAL,
}); });
expect(vm.$el.textContent.trim()).toContain(codequalityIssue.name); expect(vm.$el.textContent.trim()).toContain(codequalityIssue.name);
...@@ -55,15 +60,11 @@ describe('sast issue body', () => { ...@@ -55,15 +60,11 @@ describe('sast issue body', () => {
it('renders name', () => { it('renders name', () => {
vm = mountComponent(Component, { vm = mountComponent(Component, {
issue: codequalityIssue, issue: codequalityIssue,
isStatusSuccess: false, status: STATUS_NEUTRAL,
}); });
expect(vm.$el.querySelector('a').getAttribute('href')).toEqual( expect(vm.$el.querySelector('a').getAttribute('href')).toEqual(codequalityIssue.urlPath);
codequalityIssue.urlPath, expect(vm.$el.querySelector('a').textContent.trim()).toEqual(codequalityIssue.path);
);
expect(vm.$el.querySelector('a').textContent.trim()).toEqual(
codequalityIssue.path,
);
}); });
}); });
}); });
import Vue from 'vue'; import Vue from 'vue';
import component from 'ee/vue_merge_request_widget/components/performance_issue_body.vue'; import component from 'ee/vue_merge_request_widget/components/performance_issue_body.vue';
import mountComponent from '../../helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('performance issue body', () => { describe('performance issue body', () => {
let vm; let vm;
......
import Vue from 'vue'; import Vue from 'vue';
import reportIssues from '~/vue_shared/components/reports/report_issues.vue'; import reportIssues from '~/vue_shared/components/reports/report_issues.vue';
import { STATUS_FAILED, STATUS_SUCCESS } from '~/vue_shared/components/reports/constants';
import { componentNames } from 'ee/vue_shared/components/reports/issue_body';
import store from 'ee/vue_shared/security_reports/store'; import store from 'ee/vue_shared/security_reports/store';
import mountComponent, { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import mountComponent, { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { import { codequalityParsedIssues } from 'spec/vue_mr_widget/mock_data';
codequalityParsedIssues,
} from 'spec/vue_mr_widget/mock_data';
import { import {
sastParsedIssues, sastParsedIssues,
dockerReportParsed, dockerReportParsed,
...@@ -28,8 +28,8 @@ describe('Report issues', () => { ...@@ -28,8 +28,8 @@ describe('Report issues', () => {
beforeEach(() => { beforeEach(() => {
vm = mountComponent(ReportIssues, { vm = mountComponent(ReportIssues, {
issues: codequalityParsedIssues, issues: codequalityParsedIssues,
type: 'codequality', component: componentNames.CodequalityIssueBody,
status: 'success', status: STATUS_SUCCESS,
}); });
}); });
...@@ -49,8 +49,8 @@ describe('Report issues', () => { ...@@ -49,8 +49,8 @@ describe('Report issues', () => {
beforeEach(() => { beforeEach(() => {
vm = mountComponent(ReportIssues, { vm = mountComponent(ReportIssues, {
issues: codequalityParsedIssues, issues: codequalityParsedIssues,
type: 'codequality', component: componentNames.CodequalityIssueBody,
status: 'failed', status: STATUS_FAILED,
}); });
}); });
...@@ -68,8 +68,8 @@ describe('Report issues', () => { ...@@ -68,8 +68,8 @@ describe('Report issues', () => {
beforeEach(() => { beforeEach(() => {
vm = mountComponent(ReportIssues, { vm = mountComponent(ReportIssues, {
issues: sastParsedIssues, issues: sastParsedIssues,
type: 'SAST', component: componentNames.SastIssueBody,
status: 'failed', status: STATUS_FAILED,
}); });
}); });
...@@ -82,8 +82,8 @@ describe('Report issues', () => { ...@@ -82,8 +82,8 @@ describe('Report issues', () => {
it('should render location', () => { it('should render location', () => {
vm = mountComponent(ReportIssues, { vm = mountComponent(ReportIssues, {
issues: sastParsedIssues, issues: sastParsedIssues,
type: 'SAST', component: componentNames.SastIssueBody,
status: 'failed', status: STATUS_FAILED,
}); });
expect(vm.$el.querySelector('.report-block-list li').textContent).toContain('in'); expect(vm.$el.querySelector('.report-block-list li').textContent).toContain('in');
...@@ -97,8 +97,8 @@ describe('Report issues', () => { ...@@ -97,8 +97,8 @@ describe('Report issues', () => {
issues: [{ issues: [{
title: 'foo', title: 'foo',
}], }],
type: 'SAST', component: componentNames.SastIssueBody,
status: 'failed', status: STATUS_SUCCESS,
}); });
expect(vm.$el.querySelector('.report-block-list li').textContent).not.toContain('in'); expect(vm.$el.querySelector('.report-block-list li').textContent).not.toContain('in');
...@@ -110,8 +110,8 @@ describe('Report issues', () => { ...@@ -110,8 +110,8 @@ describe('Report issues', () => {
beforeEach(() => { beforeEach(() => {
vm = mountComponent(ReportIssues, { vm = mountComponent(ReportIssues, {
issues: dockerReportParsed.unapproved, issues: dockerReportParsed.unapproved,
type: 'SAST_CONTAINER', component: componentNames.SastContainerIssueBody,
status: 'failed', status: STATUS_FAILED,
}); });
}); });
...@@ -142,8 +142,8 @@ describe('Report issues', () => { ...@@ -142,8 +142,8 @@ describe('Report issues', () => {
vm = mountComponentWithStore(ReportIssues, { store, vm = mountComponentWithStore(ReportIssues, { store,
props: { props: {
issues: parsedDast, issues: parsedDast,
type: 'DAST', component: componentNames.DastIssueBody,
status: 'failed', status: STATUS_FAILED,
}, },
}); });
}); });
......
// eslint-disable-next-line import/prefer-default-export
export const fullReport = {
status: 'SUCCESS',
successText: 'SAST improved on 1 security vulnerability and degraded on 1 security vulnerability',
errorText: 'Failed to load security report',
hasIssues: true,
loadingText: 'Loading security report',
resolvedIssues: [
{
cve: 'CVE-2016-9999',
file: 'Gemfile.lock',
message: 'Test Information Leak Vulnerability in Action View',
title: 'Test Information Leak Vulnerability in Action View',
path: 'Gemfile.lock',
solution:
'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
tool: 'bundler_audit',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
urlPath: '/Gemfile.lock',
},
],
unresolvedIssues: [
{
cve: 'CVE-2014-7829',
file: 'Gemfile.lock',
message: 'Arbitrary file existence disclosure in Action Pack',
title: 'Arbitrary file existence disclosure in Action Pack',
path: 'Gemfile.lock',
solution: 'upgrade to ~> 3.2.21, ~> 4.0.11.1, ~> 4.0.12, ~> 4.1.7.1, >= 4.1.8',
tool: 'bundler_audit',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/rMTQy4oRCGk',
urlPath: '/Gemfile.lock',
},
],
allIssues: [
{
cve: 'CVE-2016-0752',
file: 'Gemfile.lock',
message: 'Possible Information Leak Vulnerability in Action View',
title: 'Possible Information Leak Vulnerability in Action View',
path: 'Gemfile.lock',
solution:
'upgrade to >= 5.0.0.beta1.1, >= 4.2.5.1, ~> 4.2.5, >= 4.1.14.1, ~> 4.1.14, ~> 3.2.22.1',
tool: 'bundler_audit',
url: 'https://groups.google.com/forum/#!topic/rubyonrails-security/335P1DcLG00',
urlPath: '/Gemfile.lock',
},
],
};
import Vue from 'vue';
import reportSection from '~/vue_shared/components/reports/report_section.vue';
import { componentNames } from 'ee/vue_shared/components/reports/issue_body';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { fullReport } from './report_section_mock_data';
describe('Report section', () => {
let vm;
const ReportSection = Vue.extend(reportSection);
afterEach(() => {
vm.$destroy();
});
describe('With full report', () => {
beforeEach(() => {
vm = mountComponent(ReportSection, {
component: componentNames.SastIssueBody,
...fullReport,
});
});
it('should render full report section', done => {
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.js-expand-full-list').textContent.trim()).toEqual(
'Show complete code vulnerabilities report',
);
done();
});
});
it('should expand full list when clicked and hide the show all button', done => {
vm.$el.querySelector('button').click();
Vue.nextTick(() => {
vm.$el.querySelector('.js-expand-full-list').click();
Vue.nextTick(() => {
expect(vm.$el.querySelector('.js-mr-code-all-issues').textContent.trim()).toContain(
'Possible Information Leak Vulnerability in Action View',
);
done();
});
});
});
});
});
require 'spec_helper'
describe Gitlab::Kubernetes::Helm::Api do
let(:kubeclient) { spy }
let(:namespace) { spy }
let(:application) { build(:clusters_applications_prometheus) }
subject { described_class.new(kubeclient) }
before do
allow(Gitlab::Kubernetes::Namespace)
.to receive(:new)
.with(Gitlab::Kubernetes::Helm::NAMESPACE, kubeclient)
.and_return(namespace)
end
describe '#get_config_map' do
let(:command) { Gitlab::Kubernetes::Helm::GetCommand.new(application.name) }
it 'ensures the namespace exists before retrieving the config map' do
expect(namespace).to receive(:ensure_exists!).once
subject.get_config_map(command)
end
it 'gets the config map on kubeclient' do
expect(kubeclient).to receive(:get_config_map)
.with(command.config_map_name, namespace.name)
.once
subject.get_config_map(command)
end
end
describe '#update' do
let(:command) do
Gitlab::Kubernetes::Helm::UpgradeCommand.new(
application.name,
chart: application.chart,
values: application.values
)
end
it 'ensures the namespace exists before creating the pod' do
expect(namespace).to receive(:ensure_exists!).once.ordered
expect(kubeclient).to receive(:create_pod).once.ordered
subject.update(command)
end
it 'updates the config map on kubeclient when one exists' do
resource = Gitlab::Kubernetes::ConfigMap.new(
application.name, application.values
).generate
expect(kubeclient).to receive(:update_config_map).with(resource).once
subject.update(command)
end
end
end
require 'rails_helper'
describe Gitlab::Kubernetes::Helm::GetCommand do
let(:application) { build(:clusters_applications_prometheus) }
subject(:get_command) { described_class.new(application.name) }
describe '#config_map?' do
it 'returns true' do
expect(get_command.config_map?).to be true
end
end
describe '#config_map_name' do
it 'returns the ConfigMap name' do
expect(get_command.config_map_name).to eq("values-content-configuration-#{application.name}")
end
end
end
require 'rails_helper'
describe Gitlab::Kubernetes::Helm::UpgradeCommand do
let(:application) { build(:clusters_applications_prometheus) }
let(:namespace) { ::Gitlab::Kubernetes::Helm::NAMESPACE }
subject do
described_class.new(
application.name,
chart: application.chart,
values: application.values
)
end
it_behaves_like 'helm commands' do
let(:commands) do
<<~EOS
helm init --client-only >/dev/null
helm upgrade #{application.name} #{application.chart} --reset-values --install --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
EOS
end
end
context 'with an application with a repository' do
let(:ci_runner) { create(:ci_runner) }
let(:application) { build(:clusters_applications_runner, runner: ci_runner) }
subject do
described_class.new(
application.name,
chart: application.chart,
values: application.values,
repository: application.repository
)
end
it_behaves_like 'helm commands' do
let(:commands) do
<<~EOS
helm init --client-only >/dev/null
helm repo add #{application.name} #{application.repository}
helm upgrade #{application.name} #{application.chart} --reset-values --install --namespace #{namespace} -f /data/helm/#{application.name}/config/values.yaml >/dev/null
EOS
end
end
end
describe '#config_map?' do
it 'returns true' do
expect(subject.config_map?).to be_truthy
end
end
describe '#config_map_resource' do
it 'returns a KubeClient resource with config map content for the application' do
metadata = {
name: "values-content-configuration-#{application.name}",
namespace: namespace,
labels: { name: "values-content-configuration-#{application.name}" }
}
resource = ::Kubeclient::Resource.new(metadata: metadata, data: { values: application.values })
expect(subject.config_map_resource).to eq(resource)
end
end
describe '#pod_name' do
it 'returns the pod name' do
expect(subject.pod_name).to eq("upgrade-#{application.name}")
end
end
end
require 'rails_helper'
describe Clusters::Applications::Prometheus do
describe 'transition to updating' do
let(:project) { create(:project) }
let(:cluster) { create(:cluster, projects: [project]) }
subject { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
it 'sets last_update_started_at to now' do
Timecop.freeze do
expect { subject.make_updating }.to change { subject.reload.last_update_started_at }.to be_within(1.second).of(Time.now)
end
end
end
describe '#ready' do
let(:project) { create(:project) }
let(:cluster) { create(:cluster, projects: [project]) }
it 'returns true when updating' do
application = build(:clusters_applications_prometheus, :updating, cluster: cluster)
expect(application).to be_ready
end
it 'returns true when updated' do
application = build(:clusters_applications_prometheus, :updated, cluster: cluster)
expect(application).to be_ready
end
it 'returns true when errored' do
application = build(:clusters_applications_prometheus, :update_errored, cluster: cluster)
expect(application).to be_ready
end
end
context '#updated_since?' do
let(:cluster) { create(:cluster) }
let(:prometheus_app) { build(:clusters_applications_prometheus, cluster: cluster) }
let(:timestamp) { Time.now - 5.minutes }
around do |example|
Timecop.freeze { example.run }
end
before do
prometheus_app.last_update_started_at = Time.now
end
context 'when app does not have status failed' do
it 'returns true when last update started after the timestamp' do
expect(prometheus_app.updated_since?(timestamp)).to be true
end
it 'returns false when last update started before the timestamp' do
expect(prometheus_app.updated_since?(Time.now + 5.minutes)).to be false
end
end
context 'when app has status failed' do
it 'returns false when last update started after the timestamp' do
prometheus_app.status = 6
expect(prometheus_app.updated_since?(timestamp)).to be false
end
end
end
describe '#update_in_progress?' do
context 'when app is updating' do
it 'returns true' do
cluster = create(:cluster)
prometheus_app = build(:clusters_applications_prometheus, :updating, cluster: cluster)
expect(prometheus_app.update_in_progress?).to be true
end
end
end
describe '#update_errored?' do
context 'when app errored' do
it 'returns true' do
cluster = create(:cluster)
prometheus_app = build(:clusters_applications_prometheus, :update_errored, cluster: cluster)
expect(prometheus_app.update_errored?).to be true
end
end
end
describe '#get_command' do
let(:prometheus) { build(:clusters_applications_prometheus) }
it 'returns an instance of Gitlab::Kubernetes::Helm::GetCommand' do
expect(prometheus.get_command).to be_an_instance_of(::Gitlab::Kubernetes::Helm::GetCommand)
end
it 'should be initialized with 1 argument' do
command = prometheus.get_command
expect(command.name).to eq('prometheus')
end
end
describe '#upgrade_command' do
let(:prometheus) { build(:clusters_applications_prometheus) }
let(:values) { { foo: 'bar' } }
it 'returns an instance of Gitlab::Kubernetes::Helm::GetCommand' do
expect(prometheus.upgrade_command(values)).to be_an_instance_of(::Gitlab::Kubernetes::Helm::UpgradeCommand)
end
it 'should be initialized with 3 arguments' do
command = prometheus.upgrade_command(values)
expect(command.name).to eq('prometheus')
expect(command.chart).to eq('stable/prometheus')
expect(command.values).to eq(values)
end
end
end
...@@ -152,6 +152,24 @@ describe Project do ...@@ -152,6 +152,24 @@ describe Project do
end end
end end
describe '#environments_for_scope' do
set(:project) { create(:project) }
before do
create_list(:environment, 2, project: project)
end
it 'retrieves all project environments when using the * wildcard' do
expect(project.environments_for_scope("*")).to eq(project.environments)
end
it 'retrieves a specific project environment when using the name of that environment' do
environment = project.environments.first
expect(project.environments_for_scope(environment.name)).to eq([environment])
end
end
describe '#ensure_external_webhook_token' do describe '#ensure_external_webhook_token' do
let(:project) { create(:project, :repository) } let(:project) { create(:project, :repository) }
......
require 'spec_helper'
describe PrometheusAlert do
let(:metric) { create(:prometheus_metric) }
describe 'associations' do
it { is_expected.to belong_to(:project) }
it { is_expected.to belong_to(:environment) }
end
describe '#full_query' do
it 'returns the concatenated query' do
subject.operator = "gt"
subject.threshold = 1
subject.prometheus_metric_id = metric.id
expect(subject.full_query).to eq("#{metric.query} > 1.0")
end
end
describe '#to_param' do
it 'returns the params of the prometheus alert' do
subject.operator = "gt"
subject.threshold = 1
subject.prometheus_metric_id = metric.id
alert_params = {
"alert" => metric.title,
"expr" => "#{metric.query} > 1.0",
"for" => "5m",
"labels" => {
"gitlab" => "hook",
"gitlab_alert_id" => metric.id
}
}
expect(subject.to_param).to eq(alert_params)
end
end
end
require 'spec_helper'
describe PrometheusAlertEntity do
let(:user) { create(:user) }
let(:prometheus_alert) { create(:prometheus_alert) }
let(:request) { double('prometheus_alert', current_user: user) }
let(:entity) { described_class.new(prometheus_alert, request: request) }
subject { entity.as_json }
context 'when user can read prometheus alerts' do
before do
prometheus_alert.project.add_master(user)
stub_licensed_features(prometheus_alerts: true)
end
it 'exposes prometheus_alert attributes' do
expect(subject).to include(:id, :title, :query, :operator, :threshold)
end
it 'exposes alert_path' do
expect(subject).to include(:alert_path)
end
end
end
require 'spec_helper'
describe Clusters::Applications::CheckUpgradeProgressService do
RESCHEDULE_PHASES = ::Gitlab::Kubernetes::Pod::PHASES -
[::Gitlab::Kubernetes::Pod::SUCCEEDED, ::Gitlab::Kubernetes::Pod::FAILED, ::Gitlab].freeze
let(:application) { create(:clusters_applications_prometheus, :updating) }
let(:service) { described_class.new(application) }
let(:phase) { ::Gitlab::Kubernetes::Pod::UNKNOWN }
let(:errors) { nil }
shared_examples 'a terminated upgrade' do
it 'removes the POD' do
expect(service).to receive(:remove_pod).once
service.execute
end
end
shared_examples 'a not yet terminated upgrade' do |a_phase|
let(:phase) { a_phase }
context "when phase is #{a_phase}" do
context 'when not timed out' do
it 'reschedule a new check' do
expect(::ClusterWaitForAppUpdateWorker).to receive(:perform_in).once
expect(service).not_to receive(:remove_pod)
service.execute
expect(application).to be_updating
expect(application.status_reason).to be_nil
end
end
context 'when timed out' do
let(:application) { create(:clusters_applications_prometheus, :timeouted, :updating) }
it_behaves_like 'a terminated upgrade'
it 'make the application update errored' do
expect(::ClusterWaitForAppUpdateWorker).not_to receive(:perform_in)
service.execute
expect(application).to be_update_errored
expect(application.status_reason).to eq("Update timed out")
end
end
end
end
before do
allow(service).to receive(:phase).once.and_return(phase)
allow(service).to receive(:errors).and_return(errors)
allow(service).to receive(:remove_pod).and_return(nil)
end
describe '#execute' do
context 'when upgrade pod succeeded' do
let(:phase) { ::Gitlab::Kubernetes::Pod::SUCCEEDED }
it_behaves_like 'a terminated upgrade'
it 'make the application upgraded' do
expect(::ClusterWaitForAppUpdateWorker).not_to receive(:perform_in)
service.execute
expect(application).to be_updated
expect(application.status_reason).to be_nil
end
end
context 'when upgrade pod failed' do
let(:phase) { ::Gitlab::Kubernetes::Pod::FAILED }
let(:errors) { 'test installation failed' }
it_behaves_like 'a terminated upgrade'
it 'make the application update errored' do
service.execute
expect(application).to be_update_errored
expect(application.status_reason).to eq(errors)
end
end
RESCHEDULE_PHASES.each { |phase| it_behaves_like 'a not yet terminated upgrade', phase }
end
end
require 'spec_helper'
describe Clusters::Applications::PrometheusUpdateService do
describe '#execute' do
let(:project) { create(:project) }
let(:environment) { create(:environment, project: project) }
let(:cluster) { create(:cluster, projects: [project]) }
let(:application) { create(:clusters_applications_prometheus, :installed, cluster: cluster) }
let!(:get_command_values) { OpenStruct.new(data: OpenStruct.new(values: application.values)) }
let!(:upgrade_command) { application.upgrade_command("") }
let(:helm_client) { instance_double(::Gitlab::Kubernetes::Helm::Api) }
subject(:service) { described_class.new(application, project) }
before do
allow(service).to receive(:upgrade_command).and_return(upgrade_command)
allow(service).to receive(:helm_api).and_return(helm_client)
end
context 'when there are no errors' do
before do
expect(helm_client).to receive(:get_config_map).and_return(get_command_values)
expect(helm_client).to receive(:update).with(upgrade_command)
allow(::ClusterWaitForAppUpdateWorker).to receive(:perform_in).and_return(nil)
end
context 'when prometheus alerts exist' do
it 'generates the alert manager values' do
create(:prometheus_alert, project: project, environment: environment)
expect(service).to receive(:generate_alert_manager).once
service.execute
end
end
context 'when prometheus alerts do not exist' do
it 'resets the alert manager values' do
expect(service).to receive(:reset_alert_manager).once
service.execute
end
end
it 'make the application updating' do
expect(application.cluster).not_to be_nil
service.execute
expect(application).to be_updating
end
it 'schedules async update status check' do
expect(::ClusterWaitForAppUpdateWorker).to receive(:perform_in).once
service.execute
end
end
context 'when k8s cluster communication fails' do
it 'make the application update errored' do
error = ::Kubeclient::HttpError.new(500, 'system failure', nil)
allow(helm_client).to receive(:get_config_map).and_raise(error)
service.execute
expect(application).to be_update_errored
expect(application.status_reason).to match(/kubernetes error:/i)
end
end
context 'when application cannot be persisted' do
let(:application) { build(:clusters_applications_prometheus, :installed) }
it 'make the application update errored' do
allow(application).to receive(:make_updating!).once.and_raise(ActiveRecord::RecordInvalid)
expect(helm_client).not_to receive(:get_config_map)
expect(helm_client).not_to receive(:update)
service.execute
expect(application).to be_update_errored
end
end
end
end
require 'spec_helper'
describe Clusters::Applications::ScheduleUpdateService do
describe '#execute' do
let(:project) { create(:project) }
around do |example|
Timecop.freeze { example.run }
end
context 'when application is able to be updated' do
context 'when the application was recently scheduled' do
it 'schedules worker with a backoff delay' do
application = create(:clusters_applications_prometheus, :installed, last_update_started_at: Time.now + 5.minutes)
service = described_class.new(application, project)
expect(::ClusterUpdateAppWorker).to receive(:perform_in).with(described_class::BACKOFF_DELAY, application.name, application.id, project.id, Time.now).once
service.execute
end
end
context 'when the application has not been recently updated' do
it 'schedules worker' do
application = create(:clusters_applications_prometheus, :installed)
service = described_class.new(application, project)
expect(::ClusterUpdateAppWorker).to receive(:perform_async).with(application.name, application.id, project.id, Time.now).once
service.execute
end
end
end
end
end
...@@ -238,6 +238,23 @@ describe EE::NotificationService, :mailer do ...@@ -238,6 +238,23 @@ describe EE::NotificationService, :mailer do
end end
end end
describe '#prometheus_alerts_fired' do
it 'sends the email to owners and masters' do
project = create(:project)
prometheus_alert = create(:prometheus_alert, project: project)
master = create(:user)
developer = create(:user)
project.add_master(master)
expect(Notify).to receive(:prometheus_alert_fired_email).with(project.id, master.id, prometheus_alert).and_call_original
expect(Notify).to receive(:prometheus_alert_fired_email).with(project.id, project.owner.id, prometheus_alert).and_call_original
expect(Notify).not_to receive(:prometheus_alert_fired_email).with(project.id, developer.id, prometheus_alert)
subject.prometheus_alerts_fired(prometheus_alert.project, [prometheus_alert])
end
end
describe 'Notes' do describe 'Notes' do
around do |example| around do |example|
perform_enqueued_jobs do perform_enqueued_jobs do
......
require 'spec_helper'
describe Projects::Prometheus::Metrics::DestroyService do
let(:metric) { create(:prometheus_metric) }
subject { described_class.new(metric) }
it 'destroys metric' do
subject.execute
expect(PrometheusMetric.find_by(id: metric.id)).to be_nil
end
context 'when metric has a prometheus alert associated' do
it 'schedules a prometheus alert update' do
create(:prometheus_alert, prometheus_metric: metric)
schedule_update_service = spy
allow(::Clusters::Applications::ScheduleUpdateService).to receive(:new).and_return(schedule_update_service)
subject.execute
expect(schedule_update_service).to have_received(:execute)
end
end
end
require 'spec_helper'
describe Projects::Prometheus::Metrics::UpdateService do
let(:metric) { create(:prometheus_metric) }
it 'updates the prometheus metric' do
expect do
described_class.new(metric, { title: "bar" }).execute
end.to change { metric.reload.title }.to("bar")
end
context 'when metric has a prometheus alert associated' do
let(:schedule_update_service) { spy }
before do
create(:prometheus_alert, prometheus_metric: metric)
allow(::Clusters::Applications::ScheduleUpdateService).to receive(:new).and_return(schedule_update_service)
end
context 'when updating title' do
it 'schedules a prometheus alert update' do
described_class.new(metric, { title: "bar" }).execute
expect(schedule_update_service).to have_received(:execute)
end
end
context 'when updating query' do
it 'schedules a prometheus alert update' do
described_class.new(metric, { query: "sum(bar)" }).execute
expect(schedule_update_service).to have_received(:execute)
end
end
it 'does not schedule a prometheus alert update without title nor query being changed' do
described_class.new(metric, { y_label: "bar" }).execute
expect(schedule_update_service).not_to have_received(:execute)
end
end
end
require 'spec_helper'
describe ClusterUpdateAppWorker do
let(:project) { create(:project) }
let(:prometheus_update_service) { spy }
subject { described_class.new }
around do |example|
Timecop.freeze(Time.now) { example.run }
end
before do
allow(::Clusters::Applications::PrometheusUpdateService).to receive(:new).and_return(prometheus_update_service)
end
describe '#perform' do
context 'when the application last_update_started_at is higher than the time the job was scheduled in' do
it 'does nothing' do
application = create(:clusters_applications_prometheus, :updated, last_update_started_at: Time.now)
expect(prometheus_update_service).not_to receive(:execute)
expect(subject.perform(application.name, application.id, project.id, Time.now - 5.minutes)).to be_nil
end
end
context 'when another worker is already running' do
it 'returns nil' do
application = create(:clusters_applications_prometheus, :updating)
expect(subject.perform(application.name, application.id, project.id, Time.now)).to be_nil
end
end
it 'executes PrometheusUpdateService' do
application = create(:clusters_applications_prometheus, :installed)
expect(prometheus_update_service).to receive(:execute)
subject.perform(application.name, application.id, project.id, Time.now)
end
end
end
require 'spec_helper'
describe ClusterWaitForAppUpdateWorker do
let(:check_upgrade_progress_service) { spy }
before do
allow(::Clusters::Applications::CheckUpgradeProgressService).to receive(:new).and_return(check_upgrade_progress_service)
end
it 'runs CheckUpgradeProgressService when application is found' do
application = create(:clusters_applications_prometheus)
expect(check_upgrade_progress_service).to receive(:execute)
subject.perform(application.name, application.id)
end
it 'does not run CheckUpgradeProgressService when application is not found' do
expect(check_upgrade_progress_service).not_to receive(:execute)
expect do
subject.perform("prometheus", -1)
end.to raise_error(ActiveRecord::RecordNotFound)
end
end
module Gitlab module Gitlab
module Kubernetes module Kubernetes
class ConfigMap class ConfigMap
def initialize(name, values) def initialize(name, values = "")
@name = name @name = name
@values = values @values = values
end end
...@@ -13,6 +13,10 @@ module Gitlab ...@@ -13,6 +13,10 @@ module Gitlab
resource resource
end end
def config_map_name
"values-content-configuration-#{name}"
end
private private
attr_reader :name, :values attr_reader :name, :values
...@@ -25,10 +29,6 @@ module Gitlab ...@@ -25,10 +29,6 @@ module Gitlab
} }
end end
def config_map_name
"values-content-configuration-#{name}"
end
def namespace def namespace
Gitlab::Kubernetes::Helm::NAMESPACE Gitlab::Kubernetes::Helm::NAMESPACE
end end
......
...@@ -2,15 +2,17 @@ module Gitlab ...@@ -2,15 +2,17 @@ module Gitlab
module Kubernetes module Kubernetes
module Helm module Helm
class Api class Api
prepend EE::Gitlab::Kubernetes::Helm::Api
def initialize(kubeclient) def initialize(kubeclient)
@kubeclient = kubeclient @kubeclient = kubeclient
@namespace = Gitlab::Kubernetes::Namespace.new(Gitlab::Kubernetes::Helm::NAMESPACE, kubeclient) @namespace = Gitlab::Kubernetes::Namespace.new(Gitlab::Kubernetes::Helm::NAMESPACE, kubeclient)
end end
def install(command) def install(command)
@namespace.ensure_exists! namespace.ensure_exists!
create_config_map(command) if command.config_map? create_config_map(command) if command.config_map?
@kubeclient.create_pod(command.pod_resource) kubeclient.create_pod(command.pod_resource)
end end
## ##
...@@ -20,23 +22,25 @@ module Gitlab ...@@ -20,23 +22,25 @@ module Gitlab
# #
# values: "Pending", "Running", "Succeeded", "Failed", "Unknown" # values: "Pending", "Running", "Succeeded", "Failed", "Unknown"
# #
def installation_status(pod_name) def status(pod_name)
@kubeclient.get_pod(pod_name, @namespace.name).status.phase kubeclient.get_pod(pod_name, namespace.name).status.phase
end end
def installation_log(pod_name) def log(pod_name)
@kubeclient.get_pod_log(pod_name, @namespace.name).body kubeclient.get_pod_log(pod_name, namespace.name).body
end end
def delete_installation_pod!(pod_name) def delete_pod!(pod_name)
@kubeclient.delete_pod(pod_name, @namespace.name) kubeclient.delete_pod(pod_name, namespace.name)
end end
private private
attr_reader :kubeclient, :namespace
def create_config_map(command) def create_config_map(command)
command.config_map_resource.tap do |config_map_resource| command.config_map_resource.tap do |config_map_resource|
@kubeclient.create_config_map(config_map_resource) kubeclient.create_config_map(config_map_resource)
end end
end end
end end
......
...@@ -3,7 +3,7 @@ module Gitlab ...@@ -3,7 +3,7 @@ module Gitlab
class Metric class Metric
include ActiveModel::Model include ActiveModel::Model
attr_accessor :title, :required_metrics, :weight, :y_label, :queries attr_accessor :id, :title, :required_metrics, :weight, :y_label, :queries
validates :title, :required_metrics, :weight, :y_label, :queries, presence: true validates :title, :required_metrics, :weight, :y_label, :queries, presence: true
......
...@@ -8,6 +8,7 @@ module Gitlab ...@@ -8,6 +8,7 @@ module Gitlab
Deployment.find_by(id: deployment_id).try do |deployment| Deployment.find_by(id: deployment_id).try do |deployment|
query_metrics( query_metrics(
deployment.project, deployment.project,
deployment.environment,
common_query_context( common_query_context(
deployment.environment, deployment.environment,
timeframe_start: (deployment.created_at - 30.minutes).to_f, timeframe_start: (deployment.created_at - 30.minutes).to_f,
......
...@@ -8,6 +8,7 @@ module Gitlab ...@@ -8,6 +8,7 @@ module Gitlab
::Environment.find_by(id: environment_id).try do |environment| ::Environment.find_by(id: environment_id).try do |environment|
query_metrics( query_metrics(
environment.project, environment.project,
environment,
common_query_context(environment, timeframe_start: 8.hours.ago.to_f, timeframe_end: Time.now.to_f) common_query_context(environment, timeframe_start: 8.hours.ago.to_f, timeframe_end: Time.now.to_f)
) )
end end
......
...@@ -2,7 +2,9 @@ module Gitlab ...@@ -2,7 +2,9 @@ module Gitlab
module Prometheus module Prometheus
module Queries module Queries
module QueryAdditionalMetrics module QueryAdditionalMetrics
def query_metrics(project, query_context) prepend EE::Gitlab::Prometheus::Queries::QueryAdditionalMetrics
def query_metrics(project, environment, query_context)
matched_metrics(project).map(&query_group(query_context)) matched_metrics(project).map(&query_group(query_context))
.select(&method(:group_with_any_metrics)) .select(&method(:group_with_any_metrics))
end end
...@@ -14,12 +16,16 @@ module Gitlab ...@@ -14,12 +16,16 @@ module Gitlab
lambda do |group| lambda do |group|
metrics = group.metrics.map do |metric| metrics = group.metrics.map do |metric|
{ metric_hsh = {
title: metric.title, title: metric.title,
weight: metric.weight, weight: metric.weight,
y_label: metric.y_label, y_label: metric.y_label,
queries: metric.queries.map(&query_processor).select(&method(:query_with_result)) queries: metric.queries.map(&query_processor).select(&method(:query_with_result))
} }
metric_hsh[:id] = metric.id if metric.id
metric_hsh
end end
{ {
......
...@@ -5060,6 +5060,36 @@ msgstr "" ...@@ -5060,6 +5060,36 @@ msgstr ""
msgid "ProjectsDropdown|Sorry, no projects matched your search" msgid "ProjectsDropdown|Sorry, no projects matched your search"
msgstr "" msgstr ""
msgid "PrometheusAlerts|Add alert"
msgstr ""
msgid "PrometheusAlerts|Alert set"
msgstr ""
msgid "PrometheusAlerts|Edit alert"
msgstr ""
msgid "PrometheusAlerts|Error creating alert"
msgstr ""
msgid "PrometheusAlerts|Error deleting alert"
msgstr ""
msgid "PrometheusAlerts|Error fetching alert"
msgstr ""
msgid "PrometheusAlerts|Error saving alert"
msgstr ""
msgid "PrometheusAlerts|No alert set"
msgstr ""
msgid "PrometheusAlerts|Operator"
msgstr ""
msgid "PrometheusAlerts|Threshold"
msgstr ""
msgid "PrometheusDashboard|Time" msgid "PrometheusDashboard|Time"
msgstr "" msgstr ""
...@@ -6227,7 +6257,7 @@ msgstr "" ...@@ -6227,7 +6257,7 @@ msgstr ""
msgid "This application will be able to:" msgid "This application will be able to:"
msgstr "" msgstr ""
msgid "This board\\'s scope is reduced" msgid "This board's scope is reduced"
msgstr "" msgstr ""
msgid "This diff is collapsed." msgid "This diff is collapsed."
......
...@@ -22,11 +22,24 @@ FactoryBot.define do ...@@ -22,11 +22,24 @@ FactoryBot.define do
status 3 status 3
end end
trait :updating do
status 4
end
trait :updated do
status 5
end
trait :errored do trait :errored do
status(-1) status(-1)
status_reason 'something went wrong' status_reason 'something went wrong'
end end
trait :update_errored do
status(6)
status_reason 'something went wrong'
end
trait :timeouted do trait :timeouted do
installing installing
updated_at ClusterWaitForAppInstallationWorker::TIMEOUT.ago updated_at ClusterWaitForAppInstallationWorker::TIMEOUT.ago
......
import Vue from 'vue';
import AlertWidgetForm from 'ee/monitoring/components/alert_widget_form.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('AlertWidgetForm', () => {
let AlertWidgetFormComponent;
let vm;
const props = {
disabled: false,
};
beforeAll(() => {
AlertWidgetFormComponent = Vue.extend(AlertWidgetForm);
});
afterEach(() => {
if (vm) vm.$destroy();
});
it('disables the input when disabled prop is set', () => {
vm = mountComponent(AlertWidgetFormComponent, { ...props, disabled: true });
expect(vm.$refs.cancelButton).toBeDisabled();
expect(vm.$refs.submitButton).toBeDisabled();
});
it('emits a "create" event when form submitted without existing alert', done => {
vm = mountComponent(AlertWidgetFormComponent, props);
expect(vm.$refs.submitButton.innerText).toContain('Add');
vm.$once('create', alert => {
expect(alert).toEqual({
alert: null,
operator: '<',
threshold: 5,
});
done();
});
// the button should be disabled until an operator and threshold are selected
expect(vm.$refs.submitButton).toBeDisabled();
vm.operator = '<';
vm.threshold = 5;
Vue.nextTick(() => {
vm.$refs.submitButton.click();
});
});
it('emits a "delete" event when form submitted with existing alert and no changes are made', done => {
vm = mountComponent(AlertWidgetFormComponent, {
...props,
alert: 'alert',
alertData: { operator: '<', threshold: 5 },
});
vm.$once('delete', alert => {
expect(alert).toEqual({
alert: 'alert',
operator: '<',
threshold: 5,
});
done();
});
expect(vm.$refs.submitButton.innerText).toContain('Delete');
vm.$refs.submitButton.click();
});
it('emits a "update" event when form submitted with existing alert', done => {
vm = mountComponent(AlertWidgetFormComponent, {
...props,
alert: 'alert',
alertData: { operator: '<', threshold: 5 },
});
expect(vm.$refs.submitButton.innerText).toContain('Delete');
vm.$once('update', alert => {
expect(alert).toEqual({
alert: 'alert',
operator: '=',
threshold: 5,
});
done();
});
// change operator to allow update
vm.operator = '=';
Vue.nextTick(() => {
expect(vm.$refs.submitButton.innerText).toContain('Save');
vm.$refs.submitButton.click();
});
});
});
import Vue from 'vue';
import AlertWidget from 'ee/monitoring/components/alert_widget.vue';
import AlertsService from 'ee/monitoring/services/alerts_service';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('AlertWidget', () => {
let AlertWidgetComponent;
let vm;
const props = {
alertsEndpoint: '',
customMetricId: 5,
label: 'alert-label',
currentAlerts: ['my/alert.json'],
};
beforeAll(() => {
AlertWidgetComponent = Vue.extend(AlertWidget);
});
beforeEach(() => {
setFixtures('<div id="alert-widget"></div>');
});
afterEach(() => {
if (vm) vm.$destroy();
});
it('displays a loading spinner when fetching alerts', done => {
let resolveReadAlert;
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(
new Promise(cb => {
resolveReadAlert = cb;
}),
);
vm = mountComponent(AlertWidgetComponent, props, '#alert-widget');
// expect loading spinner to exist during fetch
expect(vm.isLoading).toBeTruthy();
expect(vm.$el.querySelector('.loading-container')).toBeVisible();
resolveReadAlert({ operator: '=', threshold: 42 });
// expect loading spinner to go away after fetch
setTimeout(() =>
vm.$nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.$el.querySelector('.loading-container')).toBeHidden();
done();
}),
);
});
it('displays an error message when fetch fails', done => {
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.reject());
vm = mountComponent(AlertWidgetComponent, props, '#alert-widget');
setTimeout(() =>
vm.$nextTick(() => {
expect(vm.errorMessage).toBe('Error fetching alert');
expect(vm.isLoading).toEqual(false);
expect(vm.$el.querySelector('.alert-error-message')).toBeVisible();
done();
}),
);
});
it('displays an alert summary when fetch succeeds', done => {
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(
Promise.resolve({ operator: '>', threshold: 42 }),
);
vm = mountComponent(AlertWidgetComponent, props, '#alert-widget');
setTimeout(() =>
vm.$nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBe('alert-label > 42');
expect(vm.$el.querySelector('.alert-current-setting')).toBeVisible();
done();
}),
);
});
it('opens and closes a dropdown menu by clicking close button', done => {
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [] });
expect(vm.isOpen).toEqual(false);
expect(vm.$el.querySelector('.alert-dropdown-menu')).toBeHidden();
vm.$el.querySelector('.alert-dropdown-button').click();
Vue.nextTick(() => {
expect(vm.isOpen).toEqual(true);
expect(vm.$el).toHaveClass('show');
vm.$el.querySelector('.dropdown-menu-close').click();
Vue.nextTick(() => {
expect(vm.isOpen).toEqual(false);
expect(vm.$el).not.toHaveClass('show');
done();
});
});
});
it('opens and closes a dropdown menu by clicking outside the menu', done => {
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [] });
expect(vm.isOpen).toEqual(false);
expect(vm.$el.querySelector('.alert-dropdown-menu')).toBeHidden();
vm.$el.querySelector('.alert-dropdown-button').click();
Vue.nextTick(() => {
expect(vm.isOpen).toEqual(true);
expect(vm.$el).toHaveClass('show');
document.body.click();
Vue.nextTick(() => {
expect(vm.isOpen).toEqual(false);
expect(vm.$el).not.toHaveClass('show');
done();
});
});
});
it('creates an alert with an appropriate handler', done => {
const alertParams = {
operator: '<',
threshold: 4,
prometheus_metric_id: 5,
};
spyOn(AlertsService.prototype, 'createAlert').and.returnValue(
Promise.resolve({
alert_path: 'foo/bar',
...alertParams,
}),
);
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [] });
vm.$refs.widgetForm.$emit('create', alertParams);
expect(AlertsService.prototype.createAlert).toHaveBeenCalledWith(alertParams);
Vue.nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBe('alert-label < 4');
done();
});
});
it('updates an alert with an appropriate handler', done => {
const alertPath = 'my/test/alert.json';
const alertParams = {
operator: '<',
threshold: 4,
};
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.resolve(alertParams));
spyOn(AlertsService.prototype, 'updateAlert').and.returnValue(Promise.resolve());
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [alertPath] });
vm.$refs.widgetForm.$emit('update', {
...alertParams,
alert: alertPath,
operator: '=',
threshold: 12,
});
expect(AlertsService.prototype.updateAlert).toHaveBeenCalledWith(alertPath, {
...alertParams,
operator: '=',
threshold: 12,
});
Vue.nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBe('alert-label = 12');
done();
});
});
it('deletes an alert with an appropriate handler', done => {
const alertPath = 'my/test/alert.json';
const alertParams = {
operator: '<',
threshold: 4,
};
spyOn(AlertsService.prototype, 'readAlert').and.returnValue(Promise.resolve(alertParams));
spyOn(AlertsService.prototype, 'deleteAlert').and.returnValue(Promise.resolve());
vm = mountComponent(AlertWidgetComponent, { ...props, currentAlerts: [alertPath] });
vm.$refs.widgetForm.$emit('delete', { alert: alertPath });
expect(AlertsService.prototype.deleteAlert).toHaveBeenCalledWith(alertPath);
Vue.nextTick(() => {
expect(vm.isLoading).toEqual(false);
expect(vm.alertSummary).toBeFalsy();
done();
});
});
});
...@@ -91,6 +91,19 @@ beforeEach(() => { ...@@ -91,6 +91,19 @@ beforeEach(() => {
Vue.http.interceptors = builtinVueHttpInterceptors.slice(); Vue.http.interceptors = builtinVueHttpInterceptors.slice();
}); });
let longRunningTestTimeoutHandle;
beforeEach((done) => {
longRunningTestTimeoutHandle = setTimeout(() => {
done.fail('Test is running too long!');
}, 1000);
done();
});
afterEach(() => {
clearTimeout(longRunningTestTimeoutHandle);
});
const axiosDefaultAdapter = getDefaultAdapter(); const axiosDefaultAdapter = getDefaultAdapter();
// render all of our tests // render all of our tests
......
This diff is collapsed.
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment