tinyci 0.4 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
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
  }