You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
libshit/tools/ci_lib.rb

299 lines
8.1 KiB
Ruby

# frozen_string_literal: true
require 'open3'
require 'pathname'
require 'set'
module CI
extend self
def einfo str; $stderr.puts " \e[32;1m*\e[0m #{str}"; end
def ewarn str; $stderr.puts " \e[33;1m*\e[0m #{str}"; end
def eerror str; $stderr.puts " \e[31;1m*\e[0m #{str}"; end
def ecmd str; $stderr.puts " \e[35;1m*\e[0m #{str}"; end
# Replacement for Shellwords.shellescape. Unfortunately it outputs pretty
# unreadable shit as it escapes space with \ instead of putting it in quotes,
# and if there are like, newlines in the string, it puts just the newline into
# quotes. Our primary goal here is to have a *readable* output as the output
# normally won't be run through the shell, try to create something that's
# readable and also valid bash input.
refine String do
def shellescape
if self =~ %r{^[0-9a-zA-Z%+,./:=@^_'-]+$}
gsub "'", "\\\\'"
else
"'" + gsub("'", "'\\\\''") + "'"
end
end
# because adding this to ruby is fucking impossible, as there's an alternate
# solution, which is:
# base = expr()
# Dir.glob("*/glob...", base: base).map {|f| File.join base, f }
# instead of
# Dir.glob "#{Dir.glob_escape expr()}/*/glob..."
# or our version:
# Dir.glob "#{expr().globescape}/*/glob..."
# yeah, I'm totally gonna write that boilerplate every fucking time. And it
# also only works at the beginning of the path, not anywhere else. Why must
# every language became java?!
def globescape; gsub /\[|\]|\*|\?|\{|\}/, '\\\\' + '\0'; end
end
# Fuckin shellwords (required by nokogiri) puts shelljoin onto Array instead
# of Enumerable, so override it here too.
refine Array do
def shelljoin; map(&:shellescape).join ' '; end
end
refine Enumerable do
def shelljoin; map(&:shellescape).join ' '; end
end
using self
def get_path
opt.extra_path + ENV['PATH'].split(':')
end
def which cmd
get_path.lazy.map {|d| File.join(d, cmd) }.find {|f| File.executable? f }
end
class Store
include Enumerable
def initialize
@map = {}
end
def set name, val
name = name.to_sym
fail "Overwriting variable #{name}" if @map[name].is_a? Array
@map[name] = check_val val
end
def set! name, val; @map[name.to_sym] = check_val val; end
def unset name; @map.delete name.to_sym; end
def get name; @map[name.to_sym]; end
def method_missing name, *args
if name.end_with? '='
set name.to_s.chomp('='), *args
else
@map.[] name.to_sym, *args
end
end
def each &blk; @map.each &blk; end
private
def check_val val
unless val.is_a?(Array) || val.is_a?(Integer) || val.is_a?(String) ||
val == true || val == false
fail "Unsupported value type #{val.class}"
end
val
end
end
@env = Store.new; attr_reader :env
@opt = Store.new; attr_reader :opt
@last_env = {}
def calc_env args
args = args[0] if args.size == 1 && args[0].is_a?(Array)
now_env = env.map {|k,v| [k.to_s, v.is_a?(Array) ? v.shelljoin : v.to_s] }.to_h
now_env['PATH'] = get_path.join ':'
@last_env.each {|k,v| ecmd "unset #{k.shellescape}" unless now_env.key? k }
now_env.each do |k,v|
ecmd "export #{k.shellescape}=#{v.shellescape}" unless @last_env[k] == v
end
@last_env = now_env
args
end
def print_cd cd
ecmd "cd #{cd.shellescape}" unless @last_cd == cd
@last_cd = cd
end
private :print_cd
def maybe_run dir, *args
args = calc_env args
print_cd dir
ecmd args.shelljoin
system @last_env, *args, chdir: dir
end
def run dir, *args; maybe_run(dir, *args) or fail "Command failure: #{$?}"; end
def run_capture dir, *args
args = calc_env args
print_cd dir
ecmd args.shelljoin
out, st = Open3.capture2 @last_env, *args, chdir: dir
fail "Command failure: #{st}" unless st.success?
out
end
# random filesystem helpers that look like commands
def rm dir, *files
files = files[0] if files.size == 1 && files[0].is_a?(Array)
return if files.empty?
print_cd dir
ecmd "rm #{files.shelljoin}"
Dir.chdir(dir) { File.unlink *files }
end
def rm_if dir, *files
files = files[0] if files.size == 1 && files[0].is_a?(Array)
files.select! {|f| File.exist? File.join dir, f }
rm dir, *files
end
def rel_from path, from
res = Pathname.new(path).relative_path_from(from).to_s
# make sure it has at least a slash, in case it is used in shell, except .
# stays ., because ./. looks weird
res == ?. || res.include?('/') ? res : "./#{res}"
end
@anon_step_id = 0
@steps = {}
Step = Struct.new :blk, :dep, :always_dep, :cond
def get_step name
@steps[name] ||= Step.new nil, [], [], nil
end
private :get_step
def step name = nil, before: [], after: [], always_after: [], cond: nil, &blk
name = :"__anon_step_#{@anon_step_id += 1}" unless name
before = [before] unless before.is_a? Array
after = [after] unless after.is_a? Array
always_after = [always_after] unless always_after.is_a? Array
blk ||= -> {}
step = get_step name
if step.blk || step.cond
fail "Duplicate step #{name}. Previous: #{step.blk.source_location.join ':'}"
end
before.each {|s| get_step(s).dep << name }
after.each {|s| get_step s }
always_after.each {|s| get_step s }
step.blk = blk
step.dep.concat after
step.always_dep.concat always_after
step.cond = cond
true
end
def run_steps *steps
steps = steps[0] if steps.size == 1 && steps[0].is_a?(Array)
fail "No steps specified" if steps.empty?
state = {}
queue = steps.reverse
while !queue.empty?
step_id = queue.pop
step = @steps[step_id]
fail "Invalid step #{step_id}" unless step
case state[step_id]
when nil
queue << step_id
queue.concat step.always_dep.reverse
state[step_id] = :always
when :always
if step.cond && !step.cond[]
state[step_id] = :done
else
queue << step_id
queue.concat step.dep.reverse
state[step_id] = :deps
end
when :deps
if step.cond && !step.cond[]
ewarn "Step #{step_id} was scheduled, but condition changed, skipping"
else
step.blk[]
state[step_id] = :done
end
when :done
end
end
end
def check_steps
fail 'Step error' unless @steps.select {|k,s| s.blk.nil? }.each do |k,s|
eerror "Missing step dependency: #{k.inspect}"
end.empty?
end
HOUR = 60*60
MIN = 60
def format_time f
if f >= HOUR
sprintf "%d:%02d:%06.3f", f/HOUR, f/MIN%MIN, f%MIN
else
sprintf "%02d:%06.3f", f/MIN, f%MIN
end
end
# add test decorators around step
def test_step name, **args, &blk
step name, always_after: :final_opts, before: :test, **args do
einfo "Running test #{name}..."
start = Time.now
begin
blk[]
einfo "Test #{name} success (#{format_time Time.now - start}s)"
rescue
ewarn "Test #{name} failed (#{format_time Time.now - start}s): #{$!}"
puts $!.backtrace
mark_failed
end
end
end
@status = true; attr_reader :status
def mark_failed; @status = false; end
# Different function, to make `return` inside a `step` throw a
# `LocalJumpError` instead of silently terminating the script... See
# https://ruby-doc.org/core-3.0.2/Proc.html#class-Proc-label-Orphaned+Proc
# https://archive.vn/Q7LIl
def load_file name
instance_eval File.read(name), name
end
def main
[opt.tools_dir, *opt.extra_tools_dirs].each do |d|
Dir.glob 'ci_conf.*.rb', base: d do |fn|
load_file File.join(d, fn)
end
end
check_steps
steps = []
ARGV.each do |a|
if a =~ /^([^=]+)=(.*)/
opt.set $1, $2
else
steps << a.to_sym
end
end
run_steps *steps
status ? einfo('Success') : ewarn('Test failure')
exit status ? 0 : 42
rescue
eerror $!.to_s
puts $!.backtrace
exit 1
end
end
CI.opt.tools_dir = __dir__
CI.opt.extra_tools_dirs = []
CI.opt.libshit_dir = File.dirname __dir__
CI.opt.extra_path = []
CI.opt.run_dir = Dir.pwd