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.
- data/.gitignore +10 -0
- data/Gemfile +11 -0
- data/Gemfile.lock +36 -0
- data/LICENSE +20 -0
- data/README.md +71 -0
- data/Rakefile +76 -0
- data/VERSION +1 -0
- data/bin/hercules +6 -0
- data/hdi/README.md +4 -0
- data/hdi/config.rb +7 -0
- data/hdi/site/index.html +267 -0
- data/hdi/site/stylesheets/style.css +1 -0
- data/hdi/src/configuration.rb +5 -0
- data/hdi/src/layouts/application.haml +12 -0
- data/hdi/src/pages/_hdi.haml +71 -0
- data/hdi/src/pages/_header.haml +8 -0
- data/hdi/src/pages/_jquery.haml +155 -0
- data/hdi/src/pages/index.haml +10 -0
- data/hdi/src/stylesheets/style.sass +173 -0
- data/hercules.gemspec +120 -0
- data/lib/command_runner.rb +73 -0
- data/lib/config.rb +82 -0
- data/lib/deployer.rb +95 -0
- data/lib/git_handler.rb +78 -0
- data/lib/hercules.rb +167 -0
- data/lib/http_handler.rb +41 -0
- data/lib/request_handler.rb +142 -0
- data/tests/command_runner_test.rb +35 -0
- data/tests/config_test.rb +39 -0
- data/tests/fixtures/Gemfile +1 -0
- data/tests/fixtures/Gemfile.lock +8 -0
- data/tests/fixtures/Gemfile.with_git_gem +2 -0
- data/tests/fixtures/Gemfile.with_git_gem.lock +10 -0
- data/tests/fixtures/bogus_config.yml +9 -0
- data/tests/fixtures/bogus_deployer.rb +2 -0
- data/tests/fixtures/config.yml +12 -0
- data/tests/fixtures/config_empty.yml +1 -0
- data/tests/fixtures/config_empty_branches.yml +8 -0
- data/tests/fixtures/config_empty_projects.yml +2 -0
- data/tests/fixtures/config_global.yml +14 -0
- data/tests/fixtures/config_partial_1.yml +7 -0
- data/tests/fixtures/config_partial_2.yml +7 -0
- data/tests/fixtures/config_partial_3.yml +7 -0
- data/tests/fixtures/deployer_branch.rb +11 -0
- data/tests/fixtures/deployer_exception.rb +11 -0
- data/tests/fixtures/deployer_false.rb +11 -0
- data/tests/fixtures/deployer_path.rb +11 -0
- data/tests/fixtures/deployer_true.rb +11 -0
- data/tests/fixtures/deployer_undefined_variable.rb +11 -0
- data/tests/fixtures/startup_checkout_config.yml +12 -0
- data/tests/fixtures/startup_checkout_error_config.yml +12 -0
- data/tests/git_handler_test.rb +95 -0
- data/tests/git_setup.rb +70 -0
- data/tests/hercules_test.rb +128 -0
- data/tests/http_handler_test.rb +88 -0
- data/tests/request_handler_test.rb +242 -0
- data/tests/startup.rb +36 -0
- 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
|
data/lib/http_handler.rb
ADDED
@@ -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
|