tinyci 0.4 → 0.5.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/lib/tinyci/cli.rb CHANGED
@@ -1,118 +1,199 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'tinyci/version'
2
4
  require 'tinyci/scheduler'
3
5
  require 'tinyci/installer'
4
6
  require 'tinyci/compactor'
7
+ require 'tinyci/log_viewer'
5
8
  require 'tinyci/git_utils'
9
+ require 'tinyci/cli_ssh_delegator'
6
10
  require 'optparse'
7
11
  require 'pidfile'
8
12
 
9
13
  module TinyCI
10
14
  # Defines the CLI interface. Uses OptionParser.
11
15
  class CLI
12
- extend GitUtils
13
-
14
- LOGO = File.read(File.expand_path('logo.txt', __dir__))
15
-
16
- def self.parse!(argv = ARGV)
17
- opts = {}
18
-
19
- global = OptionParser.new do |o|
16
+ include GitUtils
17
+
18
+ LOGO = File.read(File.expand_path('logo.txt', __dir__)).freeze
19
+ HIDDEN_OPTIONS = %w[
20
+ running-remotely
21
+ ].freeze
22
+
23
+ def initialize(argv = ARGV)
24
+ @argv = argv
25
+ @opts = {}
26
+ end
27
+
28
+ def parse!
29
+ if @argv[0] == '--help'
30
+ puts banner
31
+ return false
32
+ end
33
+
34
+ unless subcommand
35
+ puts banner
36
+ return false
37
+ end
38
+
39
+ global_parser.order!(global_args)
40
+ subcommand_parsers[subcommand].order!(subcommand_args)
41
+
42
+ @opts[:working_dir] ||= working_dir
43
+
44
+ if @opts[:remote]
45
+ CLISSHDelegator.new(@argv, **@opts).run!
46
+ else
47
+ send "do_#{subcommand}", @opts
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def working_dir
54
+ repo_root
55
+ rescue TinyCI::Subprocesses::SubprocessError => e
56
+ raise e unless e.message == '`git rev-parse --is-inside-git-dir` failed with code 32768'
57
+
58
+ exit 1
59
+ end
60
+
61
+ def global_parser
62
+ OptionParser.new do |o|
20
63
  o.banner = ''
21
- o.on("-q", "--[no-]quiet", "surpress output") {|q| opts[:quiet] = q}
22
- o.on("-D <DIR>", "--dir <DIR>", "specify repository location") {|d| opts[:dir] = d}
64
+ o.on('-q', '--[no-]quiet', 'surpress output') { |q| @opts[:quiet] = q }
65
+ o.on('--running-remotely') { |_rr| @opts[:running_remotely] = true }
66
+ o.on('-D=DIR', '--dir=DIR', 'specify repository location') { |d| @opts[:working_dir] = d }
67
+ o.on('-r [REMOTE]', '--remote [REMOTE]',
68
+ 'specify remote') { |r| @opts[:remote] = r.nil? ? true : r }
23
69
  end
24
-
25
- subcommands = {
70
+ end
71
+
72
+ def global_help
73
+ global_parser.help.split("\n").reject do |help_line|
74
+ HIDDEN_OPTIONS.any? { |o| help_line =~ Regexp.new(o) }
75
+ end.join("\n").strip
76
+ end
77
+
78
+ def subcommand_banner(subcommand_name)
79
+ "#{LOGO % TinyCI::VERSION}\nGlobal options:\n #{global_help}\n\n#{subcommand_name} options:"
80
+ end
81
+
82
+ def subcommand_parsers
83
+ {
26
84
  'run' => OptionParser.new do |o|
27
- o.banner = "#{LOGO % TinyCI::VERSION}\nGlobal options:\n #{global.help.slice(3..-1)}\nrun options:"
28
- o.on("-c <SHA>", "--commit <SHA>", "run against a specific commit") {|c| opts[:commit] = c}
29
- o.on("-a", "--all", "run against all commits which have not been run against before") {|a| opts[:all] = a}
85
+ o.banner = subcommand_banner('run')
86
+ o.on('-c <SHA>', '--commit <SHA>',
87
+ 'run against a specific commit') { |c| @opts[:commit] = c }
88
+ o.on('-a', '--all',
89
+ 'run against all commits which have not been run against before') { |a| @opts[:all] = a }
30
90
  end,
31
91
  'install' => OptionParser.new do |o|
32
- o.banner = "Usage: install [options]"
33
- o.on("-q", "--[no-]quiet", "quietly run") {|v| opts[:quiet] = v}
92
+ o.banner = subcommand_banner('install')
93
+ o.on('-a', '--[no-]absolute-path',
94
+ 'install hook with absolute path to specific tinyci version (not recommended)') { |v| @opts[:absolute_path] = v }
34
95
  end,
35
96
  'compact' => OptionParser.new do |o|
36
- o.banner = "Usage: compact [options]"
37
- o.on("-n", "--num-builds-to-leave <NUM>", "number of builds to leave in place, starting from the most recent") {|n| opts[:num_builds_to_leave] = n}
38
- o.on("-b", "--builds-to-leave <BUILDS>", "specific build directories to leave in place, comma-separated") {|b| opts[:builds_to_leave] = b.split(',')}
39
- o.on("-q", "--[no-]quiet", "quietly run") {|v| opts[:quiet] = v}
97
+ o.banner = subcommand_banner('compact')
98
+ o.on('-n', '--num-builds-to-leave <NUM>',
99
+ 'number of builds to leave in place, starting from the most recent') { |n| @opts[:num_builds_to_leave] = n }
100
+ o.on('-b', '--builds-to-leave <BUILDS>',
101
+ 'specific build directories to leave in place, comma-separated') { |b| @opts[:builds_to_leave] = b.split(',') }
102
+ end,
103
+ 'log' => OptionParser.new do |o|
104
+ o.banner = subcommand_banner('log')
105
+ o.on('-f', '--follow', 'follow the logfile') { |f| @opts[:follow] = f }
106
+ o.on('-n <NUM>', '--num-lines <NUM>',
107
+ 'number of lines to print') { |n| @opts[:num_lines] = n.to_i }
108
+ o.on('-c <SHA>', '--commit <SHA>',
109
+ 'run against a specific commit') { |c| @opts[:commit] = c }
40
110
  end
41
111
  }
42
-
43
- banner = <<TXT
44
- #{LOGO % TinyCI::VERSION}
45
- Global options:
46
- #{global.help.strip}
47
-
48
- Available commands:
49
- run build and test the repo
50
- install install the git hook into the current repository
51
- compact compress old build artifacts
52
- version print the TinyCI version number
53
- TXT
54
- if argv[0] == '--help'
55
- puts banner
56
- return false
57
- end
58
-
59
- global.order!(argv)
60
- command = argv.shift
61
-
62
- if command.nil? || subcommands[command].nil?
63
- puts banner
64
- return false
65
- end
66
-
67
- subcommands[command].order!(argv)
68
-
69
- opts[:dir] ||= begin
70
- repo_root
71
- rescue TinyCI::Subprocesses::SubprocessError => e
72
- if e.message == '`git rev-parse --is-inside-git-dir` failed with code 32768'
73
- exit 1
74
- else
75
- raise e
76
- end
77
- end
78
-
79
- send "do_#{command}", opts
80
112
  end
81
-
82
- def self.do_run(opts)
113
+
114
+ def banner
115
+ <<~TXT
116
+ #{LOGO % TinyCI::VERSION}
117
+ Global options:
118
+ #{global_help}
119
+
120
+ Available commands:
121
+ run build and test the repo
122
+ install install the git hook into the current repository
123
+ compact compress old build artifacts
124
+ log print logfiles
125
+ version print the TinyCI version number
126
+ TXT
127
+ end
128
+
129
+ def subcommand_index
130
+ @argv.index { |arg| subcommand_parsers.keys.include? arg }
131
+ end
132
+
133
+ def subcommand
134
+ return nil unless subcommand_index
135
+
136
+ @argv[subcommand_index]
137
+ end
138
+
139
+ def global_args
140
+ @argv[0..subcommand_index - 1]
141
+ end
142
+
143
+ def subcommand_args
144
+ @argv[subcommand_index + 1..-1]
145
+ end
146
+
147
+ def do_run(opts)
83
148
  if PidFile.running?
84
149
  puts 'TinyCI is already running!' unless opts[:quiet]
85
150
  return false
86
151
  end
87
-
152
+
88
153
  opts.delete(:commit) if opts[:all]
89
-
154
+
90
155
  if !opts[:commit] && !opts[:all]
91
- puts "You must pass either --commit or --all, or try --help" unless opts[:quiet]
156
+ puts 'You must pass either --commit or --all, or try --help' unless opts[:quiet]
92
157
  return false
93
158
  end
94
-
159
+
95
160
  logger = MultiLogger.new(quiet: opts[:quiet])
96
- result = Scheduler.new(commit: opts[:commit], logger: logger, working_dir: opts[:dir]).run!
97
-
161
+ result = Scheduler.new(commit: opts[:commit], logger: logger,
162
+ working_dir: opts[:working_dir]).run!
163
+
98
164
  result
99
165
  end
100
-
101
- def self.do_install(opts)
166
+
167
+ def do_install(opts)
102
168
  logger = MultiLogger.new(quiet: opts[:quiet])
103
-
104
- TinyCI::Installer.new(logger: logger, working_dir: opts[:dir]).install!
169
+
170
+ TinyCI::Installer.new(logger: logger, working_dir: opts[:working_dir],
171
+ absolute_path: opts[:absolute_path]).install!
105
172
  end
106
-
107
- def self.do_compact(opts)
173
+
174
+ def do_compact(opts)
108
175
  logger = MultiLogger.new(quiet: opts[:quiet])
109
-
110
- TinyCI::Compactor.new(logger: logger, working_dir: opts[:dir], num_builds_to_leave: opts[:num_builds_to_leave], builds_to_leave: opts[:builds_to_leave]).compact!
176
+
177
+ TinyCI::Compactor.new(
178
+ logger: logger,
179
+ working_dir: opts[:working_dir],
180
+ num_builds_to_leave: opts[:num_builds_to_leave],
181
+ builds_to_leave: opts[:builds_to_leave]
182
+ ).compact!
111
183
  end
112
-
113
- def do_version(opts)
184
+
185
+ def do_log(opts)
186
+ TinyCI::LogViewer.new(
187
+ working_dir: opts[:working_dir],
188
+ commit: opts[:commit],
189
+ follow: opts[:follow],
190
+ num_lines: opts[:num_lines]
191
+ ).view!
192
+ end
193
+
194
+ def do_version(_opts)
114
195
  puts TinyCI::VERSION
115
-
196
+
116
197
  true
117
198
  end
118
199
  end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tinyci/git_utils'
4
+ require 'net/ssh'
5
+
6
+ module TinyCI
7
+ # Wrapper that takes calls to {CLI} from the command line and executes them remotely via ssh, on
8
+ # the server hosting the remote tinyci is installed to. The main purpose is to allow convenient
9
+ # execution of `tinyci log` on the remote server.
10
+ class CLISSHDelegator
11
+ include GitUtils
12
+
13
+ #
14
+ # Constructor
15
+ #
16
+ # @param [Array<String>] argv The original arguments passed into {CLI}
17
+ # @param [String] working_dir The (local) directory from which to run.
18
+ # @param [String, Boolean] remote Which remote to ssh into. If this is set to `true` then use
19
+ # the upstream remote for the current branch
20
+ # @param [<Type>] **opts <description>
21
+ #
22
+ def initialize(argv, working_dir:, remote:, **opts)
23
+ @argv = argv
24
+ @working_dir = working_dir
25
+
26
+ # Handle `remote: true` case where `--remote` switch is passed on its own without specifying a
27
+ # remote name. The fact of this case must be stored so that the arguments to the remote
28
+ # execution can be properly constructed.
29
+ if remote == true
30
+ @remote = current_tracking_remote
31
+ @derived_remote = true
32
+ else
33
+ @remote = remote
34
+ @derived_remote = false
35
+ end
36
+ @opts = opts
37
+ end
38
+
39
+ def run!
40
+ unless remote_exists?
41
+ warn "Remote `#{@remote}` not found"
42
+
43
+ return false
44
+ end
45
+
46
+ if github_remote?
47
+ msg = "`#{@remote}` is a github remote: #{remote_url}"
48
+ msg += "\nPerhaps you meant to run tinyci #{@argv.first} against a different one?"
49
+
50
+ warn msg
51
+ return false
52
+ end
53
+
54
+ unless ssh_remote?
55
+ msg = "`#{@remote}` does not appear to have an ssh remote: #{remote_url}"
56
+ msg += "\nPerhaps you meant to run tinyci #{@argv.first} against a different one?"
57
+
58
+ warn msg
59
+ return false
60
+ end
61
+
62
+ do_tunnel!(remote_url.host, remote_url.user, command)
63
+
64
+ true
65
+ end
66
+
67
+ def command
68
+ (['tinyci', '--running-remotely', '--dir', remote_url.path] + args).join ' '
69
+ end
70
+
71
+ #
72
+ # Build the argument list to execute at the remote end.
73
+ # Main concern here is removing the arguments which are specific to remote execution, ie.
74
+ # those relevant only to this class.
75
+ #
76
+ def args
77
+ args = @argv.clone
78
+
79
+ # since --dir always has an argument we can delete twice to get rid of the switch and the arg
80
+ index = args.index('--dir') || args.index('-D')
81
+ 2.times { args.delete_at index } if index
82
+
83
+ # the --remote switch can live on its own
84
+ index = args.index('--remote') || args.index('-r')
85
+ if index
86
+ args.delete_at index
87
+ args.delete_at index unless @derived_remote
88
+ end
89
+
90
+ args
91
+ end
92
+
93
+ def do_tunnel!(host, user, cmd)
94
+ Net::SSH.start(host, user) do |ssh|
95
+ ssh.open_channel do |ch|
96
+ ch.exec cmd do |e_ch, success|
97
+ abort 'could not execute command' unless success
98
+ e_ch.on_data do |_ch, data|
99
+ print data
100
+ end
101
+ e_ch.on_extended_data do |_ch, _type, data|
102
+ warn data
103
+ end
104
+ end
105
+ end
106
+
107
+ ssh.loop
108
+ end
109
+ end
110
+ end
111
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'fileutils'
2
4
  require 'tinyci/git_utils'
3
5
  require 'zlib'
@@ -5,16 +7,16 @@ require 'rubygems/package'
5
7
  require 'find'
6
8
 
7
9
  module TinyCI
8
-
9
10
  # Tool for compressing old builds into .tar.gz files
10
11
  class Compactor
11
12
  include GitUtils
12
13
  include Subprocesses
13
-
14
+
14
15
  BLOCKSIZE_TO_READ = 1024 * 1000
15
-
16
+
17
+ #
16
18
  # Constructor
17
- #
19
+ #
18
20
  # @param [String] working_dir The directory from which to run.
19
21
  # @param [Integer] num_builds_to_leave How many builds not to compact, starting from the newest
20
22
  # @param [String] builds_to_leave Comma-separated list of build directory names not to compact
@@ -23,77 +25,75 @@ module TinyCI
23
25
  @logger = logger
24
26
  @working_dir = working_dir || repo_root
25
27
  @num_builds_to_leave = (num_builds_to_leave || 1).to_i
26
- @builds_to_leave = builds_to_leave || []
28
+ @builds_to_leave = (Array(builds_to_leave) || []).map(&:to_s)
27
29
  end
28
-
30
+
29
31
  # Compress and delete the build directories
30
32
  def compact!
31
33
  unless inside_repository?
32
- log_error "not currently in a git repository"
34
+ log_error 'not currently in a git repository'
33
35
  return false
34
36
  end
35
-
37
+
36
38
  directories_to_compact.each do |dir|
37
39
  compress_directory dir
38
40
  FileUtils.rm_rf builds_dir(dir)
39
-
41
+
40
42
  log_info "Compacted #{archive_path(dir)}"
41
43
  end
42
44
  end
43
-
45
+
44
46
  private
45
-
47
+
46
48
  # Build the list of directories to compact according to the options
47
49
  def directories_to_compact
48
50
  builds = Dir.entries builds_dir
49
- builds.select! {|e| File.directory? builds_dir(e) }
50
- builds.reject! {|e| %w{. ..}.include? e }
51
+ builds.select! { |e| File.directory? builds_dir(e) }
52
+ builds.reject! { |e| %w[. ..].include? e }
51
53
  builds.sort!
52
-
53
- builds = builds[0..-(@num_builds_to_leave+1)]
54
- builds.reject! {|e| @builds_to_leave.include?(e) || @builds_to_leave.include?(builds_dir(e, 'export'))}
54
+
55
+ builds = builds[0..-(@num_builds_to_leave + 1)]
56
+ builds.reject! { |e| @builds_to_leave.include?(e) || @builds_to_leave.include?(builds_dir(e)) }
55
57
 
56
58
  builds
57
59
  end
58
-
60
+
59
61
  # Get the location of the builds directory
60
62
  def builds_dir(*path_segments)
61
63
  File.join @working_dir, 'builds/', *path_segments
62
64
  end
63
-
65
+
64
66
  # Build the path for a compressed archive
65
67
  def archive_path(dir)
66
- File.join(builds_dir, dir+".tar.gz")
68
+ File.join(builds_dir, dir + '.tar.gz')
67
69
  end
68
-
70
+
69
71
  # Create a .tar.gz file from a directory
70
72
  # Done in pure ruby to ensure portability
71
73
  def compress_directory(dir)
72
74
  File.open archive_path(dir), 'wb' do |oarchive_path|
73
75
  Zlib::GzipWriter.wrap oarchive_path do |gz|
74
76
  Gem::Package::TarWriter.new gz do |tar|
75
- Find.find "#{builds_dir}/"+dir do |f|
76
- relative_path = f.sub "#{builds_dir}/", ""
77
+ Find.find "#{builds_dir}/" + dir do |f|
78
+ relative_path = f.sub "#{builds_dir}/", ''
77
79
  mode = File.stat(f).mode
78
80
  size = File.stat(f).size
79
-
81
+
80
82
  if File.directory? f
81
83
  tar.mkdir relative_path, mode
82
84
  else
83
85
  tar.add_file_simple relative_path, mode, size do |tio|
84
86
  File.open f, 'rb' do |rio|
85
- while buffer = rio.read(BLOCKSIZE_TO_READ)
87
+ while (buffer = rio.read(BLOCKSIZE_TO_READ))
86
88
  tio.write buffer
87
89
  end
88
90
  end
89
91
  end
90
92
  end
91
-
92
93
  end
93
94
  end
94
95
  end
95
96
  end
96
-
97
97
  end
98
98
  end
99
99
  end
data/lib/tinyci/config.rb CHANGED
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'tinyci/config_transformer'
2
4
  require 'tinyci/symbolize'
3
5
  require 'yaml'
@@ -6,60 +8,57 @@ module TinyCI
6
8
  # Represents the Configuration for a repo, parsed from the `.tinyci.yml` file in the repo root.
7
9
  # Mainly a wrapper around a the hash object parsed from the yaml in the config file.
8
10
  # The keys of the hash are recursively symbolized.
9
- #
11
+ #
10
12
  # As it is loaded, the configuration file data is passed through the {TinyCI::ConfigTransformer}
11
13
  # class, which translates any definitions in the concise format into the more verbose format
12
14
  class Config
13
15
  include Symbolize
14
-
16
+
17
+ attr_accessor :config_path
18
+
15
19
  # Constructor
16
- #
20
+ #
17
21
  # @param [String] working_dir The working directory in which to find the config file
18
22
  # @param [String] config_path Override the path to the config file
19
23
  # @param [String] config Override the config content
20
- #
24
+ #
21
25
  # @raise [ConfigMissingError] if the config file is not found
22
- def initialize(working_dir: '.', config_path: nil, config: nil)
23
- @working_dir = working_dir
24
- @config_pathname = config_path
26
+ def initialize(config_path, config: nil)
27
+ @config_path = config_path
25
28
  @config_content = config
26
-
27
- raise ConfigMissingError, "config file #{config_pathname} not found" unless config_file_exists?
29
+
30
+ raise ConfigMissingError, "config file #{config_path} not found" unless config_file_exists?
28
31
  end
29
-
32
+
30
33
  # Address into the config object
31
- #
34
+ #
32
35
  # @param [Symbol] key The key to address
33
36
  def [](key)
34
37
  config_content[key]
35
38
  end
36
-
39
+
37
40
  # Return the raw hash representation
38
- #
41
+ #
39
42
  # @return [Hash] The configuration as a hash
40
43
  def to_hash
41
44
  config_content
42
45
  end
43
-
46
+
44
47
  private
45
-
48
+
46
49
  def config_file_exists?
47
- File.exist? config_pathname
48
- end
49
-
50
- def config_pathname
51
- @config_pathname || File.expand_path('.tinyci.yml', @working_dir)
50
+ File.exist? config_path
52
51
  end
53
-
52
+
54
53
  def config_content
55
54
  @config_content ||= begin
56
- config = YAML.safe_load(File.read(config_pathname))
55
+ config = YAML.safe_load(File.read(config_path))
57
56
  transformed_config = ConfigTransformer.new(config).transform!
58
57
  symbolize(transformed_config).freeze
59
58
  end
60
59
  end
61
60
  end
62
-
61
+
63
62
  # Error raised when the config file cannot be found
64
63
  class ConfigMissingError < StandardError; end
65
64
  end
@@ -1,61 +1,59 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module TinyCI
2
4
  # Transforms the configuration format from the condensed format to the
3
5
  # more verbose format accepted by the rest of the system
4
6
  class ConfigTransformer
5
-
6
7
  # Constructor
7
- #
8
+ #
8
9
  # @param [Hash] input The configuration object, in the condensed format
9
10
  def initialize(input)
10
11
  @input = input
11
12
  end
12
-
13
+
13
14
  # Transforms the config object
14
- #
15
+ #
15
16
  # @return [Hash] The config object in the verbose form
16
17
  def transform!
17
- @input.inject({}) do |acc, (key, value)|
18
+ @input.each_with_object({}) do |(key, value), acc|
18
19
  method_name = "transform_#{key}"
19
-
20
+
20
21
  if respond_to? method_name, true
21
-
22
22
  acc.merge! send(method_name, value)
23
23
  else
24
24
  acc[key] = value
25
25
  end
26
-
27
- acc
28
26
  end
29
27
  end
30
-
28
+
31
29
  private
32
-
30
+
33
31
  def transform_build(value)
34
32
  {
35
33
  builder: {
36
- :class => "ScriptBuilder",
34
+ class: 'ScriptBuilder',
37
35
  config: {
38
36
  command: value
39
37
  }
40
38
  }
41
39
  }
42
40
  end
43
-
41
+
44
42
  def transform_test(value)
45
43
  {
46
44
  tester: {
47
- :class => "ScriptTester",
45
+ class: 'ScriptTester',
48
46
  config: {
49
47
  command: value
50
48
  }
51
49
  }
52
50
  }
53
51
  end
54
-
52
+
55
53
  def transform_hooks(value)
56
54
  {
57
55
  hooker: {
58
- :class => "ScriptHooker",
56
+ class: 'ScriptHooker',
59
57
  config: value
60
58
  }
61
59
  }