tinyci 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
data/lib/tinyci/cli.rb ADDED
@@ -0,0 +1,104 @@
1
+ require 'tinyci/version'
2
+ require 'tinyci/scheduler'
3
+ require 'tinyci/installer'
4
+ require 'tinyci/git_utils'
5
+ require 'optparse'
6
+
7
+ module TinyCI
8
+ # Defines the CLI interface. Uses OptionParser.
9
+ class CLI
10
+ extend GitUtils
11
+
12
+ LOGO = File.read(File.expand_path('logo.txt', __dir__))
13
+
14
+ def self.parse!(argv = ARGV)
15
+ opts = {}
16
+
17
+ global = OptionParser.new do |o|
18
+ o.banner = ''
19
+ o.on("-q", "--[no-]quiet", "surpress output") {|q| opts[:quiet] = q}
20
+ o.on("-D <DIR>", "--dir <DIR>", "specify repository location") {|d| opts[:dir] = d}
21
+ end
22
+
23
+ subcommands = {
24
+ 'run' => OptionParser.new do |o|
25
+ o.banner = "#{LOGO % TinyCI::VERSION}\nGlobal options:\n #{global.help.slice(3..-1)}\nrun options:"
26
+ o.on("-c <SHA>", "--commit <SHA>", "run against a specific commit") {|c| opts[:commit] = c}
27
+ o.on("-a", "--all", "run against all commits which have not been run against before") {|a| opts[:all] = a}
28
+ end,
29
+ 'install' => OptionParser.new do |o|
30
+ o.banner = "Usage: install [options]"
31
+ o.on("-q", "--[no-]quiet", "quietly run") {|v| opts[:quiet] = v}
32
+ end
33
+ }
34
+
35
+ banner = <<TXT
36
+ #{LOGO % TinyCI::VERSION}
37
+ Global options:
38
+ #{global.help.strip}
39
+
40
+ Available commands:
41
+ run build and test the repo
42
+ install install the git hook into the current repository
43
+ version print the TinyCI version number
44
+ TXT
45
+ if argv[0] == '--help'
46
+ puts banner
47
+ return false
48
+ end
49
+
50
+ global.order!(argv)
51
+ command = argv.shift
52
+
53
+ if command.nil? || subcommands[command].nil?
54
+ puts banner
55
+ return false
56
+ end
57
+
58
+ subcommands[command].order!(argv)
59
+
60
+ opts[:dir] ||= begin
61
+ repo_root
62
+ rescue TinyCI::Subprocesses::SubprocessError => e
63
+ if e.message == '`git rev-parse --is-inside-git-dir` failed with code 32768'
64
+ exit 1
65
+ else
66
+ raise e
67
+ end
68
+ end
69
+
70
+ send "do_#{command}", opts
71
+ end
72
+
73
+ def self.do_run(opts)
74
+ if PidFile.running?
75
+ puts 'TinyCI is already running!' unless opts[:quiet]
76
+ return false
77
+ end
78
+
79
+ opts.delete(:commit) if opts[:all]
80
+
81
+ if !opts[:commit] && !opts[:all]
82
+ puts "You must pass either --commit or --all, or try --help" unless opts[:quiet]
83
+ return false
84
+ end
85
+
86
+ logger = MultiLogger.new(quiet: opts[:quiet])
87
+ result = Scheduler.new(commit: opts[:commit], logger: logger, working_dir: opts[:dir]).run!
88
+
89
+ result
90
+ end
91
+
92
+ def self.do_install(opts)
93
+ logger = MultiLogger.new(quiet: opts[:quiet])
94
+
95
+ TinyCI::Installer.new(logger: logger, working_dir: opts[:dir]).write!
96
+ end
97
+
98
+ def do_version(opts)
99
+ puts TinyCI::VERSION
100
+
101
+ true
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,55 @@
1
+ require 'tinyci/symbolize'
2
+ require 'yaml'
3
+
4
+ module TinyCI
5
+ # Represents the Configuration for a repo, parsed from the `.tinyci.yml` file in the repo root.
6
+ # Mainly a wrapper around a the hash object parsed from the yaml in the config file.
7
+ # The keys of the hash are recursively symbolized.
8
+ class Config
9
+ include Symbolize
10
+
11
+ # Constructor
12
+ #
13
+ # @param [String] working_dir The working directory in which to find the config file
14
+ # @param [String] config_path Override the path to the config file
15
+ # @param [String] config Override the config content
16
+ #
17
+ # @raise [ConfigMissingError] if the config file is not found
18
+ def initialize(working_dir: '.', config_path: nil, config: nil)
19
+ @working_dir = working_dir
20
+ @config_pathname = config_path
21
+ @config_content = config
22
+
23
+ raise ConfigMissingError, "config file #{config_pathname} not found" unless config_file_exists?
24
+ end
25
+
26
+ # Address into the config object
27
+ #
28
+ # @param [Symbol] key The key to address
29
+ def [](key)
30
+ config_content[key]
31
+ end
32
+
33
+ # Return the raw hash representation
34
+ def to_hash
35
+ config_content
36
+ end
37
+
38
+ private
39
+
40
+ def config_file_exists?
41
+ File.exist? config_pathname
42
+ end
43
+
44
+ def config_pathname
45
+ @config_pathname || File.expand_path('.tinyci.yml', @working_dir)
46
+ end
47
+
48
+ def config_content
49
+ @config_content ||= symbolize(YAML.safe_load(File.read(config_pathname))).freeze
50
+ end
51
+ end
52
+
53
+ # Error raised when the config file cannot be found
54
+ class ConfigMissingError < StandardError; end
55
+ end
@@ -0,0 +1,21 @@
1
+ require 'tinyci/subprocesses'
2
+ require 'tinyci/logging'
3
+
4
+ module TinyCI
5
+ # Parent class for Builder and Tester classes
6
+ #
7
+ # @abstract
8
+ class Executor
9
+ include Subprocesses
10
+ include Logging
11
+
12
+ # Returns a new instance of the executor.
13
+ #
14
+ # @param config [Hash] Configuration hash, typically taken from relevant key in the {Config} object.
15
+ # @param logger [Logger] Logger object
16
+ def initialize(config, logger: nil)
17
+ @config = config
18
+ @logger = logger
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,68 @@
1
+ require 'tinyci/subprocesses'
2
+
3
+ module TinyCI
4
+
5
+ # Methods for dealing with git repos.
6
+ module GitUtils
7
+
8
+ # Returns the absolute path to the root of the current git directory
9
+ #
10
+ # @return [String] the path
11
+ def repo_root
12
+ return git_directory_path if inside_bare_repo?
13
+
14
+ if inside_git_directory?
15
+ File.expand_path('..', git_directory_path)
16
+ elsif inside_work_tree?
17
+ execute(git_cmd('rev-parse', '--show-toplevel'))
18
+ else
19
+ raise 'not in git directory or work tree!?'
20
+ end
21
+ end
22
+
23
+ # Are we currently under a git repo?
24
+ def inside_git_directory?
25
+ execute(git_cmd('rev-parse', '--is-inside-git-dir')) == 'true'
26
+ end
27
+
28
+ # Are we under a bare repo?
29
+ def inside_bare_repo?
30
+ execute(git_cmd('rev-parse', '--is-bare-repository')) == 'true'
31
+ end
32
+
33
+ # Are we currently under a git work tree?
34
+ def inside_work_tree?
35
+ execute(git_cmd('rev-parse', '--is-inside-work-tree')) == 'true'
36
+ end
37
+
38
+ # Are we currently under a repo in any sense?
39
+ def inside_repository?
40
+ execute(git_cmd('rev-parse', '--is-inside-work-tree', '--is-inside-git-dir')).split.any? {|s| s == 'true'}
41
+ end
42
+
43
+ # Returns the absolute path to the .git directory
44
+ def git_directory_path
45
+ File.expand_path(execute(git_cmd('rev-parse', '--git-dir')), defined?(@working_dir) ? @working_dir : nil)
46
+ end
47
+
48
+ # Execute a git command, passing the -C parameter if the current object has
49
+ # the working_directory instance var set
50
+ def git_cmd(*args)
51
+ cmd = ['git']
52
+ cmd += ['-C', @working_dir] if defined?(@working_dir) && !@working_dir.nil?
53
+ cmd += args
54
+
55
+ cmd
56
+ end
57
+
58
+ private
59
+
60
+ def self.included(base)
61
+ base.include Subprocesses
62
+ end
63
+
64
+ def self.extended(base)
65
+ base.extend Subprocesses
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,55 @@
1
+ require 'fileutils'
2
+ require 'tinyci/git_utils'
3
+
4
+ module TinyCI
5
+
6
+ # Responsible for writing the git hook file
7
+ class Installer
8
+ include GitUtils
9
+
10
+ # Constructor
11
+ #
12
+ # @param [String] working_dir The directory from which to run. Does not have to be the root of the repo
13
+ # @param [Logger] logger Logger object
14
+ def initialize(working_dir: nil, logger: nil)
15
+ @logger = logger
16
+ @working_dir = working_dir || repo_root
17
+ end
18
+
19
+ # Write the hook to the relevant path and make it executable
20
+ def write!
21
+ unless inside_repository?
22
+ log_error "not currently in a git repository"
23
+ return false
24
+ end
25
+
26
+ if hook_exists?
27
+ log_error "post-update hook already exists in this repository"
28
+ return false
29
+ end
30
+
31
+ File.open(hook_path, 'a') {|f| f.write hook_content}
32
+ FileUtils.chmod('u+x', hook_path)
33
+
34
+ log_info 'tinyci post-update hook installed successfully'
35
+ end
36
+
37
+ private
38
+
39
+ def hook_exists?
40
+ File.exist? hook_path
41
+ end
42
+
43
+ def hook_path
44
+ File.expand_path('hooks/post-update', git_directory_path)
45
+ end
46
+
47
+ def hook_content
48
+ <<-EOF
49
+ #!/bin/sh
50
+
51
+ tinyci run --all
52
+ EOF
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,16 @@
1
+ require 'tinyci/multi_logger'
2
+
3
+ module TinyCI
4
+ # Defines helper instance methods for logging to reduce code verbosity
5
+ module Logging
6
+
7
+ %w(log debug info warn error fatal unknown).each do |m|
8
+ define_method("log_#{m}") do |*args|
9
+ return false unless defined?(@logger) && @logger.is_a?(MultiLogger)
10
+
11
+ @logger.send(m, *args)
12
+ end
13
+ end
14
+
15
+ end
16
+ end
@@ -0,0 +1,6 @@
1
+ _____ _ _____ _____
2
+ /__ (_)_ __ _ _ / ___/ /_ _/
3
+ | || | '_ \| | | |/ / / /
4
+ | || | | | | |_| / /___/\/ /_
5
+ |_||_|_| |_|\__, \____/\____/ %s
6
+ |___/
@@ -0,0 +1,49 @@
1
+ require 'logger'
2
+
3
+ module TinyCI
4
+ # This class allows logging to both `STDOUT` and to a file with a single call.
5
+ # @attr [Boolean] quiet Disables logging to STDOUT
6
+ class MultiLogger
7
+ FORMAT = Proc.new do |severity, datetime, progname, msg|
8
+ "[#{datetime.strftime "%T"}] #{msg}\n"
9
+ end
10
+
11
+ attr_accessor :quiet
12
+
13
+ # Constructor
14
+ #
15
+ # @param [Boolean] quiet Disables logging to STDOUT
16
+ # @param [String] path Location to write logfile to
17
+ def initialize(quiet: false, path: nil)
18
+ @file_logger = nil
19
+ self.output_path = path
20
+ @quiet = quiet
21
+
22
+ @stdout_logger = Logger.new($stdout)
23
+ @stdout_logger.formatter = FORMAT
24
+ @stdout_logger.level = Logger::INFO
25
+ end
26
+
27
+ def targets
28
+ logs = []
29
+ logs << @file_logger if @file_logger
30
+ logs << @stdout_logger unless @quiet
31
+
32
+ logs
33
+ end
34
+
35
+ def output_path=(path)
36
+ if path
37
+ @file_logger = Logger.new(path)
38
+ @file_logger.formatter = FORMAT
39
+ @file_logger.level = Logger::INFO
40
+ end
41
+ end
42
+
43
+ %w(log debug info warn error fatal unknown).each do |m|
44
+ define_method(m) do |*args|
45
+ targets.each { |t| t.send(m, *args) }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,154 @@
1
+ require 'tinyci/subprocesses'
2
+ require 'tinyci/git_utils'
3
+ require 'tinyci/logging'
4
+ require 'tinyci/config'
5
+
6
+ require 'tinyci/builders/test_builder'
7
+ require 'tinyci/testers/test_tester'
8
+
9
+ require 'tinyci/builders/rkt_builder'
10
+ require 'tinyci/testers/rkt_tester'
11
+
12
+ require 'tinyci/builders/script_builder'
13
+ require 'tinyci/testers/script_tester'
14
+
15
+ require 'fileutils'
16
+
17
+ module TinyCI
18
+ # Responsible for managing the running of TinyCI against a single git object.
19
+ #
20
+ # @attr builder [TinyCI::Executor] Returns the Builder object. Used solely for testing at this time.
21
+ # @attr tester [TinyCI::Executor] Returns the Tester object. Used solely for testing at this time.
22
+ class Runner
23
+ include Subprocesses
24
+ include GitUtils
25
+ include Logging
26
+
27
+ attr_accessor :builder, :tester
28
+
29
+ # Constructor, allows injection of generic configuration params.
30
+ #
31
+ # @param working_dir [String] The working directory to execute against.
32
+ # @param commit [String] SHA1 of git object to run against
33
+ # @param logger [Logger] Logger object
34
+ # @param time [Time] Override time of object creation. Used solely for testing at this time.
35
+ # @param config [Hash] Override TinyCI config object, normally loaded from `.tinyci` file. Used solely for testing at this time.
36
+ def initialize(working_dir: '.', commit:, time: nil, logger: nil, config: nil)
37
+ @working_dir = working_dir
38
+ @logger = logger
39
+ @config = config
40
+ @commit = commit
41
+ @time = time || commit_time
42
+ end
43
+
44
+ # Runs the TinyCI system against the single git object referenced in `@commit`.
45
+ #
46
+ # @return [Boolean] `true` if the commit was built and tested successfully, `false` otherwise
47
+ def run!
48
+ begin
49
+ ensure_path target_path
50
+ setup_log
51
+
52
+ log_info "Commit: #{@commit}"
53
+
54
+ log_info "Cleaning..."
55
+ clean
56
+
57
+ log_info "Exporting..."
58
+ ensure_path export_path
59
+ export
60
+
61
+ begin
62
+ load_config
63
+ rescue ConfigMissingError => e
64
+ log_error e.message
65
+ log_error 'Removing export...'
66
+ clean
67
+
68
+ return false
69
+ end
70
+ @builder ||= instantiate_builder
71
+ @tester ||= instantiate_tester
72
+
73
+ log_info "Building..."
74
+ @builder.build
75
+
76
+ log_info "Testing..."
77
+ @tester.test
78
+
79
+ log_info "Finished #{@commit}"
80
+ rescue => e
81
+ raise e if ENV['TINYCI_ENV'] == 'test'
82
+
83
+ log_error e
84
+ log_error e.backtrace
85
+ return false
86
+ ensure
87
+
88
+ end
89
+
90
+ true
91
+ end
92
+
93
+ private
94
+
95
+ # Creates log file if it doesnt exist
96
+ def setup_log
97
+ return unless @logger.is_a? MultiLogger
98
+ FileUtils.touch logfile_path
99
+ @logger.output_path = logfile_path
100
+ end
101
+
102
+ def logfile_path
103
+ File.join(target_path, 'tinyci.log')
104
+ end
105
+
106
+ # Instantiate the Builder object according to the class named in the config
107
+ def instantiate_builder
108
+ klass = TinyCI::Builders.const_get(@config[:builder][:class])
109
+ klass.new(@config[:builder][:config].merge(target: export_path), logger: @logger)
110
+ end
111
+
112
+ # Instantiate the Tester object according to the class named in the config
113
+ def instantiate_tester
114
+ klass = TinyCI::Testers.const_get(@config[:tester][:class])
115
+ klass.new(@config[:tester][:config].merge(target: export_path), logger: @logger)
116
+ end
117
+
118
+ # Instantiate the {Config} object from the `.tinyci.yml` file in the exported directory
119
+ def load_config
120
+ @config ||= Config.new(working_dir: export_path)
121
+ end
122
+
123
+ # Parse the commit time from git
124
+ def commit_time
125
+ Time.at execute(git_cmd('show', '-s', '--format=%ct', @commit)).to_i
126
+ end
127
+
128
+ # Build the absolute target path
129
+ def target_path
130
+ File.absolute_path("#{@working_dir}/builds/#{@time.to_i}_#{@commit}/")
131
+ end
132
+
133
+ # Build the export path
134
+ def export_path
135
+ File.join(target_path, 'export')
136
+ end
137
+
138
+ def ensure_path(path)
139
+ execute 'mkdir', '-p', path
140
+ end
141
+
142
+ def clean
143
+ FileUtils.rm_rf export_path
144
+ end
145
+
146
+ # Export a clean copy of the repo at the given commit, without a .git directory etc.
147
+ # This implementation is slightly hacky but its the cleanest way to do it in the absence of
148
+ # a `git export` subcommand.
149
+ # see https://stackoverflow.com/a/163769
150
+ def export
151
+ execute_pipe git_cmd('archive', '--format=tar', @commit), ['tar', '-C', export_path, '-xf', '-']
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,105 @@
1
+ require 'tinyci/runner'
2
+ require 'tinyci/subprocesses'
3
+ require 'tinyci/git_utils'
4
+ require 'pidfile'
5
+
6
+ module TinyCI
7
+ # Manages the execution of test jobs. Responsible for deciding which
8
+ # commits need to be built and tested. Also manages the pidfile. This
9
+ # is the main entrypoint for TinyCI.
10
+ #
11
+ # @attr_reader [String] working_dir The working directory to execute against
12
+ class Scheduler
13
+ include Subprocesses
14
+ include Logging
15
+ include GitUtils
16
+
17
+ attr_reader :working_dir
18
+
19
+ # Constructor, allows injection of configuration and custom {Runner} class.
20
+ # Config params are passed to {Runner} instances.
21
+ #
22
+ # @param working_dir [String] The working directory to execute against
23
+ # @param logger [Logger] Logger object
24
+ # @param commit [String] specific git object to run against
25
+ # @param runner_class [TinyCI::Runner] Injection of {Runner} dependency
26
+ def initialize(
27
+ working_dir: nil,
28
+ logger: nil,
29
+ commit: nil,
30
+ runner_class: Runner
31
+ )
32
+
33
+ @working_dir = working_dir || repo_root
34
+ @logger = logger
35
+ @runner_class = runner_class
36
+ @commit = commit
37
+ end
38
+
39
+ # Runs the TinyCI system against the relevant commits. Also sets up the pidfile.
40
+ #
41
+ # @return [Boolean] `true` if all commits built and tested successfully, `false` otherwise
42
+ def run!
43
+ pid = PidFile.new(pidfile: 'tinyci.pid', piddir: @working_dir)
44
+
45
+ result = if @commit
46
+ run_commit @commit
47
+ else
48
+ run_all_commits
49
+ end
50
+
51
+ pid.release
52
+
53
+ result
54
+ end
55
+
56
+ private
57
+
58
+ # Git objects to be executed against, all those without a tinyci tag
59
+ #
60
+ # @return [Array<String>] the sha1 hashes in reverse order of creation time
61
+ def get_commits
62
+ log = execute(git_cmd('log', '--notes=tinyci*', '--format=%H %ct %N§§§', '--reverse'))
63
+
64
+ lines = log.split("§§§")
65
+ lines.map do |line|
66
+ parts = line.split(' ')
67
+ {
68
+ sha: parts[0],
69
+ time: parts[1],
70
+ result: parts[2]
71
+ }
72
+ end.select {|c| c[:result].nil?}
73
+ end
74
+
75
+ # Instantiates {Runner} for a given git object, runs it, and stores the result
76
+ def run_commit(commit)
77
+ result = @runner_class.new(
78
+ working_dir: @working_dir,
79
+ commit: commit[:sha],
80
+ time: commit[:time],
81
+ logger: @logger
82
+ ).run!
83
+
84
+ set_result(commit, result)
85
+ end
86
+
87
+ # Repeatedly gets the list of eligable commits and runs TinyCI against them until there are no more remaining
88
+ def run_all_commits
89
+ commits = get_commits
90
+
91
+ until commits.empty? do
92
+ commits.each {|c| run_commit(c)}
93
+
94
+ commits = get_commits
95
+ end
96
+ end
97
+
98
+ # Stores the result in a git note
99
+ def set_result(commit, result)
100
+ result_message = result ? 'success' : 'failure'
101
+
102
+ execute git_cmd('notes', '--ref', 'tinyci-result', 'add', '-m', result_message, commit[:sha])
103
+ end
104
+ end
105
+ end