fun_ci 1.2.1 → 2.0.1
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 +236 -0
- data/README.md +182 -59
- data/lib/fun_ci/agent/age.rb +15 -0
- data/lib/fun_ci/agent/command_support.rb +27 -0
- data/lib/fun_ci/agent/commands.rb +27 -0
- data/lib/fun_ci/agent/context.rb +10 -0
- data/lib/fun_ci/agent/digest.rb +49 -0
- data/lib/fun_ci/agent/events.rb +47 -0
- data/lib/fun_ci/agent/events_command.rb +61 -0
- data/lib/fun_ci/agent/evidence_text.rb +46 -0
- data/lib/fun_ci/agent/exit_code.rb +11 -0
- data/lib/fun_ci/agent/git.rb +32 -0
- data/lib/fun_ci/agent/live_pipeline.rb +35 -0
- data/lib/fun_ci/agent/options.rb +75 -0
- data/lib/fun_ci/agent/output.rb +81 -0
- data/lib/fun_ci/agent/report_stage.rb +71 -0
- data/lib/fun_ci/agent/reports.rb +47 -0
- data/lib/fun_ci/agent/run_report.rb +30 -0
- data/lib/fun_ci/agent/runs_command.rb +34 -0
- data/lib/fun_ci/agent/runs_text.rb +27 -0
- data/lib/fun_ci/agent/snapshots.rb +39 -0
- data/lib/fun_ci/agent/stage_summary.rb +30 -0
- data/lib/fun_ci/agent/status_command.rb +36 -0
- data/lib/fun_ci/agent/status_json.rb +27 -0
- data/lib/fun_ci/agent/status_text.rb +37 -0
- data/lib/fun_ci/agent/system_clock.rb +11 -0
- data/lib/fun_ci/agent/verdict.rb +32 -0
- data/lib/fun_ci/agent/wait_command.rb +71 -0
- data/lib/fun_ci/agent/waiting.rb +32 -0
- data/lib/fun_ci/agent/why_command.rb +50 -0
- data/lib/fun_ci/agent/why_json.rb +35 -0
- data/lib/fun_ci/agent/why_text.rb +43 -0
- data/lib/fun_ci/cli.rb +72 -94
- data/lib/fun_ci/cli_help.rb +65 -0
- data/lib/fun_ci/console/board_data.rb +66 -0
- data/lib/fun_ci/console/board_heartbeat.rb +40 -0
- data/lib/fun_ci/console/console_events.rb +20 -0
- data/lib/fun_ci/console/console_log.rb +26 -0
- data/lib/fun_ci/console/console_loop.rb +41 -0
- data/lib/fun_ci/console/console_session.rb +82 -0
- data/lib/fun_ci/console/console_state.rb +42 -0
- data/lib/fun_ci/console/key_handler.rb +73 -0
- data/lib/fun_ci/console/launcher.rb +43 -0
- data/lib/fun_ci/console/milestone_events.rb +38 -0
- data/lib/fun_ci/console/milestones.rb +25 -0
- data/lib/fun_ci/console/renderer_lookup.rb +51 -0
- data/lib/fun_ci/console/renderer_process.rb +60 -0
- data/lib/fun_ci/console/run_message.rb +33 -0
- data/lib/fun_ci/console/stage_change_detector.rb +32 -0
- data/lib/fun_ci/console/stage_events.rb +31 -0
- data/lib/fun_ci/console/stop_report.rb +39 -0
- data/lib/fun_ci/console/streak_counter.rb +19 -0
- data/lib/fun_ci/console/view.rb +47 -0
- data/lib/fun_ci/evidence/about.rb +12 -0
- data/lib/fun_ci/evidence/about_stage.rb +29 -0
- data/lib/fun_ci/evidence/caps.rb +77 -0
- data/lib/fun_ci/evidence/catalog.rb +83 -0
- data/lib/fun_ci/evidence/collector.rb +95 -0
- data/lib/fun_ci/evidence/command_output.rb +45 -0
- data/lib/fun_ci/evidence/command_reading.rb +38 -0
- data/lib/fun_ci/evidence/command_runner.rb +75 -0
- data/lib/fun_ci/evidence/context.rb +21 -0
- data/lib/fun_ci/evidence/context_document.rb +29 -0
- data/lib/fun_ci/evidence/deadline.rb +15 -0
- data/lib/fun_ci/evidence/detection.rb +82 -0
- data/lib/fun_ci/evidence/document.rb +59 -0
- data/lib/fun_ci/evidence/extract_command.rb +83 -0
- data/lib/fun_ci/evidence/extract_options.rb +45 -0
- data/lib/fun_ci/evidence/extraction.rb +54 -0
- data/lib/fun_ci/evidence/extractors/command.rb +52 -0
- data/lib/fun_ci/evidence/extractors/grep.rb +49 -0
- data/lib/fun_ci/evidence/extractors/json_log.rb +45 -0
- data/lib/fun_ci/evidence/extractors/junit_files.rb +32 -0
- data/lib/fun_ci/evidence/extractors/last_lines.rb +25 -0
- data/lib/fun_ci/evidence/extractors/log_file.rb +62 -0
- data/lib/fun_ci/evidence/extractors/process_tree.rb +51 -0
- data/lib/fun_ci/evidence/extractors/section.rb +49 -0
- data/lib/fun_ci/evidence/findings.rb +14 -0
- data/lib/fun_ci/evidence/line_ranges.rb +21 -0
- data/lib/fun_ci/evidence/line_scan.rb +22 -0
- data/lib/fun_ci/evidence/log_record.rb +58 -0
- data/lib/fun_ci/evidence/masking.rb +55 -0
- data/lib/fun_ci/evidence/option_check.rb +53 -0
- data/lib/fun_ci/evidence/outcome.rb +15 -0
- data/lib/fun_ci/evidence/patterns.rb +22 -0
- data/lib/fun_ci/evidence/presets/README.md +7 -0
- data/lib/fun_ci/evidence/presets/bun-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/cargo-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/dart-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/deno-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/dotnet-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/ecs.yml +3 -0
- data/lib/fun_ci/evidence/presets/eslint.yml +6 -0
- data/lib/fun_ci/evidence/presets/exunit.yml +6 -0
- data/lib/fun_ci/evidence/presets/gcc.yml +6 -0
- data/lib/fun_ci/evidence/presets/go-build.yml +5 -0
- data/lib/fun_ci/evidence/presets/go-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/gradle.yml +6 -0
- data/lib/fun_ci/evidence/presets/gtest.yml +6 -0
- data/lib/fun_ci/evidence/presets/jest.yml +6 -0
- data/lib/fun_ci/evidence/presets/logstash.yml +3 -0
- data/lib/fun_ci/evidence/presets/maven.yml +6 -0
- data/lib/fun_ci/evidence/presets/minitest.yml +6 -0
- data/lib/fun_ci/evidence/presets/mocha.yml +6 -0
- data/lib/fun_ci/evidence/presets/mypy.yml +6 -0
- data/lib/fun_ci/evidence/presets/node-test.yml +6 -0
- data/lib/fun_ci/evidence/presets/phpunit.yml +6 -0
- data/lib/fun_ci/evidence/presets/pino.yml +5 -0
- data/lib/fun_ci/evidence/presets/prove.yml +6 -0
- data/lib/fun_ci/evidence/presets/pytest.yml +6 -0
- data/lib/fun_ci/evidence/presets/rspec.yml +6 -0
- data/lib/fun_ci/evidence/presets/rubocop.yml +6 -0
- data/lib/fun_ci/evidence/presets/ruff.yml +6 -0
- data/lib/fun_ci/evidence/presets/rustc.yml +6 -0
- data/lib/fun_ci/evidence/presets/shellcheck.yml +5 -0
- data/lib/fun_ci/evidence/presets/structlog.yml +4 -0
- data/lib/fun_ci/evidence/presets/swift-test.yml +5 -0
- data/lib/fun_ci/evidence/presets/tsc.yml +6 -0
- data/lib/fun_ci/evidence/presets/unittest.yml +6 -0
- data/lib/fun_ci/evidence/presets/vitest.yml +6 -0
- data/lib/fun_ci/evidence/presets.rb +30 -0
- data/lib/fun_ci/evidence/problem.rb +9 -0
- data/lib/fun_ci/evidence/process_table.rb +40 -0
- data/lib/fun_ci/evidence/saved_run.rb +22 -0
- data/lib/fun_ci/evidence/section_walk.rb +49 -0
- data/lib/fun_ci/evidence/settings.rb +64 -0
- data/lib/fun_ci/evidence/settings_check.rb +78 -0
- data/lib/fun_ci/evidence/source.rb +34 -0
- data/lib/fun_ci/evidence/sources.rb +21 -0
- data/lib/fun_ci/evidence/stage_context.rb +30 -0
- data/lib/fun_ci/evidence/stamp.rb +10 -0
- data/lib/fun_ci/evidence/start.rb +36 -0
- data/lib/fun_ci/evidence/worktree.rb +62 -0
- data/lib/fun_ci/persistence/active_runs.rb +69 -0
- data/lib/fun_ci/persistence/database.rb +63 -30
- data/lib/fun_ci/persistence/null_recorder.rb +25 -0
- data/lib/fun_ci/persistence/output_tail.rb +24 -0
- data/lib/fun_ci/persistence/pipeline_recorder.rb +78 -32
- data/lib/fun_ci/persistence/pipeline_run.rb +26 -17
- data/lib/fun_ci/persistence/project_runs.rb +52 -0
- data/lib/fun_ci/persistence/raw_outputs.rb +46 -0
- data/lib/fun_ci/persistence/retention.rb +25 -0
- data/lib/fun_ci/persistence/run_status.rb +31 -0
- data/lib/fun_ci/persistence/stage_job.rb +61 -14
- data/lib/fun_ci/persistence/state_dir.rb +26 -0
- data/lib/fun_ci/persistence/write_trouble.rb +46 -0
- data/lib/fun_ci/pipeline/background_fork.rb +42 -0
- data/lib/fun_ci/pipeline/command_executor.rb +58 -0
- data/lib/fun_ci/pipeline/gate.rb +35 -0
- data/lib/fun_ci/pipeline/git_environment.rb +16 -0
- data/lib/fun_ci/pipeline/head_part.rb +40 -0
- data/lib/fun_ci/pipeline/in_place.rb +17 -0
- data/lib/fun_ci/pipeline/output_window.rb +73 -0
- data/lib/fun_ci/pipeline/pipeline_forker.rb +18 -16
- data/lib/fun_ci/pipeline/process_runner.rb +90 -17
- data/lib/fun_ci/pipeline/run_canceller.rb +75 -0
- data/lib/fun_ci/pipeline/slot.rb +30 -0
- data/lib/fun_ci/pipeline/slot_lock.rb +13 -0
- data/lib/fun_ci/pipeline/slot_run.rb +86 -0
- data/lib/fun_ci/pipeline/stage_dir.rb +50 -0
- data/lib/fun_ci/pipeline/stage_end.rb +70 -0
- data/lib/fun_ci/pipeline/stage_execution.rb +67 -0
- data/lib/fun_ci/pipeline/stage_runner.rb +42 -65
- data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +16 -33
- data/lib/fun_ci/pipeline/tail_segments.rb +60 -0
- data/lib/fun_ci/pipeline/test_report.rb +48 -0
- data/lib/fun_ci/pipeline/trigger.rb +55 -115
- data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
- data/lib/fun_ci/pipeline/trigger_params.rb +56 -0
- data/lib/fun_ci/pipeline/workspaces.rb +22 -0
- data/lib/fun_ci/pipeline/worktree_pool.rb +66 -0
- data/lib/fun_ci/pipeline/worktree_prune.rb +49 -0
- data/lib/fun_ci/pipeline/worktrees.rb +45 -0
- data/lib/fun_ci/setup/agent_instructions.rb +31 -0
- data/lib/fun_ci/setup/commands.rb +49 -0
- data/lib/fun_ci/setup/git_hooks.rb +20 -0
- data/lib/fun_ci/setup/hook_script.rb +47 -0
- data/lib/fun_ci/setup/hook_writer.rb +13 -31
- data/lib/fun_ci/setup/installer.rb +39 -23
- data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
- data/lib/fun_ci/setup/project_config.rb +43 -17
- data/lib/fun_ci/setup/project_detector.rb +39 -7
- data/lib/fun_ci/setup/settings.rb +36 -0
- data/lib/fun_ci/setup/setup_checker.rb +18 -12
- data/lib/fun_ci/setup/stage_templates/javascript.rb +18 -0
- data/lib/fun_ci/setup/stage_templates/managed.rb +24 -0
- data/lib/fun_ci/setup/stage_templates/native.rb +25 -0
- data/lib/fun_ci/setup/stage_templates/scripting.rb +27 -0
- data/lib/fun_ci/setup/stage_templates/shell.rb +16 -0
- data/lib/fun_ci/setup/stage_templates.rb +25 -0
- data/lib/fun_ci/setup/template_writer.rb +11 -36
- data/lib/fun_ci/version.rb +1 -1
- data/lib/fun_ci.rb +0 -1
- metadata +205 -41
- data/exe/fun-ci-trigger +0 -19
- data/exe/fun-ci-tui +0 -23
- data/lib/fun_ci/animations/celebrate.rb +0 -210
- data/lib/fun_ci/animations/explosion.rb +0 -138
- data/lib/fun_ci/animations/flash.rb +0 -205
- data/lib/fun_ci/animations/idle.rb +0 -127
- data/lib/fun_ci/animations/leprechauns.rb +0 -240
- data/lib/fun_ci/animations/running.rb +0 -129
- data/lib/fun_ci/animations/success.rb +0 -281
- data/lib/fun_ci/animations/yay.rb +0 -169
- data/lib/fun_ci/persistence/state_machine.rb +0 -50
- data/lib/fun_ci/pipeline/background_wrapper.rb +0 -27
- data/lib/fun_ci/tui/admin_tui.rb +0 -90
- data/lib/fun_ci/tui/animation.rb +0 -49
- data/lib/fun_ci/tui/animation_compositor.rb +0 -107
- data/lib/fun_ci/tui/animation_frames.rb +0 -112
- data/lib/fun_ci/tui/animation_library.rb +0 -46
- data/lib/fun_ci/tui/animation_renderer.rb +0 -144
- data/lib/fun_ci/tui/ansi.rb +0 -34
- data/lib/fun_ci/tui/board_data.rb +0 -53
- data/lib/fun_ci/tui/board_renderer.rb +0 -105
- data/lib/fun_ci/tui/duration_formatter.rb +0 -24
- data/lib/fun_ci/tui/header_animation_manager.rb +0 -71
- data/lib/fun_ci/tui/header_animation_player.rb +0 -45
- data/lib/fun_ci/tui/key_handler.rb +0 -86
- data/lib/fun_ci/tui/looping_animation_player.rb +0 -45
- data/lib/fun_ci/tui/relative_time.rb +0 -22
- data/lib/fun_ci/tui/row_formatter.rb +0 -108
- data/lib/fun_ci/tui/screen.rb +0 -103
- data/lib/fun_ci/tui/spinner.rb +0 -24
- data/lib/fun_ci/tui/stage_change_detector.rb +0 -58
- data/lib/fun_ci/tui/streak_counter.rb +0 -29
- data/lib/fun_ci/tui/terminal_input.rb +0 -69
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../evidence/outcome"
|
|
4
|
+
require_relative "../evidence/document"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Pipeline
|
|
8
|
+
# Records a stage that has finished, whichever process ran it: a failed
|
|
9
|
+
# stage's evidence first, so whoever sees the outcome can read why, then
|
|
10
|
+
# how it exited, then the outcome. Called while the stage still holds its
|
|
11
|
+
# slot and stage directory, which the collector reads.
|
|
12
|
+
class StageEnd
|
|
13
|
+
# What running a stage answered: its output, its Process::Status (nil
|
|
14
|
+
# when it was killed over budget), and for an overrun, what was found
|
|
15
|
+
# before the kill.
|
|
16
|
+
Finished = Data.define(:output, :status, :timed_out, :overrun)
|
|
17
|
+
|
|
18
|
+
class Finished
|
|
19
|
+
def initialize(overrun: nil, **) = super
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A failed stage's state in the words `status --json` uses.
|
|
23
|
+
STATES = { "failed" => "failed", "timed_out" => "over_budget" }.freeze
|
|
24
|
+
|
|
25
|
+
def initialize(recorder, job_id, collector)
|
|
26
|
+
@recorder = recorder
|
|
27
|
+
@job_id = job_id
|
|
28
|
+
@collector = collector
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Answers the outcome recorded: completed, failed or timed_out.
|
|
32
|
+
def record(finished)
|
|
33
|
+
result = outcome(finished)
|
|
34
|
+
keep_evidence(finished) unless result == "completed"
|
|
35
|
+
keep_exit(finished.status) if finished.status
|
|
36
|
+
@recorder.end_stage(@job_id, result)
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def outcome(finished)
|
|
43
|
+
return "timed_out" if finished.timed_out
|
|
44
|
+
|
|
45
|
+
finished.status.success? ? "completed" : "failed"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Whatever goes wrong collecting the evidence is kept as a problem, and
|
|
49
|
+
# the outcome is recorded all the same.
|
|
50
|
+
def keep_evidence(finished)
|
|
51
|
+
@recorder.keep_evidence(@job_id, @collector.collect(finished.output, ending(finished)))
|
|
52
|
+
@recorder.keep_raw(@job_id, @collector.masked(finished.output))
|
|
53
|
+
rescue StandardError => e
|
|
54
|
+
@recorder.keep_evidence(@job_id, Evidence::Document.broken("#{e.class}: #{e.message}"))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def ending(finished)
|
|
58
|
+
status = finished.status
|
|
59
|
+
Evidence::Outcome.new(state: STATES.fetch(outcome(finished)), exit_status: status&.exitstatus,
|
|
60
|
+
signal: status&.termsig && Signal.signame(status.termsig),
|
|
61
|
+
alongside: @recorder.alongside(@job_id), overrun: finished.overrun)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def keep_exit(status)
|
|
65
|
+
signal = status.termsig && Signal.signame(status.termsig)
|
|
66
|
+
@recorder.keep_exit(@job_id, status.exitstatus, signal)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stage_end"
|
|
4
|
+
require_relative "../evidence/start"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Pipeline
|
|
8
|
+
# Runs a stage that has been started, in `dir`, with a stage directory of
|
|
9
|
+
# its own, and records how it ended while it still holds both. The same
|
|
10
|
+
# for every stage, the slow suite in its forked child included.
|
|
11
|
+
class StageExecution
|
|
12
|
+
def initialize(seams:, dir:, commit:)
|
|
13
|
+
@seams = seams
|
|
14
|
+
@dir = dir
|
|
15
|
+
@commit = commit
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Answers [the outcome recorded, what the stage printed].
|
|
19
|
+
def run(stage, command, recorder, job_id)
|
|
20
|
+
with_stage_dir do |stage_dir|
|
|
21
|
+
collector = collector(stage, stage_dir)
|
|
22
|
+
finished = finished(stage, command, stage_dir, collector) { |pid| recorder.stage_process(job_id, pid) }
|
|
23
|
+
[StageEnd.new(recorder, job_id, collector).record(finished), finished.output]
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# A stage over budget is looked at before the kill (acceptance-tests.md, AT-10.17).
|
|
30
|
+
def finished(stage, command, stage_dir, collector, &)
|
|
31
|
+
overrun = nil
|
|
32
|
+
output, status, timed_out = execute(stage, command, stage_dir, lambda { |pgid|
|
|
33
|
+
overrun = collector.before_kill(pgid)
|
|
34
|
+
},
|
|
35
|
+
&)
|
|
36
|
+
StageEnd::Finished.new(output: output, status: status, timed_out: timed_out, overrun: overrun)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The stage learns its name from FUN_CI_STAGE (acceptance-tests.md, AT-10.7).
|
|
40
|
+
def execute(stage, command, stage_dir, before_kill, &)
|
|
41
|
+
window = stage_dir.window
|
|
42
|
+
launch = ProcessRunner::Launch.new(env: { "FUN_CI_STAGE" => stage }, output: window,
|
|
43
|
+
before_kill: before_kill)
|
|
44
|
+
@seams.executor(@dir).call(command, @seams.budgets[stage], launch, &)
|
|
45
|
+
ensure
|
|
46
|
+
window&.close
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def with_stage_dir
|
|
50
|
+
stage_dir = @seams.stage_dir.call
|
|
51
|
+
yield stage_dir
|
|
52
|
+
ensure
|
|
53
|
+
stage_dir&.remove
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Made before the stage runs, which is when its watched files are stamped.
|
|
57
|
+
def collector(stage, stage_dir)
|
|
58
|
+
sources = Evidence::Sources.new(stage: stage, worktree: @dir, stage_dir: stage_dir,
|
|
59
|
+
environment: @seams.environment,
|
|
60
|
+
budget: @seams.budgets[stage], commit: @commit.to_h, started: Time.now,
|
|
61
|
+
processes: @seams.process_table)
|
|
62
|
+
commands = @seams.extractor_runner.call(dir: @dir, scratch: stage_dir.scratch)
|
|
63
|
+
Evidence::Start.collector(sources, @seams.clock, commands)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -1,86 +1,63 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
require_relative "
|
|
5
|
-
require_relative "../persistence/pipeline_recorder"
|
|
3
|
+
require_relative "trigger_params"
|
|
4
|
+
require_relative "stage_execution"
|
|
6
5
|
|
|
7
6
|
module FunCi
|
|
8
7
|
module Pipeline
|
|
9
8
|
class StageRunner
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
9
|
+
LABELS = { "lint" => "Lint", "build" => "Build", "fast" => "Fast suite", "slow" => "Slow suite" }.freeze
|
|
10
|
+
ADVICE = {
|
|
11
|
+
"lint" => "Trim your linter config or split into stages.",
|
|
12
|
+
"build" => "Keep your build efficient and not let it become a bottleneck.",
|
|
13
|
+
"fast" => "Your fast tests have gotten too slow. Split or speed them up.",
|
|
14
|
+
"slow" => "Pare down integration tests, parallelise, or raise the budget."
|
|
15
|
+
}.freeze
|
|
16
|
+
FIX_TESTS = "Fix the failing tests above, then try again."
|
|
17
|
+
NEXT_STEPS = {
|
|
18
|
+
"lint" => "Fix what the linter reported above, then try again.",
|
|
19
|
+
"build" => "Fix the build errors above, then try again.",
|
|
20
|
+
"fast" => FIX_TESTS,
|
|
21
|
+
"slow" => FIX_TESTS
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(commit:, stdout:, seams: Seams.new, dir: Dir.pwd)
|
|
25
|
+
@commit = commit
|
|
14
26
|
@stdout = stdout
|
|
15
|
-
@
|
|
16
|
-
@
|
|
17
|
-
@recorder = recorder
|
|
27
|
+
@seams = seams
|
|
28
|
+
@dir = dir
|
|
18
29
|
end
|
|
19
30
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
output,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@stdout.puts "#{stage_label(stage)} killed -- exceeded #{budget}s time budget."
|
|
30
|
-
@stdout.puts budget_advice(stage)
|
|
31
|
-
return false
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
unless status.success?
|
|
35
|
-
@recorder.end_stage(job_id, "failed")
|
|
36
|
-
@stdout.puts output unless output.empty?
|
|
37
|
-
@stdout.puts "#{stage_label(stage)} failed."
|
|
38
|
-
return false
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
@recorder.end_stage(job_id, "completed")
|
|
42
|
-
true
|
|
31
|
+
# A failed stage's evidence is kept before its outcome is recorded, so
|
|
32
|
+
# whoever sees the outcome can read why.
|
|
33
|
+
def passes?(config, stage)
|
|
34
|
+
job_id = @seams.recorder.start_stage(stage, budget: @seams.budgets[stage])
|
|
35
|
+
command = config.stage_command(stage, @commit.sha)
|
|
36
|
+
result, output = StageExecution.new(seams: @seams, dir: @dir, commit: @commit).run(stage, command,
|
|
37
|
+
@seams.recorder, job_id)
|
|
38
|
+
tell(stage, output, result)
|
|
39
|
+
result == "completed"
|
|
43
40
|
end
|
|
44
41
|
|
|
45
42
|
private
|
|
46
43
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
if @command_runner
|
|
51
|
-
begin
|
|
52
|
-
output, status = @command_runner.call(cmd)
|
|
53
|
-
[output, status, false]
|
|
54
|
-
rescue Timeout::Error
|
|
55
|
-
["", nil, true]
|
|
56
|
-
end
|
|
57
|
-
else
|
|
58
|
-
run_process_with_timeout(cmd, budget)
|
|
59
|
-
end
|
|
44
|
+
def tell(stage, output, result)
|
|
45
|
+
report_timeout(stage) if result == "timed_out"
|
|
46
|
+
report_failure(stage, output) if result == "failed"
|
|
60
47
|
end
|
|
61
48
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
when "build" then "Build"
|
|
66
|
-
when "fast" then "Fast suite"
|
|
67
|
-
when "slow" then "Slow suite"
|
|
68
|
-
else stage
|
|
69
|
-
end
|
|
49
|
+
def report_timeout(stage)
|
|
50
|
+
@stdout.puts "#{label(stage)} killed -- exceeded #{@seams.budgets[stage]}s time budget."
|
|
51
|
+
@stdout.puts ADVICE[stage]
|
|
70
52
|
end
|
|
71
53
|
|
|
72
|
-
def
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
when "build"
|
|
77
|
-
"Keep your build efficient and not let it become a bottleneck."
|
|
78
|
-
when "fast"
|
|
79
|
-
"Your fast tests have gotten too slow. Split or speed them up."
|
|
80
|
-
when "slow"
|
|
81
|
-
"Pare down integration tests, parallelise, or raise the budget."
|
|
82
|
-
end
|
|
54
|
+
def report_failure(stage, output)
|
|
55
|
+
@stdout.puts output unless output.empty?
|
|
56
|
+
@stdout.puts "#{label(stage)} failed."
|
|
57
|
+
@stdout.puts NEXT_STEPS[stage]
|
|
83
58
|
end
|
|
59
|
+
|
|
60
|
+
def label(stage) = LABELS.fetch(stage, stage)
|
|
84
61
|
end
|
|
85
62
|
end
|
|
86
63
|
end
|
|
@@ -1,52 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "../persistence/
|
|
3
|
+
require_relative "../persistence/active_runs"
|
|
4
|
+
require_relative "run_canceller"
|
|
4
5
|
|
|
5
6
|
module FunCi
|
|
6
7
|
module Pipeline
|
|
8
|
+
# A newer commit on a branch cancels every run still going for an older
|
|
9
|
+
# one, unless an agent is waiting on it: its processes are stopped and it
|
|
10
|
+
# is recorded cancelled (acceptance-tests.md, AT-1.6, AT-9.12).
|
|
7
11
|
class StalePipelineCanceller
|
|
8
|
-
def initialize(db:, branch:, stdout:,
|
|
12
|
+
def initialize(db:, branch:, stdout:, run_canceller: RunCanceller.new)
|
|
9
13
|
@db = db
|
|
10
14
|
@branch = branch
|
|
11
15
|
@stdout = stdout
|
|
12
|
-
@
|
|
16
|
+
@run_canceller = run_canceller
|
|
13
17
|
end
|
|
14
18
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
19
|
+
# How long an agent's last poll keeps a run it waits on from being cancelled.
|
|
20
|
+
WAITED_ON_SECONDS = 10
|
|
23
21
|
|
|
24
|
-
def
|
|
25
|
-
|
|
22
|
+
def cancel(new_commit_hash:)
|
|
23
|
+
waited_before = (Time.now - WAITED_ON_SECONDS).utc.iso8601
|
|
24
|
+
Persistence::ActiveRuns.cancellable(@db, @branch, new_commit: new_commit_hash, waited_before: waited_before)
|
|
25
|
+
.each { |run| cancel_run(run, new_commit_hash) }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
private
|
|
29
29
|
|
|
30
|
-
def
|
|
31
|
-
@
|
|
32
|
-
|
|
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)
|
|
30
|
+
def cancel_run(run, new_commit_hash)
|
|
31
|
+
@run_canceller.cancel(@db, run)
|
|
32
|
+
@stdout.puts "Cancelled stale pipeline for #{run.commit_hash}. Starting fresh for #{new_commit_hash}."
|
|
50
33
|
end
|
|
51
34
|
end
|
|
52
35
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Pipeline
|
|
5
|
+
# The last `size` bytes of a stream, kept in two IOs of `size` bytes that
|
|
6
|
+
# take turns: when one fills, the other is emptied and written next.
|
|
7
|
+
class TailSegments
|
|
8
|
+
def initialize(open, size)
|
|
9
|
+
@open = open
|
|
10
|
+
@size = size
|
|
11
|
+
@filled = 0
|
|
12
|
+
@segments = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def <<(bytes)
|
|
16
|
+
until bytes.empty?
|
|
17
|
+
rotate if @filled == @size
|
|
18
|
+
part = bytes.byteslice(0, @size - @filled)
|
|
19
|
+
segments.last.write(part)
|
|
20
|
+
@filled += part.bytesize
|
|
21
|
+
bytes = bytes.byteslice(part.bytesize..)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def read
|
|
26
|
+
return +"".b unless @segments
|
|
27
|
+
|
|
28
|
+
earlier(@size - @filled) + whole(segments.last)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def close = @segments&.each(&:close)
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def segments = @segments ||= [@open.call("tail-0"), @open.call("tail-1")]
|
|
36
|
+
|
|
37
|
+
def rotate
|
|
38
|
+
@segments = segments.reverse
|
|
39
|
+
segments.last.truncate(0)
|
|
40
|
+
segments.last.rewind
|
|
41
|
+
@filled = 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# The earlier segment is full once the tail has wrapped; before that,
|
|
45
|
+
# nothing came earlier.
|
|
46
|
+
def earlier(count)
|
|
47
|
+
io = segments.first
|
|
48
|
+
return +"".b if count.zero? || io.size < @size
|
|
49
|
+
|
|
50
|
+
io.seek(io.size - count)
|
|
51
|
+
io.read(count)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def whole(io)
|
|
55
|
+
io.rewind
|
|
56
|
+
io.read
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rexml/document"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Pipeline
|
|
7
|
+
# The failures a JUnit XML report names (acceptance-tests.md, AT-9.7),
|
|
8
|
+
# each as { file:, line:, test:, message: }, with output: when the failure
|
|
9
|
+
# kept its own (AT-10.14), its last 4 KB; nil for a report that can't be
|
|
10
|
+
# read.
|
|
11
|
+
module TestReport
|
|
12
|
+
OUTPUT_BYTES = 4096
|
|
13
|
+
|
|
14
|
+
def self.junit(xml)
|
|
15
|
+
REXML::XPath.match(REXML::Document.new(xml), "//testcase[failure or error]").map { |test| junit_failure(test) }
|
|
16
|
+
rescue REXML::ParseException
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.junit_failure(test)
|
|
21
|
+
with_output({ file: test.attributes["file"], line: test.attributes["line"]&.to_i, test: junit_name(test),
|
|
22
|
+
message: junit_message(test.elements["failure"] || test.elements["error"]) },
|
|
23
|
+
junit_output(test))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# The failure's text, or its message attribute when it has none.
|
|
27
|
+
def self.junit_message(problem)
|
|
28
|
+
text = problem.text.to_s.strip
|
|
29
|
+
text.empty? ? problem.attributes["message"].to_s : text
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.junit_name(test)
|
|
33
|
+
[test.attributes["classname"], test.attributes["name"]].compact.join("#")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.junit_output(test)
|
|
37
|
+
%w[system-out system-err].filter_map { |name| test.elements[name]&.text&.strip }.reject(&:empty?).join("\n")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.with_output(failure, output)
|
|
41
|
+
return failure if output.empty?
|
|
42
|
+
|
|
43
|
+
failure.merge(output: output.byteslice([output.bytesize - OUTPUT_BYTES, 0].max..).scrub(""))
|
|
44
|
+
end
|
|
45
|
+
private_class_method :junit_failure, :junit_message, :junit_name, :junit_output, :with_output
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -1,152 +1,92 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "open3"
|
|
4
3
|
require_relative "../setup/project_config"
|
|
5
4
|
require_relative "../persistence/pipeline_recorder"
|
|
6
|
-
require_relative "
|
|
7
|
-
require_relative "
|
|
5
|
+
require_relative "trigger_params"
|
|
6
|
+
require_relative "trigger_command"
|
|
7
|
+
require_relative "slot_run"
|
|
8
8
|
require_relative "stale_pipeline_canceller"
|
|
9
|
-
require_relative "
|
|
10
|
-
require_relative "pipeline_forker"
|
|
11
|
-
require_relative "process_runner"
|
|
9
|
+
require_relative "workspaces"
|
|
12
10
|
|
|
13
11
|
module FunCi
|
|
14
12
|
module Pipeline
|
|
15
13
|
class Trigger
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
14
|
+
NULL_SHA = Workspaces::NULL_SHA
|
|
15
|
+
|
|
16
|
+
def self.run_from_args(args, io: Io.new, recorder: Persistence::NullRecorder.new, pipeline_forker: nil)
|
|
17
|
+
TriggerCommand.new(io: io, recorder: recorder, pipeline_forker: pipeline_forker).run(args)
|
|
38
18
|
end
|
|
39
19
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@
|
|
44
|
-
@
|
|
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)
|
|
20
|
+
def initialize(project:, commit:, io: Io.new, seams: Seams.new)
|
|
21
|
+
@project = project
|
|
22
|
+
@commit = commit
|
|
23
|
+
@io = io
|
|
24
|
+
@seams = seams
|
|
53
25
|
end
|
|
54
26
|
|
|
55
27
|
def run
|
|
56
|
-
config = Setup::ProjectConfig.new(@
|
|
28
|
+
config = Setup::ProjectConfig.new(@project)
|
|
57
29
|
return handle_config_errors(config) if config.validate.any?
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
|
30
|
+
return unknown_commit unless known_commit?
|
|
31
|
+
|
|
32
|
+
start_run
|
|
33
|
+
run_in(workspace.acquire(@commit.sha))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The background launcher swaps in a fresh recorder after forking, so
|
|
37
|
+
# callers release the connection through the trigger, not their own copy.
|
|
38
|
+
def close
|
|
39
|
+
recorder.close
|
|
82
40
|
end
|
|
83
41
|
|
|
84
42
|
private
|
|
85
43
|
|
|
44
|
+
def recorder = @seams.recorder
|
|
45
|
+
def known_commit? = @commit.sha == NULL_SHA || @seams.commit_validator.call(@commit.sha)
|
|
46
|
+
|
|
47
|
+
def workspace = @seams.workspace || Workspaces.for(@project, @commit.sha)
|
|
48
|
+
def branch = Persistence::Branch.new(project: @project, name: @commit.branch)
|
|
49
|
+
|
|
50
|
+
# The scripts come from the commit when it has them, so they match the
|
|
51
|
+
# code they test; a project that keeps .fun-ci/ out of git uses its own.
|
|
52
|
+
def config_for(slot)
|
|
53
|
+
committed = Setup::ProjectConfig.new(slot.path)
|
|
54
|
+
committed.folder_exists? ? committed : Setup::ProjectConfig.new(@project)
|
|
55
|
+
end
|
|
56
|
+
|
|
86
57
|
def handle_config_errors(config)
|
|
87
|
-
config.validate.each { |e| @stdout.puts "fun-ci: #{e}" }
|
|
58
|
+
config.validate.each { |e| @io.stdout.puts "fun-ci: #{e}" }
|
|
88
59
|
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."
|
|
60
|
+
@io.stdout.puts "Create .fun-ci/lint.sh, build.sh, fast.sh, and slow.sh to set up this project."
|
|
61
|
+
@io.stdout.puts "Commit will proceed without CI."
|
|
91
62
|
end
|
|
92
63
|
0
|
|
93
64
|
end
|
|
94
65
|
|
|
95
|
-
def
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
Thread.new { results[stage] = stage_runner.run_stage(config, stage) }
|
|
99
|
-
end
|
|
100
|
-
threads.each(&:join)
|
|
101
|
-
results
|
|
66
|
+
def unknown_commit
|
|
67
|
+
@io.stderr.puts "fun-ci: commit #{@commit.sha} not found in this repository."
|
|
68
|
+
1
|
|
102
69
|
end
|
|
103
70
|
|
|
104
|
-
def
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
)
|
|
71
|
+
def start_run
|
|
72
|
+
recorder.report_trouble_to(@io.stdout)
|
|
73
|
+
cancel_stale_pipelines
|
|
74
|
+
recorder.create_run(commit_hash: @commit.sha, branch: @commit.branch, project_path: @project)
|
|
123
75
|
end
|
|
124
76
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
77
|
+
# After forking the slow suite the run holds a recorder of its own.
|
|
78
|
+
def run_in(slot)
|
|
79
|
+
slot_run = SlotRun.new(commit: @commit, io: @io, seams: @seams, slot: slot)
|
|
80
|
+
slot_run.run(config_for(slot)).tap { @seams = slot_run.seams }
|
|
128
81
|
end
|
|
129
82
|
|
|
130
83
|
def cancel_stale_pipelines
|
|
131
|
-
return unless
|
|
132
|
-
StalePipelineCanceller.new(db: @recorder.db, branch: @branch, stdout: @stdout)
|
|
133
|
-
.cancel(new_commit_hash: @commit_hash)
|
|
134
|
-
end
|
|
84
|
+
return unless recorder.db
|
|
135
85
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
86
|
+
recorder.tolerating do
|
|
87
|
+
StalePipelineCanceller.new(db: recorder.db, branch: branch, stdout: @io.stdout)
|
|
88
|
+
.cancel(new_commit_hash: @commit.sha)
|
|
142
89
|
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
90
|
end
|
|
151
91
|
end
|
|
152
92
|
end
|