fun_ci 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/README.md +3 -1
- data/exe/fun-ci-trigger +6 -6
- data/exe/fun-ci-tui +9 -4
- data/lib/fun_ci/animations/celebrate.rb +210 -0
- data/lib/fun_ci/animations/explosion.rb +138 -0
- data/lib/fun_ci/animations/flash.rb +205 -0
- data/lib/fun_ci/animations/idle.rb +127 -0
- data/lib/fun_ci/animations/leprechauns.rb +240 -0
- data/lib/fun_ci/animations/running.rb +129 -0
- data/lib/fun_ci/animations/success.rb +281 -0
- data/lib/fun_ci/animations/yay.rb +169 -0
- data/lib/fun_ci/cli.rb +20 -14
- data/lib/fun_ci/persistence/database.rb +52 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +74 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +59 -0
- data/lib/fun_ci/persistence/stage_job.rb +46 -0
- data/lib/fun_ci/{state_machine.rb → persistence/state_machine.rb} +3 -12
- data/lib/fun_ci/pipeline/background_wrapper.rb +27 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +38 -0
- data/lib/fun_ci/pipeline/process_runner.rb +28 -0
- data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
- data/lib/fun_ci/pipeline/stage_runner.rb +85 -0
- data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +53 -0
- data/lib/fun_ci/pipeline/trigger.rb +153 -0
- data/lib/fun_ci/setup/hook_writer.rb +75 -0
- data/lib/fun_ci/setup/installer.rb +55 -0
- data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
- data/lib/fun_ci/setup/project_config.rb +42 -0
- data/lib/fun_ci/setup/project_detector.rb +22 -0
- data/lib/fun_ci/setup/setup_checker.rb +30 -0
- data/lib/fun_ci/setup/template_writer.rb +53 -0
- data/lib/fun_ci/tui/admin_tui.rb +90 -0
- data/lib/fun_ci/tui/animation.rb +49 -0
- data/lib/fun_ci/tui/animation_compositor.rb +107 -0
- data/lib/fun_ci/tui/animation_frames.rb +112 -0
- data/lib/fun_ci/tui/animation_library.rb +46 -0
- data/lib/fun_ci/tui/animation_renderer.rb +144 -0
- data/lib/fun_ci/tui/ansi.rb +34 -0
- data/lib/fun_ci/tui/board_data.rb +53 -0
- data/lib/fun_ci/tui/board_renderer.rb +105 -0
- data/lib/fun_ci/tui/duration_formatter.rb +24 -0
- data/lib/fun_ci/tui/header_animation_manager.rb +71 -0
- data/lib/fun_ci/tui/header_animation_player.rb +45 -0
- data/lib/fun_ci/tui/key_handler.rb +86 -0
- data/lib/fun_ci/tui/looping_animation_player.rb +45 -0
- data/lib/fun_ci/tui/relative_time.rb +22 -0
- data/lib/fun_ci/tui/row_formatter.rb +108 -0
- data/lib/fun_ci/tui/screen.rb +103 -0
- data/lib/fun_ci/tui/spinner.rb +24 -0
- data/lib/fun_ci/tui/stage_change_detector.rb +58 -0
- data/lib/fun_ci/tui/streak_counter.rb +29 -0
- data/lib/fun_ci/tui/terminal_input.rb +69 -0
- data/lib/fun_ci.rb +6 -6
- metadata +49 -28
- data/lib/fun_ci/admin_tui.rb +0 -226
- data/lib/fun_ci/ansi.rb +0 -21
- data/lib/fun_ci/background_wrapper.rb +0 -27
- data/lib/fun_ci/board_data.rb +0 -51
- data/lib/fun_ci/database.rb +0 -50
- data/lib/fun_ci/duration_formatter.rb +0 -23
- data/lib/fun_ci/hook_writer.rb +0 -73
- data/lib/fun_ci/installer.rb +0 -53
- data/lib/fun_ci/maven_linter_detector.rb +0 -24
- data/lib/fun_ci/pipeline_forker.rb +0 -36
- data/lib/fun_ci/pipeline_recorder.rb +0 -72
- data/lib/fun_ci/pipeline_run.rb +0 -57
- data/lib/fun_ci/progress_reporter.rb +0 -29
- data/lib/fun_ci/project_config.rb +0 -40
- data/lib/fun_ci/project_detector.rb +0 -18
- data/lib/fun_ci/relative_time.rb +0 -20
- data/lib/fun_ci/row_formatter.rb +0 -106
- data/lib/fun_ci/screen.rb +0 -81
- data/lib/fun_ci/setup_checker.rb +0 -28
- data/lib/fun_ci/spinner.rb +0 -22
- data/lib/fun_ci/stage_job.rb +0 -44
- data/lib/fun_ci/stage_runner.rb +0 -108
- data/lib/fun_ci/stale_pipeline_canceller.rb +0 -51
- data/lib/fun_ci/streak_counter.rb +0 -30
- data/lib/fun_ci/template_writer.rb +0 -51
- data/lib/fun_ci/trigger.rb +0 -150
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "sqlite3"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Persistence
|
|
7
|
+
module Database
|
|
8
|
+
def self.connection(db_path)
|
|
9
|
+
db = SQLite3::Database.new(db_path)
|
|
10
|
+
db.busy_timeout = 5000
|
|
11
|
+
db.execute("PRAGMA journal_mode=WAL")
|
|
12
|
+
db.execute("PRAGMA foreign_keys=ON")
|
|
13
|
+
db
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.migrate!(db)
|
|
17
|
+
db.execute(<<~SQL)
|
|
18
|
+
CREATE TABLE IF NOT EXISTS pipeline_runs (
|
|
19
|
+
id INTEGER PRIMARY KEY,
|
|
20
|
+
commit_hash TEXT,
|
|
21
|
+
branch TEXT,
|
|
22
|
+
status TEXT DEFAULT 'scheduled',
|
|
23
|
+
pid INTEGER,
|
|
24
|
+
created_at TEXT,
|
|
25
|
+
updated_at TEXT
|
|
26
|
+
)
|
|
27
|
+
SQL
|
|
28
|
+
|
|
29
|
+
add_column_if_missing(db, "pipeline_runs", "pid", "INTEGER")
|
|
30
|
+
add_column_if_missing(db, "pipeline_runs", "project_path", "TEXT")
|
|
31
|
+
|
|
32
|
+
db.execute(<<~SQL)
|
|
33
|
+
CREATE TABLE IF NOT EXISTS stage_jobs (
|
|
34
|
+
id INTEGER PRIMARY KEY,
|
|
35
|
+
pipeline_run_id INTEGER REFERENCES pipeline_runs(id),
|
|
36
|
+
stage TEXT,
|
|
37
|
+
status TEXT DEFAULT 'scheduled',
|
|
38
|
+
started_at TEXT,
|
|
39
|
+
completed_at TEXT
|
|
40
|
+
)
|
|
41
|
+
SQL
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.add_column_if_missing(db, table, column, type)
|
|
45
|
+
columns = db.execute("PRAGMA table_info(#{table})").map { |row| row[1] }
|
|
46
|
+
return if columns.include?(column)
|
|
47
|
+
db.execute("ALTER TABLE #{table} ADD COLUMN #{column} #{type}")
|
|
48
|
+
end
|
|
49
|
+
private_class_method :add_column_if_missing
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pipeline_run"
|
|
4
|
+
require_relative "stage_job"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Persistence
|
|
8
|
+
class NullRecorder
|
|
9
|
+
def create_run(commit_hash:, branch:, project_path: nil) = nil
|
|
10
|
+
def start_stage(stage) = nil
|
|
11
|
+
def end_stage(job_id, status) = nil
|
|
12
|
+
def complete_run = nil
|
|
13
|
+
def fail_run = nil
|
|
14
|
+
def db = nil
|
|
15
|
+
def db_path = nil
|
|
16
|
+
def pipeline_run_id = nil
|
|
17
|
+
def close = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
class DbRecorder
|
|
21
|
+
attr_reader :db, :db_path, :pipeline_run_id
|
|
22
|
+
|
|
23
|
+
def self.for_background(db_path, pipeline_run_id)
|
|
24
|
+
db = Database.connection(db_path)
|
|
25
|
+
new(db, pipeline_run_id: pipeline_run_id)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(db, pipeline_run_id: nil)
|
|
29
|
+
@db = db
|
|
30
|
+
@db_path = db.filename("main")
|
|
31
|
+
@pipeline_run_id = pipeline_run_id
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def close
|
|
35
|
+
@db.close rescue nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def create_run(commit_hash:, branch:, project_path: nil)
|
|
39
|
+
@pipeline_run_id = PipelineRun.create(@db, commit_hash: commit_hash, branch: branch, project_path: project_path)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def start_stage(stage)
|
|
43
|
+
return nil unless @pipeline_run_id
|
|
44
|
+
ensure_running
|
|
45
|
+
job_id = StageJob.create(@db, pipeline_run_id: @pipeline_run_id, stage: stage)
|
|
46
|
+
StageJob.update_status(@db, job_id, "running")
|
|
47
|
+
job_id
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def end_stage(job_id, status)
|
|
51
|
+
return unless job_id
|
|
52
|
+
StageJob.update_status(@db, job_id, status)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def complete_run
|
|
56
|
+
return unless @pipeline_run_id
|
|
57
|
+
PipelineRun.update_status(@db, @pipeline_run_id, "completed")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def fail_run
|
|
61
|
+
return unless @pipeline_run_id
|
|
62
|
+
PipelineRun.update_status(@db, @pipeline_run_id, "failed")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def ensure_running
|
|
68
|
+
run = PipelineRun.find(@db, @pipeline_run_id)
|
|
69
|
+
return unless run && run[:status] == "scheduled"
|
|
70
|
+
PipelineRun.update_status(@db, @pipeline_run_id, "running")
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Persistence
|
|
7
|
+
module PipelineRun
|
|
8
|
+
def self.create(db, commit_hash:, branch:, project_path: nil)
|
|
9
|
+
now = Time.now.utc.iso8601
|
|
10
|
+
db.execute(
|
|
11
|
+
"INSERT INTO pipeline_runs (commit_hash, branch, project_path, status, created_at, updated_at) VALUES (?, ?, ?, 'scheduled', ?, ?)",
|
|
12
|
+
[commit_hash, branch, project_path, now, now]
|
|
13
|
+
)
|
|
14
|
+
db.last_insert_row_id
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.find(db, id)
|
|
18
|
+
row = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE id = ?", [id]).first
|
|
19
|
+
return nil unless row
|
|
20
|
+
row_to_hash(row)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.find_by_branch(db, branch)
|
|
24
|
+
rows = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE branch = ? ORDER BY id DESC", [branch])
|
|
25
|
+
rows.map { |row| row_to_hash(row) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.find_by_commit(db, commit_hash)
|
|
29
|
+
rows = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE commit_hash = ? ORDER BY id DESC", [commit_hash])
|
|
30
|
+
rows.map { |row| row_to_hash(row) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.store_pid(db, id, pid)
|
|
34
|
+
db.execute("UPDATE pipeline_runs SET pid = ? WHERE id = ?", [pid, id])
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.find_running_with_pid(db, branch)
|
|
38
|
+
row = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs WHERE branch = ? AND status = 'running' AND pid IS NOT NULL ORDER BY id DESC LIMIT 1", [branch]).first
|
|
39
|
+
return nil unless row
|
|
40
|
+
row_to_hash(row)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.update_status(db, id, new_status)
|
|
44
|
+
now = Time.now.utc.iso8601
|
|
45
|
+
db.execute("UPDATE pipeline_runs SET status = ?, updated_at = ? WHERE id = ?", [new_status, now, id])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.recent(db, limit: 20)
|
|
49
|
+
rows = db.execute("SELECT id, commit_hash, branch, status, pid, project_path, created_at, updated_at FROM pipeline_runs ORDER BY id DESC LIMIT ?", [limit])
|
|
50
|
+
rows.map { |row| row_to_hash(row) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def self.row_to_hash(row)
|
|
54
|
+
{ id: row[0], commit_hash: row[1], branch: row[2], status: row[3], pid: row[4], project_path: row[5], created_at: row[6], updated_at: row[7] }
|
|
55
|
+
end
|
|
56
|
+
private_class_method :row_to_hash
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Persistence
|
|
7
|
+
module StageJob
|
|
8
|
+
TERMINAL_STATUSES = %w[completed failed timed_out cancelled].freeze
|
|
9
|
+
|
|
10
|
+
def self.create(db, pipeline_run_id:, stage:)
|
|
11
|
+
db.execute(
|
|
12
|
+
"INSERT INTO stage_jobs (pipeline_run_id, stage, status) VALUES (?, ?, 'scheduled')",
|
|
13
|
+
[pipeline_run_id, stage]
|
|
14
|
+
)
|
|
15
|
+
db.last_insert_row_id
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.find(db, id)
|
|
19
|
+
row = db.execute("SELECT id, pipeline_run_id, stage, status, started_at, completed_at FROM stage_jobs WHERE id = ?", [id]).first
|
|
20
|
+
return nil unless row
|
|
21
|
+
row_to_hash(row)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.update_status(db, id, new_status)
|
|
25
|
+
now = Time.now.utc.iso8601
|
|
26
|
+
if new_status == "running"
|
|
27
|
+
db.execute("UPDATE stage_jobs SET status = ?, started_at = ? WHERE id = ?", [new_status, now, id])
|
|
28
|
+
elsif TERMINAL_STATUSES.include?(new_status)
|
|
29
|
+
db.execute("UPDATE stage_jobs SET status = ?, completed_at = ? WHERE id = ?", [new_status, now, id])
|
|
30
|
+
else
|
|
31
|
+
db.execute("UPDATE stage_jobs SET status = ? WHERE id = ?", [new_status, id])
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.elapsed_duration(job)
|
|
36
|
+
return nil unless job[:started_at] && job[:completed_at]
|
|
37
|
+
Time.parse(job[:completed_at]) - Time.parse(job[:started_at])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.row_to_hash(row)
|
|
41
|
+
{ id: row[0], pipeline_run_id: row[1], stage: row[2], status: row[3], started_at: row[4], completed_at: row[5] }
|
|
42
|
+
end
|
|
43
|
+
private_class_method :row_to_hash
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -1,18 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module FunCi
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
# States: scheduled, running, completed, failed, timed_out, cancelled
|
|
7
|
-
#
|
|
8
|
-
# Valid transitions:
|
|
9
|
-
# scheduled -> running
|
|
10
|
-
# scheduled -> cancelled
|
|
11
|
-
# running -> completed
|
|
12
|
-
# running -> failed
|
|
13
|
-
# running -> timed_out
|
|
14
|
-
# running -> cancelled
|
|
15
|
-
class StateMachine
|
|
4
|
+
module Persistence
|
|
5
|
+
class StateMachine
|
|
16
6
|
class InvalidTransition < StandardError; end
|
|
17
7
|
|
|
18
8
|
STATES = %i[scheduled running completed failed timed_out cancelled].freeze
|
|
@@ -55,5 +45,6 @@ module FunCi
|
|
|
55
45
|
|
|
56
46
|
raise ArgumentError, "Unknown state: #{state}. Valid states: #{STATES.join(", ")}"
|
|
57
47
|
end
|
|
48
|
+
end
|
|
58
49
|
end
|
|
59
50
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Pipeline
|
|
5
|
+
class BackgroundWrapper
|
|
6
|
+
def initialize(recorder:, job_id:, executor:)
|
|
7
|
+
@recorder = recorder
|
|
8
|
+
@job_id = job_id
|
|
9
|
+
@executor = executor
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def run
|
|
13
|
+
_output, status, timed_out = @executor.call
|
|
14
|
+
if timed_out
|
|
15
|
+
@recorder.end_stage(@job_id, "timed_out")
|
|
16
|
+
@recorder.fail_run
|
|
17
|
+
elsif status.success?
|
|
18
|
+
@recorder.end_stage(@job_id, "completed")
|
|
19
|
+
@recorder.complete_run
|
|
20
|
+
else
|
|
21
|
+
@recorder.end_stage(@job_id, "failed")
|
|
22
|
+
@recorder.fail_run
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../persistence/database"
|
|
4
|
+
require_relative "../persistence/pipeline_recorder"
|
|
5
|
+
require_relative "background_wrapper"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Pipeline
|
|
9
|
+
class PipelineForker
|
|
10
|
+
def self.fork_pipeline(commit_hash:, branch:, db_path:)
|
|
11
|
+
pid = fork do
|
|
12
|
+
run_in_child(commit_hash: commit_hash, branch: branch, db_path: db_path)
|
|
13
|
+
end
|
|
14
|
+
Process.detach(pid)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.run_in_child(commit_hash:, branch:, db_path:)
|
|
18
|
+
db = Persistence::Database.connection(db_path)
|
|
19
|
+
recorder = Persistence::DbRecorder.new(db)
|
|
20
|
+
Trigger.new(
|
|
21
|
+
project_root: Dir.pwd,
|
|
22
|
+
commit_hash: commit_hash,
|
|
23
|
+
branch: branch,
|
|
24
|
+
stdout: File.open(File::NULL, "w"),
|
|
25
|
+
recorder: recorder,
|
|
26
|
+
background_launcher: method(:sync_launcher)
|
|
27
|
+
).run
|
|
28
|
+
recorder.close
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def self.sync_launcher(db_path:, pipeline_run_id:, job_id:, executor:)
|
|
32
|
+
recorder = Persistence::DbRecorder.for_background(db_path, pipeline_run_id)
|
|
33
|
+
BackgroundWrapper.new(recorder: recorder, job_id: job_id, executor: executor).run
|
|
34
|
+
recorder.close
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Pipeline
|
|
5
|
+
module ProcessRunner
|
|
6
|
+
def run_process_with_timeout(cmd, budget)
|
|
7
|
+
r, w = IO.pipe
|
|
8
|
+
pid = Process.spawn(cmd, out: w, err: w, pgroup: true)
|
|
9
|
+
w.close
|
|
10
|
+
|
|
11
|
+
reader = Thread.new { r.read }
|
|
12
|
+
|
|
13
|
+
if reader.join(budget)
|
|
14
|
+
output = reader.value
|
|
15
|
+
_, status = Process.waitpid2(pid)
|
|
16
|
+
[output, status, false]
|
|
17
|
+
else
|
|
18
|
+
Process.kill("TERM", -pid) rescue nil
|
|
19
|
+
Process.kill("KILL", -pid) rescue nil
|
|
20
|
+
Process.waitpid(pid) rescue nil
|
|
21
|
+
["", nil, true]
|
|
22
|
+
end
|
|
23
|
+
ensure
|
|
24
|
+
r&.close rescue nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Pipeline
|
|
5
|
+
class ProgressReporter
|
|
6
|
+
def initialize(stdout:)
|
|
7
|
+
@stdout = stdout
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def phase_one_result(results)
|
|
11
|
+
markers = results.map { |stage, passed| "#{stage} #{result_marker(passed)}" }
|
|
12
|
+
summary = results.values.all? ? "phase 1 passed" : "phase 1 failed"
|
|
13
|
+
@stdout.puts "fun-ci: #{markers.join(" ")} (#{summary})"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def fast_result(passed)
|
|
17
|
+
@stdout.puts "fun-ci: fast #{result_marker(passed)}"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def slow_launched
|
|
21
|
+
@stdout.puts "fun-ci: slow (running in background)"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def result_marker(passed)
|
|
27
|
+
passed ? "ok" : "FAIL"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "timeout"
|
|
4
|
+
require_relative "process_runner"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Pipeline
|
|
8
|
+
class StageRunner
|
|
9
|
+
include ProcessRunner
|
|
10
|
+
|
|
11
|
+
def initialize(commit_hash:, stdout:, command_runner: nil, time_budgets: {}, recorder: FunCi::NullRecorder.new)
|
|
12
|
+
@commit_hash = commit_hash
|
|
13
|
+
@stdout = stdout
|
|
14
|
+
@command_runner = command_runner
|
|
15
|
+
@time_budgets = time_budgets
|
|
16
|
+
@recorder = recorder
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run_stage(config, stage)
|
|
20
|
+
script = config.script_path(stage)
|
|
21
|
+
budget = @time_budgets[stage]
|
|
22
|
+
|
|
23
|
+
job_id = @recorder.start_stage(stage)
|
|
24
|
+
output, status, timed_out = run_with_timeout(script, budget)
|
|
25
|
+
|
|
26
|
+
if timed_out
|
|
27
|
+
@recorder.end_stage(job_id, "timed_out")
|
|
28
|
+
@stdout.puts "#{stage_label(stage)} killed -- exceeded #{budget}s time budget."
|
|
29
|
+
@stdout.puts budget_advice(stage)
|
|
30
|
+
return false
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
unless status.success?
|
|
34
|
+
@recorder.end_stage(job_id, "failed")
|
|
35
|
+
@stdout.puts output unless output.empty?
|
|
36
|
+
@stdout.puts "#{stage_label(stage)} failed."
|
|
37
|
+
return false
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
@recorder.end_stage(job_id, "completed")
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def run_with_timeout(script, budget)
|
|
47
|
+
cmd = "#{script} #{@commit_hash}"
|
|
48
|
+
|
|
49
|
+
if @command_runner
|
|
50
|
+
begin
|
|
51
|
+
output, status = @command_runner.call(cmd)
|
|
52
|
+
[output, status, false]
|
|
53
|
+
rescue Timeout::Error
|
|
54
|
+
["", nil, true]
|
|
55
|
+
end
|
|
56
|
+
else
|
|
57
|
+
run_process_with_timeout(cmd, budget)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def stage_label(stage)
|
|
62
|
+
case stage
|
|
63
|
+
when "lint" then "Lint"
|
|
64
|
+
when "build" then "Build"
|
|
65
|
+
when "fast" then "Fast suite"
|
|
66
|
+
when "slow" then "Slow suite"
|
|
67
|
+
else stage
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def budget_advice(stage)
|
|
72
|
+
case stage
|
|
73
|
+
when "lint"
|
|
74
|
+
"Trim your linter config or split into stages."
|
|
75
|
+
when "build"
|
|
76
|
+
"Keep your build efficient and not let it become a bottleneck."
|
|
77
|
+
when "fast"
|
|
78
|
+
"Your fast tests have gotten too slow. Split or speed them up."
|
|
79
|
+
when "slow"
|
|
80
|
+
"Pare down integration tests, parallelise, or raise the budget."
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../persistence/pipeline_run"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Pipeline
|
|
7
|
+
class StalePipelineCanceller
|
|
8
|
+
def initialize(db:, branch:, stdout:, process_killer: nil)
|
|
9
|
+
@db = db
|
|
10
|
+
@branch = branch
|
|
11
|
+
@stdout = stdout
|
|
12
|
+
@process_killer = process_killer || method(:default_process_killer)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def cancel(new_commit_hash:)
|
|
16
|
+
run = Persistence::PipelineRun.find_running_with_pid(@db, @branch)
|
|
17
|
+
return unless run
|
|
18
|
+
|
|
19
|
+
kill_process(run[:pid])
|
|
20
|
+
Persistence::PipelineRun.update_status(@db, run[:id], "cancelled")
|
|
21
|
+
@stdout.puts "Cancelled stale pipeline for #{run[:commit_hash]}. Starting fresh for #{new_commit_hash}."
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def store_pid(run_id, pid)
|
|
25
|
+
Persistence::PipelineRun.store_pid(@db, run_id, pid)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def kill_process(pid)
|
|
31
|
+
@process_killer.call(0, pid)
|
|
32
|
+
safe_signal("TERM", pid)
|
|
33
|
+
safe_signal("KILL", pid)
|
|
34
|
+
begin
|
|
35
|
+
Process.waitpid(pid)
|
|
36
|
+
rescue Errno::ECHILD, Errno::ESRCH # rubocop:disable Lint/SuppressedException
|
|
37
|
+
end
|
|
38
|
+
rescue Errno::ESRCH
|
|
39
|
+
# Process already dead
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def safe_signal(signal, pid)
|
|
43
|
+
@process_killer.call(signal, pid)
|
|
44
|
+
rescue Errno::ESRCH
|
|
45
|
+
# Already dead
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def default_process_killer(signal, pid)
|
|
49
|
+
Process.kill(signal, pid)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../setup/project_config"
|
|
5
|
+
require_relative "../persistence/pipeline_recorder"
|
|
6
|
+
require_relative "background_wrapper"
|
|
7
|
+
require_relative "stage_runner"
|
|
8
|
+
require_relative "stale_pipeline_canceller"
|
|
9
|
+
require_relative "progress_reporter"
|
|
10
|
+
require_relative "pipeline_forker"
|
|
11
|
+
require_relative "process_runner"
|
|
12
|
+
|
|
13
|
+
module FunCi
|
|
14
|
+
module Pipeline
|
|
15
|
+
class Trigger
|
|
16
|
+
include ProcessRunner
|
|
17
|
+
NULL_SHA = ("0" * 40).freeze
|
|
18
|
+
DEFAULT_BUDGETS = { "lint" => 30, "build" => 30, "fast" => 10, "slow" => 300 }.freeze
|
|
19
|
+
|
|
20
|
+
def self.run_from_args(args, stdout: $stdout, stderr: $stderr, recorder: Persistence::NullRecorder.new, pipeline_forker: nil)
|
|
21
|
+
positional = args.reject { |a| a.start_with?("--") }
|
|
22
|
+
if positional.length < 2
|
|
23
|
+
stderr.puts "fun-ci: commit hash and branch name are required."
|
|
24
|
+
stderr.puts "Usage: fun-ci trigger <commit-hash> <branch>"
|
|
25
|
+
return 1
|
|
26
|
+
end
|
|
27
|
+
commit_hash, branch = positional
|
|
28
|
+
if args.include?("--no-validate")
|
|
29
|
+
db_path = recorder.db_path
|
|
30
|
+
recorder.close
|
|
31
|
+
(pipeline_forker || PipelineForker.method(:fork_pipeline)).call(
|
|
32
|
+
commit_hash: commit_hash, branch: branch, db_path: db_path
|
|
33
|
+
)
|
|
34
|
+
return 0
|
|
35
|
+
end
|
|
36
|
+
new(project_root: Dir.pwd, commit_hash: commit_hash, branch: branch,
|
|
37
|
+
stdout: stdout, stderr: stderr, recorder: recorder).run
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_writer :command_runner
|
|
41
|
+
|
|
42
|
+
def initialize(project_root:, commit_hash:, branch:, stdout: $stdout, stderr: $stderr, command_runner: nil, time_budgets: {}, commit_validator: nil, recorder: Persistence::NullRecorder.new, background_launcher: nil)
|
|
43
|
+
@project_root = project_root
|
|
44
|
+
@commit_hash = commit_hash
|
|
45
|
+
@branch = branch
|
|
46
|
+
@stdout = stdout
|
|
47
|
+
@stderr = stderr
|
|
48
|
+
@command_runner = command_runner
|
|
49
|
+
@time_budgets = DEFAULT_BUDGETS.merge(time_budgets)
|
|
50
|
+
@commit_validator = commit_validator || method(:default_commit_validator)
|
|
51
|
+
@recorder = recorder
|
|
52
|
+
@background_launcher = background_launcher || method(:default_background_launcher)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def run
|
|
56
|
+
config = Setup::ProjectConfig.new(@project_root)
|
|
57
|
+
return handle_config_errors(config) if config.validate.any?
|
|
58
|
+
unless @commit_hash == NULL_SHA || @commit_validator.call(@commit_hash)
|
|
59
|
+
@stderr.puts "fun-ci: commit #{@commit_hash} not found in this repository."
|
|
60
|
+
return 1
|
|
61
|
+
end
|
|
62
|
+
cancel_stale_pipelines
|
|
63
|
+
@recorder.create_run(commit_hash: @commit_hash, branch: @branch, project_path: @project_root)
|
|
64
|
+
stage_runner = make_stage_runner
|
|
65
|
+
progress = ProgressReporter.new(stdout: @stdout)
|
|
66
|
+
results = run_phase_one(stage_runner, config)
|
|
67
|
+
progress.phase_one_result(results)
|
|
68
|
+
unless results.values.all?
|
|
69
|
+
@recorder.fail_run
|
|
70
|
+
return 1
|
|
71
|
+
end
|
|
72
|
+
spawn_slow_suite(config)
|
|
73
|
+
progress.slow_launched
|
|
74
|
+
fast_runner = make_stage_runner
|
|
75
|
+
fast_passed = fast_runner.run_stage(config, "fast")
|
|
76
|
+
progress.fast_result(fast_passed)
|
|
77
|
+
unless fast_passed
|
|
78
|
+
@recorder.fail_run
|
|
79
|
+
return 1
|
|
80
|
+
end
|
|
81
|
+
0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def handle_config_errors(config)
|
|
87
|
+
config.validate.each { |e| @stdout.puts "fun-ci: #{e}" }
|
|
88
|
+
unless config.folder_exists?
|
|
89
|
+
@stdout.puts "Create .fun-ci/lint.sh, build.sh, fast.sh, and slow.sh to set up this project."
|
|
90
|
+
@stdout.puts "Commit will proceed without CI."
|
|
91
|
+
end
|
|
92
|
+
0
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def run_phase_one(stage_runner, config)
|
|
96
|
+
results = {}
|
|
97
|
+
threads = %w[lint build].map do |stage|
|
|
98
|
+
Thread.new { results[stage] = stage_runner.run_stage(config, stage) }
|
|
99
|
+
end
|
|
100
|
+
threads.each(&:join)
|
|
101
|
+
results
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def spawn_slow_suite(config)
|
|
105
|
+
cmd = "#{config.script_path("slow")} #{@commit_hash}"
|
|
106
|
+
job_id = @recorder.start_stage("slow")
|
|
107
|
+
budget = @time_budgets["slow"]
|
|
108
|
+
executor = if @command_runner
|
|
109
|
+
runner = @command_runner
|
|
110
|
+
-> do
|
|
111
|
+
output, status = runner.call(cmd)
|
|
112
|
+
[output, status, false]
|
|
113
|
+
rescue Timeout::Error
|
|
114
|
+
["", nil, true]
|
|
115
|
+
end
|
|
116
|
+
else
|
|
117
|
+
-> { run_process_with_timeout(cmd, budget) }
|
|
118
|
+
end
|
|
119
|
+
@background_launcher.call(
|
|
120
|
+
db_path: @recorder.db_path, pipeline_run_id: @recorder.pipeline_run_id,
|
|
121
|
+
job_id: job_id, executor: executor
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def make_stage_runner
|
|
126
|
+
StageRunner.new(commit_hash: @commit_hash, stdout: @stdout,
|
|
127
|
+
command_runner: @command_runner, time_budgets: @time_budgets, recorder: @recorder)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def cancel_stale_pipelines
|
|
131
|
+
return unless @recorder.db
|
|
132
|
+
StalePipelineCanceller.new(db: @recorder.db, branch: @branch, stdout: @stdout)
|
|
133
|
+
.cancel(new_commit_hash: @commit_hash)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def default_background_launcher(db_path:, pipeline_run_id:, job_id:, executor:)
|
|
137
|
+
@recorder.close
|
|
138
|
+
pid = fork do
|
|
139
|
+
recorder = Persistence::DbRecorder.for_background(db_path, pipeline_run_id)
|
|
140
|
+
BackgroundWrapper.new(recorder: recorder, job_id: job_id, executor: executor).run
|
|
141
|
+
recorder.close
|
|
142
|
+
end
|
|
143
|
+
@recorder = Persistence::DbRecorder.for_background(db_path, pipeline_run_id)
|
|
144
|
+
Process.detach(pid)
|
|
145
|
+
Persistence::PipelineRun.store_pid(@recorder.db, pipeline_run_id, pid)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def default_commit_validator(hash)
|
|
149
|
+
Open3.capture2e("git", "cat-file", "-t", hash).last.success?
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|