diff --git a/config/initializers/config.rb b/config/initializers/config.rb index 6103a8eb4..7980b0f08 100644 --- a/config/initializers/config.rb +++ b/config/initializers/config.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require 'config' +require 'yeti_config_loader' Config.class_eval do def self.setting_files(config_root, _env) @@ -10,154 +11,9 @@ def self.setting_files(config_root, _env) end end -Config.setup do |setup_config| - setup_config.const_name = 'YetiConfig' - setup_config.use_env = false - - # Validate presence and type of specific config values. - # Check https://github.com/dry-rb/dry-validation for details. - setup_config.schema do - # config.validate_keys = true - - required(:site_title).filled(:string) - required(:site_title_image).filled(:string) - - required(:calls_monitoring).schema do - required(:write_account_stats).value(:bool?) - required(:write_gateway_stats).value(:bool?) - optional(:teardown_on_disabled_customer_auth).value(:bool?) - optional(:teardown_on_disabled_term_gw).value(:bool?) - optional(:teardown_on_disabled_orig_gw).value(:bool?) - end - - required(:api).schema do - required(:token_lifetime).maybe(:int?) - optional(:customer).schema do - required(:token_lifetime).maybe(:int?) - optional(:call_jwt_lifetime).maybe(:int?) - optional(:call_jwt_secret).maybe(:string) - optional(:outgoing_cdr_hide_fields).array(:string) - optional(:outgoing_statistics_use_customer_duration).value(:bool?) - optional(:incoming_cdr_hide_fields).array(:string) - optional(:incoming_statistics_use_vendor_duration).value(:bool?) - end - optional(:system).schema do - optional(:token).maybe(:string) - end - end - - optional(:rec_format).value(Dry::Types['string'].enum('wav', 'mp3')) - - optional(:routing_simulation_default_interface).filled(:string) - - required(:cdr_export).schema do - required(:dir_path).filled(:string) - required(:delete_url).filled(:string) - end - - required(:role_policy).schema do - required(:when_no_config).value(Dry::Types['string'].enum('allow', 'disallow', 'raise')) - required(:when_no_policy_class).value(Dry::Types['string'].enum('allow', 'disallow', 'raise')) - end - - required(:partition_remove_delay).hash do - required(:'cdr.cdr').maybe(:string, format?: /\A\d+ days\z/) - required(:'auth_log.auth_log').maybe(:string, format?: /\A\d+ days\z/) - required(:'rtp_statistics.rx_streams').maybe(:string, format?: /\A\d+ days\z/) - required(:'rtp_statistics.tx_streams').maybe(:string, format?: /\A\d+ days\z/) - required(:'logs.api_requests').maybe(:string, format?: /\A\d+ days\z/) - end - - optional(:partition_detach_before_drop).filled(:bool) - - optional(:disable_balance_notification_emails).filled(:bool) - - required(:prometheus).schema do - required(:enabled).value(:bool?) - required(:host).maybe(:string) - required(:port).maybe(:int?) - optional(:default_labels).hash - end - - required(:sentry).schema do - required(:enabled).value(:bool?) - required(:dsn).maybe(:string) - required(:node_name).filled(:string) - required(:environment).filled(:string) - end - - optional(:telemetry).schema do - optional(:enabled).filled(:bool) - end - - # Mounts the Doorkeeper OAuth provider (/oauth/authorize, /oauth/token, - # /oauth/register, /.well-known/oauth-authorization-server). Independent - # of MCP — can be enabled on its own to power SSO for other clients. - # Block AND `enabled` key are both optional; missing → treated as false. - optional(:oauth).schema do - optional(:enabled).value(:bool?) - optional(:issuer).maybe(:string) - - optional(:oidc).schema do - optional(:enabled).value(:bool?) - optional(:signing_key_path).maybe(:string) - end - end - - # Mounts /api/mcp. Requires oauth.enabled (MCP authenticates via OAuth - # bearer tokens); if oauth.enabled is false this flag has no effect. - # Block AND `enabled` key are both optional; missing → treated as false. - optional(:mcp).schema do - optional(:enabled).value(:bool?) - end - - required(:versioning_disable_for_models).each(:string) - - optional(:keep_expired_destinations_days) - optional(:keep_expired_dialpeers_days) - optional(:keep_balance_notifications_days) - - optional(:cryptomus).schema do - optional(:api_key).maybe(:string) - optional(:merchant_id).maybe(:string) - optional(:base_url).maybe(:string) - optional(:url_callback).maybe(:string) - optional(:url_return).maybe(:string) - end - - optional(:invoice).schema do - optional(:auto_approve).value(:bool) - optional(:pdf_converter).maybe(:string) - # External yeti-pdf render service. When configured (and a template has - # html_template) invoice PDFs are produced via yeti-pdf instead of the - # legacy ODT + pdf_converter path. - optional(:pdf_api).schema do - required(:base_url).filled(:string) - optional(:auth_token).maybe(:string) - optional(:timeout).maybe(:integer) - optional(:http_proxy).maybe(:string) - optional(:use_env_proxy).maybe(:bool?) - end - end - optional(:admin_ui).schema do - optional(:session_lifetime).maybe(:int?) - optional(:per_page).array(:integer) - end - - optional(:ip_access).schema do - optional(:cdr_lookback_days).maybe(:int?) - optional(:lega_sip_min_ipv4_mask).maybe(:int?) - optional(:lega_sip_min_ipv6_mask).maybe(:int?) - optional(:lega_rtp_min_ipv4_mask).maybe(:int?) - optional(:lega_rtp_min_ipv6_mask).maybe(:int?) - end - end -end - -Config.evaluate_erb_in_yaml = true begin - Config.load_and_set_settings(Config.setting_files(::Rails.root.join('config'), ::Rails.env)) -rescue Config::Validation::Error => e + YetiConfigLoader.call +rescue YetiConfigLoader::Error => e warn e.message exit 1 # rubocop:disable Rails/Exit end diff --git a/lib/prometheus/cdr_compaction_hook_collector.rb b/lib/prometheus/cdr_compaction_hook_collector.rb index b9d4cb7e1..899fedebe 100644 --- a/lib/prometheus/cdr_compaction_hook_collector.rb +++ b/lib/prometheus/cdr_compaction_hook_collector.rb @@ -6,7 +6,9 @@ class CdrCompactionHookCollector < PrometheusExporter::Server::TypeCollector # Labels are resolved here instead of being taken from the payload, so that the counters can be # exported with a zero value from process start. Otherwise they only appear once the hook runs for # the first time, and an alert on "no executions" has no series to evaluate against. - def initialize(labels = CollectorLabels.call) + # Skipped when no cdr_compaction_hook is configured: the hook never runs, so a seeded zero would + # describe a switched-off feature forever. + def initialize(labels = CollectorLabels.call, seed_zeros: YetiConfig.cdr_compaction_hook.present?) super() @labels = labels @@ -16,7 +18,7 @@ def initialize(labels = CollectorLabels.call) 'errors' => PrometheusExporter::Metric::Counter.new('yeti_cdr_compaction_hook_errors', 'Sum of Yeti CDR Compaction Hook execution fails'), 'duration' => PrometheusExporter::Metric::Counter.new('yeti_cdr_compaction_hook_duration', 'Sum of Yeti CDR Compaction Hook execution duration in seconds') } - @observers.each_value { |observer| observer.observe(0, @labels) } + @observers.each_value { |observer| observer.observe(0, @labels) } if seed_zeros end def type diff --git a/lib/prometheus/collector_labels.rb b/lib/prometheus/collector_labels.rb index da03b98d8..aa96ea901 100644 --- a/lib/prometheus/collector_labels.rb +++ b/lib/prometheus/collector_labels.rb @@ -12,16 +12,11 @@ module CollectorLabels module_function - # @return [Hash] empty when YetiConfig is unavailable or defines no default_labels + # @return [Hash] empty when no default_labels are configured def call labels = PrometheusConfig.default_labels return {} if labels.nil? labels.to_h.to_h { |name, value| [name.to_s, value.to_s] } - rescue StandardError => e - # YetiConfig is absent when config/yeti_web.yml could not be loaded (see YetiConfigLoader). - # Unlabelled counters are strictly better than an exporter that refuses to start. - warn "CollectorLabels: #{e.class} #{e.message}" - {} end end diff --git a/lib/prometheus/partition_remove_hook_collector.rb b/lib/prometheus/partition_remove_hook_collector.rb index b84e3cf7c..415ba7611 100644 --- a/lib/prometheus/partition_remove_hook_collector.rb +++ b/lib/prometheus/partition_remove_hook_collector.rb @@ -6,7 +6,9 @@ class PartitionRemoveHookCollector < PrometheusExporter::Server::TypeCollector # Labels are resolved here instead of being taken from the payload, so that the counters can be # exported with a zero value from process start. Otherwise they only appear once the hook runs for # the first time, and an alert on "no executions" has no series to evaluate against. - def initialize(labels = CollectorLabels.call) + # Skipped when no partition_remove_hook is configured: the hook never runs, so a seeded zero would + # describe a switched-off feature forever. + def initialize(labels = CollectorLabels.call, seed_zeros: YetiConfig.partition_remove_hook.present?) super() @labels = labels @@ -16,7 +18,7 @@ def initialize(labels = CollectorLabels.call) 'errors' => PrometheusExporter::Metric::Counter.new('yeti_partition_removing_hook_errors', 'Sum of Yeti Partition Remove Hook execution fails'), 'duration' => PrometheusExporter::Metric::Counter.new('yeti_partition_removing_hook_duration', 'Sum of Yeti Partition Remove Hook execution duration in seconds') } - @observers.each_value { |observer| observer.observe(0, @labels) } + @observers.each_value { |observer| observer.observe(0, @labels) } if seed_zeros end def type diff --git a/lib/yeti_config_loader.rb b/lib/yeti_config_loader.rb index ada914aa9..e3d6b9f04 100644 --- a/lib/yeti_config_loader.rb +++ b/lib/yeti_config_loader.rb @@ -1,35 +1,37 @@ # frozen_string_literal: true require 'config' +require_relative 'yeti_config_schema' -# Loads config/yeti_web.yml into YetiConfig for processes that never boot Rails, -# such as the standalone prometheus_exporter (see lib/prometheus_collectors.rb). -# -# The Rails application loads the same file, additionally validating it against a schema, -# from config/initializers/config.rb. Schema validation is skipped here: the application -# already fails loudly on an invalid config, and the exporter must keep exporting metrics -# of every other yeti process regardless. +# Loads config/yeti_web.yml into YetiConfig. Used both by the Rails application +# (config/initializers/config.rb) and by processes that never boot Rails, such as the standalone +# prometheus_exporter (lib/prometheus_collectors.rb), so that every reader of YetiConfig gets the +# same file validated against the same schema. module YetiConfigLoader + class Error < StandardError; end + CONFIG_PATH = File.expand_path('../config/yeti_web.yml', __dir__) module_function # @param path [String] - # @return [Boolean] whether YetiConfig is available afterwards + # @raise [YetiConfigLoader::Error] when the file is missing or does not satisfy YetiConfigSchema def call(path = CONFIG_PATH) - return true if defined?(::YetiConfig) + return if defined?(::YetiConfig) + + # Config.load_and_set_settings accepts a missing path and defines an empty YetiConfig, so the + # absence has to be caught here. Checked before Config.setup to leave no global config applied. + raise Error, "config file not found: #{path}" unless File.exist?(path) Config.setup do |config| config.const_name = 'YetiConfig' config.use_env = false + YetiConfigSchema.apply(config) end Config.evaluate_erb_in_yaml = true Config.load_and_set_settings(path) - true - rescue StandardError => e - # A missing or broken config must not prevent the exporter from starting, otherwise every - # metric of every yeti process disappears at once. - warn "YetiConfigLoader: #{e.class} #{e.message}" - false + nil + rescue Config::Validation::Error => e + raise Error, "invalid config #{path}: #{e.message}" end end diff --git a/lib/yeti_config_schema.rb b/lib/yeti_config_schema.rb new file mode 100644 index 000000000..c7e54d2dd --- /dev/null +++ b/lib/yeti_config_schema.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'dry-validation' + +# Validation schema for config/yeti_web.yml. Lives here rather than in the Rails initializer so +# that YetiConfigLoader can apply it for every process reading YetiConfig, Rails or not. +# See https://github.com/dry-rb/dry-validation for the DSL. +module YetiConfigSchema + module_function + + # @param setup_config [Config::Options] the object yielded by Config.setup + def apply(setup_config) + setup_config.schema do + # config.validate_keys = true + + required(:site_title).filled(:string) + required(:site_title_image).filled(:string) + + required(:calls_monitoring).schema do + required(:write_account_stats).value(:bool?) + required(:write_gateway_stats).value(:bool?) + optional(:teardown_on_disabled_customer_auth).value(:bool?) + optional(:teardown_on_disabled_term_gw).value(:bool?) + optional(:teardown_on_disabled_orig_gw).value(:bool?) + end + + required(:api).schema do + required(:token_lifetime).maybe(:int?) + optional(:customer).schema do + required(:token_lifetime).maybe(:int?) + optional(:call_jwt_lifetime).maybe(:int?) + optional(:call_jwt_secret).maybe(:string) + optional(:outgoing_cdr_hide_fields).array(:string) + optional(:outgoing_statistics_use_customer_duration).value(:bool?) + optional(:incoming_cdr_hide_fields).array(:string) + optional(:incoming_statistics_use_vendor_duration).value(:bool?) + end + optional(:system).schema do + optional(:token).maybe(:string) + end + end + + optional(:rec_format).value(Dry::Types['string'].enum('wav', 'mp3')) + + optional(:routing_simulation_default_interface).filled(:string) + + required(:cdr_export).schema do + required(:dir_path).filled(:string) + required(:delete_url).filled(:string) + end + + required(:role_policy).schema do + required(:when_no_config).value(Dry::Types['string'].enum('allow', 'disallow', 'raise')) + required(:when_no_policy_class).value(Dry::Types['string'].enum('allow', 'disallow', 'raise')) + end + + required(:partition_remove_delay).hash do + required(:'cdr.cdr').maybe(:string, format?: /\A\d+ days\z/) + required(:'auth_log.auth_log').maybe(:string, format?: /\A\d+ days\z/) + required(:'rtp_statistics.rx_streams').maybe(:string, format?: /\A\d+ days\z/) + required(:'rtp_statistics.tx_streams').maybe(:string, format?: /\A\d+ days\z/) + required(:'logs.api_requests').maybe(:string, format?: /\A\d+ days\z/) + end + + optional(:partition_detach_before_drop).filled(:bool) + + optional(:disable_balance_notification_emails).filled(:bool) + + required(:prometheus).schema do + required(:enabled).value(:bool?) + required(:host).maybe(:string) + required(:port).maybe(:int?) + optional(:default_labels).hash + end + + required(:sentry).schema do + required(:enabled).value(:bool?) + required(:dsn).maybe(:string) + required(:node_name).filled(:string) + required(:environment).filled(:string) + end + + optional(:telemetry).schema do + optional(:enabled).filled(:bool) + end + + # Mounts the Doorkeeper OAuth provider (/oauth/authorize, /oauth/token, + # /oauth/register, /.well-known/oauth-authorization-server). Independent + # of MCP — can be enabled on its own to power SSO for other clients. + # Block AND `enabled` key are both optional; missing → treated as false. + optional(:oauth).schema do + optional(:enabled).value(:bool?) + optional(:issuer).maybe(:string) + + optional(:oidc).schema do + optional(:enabled).value(:bool?) + optional(:signing_key_path).maybe(:string) + end + end + + # Mounts /api/mcp. Requires oauth.enabled (MCP authenticates via OAuth + # bearer tokens); if oauth.enabled is false this flag has no effect. + # Block AND `enabled` key are both optional; missing → treated as false. + optional(:mcp).schema do + optional(:enabled).value(:bool?) + end + + required(:versioning_disable_for_models).each(:string) + + optional(:keep_expired_destinations_days) + optional(:keep_expired_dialpeers_days) + optional(:keep_balance_notifications_days) + + optional(:cryptomus).schema do + optional(:api_key).maybe(:string) + optional(:merchant_id).maybe(:string) + optional(:base_url).maybe(:string) + optional(:url_callback).maybe(:string) + optional(:url_return).maybe(:string) + end + + optional(:invoice).schema do + optional(:auto_approve).value(:bool) + optional(:pdf_converter).maybe(:string) + # External yeti-pdf render service. When configured (and a template has + # html_template) invoice PDFs are produced via yeti-pdf instead of the + # legacy ODT + pdf_converter path. + optional(:pdf_api).schema do + required(:base_url).filled(:string) + optional(:auth_token).maybe(:string) + optional(:timeout).maybe(:integer) + optional(:http_proxy).maybe(:string) + optional(:use_env_proxy).maybe(:bool?) + end + end + optional(:admin_ui).schema do + optional(:session_lifetime).maybe(:int?) + optional(:per_page).array(:integer) + end + + optional(:ip_access).schema do + optional(:cdr_lookback_days).maybe(:int?) + optional(:lega_sip_min_ipv4_mask).maybe(:int?) + optional(:lega_sip_min_ipv6_mask).maybe(:int?) + optional(:lega_rtp_min_ipv4_mask).maybe(:int?) + optional(:lega_rtp_min_ipv6_mask).maybe(:int?) + end + end + end +end diff --git a/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb b/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb index b80a495c4..eb4a37933 100644 --- a/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb +++ b/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb @@ -6,7 +6,8 @@ RSpec.describe CdrCompactionHookCollector, '#metrics' do subject { described_instance.metrics.map(&:metric_text).compact_blank.map { |metrics| metrics.split("\n") }.flatten } - let(:described_instance) { described_class.new(labels) } + let(:described_instance) { described_class.new(labels, seed_zeros: seed_zeros) } + let(:seed_zeros) { true } let(:labels) { { 'host' => 'yeti-1' } } let(:data) { [metric_executions, metric_success, metric_errors, metric_duration] } let(:metric_executions) { { executions: 1 } } @@ -85,11 +86,41 @@ end describe 'default labels' do - subject { described_class.new } + subject { described_class.new(seed_zeros: true) } it 'resolves them from PrometheusConfig, matching the client custom_labels' do allow(PrometheusConfig).to receive(:default_labels).and_return({ host: :'yeti-2' }) expect(subject.metrics.map(&:metric_text)).to include('yeti_cdr_compaction_hook_executions{host="yeti-2"} 0') end end + + context 'when no cdr_compaction_hook is configured' do + let(:seed_zeros) { false } + let(:data) { [] } + + it 'exports no series at all' do + expect(subject).to be_empty + end + + it 'still records anything the job does report' do + described_instance.collect('executions' => 1) + expect(subject).to contain_exactly('yeti_cdr_compaction_hook_executions{host="yeti-1"} 1') + end + end + + describe 'the zero seed' do + def seeded_series + described_class.new(labels).metrics.map(&:metric_text).compact_blank + end + + it 'is skipped when the hook is not configured' do + allow(YetiConfig).to receive(:cdr_compaction_hook).and_return(nil) + expect(seeded_series).to be_empty + end + + it 'happens when the hook is configured' do + allow(YetiConfig).to receive(:cdr_compaction_hook).and_return('/usr/local/bin/hook') + expect(seeded_series).not_to be_empty + end + end end diff --git a/spec/lib/prometheus/collector_labels_spec.rb b/spec/lib/prometheus/collector_labels_spec.rb index f41bd8dcb..2b773f331 100644 --- a/spec/lib/prometheus/collector_labels_spec.rb +++ b/spec/lib/prometheus/collector_labels_spec.rb @@ -26,13 +26,4 @@ it { is_expected.to eq({}) } end - - context 'when YetiConfig is unavailable' do - before { allow(PrometheusConfig).to receive(:default_labels).and_raise(NameError, 'uninitialized constant YetiConfig') } - - it 'falls back to unlabelled metrics instead of preventing the exporter from starting' do - expect { subject }.to_not raise_error - expect(subject).to eq({}) - end - end end diff --git a/spec/lib/prometheus/partition_remove_hook_collector_spec.rb b/spec/lib/prometheus/partition_remove_hook_collector_spec.rb index 2a5e189ed..32fda8666 100644 --- a/spec/lib/prometheus/partition_remove_hook_collector_spec.rb +++ b/spec/lib/prometheus/partition_remove_hook_collector_spec.rb @@ -6,7 +6,8 @@ RSpec.describe PartitionRemoveHookCollector, '#metrics' do subject { described_instance.metrics.map(&:metric_text).compact_blank.map { |metrics| metrics.split("\n") }.flatten } - let(:described_instance) { described_class.new(labels) } + let(:described_instance) { described_class.new(labels, seed_zeros: seed_zeros) } + let(:seed_zeros) { true } let(:labels) { { 'host' => 'yeti-1' } } let(:data) { [metric_executions, metric_success, metric_errors, metric_duration] } let(:metric_executions) { { executions: 1 } } @@ -85,11 +86,41 @@ end describe 'default labels' do - subject { described_class.new } + subject { described_class.new(seed_zeros: true) } it 'resolves them from PrometheusConfig, matching the client custom_labels' do allow(PrometheusConfig).to receive(:default_labels).and_return({ host: :'yeti-2' }) expect(subject.metrics.map(&:metric_text)).to include('yeti_partition_removing_hook_executions{host="yeti-2"} 0') end end + + context 'when no partition_remove_hook is configured' do + let(:seed_zeros) { false } + let(:data) { [] } + + it 'exports no series at all' do + expect(subject).to be_empty + end + + it 'still records anything the job does report' do + described_instance.collect('executions' => 1) + expect(subject).to contain_exactly('yeti_partition_removing_hook_executions{host="yeti-1"} 1') + end + end + + describe 'the zero seed' do + def seeded_series + described_class.new(labels).metrics.map(&:metric_text).compact_blank + end + + it 'is skipped when the hook is not configured' do + allow(YetiConfig).to receive(:partition_remove_hook).and_return(nil) + expect(seeded_series).to be_empty + end + + it 'happens when the hook is configured' do + allow(YetiConfig).to receive(:partition_remove_hook).and_return('/usr/local/bin/hook') + expect(seeded_series).not_to be_empty + end + end end diff --git a/spec/lib/yeti_config_loader_spec.rb b/spec/lib/yeti_config_loader_spec.rb new file mode 100644 index 000000000..c9f03abdc --- /dev/null +++ b/spec/lib/yeti_config_loader_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require_relative Rails.root.join('lib/yeti_config_loader') + +RSpec.describe YetiConfigLoader, '.call' do + it 'does nothing when YetiConfig is already loaded' do + expect { described_class.call('/nonexistent/yeti_web.yml') }.not_to raise_error + end + + context 'when YetiConfig has not been loaded yet' do + before { hide_const('YetiConfig') } + + it 'raises rather than starting without a config' do + expect { described_class.call('/nonexistent/yeti_web.yml') } + .to raise_error(YetiConfigLoader::Error, %r{config file not found: /nonexistent/yeti_web.yml}) + end + + it 'raises before touching the global Config setup' do + expect(Config).not_to receive(:setup) + expect { described_class.call('/nonexistent/yeti_web.yml') }.to raise_error(YetiConfigLoader::Error) + end + + it 'reports an invalid config as its own error, not the gem class' do + allow(Config).to receive(:load_and_set_settings) + .and_raise(Config::Validation::Error, 'site_title: must be a string') + + expect { described_class.call } + .to raise_error(YetiConfigLoader::Error, /invalid config .*yeti_web\.yml: site_title: must be a string/) + end + end +end