fake_florence 1.0.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/LICENSE.md +21 -0
- data/README.md +51 -0
- data/exe/flo +6 -0
- data/lib/fake_florence/announcers/routemaster.rb +51 -0
- data/lib/fake_florence/announcers.rb +15 -0
- data/lib/fake_florence/cli.rb +174 -0
- data/lib/fake_florence/config.rb +78 -0
- data/lib/fake_florence/daemon.rb +66 -0
- data/lib/fake_florence/feature.rb +30 -0
- data/lib/fake_florence/feature_cache.rb +75 -0
- data/lib/fake_florence/feature_schema.rb +28 -0
- data/lib/fake_florence/retriever.rb +47 -0
- data/lib/fake_florence/server.rb +66 -0
- data/lib/fake_florence/version.rb +3 -0
- data/lib/fake_florence.rb +14 -0
- data/templates/config.yaml +18 -0
- data/templates/feature.yaml +58 -0
- metadata +190 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA1:
|
|
3
|
+
metadata.gz: 6d2c5123d928582a6c986f0243198c4c4efecdf0
|
|
4
|
+
data.tar.gz: ec1eb451cc86867f18fc23cb37bc226a570d661d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: ca69fa7eb6684c6f72401f27ef2c888c44a1c9560fca534bfcc9b62b747ccba271e6af3726587e0b6c4e6ccb6a216114f3e0a01aed49614a8dc27c2699b5351f
|
|
7
|
+
data.tar.gz: c02fdd8aaebffec882f0a9a368825376355140f3bb4cb8199ecf1ee36978a62d59d7ae772eb952107b1b9f54f98f02b58a37e40d258b42f88e54eb0371c51343
|
data/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2017 JP Hastings-Spital
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Fake Florence
|
|
2
|
+
|
|
3
|
+
A command line application, bundled as a gem, which provides a stub interface for [Determinator](https://github.com/deliveroo/determinator) to read from.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install this gem with:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
$ gem install fake_florence
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
Basic usage of the `flo` command line gem looks like this:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
$ flo start
|
|
19
|
+
Flo now is running at https://flo.dev
|
|
20
|
+
Use other commands to create or edit Feature flags and Experiments.
|
|
21
|
+
See `flo help` for more information
|
|
22
|
+
|
|
23
|
+
$ flo create my_experiment
|
|
24
|
+
my_experiment created and opened for editing
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The default config expects that you have [puma-dev](https://github.com/puma/puma-dev) running, and https://flo.dev pointing to port 35600. You can do this with the command:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
$ echo 35600 > ~/.puma-dev/flo
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Once the Fake Florence server is running, point your instance of determinator at Flo and run `flo announce` to announce all the fake features you currently have specified.
|
|
34
|
+
|
|
35
|
+
## Planned features
|
|
36
|
+
|
|
37
|
+
- Better logging
|
|
38
|
+
- Logging of requests for features received by the server
|
|
39
|
+
- Tests
|
|
40
|
+
|
|
41
|
+
## Contributing
|
|
42
|
+
|
|
43
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/deliveroo/fake_florence. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT).
|
|
48
|
+
|
|
49
|
+
## Code of Conduct
|
|
50
|
+
|
|
51
|
+
Everyone interacting in the FakeFlorence project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/[USERNAME]/fake_florence/blob/master/CODE_OF_CONDUCT.md).
|
data/exe/flo
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
|
|
3
|
+
module FakeFlorence
|
|
4
|
+
module Announcers
|
|
5
|
+
class Routemaster
|
|
6
|
+
attr_reader :name
|
|
7
|
+
|
|
8
|
+
def initialize(url, name: nil)
|
|
9
|
+
@name = name
|
|
10
|
+
@http = Faraday.new(url: url) do |f|
|
|
11
|
+
f.use Faraday::Response::RaiseError
|
|
12
|
+
f.request :json
|
|
13
|
+
f.adapter Faraday.default_adapter
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def announce(hash)
|
|
18
|
+
t = Time.now.to_i
|
|
19
|
+
|
|
20
|
+
events = []
|
|
21
|
+
hash.each_pair do |type, features|
|
|
22
|
+
features.each do |feature|
|
|
23
|
+
events.push(
|
|
24
|
+
topic: 'feature',
|
|
25
|
+
type: type,
|
|
26
|
+
url: Config.url_for(feature),
|
|
27
|
+
t: t
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
res = @http.post do |req|
|
|
33
|
+
req.body = events.to_json
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
count = events.size
|
|
37
|
+
counter = "#{count} feature#{count == 1 ? '' : 's'}"
|
|
38
|
+
Config.log.info "#{counter} announced to '#{@name}'."
|
|
39
|
+
true
|
|
40
|
+
|
|
41
|
+
rescue Faraday::ConnectionFailed => e
|
|
42
|
+
Config.log.error e.message
|
|
43
|
+
false
|
|
44
|
+
|
|
45
|
+
rescue Faraday::Error::ClientError => e
|
|
46
|
+
Config.log.warn "Could not announce to #{@name}: HTTP #{e.response[:status]}"
|
|
47
|
+
false
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module FakeFlorence
|
|
2
|
+
module Announcers
|
|
3
|
+
def self.load(config_array)
|
|
4
|
+
config_array.map do |config|
|
|
5
|
+
case config['type']
|
|
6
|
+
when 'routemaster'
|
|
7
|
+
require 'fake_florence/announcers/routemaster'
|
|
8
|
+
Routemaster.new(config['url'], name: config['name'])
|
|
9
|
+
else
|
|
10
|
+
raise "Unsupported announcer: #{config}"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
require 'thor'
|
|
2
|
+
|
|
3
|
+
module FakeFlorence
|
|
4
|
+
class Cli < Thor
|
|
5
|
+
include Thor::Actions
|
|
6
|
+
|
|
7
|
+
def self.source_root
|
|
8
|
+
File.join(__dir__, '../../templates')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def help
|
|
12
|
+
puts <<~MSG
|
|
13
|
+
Fake Florence v#{VERSION} (#{status_text})
|
|
14
|
+
|
|
15
|
+
This is a command line application which will help you with development of Florence & Determinator based experiments and feature flags.
|
|
16
|
+
Find out more about specific commands with `#{set_color 'flo help [COMMAND]', :blue}`, but you're probably looking for:
|
|
17
|
+
|
|
18
|
+
#{set_color '$ flo start', :blue}
|
|
19
|
+
#{set_color '$ flo create my_experiment', :blue}
|
|
20
|
+
|
|
21
|
+
MSG
|
|
22
|
+
super
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
desc 'start', 'Starts the Florence server'
|
|
26
|
+
long_desc <<~MSG
|
|
27
|
+
Begins a Fake Florence webserver bound to the address and port given in the config file.
|
|
28
|
+
Typical usage is to run `flo start` then use `flo create` and `flo edit` to configure your features.
|
|
29
|
+
MSG
|
|
30
|
+
|
|
31
|
+
method_option(:daemonize,
|
|
32
|
+
aliases: '-d',
|
|
33
|
+
type: :boolean,
|
|
34
|
+
default: true,
|
|
35
|
+
desc: 'Run in the background'
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def start
|
|
39
|
+
if daemon.running?
|
|
40
|
+
abort 'Flo is already running.'
|
|
41
|
+
else
|
|
42
|
+
puts <<~MSG
|
|
43
|
+
Flo is now #{set_color 'running', :green} at #{set_color Config.base_url, :white}
|
|
44
|
+
Use other commands to create or edit Feature flags and Experiments.
|
|
45
|
+
See `#{set_color 'flo help', :blue}` for more information
|
|
46
|
+
MSG
|
|
47
|
+
|
|
48
|
+
daemon.start(daemonize: options[:daemonize])
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc 'stop', 'Stops the Florence server'
|
|
53
|
+
long_desc <<~MSG
|
|
54
|
+
Stops any daemon or foregrounded copy of Fake Florence running on this machine.
|
|
55
|
+
This uses the PID file in the config directory for reference, so its location has been changed versions of Fake Florence may still be running.
|
|
56
|
+
MSG
|
|
57
|
+
|
|
58
|
+
def stop
|
|
59
|
+
if daemon.running?
|
|
60
|
+
daemon.stop
|
|
61
|
+
puts "Flo is now #{set_color 'stopped', :red}."
|
|
62
|
+
else
|
|
63
|
+
abort 'Flo was not running.'
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
desc 'status', 'The status of the Florence server'
|
|
68
|
+
long_desc <<~MSG
|
|
69
|
+
Declares whether Fake Florence is running or not.
|
|
70
|
+
MSG
|
|
71
|
+
|
|
72
|
+
def status
|
|
73
|
+
puts "Flo is #{status_text}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
desc 'edit [NAME]', 'Opens Feature Flags and Experiments for editing'
|
|
77
|
+
long_desc <<~MSG
|
|
78
|
+
Uses the editor defined in the '$EDITOR' environment variable to edit a given feature, by name.
|
|
79
|
+
If no name is given, the editor will be passed the folder name.
|
|
80
|
+
MSG
|
|
81
|
+
|
|
82
|
+
def edit(id = nil)
|
|
83
|
+
path = id.nil? ? cache.root : cache.id_to_file(id)
|
|
84
|
+
|
|
85
|
+
unless path.exist?
|
|
86
|
+
puts "This feature doesn't exist. In future, `#{set_color "flo create #{id}", :blue}` will provide a template to start from."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
open_editor(path)
|
|
90
|
+
puts "#{set_color(id || 'The features folder', :white)} has been opened for editing"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
desc 'create NAME', 'Creates a new feature for editing'
|
|
94
|
+
long_desc <<~MSG
|
|
95
|
+
Creates a new feature with the given name, with default configuration and opens it for editing.
|
|
96
|
+
MSG
|
|
97
|
+
|
|
98
|
+
def create(id)
|
|
99
|
+
path = cache.id_to_file(id)
|
|
100
|
+
copy_file('feature.yaml', path)
|
|
101
|
+
open_editor(path)
|
|
102
|
+
puts "#{set_color id, :white} created and opened for editing"
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
desc 'clone URL', 'Clones the features on the given server locally'
|
|
106
|
+
long_desc <<~MSG
|
|
107
|
+
Copies all the feature flags and experiments from a given remote server to the local instance of Fake Florence.
|
|
108
|
+
This is particularly useful for making your local instance of Fake Florence look like production.
|
|
109
|
+
MSG
|
|
110
|
+
|
|
111
|
+
def clone(url)
|
|
112
|
+
r = Retriever.new(url)
|
|
113
|
+
|
|
114
|
+
r.each_feature do |feature|
|
|
115
|
+
create_file(cache.id_to_file(feature.id)) do
|
|
116
|
+
feature.saveable
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
rescue Retriever::HTTPFailure => e
|
|
120
|
+
abort "HTTP error: #{e.message}"
|
|
121
|
+
rescue Retriever::NotFlorenceServerError
|
|
122
|
+
abort "The given URL doesn't quack like a Florence server"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
desc 'announce', 'Announces all local features'
|
|
126
|
+
|
|
127
|
+
def announce
|
|
128
|
+
abort "Florence is #{status_text}, please `#{set_colour 'flo start', :blue}` before announcing." unless daemon.running?
|
|
129
|
+
Config.log.level = Logger::WARN
|
|
130
|
+
res = cache.announce_all_now
|
|
131
|
+
announcers = res[:announcers].map do |name|
|
|
132
|
+
set_color name, :white
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
if announcers.any?
|
|
136
|
+
puts "Announced #{set_color res[:features], :white} features to #{announcers.join(', ')}."
|
|
137
|
+
else
|
|
138
|
+
puts 'Did not announce any features.'
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
desc 'config', 'Opens the config file for editing'
|
|
143
|
+
|
|
144
|
+
def config
|
|
145
|
+
open_editor(Config.store_file)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
desc 'logs', 'Tails the logfile'
|
|
149
|
+
|
|
150
|
+
def logs
|
|
151
|
+
exec("tail -f \"#{Config.logfile}\"")
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def open_editor(path)
|
|
157
|
+
abort "Unsure how to edit #{path.to_s}\nEDITOR variable is empty" unless ENV['EDITOR']
|
|
158
|
+
system("$EDITOR \"#{path.to_s}\"")
|
|
159
|
+
abort "Flo failed to open #{path.to_s} for editing" unless $CHILD_STATUS.exitstatus == 0
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def cache
|
|
163
|
+
@cache ||= FeatureCache.new(Config.home_dir)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def daemon
|
|
167
|
+
@daemon ||= FakeFlorence::Daemon.new
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def status_text
|
|
171
|
+
daemon.running? ? set_color('running', :green) : set_color('stopped', :red)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
require 'logger'
|
|
2
|
+
require 'pathname'
|
|
3
|
+
require 'uri'
|
|
4
|
+
require 'yaml'
|
|
5
|
+
|
|
6
|
+
module FakeFlorence
|
|
7
|
+
module Config
|
|
8
|
+
class << self
|
|
9
|
+
DEFAULT_CONFIG = Pathname.new(__dir__).join('../../templates/config.yaml').freeze
|
|
10
|
+
SEVERITY = {
|
|
11
|
+
'DEBUG' => '🤖',
|
|
12
|
+
'INFO' => 'ℹ️',
|
|
13
|
+
'WARN' => '⚠️',
|
|
14
|
+
'ERROR' => '😡',
|
|
15
|
+
'FATAL' => '🤢',
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
attr_accessor :log
|
|
19
|
+
|
|
20
|
+
def url_for(feature)
|
|
21
|
+
URI.join(
|
|
22
|
+
read_config(:base_url),
|
|
23
|
+
File.join(
|
|
24
|
+
mount_path,
|
|
25
|
+
feature.id
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def store_file
|
|
31
|
+
home_dir.join('config.yaml')
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def home_dir
|
|
35
|
+
Pathname.new('~/.flo').expand_path.tap do |home|
|
|
36
|
+
home.mkpath
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def log
|
|
41
|
+
@log ||= Logger.new($stdout).tap do |logger|
|
|
42
|
+
logger.formatter = -> (sev, time, name, msg) do
|
|
43
|
+
"#{time.utc.strftime('%Y-%m-%d %H:%M:%SZ')} #{SEVERITY[sev]}: #{msg}\n"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def pidfile
|
|
49
|
+
home_dir.join('flo.pid')
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def logfile
|
|
53
|
+
home_dir.join('flo.log')
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def method_missing(m)
|
|
57
|
+
read_config(m)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def read_config(key)
|
|
63
|
+
@config_data ||= begin
|
|
64
|
+
if store_file.exist?
|
|
65
|
+
YAML.load_file(store_file)
|
|
66
|
+
else
|
|
67
|
+
YAML.load_file(DEFAULT_CONFIG).tap do |data|
|
|
68
|
+
File.open(store_file, 'w') do |f|
|
|
69
|
+
YAML.dump(data, f)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
@config_data[key.to_s]
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module FakeFlorence
|
|
2
|
+
class Daemon
|
|
3
|
+
def initialize
|
|
4
|
+
@cache = FeatureCache.new(Config.home_dir)
|
|
5
|
+
@announcers = Announcers.load(Config.announce)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def run
|
|
9
|
+
start_listeners
|
|
10
|
+
Server.set :feature_cache, @cache
|
|
11
|
+
Config.log.info "Florence is listening at #{Config.base_url}"
|
|
12
|
+
|
|
13
|
+
prepare_for_run
|
|
14
|
+
Server.run!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def start(daemonize:)
|
|
18
|
+
raise 'Already running' if running?
|
|
19
|
+
|
|
20
|
+
if daemonize
|
|
21
|
+
$stdout = Config.logfile.open('a')
|
|
22
|
+
$stdout.sync = true
|
|
23
|
+
Process.daemon(true)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
run
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def stop
|
|
30
|
+
raise 'Not running' unless running?
|
|
31
|
+
|
|
32
|
+
Process.kill('TERM', pid)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def running?
|
|
36
|
+
return false if pid.nil?
|
|
37
|
+
Process.getpgid(pid)
|
|
38
|
+
true
|
|
39
|
+
rescue Errno::ESRCH
|
|
40
|
+
Config.pidfile.delete
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def pid
|
|
45
|
+
Config.pidfile.read.to_i
|
|
46
|
+
rescue Errno::ENOENT
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def start_listeners
|
|
53
|
+
@announcers.each do |announcer|
|
|
54
|
+
@cache.register_listener(&announcer.method(:announce))
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def prepare_for_run
|
|
59
|
+
Config.pidfile.write(Process.pid)
|
|
60
|
+
at_exit do
|
|
61
|
+
Config.pidfile.delete if Config.pidfile.exist?
|
|
62
|
+
Config.log.info 'Shutting down'
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require 'yaml'
|
|
2
|
+
require 'json'
|
|
3
|
+
require 'ostruct'
|
|
4
|
+
|
|
5
|
+
module FakeFlorence
|
|
6
|
+
class Feature < OpenStruct
|
|
7
|
+
def self.read(id, pathname)
|
|
8
|
+
new(YAML.load_file(pathname)).tap do |feature|
|
|
9
|
+
feature[:id] = id
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def initialize(keys = {})
|
|
14
|
+
super(FeatureSchema.call(keys).to_h)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_h
|
|
18
|
+
each_pair.reduce({}) do |h, (k, v)|
|
|
19
|
+
h[k.to_s] = v
|
|
20
|
+
h
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def saveable
|
|
25
|
+
to_h.tap do |h|
|
|
26
|
+
h.delete('id')
|
|
27
|
+
end.to_yaml
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
require 'listen'
|
|
2
|
+
|
|
3
|
+
module FakeFlorence
|
|
4
|
+
class FeatureCache
|
|
5
|
+
attr_reader :root
|
|
6
|
+
|
|
7
|
+
def initialize(path)
|
|
8
|
+
@root = path.join('features')
|
|
9
|
+
@root.mkpath
|
|
10
|
+
@callbacks = []
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def features
|
|
14
|
+
Dir.glob(@root.join('*.yaml')).map(&method(:file_to_feature))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def feature(id)
|
|
18
|
+
Feature.read(id, id_to_file(id))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def register_listener(&block)
|
|
22
|
+
@callbacks.push(block)
|
|
23
|
+
listen_for_changes unless @listener
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def id_to_file(id)
|
|
27
|
+
@root.join("#{id}.yaml")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def announce_all_now
|
|
31
|
+
names = []
|
|
32
|
+
|
|
33
|
+
Announcers.load(Config.announce).each do |announcer|
|
|
34
|
+
names.push(announcer.name) if announcer.announce(noop: features)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
{ features: features.size, announcers: names }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def listen_for_changes
|
|
43
|
+
@listener = Listen.to(@root.to_s, only: /.yaml$/) do |updated, created, deleted|
|
|
44
|
+
features_map = {
|
|
45
|
+
create: created.map(&method(:file_to_feature)),
|
|
46
|
+
update: updated.map(&method(:file_to_feature)),
|
|
47
|
+
delete: deleted.map(&method(:file_to_stub_feature)),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
features_map.each_pair do |type, fs|
|
|
51
|
+
next if fs.empty?
|
|
52
|
+
Config.log.debug "Features #{type}d: #{fs.map(&:id).join(', ')}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
@callbacks.each do |callback|
|
|
56
|
+
callback.call(features_map)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
@listener.start
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def file_to_feature(file)
|
|
64
|
+
Feature.read(file_to_id(file), file)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def file_to_stub_feature(file)
|
|
68
|
+
Feature.new('id' => file_to_id(file))
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def file_to_id(file)
|
|
72
|
+
File.basename(file, '.yaml')
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
require 'dry-validation'
|
|
2
|
+
|
|
3
|
+
module FakeFlorence
|
|
4
|
+
FeatureSchema = Dry::Validation.Schema do
|
|
5
|
+
configure { config.input_processor = :sanitizer }
|
|
6
|
+
|
|
7
|
+
required('id').maybe(:str?)
|
|
8
|
+
|
|
9
|
+
required('name').filled(:str?)
|
|
10
|
+
required('identifier').filled(:str?)
|
|
11
|
+
required('bucket_type').filled(:str?)
|
|
12
|
+
required('active').filled(:bool?)
|
|
13
|
+
required('target_groups').each do
|
|
14
|
+
schema do
|
|
15
|
+
required('rollout') { int? & gteq?(0) & lteq?(65536) }
|
|
16
|
+
# TODO: any key => :str?
|
|
17
|
+
required('constraints').filled(:hash?)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# TODO: any key => :int?
|
|
22
|
+
required('variants').filled(:hash?)
|
|
23
|
+
required('winning_variant').filled(:str?)
|
|
24
|
+
|
|
25
|
+
# TODO: any key => :str?, :bool?
|
|
26
|
+
required('overrides', Dry::Types::Hash).filled
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
require 'faraday'
|
|
2
|
+
require 'pp'
|
|
3
|
+
require 'faraday_middleware'
|
|
4
|
+
|
|
5
|
+
module FakeFlorence
|
|
6
|
+
class Retriever
|
|
7
|
+
|
|
8
|
+
class NotFlorenceServerError < RuntimeError; end
|
|
9
|
+
class HTTPFailure < RuntimeError; end
|
|
10
|
+
|
|
11
|
+
def initialize(root_url)
|
|
12
|
+
@root = root_url
|
|
13
|
+
|
|
14
|
+
@http = Faraday.new do |f|
|
|
15
|
+
f.response :json, content_type: /\bjson$/
|
|
16
|
+
f.use Faraday::Response::RaiseError
|
|
17
|
+
f.adapter Faraday.default_adapter
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def each_feature
|
|
22
|
+
urls = [@root]
|
|
23
|
+
|
|
24
|
+
while url = urls.shift
|
|
25
|
+
response = @http.get(url)
|
|
26
|
+
links = response.body['_links']
|
|
27
|
+
raise NotFlorenceServerError if links.nil?
|
|
28
|
+
|
|
29
|
+
urls << links['next']['href'] if links['next']
|
|
30
|
+
|
|
31
|
+
(links['features'] ||[]).each do |listing|
|
|
32
|
+
yield get_feature(listing['href'])
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
rescue Faraday::Error::ClientError => e
|
|
36
|
+
Config.log.debug e.message
|
|
37
|
+
raise HTTPFailure
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def get_feature(url)
|
|
43
|
+
response = @http.get(url)
|
|
44
|
+
Feature.new(response.body)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
require 'sinatra/base'
|
|
2
|
+
|
|
3
|
+
module FakeFlorence
|
|
4
|
+
class Server < Sinatra::Base
|
|
5
|
+
set :bind, Config.bind
|
|
6
|
+
set :port, Config.port
|
|
7
|
+
set :quiet, false
|
|
8
|
+
set :show_exceptions, false
|
|
9
|
+
set :server, :puma
|
|
10
|
+
set :server_settings, { Silent: true }
|
|
11
|
+
set :environment, :production
|
|
12
|
+
|
|
13
|
+
use Rack::CommonLogger, Config.log
|
|
14
|
+
|
|
15
|
+
helpers do
|
|
16
|
+
def feature
|
|
17
|
+
settings.feature_cache.feature(params[:id])
|
|
18
|
+
rescue Errno::ENOENT
|
|
19
|
+
halt 404
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def url_for(*path_bits)
|
|
23
|
+
File.join(Config.base_url, *path_bits)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def jsonhal_for(*path_bits, extra_links: {})
|
|
27
|
+
{
|
|
28
|
+
_links: {
|
|
29
|
+
curies: [],
|
|
30
|
+
self: { href: url_for(*path_bits) }
|
|
31
|
+
}.merge(extra_links)
|
|
32
|
+
}.tap { |doc|
|
|
33
|
+
doc.merge!(yield) if block_given?
|
|
34
|
+
}.to_json
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
before do
|
|
39
|
+
content_type :json
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
get '/' do
|
|
43
|
+
jsonhal_for('/', extra_links: {
|
|
44
|
+
features: { href: url_for(Config.mount_path) },
|
|
45
|
+
feature: {
|
|
46
|
+
href: url_for(File.join(Config.mount_path, '{id}')),
|
|
47
|
+
templated: true
|
|
48
|
+
}
|
|
49
|
+
})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
get Config.mount_path do
|
|
53
|
+
jsonhal_for(Config.mount_path, extra_links: {
|
|
54
|
+
features: settings.feature_cache.features.map { |feature|
|
|
55
|
+
{ href: url_for(Config.mount_path, feature.id) }
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
get File.join(Config.mount_path, ':id') do
|
|
61
|
+
jsonhal_for(Config.mount_path, feature.id) do
|
|
62
|
+
feature.to_h
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
require 'fake_florence/announcers'
|
|
2
|
+
require 'fake_florence/cli'
|
|
3
|
+
require 'fake_florence/config'
|
|
4
|
+
require 'fake_florence/daemon'
|
|
5
|
+
require 'fake_florence/feature'
|
|
6
|
+
require 'fake_florence/feature_cache'
|
|
7
|
+
require 'fake_florence/feature_schema'
|
|
8
|
+
require 'fake_florence/retriever'
|
|
9
|
+
require 'fake_florence/server'
|
|
10
|
+
require 'fake_florence/version'
|
|
11
|
+
|
|
12
|
+
module FakeFlorence
|
|
13
|
+
|
|
14
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
## Server settings
|
|
2
|
+
|
|
3
|
+
# The address the server is going to listen on. Probably best to stick local.
|
|
4
|
+
bind: 127.0.0.1
|
|
5
|
+
# The port that Fake Florence will run on.
|
|
6
|
+
port: 35600
|
|
7
|
+
# This will be the URL Fake Florence uses in its responses. Using puma-dev is
|
|
8
|
+
# recommended (https://github.com/puma/puma-dev)
|
|
9
|
+
base_url: https://flo.dev
|
|
10
|
+
# The path at which feature information will be mouted. It's highly unlikely you
|
|
11
|
+
# need to change this.
|
|
12
|
+
mount_path: /features
|
|
13
|
+
# These are the services that will be pinged whenever a feature is added, edited
|
|
14
|
+
# or removed. This can be an empty array if you like.
|
|
15
|
+
announce:
|
|
16
|
+
- type: routemaster
|
|
17
|
+
name: Determinator Example
|
|
18
|
+
url: https://determinator-example.dev/events
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
## A Florence template file
|
|
2
|
+
|
|
3
|
+
# A human readable description of the Feature flag or experiment
|
|
4
|
+
name: A descriptive name
|
|
5
|
+
# The identifier is the salt that determines the seed
|
|
6
|
+
# for the random distribution. Two experiments with the
|
|
7
|
+
# same identifier would have the same actors in the same
|
|
8
|
+
# variants.
|
|
9
|
+
identifier: anything
|
|
10
|
+
# Which of an actor's identifying properties will be used
|
|
11
|
+
# to bucket them into rollouts and variants. 'id' and 'guid'
|
|
12
|
+
# are self-evident, 'fallback' uses the actor's id, if
|
|
13
|
+
# available, or uses the actor's guid if not.
|
|
14
|
+
bucket_type: id
|
|
15
|
+
# Inactive features are always off
|
|
16
|
+
active: true
|
|
17
|
+
# Each target group specifies a rollout fraction and a number
|
|
18
|
+
# of constraints that limit the actors to which this rollout
|
|
19
|
+
# will apply.
|
|
20
|
+
target_groups:
|
|
21
|
+
# The rollout fraction is out of 65536
|
|
22
|
+
- rollout: 65536
|
|
23
|
+
# Every constraint must be matched by the actor for the
|
|
24
|
+
# given rollout to apply. This can be an empty hash to mean
|
|
25
|
+
# 'every actor'.
|
|
26
|
+
constraints:
|
|
27
|
+
# Here, the actor's 'platform' must be 'ios'
|
|
28
|
+
platform: ios
|
|
29
|
+
# Here, the actor's 'country' must be either 'uk' or 'de'
|
|
30
|
+
country: [uk, de]
|
|
31
|
+
|
|
32
|
+
## Experiment only options
|
|
33
|
+
|
|
34
|
+
# Listing variants declares this an experiment
|
|
35
|
+
# Each key of this hash is the string which will
|
|
36
|
+
# be given if the actor is in that variant.
|
|
37
|
+
# Each value is the weighting of that variant,
|
|
38
|
+
# eg. if all are '1' then the actors have an equal
|
|
39
|
+
# chance of being in each variant.
|
|
40
|
+
variants:
|
|
41
|
+
# The first is always considered the control.
|
|
42
|
+
anchovy: 1
|
|
43
|
+
mackerel: 1
|
|
44
|
+
# A non-null value declares the winner of the experiment
|
|
45
|
+
# and the result that all actors within any target group's
|
|
46
|
+
# rollout will see.
|
|
47
|
+
winning_variant: anchovy
|
|
48
|
+
|
|
49
|
+
# Overrides force specific actors to have a given outcome,
|
|
50
|
+
# instead of the determinated one. Because actors must be
|
|
51
|
+
# specified by identifier this should be kept to a select
|
|
52
|
+
# few for testing and evaluation. Target Groups should be
|
|
53
|
+
# used instead product cases.
|
|
54
|
+
overrides:
|
|
55
|
+
# The key is the actor identifier (ie. the ID or anonymous
|
|
56
|
+
# ID as specified by the buckt type).
|
|
57
|
+
# The key is the desired outcome for this actor.
|
|
58
|
+
123: false
|
metadata
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: fake_florence
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- JP Hastings-Spital
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: exe
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2017-09-15 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: dry-validation
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0.11'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0.11'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: faraday
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '0.13'
|
|
34
|
+
type: :runtime
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '0.13'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: faraday_middleware
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0.12'
|
|
48
|
+
type: :runtime
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '0.12'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: listen
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: '3.0'
|
|
62
|
+
type: :runtime
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '3.0'
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: puma
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '3.10'
|
|
76
|
+
type: :runtime
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '3.10'
|
|
83
|
+
- !ruby/object:Gem::Dependency
|
|
84
|
+
name: sinatra
|
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
|
86
|
+
requirements:
|
|
87
|
+
- - "~>"
|
|
88
|
+
- !ruby/object:Gem::Version
|
|
89
|
+
version: '2.0'
|
|
90
|
+
type: :runtime
|
|
91
|
+
prerelease: false
|
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
93
|
+
requirements:
|
|
94
|
+
- - "~>"
|
|
95
|
+
- !ruby/object:Gem::Version
|
|
96
|
+
version: '2.0'
|
|
97
|
+
- !ruby/object:Gem::Dependency
|
|
98
|
+
name: thor
|
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
|
100
|
+
requirements:
|
|
101
|
+
- - "~>"
|
|
102
|
+
- !ruby/object:Gem::Version
|
|
103
|
+
version: '0.20'
|
|
104
|
+
type: :runtime
|
|
105
|
+
prerelease: false
|
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
107
|
+
requirements:
|
|
108
|
+
- - "~>"
|
|
109
|
+
- !ruby/object:Gem::Version
|
|
110
|
+
version: '0.20'
|
|
111
|
+
- !ruby/object:Gem::Dependency
|
|
112
|
+
name: bundler
|
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
|
114
|
+
requirements:
|
|
115
|
+
- - "~>"
|
|
116
|
+
- !ruby/object:Gem::Version
|
|
117
|
+
version: '1.15'
|
|
118
|
+
type: :development
|
|
119
|
+
prerelease: false
|
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
121
|
+
requirements:
|
|
122
|
+
- - "~>"
|
|
123
|
+
- !ruby/object:Gem::Version
|
|
124
|
+
version: '1.15'
|
|
125
|
+
- !ruby/object:Gem::Dependency
|
|
126
|
+
name: rake
|
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
|
128
|
+
requirements:
|
|
129
|
+
- - "~>"
|
|
130
|
+
- !ruby/object:Gem::Version
|
|
131
|
+
version: '10.0'
|
|
132
|
+
type: :development
|
|
133
|
+
prerelease: false
|
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
135
|
+
requirements:
|
|
136
|
+
- - "~>"
|
|
137
|
+
- !ruby/object:Gem::Version
|
|
138
|
+
version: '10.0'
|
|
139
|
+
description: A local Florence server for use with Determinator locally
|
|
140
|
+
email:
|
|
141
|
+
- jp@deliveroo.co.uk
|
|
142
|
+
executables:
|
|
143
|
+
- flo
|
|
144
|
+
extensions: []
|
|
145
|
+
extra_rdoc_files: []
|
|
146
|
+
files:
|
|
147
|
+
- LICENSE.md
|
|
148
|
+
- README.md
|
|
149
|
+
- exe/flo
|
|
150
|
+
- lib/fake_florence.rb
|
|
151
|
+
- lib/fake_florence/announcers.rb
|
|
152
|
+
- lib/fake_florence/announcers/routemaster.rb
|
|
153
|
+
- lib/fake_florence/cli.rb
|
|
154
|
+
- lib/fake_florence/config.rb
|
|
155
|
+
- lib/fake_florence/daemon.rb
|
|
156
|
+
- lib/fake_florence/feature.rb
|
|
157
|
+
- lib/fake_florence/feature_cache.rb
|
|
158
|
+
- lib/fake_florence/feature_schema.rb
|
|
159
|
+
- lib/fake_florence/retriever.rb
|
|
160
|
+
- lib/fake_florence/server.rb
|
|
161
|
+
- lib/fake_florence/version.rb
|
|
162
|
+
- templates/config.yaml
|
|
163
|
+
- templates/feature.yaml
|
|
164
|
+
homepage: https://github.com/deliveroo/fake_florence
|
|
165
|
+
licenses:
|
|
166
|
+
- MIT
|
|
167
|
+
metadata: {}
|
|
168
|
+
post_install_message: 'Fake Florence has been installed. Run `flo help` for more information.
|
|
169
|
+
|
|
170
|
+
'
|
|
171
|
+
rdoc_options: []
|
|
172
|
+
require_paths:
|
|
173
|
+
- lib
|
|
174
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
175
|
+
requirements:
|
|
176
|
+
- - ">="
|
|
177
|
+
- !ruby/object:Gem::Version
|
|
178
|
+
version: '0'
|
|
179
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
180
|
+
requirements:
|
|
181
|
+
- - ">="
|
|
182
|
+
- !ruby/object:Gem::Version
|
|
183
|
+
version: '0'
|
|
184
|
+
requirements: []
|
|
185
|
+
rubyforge_project:
|
|
186
|
+
rubygems_version: 2.5.1
|
|
187
|
+
signing_key:
|
|
188
|
+
specification_version: 4
|
|
189
|
+
summary: A local Florence server for use with Determinator locally
|
|
190
|
+
test_files: []
|