brillo 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/.gitignore +11 -0
- data/.rspec +2 -0
- data/.travis.yml +4 -0
- data/CHANGELOG.md +6 -0
- data/Gemfile +6 -0
- data/LICENSE +21 -0
- data/README.md +104 -0
- data/Rakefile +1 -0
- data/bin/console +15 -0
- data/bin/setup +7 -0
- data/brillo.gemspec +30 -0
- data/config/brillo-example.yml +13 -0
- data/config/brillo-initializer.rb +14 -0
- data/lib/brillo/adapter/README.md +3 -0
- data/lib/brillo/adapter/base.rb +27 -0
- data/lib/brillo/adapter/mysql.rb +32 -0
- data/lib/brillo/adapter/postgres.rb +9 -0
- data/lib/brillo/config.rb +92 -0
- data/lib/brillo/dumper/mysql_dumper.rb +44 -0
- data/lib/brillo/errors.rb +4 -0
- data/lib/brillo/helpers/exec_helper.rb +31 -0
- data/lib/brillo/loader.rb +42 -0
- data/lib/brillo/logger.rb +15 -0
- data/lib/brillo/railtie.rb +10 -0
- data/lib/brillo/scrubber.rb +111 -0
- data/lib/brillo/transferrer/s3.rb +66 -0
- data/lib/brillo/validator.rb +32 -0
- data/lib/brillo/version.rb +3 -0
- data/lib/brillo.rb +50 -0
- data/lib/capistrano/brillo.rb +1 -0
- data/lib/capistrano/tasks/db.cap +30 -0
- data/lib/generators/brillo.rb +9 -0
- data/lib/tasks/brillo.rake +26 -0
- metadata +191 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: b684f9b150cde1eded3db5ae684ca02853e6ae37
|
4
|
+
data.tar.gz: 1a052b7afc30957c7b87b04506be442c511b65ea
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: b1726eac1715f1fda25a19e35f77e82bdfe528378c83736ca194ff86c0a1d6bd57eb2d203d4583d8e0cef9e758209200b3d818d167e3533f13d013b1ca0f8fb9
|
7
|
+
data.tar.gz: 2e9857f15ea8486806208bf64954b35eec1371fceb183b27803e828baf1d6726a718f350f50f91ffe6f23e230f80270d8529820db706dfd9a4f5f7ed62055fed
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.travis.yml
ADDED
data/CHANGELOG.md
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2016 Matt Bessey
|
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 all
|
13
|
+
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 THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,104 @@
|
|
1
|
+
[](https://travis-ci.com/bessey/brillo)
|
2
|
+
|
3
|
+
# Brillo
|
4
|
+
|
5
|
+
Brillo is a Rails database scrubber and loader, useful for making lightweight copies of your production database for development machines, with sensitive information obfuscated. Most configuration is done through YAML: Specify the models that you want to back up, what associations you want with them, and what fields should be obfuscated (and how).
|
6
|
+
|
7
|
+
Once that is done, dropping your local DB and replacing it with the latest scrubbed copy is as easy as `rake db:load`.
|
8
|
+
|
9
|
+
Under the hood we use [Polo](https://github.com/IFTTT/polo) to explore the classes and associations you specify in brillo.yml, obfuscated fields as configured.
|
10
|
+
|
11
|
+
## Installation
|
12
|
+
|
13
|
+
Add this line to your application's Gemfile:
|
14
|
+
|
15
|
+
```ruby
|
16
|
+
gem 'brillo'
|
17
|
+
# We currently rely on an unreleased version of Polo
|
18
|
+
gem 'polo', github: 'IFTTT/polo'
|
19
|
+
```
|
20
|
+
|
21
|
+
Generate a starter `brillo.yml` file and `config/initializers/brillo.rb` with
|
22
|
+
|
23
|
+
```bash
|
24
|
+
$ rails g brillo_config
|
25
|
+
```
|
26
|
+
|
27
|
+
If you're using Capistrano, add Brillo's tasks to your Capfile:
|
28
|
+
|
29
|
+
```ruby
|
30
|
+
# Capfile
|
31
|
+
require 'capistrano/brillo'
|
32
|
+
```
|
33
|
+
|
34
|
+
Lastly, since the scrubber is pretty resource intensive you may wish to ensure it runs on separate hardware from your app servers:
|
35
|
+
|
36
|
+
```ruby
|
37
|
+
# config/deploy.rb
|
38
|
+
set :brillo_role, :my_batch_role
|
39
|
+
```
|
40
|
+
|
41
|
+
## Usage
|
42
|
+
|
43
|
+
Here's an example `brillo.yml` for IMDB:
|
44
|
+
|
45
|
+
```yaml
|
46
|
+
name: imdb # Namespace the scrubbed file will occupy in S3
|
47
|
+
explore:
|
48
|
+
user: # Name of ActiveRecord class in snake_case
|
49
|
+
tactic: all # Scrubbing tactic to use (see Brillo:TACTICS for choices)
|
50
|
+
associations: # Associations to include in the scrub (ALL associated records included)
|
51
|
+
- comments
|
52
|
+
movie:
|
53
|
+
tactic: latest # The latest tactic explores the most recent 1,000 records
|
54
|
+
associations:
|
55
|
+
- actors
|
56
|
+
- ratings
|
57
|
+
admin/note: # Corresponds to the Admin::Note class
|
58
|
+
tactic: all
|
59
|
+
obfuscations: #
|
60
|
+
user.name: name # Scrub user.name with the "name" scrubber (see Brillo::SCRUBBERS for choices)
|
61
|
+
user.phone: phone
|
62
|
+
user.email: email
|
63
|
+
```
|
64
|
+
|
65
|
+
In order to communicate with S3, Brillo expects `AWS_ACCESS_KEY` and `AWS_SECRET_KEY` to be set in the environment. It uses [Tim Kay's AWS cli](http://timkay.com/aws/) to communicate with AWS.
|
66
|
+
|
67
|
+
### Loading a database in development
|
68
|
+
|
69
|
+
```bash
|
70
|
+
$ rake db:load
|
71
|
+
```
|
72
|
+
|
73
|
+
### Loading a database on a stage
|
74
|
+
|
75
|
+
```bash
|
76
|
+
$ cap staging db:load
|
77
|
+
```
|
78
|
+
|
79
|
+
### Adding scrub tactics and obfuscations
|
80
|
+
|
81
|
+
If the built in record selection tactics aren't enough for you, or you need a custom obfuscation strategy, you can add them via the initializer. They are available in the YAML config like any other strategy.
|
82
|
+
|
83
|
+
```ruby
|
84
|
+
# config/initializers/brillo.rb
|
85
|
+
|
86
|
+
Brillo.configure do |config|
|
87
|
+
config.add_tactic :oldest, -> (klass) { klass.order(created_at: :desc).limit(1000) }
|
88
|
+
|
89
|
+
config.add_obfuscation :remove_ls, -> (field) {
|
90
|
+
field.gsub(/l/, "X")
|
91
|
+
}
|
92
|
+
|
93
|
+
# If you need the context of the entire record being obfuscated, it is available in the second argument
|
94
|
+
config.add_obfuscation :phone_with_id, -> (field, instance) {
|
95
|
+
(555_000_0000 + instance.id).to_s
|
96
|
+
}
|
97
|
+
end
|
98
|
+
|
99
|
+
```
|
100
|
+
|
101
|
+
## To Do
|
102
|
+
|
103
|
+
- Support S3 transfer via the usual AWS CLI
|
104
|
+
- Support alternative transfer mechanisms
|
data/Rakefile
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
require "bundler/gem_tasks"
|
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "rails"
|
5
|
+
require "brillo"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require "irb"
|
15
|
+
IRB.start
|
data/bin/setup
ADDED
data/brillo.gemspec
ADDED
@@ -0,0 +1,30 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
lib = File.expand_path('../lib', __FILE__)
|
3
|
+
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
|
4
|
+
require 'brillo/version'
|
5
|
+
|
6
|
+
Gem::Specification.new do |spec|
|
7
|
+
spec.name = "brillo"
|
8
|
+
spec.version = Brillo::VERSION
|
9
|
+
spec.authors = ["Matt Bessey"]
|
10
|
+
spec.email = ["mbessey@caring.com"]
|
11
|
+
|
12
|
+
spec.summary = %q{Rails database scrubber and loader, great for seeding your dev db with real but sanitized data}
|
13
|
+
spec.homepage = "https://github.com/bessey/brillo"
|
14
|
+
spec.license = "MIT"
|
15
|
+
|
16
|
+
spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|dummy)/}) }
|
17
|
+
spec.bindir = "exe"
|
18
|
+
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
|
19
|
+
spec.require_paths = ["lib"]
|
20
|
+
|
21
|
+
spec.add_runtime_dependency "rake", "~> 10.0"
|
22
|
+
spec.add_runtime_dependency "capistrano", "~> 3.0"
|
23
|
+
spec.add_runtime_dependency "polo", "~> 0.3"
|
24
|
+
|
25
|
+
spec.add_development_dependency "rails", ">= 3.2"
|
26
|
+
spec.add_development_dependency "rspec", "~> 3.4"
|
27
|
+
spec.add_development_dependency "pry"
|
28
|
+
spec.add_development_dependency "benchmark-ips"
|
29
|
+
spec.add_development_dependency "geminabox"
|
30
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
name: my-app # Namespace the scrubbed file will occupy
|
2
|
+
## Optional configuration (with defaults shown)
|
3
|
+
# compress: true # Enables gzip on scrub and ungzip on load
|
4
|
+
# send_to_s3: true # Disable to skip sending to S3, useful for debugging
|
5
|
+
# fetch_from_s3: true # Disable to skip fetching from S3, useful when you have an existing scrub in tmp/
|
6
|
+
#
|
7
|
+
# obfuscations:
|
8
|
+
# user.name: name # Scrub table.field with scrubber (see Brillo::SCRUBBERS for choices)
|
9
|
+
explore:
|
10
|
+
user: # Name of ActiveRecord class in snake_case
|
11
|
+
tactic: all # Scrubbing tactic to use (see Brillo:TACTICS for choices)
|
12
|
+
associations: # Associations to include in the scrub (ALL associated records included)
|
13
|
+
- comments
|
@@ -0,0 +1,14 @@
|
|
1
|
+
Brillo.configure do |config|
|
2
|
+
## Add extra tactics for selecting records as you need them
|
3
|
+
# config.add_tactic :oldest, -> (klass) { klass.order(created_at: :desc).limit(1000) }
|
4
|
+
|
5
|
+
## Custom obfuscations can also be added
|
6
|
+
# config.add_obfuscation :remove_ls, -> (field) {
|
7
|
+
# field.gsub(/l/, "X")
|
8
|
+
# }
|
9
|
+
|
10
|
+
## If you need the context of the entire record being obfuscated, it is available in the second argument
|
11
|
+
# config.add_obfuscation :phone_with_id, -> (field, instance) {
|
12
|
+
# (555_000_0000 + instance.id).to_s
|
13
|
+
# }
|
14
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Brillo
|
2
|
+
module Adapter
|
3
|
+
class Base
|
4
|
+
attr_reader :config
|
5
|
+
def initialize(db_config)
|
6
|
+
@config = db_config
|
7
|
+
end
|
8
|
+
def header
|
9
|
+
ActiveRecord::Base.connection.dump_schema_information
|
10
|
+
end
|
11
|
+
|
12
|
+
def footer
|
13
|
+
""
|
14
|
+
end
|
15
|
+
|
16
|
+
def dump_structure_and_migrations(config)
|
17
|
+
# Overrides the path the structure is dumped to in Rails >= 3.2
|
18
|
+
ENV['SCHEMA'] = ENV['DB_STRUCTURE'] = config.dump_path.to_s
|
19
|
+
Rake::Task["db:structure:dump"].invoke
|
20
|
+
end
|
21
|
+
|
22
|
+
def load_command
|
23
|
+
raise NotImplementedError
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Brillo
|
2
|
+
module Adapter
|
3
|
+
class MySQL < Base
|
4
|
+
def header
|
5
|
+
super + <<-SQL
|
6
|
+
-- Disable autocommit, uniquechecks, and foreign key checks, for performance on InnoDB
|
7
|
+
-- http://dev.mysql.com/doc/refman/5.5/en/optimizing-innodb-bulk-data-loading.html
|
8
|
+
SET @OLD_AUTOCOMMIT=@@AUTOCOMMIT, AUTOCOMMIT = 0;
|
9
|
+
SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS = 0;
|
10
|
+
SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS = 0;
|
11
|
+
SQL
|
12
|
+
end
|
13
|
+
|
14
|
+
def footer
|
15
|
+
super + <<-SQL
|
16
|
+
SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS;
|
17
|
+
SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS;
|
18
|
+
SET AUTOCOMMIT = @OLD_AUTOCOMMIT;
|
19
|
+
COMMIT;
|
20
|
+
SQL
|
21
|
+
end
|
22
|
+
|
23
|
+
def dump_structure_and_migrations(config)
|
24
|
+
Dumper::MysqlDumper.new(config).dump
|
25
|
+
end
|
26
|
+
|
27
|
+
def load_command
|
28
|
+
"mysql --host #{config[:host]} -u #{config[:username]} #{config[:password] ? "-p#{config[:password]}" : ""} #{config[:database]}"
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
module Brillo
|
2
|
+
class Config
|
3
|
+
AWS_KEY_PATH = '/etc/ec2_secure_env.yml'
|
4
|
+
S3_BUCKET = 'scrubbed_databases2'
|
5
|
+
attr_reader :app_name, :compress, :obfuscations, :klass_association_map, :db, :send_to_s3, :fetch_from_s3,
|
6
|
+
:aws_key_path, :s3_bucket
|
7
|
+
|
8
|
+
def initialize(options = {})
|
9
|
+
@app_name = options.fetch("name")
|
10
|
+
@klass_association_map = options["explore"] || {}
|
11
|
+
@compress = options.fetch("compress", true)
|
12
|
+
@fetch_from_s3 = options.fetch("fetch_from_s3", true)
|
13
|
+
@send_to_s3 = options.fetch("send_to_s3", true)
|
14
|
+
@aws_key_path = options.fetch("aws_key_path", AWS_KEY_PATH)
|
15
|
+
@s3_bucket = options.fetch("s3_bucket", S3_BUCKET)
|
16
|
+
@obfuscations = parse_obfuscations(options["obfuscations"] || {})
|
17
|
+
rescue KeyError => e
|
18
|
+
raise ConfigParseError, e
|
19
|
+
end
|
20
|
+
|
21
|
+
def verify!
|
22
|
+
@obfuscations.each do |field, strategy|
|
23
|
+
next if Scrubber::SCRUBBERS[strategy]
|
24
|
+
raise ConfigParseError, "Scrub strategy '#{strategy}' not found, but required by '#{field}'"
|
25
|
+
end
|
26
|
+
@klass_association_map.each do |klass, _|
|
27
|
+
next if klass.camelize.safe_constantize
|
28
|
+
raise ConfigParseError, "Class #{klass} not found"
|
29
|
+
end
|
30
|
+
self
|
31
|
+
end
|
32
|
+
|
33
|
+
def add_obfuscation(name, scrubber)
|
34
|
+
Scrubber::SCRUBBERS[name] = scrubber
|
35
|
+
end
|
36
|
+
|
37
|
+
def add_tactic(name, tactic)
|
38
|
+
Scrubber::TACTICS[name] = tactic
|
39
|
+
end
|
40
|
+
|
41
|
+
def app_tmp
|
42
|
+
Rails.root.join "tmp"
|
43
|
+
end
|
44
|
+
|
45
|
+
def dump_filename
|
46
|
+
"#{app_name}-scrubbed.dmp"
|
47
|
+
end
|
48
|
+
|
49
|
+
def remote_filename
|
50
|
+
compress ? "#{dump_filename}.gz" : dump_filename
|
51
|
+
end
|
52
|
+
|
53
|
+
def dump_path
|
54
|
+
app_tmp + dump_filename
|
55
|
+
end
|
56
|
+
|
57
|
+
def remote_path
|
58
|
+
app_tmp + remote_filename
|
59
|
+
end
|
60
|
+
|
61
|
+
def db
|
62
|
+
@db_config ||= ActiveRecord::Base.connection.instance_variable_get(:@config).dup
|
63
|
+
end
|
64
|
+
|
65
|
+
# TODO support other tranfer systems
|
66
|
+
def transferrer
|
67
|
+
Transferrer::S3.new(self)
|
68
|
+
end
|
69
|
+
|
70
|
+
def adapter
|
71
|
+
case db[:adapter].to_sym
|
72
|
+
when :mysql2
|
73
|
+
Adapter::MySQL.new(db)
|
74
|
+
when :postgres
|
75
|
+
Adapter::Postgres.new(db)
|
76
|
+
else
|
77
|
+
raise ConfigParseError, "Unsupported DB adapter #{db[:adapter]}"
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
# Convert generic cross table obfuscations to symbols so Polo parses them correctly
|
82
|
+
# "my_table.field" => "my_table.field"
|
83
|
+
# "my_field" => :my_field
|
84
|
+
def parse_obfuscations(obfuscations)
|
85
|
+
obfuscations.each_pair.with_object({}) do |field_and_strategy, hash|
|
86
|
+
field, strategy = field_and_strategy
|
87
|
+
strategy = strategy.to_sym
|
88
|
+
field.match(/\./) ? hash[field] = strategy : hash[field.to_sym] = strategy
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,44 @@
|
|
1
|
+
module Brillo
|
2
|
+
module Dumper
|
3
|
+
class MysqlDumper
|
4
|
+
include Helpers::ExecHelper
|
5
|
+
include Logger
|
6
|
+
attr_reader :config
|
7
|
+
def initialize(config)
|
8
|
+
@config = config
|
9
|
+
end
|
10
|
+
|
11
|
+
def dump
|
12
|
+
db = config.db
|
13
|
+
execute!(
|
14
|
+
"mysqldump",
|
15
|
+
host_arg,
|
16
|
+
"-u #{db[:username]}",
|
17
|
+
password_arg,
|
18
|
+
"--no-data",
|
19
|
+
"--single-transaction", # InnoDB only. Prevent MySQL locking the whole database during dump.
|
20
|
+
"#{db[:database]}",
|
21
|
+
"> #{config.dump_path}"
|
22
|
+
)
|
23
|
+
end
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def password_arg
|
28
|
+
if password = config.db[:password].presence
|
29
|
+
"--password=#{password}"
|
30
|
+
else
|
31
|
+
""
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def host_arg
|
36
|
+
if (host = config.db[:host].presence) && host != 'localhost'
|
37
|
+
"-h #{host}"
|
38
|
+
else
|
39
|
+
""
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'open3'
|
2
|
+
module Brillo
|
3
|
+
module Helpers
|
4
|
+
module ExecHelper
|
5
|
+
def execute *command
|
6
|
+
command_string = Array(command).join(' ')
|
7
|
+
log_anonymized command_string
|
8
|
+
stdout, stderr, status = Open3.capture3 command_string
|
9
|
+
[status.success?, stdout, stderr]
|
10
|
+
end
|
11
|
+
|
12
|
+
def execute! *command
|
13
|
+
success, stdout, stderr = execute(command)
|
14
|
+
if success
|
15
|
+
[success, stdout, stderr]
|
16
|
+
else
|
17
|
+
raise RuntimeError, stderr
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
private
|
22
|
+
|
23
|
+
def log_anonymized command_string
|
24
|
+
command_string = command_string.gsub(/--password=[^\s]+/, "--password={FILTERED}")
|
25
|
+
command_string = command_string.gsub(/EC2_ACCESS_KEY=[^\s]+/, "EC2_ACCESS_KEY={FILTERED}")
|
26
|
+
command_string = command_string.gsub(/EC2_SECRET_KEY=[^\s]+/, "EC2_SECRET_KEY={FILTERED}")
|
27
|
+
logger.info "Running \n\t #{command_string}"
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
module Brillo
|
2
|
+
# Responsible for fetching an existing SQL scrub from S3, cleaning the database,
|
3
|
+
# and loading the SQL.
|
4
|
+
class Loader
|
5
|
+
include Helpers::ExecHelper
|
6
|
+
include Logger
|
7
|
+
attr_reader :config
|
8
|
+
|
9
|
+
def initialize(config)
|
10
|
+
raise "⚠️ DON'T LOAD IN PRODUCTION! ⚠️" if Rails.env.production?
|
11
|
+
@config = config
|
12
|
+
end
|
13
|
+
|
14
|
+
def load!
|
15
|
+
config.transferrer.download
|
16
|
+
recreate_db
|
17
|
+
import_sql
|
18
|
+
end
|
19
|
+
|
20
|
+
def recreate_db
|
21
|
+
["db:drop", "db:create"].each do |t|
|
22
|
+
logger.info "Running\n\trake #{t}"
|
23
|
+
Rake::Task[t].invoke
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
def import_sql
|
28
|
+
if config.compress
|
29
|
+
execute!("gunzip -c #{config.remote_path} | #{sql_load_command}")
|
30
|
+
else
|
31
|
+
execute!("cat #{config.dump_path} | #{sql_load_command}")
|
32
|
+
end
|
33
|
+
logger.info "Import complete!"
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
37
|
+
|
38
|
+
def sql_load_command
|
39
|
+
config.adapter.load_command
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
module Brillo
|
2
|
+
# Responsible for creating a fresh scrubbed SQL copy of the database,
|
3
|
+
# as specified via config, and uploading to S3
|
4
|
+
class Scrubber
|
5
|
+
include Helpers::ExecHelper
|
6
|
+
include Logger
|
7
|
+
JUMBLE_PRNG = Random.new
|
8
|
+
LATEST_LIMIT = 1_000
|
9
|
+
|
10
|
+
# Define some procs as scrubbing strategies for Polo
|
11
|
+
SCRUBBERS = {
|
12
|
+
default_time: ->(t) { t.nil? ? Time.now.to_s(:sql) : t },
|
13
|
+
email: ->(e) { e.match(/@caring.com/) ? e : Digest::MD5.hexdigest(e) + "@example.com".freeze },
|
14
|
+
jumble: ->(j) { j.downcase.chars.shuffle!(random: JUMBLE_PRNG.clone).join },
|
15
|
+
phone: ->(n) { n = n.split(' ').first; n && n.length > 9 ? n[0..-5] + n[-1] + n[-2] + n[-3] + n[-4] : n}, # strips extensions
|
16
|
+
name: ->(n) { n.downcase.split(' ').map do |word|
|
17
|
+
word.chars.shuffle!(random: JUMBLE_PRNG.clone).join
|
18
|
+
end.each(&:capitalize!).join(' ')
|
19
|
+
},
|
20
|
+
}
|
21
|
+
|
22
|
+
TACTICS = {
|
23
|
+
latest: -> (klass) { klass.order('id desc').limit(LATEST_LIMIT).pluck(:id) },
|
24
|
+
all: -> (klass) { klass.pluck(:id) }
|
25
|
+
}
|
26
|
+
|
27
|
+
attr_reader :config, :adapter, :transferrer
|
28
|
+
|
29
|
+
def initialize(config)
|
30
|
+
@config = config
|
31
|
+
@adapter = config.adapter
|
32
|
+
end
|
33
|
+
|
34
|
+
def scrub!
|
35
|
+
FileUtils.rm [config.dump_path, config.remote_path], force: true
|
36
|
+
configure_polo
|
37
|
+
adapter.dump_structure_and_migrations(config)
|
38
|
+
explore_all_classes
|
39
|
+
compress
|
40
|
+
config.transferrer.upload
|
41
|
+
end
|
42
|
+
|
43
|
+
def explore_all_classes
|
44
|
+
File.open(config.dump_path, "a") do |sql_file|
|
45
|
+
sql_file.puts(adapter.header)
|
46
|
+
klass_association_map.each do |klass, options|
|
47
|
+
begin
|
48
|
+
klass = deserialize_class(klass)
|
49
|
+
tactic = deserialize_tactic(options)
|
50
|
+
rescue ConfigParseError => e
|
51
|
+
logger.error "Error in brillo.yml: #{e.message}"
|
52
|
+
next
|
53
|
+
end
|
54
|
+
associations = options.fetch("associations", [])
|
55
|
+
explore_class(klass, tactic, associations) do |insert|
|
56
|
+
sql_file.puts(insert)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
sql_file.puts(adapter.footer)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
private
|
64
|
+
|
65
|
+
def compress
|
66
|
+
return unless config.compress
|
67
|
+
execute!("gzip -f #{config.dump_path}")
|
68
|
+
end
|
69
|
+
|
70
|
+
def explore_class(klass, tactic_or_ids, associations)
|
71
|
+
ids = tactic_or_ids.is_a?(Symbol) ? TACTICS.fetch(tactic_or_ids).call(klass) : tactic_or_ids
|
72
|
+
logger.info("Scrubbing #{ids.length} #{klass} rows with associations #{associations}")
|
73
|
+
Polo.explore(klass, ids, associations).each do |row|
|
74
|
+
yield "#{row};"
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def klass_association_map
|
79
|
+
config.klass_association_map
|
80
|
+
end
|
81
|
+
|
82
|
+
def obfuscations
|
83
|
+
config.obfuscations.map do |field, strategy|
|
84
|
+
[field, SCRUBBERS.fetch(strategy)]
|
85
|
+
end.to_h
|
86
|
+
end
|
87
|
+
|
88
|
+
def configure_polo
|
89
|
+
obfs = obfuscations
|
90
|
+
adapter = config.db[:adapter]
|
91
|
+
Polo.configure do
|
92
|
+
obfuscate obfs
|
93
|
+
if adapter == "mysql2"
|
94
|
+
on_duplicate :ignore
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
def deserialize_class(klass)
|
100
|
+
klass.camelize.constantize
|
101
|
+
rescue
|
102
|
+
raise Config::ConfigParseError, "Could not process class '#{klass}'"
|
103
|
+
end
|
104
|
+
|
105
|
+
def deserialize_tactic(options)
|
106
|
+
options.fetch("tactic").to_sym
|
107
|
+
rescue KeyError
|
108
|
+
raise ConfigParseError, "Tactic not specified for class #{klass}"
|
109
|
+
end
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
module Brillo
|
2
|
+
module Transferrer
|
3
|
+
class S3
|
4
|
+
include Helpers::ExecHelper
|
5
|
+
include Logger
|
6
|
+
attr_reader :credentials, :bucket, :remote_filename, :remote_path, :download_enabled, :upload_enabled
|
7
|
+
attr_reader :key_path
|
8
|
+
|
9
|
+
def initialize(config)
|
10
|
+
@download_enabled = config.fetch_from_s3
|
11
|
+
@upload_enabled = config.send_to_s3
|
12
|
+
@bucket = config.s3_bucket
|
13
|
+
@key_path = config.aws_key_path
|
14
|
+
@remote_filename = config.remote_filename
|
15
|
+
@remote_path = config.remote_path
|
16
|
+
load_credentials
|
17
|
+
end
|
18
|
+
|
19
|
+
def download
|
20
|
+
return unless download_enabled
|
21
|
+
load_credentials
|
22
|
+
FileUtils.rm [config.dump_path, config.remote_path], force: true
|
23
|
+
aws_s3 "get"
|
24
|
+
end
|
25
|
+
|
26
|
+
def upload
|
27
|
+
return unless upload_enabled
|
28
|
+
load_credentials
|
29
|
+
aws_s3 "put"
|
30
|
+
end
|
31
|
+
|
32
|
+
private
|
33
|
+
|
34
|
+
def load_credentials
|
35
|
+
if File.exist?(key_path)
|
36
|
+
@credentials = YAML.load_file(key_path)
|
37
|
+
else
|
38
|
+
key = ENV["AWS_SECRET_KEY"] || ENV["EC2_SECRET_KEY"]
|
39
|
+
unless key && key.length > 10
|
40
|
+
raise CredentialsError, "AWS credentials not found. Expected AWS_ACCESS_KEY and AWS_SECRET_KEY to be set!"
|
41
|
+
end
|
42
|
+
@credentials = {
|
43
|
+
'aws_access_key' => ENV["AWS_ACCESS_KEY"] || ENV["EC2_ACCESS_KEY"],
|
44
|
+
'aws_secret_key' => key
|
45
|
+
}
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
def aws_s3 api_command
|
50
|
+
execute!("#{aws_env} #{aws_bin} #{api_command} #{bucket}/#{remote_filename} #{remote_path}")
|
51
|
+
end
|
52
|
+
|
53
|
+
def aws_bin
|
54
|
+
if File.exist?('/usr/local/bin/awstk')
|
55
|
+
'/usr/local/bin/awstk'
|
56
|
+
else
|
57
|
+
'/usr/local/bin/aws'
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def aws_env
|
62
|
+
"EC2_ACCESS_KEY=#{credentials['aws_access_key']} EC2_SECRET_KEY=#{credentials['aws_secret_key']}"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Brillo
|
2
|
+
# Responsible for asserting that the config file is valid
|
3
|
+
class Scrubber
|
4
|
+
include Common
|
5
|
+
|
6
|
+
attr_reader :config
|
7
|
+
|
8
|
+
def initialize(config)
|
9
|
+
parse_config(config)
|
10
|
+
end
|
11
|
+
|
12
|
+
def validate
|
13
|
+
errors = Hash.new({}.freeze)
|
14
|
+
klass_association_map.each do |klass, options|
|
15
|
+
begin
|
16
|
+
real_klass = deserialize_class(klass)
|
17
|
+
rescue
|
18
|
+
errors[klass][:name] = "No such class #{klass.camelize}, did you use the singular?"
|
19
|
+
end
|
20
|
+
begin
|
21
|
+
tactic = options.fetch("tactic").to_sym
|
22
|
+
rescue KeyError
|
23
|
+
errors[klass][:tactic] "Tactic not specified"
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def deserialize_class(klass)
|
29
|
+
klass.camelize.constantize
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
data/lib/brillo.rb
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
require "brillo/version"
|
2
|
+
|
3
|
+
require 'brillo/errors'
|
4
|
+
require 'brillo/helpers/exec_helper'
|
5
|
+
require 'brillo/logger'
|
6
|
+
|
7
|
+
require 'brillo/adapter/base'
|
8
|
+
require 'brillo/adapter/mysql'
|
9
|
+
require 'brillo/adapter/postgres'
|
10
|
+
|
11
|
+
require 'brillo/transferrer/s3'
|
12
|
+
|
13
|
+
require 'brillo/dumper/mysql_dumper'
|
14
|
+
require 'brillo/railtie'
|
15
|
+
require 'brillo/config'
|
16
|
+
require 'brillo/scrubber'
|
17
|
+
require 'brillo/loader'
|
18
|
+
require 'polo'
|
19
|
+
|
20
|
+
module Brillo
|
21
|
+
def self.configure
|
22
|
+
yield config
|
23
|
+
begin
|
24
|
+
config.verify!
|
25
|
+
rescue ConfigParseError => e
|
26
|
+
puts "Brillo config contains errors: #{e}"
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.scrub!(logger: ::Logger.new(STDOUT))
|
31
|
+
Brillo::Logger.logger = logger
|
32
|
+
Scrubber.new(config).scrub!
|
33
|
+
end
|
34
|
+
|
35
|
+
def self.load!(logger: ::Logger.new(STDOUT))
|
36
|
+
Brillo::Logger.logger = logger
|
37
|
+
Loader.new(config).load!
|
38
|
+
end
|
39
|
+
|
40
|
+
def self.config
|
41
|
+
@config ||= begin
|
42
|
+
static_config = YAML.load_file("#{Rails.root.to_s}/config/brillo.yml")
|
43
|
+
Config.new(static_config)
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.config=(config)
|
48
|
+
@config = config
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
load File.expand_path('../tasks/db.cap', __FILE__)
|
@@ -0,0 +1,30 @@
|
|
1
|
+
namespace :db do
|
2
|
+
desc "Run the DB scrubber and push to S3"
|
3
|
+
task :scrub do
|
4
|
+
on primary(fetch(:brillo_role)) do
|
5
|
+
with rails_env: fetch(:stage) do
|
6
|
+
within current_path do
|
7
|
+
execute :rake, 'db:scrub'
|
8
|
+
end
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
|
13
|
+
desc "Run the S3 scrubbed DB loader"
|
14
|
+
task :load do
|
15
|
+
raise "Don't you dare!" if fetch(:stage) == "production"
|
16
|
+
on release_roles(:all).sample do
|
17
|
+
with rails_env: fetch(:stage) do
|
18
|
+
within current_path do
|
19
|
+
execute :rake, 'db:load'
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
namespace :load do
|
27
|
+
task :defaults do
|
28
|
+
set :brillo_role, :batch
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,9 @@
|
|
1
|
+
class BrilloConfigGenerator < Rails::Generators::Base
|
2
|
+
source_root File.expand_path("../../../", __FILE__)
|
3
|
+
|
4
|
+
desc "Create a plain Brillo YAML configuration and initializer"
|
5
|
+
def create_initializer_file
|
6
|
+
copy_file "config/brillo-example.yml", "config/brillo.yml"
|
7
|
+
copy_file "config/brillo-initializer.rb", "config/initializers/brillo.rb"
|
8
|
+
end
|
9
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
namespace :db do
|
4
|
+
desc 'Upload a scrubbed copy of the database as specified by config/scrub.yml to S3'
|
5
|
+
task :scrub => :environment do
|
6
|
+
logger = ENV["VERBOSE"] ? Logger.new(STDOUT) : Rails.logger
|
7
|
+
logger = Logger.new(STDOUT)
|
8
|
+
logger.level = ENV["VERBOSE"] ? Logger::DEBUG : Logger::WARN
|
9
|
+
begin
|
10
|
+
Brillo.scrub!(logger: logger)
|
11
|
+
rescue CredentialsError => e
|
12
|
+
puts e
|
13
|
+
exit(1)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
desc 'Load a previously created scrubbed database copy from S3'
|
18
|
+
task :load => :environment do
|
19
|
+
begin
|
20
|
+
Brillo.load!
|
21
|
+
rescue CredentialsError => e
|
22
|
+
puts e
|
23
|
+
exit(1)
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
metadata
ADDED
@@ -0,0 +1,191 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: brillo
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 1.0.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Matt Bessey
|
8
|
+
autorequire:
|
9
|
+
bindir: exe
|
10
|
+
cert_chain: []
|
11
|
+
date: 2016-05-29 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: rake
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - "~>"
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '10.0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - "~>"
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '10.0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: capistrano
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - "~>"
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '3.0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - "~>"
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '3.0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: polo
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - "~>"
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0.3'
|
48
|
+
type: :runtime
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - "~>"
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0.3'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rails
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '3.2'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '3.2'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - "~>"
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '3.4'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - "~>"
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '3.4'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: pry
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
- !ruby/object:Gem::Dependency
|
98
|
+
name: benchmark-ips
|
99
|
+
requirement: !ruby/object:Gem::Requirement
|
100
|
+
requirements:
|
101
|
+
- - ">="
|
102
|
+
- !ruby/object:Gem::Version
|
103
|
+
version: '0'
|
104
|
+
type: :development
|
105
|
+
prerelease: false
|
106
|
+
version_requirements: !ruby/object:Gem::Requirement
|
107
|
+
requirements:
|
108
|
+
- - ">="
|
109
|
+
- !ruby/object:Gem::Version
|
110
|
+
version: '0'
|
111
|
+
- !ruby/object:Gem::Dependency
|
112
|
+
name: geminabox
|
113
|
+
requirement: !ruby/object:Gem::Requirement
|
114
|
+
requirements:
|
115
|
+
- - ">="
|
116
|
+
- !ruby/object:Gem::Version
|
117
|
+
version: '0'
|
118
|
+
type: :development
|
119
|
+
prerelease: false
|
120
|
+
version_requirements: !ruby/object:Gem::Requirement
|
121
|
+
requirements:
|
122
|
+
- - ">="
|
123
|
+
- !ruby/object:Gem::Version
|
124
|
+
version: '0'
|
125
|
+
description:
|
126
|
+
email:
|
127
|
+
- mbessey@caring.com
|
128
|
+
executables: []
|
129
|
+
extensions: []
|
130
|
+
extra_rdoc_files: []
|
131
|
+
files:
|
132
|
+
- ".gitignore"
|
133
|
+
- ".rspec"
|
134
|
+
- ".travis.yml"
|
135
|
+
- CHANGELOG.md
|
136
|
+
- Gemfile
|
137
|
+
- LICENSE
|
138
|
+
- README.md
|
139
|
+
- Rakefile
|
140
|
+
- bin/console
|
141
|
+
- bin/setup
|
142
|
+
- brillo.gemspec
|
143
|
+
- config/brillo-example.yml
|
144
|
+
- config/brillo-initializer.rb
|
145
|
+
- lib/brillo.rb
|
146
|
+
- lib/brillo/adapter/README.md
|
147
|
+
- lib/brillo/adapter/base.rb
|
148
|
+
- lib/brillo/adapter/mysql.rb
|
149
|
+
- lib/brillo/adapter/postgres.rb
|
150
|
+
- lib/brillo/config.rb
|
151
|
+
- lib/brillo/dumper/mysql_dumper.rb
|
152
|
+
- lib/brillo/errors.rb
|
153
|
+
- lib/brillo/helpers/exec_helper.rb
|
154
|
+
- lib/brillo/loader.rb
|
155
|
+
- lib/brillo/logger.rb
|
156
|
+
- lib/brillo/railtie.rb
|
157
|
+
- lib/brillo/scrubber.rb
|
158
|
+
- lib/brillo/transferrer/s3.rb
|
159
|
+
- lib/brillo/validator.rb
|
160
|
+
- lib/brillo/version.rb
|
161
|
+
- lib/capistrano/brillo.rb
|
162
|
+
- lib/capistrano/tasks/db.cap
|
163
|
+
- lib/generators/brillo.rb
|
164
|
+
- lib/tasks/brillo.rake
|
165
|
+
homepage: https://github.com/bessey/brillo
|
166
|
+
licenses:
|
167
|
+
- MIT
|
168
|
+
metadata: {}
|
169
|
+
post_install_message:
|
170
|
+
rdoc_options: []
|
171
|
+
require_paths:
|
172
|
+
- lib
|
173
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
174
|
+
requirements:
|
175
|
+
- - ">="
|
176
|
+
- !ruby/object:Gem::Version
|
177
|
+
version: '0'
|
178
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
179
|
+
requirements:
|
180
|
+
- - ">="
|
181
|
+
- !ruby/object:Gem::Version
|
182
|
+
version: '0'
|
183
|
+
requirements: []
|
184
|
+
rubyforge_project:
|
185
|
+
rubygems_version: 2.2.2
|
186
|
+
signing_key:
|
187
|
+
specification_version: 4
|
188
|
+
summary: Rails database scrubber and loader, great for seeding your dev db with real
|
189
|
+
but sanitized data
|
190
|
+
test_files: []
|
191
|
+
has_rdoc:
|