rails-profiler 0.30.9 → 0.30.11

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -0
  3. data/app/controllers/profiler/api/cluster_controller.rb +37 -1
  4. data/app/controllers/profiler/api/env_vars_controller.rb +6 -0
  5. data/app/controllers/profiler/api/slave_proxy_controller.rb +4 -2
  6. data/app/controllers/profiler/api/test_runner_controller.rb +3 -9
  7. data/app/controllers/profiler/application_controller.rb +12 -1
  8. data/app/controllers/profiler/profiles_controller.rb +2 -0
  9. data/config/routes.rb +15 -8
  10. data/lib/profiler/boot_env.rb +8 -0
  11. data/lib/profiler/cluster/master_client.rb +24 -6
  12. data/lib/profiler/cluster/security.rb +179 -0
  13. data/lib/profiler/cluster/slave_proxy.rb +24 -1
  14. data/lib/profiler/collectors/base_collector.rb +2 -0
  15. data/lib/profiler/collectors/console_collector.rb +2 -2
  16. data/lib/profiler/collectors/flamegraph_collector.rb +2 -2
  17. data/lib/profiler/collectors/i18n_collector.rb +1 -1
  18. data/lib/profiler/collectors/mailer_collector.rb +5 -5
  19. data/lib/profiler/configuration.rb +25 -2
  20. data/lib/profiler/console_profiler.rb +1 -1
  21. data/lib/profiler/env_override_store.rb +11 -0
  22. data/lib/profiler/instrumentation/net_http_instrumentation.rb +2 -1
  23. data/lib/profiler/job_profiler.rb +2 -2
  24. data/lib/profiler/mcp/http_guard.rb +73 -0
  25. data/lib/profiler/mcp/server.rb +5 -2
  26. data/lib/profiler/mcp/tools/delete_env_var.rb +7 -0
  27. data/lib/profiler/mcp/tools/run_tests.rb +7 -1
  28. data/lib/profiler/mcp/tools/set_env_var.rb +7 -0
  29. data/lib/profiler/models/profile.rb +14 -4
  30. data/lib/profiler/railtie.rb +5 -1
  31. data/lib/profiler/redaction.rb +98 -0
  32. data/lib/profiler/test_runner/discovery.rb +2 -2
  33. data/lib/profiler/test_runner/run_store.rb +39 -2
  34. data/lib/profiler/test_runner/runner.rb +144 -10
  35. data/lib/profiler/version.rb +1 -1
  36. data/lib/profiler.rb +2 -0
  37. metadata +6 -3
@@ -1,12 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "set"
4
+ require_relative "../boot_env"
5
+ require_relative "../env_override_store"
3
6
  require_relative "run_store"
4
7
  require_relative "discovery"
5
8
 
6
9
  module Profiler
7
10
  module TestRunner
11
+ # Raised by Runner.start when the selection holds a file that is not a discovered test.
12
+ class InvalidFileError < ArgumentError; end
13
+
8
14
  class Runner
15
+ # "spec/models/user_spec.rb:12" or ":12:30": the line selection rspec and minitest accept.
16
+ LINE_SUFFIX = /\A(.+?)((?::\d+)+)\z/
17
+
18
+ @warn_mutex = Mutex.new
19
+ @warned = {}
20
+
21
+ # Every caller (the HTTP controller, the MCP run_tests tool) goes through here, so the
22
+ # selection is checked once, before any run is recorded or any process is started.
9
23
  def self.start(files:, framework:)
24
+ validate_files!(files, framework)
10
25
  run = Profiler::TestRunner.run_store.create(files: files, framework: framework)
11
26
  spawn_async(run)
12
27
  run
@@ -26,6 +41,77 @@ module Profiler
26
41
  end
27
42
  end
28
43
 
44
+ # Accepts only files listed by Discovery.files for the framework, compared by their
45
+ # canonical path (File.realpath, symbolic links resolved), so that a test run cannot be
46
+ # used to execute any other Ruby file of the application.
47
+ def self.validate_files!(files, framework)
48
+ files = Array(files)
49
+ raise InvalidFileError, "No files selected" if files.empty?
50
+
51
+ root = project_root
52
+ allowed = if Profiler.configuration.test_runner_allow_undiscovered_files
53
+ warn_once(:undiscovered,
54
+ "[Profiler] config.test_runner_allow_undiscovered_files is enabled: the test runner " \
55
+ "runs any file under the Rails root, not only the discovered tests.")
56
+ nil
57
+ else
58
+ discovered_real_paths(root, framework)
59
+ end
60
+
61
+ refused = files.reject do |entry|
62
+ next false unless entry.is_a?(String) && !entry.empty? && !entry.include?("\0")
63
+
64
+ path = entry[LINE_SUFFIX, 1] || entry
65
+ allowed ? allowed.include?(real_path(File.join(root, path))) : under_root?(root, File.join(root, path))
66
+ end
67
+ return if refused.empty?
68
+
69
+ message = allowed ? "Not a discovered test file" : "Not a file under the Rails root"
70
+ raise InvalidFileError, "#{message}: #{refused.map(&:to_s).join(", ")}"
71
+ end
72
+
73
+ def self.discovered_real_paths(root, framework)
74
+ real_root = real_path(root)
75
+ Discovery.files(framework: framework).flat_map { |dir| dir[:files] }.filter_map do |file|
76
+ path = real_path(File.join(root, file[:path]))
77
+ # A discovered symbolic link whose target leaves the project is not a project test.
78
+ path if path && real_root && path.start_with?("#{real_root}/")
79
+ end.to_set
80
+ end
81
+
82
+ def self.real_path(path)
83
+ File.realpath(path)
84
+ rescue SystemCallError, ArgumentError
85
+ nil
86
+ end
87
+
88
+ def self.under_root?(root, path)
89
+ expanded = File.expand_path(path)
90
+ expanded == root || expanded.start_with?("#{root}/")
91
+ end
92
+
93
+ def self.project_root
94
+ defined?(Rails) ? Rails.root.to_s : Dir.pwd
95
+ end
96
+
97
+ def self.warn_once(key, message)
98
+ @warn_mutex.synchronize do
99
+ return if @warned[key]
100
+
101
+ @warned[key] = true
102
+ end
103
+
104
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
105
+ Rails.logger.warn(message)
106
+ else
107
+ warn(message)
108
+ end
109
+ end
110
+
111
+ def self.reset_warnings!
112
+ @warn_mutex.synchronize { @warned = {} }
113
+ end
114
+
29
115
  private
30
116
 
31
117
  def self.spawn_async(run)
@@ -38,6 +124,7 @@ module Profiler
38
124
  finished_at: Time.now
39
125
  )
40
126
  Profiler::TestRunner.run_store.append_output(run.id, "\n[Profiler] Error: #{e.message}\n")
127
+ Profiler::TestRunner.run_store.finish_output(run.id)
41
128
  end
42
129
  end
43
130
 
@@ -47,13 +134,19 @@ module Profiler
47
134
 
48
135
  Profiler::TestRunner.run_store.update(run.id, status: "running", started_at: Time.now)
49
136
 
50
- IO.popen([env, *cmd, err: [:child, :out]], "r") do |io|
137
+ # unsetenv_others: the child gets exactly env, not the ENV of this process
138
+ IO.popen([env, *cmd, unsetenv_others: true, err: [:child, :out]], "r") do |io|
51
139
  Profiler::TestRunner.run_store.update(run.id, pid: io.pid)
52
140
 
53
- while (chunk = io.read(256))
54
- break if chunk.empty?
141
+ begin
142
+ while (chunk = io.read(256))
143
+ break if chunk.empty?
55
144
 
56
- Profiler::TestRunner.run_store.append_output(run.id, chunk)
145
+ Profiler::TestRunner.run_store.append_output(run.id, chunk)
146
+ end
147
+ ensure
148
+ # The process printed everything, killed or not: what was held back can be shown.
149
+ Profiler::TestRunner.run_store.finish_output(run.id)
57
150
  end
58
151
  end
59
152
 
@@ -69,7 +162,7 @@ module Profiler
69
162
  end
70
163
 
71
164
  def self.build_command(files, framework)
72
- root = defined?(Rails) ? Rails.root.to_s : Dir.pwd
165
+ root = project_root
73
166
  absolute_files = files.map { |f| File.join(root, f) }
74
167
 
75
168
  case framework.to_sym
@@ -82,22 +175,63 @@ module Profiler
82
175
  end
83
176
  end
84
177
 
85
- BLOCKED_ENV_KEYS = %w[RAILS_ENV RACK_ENV DATABASE_URL SECRET_KEY_BASE].freeze
178
+ BLOCKED_ENV_KEYS = %w[RAILS_ENV RACK_ENV DATABASE_URL SECRET_KEY_BASE PROFILER_TEST_RUNNER_CHILD].freeze
179
+
180
+ # Overrides of these variables make the test process, or the shell shims (rbenv, asdf) that
181
+ # start it, load or run code other than the selected tests. It is a deny list, so it cannot
182
+ # be complete; a variable needed by the tests can be set in the shell that starts Rails.
183
+ CODE_LOADING_ENV_KEYS = %w[
184
+ SPEC_OPTS TESTOPTS TEST PATH HOME XDG_CONFIG_HOME GEMRC NODE_OPTIONS NODE_PATH
185
+ SHELLOPTS BASHOPTS PS4 ENV CDPATH IFS
186
+ ].freeze
187
+ CODE_LOADING_ENV_PREFIXES = %w[
188
+ RUBY GEM_ BUNDLE_ BUNDLER_ LD_ DYLD_ BASH_ RBENV_ ASDF_ RVM_ CHRUBY GIT_ BOOTSNAP_
189
+ PYTHON PERL5
190
+ ].freeze
191
+ ENV_NAME = /\A[A-Z_][A-Z0-9_]*\z/i
192
+
193
+ def self.code_loading_env_key?(key)
194
+ name = key.to_s.upcase
195
+ return true unless ENV_NAME.match?(name)
196
+
197
+ CODE_LOADING_ENV_KEYS.include?(name) || CODE_LOADING_ENV_PREFIXES.any? { |prefix| name.start_with?(prefix) }
198
+ end
86
199
 
200
+ # Starts from the copy of ENV taken when the gem was loaded, not from ENV: the overrides are
201
+ # also written into ENV (by the env vars endpoint, the MCP tools and EnvOverrideStore#apply!),
202
+ # and one whose entry the store lost would pass unnoticed. Only the overrides read from the
203
+ # store and admitted are applied on top; a left-out or blocked key keeps its shell value.
87
204
  def self.build_env
88
- base = ENV.to_h
205
+ base = Profiler::BOOT_ENV.dup
206
+ left_out = []
89
207
 
90
- # Inject env var overrides configured in the profiler — skip blocked keys
91
208
  overrides = Profiler.env_override_store.all_overrides
92
209
  overrides.each do |key, entry|
93
- next if BLOCKED_ENV_KEYS.include?(key.upcase)
210
+ blocked = BLOCKED_ENV_KEYS.include?(key.upcase)
211
+ if blocked || code_loading_env_key?(key)
212
+ left_out << key unless blocked
213
+ next
214
+ end
94
215
  value = entry.is_a?(Hash) ? entry["value"] : entry
95
- base[key] = value
216
+ if value == EnvOverrideStore::DELETED_SENTINEL
217
+ base.delete(key)
218
+ else
219
+ base[key] = value
220
+ end
221
+ end
222
+
223
+ unless left_out.empty?
224
+ warn_once([:env, left_out.sort],
225
+ "[Profiler] Environment overrides not passed to the test runner, because they make it " \
226
+ "load other code: #{left_out.sort.join(", ")}.")
96
227
  end
97
228
 
98
229
  # Ensure test environment regardless of overrides
99
230
  base["RAILS_ENV"] = "test"
100
231
  base["RACK_ENV"] = "test"
232
+ # The test process boots the same application: its EnvOverrideStore#apply! must not
233
+ # replay the overrides left out above
234
+ base[EnvOverrideStore::TEST_RUNNER_CHILD_ENV] = "1"
101
235
 
102
236
  base
103
237
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Profiler
4
- VERSION = "0.30.9"
4
+ VERSION = "0.30.11"
5
5
  end
data/lib/profiler.rb CHANGED
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # First, so that the copy of ENV is taken before anything writes into it
4
+ require_relative "profiler/boot_env"
3
5
  require_relative "profiler/version"
4
6
  require_relative "profiler/configuration"
5
7
  require_relative "profiler/redaction"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-profiler
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.30.9
4
+ version: 0.30.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sébastien Duplessy
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - ">="
60
60
  - !ruby/object:Gem::Version
61
- version: '0'
61
+ version: '0.3'
62
62
  type: :runtime
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - ">="
67
67
  - !ruby/object:Gem::Version
68
- version: '0'
68
+ version: '0.3'
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: webmock
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -133,7 +133,9 @@ files:
133
133
  - config/routes.rb
134
134
  - exe/profiler-mcp
135
135
  - lib/profiler.rb
136
+ - lib/profiler/boot_env.rb
136
137
  - lib/profiler/cluster/master_client.rb
138
+ - lib/profiler/cluster/security.rb
137
139
  - lib/profiler/cluster/slave_proxy.rb
138
140
  - lib/profiler/cluster/slave_registry.rb
139
141
  - lib/profiler/cluster/token_cache.rb
@@ -171,6 +173,7 @@ files:
171
173
  - lib/profiler/local_request.rb
172
174
  - lib/profiler/mcp/body_formatter.rb
173
175
  - lib/profiler/mcp/file_cache.rb
176
+ - lib/profiler/mcp/http_guard.rb
174
177
  - lib/profiler/mcp/path_extractor.rb
175
178
  - lib/profiler/mcp/resources/failing_tests.rb
176
179
  - lib/profiler/mcp/resources/n1_patterns.rb