Skip to content

Commit c7a3be1

Browse files
committed
Add plugin system
1 parent d2455bd commit c7a3be1

24 files changed

+387
-105
lines changed

chronicle-etl.gemspec

+2
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ Gem::Specification.new do |spec|
4949
spec.add_dependency "thor", "~> 1.2"
5050
spec.add_dependency "thor-hollaback", "~> 0.2"
5151
spec.add_dependency "tty-progressbar", "~> 0.17"
52+
spec.add_dependency "tty-spinner"
5253
spec.add_dependency "tty-table", "~> 0.11"
54+
spec.add_dependency "tty-prompt", "~> 0.23"
5355

5456
spec.add_development_dependency "bundler", "~> 2.1"
5557
spec.add_development_dependency "pry-byebug", "~> 3.9"

lib/chronicle/etl/cli.rb

+1
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
require 'chronicle/etl/cli/subcommand_base'
66
require 'chronicle/etl/cli/connectors'
77
require 'chronicle/etl/cli/jobs'
8+
require 'chronicle/etl/cli/plugins'
89
require 'chronicle/etl/cli/main'

lib/chronicle/etl/cli/connectors.rb

+6-11
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,6 @@ class Connectors < SubcommandBase
88
default_task 'list'
99
namespace :connectors
1010

11-
desc "install NAME", "Installs connector NAME"
12-
def install(name)
13-
Chronicle::ETL::Registry.install_connector(name)
14-
end
15-
1611
desc "list", "Lists available connectors"
1712
# Display all available connectors that chronicle-etl has access to
1813
def list
@@ -44,21 +39,21 @@ def list
4439
desc "show PHASE IDENTIFIER", "Show information about a connector"
4540
def show(phase, identifier)
4641
unless ['extractor', 'transformer', 'loader'].include?(phase)
47-
puts "phase argument must be one of: [extractor, transformer, loader]"
48-
return
42+
Chronicle::ETL::Logger.fatal("Phase argument must be one of: [extractor, transformer, loader]")
43+
exit 1
4944
end
5045

5146
begin
5247
connector = Chronicle::ETL::Registry.find_by_phase_and_identifier(phase.to_sym, identifier)
53-
rescue Chronicle::ETL::ConnectorNotAvailableError
54-
puts "Could not find #{phase} #{identifier}"
55-
return
48+
rescue Chronicle::ETL::ConnectorNotAvailableError, Chronicle::ETL::PluginError
49+
Chronicle::ETL::Logger.fatal("Could not find #{phase} #{identifier}")
50+
exit 1
5651
end
5752

5853
puts connector.klass.to_s.bold
5954
puts " #{connector.descriptive_phrase}"
6055
puts
61-
puts "OPTIONS"
56+
puts "Settings:"
6257

6358
headers = ['name', 'default', 'required'].map{ |h| h.to_s.upcase.bold }
6459

lib/chronicle/etl/cli/jobs.rb

+36-20
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
require 'pp'
2+
require 'tty-prompt'
23

34
module Chronicle
45
module ETL
56
module CLI
67
# CLI commands for working with ETL jobs
78
class Jobs < SubcommandBase
89
default_task "start"
9-
namespace :jobs
10+
namespace :jobs
1011

1112
class_option :name, aliases: '-j', desc: 'Job configuration name'
1213

@@ -26,15 +27,9 @@ class Jobs < SubcommandBase
2627
class_option :output, aliases: '-o', desc: 'Output filename', type: 'string'
2728
class_option :fields, desc: 'Output only these fields', type: 'array', banner: 'field1 field2 ...'
2829

29-
class_option :log_level, desc: 'Log level (debug, info, warn, error, fatal)', default: 'info'
30-
class_option :verbose, aliases: '-v', desc: 'Set log level to verbose', type: :boolean
31-
class_option :silent, desc: 'Silence all output', type: :boolean
32-
3330
# Thor doesn't like `run` as a command name
3431
map run: :start
3532
desc "run", "Start a job"
36-
option :log_level, desc: 'Log level (debug, info, warn, error, fatal)', default: 'info'
37-
option :verbose, aliases: '-v', desc: 'Set log level to verbose', type: :boolean
3833
option :dry_run, desc: 'Only run the extraction and transform steps, not the loading', type: :boolean
3934
long_desc <<-LONG_DESC
4035
This will run an ETL job. Each job needs three parts:
@@ -49,11 +44,14 @@ class Jobs < SubcommandBase
4944
LONG_DESC
5045
# Run an ETL job
5146
def start
52-
setup_log_level
53-
job_definition = build_job_definition(options)
54-
job = Chronicle::ETL::Job.new(job_definition)
55-
runner = Chronicle::ETL::Runner.new(job)
56-
runner.run!
47+
run_job(options)
48+
rescue Chronicle::ETL::JobDefinitionError => e
49+
missing_plugins = e.job_definition.errors
50+
.select { |error| error.is_a?(Chronicle::ETL::PluginLoadError) }
51+
.map(&:name)
52+
.uniq
53+
return unless install_missing_plugins(missing_plugins)
54+
run_job(options)
5755
end
5856

5957
desc "create", "Create a job"
@@ -87,21 +85,39 @@ def list
8785

8886
headers = ['name', 'extractor', 'transformer', 'loader'].map { |h| h.upcase.bold }
8987

88+
puts "Available jobs:"
9089
table = TTY::Table.new(headers, job_details)
9190
puts table.render(indent: 0, padding: [0, 2])
9291
end
9392

9493
private
9594

96-
def setup_log_level
97-
if options[:silent]
98-
Chronicle::ETL::Logger.log_level = Chronicle::ETL::Logger::SILENT
99-
elsif options[:verbose]
100-
Chronicle::ETL::Logger.log_level = Chronicle::ETL::Logger::DEBUG
101-
elsif options[:log_level]
102-
level = Chronicle::ETL::Logger.const_get(options[:log_level].upcase)
103-
Chronicle::ETL::Logger.log_level = level
95+
def run_job(options)
96+
job_definition = build_job_definition(options)
97+
job = Chronicle::ETL::Job.new(job_definition)
98+
runner = Chronicle::ETL::Runner.new(job)
99+
runner.run!
100+
end
101+
102+
def install_missing_plugins(missing_plugins)
103+
prompt = TTY::Prompt.new
104+
message = "Plugin#{'s' if missing_plugins.count > 1} specified by job not installed.\n"
105+
message += "Do you want to install "
106+
message += missing_plugins.map { |name| "chronicle-#{name}".bold}.join(", ")
107+
message += " and start the job?"
108+
install = prompt.yes?(message)
109+
return unless install
110+
111+
spinner = TTY::Spinner.new("[:spinner] Installing plugins...", format: :dots_2)
112+
spinner.auto_spin
113+
missing_plugins.each do |plugin|
114+
Chronicle::ETL::Registry::PluginRegistry.install(plugin)
104115
end
116+
spinner.success("(#{'successful'.green})")
117+
true
118+
rescue Chronicle::ETL::PluginNotAvailableError => e
119+
spinner.error("Plugin #{e.name} could not be installed")
120+
false
105121
end
106122

107123
# Create job definition by reading config file and then overwriting with flag options

lib/chronicle/etl/cli/main.rb

+3
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ class Main < ::Thor
2121
desc 'jobs:COMMAND', 'Configure and run jobs', hide: true
2222
subcommand 'jobs', Jobs
2323

24+
desc 'plugins:COMMAND', 'Configure plugins', hide: true
25+
subcommand 'plugins', Plugins
26+
2427
# Entrypoint for the CLI
2528
def self.start(given_args = ARGV, config = {})
2629
# take a subcommand:command and splits them so Thor knows how to hand off to the subcommand class

lib/chronicle/etl/cli/plugins.rb

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# frozen_string_literal: true
2+
3+
require "tty-prompt"
4+
require "tty-spinner"
5+
6+
7+
module Chronicle
8+
module ETL
9+
module CLI
10+
# CLI commands for working with ETL plugins
11+
class Plugins < SubcommandBase
12+
default_task 'list'
13+
namespace :plugins
14+
15+
desc "install", "Install a plugin"
16+
def install(name)
17+
spinner = TTY::Spinner.new("[:spinner] Installing plugin #{name}...", format: :dots_2)
18+
spinner.auto_spin
19+
Chronicle::ETL::Registry::PluginRegistry.install(name)
20+
spinner.success("(#{'successful'.green})")
21+
rescue Chronicle::ETL::PluginError => e
22+
spinner.error("Error".red)
23+
Chronicle::ETL::Logger.debug(e.full_message)
24+
Chronicle::ETL::Logger.fatal("Plugin '#{name}' could not be installed".red)
25+
exit 1
26+
end
27+
28+
desc "uninstall", "Unintall a plugin"
29+
def uninstall(name)
30+
spinner = TTY::Spinner.new("[:spinner] Uninstalling plugin #{name}...", format: :dots_2)
31+
spinner.auto_spin
32+
Chronicle::ETL::Registry::PluginRegistry.uninstall(name)
33+
spinner.success("(#{'successful'.green})")
34+
rescue Chronicle::ETL::PluginError => e
35+
spinner.error("Error".red)
36+
Chronicle::ETL::Logger.debug(e.full_message)
37+
Chronicle::ETL::Logger.fatal("Plugin '#{name}' could not be uninstalled (was it installed?)".red)
38+
exit 1
39+
end
40+
41+
desc "list", "Lists available plugins"
42+
# Display all available plugins that chronicle-etl has access to
43+
def list
44+
plugins = Chronicle::ETL::Registry::PluginRegistry.all_installed_latest
45+
46+
info = plugins.map do |plugin|
47+
{
48+
name: plugin.name.sub("chronicle-", ""),
49+
description: plugin.description,
50+
version: plugin.version
51+
}
52+
end
53+
54+
headers = ['name', 'description', 'latest version'].map{ |h| h.to_s.upcase.bold }
55+
table = TTY::Table.new(headers, info.map(&:values))
56+
puts "Installed plugins:"
57+
puts table.render(indent: 2, padding: [0, 0])
58+
end
59+
end
60+
end
61+
end
62+
end

lib/chronicle/etl/config.rb

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ def write(path, data)
2323
end
2424

2525
# Returns all jobs available in ~/.config/chronicle/etl/jobs/*.yml
26+
# TODO: raise error if we can't read directory
2627
def available_jobs
2728
job_directory = Runcom::Config.new('chronicle/etl/jobs').current
2829
Dir.glob(File.join(job_directory, "*.yml")).map do |filename|

lib/chronicle/etl/exceptions.rb

+20
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,26 @@ class ConfigurationError < Error; end
66

77
class RunnerTypeError < Error; end
88

9+
class JobDefinitionError < Error
10+
attr_reader :job_definition
11+
12+
def initialize(job_definition)
13+
@job_definition = job_definition
14+
super
15+
end
16+
end
17+
18+
class PluginError < Error
19+
attr_reader :name
20+
21+
def initialize(name)
22+
@name = name
23+
end
24+
end
25+
26+
class PluginNotAvailableError < PluginError; end
27+
class PluginLoadError < PluginError; end
28+
929
class ConnectorNotAvailableError < Error
1030
def initialize(message, provider: nil, name: nil)
1131
super(message)

lib/chronicle/etl/job.rb

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
require 'forwardable'
2+
23
module Chronicle
34
module ETL
5+
# A runner job
6+
#
7+
# TODO: this can probably be merged with JobDefinition. Not clear
8+
# where the boundaries are
49
class Job
510
extend Forwardable
611

@@ -12,7 +17,8 @@ class Job
1217
:transformer_klass,
1318
:transformer_options,
1419
:loader_klass,
15-
:loader_options
20+
:loader_options,
21+
:job_definition
1622

1723
# TODO: build a proper id system
1824
alias id name

lib/chronicle/etl/job_definition.rb

+19-4
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,31 @@ class JobDefinition
1919
}
2020
}.freeze
2121

22+
attr_reader :errors
2223
attr_accessor :definition
2324

2425
def initialize()
2526
@definition = SKELETON_DEFINITION
2627
end
2728

29+
def validate
30+
@errors = []
31+
32+
Chronicle::ETL::Registry::PHASES.each do |phase|
33+
__send__("#{phase}_klass".to_sym)
34+
rescue Chronicle::ETL::PluginError => e
35+
@errors << e
36+
end
37+
38+
@errors.empty?
39+
end
40+
41+
def validate!
42+
raise(Chronicle::ETL::JobDefinitionError.new(self), "Job definition is invalid") unless validate
43+
44+
true
45+
end
46+
2847
# Add config hash to this definition
2948
def add_config(config = {})
3049
@definition = @definition.deep_merge(config)
@@ -80,10 +99,6 @@ def load_credentials
8099
end
81100
end
82101
end
83-
84-
def validate
85-
return true # TODO
86-
end
87102
end
88103
end
89104
end

lib/chronicle/etl/logger.rb

+5-2
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,21 @@ module Logger
1313
attr_accessor :log_level
1414

1515
@log_level = INFO
16-
@destination = $stderr
1716

1817
def output message, level
1918
return unless level >= @log_level
2019

2120
if @progress_bar
2221
@progress_bar.log(message)
2322
else
24-
@destination.puts(message)
23+
$stderr.puts(message)
2524
end
2625
end
2726

27+
def fatal(message)
28+
output(message, FATAL)
29+
end
30+
2831
def error(message)
2932
output(message, ERROR)
3033
end

lib/chronicle/etl/registry/connector_registration.rb

+5
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ def provider
4444
@provider || (built_in? ? 'chronicle' : '')
4545
end
4646

47+
# TODO: allow overriding here. Maybe through self-registration process
48+
def plugin
49+
@provider
50+
end
51+
4752
def descriptive_phrase
4853
prefix = case phase
4954
when :extractor

0 commit comments

Comments
 (0)