fun_ci 1.2.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +218 -0
  3. data/README.md +180 -59
  4. data/lib/fun_ci/agent/age.rb +15 -0
  5. data/lib/fun_ci/agent/command_support.rb +27 -0
  6. data/lib/fun_ci/agent/commands.rb +27 -0
  7. data/lib/fun_ci/agent/context.rb +10 -0
  8. data/lib/fun_ci/agent/digest.rb +49 -0
  9. data/lib/fun_ci/agent/events.rb +47 -0
  10. data/lib/fun_ci/agent/events_command.rb +61 -0
  11. data/lib/fun_ci/agent/evidence_text.rb +46 -0
  12. data/lib/fun_ci/agent/exit_code.rb +11 -0
  13. data/lib/fun_ci/agent/git.rb +31 -0
  14. data/lib/fun_ci/agent/live_pipeline.rb +35 -0
  15. data/lib/fun_ci/agent/options.rb +75 -0
  16. data/lib/fun_ci/agent/output.rb +81 -0
  17. data/lib/fun_ci/agent/report_stage.rb +73 -0
  18. data/lib/fun_ci/agent/reports.rb +47 -0
  19. data/lib/fun_ci/agent/run_report.rb +30 -0
  20. data/lib/fun_ci/agent/runs_command.rb +34 -0
  21. data/lib/fun_ci/agent/runs_text.rb +27 -0
  22. data/lib/fun_ci/agent/snapshots.rb +39 -0
  23. data/lib/fun_ci/agent/stage_summary.rb +30 -0
  24. data/lib/fun_ci/agent/status_command.rb +36 -0
  25. data/lib/fun_ci/agent/status_json.rb +27 -0
  26. data/lib/fun_ci/agent/status_text.rb +37 -0
  27. data/lib/fun_ci/agent/system_clock.rb +11 -0
  28. data/lib/fun_ci/agent/verdict.rb +32 -0
  29. data/lib/fun_ci/agent/wait_command.rb +71 -0
  30. data/lib/fun_ci/agent/waiting.rb +32 -0
  31. data/lib/fun_ci/agent/why_command.rb +50 -0
  32. data/lib/fun_ci/agent/why_json.rb +35 -0
  33. data/lib/fun_ci/agent/why_text.rb +43 -0
  34. data/lib/fun_ci/cli.rb +72 -94
  35. data/lib/fun_ci/cli_help.rb +65 -0
  36. data/lib/fun_ci/console/board_data.rb +66 -0
  37. data/lib/fun_ci/console/board_heartbeat.rb +40 -0
  38. data/lib/fun_ci/console/console_events.rb +20 -0
  39. data/lib/fun_ci/console/console_log.rb +26 -0
  40. data/lib/fun_ci/console/console_loop.rb +41 -0
  41. data/lib/fun_ci/console/console_session.rb +82 -0
  42. data/lib/fun_ci/console/console_state.rb +42 -0
  43. data/lib/fun_ci/console/key_handler.rb +73 -0
  44. data/lib/fun_ci/console/launcher.rb +43 -0
  45. data/lib/fun_ci/console/milestone_events.rb +38 -0
  46. data/lib/fun_ci/console/milestones.rb +25 -0
  47. data/lib/fun_ci/console/renderer_lookup.rb +51 -0
  48. data/lib/fun_ci/console/renderer_process.rb +60 -0
  49. data/lib/fun_ci/console/run_message.rb +33 -0
  50. data/lib/fun_ci/console/stage_change_detector.rb +32 -0
  51. data/lib/fun_ci/console/stage_events.rb +31 -0
  52. data/lib/fun_ci/console/stop_report.rb +39 -0
  53. data/lib/fun_ci/console/streak_counter.rb +19 -0
  54. data/lib/fun_ci/console/view.rb +47 -0
  55. data/lib/fun_ci/evidence/about.rb +12 -0
  56. data/lib/fun_ci/evidence/about_stage.rb +29 -0
  57. data/lib/fun_ci/evidence/caps.rb +77 -0
  58. data/lib/fun_ci/evidence/catalog.rb +83 -0
  59. data/lib/fun_ci/evidence/collector.rb +95 -0
  60. data/lib/fun_ci/evidence/command_output.rb +45 -0
  61. data/lib/fun_ci/evidence/command_reading.rb +38 -0
  62. data/lib/fun_ci/evidence/command_runner.rb +75 -0
  63. data/lib/fun_ci/evidence/context.rb +21 -0
  64. data/lib/fun_ci/evidence/context_document.rb +29 -0
  65. data/lib/fun_ci/evidence/deadline.rb +15 -0
  66. data/lib/fun_ci/evidence/detection.rb +82 -0
  67. data/lib/fun_ci/evidence/document.rb +59 -0
  68. data/lib/fun_ci/evidence/extract_command.rb +83 -0
  69. data/lib/fun_ci/evidence/extract_options.rb +45 -0
  70. data/lib/fun_ci/evidence/extraction.rb +54 -0
  71. data/lib/fun_ci/evidence/extractors/command.rb +52 -0
  72. data/lib/fun_ci/evidence/extractors/grep.rb +49 -0
  73. data/lib/fun_ci/evidence/extractors/json_log.rb +45 -0
  74. data/lib/fun_ci/evidence/extractors/junit_files.rb +32 -0
  75. data/lib/fun_ci/evidence/extractors/last_lines.rb +25 -0
  76. data/lib/fun_ci/evidence/extractors/log_file.rb +62 -0
  77. data/lib/fun_ci/evidence/extractors/process_tree.rb +51 -0
  78. data/lib/fun_ci/evidence/extractors/section.rb +49 -0
  79. data/lib/fun_ci/evidence/findings.rb +14 -0
  80. data/lib/fun_ci/evidence/line_ranges.rb +21 -0
  81. data/lib/fun_ci/evidence/line_scan.rb +22 -0
  82. data/lib/fun_ci/evidence/log_record.rb +58 -0
  83. data/lib/fun_ci/evidence/masking.rb +55 -0
  84. data/lib/fun_ci/evidence/option_check.rb +53 -0
  85. data/lib/fun_ci/evidence/outcome.rb +15 -0
  86. data/lib/fun_ci/evidence/patterns.rb +22 -0
  87. data/lib/fun_ci/evidence/presets/README.md +7 -0
  88. data/lib/fun_ci/evidence/presets/bun-test.yml +6 -0
  89. data/lib/fun_ci/evidence/presets/cargo-test.yml +6 -0
  90. data/lib/fun_ci/evidence/presets/dart-test.yml +6 -0
  91. data/lib/fun_ci/evidence/presets/deno-test.yml +6 -0
  92. data/lib/fun_ci/evidence/presets/dotnet-test.yml +6 -0
  93. data/lib/fun_ci/evidence/presets/ecs.yml +3 -0
  94. data/lib/fun_ci/evidence/presets/eslint.yml +6 -0
  95. data/lib/fun_ci/evidence/presets/exunit.yml +6 -0
  96. data/lib/fun_ci/evidence/presets/gcc.yml +6 -0
  97. data/lib/fun_ci/evidence/presets/go-build.yml +5 -0
  98. data/lib/fun_ci/evidence/presets/go-test.yml +6 -0
  99. data/lib/fun_ci/evidence/presets/gradle.yml +6 -0
  100. data/lib/fun_ci/evidence/presets/gtest.yml +6 -0
  101. data/lib/fun_ci/evidence/presets/jest.yml +6 -0
  102. data/lib/fun_ci/evidence/presets/logstash.yml +3 -0
  103. data/lib/fun_ci/evidence/presets/maven.yml +6 -0
  104. data/lib/fun_ci/evidence/presets/minitest.yml +6 -0
  105. data/lib/fun_ci/evidence/presets/mocha.yml +6 -0
  106. data/lib/fun_ci/evidence/presets/mypy.yml +6 -0
  107. data/lib/fun_ci/evidence/presets/node-test.yml +6 -0
  108. data/lib/fun_ci/evidence/presets/phpunit.yml +6 -0
  109. data/lib/fun_ci/evidence/presets/pino.yml +5 -0
  110. data/lib/fun_ci/evidence/presets/prove.yml +6 -0
  111. data/lib/fun_ci/evidence/presets/pytest.yml +6 -0
  112. data/lib/fun_ci/evidence/presets/rspec.yml +6 -0
  113. data/lib/fun_ci/evidence/presets/rubocop.yml +6 -0
  114. data/lib/fun_ci/evidence/presets/ruff.yml +6 -0
  115. data/lib/fun_ci/evidence/presets/rustc.yml +6 -0
  116. data/lib/fun_ci/evidence/presets/shellcheck.yml +5 -0
  117. data/lib/fun_ci/evidence/presets/structlog.yml +4 -0
  118. data/lib/fun_ci/evidence/presets/swift-test.yml +5 -0
  119. data/lib/fun_ci/evidence/presets/tsc.yml +6 -0
  120. data/lib/fun_ci/evidence/presets/unittest.yml +6 -0
  121. data/lib/fun_ci/evidence/presets/vitest.yml +6 -0
  122. data/lib/fun_ci/evidence/presets.rb +30 -0
  123. data/lib/fun_ci/evidence/problem.rb +9 -0
  124. data/lib/fun_ci/evidence/process_table.rb +40 -0
  125. data/lib/fun_ci/evidence/saved_run.rb +22 -0
  126. data/lib/fun_ci/evidence/section_walk.rb +49 -0
  127. data/lib/fun_ci/evidence/settings.rb +64 -0
  128. data/lib/fun_ci/evidence/settings_check.rb +78 -0
  129. data/lib/fun_ci/evidence/source.rb +34 -0
  130. data/lib/fun_ci/evidence/sources.rb +21 -0
  131. data/lib/fun_ci/evidence/stage_context.rb +30 -0
  132. data/lib/fun_ci/evidence/stamp.rb +10 -0
  133. data/lib/fun_ci/evidence/start.rb +36 -0
  134. data/lib/fun_ci/evidence/worktree.rb +62 -0
  135. data/lib/fun_ci/persistence/active_runs.rb +69 -0
  136. data/lib/fun_ci/persistence/database.rb +59 -30
  137. data/lib/fun_ci/persistence/null_recorder.rb +25 -0
  138. data/lib/fun_ci/persistence/output_tail.rb +24 -0
  139. data/lib/fun_ci/persistence/pipeline_recorder.rb +78 -32
  140. data/lib/fun_ci/persistence/pipeline_run.rb +26 -17
  141. data/lib/fun_ci/persistence/project_runs.rb +52 -0
  142. data/lib/fun_ci/persistence/raw_outputs.rb +46 -0
  143. data/lib/fun_ci/persistence/retention.rb +25 -0
  144. data/lib/fun_ci/persistence/run_status.rb +31 -0
  145. data/lib/fun_ci/persistence/stage_job.rb +58 -13
  146. data/lib/fun_ci/persistence/state_dir.rb +26 -0
  147. data/lib/fun_ci/persistence/write_trouble.rb +46 -0
  148. data/lib/fun_ci/pipeline/background_fork.rb +42 -0
  149. data/lib/fun_ci/pipeline/command_executor.rb +58 -0
  150. data/lib/fun_ci/pipeline/gate.rb +35 -0
  151. data/lib/fun_ci/pipeline/git_environment.rb +16 -0
  152. data/lib/fun_ci/pipeline/head_part.rb +40 -0
  153. data/lib/fun_ci/pipeline/in_place.rb +17 -0
  154. data/lib/fun_ci/pipeline/output_window.rb +73 -0
  155. data/lib/fun_ci/pipeline/pipeline_forker.rb +18 -16
  156. data/lib/fun_ci/pipeline/process_runner.rb +90 -17
  157. data/lib/fun_ci/pipeline/run_canceller.rb +75 -0
  158. data/lib/fun_ci/pipeline/slot.rb +30 -0
  159. data/lib/fun_ci/pipeline/slot_lock.rb +13 -0
  160. data/lib/fun_ci/pipeline/slot_run.rb +86 -0
  161. data/lib/fun_ci/pipeline/stage_dir.rb +50 -0
  162. data/lib/fun_ci/pipeline/stage_end.rb +70 -0
  163. data/lib/fun_ci/pipeline/stage_execution.rb +67 -0
  164. data/lib/fun_ci/pipeline/stage_runner.rb +42 -65
  165. data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +16 -33
  166. data/lib/fun_ci/pipeline/tail_segments.rb +60 -0
  167. data/lib/fun_ci/pipeline/test_report.rb +48 -0
  168. data/lib/fun_ci/pipeline/trigger.rb +55 -115
  169. data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
  170. data/lib/fun_ci/pipeline/trigger_params.rb +48 -0
  171. data/lib/fun_ci/pipeline/workspaces.rb +22 -0
  172. data/lib/fun_ci/pipeline/worktree_pool.rb +66 -0
  173. data/lib/fun_ci/pipeline/worktree_prune.rb +49 -0
  174. data/lib/fun_ci/pipeline/worktrees.rb +45 -0
  175. data/lib/fun_ci/setup/agent_instructions.rb +31 -0
  176. data/lib/fun_ci/setup/commands.rb +49 -0
  177. data/lib/fun_ci/setup/git_hooks.rb +20 -0
  178. data/lib/fun_ci/setup/hook_script.rb +47 -0
  179. data/lib/fun_ci/setup/hook_writer.rb +13 -31
  180. data/lib/fun_ci/setup/installer.rb +39 -23
  181. data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
  182. data/lib/fun_ci/setup/project_config.rb +43 -17
  183. data/lib/fun_ci/setup/project_detector.rb +39 -7
  184. data/lib/fun_ci/setup/settings.rb +36 -0
  185. data/lib/fun_ci/setup/setup_checker.rb +18 -12
  186. data/lib/fun_ci/setup/stage_templates/javascript.rb +18 -0
  187. data/lib/fun_ci/setup/stage_templates/managed.rb +24 -0
  188. data/lib/fun_ci/setup/stage_templates/native.rb +25 -0
  189. data/lib/fun_ci/setup/stage_templates/scripting.rb +27 -0
  190. data/lib/fun_ci/setup/stage_templates/shell.rb +16 -0
  191. data/lib/fun_ci/setup/stage_templates.rb +25 -0
  192. data/lib/fun_ci/setup/template_writer.rb +11 -36
  193. data/lib/fun_ci/version.rb +1 -1
  194. data/lib/fun_ci.rb +0 -1
  195. metadata +205 -41
  196. data/exe/fun-ci-trigger +0 -19
  197. data/exe/fun-ci-tui +0 -23
  198. data/lib/fun_ci/animations/celebrate.rb +0 -210
  199. data/lib/fun_ci/animations/explosion.rb +0 -138
  200. data/lib/fun_ci/animations/flash.rb +0 -205
  201. data/lib/fun_ci/animations/idle.rb +0 -127
  202. data/lib/fun_ci/animations/leprechauns.rb +0 -240
  203. data/lib/fun_ci/animations/running.rb +0 -129
  204. data/lib/fun_ci/animations/success.rb +0 -281
  205. data/lib/fun_ci/animations/yay.rb +0 -169
  206. data/lib/fun_ci/persistence/state_machine.rb +0 -50
  207. data/lib/fun_ci/pipeline/background_wrapper.rb +0 -27
  208. data/lib/fun_ci/tui/admin_tui.rb +0 -90
  209. data/lib/fun_ci/tui/animation.rb +0 -49
  210. data/lib/fun_ci/tui/animation_compositor.rb +0 -107
  211. data/lib/fun_ci/tui/animation_frames.rb +0 -112
  212. data/lib/fun_ci/tui/animation_library.rb +0 -46
  213. data/lib/fun_ci/tui/animation_renderer.rb +0 -144
  214. data/lib/fun_ci/tui/ansi.rb +0 -34
  215. data/lib/fun_ci/tui/board_data.rb +0 -53
  216. data/lib/fun_ci/tui/board_renderer.rb +0 -105
  217. data/lib/fun_ci/tui/duration_formatter.rb +0 -24
  218. data/lib/fun_ci/tui/header_animation_manager.rb +0 -71
  219. data/lib/fun_ci/tui/header_animation_player.rb +0 -45
  220. data/lib/fun_ci/tui/key_handler.rb +0 -86
  221. data/lib/fun_ci/tui/looping_animation_player.rb +0 -45
  222. data/lib/fun_ci/tui/relative_time.rb +0 -22
  223. data/lib/fun_ci/tui/row_formatter.rb +0 -108
  224. data/lib/fun_ci/tui/screen.rb +0 -103
  225. data/lib/fun_ci/tui/spinner.rb +0 -24
  226. data/lib/fun_ci/tui/stage_change_detector.rb +0 -58
  227. data/lib/fun_ci/tui/streak_counter.rb +0 -29
  228. data/lib/fun_ci/tui/terminal_input.rb +0 -69
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Persistence
5
+ # Records nothing, for a pipeline run with no database.
6
+ class NullRecorder
7
+ def create_run(**); end
8
+ def start_stage(_stage, **); end
9
+ def end_stage(_job_id, _status); end
10
+ def keep_exit(_job_id, _exit_status, _signal); end
11
+ def keep_evidence(_job_id, _document); end
12
+ def keep_raw(_job_id, _text); end
13
+ def alongside(_job_id) = []
14
+ def stage_process(_job_id, _pid); end
15
+ def slot_taken(_lock_file); end
16
+ def foreground_done; end
17
+ def db; end
18
+ def db_path; end
19
+ def pipeline_run_id; end
20
+ def close; end
21
+ def tolerating = yield
22
+ def report_trouble_to(_out); end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module FunCi
4
+ module Persistence
5
+ # The end of what a stage printed, as fun-ci keeps it for a stage that
6
+ # failed (acceptance-tests.md, AT-9.5): the last 200 lines, at most 64 KB,
7
+ # colour codes stripped and bytes that aren't UTF-8 replaced. `mask`
8
+ # sees the last lines before they are cut to size, so no cut splits a
9
+ # secret it would have masked.
10
+ module OutputTail
11
+ LINES = 200
12
+ BYTES = 65_536
13
+ COLOUR = /\e\[[0-9;?]*[A-Za-z]/
14
+
15
+ def self.of(output, mask: :itself.to_proc)
16
+ text = output.dup.force_encoding(Encoding::UTF_8).scrub("?").gsub(COLOUR, "")
17
+ last_bytes(mask.call(text.lines.last(LINES).join))
18
+ end
19
+
20
+ def self.last_bytes(text) = text.byteslice([text.bytesize - BYTES, 0].max..).scrub("")
21
+ private_class_method :last_bytes
22
+ end
23
+ end
24
+ end
@@ -2,64 +2,109 @@
2
2
 
3
3
  require_relative "pipeline_run"
4
4
  require_relative "stage_job"
5
+ require_relative "run_status"
6
+ require_relative "write_trouble"
7
+ require "json"
8
+ require_relative "null_recorder"
9
+ require_relative "project_runs"
10
+ require_relative "retention"
5
11
 
6
12
  module FunCi
7
13
  module Persistence
8
- class NullRecorder
9
- def create_run(commit_hash:, branch:, project_path: nil) = nil
10
- def start_stage(stage) = nil
11
- def end_stage(job_id, status) = nil
12
- def complete_run = nil
13
- def fail_run = nil
14
- def db = nil
15
- def db_path = nil
16
- def pipeline_run_id = nil
17
- def close = nil
18
- end
19
-
20
14
  class DbRecorder
21
- attr_reader :db, :db_path, :pipeline_run_id
15
+ KEPT_RUNS = Retention::KEPT_RUNS
16
+
17
+ attr_reader :db, :db_path, :pipeline_run_id, :trouble
22
18
 
23
- def self.for_background(db_path, pipeline_run_id)
19
+ def self.for_background(db_path, pipeline_run_id, trouble: WriteTrouble.silent(db_path))
24
20
  db = Database.connection(db_path)
25
- new(db, pipeline_run_id: pipeline_run_id)
21
+ new(db, pipeline_run_id: pipeline_run_id, trouble: trouble)
26
22
  end
27
23
 
28
- def initialize(db, pipeline_run_id: nil)
24
+ # `trouble` decides what a refused write does; by default, nothing is said.
25
+ def initialize(db, pipeline_run_id: nil, trouble: nil)
29
26
  @db = db
30
27
  @db_path = db.filename("main")
31
28
  @pipeline_run_id = pipeline_run_id
29
+ @trouble = trouble || WriteTrouble.silent(@db_path)
30
+ end
31
+
32
+ # Says what goes wrong writing to the database on `out` from now on.
33
+ def report_trouble_to(out)
34
+ @trouble = WriteTrouble.new(out, @db_path)
32
35
  end
33
36
 
37
+ # The block's value, or nil when the database refused a write in it.
38
+ def tolerating(&) = @trouble.guard(&)
39
+
34
40
  def close
35
- @db.close rescue nil
41
+ @db.close
42
+ rescue StandardError
43
+ nil
36
44
  end
37
45
 
46
+ # Notes this process as the one running the pipeline, so cancelling can stop it.
38
47
  def create_run(commit_hash:, branch:, project_path: nil)
39
- @pipeline_run_id = PipelineRun.create(@db, commit_hash: commit_hash, branch: branch, project_path: project_path)
48
+ tolerating do
49
+ @pipeline_run_id = PipelineRun.create(@db, commit_hash: commit_hash, branch: branch,
50
+ project_path: project_path)
51
+ PipelineRun.store_trigger_pid(@db, @pipeline_run_id, Process.pid)
52
+ Retention.apply(@db, project_path) if project_path
53
+ @pipeline_run_id
54
+ end
55
+ end
56
+
57
+ def stage_process(job_id, pid)
58
+ tolerating { StageJob.store_pid(@db, job_id, pid) }
59
+ end
60
+
61
+ # Once it has exited its pid may be reused, so a cancel must not signal it.
62
+ def foreground_done
63
+ tolerating { PipelineRun.store_trigger_pid(@db, @pipeline_run_id, nil) }
40
64
  end
41
65
 
42
- def start_stage(stage)
66
+ def slot_taken(lock_file)
67
+ tolerating { PipelineRun.store_slot_lock(@db, @pipeline_run_id, lock_file) }
68
+ end
69
+
70
+ def start_stage(stage, budget: nil)
43
71
  return nil unless @pipeline_run_id
44
- ensure_running
45
- job_id = StageJob.create(@db, pipeline_run_id: @pipeline_run_id, stage: stage)
46
- StageJob.update_status(@db, job_id, "running")
47
- job_id
72
+
73
+ tolerating do
74
+ ensure_running
75
+ job_id = StageJob.create(@db, pipeline_run_id: @pipeline_run_id, stage: stage, budget: budget)
76
+ StageJob.update_status(@db, job_id, "running")
77
+ job_id
78
+ end
48
79
  end
49
80
 
50
- def end_stage(job_id, status)
51
- return unless job_id
52
- StageJob.update_status(@db, job_id, status)
81
+ # Keeps a failed stage's evidence, and the output's tail where an older
82
+ # fun-ci sharing the database reads it.
83
+ def keep_evidence(job_id, document)
84
+ tolerating do
85
+ StageJob.keep_evidence(@db, job_id, JSON.generate(document.to_h))
86
+ StageJob.keep_output(@db, job_id, document.tail)
87
+ end
53
88
  end
54
89
 
55
- def complete_run
56
- return unless @pipeline_run_id
57
- PipelineRun.update_status(@db, @pipeline_run_id, "completed")
90
+ # Keeps what a failed stage printed, masked and cut to its window.
91
+ def keep_raw(job_id, text)
92
+ tolerating { RawOutputs.beside(@db_path).write(job_id, text) }
58
93
  end
59
94
 
60
- def fail_run
61
- return unless @pipeline_run_id
62
- PipelineRun.update_status(@db, @pipeline_run_id, "failed")
95
+ # The stages that shared the slot with this one (architecture.md, "Evidence of a failed stage").
96
+ def alongside(job_id) = tolerating { StageJob.alongside(@db, job_id) } || []
97
+
98
+ def keep_exit(job_id, exit_status, signal)
99
+ tolerating { StageJob.keep_exit(@db, job_id, exit_status, signal) }
100
+ end
101
+
102
+ # Records the stage's outcome, then settles the run's status from its stages.
103
+ def end_stage(job_id, status)
104
+ tolerating do
105
+ StageJob.update_status(@db, job_id, status)
106
+ RunStatus.settle(@db, @pipeline_run_id)
107
+ end
63
108
  end
64
109
 
65
110
  private
@@ -67,6 +112,7 @@ module FunCi
67
112
  def ensure_running
68
113
  run = PipelineRun.find(@db, @pipeline_run_id)
69
114
  return unless run && run[:status] == "scheduled"
115
+
70
116
  PipelineRun.update_status(@db, @pipeline_run_id, "running")
71
117
  end
72
118
  end
@@ -5,39 +5,49 @@ require "time"
5
5
  module FunCi
6
6
  module Persistence
7
7
  module PipelineRun
8
+ COLUMNS = %i[id commit_hash branch status pid project_path created_at updated_at].freeze
9
+ SELECT = "SELECT #{COLUMNS.join(", ")} FROM pipeline_runs".freeze
10
+
8
11
  def self.create(db, commit_hash:, branch:, project_path: nil)
9
12
  now = Time.now.utc.iso8601
10
13
  db.execute(
11
- "INSERT INTO pipeline_runs (commit_hash, branch, project_path, status, created_at, updated_at) VALUES (?, ?, ?, 'scheduled', ?, ?)",
14
+ "INSERT INTO pipeline_runs (commit_hash, branch, project_path, status, created_at, updated_at) " \
15
+ "VALUES (?, ?, ?, 'scheduled', ?, ?)",
12
16
  [commit_hash, branch, project_path, now, now]
13
17
  )
14
18
  db.last_insert_row_id
15
19
  end
16
20
 
17
21
  def self.find(db, id)
18
- row = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE id = ?", [id]).first
19
- return nil unless row
20
- row_to_hash(row)
22
+ query(db, "WHERE id = ?", id).first
21
23
  end
22
24
 
23
25
  def self.find_by_branch(db, branch)
24
- rows = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE branch = ? ORDER BY id DESC", [branch])
25
- rows.map { |row| row_to_hash(row) }
26
+ query(db, "WHERE branch = ? ORDER BY id DESC", branch)
26
27
  end
27
28
 
28
29
  def self.find_by_commit(db, commit_hash)
29
- rows = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE commit_hash = ? ORDER BY id DESC", [commit_hash])
30
- rows.map { |row| row_to_hash(row) }
30
+ query(db, "WHERE commit_hash = ? ORDER BY id DESC", commit_hash)
31
31
  end
32
32
 
33
+ # The forked slow suite's process.
33
34
  def self.store_pid(db, id, pid)
34
35
  db.execute("UPDATE pipeline_runs SET pid = ? WHERE id = ?", [pid, id])
35
36
  end
36
37
 
37
- def self.find_running_with_pid(db, branch)
38
- row = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE branch = ? AND status = 'running' AND pid IS NOT NULL ORDER BY id DESC LIMIT 1", [branch]).first
39
- return nil unless row
40
- row_to_hash(row)
38
+ # The lock file of the worktree slot the run holds.
39
+ def self.store_slot_lock(db, id, path)
40
+ db.execute("UPDATE pipeline_runs SET slot_lock = ? WHERE id = ?", [path, id])
41
+ end
42
+
43
+ # The process running the pipeline itself.
44
+ def self.store_trigger_pid(db, id, pid)
45
+ db.execute("UPDATE pipeline_runs SET trigger_pid = ? WHERE id = ?", [pid, id])
46
+ end
47
+
48
+ # When an agent last polled the run, waiting on it (acceptance-tests.md, AT-9.12).
49
+ def self.mark_waited(db, id, at)
50
+ db.execute("UPDATE pipeline_runs SET waited_at = ? WHERE id = ?", [at, id])
41
51
  end
42
52
 
43
53
  def self.update_status(db, id, new_status)
@@ -46,14 +56,13 @@ module FunCi
46
56
  end
47
57
 
48
58
  def self.recent(db, limit: 20)
49
- rows = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs ORDER BY id DESC LIMIT ?", [limit])
50
- rows.map { |row| row_to_hash(row) }
59
+ query(db, "ORDER BY id DESC LIMIT ?", limit)
51
60
  end
52
61
 
53
- def self.row_to_hash(row)
54
- { id: row[0], commit_hash: row[1], branch: row[2], status: row[3], pid: row[4], project_path: row[5], created_at: row[6], updated_at: row[7] }
62
+ def self.query(db, clause, param)
63
+ db.execute("#{SELECT} #{clause}", [param]).map { |row| COLUMNS.zip(row).to_h }
55
64
  end
56
- private_class_method :row_to_hash
65
+ private_class_method :query
57
66
  end
58
67
  end
59
68
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pipeline_run"
4
+
5
+ module FunCi
6
+ module Persistence
7
+ # The runs of one project. Every project records its runs in the same
8
+ # database, so each query is scoped to the project's path.
9
+ class ProjectRuns
10
+ def initialize(db, project)
11
+ @db = db
12
+ @project = project
13
+ end
14
+
15
+ def latest_of_commit(sha) = query("AND commit_hash = ? ORDER BY id DESC LIMIT 1", sha).first
16
+
17
+ # The commit of the first run on the same branch after this one, if any.
18
+ def superseded_by(run)
19
+ query("AND branch = ? AND id > ? ORDER BY id LIMIT 1", run[:branch], run[:id]).first&.fetch(:commit_hash)
20
+ end
21
+
22
+ # The newest runs first, on one branch when one is named.
23
+ def recent(limit:, branch: nil)
24
+ return query("ORDER BY id DESC LIMIT ?", limit) unless branch
25
+
26
+ query("AND branch = ? ORDER BY id DESC LIMIT ?", branch, limit)
27
+ end
28
+
29
+ # Drops the kept output and an older fun-ci's failures of every run but the
30
+ # project's newest `keep`, and marks their stages pruned.
31
+ def forget_output(keep:)
32
+ @db.execute("UPDATE stage_jobs SET output_tail = NULL, failures = NULL, pruned = 1 WHERE pipeline_run_id IN " \
33
+ "(SELECT id FROM pipeline_runs WHERE project_path = ? ORDER BY id DESC LIMIT -1 OFFSET ?)",
34
+ [@project, keep])
35
+ end
36
+
37
+ # The stages of every run but the project's newest `keep`.
38
+ def stage_ids_beyond(keep:)
39
+ @db.execute("SELECT id FROM stage_jobs WHERE pipeline_run_id IN " \
40
+ "(SELECT id FROM pipeline_runs WHERE project_path = ? ORDER BY id DESC LIMIT -1 OFFSET ?)",
41
+ [@project, keep]).flatten
42
+ end
43
+
44
+ private
45
+
46
+ def query(clause, *params)
47
+ @db.execute("#{PipelineRun::SELECT} WHERE project_path = ? #{clause}", [@project, *params])
48
+ .map { |row| PipelineRun::COLUMNS.zip(row).to_h }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "zlib"
5
+
6
+ module FunCi
7
+ module Persistence
8
+ # What each failed stage printed, masked and cut to its window, kept as a
9
+ # gzip file per stage in a directory beside the database: SQLite would
10
+ # not give a deleted blob's space back without a VACUUM (architecture.md, "Evidence of a failed stage").
11
+ class RawOutputs
12
+ def self.beside(db_path) = new(File.join(File.dirname(db_path), "raw"))
13
+
14
+ def initialize(dir)
15
+ @dir = dir
16
+ end
17
+
18
+ def write(job_id, text)
19
+ FileUtils.mkdir_p(@dir)
20
+ Zlib::GzipWriter.open(path(job_id)) { |gzip| gzip.write(text) }
21
+ end
22
+
23
+ def read(job_id)
24
+ File.exist?(path(job_id)) ? Zlib::GzipReader.open(path(job_id), &:read) : nil
25
+ end
26
+
27
+ # How many bytes it kept, from the size gzip records at the file's end.
28
+ def bytes(job_id)
29
+ File.exist?(path(job_id)) ? File.binread(path(job_id), 4, File.size(path(job_id)) - 4).unpack1("V") : nil
30
+ end
31
+
32
+ def delete(job_ids) = job_ids.each { |job_id| FileUtils.rm_f(path(job_id)) }
33
+
34
+ # Deletes what it kept of every stage but those named.
35
+ def keep_only(job_ids)
36
+ kept = job_ids.to_set(&:to_s)
37
+ Dir.glob("*.gz", base: @dir).reject { |name| kept.include?(File.basename(name, ".gz")) }
38
+ .each { |name| FileUtils.rm_f(File.join(@dir, name)) }
39
+ end
40
+
41
+ private
42
+
43
+ def path(job_id) = File.join(@dir, "#{job_id}.gz")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "project_runs"
4
+ require_relative "raw_outputs"
5
+ require_relative "stage_job"
6
+
7
+ module FunCi
8
+ module Persistence
9
+ # How long fun-ci keeps what failed stages left, applied when a run
10
+ # starts: the evidence for a project's 50 newest runs, the raw output for
11
+ # its 10 newest, and no raw output whose stage row is gone.
12
+ module Retention
13
+ KEPT_RUNS = 50
14
+ KEPT_RAW = 10
15
+
16
+ def self.apply(db, project)
17
+ runs = ProjectRuns.new(db, project)
18
+ runs.forget_output(keep: KEPT_RUNS)
19
+ raw = RawOutputs.beside(db.filename("main"))
20
+ raw.delete(runs.stage_ids_beyond(keep: KEPT_RAW))
21
+ raw.keep_only(StageJob.ids(db))
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module FunCi
6
+ module Persistence
7
+ # A run's status, worked out from its stages alone (acceptance-tests.md,
8
+ # AT-7.1): failed once any stage failed or timed out, completed once all
9
+ # four passed, and unchanged until then. One statement reads the stage rows
10
+ # and writes the run, so the foreground pipeline and the forked slow suite
11
+ # can't overwrite each other's verdict; failed, completed and cancelled
12
+ # runs are never touched again.
13
+ module RunStatus
14
+ OF_RUN = "pipeline_runs.id = stage_jobs.pipeline_run_id"
15
+ DERIVED = <<~SQL.freeze
16
+ CASE
17
+ WHEN EXISTS (SELECT 1 FROM stage_jobs WHERE #{OF_RUN} AND status IN ('failed', 'timed_out')) THEN 'failed'
18
+ WHEN (SELECT COUNT(DISTINCT stage) FROM stage_jobs WHERE #{OF_RUN} AND status = 'completed'
19
+ AND stage IN ('lint', 'build', 'fast', 'slow')) = 4 THEN 'completed'
20
+ ELSE status
21
+ END
22
+ SQL
23
+ SETTLE = "UPDATE pipeline_runs SET status = #{DERIVED}, updated_at = ? " \
24
+ "WHERE id = ? AND status IN ('scheduled', 'running') AND status != #{DERIVED}".freeze
25
+
26
+ def self.settle(db, id)
27
+ db.execute(SETTLE, [Time.now.utc.iso8601, id])
28
+ end
29
+ end
30
+ end
31
+ end
@@ -6,39 +6,84 @@ module FunCi
6
6
  module Persistence
7
7
  module StageJob
8
8
  TERMINAL_STATUSES = %w[completed failed timed_out cancelled].freeze
9
+ FIELDS = %i[id pipeline_run_id stage status started_at completed_at finished_order output_tail failures
10
+ exit_status signal budget pruned evidence].freeze
11
+ COLUMNS = FIELDS.join(", ")
12
+ NEXT_IN_RUN = "(SELECT COALESCE(MAX(others.finished_order), 0) + 1 FROM stage_jobs AS others " \
13
+ "WHERE others.pipeline_run_id = stage_jobs.pipeline_run_id)"
14
+ TIMESTAMP_COLUMNS = TERMINAL_STATUSES.to_h { |status| [status, "completed_at"] }
15
+ .merge("running" => "started_at").freeze
9
16
 
10
- def self.create(db, pipeline_run_id:, stage:)
17
+ def self.create(db, pipeline_run_id:, stage:, budget: nil)
11
18
  db.execute(
12
- "INSERT INTO stage_jobs (pipeline_run_id, stage, status) VALUES (?, ?, 'scheduled')",
13
- [pipeline_run_id, stage]
19
+ "INSERT INTO stage_jobs (pipeline_run_id, stage, status, budget) VALUES (?, ?, 'scheduled', ?)",
20
+ [pipeline_run_id, stage, budget]
14
21
  )
15
22
  db.last_insert_row_id
16
23
  end
17
24
 
25
+ # The stage script's process, which leads a process group of its own.
26
+ def self.store_pid(db, id, pid)
27
+ db.execute("UPDATE stage_jobs SET pid = ? WHERE id = ?", [pid, id])
28
+ end
29
+
30
+ # The end of what the stage printed, kept when it failed (acceptance-tests.md, AT-9.5).
31
+ def self.keep_output(db, id, tail)
32
+ db.execute("UPDATE stage_jobs SET output_tail = ? WHERE id = ?", [tail, id])
33
+ end
34
+
35
+ # What was kept about why the stage failed, as JSON (architecture.md, "Evidence of a failed stage").
36
+ def self.keep_evidence(db, id, evidence_json)
37
+ db.execute("UPDATE stage_jobs SET evidence = ? WHERE id = ?", [evidence_json, id])
38
+ end
39
+
40
+ # How the stage's process exited: its status, or the signal that ended it.
41
+ def self.keep_exit(db, id, exit_status, signal)
42
+ db.execute("UPDATE stage_jobs SET exit_status = ?, signal = ? WHERE id = ?", [exit_status, signal, id])
43
+ end
44
+
45
+ # The other stages of its run that were running at any moment since it started.
46
+ def self.alongside(db, id)
47
+ db.execute("SELECT others.stage FROM stage_jobs AS others JOIN stage_jobs AS me ON me.id = ? " \
48
+ "WHERE others.pipeline_run_id = me.pipeline_run_id AND others.id != me.id " \
49
+ "AND others.started_at IS NOT NULL " \
50
+ "AND (others.completed_at IS NULL OR others.completed_at > me.started_at) ORDER BY others.id",
51
+ [id]).flatten
52
+ end
53
+
54
+ def self.ids(db) = db.execute("SELECT id FROM stage_jobs").flatten
55
+
18
56
  def self.find(db, id)
19
- row = db.execute("SELECT id, pipeline_run_id, stage, status, started_at, completed_at FROM stage_jobs WHERE id = ?", [id]).first
57
+ row = db.execute("SELECT #{COLUMNS} FROM stage_jobs WHERE id = ?", [id]).first
20
58
  return nil unless row
59
+
21
60
  row_to_hash(row)
22
61
  end
23
62
 
63
+ # The run's jobs, in the order they were created.
64
+ def self.for_run(db, pipeline_run_id)
65
+ db.execute("SELECT #{COLUMNS} FROM stage_jobs WHERE pipeline_run_id = ? ORDER BY id", [pipeline_run_id])
66
+ .map { |row| row_to_hash(row) }
67
+ end
68
+
69
+ # A stage that finishes is numbered after every other of its run that
70
+ # finished before it, in the same statement, so the order holds however
71
+ # close together they finished.
24
72
  def self.update_status(db, id, new_status)
25
- now = Time.now.utc.iso8601
26
- if new_status == "running"
27
- db.execute("UPDATE stage_jobs SET status = ?, started_at = ? WHERE id = ?", [new_status, now, id])
28
- elsif TERMINAL_STATUSES.include?(new_status)
29
- db.execute("UPDATE stage_jobs SET status = ?, completed_at = ? WHERE id = ?", [new_status, now, id])
30
- else
31
- db.execute("UPDATE stage_jobs SET status = ? WHERE id = ?", [new_status, id])
32
- end
73
+ column = TIMESTAMP_COLUMNS.fetch(new_status)
74
+ order = TERMINAL_STATUSES.include?(new_status) ? NEXT_IN_RUN : "finished_order"
75
+ db.execute("UPDATE stage_jobs SET status = ?, #{column} = ?, finished_order = #{order} WHERE id = ?",
76
+ [new_status, Time.now.utc.iso8601(3), id])
33
77
  end
34
78
 
35
79
  def self.elapsed_duration(job)
36
80
  return nil unless job[:started_at] && job[:completed_at]
81
+
37
82
  Time.parse(job[:completed_at]) - Time.parse(job[:started_at])
38
83
  end
39
84
 
40
85
  def self.row_to_hash(row)
41
- { id: row[0], pipeline_run_id: row[1], stage: row[2], status: row[3], started_at: row[4], completed_at: row[5] }
86
+ FIELDS.zip(row).to_h
42
87
  end
43
88
  private_class_method :row_to_hash
44
89
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module FunCi
6
+ module Persistence
7
+ # Where fun-ci keeps its database: the user's state directory, which every
8
+ # process of that user agrees on (acceptance-tests.md, AT-9.1). Only that
9
+ # user can read it, since what fun-ci keeps can hold a secret masking missed.
10
+ module StateDir
11
+ MODE = 0o700
12
+
13
+ # Creates the directory if need be, and makes it the user's alone.
14
+ def self.prepare(path)
15
+ FileUtils.mkdir_p(path)
16
+ File.chmod(MODE, path)
17
+ end
18
+
19
+ def self.path(env)
20
+ base = env["XDG_STATE_HOME"].to_s
21
+ base = File.join(env.fetch("HOME"), ".local", "state") if base.empty?
22
+ File.join(base, "fun-ci")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "stringio"
5
+
6
+ module FunCi
7
+ module Persistence
8
+ # What happens when the database won't take a write (acceptance-tests.md,
9
+ # AT-8.2, AT-8.4): the pipeline carries on without recording it, and the
10
+ # developer is told once, with what to do about it.
11
+ class WriteTrouble
12
+ # SystemCallError: the files kept beside the database, such as raw output.
13
+ UNWRITABLE = [SQLite3::FullException, SQLite3::ReadOnlyException, SQLite3::IOException,
14
+ SQLite3::CantOpenException, SystemCallError].freeze
15
+ BUSY = "fun-ci: couldn't record this run: the database stayed busy. Your stages still ran. " \
16
+ "Trigger it again to record it."
17
+ STILL_RAN = "Check the disk has space. Your stages still ran."
18
+
19
+ # Says nothing, for a process whose output nobody reads.
20
+ def self.silent(db_path) = new(StringIO.new, db_path)
21
+
22
+ def initialize(out, db_path)
23
+ @out = out
24
+ @db_path = db_path
25
+ @told = false
26
+ end
27
+
28
+ # The block's value, or nil when the database refused the write.
29
+ def guard
30
+ yield
31
+ rescue SQLite3::BusyException
32
+ tell(BUSY)
33
+ rescue *UNWRITABLE
34
+ tell("fun-ci: can't write to the database at #{@db_path}. #{STILL_RAN}")
35
+ end
36
+
37
+ private
38
+
39
+ def tell(message)
40
+ @out.puts(message) unless @told
41
+ @told = true
42
+ nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/pipeline_recorder"
4
+ require_relative "../persistence/pipeline_run"
5
+
6
+ module FunCi
7
+ module Pipeline
8
+ # Runs the slow suite in a forked child. A child must not inherit an open
9
+ # SQLite connection, so the parent's recorder is closed before the fork and
10
+ # #launch answers a fresh one for the parent to carry on with. The slot was
11
+ # shared for the slow suite before the fork; after it, each process drops
12
+ # the other's share and keeps its own.
13
+ class BackgroundFork
14
+ def initialize(recorder, slot)
15
+ @recorder = recorder
16
+ @slot = slot
17
+ end
18
+
19
+ def launch(db_path:, pipeline_run_id:, job_id:, executor:)
20
+ @recorder.close
21
+ pid = fork { run_in_child(db_path, pipeline_run_id, job_id, executor) }
22
+ @slot.release
23
+ Process.detach(pid)
24
+ reopened = Persistence::DbRecorder.for_background(db_path, pipeline_run_id, trouble: @recorder.trouble)
25
+ reopened.tolerating { Persistence::PipelineRun.store_pid(reopened.db, pipeline_run_id, pid) }
26
+ reopened
27
+ end
28
+
29
+ private
30
+
31
+ # Records its own pid before the slow suite starts, so a cancel can't
32
+ # miss it; the parent records it too, for its callers.
33
+ def run_in_child(db_path, pipeline_run_id, job_id, executor)
34
+ @slot.release
35
+ recorder = Persistence::DbRecorder.for_background(db_path, pipeline_run_id)
36
+ recorder.tolerating { Persistence::PipelineRun.store_pid(recorder.db, pipeline_run_id, Process.pid) }
37
+ executor.call(recorder, job_id)
38
+ recorder.close
39
+ end
40
+ end
41
+ end
42
+ end