ibm_appconfiguration_ruby_sdk 0.1.0.pre.rc.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +76 -0
- data/CONTRIBUTING.md +9 -0
- data/LICENSE +201 -0
- data/README.md +474 -0
- data/Rakefile +8 -0
- data/examples/README.md +60 -0
- data/examples/app.rb +104 -0
- data/lib/ibm_appconfiguration_ruby_sdk/app_configuration.rb +291 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/configuration_handler.rb +828 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/constants.rb +89 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/file_manager.rb +72 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/logger.rb +98 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/background_retry_manager.rb +284 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/config_fetcher.rb +254 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/utils.rb +240 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connection_manager.rb +501 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/connectivity.rb +30 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/driver_socket.rb +28 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/retry_policy.rb +42 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/state.rb +24 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/watchdog.rb +50 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/websocket_client/websocket_client.rb +43 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/feature.rb +121 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/property.rb +107 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/rule.rb +87 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/secret_property.rb +81 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment.rb +39 -0
- data/lib/ibm_appconfiguration_ruby_sdk/configurations/models/segment_rules.rb +57 -0
- data/lib/ibm_appconfiguration_ruby_sdk/core/api_manager.rb +269 -0
- data/lib/ibm_appconfiguration_ruby_sdk/core/metering.rb +400 -0
- data/lib/ibm_appconfiguration_ruby_sdk/core/url_builder.rb +252 -0
- data/lib/ibm_appconfiguration_ruby_sdk/version.rb +20 -0
- data/lib/ibm_appconfiguration_ruby_sdk.rb +20 -0
- data/sig/ibm_appconfiguration_ruby_sdk.rbs +4 -0
- metadata +209 -0
data/lib/ibm_appconfiguration_ruby_sdk/configurations/internal/retry_manager/config_fetcher.rb
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Copyright 2026 IBM Corp. All Rights Reserved.
|
|
4
|
+
#
|
|
5
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
# you may not use this file except in compliance with the License.
|
|
7
|
+
# You may obtain a copy of the License at
|
|
8
|
+
#
|
|
9
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
#
|
|
11
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
12
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
13
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
14
|
+
# See the License for the specific language governing permissions and
|
|
15
|
+
# limitations under the License.
|
|
16
|
+
|
|
17
|
+
require_relative "../../../core/api_manager"
|
|
18
|
+
require_relative "../../../core/url_builder"
|
|
19
|
+
require_relative "../utils"
|
|
20
|
+
require_relative "../logger"
|
|
21
|
+
require_relative "../../models/feature"
|
|
22
|
+
require_relative "../../models/property"
|
|
23
|
+
require_relative "../../models/segment"
|
|
24
|
+
require_relative "../../configuration_handler"
|
|
25
|
+
|
|
26
|
+
# ConfigFetcher
|
|
27
|
+
#
|
|
28
|
+
# Handles fetching configurations from the App Configuration API.
|
|
29
|
+
# This class encapsulates all API call logic and response handling.
|
|
30
|
+
#
|
|
31
|
+
class ConfigFetcher
|
|
32
|
+
# Initialize the config fetcher
|
|
33
|
+
#
|
|
34
|
+
# @param collection_id [String] Collection ID for API request
|
|
35
|
+
# @param environment_id [String] Environment ID for API request
|
|
36
|
+
# @param logger [Logger] Optional logger instance
|
|
37
|
+
def initialize(collection_id:, environment_id:, logger: nil)
|
|
38
|
+
@collection_id = collection_id
|
|
39
|
+
@environment_id = environment_id
|
|
40
|
+
@logger = logger || Logger.instance
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Fetch configuration from API
|
|
44
|
+
#
|
|
45
|
+
# Makes a direct API call to the /config endpoint
|
|
46
|
+
# Returns a hash with status information
|
|
47
|
+
#
|
|
48
|
+
# @return [Hash] Result hash with :ok, :retryable, :status, and :data keys
|
|
49
|
+
def fetch
|
|
50
|
+
# Get the BaseService client
|
|
51
|
+
client = ApiManager.base_service_client
|
|
52
|
+
url_builder = UrlBuilder.instance
|
|
53
|
+
|
|
54
|
+
# Build the API endpoint URL
|
|
55
|
+
api_path = "/apprapp/feature/v1/instances/#{url_builder.guid}/config"
|
|
56
|
+
|
|
57
|
+
@logger.info("📡 Calling API: #{url_builder.base_service_url}#{api_path}")
|
|
58
|
+
|
|
59
|
+
# Make the API request
|
|
60
|
+
response = client.request(
|
|
61
|
+
method: "GET",
|
|
62
|
+
url: api_path,
|
|
63
|
+
headers: ApiManager.headers,
|
|
64
|
+
params: {
|
|
65
|
+
action: "sdkConfig",
|
|
66
|
+
collection_id: @collection_id,
|
|
67
|
+
environment_id: @environment_id
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Success case
|
|
72
|
+
if response.status == 200
|
|
73
|
+
@logger.info("✓ API call successful (200)")
|
|
74
|
+
{
|
|
75
|
+
ok: true,
|
|
76
|
+
retryable: false,
|
|
77
|
+
status: 200,
|
|
78
|
+
data: response.result
|
|
79
|
+
}
|
|
80
|
+
else
|
|
81
|
+
# Unexpected status code
|
|
82
|
+
@logger.warn("⚠️ Unexpected status code: #{response.status}")
|
|
83
|
+
{
|
|
84
|
+
ok: false,
|
|
85
|
+
retryable: true,
|
|
86
|
+
status: response.status,
|
|
87
|
+
data: nil
|
|
88
|
+
}
|
|
89
|
+
end
|
|
90
|
+
rescue IBMCloudSdkCore::ApiException => e
|
|
91
|
+
status_code = e.code.to_i
|
|
92
|
+
@logger.error("❌ API Exception: #{e.message} (Status: #{status_code})")
|
|
93
|
+
|
|
94
|
+
# Determine if error is retryable
|
|
95
|
+
# Non-retryable: 4xx except 429
|
|
96
|
+
# Retryable: 429, 5xx
|
|
97
|
+
retryable = status_code == 429 || status_code >= 500
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
ok: false,
|
|
101
|
+
retryable: retryable,
|
|
102
|
+
status: status_code,
|
|
103
|
+
data: nil
|
|
104
|
+
}
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
@logger.error("❌ Unexpected error: #{e.message}")
|
|
107
|
+
@logger.error(e.backtrace.first(3).join("\n"))
|
|
108
|
+
|
|
109
|
+
# Treat unexpected errors as retryable
|
|
110
|
+
{
|
|
111
|
+
ok: false,
|
|
112
|
+
retryable: true,
|
|
113
|
+
status: 500,
|
|
114
|
+
data: nil
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Display API response in a readable format
|
|
119
|
+
#
|
|
120
|
+
# @param data [Hash] API response data
|
|
121
|
+
def display_response(data)
|
|
122
|
+
# require 'json'
|
|
123
|
+
|
|
124
|
+
# @logger.info("\n📊 API Response Summary:")
|
|
125
|
+
# @logger.info("-" * 80)
|
|
126
|
+
|
|
127
|
+
# # Display features
|
|
128
|
+
# if data['features'] && data['features'].any?
|
|
129
|
+
# @logger.info("\n📋 Features (#{data['features'].length}):")
|
|
130
|
+
# data['features'].each_with_index do |feature, index|
|
|
131
|
+
# @logger.info(" #{index + 1}. #{feature['name']} (#{feature['feature_id']})")
|
|
132
|
+
# @logger.info(" Type: #{feature['type']}")
|
|
133
|
+
# @logger.info(" Enabled: #{feature['enabled']}")
|
|
134
|
+
# if feature['segment_rules'] && feature['segment_rules'].any?
|
|
135
|
+
# @logger.info(" Segment Rules: #{feature['segment_rules'].length}")
|
|
136
|
+
# end
|
|
137
|
+
# end
|
|
138
|
+
# else
|
|
139
|
+
# @logger.info("\n📋 Features: None")
|
|
140
|
+
# end
|
|
141
|
+
|
|
142
|
+
# # Display properties
|
|
143
|
+
# if data['properties'] && data['properties'].any?
|
|
144
|
+
# @logger.info("\n🔧 Properties (#{data['properties'].length}):")
|
|
145
|
+
# data['properties'].each_with_index do |property, index|
|
|
146
|
+
# @logger.info(" #{index + 1}. #{property['name']} (#{property['property_id']})")
|
|
147
|
+
# @logger.info(" Type: #{property['type']}")
|
|
148
|
+
# @logger.info(" Value: #{property['value']}")
|
|
149
|
+
# if property['segment_rules'] && property['segment_rules'].any?
|
|
150
|
+
# @logger.info(" Segment Rules: #{property['segment_rules'].length}")
|
|
151
|
+
# end
|
|
152
|
+
# end
|
|
153
|
+
# else
|
|
154
|
+
# @logger.info("\n🔧 Properties: None")
|
|
155
|
+
# end
|
|
156
|
+
|
|
157
|
+
# # Display segments
|
|
158
|
+
# if data['segments'] && data['segments'].any?
|
|
159
|
+
# @logger.info("\n👥 Segments (#{data['segments'].length}):")
|
|
160
|
+
# data['segments'].each_with_index do |segment, index|
|
|
161
|
+
# @logger.info(" #{index + 1}. #{segment['name']} (#{segment['segment_id']})")
|
|
162
|
+
# if segment['rules'] && segment['rules'].any?
|
|
163
|
+
# @logger.info(" Rules: #{segment['rules'].length}")
|
|
164
|
+
# end
|
|
165
|
+
# end
|
|
166
|
+
# else
|
|
167
|
+
# @logger.info("\n👥 Segments: None")
|
|
168
|
+
# end
|
|
169
|
+
|
|
170
|
+
# @logger.info("\n📄 Full JSON Response:")
|
|
171
|
+
# @logger.info("-" * 80)
|
|
172
|
+
# @logger.info(JSON.pretty_generate(data))
|
|
173
|
+
# @logger.info("=" * 80)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Process API response and load to cache
|
|
177
|
+
#
|
|
178
|
+
# This method:
|
|
179
|
+
# 1. Takes the raw API response
|
|
180
|
+
# 2. Calls extract_configurations to parse and validate the data
|
|
181
|
+
# 3. Calls load_configurations_to_cache to store in cache
|
|
182
|
+
#
|
|
183
|
+
# @param api_response [Hash] Raw API response data
|
|
184
|
+
# @return [Boolean] true if processing was successful, false otherwise
|
|
185
|
+
def process_and_load_configurations(api_response)
|
|
186
|
+
return false unless api_response
|
|
187
|
+
|
|
188
|
+
begin
|
|
189
|
+
@logger.info("🔄 Processing API response...")
|
|
190
|
+
|
|
191
|
+
# Convert string keys to symbol keys if needed
|
|
192
|
+
symbolized_data = symbolize_keys(api_response)
|
|
193
|
+
|
|
194
|
+
# Extract configurations using utils.rb method
|
|
195
|
+
# This validates the data and extracts only the relevant features, properties, and segments
|
|
196
|
+
# for the specified environment and collection
|
|
197
|
+
extracted_config = extract_configurations(
|
|
198
|
+
symbolized_data,
|
|
199
|
+
@environment_id,
|
|
200
|
+
@collection_id
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
@logger.info("✓ Configurations extracted successfully")
|
|
204
|
+
@logger.info(" Features: #{extracted_config[:features]&.length || 0}")
|
|
205
|
+
@logger.info(" Properties: #{extracted_config[:properties]&.length || 0}")
|
|
206
|
+
@logger.info(" Segments: #{extracted_config[:segments]&.length || 0}")
|
|
207
|
+
|
|
208
|
+
# Load the extracted configurations to cache
|
|
209
|
+
success = load_configurations_to_cache(extracted_config)
|
|
210
|
+
|
|
211
|
+
if success
|
|
212
|
+
@logger.info("✅ Configurations processed and loaded successfully")
|
|
213
|
+
else
|
|
214
|
+
@logger.error("❌ Failed to load configurations to cache")
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
success
|
|
218
|
+
rescue StandardError => e
|
|
219
|
+
@logger.error("❌ Error processing API response: #{e.message}")
|
|
220
|
+
@logger.error(e.backtrace.first(5).join("\n"))
|
|
221
|
+
false
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Load configurations to cache
|
|
226
|
+
#
|
|
227
|
+
# Delegates to ConfigurationHandler singleton to maintain a single source of truth.
|
|
228
|
+
# This ensures all parts of the application use the same cache.
|
|
229
|
+
#
|
|
230
|
+
# @param data [Hash] Configuration data with :features, :properties, and :segments keys
|
|
231
|
+
# @return [Boolean] true if configurations were loaded successfully
|
|
232
|
+
def load_configurations_to_cache(data)
|
|
233
|
+
return false unless data
|
|
234
|
+
|
|
235
|
+
begin
|
|
236
|
+
@logger.info("📦 Loading configurations to ConfigurationHandler cache...")
|
|
237
|
+
|
|
238
|
+
# Delegate to ConfigurationHandler singleton (single source of truth)
|
|
239
|
+
handler = ConfigurationHandler.instance
|
|
240
|
+
handler.load_configurations_to_cache(data)
|
|
241
|
+
|
|
242
|
+
@logger.info("✅ Configurations loaded to cache successfully")
|
|
243
|
+
@logger.info(" ✓ Features: #{data[:features]&.length || 0}")
|
|
244
|
+
@logger.info(" ✓ Properties: #{data[:properties]&.length || 0}")
|
|
245
|
+
@logger.info(" ✓ Segments: #{data[:segments]&.length || 0}")
|
|
246
|
+
|
|
247
|
+
true
|
|
248
|
+
rescue StandardError => e
|
|
249
|
+
@logger.error("❌ Error loading configurations to cache: #{e.message}")
|
|
250
|
+
@logger.error(e.backtrace.first(5).join("\n"))
|
|
251
|
+
false
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
# Copyright 2026 IBM Corp. All Rights Reserved.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
# frozen_string_literal: true
|
|
16
|
+
|
|
17
|
+
require "murmurhash3"
|
|
18
|
+
|
|
19
|
+
# Validates feature/property belongs to collection if it contains collections else gives true as default
|
|
20
|
+
# @param resource [Hash] The resource (feature or property)
|
|
21
|
+
# @param collection [String] The collection ID
|
|
22
|
+
# @return [Boolean]
|
|
23
|
+
def validate_resource(resource, collection)
|
|
24
|
+
# If collections is not present the resource data is coming from SDK APIs
|
|
25
|
+
return true unless resource.key?(:collections)
|
|
26
|
+
|
|
27
|
+
collections = resource[:collections]
|
|
28
|
+
raise "Improper collection format in resource data" unless collections.is_a?(Array)
|
|
29
|
+
|
|
30
|
+
collections.any? { |coll| coll[:collection_id] == collection }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Appends segment ids to the provided set
|
|
34
|
+
# @param resource [Hash] The resource (feature or property)
|
|
35
|
+
# @param segment_ids [Set] Set to store segment IDs
|
|
36
|
+
def append_segment_id(resource, segment_ids)
|
|
37
|
+
return unless resource[:segment_rules]
|
|
38
|
+
|
|
39
|
+
resource[:segment_rules].each do |segment_rule|
|
|
40
|
+
segment_rule[:rules].each do |rule|
|
|
41
|
+
rule[:segments].each do |segment_id|
|
|
42
|
+
segment_ids.add(segment_id)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Prepares config data for extraction with validation
|
|
49
|
+
# @param data [Hash] Configuration data
|
|
50
|
+
# @param environment_id [String] Environment ID
|
|
51
|
+
# @return [Hash] Hash containing features, properties, and segments
|
|
52
|
+
def extract_environment_data(data, environment_id)
|
|
53
|
+
unless data.key?(:segments) && data[:segments].is_a?(Array) &&
|
|
54
|
+
data.key?(:environments) && data[:environments].is_a?(Array)
|
|
55
|
+
raise "Improper Data format present in configuration"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
data[:environments].each do |environment|
|
|
59
|
+
next unless environment[:environment_id] == environment_id
|
|
60
|
+
|
|
61
|
+
result = {
|
|
62
|
+
features: environment[:features] || [],
|
|
63
|
+
properties: environment[:properties] || [],
|
|
64
|
+
segments: data[:segments]
|
|
65
|
+
}
|
|
66
|
+
puts "🔍 extract_environment_data: Found environment '#{environment_id}'"
|
|
67
|
+
puts " Features: #{result[:features].length}"
|
|
68
|
+
puts " Properties: #{result[:properties].length}"
|
|
69
|
+
puts " Segments: #{result[:segments].length}"
|
|
70
|
+
return result
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
raise "Matching environment not found in configuration"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Returns object containing features, properties, segments after validation
|
|
77
|
+
# @param resource_data [Hash] Resource data containing features, properties, and segments
|
|
78
|
+
# @param collection [String] Collection ID
|
|
79
|
+
# @return [Hash] Hash containing validated features, properties, and segments
|
|
80
|
+
def extract_resources(resource_data, collection)
|
|
81
|
+
features = []
|
|
82
|
+
properties = []
|
|
83
|
+
segments = []
|
|
84
|
+
segment_ids = Set.new
|
|
85
|
+
|
|
86
|
+
puts "🔍 DEBUG extract_resources:"
|
|
87
|
+
puts " Features in resource_data: #{resource_data[:features]&.length || 0}"
|
|
88
|
+
puts " Properties in resource_data: #{resource_data[:properties]&.length || 0}"
|
|
89
|
+
puts " Collection to match: #{collection}"
|
|
90
|
+
|
|
91
|
+
# Appending features with validation to features array
|
|
92
|
+
resource_data[:features].each do |feature|
|
|
93
|
+
valid = validate_resource(feature, collection)
|
|
94
|
+
if valid
|
|
95
|
+
append_segment_id(feature, segment_ids)
|
|
96
|
+
features << feature
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Appending properties with validation to properties array
|
|
101
|
+
resource_data[:properties].each do |property|
|
|
102
|
+
valid = validate_resource(property, collection)
|
|
103
|
+
if valid
|
|
104
|
+
append_segment_id(property, segment_ids)
|
|
105
|
+
properties << property
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Appending only required segments to segments array and throw error if any required segment is absent
|
|
110
|
+
resource_data[:segments].each do |segment|
|
|
111
|
+
if segment_ids.include?(segment[:segment_id])
|
|
112
|
+
segments << segment
|
|
113
|
+
segment_ids.delete(segment[:segment_id])
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
raise "Required segment doesn't exist in provided segments" if segment_ids.size.positive?
|
|
118
|
+
|
|
119
|
+
{
|
|
120
|
+
features: features,
|
|
121
|
+
properties: properties,
|
|
122
|
+
segments: segments
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Unified parser for app-config data for new sdk-config format, export and promote data format
|
|
127
|
+
# @param configurations [Hash] Configuration JSON data (with symbol keys)
|
|
128
|
+
# @param environment [String] Environment ID
|
|
129
|
+
# @param collection [String] Collection ID
|
|
130
|
+
# @return [Hash] Hash containing features, properties, and segments
|
|
131
|
+
def extract_configurations(configurations, environment, collection)
|
|
132
|
+
puts "🔍 extract_configurations called"
|
|
133
|
+
puts " Environment: #{environment}"
|
|
134
|
+
puts " Collection: #{collection}"
|
|
135
|
+
|
|
136
|
+
# Check if data belongs to correct collection
|
|
137
|
+
raise "Improper/Missing collections in configuration" unless configurations.key?(:collections) && configurations[:collections].is_a?(Array)
|
|
138
|
+
|
|
139
|
+
match_found = false
|
|
140
|
+
configurations[:collections].each do |coll|
|
|
141
|
+
puts " Checking collection: #{coll[:collection_id]}"
|
|
142
|
+
if coll[:collection_id] == collection
|
|
143
|
+
match_found = true
|
|
144
|
+
break
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
raise "Required collection not found in collections" unless match_found
|
|
149
|
+
|
|
150
|
+
puts " Collection match found!"
|
|
151
|
+
|
|
152
|
+
# Data in SDK config/export/promote format
|
|
153
|
+
config_data = extract_environment_data(configurations, environment)
|
|
154
|
+
puts " After extract_environment_data: features=#{config_data[:features]&.length}"
|
|
155
|
+
|
|
156
|
+
result = extract_resources(config_data, collection)
|
|
157
|
+
puts " After extract_resources: features=#{result[:features]&.length}"
|
|
158
|
+
|
|
159
|
+
result
|
|
160
|
+
rescue StandardError => e
|
|
161
|
+
puts "❌ ERROR in extract_configurations: #{e.message}"
|
|
162
|
+
puts e.backtrace.first(5).join("\n")
|
|
163
|
+
raise "Extraction of configurations failed with error:\n #{e.message}"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Helper method to convert string keys to symbol keys recursively
|
|
167
|
+
# @param obj [Object] The object to convert
|
|
168
|
+
# @return [Object] The converted object with symbol keys
|
|
169
|
+
def symbolize_keys(obj)
|
|
170
|
+
case obj
|
|
171
|
+
when Hash
|
|
172
|
+
obj.each_with_object({}) do |(key, value), result|
|
|
173
|
+
result[key.to_sym] = symbolize_keys(value)
|
|
174
|
+
end
|
|
175
|
+
when Array
|
|
176
|
+
obj.map { |item| symbolize_keys(item) }
|
|
177
|
+
else
|
|
178
|
+
obj
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
##
|
|
183
|
+
# Compute hash using MurmurHash3
|
|
184
|
+
# @param str [String] String to hash
|
|
185
|
+
# @return [Integer] Hash value
|
|
186
|
+
def compute_hash(str)
|
|
187
|
+
seed = 0
|
|
188
|
+
MurmurHash3::V32.str_hash(str, seed)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
##
|
|
192
|
+
# Get normalized value for rollout percentage calculation
|
|
193
|
+
# @param str [String] String to normalize
|
|
194
|
+
# @return [Integer] Normalized value (0-100)
|
|
195
|
+
def get_normalized_value(str)
|
|
196
|
+
max_hash_value = 2**32
|
|
197
|
+
normalizer = 100
|
|
198
|
+
((compute_hash(str).to_f / max_hash_value) * normalizer).floor
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
##
|
|
202
|
+
# Parse progressive rollout phases into a sorted hash for timestamp-to-percentage lookups.
|
|
203
|
+
# @param configuration [Hash] Rollout config with start_at and phases
|
|
204
|
+
# @return [Hash] Sorted hash mapping timestamp (ms) -> percentage
|
|
205
|
+
# @raise [ArgumentError] If configuration is invalid
|
|
206
|
+
def parse_rollout_configuration_phases(configuration)
|
|
207
|
+
# Validate input
|
|
208
|
+
raise ArgumentError.new("Invalid rollout configuration") unless configuration&.key?(:start_at) && configuration[:phases].is_a?(Array)
|
|
209
|
+
|
|
210
|
+
# Time unit multipliers (to milliseconds)
|
|
211
|
+
multipliers = {
|
|
212
|
+
"days" => 86_400_000,
|
|
213
|
+
"hours" => 3_600_000,
|
|
214
|
+
"minutes" => 60_000
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
# Parse start timestamp
|
|
218
|
+
begin
|
|
219
|
+
start_timestamp = Time.parse(configuration[:start_at]).to_i * 1000 # Convert to milliseconds
|
|
220
|
+
rescue ArgumentError
|
|
221
|
+
raise ArgumentError.new("Invalid start_at: #{configuration[:start_at]}")
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Initialize result hash with initial entry
|
|
225
|
+
result = { 0 => 0 }
|
|
226
|
+
transition_time = start_timestamp
|
|
227
|
+
|
|
228
|
+
# Process each phase
|
|
229
|
+
configuration[:phases].each do |phase|
|
|
230
|
+
next unless phase.is_a?(Hash) && phase.key?(:percentage) && phase[:percentage].is_a?(Numeric)
|
|
231
|
+
|
|
232
|
+
result[transition_time] = phase[:percentage]
|
|
233
|
+
|
|
234
|
+
# Calculate next transition time if duration is specified
|
|
235
|
+
transition_time += multipliers[phase[:duration_type]] * phase[:duration] if phase[:duration] && phase[:duration_type] && multipliers[phase[:duration_type]]
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Return sorted hash (Ruby hashes maintain insertion order, so we sort by key)
|
|
239
|
+
result.sort.to_h
|
|
240
|
+
end
|