fun_ci 2.0.2-aarch64-linux → 2.1.1-aarch64-linux

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +84 -0
  3. data/README.md +34 -8
  4. data/lib/fun_ci/agent/command_support.rb +14 -1
  5. data/lib/fun_ci/agent/commands.rb +1 -0
  6. data/lib/fun_ci/agent/context.rb +3 -2
  7. data/lib/fun_ci/agent/events.rb +19 -5
  8. data/lib/fun_ci/agent/events_command.rb +1 -1
  9. data/lib/fun_ci/agent/exit_code.rb +1 -1
  10. data/lib/fun_ci/agent/next_step.rb +26 -0
  11. data/lib/fun_ci/agent/options.rb +6 -4
  12. data/lib/fun_ci/agent/output.rb +15 -2
  13. data/lib/fun_ci/agent/report_stage.rb +1 -1
  14. data/lib/fun_ci/agent/reports.rb +13 -9
  15. data/lib/fun_ci/agent/run_details.rb +31 -0
  16. data/lib/fun_ci/agent/run_report.rb +4 -3
  17. data/lib/fun_ci/agent/runs_text.rb +7 -4
  18. data/lib/fun_ci/agent/snapshots.rb +13 -3
  19. data/lib/fun_ci/agent/status_command.rb +2 -2
  20. data/lib/fun_ci/agent/status_json.rb +2 -1
  21. data/lib/fun_ci/agent/status_text.rb +13 -2
  22. data/lib/fun_ci/agent/trunk_json.rb +32 -0
  23. data/lib/fun_ci/agent/trunk_reading.rb +53 -0
  24. data/lib/fun_ci/agent/trunk_text.rb +50 -0
  25. data/lib/fun_ci/agent/trunk_verdict.rb +20 -0
  26. data/lib/fun_ci/agent/trunk_why.rb +52 -0
  27. data/lib/fun_ci/agent/trunk_why_json.rb +23 -0
  28. data/lib/fun_ci/agent/wait_command.rb +5 -5
  29. data/lib/fun_ci/agent/why_command.rb +11 -0
  30. data/lib/fun_ci/cli.rb +1 -1
  31. data/lib/fun_ci/cli_help.rb +5 -3
  32. data/lib/fun_ci/console/board_data.rb +22 -8
  33. data/lib/fun_ci/console/cancelled_folding.rb +19 -0
  34. data/lib/fun_ci/console/console_events.rb +5 -2
  35. data/lib/fun_ci/console/console_log.rb +4 -1
  36. data/lib/fun_ci/console/console_state.rb +7 -1
  37. data/lib/fun_ci/console/row_order.rb +31 -0
  38. data/lib/fun_ci/console/run_message.rb +9 -2
  39. data/lib/fun_ci/console/trunk_events.rb +43 -0
  40. data/lib/fun_ci/console/trunk_marks.rb +53 -0
  41. data/lib/fun_ci/console/view.rb +9 -8
  42. data/lib/fun_ci/evidence/caps.rb +7 -2
  43. data/lib/fun_ci/persistence/active_runs.rb +5 -4
  44. data/lib/fun_ci/persistence/database.rb +23 -2
  45. data/lib/fun_ci/persistence/null_recorder.rb +8 -0
  46. data/lib/fun_ci/persistence/pipeline_recorder.rb +3 -0
  47. data/lib/fun_ci/persistence/pipeline_run.rb +24 -3
  48. data/lib/fun_ci/persistence/trunk_checks.rb +68 -0
  49. data/lib/fun_ci/persistence/trunk_fetches.rb +57 -0
  50. data/lib/fun_ci/persistence/trunk_recording.rb +49 -0
  51. data/lib/fun_ci/pipeline/pipeline_forker.rb +17 -2
  52. data/lib/fun_ci/pipeline/process_runner.rb +18 -4
  53. data/lib/fun_ci/pipeline/trigger.rb +10 -1
  54. data/lib/fun_ci/pipeline/trigger_command.rb +9 -5
  55. data/lib/fun_ci/pipeline/trigger_params.rb +5 -3
  56. data/lib/fun_ci/pipeline/trunk_run.rb +81 -0
  57. data/lib/fun_ci/setup/agent_instructions.rb +3 -0
  58. data/lib/fun_ci/setup/project_config.rb +3 -1
  59. data/lib/fun_ci/setup/settings.rb +35 -3
  60. data/lib/fun_ci/setup/setup_checker.rb +7 -2
  61. data/lib/fun_ci/setup/trunk_report.rb +28 -0
  62. data/lib/fun_ci/trunk/check.rb +56 -0
  63. data/lib/fun_ci/trunk/checker.rb +91 -0
  64. data/lib/fun_ci/trunk/description.rb +52 -0
  65. data/lib/fun_ci/trunk/fetch.rb +58 -0
  66. data/lib/fun_ci/trunk/first_fetch.rb +27 -0
  67. data/lib/fun_ci/trunk/git.rb +102 -0
  68. data/lib/fun_ci/trunk/local.rb +58 -0
  69. data/lib/fun_ci/trunk/merge_check.rb +54 -0
  70. data/lib/fun_ci/trunk/regions.rb +38 -0
  71. data/lib/fun_ci/trunk/resolver.rb +64 -0
  72. data/lib/fun_ci/trunk/shown.rb +47 -0
  73. data/lib/fun_ci/trunk/tip_reader.rb +39 -0
  74. data/lib/fun_ci/version.rb +1 -1
  75. data/libexec/fun-ci-renderer +0 -0
  76. metadata +30 -1
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../persistence/trunk_checks"
4
+ require_relative "../persistence/trunk_fetches"
5
+ require_relative "../trunk/shown"
6
+
7
+ module FunCi
8
+ module Console
9
+ # What the console shows of the trunk (design.md, The
10
+ # trunk). The standing belongs to a branch, not a run: it is shown on
11
+ # the branch's newest run and is the branch's latest settled check, so a
12
+ # new run still checking, or whose check couldn't be made, keeps it.
13
+ class TrunkMarks
14
+ def initialize(db)
15
+ @db = db
16
+ end
17
+
18
+ # runs: newest first. Each gains :trunk, { branch_state:, trunk: } on a
19
+ # branch's newest run with a settled check, nil otherwise.
20
+ def mark(runs)
21
+ branches = runs.group_by { |run| run.values_at(:project_path, :branch) }.values
22
+ standing = branches.to_h { |branch_runs| [branch_runs.first[:id], standing(branch_runs)] }
23
+ runs.map { |run| run.merge(trunk: standing[run[:id]]) }
24
+ end
25
+
26
+ # [{ project:, since: }] for each project on the page whose trunk is
27
+ # stale: its last fetch failed, or its last good one (since, epoch
28
+ # seconds, nil for none) is over an hour old.
29
+ def stale(runs, now:)
30
+ runs.filter_map { |run| run[:project_path] }.uniq.filter_map { |project| stale_since(project, now) }
31
+ end
32
+
33
+ private
34
+
35
+ def standing(branch_runs)
36
+ check = branch_runs.lazy.filter_map { |run| settled(run) }.first
37
+ check && { branch_state: Trunk::Shown.of(check, now: Time.now).state, trunk: check.tip.branch }
38
+ end
39
+
40
+ def settled(run)
41
+ check = Persistence::TrunkChecks.new(@db, run[:project_path]).latest(run[:commit_hash])
42
+ check if check && check.merge.outcome != "unknown"
43
+ end
44
+
45
+ def stale_since(project, now)
46
+ last = Persistence::TrunkFetches.new(@db, project).last
47
+ return nil unless last && (last.error || now - last.fetched_at > Trunk::Shown::FRESH_FOR)
48
+
49
+ { project: project, since: last.fetched_at&.to_i }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -2,14 +2,15 @@
2
2
 
3
3
  module FunCi
4
4
  module Console
5
- # What the user sees of the runs and does with them: KeyHandler's cursor
6
- # and cancel confirmation, and one page of runs, as many as the terminal
7
- # fits, scrolled so the run under the cursor is on it (renderer-protocol.md,
8
- # `board`).
5
+ # What the user sees of the rows and does with them: KeyHandler's cursor
6
+ # and cancel confirmation, and one page of rows, one per branch, as many as
7
+ # the terminal fits, scrolled so the row under the cursor is on it
8
+ # (renderer-protocol.md, `board`).
9
9
  class View
10
- # The renderer's 14-row header, a blank line and the footer.
11
- CHROME_ROWS = 16
12
- ROWS_PER_RUN = 2
10
+ # The renderer's 14-row header, the footer and the lines around the
11
+ # table: a page this many rows short of the terminal fits one line per
12
+ # row, however the renderer spaces them (renderer-protocol.md, `board`).
13
+ CHROME_ROWS = 18
13
14
 
14
15
  def initialize(key_handler:)
15
16
  @key_handler = key_handler
@@ -18,7 +19,7 @@ module FunCi
18
19
 
19
20
  # The number of runs a terminal of `rows` fits.
20
21
  def resize(rows)
21
- @page_size = [(rows - CHROME_ROWS) / ROWS_PER_RUN, 0].max
22
+ @page_size = [rows - CHROME_ROWS, 0].max
22
23
  end
23
24
 
24
25
  # :quit when the key ends the session.
@@ -15,8 +15,13 @@ module FunCi
15
15
  # What a line costs in JSON besides its own bytes: quotes and a comma.
16
16
  LINE_COST = 3
17
17
 
18
- def initialize(limits = REAL)
18
+ # How many bytes of JSON a document makes.
19
+ JSON_SIZE = ->(document) { JSON.generate(document.to_h).bytesize }
20
+
21
+ # size: measures a document, JSON_SIZE but where a test counts the measuring.
22
+ def initialize(limits = REAL, size: JSON_SIZE)
19
23
  @limits = limits
24
+ @size = size
20
25
  end
21
26
 
22
27
  def apply(document)
@@ -36,7 +41,7 @@ module FunCi
36
41
  document
37
42
  end
38
43
 
39
- def over(document) = JSON.generate(document.to_h).bytesize - @limits.bytes
44
+ def over(document) = @size.call(document) - @limits.bytes
40
45
 
41
46
  def few_failures(document)
42
47
  left_out = document.failures.size - @limits.failures
@@ -52,16 +52,17 @@ module FunCi
52
52
  end
53
53
 
54
54
  def self.where(db, clause, *values)
55
- db.execute("SELECT id, commit_hash, trigger_pid, pid, slot_lock FROM pipeline_runs " \
55
+ db.execute("SELECT id, commit_hash, trigger_pid, pid, slot_lock, fetch_pgid FROM pipeline_runs " \
56
56
  "WHERE #{clause} AND #{ACTIVE} ORDER BY id", values).map { |row| active_run(db, row) }
57
57
  end
58
58
  private_class_method :where
59
59
 
60
- def self.active_run(db, (id, commit_hash, trigger_pid, pid, slot_lock))
60
+ # The groups are each running stage's, and the trunk fetch's (architecture.md, Checking against the trunk).
61
+ def self.active_run(db, (id, commit_hash, trigger_pid, pid, slot_lock, fetch_pgid))
61
62
  groups = db.execute("SELECT pid FROM stage_jobs WHERE pipeline_run_id = ? AND #{ACTIVE} AND pid IS NOT NULL",
62
63
  [id]).flatten
63
- ActiveRun.new(id: id, commit_hash: commit_hash, processes: [trigger_pid, pid].compact, stage_groups: groups,
64
- slot_lock: slot_lock)
64
+ ActiveRun.new(id: id, commit_hash: commit_hash, processes: [trigger_pid, pid].compact,
65
+ stage_groups: [*groups, *fetch_pgid], slot_lock: slot_lock)
65
66
  end
66
67
  private_class_method :active_run
67
68
  end
@@ -28,6 +28,24 @@ module FunCi
28
28
  )
29
29
  SQL
30
30
 
31
+ # One row per commit and trunk SHA, written once (architecture.md, Checking against the trunk).
32
+ TRUNK_CHECKS_TABLE = <<~SQL
33
+ CREATE TABLE IF NOT EXISTS trunk_checks (
34
+ id INTEGER PRIMARY KEY,
35
+ project_path TEXT, commit_hash TEXT, trunk_remote TEXT, trunk_branch TEXT, trunk_sha TEXT,
36
+ trunk_seen_at TEXT, checked_at TEXT,
37
+ outcome TEXT, ahead INTEGER, behind INTEGER, files TEXT, reason TEXT,
38
+ UNIQUE (project_path, commit_hash, trunk_sha)
39
+ )
40
+ SQL
41
+
42
+ # When fun-ci last fetched each project's trunk (architecture.md, Checking against the trunk).
43
+ TRUNK_FETCHES_TABLE = <<~SQL
44
+ CREATE TABLE IF NOT EXISTS trunk_fetches (
45
+ project_path TEXT PRIMARY KEY, claimed_at INTEGER, fetched_at TEXT, failures INTEGER, error TEXT
46
+ )
47
+ SQL
48
+
31
49
  # Opening and migrating hold an exclusive lock on a file beside the
32
50
  # database, so fun-ci processes starting at once set it up one at a time.
33
51
  # SQLite's busy timeout doesn't cover the switch to WAL, and a column can
@@ -44,11 +62,14 @@ module FunCi
44
62
  %w[stage_jobs output_tail TEXT], %w[stage_jobs failures TEXT],
45
63
  %w[pipeline_runs waited_at TEXT], %w[stage_jobs exit_status INTEGER],
46
64
  %w[stage_jobs signal TEXT], %w[stage_jobs budget INTEGER], %w[stage_jobs pruned INTEGER],
47
- %w[stage_jobs evidence TEXT]].freeze
65
+ %w[stage_jobs evidence TEXT], %w[pipeline_runs fetch_pgid INTEGER],
66
+ %w[pipeline_runs trunk_started_at TEXT]].freeze
48
67
 
49
68
  def self.migrate!(db)
50
69
  with_setup_lock(db.filename("main")) do
51
- [PIPELINE_RUNS_TABLE, STAGE_JOBS_TABLE].each { |table| db.execute(table) }
70
+ [PIPELINE_RUNS_TABLE, STAGE_JOBS_TABLE, TRUNK_CHECKS_TABLE, TRUNK_FETCHES_TABLE].each do |table|
71
+ db.execute(table)
72
+ end
52
73
  ADDED_COLUMNS.each { |table, column, type| add_column_if_missing(db, table, column, type) }
53
74
  db.execute(NAME_DETACHED)
54
75
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "trunk_recording"
4
+
3
5
  module FunCi
4
6
  module Persistence
5
7
  # Records nothing, for a pipeline run with no database.
@@ -10,6 +12,12 @@ module FunCi
10
12
  def keep_exit(_job_id, _exit_status, _signal); end
11
13
  def keep_evidence(_job_id, _document); end
12
14
  def keep_raw(_job_id, _text); end
15
+ def trunk_checked(_check); end
16
+ def trunk_fetched(_fetched, _tip); end
17
+ def trunk_fetch_process(_pgid); end
18
+ def trunk_check_started; end
19
+ def trunk_heads(**) = {}
20
+ def trunk_fetches = TrunkRecording::NoFetches.new
13
21
  def alongside(_job_id) = []
14
22
  def stage_process(_job_id, _pid); end
15
23
  def slot_taken(_lock_file); end
@@ -8,10 +8,13 @@ require "json"
8
8
  require_relative "null_recorder"
9
9
  require_relative "project_runs"
10
10
  require_relative "retention"
11
+ require_relative "trunk_recording"
11
12
 
12
13
  module FunCi
13
14
  module Persistence
14
15
  class DbRecorder
16
+ include TrunkRecording
17
+
15
18
  KEPT_RUNS = Retention::KEPT_RUNS
16
19
 
17
20
  attr_reader :db, :db_path, :pipeline_run_id, :trouble
@@ -5,7 +5,7 @@ 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
8
+ COLUMNS = %i[id commit_hash branch status pid project_path created_at updated_at trunk_started_at].freeze
9
9
  SELECT = "SELECT #{COLUMNS.join(", ")} FROM pipeline_runs".freeze
10
10
 
11
11
  def self.create(db, commit_hash:, branch:, project_path: nil)
@@ -50,6 +50,11 @@ module FunCi
50
50
  db.execute("UPDATE pipeline_runs SET waited_at = ? WHERE id = ?", [at, id])
51
51
  end
52
52
 
53
+ # When the run began checking its commit against the trunk (architecture.md, Checking against the trunk).
54
+ def self.trunk_started(db, id, at)
55
+ db.execute("UPDATE pipeline_runs SET trunk_started_at = ? WHERE id = ?", [at.utc.iso8601, id])
56
+ end
57
+
53
58
  def self.update_status(db, id, new_status)
54
59
  now = Time.now.utc.iso8601
55
60
  db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, now, id])
@@ -59,8 +64,24 @@ module FunCi
59
64
  query(db, "ORDER BY id DESC LIMIT ?", limit)
60
65
  end
61
66
 
62
- def self.query(db, clause, param)
63
- db.execute("#{SELECT} #{clause}", [param]).map { |row| COLUMNS.zip(row).to_h }
67
+ # How much a branch whose newest run has a status needs you: a failure most.
68
+ NEEDING = "CASE status WHEN 'failed' THEN 0 WHEN 'timed_out' THEN 1 WHEN 'running' THEN 2 " \
69
+ "WHEN 'scheduled' THEN 3 WHEN 'completed' THEN 4 ELSE 5 END"
70
+
71
+ # The newest run of each of `limit` branches: those that most need you,
72
+ # then those run most recently, in that order.
73
+ def self.branch_heads(db, limit:)
74
+ query(db, "WHERE id IN (SELECT MAX(id) FROM pipeline_runs GROUP BY project_path, branch) " \
75
+ "ORDER BY #{NEEDING}, id DESC LIMIT ?", limit)
76
+ end
77
+
78
+ # A project's branch's runs, newest first.
79
+ def self.of_branch(db, project_path, branch, limit:)
80
+ query(db, "WHERE project_path IS ? AND branch = ? ORDER BY id DESC LIMIT ?", project_path, branch, limit)
81
+ end
82
+
83
+ def self.query(db, clause, *params)
84
+ db.execute("#{SELECT} #{clause}", params).map { |row| COLUMNS.zip(row).to_h }
64
85
  end
65
86
  private_class_method :query
66
87
  end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../trunk/check"
6
+
7
+ module FunCi
8
+ module Persistence
9
+ # A project's checks against its trunk. A check is a fact about a commit
10
+ # and a trunk SHA, so each pair is written once and never changed.
11
+ class TrunkChecks
12
+ TIP = %i[trunk_remote trunk_branch trunk_sha trunk_seen_at].freeze
13
+ MERGE = %i[outcome ahead behind files reason].freeze
14
+ COLUMNS = [:commit_hash, *TIP, *MERGE].freeze
15
+ SELECT = "SELECT #{COLUMNS.join(", ")} FROM trunk_checks".freeze
16
+
17
+ def initialize(db, project)
18
+ @db = db
19
+ @project = project
20
+ end
21
+
22
+ def record(check, checked_at:)
23
+ @db.execute("INSERT OR IGNORE INTO trunk_checks (project_path, checked_at, #{COLUMNS.join(", ")}) " \
24
+ "VALUES (?, ?, #{(["?"] * COLUMNS.size).join(", ")})",
25
+ [@project, checked_at.utc.iso8601, check.commit, *tip_row(check.tip), *merge_row(check.merge)])
26
+ end
27
+
28
+ # A fetch found the trunk still at `sha`: the checks against it are as fresh as that fetch.
29
+ def seen(sha, at:)
30
+ @db.execute("UPDATE trunk_checks SET trunk_seen_at = ? WHERE project_path = ? AND trunk_sha = ?",
31
+ [at.utc.iso8601, @project, sha])
32
+ end
33
+
34
+ # The commit's check against the trunk SHA fun-ci saw last, or nil.
35
+ def latest(sha)
36
+ found = @db.execute("#{SELECT} WHERE project_path = ? AND commit_hash = ? " \
37
+ "ORDER BY COALESCE(trunk_seen_at, checked_at) DESC, id DESC LIMIT 1", [@project, sha]).first
38
+ found && Trunk::Check.new(commit: found.first, tip: tip(found[1, TIP.size]),
39
+ merge: merge(found.last(MERGE.size)))
40
+ end
41
+
42
+ # { commit => trunk SHA } for each branch's newest run, other than `except`,
43
+ # whose commit has been checked against a trunk tip.
44
+ def branch_heads(except:)
45
+ heads = @db.execute("SELECT commit_hash FROM pipeline_runs WHERE id IN (SELECT MAX(id) FROM pipeline_runs " \
46
+ "WHERE project_path = ? GROUP BY branch) AND commit_hash != ?", [@project, except]).flatten
47
+ heads.to_h { |sha| [sha, latest(sha)&.tip&.sha] }.compact
48
+ end
49
+
50
+ private
51
+
52
+ def tip_row(tip) = tip ? [tip.remote, tip.branch, tip.sha, tip.seen_at.utc.iso8601] : [nil] * TIP.size
53
+ def merge_row(merge) = [merge.outcome, merge.ahead, merge.behind, JSON.generate(merge.files), merge.reason]
54
+
55
+ def tip(values)
56
+ remote, branch, sha, seen_at = values
57
+ return nil unless sha
58
+
59
+ Trunk::Tip.new(remote: remote, branch: branch, sha: sha, seen_at: Time.parse(seen_at))
60
+ end
61
+
62
+ def merge(values)
63
+ outcome, ahead, behind, files, reason = values
64
+ Trunk::Merge.new(outcome: outcome, ahead: ahead, behind: behind, files: JSON.parse(files), reason: reason)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require_relative "../trunk/fetch"
5
+ require_relative "../trunk/check"
6
+
7
+ module FunCi
8
+ module Persistence
9
+ # When fun-ci last fetched each project's trunk, and how that went
10
+ # (architecture.md, Checking against the trunk). A fetch is claimed
11
+ # in one statement, so of two processes claiming at once only one
12
+ # fetches; after failures the interval doubles, up to an hour.
13
+ class TrunkFetches
14
+ LONGEST = 3600
15
+ Last = Trunk::LastFetch
16
+
17
+ def initialize(db, project)
18
+ @db = db
19
+ @project = project
20
+ end
21
+
22
+ # `now` when this process may fetch now, and the interval starts again; nil otherwise.
23
+ def claim(now:, interval:)
24
+ ensure_row
25
+ @db.execute("UPDATE trunk_fetches SET claimed_at = ? WHERE project_path = ? AND (claimed_at IS NULL " \
26
+ "OR claimed_at + MIN(? * (1 << failures), ?) <= ?)",
27
+ [now.to_i, @project, interval, LONGEST, now.to_i])
28
+ now if @db.changes == 1
29
+ end
30
+
31
+ def finished(fetched, at:)
32
+ ensure_row
33
+ return failed(fetched.error) if fetched.error
34
+
35
+ @db.execute("UPDATE trunk_fetches SET fetched_at = ?, failures = 0, error = NULL WHERE project_path = ?",
36
+ [at.utc.iso8601, @project])
37
+ end
38
+
39
+ # The last fetch tried, or nil when there has been none.
40
+ def last
41
+ row = @db.execute("SELECT fetched_at, error FROM trunk_fetches WHERE project_path = ?", [@project]).first
42
+ row && Last.new(fetched_at: row[0] && Time.parse(row[0]), error: row[1])
43
+ end
44
+
45
+ private
46
+
47
+ def ensure_row
48
+ @db.execute("INSERT OR IGNORE INTO trunk_fetches (project_path, failures) VALUES (?, 0)", [@project])
49
+ end
50
+
51
+ def failed(error)
52
+ @db.execute("UPDATE trunk_fetches SET failures = failures + 1, error = ? WHERE project_path = ?",
53
+ [error, @project])
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pipeline_run"
4
+ require_relative "trunk_checks"
5
+ require_relative "trunk_fetches"
6
+
7
+ module FunCi
8
+ module Persistence
9
+ # What a run records about the trunk (architecture.md, Checking against the trunk), under the
10
+ # run's project. Expects @db and @pipeline_run_id, and #tolerating.
11
+ module TrunkRecording
12
+ # A fetch that never happens, for a run whose project can't be read.
13
+ class NoFetches
14
+ def claim(now:, **) = now
15
+ def last = nil
16
+ end
17
+
18
+ def trunk_check_started
19
+ tolerating { PipelineRun.trunk_started(@db, @pipeline_run_id, Time.now) }
20
+ end
21
+
22
+ def trunk_fetches = tolerating { TrunkFetches.new(@db, project) } || NoFetches.new
23
+
24
+ # The fetch's process group, which cancelling the run kills.
25
+ def trunk_fetch_process(pgid)
26
+ tolerating { @db.execute("UPDATE pipeline_runs SET fetch_pgid = ? WHERE id = ?", [pgid, @pipeline_run_id]) }
27
+ end
28
+
29
+ # How the fetch went; a tip it fetched counts as seen now in every check against it.
30
+ def trunk_fetched(fetched, tip)
31
+ tolerating do
32
+ TrunkFetches.new(@db, project).finished(fetched, at: Time.now)
33
+ TrunkChecks.new(@db, project).seen(tip.sha, at: Time.now) if tip && !fetched.error
34
+ end
35
+ end
36
+
37
+ # { commit => trunk SHA } for the other branches' newest checked runs.
38
+ def trunk_heads(except:) = tolerating { TrunkChecks.new(@db, project).branch_heads(except: except) } || {}
39
+
40
+ def trunk_checked(check)
41
+ tolerating { TrunkChecks.new(@db, project).record(check, checked_at: Time.now) }
42
+ end
43
+
44
+ private
45
+
46
+ def project = PipelineRun.find(@db, @pipeline_run_id)[:project_path]
47
+ end
48
+ end
49
+ end
@@ -4,18 +4,33 @@ require_relative "../persistence/database"
4
4
  require_relative "../persistence/pipeline_recorder"
5
5
  require_relative "trigger_params"
6
6
  require_relative "../setup/project_config"
7
+ require_relative "../trunk/first_fetch"
7
8
 
8
9
  module FunCi
9
10
  module Pipeline
10
11
  class PipelineForker
11
- # Answers whether it started a run: none in a project not set up for fun-ci.
12
+ # A run started, and what to say before the project's first fetch of its trunk, if this is it.
13
+ Forked = Data.define(:notice)
14
+
15
+ # Answers a Forked, or false in a project not set up for fun-ci. What the
16
+ # run prints goes nowhere, so the notice is worked out here, before the fork.
12
17
  def self.fork_pipeline(commit_hash:, branch:, db_path:)
13
18
  return false unless Setup::ProjectConfig.new(Dir.pwd).validate.empty?
14
19
 
20
+ notice = first_fetch(db_path)
15
21
  pid = fork do
16
22
  run_in_child(commit_hash: commit_hash, branch: branch, db_path: db_path)
17
23
  end
18
24
  Process.detach(pid)
25
+ Forked.new(notice: notice)
26
+ end
27
+
28
+ # The connection closes before the fork, which must not inherit it.
29
+ def self.first_fetch(db_path)
30
+ db = Persistence::Database.connection(db_path)
31
+ Trunk::FirstFetch.notice(Dir.pwd, db)
32
+ ensure
33
+ db&.close
19
34
  end
20
35
 
21
36
  # Trigger requires this file, through TriggerCommand, so it is loaded
@@ -34,7 +49,7 @@ module FunCi
34
49
  Trigger.new(project: Dir.pwd, commit: Commit.new(sha: commit_hash, branch: branch),
35
50
  io: Io.new(stdout: File.open(File::NULL, "w")), seams: Seams.new(recorder: recorder))
36
51
  end
37
- private_class_method :trigger
52
+ private_class_method :trigger, :first_fetch
38
53
  end
39
54
  end
40
55
  end
@@ -33,14 +33,28 @@ module FunCi
33
33
  end
34
34
  end
35
35
 
36
+ # A command started and let go, and the pipe that carries what it prints.
37
+ Started = Data.define(:pid, :reader)
38
+
36
39
  def run_process_with_timeout(cmd, budget, launch: Launch.new, timer: BUDGET, &)
40
+ await_process(start_process(cmd, launch, &), budget, launch: launch, timer: timer)
41
+ end
42
+
43
+ # Starts the command and yields its pid before letting it run.
44
+ def start_process(cmd, launch = Launch.new, &)
37
45
  reader, writer = IO.pipe
38
- pid = start(cmd, writer, launch, &)
46
+ Started.new(pid: start(cmd, writer, launch, &), reader: reader)
47
+ end
48
+
49
+ # [what it printed, its status (nil when killed), whether it ran over budget].
50
+ def await_process(started, budget, launch: Launch.new, timer: BUDGET)
39
51
  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)
52
+ reading = Thread.new { read_until_closed(started.reader, printed) }
53
+ return process_finished(started.pid, text(reading.value)) if timer.call(reading, budget)
54
+
55
+ over_budget(started.pid, reading, launch)
42
56
  ensure
43
- ignoring_errors { reader&.close }
57
+ ignoring_errors { started.reader.close }
44
58
  end
45
59
 
46
60
  private
@@ -7,6 +7,8 @@ require_relative "trigger_command"
7
7
  require_relative "slot_run"
8
8
  require_relative "stale_pipeline_canceller"
9
9
  require_relative "workspaces"
10
+ require_relative "trunk_run"
11
+ require_relative "../trunk/checker"
10
12
 
11
13
  module FunCi
12
14
  module Pipeline
@@ -30,7 +32,7 @@ module FunCi
30
32
  return unknown_commit unless known_commit?
31
33
 
32
34
  start_run
33
- run_in(workspace.acquire(@commit.sha))
35
+ checking_the_trunk { run_in(workspace.acquire(@commit.sha)) }
34
36
  end
35
37
 
36
38
  # The background launcher swaps in a fresh recorder after forking, so
@@ -54,6 +56,13 @@ module FunCi
54
56
  committed.folder_exists? ? committed : Setup::ProjectConfig.new(@project)
55
57
  end
56
58
 
59
+ # The check is recorded by the recorder the run holds once its stages are done.
60
+ def checking_the_trunk
61
+ trunk = TrunkRun.start(@seams.trunk || Trunk::Checker.for(@project), @commit.sha, recorder)
62
+ @io.stdout.puts(trunk.notice) if trunk.notice
63
+ yield.tap { trunk.finish(recorder) }
64
+ end
65
+
57
66
  def handle_config_errors(config)
58
67
  config.validate.each { |e| @io.stdout.puts "fun-ci: #{e}" }
59
68
  unless config.folder_exists?
@@ -6,10 +6,10 @@ require_relative "pipeline_forker"
6
6
  module FunCi
7
7
  module Pipeline
8
8
  # `fun-ci trigger [--background] <commit-hash> <branch>`. --no-validate is
9
- # the 1.x name for --background, kept for one release.
9
+ # the 1.x name for --background, kept until 3.0.
10
10
  class TriggerCommand
11
11
  BACKGROUND = %w[--background --no-validate].freeze
12
- DEPRECATED = "fun-ci: --no-validate is now --background; the old name goes in 2.1."
12
+ DEPRECATED = "fun-ci: --no-validate is now --background; the old name goes in 3.0."
13
13
 
14
14
  def initialize(io:, recorder:, pipeline_forker:, project: Dir.pwd)
15
15
  @io = io
@@ -47,12 +47,16 @@ module FunCi
47
47
  end
48
48
 
49
49
  # The forker answers whether it started a run; one that did says how an
50
- # agent gets its verdict (acceptance-tests.md, AT-9.14).
50
+ # agent gets its verdict (acceptance-tests.md, AT-9.14), and what the
51
+ # forker says of a first fetch of the trunk.
51
52
  def fork_pipeline(commit)
52
53
  db_path = @recorder.db_path
53
54
  @recorder.close
54
- started = @pipeline_forker.call(commit_hash: commit.sha, branch: commit.branch, db_path: db_path)
55
- say_how_to_wait(commit.sha[0, 7]) if started
55
+ forked = @pipeline_forker.call(commit_hash: commit.sha, branch: commit.branch, db_path: db_path)
56
+ return 0 unless forked
57
+
58
+ say_how_to_wait(commit.sha[0, 7])
59
+ @io.stdout.puts(forked.notice) if forked.notice
56
60
  0
57
61
  end
58
62
 
@@ -31,9 +31,11 @@ module FunCi
31
31
  # which masking and injected command runners see; `clock` answers seconds
32
32
  # for the evidence budget; `extractor_runner` makes what runs a project's
33
33
  # own extractors (`run:` entries), given dir:, env: and scratch:;
34
- # `process_table` answers what ps lists, for a stage over budget.
34
+ # `process_table` answers what ps lists, for a stage over budget; `trunk`
35
+ # checks a commit against the trunk (#check(sha) answers a Trunk::Check,
36
+ # or nil for no check), nil for the project's own.
35
37
  Seams = Data.define(:command_runner, :time_budgets, :commit_validator, :recorder, :background_launcher, :workspace,
36
- :stage_dir, :environment, :clock, :extractor_runner, :process_table)
38
+ :stage_dir, :environment, :clock, :extractor_runner, :process_table, :trunk)
37
39
 
38
40
  # Reopened rather than given as a block to Data.define, so tools that read
39
41
  # the source (mutineer) see these as Seams' methods.
@@ -43,7 +45,7 @@ module FunCi
43
45
  workspace: nil, commit_validator: method(:commit_exists?), stage_dir: StageDir.method(:create),
44
46
  environment: ENV.to_h, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
45
47
  extractor_runner: Evidence::CommandRunner.method(:new),
46
- process_table: Evidence::ProcessTable.method(:now) }
48
+ process_table: Evidence::ProcessTable.method(:now), trunk: nil }
47
49
  end
48
50
 
49
51
  def self.commit_exists?(sha) = Open3.capture2e(GitEnvironment::CLEAN, "git", "cat-file", "-t", sha).last.success?