diff options
Diffstat (limited to 'lib/gitlab/metrics/dashboard/validator')
11 files changed, 294 insertions, 0 deletions
diff --git a/lib/gitlab/metrics/dashboard/validator/client.rb b/lib/gitlab/metrics/dashboard/validator/client.rb new file mode 100644 index 00000000000..c63415abcfc --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/client.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Gitlab + module Metrics + module Dashboard + module Validator + class Client + # @param content [Hash] Representing a raw, unprocessed + # dashboard object + # @param schema_path [String] Representing path to dashboard schema file + # @param dashboard_path[String] Representing path to dashboard content file + # @param project [Project] Project to validate dashboard against + def initialize(content, schema_path, dashboard_path: nil, project: nil) + @content = content + @schema_path = schema_path + @dashboard_path = dashboard_path + @project = project + end + + def execute + errors = validate_against_schema + errors += post_schema_validator.validate + + errors.compact + end + + private + + attr_reader :content, :schema_path, :project, :dashboard_path + + def custom_formats + @custom_formats ||= CustomFormats.new + end + + def post_schema_validator + PostSchemaValidator.new( + project: project, + metric_ids: custom_formats.metric_ids_cache, + dashboard_path: dashboard_path + ) + end + + def schemer + @schemer ||= ::JSONSchemer.schema(Pathname.new(schema_path), formats: custom_formats.format_handlers) + end + + def validate_against_schema + schemer.validate(content).map do |error| + Errors::SchemaValidationError.new(error) + end + end + end + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/validator/custom_formats.rb b/lib/gitlab/metrics/dashboard/validator/custom_formats.rb new file mode 100644 index 00000000000..485e80ad1b7 --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/custom_formats.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module Metrics + module Dashboard + module Validator + class CustomFormats + def format_handlers + # Key is custom JSON Schema format name. Value is a proc that takes data and schema and handles + # validations. + @format_handlers ||= { + "add_to_metric_id_cache" => ->(data, schema) { metric_ids_cache << data } + } + end + + def metric_ids_cache + @metric_ids_cache ||= [] + end + end + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/validator/errors.rb b/lib/gitlab/metrics/dashboard/validator/errors.rb new file mode 100644 index 00000000000..0f6e687d291 --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/errors.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Gitlab + module Metrics + module Dashboard + module Validator + module Errors + InvalidDashboardError = Class.new(StandardError) + + class SchemaValidationError < InvalidDashboardError + def initialize(error = {}) + super(error_message(error)) + end + + private + + def error_message(error) + if error.is_a?(Hash) && error.present? + pretty(error) + else + "Dashboard failed schema validation" + end + end + + # based on https://github.com/davishmcclurg/json_schemer/blob/master/lib/json_schemer/errors.rb + # with addition ability to translate error messages + def pretty(error) + data, data_pointer, type, schema = error.values_at('data', 'data_pointer', 'type', 'schema') + location = data_pointer.empty? ? 'root' : data_pointer + + case type + when 'required' + keys = error.fetch('details').fetch('missing_keys').join(', ') + _("%{location} is missing required keys: %{keys}") % { location: location, keys: keys } + when 'null', 'string', 'boolean', 'integer', 'number', 'array', 'object' + _("'%{data}' at %{location} is not of type: %{type}") % { data: data, location: location, type: type } + when 'pattern' + _("'%{data}' at %{location} does not match pattern: %{pattern}") % { data: data, location: location, pattern: schema.fetch('pattern') } + when 'format' + _("'%{data}' at %{location} does not match format: %{format}") % { data: data, location: location, format: schema.fetch('format') } + when 'const' + _("'%{data}' at %{location} is not: %{const}") % { data: data, location: location, const: schema.fetch('const').inspect } + when 'enum' + _("'%{data}' at %{location} is not one of: %{enum}") % { data: data, location: location, enum: schema.fetch('enum') } + else + _("'%{data}' at %{location} is invalid: error_type=%{type}") % { data: data, location: location, type: type } + end + end + end + + class DuplicateMetricIds < InvalidDashboardError + def initialize + super(_("metric_id must be unique across a project")) + end + end + end + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/validator/post_schema_validator.rb b/lib/gitlab/metrics/dashboard/validator/post_schema_validator.rb new file mode 100644 index 00000000000..73bfc5a6294 --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/post_schema_validator.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Gitlab + module Metrics + module Dashboard + module Validator + class PostSchemaValidator + def initialize(metric_ids:, project: nil, dashboard_path: nil) + @metric_ids = metric_ids + @project = project + @dashboard_path = dashboard_path + end + + def validate + errors = [] + errors << uniq_metric_ids + errors.compact + end + + private + + attr_reader :project, :metric_ids, :dashboard_path + + def uniq_metric_ids + return Validator::Errors::DuplicateMetricIds.new if metric_ids.uniq! + + uniq_metric_ids_across_project if project.present? || dashboard_path.present? + end + + # rubocop: disable CodeReuse/ActiveRecord + def uniq_metric_ids_across_project + return ArgumentError.new(_('Both project and dashboard_path are required')) unless + dashboard_path.present? && project.present? + + # If PrometheusMetric identifier is not unique across project and dashboard_path, + # we need to error because we don't know if the user is trying to create a new metric + # or update an existing one. + identifier_on_other_dashboard = PrometheusMetric.where( + project: project, + identifier: metric_ids + ).where.not( + dashboard_path: dashboard_path + ).exists? + + Validator::Errors::DuplicateMetricIds.new if identifier_on_other_dashboard + end + # rubocop: enable CodeReuse/ActiveRecord + end + end + end + end +end diff --git a/lib/gitlab/metrics/dashboard/validator/schemas/axis.json b/lib/gitlab/metrics/dashboard/validator/schemas/axis.json new file mode 100644 index 00000000000..54334022426 --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/schemas/axis.json @@ -0,0 +1,14 @@ +{ + "type": "object", + "properties": { + "name": { "type": "string" }, + "format": { + "type": "string", + "default": "engineering" + }, + "precision": { + "type": "number", + "default": 2 + } + } +} diff --git a/lib/gitlab/metrics/dashboard/validator/schemas/dashboard.json b/lib/gitlab/metrics/dashboard/validator/schemas/dashboard.json new file mode 100644 index 00000000000..313f03be7dc --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/schemas/dashboard.json @@ -0,0 +1,18 @@ +{ + "type": "object", + "required": ["dashboard", "panel_groups"], + "properties": { + "dashboard": { "type": "string" }, + "panel_groups": { + "type": "array", + "items": { "$ref": "./panel_group.json" } + }, + "templating": { + "$ref": "./templating.json" + }, + "links": { + "type": "array", + "items": { "$ref": "./link.json" } + } + } +} diff --git a/lib/gitlab/metrics/dashboard/validator/schemas/link.json b/lib/gitlab/metrics/dashboard/validator/schemas/link.json new file mode 100644 index 00000000000..4ea7b5dd324 --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/schemas/link.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required": ["url"], + "properties": { + "url": { "type": "string" }, + "title": { "type": "string" }, + "type": { + "type": "string", + "enum": ["grafana"] + } + } +} diff --git a/lib/gitlab/metrics/dashboard/validator/schemas/metric.json b/lib/gitlab/metrics/dashboard/validator/schemas/metric.json new file mode 100644 index 00000000000..13831b77e3e --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/schemas/metric.json @@ -0,0 +1,16 @@ +{ + "type": "object", + "required": ["unit"], + "oneOf": [{ "required": ["query"] }, { "required": ["query_range"] }], + "properties": { + "id": { + "type": "string", + "format": "add_to_metric_id_cache" + }, + "unit": { "type": "string" }, + "label": { "type": "string" }, + "query": { "type": ["string", "number"] }, + "query_range": { "type": ["string", "number"] }, + "step": { "type": "number" } + } +} diff --git a/lib/gitlab/metrics/dashboard/validator/schemas/panel.json b/lib/gitlab/metrics/dashboard/validator/schemas/panel.json new file mode 100644 index 00000000000..011eef53e40 --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/schemas/panel.json @@ -0,0 +1,24 @@ +{ + "type": "object", + "required": ["title", "metrics"], + "properties": { + "type": { + "type": "string", + "enum": ["area-chart", "anomaly-chart", "bar", "column", "stacked-column", "single-stat", "heatmap"], + "default": "area-chart" + }, + "title": { "type": "string" }, + "y_label": { "type": "string" }, + "y_axis": { "$ref": "./axis.json" }, + "max_value": { "type": "number" }, + "weight": { "type": "number" }, + "metrics": { + "type": "array", + "items": { "$ref": "./metric.json" } + }, + "links": { + "type": "array", + "items": { "$ref": "./link.json" } + } + } +} diff --git a/lib/gitlab/metrics/dashboard/validator/schemas/panel_group.json b/lib/gitlab/metrics/dashboard/validator/schemas/panel_group.json new file mode 100644 index 00000000000..1306fc475db --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/schemas/panel_group.json @@ -0,0 +1,12 @@ +{ + "type": "object", + "required": ["group", "panels"], + "properties": { + "group": { "type": "string" }, + "priority": { "type": "number" }, + "panels": { + "type": "array", + "items": { "$ref": "./panel.json" } + } + } +} diff --git a/lib/gitlab/metrics/dashboard/validator/schemas/templating.json b/lib/gitlab/metrics/dashboard/validator/schemas/templating.json new file mode 100644 index 00000000000..6f8664c89af --- /dev/null +++ b/lib/gitlab/metrics/dashboard/validator/schemas/templating.json @@ -0,0 +1,7 @@ +{ + "type": "object", + "required": ["variables"], + "properties": { + "variables": { "type": "object" } + } +} |