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.
Files changed (200) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +294 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +230 -0
  5. data/exe/fun-ci +7 -0
  6. data/lib/fun_ci/agent/age.rb +15 -0
  7. data/lib/fun_ci/agent/command_support.rb +27 -0
  8. data/lib/fun_ci/agent/commands.rb +27 -0
  9. data/lib/fun_ci/agent/context.rb +10 -0
  10. data/lib/fun_ci/agent/digest.rb +49 -0
  11. data/lib/fun_ci/agent/events.rb +47 -0
  12. data/lib/fun_ci/agent/events_command.rb +61 -0
  13. data/lib/fun_ci/agent/evidence_text.rb +46 -0
  14. data/lib/fun_ci/agent/exit_code.rb +11 -0
  15. data/lib/fun_ci/agent/git.rb +31 -0
  16. data/lib/fun_ci/agent/live_pipeline.rb +35 -0
  17. data/lib/fun_ci/agent/options.rb +75 -0
  18. data/lib/fun_ci/agent/output.rb +81 -0
  19. data/lib/fun_ci/agent/report_stage.rb +73 -0
  20. data/lib/fun_ci/agent/reports.rb +47 -0
  21. data/lib/fun_ci/agent/run_report.rb +30 -0
  22. data/lib/fun_ci/agent/runs_command.rb +34 -0
  23. data/lib/fun_ci/agent/runs_text.rb +27 -0
  24. data/lib/fun_ci/agent/snapshots.rb +39 -0
  25. data/lib/fun_ci/agent/stage_summary.rb +30 -0
  26. data/lib/fun_ci/agent/status_command.rb +36 -0
  27. data/lib/fun_ci/agent/status_json.rb +27 -0
  28. data/lib/fun_ci/agent/status_text.rb +37 -0
  29. data/lib/fun_ci/agent/system_clock.rb +11 -0
  30. data/lib/fun_ci/agent/verdict.rb +32 -0
  31. data/lib/fun_ci/agent/wait_command.rb +71 -0
  32. data/lib/fun_ci/agent/waiting.rb +32 -0
  33. data/lib/fun_ci/agent/why_command.rb +50 -0
  34. data/lib/fun_ci/agent/why_json.rb +35 -0
  35. data/lib/fun_ci/agent/why_text.rb +43 -0
  36. data/lib/fun_ci/cli.rb +128 -0
  37. data/lib/fun_ci/cli_help.rb +65 -0
  38. data/lib/fun_ci/console/board_data.rb +66 -0
  39. data/lib/fun_ci/console/board_heartbeat.rb +40 -0
  40. data/lib/fun_ci/console/console_events.rb +20 -0
  41. data/lib/fun_ci/console/console_log.rb +26 -0
  42. data/lib/fun_ci/console/console_loop.rb +41 -0
  43. data/lib/fun_ci/console/console_session.rb +82 -0
  44. data/lib/fun_ci/console/console_state.rb +42 -0
  45. data/lib/fun_ci/console/key_handler.rb +73 -0
  46. data/lib/fun_ci/console/launcher.rb +43 -0
  47. data/lib/fun_ci/console/milestone_events.rb +38 -0
  48. data/lib/fun_ci/console/milestones.rb +25 -0
  49. data/lib/fun_ci/console/renderer_lookup.rb +51 -0
  50. data/lib/fun_ci/console/renderer_process.rb +60 -0
  51. data/lib/fun_ci/console/run_message.rb +33 -0
  52. data/lib/fun_ci/console/stage_change_detector.rb +32 -0
  53. data/lib/fun_ci/console/stage_events.rb +31 -0
  54. data/lib/fun_ci/console/stop_report.rb +39 -0
  55. data/lib/fun_ci/console/streak_counter.rb +19 -0
  56. data/lib/fun_ci/console/view.rb +47 -0
  57. data/lib/fun_ci/evidence/about.rb +12 -0
  58. data/lib/fun_ci/evidence/about_stage.rb +29 -0
  59. data/lib/fun_ci/evidence/caps.rb +77 -0
  60. data/lib/fun_ci/evidence/catalog.rb +83 -0
  61. data/lib/fun_ci/evidence/collector.rb +95 -0
  62. data/lib/fun_ci/evidence/command_output.rb +45 -0
  63. data/lib/fun_ci/evidence/command_reading.rb +38 -0
  64. data/lib/fun_ci/evidence/command_runner.rb +75 -0
  65. data/lib/fun_ci/evidence/context.rb +21 -0
  66. data/lib/fun_ci/evidence/context_document.rb +29 -0
  67. data/lib/fun_ci/evidence/deadline.rb +15 -0
  68. data/lib/fun_ci/evidence/detection.rb +82 -0
  69. data/lib/fun_ci/evidence/document.rb +59 -0
  70. data/lib/fun_ci/evidence/extract_command.rb +83 -0
  71. data/lib/fun_ci/evidence/extract_options.rb +45 -0
  72. data/lib/fun_ci/evidence/extraction.rb +54 -0
  73. data/lib/fun_ci/evidence/extractors/command.rb +52 -0
  74. data/lib/fun_ci/evidence/extractors/grep.rb +49 -0
  75. data/lib/fun_ci/evidence/extractors/json_log.rb +45 -0
  76. data/lib/fun_ci/evidence/extractors/junit_files.rb +32 -0
  77. data/lib/fun_ci/evidence/extractors/last_lines.rb +25 -0
  78. data/lib/fun_ci/evidence/extractors/log_file.rb +62 -0
  79. data/lib/fun_ci/evidence/extractors/process_tree.rb +51 -0
  80. data/lib/fun_ci/evidence/extractors/section.rb +49 -0
  81. data/lib/fun_ci/evidence/findings.rb +14 -0
  82. data/lib/fun_ci/evidence/line_ranges.rb +21 -0
  83. data/lib/fun_ci/evidence/line_scan.rb +22 -0
  84. data/lib/fun_ci/evidence/log_record.rb +58 -0
  85. data/lib/fun_ci/evidence/masking.rb +55 -0
  86. data/lib/fun_ci/evidence/option_check.rb +53 -0
  87. data/lib/fun_ci/evidence/outcome.rb +15 -0
  88. data/lib/fun_ci/evidence/patterns.rb +22 -0
  89. data/lib/fun_ci/evidence/presets/README.md +7 -0
  90. data/lib/fun_ci/evidence/presets/bun-test.yml +6 -0
  91. data/lib/fun_ci/evidence/presets/cargo-test.yml +6 -0
  92. data/lib/fun_ci/evidence/presets/dart-test.yml +6 -0
  93. data/lib/fun_ci/evidence/presets/deno-test.yml +6 -0
  94. data/lib/fun_ci/evidence/presets/dotnet-test.yml +6 -0
  95. data/lib/fun_ci/evidence/presets/ecs.yml +3 -0
  96. data/lib/fun_ci/evidence/presets/eslint.yml +6 -0
  97. data/lib/fun_ci/evidence/presets/exunit.yml +6 -0
  98. data/lib/fun_ci/evidence/presets/gcc.yml +6 -0
  99. data/lib/fun_ci/evidence/presets/go-build.yml +5 -0
  100. data/lib/fun_ci/evidence/presets/go-test.yml +6 -0
  101. data/lib/fun_ci/evidence/presets/gradle.yml +6 -0
  102. data/lib/fun_ci/evidence/presets/gtest.yml +6 -0
  103. data/lib/fun_ci/evidence/presets/jest.yml +6 -0
  104. data/lib/fun_ci/evidence/presets/logstash.yml +3 -0
  105. data/lib/fun_ci/evidence/presets/maven.yml +6 -0
  106. data/lib/fun_ci/evidence/presets/minitest.yml +6 -0
  107. data/lib/fun_ci/evidence/presets/mocha.yml +6 -0
  108. data/lib/fun_ci/evidence/presets/mypy.yml +6 -0
  109. data/lib/fun_ci/evidence/presets/node-test.yml +6 -0
  110. data/lib/fun_ci/evidence/presets/phpunit.yml +6 -0
  111. data/lib/fun_ci/evidence/presets/pino.yml +5 -0
  112. data/lib/fun_ci/evidence/presets/prove.yml +6 -0
  113. data/lib/fun_ci/evidence/presets/pytest.yml +6 -0
  114. data/lib/fun_ci/evidence/presets/rspec.yml +6 -0
  115. data/lib/fun_ci/evidence/presets/rubocop.yml +6 -0
  116. data/lib/fun_ci/evidence/presets/ruff.yml +6 -0
  117. data/lib/fun_ci/evidence/presets/rustc.yml +6 -0
  118. data/lib/fun_ci/evidence/presets/shellcheck.yml +5 -0
  119. data/lib/fun_ci/evidence/presets/structlog.yml +4 -0
  120. data/lib/fun_ci/evidence/presets/swift-test.yml +5 -0
  121. data/lib/fun_ci/evidence/presets/tsc.yml +6 -0
  122. data/lib/fun_ci/evidence/presets/unittest.yml +6 -0
  123. data/lib/fun_ci/evidence/presets/vitest.yml +6 -0
  124. data/lib/fun_ci/evidence/presets.rb +30 -0
  125. data/lib/fun_ci/evidence/problem.rb +9 -0
  126. data/lib/fun_ci/evidence/process_table.rb +40 -0
  127. data/lib/fun_ci/evidence/saved_run.rb +22 -0
  128. data/lib/fun_ci/evidence/section_walk.rb +49 -0
  129. data/lib/fun_ci/evidence/settings.rb +64 -0
  130. data/lib/fun_ci/evidence/settings_check.rb +78 -0
  131. data/lib/fun_ci/evidence/source.rb +34 -0
  132. data/lib/fun_ci/evidence/sources.rb +21 -0
  133. data/lib/fun_ci/evidence/stage_context.rb +30 -0
  134. data/lib/fun_ci/evidence/stamp.rb +10 -0
  135. data/lib/fun_ci/evidence/start.rb +36 -0
  136. data/lib/fun_ci/evidence/worktree.rb +62 -0
  137. data/lib/fun_ci/persistence/active_runs.rb +69 -0
  138. data/lib/fun_ci/persistence/database.rb +81 -0
  139. data/lib/fun_ci/persistence/null_recorder.rb +25 -0
  140. data/lib/fun_ci/persistence/output_tail.rb +24 -0
  141. data/lib/fun_ci/persistence/pipeline_recorder.rb +120 -0
  142. data/lib/fun_ci/persistence/pipeline_run.rb +68 -0
  143. data/lib/fun_ci/persistence/project_runs.rb +52 -0
  144. data/lib/fun_ci/persistence/raw_outputs.rb +46 -0
  145. data/lib/fun_ci/persistence/retention.rb +25 -0
  146. data/lib/fun_ci/persistence/run_status.rb +31 -0
  147. data/lib/fun_ci/persistence/stage_job.rb +91 -0
  148. data/lib/fun_ci/persistence/state_dir.rb +26 -0
  149. data/lib/fun_ci/persistence/write_trouble.rb +46 -0
  150. data/lib/fun_ci/pipeline/background_fork.rb +42 -0
  151. data/lib/fun_ci/pipeline/command_executor.rb +58 -0
  152. data/lib/fun_ci/pipeline/gate.rb +35 -0
  153. data/lib/fun_ci/pipeline/git_environment.rb +16 -0
  154. data/lib/fun_ci/pipeline/head_part.rb +40 -0
  155. data/lib/fun_ci/pipeline/in_place.rb +17 -0
  156. data/lib/fun_ci/pipeline/output_window.rb +73 -0
  157. data/lib/fun_ci/pipeline/pipeline_forker.rb +40 -0
  158. data/lib/fun_ci/pipeline/process_runner.rb +101 -0
  159. data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
  160. data/lib/fun_ci/pipeline/run_canceller.rb +75 -0
  161. data/lib/fun_ci/pipeline/slot.rb +30 -0
  162. data/lib/fun_ci/pipeline/slot_lock.rb +13 -0
  163. data/lib/fun_ci/pipeline/slot_run.rb +86 -0
  164. data/lib/fun_ci/pipeline/stage_dir.rb +50 -0
  165. data/lib/fun_ci/pipeline/stage_end.rb +70 -0
  166. data/lib/fun_ci/pipeline/stage_execution.rb +67 -0
  167. data/lib/fun_ci/pipeline/stage_runner.rb +63 -0
  168. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +36 -0
  169. data/lib/fun_ci/pipeline/tail_segments.rb +60 -0
  170. data/lib/fun_ci/pipeline/test_report.rb +48 -0
  171. data/lib/fun_ci/pipeline/trigger.rb +93 -0
  172. data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
  173. data/lib/fun_ci/pipeline/trigger_params.rb +48 -0
  174. data/lib/fun_ci/pipeline/workspaces.rb +22 -0
  175. data/lib/fun_ci/pipeline/worktree_pool.rb +66 -0
  176. data/lib/fun_ci/pipeline/worktree_prune.rb +49 -0
  177. data/lib/fun_ci/pipeline/worktrees.rb +45 -0
  178. data/lib/fun_ci/setup/agent_instructions.rb +31 -0
  179. data/lib/fun_ci/setup/commands.rb +49 -0
  180. data/lib/fun_ci/setup/git_hooks.rb +20 -0
  181. data/lib/fun_ci/setup/hook_script.rb +47 -0
  182. data/lib/fun_ci/setup/hook_writer.rb +57 -0
  183. data/lib/fun_ci/setup/installer.rb +71 -0
  184. data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
  185. data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
  186. data/lib/fun_ci/setup/project_config.rb +68 -0
  187. data/lib/fun_ci/setup/project_detector.rb +54 -0
  188. data/lib/fun_ci/setup/settings.rb +36 -0
  189. data/lib/fun_ci/setup/setup_checker.rb +36 -0
  190. data/lib/fun_ci/setup/stage_templates/javascript.rb +18 -0
  191. data/lib/fun_ci/setup/stage_templates/managed.rb +24 -0
  192. data/lib/fun_ci/setup/stage_templates/native.rb +25 -0
  193. data/lib/fun_ci/setup/stage_templates/scripting.rb +27 -0
  194. data/lib/fun_ci/setup/stage_templates/shell.rb +16 -0
  195. data/lib/fun_ci/setup/stage_templates.rb +25 -0
  196. data/lib/fun_ci/setup/template_writer.rb +28 -0
  197. data/lib/fun_ci/version.rb +6 -0
  198. data/lib/fun_ci.rb +13 -0
  199. data/libexec/fun-ci-renderer +0 -0
  200. metadata +286 -0
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "stage_end"
4
+ require_relative "../evidence/start"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # Runs a stage that has been started, in `dir`, with a stage directory of
9
+ # its own, and records how it ended while it still holds both. The same
10
+ # for every stage, the slow suite in its forked child included.
11
+ class StageExecution
12
+ def initialize(seams:, dir:, commit:)
13
+ @seams = seams
14
+ @dir = dir
15
+ @commit = commit
16
+ end
17
+
18
+ # Answers [the outcome recorded, what the stage printed].
19
+ def run(stage, command, recorder, job_id)
20
+ with_stage_dir do |stage_dir|
21
+ collector = collector(stage, stage_dir)
22
+ finished = finished(stage, command, stage_dir, collector) { |pid| recorder.stage_process(job_id, pid) }
23
+ [StageEnd.new(recorder, job_id, collector).record(finished), finished.output]
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ # A stage over budget is looked at before the kill (acceptance-tests.md, AT-10.17).
30
+ def finished(stage, command, stage_dir, collector, &)
31
+ overrun = nil
32
+ output, status, timed_out = execute(stage, command, stage_dir, lambda { |pgid|
33
+ overrun = collector.before_kill(pgid)
34
+ },
35
+ &)
36
+ StageEnd::Finished.new(output: output, status: status, timed_out: timed_out, overrun: overrun)
37
+ end
38
+
39
+ # The stage learns its name from FUN_CI_STAGE (acceptance-tests.md, AT-10.7).
40
+ def execute(stage, command, stage_dir, before_kill, &)
41
+ window = stage_dir.window
42
+ launch = ProcessRunner::Launch.new(env: { "FUN_CI_STAGE" => stage }, output: window,
43
+ before_kill: before_kill)
44
+ @seams.executor(@dir).call(command, @seams.budgets[stage], launch, &)
45
+ ensure
46
+ window&.close
47
+ end
48
+
49
+ def with_stage_dir
50
+ stage_dir = @seams.stage_dir.call
51
+ yield stage_dir
52
+ ensure
53
+ stage_dir&.remove
54
+ end
55
+
56
+ # Made before the stage runs, which is when its watched files are stamped.
57
+ def collector(stage, stage_dir)
58
+ sources = Evidence::Sources.new(stage: stage, worktree: @dir, stage_dir: stage_dir,
59
+ environment: @seams.environment,
60
+ budget: @seams.budgets[stage], commit: @commit.to_h, started: Time.now,
61
+ processes: @seams.process_table)
62
+ commands = @seams.extractor_runner.call(dir: @dir, scratch: stage_dir.scratch)
63
+ Evidence::Start.collector(sources, @seams.clock, commands)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "trigger_params"
4
+ require_relative "stage_execution"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ class StageRunner
9
+ LABELS = { "lint" => "Lint", "build" => "Build", "fast" => "Fast suite", "slow" => "Slow suite" }.freeze
10
+ ADVICE = {
11
+ "lint" => "Trim your linter config or split into stages.",
12
+ "build" => "Keep your build efficient and not let it become a bottleneck.",
13
+ "fast" => "Your fast tests have gotten too slow. Split or speed them up.",
14
+ "slow" => "Pare down integration tests, parallelise, or raise the budget."
15
+ }.freeze
16
+ FIX_TESTS = "Fix the failing tests above, then try again."
17
+ NEXT_STEPS = {
18
+ "lint" => "Fix what the linter reported above, then try again.",
19
+ "build" => "Fix the build errors above, then try again.",
20
+ "fast" => FIX_TESTS,
21
+ "slow" => FIX_TESTS
22
+ }.freeze
23
+
24
+ def initialize(commit:, stdout:, seams: Seams.new, dir: Dir.pwd)
25
+ @commit = commit
26
+ @stdout = stdout
27
+ @seams = seams
28
+ @dir = dir
29
+ end
30
+
31
+ # A failed stage's evidence is kept before its outcome is recorded, so
32
+ # whoever sees the outcome can read why.
33
+ def passes?(config, stage)
34
+ job_id = @seams.recorder.start_stage(stage, budget: @seams.budgets[stage])
35
+ command = config.stage_command(stage, @commit.sha)
36
+ result, output = StageExecution.new(seams: @seams, dir: @dir, commit: @commit).run(stage, command,
37
+ @seams.recorder, job_id)
38
+ tell(stage, output, result)
39
+ result == "completed"
40
+ end
41
+
42
+ private
43
+
44
+ def tell(stage, output, result)
45
+ report_timeout(stage) if result == "timed_out"
46
+ report_failure(stage, output) if result == "failed"
47
+ end
48
+
49
+ def report_timeout(stage)
50
+ @stdout.puts "#{label(stage)} killed -- exceeded #{@seams.budgets[stage]}s time budget."
51
+ @stdout.puts ADVICE[stage]
52
+ end
53
+
54
+ def report_failure(stage, output)
55
+ @stdout.puts output unless output.empty?
56
+ @stdout.puts "#{label(stage)} failed."
57
+ @stdout.puts NEXT_STEPS[stage]
58
+ end
59
+
60
+ def label(stage) = LABELS.fetch(stage, stage)
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/active_runs"
4
+ require_relative "run_canceller"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # A newer commit on a branch cancels every run still going for an older
9
+ # one, unless an agent is waiting on it: its processes are stopped and it
10
+ # is recorded cancelled (acceptance-tests.md, AT-1.6, AT-9.12).
11
+ class StalePipelineCanceller
12
+ def initialize(db:, branch:, stdout:, run_canceller: RunCanceller.new)
13
+ @db = db
14
+ @branch = branch
15
+ @stdout = stdout
16
+ @run_canceller = run_canceller
17
+ end
18
+
19
+ # How long an agent's last poll keeps a run it waits on from being cancelled.
20
+ WAITED_ON_SECONDS = 10
21
+
22
+ def cancel(new_commit_hash:)
23
+ waited_before = (Time.now - WAITED_ON_SECONDS).utc.iso8601
24
+ Persistence::ActiveRuns.cancellable(@db, @branch, new_commit: new_commit_hash, waited_before: waited_before)
25
+ .each { |run| cancel_run(run, new_commit_hash) }
26
+ end
27
+
28
+ private
29
+
30
+ def cancel_run(run, new_commit_hash)
31
+ @run_canceller.cancel(@db, run)
32
+ @stdout.puts "Cancelled stale pipeline for #{run.commit_hash}. Starting fresh for #{new_commit_hash}."
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # The last `size` bytes of a stream, kept in two IOs of `size` bytes that
6
+ # take turns: when one fills, the other is emptied and written next.
7
+ class TailSegments
8
+ def initialize(open, size)
9
+ @open = open
10
+ @size = size
11
+ @filled = 0
12
+ @segments = nil
13
+ end
14
+
15
+ def <<(bytes)
16
+ until bytes.empty?
17
+ rotate if @filled == @size
18
+ part = bytes.byteslice(0, @size - @filled)
19
+ segments.last.write(part)
20
+ @filled += part.bytesize
21
+ bytes = bytes.byteslice(part.bytesize..)
22
+ end
23
+ end
24
+
25
+ def read
26
+ return +"".b unless @segments
27
+
28
+ earlier(@size - @filled) + whole(segments.last)
29
+ end
30
+
31
+ def close = @segments&.each(&:close)
32
+
33
+ private
34
+
35
+ def segments = @segments ||= [@open.call("tail-0"), @open.call("tail-1")]
36
+
37
+ def rotate
38
+ @segments = segments.reverse
39
+ segments.last.truncate(0)
40
+ segments.last.rewind
41
+ @filled = 0
42
+ end
43
+
44
+ # The earlier segment is full once the tail has wrapped; before that,
45
+ # nothing came earlier.
46
+ def earlier(count)
47
+ io = segments.first
48
+ return +"".b if count.zero? || io.size < @size
49
+
50
+ io.seek(io.size - count)
51
+ io.read(count)
52
+ end
53
+
54
+ def whole(io)
55
+ io.rewind
56
+ io.read
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+
5
+ module FunCi
6
+ module Pipeline
7
+ # The failures a JUnit XML report names (acceptance-tests.md, AT-9.7),
8
+ # each as { file:, line:, test:, message: }, with output: when the failure
9
+ # kept its own (AT-10.14), its last 4 KB; nil for a report that can't be
10
+ # read.
11
+ module TestReport
12
+ OUTPUT_BYTES = 4096
13
+
14
+ def self.junit(xml)
15
+ REXML::XPath.match(REXML::Document.new(xml), "//testcase[failure or error]").map { |test| junit_failure(test) }
16
+ rescue REXML::ParseException
17
+ nil
18
+ end
19
+
20
+ def self.junit_failure(test)
21
+ with_output({ file: test.attributes["file"], line: test.attributes["line"]&.to_i, test: junit_name(test),
22
+ message: junit_message(test.elements["failure"] || test.elements["error"]) },
23
+ junit_output(test))
24
+ end
25
+
26
+ # The failure's text, or its message attribute when it has none.
27
+ def self.junit_message(problem)
28
+ text = problem.text.to_s.strip
29
+ text.empty? ? problem.attributes["message"].to_s : text
30
+ end
31
+
32
+ def self.junit_name(test)
33
+ [test.attributes["classname"], test.attributes["name"]].compact.join("#")
34
+ end
35
+
36
+ def self.junit_output(test)
37
+ %w[system-out system-err].filter_map { |name| test.elements[name]&.text&.strip }.reject(&:empty?).join("\n")
38
+ end
39
+
40
+ def self.with_output(failure, output)
41
+ return failure if output.empty?
42
+
43
+ failure.merge(output: output.byteslice([output.bytesize - OUTPUT_BYTES, 0].max..).scrub(""))
44
+ end
45
+ private_class_method :junit_failure, :junit_message, :junit_name, :junit_output, :with_output
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../setup/project_config"
4
+ require_relative "../persistence/pipeline_recorder"
5
+ require_relative "trigger_params"
6
+ require_relative "trigger_command"
7
+ require_relative "slot_run"
8
+ require_relative "stale_pipeline_canceller"
9
+ require_relative "workspaces"
10
+
11
+ module FunCi
12
+ module Pipeline
13
+ class Trigger
14
+ NULL_SHA = Workspaces::NULL_SHA
15
+
16
+ def self.run_from_args(args, io: Io.new, recorder: Persistence::NullRecorder.new, pipeline_forker: nil)
17
+ TriggerCommand.new(io: io, recorder: recorder, pipeline_forker: pipeline_forker).run(args)
18
+ end
19
+
20
+ def initialize(project:, commit:, io: Io.new, seams: Seams.new)
21
+ @project = project
22
+ @commit = commit
23
+ @io = io
24
+ @seams = seams
25
+ end
26
+
27
+ def run
28
+ config = Setup::ProjectConfig.new(@project)
29
+ return handle_config_errors(config) if config.validate.any?
30
+ return unknown_commit unless known_commit?
31
+
32
+ start_run
33
+ run_in(workspace.acquire(@commit.sha))
34
+ end
35
+
36
+ # The background launcher swaps in a fresh recorder after forking, so
37
+ # callers release the connection through the trigger, not their own copy.
38
+ def close
39
+ recorder.close
40
+ end
41
+
42
+ private
43
+
44
+ def recorder = @seams.recorder
45
+ def known_commit? = @commit.sha == NULL_SHA || @seams.commit_validator.call(@commit.sha)
46
+
47
+ def workspace = @seams.workspace || Workspaces.for(@project, @commit.sha)
48
+ def branch = Persistence::Branch.new(project: @project, name: @commit.branch)
49
+
50
+ # The scripts come from the commit when it has them, so they match the
51
+ # code they test; a project that keeps .fun-ci/ out of git uses its own.
52
+ def config_for(slot)
53
+ committed = Setup::ProjectConfig.new(slot.path)
54
+ committed.folder_exists? ? committed : Setup::ProjectConfig.new(@project)
55
+ end
56
+
57
+ def handle_config_errors(config)
58
+ config.validate.each { |e| @io.stdout.puts "fun-ci: #{e}" }
59
+ unless config.folder_exists?
60
+ @io.stdout.puts "Create .fun-ci/lint.sh, build.sh, fast.sh, and slow.sh to set up this project."
61
+ @io.stdout.puts "Commit will proceed without CI."
62
+ end
63
+ 0
64
+ end
65
+
66
+ def unknown_commit
67
+ @io.stderr.puts "fun-ci: commit #{@commit.sha} not found in this repository."
68
+ 1
69
+ end
70
+
71
+ def start_run
72
+ recorder.report_trouble_to(@io.stdout)
73
+ cancel_stale_pipelines
74
+ recorder.create_run(commit_hash: @commit.sha, branch: @commit.branch, project_path: @project)
75
+ end
76
+
77
+ # After forking the slow suite the run holds a recorder of its own.
78
+ def run_in(slot)
79
+ slot_run = SlotRun.new(commit: @commit, io: @io, seams: @seams, slot: slot)
80
+ slot_run.run(config_for(slot)).tap { @seams = slot_run.seams }
81
+ end
82
+
83
+ def cancel_stale_pipelines
84
+ return unless recorder.db
85
+
86
+ recorder.tolerating do
87
+ StalePipelineCanceller.new(db: recorder.db, branch: branch, stdout: @io.stdout)
88
+ .cancel(new_commit_hash: @commit.sha)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "trigger_params"
4
+ require_relative "pipeline_forker"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # `fun-ci trigger [--background] <commit-hash> <branch>`. --no-validate is
9
+ # the 1.x name for --background, kept for one release.
10
+ class TriggerCommand
11
+ BACKGROUND = %w[--background --no-validate].freeze
12
+ DEPRECATED = "fun-ci: --no-validate is now --background; the old name goes in 2.1."
13
+
14
+ def initialize(io:, recorder:, pipeline_forker:, project: Dir.pwd)
15
+ @io = io
16
+ @recorder = recorder
17
+ @pipeline_forker = pipeline_forker || PipelineForker.method(:fork_pipeline)
18
+ @project = project
19
+ end
20
+
21
+ # Closes the database connection it was given on every path.
22
+ def run(args)
23
+ sha, branch = args.reject { |a| a.start_with?("--") }
24
+ return usage unless branch
25
+
26
+ @io.stderr.puts DEPRECATED if args.include?("--no-validate")
27
+ commit = Commit.new(sha: sha, branch: branch)
28
+ args.intersect?(BACKGROUND) ? fork_pipeline(commit) : run_pipeline(commit)
29
+ ensure
30
+ @recorder.close
31
+ end
32
+
33
+ private
34
+
35
+ # After forking the slow suite the trigger holds a recorder of its own.
36
+ def run_pipeline(commit)
37
+ trigger = Trigger.new(project: @project, commit: commit, io: @io, seams: Seams.new(recorder: @recorder))
38
+ trigger.run
39
+ ensure
40
+ trigger&.close
41
+ end
42
+
43
+ def usage
44
+ @io.stderr.puts "fun-ci: commit hash and branch name are required."
45
+ @io.stderr.puts "Usage: fun-ci trigger <commit-hash> <branch>"
46
+ 1
47
+ end
48
+
49
+ # The forker answers whether it started a run; one that did says how an
50
+ # agent gets its verdict (acceptance-tests.md, AT-9.14).
51
+ def fork_pipeline(commit)
52
+ db_path = @recorder.db_path
53
+ @recorder.close
54
+ started = @pipeline_forker.call(commit_hash: commit.sha, branch: commit.branch, db_path: db_path)
55
+ say_how_to_wait(commit.sha[0, 7]) if started
56
+ 0
57
+ end
58
+
59
+ def say_how_to_wait(sha) = @io.stdout.puts("fun-ci: testing #{sha}. Verdict: fun-ci wait #{sha} --need all")
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "../persistence/pipeline_recorder"
5
+ require_relative "command_executor"
6
+ require_relative "stage_dir"
7
+ require_relative "git_environment"
8
+ require_relative "../evidence/command_runner"
9
+ require_relative "../evidence/process_table"
10
+
11
+ module FunCi
12
+ module Pipeline
13
+ DEFAULT_BUDGETS = { "lint" => 30, "build" => 30, "fast" => 10, "slow" => 300 }.freeze
14
+
15
+ Commit = Data.define(:sha, :branch)
16
+
17
+ Io = Data.define(:stdout, :stderr) do
18
+ def initialize(stdout: $stdout, stderr: $stderr) = super
19
+ end
20
+
21
+ # The collaborators a pipeline run can have replaced. Each one left out
22
+ # gets the real thing. `environment` is the stages' environment as a hash,
23
+ # which masking and injected command runners see; `clock` answers seconds
24
+ # for the evidence budget; `extractor_runner` makes what runs a project's
25
+ # own extractors (`run:` entries), given dir:, env: and scratch:;
26
+ # `process_table` answers what ps lists, for a stage over budget.
27
+ Seams = Data.define(:command_runner, :time_budgets, :commit_validator, :recorder, :background_launcher, :workspace,
28
+ :stage_dir, :environment, :clock, :extractor_runner, :process_table)
29
+
30
+ # Reopened rather than given as a block to Data.define, so tools that read
31
+ # the source (mutineer) see these as Seams' methods.
32
+ class Seams
33
+ def self.defaults
34
+ { command_runner: nil, time_budgets: {}, recorder: Persistence::NullRecorder.new, background_launcher: nil,
35
+ workspace: nil, commit_validator: method(:commit_exists?), stage_dir: StageDir.method(:create),
36
+ environment: ENV.to_h, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
37
+ extractor_runner: Evidence::CommandRunner.method(:new),
38
+ process_table: Evidence::ProcessTable.method(:now) }
39
+ end
40
+
41
+ def self.commit_exists?(sha) = Open3.capture2e(GitEnvironment::CLEAN, "git", "cat-file", "-t", sha).last.success?
42
+
43
+ def initialize(**given) = super(**self.class.defaults.merge(given))
44
+ def budgets = DEFAULT_BUDGETS.merge(time_budgets)
45
+ def executor(dir) = CommandExecutor.new(command_runner, dir, environment)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../setup/project_config"
4
+ require_relative "worktree_pool"
5
+ require_relative "in_place"
6
+
7
+ module FunCi
8
+ module Pipeline
9
+ # Where a pipeline for a commit runs: a slot from the project's worktree
10
+ # pool, sized by .fun-ci/config, or, for a root commit's null SHA, which
11
+ # no worktree can check out, the project directory itself.
12
+ module Workspaces
13
+ NULL_SHA = ("0" * 40).freeze
14
+
15
+ def self.for(project, sha)
16
+ return InPlace.new(project) if sha == NULL_SHA
17
+
18
+ WorktreePool.new(Worktrees.new(project), size: Setup::ProjectConfig.new(project).worktree_slots)
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "slot"
5
+ require_relative "worktrees"
6
+
7
+ module FunCi
8
+ module Pipeline
9
+ # Hands each pipeline a worktree slot of its own under
10
+ # <git-common-dir>/fun-ci/worktrees, checked out at its commit, waiting
11
+ # while every slot is taken.
12
+ class WorktreePool
13
+ DEFAULT_SIZE = 2
14
+
15
+ attr_reader :size
16
+
17
+ def initialize(worktrees, size: DEFAULT_SIZE, waiter: -> { sleep 0.25 })
18
+ @worktrees = worktrees
19
+ @size = size
20
+ @waiter = waiter
21
+ end
22
+
23
+ def acquire(sha)
24
+ slot = wait_for_free_slot
25
+ @worktrees.check_out(slot.path, sha)
26
+ slot
27
+ rescue StandardError
28
+ slot&.release
29
+ raise
30
+ end
31
+
32
+ private
33
+
34
+ def wait_for_free_slot
35
+ loop do
36
+ slot = free_slot
37
+ return slot if slot
38
+
39
+ @waiter.call
40
+ end
41
+ end
42
+
43
+ def free_slot
44
+ root = @worktrees.root
45
+ FileUtils.mkdir_p(root)
46
+ @size.times.lazy.filter_map { |index| try_lock(File.join(root, "slot-#{index}")) }.first
47
+ end
48
+
49
+ # The lock stays open for as long as the slot is held, so no block.
50
+ def try_lock(path)
51
+ lock = File.new("#{path}.lock", File::RDWR | File::CREAT, 0o644)
52
+ return Slot.new(path, stamped(lock), lock.path) if lock.flock(File::LOCK_EX | File::LOCK_NB)
53
+
54
+ lock.close
55
+ nil
56
+ end
57
+
58
+ def stamped(lock)
59
+ lock.truncate(0)
60
+ lock.write(Process.pid.to_s)
61
+ lock.flush
62
+ lock
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "worktrees"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # Removes every slot of a project's worktree pool, and git's entries for
9
+ # them, when no run holds one. It takes each slot's lock first, so a run
10
+ # starting meanwhile waits until the slot is gone and then checks it out
11
+ # afresh.
12
+ class WorktreePrune
13
+ class Busy < StandardError; end
14
+
15
+ def initialize(worktrees)
16
+ @worktrees = worktrees
17
+ end
18
+
19
+ # How many slots it removed. Raises Busy, removing nothing, while a run
20
+ # holds a slot.
21
+ def run
22
+ locks = take_locks
23
+ locks.each { |lock| FileUtils.rm_rf(lock.path.delete_suffix(".lock")) }
24
+ @worktrees.prune
25
+ locks.size
26
+ ensure
27
+ locks&.each(&:close)
28
+ end
29
+
30
+ private
31
+
32
+ # Every slot's lock, or none: on the first slot a run holds, it lets go
33
+ # of the locks it took before raising Busy.
34
+ def take_locks
35
+ slot_paths.each_with_object([]) do |path, locks|
36
+ locks << File.new("#{path}.lock", File::RDWR | File::CREAT, 0o644)
37
+ next if locks.last.flock(File::LOCK_EX | File::LOCK_NB)
38
+
39
+ locks.each(&:close)
40
+ raise Busy, "a pipeline is running in #{path}"
41
+ end
42
+ end
43
+
44
+ def slot_paths
45
+ Dir.glob(File.join(@worktrees.root, "slot-*")).map { |path| path.delete_suffix(".lock") }.uniq
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require_relative "git_environment"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # The git commands a worktree slot needs, run against the project's repository.
9
+ class Worktrees
10
+ class GitError < StandardError; end
11
+
12
+ def initialize(project_root)
13
+ @project_root = project_root
14
+ end
15
+
16
+ def root
17
+ File.join(File.expand_path(git(@project_root, "rev-parse", "--git-common-dir").strip, @project_root),
18
+ "fun-ci", "worktrees")
19
+ end
20
+
21
+ # Leaves +path+ as a clean detached checkout of +sha+. Ignored files stay,
22
+ # so caches such as vendor/bundle survive between runs in the same slot.
23
+ def check_out(path, sha)
24
+ return add(path, sha) unless File.exist?(File.join(path, ".git"))
25
+
26
+ git(path, "checkout", "--detach", "--force", sha)
27
+ git(path, "clean", "-fd")
28
+ end
29
+
30
+ # Has git forget the worktrees whose directories are gone.
31
+ def prune = git(@project_root, "worktree", "prune")
32
+
33
+ private
34
+
35
+ def add(path, sha) = git(@project_root, "worktree", "add", "--detach", "--force", path, sha)
36
+
37
+ def git(dir, *args)
38
+ output, status = Open3.capture2e(GitEnvironment::CLEAN, "git", *args, chdir: dir)
39
+ raise GitError, "git #{args.join(" ")}: #{output}" unless status.success?
40
+
41
+ output
42
+ end
43
+ end
44
+ end
45
+ end