tinyci 0.4.2 → 0.5

Sign up to get free protection for your applications and to get access to all the features.
@@ -1,119 +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}
34
- o.on("-a", "--[no-]absolute-path", "install hook with absolute path to specific tinyci version (not recommended)") {|v| opts[:absolute_path] = 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 }
35
95
  end,
36
96
  'compact' => OptionParser.new do |o|
37
- o.banner = "Usage: compact [options]"
38
- 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}
39
- o.on("-b", "--builds-to-leave <BUILDS>", "specific build directories to leave in place, comma-separated") {|b| opts[:builds_to_leave] = b.split(',')}
40
- 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 }
41
110
  end
42
111
  }
43
-
44
- banner = <<TXT
45
- #{LOGO % TinyCI::VERSION}
46
- Global options:
47
- #{global.help.strip}
48
-
49
- Available commands:
50
- run build and test the repo
51
- install install the git hook into the current repository
52
- compact compress old build artifacts
53
- version print the TinyCI version number
54
- TXT
55
- if argv[0] == '--help'
56
- puts banner
57
- return false
58
- end
59
-
60
- global.order!(argv)
61
- command = argv.shift
62
-
63
- if command.nil? || subcommands[command].nil?
64
- puts banner
65
- return false
66
- end
67
-
68
- subcommands[command].order!(argv)
69
-
70
- opts[:dir] ||= begin
71
- repo_root
72
- rescue TinyCI::Subprocesses::SubprocessError => e
73
- if e.message == '`git rev-parse --is-inside-git-dir` failed with code 32768'
74
- exit 1
75
- else
76
- raise e
77
- end
78
- end
79
-
80
- send "do_#{command}", opts
81
112
  end
82
-
83
- 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)
84
148
  if PidFile.running?
85
149
  puts 'TinyCI is already running!' unless opts[:quiet]
86
150
  return false
87
151
  end
88
-
152
+
89
153
  opts.delete(:commit) if opts[:all]
90
-
154
+
91
155
  if !opts[:commit] && !opts[:all]
92
- 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]
93
157
  return false
94
158
  end
95
-
159
+
96
160
  logger = MultiLogger.new(quiet: opts[:quiet])
97
- result = Scheduler.new(commit: opts[:commit], logger: logger, working_dir: opts[:dir]).run!
98
-
161
+ result = Scheduler.new(commit: opts[:commit], logger: logger,
162
+ working_dir: opts[:working_dir]).run!
163
+
99
164
  result
100
165
  end
101
-
102
- def self.do_install(opts)
166
+
167
+ def do_install(opts)
103
168
  logger = MultiLogger.new(quiet: opts[:quiet])
104
-
105
- TinyCI::Installer.new(logger: logger, working_dir: opts[:dir], absolute_path: opts[:absolute_path]).install!
169
+
170
+ TinyCI::Installer.new(logger: logger, working_dir: opts[:working_dir],
171
+ absolute_path: opts[:absolute_path]).install!
106
172
  end
107
-
108
- def self.do_compact(opts)
173
+
174
+ def do_compact(opts)
109
175
  logger = MultiLogger.new(quiet: opts[:quiet])
110
-
111
- 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!
112
183
  end
113
-
114
- 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)
115
195
  puts TinyCI::VERSION
116
-
196
+
117
197
  true
118
198
  end
119
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