fun_ci 1.2.1 → 2.0.1

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 +236 -0
  3. data/README.md +182 -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 +32 -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 +71 -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 +63 -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 +61 -14
  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 +56 -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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+ require_relative "process_runner"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # Runs a stage command within its budget and answers [output, status, timed_out].
9
+ # An injected command runner replaces the real process and signals a blown
10
+ # budget by raising Timeout::Error. A real process inherits fun-ci's own
11
+ # environment; an injected runner sees `environment` in its place.
12
+ class CommandExecutor
13
+ include ProcessRunner
14
+
15
+ def initialize(command_runner, dir = Dir.pwd, environment = {})
16
+ @command_runner = command_runner
17
+ @dir = dir
18
+ @environment = environment
19
+ end
20
+
21
+ # Yields the pid of the process the command runs in, when there is one.
22
+ # The launch's `env` is added to the command's environment; an injected
23
+ # runner that takes a second argument is given the whole of it. What
24
+ # the command prints is written to the launch's `output`, and answered
25
+ # as the window keeps it; its `before_kill` is called with the pid of
26
+ # a command over budget, while it still runs.
27
+ def call(cmd, budget, launch = Launch.new, &)
28
+ launch = launch.with(chdir: @dir)
29
+ return run_process_with_timeout(cmd, budget, launch: launch, &) unless @command_runner
30
+
31
+ injected(cmd, launch, &)
32
+ end
33
+
34
+ private
35
+
36
+ # An injected runner signals a blown budget by raising Timeout::Error,
37
+ # which is its moment before the kill.
38
+ def injected(cmd, launch)
39
+ started = nil
40
+ printed, status = run_injected(cmd, launch.env) { |pid| (started = pid) && (yield pid if block_given?) }
41
+ [text(launch.output << printed), status, false]
42
+ rescue Timeout::Error
43
+ launch.before_kill&.call(started)
44
+ ["", nil, true]
45
+ end
46
+
47
+ # A lambda's own, or those of an object's #call.
48
+ def parameters
49
+ @command_runner.respond_to?(:parameters) ? @command_runner.parameters : @command_runner.method(:call).parameters
50
+ end
51
+
52
+ def run_injected(cmd, env, &)
53
+ takes_env = parameters.count { |kind, _| %i[req opt].include?(kind) } > 1
54
+ takes_env ? @command_runner.call(cmd, @environment.merge(env), &) : @command_runner.call(cmd, &)
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/nonblock"
4
+
5
+ module FunCi
6
+ module Pipeline
7
+ # A pipe a child process waits on until the caller opens it. Ruby makes
8
+ # pipes non-blocking, and a child shares that setting with the parent, so
9
+ # the child's end is made blocking: otherwise a child reading before the
10
+ # caller has opened the gate fails instead of waiting.
11
+ class Gate
12
+ attr_reader :child_end
13
+
14
+ def self.create
15
+ child_end, opener = IO.pipe
16
+ child_end.nonblock = false
17
+ new(child_end, opener)
18
+ end
19
+
20
+ def initialize(child_end, opener)
21
+ @child_end = child_end
22
+ @opener = opener
23
+ end
24
+
25
+ # Lets the child through. A child that has gone already needs nothing.
26
+ def open
27
+ @opener.puts
28
+ rescue Errno::EPIPE
29
+ nil
30
+ ensure
31
+ @opener.close
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Pipeline
5
+ # Git runs hooks with variables naming the repository and index that ran
6
+ # them. Passed on, they would point fun-ci's git commands and the stage
7
+ # scripts in a worktree at the checkout's index instead of their own.
8
+ module GitEnvironment
9
+ REPOSITORY = %w[GIT_DIR GIT_WORK_TREE GIT_INDEX_FILE GIT_PREFIX GIT_COMMON_DIR GIT_OBJECT_DIRECTORY
10
+ GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_NAMESPACE].freeze
11
+
12
+ # An environment for spawn that unsets each of them.
13
+ CLEAN = REPOSITORY.to_h { |name| [name, nil] }.freeze
14
+ end
15
+ end
16
+ end
@@ -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
@@ -2,37 +2,39 @@
2
2
 
3
3
  require_relative "../persistence/database"
4
4
  require_relative "../persistence/pipeline_recorder"
5
- require_relative "background_wrapper"
5
+ require_relative "trigger_params"
6
+ require_relative "../setup/project_config"
6
7
 
7
8
  module FunCi
8
9
  module Pipeline
9
10
  class PipelineForker
11
+ # Answers whether it started a run: none in a project not set up for fun-ci.
10
12
  def self.fork_pipeline(commit_hash:, branch:, db_path:)
13
+ return false unless Setup::ProjectConfig.new(Dir.pwd).validate.empty?
14
+
11
15
  pid = fork do
12
16
  run_in_child(commit_hash: commit_hash, branch: branch, db_path: db_path)
13
17
  end
14
18
  Process.detach(pid)
15
19
  end
16
20
 
21
+ # Trigger requires this file, through TriggerCommand, so it is loaded
22
+ # here, where it is used, rather than above.
17
23
  def self.run_in_child(commit_hash:, branch:, db_path:)
18
- db = Persistence::Database.connection(db_path)
19
- recorder = Persistence::DbRecorder.new(db)
20
- Trigger.new(
21
- project_root: Dir.pwd,
22
- commit_hash: commit_hash,
23
- branch: branch,
24
- stdout: File.open(File::NULL, "w"),
25
- recorder: recorder,
26
- background_launcher: method(:sync_launcher)
27
- ).run
28
- recorder.close
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
29
  end
30
30
 
31
- def self.sync_launcher(db_path:, pipeline_run_id:, job_id:, executor:)
32
- recorder = Persistence::DbRecorder.for_background(db_path, pipeline_run_id)
33
- BackgroundWrapper.new(recorder: recorder, job_id: job_id, executor: executor).run
34
- recorder.close
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))
35
36
  end
37
+ private_class_method :trigger
36
38
  end
37
39
  end
38
40
  end
@@ -1,27 +1,100 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "gate"
4
+ require_relative "git_environment"
5
+ require_relative "output_window"
6
+
3
7
  module FunCi
4
8
  module Pipeline
5
9
  module ProcessRunner
6
- def run_process_with_timeout(cmd, budget)
7
- r, w = IO.pipe
8
- pid = Process.spawn(cmd, out: w, err: w, pgroup: true)
9
- w.close
10
-
11
- reader = Thread.new { r.read }
12
-
13
- if reader.join(budget)
14
- output = reader.value
15
- _, status = Process.waitpid2(pid)
16
- [output, status, false]
17
- else
18
- Process.kill("TERM", -pid) rescue nil
19
- Process.kill("KILL", -pid) rescue nil
20
- Process.waitpid(pid) rescue nil
21
- ["", nil, true]
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)
22
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)
23
42
  ensure
24
- r&.close rescue nil
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.
25
98
  end
26
99
  end
27
100
  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