fun_ci 2.0.0-x86_64-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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # The first `size` bytes of a stream, kept in an IO opened when first written.
6
+ class HeadPart
7
+ def initialize(open, size)
8
+ @open = open
9
+ @size = size
10
+ @closed = false
11
+ end
12
+
13
+ # Writes what fits, and answers the rest.
14
+ def take(bytes)
15
+ room = [@size - written, 0].max
16
+ io.write(bytes.byteslice(0, room)) if room.positive? && !bytes.empty?
17
+ bytes.byteslice(room..) || "".b
18
+ end
19
+
20
+ def read
21
+ return +"".b unless @io
22
+
23
+ @io.rewind
24
+ @io.read
25
+ end
26
+
27
+ def close
28
+ @io&.close
29
+ @closed = true
30
+ end
31
+
32
+ def closed? = @closed
33
+
34
+ private
35
+
36
+ def written = @io ? @io.size : 0
37
+ def io = @io ||= @open.call("head")
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "slot"
4
+
5
+ module FunCi
6
+ module Pipeline
7
+ # A workspace that is the project directory itself, with nothing to lock.
8
+ # For a root commit's null SHA, which no worktree can check out.
9
+ InPlace = Data.define(:path)
10
+
11
+ class InPlace
12
+ Unlocked = Struct.new(:closed?) { def close = nil }
13
+
14
+ def acquire(_sha) = Slot.new(path, Unlocked.new(false))
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "stringio"
4
+ require_relative "head_part"
5
+ require_relative "tail_segments"
6
+
7
+ module FunCi
8
+ module Pipeline
9
+ # What a stage printed, kept as its first `head` bytes and its last
10
+ # `tail` bytes, written as it comes so no more than a chunk is held in
11
+ # memory. Both cuts fall on line ends, unless a line is longer than the
12
+ # part it is in, and a marker line says how many bytes were dropped.
13
+ class OutputWindow
14
+ Sizes = Data.define(:head, :tail)
15
+ REAL = Sizes.new(head: 1_048_576, tail: 7_340_032)
16
+
17
+ def self.in_memory(sizes = REAL) = new(->(_name) { StringIO.new(+"".b) }, sizes)
18
+
19
+ def self.in(dir, sizes = REAL)
20
+ new(->(name) { File.open(File.join(dir, name), "w+b") }, sizes)
21
+ end
22
+
23
+ # `open` answers a new, empty read-write IO for a name.
24
+ def initialize(open, sizes)
25
+ @sizes = sizes
26
+ @written = 0
27
+ @head = HeadPart.new(open, sizes.head)
28
+ @tail = TailSegments.new(open, sizes.tail + 1)
29
+ end
30
+
31
+ def <<(chunk)
32
+ bytes = chunk.b
33
+ rest = @head.take(bytes)
34
+ @tail << rest unless rest.empty?
35
+ @written += bytes.bytesize
36
+ self
37
+ end
38
+
39
+ def text
40
+ head = @head.read
41
+ return head + @tail.read if @written <= @sizes.head + @sizes.tail
42
+
43
+ cut(head, @tail.read)
44
+ end
45
+
46
+ def close
47
+ @head.close
48
+ @tail.close
49
+ end
50
+
51
+ def closed? = @head.closed?
52
+
53
+ private
54
+
55
+ def cut(head, tail)
56
+ kept_head = head.rindex("\n") ? head[0..head.rindex("\n")] : head
57
+ kept_tail = line_start(tail)
58
+ dropped = @written - kept_head.bytesize - kept_tail.bytesize
59
+ "#{kept_head}[fun-ci: #{dropped} bytes dropped here]\n".b + kept_tail
60
+ end
61
+
62
+ # The tail holds one byte more than it keeps, to tell whether it starts
63
+ # on a line start. With no line start in it but after its last byte, it
64
+ # is the end of one long line, and is kept from mid-line.
65
+ def line_start(tail)
66
+ first_end = tail.index("\n")
67
+ return tail.byteslice(1..) if first_end.nil? || first_end.zero? || first_end == tail.bytesize - 1
68
+
69
+ tail.byteslice((first_end + 1)..)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/database"
4
+ require_relative "../persistence/pipeline_recorder"
5
+ require_relative "trigger_params"
6
+ require_relative "../setup/project_config"
7
+
8
+ module FunCi
9
+ module Pipeline
10
+ class PipelineForker
11
+ # Answers whether it started a run: none in a project not set up for fun-ci.
12
+ def self.fork_pipeline(commit_hash:, branch:, db_path:)
13
+ return false unless Setup::ProjectConfig.new(Dir.pwd).validate.empty?
14
+
15
+ pid = fork do
16
+ run_in_child(commit_hash: commit_hash, branch: branch, db_path: db_path)
17
+ end
18
+ Process.detach(pid)
19
+ end
20
+
21
+ # Trigger requires this file, through TriggerCommand, so it is loaded
22
+ # here, where it is used, rather than above.
23
+ def self.run_in_child(commit_hash:, branch:, db_path:)
24
+ require_relative "trigger"
25
+ recorder = Persistence::DbRecorder.new(Persistence::Database.connection(db_path))
26
+ trigger = trigger(commit_hash, branch, recorder)
27
+ trigger.run
28
+ trigger.close
29
+ end
30
+
31
+ # The slow suite forks from here as it does in the foreground, so the
32
+ # fast suite, whose verdict a push waits for, runs beside it.
33
+ def self.trigger(commit_hash, branch, recorder)
34
+ Trigger.new(project: Dir.pwd, commit: Commit.new(sha: commit_hash, branch: branch),
35
+ io: Io.new(stdout: File.open(File::NULL, "w")), seams: Seams.new(recorder: recorder))
36
+ end
37
+ private_class_method :trigger
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "gate"
4
+ require_relative "git_environment"
5
+ require_relative "output_window"
6
+
7
+ module FunCi
8
+ module Pipeline
9
+ module ProcessRunner
10
+ # The command waits on fd 9 until the caller has been told its pid, so a
11
+ # cancel that reads the recorded pid can never miss a stage that has
12
+ # started; exec keeps the pid and the process group. If the caller dies
13
+ # first, the pipe closes and the command never runs.
14
+ GATED = "read _ <&9 || exit 125; exec 9<&-; exec %s"
15
+
16
+ # Yields the pid of the process it starts, which leads a process group
17
+ # of its own, so the caller can stop the command and all it spawned.
18
+ # A command over budget answers what it printed before the kill, which
19
+ # is the only clue to what it was stuck on (acceptance-tests.md, AT-9.5).
20
+ # `timer` answers whether the reading finished within the budget.
21
+ BUDGET = ->(reading, budget) { reading.join(budget) }
22
+ # How long a killed command's last output may take to drain.
23
+ DRAIN_SECONDS = 1
24
+
25
+ # Where the command runs, what it finds in its environment besides
26
+ # fun-ci's own, the window what it prints is written to, and what to do
27
+ # with its pid when it runs over budget, before it is killed.
28
+ Launch = Data.define(:chdir, :env, :output, :before_kill)
29
+
30
+ class Launch
31
+ def initialize(**given)
32
+ super(chdir: Dir.pwd, env: {}, output: OutputWindow.in_memory, before_kill: nil, **given)
33
+ end
34
+ end
35
+
36
+ def run_process_with_timeout(cmd, budget, launch: Launch.new, timer: BUDGET, &)
37
+ reader, writer = IO.pipe
38
+ pid = start(cmd, writer, launch, &)
39
+ printed = launch.output
40
+ reading = Thread.new { read_until_closed(reader, printed) }
41
+ timer.call(reading, budget) ? process_finished(pid, text(reading.value)) : over_budget(pid, reading, launch)
42
+ ensure
43
+ ignoring_errors { reader&.close }
44
+ end
45
+
46
+ private
47
+
48
+ def start(cmd, writer, launch)
49
+ gate = Gate.create
50
+ pid = spawn_gated(cmd, writer, gate, launch)
51
+ [writer, gate.child_end].each(&:close)
52
+ yield pid if block_given?
53
+ gate.open
54
+ pid
55
+ end
56
+
57
+ def spawn_gated(cmd, writer, gate, launch)
58
+ Process.spawn(GitEnvironment::CLEAN.merge(launch.env), format(GATED, cmd),
59
+ out: writer, err: writer, 9 => gate.child_end, pgroup: true, chdir: launch.chdir)
60
+ end
61
+
62
+ # The reader may be closed while this thread still reads, once a killed
63
+ # command's output has drained or stopped coming.
64
+ def read_until_closed(reader, printed)
65
+ loop { printed << reader.readpartial(65_536) }
66
+ rescue IOError
67
+ printed
68
+ end
69
+
70
+ # What looks at the stage before the kill does so while its output is still read.
71
+ def over_budget(pid, reading, launch)
72
+ launch.before_kill&.call(pid)
73
+ kill_process_group(pid)
74
+ [drained(reading, launch.output), nil, true]
75
+ end
76
+
77
+ def drained(reading, printed)
78
+ reading.join(DRAIN_SECONDS)
79
+ text(printed)
80
+ end
81
+
82
+ def text(window) = window.text.force_encoding(Encoding.default_external)
83
+
84
+ def process_finished(pid, output)
85
+ _, status = Process.waitpid2(pid)
86
+ [output, status, false]
87
+ end
88
+
89
+ def kill_process_group(pid)
90
+ %w[TERM KILL].each { |signal| ignoring_errors { Process.kill(signal, -pid) } }
91
+ ignoring_errors { Process.waitpid(pid) }
92
+ end
93
+
94
+ def ignoring_errors
95
+ yield
96
+ rescue StandardError
97
+ # A process that already exited or was already reaped needs nothing.
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ class ProgressReporter
6
+ def initialize(stdout:)
7
+ @stdout = stdout
8
+ end
9
+
10
+ def phase_one_result(results)
11
+ markers = results.map { |stage, passed| "#{stage} #{result_marker(passed)}" }
12
+ summary = results.values.all? ? "phase 1 passed" : "phase 1 failed"
13
+ @stdout.puts "fun-ci: #{markers.join(" ")} (#{summary})"
14
+ end
15
+
16
+ def fast_result(passed)
17
+ @stdout.puts "fun-ci: fast #{result_marker(passed)}"
18
+ end
19
+
20
+ def slow_launched
21
+ @stdout.puts "fun-ci: slow (running in background)"
22
+ end
23
+
24
+ private
25
+
26
+ def result_marker(passed)
27
+ passed ? "ok" : "FAIL"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/active_runs"
4
+ require_relative "../persistence/run_status"
5
+ require_relative "../persistence/write_trouble"
6
+ require_relative "slot_lock"
7
+
8
+ module FunCi
9
+ module Pipeline
10
+ # Stops every process of a run. The run's own processes go first, so none
11
+ # of them records the run as failed once its stages die; then each stage's
12
+ # process group. A run whose slot nobody holds any more has died already,
13
+ # and the pids it recorded may belong to other processes now, so it is
14
+ # left alone; so is this process.
15
+ class RunCanceller
16
+ def initialize(killer: Process.method(:kill), slot_held: SlotLock.method(:held?))
17
+ @killer = killer
18
+ @slot_held = slot_held
19
+ end
20
+
21
+ # Stops the run and records it, and its unfinished stages, cancelled.
22
+ def cancel(db, run)
23
+ stop(run) && stop_late_stages(db, run)
24
+ Persistence::ActiveRuns.cancelled(db, run)
25
+ end
26
+
27
+ # Records failed each slow stage whose forked process is gone without
28
+ # having recorded its result (acceptance-tests.md, AT-8.3), and settles
29
+ # its run.
30
+ def record_dead(db)
31
+ Persistence::ActiveRuns.slow_suites(db).reject { |_, _, pid| exists?(pid) }.each do |job_id, run_id, _|
32
+ Persistence::ActiveRuns.slow_suite_died(db, job_id)
33
+ Persistence::RunStatus.settle(db, run_id)
34
+ end
35
+ rescue SQLite3::BusyException, *Persistence::WriteTrouble::UNWRITABLE
36
+ # The next poll looks again.
37
+ end
38
+
39
+ def stop(run)
40
+ return unless alive?(run)
41
+
42
+ (run.processes - [Process.pid] + run.stage_groups.map(&:-@)).each { |pid| kill(pid) }
43
+ end
44
+
45
+ private
46
+
47
+ # A stage its process started after `run` was read. A run's process
48
+ # records a stage's group before it lets the stage start, so once those
49
+ # processes are dead, looking again finds every stage they started.
50
+ def stop_late_stages(db, run)
51
+ Persistence::ActiveRuns.with_id(db, run.id).each do |now|
52
+ (now.stage_groups - run.stage_groups).each { |group| kill(-group) }
53
+ end
54
+ end
55
+
56
+ def alive?(run) = run.slot_lock.nil? || @slot_held.call(run.slot_lock)
57
+
58
+ # Whether a process with this pid exists; one this user may not signal does.
59
+ def exists?(pid)
60
+ @killer.call(0, pid)
61
+ true
62
+ rescue Errno::ESRCH
63
+ false
64
+ rescue Errno::EPERM
65
+ true
66
+ end
67
+
68
+ def kill(pid)
69
+ @killer.call("KILL", pid)
70
+ rescue Errno::ESRCH
71
+ # Gone already.
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # A worktree a pipeline has to itself, held through an flock on its lock
6
+ # file. The fast suite and the slow suite each hold it; it frees when the
7
+ # last of them lets go. A process that dies lets go with it.
8
+ class Slot
9
+ attr_reader :path, :lock_file
10
+
11
+ # +lock_file+ names the lock on disk; nil for a slot with nothing to lock.
12
+ def initialize(path, lock, lock_file = nil)
13
+ @path = path
14
+ @lock = lock
15
+ @lock_file = lock_file
16
+ @holders = 1
17
+ end
18
+
19
+ def share
20
+ @holders += 1
21
+ self
22
+ end
23
+
24
+ def release
25
+ @holders -= 1
26
+ @lock.close if @holders.zero? && !@lock.closed?
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # Whether some process holds a slot's lock, which is whether the run that
6
+ # took the slot is still alive: an flock dies with its holders.
7
+ module SlotLock
8
+ def self.held?(path)
9
+ File.exist?(path) && File.open(path) { |lock| !lock.flock(File::LOCK_EX | File::LOCK_NB) }
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "trigger_params"
4
+ require_relative "background_fork"
5
+ require_relative "stage_runner"
6
+ require_relative "stage_execution"
7
+ require_relative "progress_reporter"
8
+
9
+ module FunCi
10
+ module Pipeline
11
+ # The stages of one pipeline, run in the slot it was given: lint and build
12
+ # side by side, then the slow suite in the background while the fast suite
13
+ # runs. The slot frees when the last stage holding it finishes.
14
+ class SlotRun
15
+ attr_reader :seams
16
+
17
+ def initialize(commit:, io:, seams:, slot:)
18
+ @commit = commit
19
+ @io = io
20
+ @seams = seams
21
+ @slot = slot
22
+ end
23
+
24
+ def run(config)
25
+ recorder.slot_taken(@slot.lock_file) if @slot.lock_file
26
+ return released(1) unless phase_one_passed?(config)
27
+
28
+ launch_slow_suite(config)
29
+ progress.slow_launched
30
+ released(fast_passed?(config) ? 0 : 1)
31
+ end
32
+
33
+ private
34
+
35
+ def recorder = @seams.recorder
36
+ def progress = ProgressReporter.new(stdout: @io.stdout)
37
+ def stage_runner = StageRunner.new(commit: @commit, stdout: @io.stdout, seams: @seams, dir: @slot.path)
38
+
39
+ # This process's part is over; the slow suite may still be running.
40
+ def released(exit_code)
41
+ @slot.release
42
+ recorder.foreground_done
43
+ exit_code
44
+ end
45
+
46
+ def phase_one_passed?(config)
47
+ results = %w[lint build].to_h { |stage| [stage, Thread.new { stage_runner.passes?(config, stage) }] }
48
+ .transform_values(&:value)
49
+ progress.phase_one_result(results)
50
+ results.values.all?
51
+ end
52
+
53
+ def fast_passed?(config)
54
+ passed = stage_runner.passes?(config, "fast")
55
+ progress.fast_result(passed)
56
+ passed
57
+ end
58
+
59
+ def launch_slow_suite(config)
60
+ job_id = recorder.start_stage("slow", budget: @seams.budgets["slow"])
61
+ launch(db_path: recorder.db_path, pipeline_run_id: recorder.pipeline_run_id, job_id: job_id,
62
+ executor: slow_suite(config, @slot.share))
63
+ end
64
+
65
+ # Called with the recorder and job to record the slow suite with, in
66
+ # whichever process runs it; the slot is let go once it is recorded.
67
+ def slow_suite(config, slot)
68
+ execution = StageExecution.new(seams: @seams, dir: slot.path, commit: @commit)
69
+ command = config.stage_command("slow", @commit.sha)
70
+ ->(recorder, job_id) { holding(slot) { execution.run("slow", command, recorder, job_id) } }
71
+ end
72
+
73
+ def holding(slot)
74
+ yield
75
+ ensure
76
+ slot.release
77
+ end
78
+
79
+ def launch(**)
80
+ return @seams.background_launcher.call(**) if @seams.background_launcher
81
+
82
+ @seams = @seams.with(recorder: BackgroundFork.new(recorder, @slot).launch(**))
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tmpdir"
4
+ require "fileutils"
5
+ require_relative "output_window"
6
+ require_relative "../persistence/state_dir"
7
+
8
+ module FunCi
9
+ module Pipeline
10
+ # A stage's own directory, named for the process that made it, in the
11
+ # state directory: it holds the window its output is written to, unmasked,
12
+ # until the stage is recorded (AT-10.6), and what a project's extractor
13
+ # reads and writes.
14
+ class StageDir
15
+ def self.default_root = File.join(Persistence::StateDir.path(ENV), "stages")
16
+
17
+ # Removes the directories of processes that died before removing their own.
18
+ def self.create(root = default_root)
19
+ FileUtils.mkdir_p(root)
20
+ Dir.children(root).reject { |name| alive?(name.to_i) }.each { |name| FileUtils.rm_rf(File.join(root, name)) }
21
+ new(Dir.mktmpdir("#{Process.pid}-", root))
22
+ end
23
+
24
+ def self.alive?(pid)
25
+ Process.kill(0, pid)
26
+ true
27
+ rescue Errno::ESRCH
28
+ false
29
+ rescue Errno::EPERM
30
+ true
31
+ end
32
+ private_class_method :alive?
33
+
34
+ def initialize(path)
35
+ @path = path
36
+ end
37
+
38
+ # The output as the window kept it, written for a project's extractor to read.
39
+ def output_file(text)
40
+ File.join(@path, "output.log").tap { |file| File.binwrite(file, text) }
41
+ end
42
+
43
+ # Where a project's extractor writes what it needs to (its stdin and stderr).
44
+ def scratch = @path
45
+ def window(sizes = OutputWindow::REAL) = OutputWindow.in(@path, sizes)
46
+
47
+ def remove = FileUtils.rm_rf(@path)
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../evidence/outcome"
4
+ require_relative "../evidence/document"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # Records a stage that has finished, whichever process ran it: a failed
9
+ # stage's evidence first, so whoever sees the outcome can read why, then
10
+ # how it exited, then the outcome. Called while the stage still holds its
11
+ # slot and stage directory, which the collector reads.
12
+ class StageEnd
13
+ # What running a stage answered: its output, its Process::Status (nil
14
+ # when it was killed over budget), and for an overrun, what was found
15
+ # before the kill.
16
+ Finished = Data.define(:output, :status, :timed_out, :overrun)
17
+
18
+ class Finished
19
+ def initialize(overrun: nil, **) = super
20
+ end
21
+
22
+ # A failed stage's state in the words `status --json` uses.
23
+ STATES = { "failed" => "failed", "timed_out" => "over_budget" }.freeze
24
+
25
+ def initialize(recorder, job_id, collector)
26
+ @recorder = recorder
27
+ @job_id = job_id
28
+ @collector = collector
29
+ end
30
+
31
+ # Answers the outcome recorded: completed, failed or timed_out.
32
+ def record(finished)
33
+ result = outcome(finished)
34
+ keep_evidence(finished) unless result == "completed"
35
+ keep_exit(finished.status) if finished.status
36
+ @recorder.end_stage(@job_id, result)
37
+ result
38
+ end
39
+
40
+ private
41
+
42
+ def outcome(finished)
43
+ return "timed_out" if finished.timed_out
44
+
45
+ finished.status.success? ? "completed" : "failed"
46
+ end
47
+
48
+ # Whatever goes wrong collecting the evidence is kept as a problem, and
49
+ # the outcome is recorded all the same.
50
+ def keep_evidence(finished)
51
+ @recorder.keep_evidence(@job_id, @collector.collect(finished.output, ending(finished)))
52
+ @recorder.keep_raw(@job_id, @collector.masked(finished.output))
53
+ rescue StandardError => e
54
+ @recorder.keep_evidence(@job_id, Evidence::Document.broken("#{e.class}: #{e.message}"))
55
+ end
56
+
57
+ def ending(finished)
58
+ status = finished.status
59
+ Evidence::Outcome.new(state: STATES.fetch(outcome(finished)), exit_status: status&.exitstatus,
60
+ signal: status&.termsig && Signal.signame(status.termsig),
61
+ alongside: @recorder.alongside(@job_id), overrun: finished.overrun)
62
+ end
63
+
64
+ def keep_exit(status)
65
+ signal = status.termsig && Signal.signame(status.termsig)
66
+ @recorder.keep_exit(@job_id, status.exitstatus, signal)
67
+ end
68
+ end
69
+ end
70
+ end