Commit 38521d11 authored by Robert Speicher's avatar Robert Speicher

Merge branch 'sh-api-shard-config' into 'master'

Check if shard configuration is same across Geo nodes

Closes #3243

See merge request gitlab-org/gitlab-ee!3772
parents ed6b048d f78b0583
...@@ -14,7 +14,8 @@ const unhealthyIcon = 'fa-times'; ...@@ -14,7 +14,8 @@ const unhealthyIcon = 'fa-times';
const unknownIcon = 'fa-times'; const unknownIcon = 'fa-times';
const notAvailable = 'Not Available'; const notAvailable = 'Not Available';
const versionMismatch = 'Does not match the primary node version'; const versionMismatch = 'Does not match the primary node version';
const versionMismatchClass = 'geo-node-version-mismatch'; const nodeMismatchClass = 'geo-node-mismatch';
const storageMismatch = 'Does not match the primary storage configuration';
class GeoNodeStatus { class GeoNodeStatus {
constructor(el) { constructor(el) {
...@@ -34,6 +35,7 @@ class GeoNodeStatus { ...@@ -34,6 +35,7 @@ class GeoNodeStatus {
this.$health = $('.js-health-message', this.$status.parent()); this.$health = $('.js-health-message', this.$status.parent());
this.$version = $('.js-gitlab-version', this.$status); this.$version = $('.js-gitlab-version', this.$status);
this.$secondaryVersion = $('.js-secondary-version', this.$status); this.$secondaryVersion = $('.js-secondary-version', this.$status);
this.$secondaryStorage = $('.js-secondary-storage-shards', this.$status);
this.endpoint = this.$el.data('status-url'); this.endpoint = this.$el.data('status-url');
this.$advancedStatus = $('.js-advanced-geo-node-status-toggler', this.$status.parent()); this.$advancedStatus = $('.js-advanced-geo-node-status-toggler', this.$status.parent());
this.$advancedStatus.on('click', GeoNodeStatus.toggleShowAdvancedStatus.bind(this)); this.$advancedStatus.on('click', GeoNodeStatus.toggleShowAdvancedStatus.bind(this));
...@@ -204,13 +206,23 @@ class GeoNodeStatus { ...@@ -204,13 +206,23 @@ class GeoNodeStatus {
if (!this.primaryVersion || (this.primaryVersion === status.version if (!this.primaryVersion || (this.primaryVersion === status.version
&& this.primaryRevision === status.revision)) { && this.primaryRevision === status.revision)) {
this.$secondaryVersion.removeClass(`${versionMismatchClass}`); this.$secondaryVersion.removeClass(`${nodeMismatchClass}`);
this.$secondaryVersion.text(`${status.version} (${status.revision})`); this.$secondaryVersion.text(`${status.version} (${status.revision})`);
} else { } else {
this.$secondaryVersion.addClass(`${versionMismatchClass}`); this.$secondaryVersion.addClass(`${nodeMismatchClass}`);
this.$secondaryVersion.text(`${status.version} (${status.revision}) - ${versionMismatch}`); this.$secondaryVersion.text(`${status.version} (${status.revision}) - ${versionMismatch}`);
} }
if (status.storage_shards_match == null) {
this.$secondaryStorage.text('UNKNOWN');
} else if (status.storage_shards_match) {
this.$secondaryStorage.removeClass(`${nodeMismatchClass}`);
this.$secondaryStorage.text('OK');
} else {
this.$secondaryStorage.addClass(`${nodeMismatchClass}`);
this.$secondaryStorage.text(storageMismatch);
}
if (status.repositories_count > 0) { if (status.repositories_count > 0) {
const repositoriesStats = GeoNodeStatus.getSyncStatistics({ const repositoriesStats = GeoNodeStatus.getSyncStatistics({
syncedCount: status.repositories_synced_count, syncedCount: status.repositories_synced_count,
......
...@@ -127,7 +127,7 @@ ...@@ -127,7 +127,7 @@
} }
} }
.geo-node-version-mismatch { .geo-node-mismatch {
color: $gl-danger; color: $gl-danger;
} }
......
# This is an in-memory structure only. The repository storage configuration is
# in gitlab.yml and not in the database. This model makes it easier to work
# with the configuration.
class StorageShard
include ActiveModel::Model
attr_accessor :name, :path
validates :name, presence: true
validates :path, presence: true
# Generates an array of StorageShard objects from the currrent storage
# configuration using the gitlab.yml array of key/value pairs:
#
# {"default"=>{"path"=>"/home/git/repositories", ...}
#
# The key is the shard name, and the values are the parameters for that shard.
def self.all
Settings.repositories.storages.map do |name, params|
config = params.symbolize_keys.merge(name: name)
config.slice!(*allowed_params)
StorageShard.new(config)
end
end
def self.allowed_params
%i(name path).freeze
end
end
---
title: Check if shard configuration is same across Geo nodes
merge_request:
author:
type: added
...@@ -4,6 +4,7 @@ class GeoNodeStatus < ActiveRecord::Base ...@@ -4,6 +4,7 @@ class GeoNodeStatus < ActiveRecord::Base
# Whether we were successful in reaching this node # Whether we were successful in reaching this node
attr_accessor :success, :version, :revision attr_accessor :success, :version, :revision
attr_writer :health_status attr_writer :health_status
attr_accessor :storage_shards
# Be sure to keep this consistent with Prometheus naming conventions # Be sure to keep this consistent with Prometheus naming conventions
PROMETHEUS_METRICS = { PROMETHEUS_METRICS = {
...@@ -49,12 +50,12 @@ class GeoNodeStatus < ActiveRecord::Base ...@@ -49,12 +50,12 @@ class GeoNodeStatus < ActiveRecord::Base
def self.from_json(json_data) def self.from_json(json_data)
json_data.slice!(*allowed_params) json_data.slice!(*allowed_params)
GeoNodeStatus.new(json_data) GeoNodeStatus.new(HashWithIndifferentAccess.new(json_data))
end end
def self.allowed_params def self.allowed_params
excluded_params = %w(id created_at updated_at).freeze excluded_params = %w(id created_at updated_at).freeze
extra_params = %w(success health health_status last_event_timestamp cursor_last_event_timestamp version revision).freeze extra_params = %w(success health health_status last_event_timestamp cursor_last_event_timestamp version revision storage_shards).freeze
self.column_names - excluded_params + extra_params self.column_names - excluded_params + extra_params
end end
...@@ -74,6 +75,7 @@ class GeoNodeStatus < ActiveRecord::Base ...@@ -74,6 +75,7 @@ class GeoNodeStatus < ActiveRecord::Base
self.lfs_objects_count = lfs_objects_finder.count_lfs_objects self.lfs_objects_count = lfs_objects_finder.count_lfs_objects
self.attachments_count = attachments_finder.count_attachments self.attachments_count = attachments_finder.count_attachments
self.last_successful_status_check_at = Time.now self.last_successful_status_check_at = Time.now
self.storage_shards = StorageShard.all
if Gitlab::Geo.primary? if Gitlab::Geo.primary?
self.replication_slots_count = geo_node.replication_slots_count self.replication_slots_count = geo_node.replication_slots_count
...@@ -152,12 +154,40 @@ class GeoNodeStatus < ActiveRecord::Base ...@@ -152,12 +154,40 @@ class GeoNodeStatus < ActiveRecord::Base
calc_percentage(replication_slots_count, replication_slots_used_count) calc_percentage(replication_slots_count, replication_slots_used_count)
end end
# This method only is useful when the storage shard information is loaded
# from a remote node via JSON.
def storage_shards_match?
return unless Gitlab::Geo.primary?
shards_match?(current_shards, primary_shards)
end
def [](key) def [](key)
public_send(key) # rubocop:disable GitlabSecurity/PublicSend public_send(key) # rubocop:disable GitlabSecurity/PublicSend
end end
private private
def current_shards
serialize_storage_shards(storage_shards)
end
def primary_shards
serialize_storage_shards(StorageShard.all)
end
def serialize_storage_shards(shards)
StorageShardSerializer.new.represent(shards).as_json
end
def shards_match?(first, second)
sort_by_name(first) == sort_by_name(second)
end
def sort_by_name(shards)
shards.sort_by { |shard| shard['name'] }
end
def attachments_finder def attachments_finder
@attachments_finder ||= Geo::AttachmentRegistryFinder.new(current_node: geo_node) @attachments_finder ||= Geo::AttachmentRegistryFinder.new(current_node: geo_node)
end end
......
...@@ -59,6 +59,21 @@ class GeoNodeStatusEntity < Grape::Entity ...@@ -59,6 +59,21 @@ class GeoNodeStatusEntity < Grape::Entity
expose :namespaces, using: NamespaceEntity expose :namespaces, using: NamespaceEntity
# We load GeoNodeStatus data in two ways:
#
# 1. Directly by asking a Geo node via an API call
# 2. Via cached state in the database
#
# We don't yet cached the state of the shard information in the database, so if
# we don't have this information omit from the serialization entirely.
expose :storage_shards, using: StorageShardEntity, if: ->(status, options) do
status.storage_shards.present?
end
expose :storage_shards_match?, as: :storage_shards_match, if: -> (status, options) do
Gitlab::Geo.primary? && status.storage_shards.present?
end
private private
def namespaces def namespaces
......
class StorageShardEntity < Grape::Entity
expose :name, :path
end
class StorageShardSerializer < BaseSerializer
entity StorageShardEntity
end
...@@ -15,6 +15,7 @@ ...@@ -15,6 +15,7 @@
%strong exact order %strong exact order
they appear. they appear.
- current_primary = Gitlab::Geo.primary?
- if @nodes.any? - if @nodes.any?
.panel.panel-default .panel.panel-default
.panel-heading .panel-heading
...@@ -46,6 +47,13 @@ ...@@ -46,6 +47,13 @@
GitLab version: GitLab version:
%td %td
.node-info.prepend-top-5.prepend-left-5.js-secondary-version .node-info.prepend-top-5.prepend-left-5.js-secondary-version
- if current_primary
%tr
%td
.help-block
Storage config:
%td
.node-info.prepend-top-5.prepend-left-5.js-secondary-storage-shards
- if node.enabled? - if node.enabled?
%tr %tr
%td %td
......
...@@ -396,18 +396,49 @@ describe GeoNodeStatus, :geo do ...@@ -396,18 +396,49 @@ describe GeoNodeStatus, :geo do
it_behaves_like 'timestamp parameters', :cursor_last_event_timestamp, :cursor_last_event_date it_behaves_like 'timestamp parameters', :cursor_last_event_timestamp, :cursor_last_event_date
end end
describe '#storage_shards' do
it "returns the current node's shard config" do
expect(subject[:storage_shards].as_json).to eq(StorageShard.all.as_json)
end
end
describe '#from_json' do describe '#from_json' do
it 'returns a new GeoNodeStatus excluding parameters' do it 'returns a new GeoNodeStatus excluding parameters' do
status = create(:geo_node_status) status = create(:geo_node_status)
data = status.as_json data = GeoNodeStatusSerializer.new.represent(status).as_json
data[:id] = 10000 data['id'] = 10000
result = GeoNodeStatus.from_json(data) result = described_class.from_json(data)
expect(result.id).to be_nil expect(result.id).to be_nil
expect(result.attachments_count).to eq(status.attachments_count) expect(result.attachments_count).to eq(status.attachments_count)
expect(result.cursor_last_event_date).to eq(status.cursor_last_event_date) expect(result.cursor_last_event_date).to eq(Time.at(status.cursor_last_event_timestamp))
expect(result.storage_shards.count).to eq(Settings.repositories.storages.count)
end
end
describe '#storage_shards_match?' do
before { stub_primary_node }
it 'returns false if the storage shards do not match' do
status = create(:geo_node_status)
data = GeoNodeStatusSerializer.new.represent(status).as_json
data['storage_shards'].first['name'] = 'broken-shard'
result = described_class.from_json(data)
expect(result.storage_shards_match?).to be false
end
it 'returns true if the storage shards match in different order' do
status = create(:geo_node_status)
status.storage_shards.shuffle!
data = GeoNodeStatusSerializer.new.represent(status).as_json
result = described_class.from_json(data)
expect(result.storage_shards_match?).to be true
end end
end end
end end
require 'spec_helper' require 'spec_helper'
describe GeoNodeStatusEntity, :postgresql do describe GeoNodeStatusEntity, :postgresql do
include ::EE::GeoHelpers
let(:geo_node_status) { build(:geo_node_status) } let(:geo_node_status) { build(:geo_node_status) }
let(:entity) { described_class.new(geo_node_status, request: double) } let(:entity) { described_class.new(geo_node_status, request: double) }
let(:error) { 'Could not connect to Geo database' } let(:error) { 'Could not connect to Geo database' }
subject { entity.as_json } subject { entity.as_json }
before { stub_primary_node }
it { is_expected.to have_key(:geo_node_id) } it { is_expected.to have_key(:geo_node_id) }
it { is_expected.to have_key(:healthy) } it { is_expected.to have_key(:healthy) }
it { is_expected.to have_key(:health) } it { is_expected.to have_key(:health) }
...@@ -32,6 +36,8 @@ describe GeoNodeStatusEntity, :postgresql do ...@@ -32,6 +36,8 @@ describe GeoNodeStatusEntity, :postgresql do
it { is_expected.to have_key(:replication_slots_max_retained_wal_bytes) } it { is_expected.to have_key(:replication_slots_max_retained_wal_bytes) }
it { is_expected.to have_key(:last_successful_status_check_timestamp) } it { is_expected.to have_key(:last_successful_status_check_timestamp) }
it { is_expected.to have_key(:namespaces) } it { is_expected.to have_key(:namespaces) }
it { is_expected.to have_key(:storage_shards) }
it { is_expected.to have_key(:storage_shards_match) }
describe '#healthy' do describe '#healthy' do
context 'when node is healthy' do context 'when node is healthy' do
...@@ -127,4 +133,21 @@ describe GeoNodeStatusEntity, :postgresql do ...@@ -127,4 +133,21 @@ describe GeoNodeStatusEntity, :postgresql do
expect(subject[:namespaces].first[:path]).to eq(namespace.path) expect(subject[:namespaces].first[:path]).to eq(namespace.path)
end end
end end
describe '#storage_shards' do
it 'returns the config' do
shards = StorageShard.all
expect(subject[:storage_shards].count).to eq(shards.count)
expect(subject[:storage_shards].first[:name]).to eq(shards.first.name)
expect(subject[:storage_shards].first[:path]).to eq(shards.first.path)
end
end
context 'secondary Geo node' do
before { stub_secondary_node }
it { is_expected.to have_key(:storage_shards) }
it { is_expected.not_to have_key(:storage_shards_match) }
end
end end
...@@ -4,5 +4,14 @@ module EE ...@@ -4,5 +4,14 @@ module EE
allow(::Gitlab::Geo).to receive(:current_node).and_return(node) allow(::Gitlab::Geo).to receive(:current_node).and_return(node)
allow(node).to receive(:current?).and_return(true) unless node.nil? allow(node).to receive(:current?).and_return(true) unless node.nil?
end end
def stub_primary_node
allow(::Gitlab::Geo).to receive(:primary?).and_return(true)
end
def stub_secondary_node
allow(::Gitlab::Geo).to receive(:primary?).and_return(false)
allow(::Gitlab::Geo).to receive(:secondary?).and_return(true)
end
end end
end end
...@@ -2,6 +2,7 @@ FactoryBot.define do ...@@ -2,6 +2,7 @@ FactoryBot.define do
factory :geo_node_status do factory :geo_node_status do
sequence(:id) sequence(:id)
geo_node geo_node
storage_shards { StorageShard.all }
trait :healthy do trait :healthy do
health nil health nil
......
...@@ -64,6 +64,8 @@ ...@@ -64,6 +64,8 @@
"cursor_last_event_timestamp": { "type": ["integer", "null"] }, "cursor_last_event_timestamp": { "type": ["integer", "null"] },
"last_successful_status_check_timestamp": { "type": ["integer", "null"] }, "last_successful_status_check_timestamp": { "type": ["integer", "null"] },
"namespaces": { "type": "array" }, "namespaces": { "type": "array" },
"storage_shards": { "type": "array" },
"storage_shards_match": { "type": "boolean" },
"version": { "type": ["string"] }, "version": { "type": ["string"] },
"revision": { "type": ["string"] } "revision": { "type": ["string"] }
}, },
......
require 'spec_helper'
describe StorageShard do
describe '.current_shards' do
it 'returns an array of StorageShard objects' do
shards = described_class.all
expect(shards.count).to eq(Settings.repositories.storages.count)
expect(shards.map(&:name)).to match_array(Settings.repositories.storages.keys)
expect(shards.map(&:path)).to match_array(Settings.repositories.storages.values.map(&:path))
end
end
end
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