fun_ci 2.1.1-aarch64-linux → 2.2.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 +24 -0
- data/README.md +24 -1
- data/lib/fun_ci/agent/command_support.rb +7 -0
- data/lib/fun_ci/agent/commands.rb +2 -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 +37 -0
- data/lib/fun_ci/agent/job_json.rb +16 -0
- data/lib/fun_ci/agent/job_report.rb +85 -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 +51 -0
- data/lib/fun_ci/agent/jobs_command.rb +43 -0
- data/lib/fun_ci/agent/jobs_text.rb +50 -0
- data/lib/fun_ci/agent/live_pipeline.rb +7 -2
- 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 +16 -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/status_command.rb +3 -2
- data/lib/fun_ci/agent/status_text.rb +19 -5
- 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 +3 -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 +33 -0
- data/lib/fun_ci/console/job_order.rb +22 -0
- data/lib/fun_ci/console/job_rows.rb +21 -0
- data/lib/fun_ci/console/key_handler.rb +33 -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 +14 -7
- data/lib/fun_ci/evidence/settings_check.rb +14 -3
- data/lib/fun_ci/jobs/due.rb +33 -0
- data/lib/fun_ci/jobs/due_jobs.rb +26 -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 +45 -0
- data/lib/fun_ci/jobs/job_run.rb +85 -0
- data/lib/fun_ci/jobs/locks.rb +30 -0
- data/lib/fun_ci/jobs/site.rb +10 -0
- data/lib/fun_ci/jobs/standings.rb +46 -0
- data/lib/fun_ci/jobs/state.rb +26 -0
- data/lib/fun_ci/persistence/active_jobs.rb +54 -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 +68 -0
- data/lib/fun_ci/persistence/raw_outputs.rb +3 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +39 -10
- data/lib/fun_ci/pipeline/process_runner.rb +15 -5
- data/lib/fun_ci/pipeline/run_canceller.rb +24 -0
- data/lib/fun_ci/pipeline/slot.rb +1 -1
- data/lib/fun_ci/pipeline/stage_execution.rb +9 -4
- data/lib/fun_ci/pipeline/trigger_command.rb +7 -1
- data/lib/fun_ci/pipeline/worktree_prune.rb +8 -6
- data/lib/fun_ci/pipeline/worktrees.rb +8 -4
- data/lib/fun_ci/setup/agent_instructions.rb +3 -0
- data/lib/fun_ci/setup/project_config.rb +6 -0
- data/lib/fun_ci/setup/setup_checker.rb +7 -1
- data/lib/fun_ci/version.rb +1 -1
- data/libexec/fun-ci-renderer +0 -0
- metadata +28 -1
|
@@ -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,33 @@
|
|
|
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
|
|
10
|
+
# (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
|
+
def running? = @latest[:status] == "running"
|
|
30
|
+
def again = Time.parse(@latest[:started_at]) + @period
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "standings"
|
|
4
|
+
require_relative "../pipeline/run_canceller"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Jobs
|
|
8
|
+
# The project's jobs that are due at `now`, read from the database without
|
|
9
|
+
# taking any lock: who starts a job is decided again, under its lock, by
|
|
10
|
+
# the process that runs it. A job left running by a process that died is
|
|
11
|
+
# recorded failed first, or it would never be due again, and no process
|
|
12
|
+
# would start to find it dead.
|
|
13
|
+
class DueJobs
|
|
14
|
+
def initialize(project, db, now:)
|
|
15
|
+
@project = project
|
|
16
|
+
@db = db
|
|
17
|
+
@now = now
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def list
|
|
21
|
+
Pipeline::RunCanceller.new.record_dead_jobs(@db)
|
|
22
|
+
Standings.new(@db, @project, now: @now).all.select(&:due_now?).map(&:job)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
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,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "job_run"
|
|
4
|
+
require_relative "../persistence/database"
|
|
5
|
+
require_relative "../pipeline/worktrees"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Jobs
|
|
9
|
+
# Starts a job in a process of its own (architecture.md, Daily and weekly
|
|
10
|
+
# jobs). The trigger that forks it exits when its pipeline ends, and the
|
|
11
|
+
# terminal of the commit may close; a job can run for hours, so it leads a
|
|
12
|
+
# session of its own, prints nowhere, and opens its own connection, since
|
|
13
|
+
# a child must not inherit one.
|
|
14
|
+
module JobFork
|
|
15
|
+
def self.start(job, commit, project:, db_path:)
|
|
16
|
+
Process.detach(fork { run_in_child(job, commit, project, db_path) })
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.run_in_child(job, commit, project, db_path)
|
|
20
|
+
Process.setsid
|
|
21
|
+
silence
|
|
22
|
+
db = Persistence::Database.connection(db_path)
|
|
23
|
+
JobRun.new(job, commit, site(project, db), Pipeline::Seams.new).start
|
|
24
|
+
ensure
|
|
25
|
+
db&.close
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The process's own descriptors, whatever $stdout and $stderr stand for
|
|
29
|
+
# (a test's StringIO, a guard's file), onto an open /dev/null, since
|
|
30
|
+
# reopening by path can't change a stream's access mode.
|
|
31
|
+
def self.silence
|
|
32
|
+
File.open(File::NULL, File::RDWR) do |null|
|
|
33
|
+
(0..2).each { |fd| IO.for_fd(fd, autoclose: false).reopen(null) }
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.site(project, db)
|
|
38
|
+
worktrees = Pipeline::Worktrees.new(project)
|
|
39
|
+
Site.new(project: project, db: db, worktrees: worktrees, locks: Locks.new(worktrees.jobs_root),
|
|
40
|
+
clock: -> { Time.now })
|
|
41
|
+
end
|
|
42
|
+
private_class_method :run_in_child, :silence, :site
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "folders"
|
|
4
|
+
require_relative "locks"
|
|
5
|
+
require_relative "site"
|
|
6
|
+
require_relative "../persistence/job_runs"
|
|
7
|
+
require_relative "../persistence/job_recorder"
|
|
8
|
+
require_relative "../pipeline/stage_execution"
|
|
9
|
+
require_relative "../pipeline/trigger_params"
|
|
10
|
+
require_relative "../evidence/document"
|
|
11
|
+
|
|
12
|
+
module FunCi
|
|
13
|
+
module Jobs
|
|
14
|
+
# One run of a job, in the process that runs it (design.md, Daily and
|
|
15
|
+
# weekly jobs): with the job's lock held, a run left running is known to
|
|
16
|
+
# be dead and is recorded failed, then the run is claimed if the job is
|
|
17
|
+
# due, and the job runs on the commit in its own worktree, recorded as a
|
|
18
|
+
# stage is.
|
|
19
|
+
class JobRun
|
|
20
|
+
def initialize(job, commit, site, seams)
|
|
21
|
+
@job = job
|
|
22
|
+
@commit = commit
|
|
23
|
+
@site = site
|
|
24
|
+
@seams = seams
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# The run's id, or nil when another process runs the job or it isn't due.
|
|
28
|
+
def start
|
|
29
|
+
slot = @site.locks.take(@job.name)
|
|
30
|
+
slot && holding(slot) { claimed(slot) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def runs = Persistence::JobRuns.new(@site.db, @site.project)
|
|
36
|
+
def recorder = Persistence::JobRecorder.new(@site.db)
|
|
37
|
+
|
|
38
|
+
def holding(slot)
|
|
39
|
+
yield
|
|
40
|
+
ensure
|
|
41
|
+
slot.release
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def claimed(slot)
|
|
45
|
+
runs.died(@job.name)
|
|
46
|
+
id = runs.claim(@job, commit: @commit.to_h, lock_file: slot.lock_file, now: @site.clock.call)
|
|
47
|
+
id && execute(slot, id)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def execute(slot, id)
|
|
51
|
+
recorder.started_by(id, Process.pid, budget: budget)
|
|
52
|
+
@site.worktrees.check_out(slot.path, @commit.sha)
|
|
53
|
+
execution(slot).run(@job.stage, command(slot.path), recorder, id)
|
|
54
|
+
id
|
|
55
|
+
rescue StandardError => e
|
|
56
|
+
failed(id, e.message)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The script holds the job's lock too, so the job reads as alive while
|
|
60
|
+
# any of it runs, its runner gone or not.
|
|
61
|
+
# A day for every job, unless a test says otherwise.
|
|
62
|
+
def seams = @seams.with(time_budgets: { @job.stage => Job::BUDGET }.merge(@seams.time_budgets))
|
|
63
|
+
def budget = seams.budgets[@job.stage]
|
|
64
|
+
|
|
65
|
+
def execution(slot)
|
|
66
|
+
Pipeline::StageExecution.new(seams: seams, dir: slot.path, commit: @commit,
|
|
67
|
+
launching: { env: { "FUN_CI_JOB" => @job.name }, held: [slot.lock] })
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# The commit's own copy of the script when it has one, as a pipeline's stages are.
|
|
71
|
+
def command(dir)
|
|
72
|
+
committed = File.join(dir, ".fun-ci", @job.cadence, "#{@job.name}.sh")
|
|
73
|
+
(File.exist?(committed) ? @job.with(script: committed) : @job).command(@commit.sha)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Whatever stopped the run, so it isn't left running: a run left running
|
|
77
|
+
# is only found dead once its lock is free, and then says nothing of why.
|
|
78
|
+
def failed(id, problem)
|
|
79
|
+
recorder.keep_evidence(id, Evidence::Document.broken(problem))
|
|
80
|
+
recorder.end_stage(id, "failed")
|
|
81
|
+
id
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../pipeline/slot"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Jobs
|
|
8
|
+
# Each job's worktree and the lock beside it, under a project's
|
|
9
|
+
# <git-common-dir>/fun-ci/jobs: `<name>` and `<name>.lock`. Whoever holds
|
|
10
|
+
# a job's lock runs it, so it runs once at a time, and a lock dies with
|
|
11
|
+
# the process that held it.
|
|
12
|
+
class Locks
|
|
13
|
+
def initialize(root)
|
|
14
|
+
@root = root
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The job's worktree, held through its lock as a Pipeline::Slot, or nil
|
|
18
|
+
# while another process holds it.
|
|
19
|
+
def take(name)
|
|
20
|
+
FileUtils.mkdir_p(@root)
|
|
21
|
+
path = File.join(@root, name)
|
|
22
|
+
lock = File.new("#{path}.lock", File::RDWR | File::CREAT, 0o644)
|
|
23
|
+
return Pipeline::Slot.new(path, lock, lock.path) if lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
24
|
+
|
|
25
|
+
lock.close
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Jobs
|
|
5
|
+
# Where a project's jobs run and are recorded: the project's path, the
|
|
6
|
+
# database, its Pipeline::Worktrees (#check_out), its job Locks, and
|
|
7
|
+
# `clock`, which answers the time now.
|
|
8
|
+
Site = Data.define(:project, :db, :worktrees, :locks, :clock)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "folders"
|
|
4
|
+
require_relative "due"
|
|
5
|
+
require_relative "state"
|
|
6
|
+
require_relative "../persistence/job_runs"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Jobs
|
|
10
|
+
# Where a job stands: its project, the job, its latest run as job_runs
|
|
11
|
+
# keeps it (nil while it never ran), and Due, which says whether the
|
|
12
|
+
# next commit starts it and when it is due again.
|
|
13
|
+
Standing = Data.define(:project, :job, :run, :due)
|
|
14
|
+
|
|
15
|
+
class Standing
|
|
16
|
+
def name = job.name
|
|
17
|
+
def cadence = job.cadence
|
|
18
|
+
def state = State.of(run)
|
|
19
|
+
def needs_you? = State.needs_you?(state)
|
|
20
|
+
def due_now? = due.now?
|
|
21
|
+
def due_at = due.at
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Where each of a project's jobs stands at `now`, read once for the
|
|
25
|
+
# console, the agent commands and the trigger.
|
|
26
|
+
class Standings
|
|
27
|
+
def initialize(db, project, now:)
|
|
28
|
+
@db = db
|
|
29
|
+
@project = project
|
|
30
|
+
@now = now
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# By name.
|
|
34
|
+
def all
|
|
35
|
+
runs = Persistence::JobRuns.new(@db, @project)
|
|
36
|
+
Folders.new(@project).jobs.map { |job| standing(job, runs.latest(job.name)) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def standing(job, run)
|
|
42
|
+
Standing.new(project: @project, job: job, run: run, due: Due.new(run, job.period, now: @now))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Jobs
|
|
5
|
+
# A job's state, read once from its latest run as job_runs keeps it
|
|
6
|
+
# (design.md, Daily and weekly jobs): `due` while it never ran, then
|
|
7
|
+
# `running`, `passed`, `failed`, `over_budget`, `cancelled`, or `lost`
|
|
8
|
+
# when its process died before it said how the run ended. Each surface
|
|
9
|
+
# (the console, the agent commands) says these in its own words.
|
|
10
|
+
module State
|
|
11
|
+
OF_STATUS = { "completed" => "passed", "failed" => "failed", "timed_out" => "over_budget",
|
|
12
|
+
"running" => "running", "cancelled" => "cancelled" }.freeze
|
|
13
|
+
NEEDS_YOU = %w[failed over_budget lost].freeze
|
|
14
|
+
|
|
15
|
+
# run: { status:, completed_at: }, or nil when the job never ran.
|
|
16
|
+
def self.of(run)
|
|
17
|
+
return "due" unless run
|
|
18
|
+
return "lost" if run[:status] == "failed" && run[:completed_at].nil?
|
|
19
|
+
|
|
20
|
+
OF_STATUS.fetch(run[:status], "unknown")
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.needs_you?(state) = NEEDS_YOU.include?(state)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "active_runs"
|
|
5
|
+
require "json"
|
|
6
|
+
require_relative "../evidence/document"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Persistence
|
|
10
|
+
# Daily and weekly jobs' runs still running, of every project, as the
|
|
11
|
+
# console sees them: to cancel one, and to notice one whose process died.
|
|
12
|
+
module ActiveJobs
|
|
13
|
+
# The job run with this id as an ActiveRun, when it still runs; none otherwise.
|
|
14
|
+
def self.with_id(db, id)
|
|
15
|
+
db.execute("SELECT id, commit_hash, pid, group_pid, lock_file FROM job_runs " \
|
|
16
|
+
"WHERE id = ? AND status = 'running'", [id]).map do |(run_id, sha, pid, group_pid, lock_file)|
|
|
17
|
+
ActiveRun.new(id: run_id, commit_hash: sha, processes: [pid].compact, stage_groups: [group_pid].compact,
|
|
18
|
+
slot_lock: lock_file)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# [id, lock file] of each job run still running.
|
|
23
|
+
def self.running(db) = db.execute("SELECT id, lock_file FROM job_runs WHERE status = 'running'")
|
|
24
|
+
|
|
25
|
+
# What a run whose process died keeps: that it has no result, and why
|
|
26
|
+
# that may be. It keeps no end, since when it was found dead says
|
|
27
|
+
# nothing of how long it ran.
|
|
28
|
+
DIED_FACT = { name: "ended", extractor: "fun-ci",
|
|
29
|
+
value: "its process stopped before it said how the run ended (the machine slept or " \
|
|
30
|
+
"restarted, or something killed it), so this run has no result" }.freeze
|
|
31
|
+
DIED = Evidence::Document.new(chosen: [], failures: [], excerpts: [], problems: [], facts: [DIED_FACT])
|
|
32
|
+
|
|
33
|
+
def self.cancelled(db, id) = ended(db, id, "cancelled")
|
|
34
|
+
|
|
35
|
+
# A run whose process is gone without recording how it ended: failed, with no end.
|
|
36
|
+
def self.died(db, id)
|
|
37
|
+
db.execute("UPDATE job_runs SET status = 'failed', evidence = ? WHERE id = ? AND status = 'running'",
|
|
38
|
+
[JSON.generate(DIED.to_h), id])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The ids of a project's job's runs still running.
|
|
42
|
+
def self.running_of(db, project, name)
|
|
43
|
+
db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? AND status = 'running'",
|
|
44
|
+
[project, name]).flatten
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.ended(db, id, status)
|
|
48
|
+
db.execute("UPDATE job_runs SET status = ?, completed_at = ? WHERE id = ? AND status = 'running'",
|
|
49
|
+
[status, Time.now.utc.iso8601(3), id])
|
|
50
|
+
end
|
|
51
|
+
private_class_method :ended
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -46,6 +46,16 @@ module FunCi
|
|
|
46
46
|
)
|
|
47
47
|
SQL
|
|
48
48
|
|
|
49
|
+
# Each run of a project's daily and weekly jobs (architecture.md, Daily and weekly jobs).
|
|
50
|
+
JOB_RUNS_TABLE = <<~SQL
|
|
51
|
+
CREATE TABLE IF NOT EXISTS job_runs (
|
|
52
|
+
id INTEGER PRIMARY KEY,
|
|
53
|
+
project_path TEXT, job TEXT, cadence TEXT, commit_hash TEXT, branch TEXT,
|
|
54
|
+
status TEXT, started_at TEXT, completed_at TEXT, lock_file TEXT, pid INTEGER, group_pid INTEGER,
|
|
55
|
+
exit_status INTEGER, signal TEXT, output_tail TEXT, evidence TEXT, budget INTEGER
|
|
56
|
+
)
|
|
57
|
+
SQL
|
|
58
|
+
|
|
49
59
|
# Opening and migrating hold an exclusive lock on a file beside the
|
|
50
60
|
# database, so fun-ci processes starting at once set it up one at a time.
|
|
51
61
|
# SQLite's busy timeout doesn't cover the switch to WAL, and a column can
|
|
@@ -63,13 +73,13 @@ module FunCi
|
|
|
63
73
|
%w[pipeline_runs waited_at TEXT], %w[stage_jobs exit_status INTEGER],
|
|
64
74
|
%w[stage_jobs signal TEXT], %w[stage_jobs budget INTEGER], %w[stage_jobs pruned INTEGER],
|
|
65
75
|
%w[stage_jobs evidence TEXT], %w[pipeline_runs fetch_pgid INTEGER],
|
|
66
|
-
%w[pipeline_runs trunk_started_at TEXT]].freeze
|
|
76
|
+
%w[pipeline_runs trunk_started_at TEXT], %w[job_runs budget INTEGER]].freeze
|
|
77
|
+
|
|
78
|
+
TABLES = [PIPELINE_RUNS_TABLE, STAGE_JOBS_TABLE, TRUNK_CHECKS_TABLE, TRUNK_FETCHES_TABLE, JOB_RUNS_TABLE].freeze
|
|
67
79
|
|
|
68
80
|
def self.migrate!(db)
|
|
69
81
|
with_setup_lock(db.filename("main")) do
|
|
70
|
-
|
|
71
|
-
db.execute(table)
|
|
72
|
-
end
|
|
82
|
+
TABLES.each { |table| db.execute(table) }
|
|
73
83
|
ADDED_COLUMNS.each { |table, column, type| add_column_if_missing(db, table, column, type) }
|
|
74
84
|
db.execute(NAME_DETACHED)
|
|
75
85
|
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require_relative "raw_outputs"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Persistence
|
|
9
|
+
# Records a daily or weekly job's run as it runs, through the calls
|
|
10
|
+
# Pipeline::StageExecution makes of a stage's recorder, so a job's
|
|
11
|
+
# evidence is collected as a stage's is (architecture.md, Daily and
|
|
12
|
+
# weekly jobs). `id` is the job run's.
|
|
13
|
+
class JobRecorder
|
|
14
|
+
def initialize(db)
|
|
15
|
+
@db = db
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# The process that runs the job, which a cancel stops first, and the budget it runs to.
|
|
19
|
+
def started_by(id, pid, budget: nil) = set(id, pid: pid, budget: budget)
|
|
20
|
+
|
|
21
|
+
# The job's script, which leads a process group of its own.
|
|
22
|
+
def stage_process(id, pid) = set(id, group_pid: pid)
|
|
23
|
+
|
|
24
|
+
# Unless the run was cancelled meanwhile.
|
|
25
|
+
def end_stage(id, status)
|
|
26
|
+
@db.execute("UPDATE job_runs SET status = ?, completed_at = ? WHERE id = ? AND status = 'running'",
|
|
27
|
+
[status, Time.now.utc.iso8601(3), id])
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# And the output's last lines, as a stage keeps them.
|
|
31
|
+
def keep_evidence(id, document) = set(id, evidence: JSON.generate(document.to_h), output_tail: document.tail)
|
|
32
|
+
|
|
33
|
+
def keep_exit(id, exit_status, signal) = set(id, exit_status: exit_status, signal: signal)
|
|
34
|
+
def keep_raw(id, text) = RawOutputs.for_jobs(@db.filename("main")).write(id, text)
|
|
35
|
+
|
|
36
|
+
# No stage shares a job's worktree.
|
|
37
|
+
def alongside(_id) = []
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def set(id, **values)
|
|
42
|
+
@db.execute("UPDATE job_runs SET #{values.keys.map { |column| "#{column} = ?" }.join(", ")} WHERE id = ?",
|
|
43
|
+
[*values.values, id])
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "../jobs/due"
|
|
5
|
+
require_relative "raw_outputs"
|
|
6
|
+
require_relative "active_jobs"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Persistence
|
|
10
|
+
# The runs of one project's daily and weekly jobs (architecture.md, Daily
|
|
11
|
+
# and weekly jobs), apart from its pipeline runs.
|
|
12
|
+
class JobRuns
|
|
13
|
+
COLUMNS = %i[id project_path job cadence commit_hash branch status started_at completed_at lock_file pid
|
|
14
|
+
group_pid exit_status signal output_tail evidence budget].freeze
|
|
15
|
+
SELECT = "SELECT #{COLUMNS.join(", ")} FROM job_runs".freeze
|
|
16
|
+
KEPT = 10
|
|
17
|
+
|
|
18
|
+
def initialize(db, project)
|
|
19
|
+
@db = db
|
|
20
|
+
@project = project
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Records a run of `job` running, testing `commit` ({ sha:, branch: }),
|
|
24
|
+
# and answers its id, when the job is due at `now`; nil otherwise. The
|
|
25
|
+
# job's latest run is read and the new one written in one transaction,
|
|
26
|
+
# so of two claims at once only one starts it. A claim forgets the
|
|
27
|
+
# job's runs beyond its newest 10.
|
|
28
|
+
def claim(job, commit:, lock_file:, now:)
|
|
29
|
+
@db.transaction(:immediate) do
|
|
30
|
+
next nil unless Jobs::Due.new(latest(job.name), job.period, now: now).now?
|
|
31
|
+
|
|
32
|
+
insert(job, commit, lock_file, now).tap { forget_beyond(job.name) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The project's newest job runs, newest first.
|
|
37
|
+
def recent(limit:) = query("WHERE project_path = ? ORDER BY id DESC LIMIT ?", @project, limit)
|
|
38
|
+
|
|
39
|
+
# The job's newest run, or nil when it never ran.
|
|
40
|
+
def latest(name) = query("WHERE project_path = ? AND job = ? ORDER BY id DESC LIMIT 1", @project, name).first
|
|
41
|
+
|
|
42
|
+
# Records failed the job's runs still running, whose process is known to be gone.
|
|
43
|
+
def died(name) = ActiveJobs.running_of(@db, @project, name).each { |id| ActiveJobs.died(@db, id) }
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def insert(job, commit, lock_file, now)
|
|
48
|
+
@db.execute("INSERT INTO job_runs (project_path, job, cadence, commit_hash, branch, status, started_at, " \
|
|
49
|
+
"lock_file) VALUES (?, ?, ?, ?, ?, 'running', ?, ?)",
|
|
50
|
+
[@project, job.name, job.cadence, commit[:sha], commit[:branch], stamp(now), lock_file])
|
|
51
|
+
@db.last_insert_row_id
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def forget_beyond(name)
|
|
55
|
+
ids = @db.execute("SELECT id FROM job_runs WHERE project_path = ? AND job = ? ORDER BY id DESC " \
|
|
56
|
+
"LIMIT -1 OFFSET ?", [@project, name, KEPT]).flatten
|
|
57
|
+
@db.execute("DELETE FROM job_runs WHERE id IN (#{ids.map { "?" }.join(", ")})", ids) if ids.any?
|
|
58
|
+
RawOutputs.for_jobs(@db.filename("main")).delete(ids)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stamp(time) = time.utc.iso8601(3)
|
|
62
|
+
|
|
63
|
+
def query(clause, *params)
|
|
64
|
+
@db.execute("#{SELECT} #{clause}", params).map { |row| COLUMNS.zip(row).to_h }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -11,6 +11,9 @@ module FunCi
|
|
|
11
11
|
class RawOutputs
|
|
12
12
|
def self.beside(db_path) = new(File.join(File.dirname(db_path), "raw"))
|
|
13
13
|
|
|
14
|
+
# What failed daily and weekly jobs printed, apart, since their ids are their own.
|
|
15
|
+
def self.for_jobs(db_path) = new(File.join(File.dirname(db_path), "raw-jobs"))
|
|
16
|
+
|
|
14
17
|
def initialize(dir)
|
|
15
18
|
@dir = dir
|
|
16
19
|
end
|