robot_sweatshop 0.1.2

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +6 -0
  3. data/.rubocop.yml +12 -0
  4. data/Gemfile +2 -0
  5. data/LICENSE +21 -0
  6. data/README.md +45 -0
  7. data/Rakefile +3 -0
  8. data/bin/lib/common.rb +49 -0
  9. data/bin/lib/config.rb +20 -0
  10. data/bin/lib/inspect.rb +42 -0
  11. data/bin/lib/job.rb +13 -0
  12. data/bin/lib/start.rb +11 -0
  13. data/bin/sweatshop +73 -0
  14. data/config.rb +24 -0
  15. data/config.yaml +12 -0
  16. data/jobs/example.yaml +10 -0
  17. data/kintama/README.md +3 -0
  18. data/kintama/data/payload_data.yaml +6 -0
  19. data/kintama/end-to-end_spec.rb +30 -0
  20. data/kintama/input_http_spec.rb +45 -0
  21. data/kintama/job_assembler_spec.rb +72 -0
  22. data/kintama/job_worker_spec.rb +65 -0
  23. data/kintama/moneta-queue_spec.rb +48 -0
  24. data/kintama/payload_parser_spec.rb +71 -0
  25. data/kintama/queue_broadcaster_spec.rb +39 -0
  26. data/kintama/queue_handler_spec.rb +54 -0
  27. data/kintama/run_all.rb +3 -0
  28. data/kintama/shared/helpers.rb +55 -0
  29. data/kintama/shared/process_spawning.rb +13 -0
  30. data/lib/README.md +12 -0
  31. data/lib/input/http.rb +27 -0
  32. data/lib/job/assembler.rb +36 -0
  33. data/lib/job/worker.rb +40 -0
  34. data/lib/payload/lib/bitbucket.rb +61 -0
  35. data/lib/payload/lib/github.rb +45 -0
  36. data/lib/payload/lib/payload.rb +12 -0
  37. data/lib/payload/parser.rb +23 -0
  38. data/lib/queue-helper.rb +32 -0
  39. data/lib/queue/broadcaster.rb +18 -0
  40. data/lib/queue/handler.rb +23 -0
  41. data/lib/queue/lib/moneta-queue.rb +49 -0
  42. data/lib/queue/watcher.rb +18 -0
  43. data/robot_sweatshop.eye +58 -0
  44. data/robot_sweatshop.gemspec +26 -0
  45. data/robot_sweatshop.production.eye +8 -0
  46. data/robot_sweatshop.testing.eye +8 -0
  47. data/workspaces/.keep +0 -0
  48. metadata +233 -0
data/lib/input/http.rb ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ require 'sinatra'
3
+ require 'ezmq'
4
+ require 'json'
5
+ require_relative '../../config'
6
+
7
+ configure do
8
+ set :port, configatron.input.http.port
9
+ set :bind, configatron.input.http.bind
10
+ set :output_queue, 'raw-payload'
11
+ end
12
+
13
+ get '/' do
14
+ 'Everything\'s on schedule!'
15
+ end
16
+
17
+ post '/:format/payload-for/:job_name' do
18
+ puts "Received #{params['format']} payload for #{params['job_name']}"
19
+ request.body.rewind
20
+ hash = {
21
+ payload: request.body.read,
22
+ format: params['format'],
23
+ job_name: params['job_name']
24
+ }
25
+ client = EZMQ::Client.new port: 5556
26
+ client.request "#{settings.output_queue} #{JSON.generate hash}"
27
+ end
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env ruby
2
+ require 'yaml'
3
+ require_relative '../queue-helper'
4
+
5
+ def get_config(for_job_name:)
6
+ job_directory = "#{__dir__}/../../jobs"
7
+ job_config_path = "#{job_directory}/#{for_job_name}.yaml"
8
+ unless File.file? job_config_path
9
+ puts "No config found for job '#{for_job_name}'"
10
+ return nil
11
+ end
12
+ YAML.load_file job_config_path
13
+ end
14
+
15
+ def assemble_job(data)
16
+ job_config = get_config for_job_name: data['job_name']
17
+ return nil unless job_config
18
+ if job_config['branch_whitelist'].include? data['payload']['branch']
19
+ context = job_config['environment'].merge(data['payload'])
20
+ context.each { |key, value| context[key] = value.to_s }
21
+ {
22
+ commands: job_config['commands'],
23
+ context: context,
24
+ job_name: data['job_name']
25
+ }
26
+ else
27
+ puts "Branch '#{data['payload']['branch']}' is not whitelisted"
28
+ nil
29
+ end
30
+ end
31
+
32
+ QueueHelper.wait_for('parsed-payload') do |data|
33
+ puts "Assembling: #{data}"
34
+ assembled_job = assemble_job data
35
+ QueueHelper.enqueue assembled_job, to: 'jobs' if assembled_job
36
+ end
data/lib/job/worker.rb ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env ruby
2
+ require 'faker'
3
+ require 'fileutils'
4
+ require_relative '../queue-helper'
5
+
6
+ # TODO: check existing worker ids. it'd be disastrous to have two sharing a workspace
7
+ @worker_id = ARGV[0] || "#{Faker::Name.first_name}"
8
+
9
+ def from_workspace(named: 'no_job_name')
10
+ workspace = "#{named}-#{@worker_id}"
11
+ puts "Workspace: #{workspace}"
12
+ path = File.expand_path "#{__dir__}/../../workspaces/#{workspace}"
13
+ FileUtils.mkpath path
14
+ Dir.chdir(path) { yield if block_given? }
15
+ end
16
+
17
+ def execute(context = {}, command)
18
+ puts "Executing '#{command}'..."
19
+ # TODO: path.split(' ') to bypass the shell when we're not using env vars
20
+
21
+ # Run the command with the context in environment,
22
+ # printing the output as it's generated
23
+ IO.popen(context, command) do |io_stream|
24
+ while line = io_stream.gets
25
+ puts line
26
+ end
27
+ end
28
+ puts "Execution complete with exit status: #{$?.exitstatus}"
29
+ end
30
+
31
+ QueueHelper.wait_for('jobs') do |data|
32
+ puts "Running: #{data}"
33
+ if data['commands'].is_a? Array
34
+ from_workspace(named: data['job_name']) do
35
+ context = data['context'] || {}
36
+ data['commands'].each { |command| execute context, command }
37
+ end
38
+ end
39
+ puts "Job finished.\n\n"
40
+ end
@@ -0,0 +1,61 @@
1
+ require 'uri'
2
+ require 'json'
3
+ require_relative 'payload'
4
+
5
+ # A parser for Bitbucket payload data
6
+ class BitbucketPayload < Payload
7
+ def initialize(data)
8
+ data = URI.decode_www_form(data)[0][1]
9
+ @data = JSON.parse data || {}
10
+ end
11
+
12
+ def latest_commit
13
+ return {} if @data['commits'].nil?
14
+ @data['commits'].last
15
+ end
16
+
17
+ def repository
18
+ return {} if @data['repository'].nil?
19
+ @data['repository']
20
+ end
21
+
22
+ def author
23
+ return {} if latest_commit['raw_author'].nil?
24
+ name, email = latest_commit['raw_author'].split(/\s+</)
25
+ email.slice! '>' unless email.nil?
26
+ {
27
+ 'name' => name,
28
+ 'email' => email || '',
29
+ 'username' => latest_commit['author']
30
+ }
31
+ end
32
+
33
+ def clone_url
34
+ "#{ @data['canon_url'] }#{ repository['absolute_url'] }"
35
+ end
36
+
37
+ def hash
38
+ latest_commit['raw_node']
39
+ end
40
+
41
+ def branch
42
+ latest_commit['branch']
43
+ end
44
+
45
+ def message
46
+ latest_commit['message']
47
+ end
48
+
49
+ def repo_slug
50
+ slug = repository['absolute_url']
51
+ slug.nil? ? nil : slug[1...-1]
52
+ end
53
+
54
+ def source_url
55
+ return '' if @data['canon_url'].nil? ||
56
+ repository.empty? ||
57
+ latest_commit.empty?
58
+ base_url = @data['canon_url']
59
+ "#{base_url}/#{repo_slug}/commits/#{hash}/?at=#{branch}"
60
+ end
61
+ end
@@ -0,0 +1,45 @@
1
+ require 'json'
2
+ require_relative 'payload'
3
+
4
+ # A parser for Github payload data
5
+ class GithubPayload < Payload
6
+ def initialize(payload)
7
+ @payload = JSON.parse payload || {}
8
+ end
9
+
10
+ def commit
11
+ @payload['head_commit'] || {}
12
+ end
13
+
14
+ def repository
15
+ @payload['repository'] || {}
16
+ end
17
+
18
+ def clone_url
19
+ repository['clone_url'] || ''
20
+ end
21
+
22
+ def author
23
+ commit['author']
24
+ end
25
+
26
+ def hash
27
+ commit['id'] || {}
28
+ end
29
+
30
+ def branch
31
+ @payload['ref'] || ''
32
+ end
33
+
34
+ def message
35
+ commit['message'] || ''
36
+ end
37
+
38
+ def repo_slug
39
+ repository['full_name'] || ''
40
+ end
41
+
42
+ def source_url
43
+ head_commit['url'] || ''
44
+ end
45
+ end
@@ -0,0 +1,12 @@
1
+ # The base interface that the payload parser will be expecting
2
+ class Payload
3
+ def self.hash_keys
4
+ %w(author hash branch message repo_slug source_url clone_url)
5
+ end
6
+
7
+ def to_hash
8
+ keys = Payload.hash_keys
9
+ values = Payload.hash_keys.map { |method| method(method.to_sym).call }
10
+ [keys, values].transpose.to_h
11
+ end
12
+ end
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative '../queue-helper'
3
+
4
+ def parse(payload = '', of_format:)
5
+ of_format = of_format.downcase
6
+ lib_file = "#{__dir__}/lib/#{of_format}.rb"
7
+ if of_format != 'payload' && File.file?(lib_file)
8
+ require_relative lib_file
9
+ Object.const_get("#{of_format.capitalize}Payload").new payload
10
+ else
11
+ puts "Dropping bad format: #{of_format}"
12
+ nil
13
+ end
14
+ end
15
+
16
+ QueueHelper.wait_for('raw-payload') do |data|
17
+ puts "Parsing: #{data}"
18
+ payload = parse data['payload'], of_format: data['format']
19
+ if payload
20
+ hash = { payload: payload.to_hash, job_name: data['job_name'] }
21
+ QueueHelper.enqueue hash, to: 'parsed-payload'
22
+ end
23
+ end
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env ruby
2
+ require 'ezmq'
3
+ require 'json'
4
+
5
+ # A collection of common methods for queue interactions with EZMQ
6
+ module QueueHelper
7
+ @@client = EZMQ::Client.new port: 5556
8
+
9
+ def self.dequeue(queue_name = 'default')
10
+ data = @@client.request queue_name
11
+ begin
12
+ JSON.parse data
13
+ rescue JSON::ParserError
14
+ nil
15
+ end
16
+ end
17
+
18
+ def self.enqueue(object = {}, to: 'default')
19
+ @@client.request "#{to} #{JSON.generate object}"
20
+ end
21
+
22
+ def self.wait_for(queue_name = 'default')
23
+ puts "Waiting for messages on #{queue_name}"
24
+ subscriber = EZMQ::Subscriber.new port: 5557, topic: 'busy-queues'
25
+ subscriber.listen do |message|
26
+ if message == queue_name
27
+ data = dequeue queue_name
28
+ yield data unless data.nil?
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative 'lib/moneta-queue'
3
+ require 'ezmq'
4
+
5
+ publisher = EZMQ::Publisher.new port: 5557
6
+ queues = {}
7
+ MonetaQueue.watched_queues.each do |queue|
8
+ queues[queue] = MonetaQueue.new queue
9
+ end
10
+
11
+ @wait_time = ARGV[0].to_f || 0.5
12
+
13
+ loop do
14
+ queues.each do |queue_name, queue|
15
+ publisher.send queue_name, topic: 'busy-queues' if queue.size > 0
16
+ end
17
+ sleep @wait_time unless @wait_time == 0
18
+ end
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative 'lib/moneta-queue'
3
+ require 'ezmq'
4
+
5
+ def enqueue(name, item)
6
+ puts "enqueue #{name} #{item}"
7
+ queue = MonetaQueue.new name
8
+ queue.enqueue item
9
+ queue.size.to_s
10
+ end
11
+
12
+ def dequeue(name)
13
+ puts "dequeue #{name}"
14
+ queue = MonetaQueue.new name
15
+ queue.dequeue
16
+ end
17
+
18
+ server = EZMQ::Server.new port: 5556
19
+ server.listen do |message|
20
+ name, item = message.split ' ', 2
21
+ is_dequeue_request = item.nil?
22
+ is_dequeue_request ? dequeue(name) : enqueue(name, item)
23
+ end
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+ require 'moneta'
3
+ require_relative '../../../config'
4
+
5
+ # A class to handle queueing through Moneta's key-value storage
6
+ class MonetaQueue
7
+ attr_reader :watched_queues
8
+
9
+ @@store = Moneta.new :File, dir: configatron.queue.moneta_directory
10
+
11
+ def initialize(name)
12
+ @name = name
13
+ @mirror_name = "mirror-#{name}"
14
+ @@store[@name] ||= []
15
+ @@store[@mirror_name] ||= []
16
+ end
17
+
18
+ def self.watched_queues
19
+ %w(raw-payload parsed-payload jobs testing)
20
+ end
21
+
22
+ def enqueue(item)
23
+ @@store[@name] = @@store[@name].push item
24
+ @@store[@mirror_name] = @@store[@mirror_name].push item
25
+ end
26
+
27
+ def dequeue
28
+ return '' if @@store[@name].empty?
29
+ item = @@store[@name].first
30
+ @@store[@name] = @@store[@name][1..-1]
31
+ item
32
+ end
33
+
34
+ def size
35
+ loop do # Moneta can return nil sometimes, so we give it time to catch up
36
+ queue = @@store[@name]
37
+ return queue.size unless queue.nil?
38
+ end
39
+ end
40
+
41
+ def clear
42
+ @@store[@mirror_name] = []
43
+ @@store[@name] = []
44
+ end
45
+
46
+ def inspect
47
+ @@store[@name].inspect
48
+ end
49
+ end
@@ -0,0 +1,18 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative 'lib/moneta-queue'
3
+
4
+ @queues = []
5
+ [ARGV[0], "mirror-#{ARGV[0]}"].each do |queue_name|
6
+ @queues.push(name: queue_name, queue: MonetaQueue.new(queue_name))
7
+ end
8
+
9
+ loop do
10
+ system 'clear'
11
+ @queues.each do |q|
12
+ puts "Queue: #{q[:name]}"
13
+ puts "Size: #{q[:queue].size}", "#{'|' * q[:queue].size}"
14
+ puts q[:queue].inspect
15
+ puts
16
+ end
17
+ sleep 0.1
18
+ end
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env ruby
2
+ require_relative 'config'
3
+
4
+ log_path = configatron.common.logfile_directory
5
+ pid_path = configatron.common.pidfile_directory
6
+
7
+ Eye.config do
8
+ logger "#{log_path}/eye.log"
9
+ end
10
+
11
+ Eye.application :robot_sweatshop do
12
+ trigger :flapping, times: 10, within: 1.minute, retry_in: 10.minutes
13
+ check :cpu, every: 10.seconds, below: 100, times: 3
14
+ working_dir "#{__dir__}/lib"
15
+
16
+ group 'input' do
17
+ process :http do
18
+ pid_file "#{pid_path}/input-http.pid"
19
+ stdall "#{log_path}/input-http.log"
20
+ start_command "ruby input/http.rb"
21
+ daemonize true
22
+ end
23
+ end
24
+ group 'queue' do
25
+ process :handler do
26
+ pid_file "#{pid_path}/queue-handler.pid"
27
+ stdall "#{log_path}/queue-handler.log"
28
+ start_command "ruby queue/handler.rb"
29
+ daemonize true
30
+ end
31
+ process :broadcaster do
32
+ pid_file "#{pid_path}/queue-broadcaster.pid"
33
+ stdall "#{log_path}/queue-broadcaster.log"
34
+ start_command "ruby queue/broadcaster.rb #{configatron.eye.broadcaster_interval}"
35
+ daemonize true
36
+ end
37
+ end
38
+ group 'job' do
39
+ process :assembler do
40
+ pid_file "#{pid_path}/job-assembler.pid"
41
+ stdall "#{log_path}/job-assembler.log"
42
+ start_command "ruby job/assembler.rb"
43
+ daemonize true
44
+ end
45
+ process :worker do
46
+ pid_file "#{pid_path}/job-worker.pid"
47
+ stdall "#{log_path}/job-worker.log"
48
+ start_command "ruby job/worker.rb #{configatron.eye.worker_id}"
49
+ daemonize true
50
+ end
51
+ end
52
+ process :payload_parser do
53
+ pid_file "#{pid_path}/payload_parser.pid"
54
+ stdall "#{log_path}/payload_parser.log"
55
+ start_command "ruby payload/parser.rb"
56
+ daemonize true
57
+ end
58
+ end