hercules 0.1.1

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 (58) hide show
  1. data/.gitignore +10 -0
  2. data/Gemfile +11 -0
  3. data/Gemfile.lock +36 -0
  4. data/LICENSE +20 -0
  5. data/README.md +71 -0
  6. data/Rakefile +76 -0
  7. data/VERSION +1 -0
  8. data/bin/hercules +6 -0
  9. data/hdi/README.md +4 -0
  10. data/hdi/config.rb +7 -0
  11. data/hdi/site/index.html +267 -0
  12. data/hdi/site/stylesheets/style.css +1 -0
  13. data/hdi/src/configuration.rb +5 -0
  14. data/hdi/src/layouts/application.haml +12 -0
  15. data/hdi/src/pages/_hdi.haml +71 -0
  16. data/hdi/src/pages/_header.haml +8 -0
  17. data/hdi/src/pages/_jquery.haml +155 -0
  18. data/hdi/src/pages/index.haml +10 -0
  19. data/hdi/src/stylesheets/style.sass +173 -0
  20. data/hercules.gemspec +120 -0
  21. data/lib/command_runner.rb +73 -0
  22. data/lib/config.rb +82 -0
  23. data/lib/deployer.rb +95 -0
  24. data/lib/git_handler.rb +78 -0
  25. data/lib/hercules.rb +167 -0
  26. data/lib/http_handler.rb +41 -0
  27. data/lib/request_handler.rb +142 -0
  28. data/tests/command_runner_test.rb +35 -0
  29. data/tests/config_test.rb +39 -0
  30. data/tests/fixtures/Gemfile +1 -0
  31. data/tests/fixtures/Gemfile.lock +8 -0
  32. data/tests/fixtures/Gemfile.with_git_gem +2 -0
  33. data/tests/fixtures/Gemfile.with_git_gem.lock +10 -0
  34. data/tests/fixtures/bogus_config.yml +9 -0
  35. data/tests/fixtures/bogus_deployer.rb +2 -0
  36. data/tests/fixtures/config.yml +12 -0
  37. data/tests/fixtures/config_empty.yml +1 -0
  38. data/tests/fixtures/config_empty_branches.yml +8 -0
  39. data/tests/fixtures/config_empty_projects.yml +2 -0
  40. data/tests/fixtures/config_global.yml +14 -0
  41. data/tests/fixtures/config_partial_1.yml +7 -0
  42. data/tests/fixtures/config_partial_2.yml +7 -0
  43. data/tests/fixtures/config_partial_3.yml +7 -0
  44. data/tests/fixtures/deployer_branch.rb +11 -0
  45. data/tests/fixtures/deployer_exception.rb +11 -0
  46. data/tests/fixtures/deployer_false.rb +11 -0
  47. data/tests/fixtures/deployer_path.rb +11 -0
  48. data/tests/fixtures/deployer_true.rb +11 -0
  49. data/tests/fixtures/deployer_undefined_variable.rb +11 -0
  50. data/tests/fixtures/startup_checkout_config.yml +12 -0
  51. data/tests/fixtures/startup_checkout_error_config.yml +12 -0
  52. data/tests/git_handler_test.rb +95 -0
  53. data/tests/git_setup.rb +70 -0
  54. data/tests/hercules_test.rb +128 -0
  55. data/tests/http_handler_test.rb +88 -0
  56. data/tests/request_handler_test.rb +242 -0
  57. data/tests/startup.rb +36 -0
  58. metadata +251 -0
data/lib/hercules.rb ADDED
@@ -0,0 +1,167 @@
1
+ #!/usr/bin/env ruby
2
+ # coding: utf-8
3
+
4
+ require 'rubygems'
5
+ require 'bundler/setup'
6
+
7
+ require 'optparse'
8
+ require 'ostruct'
9
+ require 'yaml'
10
+
11
+ require File.dirname(__FILE__) + '/http_handler'
12
+ require File.dirname(__FILE__) + '/config'
13
+
14
+ module Hercules
15
+ class Hercules
16
+ attr_reader :options
17
+
18
+ def initialize(arguments, stdin)
19
+ @arguments = arguments
20
+ @stdin = stdin
21
+
22
+ # Set default options
23
+ @options = OpenStruct.new
24
+ @options.config_file = 'config.yml'
25
+ @options.verbose = false
26
+ @options.log_file = nil
27
+ @options.pid_file = 'hercules.pid'
28
+ @options.foreground = false
29
+
30
+ @config = nil
31
+ @pid_file = nil
32
+ @log = nil
33
+ end
34
+
35
+ def run
36
+ parse_options
37
+ set_logger
38
+ read_config
39
+ be_verbose if @options.verbose
40
+
41
+ # if -f is not present we fork into the background and write hercules.pid
42
+ @options.foreground ? process_command : daemonize
43
+ @log.close
44
+ end
45
+
46
+
47
+ protected
48
+ def exit_gracefully
49
+ remove_pid
50
+ @log.info "Terminating hercules..."
51
+ @log.close unless @log.nil? rescue nil
52
+ exit 0
53
+ end
54
+
55
+ def remove_pid
56
+ if !@pid_file.nil? and File.exist? @pid_file
57
+ @log.info "Removing pid file #{@pid_file}..."
58
+ File.unlink @pid_file
59
+ end
60
+ end
61
+
62
+ def reload_config
63
+ begin
64
+ @log.info "Reloading config file #{@options.config_file}..."
65
+ @config.reload
66
+ @log.info "Configuration updated."
67
+ rescue Exception => e
68
+ @log.error "Error reading config file #{@options.config_file}: #{e.inspect}"
69
+ end
70
+ end
71
+
72
+ def read_config
73
+ begin
74
+ @config = ::Hercules::Config.new(@options.config_file)
75
+ rescue Exception => e
76
+ @log.fatal "Error reading config file #{@options.config_file}: #{e.inspect}"
77
+ exit -1
78
+ end
79
+ end
80
+
81
+ def set_logger
82
+ require 'logger'
83
+ if @options.log_file.nil?
84
+ @log = Logger.new(STDERR)
85
+ else
86
+ @log = Logger.new(@options.log_file, 'daily')
87
+ end
88
+ @log.level = Logger::INFO
89
+ end
90
+
91
+ def daemonize
92
+ begin
93
+ @pid_file = @options.pid_file
94
+ pid = fork do
95
+ process_command
96
+ end
97
+ File.open(@pid_file, 'w+'){|f| f.write pid.to_s }
98
+ Process.detach(pid)
99
+ rescue Exception => e
100
+ @log.fatal "Error while daemonizing: #{e.inspect}"
101
+ exit
102
+ end
103
+ end
104
+
105
+ def parse_options
106
+ opts = OptionParser.new
107
+ opts.on('-v', '--version') { puts "hercules version #{VERSION}" ; exit 0 }
108
+ opts.on('-h', '--help') { puts opts; exit 0 }
109
+ opts.on('-V', '--verbose') { @options.verbose = true }
110
+ opts.on('-f', '--foreground') { @options.foreground = true }
111
+ opts.on('-l', '--log log_file') { |log_file| @options.log_file = log_file }
112
+ opts.on('-p', '--pid pid_file') { |pid_file| @options.pid_file = pid_file }
113
+ opts.on('-c', '--conf config_file') { |conf| @options.config_file = conf }
114
+
115
+ opts.parse!(@arguments)
116
+ end
117
+
118
+ def startup_checkouts
119
+ @config.branches.each do |project,branches|
120
+ branches.each do |branch|
121
+ if @config[project][branch]['checkout_on_startup']
122
+ @log.info "Starting checkout of #{branch} in project #{project}..."
123
+ begin
124
+ Deployer.new(@log, @config[project], branch).deploy
125
+ rescue Exception => e
126
+ @log.error "Error in startup checkout of branch #{branch}: #{e.message}\nBacktrace:#{e.backtrace}"
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
132
+
133
+ def process_command
134
+ EventMachine::run do
135
+ Signal.trap("TERM"){ exit_gracefully }
136
+ Signal.trap("HUP"){ reload_config }
137
+ startup_checkouts
138
+ EventMachine.epoll
139
+ host = @config.host
140
+ port = @config.port
141
+ EventMachine::start_server(host, port, HttpHandler, {:log => @log, :config => @config})
142
+ @log.info "Listening on #{host}:#{port}..."
143
+ end
144
+ end
145
+
146
+ def parse_options
147
+ opts = OptionParser.new
148
+ opts.on('-v', '--version') { puts "Hercules version #{VERSION}" ; exit 0 }
149
+ opts.on('-h', '--help') { puts opts; exit 0 }
150
+ opts.on('-V', '--verbose') { @options.verbose = true }
151
+ opts.on('-f', '--foreground') { @options.foreground = true }
152
+ opts.on('-l', '--log log_file') { |log_file| @options.log_file = log_file }
153
+ opts.on('-p', '--pid pid_file') { |pid_file| @options.pid_file = pid_file }
154
+ opts.on('-c', '--conf config_file') { |conf| @options.config_file = conf }
155
+ opts.parse!(@arguments)
156
+ end
157
+
158
+ def be_verbose
159
+ @log.info "Start at #{DateTime.now}"
160
+ @log.info "Options:\n"
161
+ @options.marshal_dump.each do |name, val|
162
+ @log.info " #{name} = #{val}"
163
+ end
164
+ @log.level = Logger::DEBUG
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,41 @@
1
+ # coding: utf-8
2
+
3
+ require 'rubygems'
4
+ require 'eventmachine'
5
+ require 'evma_httpserver'
6
+ require 'json'
7
+ require File.dirname(__FILE__) + '/request_handler'
8
+
9
+ module Hercules
10
+ class HttpHandler < EventMachine::Connection
11
+ include EventMachine::HttpServer
12
+
13
+ def initialize *args
14
+ @config = args[0][:config]
15
+ @log = args[0][:log]
16
+ end
17
+
18
+ def process_http_request
19
+ begin
20
+ @config.reload
21
+ resp = EventMachine::DelegatedHttpResponse.new( self )
22
+ req = RequestHandler.new({:config => @config, :log => @log, :method => @http_request_method, :path => @http_path_info, :query => @http_query_string, :body => @http_post_content})
23
+ return send(resp, req.status, req.message)
24
+ rescue Exception => e
25
+ send(resp, 500, "Error while processing HTTP request: #{e.inspect} \nREQUEST: #{@http_request_method} #{@http_path_info}?#{@http_query_string}\n#{@http_post_content}")
26
+ @log.error "Backtrace: #{e.backtrace}"
27
+ end
28
+ end
29
+
30
+ def send resp, status, message
31
+ if status == 500
32
+ @log.error "#{status}: #{message}"
33
+ else
34
+ @log.info "#{status}: #{message}"
35
+ end
36
+ resp.status = status
37
+ resp.content = message
38
+ resp.send_response
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,142 @@
1
+ # coding: utf-8
2
+ require 'json'
3
+ require 'uri'
4
+ require File.dirname(__FILE__) + '/deployer'
5
+
6
+ module Hercules
7
+ # Class that knows how to handle deploy requests.
8
+ # This implementation will just parse a JSON as defined by github http hooks.
9
+ # In order to use other hook formats this class should be reimplemented.
10
+ class RequestHandler
11
+ # We must pass the request data (method, path, query and body).
12
+ # * options is a hash containing all the request data described above plus the logger and the config hash.
13
+ def initialize(options)
14
+ @method = options[:method]
15
+ @body = options[:body]
16
+ @log = options[:log]
17
+ @path = options[:path]
18
+ @config = options[:config]
19
+ end
20
+
21
+ # Returns the message generated as response for the request passed in the initializer.
22
+ # We also store the message for further queries.
23
+ def message
24
+ @result ||= process_request
25
+ @result[:message]
26
+ end
27
+
28
+ # Returns the status generated as response for the request passed in the initializer.
29
+ # We also store the status for further queries.
30
+ def status
31
+ @result ||= process_request
32
+ @result[:status]
33
+ end
34
+
35
+ # Returns the repository name that fired the request.
36
+ def repository_name
37
+ return payload['repository']['name'] if @method == "POST"
38
+ return @path.split('/')[1] if @method == "GET"
39
+ end
40
+
41
+ # Returns the security token that fired the request.
42
+ def request_token
43
+ @path.split('/')[2]
44
+ end
45
+
46
+ # Returns true whenever the request made was a HDI request
47
+ def request_hdi?
48
+ @path.split('/')[3] == 'hdi' and @method == "GET"
49
+ end
50
+
51
+ # Returns the assembled HDI
52
+ def hdi
53
+ response = ""
54
+ css = ""
55
+ File.open(File.dirname(__FILE__) + "/../hdi/site/index.html", 'r'){|f| response = f.read }
56
+ File.open(File.dirname(__FILE__) + "/../hdi/site/stylesheets/style.css", 'r'){|f| css = f.read }
57
+ response.gsub(/<link href="stylesheets\/style.css" media="all" rel="stylesheet" type="text\/css">/, "<style>#{css}</style>").gsub(/##REQUEST_ADDRESS##/, @path.gsub(/\/hdi/, ""))
58
+ end
59
+
60
+ # Returns the url of the repository that fired the request.
61
+ def repository_url
62
+ payload['repository']['url']
63
+ end
64
+
65
+ # Returns the branch of the repository that fired the request.
66
+ def branch
67
+ payload['ref'].split('/').pop
68
+ end
69
+
70
+ private
71
+ def process_request
72
+ @method == "GET" ? process_get : process_post
73
+ end
74
+
75
+ def project_json
76
+ response = {}
77
+ @config.branches[repository_name].each do |k|
78
+ deployed = File.exist?("#{@config[repository_name]['target_directory']}/branches/#{k}")
79
+ response[k] = {:deployed => deployed}
80
+ if deployed
81
+ checkouts = []
82
+ Dir.glob("#{@config[repository_name]['target_directory']}/checkouts/#{k}/*").sort{|a,b| File.new(a).ctime.strftime("%Y%m%d%H%M%S") <=> File.new(b).ctime.strftime("%Y%m%d%H%M%S") }.reverse_each do |path|
83
+ output = ""
84
+ checkout = path.split('/').pop
85
+ begin
86
+ File.open("#{@config[repository_name]['target_directory']}/logs/#{k}/#{checkout}.log"){|f| output = f.read }
87
+ checkouts.push({:sha1 => checkout, :timestamp => File.mtime(path).strftime("%Y-%m-%d %H:%M:%S"), :output => output})
88
+ rescue Errno::ENOENT => e
89
+ checkouts.push({:sha1 => checkout, :timestamp => File.mtime(path).strftime("%Y-%m-%d %H:%M:%S")})
90
+ end
91
+ end
92
+ response[k][:checkouts] = checkouts
93
+ end
94
+ end
95
+ response.to_json
96
+ end
97
+
98
+ def process_get
99
+ return {:status => 402, :message => "Repository not found"} if @config[repository_name].nil?
100
+ return {:status => 403, :message => "Invalid token"} unless @config[repository_name]['token'] == request_token
101
+ return {:status => 200, :message => hdi } if request_hdi?
102
+
103
+ # Otherwise we must return the json with project data
104
+ {:status => 200, :message => project_json }
105
+ end
106
+
107
+ def process_post
108
+ return {:status => 404, :message => "POST content is null"} if @body.nil?
109
+ return {:status => 404, :message => "Repository #{repository_name} not found in config"} unless @config.include? repository_name
110
+ return {:status => 404, :message => "Branch #{branch} not found in config"} unless @config[repository_name].include? branch
111
+ return {:status => 403, :message => "Invalid token"} unless @config[repository_name]['token'] == request_token
112
+ deploy
113
+ end
114
+
115
+ # Call the Deployer class to do the deploy magic.
116
+ # To implement a diferent SCM we will need to rewrite the Deployer and this method.
117
+ def deploy
118
+ d = Deployer.new(@log, @config[repository_name], branch)
119
+ begin
120
+ d.deploy
121
+ return {:status => 200, :message => "ok"}
122
+ rescue Exception => e
123
+ @log.error "Backtrace: #{e.backtrace}"
124
+ return {:status => 500, :message => "Error while deploying branch #{branch}: #{e.inspect}"}
125
+ end
126
+ end
127
+
128
+ # Parses the request body (only for POST)
129
+ # Here is the github specific code.
130
+ def parse_body
131
+ post = URI.unescape(@body)
132
+ @log.debug "Received POST: #{post}"
133
+ JSON.parse(post.gsub(/^payload=/, ""))
134
+ end
135
+
136
+ # Here we call the body parser and store the result for further queries.
137
+ def payload
138
+ @payload ||= parse_body
139
+ end
140
+
141
+ end
142
+ end
@@ -0,0 +1,35 @@
1
+ # coding: utf-8
2
+ require 'lib/command_runner'
3
+ require 'logger'
4
+ require 'test/unit'
5
+
6
+ class CommandRunnerTest < Test::Unit::TestCase
7
+ def setup
8
+ @log = Logger.new(STDERR)
9
+ @log.level = Logger::ERROR
10
+ @cmd = Hercules::CommandRunner.new @log
11
+ end
12
+
13
+ def test_command_output
14
+ assert_equal 'test', @cmd.run("echo test").output
15
+ end
16
+
17
+ def test_command_log
18
+ @cmd.run("echo test1")
19
+ @cmd.run("echo test2")
20
+ @cmd.run("echo test3")
21
+ assert_equal "test1\ntest2\ntest3\n", @cmd.output
22
+ end
23
+
24
+ def test_command_log_store
25
+ @cmd.run("echo test1")
26
+ @cmd.run("echo test2")
27
+ @cmd.run("echo test3")
28
+ file = "/tmp/output.#{Time.now.strftime "%Y%m%d%H%M%S"}.log"
29
+ @cmd.store_output file
30
+ File.open(file, 'r') do |f|
31
+ assert_equal "test1\ntest2\ntest3\n", f.read
32
+ end
33
+ end
34
+ end
35
+
@@ -0,0 +1,39 @@
1
+ # coding: utf-8
2
+ require 'lib/config.rb'
3
+ require 'test/unit'
4
+
5
+ class ConfigTest < Test::Unit::TestCase
6
+ def test_basic_methods_with_default_fixture
7
+ config = Hercules::Config.new 'tests/fixtures/config.yml'
8
+ assert_equal YAML.load_file('tests/fixtures/config.yml'), config.projects
9
+ assert_equal ['target_directory', 'repository', 'token'], Hercules::Config.project_attributes
10
+ assert_equal ['checkout_on_startup', 'checkouts_to_keep'], Hercules::Config.branch_attributes
11
+ assert_equal ['master', 'test'], config.branches['test_project']
12
+ end
13
+
14
+ def test_config_attr_reader
15
+ config = Hercules::Config.new 'tests/fixtures/config.yml'
16
+ yml = YAML.load_file('tests/fixtures/config.yml')
17
+ config.each do |k,v|
18
+ assert_equal config[k], yml[k]
19
+ end
20
+ end
21
+
22
+ def test_config_validation
23
+ [ 'tests/fixtures/config_empty.yml', 'tests/fixtures/config_empty_branches.yml', 'tests/fixtures/config_empty_projects.yml', 'tests/fixtures/config_partial_1.yml', 'tests/fixtures/config_partial_2.yml', 'tests/fixtures/config_partial_3.yml'].each do |p|
24
+ assert_invalid_config(p)
25
+ end
26
+ end
27
+
28
+ def test_config_with_global_attributes
29
+ config = Hercules::Config.new 'tests/fixtures/config_global.yml'
30
+ assert_equal "127.0.0.1", config.host
31
+ assert_equal 8081, config.port
32
+ end
33
+
34
+ def assert_invalid_config path
35
+ assert_raise(Hercules::InvalidConfig) do
36
+ config = Hercules::Config.new(path)
37
+ end
38
+ end
39
+ end
@@ -0,0 +1 @@
1
+ source :gemcutter
@@ -0,0 +1,8 @@
1
+ GEM
2
+ remote: http://rubygems.org/
3
+ specs:
4
+
5
+ PLATFORMS
6
+ ruby
7
+
8
+ DEPENDENCIES
@@ -0,0 +1,2 @@
1
+ source :gemcutter
2
+ gem "git"