fun_ci 2.0.2 → 2.1.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +69 -0
- data/README.md +31 -5
- data/lib/fun_ci/agent/command_support.rb +14 -1
- data/lib/fun_ci/agent/commands.rb +1 -0
- data/lib/fun_ci/agent/context.rb +3 -2
- data/lib/fun_ci/agent/events.rb +19 -5
- data/lib/fun_ci/agent/events_command.rb +1 -1
- data/lib/fun_ci/agent/exit_code.rb +1 -1
- data/lib/fun_ci/agent/next_step.rb +26 -0
- data/lib/fun_ci/agent/options.rb +6 -4
- data/lib/fun_ci/agent/output.rb +15 -2
- data/lib/fun_ci/agent/report_stage.rb +1 -1
- data/lib/fun_ci/agent/reports.rb +13 -9
- data/lib/fun_ci/agent/run_details.rb +31 -0
- data/lib/fun_ci/agent/run_report.rb +4 -3
- data/lib/fun_ci/agent/runs_text.rb +7 -4
- data/lib/fun_ci/agent/snapshots.rb +13 -3
- data/lib/fun_ci/agent/status_command.rb +2 -2
- data/lib/fun_ci/agent/status_json.rb +2 -1
- data/lib/fun_ci/agent/status_text.rb +13 -2
- data/lib/fun_ci/agent/trunk_json.rb +32 -0
- data/lib/fun_ci/agent/trunk_reading.rb +53 -0
- data/lib/fun_ci/agent/trunk_text.rb +50 -0
- data/lib/fun_ci/agent/trunk_verdict.rb +20 -0
- data/lib/fun_ci/agent/trunk_why.rb +52 -0
- data/lib/fun_ci/agent/trunk_why_json.rb +23 -0
- data/lib/fun_ci/agent/wait_command.rb +5 -5
- data/lib/fun_ci/agent/why_command.rb +11 -0
- data/lib/fun_ci/cli.rb +1 -1
- data/lib/fun_ci/cli_help.rb +4 -2
- data/lib/fun_ci/console/board_data.rb +22 -8
- data/lib/fun_ci/console/cancelled_folding.rb +19 -0
- data/lib/fun_ci/console/console_events.rb +5 -2
- data/lib/fun_ci/console/console_log.rb +4 -1
- data/lib/fun_ci/console/console_state.rb +7 -1
- data/lib/fun_ci/console/row_order.rb +31 -0
- data/lib/fun_ci/console/run_message.rb +9 -2
- data/lib/fun_ci/console/trunk_events.rb +43 -0
- data/lib/fun_ci/console/trunk_marks.rb +53 -0
- data/lib/fun_ci/console/view.rb +9 -8
- data/lib/fun_ci/evidence/caps.rb +7 -2
- data/lib/fun_ci/persistence/active_runs.rb +5 -4
- data/lib/fun_ci/persistence/database.rb +23 -2
- data/lib/fun_ci/persistence/null_recorder.rb +8 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +3 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +24 -3
- data/lib/fun_ci/persistence/trunk_checks.rb +68 -0
- data/lib/fun_ci/persistence/trunk_fetches.rb +57 -0
- data/lib/fun_ci/persistence/trunk_recording.rb +49 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +17 -2
- data/lib/fun_ci/pipeline/process_runner.rb +18 -4
- data/lib/fun_ci/pipeline/trigger.rb +10 -1
- data/lib/fun_ci/pipeline/trigger_command.rb +9 -5
- data/lib/fun_ci/pipeline/trigger_params.rb +5 -3
- data/lib/fun_ci/pipeline/trunk_run.rb +81 -0
- data/lib/fun_ci/setup/agent_instructions.rb +3 -0
- data/lib/fun_ci/setup/project_config.rb +3 -1
- data/lib/fun_ci/setup/settings.rb +35 -3
- data/lib/fun_ci/setup/setup_checker.rb +7 -2
- data/lib/fun_ci/setup/trunk_report.rb +28 -0
- data/lib/fun_ci/trunk/check.rb +56 -0
- data/lib/fun_ci/trunk/checker.rb +91 -0
- data/lib/fun_ci/trunk/description.rb +52 -0
- data/lib/fun_ci/trunk/fetch.rb +58 -0
- data/lib/fun_ci/trunk/first_fetch.rb +27 -0
- data/lib/fun_ci/trunk/git.rb +102 -0
- data/lib/fun_ci/trunk/local.rb +58 -0
- data/lib/fun_ci/trunk/merge_check.rb +54 -0
- data/lib/fun_ci/trunk/regions.rb +38 -0
- data/lib/fun_ci/trunk/resolver.rb +64 -0
- data/lib/fun_ci/trunk/shown.rb +47 -0
- data/lib/fun_ci/trunk/tip_reader.rb +39 -0
- data/lib/fun_ci/version.rb +1 -1
- metadata +30 -1
data/lib/fun_ci/evidence/caps.rb
CHANGED
|
@@ -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
|
-
|
|
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) =
|
|
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
|
-
|
|
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,
|
|
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]]
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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?
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../trunk/check"
|
|
4
|
+
require_relative "../trunk/checker"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Pipeline
|
|
8
|
+
# A run's check against the trunk (architecture.md, Checking against the
|
|
9
|
+
# trunk). It begins before the stages, where the database may be used
|
|
10
|
+
# (claiming a fetch, recording its process, reading the other branches'
|
|
11
|
+
# heads); finishes in a thread beside them, which touches no database,
|
|
12
|
+
# since the slow suite's fork closes the recorder, checking the other
|
|
13
|
+
# heads too when the trunk has moved; and is recorded once the stages are
|
|
14
|
+
# done, through whichever recorder the run holds then. Whatever goes wrong
|
|
15
|
+
# is recorded as an unknown check rather than raised into the run.
|
|
16
|
+
class TrunkRun
|
|
17
|
+
Result = Trunk::Checker::Result
|
|
18
|
+
# What the thread found: the run's own result, and the other heads' checks.
|
|
19
|
+
Found = Data.define(:result, :rechecks)
|
|
20
|
+
|
|
21
|
+
# For a project that checks no trunk.
|
|
22
|
+
class Skipped
|
|
23
|
+
def finish(_recorder) = nil
|
|
24
|
+
def notice = nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# trunk: begins a check (#start(sha, fetches), nil for none), finishes it
|
|
28
|
+
# (#finish(pending)) and checks other heads against a tip (#recheck(heads, tip)).
|
|
29
|
+
def self.start(trunk, sha, recorder)
|
|
30
|
+
pending = trunk.start(sha, recorder.trunk_fetches) { |pid| recorder.trunk_fetch_process(pid) }
|
|
31
|
+
return Skipped.new unless pending
|
|
32
|
+
|
|
33
|
+
recorder.trunk_check_started
|
|
34
|
+
heads = recorder.trunk_heads(except: sha)
|
|
35
|
+
new(Thread.new { found(trunk, pending, sha, heads) }, trunk.notice(pending))
|
|
36
|
+
rescue StandardError => e
|
|
37
|
+
new(Thread.new { Found.new(result: failed(sha, e), rechecks: []) })
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.found(trunk, pending, sha, heads)
|
|
41
|
+
rechecked(trunk, heads, Found.new(result: finished(trunk, pending, sha), rechecks: []))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.finished(trunk, pending, sha)
|
|
45
|
+
trunk.finish(pending)
|
|
46
|
+
rescue StandardError => e
|
|
47
|
+
failed(sha, e)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# A recheck that goes wrong costs the other heads a fresher check, never the run its own.
|
|
51
|
+
def self.rechecked(trunk, heads, found)
|
|
52
|
+
tip = found.result.check&.tip
|
|
53
|
+
tip ? found.with(rechecks: trunk.recheck(heads, tip)) : found
|
|
54
|
+
rescue StandardError
|
|
55
|
+
found
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.failed(sha, error)
|
|
59
|
+
check = Trunk::Check.new(commit: sha, tip: nil,
|
|
60
|
+
merge: Trunk::Merge.unknown("the check failed: #{error.message}"))
|
|
61
|
+
Result.new(check: check, fetched: nil)
|
|
62
|
+
end
|
|
63
|
+
private_class_method :found, :finished, :rechecked, :failed
|
|
64
|
+
|
|
65
|
+
attr_reader :notice
|
|
66
|
+
|
|
67
|
+
# notice: what to tell the developer of a first fetch, or nil.
|
|
68
|
+
def initialize(thread, notice = nil)
|
|
69
|
+
@thread = thread
|
|
70
|
+
@notice = notice
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def finish(recorder)
|
|
74
|
+
found = @thread.value
|
|
75
|
+
result = found.result
|
|
76
|
+
recorder.trunk_fetched(result.fetched, result.check&.tip) if result.fetched
|
|
77
|
+
[result.check, *found.rechecks].compact.each { |check| recorder.trunk_checked(check) }
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -15,6 +15,9 @@ module FunCi
|
|
|
15
15
|
`fun-ci wait` command it prints, in the background, and act on its exit
|
|
16
16
|
code: 0 passed, 1 failed, 2 over budget, 3 undecided, 4 superseded.
|
|
17
17
|
`fun-ci wait --need all` must exit 0 before the work is called done.
|
|
18
|
+
Before calling work done, run `fun-ci wait --need all --trunk`; 6 means
|
|
19
|
+
the commit conflicts with the trunk, which has to be integrated first,
|
|
20
|
+
as it prints.
|
|
18
21
|
`fun-ci runs` lists recent runs, and `fun-ci status` says where one stands.
|
|
19
22
|
MD
|
|
20
23
|
|
|
@@ -38,6 +38,8 @@ module FunCi
|
|
|
38
38
|
def any_project_presets = preset_candidates.reject(&:because).map { |found| found.preset.name }
|
|
39
39
|
|
|
40
40
|
def worktree_slots = settings.worktree_slots
|
|
41
|
+
def trunk = settings.trunk
|
|
42
|
+
def trunk_fetch = settings.trunk_fetch
|
|
41
43
|
|
|
42
44
|
def script_path(stage)
|
|
43
45
|
File.join(@fun_ci_dir, "#{stage}.sh")
|
|
@@ -49,7 +51,7 @@ module FunCi
|
|
|
49
51
|
|
|
50
52
|
private
|
|
51
53
|
|
|
52
|
-
def settings = Settings.
|
|
54
|
+
def settings = Settings.at(File.join(@fun_ci_dir, "config"))
|
|
53
55
|
|
|
54
56
|
def preset_candidates
|
|
55
57
|
evidence = Evidence::Settings.load(File.join(@fun_ci_dir, "config"))
|