fun_ci 2.0.0-aarch64-linux
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +294 -0
- data/LICENSE.txt +21 -0
- data/README.md +230 -0
- data/exe/fun-ci +7 -0
- data/lib/fun_ci/agent/age.rb +15 -0
- data/lib/fun_ci/agent/command_support.rb +27 -0
- data/lib/fun_ci/agent/commands.rb +27 -0
- data/lib/fun_ci/agent/context.rb +10 -0
- data/lib/fun_ci/agent/digest.rb +49 -0
- data/lib/fun_ci/agent/events.rb +47 -0
- data/lib/fun_ci/agent/events_command.rb +61 -0
- data/lib/fun_ci/agent/evidence_text.rb +46 -0
- data/lib/fun_ci/agent/exit_code.rb +11 -0
- data/lib/fun_ci/agent/git.rb +31 -0
- data/lib/fun_ci/agent/live_pipeline.rb +35 -0
- data/lib/fun_ci/agent/options.rb +75 -0
- data/lib/fun_ci/agent/output.rb +81 -0
- data/lib/fun_ci/agent/report_stage.rb +73 -0
- data/lib/fun_ci/agent/reports.rb +47 -0
- data/lib/fun_ci/agent/run_report.rb +30 -0
- data/lib/fun_ci/agent/runs_command.rb +34 -0
- data/lib/fun_ci/agent/runs_text.rb +27 -0
- data/lib/fun_ci/agent/snapshots.rb +39 -0
- data/lib/fun_ci/agent/stage_summary.rb +30 -0
- data/lib/fun_ci/agent/status_command.rb +36 -0
- data/lib/fun_ci/agent/status_json.rb +27 -0
- data/lib/fun_ci/agent/status_text.rb +37 -0
- data/lib/fun_ci/agent/system_clock.rb +11 -0
- data/lib/fun_ci/agent/verdict.rb +32 -0
- data/lib/fun_ci/agent/wait_command.rb +71 -0
- data/lib/fun_ci/agent/waiting.rb +32 -0
- data/lib/fun_ci/agent/why_command.rb +50 -0
- data/lib/fun_ci/agent/why_json.rb +35 -0
- data/lib/fun_ci/agent/why_text.rb +43 -0
- data/lib/fun_ci/cli.rb +128 -0
- data/lib/fun_ci/cli_help.rb +65 -0
- data/lib/fun_ci/console/board_data.rb +66 -0
- data/lib/fun_ci/console/board_heartbeat.rb +40 -0
- data/lib/fun_ci/console/console_events.rb +20 -0
- data/lib/fun_ci/console/console_log.rb +26 -0
- data/lib/fun_ci/console/console_loop.rb +41 -0
- data/lib/fun_ci/console/console_session.rb +82 -0
- data/lib/fun_ci/console/console_state.rb +42 -0
- data/lib/fun_ci/console/key_handler.rb +73 -0
- data/lib/fun_ci/console/launcher.rb +43 -0
- data/lib/fun_ci/console/milestone_events.rb +38 -0
- data/lib/fun_ci/console/milestones.rb +25 -0
- data/lib/fun_ci/console/renderer_lookup.rb +51 -0
- data/lib/fun_ci/console/renderer_process.rb +60 -0
- data/lib/fun_ci/console/run_message.rb +33 -0
- data/lib/fun_ci/console/stage_change_detector.rb +32 -0
- data/lib/fun_ci/console/stage_events.rb +31 -0
- data/lib/fun_ci/console/stop_report.rb +39 -0
- data/lib/fun_ci/console/streak_counter.rb +19 -0
- data/lib/fun_ci/console/view.rb +47 -0
- data/lib/fun_ci/evidence/about.rb +12 -0
- data/lib/fun_ci/evidence/about_stage.rb +29 -0
- data/lib/fun_ci/evidence/caps.rb +77 -0
- data/lib/fun_ci/evidence/catalog.rb +83 -0
- data/lib/fun_ci/evidence/collector.rb +95 -0
- data/lib/fun_ci/evidence/command_output.rb +45 -0
- data/lib/fun_ci/evidence/command_reading.rb +38 -0
- data/lib/fun_ci/evidence/command_runner.rb +75 -0
- data/lib/fun_ci/evidence/context.rb +21 -0
- data/lib/fun_ci/evidence/context_document.rb +29 -0
- data/lib/fun_ci/evidence/deadline.rb +15 -0
- data/lib/fun_ci/evidence/detection.rb +82 -0
- data/lib/fun_ci/evidence/document.rb +59 -0
- data/lib/fun_ci/evidence/extract_command.rb +83 -0
- data/lib/fun_ci/evidence/extract_options.rb +45 -0
- data/lib/fun_ci/evidence/extraction.rb +54 -0
- data/lib/fun_ci/evidence/extractors/command.rb +52 -0
- data/lib/fun_ci/evidence/extractors/grep.rb +49 -0
- data/lib/fun_ci/evidence/extractors/json_log.rb +45 -0
- data/lib/fun_ci/evidence/extractors/junit_files.rb +32 -0
- data/lib/fun_ci/evidence/extractors/last_lines.rb +25 -0
- data/lib/fun_ci/evidence/extractors/log_file.rb +62 -0
- data/lib/fun_ci/evidence/extractors/process_tree.rb +51 -0
- data/lib/fun_ci/evidence/extractors/section.rb +49 -0
- data/lib/fun_ci/evidence/findings.rb +14 -0
- data/lib/fun_ci/evidence/line_ranges.rb +21 -0
- data/lib/fun_ci/evidence/line_scan.rb +22 -0
- data/lib/fun_ci/evidence/log_record.rb +58 -0
- data/lib/fun_ci/evidence/masking.rb +55 -0
- data/lib/fun_ci/evidence/option_check.rb +53 -0
- data/lib/fun_ci/evidence/outcome.rb +15 -0
- data/lib/fun_ci/evidence/patterns.rb +22 -0
- data/lib/fun_ci/evidence/presets/README.md +7 -0
- data/lib/fun_ci/evidence/presets/bun-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/cargo-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/dart-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/deno-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/dotnet-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/ecs.yml +3 -0
- data/lib/fun_ci/evidence/presets/eslint.yml +6 -0
- data/lib/fun_ci/evidence/presets/exunit.yml +6 -0
- data/lib/fun_ci/evidence/presets/gcc.yml +6 -0
- data/lib/fun_ci/evidence/presets/go-build.yml +5 -0
- data/lib/fun_ci/evidence/presets/go-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/gradle.yml +6 -0
- data/lib/fun_ci/evidence/presets/gtest.yml +6 -0
- data/lib/fun_ci/evidence/presets/jest.yml +6 -0
- data/lib/fun_ci/evidence/presets/logstash.yml +3 -0
- data/lib/fun_ci/evidence/presets/maven.yml +6 -0
- data/lib/fun_ci/evidence/presets/minitest.yml +6 -0
- data/lib/fun_ci/evidence/presets/mocha.yml +6 -0
- data/lib/fun_ci/evidence/presets/mypy.yml +6 -0
- data/lib/fun_ci/evidence/presets/node-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/phpunit.yml +6 -0
- data/lib/fun_ci/evidence/presets/pino.yml +5 -0
- data/lib/fun_ci/evidence/presets/prove.yml +6 -0
- data/lib/fun_ci/evidence/presets/pytest.yml +6 -0
- data/lib/fun_ci/evidence/presets/rspec.yml +6 -0
- data/lib/fun_ci/evidence/presets/rubocop.yml +6 -0
- data/lib/fun_ci/evidence/presets/ruff.yml +6 -0
- data/lib/fun_ci/evidence/presets/rustc.yml +6 -0
- data/lib/fun_ci/evidence/presets/shellcheck.yml +5 -0
- data/lib/fun_ci/evidence/presets/structlog.yml +4 -0
- data/lib/fun_ci/evidence/presets/swift-test.yml +5 -0
- data/lib/fun_ci/evidence/presets/tsc.yml +6 -0
- data/lib/fun_ci/evidence/presets/unittest.yml +6 -0
- data/lib/fun_ci/evidence/presets/vitest.yml +6 -0
- data/lib/fun_ci/evidence/presets.rb +30 -0
- data/lib/fun_ci/evidence/problem.rb +9 -0
- data/lib/fun_ci/evidence/process_table.rb +40 -0
- data/lib/fun_ci/evidence/saved_run.rb +22 -0
- data/lib/fun_ci/evidence/section_walk.rb +49 -0
- data/lib/fun_ci/evidence/settings.rb +64 -0
- data/lib/fun_ci/evidence/settings_check.rb +78 -0
- data/lib/fun_ci/evidence/source.rb +34 -0
- data/lib/fun_ci/evidence/sources.rb +21 -0
- data/lib/fun_ci/evidence/stage_context.rb +30 -0
- data/lib/fun_ci/evidence/stamp.rb +10 -0
- data/lib/fun_ci/evidence/start.rb +36 -0
- data/lib/fun_ci/evidence/worktree.rb +62 -0
- data/lib/fun_ci/persistence/active_runs.rb +69 -0
- data/lib/fun_ci/persistence/database.rb +81 -0
- data/lib/fun_ci/persistence/null_recorder.rb +25 -0
- data/lib/fun_ci/persistence/output_tail.rb +24 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +120 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +68 -0
- data/lib/fun_ci/persistence/project_runs.rb +52 -0
- data/lib/fun_ci/persistence/raw_outputs.rb +46 -0
- data/lib/fun_ci/persistence/retention.rb +25 -0
- data/lib/fun_ci/persistence/run_status.rb +31 -0
- data/lib/fun_ci/persistence/stage_job.rb +91 -0
- data/lib/fun_ci/persistence/state_dir.rb +26 -0
- data/lib/fun_ci/persistence/write_trouble.rb +46 -0
- data/lib/fun_ci/pipeline/background_fork.rb +42 -0
- data/lib/fun_ci/pipeline/command_executor.rb +58 -0
- data/lib/fun_ci/pipeline/gate.rb +35 -0
- data/lib/fun_ci/pipeline/git_environment.rb +16 -0
- data/lib/fun_ci/pipeline/head_part.rb +40 -0
- data/lib/fun_ci/pipeline/in_place.rb +17 -0
- data/lib/fun_ci/pipeline/output_window.rb +73 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +40 -0
- data/lib/fun_ci/pipeline/process_runner.rb +101 -0
- data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
- data/lib/fun_ci/pipeline/run_canceller.rb +75 -0
- data/lib/fun_ci/pipeline/slot.rb +30 -0
- data/lib/fun_ci/pipeline/slot_lock.rb +13 -0
- data/lib/fun_ci/pipeline/slot_run.rb +86 -0
- data/lib/fun_ci/pipeline/stage_dir.rb +50 -0
- data/lib/fun_ci/pipeline/stage_end.rb +70 -0
- data/lib/fun_ci/pipeline/stage_execution.rb +67 -0
- data/lib/fun_ci/pipeline/stage_runner.rb +63 -0
- data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +36 -0
- data/lib/fun_ci/pipeline/tail_segments.rb +60 -0
- data/lib/fun_ci/pipeline/test_report.rb +48 -0
- data/lib/fun_ci/pipeline/trigger.rb +93 -0
- data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
- data/lib/fun_ci/pipeline/trigger_params.rb +48 -0
- data/lib/fun_ci/pipeline/workspaces.rb +22 -0
- data/lib/fun_ci/pipeline/worktree_pool.rb +66 -0
- data/lib/fun_ci/pipeline/worktree_prune.rb +49 -0
- data/lib/fun_ci/pipeline/worktrees.rb +45 -0
- data/lib/fun_ci/setup/agent_instructions.rb +31 -0
- data/lib/fun_ci/setup/commands.rb +49 -0
- data/lib/fun_ci/setup/git_hooks.rb +20 -0
- data/lib/fun_ci/setup/hook_script.rb +47 -0
- data/lib/fun_ci/setup/hook_writer.rb +57 -0
- data/lib/fun_ci/setup/installer.rb +71 -0
- data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
- data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
- data/lib/fun_ci/setup/project_config.rb +68 -0
- data/lib/fun_ci/setup/project_detector.rb +54 -0
- data/lib/fun_ci/setup/settings.rb +36 -0
- data/lib/fun_ci/setup/setup_checker.rb +36 -0
- data/lib/fun_ci/setup/stage_templates/javascript.rb +18 -0
- data/lib/fun_ci/setup/stage_templates/managed.rb +24 -0
- data/lib/fun_ci/setup/stage_templates/native.rb +25 -0
- data/lib/fun_ci/setup/stage_templates/scripting.rb +27 -0
- data/lib/fun_ci/setup/stage_templates/shell.rb +16 -0
- data/lib/fun_ci/setup/stage_templates.rb +25 -0
- data/lib/fun_ci/setup/template_writer.rb +28 -0
- data/lib/fun_ci/version.rb +6 -0
- data/lib/fun_ci.rb +13 -0
- data/libexec/fun-ci-renderer +0 -0
- metadata +286 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Evidence
|
|
7
|
+
# The processes running on the machine, as `ps` lists them: BSD and
|
|
8
|
+
# procps take `-A -o pid=,ppid=,pgid=,etime=,args=`; busybox (the musl
|
|
9
|
+
# target's) takes neither -A nor `=`, and lists every process with a
|
|
10
|
+
# header under `-o pid,ppid,pgid,etime,args`.
|
|
11
|
+
module ProcessTable
|
|
12
|
+
Row = Data.define(:pid, :ppid, :pgid, :seconds, :command)
|
|
13
|
+
COMMANDS = [%w[ps -A -o pid=,ppid=,pgid=,etime=,args=], %w[ps -o pid,ppid,pgid,etime,args]].freeze
|
|
14
|
+
LINE = /\A\s*(\d+)\s+(\d+)\s+(\d+)\s+(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)\s+(.*)\z/
|
|
15
|
+
|
|
16
|
+
# What `ps` lists now, from the first form this machine's ps takes.
|
|
17
|
+
def self.now
|
|
18
|
+
COMMANDS.each do |command|
|
|
19
|
+
listing, status = Open3.capture2e(*command)
|
|
20
|
+
return parse(listing) if status.success?
|
|
21
|
+
end
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.parse(listing) = listing.lines(chomp: true).filter_map { |line| row(line) }
|
|
26
|
+
|
|
27
|
+
def self.row(line)
|
|
28
|
+
match = LINE.match(line)
|
|
29
|
+
return nil unless match
|
|
30
|
+
|
|
31
|
+
Row.new(pid: match[1].to_i, ppid: match[2].to_i, pgid: match[3].to_i, command: match[8],
|
|
32
|
+
seconds: seconds(match.captures[3, 4].map(&:to_i)))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Elapsed [days, hours, minutes, seconds] in seconds.
|
|
36
|
+
def self.seconds(parts) = parts.zip([86_400, 3600, 60, 1]).sum { |count, unit| count * unit }
|
|
37
|
+
private_class_method :row, :seconds
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Evidence
|
|
7
|
+
# A failed run's stage directory, stood in for by what `fun-ci extract`
|
|
8
|
+
# is given: a scratch directory for what a project's command reads and
|
|
9
|
+
# writes.
|
|
10
|
+
class SavedRun
|
|
11
|
+
def initialize(scratch)
|
|
12
|
+
@scratch = scratch
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :scratch
|
|
16
|
+
|
|
17
|
+
def output_file(text)
|
|
18
|
+
File.join(@scratch, "output.log").tap { |file| File.binwrite(file, text) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Evidence
|
|
5
|
+
# Finds sections line by line: one opens on a start line, and closes
|
|
6
|
+
# before an end line or once it is `limit` lines long (cut); the line
|
|
7
|
+
# that closes one may open the next, so back-to-back items each get one.
|
|
8
|
+
class SectionWalk
|
|
9
|
+
# first, last: line indexes; cut: whether the limit closed it.
|
|
10
|
+
Found = Data.define(:first, :last, :cut)
|
|
11
|
+
|
|
12
|
+
def initialize(starts, ends, limit)
|
|
13
|
+
@starts = starts
|
|
14
|
+
@ends = ends
|
|
15
|
+
@limit = limit
|
|
16
|
+
@found = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def step(line, index)
|
|
20
|
+
carry_on(line, index) if open?
|
|
21
|
+
@found << Found.new(first: index, last: nil, cut: false) if !open? && matches?(@starts, line)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The sections found, the one still open ending at the last line, each
|
|
25
|
+
# without the blank lines it ends with.
|
|
26
|
+
def finish(lines)
|
|
27
|
+
close(lines.size - 1, cut: false) if open?
|
|
28
|
+
@found.map { |found| found.with(last: last_written(lines, found)) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def carry_on(line, index)
|
|
34
|
+
return close(index - 1, cut: false) if matches?(@ends, line)
|
|
35
|
+
|
|
36
|
+
close(index - 1, cut: true) if index - @found.last.first >= @limit
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def matches?(patterns, line) = patterns.any? { |pattern| pattern.match?(line) }
|
|
40
|
+
|
|
41
|
+
def last_written(lines, found)
|
|
42
|
+
found.last.downto(found.first).find { |index| !lines[index].strip.empty? } || found.first
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def open? = !@found.empty? && @found.last.last.nil?
|
|
46
|
+
def close(last, cut:) = @found[-1] = @found.last.with(last: last, cut: cut)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require_relative "catalog"
|
|
5
|
+
require_relative "patterns"
|
|
6
|
+
require_relative "settings_check"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Evidence
|
|
10
|
+
# The `evidence` key of .fun-ci/config (architecture.md, "Evidence of a failed stage"): the
|
|
11
|
+
# settings, each with its default, and each stage's entries, those for
|
|
12
|
+
# `all` first. A setting that is wrong takes its default; #errors says
|
|
13
|
+
# what is wrong, for `fun-ci check`.
|
|
14
|
+
class Settings
|
|
15
|
+
DEFAULTS = { "budget" => 2, "detect" => true, "skip" => [], "mask" => [], "masking" => true,
|
|
16
|
+
"stages" => {} }.freeze
|
|
17
|
+
|
|
18
|
+
# The settings in the config file at `path`, or the defaults without one.
|
|
19
|
+
def self.load(path)
|
|
20
|
+
new(File.exist?(path) ? (YAML.safe_load_file(path) || {})["evidence"] : nil)
|
|
21
|
+
rescue Psych::Exception, NoMethodError, TypeError
|
|
22
|
+
new(nil)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(raw)
|
|
26
|
+
@raw = raw
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def budget = SettingsCheck.seconds(value("budget")) || DEFAULTS.fetch("budget").to_f
|
|
30
|
+
def detect? = value("detect") != false
|
|
31
|
+
def masking? = value("masking") != false
|
|
32
|
+
def skip = Array(value("skip"))
|
|
33
|
+
|
|
34
|
+
def mask_patterns
|
|
35
|
+
Array(value("mask")).select { |source| source.is_a?(String) && !Patterns.mistake(source) }
|
|
36
|
+
.then { |sources| Patterns.compile(sources) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The raw entries for `stage`: those for every stage, then its own.
|
|
40
|
+
def entries(stage) = stage_list("all") + stage_list(stage)
|
|
41
|
+
|
|
42
|
+
# The globs whose files are stamped when the stage starts, so what it
|
|
43
|
+
# wrote to them can be told from what was there before.
|
|
44
|
+
def watched(stage)
|
|
45
|
+
entries(stage).grep(Hash).flat_map { |raw| Array(raw["path"]) + Array(raw["paths"]) + Array(raw["watch"]) }
|
|
46
|
+
.grep(String).uniq
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# root: the project, whose `run:` scripts are checked when it is given.
|
|
50
|
+
def errors(root: nil) = SettingsCheck.new(@raw, root).errors
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def values = @raw.is_a?(Hash) ? DEFAULTS.merge(@raw) : DEFAULTS
|
|
55
|
+
def value(key) = values[key]
|
|
56
|
+
|
|
57
|
+
def stage_list(stage)
|
|
58
|
+
stages = value("stages")
|
|
59
|
+
list = stages[stage] if stages.is_a?(Hash)
|
|
60
|
+
list.is_a?(Array) ? list : []
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "catalog"
|
|
4
|
+
require_relative "patterns"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Evidence
|
|
8
|
+
# What is wrong with the `evidence` key of .fun-ci/config, each as a line
|
|
9
|
+
# `fun-ci check` prints.
|
|
10
|
+
class SettingsCheck
|
|
11
|
+
KNOWN = %w[budget detect skip mask masking stages].freeze
|
|
12
|
+
STAGES = %w[all lint build fast slow].freeze
|
|
13
|
+
|
|
14
|
+
# "2", 2, 2.5 or "2s" as seconds, or nil.
|
|
15
|
+
def self.seconds(value)
|
|
16
|
+
return value.to_f if value.is_a?(Numeric) && value.positive?
|
|
17
|
+
|
|
18
|
+
match = /\A(\d+(?:\.\d+)?)s?\z/.match(value.to_s)
|
|
19
|
+
match && match[1].to_f.positive? ? match[1].to_f : nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def initialize(raw, root)
|
|
23
|
+
@raw = raw
|
|
24
|
+
@root = root
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def errors
|
|
28
|
+
return [] if @raw.nil?
|
|
29
|
+
return ["evidence must be a mapping, not #{@raw.inspect}"] unless @raw.is_a?(Hash)
|
|
30
|
+
|
|
31
|
+
unknown + budget + stages
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def unknown = (@raw.keys - KNOWN).map { |key| "evidence has no setting '#{key}'" }
|
|
37
|
+
|
|
38
|
+
def budget
|
|
39
|
+
return [] if !@raw.key?("budget") || self.class.seconds(@raw["budget"])
|
|
40
|
+
|
|
41
|
+
["evidence.budget must be a number of seconds such as 2 or 2s, not #{@raw["budget"].inspect}"]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def stages
|
|
45
|
+
lists = @raw.fetch("stages", {})
|
|
46
|
+
return ["evidence.stages must be a mapping of stage to entries"] unless lists.is_a?(Hash)
|
|
47
|
+
|
|
48
|
+
lists.flat_map { |stage, entries| stage_errors(stage, entries) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def stage_errors(stage, entries)
|
|
52
|
+
unless STAGES.include?(stage)
|
|
53
|
+
return ["evidence.stages has no stage '#{stage}': use all, lint, build, fast or slow"]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
Array(entries).filter_map { |entry| refusal(entry) }.map { |message| "evidence.stages.#{stage}: #{message}" }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def refusal(entry)
|
|
60
|
+
Catalog.entry(entry)
|
|
61
|
+
script_mistake(entry)
|
|
62
|
+
rescue Catalog::Refused => e
|
|
63
|
+
e.message
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# A `run:` command's first word, when it is a path, must name a script that can be run.
|
|
67
|
+
def script_mistake(entry)
|
|
68
|
+
script = entry["run"].to_s.split.first.to_s
|
|
69
|
+
return nil unless @root && script.include?("/")
|
|
70
|
+
|
|
71
|
+
path = File.join(@root, script)
|
|
72
|
+
return "run:#{entry["run"]}: #{script} doesn't exist" unless File.exist?(path)
|
|
73
|
+
|
|
74
|
+
"run:#{entry["run"]}: #{script} isn't executable" unless File.executable?(path)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Evidence
|
|
5
|
+
# The lines an extractor reads, and the name of where they came from: the
|
|
6
|
+
# output, or a file in the worktree. Colour codes are stripped and bytes
|
|
7
|
+
# that aren't UTF-8 replaced, so patterns match what a person would read.
|
|
8
|
+
# `skipped` is how many lines of the file came before these.
|
|
9
|
+
Source = Data.define(:name, :lines, :skipped)
|
|
10
|
+
|
|
11
|
+
class Source
|
|
12
|
+
COLOUR = /\e\[[0-9;?]*[A-Za-z]/
|
|
13
|
+
|
|
14
|
+
def self.for(context, path)
|
|
15
|
+
return of("output", context.output) unless path
|
|
16
|
+
|
|
17
|
+
of(path, context.worktree.read(path))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.of(name, text, skipped: 0)
|
|
21
|
+
lines = text.dup.force_encoding(Encoding::UTF_8).scrub("?").gsub(COLOUR, "").lines(chomp: true)
|
|
22
|
+
new(name: name, lines: lines, skipped: skipped)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Where lines first..last (counted from 0) are, as `name:first-last`
|
|
26
|
+
# counted from 1 in the whole file.
|
|
27
|
+
def location(first, last)
|
|
28
|
+
from = first + skipped + 1
|
|
29
|
+
to = last + skipped + 1
|
|
30
|
+
from == to ? "#{name}:#{from}" : "#{name}:#{from}-#{to}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Evidence
|
|
5
|
+
# Where a failed stage's evidence can come from while it still holds its
|
|
6
|
+
# slot (architecture.md, "Evidence of a failed stage"): the stage, the worktree
|
|
7
|
+
# it ran in, its stage directory, its environment as a hash,
|
|
8
|
+
# the Stamp of each watched file when it started, by path, its budget,
|
|
9
|
+
# its commit ({ sha:, branch: }), when it started, `processes`, which
|
|
10
|
+
# answers what ps lists, for a stage that runs over budget, and the
|
|
11
|
+
# presets that were candidates when it started (Detection::Found).
|
|
12
|
+
Sources = Data.define(:stage, :worktree, :stage_dir, :environment, :watched, :budget, :commit, :started, :processes,
|
|
13
|
+
:candidates)
|
|
14
|
+
|
|
15
|
+
class Sources
|
|
16
|
+
UNKNOWN = { watched: {}, budget: nil, commit: nil, started: nil, processes: -> { [] }, candidates: [] }.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(**given) = super(**UNKNOWN, **given)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "context"
|
|
4
|
+
require_relative "deadline"
|
|
5
|
+
require_relative "worktree"
|
|
6
|
+
require_relative "about_stage"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Evidence
|
|
10
|
+
# Makes the Context a stage's extractors are given, with a deadline
|
|
11
|
+
# `seconds` from now.
|
|
12
|
+
class StageContext
|
|
13
|
+
def initialize(sources, clock, commands)
|
|
14
|
+
@sources = sources
|
|
15
|
+
@clock = clock
|
|
16
|
+
@commands = commands
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def after(output, outcome, seconds)
|
|
20
|
+
Context.new(stage: @sources.stage, output: output, worktree: Worktree.new(@sources.worktree),
|
|
21
|
+
deadline: Deadline.after(@clock, seconds), watched: @sources.watched,
|
|
22
|
+
about: AboutStage.of(@sources, outcome, output), commands: @commands,
|
|
23
|
+
processes: @sources.processes)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Before the kill of a stage over budget, while its group still runs.
|
|
27
|
+
def before_kill(pgid, seconds) = after("", Outcome.new(state: "over_budget"), seconds).with(pgid: pgid)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Evidence
|
|
5
|
+
# What a watched file looked like: a file counts as changed if any of
|
|
6
|
+
# the three differ, since some file systems keep whole seconds, and
|
|
7
|
+
# `cp -p` or a build cache can put an old time back (architecture.md, "Evidence of a failed stage").
|
|
8
|
+
Stamp = Data.define(:size, :mtime, :inode)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "collector"
|
|
4
|
+
require_relative "settings"
|
|
5
|
+
require_relative "worktree"
|
|
6
|
+
require_relative "detection"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Evidence
|
|
10
|
+
# What happens when a stage starts, so its evidence can be collected if
|
|
11
|
+
# it fails: its settings are read from the worktree, so they are the
|
|
12
|
+
# commit's own, and the files it watches are stamped.
|
|
13
|
+
module Start
|
|
14
|
+
def self.collector(sources, clock, commands)
|
|
15
|
+
settings = Settings.load(File.join(sources.worktree, ".fun-ci", "config"))
|
|
16
|
+
worktree = Worktree.new(sources.worktree)
|
|
17
|
+
Collector.new(sources.with(watched: stamps(worktree, settings.watched(sources.stage)),
|
|
18
|
+
candidates: candidates(worktree, settings, sources.stage)),
|
|
19
|
+
settings: settings, clock: clock, commands: commands)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The presets that may run: detected ones, less those skipped or configured.
|
|
23
|
+
def self.candidates(worktree, settings, stage)
|
|
24
|
+
return [] unless settings.detect?
|
|
25
|
+
|
|
26
|
+
left_out = settings.skip + settings.entries(stage).grep(Hash).filter_map { |raw| raw["preset"] }
|
|
27
|
+
Detection.candidates(worktree, Presets.all.reject { |preset| left_out.include?(preset.name) })
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.stamps(worktree, globs)
|
|
31
|
+
globs.flat_map { |glob| worktree.glob(glob) }.uniq.to_h { |path| [path, worktree.stamp(path)] }
|
|
32
|
+
end
|
|
33
|
+
private_class_method :stamps
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stamp"
|
|
4
|
+
require_relative "../pipeline/output_window"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Evidence
|
|
8
|
+
# The files of the worktree a stage ran in, by path relative to it. A
|
|
9
|
+
# file is read through a window of the output's sizes, so a log that
|
|
10
|
+
# grew without end is read as its first and last parts.
|
|
11
|
+
class Worktree
|
|
12
|
+
CHUNK = 1_048_576
|
|
13
|
+
|
|
14
|
+
def initialize(dir, sizes = Pipeline::OutputWindow::REAL)
|
|
15
|
+
@dir = dir
|
|
16
|
+
@sizes = sizes
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def exist?(path) = File.file?(full(path))
|
|
20
|
+
def glob(pattern) = Dir.glob(pattern, base: @dir).select { |path| exist?(path) }.sort
|
|
21
|
+
|
|
22
|
+
def read(path, from: 0)
|
|
23
|
+
window = Pipeline::OutputWindow.in_memory(@sizes)
|
|
24
|
+
each_chunk(path, from) { |chunk| window << chunk }
|
|
25
|
+
window.text
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def lines_before(path, offset)
|
|
29
|
+
count = 0
|
|
30
|
+
each_chunk(path, 0, offset) { |chunk| count += chunk.count("\n") }
|
|
31
|
+
count
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def stamp(path)
|
|
35
|
+
stat = File.stat(full(path))
|
|
36
|
+
Stamp.new(size: stat.size, mtime: stat.mtime.to_r, inode: stat.ino)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def full(path) = File.join(@dir, path)
|
|
42
|
+
|
|
43
|
+
# Yields the file's bytes from `from`, `length` of them or to its end, a chunk at a time.
|
|
44
|
+
def each_chunk(path, from, length = nil, &)
|
|
45
|
+
File.open(full(path), "rb") do |file|
|
|
46
|
+
file.seek(from)
|
|
47
|
+
read_chunks(file, length, &)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def read_chunks(file, left)
|
|
52
|
+
loop do
|
|
53
|
+
chunk = file.read(left ? [CHUNK, left].min : CHUNK)
|
|
54
|
+
break if chunk.nil? || chunk.empty?
|
|
55
|
+
|
|
56
|
+
yield chunk
|
|
57
|
+
left -= chunk.bytesize if left
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "pipeline_run"
|
|
5
|
+
require_relative "stage_job"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Persistence
|
|
9
|
+
# A run that has not finished: the processes running it (the trigger and
|
|
10
|
+
# the forked slow suite), the process group of each stage still going, and
|
|
11
|
+
# the lock of the worktree slot it holds (nil while it waits for one).
|
|
12
|
+
ActiveRun = Data.define(:id, :commit_hash, :processes, :stage_groups, :slot_lock)
|
|
13
|
+
|
|
14
|
+
# A branch of one project. Every project records its runs in the same
|
|
15
|
+
# database, and two projects can each have a main.
|
|
16
|
+
Branch = Data.define(:project, :name)
|
|
17
|
+
|
|
18
|
+
# Runs waiting for a slot or running, and recording one as cancelled.
|
|
19
|
+
module ActiveRuns
|
|
20
|
+
ACTIVE = "status IN ('scheduled', 'running')"
|
|
21
|
+
|
|
22
|
+
# The runs on the branch a new commit's run may cancel: not the new
|
|
23
|
+
# commit's own, nor one an agent has waited on since `waited_before`
|
|
24
|
+
# (an ISO 8601 UTC time; acceptance-tests.md, AT-9.12).
|
|
25
|
+
def self.cancellable(db, branch, new_commit:, waited_before:)
|
|
26
|
+
where(db, "project_path = ? AND branch = ? AND commit_hash != ? AND (waited_at IS NULL OR waited_at < ?)",
|
|
27
|
+
branch.project, branch.name, new_commit, waited_before)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The run with this id, when it has not finished; none otherwise.
|
|
31
|
+
def self.with_id(db, id) = where(db, "id = ?", id)
|
|
32
|
+
|
|
33
|
+
# [job id, run id, pid] for each slow stage still running, pid being the
|
|
34
|
+
# forked process that runs it.
|
|
35
|
+
def self.slow_suites(db)
|
|
36
|
+
db.execute("SELECT stage_jobs.id, pipeline_runs.id, pipeline_runs.pid FROM stage_jobs " \
|
|
37
|
+
"JOIN pipeline_runs ON pipeline_runs.id = stage_jobs.pipeline_run_id " \
|
|
38
|
+
"WHERE stage_jobs.stage = 'slow' AND stage_jobs.status = 'running' " \
|
|
39
|
+
"AND pipeline_runs.pid IS NOT NULL")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Records the slow stage failed, unless its result was recorded meanwhile.
|
|
43
|
+
def self.slow_suite_died(db, job_id)
|
|
44
|
+
db.execute("UPDATE stage_jobs SET status = 'failed', completed_at = ? WHERE id = ? AND status = 'running'",
|
|
45
|
+
[Time.now.utc.iso8601, job_id])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.cancelled(db, run)
|
|
49
|
+
db.execute("UPDATE stage_jobs SET status = 'cancelled', completed_at = ? " \
|
|
50
|
+
"WHERE pipeline_run_id = ? AND #{ACTIVE}", [Time.now.utc.iso8601, run.id])
|
|
51
|
+
PipelineRun.update_status(db, run.id, "cancelled")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def self.where(db, clause, *values)
|
|
55
|
+
db.execute("SELECT id, commit_hash, trigger_pid, pid, slot_lock FROM pipeline_runs " \
|
|
56
|
+
"WHERE #{clause} AND #{ACTIVE} ORDER BY id", values).map { |row| active_run(db, row) }
|
|
57
|
+
end
|
|
58
|
+
private_class_method :where
|
|
59
|
+
|
|
60
|
+
def self.active_run(db, (id, commit_hash, trigger_pid, pid, slot_lock))
|
|
61
|
+
groups = db.execute("SELECT pid FROM stage_jobs WHERE pipeline_run_id = ? AND #{ACTIVE} AND pid IS NOT NULL",
|
|
62
|
+
[id]).flatten
|
|
63
|
+
ActiveRun.new(id: id, commit_hash: commit_hash, processes: [trigger_pid, pid].compact, stage_groups: groups,
|
|
64
|
+
slot_lock: slot_lock)
|
|
65
|
+
end
|
|
66
|
+
private_class_method :active_run
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Persistence
|
|
7
|
+
module Database
|
|
8
|
+
PIPELINE_RUNS_TABLE = <<~SQL
|
|
9
|
+
CREATE TABLE IF NOT EXISTS pipeline_runs (
|
|
10
|
+
id INTEGER PRIMARY KEY,
|
|
11
|
+
commit_hash TEXT,
|
|
12
|
+
branch TEXT,
|
|
13
|
+
status TEXT DEFAULT 'scheduled',
|
|
14
|
+
pid INTEGER,
|
|
15
|
+
created_at TEXT,
|
|
16
|
+
updated_at TEXT
|
|
17
|
+
)
|
|
18
|
+
SQL
|
|
19
|
+
|
|
20
|
+
STAGE_JOBS_TABLE = <<~SQL
|
|
21
|
+
CREATE TABLE IF NOT EXISTS stage_jobs (
|
|
22
|
+
id INTEGER PRIMARY KEY,
|
|
23
|
+
pipeline_run_id INTEGER REFERENCES pipeline_runs(id),
|
|
24
|
+
stage TEXT,
|
|
25
|
+
status TEXT DEFAULT 'scheduled',
|
|
26
|
+
started_at TEXT,
|
|
27
|
+
completed_at TEXT
|
|
28
|
+
)
|
|
29
|
+
SQL
|
|
30
|
+
|
|
31
|
+
# Opening and migrating hold an exclusive lock on a file beside the
|
|
32
|
+
# database, so fun-ci processes starting at once set it up one at a time.
|
|
33
|
+
# SQLite's busy timeout doesn't cover the switch to WAL, and a column can
|
|
34
|
+
# appear between one process's check and its ALTER.
|
|
35
|
+
def self.connection(db_path, open: SQLite3::Database.method(:new))
|
|
36
|
+
with_setup_lock(db_path) { configure(open.call(db_path)) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Columns added after the tables were first released, so older
|
|
40
|
+
# databases gain them on the next migrate!.
|
|
41
|
+
ADDED_COLUMNS = [%w[pipeline_runs pid INTEGER], %w[pipeline_runs project_path TEXT],
|
|
42
|
+
%w[pipeline_runs trigger_pid INTEGER], %w[pipeline_runs slot_lock TEXT],
|
|
43
|
+
%w[stage_jobs pid INTEGER], %w[stage_jobs finished_order INTEGER],
|
|
44
|
+
%w[stage_jobs output_tail TEXT], %w[stage_jobs failures TEXT],
|
|
45
|
+
%w[pipeline_runs waited_at TEXT], %w[stage_jobs exit_status INTEGER],
|
|
46
|
+
%w[stage_jobs signal TEXT], %w[stage_jobs budget INTEGER], %w[stage_jobs pruned INTEGER],
|
|
47
|
+
%w[stage_jobs evidence TEXT]].freeze
|
|
48
|
+
|
|
49
|
+
def self.migrate!(db)
|
|
50
|
+
with_setup_lock(db.filename("main")) do
|
|
51
|
+
[PIPELINE_RUNS_TABLE, STAGE_JOBS_TABLE].each { |table| db.execute(table) }
|
|
52
|
+
ADDED_COLUMNS.each { |table, column, type| add_column_if_missing(db, table, column, type) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.with_setup_lock(db_path)
|
|
57
|
+
File.open("#{db_path}.setup-lock", File::RDWR | File::CREAT, 0o644) do |lock|
|
|
58
|
+
lock.flock(File::LOCK_EX)
|
|
59
|
+
yield
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
private_class_method :with_setup_lock
|
|
63
|
+
|
|
64
|
+
def self.configure(db)
|
|
65
|
+
db.busy_timeout = 5000
|
|
66
|
+
db.execute("PRAGMA journal_mode=WAL")
|
|
67
|
+
db.execute("PRAGMA foreign_keys=ON")
|
|
68
|
+
db
|
|
69
|
+
end
|
|
70
|
+
private_class_method :configure
|
|
71
|
+
|
|
72
|
+
def self.add_column_if_missing(db, table, column, type)
|
|
73
|
+
columns = db.execute("PRAGMA table_info(#{table})").map { |row| row[1] }
|
|
74
|
+
return if columns.include?(column)
|
|
75
|
+
|
|
76
|
+
db.execute("ALTER TABLE #{table} ADD COLUMN #{column} #{type}")
|
|
77
|
+
end
|
|
78
|
+
private_class_method :add_column_if_missing
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Persistence
|
|
5
|
+
# Records nothing, for a pipeline run with no database.
|
|
6
|
+
class NullRecorder
|
|
7
|
+
def create_run(**); end
|
|
8
|
+
def start_stage(_stage, **); end
|
|
9
|
+
def end_stage(_job_id, _status); end
|
|
10
|
+
def keep_exit(_job_id, _exit_status, _signal); end
|
|
11
|
+
def keep_evidence(_job_id, _document); end
|
|
12
|
+
def keep_raw(_job_id, _text); end
|
|
13
|
+
def alongside(_job_id) = []
|
|
14
|
+
def stage_process(_job_id, _pid); end
|
|
15
|
+
def slot_taken(_lock_file); end
|
|
16
|
+
def foreground_done; end
|
|
17
|
+
def db; end
|
|
18
|
+
def db_path; end
|
|
19
|
+
def pipeline_run_id; end
|
|
20
|
+
def close; end
|
|
21
|
+
def tolerating = yield
|
|
22
|
+
def report_trouble_to(_out); end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Persistence
|
|
5
|
+
# The end of what a stage printed, as fun-ci keeps it for a stage that
|
|
6
|
+
# failed (acceptance-tests.md, AT-9.5): the last 200 lines, at most 64 KB,
|
|
7
|
+
# colour codes stripped and bytes that aren't UTF-8 replaced. `mask`
|
|
8
|
+
# sees the last lines before they are cut to size, so no cut splits a
|
|
9
|
+
# secret it would have masked.
|
|
10
|
+
module OutputTail
|
|
11
|
+
LINES = 200
|
|
12
|
+
BYTES = 65_536
|
|
13
|
+
COLOUR = /\e\[[0-9;?]*[A-Za-z]/
|
|
14
|
+
|
|
15
|
+
def self.of(output, mask: :itself.to_proc)
|
|
16
|
+
text = output.dup.force_encoding(Encoding::UTF_8).scrub("?").gsub(COLOUR, "")
|
|
17
|
+
last_bytes(mask.call(text.lines.last(LINES).join))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.last_bytes(text) = text.byteslice([text.bytesize - BYTES, 0].max..).scrub("")
|
|
21
|
+
private_class_method :last_bytes
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|