fun_ci 2.1.1-aarch64-linux → 2.3.0-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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -0
- data/README.md +36 -6
- data/lib/fun_ci/agent/cancel_command.rb +52 -0
- data/lib/fun_ci/agent/command_support.rb +7 -0
- data/lib/fun_ci/agent/commands.rb +4 -1
- data/lib/fun_ci/agent/due_in.rb +21 -0
- data/lib/fun_ci/agent/events_command.rb +14 -7
- data/lib/fun_ci/agent/job_events.rb +56 -0
- data/lib/fun_ci/agent/job_json.rb +25 -0
- data/lib/fun_ci/agent/job_report.rb +89 -0
- data/lib/fun_ci/agent/job_why.rb +59 -0
- data/lib/fun_ci/agent/job_why_json.rb +31 -0
- data/lib/fun_ci/agent/job_why_text.rb +59 -0
- data/lib/fun_ci/agent/jobs_command.rb +43 -0
- data/lib/fun_ci/agent/jobs_text.rb +55 -0
- data/lib/fun_ci/agent/live_pipeline.rb +14 -6
- data/lib/fun_ci/agent/options.rb +3 -3
- data/lib/fun_ci/agent/output.rb +14 -2
- data/lib/fun_ci/agent/snapshots.rb +20 -1
- data/lib/fun_ci/agent/span.rb +24 -0
- data/lib/fun_ci/agent/stage_summary.rb +10 -6
- data/lib/fun_ci/agent/starts_in.rb +14 -0
- data/lib/fun_ci/agent/status_command.rb +3 -2
- data/lib/fun_ci/agent/status_json.rb +1 -1
- data/lib/fun_ci/agent/status_text.rb +32 -6
- data/lib/fun_ci/agent/trunk_reading.rb +2 -1
- data/lib/fun_ci/agent/wait_command.rb +2 -1
- data/lib/fun_ci/agent/why_command.rb +6 -2
- data/lib/fun_ci/cli.rb +1 -1
- data/lib/fun_ci/cli_help.rb +4 -1
- data/lib/fun_ci/console/board_data.rb +26 -13
- data/lib/fun_ci/console/console_state.rb +12 -6
- data/lib/fun_ci/console/job_message.rb +39 -0
- data/lib/fun_ci/console/job_order.rb +23 -0
- data/lib/fun_ci/console/job_rows.rb +21 -0
- data/lib/fun_ci/console/key_handler.rb +35 -14
- data/lib/fun_ci/console/paging.rb +26 -0
- data/lib/fun_ci/console/view.rb +18 -9
- data/lib/fun_ci/evidence/settings.rb +15 -8
- data/lib/fun_ci/evidence/settings_check.rb +14 -3
- data/lib/fun_ci/jobs/due.rb +34 -0
- data/lib/fun_ci/jobs/due_jobs.rb +50 -0
- data/lib/fun_ci/jobs/folders.rb +50 -0
- data/lib/fun_ci/jobs/job.rb +31 -0
- data/lib/fun_ci/jobs/job_fork.rb +52 -0
- data/lib/fun_ci/jobs/job_run.rb +104 -0
- data/lib/fun_ci/jobs/locks.rb +30 -0
- data/lib/fun_ci/jobs/schedule.rb +25 -0
- data/lib/fun_ci/jobs/site.rb +17 -0
- data/lib/fun_ci/jobs/standings.rb +50 -0
- data/lib/fun_ci/jobs/state.rb +27 -0
- data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
- data/lib/fun_ci/persistence/active_jobs.rb +60 -0
- data/lib/fun_ci/persistence/database.rb +14 -4
- data/lib/fun_ci/persistence/job_recorder.rb +47 -0
- data/lib/fun_ci/persistence/job_runs.rb +80 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +2 -1
- data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
- data/lib/fun_ci/pipeline/budgets.rb +9 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +42 -10
- data/lib/fun_ci/pipeline/priorities.rb +23 -0
- data/lib/fun_ci/pipeline/process_runner.rb +18 -6
- data/lib/fun_ci/pipeline/progress_reporter.rb +4 -0
- data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
- data/lib/fun_ci/pipeline/slot.rb +12 -1
- data/lib/fun_ci/pipeline/slot_run.rb +19 -4
- data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
- data/lib/fun_ci/pipeline/trigger.rb +17 -6
- data/lib/fun_ci/pipeline/trigger_command.rb +12 -2
- data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
- data/lib/fun_ci/pipeline/worktree_pool.rb +2 -2
- data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
- data/lib/fun_ci/pipeline/worktrees.rb +11 -5
- data/lib/fun_ci/setup/agent_instructions.rb +24 -5
- data/lib/fun_ci/setup/commands.rb +3 -1
- data/lib/fun_ci/setup/hook_script.rb +9 -4
- data/lib/fun_ci/setup/hook_writer.rb +13 -5
- data/lib/fun_ci/setup/installed_hooks.rb +45 -0
- data/lib/fun_ci/setup/installer.rb +3 -2
- data/lib/fun_ci/setup/lfs_hook.rb +50 -0
- data/lib/fun_ci/setup/project_config.rb +13 -1
- data/lib/fun_ci/setup/settings.rb +40 -11
- data/lib/fun_ci/setup/setup_checker.rb +20 -3
- data/lib/fun_ci/trunk/shown.rb +7 -4
- data/lib/fun_ci/version.rb +1 -1
- data/libexec/fun-ci-renderer +0 -0
- metadata +36 -1
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "run_message"
|
|
4
4
|
require_relative "console_events"
|
|
5
|
+
require_relative "job_message"
|
|
5
6
|
|
|
6
7
|
module FunCi
|
|
7
8
|
module Console
|
|
8
9
|
# What the console shows: the page of runs BoardData reads that the View
|
|
9
|
-
# puts on screen, and
|
|
10
|
-
# after the `event`s for what changed
|
|
10
|
+
# puts on screen, the daily and weekly jobs below it, and the clock's
|
|
11
|
+
# time, as a protocol `board` message, after the `event`s for what changed
|
|
12
|
+
# since the last one.
|
|
11
13
|
class ConsoleState
|
|
12
14
|
KEYS = { "up" => :up, "down" => :down, "esc" => :escape, "ctrl_c" => "q", "enter" => :enter }.freeze
|
|
13
15
|
|
|
@@ -26,18 +28,22 @@ module FunCi
|
|
|
26
28
|
|
|
27
29
|
def updates
|
|
28
30
|
@board_data.record_dead_slow_suites
|
|
31
|
+
@board_data.record_dead_jobs
|
|
29
32
|
runs = @board_data.runs
|
|
30
|
-
[*@events.since_last(runs), board(runs)]
|
|
33
|
+
[*@events.since_last(runs), board(runs, @board_data.jobs(runs))]
|
|
31
34
|
end
|
|
32
35
|
|
|
33
36
|
private
|
|
34
37
|
|
|
35
|
-
def board(runs)
|
|
36
|
-
page = @view.page(runs, more: @board_data.more
|
|
38
|
+
def board(runs, jobs)
|
|
39
|
+
page = @view.page(runs, more: @board_data.more?, jobs: jobs)
|
|
37
40
|
{ t: "board", now: @clock.call.to_i, streak: @board_data.streak, **page,
|
|
38
|
-
runs: page[:runs].map { |run| RunMessage.from(run) }, **stale_trunks(runs) }
|
|
41
|
+
runs: page[:runs].map { |run| RunMessage.from(run) }, **job_section(jobs), **stale_trunks(runs) }
|
|
39
42
|
end
|
|
40
43
|
|
|
44
|
+
# Only when there are jobs, as the protocol lets a board leave them out.
|
|
45
|
+
def job_section(jobs) = jobs.empty? ? {} : { jobs: jobs.map { |job| JobMessage.from(job) } }
|
|
46
|
+
|
|
41
47
|
# Only when a trunk is stale, as the protocol lets a board leave it out.
|
|
42
48
|
def stale_trunks(runs)
|
|
43
49
|
stale = @board_data.stale_trunks(runs, now: @clock.call)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Console
|
|
7
|
+
# Where a job stands (Jobs::Standing) as the protocol's `board` carries it
|
|
8
|
+
# under `jobs`: protocol status names and epoch seconds. Formatting them
|
|
9
|
+
# is the renderer's job.
|
|
10
|
+
module JobMessage
|
|
11
|
+
# The protocol's word for a Jobs::State, where it has one of its own:
|
|
12
|
+
# a cancelled job is due again, and one it can't read is shown due.
|
|
13
|
+
STATUS = { "over_budget" => "timeout", "cancelled" => "due", "unknown" => "due" }.freeze
|
|
14
|
+
|
|
15
|
+
def self.from(standing)
|
|
16
|
+
{ project: standing.project, name: standing.name, cadence: standing.cadence,
|
|
17
|
+
status: STATUS.fetch(standing.state, standing.state), **run(standing.run, standing.starts_at),
|
|
18
|
+
due_at: standing.due_at&.to_i }.compact
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# What the latest run says: which, on what, and when; a run waiting its
|
|
22
|
+
# turn (Jobs::Schedule) has not started, and says when it starts.
|
|
23
|
+
def self.run(run, starts_at)
|
|
24
|
+
return {} unless run
|
|
25
|
+
|
|
26
|
+
{ run_id: run[:id], sha: run[:commit_hash], branch: run[:branch], **times(run, starts_at) }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.times(run, starts_at)
|
|
30
|
+
return { starts_at: starts_at.to_i } if starts_at
|
|
31
|
+
|
|
32
|
+
{ started_at: epoch(run[:started_at]), updated_at: epoch(run[:completed_at] || run[:started_at]) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.epoch(iso) = Time.parse(iso).to_i
|
|
36
|
+
private_class_method :run, :times, :epoch
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
# The order of the job section's rows (design.md, The console): what
|
|
6
|
+
# needs you first, then running, waiting its turn to (Jobs::Schedule),
|
|
7
|
+
# due and passed; among equals, the
|
|
8
|
+
# board's projects in its order, then each project's jobs by name. Rows
|
|
9
|
+
# answer #state (Jobs::State), #project and #name.
|
|
10
|
+
module JobOrder
|
|
11
|
+
URGENCY = { "failed" => 0, "lost" => 0, "over_budget" => 1, "running" => 2, "scheduled" => 3, "due" => 4,
|
|
12
|
+
"cancelled" => 4, "passed" => 5 }.freeze
|
|
13
|
+
# A state this fun-ci doesn't know, which a newer one sharing the database may have written.
|
|
14
|
+
UNKNOWN = 6
|
|
15
|
+
|
|
16
|
+
def self.of(rows, projects:)
|
|
17
|
+
rows.sort_by do |row|
|
|
18
|
+
[URGENCY.fetch(row.state, UNKNOWN), projects.index(row.project) || projects.size, row.name]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "job_order"
|
|
4
|
+
require_relative "../jobs/standings"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Console
|
|
8
|
+
# The job section's rows (design.md, The console): where each daily and
|
|
9
|
+
# weekly job of the board's projects stands (Jobs::Standing), in JobOrder.
|
|
10
|
+
class JobRows
|
|
11
|
+
def initialize(db)
|
|
12
|
+
@db = db
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def of(projects, now:)
|
|
16
|
+
standings = projects.flat_map { |project| Jobs::Standings.new(@db, project, now: now).all }
|
|
17
|
+
JobOrder.of(standings, projects: projects)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module FunCi
|
|
4
4
|
module Console
|
|
5
|
-
# What a key means for the runs BoardData loads
|
|
6
|
-
#
|
|
7
|
-
# the
|
|
5
|
+
# What a key means for the runs BoardData loads, and the daily and weekly
|
|
6
|
+
# jobs after them: moving the cursor through both, quitting, and
|
|
7
|
+
# cancelling the run or job under the cursor (a running one after the
|
|
8
|
+
# user confirms with `y`).
|
|
8
9
|
class KeyHandler
|
|
9
10
|
CONFIRMATION_ANSWERS = ["y", "n", :escape].freeze
|
|
10
11
|
|
|
@@ -27,10 +28,6 @@ module FunCi
|
|
|
27
28
|
!@confirm_cancel.nil?
|
|
28
29
|
end
|
|
29
30
|
|
|
30
|
-
def confirmation_run
|
|
31
|
-
@confirm_cancel
|
|
32
|
-
end
|
|
33
|
-
|
|
34
31
|
private
|
|
35
32
|
|
|
36
33
|
def act(key)
|
|
@@ -41,15 +38,16 @@ module FunCi
|
|
|
41
38
|
end
|
|
42
39
|
end
|
|
43
40
|
|
|
44
|
-
# Onto the last run loaded, which loads the next page.
|
|
41
|
+
# Onto the last run loaded, which loads the next page, then into the jobs.
|
|
45
42
|
def move_cursor_down
|
|
46
|
-
|
|
43
|
+
runs = @board_data.runs
|
|
44
|
+
last = runs.length + @board_data.jobs(runs).length - 1
|
|
47
45
|
return if last.negative?
|
|
48
46
|
return @cursor_index = 0 if @cursor_index.nil?
|
|
49
47
|
return unless @cursor_index < last
|
|
50
48
|
|
|
51
49
|
@cursor_index += 1
|
|
52
|
-
@board_data.load_more if @cursor_index ==
|
|
50
|
+
@board_data.load_more if @cursor_index == runs.length - 1
|
|
53
51
|
end
|
|
54
52
|
|
|
55
53
|
def move_cursor_up
|
|
@@ -57,17 +55,40 @@ module FunCi
|
|
|
57
55
|
end
|
|
58
56
|
|
|
59
57
|
def initiate_cancel
|
|
60
|
-
|
|
61
|
-
return unless
|
|
58
|
+
kind, row = under_cursor
|
|
59
|
+
return unless row
|
|
62
60
|
|
|
61
|
+
kind == :job ? offer_job_cancel(row) : offer_run_cancel(row)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# A run waiting to start is cancelled at once; a running one once the user confirms.
|
|
65
|
+
def offer_run_cancel(run)
|
|
63
66
|
@board_data.cancel_run(run[:id]) if run[:status] == "scheduled"
|
|
64
|
-
@confirm_cancel = run if run[:status] == "running"
|
|
67
|
+
@confirm_cancel = [:run, run] if run[:status] == "running"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A job (a Jobs::Standing) waiting its turn is cancelled at once; a
|
|
71
|
+
# running one once the user confirms.
|
|
72
|
+
def offer_job_cancel(job)
|
|
73
|
+
cancel(:job, job) if job.state == "scheduled"
|
|
74
|
+
@confirm_cancel = [:job, job] if job.state == "running"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# [:run, the run] or [:job, the job row] under the cursor, or none.
|
|
78
|
+
def under_cursor
|
|
79
|
+
runs = @board_data.runs
|
|
80
|
+
return [] unless @cursor_index
|
|
81
|
+
return [:run, runs[@cursor_index]] if @cursor_index < runs.length
|
|
82
|
+
|
|
83
|
+
[:job, @board_data.jobs(runs)[@cursor_index - runs.length]]
|
|
65
84
|
end
|
|
66
85
|
|
|
67
86
|
def confirm(key)
|
|
68
|
-
|
|
87
|
+
cancel(*@confirm_cancel) if key == "y"
|
|
69
88
|
@confirm_cancel = nil if CONFIRMATION_ANSWERS.include?(key)
|
|
70
89
|
end
|
|
90
|
+
|
|
91
|
+
def cancel(kind, row) = kind == :job ? @board_data.cancel_job(row.run[:id]) : @board_data.cancel_run(row[:id])
|
|
71
92
|
end
|
|
72
93
|
end
|
|
73
94
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
# How many branches BoardData loads: a page of `page_size` at first, a
|
|
6
|
+
# page more each time the cursor reaches the last one loaded.
|
|
7
|
+
class Paging
|
|
8
|
+
attr_reader :limit
|
|
9
|
+
|
|
10
|
+
def initialize(page_size)
|
|
11
|
+
@page_size = page_size
|
|
12
|
+
@limit = page_size
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def load_more
|
|
16
|
+
@limit += @page_size
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Pages by `page_size` from now on, loading at least one such page.
|
|
20
|
+
def resize(page_size)
|
|
21
|
+
@page_size = page_size
|
|
22
|
+
@limit = [@limit, page_size].max
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/fun_ci/console/view.rb
CHANGED
|
@@ -11,6 +11,8 @@ module FunCi
|
|
|
11
11
|
# table: a page this many rows short of the terminal fits one line per
|
|
12
12
|
# row, however the renderer spaces them (renderer-protocol.md, `board`).
|
|
13
13
|
CHROME_ROWS = 18
|
|
14
|
+
# A blank line and one for the job section, the least the renderer folds it to.
|
|
15
|
+
JOB_LINES = 2
|
|
14
16
|
|
|
15
17
|
def initialize(key_handler:)
|
|
16
18
|
@key_handler = key_handler
|
|
@@ -25,23 +27,30 @@ module FunCi
|
|
|
25
27
|
# :quit when the key ends the session.
|
|
26
28
|
def press(key) = @key_handler.handle_key(key)
|
|
27
29
|
|
|
28
|
-
# `more`: whether the store holds runs beyond `runs`.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
# `more`: whether the store holds runs beyond `runs`. `jobs`: the job
|
|
31
|
+
# section's rows, below the page, which the cursor reaches after the runs.
|
|
32
|
+
def page(runs, more:, jobs: [])
|
|
33
|
+
size = jobs.empty? ? @page_size : [@page_size - JOB_LINES, 0].max
|
|
34
|
+
first = first_shown(runs.size, size)
|
|
35
|
+
{ cursor: cursor_on_page(first, size), confirming: @key_handler.confirming?,
|
|
36
|
+
has_more: more || first + size < runs.size, runs: runs[first, size] || [] }
|
|
33
37
|
end
|
|
34
38
|
|
|
35
39
|
private
|
|
36
40
|
|
|
37
|
-
|
|
41
|
+
# The last page of runs while the cursor is on a job.
|
|
42
|
+
def first_shown(count, size)
|
|
38
43
|
cursor = @key_handler.cursor_index
|
|
39
|
-
|
|
44
|
+
return 0 unless cursor
|
|
45
|
+
|
|
46
|
+
[[cursor, count - 1].min - size + 1, 0].max
|
|
40
47
|
end
|
|
41
48
|
|
|
42
|
-
|
|
49
|
+
# Past the page's runs while the cursor is on a job: a page that holds
|
|
50
|
+
# a job's cursor ends at the last run.
|
|
51
|
+
def cursor_on_page(first, size)
|
|
43
52
|
cursor = @key_handler.cursor_index
|
|
44
|
-
cursor - first if cursor &&
|
|
53
|
+
cursor - first if cursor && size.positive?
|
|
45
54
|
end
|
|
46
55
|
end
|
|
47
56
|
end
|
|
@@ -4,6 +4,7 @@ require "yaml"
|
|
|
4
4
|
require_relative "catalog"
|
|
5
5
|
require_relative "patterns"
|
|
6
6
|
require_relative "settings_check"
|
|
7
|
+
require_relative "../jobs/job"
|
|
7
8
|
|
|
8
9
|
module FunCi
|
|
9
10
|
module Evidence
|
|
@@ -13,11 +14,11 @@ module FunCi
|
|
|
13
14
|
# what is wrong, for `fun-ci check`.
|
|
14
15
|
class Settings
|
|
15
16
|
DEFAULTS = { "budget" => 2, "detect" => true, "skip" => [], "mask" => [], "masking" => true,
|
|
16
|
-
"stages" => {} }.freeze
|
|
17
|
+
"stages" => {}, "jobs" => {} }.freeze
|
|
17
18
|
|
|
18
19
|
# The settings in the config file at `path`, or the defaults without one.
|
|
19
20
|
def self.load(path)
|
|
20
|
-
new(File.exist?(path) ? (YAML.safe_load_file(path) || {})["evidence"] : nil)
|
|
21
|
+
new(File.exist?(path) ? (YAML.safe_load_file(path, aliases: true) || {})["evidence"] : nil)
|
|
21
22
|
rescue Psych::Exception, NoMethodError, TypeError
|
|
22
23
|
new(nil)
|
|
23
24
|
end
|
|
@@ -36,8 +37,12 @@ module FunCi
|
|
|
36
37
|
.then { |sources| Patterns.compile(sources) }
|
|
37
38
|
end
|
|
38
39
|
|
|
39
|
-
# The raw entries for `stage`: those for every stage, then its own
|
|
40
|
-
|
|
40
|
+
# The raw entries for `stage`: those for every stage, then its own; for
|
|
41
|
+
# a job (`jobs/<name>`), those under `jobs:`.
|
|
42
|
+
def entries(stage)
|
|
43
|
+
job = Jobs::Job.named_by(stage)
|
|
44
|
+
stage_list("all") + (job ? list("jobs", job) : stage_list(stage))
|
|
45
|
+
end
|
|
41
46
|
|
|
42
47
|
# The globs whose files are stamped when the stage starts, so what it
|
|
43
48
|
# wrote to them can be told from what was there before.
|
|
@@ -54,10 +59,12 @@ module FunCi
|
|
|
54
59
|
def values = @raw.is_a?(Hash) ? DEFAULTS.merge(@raw) : DEFAULTS
|
|
55
60
|
def value(key) = values[key]
|
|
56
61
|
|
|
57
|
-
def stage_list(stage)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
def stage_list(stage) = list("stages", stage)
|
|
63
|
+
|
|
64
|
+
def list(setting, name)
|
|
65
|
+
lists = value(setting)
|
|
66
|
+
found = lists[name] if lists.is_a?(Hash)
|
|
67
|
+
found.is_a?(Array) ? found : []
|
|
61
68
|
end
|
|
62
69
|
end
|
|
63
70
|
end
|
|
@@ -8,7 +8,7 @@ module FunCi
|
|
|
8
8
|
# What is wrong with the `evidence` key of .fun-ci/config, each as a line
|
|
9
9
|
# `fun-ci check` prints.
|
|
10
10
|
class SettingsCheck
|
|
11
|
-
KNOWN = %w[budget detect skip mask masking stages].freeze
|
|
11
|
+
KNOWN = %w[budget detect skip mask masking stages jobs].freeze
|
|
12
12
|
STAGES = %w[all lint build fast slow].freeze
|
|
13
13
|
|
|
14
14
|
# "2", 2, 2.5 or "2s" as seconds, or nil.
|
|
@@ -28,7 +28,7 @@ module FunCi
|
|
|
28
28
|
return [] if @raw.nil?
|
|
29
29
|
return ["evidence must be a mapping, not #{@raw.inspect}"] unless @raw.is_a?(Hash)
|
|
30
30
|
|
|
31
|
-
unknown + budget + stages
|
|
31
|
+
unknown + budget + stages + jobs
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
private
|
|
@@ -48,12 +48,23 @@ module FunCi
|
|
|
48
48
|
lists.flat_map { |stage, entries| stage_errors(stage, entries) }
|
|
49
49
|
end
|
|
50
50
|
|
|
51
|
+
def jobs
|
|
52
|
+
lists = @raw.fetch("jobs", {})
|
|
53
|
+
return ["evidence.jobs must be a mapping of job to entries"] unless lists.is_a?(Hash)
|
|
54
|
+
|
|
55
|
+
lists.flat_map { |job, entries| entry_errors("evidence.jobs.#{job}", entries) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def entry_errors(where, entries)
|
|
59
|
+
Array(entries).filter_map { |entry| refusal(entry) }.map { |message| "#{where}: #{message}" }
|
|
60
|
+
end
|
|
61
|
+
|
|
51
62
|
def stage_errors(stage, entries)
|
|
52
63
|
unless STAGES.include?(stage)
|
|
53
64
|
return ["evidence.stages has no stage '#{stage}': use all, lint, build, fast or slow"]
|
|
54
65
|
end
|
|
55
66
|
|
|
56
|
-
|
|
67
|
+
entry_errors("evidence.stages.#{stage}", entries)
|
|
57
68
|
end
|
|
58
69
|
|
|
59
70
|
def refusal(entry)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Jobs
|
|
7
|
+
# Whether a job is due, from its latest run alone ({ status:, started_at: },
|
|
8
|
+
# or nil when it never ran): when it never ran, when that run was
|
|
9
|
+
# cancelled, or a period after that run started, unless it still runs or
|
|
10
|
+
# waits to (design.md, Daily and weekly jobs).
|
|
11
|
+
class Due
|
|
12
|
+
def initialize(latest, period, now:)
|
|
13
|
+
@latest = latest
|
|
14
|
+
@period = period
|
|
15
|
+
@now = now
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def now?
|
|
19
|
+
return true if @latest.nil? || @latest[:status] == "cancelled"
|
|
20
|
+
|
|
21
|
+
!running? && again <= @now
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# When it is due again, or nil while it is due now or still runs.
|
|
25
|
+
def at = now? || running? ? nil : again
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# A run waiting its turn (Schedule) counts as running: it is the job's run.
|
|
30
|
+
def running? = %w[running scheduled].include?(@latest[:status])
|
|
31
|
+
def again = Time.parse(@latest[:started_at]) + @period
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "standings"
|
|
5
|
+
require_relative "schedule"
|
|
6
|
+
require_relative "../pipeline/run_canceller"
|
|
7
|
+
require_relative "../setup/project_config"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
module Jobs
|
|
11
|
+
# The project's jobs that are due at `now`, read from the database without
|
|
12
|
+
# taking any lock: who starts a job is decided again, under its lock, by
|
|
13
|
+
# the process that runs it. A job left running by a process that died is
|
|
14
|
+
# recorded failed first, or it would never be due again, and no process
|
|
15
|
+
# would start to find it dead.
|
|
16
|
+
class DueJobs
|
|
17
|
+
BEGUN = %w[running scheduled].freeze
|
|
18
|
+
DAY = 86_400
|
|
19
|
+
|
|
20
|
+
def initialize(project, db, now:)
|
|
21
|
+
@project = project
|
|
22
|
+
@db = db
|
|
23
|
+
@now = now
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def list = standings.select(&:due_now?).map(&:job)
|
|
27
|
+
|
|
28
|
+
# [job, when its turn to start comes] for each due job (Schedule): a day
|
|
29
|
+
# shared evenly among the project's jobs apart, or its `job_spacing`,
|
|
30
|
+
# after its jobs still running or waiting to. Each is due again a
|
|
31
|
+
# period after its own start, so the spread holds from day to day.
|
|
32
|
+
def starts
|
|
33
|
+
all = standings
|
|
34
|
+
Schedule.new(spacing(all.size), begun(all), now: @now).starts(all.select(&:due_now?).map(&:job))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def spacing(jobs) = Setup::ProjectConfig.new(@project).job_spacing || (DAY / [jobs, 1].max)
|
|
40
|
+
|
|
41
|
+
# When each of the project's jobs still running or waiting to began, or is to.
|
|
42
|
+
def begun(all) = all.select { |one| BEGUN.include?(one.state) }.map { |one| Time.parse(one.run[:started_at]) }
|
|
43
|
+
|
|
44
|
+
def standings
|
|
45
|
+
Pipeline::RunCanceller.new.record_dead_jobs(@db)
|
|
46
|
+
Standings.new(@db, @project, now: @now).all
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "job"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Jobs
|
|
7
|
+
# The jobs a project keeps in .fun-ci/daily/ and .fun-ci/weekly/: each
|
|
8
|
+
# executable `<name>.sh`. A script that can't run, or a name in both
|
|
9
|
+
# folders, is no job; #errors says why, for `fun-ci check`.
|
|
10
|
+
class Folders
|
|
11
|
+
CADENCES = Job::PERIODS.keys
|
|
12
|
+
# A name a command line keeps whole, since agents paste it: fun-ci why --job NAME.
|
|
13
|
+
NAME = /\A[A-Za-z0-9._-]+\z/
|
|
14
|
+
|
|
15
|
+
def initialize(project_root)
|
|
16
|
+
@fun_ci_dir = File.join(project_root, ".fun-ci")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# By name.
|
|
20
|
+
def jobs
|
|
21
|
+
all = scripts
|
|
22
|
+
twice = twice(all)
|
|
23
|
+
all.select { |job| runnable?(job) && NAME.match?(job.name) && !twice.include?(job.name) }.sort_by(&:name)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def errors
|
|
27
|
+
all = scripts
|
|
28
|
+
all.reject { |job| runnable?(job) }.map { |job| "#{path(job)} is not executable" } +
|
|
29
|
+
all.reject { |job| NAME.match?(job.name) }.map { |job| misnamed(job) } +
|
|
30
|
+
twice(all).map { |name| "the job #{name} is in both .fun-ci/daily/ and .fun-ci/weekly/: rename one" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def runnable?(job) = File.executable?(job.script)
|
|
36
|
+
def path(job) = ".fun-ci/#{job.cadence}/#{job.name}.sh"
|
|
37
|
+
def misnamed(job) = "the job '#{job.name}' needs a name of letters, digits, '.', '_' and '-': rename #{path(job)}"
|
|
38
|
+
|
|
39
|
+
def twice(all) = all.group_by(&:name).select { |_, jobs| jobs.size > 1 }.keys.sort
|
|
40
|
+
|
|
41
|
+
def scripts
|
|
42
|
+
CADENCES.flat_map do |cadence|
|
|
43
|
+
Dir.glob("*.sh", base: File.join(@fun_ci_dir, cadence)).map do |file|
|
|
44
|
+
Job.new(name: File.basename(file, ".sh"), cadence: cadence, script: File.join(@fun_ci_dir, cadence, file))
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Jobs
|
|
7
|
+
# A script a project runs at most once a day or once a week, named after
|
|
8
|
+
# it (design.md, Daily and weekly jobs). `cadence` is "daily" or "weekly".
|
|
9
|
+
Job = Data.define(:name, :cadence, :script)
|
|
10
|
+
|
|
11
|
+
class Job
|
|
12
|
+
PERIODS = { "daily" => 86_400, "weekly" => 604_800 }.freeze
|
|
13
|
+
# Every job's, the weekly ones' too.
|
|
14
|
+
BUDGET = 86_400
|
|
15
|
+
# How a job is named where a stage would be.
|
|
16
|
+
STAGE = "jobs/"
|
|
17
|
+
|
|
18
|
+
# The name of the job a stage name names, or nil for a pipeline stage's.
|
|
19
|
+
def self.named_by(stage) = (stage.delete_prefix(STAGE) if stage.start_with?(STAGE))
|
|
20
|
+
|
|
21
|
+
# Seconds from one run's start until the job is due again.
|
|
22
|
+
def period = PERIODS.fetch(cadence)
|
|
23
|
+
|
|
24
|
+
# What evidence knows it by: `evidence: jobs: <name>:` in .fun-ci/config.
|
|
25
|
+
def stage = "#{STAGE}#{name}"
|
|
26
|
+
|
|
27
|
+
# The shell command that runs it on a commit, its path quoted.
|
|
28
|
+
def command(sha) = "#{Shellwords.escape(script)} #{sha}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "job_run"
|
|
4
|
+
require_relative "../persistence/database"
|
|
5
|
+
require_relative "../pipeline/worktrees"
|
|
6
|
+
require_relative "../pipeline/priorities"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Jobs
|
|
10
|
+
# Starts a job in a process of its own (architecture.md, Daily and weekly
|
|
11
|
+
# jobs). The trigger that forks it exits when its pipeline ends, and the
|
|
12
|
+
# terminal of the commit may close; a job can run for hours, so it leads a
|
|
13
|
+
# session of its own, prints nowhere, and opens its own connection, since
|
|
14
|
+
# a child must not inherit one. `priorities` says what its script runs under.
|
|
15
|
+
class JobFork
|
|
16
|
+
def initialize(project:, db_path:, priorities: Pipeline::Priorities.for(RUBY_PLATFORM))
|
|
17
|
+
@project = project
|
|
18
|
+
@db_path = db_path
|
|
19
|
+
@priorities = priorities
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# at: when the job's turn to start comes (Schedule); now when nil.
|
|
23
|
+
def start(job, commit, at: nil) = Process.detach(fork { run_in_child(job, commit, at) })
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def run_in_child(job, commit, at)
|
|
28
|
+
Process.setsid
|
|
29
|
+
silence
|
|
30
|
+
db = Persistence::Database.connection(@db_path)
|
|
31
|
+
JobRun.new(job, commit, site(db), Pipeline::Seams.new(priorities: @priorities)).start(at: at)
|
|
32
|
+
ensure
|
|
33
|
+
db&.close
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The process's own descriptors, whatever $stdout and $stderr stand for
|
|
37
|
+
# (a test's StringIO, a guard's file), onto an open /dev/null, since
|
|
38
|
+
# reopening by path can't change a stream's access mode.
|
|
39
|
+
def silence
|
|
40
|
+
File.open(File::NULL, File::RDWR) do |null|
|
|
41
|
+
(0..2).each { |fd| IO.for_fd(fd, autoclose: false).reopen(null) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def site(db)
|
|
46
|
+
worktrees = Pipeline::Worktrees.new(@project)
|
|
47
|
+
Site.new(project: @project, db: db, worktrees: worktrees, locks: Locks.new(worktrees.jobs_root),
|
|
48
|
+
clock: -> { Time.now })
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|