fun_ci 2.0.0-x86_64-linux
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +294 -0
- data/LICENSE.txt +21 -0
- data/README.md +230 -0
- data/exe/fun-ci +7 -0
- 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 +31 -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 +73 -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 +128 -0
- 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 +81 -0
- 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 +120 -0
- data/lib/fun_ci/persistence/pipeline_run.rb +68 -0
- 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 +91 -0
- 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 +40 -0
- data/lib/fun_ci/pipeline/process_runner.rb +101 -0
- data/lib/fun_ci/pipeline/progress_reporter.rb +31 -0
- 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 +63 -0
- data/lib/fun_ci/pipeline/stale_pipeline_canceller.rb +36 -0
- 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 +93 -0
- data/lib/fun_ci/pipeline/trigger_command.rb +62 -0
- data/lib/fun_ci/pipeline/trigger_params.rb +48 -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 +57 -0
- data/lib/fun_ci/setup/installer.rb +71 -0
- data/lib/fun_ci/setup/legacy_hooks.rb +38 -0
- data/lib/fun_ci/setup/maven_linter_detector.rb +26 -0
- data/lib/fun_ci/setup/project_config.rb +68 -0
- data/lib/fun_ci/setup/project_detector.rb +54 -0
- data/lib/fun_ci/setup/settings.rb +36 -0
- data/lib/fun_ci/setup/setup_checker.rb +36 -0
- 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 +28 -0
- data/lib/fun_ci/version.rb +6 -0
- data/lib/fun_ci.rb +13 -0
- data/libexec/fun-ci-renderer +0 -0
- metadata +286 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Pipeline
|
|
5
|
+
# The first `size` bytes of a stream, kept in an IO opened when first written.
|
|
6
|
+
class HeadPart
|
|
7
|
+
def initialize(open, size)
|
|
8
|
+
@open = open
|
|
9
|
+
@size = size
|
|
10
|
+
@closed = false
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Writes what fits, and answers the rest.
|
|
14
|
+
def take(bytes)
|
|
15
|
+
room = [@size - written, 0].max
|
|
16
|
+
io.write(bytes.byteslice(0, room)) if room.positive? && !bytes.empty?
|
|
17
|
+
bytes.byteslice(room..) || "".b
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def read
|
|
21
|
+
return +"".b unless @io
|
|
22
|
+
|
|
23
|
+
@io.rewind
|
|
24
|
+
@io.read
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def close
|
|
28
|
+
@io&.close
|
|
29
|
+
@closed = true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def closed? = @closed
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def written = @io ? @io.size : 0
|
|
37
|
+
def io = @io ||= @open.call("head")
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "slot"
|
|
4
|
+
|
|
5
|
+
module FunCi
|
|
6
|
+
module Pipeline
|
|
7
|
+
# A workspace that is the project directory itself, with nothing to lock.
|
|
8
|
+
# For a root commit's null SHA, which no worktree can check out.
|
|
9
|
+
InPlace = Data.define(:path)
|
|
10
|
+
|
|
11
|
+
class InPlace
|
|
12
|
+
Unlocked = Struct.new(:closed?) { def close = nil }
|
|
13
|
+
|
|
14
|
+
def acquire(_sha) = Slot.new(path, Unlocked.new(false))
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "head_part"
|
|
5
|
+
require_relative "tail_segments"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Pipeline
|
|
9
|
+
# What a stage printed, kept as its first `head` bytes and its last
|
|
10
|
+
# `tail` bytes, written as it comes so no more than a chunk is held in
|
|
11
|
+
# memory. Both cuts fall on line ends, unless a line is longer than the
|
|
12
|
+
# part it is in, and a marker line says how many bytes were dropped.
|
|
13
|
+
class OutputWindow
|
|
14
|
+
Sizes = Data.define(:head, :tail)
|
|
15
|
+
REAL = Sizes.new(head: 1_048_576, tail: 7_340_032)
|
|
16
|
+
|
|
17
|
+
def self.in_memory(sizes = REAL) = new(->(_name) { StringIO.new(+"".b) }, sizes)
|
|
18
|
+
|
|
19
|
+
def self.in(dir, sizes = REAL)
|
|
20
|
+
new(->(name) { File.open(File.join(dir, name), "w+b") }, sizes)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# `open` answers a new, empty read-write IO for a name.
|
|
24
|
+
def initialize(open, sizes)
|
|
25
|
+
@sizes = sizes
|
|
26
|
+
@written = 0
|
|
27
|
+
@head = HeadPart.new(open, sizes.head)
|
|
28
|
+
@tail = TailSegments.new(open, sizes.tail + 1)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def <<(chunk)
|
|
32
|
+
bytes = chunk.b
|
|
33
|
+
rest = @head.take(bytes)
|
|
34
|
+
@tail << rest unless rest.empty?
|
|
35
|
+
@written += bytes.bytesize
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def text
|
|
40
|
+
head = @head.read
|
|
41
|
+
return head + @tail.read if @written <= @sizes.head + @sizes.tail
|
|
42
|
+
|
|
43
|
+
cut(head, @tail.read)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def close
|
|
47
|
+
@head.close
|
|
48
|
+
@tail.close
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def closed? = @head.closed?
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def cut(head, tail)
|
|
56
|
+
kept_head = head.rindex("\n") ? head[0..head.rindex("\n")] : head
|
|
57
|
+
kept_tail = line_start(tail)
|
|
58
|
+
dropped = @written - kept_head.bytesize - kept_tail.bytesize
|
|
59
|
+
"#{kept_head}[fun-ci: #{dropped} bytes dropped here]\n".b + kept_tail
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The tail holds one byte more than it keeps, to tell whether it starts
|
|
63
|
+
# on a line start. With no line start in it but after its last byte, it
|
|
64
|
+
# is the end of one long line, and is kept from mid-line.
|
|
65
|
+
def line_start(tail)
|
|
66
|
+
first_end = tail.index("\n")
|
|
67
|
+
return tail.byteslice(1..) if first_end.nil? || first_end.zero? || first_end == tail.bytesize - 1
|
|
68
|
+
|
|
69
|
+
tail.byteslice((first_end + 1)..)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../persistence/database"
|
|
4
|
+
require_relative "../persistence/pipeline_recorder"
|
|
5
|
+
require_relative "trigger_params"
|
|
6
|
+
require_relative "../setup/project_config"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Pipeline
|
|
10
|
+
class PipelineForker
|
|
11
|
+
# Answers whether it started a run: none in a project not set up for fun-ci.
|
|
12
|
+
def self.fork_pipeline(commit_hash:, branch:, db_path:)
|
|
13
|
+
return false unless Setup::ProjectConfig.new(Dir.pwd).validate.empty?
|
|
14
|
+
|
|
15
|
+
pid = fork do
|
|
16
|
+
run_in_child(commit_hash: commit_hash, branch: branch, db_path: db_path)
|
|
17
|
+
end
|
|
18
|
+
Process.detach(pid)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Trigger requires this file, through TriggerCommand, so it is loaded
|
|
22
|
+
# here, where it is used, rather than above.
|
|
23
|
+
def self.run_in_child(commit_hash:, branch:, db_path:)
|
|
24
|
+
require_relative "trigger"
|
|
25
|
+
recorder = Persistence::DbRecorder.new(Persistence::Database.connection(db_path))
|
|
26
|
+
trigger = trigger(commit_hash, branch, recorder)
|
|
27
|
+
trigger.run
|
|
28
|
+
trigger.close
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The slow suite forks from here as it does in the foreground, so the
|
|
32
|
+
# fast suite, whose verdict a push waits for, runs beside it.
|
|
33
|
+
def self.trigger(commit_hash, branch, recorder)
|
|
34
|
+
Trigger.new(project: Dir.pwd, commit: Commit.new(sha: commit_hash, branch: branch),
|
|
35
|
+
io: Io.new(stdout: File.open(File::NULL, "w")), seams: Seams.new(recorder: recorder))
|
|
36
|
+
end
|
|
37
|
+
private_class_method :trigger
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "gate"
|
|
4
|
+
require_relative "git_environment"
|
|
5
|
+
require_relative "output_window"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Pipeline
|
|
9
|
+
module ProcessRunner
|
|
10
|
+
# The command waits on fd 9 until the caller has been told its pid, so a
|
|
11
|
+
# cancel that reads the recorded pid can never miss a stage that has
|
|
12
|
+
# started; exec keeps the pid and the process group. If the caller dies
|
|
13
|
+
# first, the pipe closes and the command never runs.
|
|
14
|
+
GATED = "read _ <&9 || exit 125; exec 9<&-; exec %s"
|
|
15
|
+
|
|
16
|
+
# Yields the pid of the process it starts, which leads a process group
|
|
17
|
+
# of its own, so the caller can stop the command and all it spawned.
|
|
18
|
+
# A command over budget answers what it printed before the kill, which
|
|
19
|
+
# is the only clue to what it was stuck on (acceptance-tests.md, AT-9.5).
|
|
20
|
+
# `timer` answers whether the reading finished within the budget.
|
|
21
|
+
BUDGET = ->(reading, budget) { reading.join(budget) }
|
|
22
|
+
# How long a killed command's last output may take to drain.
|
|
23
|
+
DRAIN_SECONDS = 1
|
|
24
|
+
|
|
25
|
+
# Where the command runs, what it finds in its environment besides
|
|
26
|
+
# fun-ci's own, the window what it prints is written to, and what to do
|
|
27
|
+
# with its pid when it runs over budget, before it is killed.
|
|
28
|
+
Launch = Data.define(:chdir, :env, :output, :before_kill)
|
|
29
|
+
|
|
30
|
+
class Launch
|
|
31
|
+
def initialize(**given)
|
|
32
|
+
super(chdir: Dir.pwd, env: {}, output: OutputWindow.in_memory, before_kill: nil, **given)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run_process_with_timeout(cmd, budget, launch: Launch.new, timer: BUDGET, &)
|
|
37
|
+
reader, writer = IO.pipe
|
|
38
|
+
pid = start(cmd, writer, launch, &)
|
|
39
|
+
printed = launch.output
|
|
40
|
+
reading = Thread.new { read_until_closed(reader, printed) }
|
|
41
|
+
timer.call(reading, budget) ? process_finished(pid, text(reading.value)) : over_budget(pid, reading, launch)
|
|
42
|
+
ensure
|
|
43
|
+
ignoring_errors { reader&.close }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def start(cmd, writer, launch)
|
|
49
|
+
gate = Gate.create
|
|
50
|
+
pid = spawn_gated(cmd, writer, gate, launch)
|
|
51
|
+
[writer, gate.child_end].each(&:close)
|
|
52
|
+
yield pid if block_given?
|
|
53
|
+
gate.open
|
|
54
|
+
pid
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def spawn_gated(cmd, writer, gate, launch)
|
|
58
|
+
Process.spawn(GitEnvironment::CLEAN.merge(launch.env), format(GATED, cmd),
|
|
59
|
+
out: writer, err: writer, 9 => gate.child_end, pgroup: true, chdir: launch.chdir)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# The reader may be closed while this thread still reads, once a killed
|
|
63
|
+
# command's output has drained or stopped coming.
|
|
64
|
+
def read_until_closed(reader, printed)
|
|
65
|
+
loop { printed << reader.readpartial(65_536) }
|
|
66
|
+
rescue IOError
|
|
67
|
+
printed
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# What looks at the stage before the kill does so while its output is still read.
|
|
71
|
+
def over_budget(pid, reading, launch)
|
|
72
|
+
launch.before_kill&.call(pid)
|
|
73
|
+
kill_process_group(pid)
|
|
74
|
+
[drained(reading, launch.output), nil, true]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def drained(reading, printed)
|
|
78
|
+
reading.join(DRAIN_SECONDS)
|
|
79
|
+
text(printed)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def text(window) = window.text.force_encoding(Encoding.default_external)
|
|
83
|
+
|
|
84
|
+
def process_finished(pid, output)
|
|
85
|
+
_, status = Process.waitpid2(pid)
|
|
86
|
+
[output, status, false]
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def kill_process_group(pid)
|
|
90
|
+
%w[TERM KILL].each { |signal| ignoring_errors { Process.kill(signal, -pid) } }
|
|
91
|
+
ignoring_errors { Process.waitpid(pid) }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def ignoring_errors
|
|
95
|
+
yield
|
|
96
|
+
rescue StandardError
|
|
97
|
+
# A process that already exited or was already reaped needs nothing.
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
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,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../persistence/active_runs"
|
|
4
|
+
require_relative "../persistence/run_status"
|
|
5
|
+
require_relative "../persistence/write_trouble"
|
|
6
|
+
require_relative "slot_lock"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Pipeline
|
|
10
|
+
# Stops every process of a run. The run's own processes go first, so none
|
|
11
|
+
# of them records the run as failed once its stages die; then each stage's
|
|
12
|
+
# process group. A run whose slot nobody holds any more has died already,
|
|
13
|
+
# and the pids it recorded may belong to other processes now, so it is
|
|
14
|
+
# left alone; so is this process.
|
|
15
|
+
class RunCanceller
|
|
16
|
+
def initialize(killer: Process.method(:kill), slot_held: SlotLock.method(:held?))
|
|
17
|
+
@killer = killer
|
|
18
|
+
@slot_held = slot_held
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Stops the run and records it, and its unfinished stages, cancelled.
|
|
22
|
+
def cancel(db, run)
|
|
23
|
+
stop(run) && stop_late_stages(db, run)
|
|
24
|
+
Persistence::ActiveRuns.cancelled(db, run)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Records failed each slow stage whose forked process is gone without
|
|
28
|
+
# having recorded its result (acceptance-tests.md, AT-8.3), and settles
|
|
29
|
+
# its run.
|
|
30
|
+
def record_dead(db)
|
|
31
|
+
Persistence::ActiveRuns.slow_suites(db).reject { |_, _, pid| exists?(pid) }.each do |job_id, run_id, _|
|
|
32
|
+
Persistence::ActiveRuns.slow_suite_died(db, job_id)
|
|
33
|
+
Persistence::RunStatus.settle(db, run_id)
|
|
34
|
+
end
|
|
35
|
+
rescue SQLite3::BusyException, *Persistence::WriteTrouble::UNWRITABLE
|
|
36
|
+
# The next poll looks again.
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def stop(run)
|
|
40
|
+
return unless alive?(run)
|
|
41
|
+
|
|
42
|
+
(run.processes - [Process.pid] + run.stage_groups.map(&:-@)).each { |pid| kill(pid) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# A stage its process started after `run` was read. A run's process
|
|
48
|
+
# records a stage's group before it lets the stage start, so once those
|
|
49
|
+
# processes are dead, looking again finds every stage they started.
|
|
50
|
+
def stop_late_stages(db, run)
|
|
51
|
+
Persistence::ActiveRuns.with_id(db, run.id).each do |now|
|
|
52
|
+
(now.stage_groups - run.stage_groups).each { |group| kill(-group) }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def alive?(run) = run.slot_lock.nil? || @slot_held.call(run.slot_lock)
|
|
57
|
+
|
|
58
|
+
# Whether a process with this pid exists; one this user may not signal does.
|
|
59
|
+
def exists?(pid)
|
|
60
|
+
@killer.call(0, pid)
|
|
61
|
+
true
|
|
62
|
+
rescue Errno::ESRCH
|
|
63
|
+
false
|
|
64
|
+
rescue Errno::EPERM
|
|
65
|
+
true
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def kill(pid)
|
|
69
|
+
@killer.call("KILL", pid)
|
|
70
|
+
rescue Errno::ESRCH
|
|
71
|
+
# Gone already.
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Pipeline
|
|
5
|
+
# A worktree a pipeline has to itself, held through an flock on its lock
|
|
6
|
+
# file. The fast suite and the slow suite each hold it; it frees when the
|
|
7
|
+
# last of them lets go. A process that dies lets go with it.
|
|
8
|
+
class Slot
|
|
9
|
+
attr_reader :path, :lock_file
|
|
10
|
+
|
|
11
|
+
# +lock_file+ names the lock on disk; nil for a slot with nothing to lock.
|
|
12
|
+
def initialize(path, lock, lock_file = nil)
|
|
13
|
+
@path = path
|
|
14
|
+
@lock = lock
|
|
15
|
+
@lock_file = lock_file
|
|
16
|
+
@holders = 1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def share
|
|
20
|
+
@holders += 1
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def release
|
|
25
|
+
@holders -= 1
|
|
26
|
+
@lock.close if @holders.zero? && !@lock.closed?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FunCi
|
|
4
|
+
module Pipeline
|
|
5
|
+
# Whether some process holds a slot's lock, which is whether the run that
|
|
6
|
+
# took the slot is still alive: an flock dies with its holders.
|
|
7
|
+
module SlotLock
|
|
8
|
+
def self.held?(path)
|
|
9
|
+
File.exist?(path) && File.open(path) { |lock| !lock.flock(File::LOCK_EX | File::LOCK_NB) }
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "trigger_params"
|
|
4
|
+
require_relative "background_fork"
|
|
5
|
+
require_relative "stage_runner"
|
|
6
|
+
require_relative "stage_execution"
|
|
7
|
+
require_relative "progress_reporter"
|
|
8
|
+
|
|
9
|
+
module FunCi
|
|
10
|
+
module Pipeline
|
|
11
|
+
# The stages of one pipeline, run in the slot it was given: lint and build
|
|
12
|
+
# side by side, then the slow suite in the background while the fast suite
|
|
13
|
+
# runs. The slot frees when the last stage holding it finishes.
|
|
14
|
+
class SlotRun
|
|
15
|
+
attr_reader :seams
|
|
16
|
+
|
|
17
|
+
def initialize(commit:, io:, seams:, slot:)
|
|
18
|
+
@commit = commit
|
|
19
|
+
@io = io
|
|
20
|
+
@seams = seams
|
|
21
|
+
@slot = slot
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run(config)
|
|
25
|
+
recorder.slot_taken(@slot.lock_file) if @slot.lock_file
|
|
26
|
+
return released(1) unless phase_one_passed?(config)
|
|
27
|
+
|
|
28
|
+
launch_slow_suite(config)
|
|
29
|
+
progress.slow_launched
|
|
30
|
+
released(fast_passed?(config) ? 0 : 1)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def recorder = @seams.recorder
|
|
36
|
+
def progress = ProgressReporter.new(stdout: @io.stdout)
|
|
37
|
+
def stage_runner = StageRunner.new(commit: @commit, stdout: @io.stdout, seams: @seams, dir: @slot.path)
|
|
38
|
+
|
|
39
|
+
# This process's part is over; the slow suite may still be running.
|
|
40
|
+
def released(exit_code)
|
|
41
|
+
@slot.release
|
|
42
|
+
recorder.foreground_done
|
|
43
|
+
exit_code
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def phase_one_passed?(config)
|
|
47
|
+
results = %w[lint build].to_h { |stage| [stage, Thread.new { stage_runner.passes?(config, stage) }] }
|
|
48
|
+
.transform_values(&:value)
|
|
49
|
+
progress.phase_one_result(results)
|
|
50
|
+
results.values.all?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def fast_passed?(config)
|
|
54
|
+
passed = stage_runner.passes?(config, "fast")
|
|
55
|
+
progress.fast_result(passed)
|
|
56
|
+
passed
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def launch_slow_suite(config)
|
|
60
|
+
job_id = recorder.start_stage("slow", budget: @seams.budgets["slow"])
|
|
61
|
+
launch(db_path: recorder.db_path, pipeline_run_id: recorder.pipeline_run_id, job_id: job_id,
|
|
62
|
+
executor: slow_suite(config, @slot.share))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Called with the recorder and job to record the slow suite with, in
|
|
66
|
+
# whichever process runs it; the slot is let go once it is recorded.
|
|
67
|
+
def slow_suite(config, slot)
|
|
68
|
+
execution = StageExecution.new(seams: @seams, dir: slot.path, commit: @commit)
|
|
69
|
+
command = config.stage_command("slow", @commit.sha)
|
|
70
|
+
->(recorder, job_id) { holding(slot) { execution.run("slow", command, recorder, job_id) } }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def holding(slot)
|
|
74
|
+
yield
|
|
75
|
+
ensure
|
|
76
|
+
slot.release
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def launch(**)
|
|
80
|
+
return @seams.background_launcher.call(**) if @seams.background_launcher
|
|
81
|
+
|
|
82
|
+
@seams = @seams.with(recorder: BackgroundFork.new(recorder, @slot).launch(**))
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tmpdir"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require_relative "output_window"
|
|
6
|
+
require_relative "../persistence/state_dir"
|
|
7
|
+
|
|
8
|
+
module FunCi
|
|
9
|
+
module Pipeline
|
|
10
|
+
# A stage's own directory, named for the process that made it, in the
|
|
11
|
+
# state directory: it holds the window its output is written to, unmasked,
|
|
12
|
+
# until the stage is recorded (AT-10.6), and what a project's extractor
|
|
13
|
+
# reads and writes.
|
|
14
|
+
class StageDir
|
|
15
|
+
def self.default_root = File.join(Persistence::StateDir.path(ENV), "stages")
|
|
16
|
+
|
|
17
|
+
# Removes the directories of processes that died before removing their own.
|
|
18
|
+
def self.create(root = default_root)
|
|
19
|
+
FileUtils.mkdir_p(root)
|
|
20
|
+
Dir.children(root).reject { |name| alive?(name.to_i) }.each { |name| FileUtils.rm_rf(File.join(root, name)) }
|
|
21
|
+
new(Dir.mktmpdir("#{Process.pid}-", root))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.alive?(pid)
|
|
25
|
+
Process.kill(0, pid)
|
|
26
|
+
true
|
|
27
|
+
rescue Errno::ESRCH
|
|
28
|
+
false
|
|
29
|
+
rescue Errno::EPERM
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
private_class_method :alive?
|
|
33
|
+
|
|
34
|
+
def initialize(path)
|
|
35
|
+
@path = path
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The output as the window kept it, written for a project's extractor to read.
|
|
39
|
+
def output_file(text)
|
|
40
|
+
File.join(@path, "output.log").tap { |file| File.binwrite(file, text) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Where a project's extractor writes what it needs to (its stdin and stderr).
|
|
44
|
+
def scratch = @path
|
|
45
|
+
def window(sizes = OutputWindow::REAL) = OutputWindow.in(@path, sizes)
|
|
46
|
+
|
|
47
|
+
def remove = FileUtils.rm_rf(@path)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -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
|