ci_lib.rb (8319B)
1 # frozen_string_literal: true 2 3 require 'open3' 4 require 'pathname' 5 require 'set' 6 7 module CI 8 extend self 9 10 def einfo str; $stderr.puts " \e[32;1m*\e[0m #{str}"; end 11 def ewarn str; $stderr.puts " \e[33;1m*\e[0m #{str}"; end 12 def eerror str; $stderr.puts " \e[31;1m*\e[0m #{str}"; end 13 def ecmd str; $stderr.puts " \e[35;1m*\e[0m #{str}"; end 14 15 # Replacement for Shellwords.shellescape. Unfortunately it outputs pretty 16 # unreadable shit as it escapes space with \ instead of putting it in quotes, 17 # and if there are like, newlines in the string, it puts just the newline into 18 # quotes. Our primary goal here is to have a *readable* output as the output 19 # normally won't be run through the shell, try to create something that's 20 # readable and also valid bash input. 21 refine String do 22 def shellescape 23 if self =~ %r{^[0-9a-zA-Z%+,./:=@^_'-]+$} 24 gsub "'", "\\\\'" 25 else 26 "'" + gsub("'", "'\\\\''") + "'" 27 end 28 end 29 30 # because adding this to ruby is fucking impossible, as there's an alternate 31 # solution, which is: 32 # base = expr() 33 # Dir.glob("*/glob...", base: base).map {|f| File.join base, f } 34 # instead of 35 # Dir.glob "#{Dir.glob_escape expr()}/*/glob..." 36 # or our version: 37 # Dir.glob "#{expr().globescape}/*/glob..." 38 # yeah, I'm totally gonna write that boilerplate every fucking time. And it 39 # also only works at the beginning of the path, not anywhere else. Why must 40 # every language became java?! 41 def globescape; gsub /\[|\]|\*|\?|\{|\}/, '\\\\' + '\0'; end 42 end 43 # Fuckin shellwords (required by nokogiri) puts shelljoin onto Array instead 44 # of Enumerable, so override it here too. 45 refine Array do 46 def shelljoin; map(&:shellescape).join ' '; end 47 end 48 refine Enumerable do 49 def shelljoin; map(&:shellescape).join ' '; end 50 end 51 using self 52 53 def get_path 54 opt.extra_path + ENV['PATH'].split(':') 55 end 56 57 def which cmd 58 get_path.lazy.map {|d| File.join(d, cmd) }.find {|f| File.executable? f } 59 end 60 61 class Store 62 include Enumerable 63 def initialize 64 @map = {} 65 end 66 67 def set name, val 68 name = name.to_sym 69 fail "Overwriting variable #{name}" if @map[name].is_a? Array 70 @map[name] = check_val val 71 end 72 73 def set! name, val; @map[name.to_sym] = check_val val; end 74 def unset name; @map.delete name.to_sym; end 75 def get name; @map[name.to_sym]; end 76 77 def method_missing name, *args 78 if name.end_with? '=' 79 set name.to_s.chomp('='), *args 80 else 81 @map.[] name.to_sym, *args 82 end 83 end 84 85 def each &blk; @map.each &blk; end 86 87 private 88 def check_val val 89 unless val.is_a?(Array) || val.is_a?(Integer) || val.is_a?(String) || 90 val == true || val == false 91 fail "Unsupported value type #{val.class}" 92 end 93 val 94 end 95 end 96 @env = Store.new; attr_reader :env 97 @opt = Store.new; attr_reader :opt 98 99 @last_env = {} 100 def calc_env args 101 args = args[0] if args.size == 1 && args[0].is_a?(Array) 102 now_env = env.map {|k,v| [k.to_s, v.is_a?(Array) ? v.shelljoin : v.to_s] }.to_h 103 now_env['PATH'] = get_path.join ':' 104 @last_env.each {|k,v| ecmd "unset #{k.shellescape}" unless now_env.key? k } 105 now_env.each do |k,v| 106 ecmd "export #{k.shellescape}=#{v.shellescape}" unless @last_env[k] == v 107 end 108 @last_env = now_env 109 args 110 end 111 def print_cd cd 112 ecmd "cd #{cd.shellescape}" unless @last_cd == cd 113 @last_cd = cd 114 end 115 private :print_cd 116 def maybe_run dir, *args 117 args = calc_env args 118 print_cd dir 119 ecmd args.shelljoin 120 system @last_env, *args, chdir: dir 121 end 122 123 def run dir, *args; maybe_run(dir, *args) or fail "Command failure: #{$?}"; end 124 125 def run_capture dir, *args 126 args = calc_env args 127 print_cd dir 128 ecmd args.shelljoin 129 out, st = Open3.capture2 @last_env, *args, chdir: dir 130 fail "Command failure: #{st}" unless st.success? 131 out 132 end 133 134 # random filesystem helpers that look like commands 135 def rm dir, *files 136 files = files[0] if files.size == 1 && files[0].is_a?(Array) 137 return if files.empty? 138 print_cd dir 139 ecmd "rm #{files.shelljoin}" 140 Dir.chdir(dir) { File.unlink *files } 141 end 142 143 def rm_if dir, *files 144 files = files[0] if files.size == 1 && files[0].is_a?(Array) 145 files.select! {|f| File.exist? File.join dir, f } 146 rm dir, *files 147 end 148 149 def rel_from path, from 150 res = Pathname.new(path).relative_path_from(from).to_s 151 # make sure it has at least a slash, in case it is used in shell, except . 152 # stays ., because ./. looks weird 153 res == ?. || res.include?('/') ? res : "./#{res}" 154 end 155 156 @anon_step_id = 0 157 @steps = {} 158 Step = Struct.new :blk, :dep, :always_dep, :cond 159 def get_step name 160 @steps[name] ||= Step.new nil, [], [], nil 161 end 162 private :get_step 163 164 def step name = nil, before: [], after: [], always_after: [], cond: nil, &blk 165 name = :"__anon_step_#{@anon_step_id += 1}" unless name 166 before = [before] unless before.is_a? Array 167 after = [after] unless after.is_a? Array 168 always_after = [always_after] unless always_after.is_a? Array 169 blk ||= -> {} 170 step = get_step name 171 if step.blk || step.cond 172 fail "Duplicate step #{name}. Previous: #{step.blk.source_location.join ':'}" 173 end 174 175 before.each {|s| get_step(s).dep << name } 176 after.each {|s| get_step s } 177 always_after.each {|s| get_step s } 178 step.blk = blk 179 step.dep.concat after 180 step.always_dep.concat always_after 181 step.cond = cond 182 true 183 end 184 185 def run_steps *steps 186 steps = steps[0] if steps.size == 1 && steps[0].is_a?(Array) 187 fail "No steps specified" if steps.empty? 188 189 state = {} 190 queue = steps.reverse 191 while !queue.empty? 192 step_id = queue.pop 193 step = @steps[step_id] 194 fail "Invalid step #{step_id}" unless step 195 196 case state[step_id] 197 when nil 198 queue << step_id 199 queue.concat step.always_dep.reverse 200 state[step_id] = :always 201 202 when :always 203 if step.cond && !step.cond[] 204 state[step_id] = :done 205 else 206 queue << step_id 207 queue.concat step.dep.reverse 208 state[step_id] = :deps 209 end 210 211 when :deps 212 if step.cond && !step.cond[] 213 ewarn "Step #{step_id} was scheduled, but condition changed, skipping" 214 else 215 step.blk[] 216 state[step_id] = :done 217 end 218 219 when :done 220 end 221 end 222 end 223 224 def check_steps 225 fail 'Step error' unless @steps.select {|k,s| s.blk.nil? }.each do |k,s| 226 eerror "Missing step dependency: #{k.inspect}" 227 end.empty? 228 end 229 230 HOUR = 60*60 231 MIN = 60 232 def format_time f 233 if f >= HOUR 234 sprintf "%d:%02d:%06.3f", f/HOUR, f/MIN%MIN, f%MIN 235 else 236 sprintf "%02d:%06.3f", f/MIN, f%MIN 237 end 238 end 239 240 # add test decorators around step 241 def test_step name, **args, &blk 242 step name, always_after: :final_opts, before: :test, **args do 243 einfo "Running test #{name}..." 244 start = Time.now 245 begin 246 blk[] 247 einfo "Test #{name} success (#{format_time Time.now - start}s)" 248 rescue 249 ewarn "Test #{name} failed (#{format_time Time.now - start}s): #{$!}" 250 puts $!.backtrace 251 mark_failed 252 end 253 end 254 end 255 256 @status = true; attr_reader :status 257 def mark_failed; @status = false; end 258 259 # Different function, to make `return` inside a `step` throw a 260 # `LocalJumpError` instead of silently terminating the script... See 261 # https://ruby-doc.org/core-3.0.2/Proc.html#class-Proc-label-Orphaned+Proc 262 # https://archive.vn/Q7LIl 263 def load_file name 264 instance_eval File.read(name), name 265 end 266 267 def main 268 [opt.tools_dir, *opt.extra_tools_dirs].each do |d| 269 Dir.glob 'ci_conf.*.rb', base: d do |fn| 270 load_file File.join(d, fn) 271 end 272 end 273 check_steps 274 275 steps = [] 276 ARGV.each do |a| 277 if a =~ /^([^=]+)=(.*)/ 278 opt.set $1, $2 279 else 280 steps << a.to_sym 281 end 282 end 283 run_steps *steps 284 285 status ? einfo('Success') : ewarn('Test failure') 286 exit status ? 0 : 42 287 rescue 288 eerror $!.to_s 289 puts $!.backtrace 290 exit 1 291 end 292 end 293 294 CI.opt.tools_dir = __dir__ 295 CI.opt.extra_tools_dirs = [] 296 CI.opt.libshit_dir = File.dirname __dir__ 297 CI.opt.extra_path = [] 298 CI.opt.run_dir = Dir.pwd