fun_ci 1.2.1 → 2.0.0

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.
Files changed (228) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +218 -0
  3. data/README.md +180 -59
  4. data/lib/fun_ci/agent/age.rb +15 -0
  5. data/lib/fun_ci/agent/command_support.rb +27 -0
  6. data/lib/fun_ci/agent/commands.rb +27 -0
  7. data/lib/fun_ci/agent/context.rb +10 -0
  8. data/lib/fun_ci/agent/digest.rb +49 -0
  9. data/lib/fun_ci/agent/events.rb +47 -0
  10. data/lib/fun_ci/agent/events_command.rb +61 -0
  11. data/lib/fun_ci/agent/evidence_text.rb +46 -0
  12. data/lib/fun_ci/agent/exit_code.rb +11 -0
  13. data/lib/fun_ci/agent/git.rb +31 -0
  14. data/lib/fun_ci/agent/live_pipeline.rb +35 -0
  15. data/lib/fun_ci/agent/options.rb +75 -0
  16. data/lib/fun_ci/agent/output.rb +81 -0
  17. data/lib/fun_ci/agent/report_stage.rb +73 -0
  18. data/lib/fun_ci/agent/reports.rb +47 -0
  19. data/lib/fun_ci/agent/run_report.rb +30 -0
  20. data/lib/fun_ci/agent/runs_command.rb +34 -0
  21. data/lib/fun_ci/agent/runs_text.rb +27 -0
  22. data/lib/fun_ci/agent/snapshots.rb +39 -0
  23. data/lib/fun_ci/agent/stage_summary.rb +30 -0
  24. data/lib/fun_ci/agent/status_command.rb +36 -0
  25. data/lib/fun_ci/agent/status_json.rb +27 -0
  26. data/lib/fun_ci/agent/status_text.rb +37 -0
  27. data/lib/fun_ci/agent/system_clock.rb +11 -0
  28. data/lib/fun_ci/agent/verdict.rb +32 -0
  29. data/lib/fun_ci/agent/wait_command.rb +71 -0
  30. data/lib/fun_ci/agent/waiting.rb +32 -0
  31. data/lib/fun_ci/agent/why_command.rb +50 -0
  32. data/lib/fun_ci/agent/why_json.rb +35 -0
  33. data/lib/fun_ci/agent/why_text.rb +43 -0
  34. data/lib/fun_ci/cli.rb +72 -94
  35. data/lib/fun_ci/cli_help.rb +65 -0
  36. data/lib/fun_ci/console/board_data.rb +66 -0
  37. data/lib/fun_ci/console/board_heartbeat.rb +40 -0
  38. data/lib/fun_ci/console/console_events.rb +20 -0
  39. data/lib/fun_ci/console/console_log.rb +26 -0
  40. data/lib/fun_ci/console/console_loop.rb +41 -0
  41. data/lib/fun_ci/console/console_session.rb +82 -0
  42. data/lib/fun_ci/console/console_state.rb +42 -0
  43. data/lib/fun_ci/console/key_handler.rb +73 -0
  44. data/lib/fun_ci/console/launcher.rb +43 -0
  45. data/lib/fun_ci/console/milestone_events.rb +38 -0
  46. data/lib/fun_ci/console/milestones.rb +25 -0
  47. data/lib/fun_ci/console/renderer_lookup.rb +51 -0
  48. data/lib/fun_ci/console/renderer_process.rb +60 -0
  49. data/lib/fun_ci/console/run_message.rb +33 -0
  50. data/lib/fun_ci/console/stage_change_detector.rb +32 -0
  51. data/lib/fun_ci/console/stage_events.rb +31 -0
  52. data/lib/fun_ci/console/stop_report.rb +39 -0
  53. data/lib/fun_ci/console/streak_counter.rb +19 -0
  54. data/lib/fun_ci/console/view.rb +47 -0
  55. data/lib/fun_ci/evidence/about.rb +12 -0
  56. data/lib/fun_ci/evidence/about_stage.rb +29 -0
  57. data/lib/fun_ci/evidence/caps.rb +77 -0
  58. data/lib/fun_ci/evidence/catalog.rb +83 -0
  59. data/lib/fun_ci/evidence/collector.rb +95 -0
  60. data/lib/fun_ci/evidence/command_output.rb +45 -0
  61. data/lib/fun_ci/evidence/command_reading.rb +38 -0
  62. data/lib/fun_ci/evidence/command_runner.rb +75 -0
  63. data/lib/fun_ci/evidence/context.rb +21 -0
  64. data/lib/fun_ci/evidence/context_document.rb +29 -0
  65. data/lib/fun_ci/evidence/deadline.rb +15 -0
  66. data/lib/fun_ci/evidence/detection.rb +82 -0
  67. data/lib/fun_ci/evidence/document.rb +59 -0
  68. data/lib/fun_ci/evidence/extract_command.rb +83 -0
  69. data/lib/fun_ci/evidence/extract_options.rb +45 -0
  70. data/lib/fun_ci/evidence/extraction.rb +54 -0
  71. data/lib/fun_ci/evidence/extractors/command.rb +52 -0
  72. data/lib/fun_ci/evidence/extractors/grep.rb +49 -0
  73. data/lib/fun_ci/evidence/extractors/json_log.rb +45 -0
  74. data/lib/fun_ci/evidence/extractors/junit_files.rb +32 -0
  75. data/lib/fun_ci/evidence/extractors/last_lines.rb +25 -0
  76. data/lib/fun_ci/evidence/extractors/log_file.rb +62 -0
  77. data/lib/fun_ci/evidence/extractors/process_tree.rb +51 -0
  78. data/lib/fun_ci/evidence/extractors/section.rb +49 -0
  79. data/lib/fun_ci/evidence/findings.rb +14 -0
  80. data/lib/fun_ci/evidence/line_ranges.rb +21 -0
  81. data/lib/fun_ci/evidence/line_scan.rb +22 -0
  82. data/lib/fun_ci/evidence/log_record.rb +58 -0
  83. data/lib/fun_ci/evidence/masking.rb +55 -0
  84. data/lib/fun_ci/evidence/option_check.rb +53 -0
  85. data/lib/fun_ci/evidence/outcome.rb +15 -0
  86. data/lib/fun_ci/evidence/patterns.rb +22 -0
  87. data/lib/fun_ci/evidence/presets/README.md +7 -0
  88. data/lib/fun_ci/evidence/presets/bun-test.yml +6 -0
  89. data/lib/fun_ci/evidence/presets/cargo-test.yml +6 -0
  90. data/lib/fun_ci/evidence/presets/dart-test.yml +6 -0
  91. data/lib/fun_ci/evidence/presets/deno-test.yml +6 -0
  92. data/lib/fun_ci/evidence/presets/dotnet-test.yml +6 -0
  93. data/lib/fun_ci/evidence/presets/ecs.yml +3 -0
  94. data/lib/fun_ci/evidence/presets/eslint.yml +6 -0
  95. data/lib/fun_ci/evidence/presets/exunit.yml +6 -0
  96. data/lib/fun_ci/evidence/presets/gcc.yml +6 -0
  97. data/lib/fun_ci/evidence/presets/go-build.yml +5 -0
  98. data/lib/fun_ci/evidence/presets/go-test.yml +6 -0
  99. data/lib/fun_ci/evidence/presets/gradle.yml +6 -0
  100. data/lib/fun_ci/evidence/presets/gtest.yml +6 -0
  101. data/lib/fun_ci/evidence/presets/jest.yml +6 -0
  102. data/lib/fun_ci/evidence/presets/logstash.yml +3 -0
  103. data/lib/fun_ci/evidence/presets/maven.yml +6 -0
  104. data/lib/fun_ci/evidence/presets/minitest.yml +6 -0
  105. data/lib/fun_ci/evidence/presets/mocha.yml +6 -0
  106. data/lib/fun_ci/evidence/presets/mypy.yml +6 -0
  107. data/lib/fun_ci/evidence/presets/node-test.yml +6 -0
  108. data/lib/fun_ci/evidence/presets/phpunit.yml +6 -0
  109. data/lib/fun_ci/evidence/presets/pino.yml +5 -0
  110. data/lib/fun_ci/evidence/presets/prove.yml +6 -0
  111. data/lib/fun_ci/evidence/presets/pytest.yml +6 -0
  112. data/lib/fun_ci/evidence/presets/rspec.yml +6 -0
  113. data/lib/fun_ci/evidence/presets/rubocop.yml +6 -0
  114. data/lib/fun_ci/evidence/presets/ruff.yml +6 -0
  115. data/lib/fun_ci/evidence/presets/rustc.yml +6 -0
  116. data/lib/fun_ci/evidence/presets/shellcheck.yml +5 -0
  117. data/lib/fun_ci/evidence/presets/structlog.yml +4 -0
  118. data/lib/fun_ci/evidence/presets/swift-test.yml +5 -0
  119. data/lib/fun_ci/evidence/presets/tsc.yml +6 -0
  120. data/lib/fun_ci/evidence/presets/unittest.yml +6 -0
  121. data/lib/fun_ci/evidence/presets/vitest.yml +6 -0
  122. data/lib/fun_ci/evidence/presets.rb +30 -0
  123. data/lib/fun_ci/evidence/problem.rb +9 -0
  124. data/lib/fun_ci/evidence/process_table.rb +40 -0
  125. data/lib/fun_ci/evidence/saved_run.rb +22 -0
  126. data/lib/fun_ci/evidence/section_walk.rb +49 -0
  127. data/lib/fun_ci/evidence/settings.rb +64 -0
  128. data/lib/fun_ci/evidence/settings_check.rb +78 -0
  129. data/lib/fun_ci/evidence/source.rb +34 -0
  130. data/lib/fun_ci/evidence/sources.rb +21 -0
  131. data/lib/fun_ci/evidence/stage_context.rb +30 -0
  132. data/lib/fun_ci/evidence/stamp.rb +10 -0
  133. data/lib/fun_ci/evidence/start.rb +36 -0
  134. data/lib/fun_ci/evidence/worktree.rb +62 -0
  135. data/lib/fun_ci/persistence/active_runs.rb +69 -0
  136. data/lib/fun_ci/persistence/database.rb +59 -30
  137. data/lib/fun_ci/persistence/null_recorder.rb +25 -0
  138. data/lib/fun_ci/persistence/output_tail.rb +24 -0
  139. data/lib/fun_ci/persistence/pipeline_recorder.rb +78 -32
  140. data/lib/fun_ci/persistence/pipeline_run.rb +26 -17
  141. data/lib/fun_ci/persistence/project_runs.rb +52 -0
  142. data/lib/fun_ci/persistence/raw_outputs.rb +46 -0
  143. data/lib/fun_ci/persistence/retention.rb +25 -0
  144. data/lib/fun_ci/persistence/run_status.rb +31 -0
  145. data/lib/fun_ci/persistence/stage_job.rb +58 -13
  146. data/lib/fun_ci/persistence/state_dir.rb +26 -0
  147. data/lib/fun_ci/persistence/write_trouble.rb +46 -0
  148. data/lib/fun_ci/pipeline/background_fork.rb +42 -0
  149. data/lib/fun_ci/pipeline/command_executor.rb +58 -0
  150. data/lib/fun_ci/pipeline/gate.rb +35 -0
  151. data/lib/fun_ci/pipeline/git_environment.rb +16 -0
  152. data/lib/fun_ci/pipeline/head_part.rb +40 -0
  153. data/lib/fun_ci/pipeline/in_place.rb +17 -0
  154. data/lib/fun_ci/pipeline/output_window.rb +73 -0
  155. data/lib/fun_ci/pipeline/pipeline_forker.rb +18 -16
  156. data/lib/fun_ci/pipeline/process_runner.rb +90 -17
  157. data/lib/fun_ci/pipeline/run_canceller.rb +75 -0
  158. data/lib/fun_ci/pipeline/slot.rb +30 -0
  159. data/lib/fun_ci/pipeline/slot_lock.rb +13 -0
  160. data/lib/fun_ci/pipeline/slot_run.rb +86 -0
  161. data/lib/fun_ci/pipeline/stage_dir.rb +50 -0
  162. data/lib/fun_ci/pipeline/stage_end.rb +70 -0
  163. data/lib/fun_ci/pipeline/stage_execution.rb +67 -0
  164. data/lib/fun_ci/pipeline/stage_runner.rb +42 -65
  165. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +16 -33
  166. data/lib/fun_ci/pipeline/tail_segments.rb +60 -0
  167. data/lib/fun_ci/pipeline/test_report.rb +48 -0
  168. data/lib/fun_ci/pipeline/trigger.rb +55 -115
  169. data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
  170. data/lib/fun_ci/pipeline/trigger_params.rb +48 -0
  171. data/lib/fun_ci/pipeline/workspaces.rb +22 -0
  172. data/lib/fun_ci/pipeline/worktree_pool.rb +66 -0
  173. data/lib/fun_ci/pipeline/worktree_prune.rb +49 -0
  174. data/lib/fun_ci/pipeline/worktrees.rb +45 -0
  175. data/lib/fun_ci/setup/agent_instructions.rb +31 -0
  176. data/lib/fun_ci/setup/commands.rb +49 -0
  177. data/lib/fun_ci/setup/git_hooks.rb +20 -0
  178. data/lib/fun_ci/setup/hook_script.rb +47 -0
  179. data/lib/fun_ci/setup/hook_writer.rb +13 -31
  180. data/lib/fun_ci/setup/installer.rb +39 -23
  181. data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
  182. data/lib/fun_ci/setup/project_config.rb +43 -17
  183. data/lib/fun_ci/setup/project_detector.rb +39 -7
  184. data/lib/fun_ci/setup/settings.rb +36 -0
  185. data/lib/fun_ci/setup/setup_checker.rb +18 -12
  186. data/lib/fun_ci/setup/stage_templates/javascript.rb +18 -0
  187. data/lib/fun_ci/setup/stage_templates/managed.rb +24 -0
  188. data/lib/fun_ci/setup/stage_templates/native.rb +25 -0
  189. data/lib/fun_ci/setup/stage_templates/scripting.rb +27 -0
  190. data/lib/fun_ci/setup/stage_templates/shell.rb +16 -0
  191. data/lib/fun_ci/setup/stage_templates.rb +25 -0
  192. data/lib/fun_ci/setup/template_writer.rb +11 -36
  193. data/lib/fun_ci/version.rb +1 -1
  194. data/lib/fun_ci.rb +0 -1
  195. metadata +205 -41
  196. data/exe/fun-ci-trigger +0 -19
  197. data/exe/fun-ci-tui +0 -23
  198. data/lib/fun_ci/animations/celebrate.rb +0 -210
  199. data/lib/fun_ci/animations/explosion.rb +0 -138
  200. data/lib/fun_ci/animations/flash.rb +0 -205
  201. data/lib/fun_ci/animations/idle.rb +0 -127
  202. data/lib/fun_ci/animations/leprechauns.rb +0 -240
  203. data/lib/fun_ci/animations/running.rb +0 -129
  204. data/lib/fun_ci/animations/success.rb +0 -281
  205. data/lib/fun_ci/animations/yay.rb +0 -169
  206. data/lib/fun_ci/persistence/state_machine.rb +0 -50
  207. data/lib/fun_ci/pipeline/background_wrapper.rb +0 -27
  208. data/lib/fun_ci/tui/admin_tui.rb +0 -90
  209. data/lib/fun_ci/tui/animation.rb +0 -49
  210. data/lib/fun_ci/tui/animation_compositor.rb +0 -107
  211. data/lib/fun_ci/tui/animation_frames.rb +0 -112
  212. data/lib/fun_ci/tui/animation_library.rb +0 -46
  213. data/lib/fun_ci/tui/animation_renderer.rb +0 -144
  214. data/lib/fun_ci/tui/ansi.rb +0 -34
  215. data/lib/fun_ci/tui/board_data.rb +0 -53
  216. data/lib/fun_ci/tui/board_renderer.rb +0 -105
  217. data/lib/fun_ci/tui/duration_formatter.rb +0 -24
  218. data/lib/fun_ci/tui/header_animation_manager.rb +0 -71
  219. data/lib/fun_ci/tui/header_animation_player.rb +0 -45
  220. data/lib/fun_ci/tui/key_handler.rb +0 -86
  221. data/lib/fun_ci/tui/looping_animation_player.rb +0 -45
  222. data/lib/fun_ci/tui/relative_time.rb +0 -22
  223. data/lib/fun_ci/tui/row_formatter.rb +0 -108
  224. data/lib/fun_ci/tui/screen.rb +0 -103
  225. data/lib/fun_ci/tui/spinner.rb +0 -24
  226. data/lib/fun_ci/tui/stage_change_detector.rb +0 -58
  227. data/lib/fun_ci/tui/streak_counter.rb +0 -29
  228. data/lib/fun_ci/tui/terminal_input.rb +0 -69
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module FunCi
6
+ module Evidence
7
+ # The presets in presets/, one YAML file each (see its README): each a
8
+ # built-in (`use`) and its options.
9
+ module Presets
10
+ DIR = File.join(__dir__, "presets")
11
+
12
+ # markers: files any of which the worktree must have, each a path or
13
+ # { path, contains }; signature: a pattern only the tool prints. Either
14
+ # may be missing (architecture.md, "Evidence of a failed stage").
15
+ Preset = Data.define(:name, :use, :options, :markers, :signature)
16
+ OWN = %w[use markers signature].freeze
17
+
18
+ def self.all
19
+ @all ||= Dir.glob("*.yml", base: DIR).sort.map do |file|
20
+ raw = YAML.safe_load_file(File.join(DIR, file))
21
+ Preset.new(name: File.basename(file, ".yml"), use: raw.fetch("use"), options: raw.except(*OWN),
22
+ markers: raw.fetch("markers", []), signature: raw["signature"])
23
+ end
24
+ end
25
+
26
+ def self.fetch(name) = all.find { |preset| preset.name == name } || raise(KeyError, "no preset '#{name}'")
27
+ def self.find(use, name) = all.find { |preset| preset.use == use && preset.name == name }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Evidence
5
+ # What went wrong running an extractor, said as the problem the evidence
6
+ # records against its entry.
7
+ class Problem < StandardError; end
8
+ end
9
+ end
@@ -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
@@ -5,45 +5,74 @@ require "sqlite3"
5
5
  module FunCi
6
6
  module Persistence
7
7
  module Database
8
- def self.connection(db_path)
9
- db = SQLite3::Database.new(db_path)
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)
10
65
  db.busy_timeout = 5000
11
66
  db.execute("PRAGMA journal_mode=WAL")
12
67
  db.execute("PRAGMA foreign_keys=ON")
13
68
  db
14
69
  end
15
-
16
- def self.migrate!(db)
17
- db.execute(<<~SQL)
18
- CREATE TABLE IF NOT EXISTS pipeline_runs (
19
- id INTEGER PRIMARY KEY,
20
- commit_hash TEXT,
21
- branch TEXT,
22
- status TEXT DEFAULT 'scheduled',
23
- pid INTEGER,
24
- created_at TEXT,
25
- updated_at TEXT
26
- )
27
- SQL
28
-
29
- add_column_if_missing(db, "pipeline_runs", "pid", "INTEGER")
30
- add_column_if_missing(db, "pipeline_runs", "project_path", "TEXT")
31
-
32
- db.execute(<<~SQL)
33
- CREATE TABLE IF NOT EXISTS stage_jobs (
34
- id INTEGER PRIMARY KEY,
35
- pipeline_run_id INTEGER REFERENCES pipeline_runs(id),
36
- stage TEXT,
37
- status TEXT DEFAULT 'scheduled',
38
- started_at TEXT,
39
- completed_at TEXT
40
- )
41
- SQL
42
- end
70
+ private_class_method :configure
43
71
 
44
72
  def self.add_column_if_missing(db, table, column, type)
45
73
  columns = db.execute("PRAGMA table_info(#{table})").map { |row| row[1] }
46
74
  return if columns.include?(column)
75
+
47
76
  db.execute("ALTER TABLE #{table} ADD COLUMN #{column} #{type}")
48
77
  end
49
78
  private_class_method :add_column_if_missing