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,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Console
|
|
7
|
+
# A run as BoardData reads it from SQLite, as the protocol's `board`
|
|
8
|
+
# message carries it: protocol status names, epoch seconds and
|
|
9
|
+
# milliseconds. Formatting them is the renderer's job.
|
|
10
|
+
module RunMessage
|
|
11
|
+
STATUS = { "scheduled" => "pending", "completed" => "passed", "timed_out" => "timeout" }.freeze
|
|
12
|
+
|
|
13
|
+
def self.from(run)
|
|
14
|
+
{ id: run[:id], sha: run[:commit_hash], branch: run[:branch], project: run[:project_path],
|
|
15
|
+
status: status(run[:status]), started_at: epoch(run[:created_at]), updated_at: epoch(run[:updated_at]),
|
|
16
|
+
stages: run[:stages].map { |stage| stage(stage) } }.compact
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.stage(stage)
|
|
20
|
+
{ stage: stage[:stage], status: status(stage[:status]),
|
|
21
|
+
duration_ms: stage[:duration] && (stage[:duration] * 1000).round,
|
|
22
|
+
started_at: (epoch(stage[:started_at]) if stage[:status] == "running") }.compact
|
|
23
|
+
end
|
|
24
|
+
private_class_method :stage
|
|
25
|
+
|
|
26
|
+
def self.status(name) = STATUS.fetch(name, name)
|
|
27
|
+
private_class_method :status
|
|
28
|
+
|
|
29
|
+
def self.epoch(iso) = iso && Time.parse(iso).to_i
|
|
30
|
+
private_class_method :epoch
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
# The stages whose status differs between two polls of the runs, for the
|
|
6
|
+
# runs both polls saw; none when there was no earlier poll.
|
|
7
|
+
module StageChangeDetector
|
|
8
|
+
Change = Data.define(:run_id, :stage, :from, :to)
|
|
9
|
+
|
|
10
|
+
def self.detect(previous_runs, current_runs)
|
|
11
|
+
current_by_id = current_runs.to_h { |run| [run[:id], run] }
|
|
12
|
+
previous_runs.select { |run| current_by_id.key?(run[:id]) }
|
|
13
|
+
.flat_map { |run| compare_stages(run, current_by_id.fetch(run[:id])) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.compare_stages(previous_run, current_run)
|
|
17
|
+
before = statuses(previous_run[:stages])
|
|
18
|
+
current_run[:stages].reject { |stage| before[stage[:stage]] == stage[:status] }
|
|
19
|
+
.map { |stage| change(current_run[:id], stage, before[stage[:stage]]) }
|
|
20
|
+
end
|
|
21
|
+
private_class_method :compare_stages
|
|
22
|
+
|
|
23
|
+
def self.change(run_id, stage, from)
|
|
24
|
+
Change.new(run_id: run_id, stage: stage[:stage], from: from, to: stage[:status])
|
|
25
|
+
end
|
|
26
|
+
private_class_method :change
|
|
27
|
+
|
|
28
|
+
def self.statuses(stages) = (stages || []).to_h { |stage| [stage[:stage], stage[:status]] }
|
|
29
|
+
private_class_method :statuses
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "stage_change_detector"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Console
|
|
7
|
+
# The protocol `event` messages for the stages that changed since the
|
|
8
|
+
# last poll of the runs: one that failed or timed out, or one that
|
|
9
|
+
# passed. The renderer chooses the animation (renderer-protocol.md).
|
|
10
|
+
class StageEvents
|
|
11
|
+
NAMES = { "failed" => "stage_failed", "timed_out" => "stage_failed", "completed" => "stage_passed" }.freeze
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@previous = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# None on the first poll, which has nothing to compare with.
|
|
18
|
+
def since_last(runs)
|
|
19
|
+
changes = StageChangeDetector.detect(@previous, runs)
|
|
20
|
+
@previous = runs
|
|
21
|
+
changes.select { |change| NAMES.key?(change.to) }.map { |change| event(change) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def event(change)
|
|
27
|
+
{ t: "event", name: NAMES.fetch(change.to), run_id: change.run_id, stage: change.stage }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
# Why the console stopped, in the console log and, unless the user quit
|
|
6
|
+
# and the renderer exited cleanly, on stderr: the terminal is the
|
|
7
|
+
# renderer's until it exits, so an error is only told once it has.
|
|
8
|
+
class StopReport
|
|
9
|
+
CAUSES = { quit: "the user quit", ended: "the renderer went away" }.freeze
|
|
10
|
+
|
|
11
|
+
# `outcome`: :quit, :ended, or the error that stopped the console.
|
|
12
|
+
def initialize(outcome, status)
|
|
13
|
+
@outcome = outcome
|
|
14
|
+
@status = status
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# The exit status: 0 once the user has quit, 1 otherwise.
|
|
18
|
+
def tell(log, stderr)
|
|
19
|
+
log.write("console stopped: #{cause}; the renderer exited (#{@status})")
|
|
20
|
+
return 0 if @outcome == :quit && @status.success?
|
|
21
|
+
|
|
22
|
+
stderr.puts "#{failure}; see .fun-ci/console.log"
|
|
23
|
+
1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def failed? = @outcome.is_a?(Exception)
|
|
29
|
+
|
|
30
|
+
def cause = failed? ? "it failed: #{error}\n#{trace}" : CAUSES.fetch(@outcome)
|
|
31
|
+
|
|
32
|
+
def error = "#{@outcome.class}: #{@outcome.message}"
|
|
33
|
+
|
|
34
|
+
def trace = @outcome.backtrace.map { |line| " #{line}" }.join("\n")
|
|
35
|
+
|
|
36
|
+
def failure = failed? ? "fun-ci console failed (#{error})" : "fun-ci console: the renderer stopped (#{@status})"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
module StreakCounter
|
|
6
|
+
PASS_STATUS = "completed"
|
|
7
|
+
ACTIVE_STATUSES = %w[running scheduled].freeze
|
|
8
|
+
|
|
9
|
+
# Passed runs since the latest finished run that did not pass; nil
|
|
10
|
+
# while no run has finished.
|
|
11
|
+
def self.count(runs)
|
|
12
|
+
finished = runs.reject { |run| ACTIVE_STATUSES.include?(run[:status]) }
|
|
13
|
+
return nil if finished.empty?
|
|
14
|
+
|
|
15
|
+
finished.take_while { |run| run[:status] == PASS_STATUS }.size
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Console
|
|
5
|
+
# What the user sees of the runs and does with them: KeyHandler's cursor
|
|
6
|
+
# and cancel confirmation, and one page of runs, as many as the terminal
|
|
7
|
+
# fits, scrolled so the run under the cursor is on it (renderer-protocol.md,
|
|
8
|
+
# `board`).
|
|
9
|
+
class View
|
|
10
|
+
# The renderer's 14-row header, a blank line and the footer.
|
|
11
|
+
CHROME_ROWS = 16
|
|
12
|
+
ROWS_PER_RUN = 2
|
|
13
|
+
|
|
14
|
+
def initialize(key_handler:)
|
|
15
|
+
@key_handler = key_handler
|
|
16
|
+
@page_size = 0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The number of runs a terminal of `rows` fits.
|
|
20
|
+
def resize(rows)
|
|
21
|
+
@page_size = [(rows - CHROME_ROWS) / ROWS_PER_RUN, 0].max
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# :quit when the key ends the session.
|
|
25
|
+
def press(key) = @key_handler.handle_key(key)
|
|
26
|
+
|
|
27
|
+
# `more`: whether the store holds runs beyond `runs`.
|
|
28
|
+
def page(runs, more:)
|
|
29
|
+
first = first_shown
|
|
30
|
+
{ cursor: cursor_on_page(first), confirming: @key_handler.confirming?,
|
|
31
|
+
has_more: more || first + @page_size < runs.size, runs: runs[first, @page_size] || [] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def first_shown
|
|
37
|
+
cursor = @key_handler.cursor_index
|
|
38
|
+
cursor ? [cursor - @page_size + 1, 0].max : 0
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cursor_on_page(first)
|
|
42
|
+
cursor = @key_handler.cursor_index
|
|
43
|
+
cursor - first if cursor && @page_size.positive?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Evidence
|
|
5
|
+
# What is known about a failed stage when its evidence is collected, as a
|
|
6
|
+
# project's command is told it: how it ended, its budget, the stages
|
|
7
|
+
# alongside it, the commit, where it ran, when it started, and where its
|
|
8
|
+
# output is.
|
|
9
|
+
About = Data.define(:stage, :state, :exit_status, :signal, :seconds, :budget, :alongside, :commit, :worktree,
|
|
10
|
+
:started_at, :output)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require_relative "about"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Evidence
|
|
8
|
+
# What is known About a stage, from what was known when it started and
|
|
9
|
+
# how it ended. Its output is written to its directory for a project's
|
|
10
|
+
# command to read.
|
|
11
|
+
module AboutStage
|
|
12
|
+
def self.of(sources, outcome, output)
|
|
13
|
+
About.new(**outcome.to_h.except(:overrun), **started(sources), **where(sources, output),
|
|
14
|
+
stage: sources.stage, budget: sources.budget, commit: sources.commit)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def self.where(sources, output)
|
|
18
|
+
{ worktree: sources.worktree, output: sources.stage_dir.output_file(output) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.started(sources)
|
|
22
|
+
return { seconds: nil, started_at: nil } unless sources.started
|
|
23
|
+
|
|
24
|
+
{ seconds: (Time.now - sources.started).round(1), started_at: sources.started.utc.iso8601(3) }
|
|
25
|
+
end
|
|
26
|
+
private_class_method :started, :where
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Evidence
|
|
7
|
+
# How much evidence fun-ci keeps (architecture.md, "Evidence of a failed stage"): at
|
|
8
|
+
# most `failures` failures, the rest counted in a fact; then, over
|
|
9
|
+
# `bytes` of JSON, excerpts cut from the last backwards, then messages
|
|
10
|
+
# to their first `message_lines`, each cut marked `truncated`. Facts and
|
|
11
|
+
# problems are small and never cut.
|
|
12
|
+
class Caps
|
|
13
|
+
Limits = Data.define(:bytes, :failures, :message_lines)
|
|
14
|
+
REAL = Limits.new(bytes: 262_144, failures: 100, message_lines: 50)
|
|
15
|
+
# What a line costs in JSON besides its own bytes: quotes and a comma.
|
|
16
|
+
LINE_COST = 3
|
|
17
|
+
|
|
18
|
+
def initialize(limits = REAL)
|
|
19
|
+
@limits = limits
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def apply(document)
|
|
23
|
+
document = shorter(few_failures(document))
|
|
24
|
+
over(document).positive? ? shorter_messages(document) : document
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
# Each pass cuts at least a line, so there are never more passes than lines.
|
|
30
|
+
def shorter(document)
|
|
31
|
+
document.excerpts.sum { |excerpt| excerpt[:lines].size }.times do
|
|
32
|
+
break unless over(document).positive?
|
|
33
|
+
|
|
34
|
+
document = shorter_excerpts(document)
|
|
35
|
+
end
|
|
36
|
+
document
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def over(document) = JSON.generate(document.to_h).bytesize - @limits.bytes
|
|
40
|
+
|
|
41
|
+
def few_failures(document)
|
|
42
|
+
left_out = document.failures.size - @limits.failures
|
|
43
|
+
return document unless left_out.positive?
|
|
44
|
+
|
|
45
|
+
count = { name: "failures not kept", value: left_out.to_s, extractor: "fun-ci" }
|
|
46
|
+
document.with(failures: document.failures.first(@limits.failures), facts: document.facts + [count])
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def shorter_excerpts(document)
|
|
50
|
+
excess = over(document)
|
|
51
|
+
excerpts = document.excerpts.reverse.map do |excerpt|
|
|
52
|
+
cut, excess = cut_lines(excerpt, excess)
|
|
53
|
+
cut
|
|
54
|
+
end
|
|
55
|
+
document.with(excerpts: excerpts.reverse)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The excerpt without as many of its last lines as cover `excess` bytes, and what is left of `excess`.
|
|
59
|
+
def cut_lines(excerpt, excess)
|
|
60
|
+
lines = excerpt[:lines].dup
|
|
61
|
+
excess -= lines.pop.bytesize + LINE_COST while excess.positive? && lines.any?
|
|
62
|
+
[lines.size == excerpt[:lines].size ? excerpt : excerpt.merge(lines: lines, truncated: true), excess]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def shorter_messages(document)
|
|
66
|
+
document.with(failures: document.failures.map { |failure| short_message(failure) })
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def short_message(failure)
|
|
70
|
+
lines = failure[:message].to_s.lines
|
|
71
|
+
return failure if lines.size <= @limits.message_lines
|
|
72
|
+
|
|
73
|
+
failure.merge(message: lines.first(@limits.message_lines).join.chomp, truncated: true)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "option_check"
|
|
4
|
+
require_relative "presets"
|
|
5
|
+
require_relative "extractors/grep"
|
|
6
|
+
require_relative "extractors/section"
|
|
7
|
+
require_relative "extractors/log_file"
|
|
8
|
+
require_relative "extractors/json_log"
|
|
9
|
+
require_relative "extractors/junit_files"
|
|
10
|
+
require_relative "extractors/command"
|
|
11
|
+
require_relative "extractors/process_tree"
|
|
12
|
+
|
|
13
|
+
module FunCi
|
|
14
|
+
module Evidence
|
|
15
|
+
# The built-in extractors by name, and how an entry of .fun-ci/config
|
|
16
|
+
# becomes one to run (architecture.md, "Evidence of a failed stage"). An entry that can't is
|
|
17
|
+
# Refused, with what is wrong with it.
|
|
18
|
+
module Catalog
|
|
19
|
+
class Refused < StandardError; end
|
|
20
|
+
|
|
21
|
+
# name: what the evidence credits (`grep`, `section:rspec`); on: nil, or "overrun".
|
|
22
|
+
Entry = Data.define(:name, :extractor, :on)
|
|
23
|
+
|
|
24
|
+
BUILT_INS = { "grep" => Extractors::Grep, "section" => Extractors::Section,
|
|
25
|
+
"log-file" => Extractors::LogFile, "json-log" => Extractors::JsonLog,
|
|
26
|
+
"junit-files" => Extractors::JunitFiles, "process-tree" => Extractors::ProcessTree }.freeze
|
|
27
|
+
SHARED = %w[use on preset].freeze
|
|
28
|
+
|
|
29
|
+
def self.entry(raw)
|
|
30
|
+
raise Refused, "an entry must be a mapping with use: or run:, not #{raw.inspect}" unless raw.is_a?(Hash)
|
|
31
|
+
|
|
32
|
+
raw.key?("run") ? command(raw) : built_in(raw)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.built_in(raw)
|
|
36
|
+
built_in = BUILT_INS[raw["use"]] || raise(Refused, "unknown extractor '#{raw["use"]}'")
|
|
37
|
+
Entry.new(name: [raw["use"], raw["preset"]].compact.join(":"),
|
|
38
|
+
extractor: built_in.new(checked_options(raw, built_in)), on: raw["on"])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.checked_options(raw, built_in)
|
|
42
|
+
options = preset_options(raw).merge(raw.except(*SHARED))
|
|
43
|
+
mistake = OptionCheck.mistake(raw["use"], built_in, options) || on_mistake(raw)
|
|
44
|
+
raise Refused, mistake if mistake
|
|
45
|
+
|
|
46
|
+
options
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# A project's own extractor. Whatever the entry says besides run:,
|
|
50
|
+
# format:, watch: and on: is passed on to it.
|
|
51
|
+
def self.command(raw)
|
|
52
|
+
mistake = command_mistake(raw) || on_mistake(raw)
|
|
53
|
+
raise Refused, mistake if mistake
|
|
54
|
+
|
|
55
|
+
Entry.new(name: "run:#{raw["run"]}", extractor: Extractors::Command.new(raw.except("on")), on: raw["on"])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.command_mistake(raw)
|
|
59
|
+
unless raw["run"].is_a?(String) && !raw["run"].empty?
|
|
60
|
+
return "run: must name a command, not #{raw["run"].inspect}"
|
|
61
|
+
end
|
|
62
|
+
return nil if [nil, "text", "json"].include?(raw["format"])
|
|
63
|
+
|
|
64
|
+
"run:#{raw["run"]}: 'format' must be text or json, not #{raw["format"].inspect}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The options of the preset an entry names, which its own replace.
|
|
68
|
+
def self.preset_options(raw)
|
|
69
|
+
return {} unless raw.key?("preset")
|
|
70
|
+
|
|
71
|
+
preset = Presets.find(raw["use"], raw["preset"])
|
|
72
|
+
preset ? preset.options : raise(Refused, "#{raw["use"]} has no preset '#{raw["preset"]}'")
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def self.on_mistake(raw)
|
|
76
|
+
return nil if [nil, "overrun"].include?(raw["on"])
|
|
77
|
+
|
|
78
|
+
"#{raw["use"] || "run:#{raw["run"]}"}: 'on' must be overrun, not #{raw["on"].inspect}"
|
|
79
|
+
end
|
|
80
|
+
private_class_method :built_in, :command, :command_mistake, :checked_options, :preset_options, :on_mistake
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "sources"
|
|
4
|
+
require_relative "document"
|
|
5
|
+
require_relative "masking"
|
|
6
|
+
require_relative "settings"
|
|
7
|
+
require_relative "extraction"
|
|
8
|
+
require_relative "findings"
|
|
9
|
+
require_relative "outcome"
|
|
10
|
+
require_relative "stage_context"
|
|
11
|
+
require_relative "detection"
|
|
12
|
+
require_relative "source"
|
|
13
|
+
require_relative "caps"
|
|
14
|
+
require_relative "../persistence/output_tail"
|
|
15
|
+
|
|
16
|
+
module FunCi
|
|
17
|
+
module Evidence
|
|
18
|
+
# Picks out what fun-ci keeps about a stage that failed (architecture.md,
|
|
19
|
+
# "Evidence of a failed stage"): for an overrun, what process-tree and the
|
|
20
|
+
# entries for an overrun found before the kill; the entries configured for
|
|
21
|
+
# the stage and the presets detected, within the budget; then the output's
|
|
22
|
+
# last lines; all masked.
|
|
23
|
+
class Collector
|
|
24
|
+
MONOTONIC = -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
|
|
25
|
+
FAILED = Outcome.new(state: "failed")
|
|
26
|
+
# How long past its budget a stage runs while its overrun is looked at.
|
|
27
|
+
GRACE = 2
|
|
28
|
+
NOTHING = Extraction::Result.new(parts: [], problems: [], chosen: [])
|
|
29
|
+
|
|
30
|
+
# commands: runs a project's own extractors (a CommandRunner).
|
|
31
|
+
def initialize(sources, settings: Settings.new(nil), clock: MONOTONIC, commands: nil)
|
|
32
|
+
@sources = sources
|
|
33
|
+
@settings = settings
|
|
34
|
+
@contexts = StageContext.new(sources, clock, commands)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def collect(output, outcome = FAILED)
|
|
38
|
+
result = extracted(output, outcome)
|
|
39
|
+
overrun = outcome.overrun || NOTHING
|
|
40
|
+
parts = [["fun-ci", own_facts(outcome.alongside)], *overrun.parts, *result.parts, ["output-tail", tail(output)]]
|
|
41
|
+
Caps.new.apply(masking.document(assemble(parts, [overrun, result])))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# What a stage over budget was doing, looked at before the kill.
|
|
45
|
+
def before_kill(pgid)
|
|
46
|
+
Extraction.new(@contexts.before_kill(pgid, GRACE), GRACE)
|
|
47
|
+
.run([[{ "use" => "process-tree" }, "overrun"], *entries("overrun")])
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The raw output, masked as the evidence is.
|
|
51
|
+
def masked(output) = masking.mask(output)
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
# The configured entries, then the detected presets, within the budget.
|
|
56
|
+
def extracted(output, outcome)
|
|
57
|
+
context = @contexts.after(output, outcome, @settings.budget)
|
|
58
|
+
Extraction.new(context, @settings.budget).run(entries(nil) + detected(output, context.deadline))
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The candidates' presets whose signatures the output matched, or that have none.
|
|
62
|
+
def detected(output, deadline)
|
|
63
|
+
Detection.chosen(output, @sources.candidates, deadline).map do |found|
|
|
64
|
+
[{ "use" => found.preset.use, "preset" => found.preset.name }, found.because]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def assemble(parts, results)
|
|
69
|
+
Document.assemble(parts, problems: results.flat_map(&:problems), chosen: results.flat_map(&:chosen))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The stage's entries that run on `on`: nil, when it fails, or "overrun".
|
|
73
|
+
def entries(on)
|
|
74
|
+
@settings.entries(@sources.stage).select { |raw| on_of(raw) == on }.map { |raw| [raw, "configured"] }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def on_of(raw) = raw.is_a?(Hash) ? raw["on"] : nil
|
|
78
|
+
|
|
79
|
+
def own_facts(alongside)
|
|
80
|
+
Findings.new(facts: alongside.empty? ? [] : [{ name: "alongside", value: alongside.join(", ") }])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def tail(output)
|
|
84
|
+
text = Persistence::OutputTail.of(output, mask: masking.method(:mask))
|
|
85
|
+
Findings.new(excerpts: [{ title: Document::TAIL_TITLE, location: "output", lines: text.lines(chomp: true) }])
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def masking
|
|
89
|
+
return Masking.none unless @settings.masking?
|
|
90
|
+
|
|
91
|
+
Masking.new(@sources.environment, patterns: @settings.mask_patterns)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "findings"
|
|
5
|
+
require_relative "problem"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Evidence
|
|
9
|
+
# What a project's command printed, as findings: with `format: text`,
|
|
10
|
+
# one excerpt; with `format: json`, the document of `contract/evidence/`, whose fields
|
|
11
|
+
# fun-ci doesn't know are ignored.
|
|
12
|
+
module CommandOutput
|
|
13
|
+
SCHEMA = 1
|
|
14
|
+
FIELDS = { facts: %i[name value], failures: %i[test file line message output],
|
|
15
|
+
excerpts: %i[title location lines] }.freeze
|
|
16
|
+
|
|
17
|
+
def self.text(title, stdout)
|
|
18
|
+
Findings.new(excerpts: [{ title: title, location: "stdout", lines: stdout.lines(chomp: true) }])
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.json(stdout)
|
|
22
|
+
document = parse(stdout)
|
|
23
|
+
schema = document.fetch("schema", SCHEMA)
|
|
24
|
+
raise Problem, "printed schema #{schema}, and this fun-ci reads schema #{SCHEMA}" if schema.to_i > SCHEMA
|
|
25
|
+
|
|
26
|
+
Findings.new(**FIELDS.to_h { |list, keys| [list, items(document, list, keys)] })
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.parse(stdout)
|
|
30
|
+
document = JSON.parse(stdout)
|
|
31
|
+
document.is_a?(Hash) ? document : raise(Problem, "printed JSON that isn't an object")
|
|
32
|
+
rescue JSON::ParserError => e
|
|
33
|
+
raise Problem, "printed JSON that doesn't parse: #{e.message.lines.first.to_s.strip}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.items(document, list, keys)
|
|
37
|
+
given = document.fetch(list.to_s, [])
|
|
38
|
+
raise Problem, "printed '#{list}' that isn't a list of objects" unless given.is_a?(Array) && given.all?(Hash)
|
|
39
|
+
|
|
40
|
+
given.map { |item| keys.to_h { |key| [key, item[key.to_s]] }.compact }
|
|
41
|
+
end
|
|
42
|
+
private_class_method :parse, :items
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Evidence
|
|
5
|
+
# Reads a command's stdout in a thread, up to `limit` bytes; past that it
|
|
6
|
+
# calls `stop`, which kills the command.
|
|
7
|
+
class CommandReading
|
|
8
|
+
def self.start(reader, limit, stop) = new(reader, limit, stop).tap(&:begin)
|
|
9
|
+
|
|
10
|
+
def initialize(reader, limit, stop)
|
|
11
|
+
@reader = reader
|
|
12
|
+
@limit = limit
|
|
13
|
+
@stop = stop
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def begin = @thread = Thread.new { read(+"".b) }
|
|
17
|
+
|
|
18
|
+
# All that was read, once reading ends or the drain is over.
|
|
19
|
+
def finish(drain)
|
|
20
|
+
@thread.join(drain)
|
|
21
|
+
@reader.close
|
|
22
|
+
@thread.value
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def read(buffer)
|
|
28
|
+
loop do
|
|
29
|
+
buffer << @reader.readpartial(65_536)
|
|
30
|
+
break @stop.call if buffer.bytesize > @limit
|
|
31
|
+
end
|
|
32
|
+
buffer
|
|
33
|
+
rescue IOError
|
|
34
|
+
buffer
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../pipeline/git_environment"
|
|
4
|
+
require_relative "command_reading"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Evidence
|
|
8
|
+
# Runs a project's extractor command as stage scripts are run: in the
|
|
9
|
+
# worktree, with the clean git environment, in a process group of its
|
|
10
|
+
# own, killed with everything it started when `seconds` run out or when it
|
|
11
|
+
# prints more than `limit` bytes. Its stdin is a file in `scratch`, and
|
|
12
|
+
# its stderr another. A process that leaves the group (setsid) can't be
|
|
13
|
+
# killed with it, so reading stops once the time and the drain are over.
|
|
14
|
+
class CommandRunner
|
|
15
|
+
Ran = Data.define(:stdout, :stderr, :exit_status, :killed)
|
|
16
|
+
# bytes: the most of stdout kept; drain: seconds to read on after the command ends.
|
|
17
|
+
Limits = Data.define(:bytes, :drain)
|
|
18
|
+
LIMITS = Limits.new(bytes: 262_144, drain: 1)
|
|
19
|
+
|
|
20
|
+
def initialize(dir:, scratch:, limits: LIMITS)
|
|
21
|
+
@dir = dir
|
|
22
|
+
@scratch = scratch
|
|
23
|
+
@limit = limits.bytes
|
|
24
|
+
@drain = limits.drain
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# env: added to the command's environment for this run.
|
|
28
|
+
def call(command, stdin:, seconds:, env: {})
|
|
29
|
+
File.write(path("context.json"), stdin)
|
|
30
|
+
reader, writer = IO.pipe
|
|
31
|
+
pid = spawn(command, writer, env)
|
|
32
|
+
writer.close
|
|
33
|
+
finish(pid, CommandReading.start(reader, @limit, -> { kill(pid) }), seconds)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def path(name) = File.join(@scratch, name)
|
|
39
|
+
|
|
40
|
+
def spawn(command, writer, env)
|
|
41
|
+
Process.spawn(Pipeline::GitEnvironment::CLEAN.merge(env), command,
|
|
42
|
+
in: path("context.json"), out: writer, err: path("stderr"), pgroup: true, chdir: @dir)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def finish(pid, reading, seconds)
|
|
46
|
+
waiter = Process.detach(pid)
|
|
47
|
+
out_of_time = waiter.join(seconds).nil?
|
|
48
|
+
kill(pid) if out_of_time
|
|
49
|
+
status = waiter.value
|
|
50
|
+
stdout = reading.finish(@drain)
|
|
51
|
+
Ran.new(stdout: text(stdout.byteslice(0, @limit)), stderr: text(File.binread(path("stderr"))),
|
|
52
|
+
exit_status: status.exitstatus, killed: killed(out_of_time, stdout))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def killed(out_of_time, stdout)
|
|
56
|
+
return :budget if out_of_time
|
|
57
|
+
|
|
58
|
+
stdout.bytesize > @limit ? :overflow : nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Kills the command and all it started in its group.
|
|
62
|
+
def kill(pid)
|
|
63
|
+
%w[TERM KILL].each { |signal| ignoring_gone { Process.kill(signal, -pid) } }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def ignoring_gone
|
|
67
|
+
yield
|
|
68
|
+
rescue Errno::ESRCH, Errno::EPERM
|
|
69
|
+
# The group has gone, or what is left of it is no longer ours.
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def text(bytes) = bytes.force_encoding(Encoding::UTF_8).scrub("?")
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|