perfgate 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +25 -0
  4. data/CHANGELOG.md +18 -0
  5. data/CONTRIBUTING.md +73 -0
  6. data/LICENSE +201 -0
  7. data/NOT_FINALIZED.md +70 -0
  8. data/README.md +86 -0
  9. data/ROADMAP.md +125 -0
  10. data/Rakefile +12 -0
  11. data/SECURITY.md +63 -0
  12. data/docs/README.md +7 -0
  13. data/docs/architecture.md +125 -0
  14. data/docs/compatibility.md +49 -0
  15. data/docs/launch-article.md +97 -0
  16. data/docs/onboarding.md +122 -0
  17. data/docs/telemetry.md +81 -0
  18. data/examples/rails-rspec-app/.github/workflows/baseline.yml +48 -0
  19. data/examples/rails-rspec-app/README.md +38 -0
  20. data/examples/rails-rspec-app/spec/jobs/invoice_job_spec.rb +15 -0
  21. data/examples/rails-rspec-app/spec/requests/checkout_spec.rb +24 -0
  22. data/exe/perfgate +7 -0
  23. data/lib/perfgate/cli/compare_command.rb +91 -0
  24. data/lib/perfgate/cli/run_command.rb +123 -0
  25. data/lib/perfgate/cli/run_comparison_reporter.rb +77 -0
  26. data/lib/perfgate/cli.rb +60 -0
  27. data/lib/perfgate/comparison/deterministic_metric_decision.rb +34 -0
  28. data/lib/perfgate/comparison/diagnostics.rb +70 -0
  29. data/lib/perfgate/comparison/engine.rb +79 -0
  30. data/lib/perfgate/comparison/metric_change.rb +84 -0
  31. data/lib/perfgate/comparison/metric_decision.rb +39 -0
  32. data/lib/perfgate/comparison/statistical_metric_decision.rb +75 -0
  33. data/lib/perfgate/comparison/workload_comparison.rb +98 -0
  34. data/lib/perfgate/config/defaults.rb +70 -0
  35. data/lib/perfgate/config/env_overrides.rb +54 -0
  36. data/lib/perfgate/config/schema.rb +53 -0
  37. data/lib/perfgate/config/validator.rb +64 -0
  38. data/lib/perfgate/config.rb +136 -0
  39. data/lib/perfgate/errors.rb +20 -0
  40. data/lib/perfgate/execution/process_runner.rb +71 -0
  41. data/lib/perfgate/execution/runner.rb +60 -0
  42. data/lib/perfgate/execution/sample_context.rb +66 -0
  43. data/lib/perfgate/fingerprints/compatibility.rb +46 -0
  44. data/lib/perfgate/fingerprints/components.rb +98 -0
  45. data/lib/perfgate/fingerprints/workload_definition.rb +30 -0
  46. data/lib/perfgate/instrumentation/allocations.rb +20 -0
  47. data/lib/perfgate/instrumentation/duration.rb +20 -0
  48. data/lib/perfgate/instrumentation/gc.rb +30 -0
  49. data/lib/perfgate/instrumentation/sql_activity.rb +50 -0
  50. data/lib/perfgate/instrumentation.rb +36 -0
  51. data/lib/perfgate/metrics/.gitkeep +0 -0
  52. data/lib/perfgate/policy/engine.rb +73 -0
  53. data/lib/perfgate/rails/.gitkeep +0 -0
  54. data/lib/perfgate/report/console.rb +57 -0
  55. data/lib/perfgate/report/markdown.rb +101 -0
  56. data/lib/perfgate/reporting/.gitkeep +0 -0
  57. data/lib/perfgate/rspec/discovery.rb +31 -0
  58. data/lib/perfgate/rspec/id_resolver.rb +30 -0
  59. data/lib/perfgate/rspec/workload_builder.rb +48 -0
  60. data/lib/perfgate/rspec.rb +14 -0
  61. data/lib/perfgate/serialization/run_result.rb +58 -0
  62. data/lib/perfgate/statistics/mann_whitney_u.rb +84 -0
  63. data/lib/perfgate/statistics/summary.rb +60 -0
  64. data/lib/perfgate/storage/adapter.rb +24 -0
  65. data/lib/perfgate/storage/archive.rb +69 -0
  66. data/lib/perfgate/storage/filesystem.rb +121 -0
  67. data/lib/perfgate/telemetry/.gitkeep +0 -0
  68. data/lib/perfgate/version.rb +5 -0
  69. data/lib/perfgate/workloads/registry.rb +50 -0
  70. data/lib/perfgate/workloads/workload.rb +26 -0
  71. data/lib/perfgate.rb +46 -0
  72. data/perfgate.gemspec +41 -0
  73. data/schemas/comparison-result-v1.schema.json +7 -0
  74. data/schemas/run-result-v1.schema.json +7 -0
  75. data/sig/perfgate.rbs +4 -0
  76. metadata +139 -0
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perfgate
4
+ module Report
5
+ # Renders the schema_version 1 comparison-result document (plus its
6
+ # Policy::Engine verdict) as a Markdown report, per spec 20.2: the
7
+ # overall decision, run identities, compatibility status, a
8
+ # per-workload/per-metric table, noise warnings, diagnostics, a
9
+ # pointer to the machine-readable JSON, and an explanation of the
10
+ # exit code. This is what `baseline run --format markdown` writes
11
+ # and what the GitHub Actions example appends to the job summary.
12
+ module Markdown
13
+ DURATION_METRICS = %w[duration sql_duration].freeze
14
+
15
+ module_function
16
+
17
+ def render(comparison_result:, policy_result:, comparison_path: nil)
18
+ [
19
+ header(policy_result),
20
+ identities(comparison_result),
21
+ "**Compatibility:** #{comparison_result.dig("compatibility", "status")}",
22
+ workloads_table(comparison_result),
23
+ diagnostics_section(comparison_result),
24
+ machine_readable_section(comparison_path),
25
+ exit_code_section(policy_result)
26
+ ].compact.join("\n\n")
27
+ end
28
+
29
+ def header(policy_result)
30
+ "## Baseline Performance Assurance\n\n**Overall:** #{policy_result["status"].upcase}"
31
+ end
32
+
33
+ def identities(comparison_result)
34
+ "- Baseline run: `#{comparison_result["baseline_run_id"]}`\n" \
35
+ "- Candidate run: `#{comparison_result["candidate_run_id"]}`"
36
+ end
37
+
38
+ def workloads_table(comparison_result)
39
+ workloads = comparison_result.fetch("workloads", [])
40
+ return "_No workloads were compared._" if workloads.empty?
41
+
42
+ ([table_header] + workloads.flat_map { |workload| workload_rows(workload) }).join("\n")
43
+ end
44
+
45
+ def table_header
46
+ "| Workload | Metric | Baseline | Candidate | Change | Decision |\n|---|---|---|---|---|---|"
47
+ end
48
+
49
+ def workload_rows(workload)
50
+ return [summary_row(workload)] if workload["metrics"].empty?
51
+
52
+ workload["metrics"].map { |name, metric| metric_row(workload["id"], name, metric) }
53
+ end
54
+
55
+ def summary_row(workload)
56
+ "| #{workload["id"]} | - | - | - | - | #{workload["decision"].upcase} |"
57
+ end
58
+
59
+ def metric_row(workload_id, name, metric)
60
+ noise = metric["noisy"] ? " ⚠️ noisy" : ""
61
+ "| #{workload_id} | #{name} | #{format_value(name, metric["baseline_median"])} | " \
62
+ "#{format_value(name, metric["candidate_median"])} | #{format_change(metric["change_percent"])} | " \
63
+ "#{metric["decision"].upcase}#{noise} |"
64
+ end
65
+
66
+ def format_change(percent)
67
+ percent ? format("%+.1f%%", percent) : "n/a"
68
+ end
69
+
70
+ def format_value(name, value)
71
+ return "n/a" if value.nil?
72
+
73
+ DURATION_METRICS.include?(name) ? format("%.2fms", value / 1_000_000.0) : value.to_s
74
+ end
75
+
76
+ def diagnostics_section(comparison_result)
77
+ messages = diagnostic_messages(comparison_result)
78
+ return nil if messages.empty?
79
+
80
+ "**Diagnostics:**\n#{messages.map { |message| "- #{message}" }.join("\n")}"
81
+ end
82
+
83
+ def diagnostic_messages(comparison_result)
84
+ workload_messages = comparison_result.fetch("workloads", []).flat_map { |w| w["diagnostics"] || [] }
85
+ run_messages = comparison_result.fetch("diagnostics", []).map { |d| d["message"] }
86
+ workload_messages + run_messages
87
+ end
88
+
89
+ def machine_readable_section(comparison_path)
90
+ return nil unless comparison_path
91
+
92
+ "Machine-readable result: `#{comparison_path}`"
93
+ end
94
+
95
+ def exit_code_section(policy_result)
96
+ "Exit code `#{policy_result["exit_code"]}` (#{policy_result["status"]}). " \
97
+ "See the exit-code table in the docs for what each status means for CI."
98
+ end
99
+ end
100
+ end
101
+ end
File without changes
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "workload_builder"
4
+
5
+ module Perfgate
6
+ module RSpec
7
+ # Discovers RSpec examples tagged for Baseline (`:baseline` metadata,
8
+ # spec section 9.1) after spec files have been loaded, and registers
9
+ # a Workload for each one.
10
+ #
11
+ # `::RSpec.world.all_examples` is a private RSpec::Core API, but it is
12
+ # widely relied on by tooling that inspects a loaded suite and has
13
+ # been stable across RSpec 3.x releases.
14
+ module Discovery
15
+ module_function
16
+
17
+ def call(registry: Perfgate.registry, builder: default_builder)
18
+ ::RSpec.world.all_examples.each do |example|
19
+ next unless example.metadata[:perfgate]
20
+
21
+ registry.register(builder.build(example))
22
+ end
23
+ registry
24
+ end
25
+
26
+ def default_builder
27
+ WorkloadBuilder.new(defaults: Perfgate.configuration.execution_defaults)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Perfgate
6
+ module RSpec
7
+ # Computes the stable workload identifier for an RSpec example, per
8
+ # spec section 9.2: "<spec file relative path>:<RSpec example full
9
+ # description>", unless overridden via `perfgate: { id: "..." }`.
10
+ module IdResolver
11
+ module_function
12
+
13
+ def resolve(example)
14
+ explicit_id(example) || "#{relative_spec_path(example)}:#{example.full_description}"
15
+ end
16
+
17
+ def explicit_id(example)
18
+ metadata = example.metadata[:perfgate]
19
+ return nil unless metadata.is_a?(Hash)
20
+
21
+ metadata[:id]
22
+ end
23
+
24
+ def relative_spec_path(example)
25
+ absolute = File.expand_path(example.metadata[:file_path])
26
+ Pathname.new(absolute).relative_path_from(Pathname.pwd).to_s
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../workloads/workload"
4
+ require_relative "id_resolver"
5
+
6
+ module Perfgate
7
+ module RSpec
8
+ # Builds a Workloads::Workload that wraps a single RSpec example.
9
+ #
10
+ # Each call re-instantiates the example group and re-runs the example
11
+ # via RSpec's own `Example#run(instance, reporter)`, which is safe to
12
+ # invoke repeatedly: it resets the example's execution result and
13
+ # exercises the full before/around/after hook chain each time (spec
14
+ # section 12.2, "reset workload state" + "execute measured samples").
15
+ # A ::RSpec::Core::NullReporter discards RSpec's own reporting, since
16
+ # Baseline does its own result collection.
17
+ class WorkloadBuilder
18
+ def initialize(defaults:)
19
+ @defaults = defaults
20
+ end
21
+
22
+ def build(example)
23
+ options = example.metadata[:perfgate]
24
+ options = {} unless options.is_a?(Hash)
25
+
26
+ Workloads::Workload.new(
27
+ id: IdResolver.resolve(example),
28
+ samples: options.fetch(:samples, @defaults.fetch(:samples)),
29
+ warmup: options.fetch(:warmup, @defaults.fetch(:warmup)),
30
+ metrics: options.fetch(:metrics, @defaults.fetch(:metrics))
31
+ ) { run_example(example) }
32
+ end
33
+
34
+ private
35
+
36
+ def run_example(example)
37
+ instance = example.example_group.new
38
+ example.run(instance, ::RSpec::Core::NullReporter)
39
+
40
+ exception = example.execution_result.exception
41
+ return unless exception
42
+
43
+ raise Perfgate::WorkloadError,
44
+ "workload #{example.full_description.inspect} failed: #{exception.message}"
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rspec/core"
4
+ require_relative "rspec/id_resolver"
5
+ require_relative "rspec/workload_builder"
6
+ require_relative "rspec/discovery"
7
+
8
+ module Perfgate
9
+ # RSpec integration entry point. Require "baselined/rspec" (typically
10
+ # from spec_helper.rb) to enable `:baseline`-tagged examples and
11
+ # `Perfgate.measure`. See spec section 9 for the public API contract.
12
+ module RSpec
13
+ end
14
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+ require_relative "../statistics/summary"
6
+ require_relative "../fingerprints/components"
7
+
8
+ module Perfgate
9
+ module Serialization
10
+ # Builds the schema_version 1 run-result document described in spec
11
+ # section 14.1. Milestone 5 will add source metadata; fingerprinting
12
+ # (spec section 15) is populated here so the comparison engine can
13
+ # decide compatibility without re-deriving it from scratch.
14
+ module RunResult
15
+ SCHEMA_VERSION = 1
16
+
17
+ module_function
18
+
19
+ def build(workload_results, config: Perfgate.configuration)
20
+ {
21
+ "schema_version" => SCHEMA_VERSION,
22
+ "run_id" => SecureRandom.uuid,
23
+ "created_at" => Time.now.utc.iso8601,
24
+ "fingerprint" => Fingerprints::Components.collect(config: config),
25
+ "workloads" => workload_results.map { |result| build_workload(result) }
26
+ }
27
+ end
28
+
29
+ def build_workload(result)
30
+ samples = result.fetch("samples")
31
+
32
+ {
33
+ "id" => result.fetch("id"),
34
+ "status" => result.fetch("status"),
35
+ "error" => result["error"],
36
+ "definition_hash" => result["definition_hash"],
37
+ "samples" => samples,
38
+ "summary" => summarize(samples)
39
+ }
40
+ end
41
+
42
+ # Every metric key present in at least one sample gets its own
43
+ # summary block (spec section 14.1 shows this for duration_ns, but
44
+ # the same shape applies to sql_count, sql_duration_ns, etc. once
45
+ # those metrics are enabled).
46
+ def summarize(samples)
47
+ metric_keys(samples).each_with_object({}) do |key, summary|
48
+ values = samples.map { |sample| sample[key] }.compact
49
+ summary[key] = Statistics::Summary.call(values)
50
+ end
51
+ end
52
+
53
+ def metric_keys(samples)
54
+ samples.each_with_object([]) { |sample, keys| keys.concat(sample.keys) }.uniq
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perfgate
4
+ module Statistics
5
+ # One-sided Mann-Whitney U test: estimates how likely it is that the
6
+ # candidate distribution is drawn from a "worse" (larger-valued)
7
+ # population than the baseline distribution, without assuming
8
+ # normality (spec section 16.3). Uses a normal approximation with a
9
+ # tie correction, adapted from the Milestone 0 spike
10
+ # (spikes/regression_injection.rb) that validated this approach
11
+ # against a seeded +20% duration regression.
12
+ #
13
+ # Baseline never surfaces this p-value directly to users (spec
14
+ # 16.3: "do not expose p-values alone as user-facing proof") -- it is
15
+ # combined with a practical-significance threshold by
16
+ # Comparison::MetricDecision.
17
+ module MannWhitneyU
18
+ module_function
19
+
20
+ # Returns p, the probability of observing rank sums this extreme
21
+ # (or more) under the null hypothesis of no difference, tested
22
+ # against the one-sided alternative that `candidate` tends to be
23
+ # larger than `baseline`. Smaller p is stronger evidence the
24
+ # candidate is worse.
25
+ def one_sided_p(baseline, candidate)
26
+ return 1.0 if baseline.empty? || candidate.empty?
27
+
28
+ z = z_score(baseline, candidate)
29
+ return 0.5 if z.nil?
30
+
31
+ 0.5 * Math.erfc(z / Math.sqrt(2))
32
+ end
33
+
34
+ # Standardized U statistic for the candidate sample, or nil when the
35
+ # null distribution has zero variance (e.g. every sample tied).
36
+ def z_score(baseline, candidate)
37
+ n1 = baseline.size
38
+ n2 = candidate.size
39
+ u_candidate = candidate_rank_sum(baseline, candidate) - (n2 * (n2 + 1) / 2.0)
40
+ std_u = standard_deviation_u(n1, n2)
41
+ return nil if std_u.zero?
42
+
43
+ (u_candidate - (n1 * n2 / 2.0)) / std_u
44
+ end
45
+
46
+ def standard_deviation_u(baseline_size, candidate_size)
47
+ Math.sqrt(baseline_size * candidate_size * (baseline_size + candidate_size + 1) / 12.0)
48
+ end
49
+
50
+ def candidate_rank_sum(baseline, candidate)
51
+ ranks = rank(baseline.map { |v| [v, :baseline] } + candidate.map { |v| [v, :candidate] })
52
+ ranks.each_index.sum { |i| ranks[i][1] == :candidate ? ranks[i][2] : 0 }
53
+ end
54
+
55
+ # Assigns tied (averaged) ranks to a list of [value, label] pairs,
56
+ # returning [value, label, rank] triples sorted by value.
57
+ def rank(pairs)
58
+ sorted = pairs.sort_by { |value, _label| value }
59
+ ranked = Array.new(sorted.size)
60
+
61
+ index = 0
62
+ index = assign_tie_group(sorted, ranked, index) while index < sorted.size
63
+
64
+ ranked
65
+ end
66
+
67
+ # Finds the run of tied values starting at start_index, assigns
68
+ # them all the same averaged rank, and returns the index just past
69
+ # the run.
70
+ def assign_tie_group(sorted, ranked, start_index)
71
+ end_index = tie_group_end(sorted, start_index)
72
+ average_rank = ((start_index + 1) + (end_index + 1)) / 2.0
73
+ (start_index..end_index).each { |k| ranked[k] = sorted[k] + [average_rank] }
74
+ end_index + 1
75
+ end
76
+
77
+ def tie_group_end(sorted, start_index)
78
+ end_index = start_index
79
+ end_index += 1 while end_index + 1 < sorted.size && sorted[end_index + 1][0] == sorted[start_index][0]
80
+ end_index
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perfgate
4
+ module Statistics
5
+ # Basic summary statistics for a set of raw sample values, used to
6
+ # populate the `summary` block of a run result (spec sections 14.1
7
+ # and 16.2).
8
+ module Summary
9
+ module_function
10
+
11
+ def call(values)
12
+ return empty if values.empty?
13
+
14
+ sorted = values.sort
15
+ med = median(sorted)
16
+
17
+ central_tendency(values, sorted, med).merge("count" => values.size)
18
+ end
19
+
20
+ def central_tendency(values, sorted, med)
21
+ {
22
+ "mean" => mean(values).round,
23
+ "median" => med.round,
24
+ "min" => sorted.first.round,
25
+ "max" => sorted.last.round,
26
+ "mad" => mad(sorted, med).round,
27
+ "p90" => percentile(sorted, 90).round
28
+ }
29
+ end
30
+
31
+ def mean(values)
32
+ values.sum / values.size.to_f
33
+ end
34
+
35
+ def median(sorted)
36
+ percentile(sorted, 50)
37
+ end
38
+
39
+ # Median absolute deviation: a robust spread measure, less sensitive
40
+ # to outliers than standard deviation.
41
+ def mad(sorted, med)
42
+ deviations = sorted.map { |v| (v - med).abs }.sort
43
+ percentile(deviations, 50)
44
+ end
45
+
46
+ def percentile(sorted, pct)
47
+ return sorted.first.to_f if sorted.size == 1
48
+
49
+ rank = (pct / 100.0) * (sorted.size - 1)
50
+ lower = sorted[rank.floor]
51
+ upper = sorted[rank.ceil]
52
+ lower + ((upper - lower) * (rank - rank.floor))
53
+ end
54
+
55
+ def empty
56
+ { "mean" => 0, "median" => 0, "min" => 0, "max" => 0, "mad" => 0, "p90" => 0, "count" => 0 }
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perfgate
4
+ module Storage
5
+ # Interface every storage backend implements (spec section 18.1).
6
+ class Adapter
7
+ def save_run(run_result)
8
+ raise NotImplementedError, "#{self.class} must implement #save_run"
9
+ end
10
+
11
+ def load_run(reference)
12
+ raise NotImplementedError, "#{self.class} must implement #load_run"
13
+ end
14
+
15
+ def list_runs(filters = {})
16
+ raise NotImplementedError, "#{self.class} must implement #list_runs"
17
+ end
18
+
19
+ def save_comparison(comparison_result)
20
+ raise NotImplementedError, "#{self.class} must implement #save_comparison"
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "rubygems/package"
5
+ require "stringio"
6
+ require "zlib"
7
+
8
+ module Perfgate
9
+ module Storage
10
+ # Packs/unpacks the portable baseline-run-<run-id>.tar.gz archive
11
+ # format (spec 18.2), used to move a run bundle outside of a
12
+ # Filesystem adapter's own root -- e.g. as a manually-downloaded CI
13
+ # artifact. Extraction rejects any entry that would escape the
14
+ # destination directory (spec 22: reject path traversal).
15
+ module Archive
16
+ module_function
17
+
18
+ def write(archive_path, dir, run_id)
19
+ tar_io = StringIO.new
20
+ Gem::Package::TarWriter.new(tar_io) do |tar|
21
+ Dir.glob("**/*", File::FNM_DOTMATCH, base: dir).each { |entry| add_entry(tar, dir, run_id, entry) }
22
+ end
23
+
24
+ Zlib::GzipWriter.open(archive_path) { |gz| gz.write(tar_io.string) }
25
+ end
26
+
27
+ def extract(archive_path, into)
28
+ Zlib::GzipReader.open(archive_path) do |gz|
29
+ Gem::Package::TarReader.new(gz) { |tar| extract_entries(tar, into) }
30
+ end
31
+ end
32
+
33
+ def add_entry(tar, dir, run_id, entry)
34
+ return if [".", ".."].include?(File.basename(entry))
35
+
36
+ source = File.join(dir, entry)
37
+ name = File.join(run_id, entry)
38
+ if File.directory?(source)
39
+ tar.mkdir(name, 0o755)
40
+ else
41
+ tar.add_file(name, 0o644) { |io| io.write(File.read(source)) }
42
+ end
43
+ end
44
+
45
+ def extract_entries(tar, into)
46
+ tar.each do |entry|
47
+ destination = safe_destination(into, entry.full_name)
48
+ entry.directory? ? FileUtils.mkdir_p(destination) : extract_file(entry, destination)
49
+ end
50
+ end
51
+
52
+ def extract_file(entry, destination)
53
+ FileUtils.mkdir_p(File.dirname(destination))
54
+ File.write(destination, entry.read)
55
+ end
56
+
57
+ def safe_destination(into, entry_name)
58
+ base = File.join(into, "runs")
59
+ destination = File.expand_path(File.join(base, entry_name))
60
+
61
+ unless destination.start_with?("#{File.expand_path(base)}/")
62
+ raise Perfgate::ResultBundleError, "archive entry #{entry_name.inspect} escapes the destination directory"
63
+ end
64
+
65
+ destination
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "securerandom"
7
+ require_relative "adapter"
8
+ require_relative "archive"
9
+
10
+ module Perfgate
11
+ module Storage
12
+ # Default MVP storage adapter (spec section 18.2). Layout:
13
+ #
14
+ # <root>/runs/<run-id>/manifest.json
15
+ # <root>/runs/<run-id>/run.json
16
+ # <root>/runs/<run-id>/checksums.json
17
+ # <root>/comparisons/<comparison-id>.json
18
+ class Filesystem < Adapter
19
+ def initialize(root:)
20
+ super()
21
+ @root = root
22
+ end
23
+
24
+ def save_run(run_result)
25
+ run_id = run_result.fetch("run_id")
26
+ dir = run_directory(run_id)
27
+ FileUtils.mkdir_p(dir)
28
+
29
+ run_json = JSON.pretty_generate(run_result)
30
+ File.write(File.join(dir, "run.json"), run_json)
31
+ File.write(File.join(dir, "manifest.json"), JSON.pretty_generate(manifest_for(run_result)))
32
+ File.write(File.join(dir, "checksums.json"), JSON.pretty_generate(checksums_for(run_json)))
33
+
34
+ dir
35
+ end
36
+
37
+ def load_run(reference)
38
+ dir = File.directory?(reference) ? reference : run_directory(reference)
39
+ run_json_path = File.join(dir, "run.json")
40
+
41
+ raise Perfgate::ResultBundleError, "no run result found at #{run_json_path}" unless File.exist?(run_json_path)
42
+
43
+ verify_checksum!(dir, run_json_path)
44
+ JSON.parse(File.read(run_json_path))
45
+ end
46
+
47
+ def list_runs(filters = {}) # rubocop:disable Lint/UnusedMethodArgument
48
+ base = File.join(@root, "runs")
49
+ return [] unless File.directory?(base)
50
+
51
+ Dir.children(base).sort
52
+ end
53
+
54
+ def save_comparison(comparison_result)
55
+ dir = File.join(@root, "comparisons")
56
+ FileUtils.mkdir_p(dir)
57
+
58
+ comparison_id = SecureRandom.uuid
59
+ path = File.join(dir, "#{comparison_id}.json")
60
+ File.write(path, JSON.pretty_generate(comparison_result))
61
+
62
+ path
63
+ end
64
+
65
+ # Packs a run bundle's directory into the portable
66
+ # baseline-run-<run-id>.tar.gz format (spec 18.2), for manual
67
+ # transfer or storage outside the filesystem adapter's own root
68
+ # (e.g. as a CI artifact). Returns the archive's path.
69
+ def export_archive(run_id, into: @root)
70
+ dir = run_directory(run_id)
71
+ raise Perfgate::ResultBundleError, "no run bundle found at #{dir}" unless File.directory?(dir)
72
+
73
+ FileUtils.mkdir_p(into)
74
+ archive_path = File.join(into, "baseline-run-#{run_id}.tar.gz")
75
+ Archive.write(archive_path, dir, run_id)
76
+ archive_path
77
+ end
78
+
79
+ # Unpacks a baseline-run-<run-id>.tar.gz archive into `into`,
80
+ # rejecting any entry that would escape the destination directory
81
+ # (spec 22: reject path traversal in archives). Returns the
82
+ # extracted run directory path.
83
+ def import_archive(archive_path, into: @root)
84
+ Archive.extract(archive_path, into)
85
+ File.join(into, "runs", File.basename(archive_path, ".tar.gz").sub(/^baseline-run-/, ""))
86
+ end
87
+
88
+ private
89
+
90
+ def run_directory(run_id)
91
+ File.join(@root, "runs", run_id)
92
+ end
93
+
94
+ def manifest_for(run_result)
95
+ {
96
+ "schema_version" => run_result["schema_version"],
97
+ "run_id" => run_result["run_id"],
98
+ "created_at" => run_result["created_at"],
99
+ "workload_ids" => run_result.fetch("workloads").map { |w| w["id"] }
100
+ }
101
+ end
102
+
103
+ def checksums_for(run_json)
104
+ { "run.json" => "sha256:#{Digest::SHA256.hexdigest(run_json)}" }
105
+ end
106
+
107
+ def verify_checksum!(dir, run_json_path)
108
+ checksums_path = File.join(dir, "checksums.json")
109
+ return unless File.exist?(checksums_path)
110
+
111
+ expected = JSON.parse(File.read(checksums_path))["run.json"]
112
+ return unless expected
113
+
114
+ actual = "sha256:#{Digest::SHA256.hexdigest(File.read(run_json_path))}"
115
+ return if actual == expected
116
+
117
+ raise Perfgate::ResultBundleError, "checksum mismatch for #{run_json_path}"
118
+ end
119
+ end
120
+ end
121
+ end
File without changes
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Perfgate
4
+ VERSION = "0.1.0"
5
+ end