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,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
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "trigger_params"
|
|
4
|
+
require_relative "stage_execution"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Pipeline
|
|
8
|
+
class StageRunner
|
|
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
|
|
26
|
+
@stdout = stdout
|
|
27
|
+
@seams = seams
|
|
28
|
+
@dir = dir
|
|
29
|
+
end
|
|
30
|
+
|
|
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"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def tell(stage, output, result)
|
|
45
|
+
report_timeout(stage) if result == "timed_out"
|
|
46
|
+
report_failure(stage, output) if result == "failed"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def report_timeout(stage)
|
|
50
|
+
@stdout.puts "#{label(stage)} killed -- exceeded #{@seams.budgets[stage]}s time budget."
|
|
51
|
+
@stdout.puts ADVICE[stage]
|
|
52
|
+
end
|
|
53
|
+
|
|
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]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def label(stage) = LABELS.fetch(stage, stage)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../persistence/active_runs"
|
|
4
|
+
require_relative "run_canceller"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
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).
|
|
11
|
+
class StalePipelineCanceller
|
|
12
|
+
def initialize(db:, branch:, stdout:, run_canceller: RunCanceller.new)
|
|
13
|
+
@db = db
|
|
14
|
+
@branch = branch
|
|
15
|
+
@stdout = stdout
|
|
16
|
+
@run_canceller = run_canceller
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# How long an agent's last poll keeps a run it waits on from being cancelled.
|
|
20
|
+
WAITED_ON_SECONDS = 10
|
|
21
|
+
|
|
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
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
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}."
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
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
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../setup/project_config"
|
|
4
|
+
require_relative "../persistence/pipeline_recorder"
|
|
5
|
+
require_relative "trigger_params"
|
|
6
|
+
require_relative "trigger_command"
|
|
7
|
+
require_relative "slot_run"
|
|
8
|
+
require_relative "stale_pipeline_canceller"
|
|
9
|
+
require_relative "workspaces"
|
|
10
|
+
|
|
11
|
+
module FunCi
|
|
12
|
+
module Pipeline
|
|
13
|
+
class Trigger
|
|
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)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(project:, commit:, io: Io.new, seams: Seams.new)
|
|
21
|
+
@project = project
|
|
22
|
+
@commit = commit
|
|
23
|
+
@io = io
|
|
24
|
+
@seams = seams
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def run
|
|
28
|
+
config = Setup::ProjectConfig.new(@project)
|
|
29
|
+
return handle_config_errors(config) if config.validate.any?
|
|
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
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
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
|
+
|
|
57
|
+
def handle_config_errors(config)
|
|
58
|
+
config.validate.each { |e| @io.stdout.puts "fun-ci: #{e}" }
|
|
59
|
+
unless config.folder_exists?
|
|
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."
|
|
62
|
+
end
|
|
63
|
+
0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def unknown_commit
|
|
67
|
+
@io.stderr.puts "fun-ci: commit #{@commit.sha} not found in this repository."
|
|
68
|
+
1
|
|
69
|
+
end
|
|
70
|
+
|
|
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)
|
|
75
|
+
end
|
|
76
|
+
|
|
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 }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def cancel_stale_pipelines
|
|
84
|
+
return unless recorder.db
|
|
85
|
+
|
|
86
|
+
recorder.tolerating do
|
|
87
|
+
StalePipelineCanceller.new(db: recorder.db, branch: branch, stdout: @io.stdout)
|
|
88
|
+
.cancel(new_commit_hash: @commit.sha)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "trigger_params"
|
|
4
|
+
require_relative "pipeline_forker"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Pipeline
|
|
8
|
+
# `fun-ci trigger [--background] <commit-hash> <branch>`. --no-validate is
|
|
9
|
+
# the 1.x name for --background, kept for one release.
|
|
10
|
+
class TriggerCommand
|
|
11
|
+
BACKGROUND = %w[--background --no-validate].freeze
|
|
12
|
+
DEPRECATED = "fun-ci: --no-validate is now --background; the old name goes in 2.1."
|
|
13
|
+
|
|
14
|
+
def initialize(io:, recorder:, pipeline_forker:, project: Dir.pwd)
|
|
15
|
+
@io = io
|
|
16
|
+
@recorder = recorder
|
|
17
|
+
@pipeline_forker = pipeline_forker || PipelineForker.method(:fork_pipeline)
|
|
18
|
+
@project = project
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Closes the database connection it was given on every path.
|
|
22
|
+
def run(args)
|
|
23
|
+
sha, branch = args.reject { |a| a.start_with?("--") }
|
|
24
|
+
return usage unless branch
|
|
25
|
+
|
|
26
|
+
@io.stderr.puts DEPRECATED if args.include?("--no-validate")
|
|
27
|
+
commit = Commit.new(sha: sha, branch: branch)
|
|
28
|
+
args.intersect?(BACKGROUND) ? fork_pipeline(commit) : run_pipeline(commit)
|
|
29
|
+
ensure
|
|
30
|
+
@recorder.close
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
# After forking the slow suite the trigger holds a recorder of its own.
|
|
36
|
+
def run_pipeline(commit)
|
|
37
|
+
trigger = Trigger.new(project: @project, commit: commit, io: @io, seams: Seams.new(recorder: @recorder))
|
|
38
|
+
trigger.run
|
|
39
|
+
ensure
|
|
40
|
+
trigger&.close
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def usage
|
|
44
|
+
@io.stderr.puts "fun-ci: commit hash and branch name are required."
|
|
45
|
+
@io.stderr.puts "Usage: fun-ci trigger <commit-hash> <branch>"
|
|
46
|
+
1
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The forker answers whether it started a run; one that did says how an
|
|
50
|
+
# agent gets its verdict (acceptance-tests.md, AT-9.14).
|
|
51
|
+
def fork_pipeline(commit)
|
|
52
|
+
db_path = @recorder.db_path
|
|
53
|
+
@recorder.close
|
|
54
|
+
started = @pipeline_forker.call(commit_hash: commit.sha, branch: commit.branch, db_path: db_path)
|
|
55
|
+
say_how_to_wait(commit.sha[0, 7]) if started
|
|
56
|
+
0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def say_how_to_wait(sha) = @io.stdout.puts("fun-ci: testing #{sha}. Verdict: fun-ci wait #{sha} --need all")
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "../persistence/pipeline_recorder"
|
|
5
|
+
require_relative "command_executor"
|
|
6
|
+
require_relative "stage_dir"
|
|
7
|
+
require_relative "git_environment"
|
|
8
|
+
require_relative "../evidence/command_runner"
|
|
9
|
+
require_relative "../evidence/process_table"
|
|
10
|
+
|
|
11
|
+
module FunCi
|
|
12
|
+
module Pipeline
|
|
13
|
+
DEFAULT_BUDGETS = { "lint" => 30, "build" => 30, "fast" => 10, "slow" => 300 }.freeze
|
|
14
|
+
|
|
15
|
+
Commit = Data.define(:sha, :branch)
|
|
16
|
+
|
|
17
|
+
Io = Data.define(:stdout, :stderr) do
|
|
18
|
+
def initialize(stdout: $stdout, stderr: $stderr) = super
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The collaborators a pipeline run can have replaced. Each one left out
|
|
22
|
+
# gets the real thing. `environment` is the stages' environment as a hash,
|
|
23
|
+
# which masking and injected command runners see; `clock` answers seconds
|
|
24
|
+
# for the evidence budget; `extractor_runner` makes what runs a project's
|
|
25
|
+
# own extractors (`run:` entries), given dir:, env: and scratch:;
|
|
26
|
+
# `process_table` answers what ps lists, for a stage over budget.
|
|
27
|
+
Seams = Data.define(:command_runner, :time_budgets, :commit_validator, :recorder, :background_launcher, :workspace,
|
|
28
|
+
:stage_dir, :environment, :clock, :extractor_runner, :process_table)
|
|
29
|
+
|
|
30
|
+
# Reopened rather than given as a block to Data.define, so tools that read
|
|
31
|
+
# the source (mutineer) see these as Seams' methods.
|
|
32
|
+
class Seams
|
|
33
|
+
def self.defaults
|
|
34
|
+
{ command_runner: nil, time_budgets: {}, recorder: Persistence::NullRecorder.new, background_launcher: nil,
|
|
35
|
+
workspace: nil, commit_validator: method(:commit_exists?), stage_dir: StageDir.method(:create),
|
|
36
|
+
environment: ENV.to_h, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
|
|
37
|
+
extractor_runner: Evidence::CommandRunner.method(:new),
|
|
38
|
+
process_table: Evidence::ProcessTable.method(:now) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.commit_exists?(sha) = Open3.capture2e(GitEnvironment::CLEAN, "git", "cat-file", "-t", sha).last.success?
|
|
42
|
+
|
|
43
|
+
def initialize(**given) = super(**self.class.defaults.merge(given))
|
|
44
|
+
def budgets = DEFAULT_BUDGETS.merge(time_budgets)
|
|
45
|
+
def executor(dir) = CommandExecutor.new(command_runner, dir, environment)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../setup/project_config"
|
|
4
|
+
require_relative "worktree_pool"
|
|
5
|
+
require_relative "in_place"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Pipeline
|
|
9
|
+
# Where a pipeline for a commit runs: a slot from the project's worktree
|
|
10
|
+
# pool, sized by .fun-ci/config, or, for a root commit's null SHA, which
|
|
11
|
+
# no worktree can check out, the project directory itself.
|
|
12
|
+
module Workspaces
|
|
13
|
+
NULL_SHA = ("0" * 40).freeze
|
|
14
|
+
|
|
15
|
+
def self.for(project, sha)
|
|
16
|
+
return InPlace.new(project) if sha == NULL_SHA
|
|
17
|
+
|
|
18
|
+
WorktreePool.new(Worktrees.new(project), size: Setup::ProjectConfig.new(project).worktree_slots)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "slot"
|
|
5
|
+
require_relative "worktrees"
|
|
6
|
+
|
|
7
|
+
module FunCi
|
|
8
|
+
module Pipeline
|
|
9
|
+
# Hands each pipeline a worktree slot of its own under
|
|
10
|
+
# <git-common-dir>/fun-ci/worktrees, checked out at its commit, waiting
|
|
11
|
+
# while every slot is taken.
|
|
12
|
+
class WorktreePool
|
|
13
|
+
DEFAULT_SIZE = 2
|
|
14
|
+
|
|
15
|
+
attr_reader :size
|
|
16
|
+
|
|
17
|
+
def initialize(worktrees, size: DEFAULT_SIZE, waiter: -> { sleep 0.25 })
|
|
18
|
+
@worktrees = worktrees
|
|
19
|
+
@size = size
|
|
20
|
+
@waiter = waiter
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def acquire(sha)
|
|
24
|
+
slot = wait_for_free_slot
|
|
25
|
+
@worktrees.check_out(slot.path, sha)
|
|
26
|
+
slot
|
|
27
|
+
rescue StandardError
|
|
28
|
+
slot&.release
|
|
29
|
+
raise
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def wait_for_free_slot
|
|
35
|
+
loop do
|
|
36
|
+
slot = free_slot
|
|
37
|
+
return slot if slot
|
|
38
|
+
|
|
39
|
+
@waiter.call
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def free_slot
|
|
44
|
+
root = @worktrees.root
|
|
45
|
+
FileUtils.mkdir_p(root)
|
|
46
|
+
@size.times.lazy.filter_map { |index| try_lock(File.join(root, "slot-#{index}")) }.first
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# The lock stays open for as long as the slot is held, so no block.
|
|
50
|
+
def try_lock(path)
|
|
51
|
+
lock = File.new("#{path}.lock", File::RDWR | File::CREAT, 0o644)
|
|
52
|
+
return Slot.new(path, stamped(lock), lock.path) if lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
53
|
+
|
|
54
|
+
lock.close
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def stamped(lock)
|
|
59
|
+
lock.truncate(0)
|
|
60
|
+
lock.write(Process.pid.to_s)
|
|
61
|
+
lock.flush
|
|
62
|
+
lock
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "worktrees"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Pipeline
|
|
8
|
+
# Removes every slot of a project's worktree pool, and git's entries for
|
|
9
|
+
# them, when no run holds one. It takes each slot's lock first, so a run
|
|
10
|
+
# starting meanwhile waits until the slot is gone and then checks it out
|
|
11
|
+
# afresh.
|
|
12
|
+
class WorktreePrune
|
|
13
|
+
class Busy < StandardError; end
|
|
14
|
+
|
|
15
|
+
def initialize(worktrees)
|
|
16
|
+
@worktrees = worktrees
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# How many slots it removed. Raises Busy, removing nothing, while a run
|
|
20
|
+
# holds a slot.
|
|
21
|
+
def run
|
|
22
|
+
locks = take_locks
|
|
23
|
+
locks.each { |lock| FileUtils.rm_rf(lock.path.delete_suffix(".lock")) }
|
|
24
|
+
@worktrees.prune
|
|
25
|
+
locks.size
|
|
26
|
+
ensure
|
|
27
|
+
locks&.each(&:close)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# Every slot's lock, or none: on the first slot a run holds, it lets go
|
|
33
|
+
# of the locks it took before raising Busy.
|
|
34
|
+
def take_locks
|
|
35
|
+
slot_paths.each_with_object([]) do |path, locks|
|
|
36
|
+
locks << File.new("#{path}.lock", File::RDWR | File::CREAT, 0o644)
|
|
37
|
+
next if locks.last.flock(File::LOCK_EX | File::LOCK_NB)
|
|
38
|
+
|
|
39
|
+
locks.each(&:close)
|
|
40
|
+
raise Busy, "a pipeline is running in #{path}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def slot_paths
|
|
45
|
+
Dir.glob(File.join(@worktrees.root, "slot-*")).map { |path| path.delete_suffix(".lock") }.uniq
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "git_environment"
|
|
5
|
+
|
|
6
|
+
module FunCi
|
|
7
|
+
module Pipeline
|
|
8
|
+
# The git commands a worktree slot needs, run against the project's repository.
|
|
9
|
+
class Worktrees
|
|
10
|
+
class GitError < StandardError; end
|
|
11
|
+
|
|
12
|
+
def initialize(project_root)
|
|
13
|
+
@project_root = project_root
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def root
|
|
17
|
+
File.join(File.expand_path(git(@project_root, "rev-parse", "--git-common-dir").strip, @project_root),
|
|
18
|
+
"fun-ci", "worktrees")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Leaves +path+ as a clean detached checkout of +sha+. Ignored files stay,
|
|
22
|
+
# so caches such as vendor/bundle survive between runs in the same slot.
|
|
23
|
+
def check_out(path, sha)
|
|
24
|
+
return add(path, sha) unless File.exist?(File.join(path, ".git"))
|
|
25
|
+
|
|
26
|
+
git(path, "checkout", "--detach", "--force", sha)
|
|
27
|
+
git(path, "clean", "-fd")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Has git forget the worktrees whose directories are gone.
|
|
31
|
+
def prune = git(@project_root, "worktree", "prune")
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def add(path, sha) = git(@project_root, "worktree", "add", "--detach", "--force", path, sha)
|
|
36
|
+
|
|
37
|
+
def git(dir, *args)
|
|
38
|
+
output, status = Open3.capture2e(GitEnvironment::CLEAN, "git", *args, chdir: dir)
|
|
39
|
+
raise GitError, "git #{args.join(" ")}: #{output}" unless status.success?
|
|
40
|
+
|
|
41
|
+
output
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|