Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 3 additions & 147 deletions config/initializers/config.rb
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions lib/prometheus/cdr_compaction_hook_collector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 1 addition & 6 deletions lib/prometheus/collector_labels.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,11 @@
module CollectorLabels
module_function

# @return [Hash<String, String>] empty when YetiConfig is unavailable or defines no default_labels
# @return [Hash<String, String>] 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
6 changes: 4 additions & 2 deletions lib/prometheus/partition_remove_hook_collector.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
32 changes: 17 additions & 15 deletions lib/yeti_config_loader.rb
Original file line number Diff line number Diff line change
@@ -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}"
Comment on lines +34 to +35
end
end
Loading
Loading