tinyci 0.1.0

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/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