DEV: test nginx sample config against Rails

This commit is contained in:
Jake Goldsborough
2026-08-28 17:22:23 +00:00
committed by ducks
parent 3eb9c7672a
commit ad4bf137e5
3 changed files with 231 additions and 0 deletions
@@ -0,0 +1,30 @@
# frozen_string_literal: true
RSpec.describe "Nginx sample proxy" do
before do
skip "nginx not found on PATH" unless NginxTestProxy.available?
rails_server = Capybara::Server.new(Capybara.app).boot
@proxy = NginxTestProxy.new(upstream_port: rails_server.port).start
SiteSetting.force_hostname = "127.0.0.1"
SiteSetting.port = @proxy.port
end
after { @proxy&.stop }
it "uses the sample config's special routes", :aggregate_failures do
expect(@proxy.get("/favicon.ico").code).to eq("204")
expect(@proxy.get("/backups/private.tar.gz").code).to eq("404")
access_log = @proxy.access_log
expect(@proxy.get("/srv/status").code).to eq("200")
expect(@proxy.access_log).to eq(access_log)
end
it "routes dynamic requests through the @discourse fallback", :aggregate_failures do
response = @proxy.get("/latest.json")
expect(response).to be_a(Net::HTTPSuccess)
expect(response["X-Nginx-Discourse-Fallback"]).to eq("true")
end
end
+28
View File
@@ -0,0 +1,28 @@
# nginx sample config tests
The integration spec at `spec/integration/nginx_sample_proxy_spec.rb` starts
nginx in front of a Capybara Rails server. This exercises
`config/nginx.sample.conf` against the real application without treating
nginx configuration behavior as a browser interaction.
## Running
```sh
bin/rspec spec/integration/nginx_sample_proxy_spec.rb
```
## Requirements
- `nginx` on `$PATH` for the integration spec.
- Standard nginx 1.x. The spec skips when nginx isn't installed. Set
`NGINX_BIN` to test with a specific executable.
## How it works
The helper rewrites only the deployment-specific ports and filesystem paths
into a temporary directory, then runs the real sample config as a subprocess.
Brotli directives are disabled because that optional module is not available
in every development nginx build.
The test also installs a temporary outlet inside `location @discourse`, so it
can verify that dynamic requests use the normal Rails fallback.
+173
View File
@@ -0,0 +1,173 @@
# frozen_string_literal: true
require "fileutils"
require "net/http"
require "socket"
require "tmpdir"
class NginxTestProxy
attr_reader :port
def self.available?
executable.present?
end
def self.executable
override = ENV["NGINX_BIN"]
return override if override.present? && File.executable?(override)
ENV
.fetch("PATH", "")
.split(File::PATH_SEPARATOR)
.map { |path| File.join(path, "nginx") }
.find { |path| File.file?(path) && File.executable?(path) }
end
def initialize(upstream_port:)
@upstream_port = upstream_port
end
def start
@tmpdir = Dir.mktmpdir("nginx-spec-")
@port = available_port
FileUtils.mkdir_p(File.join(@tmpdir, "cache"))
FileUtils.mkdir_p(File.join(@tmpdir, "outlets"))
FileUtils.mkdir_p(File.join(@tmpdir, "outlets", "discourse"))
File.write(
File.join(@tmpdir, "outlets", "discourse", "test.conf"),
"add_header X-Nginx-Discourse-Fallback true always;\n",
)
File.write(sample_config_path, test_config)
File.write(wrapper_config_path, wrapper_config)
@pid =
Process.spawn(
self.class.executable,
"-c",
wrapper_config_path,
"-p",
@tmpdir,
"-e",
File.join(@tmpdir, "startup-error.log"),
out: File.join(@tmpdir, "stdout.log"),
err: File.join(@tmpdir, "stderr.log"),
)
wait_until_ready
self
rescue StandardError
stop
raise
end
def stop
if @pid
Process.kill("TERM", @pid)
Process.wait(@pid)
end
rescue Errno::ECHILD, Errno::ESRCH
nil
ensure
@pid = nil
FileUtils.remove_entry(@tmpdir) if @tmpdir && File.exist?(@tmpdir)
@tmpdir = nil
end
def url
"http://127.0.0.1:#{port}"
end
def get(path)
Net::HTTP.get_response(URI("#{url}#{path}"))
end
def access_log
path = File.join(@tmpdir, "access.log")
File.exist?(path) ? File.read(path) : ""
end
private
def test_config
File
.read(Rails.root.join("config/nginx.sample.conf"))
.gsub("server 127.0.0.1:3000;", "server 127.0.0.1:#{@upstream_port};")
.gsub("listen 80;", "listen 127.0.0.1:#{port};")
.gsub("/var/nginx/cache", File.join(@tmpdir, "cache"))
.gsub("/var/log/nginx/access.log", File.join(@tmpdir, "access.log"))
.gsub("/var/log/nginx/error.log", File.join(@tmpdir, "error.log"))
.gsub("/var/www/discourse/public", Rails.root.join("frontend/discourse/dist").to_s)
.gsub("conf.d/outlets/", File.join(@tmpdir, "outlets/"))
.lines
.map { |line| line.match?(/^\s*brotli/) ? "# #{line}" : line }
.join
end
def wrapper_config
<<~NGINX
worker_processes 1;
daemon off;
error_log #{File.join(@tmpdir, "error.log")} warn;
pid #{File.join(@tmpdir, "nginx.pid")};
events {
worker_connections 256;
}
http {
include #{mime_types_path};
default_type application/octet-stream;
client_body_temp_path #{File.join(@tmpdir, "client_body_temp")};
proxy_temp_path #{File.join(@tmpdir, "proxy_temp")};
fastcgi_temp_path #{File.join(@tmpdir, "fastcgi_temp")};
uwsgi_temp_path #{File.join(@tmpdir, "uwsgi_temp")};
scgi_temp_path #{File.join(@tmpdir, "scgi_temp")};
include #{sample_config_path};
}
NGINX
end
def mime_types_path
executable = self.class.executable
candidates = [
ENV["NGINX_MIME_TYPES"],
executable && File.expand_path("../conf/mime.types", File.dirname(executable)),
"/etc/nginx/mime.types",
]
candidates.compact.find { |path| File.file?(path) } || raise("nginx mime.types not found")
end
def wait_until_ready
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
loop do
TCPSocket.new("127.0.0.1", port).close
return
rescue Errno::ECONNREFUSED, Errno::EADDRNOTAVAIL
raise nginx_error if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
sleep 0.05
end
end
def nginx_error
logs =
%w[stderr.log stdout.log error.log].filter_map do |filename|
path = File.join(@tmpdir, filename)
"--- #{filename} ---\n#{File.read(path)}" if File.exist?(path)
end
"nginx did not start on port #{port}\n#{logs.join("\n")}"
end
def available_port
TCPServer.open("127.0.0.1", 0) { |server| server.addr[1] }
end
def sample_config_path
File.join(@tmpdir, "discourse.conf")
end
def wrapper_config_path
File.join(@tmpdir, "nginx.conf")
end
end