Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement custom bootstrapping on startup #85

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
12 changes: 11 additions & 1 deletion README.md
Expand Up @@ -79,6 +79,7 @@ Argument | Description | Required? | Type | Default Value|
`backup_file` | Filename to store the last known state from the Unleash server. Best to not change this from the default. | N | String | `Dir.tmpdir + "/unleash-#{app_name}-repo.json` |
`logger` | Specify a custom `Logger` class to handle logs for the Unleash client. | N | Class | `Logger.new(STDOUT)` |
`log_level` | Change the log level for the `Logger` class. Constant from `Logger::Severity`. | N | Constant | `Logger::WARN` |
`bootstrapper` | Defines a bootstrapper object that unleash will use to get a list of toggles on load before it reads them from the unleash server. This is useful for loading large states on startup without hitting the network. Bootstrapping classes are provided for URL and file reading but you can implement your own for other sources of toggles. | N | Class | `nil` |

For in a more in depth look, please see `lib/unleash/configuration.rb`.

Expand All @@ -88,8 +89,14 @@ For in a more in depth look, please see `lib/unleash/configuration.rb`.
```ruby
require 'unleash'
require 'unleash/context'
require 'unleash/bootstrap'

@unleash = Unleash::Client.new(app_name: 'my_ruby_app', url: 'http://unleash.herokuapp.com/api', custom_http_headers: {'Authorization': '<API token>'})
@unleash = Unleash::Client.new(
app_name: 'my_ruby_app',
url: 'http://unleash.herokuapp.com/api',
custom_http_headers: { 'Authorization': '<API token>' },
bootstrapper: Unleash::FileBootStrapper.new('./default-toggles.json')
)

feature_name = "AwesomeFeature"
unleash_context = Unleash::Context.new
Expand All @@ -109,12 +116,15 @@ end
Put in `config/initializers/unleash.rb`:

```ruby
require 'unleash/bootstrap'

Unleash.configure do |config|
config.app_name = Rails.application.class.parent.to_s
config.url = 'http://unleash.herokuapp.com/api'
# config.instance_id = "#{Socket.gethostname}"
config.logger = Rails.logger
config.environment = Rails.env
config.bootstrapper = Unleash::FileBootStrapper.new('./default-toggles.json')
end

UNLEASH = Unleash::Client.new
Expand Down
50 changes: 50 additions & 0 deletions examples/bootstrap.rb
@@ -0,0 +1,50 @@
#!/usr/bin/env ruby

require 'unleash'
require 'unleash/context'
require 'unleash/bootstrap'

puts ">> START bootstrap.rb"

@unleash = Unleash::Client.new(
url: 'http://unleash.herokuapp.com/api',
custom_http_headers: { 'Authorization': '943ca9171e2c884c545c5d82417a655fb77cec970cc3b78a8ff87f4406b495d0' },
app_name: 'bootstrap-test',
instance_id: 'local-test-cli',
refresh_interval: 2,
metrics_interval: 2,
retry_limit: 2,
bootstrapper: Unleash::FileBootStrapper.new('./examples/default-toggles.json')
)

# feature_name = "AwesomeFeature"
feature_name = "featureX"
unleash_context = Unleash::Context.new
unleash_context.user_id = 123

sleep 1
3.times do
if @unleash.is_enabled?(feature_name, unleash_context)
puts "> #{feature_name} is enabled"
else
puts "> #{feature_name} is not enabled"
end
sleep 1
puts "---"
puts ""
puts ""
end

sleep 3
feature_name = "foobar"
if @unleash.is_enabled?(feature_name, unleash_context, true)
puts "> #{feature_name} is enabled"
else
puts "> #{feature_name} is not enabled"
end

puts "> shutting down client..."

@unleash.shutdown

puts ">> END bootstrap.rb"
43 changes: 43 additions & 0 deletions examples/default-toggles.json
@@ -0,0 +1,43 @@

rarruda marked this conversation as resolved.
Show resolved Hide resolved
{
"version": 1,
"features": [
{
"name": "featureX",
"enabled": true,
"strategies": [
{
"name": "default"
}
]
},
{
"name": "featureY",
"enabled": false,
"strategies": [
{
"name": "baz",
"parameters": {
"foo": "bar"
}
}
]

},
{
"name": "featureZ",
"enabled": true,
"strategies": [
{
"name": "default"
},
{
"name": "hola",
"parameters": {
"name": "val"
}
}
]

}
]}
rarruda marked this conversation as resolved.
Show resolved Hide resolved
55 changes: 55 additions & 0 deletions lib/unleash/bootstrap.rb
@@ -0,0 +1,55 @@
require 'unleash/configuration'
require 'unleash/feature_toggle'
require 'logger'
require 'time'
require 'net/http'
require 'uri'

module Unleash
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to also see a data bootstrapper out of the box.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, this is now possible after #86 got merged.

The main concern is that the data would need to be serialized in JSON first (to only then be de-serialized).

Having an extra Unleash::Bootstrap::FromData class doesn't seem like a bad idea though.

class FileBootStrapper
attr_accessor :file_path

def initialize(file_path)
self.file_path = file_path
end

def read
file_content = File.read(self.file_path)
bootstrap_hash = JSON.parse(file_content)
Unleash.extract_bootstrap(bootstrap_hash)
end
end

class UrlBootStrapper
attr_accessor :uri, :headers

def initialize(uri, headers)
self.uri = URI(uri)
self.headers = headers
end

def read
request = Net::HTTP::Get.new(self.uri, self.build_headers(self.headers))
rarruda marked this conversation as resolved.
Show resolved Hide resolved

http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true if uri.scheme == 'https'
http.open_timeout = Unleash.configuration.timeout
http.read_timeout = Unleash.configuration.timeout

http.request(request)
end

def build_headers(headers = nil)
headers = (headers || {}).dup
headers['Content-Type'] = 'application/json'

headers
end
end

def self.extract_bootstrap(bootstrap_hash)
raise NotImplemented, "The provided bootstrap doesn't seem to be a valid set of toggles" if bootstrap_hash['version'] < 1
rarruda marked this conversation as resolved.
Show resolved Hide resolved

bootstrap_hash['features']
end
end
4 changes: 3 additions & 1 deletion lib/unleash/configuration.rb
Expand Up @@ -18,7 +18,8 @@ class Configuration
:metrics_interval,
:backup_file,
:logger,
:log_level
:log_level,
:bootstrapper

def initialize(opts = {})
ensure_valid_opts(opts)
Expand Down Expand Up @@ -92,6 +93,7 @@ def set_defaults
self.retry_limit = 5
self.backup_file = nil
self.log_level = Logger::WARN
self.bootstrapper = nil

self.custom_http_headers = {}
end
Expand Down
12 changes: 9 additions & 3 deletions lib/unleash/toggle_fetcher.rb
Expand Up @@ -4,20 +4,26 @@

module Unleash
class ToggleFetcher
attr_accessor :toggle_cache, :toggle_lock, :toggle_resource, :etag, :retry_count
attr_accessor :toggle_cache, :toggle_lock, :toggle_resource, :etag, :retry_count, :bootstrapper

def initialize
self.etag = nil
self.toggle_cache = nil
self.toggle_lock = Mutex.new
self.toggle_resource = ConditionVariable.new
self.retry_count = 0
self.bootstrapper = Unleash.configuration.bootstrapper

# start by fetching synchronously, and failing back to reading the backup file.
begin
fetch
if !self.bootstrapper.nil? # if the consumer provides a bootstrapper, we're going to assume they want to use it
rarruda marked this conversation as resolved.
Show resolved Hide resolved
synchronize_with_local_cache! self.bootstrapper.read
update_running_client!
else
fetch
end
rescue StandardError => e
Unleash.logger.warn "ToggleFetcher was unable to fetch from the network, attempting to read from backup file."
Unleash.logger.warn "ToggleFetcher was unable to fetch from the network or bootstrap, attempting to read from backup file."
Unleash.logger.debug "Exception Caught: #{e}"
read!
end
Expand Down
43 changes: 43 additions & 0 deletions spec/unleash/bootstrap-resources/features-v1.json
@@ -0,0 +1,43 @@

{
"version": 1,
"features": [
{
"name": "featureX",
"enabled": true,
"strategies": [
{
"name": "default"
}
]
},
{
"name": "featureY",
"enabled": false,
"strategies": [
{
"name": "baz",
"parameters": {
"foo": "bar"
}
}
]

},
{
"name": "featureZ",
"enabled": true,
"strategies": [
{
"name": "default"
},
{
"name": "hola",
"parameters": {
"name": "val"
}
}
]

}
]}
35 changes: 35 additions & 0 deletions spec/unleash/bootstrap_spec.rb
@@ -0,0 +1,35 @@
require 'spec_helper'
require 'unleash/constraint'
require 'unleash/bootstrap'
require 'json'

RSpec.describe Unleash::Client do
before do
Unleash.configuration = Unleash::Configuration.new
Unleash.logger = Unleash.configuration.logger
end

describe 'Bootstrap' do
it 'loads bootstrap toggle correctly from file' do
bootstrapper = Unleash::FileBootStrapper.new('./spec/unleash/bootstrap-resources/features-v1.json')
bootstrapper.read
end

it 'loads bootstrap toggle correctly from URL' do
WebMock.stub_request(:get, "http://test-url/bootstrap-goodness")
.with(
headers: {
'Accept' => '*/*',
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
'Content-Type' => 'application/json',
'Host' => 'test-url',
'User-Agent' => 'Ruby'
}
)
.to_return(status: 200, body: "", headers: {})

bootstrapper = Unleash::UrlBootStrapper.new('http://test-url/bootstrap-goodness', nil)
bootstrapper.read
end
end
end