fun_ci 2.2.0-x86_64-linux → 2.4.0-x86_64-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 +141 -0
- data/README.md +19 -11
- data/lib/fun_ci/agent/cancel_command.rb +52 -0
- data/lib/fun_ci/agent/commands.rb +3 -1
- data/lib/fun_ci/agent/job_events.rb +26 -7
- data/lib/fun_ci/agent/job_json.rb +10 -1
- data/lib/fun_ci/agent/job_report.rb +10 -6
- data/lib/fun_ci/agent/job_why_text.rb +11 -3
- data/lib/fun_ci/agent/jobs_text.rb +8 -3
- data/lib/fun_ci/agent/live_pipeline.rb +7 -4
- data/lib/fun_ci/agent/snapshots.rb +5 -1
- data/lib/fun_ci/agent/starts_in.rb +14 -0
- data/lib/fun_ci/agent/status_json.rb +1 -1
- data/lib/fun_ci/agent/status_text.rb +15 -3
- data/lib/fun_ci/agent/trunk_reading.rb +2 -1
- data/lib/fun_ci/agent/wait_command.rb +2 -1
- data/lib/fun_ci/cli.rb +3 -1
- data/lib/fun_ci/cli_command_help.rb +51 -0
- data/lib/fun_ci/cli_help.rb +11 -10
- data/lib/fun_ci/console/board_data.rb +7 -2
- data/lib/fun_ci/console/job_message.rb +12 -6
- data/lib/fun_ci/console/job_order.rb +5 -4
- data/lib/fun_ci/console/key_handler.rb +3 -1
- data/lib/fun_ci/console/trunk_marks.rb +3 -1
- data/lib/fun_ci/evidence/about_stage.rb +1 -1
- data/lib/fun_ci/evidence/collector.rb +6 -3
- data/lib/fun_ci/evidence/outcome.rb +6 -3
- data/lib/fun_ci/evidence/settings.rb +1 -1
- data/lib/fun_ci/jobs/due.rb +4 -3
- data/lib/fun_ci/jobs/due_jobs.rb +26 -2
- data/lib/fun_ci/jobs/job_fork.rb +19 -12
- data/lib/fun_ci/jobs/job_run.rb +26 -7
- data/lib/fun_ci/jobs/locks.rb +1 -1
- data/lib/fun_ci/jobs/schedule.rb +25 -0
- data/lib/fun_ci/jobs/site.rb +10 -3
- data/lib/fun_ci/jobs/standings.rb +4 -0
- data/lib/fun_ci/jobs/state.rb +3 -2
- data/lib/fun_ci/jobs/wall_clock_wait.rb +29 -0
- data/lib/fun_ci/persistence/active_jobs.rb +15 -18
- data/lib/fun_ci/persistence/active_runs.rb +5 -3
- data/lib/fun_ci/persistence/died.rb +21 -0
- data/lib/fun_ci/persistence/job_recorder.rb +1 -0
- data/lib/fun_ci/persistence/job_runs.rb +12 -0
- data/lib/fun_ci/persistence/null_recorder.rb +1 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +1 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +13 -9
- data/lib/fun_ci/persistence/stage_job.rb +12 -0
- data/lib/fun_ci/pipeline/budgets.rb +9 -0
- data/lib/fun_ci/pipeline/group_kill.rb +38 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +14 -9
- data/lib/fun_ci/pipeline/priorities.rb +23 -0
- data/lib/fun_ci/pipeline/process_runner.rb +13 -6
- data/lib/fun_ci/pipeline/process_tree.rb +37 -0
- data/lib/fun_ci/pipeline/progress_reporter.rb +5 -0
- data/lib/fun_ci/pipeline/run_canceller.rb +15 -3
- data/lib/fun_ci/pipeline/slot.rb +11 -0
- data/lib/fun_ci/pipeline/slot_run.rb +19 -4
- data/lib/fun_ci/pipeline/stage_end.rb +2 -1
- data/lib/fun_ci/pipeline/trigger.rb +17 -6
- data/lib/fun_ci/pipeline/trigger_command.rb +6 -2
- data/lib/fun_ci/pipeline/trigger_params.rb +20 -9
- data/lib/fun_ci/pipeline/workspaces.rb +2 -1
- data/lib/fun_ci/pipeline/worktree_pool.rb +22 -5
- data/lib/fun_ci/pipeline/worktree_prune.rb +9 -4
- data/lib/fun_ci/pipeline/worktrees.rb +39 -6
- data/lib/fun_ci/setup/agent_instructions.rb +21 -5
- data/lib/fun_ci/setup/commands.rb +19 -8
- data/lib/fun_ci/setup/eslint_detection.rb +38 -0
- 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/init_options.rb +34 -0
- data/lib/fun_ci/setup/installed_hooks.rb +45 -0
- data/lib/fun_ci/setup/installer.rb +44 -9
- data/lib/fun_ci/setup/lfs_hook.rb +50 -0
- data/lib/fun_ci/setup/placeholders.rb +30 -0
- data/lib/fun_ci/setup/project_config.rb +8 -1
- data/lib/fun_ci/setup/settings.rb +59 -10
- data/lib/fun_ci/setup/setup_checker.rb +16 -3
- data/lib/fun_ci/setup/stage_templates/javascript.rb +14 -4
- data/lib/fun_ci/setup/stage_templates/placeholders.rb +21 -0
- data/lib/fun_ci/setup/stage_templates.rb +6 -4
- data/lib/fun_ci/setup/template_writer.rb +3 -5
- 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 +17 -1
|
@@ -5,6 +5,8 @@ require_relative "verdict"
|
|
|
5
5
|
require_relative "digest"
|
|
6
6
|
require_relative "trunk_text"
|
|
7
7
|
require_relative "job_report"
|
|
8
|
+
require_relative "starts_in"
|
|
9
|
+
require_relative "../pipeline/budgets"
|
|
8
10
|
|
|
9
11
|
module FunCi
|
|
10
12
|
module Agent
|
|
@@ -29,10 +31,13 @@ module FunCi
|
|
|
29
31
|
# ` soak (weekly job) FAILED fun-ci why --job soak`; `where` names the
|
|
30
32
|
# commit it tested when that is another.
|
|
31
33
|
def self.job_line(job, where = "")
|
|
32
|
-
said = " #{job.name} (#{job.cadence} job) #{WORDS.fetch(job.state, job.state)}#{where}"
|
|
34
|
+
said = " #{job.name} (#{job.cadence} job) #{WORDS.fetch(job.state, job.state)}#{starts(job)}#{where}"
|
|
33
35
|
job.needs_you? ? "#{said} fun-ci why --job #{job.name}" : said
|
|
34
36
|
end
|
|
35
37
|
|
|
38
|
+
# `, starts in 8m` for a job waiting its turn.
|
|
39
|
+
def self.starts(job) = job.starts_in ? ", #{StartsIn.words(job.starts_in)}" : ""
|
|
40
|
+
|
|
36
41
|
# The trunk lines; nothing while the check is going, unless asked about
|
|
37
42
|
# (right after a commit it would say nothing useful).
|
|
38
43
|
def self.trunk(report, asked)
|
|
@@ -45,10 +50,17 @@ module FunCi
|
|
|
45
50
|
|
|
46
51
|
def self.stage_line(stage, needed)
|
|
47
52
|
seconds = stage.seconds ? format("%6.1fs", stage.seconds) : " " * 7
|
|
48
|
-
note = needed.include?(stage.name) ? "" : " (not needed)"
|
|
53
|
+
note = (needed.include?(stage.name) ? "" : " (not needed)") + longer(stage)
|
|
49
54
|
" #{stage.name.ljust(6)} #{WORDS.fetch(stage.state, stage.state).ljust(12)}#{seconds}#{note}".rstrip
|
|
50
55
|
end
|
|
51
56
|
|
|
57
|
+
# Only a cold worktree (WorktreePool) gives a stage more than its usual budget
|
|
58
|
+
# (AT-1.14), which a run in the background says nowhere else.
|
|
59
|
+
def self.longer(stage)
|
|
60
|
+
usual = Pipeline::DEFAULT_BUDGETS[stage.name]
|
|
61
|
+
stage.budget && usual && stage.budget > usual ? " (cold worktree, budget #{stage.budget}s)" : ""
|
|
62
|
+
end
|
|
63
|
+
|
|
52
64
|
def self.footer(report)
|
|
53
65
|
return ["fun-ci why #{report.sha[0, 7]} #{report.deciding}"] if report.deciding
|
|
54
66
|
return ["fun-ci why #{report.sha[0, 7]} trunk"] if report.trunk&.state == "conflicts"
|
|
@@ -56,7 +68,7 @@ module FunCi
|
|
|
56
68
|
|
|
57
69
|
["Superseded by #{report.superseded_by[0, 7]}."]
|
|
58
70
|
end
|
|
59
|
-
private_class_method :stage_line, :footer, :job_lines, :job_line
|
|
71
|
+
private_class_method :stage_line, :longer, :footer, :job_lines, :job_line, :starts
|
|
60
72
|
end
|
|
61
73
|
end
|
|
62
74
|
end
|
|
@@ -28,7 +28,8 @@ module FunCi
|
|
|
28
28
|
return shown(check) if check
|
|
29
29
|
|
|
30
30
|
started = run[:trunk_started_at]
|
|
31
|
-
started && Trunk::Shown.unchecked(started: Time.parse(started), now: @clock.now
|
|
31
|
+
started && Trunk::Shown.unchecked(started: Time.parse(started), now: @clock.now,
|
|
32
|
+
stages_running: !run[:trigger_pid].nil?)
|
|
32
33
|
end
|
|
33
34
|
|
|
34
35
|
# Checks the commit against where the trunk is now, without fetching, and keeps the check,
|
|
@@ -52,9 +52,10 @@ module FunCi
|
|
|
52
52
|
ExitCode::FOR.fetch(report.verdict)
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
+
# A waiter keeps its run from being superseded, unless it follows the branch to the newer commit.
|
|
55
56
|
def poll(sha, options)
|
|
56
57
|
@context.pipeline.watch(@context.db)
|
|
57
|
-
reports.mark_waited(sha, @context.clock.now)
|
|
58
|
+
reports.mark_waited(sha, @context.clock.now) unless options.follow_branch
|
|
58
59
|
report_for(sha, options) || start_after_grace(sha)
|
|
59
60
|
end
|
|
60
61
|
|
data/lib/fun_ci/cli.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "../fun_ci"
|
|
4
4
|
require_relative "cli_help"
|
|
5
|
+
require_relative "cli_command_help"
|
|
5
6
|
require_relative "pipeline/trigger_params"
|
|
6
7
|
require_relative "persistence/state_dir"
|
|
7
8
|
require_relative "setup/commands"
|
|
@@ -17,7 +18,7 @@ module FunCi
|
|
|
17
18
|
"prune" => :run_prune,
|
|
18
19
|
"extract" => :run_extract
|
|
19
20
|
}.freeze
|
|
20
|
-
AGENT_COMMANDS = %w[status runs wait events why jobs].freeze
|
|
21
|
+
AGENT_COMMANDS = %w[status runs wait events why jobs cancel].freeze
|
|
21
22
|
|
|
22
23
|
def self.default_db_dir = Persistence::StateDir.path(ENV)
|
|
23
24
|
|
|
@@ -37,6 +38,7 @@ module FunCi
|
|
|
37
38
|
return CliHelp.version(@io.stdout) if subcommand == "--version"
|
|
38
39
|
|
|
39
40
|
return unknown_command(subcommand) unless ROUTES.key?(subcommand) || AGENT_COMMANDS.include?(subcommand)
|
|
41
|
+
return CliCommandHelp.help(@io.stdout, subcommand) if CliCommandHelp.asked?(args.drop(1))
|
|
40
42
|
|
|
41
43
|
dispatch(subcommand, args.drop(1))
|
|
42
44
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cli_help"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
# What `fun-ci COMMAND --help` prints in place of running the command: its
|
|
7
|
+
# usage, what it is for and the options it takes, the last two read from
|
|
8
|
+
# `fun-ci --help`, whose options name in brackets the commands taking them.
|
|
9
|
+
module CliCommandHelp
|
|
10
|
+
FLAGS = %w[-h --help].freeze
|
|
11
|
+
USAGES = {
|
|
12
|
+
"trigger" => "fun-ci trigger [--background] <commit-hash> <branch>",
|
|
13
|
+
"console" => "fun-ci console",
|
|
14
|
+
"init" => "fun-ci init [--everything] [--stack NAME]",
|
|
15
|
+
"install-hooks" => "fun-ci install-hooks [post-commit | pre-push]",
|
|
16
|
+
"check" => "fun-ci check",
|
|
17
|
+
"prune" => "fun-ci prune",
|
|
18
|
+
"status" => "fun-ci status [REV] [--need LEVEL] [--trunk] [--json]",
|
|
19
|
+
"wait" => "fun-ci wait [REV] [--need LEVEL] [--within TIME] [--follow-branch] [--trunk] [--json]",
|
|
20
|
+
"runs" => "fun-ci runs [-n N] [--branch NAME] [--json]",
|
|
21
|
+
"events" => "fun-ci events [--follow] [--only failures]",
|
|
22
|
+
"why" => "fun-ci why [REV] [STAGE | trunk] [--need LEVEL] [--raw] [--json]\n " \
|
|
23
|
+
"fun-ci why --job NAME [--raw] [--json]",
|
|
24
|
+
"jobs" => "fun-ci jobs [--json]",
|
|
25
|
+
"cancel" => "fun-ci cancel --job NAME",
|
|
26
|
+
"extract" => "fun-ci extract STAGE --output FILE [--exit N | --timed-out] [--json]"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def self.asked?(args) = args.any? { |arg| FLAGS.include?(arg) }
|
|
30
|
+
|
|
31
|
+
def self.help(out, command)
|
|
32
|
+
out.puts "Usage: #{USAGES.fetch(command)}", "", purpose(command)
|
|
33
|
+
options = options_of(command)
|
|
34
|
+
out.puts "", "Options:", *options unless options.empty?
|
|
35
|
+
0
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.purpose(command) = CliHelp::TEXT[/^ #{Regexp.escape(command)} +(.+)$/, 1]
|
|
39
|
+
|
|
40
|
+
def self.options_of(command)
|
|
41
|
+
lines = CliHelp::TEXT.lines.select { |line| takes?(line, command) }
|
|
42
|
+
lines.map { |line| line.sub(/\([^)]+\) /, "") }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def self.takes?(option_line, command)
|
|
46
|
+
commands = option_line[/^\s+-\S.*?\(([^)]+)\)/, 1]
|
|
47
|
+
!commands.nil? && commands.split(", ").include?(command)
|
|
48
|
+
end
|
|
49
|
+
private_class_method :purpose, :options_of, :takes?
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/fun_ci/cli_help.rb
CHANGED
|
@@ -32,33 +32,34 @@ module FunCi
|
|
|
32
32
|
events Print this project's runs' events as JSON lines
|
|
33
33
|
why Print everything kept about why a commit's stage failed, or its conflict with the trunk
|
|
34
34
|
jobs List this project's daily and weekly jobs and how each stands
|
|
35
|
+
cancel Cancel a daily or weekly job's run: cancel --job NAME
|
|
35
36
|
extract Try a stage's evidence extractors on a saved output
|
|
36
37
|
|
|
37
38
|
Options:
|
|
38
|
-
-h, --help Show this help message
|
|
39
|
+
-h, --help Show this help message; after a command, that command's usage
|
|
39
40
|
--version Show version
|
|
40
41
|
|
|
41
|
-
|
|
42
|
-
--background Run the pipeline in the background and return at once
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
Command options:
|
|
43
|
+
--background (trigger) Run the pipeline in the background and return at once
|
|
44
|
+
--everything (init) Run init + install-hooks + check in one step
|
|
45
|
+
--stack NAME (init) Write the scripts for this stack, such as go, instead of the one detected
|
|
45
46
|
--need LEVEL (status, wait, why) What must pass: build, fast (default) or all
|
|
46
47
|
--within TIME (wait) Give up undecided after 30, 30s or 5m
|
|
47
48
|
--follow-branch (wait) Move on to the newer commit that superseded the run
|
|
48
49
|
--trunk (status, wait) Exit 6 when the run passed but conflicts with the trunk
|
|
49
50
|
--raw (why) Print the stage's whole output as it was kept
|
|
50
|
-
--job NAME (why)
|
|
51
|
+
--job NAME (why, cancel) The daily or weekly job to explain or cancel, instead of a commit
|
|
51
52
|
-n N (runs) How many runs to list (default 10)
|
|
52
53
|
--branch NAME (runs) Only runs on this branch
|
|
53
54
|
--follow (events) Keep printing events as they happen, until stopped
|
|
54
55
|
--only failures (events) Print only failed stages, superseded runs and failed jobs
|
|
55
|
-
--
|
|
56
|
+
--output FILE (extract) The saved output of the stage to read
|
|
57
|
+
--exit N (extract) The exit status the stage ended with (default 1)
|
|
58
|
+
--timed-out (extract) The stage ran out of time instead of exiting
|
|
59
|
+
--json (status, wait, runs, why, jobs, extract) Print JSON instead of text; events always print JSON lines
|
|
56
60
|
Exit codes: 0 passed, 1 failed, 2 over budget, 3 undecided,
|
|
57
61
|
4 superseded, 5 no run, 6 conflicts with the trunk (--trunk), 64 usage error
|
|
58
62
|
|
|
59
|
-
Init options:
|
|
60
|
-
--everything Run init + install-hooks + check in one step
|
|
61
|
-
|
|
62
63
|
Examples:
|
|
63
64
|
fun-ci init --everything
|
|
64
65
|
fun-ci trigger abc1234 main
|
|
@@ -19,6 +19,9 @@ module FunCi
|
|
|
19
19
|
# How many of a branch's runs are read to fold its cancelled ones and
|
|
20
20
|
# find its standing against the trunk.
|
|
21
21
|
BRANCH_WINDOW = 100
|
|
22
|
+
# How many branches the board holds at most: those whose newest run
|
|
23
|
+
# changed most recently, so old failures leave it in time.
|
|
24
|
+
RECENT_BRANCHES = 20
|
|
22
25
|
|
|
23
26
|
# `clock` answers the time now, which decides which jobs are due.
|
|
24
27
|
def initialize(db, page_size: 15, run_canceller: Pipeline::RunCanceller.new, clock: -> { Time.now })
|
|
@@ -34,7 +37,7 @@ module FunCi
|
|
|
34
37
|
def resize(page_size) = @paging.resize(page_size)
|
|
35
38
|
|
|
36
39
|
# Whether there are branches beyond those `runs` loads.
|
|
37
|
-
def more? =
|
|
40
|
+
def more? = heads(@paging.limit + 1).size > @paging.limit
|
|
38
41
|
|
|
39
42
|
# Records failed each slow suite whose process died (acceptance-tests.md, AT-8.3).
|
|
40
43
|
def record_dead_slow_suites = @run_canceller.record_dead(@db)
|
|
@@ -55,7 +58,7 @@ module FunCi
|
|
|
55
58
|
# and then those run most recently, in RowOrder: a run of cancelled
|
|
56
59
|
# runs folded into one, with its stages and the branch's standing against the trunk.
|
|
57
60
|
def runs
|
|
58
|
-
RowOrder.of(
|
|
61
|
+
RowOrder.of(heads(@paging.limit).map { |head| branch_row(head) })
|
|
59
62
|
end
|
|
60
63
|
|
|
61
64
|
# The projects among `runs` whose trunk is stale.
|
|
@@ -74,6 +77,8 @@ module FunCi
|
|
|
74
77
|
|
|
75
78
|
private
|
|
76
79
|
|
|
80
|
+
def heads(limit) = Persistence::PipelineRun.branch_heads(@db, limit: limit, among: RECENT_BRANCHES)
|
|
81
|
+
|
|
77
82
|
def branch_row(head)
|
|
78
83
|
branch_runs = Persistence::PipelineRun.of_branch(@db, head[:project_path], head[:branch], limit: BRANCH_WINDOW)
|
|
79
84
|
row = CancelledFolding.fold(branch_runs).first
|
|
@@ -14,20 +14,26 @@ module FunCi
|
|
|
14
14
|
|
|
15
15
|
def self.from(standing)
|
|
16
16
|
{ project: standing.project, name: standing.name, cadence: standing.cadence,
|
|
17
|
-
status: STATUS.fetch(standing.state, standing.state), **run(standing.run),
|
|
17
|
+
status: STATUS.fetch(standing.state, standing.state), **run(standing.run, standing.starts_at),
|
|
18
18
|
due_at: standing.due_at&.to_i }.compact
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
# What the latest run says: which, on what, and when
|
|
22
|
-
|
|
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)
|
|
23
24
|
return {} unless run
|
|
24
25
|
|
|
25
|
-
{ run_id: run[:id], sha: run[:commit_hash], branch: run[:branch],
|
|
26
|
-
|
|
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]) }
|
|
27
33
|
end
|
|
28
34
|
|
|
29
35
|
def self.epoch(iso) = Time.parse(iso).to_i
|
|
30
|
-
private_class_method :run, :epoch
|
|
36
|
+
private_class_method :run, :times, :epoch
|
|
31
37
|
end
|
|
32
38
|
end
|
|
33
39
|
end
|
|
@@ -3,14 +3,15 @@
|
|
|
3
3
|
module FunCi
|
|
4
4
|
module Console
|
|
5
5
|
# The order of the job section's rows (design.md, The console): what
|
|
6
|
-
# needs you first, then running,
|
|
6
|
+
# needs you first, then running, waiting its turn to (Jobs::Schedule),
|
|
7
|
+
# due and passed; among equals, the
|
|
7
8
|
# board's projects in its order, then each project's jobs by name. Rows
|
|
8
9
|
# answer #state (Jobs::State), #project and #name.
|
|
9
10
|
module JobOrder
|
|
10
|
-
URGENCY = { "failed" => 0, "lost" => 0, "over_budget" => 1, "running" => 2, "
|
|
11
|
-
"
|
|
11
|
+
URGENCY = { "failed" => 0, "lost" => 0, "over_budget" => 1, "running" => 2, "scheduled" => 3, "due" => 4,
|
|
12
|
+
"cancelled" => 4, "passed" => 5 }.freeze
|
|
12
13
|
# A state this fun-ci doesn't know, which a newer one sharing the database may have written.
|
|
13
|
-
UNKNOWN =
|
|
14
|
+
UNKNOWN = 6
|
|
14
15
|
|
|
15
16
|
def self.of(rows, projects:)
|
|
16
17
|
rows.sort_by do |row|
|
|
@@ -67,8 +67,10 @@ module FunCi
|
|
|
67
67
|
@confirm_cancel = [:run, run] if run[:status] == "running"
|
|
68
68
|
end
|
|
69
69
|
|
|
70
|
-
# A
|
|
70
|
+
# A job (a Jobs::Standing) waiting its turn is cancelled at once; a
|
|
71
|
+
# running one once the user confirms.
|
|
71
72
|
def offer_job_cancel(job)
|
|
73
|
+
cancel(:job, job) if job.state == "scheduled"
|
|
72
74
|
@confirm_cancel = [:job, job] if job.state == "running"
|
|
73
75
|
end
|
|
74
76
|
|
|
@@ -44,10 +44,12 @@ module FunCi
|
|
|
44
44
|
|
|
45
45
|
def stale_since(project, now)
|
|
46
46
|
last = Persistence::TrunkFetches.new(@db, project).last
|
|
47
|
-
return nil unless last && (last.error ||
|
|
47
|
+
return nil unless last && (last.error || old?(last.fetched_at, now))
|
|
48
48
|
|
|
49
49
|
{ project: project, since: last.fetched_at&.to_i }
|
|
50
50
|
end
|
|
51
|
+
|
|
52
|
+
def old?(fetched_at, now) = !fetched_at.nil? && now - fetched_at > Trunk::Shown::FRESH_FOR
|
|
51
53
|
end
|
|
52
54
|
end
|
|
53
55
|
end
|
|
@@ -10,7 +10,7 @@ module FunCi
|
|
|
10
10
|
# command to read.
|
|
11
11
|
module AboutStage
|
|
12
12
|
def self.of(sources, outcome, output)
|
|
13
|
-
About.new(**outcome.to_h.except(:overrun), **started(sources), **where(sources, output),
|
|
13
|
+
About.new(**outcome.to_h.except(:overrun, :elsewhere), **started(sources), **where(sources, output),
|
|
14
14
|
stage: sources.stage, budget: sources.budget, commit: sources.commit)
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -37,7 +37,7 @@ module FunCi
|
|
|
37
37
|
def collect(output, outcome = FAILED)
|
|
38
38
|
result = extracted(output, outcome)
|
|
39
39
|
overrun = outcome.overrun || NOTHING
|
|
40
|
-
parts = [["fun-ci", own_facts(outcome
|
|
40
|
+
parts = [["fun-ci", own_facts(outcome)], *overrun.parts, *result.parts, ["output-tail", tail(output)]]
|
|
41
41
|
Caps.new.apply(masking.document(assemble(parts, [overrun, result])))
|
|
42
42
|
end
|
|
43
43
|
|
|
@@ -76,10 +76,13 @@ module FunCi
|
|
|
76
76
|
|
|
77
77
|
def on_of(raw) = raw.is_a?(Hash) ? raw["on"] : nil
|
|
78
78
|
|
|
79
|
-
def own_facts(
|
|
80
|
-
|
|
79
|
+
def own_facts(outcome)
|
|
80
|
+
named = { "alongside" => outcome.alongside, "other runs alongside" => outcome.elsewhere }
|
|
81
|
+
Findings.new(facts: named.reject { |_, stages| stages.empty? }.map { |name, stages| fact(name, stages) })
|
|
81
82
|
end
|
|
82
83
|
|
|
84
|
+
def fact(name, stages) = { name: name, value: stages.join(", ") }
|
|
85
|
+
|
|
83
86
|
def tail(output)
|
|
84
87
|
text = Persistence::OutputTail.of(output, mask: masking.method(:mask))
|
|
85
88
|
Findings.new(excerpts: [{ title: Document::TAIL_TITLE, location: "output", lines: text.lines(chomp: true) }])
|
|
@@ -4,12 +4,15 @@ module FunCi
|
|
|
4
4
|
module Evidence
|
|
5
5
|
# How a failed stage ended, known once it has: its state in the words
|
|
6
6
|
# `status --json` uses, its exit status or signal, and the other stages
|
|
7
|
-
# that shared its slot while it ran,
|
|
7
|
+
# that shared its slot while it ran, the stages of the project's other runs
|
|
8
|
+
# that ran meanwhile (elsewhere), and for an overrun, what was found
|
|
8
9
|
# before the kill (an Extraction::Result).
|
|
9
|
-
Outcome = Data.define(:state, :exit_status, :signal, :alongside, :overrun)
|
|
10
|
+
Outcome = Data.define(:state, :exit_status, :signal, :alongside, :elsewhere, :overrun)
|
|
10
11
|
|
|
11
12
|
class Outcome
|
|
12
|
-
def initialize(**given)
|
|
13
|
+
def initialize(**given)
|
|
14
|
+
super(exit_status: nil, signal: nil, alongside: [], elsewhere: [], overrun: nil, **given)
|
|
15
|
+
end
|
|
13
16
|
end
|
|
14
17
|
end
|
|
15
18
|
end
|
|
@@ -18,7 +18,7 @@ module FunCi
|
|
|
18
18
|
|
|
19
19
|
# The settings in the config file at `path`, or the defaults without one.
|
|
20
20
|
def self.load(path)
|
|
21
|
-
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)
|
|
22
22
|
rescue Psych::Exception, NoMethodError, TypeError
|
|
23
23
|
new(nil)
|
|
24
24
|
end
|
data/lib/fun_ci/jobs/due.rb
CHANGED
|
@@ -6,8 +6,8 @@ module FunCi
|
|
|
6
6
|
module Jobs
|
|
7
7
|
# Whether a job is due, from its latest run alone ({ status:, started_at: },
|
|
8
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
|
|
10
|
-
# (design.md, Daily and weekly jobs).
|
|
9
|
+
# cancelled, or a period after that run started, unless it still runs or
|
|
10
|
+
# waits to (design.md, Daily and weekly jobs).
|
|
11
11
|
class Due
|
|
12
12
|
def initialize(latest, period, now:)
|
|
13
13
|
@latest = latest
|
|
@@ -26,7 +26,8 @@ module FunCi
|
|
|
26
26
|
|
|
27
27
|
private
|
|
28
28
|
|
|
29
|
-
|
|
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])
|
|
30
31
|
def again = Time.parse(@latest[:started_at]) + @period
|
|
31
32
|
end
|
|
32
33
|
end
|
data/lib/fun_ci/jobs/due_jobs.rb
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "time"
|
|
3
4
|
require_relative "standings"
|
|
5
|
+
require_relative "schedule"
|
|
4
6
|
require_relative "../pipeline/run_canceller"
|
|
7
|
+
require_relative "../setup/project_config"
|
|
5
8
|
|
|
6
9
|
module FunCi
|
|
7
10
|
module Jobs
|
|
@@ -11,15 +14,36 @@ module FunCi
|
|
|
11
14
|
# recorded failed first, or it would never be due again, and no process
|
|
12
15
|
# would start to find it dead.
|
|
13
16
|
class DueJobs
|
|
17
|
+
BEGUN = %w[running scheduled].freeze
|
|
18
|
+
DAY = 86_400
|
|
19
|
+
|
|
14
20
|
def initialize(project, db, now:)
|
|
15
21
|
@project = project
|
|
16
22
|
@db = db
|
|
17
23
|
@now = now
|
|
18
24
|
end
|
|
19
25
|
|
|
20
|
-
def list
|
|
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
|
|
21
45
|
Pipeline::RunCanceller.new.record_dead_jobs(@db)
|
|
22
|
-
Standings.new(@db, @project, now: @now).all
|
|
46
|
+
Standings.new(@db, @project, now: @now).all
|
|
23
47
|
end
|
|
24
48
|
end
|
|
25
49
|
end
|
data/lib/fun_ci/jobs/job_fork.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "job_run"
|
|
4
4
|
require_relative "../persistence/database"
|
|
5
5
|
require_relative "../pipeline/worktrees"
|
|
6
|
+
require_relative "../pipeline/priorities"
|
|
6
7
|
|
|
7
8
|
module FunCi
|
|
8
9
|
module Jobs
|
|
@@ -10,17 +11,24 @@ module FunCi
|
|
|
10
11
|
# jobs). The trigger that forks it exits when its pipeline ends, and the
|
|
11
12
|
# terminal of the commit may close; a job can run for hours, so it leads a
|
|
12
13
|
# session of its own, prints nowhere, and opens its own connection, since
|
|
13
|
-
# a child must not inherit one.
|
|
14
|
-
|
|
15
|
-
def
|
|
16
|
-
|
|
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
|
|
17
20
|
end
|
|
18
21
|
|
|
19
|
-
|
|
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)
|
|
20
28
|
Process.setsid
|
|
21
29
|
silence
|
|
22
|
-
db = Persistence::Database.connection(db_path)
|
|
23
|
-
JobRun.new(job, commit, site(
|
|
30
|
+
db = Persistence::Database.connection(@db_path)
|
|
31
|
+
JobRun.new(job, commit, site(db), Pipeline::Seams.new(priorities: @priorities)).start(at: at)
|
|
24
32
|
ensure
|
|
25
33
|
db&.close
|
|
26
34
|
end
|
|
@@ -28,18 +36,17 @@ module FunCi
|
|
|
28
36
|
# The process's own descriptors, whatever $stdout and $stderr stand for
|
|
29
37
|
# (a test's StringIO, a guard's file), onto an open /dev/null, since
|
|
30
38
|
# reopening by path can't change a stream's access mode.
|
|
31
|
-
def
|
|
39
|
+
def silence
|
|
32
40
|
File.open(File::NULL, File::RDWR) do |null|
|
|
33
41
|
(0..2).each { |fd| IO.for_fd(fd, autoclose: false).reopen(null) }
|
|
34
42
|
end
|
|
35
43
|
end
|
|
36
44
|
|
|
37
|
-
def
|
|
38
|
-
worktrees = Pipeline::Worktrees.new(project)
|
|
39
|
-
Site.new(project: project, db: db, worktrees: worktrees, locks: Locks.new(worktrees.jobs_root),
|
|
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),
|
|
40
48
|
clock: -> { Time.now })
|
|
41
49
|
end
|
|
42
|
-
private_class_method :run_in_child, :silence, :site
|
|
43
50
|
end
|
|
44
51
|
end
|
|
45
52
|
end
|
data/lib/fun_ci/jobs/job_run.rb
CHANGED
|
@@ -14,8 +14,8 @@ module FunCi
|
|
|
14
14
|
# One run of a job, in the process that runs it (design.md, Daily and
|
|
15
15
|
# weekly jobs): with the job's lock held, a run left running is known to
|
|
16
16
|
# be dead and is recorded failed, then the run is claimed if the job is
|
|
17
|
-
# due,
|
|
18
|
-
# stage is.
|
|
17
|
+
# due, waits its turn if it has one (Schedule), and the job runs on the
|
|
18
|
+
# commit in its own worktree, recorded as a stage is.
|
|
19
19
|
class JobRun
|
|
20
20
|
def initialize(job, commit, site, seams)
|
|
21
21
|
@job = job
|
|
@@ -25,9 +25,10 @@ module FunCi
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
# The run's id, or nil when another process runs the job or it isn't due.
|
|
28
|
-
|
|
28
|
+
# at: when its turn to start comes (Schedule); now when nil.
|
|
29
|
+
def start(at: nil)
|
|
29
30
|
slot = @site.locks.take(@job.name)
|
|
30
|
-
slot && holding(slot) { claimed(slot) }
|
|
31
|
+
slot && holding(slot) { claimed(slot, at || @site.clock.call) }
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
private
|
|
@@ -41,10 +42,27 @@ module FunCi
|
|
|
41
42
|
slot.release
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
def claimed(slot)
|
|
45
|
+
def claimed(slot, at)
|
|
45
46
|
runs.died(@job.name)
|
|
46
47
|
id = runs.claim(@job, commit: @commit.to_h, lock_file: slot.lock_file, now: @site.clock.call)
|
|
47
|
-
id && execute(slot, id)
|
|
48
|
+
id && (turn_came?(id, at) ? execute(slot, id) : id)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Waits, scheduled, for the run's turn, the lock held; false when it was
|
|
52
|
+
# cancelled meanwhile.
|
|
53
|
+
def turn_came?(id, at)
|
|
54
|
+
seconds = at - @site.clock.call
|
|
55
|
+
return true unless seconds.positive?
|
|
56
|
+
|
|
57
|
+
scheduled(id, at)
|
|
58
|
+
@site.wait.call(seconds)
|
|
59
|
+
runs.start_turn(id, @site.clock.call).positive?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# This process is recorded, so a cancel stops it while it waits.
|
|
63
|
+
def scheduled(id, at)
|
|
64
|
+
recorder.started_by(id, Process.pid, budget: budget)
|
|
65
|
+
runs.wait_until(id, at)
|
|
48
66
|
end
|
|
49
67
|
|
|
50
68
|
def execute(slot, id)
|
|
@@ -64,7 +82,8 @@ module FunCi
|
|
|
64
82
|
|
|
65
83
|
def execution(slot)
|
|
66
84
|
Pipeline::StageExecution.new(seams: seams, dir: slot.path, commit: @commit,
|
|
67
|
-
launching: { env: { "FUN_CI_JOB" => @job.name }, held: [slot.lock]
|
|
85
|
+
launching: { env: { "FUN_CI_JOB" => @job.name }, held: [slot.lock],
|
|
86
|
+
priority: seams.priorities.job })
|
|
68
87
|
end
|
|
69
88
|
|
|
70
89
|
# The commit's own copy of the script when it has one, as a pipeline's stages are.
|
data/lib/fun_ci/jobs/locks.rb
CHANGED
|
@@ -6,7 +6,7 @@ require_relative "../pipeline/slot"
|
|
|
6
6
|
module FunCi
|
|
7
7
|
module Jobs
|
|
8
8
|
# Each job's worktree and the lock beside it, under a project's
|
|
9
|
-
#
|
|
9
|
+
# Worktrees#jobs_root: `<name>` and `<name>.lock`. Whoever holds
|
|
10
10
|
# a job's lock runs it, so it runs once at a time, and a lock dies with
|
|
11
11
|
# the process that held it.
|
|
12
12
|
class Locks
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Jobs
|
|
5
|
+
# When each of the jobs a commit starts begins (design.md, Daily and
|
|
6
|
+
# weekly jobs): one at a time, `spacing` seconds apart, and no sooner
|
|
7
|
+
# than `spacing` after the latest start of the project's jobs already
|
|
8
|
+
# running or waiting to (`begun`), so their work is spread over time
|
|
9
|
+
# rather than taking every core at once. A spacing of 0 starts all now.
|
|
10
|
+
class Schedule
|
|
11
|
+
def initialize(spacing, begun, now:)
|
|
12
|
+
@spacing = spacing
|
|
13
|
+
@begun = begun
|
|
14
|
+
@now = now
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# [job, its start] for each of `jobs`, in their order.
|
|
18
|
+
def starts(jobs) = jobs.each_with_index.map { |job, index| [job, first + (index * @spacing)] }
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def first = @begun.empty? ? @now : [@now, @begun.max + @spacing].max
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/fun_ci/jobs/site.rb
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "wall_clock_wait"
|
|
4
|
+
|
|
3
5
|
module FunCi
|
|
4
6
|
module Jobs
|
|
5
7
|
# Where a project's jobs run and are recorded: the project's path, the
|
|
6
|
-
# database, its Pipeline::Worktrees (#check_out), its job Locks,
|
|
7
|
-
# `clock`, which answers the time now
|
|
8
|
-
|
|
8
|
+
# database, its Pipeline::Worktrees (#check_out), its job Locks,
|
|
9
|
+
# `clock`, which answers the time now, and `wait`, which waits the
|
|
10
|
+
# seconds it is given, for a run's turn to start (Schedule).
|
|
11
|
+
Site = Data.define(:project, :db, :worktrees, :locks, :clock, :wait)
|
|
12
|
+
|
|
13
|
+
class Site
|
|
14
|
+
def initialize(wait: WallClockWait.new, **given) = super
|
|
15
|
+
end
|
|
9
16
|
end
|
|
10
17
|
end
|