juici 0.0.0.alpha1
Sign up to get free protection for your applications and to get access to all the features.
- data/.gitignore +2 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +74 -0
- data/Procfile +1 -0
- data/README.md +138 -0
- data/Rakefile +33 -0
- data/TODO.md +28 -0
- data/bin/juici +7 -0
- data/bin/juicic +54 -0
- data/config/mongoid.yml.sample +25 -0
- data/juici-interface.gemspec +19 -0
- data/juici.gemspec +32 -0
- data/lib/juici.rb +24 -0
- data/lib/juici/app.rb +67 -0
- data/lib/juici/build_environment.rb +38 -0
- data/lib/juici/build_logic.rb +37 -0
- data/lib/juici/build_queue.rb +111 -0
- data/lib/juici/callback.rb +26 -0
- data/lib/juici/config.rb +11 -0
- data/lib/juici/controllers.rb +6 -0
- data/lib/juici/controllers/base.rb +26 -0
- data/lib/juici/controllers/build_queue.rb +14 -0
- data/lib/juici/controllers/builds.rb +74 -0
- data/lib/juici/controllers/index.rb +20 -0
- data/lib/juici/controllers/trigger.rb +85 -0
- data/lib/juici/database.rb +25 -0
- data/lib/juici/exceptions.rb +2 -0
- data/lib/juici/find_logic.rb +11 -0
- data/lib/juici/helpers/form_helpers.rb +11 -0
- data/lib/juici/helpers/html_helpers.rb +4 -0
- data/lib/juici/helpers/url_helpers.rb +38 -0
- data/lib/juici/interface.rb +13 -0
- data/lib/juici/models/build.rb +190 -0
- data/lib/juici/models/build_process.rb +7 -0
- data/lib/juici/models/project.rb +9 -0
- data/lib/juici/server.rb +172 -0
- data/lib/juici/version.rb +8 -0
- data/lib/juici/views/README.markdown +138 -0
- data/lib/juici/views/about.erb +16 -0
- data/lib/juici/views/builds.erb +7 -0
- data/lib/juici/views/builds/edit.erb +23 -0
- data/lib/juici/views/builds/list.erb +27 -0
- data/lib/juici/views/builds/new.erb +43 -0
- data/lib/juici/views/builds/show.erb +4 -0
- data/lib/juici/views/index.erb +30 -0
- data/lib/juici/views/layout.erb +44 -0
- data/lib/juici/views/not_found.erb +3 -0
- data/lib/juici/views/partials/builds/debug.erb +22 -0
- data/lib/juici/views/partials/builds/output.erb +1 -0
- data/lib/juici/views/partials/builds/show.erb +19 -0
- data/lib/juici/views/partials/builds/sidebar.erb +13 -0
- data/lib/juici/views/partials/index/recently_built.erb +19 -0
- data/lib/juici/views/queue/list.erb +6 -0
- data/lib/juici/views/redirect.erb +0 -0
- data/lib/juici/views/support.erb +6 -0
- data/lib/juici/watcher.rb +48 -0
- data/public/favicon.ico +0 -0
- data/public/images/black_denim.png +0 -0
- data/public/styles/builds.css +62 -0
- data/public/styles/juici.css +226 -0
- data/public/vendor/bootstrap.css +6004 -0
- data/public/vendor/bootstrap.js +2036 -0
- data/public/vendor/img/glyphicons-halflings-white.png +0 -0
- data/public/vendor/jquery.js +9440 -0
- data/sample/mongod.conf +5 -0
- data/script/cibuild +10 -0
- data/spec/build_callback_spec.rb +54 -0
- data/spec/build_environment_spec.rb +53 -0
- data/spec/build_process_spec.rb +96 -0
- data/spec/build_queue_spec.rb +63 -0
- data/spec/controllers/builds_spec.rb +68 -0
- data/spec/controllers/index_spec.rb +28 -0
- data/spec/juici_app_spec.rb +8 -0
- data/spec/models/build_spec.rb +54 -0
- data/spec/spec_helper.rb +26 -0
- metadata +290 -0
data/lib/juici/app.rb
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
at_exit do
|
2
|
+
Juici::App.shutdown
|
3
|
+
end
|
4
|
+
|
5
|
+
module Juici
|
6
|
+
class App
|
7
|
+
@@watchers = []
|
8
|
+
|
9
|
+
def self.shutdown
|
10
|
+
::Juici.dbgp "Shutting down Juici"
|
11
|
+
::Juici::Watcher.instance.shutdown!
|
12
|
+
|
13
|
+
shutdown_build_queue
|
14
|
+
end
|
15
|
+
|
16
|
+
attr_reader :opts
|
17
|
+
def initialize(opts={})
|
18
|
+
Database.initialize!
|
19
|
+
@opts = opts
|
20
|
+
reset_stale_children
|
21
|
+
start_watcher
|
22
|
+
init_build_queue
|
23
|
+
reload_unfinished_work
|
24
|
+
start_queue
|
25
|
+
end
|
26
|
+
|
27
|
+
private
|
28
|
+
|
29
|
+
def init_build_queue
|
30
|
+
$build_queue ||= BuildQueue.new
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.shutdown_build_queue
|
34
|
+
if $build_queue
|
35
|
+
$build_queue.shutdown!
|
36
|
+
$build_queue = nil
|
37
|
+
end
|
38
|
+
|
39
|
+
# Find any remaining children and kill them
|
40
|
+
# Ensure that any killed builds will be retried
|
41
|
+
end
|
42
|
+
|
43
|
+
def start_watcher
|
44
|
+
::Juici::Watcher.instance.start
|
45
|
+
end
|
46
|
+
|
47
|
+
def reload_unfinished_work
|
48
|
+
# At this point no workers have started yet, we can safely assume that
|
49
|
+
# :started means aborted
|
50
|
+
Build.where(:status => Juici::BuildStatus::WAIT).each do |build|
|
51
|
+
$build_queue << build
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def reset_stale_children
|
56
|
+
Build.where(:status => Juici::BuildStatus::START).each do |build|
|
57
|
+
build[:status] = Juici::BuildStatus::WAIT
|
58
|
+
build.save!
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def start_queue
|
63
|
+
$build_queue.start!
|
64
|
+
end
|
65
|
+
|
66
|
+
end
|
67
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module Juici
|
2
|
+
BUILD_SENSITIVE_VARIABLES = %w[RUBYOPT BUNDLE_GEMFILE RACK_ENV MONGOLAB_URI GEM_PATH WORKING_DIR]
|
3
|
+
class BuildEnvironment
|
4
|
+
|
5
|
+
attr_reader :env
|
6
|
+
def initialize
|
7
|
+
@env = ENV.to_hash.tap do |env|
|
8
|
+
BUILD_SENSITIVE_VARIABLES.each do |var|
|
9
|
+
env[var] = nil
|
10
|
+
end
|
11
|
+
env["BUNDLE_CONFIG"] = "/nonexistent"
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
def [](k)
|
16
|
+
env[k]
|
17
|
+
end
|
18
|
+
|
19
|
+
# XXX This is spectacular.
|
20
|
+
# Not in the good way
|
21
|
+
def load_json!(json)
|
22
|
+
return true if json == ""
|
23
|
+
loaded_json = JSON.load(json)
|
24
|
+
if loaded_json.is_a? Hash
|
25
|
+
env.merge!(loaded_json)
|
26
|
+
return true
|
27
|
+
end
|
28
|
+
false
|
29
|
+
rescue JSON::ParserError
|
30
|
+
return false
|
31
|
+
end
|
32
|
+
|
33
|
+
def to_hash
|
34
|
+
env
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
38
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'tempfile'
|
3
|
+
module Juici
|
4
|
+
module BuildLogic
|
5
|
+
|
6
|
+
def spawn_build
|
7
|
+
raise "No such work tree" unless FileUtils.mkdir_p(worktree)
|
8
|
+
spawn(command, worktree)
|
9
|
+
rescue AbortBuild
|
10
|
+
:buildaborted
|
11
|
+
end
|
12
|
+
|
13
|
+
def kill!
|
14
|
+
warn! "Killed!"
|
15
|
+
if pid = self[:pid]
|
16
|
+
Process.kill(15, pid)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
|
20
|
+
private
|
21
|
+
|
22
|
+
def spawn(cmd, dir)
|
23
|
+
@buffer = Tempfile.new('juici-xxxx')
|
24
|
+
Process.spawn(environment, cmd,
|
25
|
+
:chdir => dir,
|
26
|
+
:in => "/dev/null",
|
27
|
+
:out => @buffer.fileno,
|
28
|
+
:err => [:child, :out]
|
29
|
+
)
|
30
|
+
rescue Errno::ENOENT
|
31
|
+
:enoent
|
32
|
+
rescue TypeError
|
33
|
+
:invalidcommand
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
end
|
@@ -0,0 +1,111 @@
|
|
1
|
+
# An object representing a queue. It merely manages creating child processes
|
2
|
+
# and their priority, reaping them is a job for BuildThread
|
3
|
+
#
|
4
|
+
module Juici
|
5
|
+
class BuildQueue
|
6
|
+
|
7
|
+
def initialize
|
8
|
+
@builds = []
|
9
|
+
@child_pids = []
|
10
|
+
# This is never expired, for now
|
11
|
+
@builds_by_pid = {}
|
12
|
+
@started = false
|
13
|
+
end
|
14
|
+
|
15
|
+
def shutdown!
|
16
|
+
@child_pids.each do |pid|
|
17
|
+
::Juici.dbgp "Killing off child pid #{pid}"
|
18
|
+
Process.kill(15, pid)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
# Pushing a Build object into the BuildQueue is expressing that you want it
|
23
|
+
# run
|
24
|
+
def <<(other)
|
25
|
+
@builds << other
|
26
|
+
bump!
|
27
|
+
end
|
28
|
+
|
29
|
+
def current_min_priority
|
30
|
+
@builds.collect(&:priority).compact.min || 1
|
31
|
+
end
|
32
|
+
|
33
|
+
def next_child
|
34
|
+
@builds.sort_by(&:priority).first
|
35
|
+
end
|
36
|
+
|
37
|
+
def purge(by, build)
|
38
|
+
@builds.reject! do |i|
|
39
|
+
build.send(by) == i.send(by)
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def delete(id)
|
44
|
+
purge(:_id, OpenStruct.new(:_id => id))
|
45
|
+
end
|
46
|
+
|
47
|
+
# Magic hook that starts a process if there's a good reason to.
|
48
|
+
# Stopgap measure that means you can knock on this if there's a chance we
|
49
|
+
# should start a process
|
50
|
+
def bump!
|
51
|
+
return unless @started
|
52
|
+
update_children
|
53
|
+
if not_working? && work_to_do?
|
54
|
+
Juici.dbgp "Starting another child process"
|
55
|
+
next_child.tap do |child|
|
56
|
+
if pid = child.build!
|
57
|
+
Juici.dbgp "Started child: #{pid}"
|
58
|
+
@child_pids << pid
|
59
|
+
@builds_by_pid[pid] = child
|
60
|
+
else
|
61
|
+
Juici.dbgp "Child #{child} failed to start"
|
62
|
+
bump! # Ruby's recursion isn't great, but re{try,do} may as well be
|
63
|
+
# undefined behaviour here.
|
64
|
+
end
|
65
|
+
end
|
66
|
+
else
|
67
|
+
Juici.dbgp "I have quite enough to do"
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
def update_children
|
72
|
+
@child_pids.select! do |pid|
|
73
|
+
return false if pid.nil?
|
74
|
+
begin
|
75
|
+
Process.kill(0, pid)
|
76
|
+
true
|
77
|
+
rescue Errno::ESRCH
|
78
|
+
false
|
79
|
+
end
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
def get_build_by_pid(pid)
|
84
|
+
@builds_by_pid[pid]
|
85
|
+
end
|
86
|
+
|
87
|
+
def not_working?
|
88
|
+
@child_pids.length == 0
|
89
|
+
end
|
90
|
+
|
91
|
+
def work_to_do?
|
92
|
+
@builds.length > 0
|
93
|
+
end
|
94
|
+
|
95
|
+
def currently_building
|
96
|
+
@child_pids.map do |pid|
|
97
|
+
get_build_by_pid(pid)
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def start!
|
102
|
+
@started = true
|
103
|
+
bump!
|
104
|
+
end
|
105
|
+
|
106
|
+
def builds
|
107
|
+
@builds
|
108
|
+
end
|
109
|
+
|
110
|
+
end
|
111
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
require 'net/http'
|
2
|
+
module Juici
|
3
|
+
class Callback
|
4
|
+
|
5
|
+
attr_reader :url
|
6
|
+
attr_accessor :payload
|
7
|
+
|
8
|
+
def initialize(url, pl=nil)
|
9
|
+
@url = URI(url)
|
10
|
+
@payload = pl if pl
|
11
|
+
end
|
12
|
+
|
13
|
+
def process!
|
14
|
+
Net::HTTP.start(url.host, url.port) do |http|
|
15
|
+
request = Net::HTTP::Post.new(url.request_uri)
|
16
|
+
request.body = payload
|
17
|
+
|
18
|
+
http.request request # Net::HTTPResponse object
|
19
|
+
end
|
20
|
+
rescue SocketError => e
|
21
|
+
# We don't get a reference to build any more, can't warn :(
|
22
|
+
# TODO Throw a warning on the build
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
data/lib/juici/config.rb
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
module Juici::Controllers
|
2
|
+
class Base
|
3
|
+
|
4
|
+
NotFound = Sinatra::NotFound
|
5
|
+
|
6
|
+
attr_accessor :params
|
7
|
+
def initialize(params)
|
8
|
+
@params = params
|
9
|
+
end
|
10
|
+
|
11
|
+
def build_opts(opts)
|
12
|
+
default_opts.merge(opts)
|
13
|
+
end
|
14
|
+
|
15
|
+
def default_opts
|
16
|
+
{
|
17
|
+
:styles => styles
|
18
|
+
}
|
19
|
+
end
|
20
|
+
|
21
|
+
def styles
|
22
|
+
[]
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module Juici::Controllers
|
2
|
+
class BuildQueue < Base
|
3
|
+
|
4
|
+
def list
|
5
|
+
builds = $build_queue.builds.sort_by(&:priority)
|
6
|
+
yield [:"queue/list", opts(:builds => builds)]
|
7
|
+
end
|
8
|
+
|
9
|
+
def opts(_opts={})
|
10
|
+
{:active => :queue}.merge(_opts)
|
11
|
+
end
|
12
|
+
|
13
|
+
end
|
14
|
+
end
|
@@ -0,0 +1,74 @@
|
|
1
|
+
module Juici::Controllers
|
2
|
+
class Builds < Base
|
3
|
+
|
4
|
+
def list
|
5
|
+
params[:page] = params[:page] ? params[:page].to_i : 0
|
6
|
+
|
7
|
+
project = ::Juici::Project.find_or_raise(NotFound, name: params[:project])
|
8
|
+
|
9
|
+
builds = ::Juici::Build.where(parent: project.name)
|
10
|
+
|
11
|
+
pages = (builds.count.to_f / ::Juici::Config.builds_per_page).ceil
|
12
|
+
|
13
|
+
builds = builds.desc(:_id).
|
14
|
+
limit(::Juici::Config.builds_per_page).
|
15
|
+
skip(params[:page].to_i * ::Juici::Config.builds_per_page)
|
16
|
+
|
17
|
+
yield [:"builds/list", build_opts({:project => project, :builds => builds, :pages => pages})]
|
18
|
+
end
|
19
|
+
|
20
|
+
def show
|
21
|
+
project = ::Juici::Project.find_or_raise(NotFound, name: params[:project])
|
22
|
+
build = ::Juici::Build.find_or_raise(NotFound, parent: project.name, _id: params[:id])
|
23
|
+
# return 404 unless project && build
|
24
|
+
yield [:"builds/show", build_opts({:project => project, :build => build})]
|
25
|
+
end
|
26
|
+
|
27
|
+
def kill
|
28
|
+
project = ::Juici::Project.find_or_raise(NotFound, name: params[:project])
|
29
|
+
build = ::Juici::Build.find_or_raise(NotFound, parent: project.name, _id: params[:id])
|
30
|
+
|
31
|
+
::Juici.dbgp "Killing off build #{build[:_id]}"
|
32
|
+
build.kill! if build.status == ::Juici::BuildStatus::START
|
33
|
+
return build
|
34
|
+
end
|
35
|
+
|
36
|
+
def cancel
|
37
|
+
project = ::Juici::Project.find_or_raise(NotFound, name: params[:project])
|
38
|
+
build = ::Juici::Build.find_or_raise(NotFound, parent: project.name, _id: params[:id])
|
39
|
+
|
40
|
+
::Juici.dbgp "Cancelling build #{build[:_id]}"
|
41
|
+
build.cancel if build.status == ::Juici::BuildStatus::WAIT
|
42
|
+
return build
|
43
|
+
end
|
44
|
+
|
45
|
+
def new
|
46
|
+
yield [:"builds/new", {:active => :new_build}]
|
47
|
+
end
|
48
|
+
|
49
|
+
def styles
|
50
|
+
["builds"]
|
51
|
+
end
|
52
|
+
|
53
|
+
def edit
|
54
|
+
project = ::Juici::Project.find_or_raise(NotFound, name: params[:project])
|
55
|
+
build = ::Juici::Build.find_or_raise(NotFound, parent: project.name, _id: params[:id])
|
56
|
+
# return 404 unless project && build
|
57
|
+
yield [:"builds/edit", {:project => project, :build => build}]
|
58
|
+
end
|
59
|
+
|
60
|
+
def update!
|
61
|
+
project = ::Juici::Project.find_or_raise(NotFound, name: params[:project])
|
62
|
+
build = ::Juici::Build.find_or_raise(NotFound, parent: project.name, _id: params[:id])
|
63
|
+
|
64
|
+
::Juici::Build::EDITABLE_ATTRIBUTES[:string].each do |attr|
|
65
|
+
build[attr] = params[attr] if params[attr]
|
66
|
+
end
|
67
|
+
# binding.pry
|
68
|
+
build.tap do |b|
|
69
|
+
b.save!
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
module Juici::Controllers
|
2
|
+
class Index
|
3
|
+
|
4
|
+
def index
|
5
|
+
yield [:index, {:active => :index}]
|
6
|
+
end
|
7
|
+
|
8
|
+
def about
|
9
|
+
content = GitHub::Markdown.render(File.read("lib/juici/views/README.markdown"))
|
10
|
+
content.gsub!(/<h(\d+)>/, '<h\1 class="block-header">')
|
11
|
+
yield [:about, {:active => :about, :content => content}]
|
12
|
+
end
|
13
|
+
|
14
|
+
def support
|
15
|
+
yield [:support, {:active => :support}]
|
16
|
+
end
|
17
|
+
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
@@ -0,0 +1,85 @@
|
|
1
|
+
module Juici::Controllers
|
2
|
+
class Trigger
|
3
|
+
|
4
|
+
attr_reader :project, :params
|
5
|
+
def initialize(project, params)
|
6
|
+
@project = ::Juici::Project.find_or_create_by(name: project)
|
7
|
+
@params = params
|
8
|
+
end
|
9
|
+
|
10
|
+
# Find an existing build, duplicate the sane parts of it.
|
11
|
+
def rebuild!
|
12
|
+
unless project = ::Juici::Project.where(name: params[:project]).first
|
13
|
+
not_found
|
14
|
+
end
|
15
|
+
unless build = ::Juici::Build.where(parent: project.name, _id: params[:id]).first
|
16
|
+
not_found
|
17
|
+
end
|
18
|
+
|
19
|
+
::Juici::Build.new_from(build).tap do |new_build|
|
20
|
+
new_build.save!
|
21
|
+
$build_queue << new_build
|
22
|
+
$build_queue.bump!
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def build!
|
27
|
+
environment = ::Juici::BuildEnvironment.new
|
28
|
+
::Juici::Build.new(parent: project.name).tap do |build|
|
29
|
+
# The seperation of concerns around this madness is horrifying
|
30
|
+
unless environment.load_json!(params['environment'])
|
31
|
+
build.warn!("Failed to parse environment")
|
32
|
+
end
|
33
|
+
|
34
|
+
build[:command] = build_command
|
35
|
+
build[:priority] = build_priority
|
36
|
+
build[:environment] = environment.to_hash
|
37
|
+
build[:callbacks] = callbacks
|
38
|
+
build[:title] = title if title_given?
|
39
|
+
|
40
|
+
build.save!
|
41
|
+
$build_queue << build
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
# These accessors are not really the concern of a "Controller". On the
|
46
|
+
# other hand, this thing is also not a controller realistically, so I'm
|
47
|
+
# just going to carry on using this as a mechanism to poke at my model.
|
48
|
+
#
|
49
|
+
# I later plan to implement a more strictly bounded interface which bails
|
50
|
+
# instead of coercing.
|
51
|
+
def build_command
|
52
|
+
# TODO Throw 422 not acceptable if missing
|
53
|
+
params['command'].tap do |c|
|
54
|
+
raise "No command given" if c.nil?
|
55
|
+
# TODO Detect that we've recieved this from a browser session and only
|
56
|
+
# do this replacement there, we can trust API supplied data.
|
57
|
+
c.gsub!(/\r\n/, "\n")
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def build_priority
|
62
|
+
if params['priority'] =~ /^[0-9]+$/
|
63
|
+
params['priority'].to_i
|
64
|
+
else
|
65
|
+
1
|
66
|
+
end
|
67
|
+
end
|
68
|
+
|
69
|
+
def callbacks
|
70
|
+
JSON.load(params['callbacks'])
|
71
|
+
rescue
|
72
|
+
[]
|
73
|
+
end
|
74
|
+
|
75
|
+
def title
|
76
|
+
params['title']
|
77
|
+
end
|
78
|
+
|
79
|
+
def title_given?
|
80
|
+
t = params['title']
|
81
|
+
!(t.nil? || t.empty?)
|
82
|
+
end
|
83
|
+
|
84
|
+
end
|
85
|
+
end
|