From 1526533f12bc3c113d097e8dba2a5eb24f3be75e Mon Sep 17 00:00:00 2001 From: sdi Date: Mon, 10 Aug 2026 11:45:14 +0300 Subject: [PATCH 1/4] Do not emit partition_remove_hook and CDR compaction hook metrics when hooks are disabled --- .../cdr_compaction_hook_collector.rb | 9 ++++- lib/prometheus/hook_config.rb | 24 +++++++++++ .../partition_remove_hook_collector.rb | 10 ++++- .../cdr_compaction_hook_collector_spec.rb | 40 ++++++++++++++++++- spec/lib/prometheus/hook_config_spec.rb | 36 +++++++++++++++++ .../partition_remove_hook_collector_spec.rb | 40 ++++++++++++++++++- 6 files changed, 151 insertions(+), 8 deletions(-) create mode 100644 lib/prometheus/hook_config.rb create mode 100644 spec/lib/prometheus/hook_config_spec.rb diff --git a/lib/prometheus/cdr_compaction_hook_collector.rb b/lib/prometheus/cdr_compaction_hook_collector.rb index b9d4cb7e1..88c295004 100644 --- a/lib/prometheus/cdr_compaction_hook_collector.rb +++ b/lib/prometheus/cdr_compaction_hook_collector.rb @@ -1,12 +1,17 @@ # frozen_string_literal: true require_relative './collector_labels' +require_relative './hook_config' 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) + # + # The zero seed is skipped when no cdr_compaction_hook is configured: Jobs::CdrCompaction then + # never runs a hook and never reports, so seeded counters would export a permanently-zero series + # for a feature that is switched off. + def initialize(labels = CollectorLabels.call, seed_zeros: HookConfig.configured?(:cdr_compaction_hook)) super() @labels = labels @@ -16,7 +21,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/hook_config.rb b/lib/prometheus/hook_config.rb new file mode 100644 index 000000000..6097df397 --- /dev/null +++ b/lib/prometheus/hook_config.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Whether an optional shell hook is configured, for collectors that seed their counters with a zero +# value at process start. +# +# Seeding exists so that an alert on "the hook has not run" has a series to evaluate against from +# the moment the exporter starts. That reasoning only holds where the hook exists: on an +# installation that never configured one, the seeded zeros describe a feature that is switched off, +# and such an alert would fire forever against a hook nobody asked for. +module HookConfig + module_function + + # @param name [Symbol] the YetiConfig key holding the hook command + # @return [Boolean] false when the key is unset, or when YetiConfig is unavailable + def configured?(name) + YetiConfig.public_send(name).present? + rescue StandardError => e + # YetiConfig is absent when config/yeti_web.yml could not be loaded (see YetiConfigLoader). + # Without it we cannot tell a configured hook from an absent one; not seeding is the quieter + # of the two guesses. + warn "HookConfig: #{e.class} #{e.message}" + false + end +end diff --git a/lib/prometheus/partition_remove_hook_collector.rb b/lib/prometheus/partition_remove_hook_collector.rb index b84e3cf7c..4db466433 100644 --- a/lib/prometheus/partition_remove_hook_collector.rb +++ b/lib/prometheus/partition_remove_hook_collector.rb @@ -1,12 +1,18 @@ # frozen_string_literal: true require_relative './collector_labels' +require_relative './hook_config' 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) + # + # The zero seed is skipped when no partition_remove_hook is configured: Jobs::PartitionRemoving + # then never runs a hook and never reports, so seeded counters would export a permanently-zero + # series for a feature that is switched off. Without the seed the counters carry no series at all + # (only HELP/TYPE headers), which is what an unconfigured hook should look like. + def initialize(labels = CollectorLabels.call, seed_zeros: HookConfig.configured?(:partition_remove_hook)) super() @labels = labels @@ -16,7 +22,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/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb b/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb index b80a495c4..da9da7fd7 100644 --- a/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb +++ b/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb @@ -6,7 +6,9 @@ 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) } + # The zero seed only happens where the hook is configured; see HookConfig. + 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 +87,45 @@ 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 + + # The reported bug: with no hook configured the counters were still seeded, so a + # permanently-zero series was exported for a feature that is switched off, and a + # "hook has not run" alert would fire against it forever. + 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 + # Built per example rather than via subject, so each one sees its own stub. + 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(HookConfig).to receive(:configured?).with(:cdr_compaction_hook).and_return(false) + expect(seeded_series).to be_empty + end + + it 'happens when the hook is configured' do + allow(HookConfig).to receive(:configured?).with(:cdr_compaction_hook).and_return(true) + expect(seeded_series).not_to be_empty + end + end end diff --git a/spec/lib/prometheus/hook_config_spec.rb b/spec/lib/prometheus/hook_config_spec.rb new file mode 100644 index 000000000..34c129ee7 --- /dev/null +++ b/spec/lib/prometheus/hook_config_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require_relative Rails.root.join('lib/prometheus/hook_config') + +RSpec.describe HookConfig, '.configured?' do + subject { described_class.configured?(:partition_remove_hook) } + + # Reading through public_send keeps this stubbable: config/yeti_web.yml ships + # partition_remove_hook commented out, so YetiConfig does not respond to it. + def stub_hook(value) + allow(YetiConfig).to receive(:public_send).and_call_original + allow(YetiConfig).to receive(:public_send).with(:partition_remove_hook).and_return(value) + end + + it 'is false when the hook is not configured' do + expect(subject).to be(false) + end + + it 'is false when the hook is configured blank' do + stub_hook(' ') + expect(subject).to be(false) + end + + it 'is true when a hook command is configured' do + stub_hook('/usr/local/bin/partition-removed') + expect(subject).to be(true) + end + + # The exporter must keep exporting every other process's metrics even when + # config/yeti_web.yml could not be loaded at all (see YetiConfigLoader). + it 'is false, without raising, when YetiConfig is unusable' do + allow(YetiConfig).to receive(:public_send).and_raise(NameError, 'uninitialized constant YetiConfig') + expect { subject }.to output(/HookConfig: NameError/).to_stderr + expect(subject).to be(false) + 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..eeac67ca4 100644 --- a/spec/lib/prometheus/partition_remove_hook_collector_spec.rb +++ b/spec/lib/prometheus/partition_remove_hook_collector_spec.rb @@ -6,7 +6,9 @@ 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) } + # The zero seed only happens where the hook is configured; see HookConfig. + 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 +87,45 @@ 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 + + # The reported bug: with no hook configured the counters were still seeded, so a + # permanently-zero series was exported for a feature that is switched off, and a + # "hook has not run" alert would fire against it forever. + 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 + # Built per example rather than via subject, so each one sees its own stub. + 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(HookConfig).to receive(:configured?).with(:partition_remove_hook).and_return(false) + expect(seeded_series).to be_empty + end + + it 'happens when the hook is configured' do + allow(HookConfig).to receive(:configured?).with(:partition_remove_hook).and_return(true) + expect(seeded_series).not_to be_empty + end + end end From 346eea66d89a8acd558e529638413d7c3ca0231b Mon Sep 17 00:00:00 2001 From: sdi Date: Mon, 10 Aug 2026 12:00:52 +0300 Subject: [PATCH 2/4] always load YetiConfig --- .../cdr_compaction_hook_collector.rb | 4 +-- lib/prometheus/collector_labels.rb | 7 +--- lib/prometheus/hook_config.rb | 24 ------------- .../partition_remove_hook_collector.rb | 4 +-- lib/prometheus_config.rb | 12 +++++++ lib/yeti_config_loader.rb | 26 ++++++++------ .../cdr_compaction_hook_collector_spec.rb | 6 ++-- spec/lib/prometheus/collector_labels_spec.rb | 9 ----- spec/lib/prometheus/hook_config_spec.rb | 36 ------------------- .../partition_remove_hook_collector_spec.rb | 6 ++-- spec/lib/prometheus_config_spec.rb | 31 ++++++++++++++++ spec/lib/yeti_config_loader_spec.rb | 28 +++++++++++++++ 12 files changed, 97 insertions(+), 96 deletions(-) delete mode 100644 lib/prometheus/hook_config.rb delete mode 100644 spec/lib/prometheus/hook_config_spec.rb create mode 100644 spec/lib/prometheus_config_spec.rb create mode 100644 spec/lib/yeti_config_loader_spec.rb diff --git a/lib/prometheus/cdr_compaction_hook_collector.rb b/lib/prometheus/cdr_compaction_hook_collector.rb index 88c295004..551975ef4 100644 --- a/lib/prometheus/cdr_compaction_hook_collector.rb +++ b/lib/prometheus/cdr_compaction_hook_collector.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require_relative './collector_labels' -require_relative './hook_config' +require_relative '../prometheus_config' class CdrCompactionHookCollector < PrometheusExporter::Server::TypeCollector # Labels are resolved here instead of being taken from the payload, so that the counters can be @@ -11,7 +11,7 @@ class CdrCompactionHookCollector < PrometheusExporter::Server::TypeCollector # The zero seed is skipped when no cdr_compaction_hook is configured: Jobs::CdrCompaction then # never runs a hook and never reports, so seeded counters would export a permanently-zero series # for a feature that is switched off. - def initialize(labels = CollectorLabels.call, seed_zeros: HookConfig.configured?(:cdr_compaction_hook)) + def initialize(labels = CollectorLabels.call, seed_zeros: PrometheusConfig.cdr_compaction_hook_configured?) super() @labels = labels 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/hook_config.rb b/lib/prometheus/hook_config.rb deleted file mode 100644 index 6097df397..000000000 --- a/lib/prometheus/hook_config.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -# Whether an optional shell hook is configured, for collectors that seed their counters with a zero -# value at process start. -# -# Seeding exists so that an alert on "the hook has not run" has a series to evaluate against from -# the moment the exporter starts. That reasoning only holds where the hook exists: on an -# installation that never configured one, the seeded zeros describe a feature that is switched off, -# and such an alert would fire forever against a hook nobody asked for. -module HookConfig - module_function - - # @param name [Symbol] the YetiConfig key holding the hook command - # @return [Boolean] false when the key is unset, or when YetiConfig is unavailable - def configured?(name) - YetiConfig.public_send(name).present? - rescue StandardError => e - # YetiConfig is absent when config/yeti_web.yml could not be loaded (see YetiConfigLoader). - # Without it we cannot tell a configured hook from an absent one; not seeding is the quieter - # of the two guesses. - warn "HookConfig: #{e.class} #{e.message}" - false - end -end diff --git a/lib/prometheus/partition_remove_hook_collector.rb b/lib/prometheus/partition_remove_hook_collector.rb index 4db466433..401e658a3 100644 --- a/lib/prometheus/partition_remove_hook_collector.rb +++ b/lib/prometheus/partition_remove_hook_collector.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true require_relative './collector_labels' -require_relative './hook_config' +require_relative '../prometheus_config' class PartitionRemoveHookCollector < PrometheusExporter::Server::TypeCollector # Labels are resolved here instead of being taken from the payload, so that the counters can be @@ -12,7 +12,7 @@ class PartitionRemoveHookCollector < PrometheusExporter::Server::TypeCollector # then never runs a hook and never reports, so seeded counters would export a permanently-zero # series for a feature that is switched off. Without the seed the counters carry no series at all # (only HELP/TYPE headers), which is what an unconfigured hook should look like. - def initialize(labels = CollectorLabels.call, seed_zeros: HookConfig.configured?(:partition_remove_hook)) + def initialize(labels = CollectorLabels.call, seed_zeros: PrometheusConfig.partition_remove_hook_configured?) super() @labels = labels diff --git a/lib/prometheus_config.rb b/lib/prometheus_config.rb index 31fea4e8b..2f4b4d4e8 100644 --- a/lib/prometheus_config.rb +++ b/lib/prometheus_config.rb @@ -18,4 +18,16 @@ def port def default_labels YetiConfig.prometheus.default_labels end + + # Whether the optional shell hooks are configured, for the collectors that seed their counters + # with a zero value at process start. Seeding gives a "the hook has not run" alert a series to + # evaluate against; where no hook is configured that series would describe a switched-off feature + # and the alert would fire forever. + def partition_remove_hook_configured? + YetiConfig.partition_remove_hook.present? + end + + def cdr_compaction_hook_configured? + YetiConfig.cdr_compaction_hook.present? + end end diff --git a/lib/yeti_config_loader.rb b/lib/yeti_config_loader.rb index ada914aa9..30a213476 100644 --- a/lib/yeti_config_loader.rb +++ b/lib/yeti_config_loader.rb @@ -6,18 +6,27 @@ # 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. +# from config/initializers/config.rb. Schema validation is skipped here because the application +# already fails loudly on an invalid config; the file itself, however, must be there and must +# parse. A process that carried on without it would read nil for every setting and export +# whatever that happened to produce, which is harder to diagnose than not starting at all. 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 + # @raise [StandardError] whatever Config raises when the file does not parse def call(path = CONFIG_PATH) - return true if defined?(::YetiConfig) + return if defined?(::YetiConfig) + + # Config.load_and_set_settings does not object to a path that does not exist: it defines an + # empty YetiConfig and returns, so the absence has to be caught here. Checked before + # Config.setup so a failure leaves no half-applied global configuration behind. + raise Error, "config file not found: #{path}" unless File.exist?(path) Config.setup do |config| config.const_name = 'YetiConfig' @@ -25,11 +34,6 @@ def call(path = CONFIG_PATH) 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 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 da9da7fd7..7f67ae306 100644 --- a/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb +++ b/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb @@ -7,7 +7,7 @@ subject { described_instance.metrics.map(&:metric_text).compact_blank.map { |metrics| metrics.split("\n") }.flatten } let(:described_instance) { described_class.new(labels, seed_zeros: seed_zeros) } - # The zero seed only happens where the hook is configured; see HookConfig. + # The zero seed only happens where the hook is configured; see PrometheusConfig. let(:seed_zeros) { true } let(:labels) { { 'host' => 'yeti-1' } } let(:data) { [metric_executions, metric_success, metric_errors, metric_duration] } @@ -119,12 +119,12 @@ def seeded_series end it 'is skipped when the hook is not configured' do - allow(HookConfig).to receive(:configured?).with(:cdr_compaction_hook).and_return(false) + allow(PrometheusConfig).to receive(:cdr_compaction_hook_configured?).and_return(false) expect(seeded_series).to be_empty end it 'happens when the hook is configured' do - allow(HookConfig).to receive(:configured?).with(:cdr_compaction_hook).and_return(true) + allow(PrometheusConfig).to receive(:cdr_compaction_hook_configured?).and_return(true) expect(seeded_series).not_to be_empty 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/hook_config_spec.rb b/spec/lib/prometheus/hook_config_spec.rb deleted file mode 100644 index 34c129ee7..000000000 --- a/spec/lib/prometheus/hook_config_spec.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -require_relative Rails.root.join('lib/prometheus/hook_config') - -RSpec.describe HookConfig, '.configured?' do - subject { described_class.configured?(:partition_remove_hook) } - - # Reading through public_send keeps this stubbable: config/yeti_web.yml ships - # partition_remove_hook commented out, so YetiConfig does not respond to it. - def stub_hook(value) - allow(YetiConfig).to receive(:public_send).and_call_original - allow(YetiConfig).to receive(:public_send).with(:partition_remove_hook).and_return(value) - end - - it 'is false when the hook is not configured' do - expect(subject).to be(false) - end - - it 'is false when the hook is configured blank' do - stub_hook(' ') - expect(subject).to be(false) - end - - it 'is true when a hook command is configured' do - stub_hook('/usr/local/bin/partition-removed') - expect(subject).to be(true) - end - - # The exporter must keep exporting every other process's metrics even when - # config/yeti_web.yml could not be loaded at all (see YetiConfigLoader). - it 'is false, without raising, when YetiConfig is unusable' do - allow(YetiConfig).to receive(:public_send).and_raise(NameError, 'uninitialized constant YetiConfig') - expect { subject }.to output(/HookConfig: NameError/).to_stderr - expect(subject).to be(false) - 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 eeac67ca4..62ec877c7 100644 --- a/spec/lib/prometheus/partition_remove_hook_collector_spec.rb +++ b/spec/lib/prometheus/partition_remove_hook_collector_spec.rb @@ -7,7 +7,7 @@ subject { described_instance.metrics.map(&:metric_text).compact_blank.map { |metrics| metrics.split("\n") }.flatten } let(:described_instance) { described_class.new(labels, seed_zeros: seed_zeros) } - # The zero seed only happens where the hook is configured; see HookConfig. + # The zero seed only happens where the hook is configured; see PrometheusConfig. let(:seed_zeros) { true } let(:labels) { { 'host' => 'yeti-1' } } let(:data) { [metric_executions, metric_success, metric_errors, metric_duration] } @@ -119,12 +119,12 @@ def seeded_series end it 'is skipped when the hook is not configured' do - allow(HookConfig).to receive(:configured?).with(:partition_remove_hook).and_return(false) + allow(PrometheusConfig).to receive(:partition_remove_hook_configured?).and_return(false) expect(seeded_series).to be_empty end it 'happens when the hook is configured' do - allow(HookConfig).to receive(:configured?).with(:partition_remove_hook).and_return(true) + allow(PrometheusConfig).to receive(:partition_remove_hook_configured?).and_return(true) expect(seeded_series).not_to be_empty end end diff --git a/spec/lib/prometheus_config_spec.rb b/spec/lib/prometheus_config_spec.rb new file mode 100644 index 000000000..888f726b8 --- /dev/null +++ b/spec/lib/prometheus_config_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +RSpec.describe PrometheusConfig do + # config/yeti_web.yml ships both hooks commented out, so the unconfigured case + # is the real default rather than something the spec has to arrange. + shared_examples :a_hook_reader do |method, config_key| + subject { described_class.public_send(method) } + + it 'is false when the hook is not configured' do + expect(subject).to be(false) + end + + it 'is false when the hook is configured blank' do + allow(YetiConfig).to receive(config_key).and_return(' ') + expect(subject).to be(false) + end + + it 'is true when a hook command is configured' do + allow(YetiConfig).to receive(config_key).and_return('/usr/local/bin/hook') + expect(subject).to be(true) + end + end + + describe '.partition_remove_hook_configured?' do + it_behaves_like :a_hook_reader, :partition_remove_hook_configured?, :partition_remove_hook + end + + describe '.cdr_compaction_hook_configured?' do + it_behaves_like :a_hook_reader, :cdr_compaction_hook_configured?, :cdr_compaction_hook + 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..42279480c --- /dev/null +++ b/spec/lib/yeti_config_loader_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require_relative Rails.root.join('lib/yeti_config_loader') + +RSpec.describe YetiConfigLoader, '.call' do + # Rails has already defined YetiConfig by the time specs run, so the loader + # short-circuits — which is the behaviour every non-exporter process relies on. + 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') } + + # Config.load_and_set_settings accepts a missing path and defines an empty + # YetiConfig, so without this check the exporter would start and read nil + # for every setting instead of failing. + 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 + end +end From bcabb0baa16c33ab910dbe3a9b268747da9da324 Mon Sep 17 00:00:00 2001 From: sdi Date: Mon, 10 Aug 2026 12:49:35 +0300 Subject: [PATCH 3/4] fixes --- lib/prometheus/cdr_compaction_hook_collector.rb | 6 ++---- lib/prometheus/partition_remove_hook_collector.rb | 7 ++----- lib/prometheus_config.rb | 4 ---- lib/yeti_config_loader.rb | 13 ++++--------- .../cdr_compaction_hook_collector_spec.rb | 5 ----- .../partition_remove_hook_collector_spec.rb | 5 ----- spec/lib/prometheus_config_spec.rb | 2 -- spec/lib/yeti_config_loader_spec.rb | 5 ----- 8 files changed, 8 insertions(+), 39 deletions(-) diff --git a/lib/prometheus/cdr_compaction_hook_collector.rb b/lib/prometheus/cdr_compaction_hook_collector.rb index 551975ef4..7df62e923 100644 --- a/lib/prometheus/cdr_compaction_hook_collector.rb +++ b/lib/prometheus/cdr_compaction_hook_collector.rb @@ -7,10 +7,8 @@ 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. - # - # The zero seed is skipped when no cdr_compaction_hook is configured: Jobs::CdrCompaction then - # never runs a hook and never reports, so seeded counters would export a permanently-zero series - # for a feature that is switched off. + # 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: PrometheusConfig.cdr_compaction_hook_configured?) super() diff --git a/lib/prometheus/partition_remove_hook_collector.rb b/lib/prometheus/partition_remove_hook_collector.rb index 401e658a3..4dda062f5 100644 --- a/lib/prometheus/partition_remove_hook_collector.rb +++ b/lib/prometheus/partition_remove_hook_collector.rb @@ -7,11 +7,8 @@ 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. - # - # The zero seed is skipped when no partition_remove_hook is configured: Jobs::PartitionRemoving - # then never runs a hook and never reports, so seeded counters would export a permanently-zero - # series for a feature that is switched off. Without the seed the counters carry no series at all - # (only HELP/TYPE headers), which is what an unconfigured hook should look like. + # 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: PrometheusConfig.partition_remove_hook_configured?) super() diff --git a/lib/prometheus_config.rb b/lib/prometheus_config.rb index 2f4b4d4e8..34691f25a 100644 --- a/lib/prometheus_config.rb +++ b/lib/prometheus_config.rb @@ -19,10 +19,6 @@ def default_labels YetiConfig.prometheus.default_labels end - # Whether the optional shell hooks are configured, for the collectors that seed their counters - # with a zero value at process start. Seeding gives a "the hook has not run" alert a series to - # evaluate against; where no hook is configured that series would describe a switched-off feature - # and the alert would fire forever. def partition_remove_hook_configured? YetiConfig.partition_remove_hook.present? end diff --git a/lib/yeti_config_loader.rb b/lib/yeti_config_loader.rb index 30a213476..3244d9f09 100644 --- a/lib/yeti_config_loader.rb +++ b/lib/yeti_config_loader.rb @@ -5,11 +5,8 @@ # 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 because the application -# already fails loudly on an invalid config; the file itself, however, must be there and must -# parse. A process that carried on without it would read nil for every setting and export -# whatever that happened to produce, which is harder to diagnose than not starting at all. +# Schema validation is skipped here: the Rails application loads the same file from +# config/initializers/config.rb and already fails loudly on an invalid config. module YetiConfigLoader class Error < StandardError; end @@ -19,13 +16,11 @@ class Error < StandardError; end # @param path [String] # @raise [YetiConfigLoader::Error] when the file is missing - # @raise [StandardError] whatever Config raises when the file does not parse def call(path = CONFIG_PATH) return if defined?(::YetiConfig) - # Config.load_and_set_settings does not object to a path that does not exist: it defines an - # empty YetiConfig and returns, so the absence has to be caught here. Checked before - # Config.setup so a failure leaves no half-applied global configuration behind. + # 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| diff --git a/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb b/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb index 7f67ae306..f761e1fef 100644 --- a/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb +++ b/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb @@ -7,7 +7,6 @@ subject { described_instance.metrics.map(&:metric_text).compact_blank.map { |metrics| metrics.split("\n") }.flatten } let(:described_instance) { described_class.new(labels, seed_zeros: seed_zeros) } - # The zero seed only happens where the hook is configured; see PrometheusConfig. let(:seed_zeros) { true } let(:labels) { { 'host' => 'yeti-1' } } let(:data) { [metric_executions, metric_success, metric_errors, metric_duration] } @@ -95,9 +94,6 @@ end end - # The reported bug: with no hook configured the counters were still seeded, so a - # permanently-zero series was exported for a feature that is switched off, and a - # "hook has not run" alert would fire against it forever. context 'when no cdr_compaction_hook is configured' do let(:seed_zeros) { false } let(:data) { [] } @@ -113,7 +109,6 @@ end describe 'the zero seed' do - # Built per example rather than via subject, so each one sees its own stub. def seeded_series described_class.new(labels).metrics.map(&:metric_text).compact_blank end diff --git a/spec/lib/prometheus/partition_remove_hook_collector_spec.rb b/spec/lib/prometheus/partition_remove_hook_collector_spec.rb index 62ec877c7..cafe58a0a 100644 --- a/spec/lib/prometheus/partition_remove_hook_collector_spec.rb +++ b/spec/lib/prometheus/partition_remove_hook_collector_spec.rb @@ -7,7 +7,6 @@ subject { described_instance.metrics.map(&:metric_text).compact_blank.map { |metrics| metrics.split("\n") }.flatten } let(:described_instance) { described_class.new(labels, seed_zeros: seed_zeros) } - # The zero seed only happens where the hook is configured; see PrometheusConfig. let(:seed_zeros) { true } let(:labels) { { 'host' => 'yeti-1' } } let(:data) { [metric_executions, metric_success, metric_errors, metric_duration] } @@ -95,9 +94,6 @@ end end - # The reported bug: with no hook configured the counters were still seeded, so a - # permanently-zero series was exported for a feature that is switched off, and a - # "hook has not run" alert would fire against it forever. context 'when no partition_remove_hook is configured' do let(:seed_zeros) { false } let(:data) { [] } @@ -113,7 +109,6 @@ end describe 'the zero seed' do - # Built per example rather than via subject, so each one sees its own stub. def seeded_series described_class.new(labels).metrics.map(&:metric_text).compact_blank end diff --git a/spec/lib/prometheus_config_spec.rb b/spec/lib/prometheus_config_spec.rb index 888f726b8..3818ced50 100644 --- a/spec/lib/prometheus_config_spec.rb +++ b/spec/lib/prometheus_config_spec.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true RSpec.describe PrometheusConfig do - # config/yeti_web.yml ships both hooks commented out, so the unconfigured case - # is the real default rather than something the spec has to arrange. shared_examples :a_hook_reader do |method, config_key| subject { described_class.public_send(method) } diff --git a/spec/lib/yeti_config_loader_spec.rb b/spec/lib/yeti_config_loader_spec.rb index 42279480c..8d0098e0f 100644 --- a/spec/lib/yeti_config_loader_spec.rb +++ b/spec/lib/yeti_config_loader_spec.rb @@ -3,8 +3,6 @@ require_relative Rails.root.join('lib/yeti_config_loader') RSpec.describe YetiConfigLoader, '.call' do - # Rails has already defined YetiConfig by the time specs run, so the loader - # short-circuits — which is the behaviour every non-exporter process relies on. it 'does nothing when YetiConfig is already loaded' do expect { described_class.call('/nonexistent/yeti_web.yml') }.not_to raise_error end @@ -12,9 +10,6 @@ context 'when YetiConfig has not been loaded yet' do before { hide_const('YetiConfig') } - # Config.load_and_set_settings accepts a missing path and defines an empty - # YetiConfig, so without this check the exporter would start and read nil - # for every setting instead of failing. 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}) From ae1be24451f6d0ff31cc3fb96960c8522268a1c2 Mon Sep 17 00:00:00 2001 From: sdi Date: Mon, 10 Aug 2026 13:33:56 +0300 Subject: [PATCH 4/4] fixes --- config/initializers/config.rb | 150 +----------------- .../cdr_compaction_hook_collector.rb | 3 +- .../partition_remove_hook_collector.rb | 3 +- lib/prometheus_config.rb | 8 - lib/yeti_config_loader.rb | 15 +- lib/yeti_config_schema.rb | 150 ++++++++++++++++++ .../cdr_compaction_hook_collector_spec.rb | 4 +- .../partition_remove_hook_collector_spec.rb | 4 +- spec/lib/prometheus_config_spec.rb | 29 ---- spec/lib/yeti_config_loader_spec.rb | 8 + 10 files changed, 176 insertions(+), 198 deletions(-) create mode 100644 lib/yeti_config_schema.rb delete mode 100644 spec/lib/prometheus_config_spec.rb 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 7df62e923..899fedebe 100644 --- a/lib/prometheus/cdr_compaction_hook_collector.rb +++ b/lib/prometheus/cdr_compaction_hook_collector.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require_relative './collector_labels' -require_relative '../prometheus_config' class CdrCompactionHookCollector < PrometheusExporter::Server::TypeCollector # Labels are resolved here instead of being taken from the payload, so that the counters can be @@ -9,7 +8,7 @@ class CdrCompactionHookCollector < PrometheusExporter::Server::TypeCollector # the first time, and an alert on "no executions" has no series to evaluate against. # 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: PrometheusConfig.cdr_compaction_hook_configured?) + def initialize(labels = CollectorLabels.call, seed_zeros: YetiConfig.cdr_compaction_hook.present?) super() @labels = labels diff --git a/lib/prometheus/partition_remove_hook_collector.rb b/lib/prometheus/partition_remove_hook_collector.rb index 4dda062f5..415ba7611 100644 --- a/lib/prometheus/partition_remove_hook_collector.rb +++ b/lib/prometheus/partition_remove_hook_collector.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require_relative './collector_labels' -require_relative '../prometheus_config' class PartitionRemoveHookCollector < PrometheusExporter::Server::TypeCollector # Labels are resolved here instead of being taken from the payload, so that the counters can be @@ -9,7 +8,7 @@ class PartitionRemoveHookCollector < PrometheusExporter::Server::TypeCollector # the first time, and an alert on "no executions" has no series to evaluate against. # 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: PrometheusConfig.partition_remove_hook_configured?) + def initialize(labels = CollectorLabels.call, seed_zeros: YetiConfig.partition_remove_hook.present?) super() @labels = labels diff --git a/lib/prometheus_config.rb b/lib/prometheus_config.rb index 34691f25a..31fea4e8b 100644 --- a/lib/prometheus_config.rb +++ b/lib/prometheus_config.rb @@ -18,12 +18,4 @@ def port def default_labels YetiConfig.prometheus.default_labels end - - def partition_remove_hook_configured? - YetiConfig.partition_remove_hook.present? - end - - def cdr_compaction_hook_configured? - YetiConfig.cdr_compaction_hook.present? - end end diff --git a/lib/yeti_config_loader.rb b/lib/yeti_config_loader.rb index 3244d9f09..e3d6b9f04 100644 --- a/lib/yeti_config_loader.rb +++ b/lib/yeti_config_loader.rb @@ -1,12 +1,12 @@ # 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). -# -# Schema validation is skipped here: the Rails application loads the same file from -# config/initializers/config.rb and already fails loudly on an invalid config. +# 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 @@ -15,7 +15,7 @@ class Error < StandardError; end module_function # @param path [String] - # @raise [YetiConfigLoader::Error] when the file is missing + # @raise [YetiConfigLoader::Error] when the file is missing or does not satisfy YetiConfigSchema def call(path = CONFIG_PATH) return if defined?(::YetiConfig) @@ -26,9 +26,12 @@ def call(path = CONFIG_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) 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 f761e1fef..eb4a37933 100644 --- a/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb +++ b/spec/lib/prometheus/cdr_compaction_hook_collector_spec.rb @@ -114,12 +114,12 @@ def seeded_series end it 'is skipped when the hook is not configured' do - allow(PrometheusConfig).to receive(:cdr_compaction_hook_configured?).and_return(false) + 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(PrometheusConfig).to receive(:cdr_compaction_hook_configured?).and_return(true) + allow(YetiConfig).to receive(:cdr_compaction_hook).and_return('/usr/local/bin/hook') expect(seeded_series).not_to be_empty 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 cafe58a0a..32fda8666 100644 --- a/spec/lib/prometheus/partition_remove_hook_collector_spec.rb +++ b/spec/lib/prometheus/partition_remove_hook_collector_spec.rb @@ -114,12 +114,12 @@ def seeded_series end it 'is skipped when the hook is not configured' do - allow(PrometheusConfig).to receive(:partition_remove_hook_configured?).and_return(false) + 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(PrometheusConfig).to receive(:partition_remove_hook_configured?).and_return(true) + allow(YetiConfig).to receive(:partition_remove_hook).and_return('/usr/local/bin/hook') expect(seeded_series).not_to be_empty end end diff --git a/spec/lib/prometheus_config_spec.rb b/spec/lib/prometheus_config_spec.rb deleted file mode 100644 index 3818ced50..000000000 --- a/spec/lib/prometheus_config_spec.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe PrometheusConfig do - shared_examples :a_hook_reader do |method, config_key| - subject { described_class.public_send(method) } - - it 'is false when the hook is not configured' do - expect(subject).to be(false) - end - - it 'is false when the hook is configured blank' do - allow(YetiConfig).to receive(config_key).and_return(' ') - expect(subject).to be(false) - end - - it 'is true when a hook command is configured' do - allow(YetiConfig).to receive(config_key).and_return('/usr/local/bin/hook') - expect(subject).to be(true) - end - end - - describe '.partition_remove_hook_configured?' do - it_behaves_like :a_hook_reader, :partition_remove_hook_configured?, :partition_remove_hook - end - - describe '.cdr_compaction_hook_configured?' do - it_behaves_like :a_hook_reader, :cdr_compaction_hook_configured?, :cdr_compaction_hook - end -end diff --git a/spec/lib/yeti_config_loader_spec.rb b/spec/lib/yeti_config_loader_spec.rb index 8d0098e0f..c9f03abdc 100644 --- a/spec/lib/yeti_config_loader_spec.rb +++ b/spec/lib/yeti_config_loader_spec.rb @@ -19,5 +19,13 @@ 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