rspec-tracer 1.2.3 → 2.0.0.pre.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +384 -67
- data/README.md +454 -429
- data/bin/rspec-tracer +15 -0
- data/lib/rspec_tracer/cache/Rakefile +43 -0
- data/lib/rspec_tracer/cli/cache_clear.rb +111 -0
- data/lib/rspec_tracer/cli/cache_info.rb +104 -0
- data/lib/rspec_tracer/cli/doctor.rb +284 -0
- data/lib/rspec_tracer/cli/explain.rb +158 -0
- data/lib/rspec_tracer/cli/report_open.rb +82 -0
- data/lib/rspec_tracer/cli.rb +116 -0
- data/lib/rspec_tracer/configuration.rb +1196 -3
- data/lib/rspec_tracer/engine.rb +1168 -0
- data/lib/rspec_tracer/example.rb +141 -11
- data/lib/rspec_tracer/filter.rb +35 -0
- data/lib/rspec_tracer/line_stub.rb +61 -0
- data/lib/rspec_tracer/load_config.rb +2 -2
- data/lib/rspec_tracer/logger.rb +15 -0
- data/lib/rspec_tracer/rails/README.md +78 -0
- data/lib/rspec_tracer/rails/i18n_tracking.rb +137 -0
- data/lib/rspec_tracer/rails/notifications.rb +263 -0
- data/lib/rspec_tracer/rails/preset.rb +94 -0
- data/lib/rspec_tracer/rails/railtie.rb +22 -0
- data/lib/rspec_tracer/rails.rb +15 -0
- data/lib/rspec_tracer/remote_cache/README.md +140 -0
- data/lib/rspec_tracer/remote_cache/Rakefile +35 -11
- data/lib/rspec_tracer/remote_cache/archive.rb +137 -0
- data/lib/rspec_tracer/remote_cache/backend.rb +73 -0
- data/lib/rspec_tracer/remote_cache/git_ancestry.rb +241 -0
- data/lib/rspec_tracer/remote_cache/local_fs_backend.rb +439 -0
- data/lib/rspec_tracer/remote_cache/redis_backend.rb +554 -0
- data/lib/rspec_tracer/remote_cache/s3_backend.rb +712 -0
- data/lib/rspec_tracer/remote_cache/user_tasks.rb +436 -0
- data/lib/rspec_tracer/remote_cache/validator.rb +40 -62
- data/lib/rspec_tracer/remote_cache.rb +22 -0
- data/lib/rspec_tracer/reporters/README.md +103 -0
- data/lib/rspec_tracer/reporters/base.rb +87 -0
- data/lib/rspec_tracer/reporters/coverage_json_reporter.rb +338 -0
- data/lib/rspec_tracer/reporters/html/.gitignore +19 -0
- data/lib/rspec_tracer/reporters/html/.prettierignore +4 -0
- data/lib/rspec_tracer/reporters/html/.prettierrc.json +9 -0
- data/lib/rspec_tracer/reporters/html/README.md +80 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.css +2 -0
- data/lib/rspec_tracer/reporters/html/dist/assets/index.js +1 -0
- data/lib/rspec_tracer/reporters/html/dist/index.html +24 -0
- data/lib/rspec_tracer/reporters/html/eslint.config.js +62 -0
- data/lib/rspec_tracer/reporters/html/package-lock.json +4941 -0
- data/lib/rspec_tracer/reporters/html/package.json +29 -0
- data/lib/rspec_tracer/reporters/html/src/app.jsx +130 -0
- data/lib/rspec_tracer/reporters/html/src/components/AllExamples.jsx +86 -0
- data/lib/rspec_tracer/reporters/html/src/components/DuplicateExamples.jsx +68 -0
- data/lib/rspec_tracer/reporters/html/src/components/ExamplesDependency.jsx +78 -0
- data/lib/rspec_tracer/reporters/html/src/components/FilesDependency.jsx +72 -0
- data/lib/rspec_tracer/reporters/html/src/components/FlakyExamples.jsx +42 -0
- data/lib/rspec_tracer/reporters/html/src/components/ReportTable.jsx +131 -0
- data/lib/rspec_tracer/reporters/html/src/components/SearchBar.jsx +19 -0
- data/lib/rspec_tracer/reporters/html/src/index.html +23 -0
- data/lib/rspec_tracer/reporters/html/src/main.jsx +37 -0
- data/lib/rspec_tracer/reporters/html/src/styles.css +434 -0
- data/lib/rspec_tracer/reporters/html/vite.config.js +42 -0
- data/lib/rspec_tracer/reporters/html_reporter.rb +266 -0
- data/lib/rspec_tracer/reporters/json_reporter.rb +88 -0
- data/lib/rspec_tracer/reporters/payload_builder.rb +235 -0
- data/lib/rspec_tracer/reporters/registry.rb +120 -0
- data/lib/rspec_tracer/reporters/terminal_reporter.rb +264 -0
- data/lib/rspec_tracer/rspec/README.md +73 -0
- data/lib/rspec_tracer/rspec/installation.rb +97 -0
- data/lib/rspec_tracer/rspec/metadata.rb +96 -0
- data/lib/rspec_tracer/rspec/parallel_tests.rb +459 -0
- data/lib/rspec_tracer/rspec/reporter_hook.rb +84 -0
- data/lib/rspec_tracer/rspec/runner_hook.rb +239 -0
- data/lib/rspec_tracer/source_file.rb +24 -7
- data/lib/rspec_tracer/storage/README.md +35 -0
- data/lib/rspec_tracer/storage/backend.rb +130 -0
- data/lib/rspec_tracer/storage/json_backend.rb +884 -0
- data/lib/rspec_tracer/storage/lazy_snapshot.rb +65 -0
- data/lib/rspec_tracer/storage/schema.rb +50 -0
- data/lib/rspec_tracer/storage/serializer/json.rb +41 -0
- data/lib/rspec_tracer/storage/serializer/msgpack.rb +167 -0
- data/lib/rspec_tracer/storage/snapshot.rb +141 -0
- data/lib/rspec_tracer/storage/sqlite_backend.rb +693 -0
- data/lib/rspec_tracer/time_formatter.rb +37 -18
- data/lib/rspec_tracer/tracker/README.md +36 -0
- data/lib/rspec_tracer/tracker/coverage_adapter.rb +174 -0
- data/lib/rspec_tracer/tracker/declared_globs.rb +100 -0
- data/lib/rspec_tracer/tracker/dependency_graph.rb +134 -0
- data/lib/rspec_tracer/tracker/env_matcher.rb +127 -0
- data/lib/rspec_tracer/tracker/env_snapshot.rb +77 -0
- data/lib/rspec_tracer/tracker/example_registry.rb +153 -0
- data/lib/rspec_tracer/tracker/file_digest.rb +61 -0
- data/lib/rspec_tracer/tracker/filter.rb +127 -0
- data/lib/rspec_tracer/tracker/input.rb +99 -0
- data/lib/rspec_tracer/tracker/io_hooks/file.rb +55 -0
- data/lib/rspec_tracer/tracker/io_hooks/io.rb +24 -0
- data/lib/rspec_tracer/tracker/io_hooks/json.rb +23 -0
- data/lib/rspec_tracer/tracker/io_hooks/kernel.rb +26 -0
- data/lib/rspec_tracer/tracker/io_hooks/yaml.rb +38 -0
- data/lib/rspec_tracer/tracker/io_hooks.rb +195 -0
- data/lib/rspec_tracer/tracker/loaded_files_tracker.rb +295 -0
- data/lib/rspec_tracer/tracker/new_file_detector.rb +62 -0
- data/lib/rspec_tracer/tracker/whole_suite_invalidators.rb +96 -0
- data/lib/rspec_tracer/version.rb +4 -1
- data/lib/rspec_tracer.rb +231 -491
- metadata +94 -43
- data/lib/rspec_tracer/cache.rb +0 -207
- data/lib/rspec_tracer/coverage_merger.rb +0 -42
- data/lib/rspec_tracer/coverage_reporter.rb +0 -187
- data/lib/rspec_tracer/coverage_writer.rb +0 -58
- data/lib/rspec_tracer/html_reporter/Rakefile +0 -18
- data/lib/rspec_tracer/html_reporter/assets/javascripts/application.js +0 -56
- data/lib/rspec_tracer/html_reporter/assets/javascripts/libraries/jquery.js +0 -10881
- data/lib/rspec_tracer/html_reporter/assets/javascripts/plugins/datatables.js +0 -15381
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/application.css +0 -196
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/datatables.css +0 -459
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/plugins/jquery-ui.css +0 -436
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/print.css +0 -92
- data/lib/rspec_tracer/html_reporter/assets/stylesheets/reset.css +0 -265
- data/lib/rspec_tracer/html_reporter/public/application.css +0 -5
- data/lib/rspec_tracer/html_reporter/public/application.js +0 -6
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_asc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_both.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/datatables/images/sort_desc_disabled.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/favicon.png +0 -0
- data/lib/rspec_tracer/html_reporter/public/loading.gif +0 -0
- data/lib/rspec_tracer/html_reporter/reporter.rb +0 -242
- data/lib/rspec_tracer/html_reporter/views/duplicate_examples.erb +0 -34
- data/lib/rspec_tracer/html_reporter/views/examples.erb +0 -58
- data/lib/rspec_tracer/html_reporter/views/examples_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/files_dependency.erb +0 -36
- data/lib/rspec_tracer/html_reporter/views/flaky_examples.erb +0 -38
- data/lib/rspec_tracer/html_reporter/views/layout.erb +0 -38
- data/lib/rspec_tracer/remote_cache/aws.rb +0 -176
- data/lib/rspec_tracer/remote_cache/cache.rb +0 -75
- data/lib/rspec_tracer/remote_cache/repo.rb +0 -210
- data/lib/rspec_tracer/report_generator.rb +0 -158
- data/lib/rspec_tracer/report_merger.rb +0 -68
- data/lib/rspec_tracer/report_writer.rb +0 -141
- data/lib/rspec_tracer/reporter.rb +0 -204
- data/lib/rspec_tracer/rspec_reporter.rb +0 -41
- data/lib/rspec_tracer/rspec_runner.rb +0 -56
- data/lib/rspec_tracer/ruby_coverage.rb +0 -9
- data/lib/rspec_tracer/runner.rb +0 -278
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'open3'
|
|
5
|
+
require 'securerandom'
|
|
6
|
+
require 'set'
|
|
7
|
+
require 'time'
|
|
8
|
+
require 'tmpdir'
|
|
9
|
+
|
|
10
|
+
require_relative 'archive'
|
|
11
|
+
require_relative 'backend'
|
|
12
|
+
require_relative 'validator'
|
|
13
|
+
|
|
14
|
+
module RSpecTracer
|
|
15
|
+
# Internal RemoteCache — see {RSpecTracer} for the user-facing surface.
|
|
16
|
+
# @api private
|
|
17
|
+
module RemoteCache
|
|
18
|
+
# S3 implementation of `RemoteCache::Backend`. Shells out to the
|
|
19
|
+
# `aws` / `awslocal` CLI for every operation - matches 1.x's
|
|
20
|
+
# behavior and avoids pulling `aws-sdk-s3` into the gem's runtime
|
|
21
|
+
# deps. Users on 1.x already have `aws` on PATH per the documented
|
|
22
|
+
# CI recipe; 2.0 asks nothing new.
|
|
23
|
+
#
|
|
24
|
+
# Two-tier S3 layout (change from 1.x flat layout; paired with the
|
|
25
|
+
# schema_version bump - one cold run on upgrade). Cache payload is
|
|
26
|
+
# a single `cache.tar.gz` per ref (~15 JSON files + last_run.json
|
|
27
|
+
# packed together; ~4-6x smaller on the wire + 1 GET per download
|
|
28
|
+
# instead of 15):
|
|
29
|
+
#
|
|
30
|
+
# s3://<bucket>/<prefix>/
|
|
31
|
+
# main/<sha>/[<test_suite_id>/]cache.tar.gz
|
|
32
|
+
# pr/<branch>/<sha>/[<test_suite_id>/]cache.tar.gz
|
|
33
|
+
# pr/<branch>/branch_refs.json
|
|
34
|
+
#
|
|
35
|
+
# Local cache_path layout is unchanged - the archive is a transit
|
|
36
|
+
# boundary only. Users and external tooling continue to see the
|
|
37
|
+
# 15-file disk layout documented in `USER_FACING_SURFACE.md` section 6.
|
|
38
|
+
#
|
|
39
|
+
# Tier is determined from `branch` vs `default_branch` at construction.
|
|
40
|
+
# Main-branch builds write to main tier; PR builds write to their
|
|
41
|
+
# branch-scoped pr tier. Download tries the backend's own tier first,
|
|
42
|
+
# then falls back to main tier for the same ref (catches PRs
|
|
43
|
+
# cherry-picking from main).
|
|
44
|
+
#
|
|
45
|
+
# Retention (closes issue #20 at the architectural layer, not just
|
|
46
|
+
# with a knob):
|
|
47
|
+
# - `cache_retention_count N` keeps newest N refs per tier
|
|
48
|
+
# (main has N refs, each PR branch has N refs).
|
|
49
|
+
# - `cache_retention_duration_seconds X` prunes refs older than
|
|
50
|
+
# X seconds in any tier the backend visits.
|
|
51
|
+
# - `cache_retention_pr_branch_ttl_seconds X` deletes a PR branch
|
|
52
|
+
# entirely (including its branch_refs.json) when no ref has
|
|
53
|
+
# been touched in X seconds. Applied at upload time in the
|
|
54
|
+
# backend's own branch only; cross-branch cleanup is a separate
|
|
55
|
+
# Rake task.
|
|
56
|
+
#
|
|
57
|
+
# Graceful-degradation contract:
|
|
58
|
+
# - `download` returns false and never raises on wire/validation
|
|
59
|
+
# failure. Partial downloads are cleaned up.
|
|
60
|
+
# - `upload` raises on wire failure; the Rake task catches.
|
|
61
|
+
# - `branch_refs` returns `{}` on missing file.
|
|
62
|
+
# - `prune!` returns count removed, never raises.
|
|
63
|
+
#
|
|
64
|
+
# S3 shells out via `aws` CLI - a single class is the natural unit
|
|
65
|
+
# of composition here. The class is large; splitting would be
|
|
66
|
+
# cosmetic.
|
|
67
|
+
# rubocop:disable Metrics/ClassLength
|
|
68
|
+
class S3Backend
|
|
69
|
+
# Internal S3BackendError — see {RSpecTracer} for the user-facing surface.
|
|
70
|
+
# @api private
|
|
71
|
+
class S3BackendError < StandardError; end
|
|
72
|
+
|
|
73
|
+
# Internal constant.
|
|
74
|
+
# @api private
|
|
75
|
+
MAIN_TIER = 'main'
|
|
76
|
+
# Internal constant.
|
|
77
|
+
# @api private
|
|
78
|
+
PR_TIER = 'pr'
|
|
79
|
+
# Internal constant.
|
|
80
|
+
# @api private
|
|
81
|
+
BRANCH_REFS_FILENAME = 'branch_refs.json'
|
|
82
|
+
# Internal constant.
|
|
83
|
+
# @api private
|
|
84
|
+
LAST_RUN_FILENAME = 'last_run.json'
|
|
85
|
+
# Internal constant.
|
|
86
|
+
# @api private
|
|
87
|
+
CACHE_ARCHIVE_FILENAME = Archive::CACHE_FILENAME
|
|
88
|
+
# Internal constant.
|
|
89
|
+
# @api private
|
|
90
|
+
ENCODING = 'UTF-8'
|
|
91
|
+
|
|
92
|
+
# Internal constant.
|
|
93
|
+
# @api private
|
|
94
|
+
REQUIRED_OPTS = %i[bucket prefix branch default_branch cache_path].freeze
|
|
95
|
+
|
|
96
|
+
# rubocop:disable Metrics/ParameterLists
|
|
97
|
+
def initialize(bucket:, prefix:, branch:, default_branch:,
|
|
98
|
+
cache_path:, test_suite_id: nil, local: false, logger: nil)
|
|
99
|
+
validate_required!(bucket: bucket, prefix: prefix, branch: branch,
|
|
100
|
+
default_branch: default_branch, cache_path: cache_path)
|
|
101
|
+
|
|
102
|
+
@bucket = bucket.to_s
|
|
103
|
+
@prefix = trim_trailing_slashes(prefix.to_s)
|
|
104
|
+
@branch = branch.to_s.chomp
|
|
105
|
+
@default_branch = default_branch.to_s.chomp
|
|
106
|
+
@test_suite_id = normalize_test_suite_id(test_suite_id)
|
|
107
|
+
@cache_path = cache_path.to_s
|
|
108
|
+
@cli_binary = local ? 'awslocal' : 'aws'
|
|
109
|
+
@logger = logger
|
|
110
|
+
end
|
|
111
|
+
# rubocop:enable Metrics/ParameterLists
|
|
112
|
+
|
|
113
|
+
# Download the cache for `ref` into `cache_path`. Tries the
|
|
114
|
+
# backend's own tier first; on miss, falls back to the main tier
|
|
115
|
+
# for the same ref. Validates the downloaded `last_run.json` via
|
|
116
|
+
# schema_version before declaring success.
|
|
117
|
+
#
|
|
118
|
+
# When `tree_sha` is provided, first consults the tree-SHA
|
|
119
|
+
# secondary index (`<tier>/by_tree/<tree_sha>`) to resolve the
|
|
120
|
+
# tree to a commit ref - catches rebase / revert scenarios where
|
|
121
|
+
# the same tree lives at a different commit hash than the one
|
|
122
|
+
# the caller is asking about. The standard `<tier>/<ref>` lookup
|
|
123
|
+
# is still tried as a fallback when the tree pointer is absent
|
|
124
|
+
# or its resolved ref has no archive.
|
|
125
|
+
#
|
|
126
|
+
# Returns true on validated success, false on any failure. Cleans
|
|
127
|
+
# up partially-downloaded files on failure so a subsequent fresh
|
|
128
|
+
# load doesn't see stale data.
|
|
129
|
+
def download(ref, tree_sha: nil)
|
|
130
|
+
return false if ref.nil? || ref.to_s.empty?
|
|
131
|
+
|
|
132
|
+
attempts = build_download_attempts(ref, tree_sha)
|
|
133
|
+
attempts.any? { |tier, candidate| try_download_from(tier, candidate) }
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Upload the local cache to this backend's own tier under `ref`.
|
|
137
|
+
# Packs the 15-file local layout into a single `cache.tar.gz` and
|
|
138
|
+
# uploads that one object. Raises on wire failure. Idempotent.
|
|
139
|
+
#
|
|
140
|
+
# When `tree_sha` is provided, ALSO writes a small pointer file
|
|
141
|
+
# at `<tier>/by_tree/<tree_sha>` containing the commit-SHA. The
|
|
142
|
+
# pointer is consumed by `download(ref, tree_sha: ...)` to hit
|
|
143
|
+
# the cache when a different commit (rebase / revert) shares
|
|
144
|
+
# the same tree.
|
|
145
|
+
def upload(ref, tree_sha: nil)
|
|
146
|
+
raise S3BackendError, 'ref is required' if blank?(ref)
|
|
147
|
+
|
|
148
|
+
run_id = read_local_run_id
|
|
149
|
+
raise S3BackendError, "no local cache to upload (missing #{LAST_RUN_FILENAME})" if run_id.nil?
|
|
150
|
+
|
|
151
|
+
archive_path = tmp_archive_path('upload')
|
|
152
|
+
begin
|
|
153
|
+
Archive.pack(cache_path: @cache_path, run_id: run_id, dest_path: archive_path)
|
|
154
|
+
upload_file(archive_path, s3_archive_key(own_tier_prefix, ref))
|
|
155
|
+
upload_tree_pointer(ref, tree_sha) unless blank?(tree_sha)
|
|
156
|
+
log_debug("uploaded cache for #{ref} to #{own_tier_prefix} (#{File.size(archive_path)} bytes)")
|
|
157
|
+
ensure
|
|
158
|
+
FileUtils.rm_f(archive_path)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Read branch_refs for the given branch. Returns `{sha => ts_epoch}`
|
|
163
|
+
# or `{}` when the file is missing / malformed. PR tier only -
|
|
164
|
+
# main branch doesn't track branch_refs (rewrites not expected on
|
|
165
|
+
# the default branch).
|
|
166
|
+
def branch_refs(branch_name)
|
|
167
|
+
return {} if blank?(branch_name)
|
|
168
|
+
|
|
169
|
+
local_tmp = File.join(@cache_path, ".branch_refs_download_#{Process.pid}.json")
|
|
170
|
+
FileUtils.mkdir_p(@cache_path)
|
|
171
|
+
|
|
172
|
+
ok, = aws_cp_silent(s3_url(s3_branch_refs_key(branch_name)), local_tmp)
|
|
173
|
+
return {} unless ok
|
|
174
|
+
|
|
175
|
+
parsed = JSON.parse(File.read(local_tmp, encoding: ENCODING))
|
|
176
|
+
parsed.is_a?(Hash) ? parsed.transform_values(&:to_i) : {}
|
|
177
|
+
rescue StandardError => e
|
|
178
|
+
log_debug("branch_refs read failed (#{e.class}: #{e.message}); treating as empty")
|
|
179
|
+
{}
|
|
180
|
+
ensure
|
|
181
|
+
FileUtils.rm_f(local_tmp) if defined?(local_tmp) && local_tmp
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Persist branch_refs for the given branch. No-op for main-branch
|
|
185
|
+
# writes (main-branch doesn't use branch_refs). Raises on wire
|
|
186
|
+
# failure for PR tier.
|
|
187
|
+
def write_branch_refs(branch_name, refs)
|
|
188
|
+
return if blank?(branch_name)
|
|
189
|
+
return if branch_name.to_s.chomp == @default_branch
|
|
190
|
+
return if refs.nil? || refs.empty?
|
|
191
|
+
|
|
192
|
+
FileUtils.mkdir_p(@cache_path)
|
|
193
|
+
local_tmp = File.join(@cache_path, ".branch_refs_upload_#{Process.pid}.json")
|
|
194
|
+
File.write(local_tmp, JSON.pretty_generate(refs), encoding: ENCODING)
|
|
195
|
+
|
|
196
|
+
ok, _stdout, stderr = aws_cp_silent(local_tmp, s3_url(s3_branch_refs_key(branch_name)))
|
|
197
|
+
raise S3BackendError, "Failed to upload branch_refs for #{branch_name}: #{stderr.chomp}" unless ok
|
|
198
|
+
|
|
199
|
+
log_debug("wrote branch_refs for #{branch_name}")
|
|
200
|
+
ensure
|
|
201
|
+
FileUtils.rm_f(local_tmp) if defined?(local_tmp) && local_tmp
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Apply retention policy to the backend's own tier. Returns the
|
|
205
|
+
# number of refs removed. Never raises on a partial failure; logs
|
|
206
|
+
# and returns the count it managed to delete.
|
|
207
|
+
#
|
|
208
|
+
# Semantics:
|
|
209
|
+
# - count N: keep newest N refs, delete older.
|
|
210
|
+
# - duration_seconds X: delete refs whose last_run.json is
|
|
211
|
+
# older than X seconds.
|
|
212
|
+
# - pr_branch_ttl_seconds X: (PR tier only) if the backend's
|
|
213
|
+
# branch has no ref newer than X seconds, delete the entire
|
|
214
|
+
# pr/<branch>/ prefix (branch_refs.json included).
|
|
215
|
+
#
|
|
216
|
+
# Two or more parameters may be set; each applies independently.
|
|
217
|
+
# All nil/0 => no-op.
|
|
218
|
+
def prune!(count: nil, duration_seconds: nil, pr_branch_ttl_seconds: nil)
|
|
219
|
+
removed = 0
|
|
220
|
+
removed += prune_by_count!(count) if count&.positive?
|
|
221
|
+
removed += prune_by_duration!(duration_seconds) if duration_seconds&.positive?
|
|
222
|
+
removed += prune_dead_pr_branch!(pr_branch_ttl_seconds) if pr_tier? && pr_branch_ttl_seconds&.positive?
|
|
223
|
+
removed
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Cross-tier PR-branch cleanup. Enumerates every PR branch under
|
|
227
|
+
# the configured prefix by listing the `pr/` subtree, applies the
|
|
228
|
+
# TTL to each branch, deletes dead branches whole. Returns total
|
|
229
|
+
# refs removed. No-op on nil / non-positive TTL. Never raises
|
|
230
|
+
# (graceful-degradation contract).
|
|
231
|
+
def prune_all!(pr_branch_ttl_seconds: nil)
|
|
232
|
+
return 0 unless pr_branch_ttl_seconds&.positive?
|
|
233
|
+
|
|
234
|
+
cutoff = Time.now.to_i - pr_branch_ttl_seconds.to_i
|
|
235
|
+
branches = discover_pr_branches
|
|
236
|
+
branches.sum { |branch| maybe_prune_branch(branch, cutoff) }
|
|
237
|
+
rescue StandardError => e
|
|
238
|
+
log_warn("prune_all! failed (#{e.class}: #{e.message})")
|
|
239
|
+
0
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Check whether the backend's own tier has accumulated more than
|
|
243
|
+
# `warn_threshold` refs without retention configured. Callable
|
|
244
|
+
# from orchestrator for the "S3 growing unbounded" diagnostic.
|
|
245
|
+
def unbounded_warning(warn_threshold: 500)
|
|
246
|
+
refs = list_own_tier_refs
|
|
247
|
+
return nil unless refs.length > warn_threshold
|
|
248
|
+
|
|
249
|
+
"rspec-tracer remote cache has #{refs.length} refs in #{own_tier_prefix}; " \
|
|
250
|
+
'configure cache_retention_count or cache_retention_duration to cap growth'
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
# Internal method on the tracer pipeline.
|
|
256
|
+
# @api private
|
|
257
|
+
def blank?(value)
|
|
258
|
+
value.nil? || value.to_s.empty?
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Non-regex trailing-slash strip. The literal `/+\z` pattern trips
|
|
262
|
+
# CodeQL's `rb/polynomial-redos` heuristic because quantifier-on-
|
|
263
|
+
# library-input is a conservative-fail signal; the pattern is
|
|
264
|
+
# backtracking-safe in practice, but String#chop in a loop is
|
|
265
|
+
# both obviously safe and faster on short inputs.
|
|
266
|
+
def trim_trailing_slashes(str)
|
|
267
|
+
value = str.dup
|
|
268
|
+
value.chop! while value.end_with?('/')
|
|
269
|
+
value
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Internal method on the tracer pipeline.
|
|
273
|
+
# @api private
|
|
274
|
+
def validate_required!(**opts)
|
|
275
|
+
opts.each do |key, value|
|
|
276
|
+
raise S3BackendError, "#{key} is required" if blank?(value)
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Internal method on the tracer pipeline.
|
|
281
|
+
# @api private
|
|
282
|
+
def normalize_test_suite_id(raw)
|
|
283
|
+
return nil if raw.nil?
|
|
284
|
+
|
|
285
|
+
value = raw.to_s
|
|
286
|
+
value.empty? ? nil : value
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# -- Tier + key composition -------------------------
|
|
290
|
+
|
|
291
|
+
def pr_tier?
|
|
292
|
+
@branch != @default_branch
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Internal method on the tracer pipeline.
|
|
296
|
+
# @api private
|
|
297
|
+
def own_tier_prefix
|
|
298
|
+
pr_tier? ? "#{PR_TIER}/#{@branch}" : MAIN_TIER
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Internal method on the tracer pipeline.
|
|
302
|
+
# @api private
|
|
303
|
+
def main_tier_prefix
|
|
304
|
+
MAIN_TIER
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# Internal method on the tracer pipeline.
|
|
308
|
+
# @api private
|
|
309
|
+
def s3_url(key)
|
|
310
|
+
"s3://#{@bucket}/#{key}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Internal method on the tracer pipeline.
|
|
314
|
+
# @api private
|
|
315
|
+
def s3_archive_key(tier_prefix, ref)
|
|
316
|
+
join_key(@prefix, tier_prefix, ref, @test_suite_id, CACHE_ARCHIVE_FILENAME)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Internal method on the tracer pipeline.
|
|
320
|
+
# @api private
|
|
321
|
+
def s3_tree_pointer_key(tier_prefix, tree_sha)
|
|
322
|
+
join_key(@prefix, tier_prefix, 'by_tree', tree_sha)
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Internal method on the tracer pipeline.
|
|
326
|
+
# @api private
|
|
327
|
+
def s3_branch_refs_key(branch_name)
|
|
328
|
+
join_key(@prefix, PR_TIER, branch_name.chomp, BRANCH_REFS_FILENAME)
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
# Internal method on the tracer pipeline.
|
|
332
|
+
# @api private
|
|
333
|
+
def s3_ref_prefix_url(tier_prefix, ref)
|
|
334
|
+
"#{s3_url(join_key(@prefix, tier_prefix, ref))}/"
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Internal method on the tracer pipeline.
|
|
338
|
+
# @api private
|
|
339
|
+
def s3_tier_prefix_url(tier_prefix)
|
|
340
|
+
"#{s3_url(join_key(@prefix, tier_prefix))}/"
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
# Internal method on the tracer pipeline.
|
|
344
|
+
# @api private
|
|
345
|
+
def join_key(*segments)
|
|
346
|
+
segments.compact.reject { |s| s.to_s.empty? }.join('/')
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# -- Local-side paths -------------------------------
|
|
350
|
+
|
|
351
|
+
def local_last_run_path
|
|
352
|
+
File.join(@cache_path, LAST_RUN_FILENAME)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
# Internal method on the tracer pipeline.
|
|
356
|
+
# @api private
|
|
357
|
+
def local_run_dir(run_id)
|
|
358
|
+
File.join(@cache_path, run_id)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Internal method on the tracer pipeline.
|
|
362
|
+
# @api private
|
|
363
|
+
def read_local_run_id
|
|
364
|
+
return nil unless File.file?(local_last_run_path)
|
|
365
|
+
|
|
366
|
+
manifest = JSON.parse(File.read(local_last_run_path, encoding: ENCODING))
|
|
367
|
+
return nil unless manifest.is_a?(Hash)
|
|
368
|
+
|
|
369
|
+
run_id = manifest['run_id']
|
|
370
|
+
return nil if run_id.nil? || run_id.to_s.empty?
|
|
371
|
+
|
|
372
|
+
run_id
|
|
373
|
+
rescue StandardError
|
|
374
|
+
nil
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# -- Tree-SHA secondary index -----------------------
|
|
378
|
+
|
|
379
|
+
# Build the list of (tier_prefix, ref) pairs to try, in
|
|
380
|
+
# priority order:
|
|
381
|
+
# 1. Tree-pointer-resolved ref on own tier (rebase hit)
|
|
382
|
+
# 2. Tree-pointer-resolved ref on main tier (PR backends only)
|
|
383
|
+
# 3. Direct commit ref on own tier (standard path)
|
|
384
|
+
# 4. Direct commit ref on main tier (PR backends only)
|
|
385
|
+
# Tree-pointer attempts are only added when (a) tree_sha is
|
|
386
|
+
# given AND (b) the pointer file resolves to a non-empty ref.
|
|
387
|
+
def build_download_attempts(ref, tree_sha)
|
|
388
|
+
attempts = []
|
|
389
|
+
tree_resolved = resolve_tree_pointer(tree_sha)
|
|
390
|
+
if tree_resolved
|
|
391
|
+
attempts << [own_tier_prefix, tree_resolved]
|
|
392
|
+
attempts << [main_tier_prefix, tree_resolved] if pr_tier?
|
|
393
|
+
end
|
|
394
|
+
attempts << [own_tier_prefix, ref]
|
|
395
|
+
attempts << [main_tier_prefix, ref] if pr_tier?
|
|
396
|
+
attempts
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Read the tree pointer for `tree_sha` from the backend's own
|
|
400
|
+
# tier. Returns the resolved commit-SHA on hit, nil on miss /
|
|
401
|
+
# malformed content / wire error. Only the own-tier pointer is
|
|
402
|
+
# consulted - main-tier tree pointers from a parallel upload
|
|
403
|
+
# are not authoritative for a PR backend.
|
|
404
|
+
def resolve_tree_pointer(tree_sha)
|
|
405
|
+
return nil if blank?(tree_sha)
|
|
406
|
+
|
|
407
|
+
local_tmp = File.join(@cache_path, ".tree_pointer_download_#{Process.pid}_#{SecureRandom.hex(4)}.txt")
|
|
408
|
+
FileUtils.mkdir_p(@cache_path)
|
|
409
|
+
|
|
410
|
+
ok, = aws_cp_silent(s3_url(s3_tree_pointer_key(own_tier_prefix, tree_sha)), local_tmp)
|
|
411
|
+
return nil unless ok
|
|
412
|
+
|
|
413
|
+
resolved = File.read(local_tmp, encoding: ENCODING).strip
|
|
414
|
+
resolved.empty? ? nil : resolved
|
|
415
|
+
rescue StandardError => e
|
|
416
|
+
log_debug("tree pointer read failed (#{e.class}: #{e.message}); falling through")
|
|
417
|
+
nil
|
|
418
|
+
ensure
|
|
419
|
+
FileUtils.rm_f(local_tmp) if defined?(local_tmp) && local_tmp
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Internal method on the tracer pipeline.
|
|
423
|
+
# @api private
|
|
424
|
+
def upload_tree_pointer(ref, tree_sha)
|
|
425
|
+
pointer_path = File.join(@cache_path, ".tree_pointer_upload_#{Process.pid}_#{SecureRandom.hex(4)}.txt")
|
|
426
|
+
File.write(pointer_path, ref.to_s, encoding: ENCODING)
|
|
427
|
+
upload_file(pointer_path, s3_tree_pointer_key(own_tier_prefix, tree_sha))
|
|
428
|
+
log_debug("wrote tree pointer #{tree_sha} -> #{ref} on #{own_tier_prefix}")
|
|
429
|
+
ensure
|
|
430
|
+
FileUtils.rm_f(pointer_path) if defined?(pointer_path) && pointer_path
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# -- Download flow ----------------------------------
|
|
434
|
+
|
|
435
|
+
# Download the archive for (tier, ref), extract into cache_path,
|
|
436
|
+
# validate the resulting last_run.json. Returns true on validated
|
|
437
|
+
# success, false otherwise; rolls back extracted files on failure
|
|
438
|
+
# so a later reader never sees a half-landed cache. Action-style
|
|
439
|
+
# method (writes files + cleans up), not a predicate.
|
|
440
|
+
# rubocop:disable Naming/PredicateMethod
|
|
441
|
+
def try_download_from(tier_prefix, ref)
|
|
442
|
+
archive_path = tmp_archive_path('download')
|
|
443
|
+
ok, = aws_cp_silent(s3_url(s3_archive_key(tier_prefix, ref)), archive_path)
|
|
444
|
+
return false unless ok
|
|
445
|
+
|
|
446
|
+
extract_and_validate(archive_path, tier_prefix, ref)
|
|
447
|
+
ensure
|
|
448
|
+
FileUtils.rm_f(archive_path) if defined?(archive_path) && archive_path
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Internal method on the tracer pipeline.
|
|
452
|
+
# @api private
|
|
453
|
+
def extract_and_validate(archive_path, tier_prefix, ref)
|
|
454
|
+
begin
|
|
455
|
+
Archive.extract(archive_path: archive_path, dest_dir: @cache_path)
|
|
456
|
+
rescue StandardError => e
|
|
457
|
+
log_debug("extract failed for #{tier_prefix}/#{ref}: #{e.class}: #{e.message}")
|
|
458
|
+
rollback_extracted_cache
|
|
459
|
+
return false
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
return true if Validator.valid_file?(local_last_run_path)
|
|
463
|
+
|
|
464
|
+
log_debug("rejected #{tier_prefix}/#{ref}: schema_version mismatch")
|
|
465
|
+
rollback_extracted_cache
|
|
466
|
+
false
|
|
467
|
+
end
|
|
468
|
+
# rubocop:enable Naming/PredicateMethod
|
|
469
|
+
|
|
470
|
+
def rollback_extracted_cache
|
|
471
|
+
run_id = read_local_run_id
|
|
472
|
+
FileUtils.rm_f(local_last_run_path)
|
|
473
|
+
FileUtils.rm_rf(local_run_dir(run_id)) if run_id
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
# Internal method on the tracer pipeline.
|
|
477
|
+
# @api private
|
|
478
|
+
def tmp_archive_path(purpose)
|
|
479
|
+
FileUtils.mkdir_p(@cache_path)
|
|
480
|
+
File.join(@cache_path, ".cache_#{purpose}_#{Process.pid}_#{SecureRandom.hex(4)}.tar.gz")
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# -- Upload flow ------------------------------------
|
|
484
|
+
|
|
485
|
+
def upload_file(local_path, s3_key)
|
|
486
|
+
ok, _stdout, stderr = aws_cp_silent(local_path, s3_url(s3_key))
|
|
487
|
+
raise S3BackendError, "Failed to upload #{local_path}: #{stderr.chomp}" unless ok
|
|
488
|
+
end
|
|
489
|
+
|
|
490
|
+
# -- Retention --------------------------------------
|
|
491
|
+
|
|
492
|
+
# List refs under the backend's own tier with their last_run.json
|
|
493
|
+
# LastModified. Returns Array<[ref, epoch_timestamp]>, newest first.
|
|
494
|
+
def list_own_tier_refs
|
|
495
|
+
list_refs_in_tier(own_tier_prefix)
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
# Internal method on the tracer pipeline.
|
|
499
|
+
# @api private
|
|
500
|
+
def list_refs_in_tier(tier_prefix)
|
|
501
|
+
entries = list_objects(join_key(@prefix, tier_prefix))
|
|
502
|
+
return [] if entries.empty?
|
|
503
|
+
|
|
504
|
+
refs = {}
|
|
505
|
+
entries.each do |entry|
|
|
506
|
+
key = entry['Key']
|
|
507
|
+
next unless key.end_with?("/#{CACHE_ARCHIVE_FILENAME}")
|
|
508
|
+
|
|
509
|
+
ref = extract_ref_from_archive_key(key, tier_prefix)
|
|
510
|
+
next if ref.nil?
|
|
511
|
+
|
|
512
|
+
ts = parse_s3_timestamp(entry['LastModified'])
|
|
513
|
+
existing = refs[ref]
|
|
514
|
+
refs[ref] = ts if existing.nil? || ts > existing
|
|
515
|
+
end
|
|
516
|
+
refs.sort_by { |_, ts| -ts }
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Keys look like: <prefix>/<tier_prefix>/<ref>/[<test_suite_id>/]cache.tar.gz
|
|
520
|
+
# We want <ref>.
|
|
521
|
+
def extract_ref_from_archive_key(key, tier_prefix)
|
|
522
|
+
tier_head = "#{join_key(@prefix, tier_prefix)}/"
|
|
523
|
+
return nil unless key.start_with?(tier_head)
|
|
524
|
+
|
|
525
|
+
remainder = key[tier_head.length..]
|
|
526
|
+
segments = remainder.split('/')
|
|
527
|
+
return nil if segments.length < 2
|
|
528
|
+
|
|
529
|
+
segments.first
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Internal method on the tracer pipeline.
|
|
533
|
+
# @api private
|
|
534
|
+
def prune_by_count!(count)
|
|
535
|
+
refs = list_own_tier_refs
|
|
536
|
+
return 0 if refs.length <= count
|
|
537
|
+
|
|
538
|
+
to_delete = refs[count..] || []
|
|
539
|
+
delete_refs(to_delete.map(&:first))
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# Internal method on the tracer pipeline.
|
|
543
|
+
# @api private
|
|
544
|
+
def prune_by_duration!(duration_seconds)
|
|
545
|
+
cutoff = Time.now.to_i - duration_seconds.to_i
|
|
546
|
+
stale = list_own_tier_refs.select { |_, ts| ts < cutoff }.map(&:first)
|
|
547
|
+
delete_refs(stale)
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
# Internal method on the tracer pipeline.
|
|
551
|
+
# @api private
|
|
552
|
+
def delete_refs(refs)
|
|
553
|
+
removed = 0
|
|
554
|
+
refs.each do |ref|
|
|
555
|
+
ok, _stdout, stderr = aws_rm_recursive_silent(s3_ref_prefix_url(own_tier_prefix, ref))
|
|
556
|
+
if ok
|
|
557
|
+
removed += 1
|
|
558
|
+
log_debug("pruned ref #{own_tier_prefix}/#{ref}")
|
|
559
|
+
else
|
|
560
|
+
log_warn("failed to prune ref #{own_tier_prefix}/#{ref}: #{stderr.chomp}")
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
removed
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Internal method on the tracer pipeline.
|
|
567
|
+
# @api private
|
|
568
|
+
def prune_dead_pr_branch!(ttl_seconds)
|
|
569
|
+
refs = list_own_tier_refs
|
|
570
|
+
return 0 if refs.empty?
|
|
571
|
+
|
|
572
|
+
newest_ts = refs.first[1]
|
|
573
|
+
return 0 if newest_ts >= Time.now.to_i - ttl_seconds.to_i
|
|
574
|
+
|
|
575
|
+
delete_branch_prefix(own_tier_prefix, refs.length)
|
|
576
|
+
end
|
|
577
|
+
|
|
578
|
+
# Delete every object under `<prefix>/<tier_prefix>/` (cache refs
|
|
579
|
+
# + branch_refs.json). Returns the supplied `ref_count` on
|
|
580
|
+
# success, 0 on failure.
|
|
581
|
+
def delete_branch_prefix(tier_prefix, ref_count)
|
|
582
|
+
ok, _stdout, stderr = aws_rm_recursive_silent(s3_tier_prefix_url(tier_prefix))
|
|
583
|
+
if ok
|
|
584
|
+
log_debug("pruned dead PR branch #{tier_prefix} (#{ref_count} refs)")
|
|
585
|
+
ref_count
|
|
586
|
+
else
|
|
587
|
+
log_warn("failed to prune dead PR branch #{tier_prefix}: #{stderr.chomp}")
|
|
588
|
+
0
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Enumerate PR branches under the configured prefix. Returns an
|
|
593
|
+
# Array<String> of branch names (one per unique `pr/<branch>/`
|
|
594
|
+
# segment). Uses `list-objects-v2 --prefix pr/ --delimiter /` so
|
|
595
|
+
# we pay for one bucket listing instead of walking every object.
|
|
596
|
+
def discover_pr_branches
|
|
597
|
+
prefix_head = "#{join_key(@prefix, PR_TIER)}/"
|
|
598
|
+
common_prefixes = list_common_prefixes(prefix_head)
|
|
599
|
+
return [] if common_prefixes.empty?
|
|
600
|
+
|
|
601
|
+
branches = Set.new
|
|
602
|
+
common_prefixes.each do |entry|
|
|
603
|
+
value = entry['Prefix']
|
|
604
|
+
next if value.nil? || !value.start_with?(prefix_head)
|
|
605
|
+
|
|
606
|
+
branch = value[prefix_head.length..].delete_suffix('/')
|
|
607
|
+
branches << branch unless branch.empty?
|
|
608
|
+
end
|
|
609
|
+
branches.to_a
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
# Internal method on the tracer pipeline.
|
|
613
|
+
# @api private
|
|
614
|
+
def list_common_prefixes(prefix)
|
|
615
|
+
stdout, stderr, status = Open3.capture3(
|
|
616
|
+
@cli_binary, 's3api', 'list-objects-v2',
|
|
617
|
+
'--bucket', @bucket,
|
|
618
|
+
'--prefix', prefix,
|
|
619
|
+
'--delimiter', '/',
|
|
620
|
+
'--output', 'json'
|
|
621
|
+
)
|
|
622
|
+
unless status.success?
|
|
623
|
+
log_debug("list-objects-v2 (delimited) #{prefix} failed: #{stderr.chomp}")
|
|
624
|
+
return []
|
|
625
|
+
end
|
|
626
|
+
return [] if stdout.strip.empty?
|
|
627
|
+
|
|
628
|
+
parsed = JSON.parse(stdout)
|
|
629
|
+
Array(parsed['CommonPrefixes'])
|
|
630
|
+
rescue StandardError => e
|
|
631
|
+
log_debug("list-objects-v2 (delimited) parse failed: #{e.class}: #{e.message}")
|
|
632
|
+
[]
|
|
633
|
+
end
|
|
634
|
+
|
|
635
|
+
# Apply the TTL to a single PR branch. Deletes whole branch when
|
|
636
|
+
# its newest ref is older than `cutoff`. Returns the count of
|
|
637
|
+
# refs removed (0 when branch is alive).
|
|
638
|
+
def maybe_prune_branch(branch_name, cutoff)
|
|
639
|
+
tier_prefix = "#{PR_TIER}/#{branch_name}"
|
|
640
|
+
refs = list_refs_in_tier(tier_prefix)
|
|
641
|
+
return 0 if refs.empty?
|
|
642
|
+
|
|
643
|
+
newest_ts = refs.first[1]
|
|
644
|
+
return 0 if newest_ts >= cutoff
|
|
645
|
+
|
|
646
|
+
delete_branch_prefix(tier_prefix, refs.length)
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
# Internal method on the tracer pipeline.
|
|
650
|
+
# @api private
|
|
651
|
+
def parse_s3_timestamp(iso_string)
|
|
652
|
+
Time.parse(iso_string).to_i
|
|
653
|
+
rescue StandardError
|
|
654
|
+
0
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# -- AWS CLI shell-out ------------------------------
|
|
658
|
+
|
|
659
|
+
def aws_cp_silent(src, dst)
|
|
660
|
+
run_aws('s3', 'cp', src, dst)
|
|
661
|
+
end
|
|
662
|
+
|
|
663
|
+
# Internal method on the tracer pipeline.
|
|
664
|
+
# @api private
|
|
665
|
+
def aws_rm_recursive_silent(dst)
|
|
666
|
+
run_aws('s3', 'rm', dst, '--recursive')
|
|
667
|
+
end
|
|
668
|
+
|
|
669
|
+
# Internal method on the tracer pipeline.
|
|
670
|
+
# @api private
|
|
671
|
+
def list_objects(prefix)
|
|
672
|
+
stdout, stderr, status = Open3.capture3(
|
|
673
|
+
@cli_binary, 's3api', 'list-objects-v2',
|
|
674
|
+
'--bucket', @bucket,
|
|
675
|
+
'--prefix', "#{prefix}/",
|
|
676
|
+
'--output', 'json'
|
|
677
|
+
)
|
|
678
|
+
unless status.success?
|
|
679
|
+
log_debug("list-objects-v2 #{prefix} failed: #{stderr.chomp}")
|
|
680
|
+
return []
|
|
681
|
+
end
|
|
682
|
+
return [] if stdout.strip.empty?
|
|
683
|
+
|
|
684
|
+
parsed = JSON.parse(stdout)
|
|
685
|
+
Array(parsed['Contents'])
|
|
686
|
+
rescue StandardError => e
|
|
687
|
+
log_debug("list-objects-v2 parse failed: #{e.class}: #{e.message}")
|
|
688
|
+
[]
|
|
689
|
+
end
|
|
690
|
+
|
|
691
|
+
# Internal method on the tracer pipeline.
|
|
692
|
+
# @api private
|
|
693
|
+
def run_aws(*args)
|
|
694
|
+
stdout, stderr, status = Open3.capture3(@cli_binary, *args)
|
|
695
|
+
[status.success?, stdout, stderr]
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
# -- Logging ----------------------------------------
|
|
699
|
+
|
|
700
|
+
def log_debug(message)
|
|
701
|
+
@logger&.debug("rspec-tracer remote_cache: #{message}")
|
|
702
|
+
end
|
|
703
|
+
|
|
704
|
+
# Internal method on the tracer pipeline.
|
|
705
|
+
# @api private
|
|
706
|
+
def log_warn(message)
|
|
707
|
+
@logger&.warn("rspec-tracer remote_cache: #{message}")
|
|
708
|
+
end
|
|
709
|
+
end
|
|
710
|
+
# rubocop:enable Metrics/ClassLength
|
|
711
|
+
end
|
|
712
|
+
end
|