diff options
author | Rémy Coutable <remy@rymai.me> | 2017-03-07 16:57:44 +0000 |
---|---|---|
committer | Rémy Coutable <remy@rymai.me> | 2017-03-07 16:57:44 +0000 |
commit | 4998f15169f0d8875676070ee29b5cbca96d582d (patch) | |
tree | 6ebbdf0f12c5e0d30a3e806284c361b8764d9ccf | |
parent | a5db7f54252d22e3ecd49786a3fdff7c46658fa3 (diff) | |
parent | 55f2a5debcf10a4d3ca1d0e53fe06f38bc0b77a9 (diff) | |
download | gitlab-ce-4998f15169f0d8875676070ee29b5cbca96d582d.tar.gz |
Merge branch 'prometheus-monitoring' into 'master'
Added Prometheus Service and Prometheus graphs
See merge request !8935
30 files changed, 2301 insertions, 28 deletions
diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 31f10f89245..4f0c85f4161 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -1,3 +1,4 @@ +import PrometheusGraph from './monitoring/prometheus_graph'; // TODO: Maybe Make this a bundle /* eslint-disable func-names, space-before-function-paren, no-var, prefer-arrow-callback, wrap-iife, no-shadow, consistent-return, one-var, one-var-declaration-per-line, camelcase, default-case, no-new, quotes, no-duplicate-case, no-case-declarations, no-fallthrough, max-len */ /* global UsernameValidator */ /* global ActiveTabMemoizer */ @@ -297,6 +298,8 @@ const UserCallout = require('./user_callout'); case 'ci:lints:show': new gl.CILintEditor(); break; + case 'projects:environments:metrics': + new PrometheusGraph(); case 'users:show': new UserCallout(); break; diff --git a/app/assets/javascripts/monitoring/prometheus_graph.js b/app/assets/javascripts/monitoring/prometheus_graph.js new file mode 100644 index 00000000000..9384fe3f276 --- /dev/null +++ b/app/assets/javascripts/monitoring/prometheus_graph.js @@ -0,0 +1,333 @@ +/* eslint-disable no-new*/ +import d3 from 'd3'; +import _ from 'underscore'; +import statusCodes from '~/lib/utils/http_status'; +import '~/lib/utils/common_utils'; +import Flash from '~/flash'; + +const prometheusGraphsContainer = '.prometheus-graph'; +const metricsEndpoint = 'metrics.json'; +const timeFormat = d3.time.format('%H:%M'); +const dayFormat = d3.time.format('%b %e, %a'); +const bisectDate = d3.bisector(d => d.time).left; +const extraAddedWidthParent = 100; + +class PrometheusGraph { + + constructor() { + this.margin = { top: 80, right: 180, bottom: 80, left: 100 }; + this.marginLabelContainer = { top: 40, right: 0, bottom: 40, left: 0 }; + const parentContainerWidth = $(prometheusGraphsContainer).parent().width() + + extraAddedWidthParent; + this.originalWidth = parentContainerWidth; + this.originalHeight = 400; + this.width = parentContainerWidth - this.margin.left - this.margin.right; + this.height = 400 - this.margin.top - this.margin.bottom; + this.backOffRequestCounter = 0; + this.configureGraph(); + this.init(); + } + + createGraph() { + const self = this; + _.each(this.data, (value, key) => { + if (value.length > 0 && (key === 'cpu_values' || key === 'memory_values')) { + self.plotValues(value, key); + } + }); + } + + init() { + const self = this; + this.getData().then((metricsResponse) => { + if (metricsResponse === {}) { + new Flash('Empty metrics', 'alert'); + } else { + self.transformData(metricsResponse); + self.createGraph(); + } + }); + } + + plotValues(valuesToPlot, key) { + const x = d3.time.scale() + .range([0, this.width]); + + const y = d3.scale.linear() + .range([this.height, 0]); + + const prometheusGraphContainer = `${prometheusGraphsContainer}[graph-type=${key}]`; + + const graphSpecifics = this.graphSpecificProperties[key]; + + const chart = d3.select(prometheusGraphContainer) + .attr('width', this.width + this.margin.left + this.margin.right) + .attr('height', this.height + this.margin.bottom + this.margin.top) + .append('g') + .attr('transform', `translate(${this.margin.left},${this.margin.top})`); + + const axisLabelContainer = d3.select(prometheusGraphContainer) + .attr('width', this.originalWidth + this.marginLabelContainer.left + this.marginLabelContainer.right) + .attr('height', this.originalHeight + this.marginLabelContainer.bottom + this.marginLabelContainer.top) + .append('g') + .attr('transform', `translate(${this.marginLabelContainer.left},${this.marginLabelContainer.top})`); + + x.domain(d3.extent(valuesToPlot, d => d.time)); + y.domain([0, d3.max(valuesToPlot.map(metricValue => metricValue.value))]); + + const xAxis = d3.svg.axis() + .scale(x) + .ticks(this.commonGraphProperties.axis_no_ticks) + .orient('bottom'); + + const yAxis = d3.svg.axis() + .scale(y) + .ticks(this.commonGraphProperties.axis_no_ticks) + .tickSize(-this.width) + .orient('left'); + + this.createAxisLabelContainers(axisLabelContainer, key); + + chart.append('g') + .attr('class', 'x-axis') + .attr('transform', `translate(0,${this.height})`) + .call(xAxis); + + chart.append('g') + .attr('class', 'y-axis') + .call(yAxis); + + const area = d3.svg.area() + .x(d => x(d.time)) + .y0(this.height) + .y1(d => y(d.value)) + .interpolate('linear'); + + const line = d3.svg.line() + .x(d => x(d.time)) + .y(d => y(d.value)); + + chart.append('path') + .datum(valuesToPlot) + .attr('d', area) + .attr('class', 'metric-area') + .attr('fill', graphSpecifics.area_fill_color); + + chart.append('path') + .datum(valuesToPlot) + .attr('class', 'metric-line') + .attr('stroke', graphSpecifics.line_color) + .attr('fill', 'none') + .attr('stroke-width', this.commonGraphProperties.area_stroke_width) + .attr('d', line); + + // Overlay area for the mouseover events + chart.append('rect') + .attr('class', 'prometheus-graph-overlay') + .attr('width', this.width) + .attr('height', this.height) + .on('mousemove', this.handleMouseOverGraph.bind(this, x, y, valuesToPlot, chart, prometheusGraphContainer, key)); + } + + // The legends from the metric + createAxisLabelContainers(axisLabelContainer, key) { + const graphSpecifics = this.graphSpecificProperties[key]; + + axisLabelContainer.append('line') + .attr('class', 'label-x-axis-line') + .attr('stroke', '#000000') + .attr('stroke-width', '1') + .attr({ + x1: 0, + y1: this.originalHeight - this.marginLabelContainer.top, + x2: this.originalWidth - this.margin.right, + y2: this.originalHeight - this.marginLabelContainer.top, + }); + + axisLabelContainer.append('line') + .attr('class', 'label-y-axis-line') + .attr('stroke', '#000000') + .attr('stroke-width', '1') + .attr({ + x1: 0, + y1: 0, + x2: 0, + y2: this.originalHeight - this.marginLabelContainer.top, + }); + + axisLabelContainer.append('text') + .attr('class', 'label-axis-text') + .attr('text-anchor', 'middle') + .attr('transform', `translate(15, ${(this.originalHeight - this.marginLabelContainer.top) / 2}) rotate(-90)`) + .text(graphSpecifics.graph_legend_title); + + axisLabelContainer.append('rect') + .attr('class', 'rect-axis-text') + .attr('x', (this.originalWidth / 2) - this.margin.right) + .attr('y', this.originalHeight - this.marginLabelContainer.top - 20) + .attr('width', 30) + .attr('height', 80); + + axisLabelContainer.append('text') + .attr('class', 'label-axis-text') + .attr('x', (this.originalWidth / 2) - this.margin.right) + .attr('y', this.originalHeight - this.marginLabelContainer.top) + .attr('dy', '.35em') + .text('Time'); + + // Legends + + // Metric Usage + axisLabelContainer.append('rect') + .attr('x', this.originalWidth - 170) + .attr('y', (this.originalHeight / 2) - 80) + .style('fill', graphSpecifics.area_fill_color) + .attr('width', 20) + .attr('height', 35); + + axisLabelContainer.append('text') + .attr('class', 'label-axis-text') + .attr('x', this.originalWidth - 140) + .attr('y', (this.originalHeight / 2) - 65) + .text(graphSpecifics.graph_legend_title); + + axisLabelContainer.append('text') + .attr('class', 'text-metric-usage') + .attr('x', this.originalWidth - 140) + .attr('y', (this.originalHeight / 2) - 50); + } + + handleMouseOverGraph(x, y, valuesToPlot, chart, prometheusGraphContainer, key) { + const rectOverlay = document.querySelector(`${prometheusGraphContainer} .prometheus-graph-overlay`); + const timeValueFromOverlay = x.invert(d3.mouse(rectOverlay)[0]); + const timeValueIndex = bisectDate(valuesToPlot, timeValueFromOverlay, 1); + const d0 = valuesToPlot[timeValueIndex - 1]; + const d1 = valuesToPlot[timeValueIndex]; + const currentData = timeValueFromOverlay - d0.time > d1.time - timeValueFromOverlay ? d1 : d0; + const maxValueMetric = y(d3.max(valuesToPlot.map(metricValue => metricValue.value))); + const currentTimeCoordinate = x(currentData.time); + const graphSpecifics = this.graphSpecificProperties[key]; + // Remove the current selectors + d3.selectAll(`${prometheusGraphContainer} .selected-metric-line`).remove(); + d3.selectAll(`${prometheusGraphContainer} .circle-metric`).remove(); + d3.selectAll(`${prometheusGraphContainer} .rect-text-metric`).remove(); + d3.selectAll(`${prometheusGraphContainer} .text-metric`).remove(); + + chart.append('line') + .attr('class', 'selected-metric-line') + .attr({ + x1: currentTimeCoordinate, + y1: y(0), + x2: currentTimeCoordinate, + y2: maxValueMetric, + }); + + chart.append('circle') + .attr('class', 'circle-metric') + .attr('fill', graphSpecifics.line_color) + .attr('cx', currentTimeCoordinate) + .attr('cy', y(currentData.value)) + .attr('r', this.commonGraphProperties.circle_radius_metric); + + // The little box with text + const rectTextMetric = chart.append('g') + .attr('class', 'rect-text-metric') + .attr('translate', `(${currentTimeCoordinate}, ${y(currentData.value)})`); + + rectTextMetric.append('rect') + .attr('class', 'rect-metric') + .attr('x', currentTimeCoordinate + 10) + .attr('y', maxValueMetric) + .attr('width', this.commonGraphProperties.rect_text_width) + .attr('height', this.commonGraphProperties.rect_text_height); + + rectTextMetric.append('text') + .attr('class', 'text-metric') + .attr('x', currentTimeCoordinate + 35) + .attr('y', maxValueMetric + 35) + .text(timeFormat(currentData.time)); + + rectTextMetric.append('text') + .attr('class', 'text-metric-date') + .attr('x', currentTimeCoordinate + 15) + .attr('y', maxValueMetric + 15) + .text(dayFormat(currentData.time)); + + // Update the text + d3.select(`${prometheusGraphContainer} .text-metric-usage`) + .text(currentData.value.substring(0, 8)); + } + + configureGraph() { + this.graphSpecificProperties = { + cpu_values: { + area_fill_color: '#edf3fc', + line_color: '#5b99f7', + graph_legend_title: 'CPU Usage (Cores)', + }, + memory_values: { + area_fill_color: '#fca326', + line_color: '#fc6d26', + graph_legend_title: 'Memory Usage (MB)', + }, + }; + + this.commonGraphProperties = { + area_stroke_width: 2, + median_total_characters: 8, + circle_radius_metric: 5, + rect_text_width: 90, + rect_text_height: 40, + axis_no_ticks: 3, + }; + } + + getData() { + const maxNumberOfRequests = 3; + return gl.utils.backOff((next, stop) => { + $.ajax({ + url: metricsEndpoint, + dataType: 'json', + }) + .done((data, statusText, resp) => { + if (resp.status === statusCodes.NO_CONTENT) { + this.backOffRequestCounter = this.backOffRequestCounter += 1; + if (this.backOffRequestCounter < maxNumberOfRequests) { + next(); + } else { + stop({ + status: resp.status, + metrics: data, + }); + } + } else { + stop({ + status: resp.status, + metrics: data, + }); + } + }).fail(stop); + }) + .then((resp) => { + if (resp.status === statusCodes.NO_CONTENT) { + return {}; + } + return resp.metrics; + }) + .catch(() => new Flash('An error occurred while fetching metrics.', 'alert')); + } + + transformData(metricsResponse) { + const metricTypes = {}; + _.each(metricsResponse.metrics, (value, key) => { + const metricValues = value[0].values; + metricTypes[key] = _.map(metricValues, metric => ({ + time: new Date(metric[0] * 1000), + value: metric[1], + })); + }); + this.data = metricTypes; + } +} + +export default PrometheusGraph; diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 77e09e66340..0e2b8dba780 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -143,3 +143,71 @@ } } } + +.prometheus-graph { + text { + fill: $stat-graph-axis-fill; + } +} + +.x-axis path, +.y-axis path, +.label-x-axis-line, +.label-y-axis-line { + fill: none; + stroke-width: 1; + shape-rendering: crispEdges; +} + +.x-axis path, +.y-axis path { + stroke: $stat-graph-axis-fill; +} + +.label-x-axis-line, +.label-y-axis-line { + stroke: $border-color; +} + +.y-axis { + line { + stroke: $stat-graph-axis-fill; + stroke-width: 1; + } +} + +.metric-area { + opacity: 0.8; +} + +.prometheus-graph-overlay { + fill: none; + opacity: 0.0; + pointer-events: all; +} + +.rect-text-metric { + fill: $white-light; + stroke-width: 1; + stroke: $black; +} + +.rect-axis-text { + fill: $white-light; +} + +.text-metric, +.text-median-metric, +.text-metric-usage, +.text-metric-date { + fill: $black; +} + +.text-metric-date { + font-weight: 200; +} + +.selected-metric-line { + stroke: $black; + stroke-width: 1; +} diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index fed75396d6e..fa37963dfd4 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -5,7 +5,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController before_action :authorize_create_deployment!, only: [:stop] before_action :authorize_update_environment!, only: [:edit, :update] before_action :authorize_admin_environment!, only: [:terminal, :terminal_websocket_authorize] - before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize] + before_action :environment, only: [:show, :edit, :update, :stop, :terminal, :terminal_websocket_authorize, :metrics] before_action :verify_api_request!, only: :terminal_websocket_authorize def index @@ -109,6 +109,19 @@ class Projects::EnvironmentsController < Projects::ApplicationController end end + def metrics + # Currently, this acts as a hint to load the metrics details into the cache + # if they aren't there already + @metrics = environment.metrics || {} + + respond_to do |format| + format.html + format.json do + render json: @metrics, status: @metrics.any? ? :ok : :no_content + end + end + end + private def verify_api_request! diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index f16a63e2178..e9b7cbbad6a 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -74,6 +74,10 @@ module GitlabRoutingHelper namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args) end + def environment_metrics_path(environment, *args) + metrics_namespace_project_environment_path(environment.project.namespace, environment.project, environment, *args) + end + def issue_path(entity, *args) namespace_project_issue_path(entity.project.namespace, entity.project, entity, *args) end diff --git a/app/models/environment.rb b/app/models/environment.rb index 1a21b5e52b5..bf33010fd21 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -145,6 +145,14 @@ class Environment < ActiveRecord::Base project.deployment_service.terminals(self) if has_terminals? end + def has_metrics? + project.monitoring_service.present? && available? && last_deployment.present? + end + + def metrics + project.monitoring_service.metrics(self) if has_metrics? + end + # An environment name is not necessarily suitable for use in URLs, DNS # or other third-party contexts, so provide a slugified version. A slug has # the following properties: diff --git a/app/models/project.rb b/app/models/project.rb index 90e40c6fa8d..8c2dadf4659 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -113,6 +113,7 @@ class Project < ActiveRecord::Base has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project has_one :external_wiki_service, dependent: :destroy has_one :kubernetes_service, dependent: :destroy, inverse_of: :project + has_one :prometheus_service, dependent: :destroy, inverse_of: :project has_one :mock_ci_service, dependent: :destroy has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id" @@ -771,6 +772,14 @@ class Project < ActiveRecord::Base @deployment_service ||= deployment_services.reorder(nil).find_by(active: true) end + def monitoring_services + services.where(category: :monitoring) + end + + def monitoring_service + @monitoring_service ||= monitoring_services.reorder(nil).find_by(active: true) + end + def jira_tracker? issues_tracker.to_param == 'jira' end diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb new file mode 100644 index 00000000000..ea585721e8f --- /dev/null +++ b/app/models/project_services/monitoring_service.rb @@ -0,0 +1,16 @@ +# Base class for monitoring services +# +# These services integrate with a deployment solution like Prometheus +# to provide additional features for environments. +class MonitoringService < Service + default_value_for :category, 'monitoring' + + def self.supported_events + %w() + end + + # Environments have a number of metrics + def metrics(environment) + raise NotImplementedError + end +end diff --git a/app/models/project_services/prometheus_service.rb b/app/models/project_services/prometheus_service.rb new file mode 100644 index 00000000000..375966b9efc --- /dev/null +++ b/app/models/project_services/prometheus_service.rb @@ -0,0 +1,93 @@ +class PrometheusService < MonitoringService + include ReactiveCaching + + self.reactive_cache_key = ->(service) { [service.class.model_name.singular, service.project_id] } + self.reactive_cache_lease_timeout = 30.seconds + self.reactive_cache_refresh_interval = 30.seconds + self.reactive_cache_lifetime = 1.minute + + # Access to prometheus is directly through the API + prop_accessor :api_url + + with_options presence: true, if: :activated? do + validates :api_url, url: true + end + + after_save :clear_reactive_cache! + + def initialize_properties + if properties.nil? + self.properties = {} + end + end + + def title + 'Prometheus' + end + + def description + 'Prometheus monitoring' + end + + def help + 'Retrieves `container_cpu_usage_seconds_total` and `container_memory_usage_bytes` from the configured Prometheus server. An `environment` label is required on each metric to identify the Environment.' + end + + def self.to_param + 'prometheus' + end + + def fields + [ + { + type: 'text', + name: 'api_url', + title: 'API URL', + placeholder: 'Prometheus API Base URL, like http://prometheus.example.com/' + } + ] + end + + # Check we can connect to the Prometheus API + def test(*args) + client.ping + + { success: true, result: 'Checked API endpoint' } + rescue Gitlab::PrometheusError => err + { success: false, result: err } + end + + def metrics(environment) + with_reactive_cache(environment.slug) do |data| + data + end + end + + # Cache metrics for specific environment + def calculate_reactive_cache(environment_slug) + return unless active? && project && !project.pending_delete? + + memory_query = %{sum(container_memory_usage_bytes{container_name="app",environment="#{environment_slug}"})/1024/1024} + cpu_query = %{sum(rate(container_cpu_usage_seconds_total{container_name="app",environment="#{environment_slug}"}[2m]))} + + { + success: true, + metrics: { + # Memory used in MB + memory_values: client.query_range(memory_query, start: 8.hours.ago), + memory_current: client.query(memory_query), + # CPU Usage rate in cores. + cpu_values: client.query_range(cpu_query, start: 8.hours.ago), + cpu_current: client.query(cpu_query) + }, + last_update: Time.now.utc + } + + rescue Gitlab::PrometheusError => err + { success: false, result: err.message } + end + + def client + @prometheus ||= Gitlab::Prometheus.new(api_url: api_url) + end +end diff --git a/app/models/service.rb b/app/models/service.rb index 3ef4cbead10..2f75a2e4e7f 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -232,6 +232,7 @@ class Service < ActiveRecord::Base mattermost pipelines_email pivotaltracker + prometheus pushover redmine slack_slash_commands diff --git a/app/views/projects/environments/_metrics_button.html.haml b/app/views/projects/environments/_metrics_button.html.haml new file mode 100644 index 00000000000..acbac1869fd --- /dev/null +++ b/app/views/projects/environments/_metrics_button.html.haml @@ -0,0 +1,6 @@ +- environment = local_assigns.fetch(:environment) + +- return unless environment.has_metrics? && can?(current_user, :read_environment, environment) + += link_to environment_metrics_path(environment), title: 'See metrics', class: 'btn metrics-button' do + = icon('area-chart') diff --git a/app/views/projects/environments/metrics.html.haml b/app/views/projects/environments/metrics.html.haml new file mode 100644 index 00000000000..f8e94ca98ae --- /dev/null +++ b/app/views/projects/environments/metrics.html.haml @@ -0,0 +1,21 @@ +- @no_container = true +- page_title "Metrics for environment", @environment.name += render "projects/pipelines/head" + +%div{ class: container_class } + .top-area + .row + .col-sm-6 + %h3.page-title + Environment: + = @environment.name + + .col-sm-6 + .nav-controls + = render 'projects/deployments/actions', deployment: @environment.last_deployment + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'memory_values' } diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 7036325fff8..29a98f23b88 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -8,6 +8,7 @@ %h3.page-title= @environment.name .col-md-3 .nav-controls + = render 'projects/environments/metrics_button', environment: @environment = render 'projects/environments/terminal_button', environment: @environment = render 'projects/environments/external_url', environment: @environment - if can?(current_user, :update_environment, @environment) diff --git a/config/routes/project.rb b/config/routes/project.rb index df39c3e200c..976eaf65b42 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -159,6 +159,7 @@ constraints(ProjectUrlConstrainer.new) do member do post :stop get :terminal + get :metrics get '/terminal.ws/authorize', to: 'environments#terminal_websocket_authorize', constraints: { format: nil } end diff --git a/lib/api/services.rb b/lib/api/services.rb index 1cf29d9a1a3..5aa2f5eba7b 100644 --- a/lib/api/services.rb +++ b/lib/api/services.rb @@ -422,6 +422,14 @@ module API desc: 'Comma-separated list of branches which will be automatically inspected. Leave blank to include all branches.' } ], + 'prometheus' => [ + { + required: true, + name: :api_url, + type: String, + desc: 'Prometheus API Base URL, like http://prometheus.example.com/' + } + ], 'pushover' => [ { required: true, @@ -558,6 +566,7 @@ module API SlackSlashCommandsService, PipelinesEmailService, PivotaltrackerService, + PrometheusService, PushoverService, RedmineService, SlackService, diff --git a/lib/gitlab/prometheus.rb b/lib/gitlab/prometheus.rb new file mode 100644 index 00000000000..62239779454 --- /dev/null +++ b/lib/gitlab/prometheus.rb @@ -0,0 +1,70 @@ +module Gitlab + PrometheusError = Class.new(StandardError) + + # Helper methods to interact with Prometheus network services & resources + class Prometheus + attr_reader :api_url + + def initialize(api_url:) + @api_url = api_url + end + + def ping + json_api_get('query', query: '1') + end + + def query(query) + get_result('vector') do + json_api_get('query', query: query) + end + end + + def query_range(query, start: 8.hours.ago) + get_result('matrix') do + json_api_get('query_range', + query: query, + start: start.to_f, + end: Time.now.utc.to_f, + step: 1.minute.to_i) + end + end + + private + + def json_api_get(type, args = {}) + get(join_api_url(type, args)) + rescue Errno::ECONNREFUSED + raise PrometheusError, 'Connection refused' + end + + def join_api_url(type, args = {}) + url = URI.parse(api_url) + rescue URI::Error + raise PrometheusError, "Invalid API URL: #{api_url}" + else + url.path = [url.path.sub(%r{/+\z}, ''), 'api', 'v1', type].join('/') + url.query = args.to_query + + url.to_s + end + + def get(url) + handle_response(HTTParty.get(url)) + end + + def handle_response(response) + if response.code == 200 && response['status'] == 'success' + response['data'] || {} + elsif response.code == 400 + raise PrometheusError, response['error'] || 'Bad data received' + else + raise PrometheusError, "#{response.code} - #{response.body}" + end + end + + def get_result(expected_type) + data = yield + data['result'] if data['resultType'] == expected_type + end + end +end diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index 84d119f1867..83d80b376fb 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -187,6 +187,52 @@ describe Projects::EnvironmentsController do end end + describe 'GET #metrics' do + before do + allow(controller).to receive(:environment).and_return(environment) + end + + context 'when environment has no metrics' do + before do + expect(environment).to receive(:metrics).and_return(nil) + end + + it 'returns a metrics page' do + get :metrics, environment_params + + expect(response).to be_ok + end + + context 'when requesting metrics as JSON' do + it 'returns a metrics JSON document' do + get :metrics, environment_params(format: :json) + + expect(response).to have_http_status(204) + expect(json_response).to eq({}) + end + end + end + + context 'when environment has some metrics' do + before do + expect(environment).to receive(:metrics).and_return({ + success: true, + metrics: {}, + last_update: 42 + }) + end + + it 'returns a metrics JSON document' do + get :metrics, environment_params(format: :json) + + expect(response).to be_ok + expect(json_response['success']).to be(true) + expect(json_response['metrics']).to eq({}) + expect(json_response['last_update']).to eq(42) + end + end + end + def environment_params(opts = {}) opts.reverse_merge(namespace_id: project.namespace, project_id: project, diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 70c65bc693a..c6f91e05d83 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -195,4 +195,15 @@ FactoryGirl.define do factory :kubernetes_project, parent: :empty_project do kubernetes_service end + + factory :prometheus_project, parent: :empty_project do + after :create do |project| + project.create_prometheus_service( + active: true, + properties: { + api_url: 'https://prometheus.example.com' + } + ) + end + end end diff --git a/spec/features/projects/environments/environment_metrics_spec.rb b/spec/features/projects/environments/environment_metrics_spec.rb new file mode 100644 index 00000000000..ee925e811e1 --- /dev/null +++ b/spec/features/projects/environments/environment_metrics_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +feature 'Environment > Metrics', :feature do + include PrometheusHelpers + + given(:user) { create(:user) } + given(:project) { create(:prometheus_project) } + given(:pipeline) { create(:ci_pipeline, project: project) } + given(:build) { create(:ci_build, pipeline: pipeline) } + given(:environment) { create(:environment, project: project) } + given(:current_time) { Time.now.utc } + + background do + project.add_developer(user) + create(:deployment, environment: environment, deployable: build) + stub_all_prometheus_requests(environment.slug) + + login_as(user) + visit_environment(environment) + end + + around do |example| + Timecop.freeze(current_time) { example.run } + end + + context 'with deployments and related deployable present' do + scenario 'shows metrics' do + click_link('See metrics') + + expect(page).to have_css('svg.prometheus-graph') + end + end + + def visit_environment(environment) + visit namespace_project_environment_path(environment.project.namespace, + environment.project, + environment) + end +end diff --git a/spec/features/environment_spec.rb b/spec/features/projects/environments/environment_spec.rb index 65373e3f77d..e2d16e0830a 100644 --- a/spec/features/environment_spec.rb +++ b/spec/features/projects/environments/environment_spec.rb @@ -37,13 +37,7 @@ feature 'Environment', :feature do scenario 'does show deployment SHA' do expect(page).to have_link(deployment.short_sha) - end - - scenario 'does not show a re-deploy button for deployment without build' do expect(page).not_to have_link('Re-deploy') - end - - scenario 'does not show terminal button' do expect(page).not_to have_terminal_button end end @@ -58,13 +52,7 @@ feature 'Environment', :feature do scenario 'does show build name' do expect(page).to have_link("#{build.name} (##{build.id})") - end - - scenario 'does show re-deploy button' do expect(page).to have_link('Re-deploy') - end - - scenario 'does not show terminal button' do expect(page).not_to have_terminal_button end @@ -117,9 +105,6 @@ feature 'Environment', :feature do it 'displays a web terminal' do expect(page).to have_selector('#terminal') - end - - it 'displays a link to the environment external url' do expect(page).to have_link(nil, href: environment.external_url) end end @@ -147,10 +132,6 @@ feature 'Environment', :feature do on_stop: 'close_app') end - scenario 'does show stop button' do - expect(page).to have_link('Stop') - end - scenario 'does allow to stop environment' do click_link('Stop') diff --git a/spec/features/environments_spec.rb b/spec/features/projects/environments/environments_spec.rb index 25f31b423b8..25f31b423b8 100644 --- a/spec/features/environments_spec.rb +++ b/spec/features/projects/environments/environments_spec.rb diff --git a/spec/javascripts/fixtures/environments/metrics.html.haml b/spec/javascripts/fixtures/environments/metrics.html.haml new file mode 100644 index 00000000000..483063fb889 --- /dev/null +++ b/spec/javascripts/fixtures/environments/metrics.html.haml @@ -0,0 +1,12 @@ +%div + .top-area + .row + .col-sm-6 + %h3.page-title + Metrics for environment + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'cpu_values' } + .row + .col-sm-12 + %svg.prometheus-graph{ 'graph-type' => 'memory_values' }
\ No newline at end of file diff --git a/spec/javascripts/monitoring/prometheus_graph_spec.js b/spec/javascripts/monitoring/prometheus_graph_spec.js new file mode 100644 index 00000000000..823b4bab7fc --- /dev/null +++ b/spec/javascripts/monitoring/prometheus_graph_spec.js @@ -0,0 +1,78 @@ +import 'jquery'; +import es6Promise from 'es6-promise'; +import '~/lib/utils/common_utils'; +import PrometheusGraph from '~/monitoring/prometheus_graph'; +import { prometheusMockData } from './prometheus_mock_data'; + +es6Promise.polyfill(); + +describe('PrometheusGraph', () => { + const fixtureName = 'static/environments/metrics.html.raw'; + const prometheusGraphContainer = '.prometheus-graph'; + const prometheusGraphContents = `${prometheusGraphContainer}[graph-type=cpu_values]`; + + preloadFixtures(fixtureName); + + beforeEach(() => { + loadFixtures(fixtureName); + this.prometheusGraph = new PrometheusGraph(); + const self = this; + const fakeInit = (metricsResponse) => { + self.prometheusGraph.transformData(metricsResponse); + self.prometheusGraph.createGraph(); + }; + spyOn(this.prometheusGraph, 'init').and.callFake(fakeInit); + }); + + it('initializes graph properties', () => { + // Test for the measurements + expect(this.prometheusGraph.margin).toBeDefined(); + expect(this.prometheusGraph.marginLabelContainer).toBeDefined(); + expect(this.prometheusGraph.originalWidth).toBeDefined(); + expect(this.prometheusGraph.originalHeight).toBeDefined(); + expect(this.prometheusGraph.height).toBeDefined(); + expect(this.prometheusGraph.width).toBeDefined(); + expect(this.prometheusGraph.backOffRequestCounter).toBeDefined(); + // Test for the graph properties (colors, radius, etc.) + expect(this.prometheusGraph.graphSpecificProperties).toBeDefined(); + expect(this.prometheusGraph.commonGraphProperties).toBeDefined(); + }); + + it('transforms the data', () => { + this.prometheusGraph.init(prometheusMockData.metrics); + expect(this.prometheusGraph.data).toBeDefined(); + expect(this.prometheusGraph.data.cpu_values.length).toBe(121); + expect(this.prometheusGraph.data.memory_values.length).toBe(121); + }); + + it('creates two graphs', () => { + this.prometheusGraph.init(prometheusMockData.metrics); + expect($(prometheusGraphContainer).length).toBe(2); + }); + + describe('Graph contents', () => { + beforeEach(() => { + this.prometheusGraph.init(prometheusMockData.metrics); + }); + + it('has axis, an area, a line and a overlay', () => { + const $graphContainer = $(prometheusGraphContents).find('.x-axis').parent(); + expect($graphContainer.find('.x-axis')).toBeDefined(); + expect($graphContainer.find('.y-axis')).toBeDefined(); + expect($graphContainer.find('.prometheus-graph-overlay')).toBeDefined(); + expect($graphContainer.find('.metric-line')).toBeDefined(); + expect($graphContainer.find('.metric-area')).toBeDefined(); + }); + + it('has legends, labels and an extra axis that labels the metrics', () => { + const $prometheusGraphContents = $(prometheusGraphContents); + const $axisLabelContainer = $(prometheusGraphContents).find('.label-x-axis-line').parent(); + expect($prometheusGraphContents.find('.label-x-axis-line')).toBeDefined(); + expect($prometheusGraphContents.find('.label-y-axis-line')).toBeDefined(); + expect($prometheusGraphContents.find('.label-axis-text')).toBeDefined(); + expect($prometheusGraphContents.find('.rect-axis-text')).toBeDefined(); + expect($axisLabelContainer.find('rect').length).toBe(2); + expect($axisLabelContainer.find('text').length).toBe(4); + }); + }); +}); diff --git a/spec/javascripts/monitoring/prometheus_mock_data.js b/spec/javascripts/monitoring/prometheus_mock_data.js new file mode 100644 index 00000000000..1cdc14faaa8 --- /dev/null +++ b/spec/javascripts/monitoring/prometheus_mock_data.js @@ -0,0 +1,1014 @@ +/* eslint-disable import/prefer-default-export*/ +export const prometheusMockData = { + status: 200, + metrics: { + success: true, + metrics: { + memory_values: [ + { + metric: { + }, + values: [ + [ + 1488462917.256, + '10.12890625', + ], + [ + 1488462977.256, + '10.140625', + ], + [ + 1488463037.256, + '10.140625', + ], + [ + 1488463097.256, + '10.14453125', + ], + [ + 1488463157.256, + '10.1484375', + ], + [ + 1488463217.256, + '10.15625', + ], + [ + 1488463277.256, + '10.15625', + ], + [ + 1488463337.256, + '10.15625', + ], + [ + 1488463397.256, + '10.1640625', + ], + [ + 1488463457.256, + '10.171875', + ], + [ + 1488463517.256, + '10.171875', + ], + [ + 1488463577.256, + '10.171875', + ], + [ + 1488463637.256, + '10.18359375', + ], + [ + 1488463697.256, + '10.1953125', + ], + [ + 1488463757.256, + '10.203125', + ], + [ + 1488463817.256, + '10.20703125', + ], + [ + 1488463877.256, + '10.20703125', + ], + [ + 1488463937.256, + '10.20703125', + ], + [ + 1488463997.256, + '10.20703125', + ], + [ + 1488464057.256, + '10.2109375', + ], + [ + 1488464117.256, + '10.2109375', + ], + [ + 1488464177.256, + '10.2109375', + ], + [ + 1488464237.256, + '10.2109375', + ], + [ + 1488464297.256, + '10.21484375', + ], + [ + 1488464357.256, + '10.22265625', + ], + [ + 1488464417.256, + '10.22265625', + ], + [ + 1488464477.256, + '10.2265625', + ], + [ + 1488464537.256, + '10.23046875', + ], + [ + 1488464597.256, + '10.23046875', + ], + [ + 1488464657.256, + '10.234375', + ], + [ + 1488464717.256, + '10.234375', + ], + [ + 1488464777.256, + '10.234375', + ], + [ + 1488464837.256, + '10.234375', + ], + [ + 1488464897.256, + '10.234375', + ], + [ + 1488464957.256, + '10.234375', + ], + [ + 1488465017.256, + '10.23828125', + ], + [ + 1488465077.256, + '10.23828125', + ], + [ + 1488465137.256, + '10.2421875', + ], + [ + 1488465197.256, + '10.2421875', + ], + [ + 1488465257.256, + '10.2421875', + ], + [ + 1488465317.256, + '10.2421875', + ], + [ + 1488465377.256, + '10.2421875', + ], + [ + 1488465437.256, + '10.2421875', + ], + [ + 1488465497.256, + '10.2421875', + ], + [ + 1488465557.256, + '10.2421875', + ], + [ + 1488465617.256, + '10.2421875', + ], + [ + 1488465677.256, + '10.2421875', + ], + [ + 1488465737.256, + '10.2421875', + ], + [ + 1488465797.256, + '10.24609375', + ], + [ + 1488465857.256, + '10.25', + ], + [ + 1488465917.256, + '10.25390625', + ], + [ + 1488465977.256, + '9.98828125', + ], + [ + 1488466037.256, + '9.9921875', + ], + [ + 1488466097.256, + '9.9921875', + ], + [ + 1488466157.256, + '9.99609375', + ], + [ + 1488466217.256, + '10', + ], + [ + 1488466277.256, + '10.00390625', + ], + [ + 1488466337.256, + '10.0078125', + ], + [ + 1488466397.256, + '10.01171875', + ], + [ + 1488466457.256, + '10.0234375', + ], + [ + 1488466517.256, + '10.02734375', + ], + [ + 1488466577.256, + '10.02734375', + ], + [ + 1488466637.256, + '10.03125', + ], + [ + 1488466697.256, + '10.03125', + ], + [ + 1488466757.256, + '10.03125', + ], + [ + 1488466817.256, + '10.03125', + ], + [ + 1488466877.256, + '10.03125', + ], + [ + 1488466937.256, + '10.03125', + ], + [ + 1488466997.256, + '10.03125', + ], + [ + 1488467057.256, + '10.0390625', + ], + [ + 1488467117.256, + '10.0390625', + ], + [ + 1488467177.256, + '10.04296875', + ], + [ + 1488467237.256, + '10.05078125', + ], + [ + 1488467297.256, + '10.05859375', + ], + [ + 1488467357.256, + '10.06640625', + ], + [ + 1488467417.256, + '10.06640625', + ], + [ + 1488467477.256, + '10.0703125', + ], + [ + 1488467537.256, + '10.07421875', + ], + [ + 1488467597.256, + '10.0859375', + ], + [ + 1488467657.256, + '10.0859375', + ], + [ + 1488467717.256, + '10.09765625', + ], + [ + 1488467777.256, + '10.1015625', + ], + [ + 1488467837.256, + '10.10546875', + ], + [ + 1488467897.256, + '10.10546875', + ], + [ + 1488467957.256, + '10.125', + ], + [ + 1488468017.256, + '10.13671875', + ], + [ + 1488468077.256, + '10.1484375', + ], + [ + 1488468137.256, + '10.15625', + ], + [ + 1488468197.256, + '10.16796875', + ], + [ + 1488468257.256, + '10.171875', + ], + [ + 1488468317.256, + '10.171875', + ], + [ + 1488468377.256, + '10.171875', + ], + [ + 1488468437.256, + '10.171875', + ], + [ + 1488468497.256, + '10.171875', + ], + [ + 1488468557.256, + '10.171875', + ], + [ + 1488468617.256, + '10.171875', + ], + [ + 1488468677.256, + '10.17578125', + ], + [ + 1488468737.256, + '10.17578125', + ], + [ + 1488468797.256, + '10.265625', + ], + [ + 1488468857.256, + '10.19921875', + ], + [ + 1488468917.256, + '10.19921875', + ], + [ + 1488468977.256, + '10.19921875', + ], + [ + 1488469037.256, + '10.19921875', + ], + [ + 1488469097.256, + '10.19921875', + ], + [ + 1488469157.256, + '10.203125', + ], + [ + 1488469217.256, + '10.43359375', + ], + [ + 1488469277.256, + '10.20703125', + ], + [ + 1488469337.256, + '10.2109375', + ], + [ + 1488469397.256, + '10.22265625', + ], + [ + 1488469457.256, + '10.21484375', + ], + [ + 1488469517.256, + '10.21484375', + ], + [ + 1488469577.256, + '10.21484375', + ], + [ + 1488469637.256, + '10.22265625', + ], + [ + 1488469697.256, + '10.234375', + ], + [ + 1488469757.256, + '10.234375', + ], + [ + 1488469817.256, + '10.234375', + ], + [ + 1488469877.256, + '10.2421875', + ], + [ + 1488469937.256, + '10.25', + ], + [ + 1488469997.256, + '10.25390625', + ], + [ + 1488470057.256, + '10.26171875', + ], + [ + 1488470117.256, + '10.2734375', + ], + ], + }, + ], + memory_current: [ + { + metric: { + }, + value: [ + 1488470117.737, + '10.2734375', + ], + }, + ], + cpu_values: [ + { + metric: { + }, + values: [ + [ + 1488462918.15, + '0.0002996458625058103', + ], + [ + 1488462978.15, + '0.0002652382333333314', + ], + [ + 1488463038.15, + '0.0003485461333333421', + ], + [ + 1488463098.15, + '0.0003420421999999886', + ], + [ + 1488463158.15, + '0.00023107150000001297', + ], + [ + 1488463218.15, + '0.00030463981666664826', + ], + [ + 1488463278.15, + '0.0002477177833333677', + ], + [ + 1488463338.15, + '0.00026936656666665115', + ], + [ + 1488463398.15, + '0.000406264750000022', + ], + [ + 1488463458.15, + '0.00029592802026561453', + ], + [ + 1488463518.15, + '0.00023426999683316343', + ], + [ + 1488463578.15, + '0.0003057080666666915', + ], + [ + 1488463638.15, + '0.0003408470500000149', + ], + [ + 1488463698.15, + '0.00025497336666665166', + ], + [ + 1488463758.15, + '0.0003009282833333534', + ], + [ + 1488463818.15, + '0.0003119383499999924', + ], + [ + 1488463878.15, + '0.00028719019999998705', + ], + [ + 1488463938.15, + '0.000327864749999988', + ], + [ + 1488463998.15, + '0.0002514917333333422', + ], + [ + 1488464058.15, + '0.0003614651166666742', + ], + [ + 1488464118.15, + '0.0003221668000000122', + ], + [ + 1488464178.15, + '0.00023323083333330884', + ], + [ + 1488464238.15, + '0.00028531499475009274', + ], + [ + 1488464298.15, + '0.0002627695294921391', + ], + [ + 1488464358.15, + '0.00027145463333333453', + ], + [ + 1488464418.15, + '0.00025669488333335266', + ], + [ + 1488464478.15, + '0.00022307761666665965', + ], + [ + 1488464538.15, + '0.0003307265833333517', + ], + [ + 1488464598.15, + '0.0002817050666666709', + ], + [ + 1488464658.15, + '0.00022357458333332285', + ], + [ + 1488464718.15, + '0.00032648590000000275', + ], + [ + 1488464778.15, + '0.00028410750000000816', + ], + [ + 1488464838.15, + '0.0003038076999999954', + ], + [ + 1488464898.15, + '0.00037568226666667335', + ], + [ + 1488464958.15, + '0.00020160354999999202', + ], + [ + 1488465018.15, + '0.0003229403333333399', + ], + [ + 1488465078.15, + '0.00033516069999999236', + ], + [ + 1488465138.15, + '0.0003365978333333371', + ], + [ + 1488465198.15, + '0.00020262178333331585', + ], + [ + 1488465258.15, + '0.00040567498333331876', + ], + [ + 1488465318.15, + '0.00029114155000001436', + ], + [ + 1488465378.15, + '0.0002498841000000122', + ], + [ + 1488465438.15, + '0.00027296763333331715', + ], + [ + 1488465498.15, + '0.0002958794000000135', + ], + [ + 1488465558.15, + '0.0002922354666666867', + ], + [ + 1488465618.15, + '0.00034186624999999653', + ], + [ + 1488465678.15, + '0.0003397984166666627', + ], + [ + 1488465738.15, + '0.0002658284166666469', + ], + [ + 1488465798.15, + '0.00026221139999999346', + ], + [ + 1488465858.15, + '0.00029467960000001034', + ], + [ + 1488465918.15, + '0.0002634141333333358', + ], + [ + 1488465978.15, + '0.0003202958333333209', + ], + [ + 1488466038.15, + '0.00037890760000000394', + ], + [ + 1488466098.15, + '0.00023453356666666518', + ], + [ + 1488466158.15, + '0.0002866827333333433', + ], + [ + 1488466218.15, + '0.0003335935499999998', + ], + [ + 1488466278.15, + '0.00022787131666666125', + ], + [ + 1488466338.15, + '0.00033821938333333064', + ], + [ + 1488466398.15, + '0.00029233375000001043', + ], + [ + 1488466458.15, + '0.00026562758333333514', + ], + [ + 1488466518.15, + '0.0003142600999999819', + ], + [ + 1488466578.15, + '0.00027392178333333444', + ], + [ + 1488466638.15, + '0.00028178598333334173', + ], + [ + 1488466698.15, + '0.0002463400666666911', + ], + [ + 1488466758.15, + '0.00040234373333332125', + ], + [ + 1488466818.15, + '0.00023677453333332822', + ], + [ + 1488466878.15, + '0.00030852703333333523', + ], + [ + 1488466938.15, + '0.0003582272833333455', + ], + [ + 1488466998.15, + '0.0002176380833332973', + ], + [ + 1488467058.15, + '0.00026180203333335447', + ], + [ + 1488467118.15, + '0.00027862966666667436', + ], + [ + 1488467178.15, + '0.0002769731166666567', + ], + [ + 1488467238.15, + '0.0002832899166666477', + ], + [ + 1488467298.15, + '0.0003446533500000311', + ], + [ + 1488467358.15, + '0.0002691345999999761', + ], + [ + 1488467418.15, + '0.000284919933333357', + ], + [ + 1488467478.15, + '0.0002396026166666528', + ], + [ + 1488467538.15, + '0.00035625295000002075', + ], + [ + 1488467598.15, + '0.00036759816666664946', + ], + [ + 1488467658.15, + '0.00030326608333333855', + ], + [ + 1488467718.15, + '0.00023584972418043393', + ], + [ + 1488467778.15, + '0.00025744508892115107', + ], + [ + 1488467838.15, + '0.00036737541666663395', + ], + [ + 1488467898.15, + '0.00034325741666666094', + ], + [ + 1488467958.15, + '0.00026390046666667407', + ], + [ + 1488468018.15, + '0.0003302534500000102', + ], + [ + 1488468078.15, + '0.00035243794999999527', + ], + [ + 1488468138.15, + '0.00020149738333333407', + ], + [ + 1488468198.15, + '0.0003183469666666679', + ], + [ + 1488468258.15, + '0.0003835329166666845', + ], + [ + 1488468318.15, + '0.0002485075333333124', + ], + [ + 1488468378.15, + '0.0003011457166666768', + ], + [ + 1488468438.15, + '0.00032242785497684965', + ], + [ + 1488468498.15, + '0.0002659713747457531', + ], + [ + 1488468558.15, + '0.0003476860333333202', + ], + [ + 1488468618.15, + '0.00028336403333334794', + ], + [ + 1488468678.15, + '0.00017132354999998728', + ], + [ + 1488468738.15, + '0.0003001915833333276', + ], + [ + 1488468798.15, + '0.0003025715666666725', + ], + [ + 1488468858.15, + '0.0003012370166666815', + ], + [ + 1488468918.15, + '0.00030203619999997025', + ], + [ + 1488468978.15, + '0.0002804355000000314', + ], + [ + 1488469038.15, + '0.00033194884999998564', + ], + [ + 1488469098.15, + '0.00025201496666665455', + ], + [ + 1488469158.15, + '0.0002777531500000189', + ], + [ + 1488469218.15, + '0.0003314885833333392', + ], + [ + 1488469278.15, + '0.0002234891422095589', + ], + [ + 1488469338.15, + '0.000349117355867791', + ], + [ + 1488469398.15, + '0.0004036731333333303', + ], + [ + 1488469458.15, + '0.00024553911666667835', + ], + [ + 1488469518.15, + '0.0003056456833333184', + ], + [ + 1488469578.15, + '0.0002618737166666681', + ], + [ + 1488469638.15, + '0.00022972643333331414', + ], + [ + 1488469698.15, + '0.0003713522500000307', + ], + [ + 1488469758.15, + '0.00018322576666666515', + ], + [ + 1488469818.15, + '0.00034534762753952466', + ], + [ + 1488469878.15, + '0.00028200510008501677', + ], + [ + 1488469938.15, + '0.0002773708499999768', + ], + [ + 1488469998.15, + '0.00027547160000001013', + ], + [ + 1488470058.15, + '0.00031713610000000023', + ], + [ + 1488470118.15, + '0.00035276853333332525', + ], + ], + }, + ], + cpu_current: [ + { + metric: { + }, + value: [ + 1488470118.566, + '0.00035276853333332525', + ], + }, + ], + last_update: '2017-03-02T15:55:18.981Z', + }, + }, +}; diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 1a1280e5198..e47956a365f 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -136,6 +136,7 @@ project: - slack_slash_commands_service - irker_service - pivotaltracker_service +- prometheus_service - hipchat_service - flowdock_service - assembla_service diff --git a/spec/lib/gitlab/prometheus_spec.rb b/spec/lib/gitlab/prometheus_spec.rb new file mode 100644 index 00000000000..280264188e2 --- /dev/null +++ b/spec/lib/gitlab/prometheus_spec.rb @@ -0,0 +1,143 @@ +require 'spec_helper' + +describe Gitlab::Prometheus, lib: true do + include PrometheusHelpers + + subject { described_class.new(api_url: 'https://prometheus.example.com') } + + describe '#ping' do + it 'issues a "query" request to the API endpoint' do + req_stub = stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector')) + + expect(subject.ping).to eq({ "resultType" => "vector", "result" => [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }] }) + expect(req_stub).to have_been_requested + end + end + + # This shared examples expect: + # - query_url: A query URL + # - execute_query: A query call + shared_examples 'failure response' do + context 'when request returns 400 with an error message' do + it 'raises a Gitlab::PrometheusError error' do + req_stub = stub_prometheus_request(query_url, status: 400, body: { error: 'bar!' }) + + expect { execute_query } + .to raise_error(Gitlab::PrometheusError, 'bar!') + expect(req_stub).to have_been_requested + end + end + + context 'when request returns 400 without an error message' do + it 'raises a Gitlab::PrometheusError error' do + req_stub = stub_prometheus_request(query_url, status: 400) + + expect { execute_query } + .to raise_error(Gitlab::PrometheusError, 'Bad data received') + expect(req_stub).to have_been_requested + end + end + + context 'when request returns 500' do + it 'raises a Gitlab::PrometheusError error' do + req_stub = stub_prometheus_request(query_url, status: 500, body: { message: 'FAIL!' }) + + expect { execute_query } + .to raise_error(Gitlab::PrometheusError, '500 - {"message":"FAIL!"}') + expect(req_stub).to have_been_requested + end + end + end + + describe '#query' do + let(:prometheus_query) { prometheus_cpu_query('env-slug') } + let(:query_url) { prometheus_query_url(prometheus_query) } + + context 'when request returns vector results' do + it 'returns data from the API call' do + req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('vector')) + + expect(subject.query(prometheus_query)).to eq [{ "metric" => {}, "value" => [1488772511.004, "0.000041021495238095323"] }] + expect(req_stub).to have_been_requested + end + end + + context 'when request returns matrix results' do + it 'returns nil' do + req_stub = stub_prometheus_request(query_url, body: prometheus_value_body('matrix')) + + expect(subject.query(prometheus_query)).to be_nil + expect(req_stub).to have_been_requested + end + end + + context 'when request returns no data' do + it 'returns []' do + req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('vector')) + + expect(subject.query(prometheus_query)).to be_empty + expect(req_stub).to have_been_requested + end + end + + it_behaves_like 'failure response' do + let(:execute_query) { subject.query(prometheus_query) } + end + end + + describe '#query_range' do + let(:prometheus_query) { prometheus_memory_query('env-slug') } + let(:query_url) { prometheus_query_range_url(prometheus_query) } + + around do |example| + Timecop.freeze { example.run } + end + + context 'when a start time is passed' do + let(:query_url) { prometheus_query_range_url(prometheus_query, start: 2.hours.ago) } + + it 'passed it in the requested URL' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector')) + + subject.query_range(prometheus_query, start: 2.hours.ago) + expect(req_stub).to have_been_requested + end + end + + context 'when request returns vector results' do + it 'returns nil' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('vector')) + + expect(subject.query_range(prometheus_query)).to be_nil + expect(req_stub).to have_been_requested + end + end + + context 'when request returns matrix results' do + it 'returns data from the API call' do + req_stub = stub_prometheus_request(query_url, body: prometheus_values_body('matrix')) + + expect(subject.query_range(prometheus_query)).to eq([ + { + "metric" => {}, + "values" => [[1488758662.506, "0.00002996364761904785"], [1488758722.506, "0.00003090239047619091"]] + } + ]) + expect(req_stub).to have_been_requested + end + end + + context 'when request returns no data' do + it 'returns []' do + req_stub = stub_prometheus_request(query_url, body: prometheus_empty_body('matrix')) + + expect(subject.query_range(prometheus_query)).to be_empty + expect(req_stub).to have_been_requested + end + end + + it_behaves_like 'failure response' do + let(:execute_query) { subject.query_range(prometheus_query) } + end + end +end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb index dce18f008f8..b4305e92812 100644 --- a/spec/models/environment_spec.rb +++ b/spec/models/environment_spec.rb @@ -271,7 +271,11 @@ describe Environment, models: true do context 'when the environment is unavailable' do let(:project) { create(:kubernetes_project) } - before { environment.stop } + + before do + environment.stop + end + it { is_expected.to be_falsy } end end @@ -281,20 +285,85 @@ describe Environment, models: true do subject { environment.terminals } context 'when the environment has terminals' do - before { allow(environment).to receive(:has_terminals?).and_return(true) } + before do + allow(environment).to receive(:has_terminals?).and_return(true) + end it 'returns the terminals from the deployment service' do - expect(project.deployment_service). - to receive(:terminals).with(environment). - and_return(:fake_terminals) + expect(project.deployment_service) + .to receive(:terminals).with(environment) + .and_return(:fake_terminals) is_expected.to eq(:fake_terminals) end end context 'when the environment does not have terminals' do - before { allow(environment).to receive(:has_terminals?).and_return(false) } - it { is_expected.to eq(nil) } + before do + allow(environment).to receive(:has_terminals?).and_return(false) + end + + it { is_expected.to be_nil } + end + end + + describe '#has_metrics?' do + subject { environment.has_metrics? } + + context 'when the enviroment is available' do + context 'with a deployment service' do + let(:project) { create(:prometheus_project) } + + context 'and a deployment' do + let!(:deployment) { create(:deployment, environment: environment) } + it { is_expected.to be_truthy } + end + + context 'but no deployments' do + it { is_expected.to be_falsy } + end + end + + context 'without a monitoring service' do + it { is_expected.to be_falsy } + end + end + + context 'when the environment is unavailable' do + let(:project) { create(:prometheus_project) } + + before do + environment.stop + end + + it { is_expected.to be_falsy } + end + end + + describe '#metrics' do + let(:project) { create(:prometheus_project) } + subject { environment.metrics } + + context 'when the environment has metrics' do + before do + allow(environment).to receive(:has_metrics?).and_return(true) + end + + it 'returns the metrics from the deployment service' do + expect(project.monitoring_service) + .to receive(:metrics).with(environment) + .and_return(:fake_metrics) + + is_expected.to eq(:fake_metrics) + end + end + + context 'when the environment does not have metrics' do + before do + allow(environment).to receive(:has_metrics?).and_return(false) + end + + it { is_expected.to be_nil } end end diff --git a/spec/models/project_services/prometheus_service_spec.rb b/spec/models/project_services/prometheus_service_spec.rb new file mode 100644 index 00000000000..d15079b686b --- /dev/null +++ b/spec/models/project_services/prometheus_service_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe PrometheusService, models: true, caching: true do + include PrometheusHelpers + include ReactiveCachingHelpers + + let(:project) { create(:prometheus_project) } + let(:service) { project.prometheus_service } + + describe "Associations" do + it { is_expected.to belong_to :project } + end + + describe 'Validations' do + context 'when service is active' do + before { subject.active = true } + + it { is_expected.to validate_presence_of(:api_url) } + end + + context 'when service is inactive' do + before { subject.active = false } + + it { is_expected.not_to validate_presence_of(:api_url) } + end + end + + describe '#test' do + let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), body: prometheus_value_body('vector')) } + + context 'success' do + it 'reads the discovery endpoint' do + expect(service.test[:success]).to be_truthy + expect(req_stub).to have_been_requested + end + end + + context 'failure' do + let!(:req_stub) { stub_prometheus_request(prometheus_query_url('1'), status: 404) } + + it 'fails to read the discovery endpoint' do + expect(service.test[:success]).to be_falsy + expect(req_stub).to have_been_requested + end + end + end + + describe '#metrics' do + let(:environment) { build_stubbed(:environment, slug: 'env-slug') } + subject { service.metrics(environment) } + + around do |example| + Timecop.freeze { example.run } + end + + context 'with valid data' do + before do + stub_reactive_cache(service, prometheus_data, 'env-slug') + end + + it 'returns reactive data' do + is_expected.to eq(prometheus_data) + end + end + end + + describe '#calculate_reactive_cache' do + let(:environment) { build_stubbed(:environment, slug: 'env-slug') } + + around do |example| + Timecop.freeze { example.run } + end + + subject do + service.calculate_reactive_cache(environment.slug) + end + + context 'when service is inactive' do + before do + service.active = false + end + + it { is_expected.to be_nil } + end + + context 'when Prometheus responds with valid data' do + before do + stub_all_prometheus_requests(environment.slug) + end + + it { expect(subject.to_json).to eq(prometheus_data.to_json) } + end + + [404, 500].each do |status| + context "when Prometheus responds with #{status}" do + before do + stub_all_prometheus_requests(environment.slug, status: status, body: 'QUERY FAILED!') + end + + it { is_expected.to eq(success: false, result: %(#{status} - "QUERY FAILED!")) } + end + end + end +end diff --git a/spec/requests/api/v3/services_spec.rb b/spec/requests/api/v3/services_spec.rb index 7e8c8753d02..3a760a8f25c 100644 --- a/spec/requests/api/v3/services_spec.rb +++ b/spec/requests/api/v3/services_spec.rb @@ -6,7 +6,9 @@ describe API::V3::Services, api: true do let(:user) { create(:user) } let(:project) { create(:empty_project, creator_id: user.id, namespace: user.namespace) } - Service.available_services_names.each do |service| + available_services = Service.available_services_names + available_services.delete('prometheus') + available_services.each do |service| describe "DELETE /projects/:id/services/#{service.dasherize}" do include_context service diff --git a/spec/support/prometheus_helpers.rb b/spec/support/prometheus_helpers.rb new file mode 100644 index 00000000000..a52d8f37d14 --- /dev/null +++ b/spec/support/prometheus_helpers.rb @@ -0,0 +1,117 @@ +module PrometheusHelpers + def prometheus_memory_query(environment_slug) + %{sum(container_memory_usage_bytes{container_name="app",environment="#{environment_slug}"})/1024/1024} + end + + def prometheus_cpu_query(environment_slug) + %{sum(rate(container_cpu_usage_seconds_total{container_name="app",environment="#{environment_slug}"}[2m]))} + end + + def prometheus_query_url(prometheus_query) + query = { query: prometheus_query }.to_query + + "https://prometheus.example.com/api/v1/query?#{query}" + end + + def prometheus_query_range_url(prometheus_query, start: 8.hours.ago) + query = { + query: prometheus_query, + start: start.to_f, + end: Time.now.utc.to_f, + step: 1.minute.to_i + }.to_query + + "https://prometheus.example.com/api/v1/query_range?#{query}" + end + + def stub_prometheus_request(url, body: {}, status: 200) + WebMock.stub_request(:get, url) + .to_return({ + status: status, + headers: { 'Content-Type' => 'application/json' }, + body: body.to_json + }) + end + + def stub_all_prometheus_requests(environment_slug, body: nil, status: 200) + stub_prometheus_request( + prometheus_query_url(prometheus_memory_query(environment_slug)), + status: status, + body: body || prometheus_value_body + ) + stub_prometheus_request( + prometheus_query_range_url(prometheus_memory_query(environment_slug)), + status: status, + body: body || prometheus_values_body + ) + stub_prometheus_request( + prometheus_query_url(prometheus_cpu_query(environment_slug)), + status: status, + body: body || prometheus_value_body + ) + stub_prometheus_request( + prometheus_query_range_url(prometheus_cpu_query(environment_slug)), + status: status, + body: body || prometheus_values_body + ) + end + + def prometheus_data(last_update: Time.now.utc) + { + success: true, + metrics: { + memory_values: prometheus_values_body('matrix').dig(:data, :result), + memory_current: prometheus_value_body('vector').dig(:data, :result), + cpu_values: prometheus_values_body('matrix').dig(:data, :result), + cpu_current: prometheus_value_body('vector').dig(:data, :result) + }, + last_update: last_update + } + end + + def prometheus_empty_body(type) + { + "status": "success", + "data": { + "resultType": type, + "result": [] + } + } + end + + def prometheus_value_body(type = 'vector') + { + "status": "success", + "data": { + "resultType": type, + "result": [ + { + "metric": {}, + "value": [ + 1488772511.004, + "0.000041021495238095323" + ] + } + ] + } + } + end + + def prometheus_values_body(type = 'matrix') + { + "status": "success", + "data": { + "resultType": type, + "result": [ + { + "metric": {}, + "values": [ + [1488758662.506, "0.00002996364761904785"], + [1488758722.506, "0.00003090239047619091"] + ] + } + ] + } + } + end +end |