robot_sweatshop 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
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