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 'underscore';
import Vue from 'vue';
......@@ -56,7 +54,7 @@ export default () => {
gl.IssueBoardsApp = new Vue({
el: $boardApp,
components: {
'board': gl.issueBoards.Board,
board: gl.issueBoards.Board,
'board-sidebar': gl.issueBoards.BoardSidebar,
BoardAddIssuesModal,
},
......@@ -74,11 +72,11 @@ export default () => {
defaultAvatar: $boardApp.dataset.defaultAvatar,
},
computed: {
detailIssueVisible () {
detailIssueVisible() {
return Object.keys(this.detailIssue.issue).length;
},
},
created () {
created() {
gl.boardService = new BoardService({
boardsEndpoint: this.boardsEndpoint,
listsEndpoint: this.listsEndpoint,
......@@ -100,15 +98,16 @@ export default () => {
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
sidebarEventHub.$off('updateWeight', this.updateWeight);
},
mounted () {
mounted() {
this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit);
this.filterManager.setup();
Store.disabled = this.disabled;
gl.boardService.all()
gl.boardService
.all()
.then(res => res.data)
.then((data) => {
data.forEach((board) => {
.then(data => {
data.forEach(board => {
const list = Store.addList(board, this.defaultAvatar);
if (list.type === 'closed') {
......@@ -140,7 +139,7 @@ export default () => {
newIssue.setFetchingState('epic', true);
BoardService.getIssueInfo(sidebarInfoEndpoint)
.then(res => res.data)
.then((data) => {
.then(data => {
newIssue.setFetchingState('subscriptions', false);
newIssue.setFetchingState('weight', false);
newIssue.setFetchingState('epic', false);
......@@ -185,7 +184,7 @@ export default () => {
issue.setLoadingState('weight', true);
BoardService.updateWeight(issue.sidebarInfoEndpoint, newWeight)
.then(res => res.data)
.then((data) => {
.then(data => {
issue.setLoadingState('weight', false);
issue.updateData({
weight: data.weight,
......@@ -196,7 +195,7 @@ export default () => {
Flash(__('An error occurred when updating the issue weight'));
});
}
}
},
},
});
......@@ -206,7 +205,7 @@ export default () => {
filters: Store.state.filters,
milestoneTitle: $boardApp.dataset.boardMilestoneTitle,
},
mounted () {
mounted() {
gl.issueBoards.newListDropdownInit();
},
});
......@@ -231,8 +230,8 @@ export default () => {
return this.canAdminList ? 'Edit board' : 'View scope';
},
tooltipTitle() {
return this.hasScope ? __('This board\'s scope is reduced') : '';
}
return this.hasScope ? __("This board's scope is reduced") : '';
},
},
methods: {
showPage: page => gl.issueBoards.BoardsStore.showPage(page),
......@@ -254,76 +253,80 @@ export default () => {
});
}
gl.IssueBoardsModalAddBtn = new Vue({
el: document.getElementById('js-add-issues-btn'),
mixins: [modalMixin],
data() {
return {
modal: ModalStore.store,
store: Store.state,
isFullscreen: false,
focusModeAvailable: $boardApp.hasAttribute('data-focus-mode-available'),
canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
};
},
computed: {
disabled() {
if (!this.store) {
return true;
}
return !this.store.lists.filter(list => !list.preset).length;
const issueBoardsModal = document.getElementById('js-add-issues-btn');
if (issueBoardsModal) {
gl.IssueBoardsModalAddBtn = new Vue({
el: issueBoardsModal,
mixins: [modalMixin],
data() {
return {
modal: ModalStore.store,
store: Store.state,
isFullscreen: false,
focusModeAvailable: $boardApp.hasAttribute('data-focus-mode-available'),
canAdminList: this.$options.el.hasAttribute('data-can-admin-list'),
};
},
tooltipTitle() {
if (this.disabled) {
return 'Please add a list to your board first';
}
computed: {
disabled() {
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: {
disabled() {
watch: {
disabled() {
this.updateTooltip();
},
},
mounted() {
this.updateTooltip();
},
},
mounted() {
this.updateTooltip();
},
methods: {
updateTooltip() {
const $tooltip = $(this.$refs.addIssuesButton);
methods: {
updateTooltip() {
const $tooltip = $(this.$refs.addIssuesButton);
this.$nextTick(() => {
if (this.disabled) {
$tooltip.tooltip();
} else {
$tooltip.tooltip('dispose');
this.$nextTick(() => {
if (this.disabled) {
$tooltip.tooltip();
} else {
$tooltip.tooltip('dispose');
}
});
},
openModal() {
if (!this.disabled) {
this.toggleModal(true);
}
});
},
openModal() {
if (!this.disabled) {
this.toggleModal(true);
}
},
},
},
template: `
<div class="board-extra-actions">
<button
class="btn btn-create prepend-left-10"
type="button"
data-placement="bottom"
ref="addIssuesButton"
:class="{ 'disabled': disabled }"
:title="tooltipTitle"
:aria-disabled="disabled"
v-if="canAdminList"
@click="openModal">
Add issues
</button>
</div>
`,
});
template: `
<div class="board-extra-actions">
<button
class="btn btn-create prepend-left-10"
type="button"
data-placement="bottom"
ref="addIssuesButton"
:class="{ 'disabled': disabled }"
:title="tooltipTitle"
:aria-disabled="disabled"
v-if="canAdminList"
@click="openModal">
Add issues
</button>
</div>
`,
});
}
gl.IssueBoardsToggleFocusBtn = new Vue({
el: document.getElementById('js-toggle-focus-btn'),
......@@ -335,7 +338,9 @@ export default () => {
},
methods: {
toggleFocusMode() {
if (!this.focusModeAvailable) { return; }
if (!this.focusModeAvailable) {
return;
}
$(this.$refs.toggleFocusModeButton).tooltip('hide');
issueBoardsContent.classList.toggle('is-focused');
......@@ -369,6 +374,6 @@ export default () => {
el: '#js-multiple-boards-switcher',
components: {
'boards-selector': gl.issueBoards.BoardsSelector,
}
},
});
};
<script>
// ee-only
import DashboardMixin from 'ee/monitoring/components/dashboard_mixin';
import _ from 'underscore';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
......@@ -17,6 +20,10 @@ export default {
EmptyState,
Icon,
},
// ee-only
mixins: [DashboardMixin],
props: {
hasMetrics: {
type: Boolean,
......@@ -137,7 +144,7 @@ export default {
.catch(() => Flash(s__('Metrics|There was an error getting deployment information.'))),
this.service
.getEnvironmentsData()
.then((data) => this.store.storeEnvironmentsData(data))
.then(data => this.store.storeEnvironmentsData(data))
.catch(() => Flash(s__('Metrics|There was an error getting environments information.'))),
])
.then(() => {
......@@ -225,7 +232,13 @@ export default {
:small-graph="forceSmallGraph"
>
<!-- 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-group>
</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>
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 { SAST_CONTAINER } from 'ee/vue_shared/security_reports/store/constants';
/**
* Renders block of issues
......@@ -13,7 +18,10 @@ export default {
IssuesBlock,
SastContainerInfo,
},
sastContainer: SAST_CONTAINER,
componentNames,
success: STATUS_SUCCESS,
failed: STATUS_FAILED,
neutral: STATUS_NEUTRAL,
props: {
unresolvedIssues: {
type: Array,
......@@ -35,9 +43,10 @@ export default {
required: false,
default: () => [],
},
type: {
component: {
type: String,
required: true,
required: false,
default: '',
},
},
data() {
......@@ -45,11 +54,6 @@ export default {
isFullReportVisible: false,
};
},
computed: {
unresolvedIssuesStatus() {
return this.type === 'license' ? 'neutral' : 'failed';
},
},
methods: {
openFullReport() {
this.isFullReportVisible = true;
......@@ -59,38 +63,37 @@ export default {
</script>
<template>
<div class="report-block-container">
<sast-container-info v-if="type === $options.sastContainer" />
<sast-container-info v-if="component === $options.componentNames.SastContainerIssueBody" />
<issues-block
v-if="unresolvedIssues.length"
:type="type"
:status="unresolvedIssuesStatus"
:component="component"
:issues="unresolvedIssues"
:status="$options.failed"
class="js-mr-code-new-issues"
/>
<issues-block
v-if="isFullReportVisible"
:type="type"
:component="component"
:issues="allIssues"
:status="$options.failed"
class="js-mr-code-all-issues"
status="failed"
/>
<issues-block
v-if="neutralIssues.length"
:type="type"
:component="component"
:issues="neutralIssues"
:status="$options.neutral"
class="js-mr-code-non-issues"
status="neutral"
/>
<issues-block
v-if="resolvedIssues.length"
:type="type"
:component="component"
:issues="resolvedIssues"
:status="$options.success"
class="js-mr-code-resolved-issues"
status="success"
/>
<button
......
<script>
import Icon from '~/vue_shared/components/icon.vue';
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';
import IssueStatusIcon from '~/vue_shared/components/reports/issue_status_icon.vue';
import { components, componentNames } from 'ee/vue_shared/components/reports/issue_body';
export default {
name: 'ReportIssues',
components: {
Icon,
SastIssue,
SastContainerIssue,
DastIssue,
PerformanceIssue,
CodequalityIssue,
LicenseIssue,
IssueStatusIcon,
...components,
},
props: {
issues: {
type: Array,
required: true,
},
// security || codequality || performance || docker || dast || license
type: {
component: {
type: String,
required: true,
required: false,
default: '',
validator: value => value === '' || Object.values(componentNames).includes(value),
},
// failed || success
status: {
......@@ -36,44 +25,6 @@ export default {
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>
<template>
......@@ -85,60 +36,16 @@ export default {
:key="index"
class="report-block-list-issue"
>
<div
:class="{
failed: isStatusFailed,
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"
<issue-status-icon
:status="issue.status || status"
class="append-right-5"
/>
<license-issue
v-else-if="isTypeLicense"
<component
v-if="component"
:is="component"
:issue="issue"
:status="issue.status || status"
/>
</li>
</ul>
......
......@@ -21,7 +21,7 @@ export default {
required: false,
default: false,
},
type: {
component: {
type: String,
required: false,
default: '',
......@@ -183,8 +183,9 @@ export default {
<issues-list
:unresolved-issues="unresolvedIssues"
:resolved-issues="resolvedIssues"
:neutral-issues="neutralIssues"
:all-issues="allIssues"
:type="type"
:component="component"
/>
</slot>
</div>
......
......@@ -267,7 +267,7 @@
border: 1px solid $white-light;
background-color: $orange-300;
border-radius: 50%;
content: "";
content: '';
}
}
}
......@@ -287,8 +287,6 @@
}
}
.gl-responsive-table-row {
.branch-commit {
max-width: 100%;
......@@ -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
prepend ::EE::EnvironmentsHelper
def environments_list_data
{
endpoint: project_environments_path(@project, format: :json)
}
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
......@@ -11,6 +11,8 @@ module Clusters
include ::Clusters::Concerns::ApplicationStatus
include ::Clusters::Concerns::ApplicationData
prepend EE::Clusters::Applications::Prometheus
default_value_for :version, VERSION
state_machine :status do
......@@ -21,6 +23,14 @@ module Clusters
end
end
def ready_status
[:installed]
end
def ready?
ready_status.include?(status_name)
end
def chart
'stable/prometheus'
end
......
......@@ -4,6 +4,8 @@ module Clusters
extend ActiveSupport::Concern
included do
prepend ::EE::Clusters::ApplicationStatus
scope :installed, -> { where(status: self.state_machines[:status].states[:installed].value) }
state_machine :status, initial: :not_installable do
......
......@@ -3,6 +3,7 @@ module PrometheusAdapter
included do
include ReactiveCaching
prepend EE::PrometheusAdapter
self.reactive_cache_key = ->(adapter) { [adapter.class.model_name.singular, adapter.id] }
self.reactive_cache_lease_timeout = 30.seconds
......@@ -24,17 +25,10 @@ module PrometheusAdapter
def query(query_name, *args)
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|
if arg.respond_to?(:id)
arg.id
else
arg
end
end
with_reactive_cache(query_class.name, *args, &query_class.method(:transform_reactive_result))
with_reactive_cache(query_class.name, *query_args, &query_class.method(:transform_reactive_result))
end
# Cache metrics for specific environment
......@@ -50,5 +44,13 @@ module PrometheusAdapter
rescue Gitlab::PrometheusClient::Error => err
{ success: false, result: err.message }
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
......@@ -5,6 +5,8 @@ module Clusters
class BaseHelmService
attr_accessor :app
prepend EE::Clusters::Applications::BaseHelmService
def initialize(app)
@app = app
end
......
......@@ -50,17 +50,17 @@ module Clusters
end
def remove_installation_pod
helm_api.delete_installation_pod!(install_command.pod_name)
helm_api.delete_pod!(install_command.pod_name)
rescue
# no-op
end
def installation_phase
helm_api.installation_status(install_command.pod_name)
helm_api.status(install_command.pod_name)
end
def installation_errors
helm_api.installation_log(install_command.pod_name)
helm_api.log(install_command.pod_name)
end
end
end
......
......@@ -30,7 +30,7 @@ module Prometheus
return unless deployment_platform.respond_to?(:cluster)
cluster = deployment_platform.cluster
return unless cluster.application_prometheus&.installed?
return unless cluster.application_prometheus&.ready?
cluster.application_prometheus
end
......
......@@ -2,17 +2,11 @@
- page_title "Metrics for environment", @environment.name
.prometheus-container{ class: container_class }
#prometheus-graphs{ data: { "settings-path": edit_project_service_path(@project, 'prometheus'),
"clusters-path": project_clusters_path(@project),
"current-environment-name": @environment.name,
"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?}" } }
.top-area
.row
.col-sm-6
%h3
Environment:
= link_to @environment.name, environment_path(@environment)
#prometheus-graphs{ data: metrics_data(@project, @environment) }
......@@ -22,8 +22,10 @@
- cronjob:prune_web_hook_logs
- gcp_cluster:cluster_install_app
- gcp_cluster:cluster_update_app
- gcp_cluster:cluster_provision
- gcp_cluster:cluster_wait_for_app_installation
- gcp_cluster:cluster_wait_for_app_update
- gcp_cluster:wait_for_cluster_creation
- gcp_cluster:cluster_wait_for_ingress_ip_address
......
......@@ -80,6 +80,12 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
post :validate_query, on: :collection
get :active_common, on: :collection
end
# EE-specific
resources :alerts, constraints: { id: /\d+/ }, only: [:index, :create, :show, :update, :destroy] do
post :notify, on: :collection
end
# EE-specific
end
resources :deploy_keys, constraints: { id: /\d+/ }, only: [:index, :new, :create, :edit, :update] do
......
......@@ -778,6 +778,7 @@ ActiveRecord::Schema.define(version: 20180722103201) do
t.text "status_reason"
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
t.datetime_with_timezone "last_update_started_at"
end
create_table "clusters_applications_runners", force: :cascade do |t|
......@@ -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", ["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|
t.integer "project_id"
t.string "title", null: false
......@@ -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_repository_states", "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 "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
......
......@@ -83,7 +83,7 @@ export default {
});
},
fetchGeoNodes() {
this.service
return this.service
.getGeoNodes()
.then(res => res.data)
.then(nodes => {
......@@ -92,9 +92,7 @@ export default {
})
.catch(() => {
this.isLoading = false;
Flash(
s__('GeoNodes|Something went wrong while fetching nodes'),
);
Flash(s__('GeoNodes|Something went wrong while fetching nodes'));
});
},
fetchNodeDetails(node) {
......@@ -109,10 +107,7 @@ export default {
primaryRevision: primaryNodeVersion.revision,
});
this.store.setNodeDetails(nodeId, updatedNodeDetails);
eventHub.$emit(
'nodeDetailsLoaded',
this.store.getNodeDetails(nodeId),
);
eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
})
.catch(err => {
if (err.response && err.response.data) {
......@@ -124,10 +119,7 @@ export default {
sync_status_unavailable: true,
storage_shards_match: null,
});
eventHub.$emit(
'nodeDetailsLoaded',
this.store.getNodeDetails(nodeId),
);
eventHub.$emit('nodeDetailsLoaded', this.store.getNodeDetails(nodeId));
} else {
eventHub.$emit('nodeDetailsLoadFailed', nodeId, err);
}
......@@ -135,14 +127,11 @@ export default {
},
repairNode(targetNode) {
this.setNodeActionStatus(targetNode, true);
this.service
return this.service
.repairNode(targetNode)
.then(() => {
this.setNodeActionStatus(targetNode, false);
Flash(
s__('GeoNodes|Node Authentication was successfully repaired.'),
'notice',
);
Flash(s__('GeoNodes|Node Authentication was successfully repaired.'), 'notice');
})
.catch(() => {
this.setNodeActionStatus(targetNode, false);
......@@ -151,7 +140,7 @@ export default {
},
toggleNode(targetNode) {
this.setNodeActionStatus(targetNode, true);
this.service
return this.service
.toggleNode(targetNode)
.then(res => res.data)
.then(node => {
......@@ -162,14 +151,12 @@ export default {
})
.catch(() => {
this.setNodeActionStatus(targetNode, false);
Flash(
s__('GeoNodes|Something went wrong while changing node status'),
);
Flash(s__('GeoNodes|Something went wrong while changing node status'));
});
},
removeNode(targetNode) {
this.setNodeActionStatus(targetNode, true);
this.service
return this.service
.removeNode(targetNode)
.then(() => {
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 @@
* Fixed: [name] in [link]:[line]
*/
import ReportLink from '~/vue_shared/components/reports/report_link.vue';
import { STATUS_SUCCESS } from '~/vue_shared/components/reports/constants';
export default {
name: 'CodequalityIssueBody',
......@@ -11,10 +12,9 @@ export default {
components: {
ReportLink,
},
props: {
isStatusSuccess: {
type: Boolean,
status: {
type: String,
required: true,
},
issue: {
......@@ -22,6 +22,11 @@ export default {
required: true,
},
},
computed: {
isStatusSuccess() {
return this.status === STATUS_SUCCESS;
},
},
};
</script>
<template>
......
......@@ -2,6 +2,7 @@
import { s__, sprintf } from '~/locale';
export default {
name: 'LicenseIssueBody',
props: {
issue: {
type: Object,
......
......@@ -2,6 +2,7 @@
import ReportSection from '~/vue_shared/components/reports/report_section.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 { componentNames } from 'ee/vue_shared/components/reports/issue_body';
import { n__, s__, __, sprintf } from '~/locale';
import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
......@@ -17,6 +18,7 @@ export default {
},
extends: CEWidgetOptions,
mixins: [reportsMixin],
componentNames,
data() {
return {
isLoadingCodequality: false,
......@@ -255,8 +257,8 @@ export default {
:unresolved-issues="mr.codeclimateMetrics.newIssues"
:resolved-issues="mr.codeclimateMetrics.resolvedIssues"
:has-issues="hasCodequalityIssues"
:component="$options.componentNames.CodequalityIssueBody"
class="js-codequality-widget mr-widget-border-top"
type="codequality"
/>
<report-section
v-if="shouldRenderPerformance"
......@@ -268,8 +270,8 @@ export default {
:resolved-issues="mr.performanceMetrics.improved"
:neutral-issues="mr.performanceMetrics.neutral"
:has-issues="hasPerformanceMetrics"
:component="$options.componentNames.PerformanceIssueBody"
class="js-performance-widget mr-widget-border-top"
type="performance"
/>
<grouped-security-reports-app
v-if="shouldRenderSecurityReport"
......@@ -299,10 +301,10 @@ export default {
:loading-text="translateText('license management').loading"
:error-text="translateText('license management').error"
:success-text="licenseReportText"
:unresolved-issues="mr.licenseReport"
:neutral-issues="mr.licenseReport"
:has-issues="hasLicenseReportIssues"
:component="$options.componentNames.LicenseIssueBody"
class="js-license-report-widget mr-widget-border-top"
type="license"
/>
<div class="mr-section-container">
<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 @@
import ModalOpenName from '~/vue_shared/components/reports/modal_open_name.vue';
export default {
name: 'SastIssueBody',
name: 'DastIssueBody',
components: {
ModalOpenName,
},
......@@ -16,11 +16,6 @@ export default {
type: Object,
required: true,
},
issueIndex: {
type: Number,
required: true,
},
// failed || success
status: {
type: String,
......
......@@ -3,8 +3,8 @@ import { mapActions, mapState, mapGetters } from 'vuex';
import ReportSection from '~/vue_shared/components/reports/report_section.vue';
import SummaryRow from '~/vue_shared/components/reports/summary_row.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 { SAST, DAST, SAST_CONTAINER } from './store/constants';
import securityReportsMixin from './mixins/security_report_mixin';
import createStore from './store';
......@@ -111,9 +111,7 @@ export default {
required: true,
},
},
sast: SAST,
dast: DAST,
sastContainer: SAST_CONTAINER,
componentNames,
computed: {
...mapState(['sast', 'sastContainer', 'dast', 'dependencyScanning', 'summaryCounts']),
...mapGetters([
......@@ -229,7 +227,7 @@ export default {
:unresolved-issues="sast.newIssues"
:resolved-issues="sast.resolvedIssues"
:all-issues="sast.allIssues"
:type="$options.sast"
:component="$options.componentNames.SastIssueBody"
class="js-sast-issue-list report-block-group-list"
/>
</template>
......@@ -248,7 +246,7 @@ export default {
:unresolved-issues="dependencyScanning.newIssues"
:resolved-issues="dependencyScanning.resolvedIssues"
:all-issues="dependencyScanning.allIssues"
:type="$options.sast"
:component="$options.componentNames.SastIssueBody"
class="js-dss-issue-list report-block-group-list"
/>
</template>
......@@ -265,7 +263,7 @@ export default {
v-if="sastContainer.newIssues.length || sastContainer.resolvedIssues.length"
:unresolved-issues="sastContainer.newIssues"
:neutral-issues="sastContainer.resolvedIssues"
:type="$options.sastContainer"
:component="$options.componentNames.SastContainerIssueBody"
class="report-block-group-list"
/>
</template>
......@@ -282,7 +280,7 @@ export default {
v-if="dast.newIssues.length || dast.resolvedIssues.length"
:unresolved-issues="dast.newIssues"
:resolved-issues="dast.resolvedIssues"
:type="$options.dast"
:component="$options.componentNames.DastIssueBody"
class="report-block-group-list"
/>
</template>
......
......@@ -3,7 +3,7 @@ import { mapActions, mapState } from 'vuex';
import { s__, sprintf, n__ } from '~/locale';
import createFlash from '~/flash';
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 mixin from './mixins/security_report_mixin';
import reportsMixin from './mixins/reports_mixin';
......@@ -88,9 +88,7 @@ export default {
required: true,
},
},
sast: SAST,
dast: DAST,
sastContainer: SAST_CONTAINER,
componentNames,
computed: {
...mapState(['sast', 'dependencyScanning', 'sastContainer', 'dast']),
......@@ -216,7 +214,7 @@ export default {
<report-section
v-if="sastHeadPath"
:always-open="alwaysOpen"
:type="$options.sast"
:component="$options.componentNames.SastIssueBody"
:status="checkReportStatus(sast.isLoading, sast.hasError)"
:loading-text="translateText('SAST').loading"
:error-text="translateText('SAST').error"
......@@ -230,7 +228,7 @@ export default {
<report-section
v-if="dependencyScanningHeadPath"
:always-open="alwaysOpen"
:type="$options.sast"
:component="$options.componentNames.SastIssueBody"
:status="checkReportStatus(dependencyScanning.isLoading, dependencyScanning.hasError)"
:loading-text="translateText('Dependency scanning').loading"
:error-text="translateText('Dependency scanning').error"
......@@ -244,7 +242,7 @@ export default {
<report-section
v-if="sastContainerHeadPath"
:always-open="alwaysOpen"
:type="$options.sastContainer"
:component="$options.componentNames.SastContainerIssueBody"
:status="checkReportStatus(sastContainer.isLoading, sastContainer.hasError)"
:loading-text="translateText('Container scanning').loading"
:error-text="translateText('Container scanning').error"
......@@ -258,7 +256,7 @@ export default {
<report-section
v-if="dastHeadPath"
:always-open="alwaysOpen"
:type="$options.dast"
:component="$options.componentNames.DastIssueBody"
:status="checkReportStatus(dast.isLoading, dast.hasError)"
:loading-text="translateText('DAST').loading"
:error-text="translateText('DAST').error"
......
......@@ -47,7 +47,7 @@ module EE
def update
@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
redirect_to edit_project_service_path(project, ::PrometheusService),
......@@ -63,7 +63,7 @@ module EE
def destroy
metric = project.prometheus_metrics.find(params[:id])
metric.destroy
destroy_metrics_service(metric).execute
respond_to do |format|
format.html do
......@@ -77,6 +77,14 @@ module EE
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
params.require(:prometheus_metric).permit(:title, :query, :y_label, :unit, :legend, :group)
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
mail(to: new_mirror_user.notification_email,
subject: subject('Mirror user changed'))
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
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 Environment
extend ActiveSupport::Concern
prepended do
has_many :prometheus_alerts, inverse_of: :environment
end
def pod_names
return [] unless rollout_status
......@@ -7,5 +13,13 @@ module EE
instance[:pod_name]
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
......@@ -39,6 +39,8 @@ module EE
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 :mirror, -> { where(mirror: true) }
......@@ -99,6 +101,12 @@ module EE
pipelines.newest_first(default_branch).with_security_reports.first
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
return if external_webhook_token.present?
......
......@@ -75,6 +75,7 @@ class License < ActiveRecord::Base
chatops
pod_logs
pseudonymizer
prometheus_alerts
].freeze
# 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
belongs_to :project, required: true, validate: true, inverse_of: :prometheus_metrics
has_one :prometheus_alert, inverse_of: :prometheus_metric
enum group: [:business, :response, :system]
validates :title, presence: true
......@@ -19,7 +22,7 @@ class PrometheusMetric < ActiveRecord::Base
end
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
private
......
......@@ -49,6 +49,10 @@ module EE
@subject.feature_available?(:pod_logs, @user)
end
condition(:prometheus_alerts_enabled) do
@subject.feature_available?(:prometheus_alerts, @user)
end
rule { admin }.enable :change_repository_storage
rule { support_bot }.enable :guest_access
......@@ -97,6 +101,7 @@ module EE
end
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
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
mailer.project_mirror_user_changed_email(new_mirror_user.id, deleted_user_name, project.id).deliver_later
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
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 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;
const Component = Vue.extend(component);
......@@ -22,7 +27,7 @@ describe('sast issue body', () => {
it('renders fixed label', () => {
vm = mountComponent(Component, {
issue: codequalityIssue,
isStatusSuccess: true,
status: STATUS_SUCCESS,
});
expect(vm.$el.textContent.trim()).toContain('Fixed');
......@@ -33,7 +38,7 @@ describe('sast issue body', () => {
it('renders fixed label', () => {
vm = mountComponent(Component, {
issue: codequalityIssue,
isStatusSuccess: false,
status: STATUS_FAILED,
});
expect(vm.$el.textContent.trim()).not.toContain('Fixed');
......@@ -44,7 +49,7 @@ describe('sast issue body', () => {
it('renders name', () => {
vm = mountComponent(Component, {
issue: codequalityIssue,
isStatusSuccess: false,
status: STATUS_NEUTRAL,
});
expect(vm.$el.textContent.trim()).toContain(codequalityIssue.name);
......@@ -55,15 +60,11 @@ describe('sast issue body', () => {
it('renders name', () => {
vm = mountComponent(Component, {
issue: codequalityIssue,
isStatusSuccess: false,
status: STATUS_NEUTRAL,
});
expect(vm.$el.querySelector('a').getAttribute('href')).toEqual(
codequalityIssue.urlPath,
);
expect(vm.$el.querySelector('a').textContent.trim()).toEqual(
codequalityIssue.path,
);
expect(vm.$el.querySelector('a').getAttribute('href')).toEqual(codequalityIssue.urlPath);
expect(vm.$el.querySelector('a').textContent.trim()).toEqual(codequalityIssue.path);
});
});
});
import Vue from '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', () => {
let vm;
......
import Vue from '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 mountComponent, { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import {
codequalityParsedIssues,
} from 'spec/vue_mr_widget/mock_data';
import { codequalityParsedIssues } from 'spec/vue_mr_widget/mock_data';
import {
sastParsedIssues,
dockerReportParsed,
......@@ -28,8 +28,8 @@ describe('Report issues', () => {
beforeEach(() => {
vm = mountComponent(ReportIssues, {
issues: codequalityParsedIssues,
type: 'codequality',
status: 'success',
component: componentNames.CodequalityIssueBody,
status: STATUS_SUCCESS,
});
});
......@@ -49,8 +49,8 @@ describe('Report issues', () => {
beforeEach(() => {
vm = mountComponent(ReportIssues, {
issues: codequalityParsedIssues,
type: 'codequality',
status: 'failed',
component: componentNames.CodequalityIssueBody,
status: STATUS_FAILED,
});
});
......@@ -68,8 +68,8 @@ describe('Report issues', () => {
beforeEach(() => {
vm = mountComponent(ReportIssues, {
issues: sastParsedIssues,
type: 'SAST',
status: 'failed',
component: componentNames.SastIssueBody,
status: STATUS_FAILED,
});
});
......@@ -82,8 +82,8 @@ describe('Report issues', () => {
it('should render location', () => {
vm = mountComponent(ReportIssues, {
issues: sastParsedIssues,
type: 'SAST',
status: 'failed',
component: componentNames.SastIssueBody,
status: STATUS_FAILED,
});
expect(vm.$el.querySelector('.report-block-list li').textContent).toContain('in');
......@@ -97,8 +97,8 @@ describe('Report issues', () => {
issues: [{
title: 'foo',
}],
type: 'SAST',
status: 'failed',
component: componentNames.SastIssueBody,
status: STATUS_SUCCESS,
});
expect(vm.$el.querySelector('.report-block-list li').textContent).not.toContain('in');
......@@ -110,8 +110,8 @@ describe('Report issues', () => {
beforeEach(() => {
vm = mountComponent(ReportIssues, {
issues: dockerReportParsed.unapproved,
type: 'SAST_CONTAINER',
status: 'failed',
component: componentNames.SastContainerIssueBody,
status: STATUS_FAILED,
});
});
......@@ -142,8 +142,8 @@ describe('Report issues', () => {
vm = mountComponentWithStore(ReportIssues, { store,
props: {
issues: parsedDast,
type: 'DAST',
status: 'failed',
component: componentNames.DastIssueBody,
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
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
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
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
around do |example|
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 Kubernetes
class ConfigMap
def initialize(name, values)
def initialize(name, values = "")
@name = name
@values = values
end
......@@ -13,6 +13,10 @@ module Gitlab
resource
end
def config_map_name
"values-content-configuration-#{name}"
end
private
attr_reader :name, :values
......@@ -25,10 +29,6 @@ module Gitlab
}
end
def config_map_name
"values-content-configuration-#{name}"
end
def namespace
Gitlab::Kubernetes::Helm::NAMESPACE
end
......
......@@ -2,15 +2,17 @@ module Gitlab
module Kubernetes
module Helm
class Api
prepend EE::Gitlab::Kubernetes::Helm::Api
def initialize(kubeclient)
@kubeclient = kubeclient
@namespace = Gitlab::Kubernetes::Namespace.new(Gitlab::Kubernetes::Helm::NAMESPACE, kubeclient)
end
def install(command)
@namespace.ensure_exists!
namespace.ensure_exists!
create_config_map(command) if command.config_map?
@kubeclient.create_pod(command.pod_resource)
kubeclient.create_pod(command.pod_resource)
end
##
......@@ -20,23 +22,25 @@ module Gitlab
#
# values: "Pending", "Running", "Succeeded", "Failed", "Unknown"
#
def installation_status(pod_name)
@kubeclient.get_pod(pod_name, @namespace.name).status.phase
def status(pod_name)
kubeclient.get_pod(pod_name, namespace.name).status.phase
end
def installation_log(pod_name)
@kubeclient.get_pod_log(pod_name, @namespace.name).body
def log(pod_name)
kubeclient.get_pod_log(pod_name, namespace.name).body
end
def delete_installation_pod!(pod_name)
@kubeclient.delete_pod(pod_name, @namespace.name)
def delete_pod!(pod_name)
kubeclient.delete_pod(pod_name, namespace.name)
end
private
attr_reader :kubeclient, :namespace
def create_config_map(command)
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
......
......@@ -3,7 +3,7 @@ module Gitlab
class Metric
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
......
......@@ -8,6 +8,7 @@ module Gitlab
Deployment.find_by(id: deployment_id).try do |deployment|
query_metrics(
deployment.project,
deployment.environment,
common_query_context(
deployment.environment,
timeframe_start: (deployment.created_at - 30.minutes).to_f,
......
......@@ -8,6 +8,7 @@ module Gitlab
::Environment.find_by(id: environment_id).try do |environment|
query_metrics(
environment.project,
environment,
common_query_context(environment, timeframe_start: 8.hours.ago.to_f, timeframe_end: Time.now.to_f)
)
end
......
......@@ -2,7 +2,9 @@ module Gitlab
module Prometheus
module Queries
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))
.select(&method(:group_with_any_metrics))
end
......@@ -14,12 +16,16 @@ module Gitlab
lambda do |group|
metrics = group.metrics.map do |metric|
{
metric_hsh = {
title: metric.title,
weight: metric.weight,
y_label: metric.y_label,
queries: metric.queries.map(&query_processor).select(&method(:query_with_result))
}
metric_hsh[:id] = metric.id if metric.id
metric_hsh
end
{
......
......@@ -5060,6 +5060,36 @@ msgstr ""
msgid "ProjectsDropdown|Sorry, no projects matched your search"
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"
msgstr ""
......@@ -6227,7 +6257,7 @@ msgstr ""
msgid "This application will be able to:"
msgstr ""
msgid "This board\\'s scope is reduced"
msgid "This board's scope is reduced"
msgstr ""
msgid "This diff is collapsed."
......
......@@ -22,11 +22,24 @@ FactoryBot.define do
status 3
end
trait :updating do
status 4
end
trait :updated do
status 5
end
trait :errored do
status(-1)
status_reason 'something went wrong'
end
trait :update_errored do
status(6)
status_reason 'something went wrong'
end
trait :timeouted do
installing
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(() => {
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();
// 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