diff --git a/shared/test/showcase/test_helper.rb b/shared/test/showcase/test_helper.rb index dedea68e7..c2af83296 100644 --- a/shared/test/showcase/test_helper.rb +++ b/shared/test/showcase/test_helper.rb @@ -18,11 +18,86 @@ require "minitest/focus" require "fileutils" require "open3" +require "openssl" require "tmpdir" # @private GAPIC_SHOWCASE_VERSION = "0.44.0" +# @private +SHOWCASE_PORT = 7469 + +# @private +# File Showcase is asked to write its CA to. +SHOWCASE_CA_FILE = "ca.pem" + +# @private +# Blocks until Showcase writes the CA requested via --ca-cert-output-file. +# +# Showcase binds its port before generating TLS material, so the CA lands up to +# 3.2s later (median 1.4s over 40 boots) - too variable for a fixed sleep. The +# file is parsed, not merely tested for existence, since it is briefly visible +# mid-write. +# +# @param ca_path [String] +# @param timeout [Numeric] +# @return [String] ca_path, once it holds a usable certificate. +def wait_for_showcase_ca ca_path, timeout: 30 + deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout + begin + OpenSSL::X509::Certificate.new File.read ca_path + rescue Errno::ENOENT, OpenSSL::X509::CertificateError + if Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline + raise "showcase did not write a CA certificate to #{ca_path} within #{timeout}s" + end + sleep 0.05 + retry + end + ca_path +end + +# @private +# Starts a Showcase server over TLS and returns once it is usable. +# +# @param binary [String] Path to the gapic-showcase executable. +# @param port [Integer] +# @param ca_path [String] Where Showcase should publish its CA certificate. +# @param log_file [String] +# @param extra_args [Array] Additional flags, e.g. --tls-groups. +# @return [Integer] The server's pid. +def spawn_showcase binary, port:, ca_path:, log_file:, extra_args: [] + # A stale CA would satisfy the wait instantly, then fail verification. + FileUtils.rm_f ca_path + # err: [:child, :out] rather than a second redirect to log_file: two redirects + # to one path get independent offsets and would overwrite each other. + pid = Process.spawn( + binary, "run", "--port", ":#{port}", + "--tls", "--ca-cert-output-file", ca_path, *extra_args, + out: [log_file, "w"], err: [:child, :out] + ) + begin + wait_for_showcase_ca ca_path + rescue StandardError + # Otherwise it survives holding the port and the next run silently reuses it. + stop_showcase pid + raise + end + pid +end + +# @private +# Terminates a Showcase server, tolerating one that has already gone away. +# +# @param pid [Integer, nil] +# @return [void] +def stop_showcase pid + return if pid.nil? + Process.kill "TERM", pid + Process.wait pid +rescue Errno::ESRCH, Errno::ECHILD + nil +end + def generate_library_for_test imports, protos client_lib = Dir.mktmpdir FileUtils.mkdir "#{client_lib}/lib" @@ -68,48 +143,89 @@ def tar_file_name class ShowcaseTest < Minitest::Test def new_echo_client Google::Showcase::V1beta1::Echo::Client.new do |config| - config.credentials = :this_channel_is_insecure + config.credentials = ShowcaseTest.channel_credentials end end def new_echo_rest_client Google::Showcase::V1beta1::Echo::Rest::Client.new do |config| - config.endpoint = "http://localhost:7469" + config.endpoint = "https://localhost:#{SHOWCASE_PORT}" config.credentials = :this_channel_is_insecure end end def new_identity_client Google::Showcase::V1beta1::Identity::Client.new do |config| - config.credentials = :this_channel_is_insecure + config.credentials = ShowcaseTest.channel_credentials end end def new_echo_operations_client Google::Showcase::V1beta1::Echo::Operations.new do |config| - config.credentials = :this_channel_is_insecure + config.credentials = ShowcaseTest.channel_credentials end end def new_compliance_rest_client Google::Showcase::V1beta1::Compliance::Rest::Client.new do |config| - config.endpoint = "http://localhost:7469" + config.endpoint = "https://localhost:#{SHOWCASE_PORT}" config.credentials = :this_channel_is_insecure end end + # Env vars pointing REST at Showcase's CA, saved so after_run can restore them + # rather than leak a test-only root. REST only: Net::HTTP re-reads + # SSL_CERT_FILE per connection, while the gRPC C core resolves + # GRPC_DEFAULT_SSL_ROOTS_FILE_PATH once per process and gets credentials + # explicitly instead. + TLS_ENV_KEYS = ["SSL_CERT_FILE"].freeze + + @original_tls_env = TLS_ENV_KEYS.to_h { |key| [key, ENV[key]] } + @showcase_dir = nil + @showcase_ca_path = nil + + # Channel credentials trusting the CA of the Showcase server under test. + # + # Each --tls server mints its own CA, so trust cannot be set once per process; + # every channel must be handed its roots. + # + # @param ca_path [String, nil] Defaults to the server this helper started; nil + # falls back to system roots, all that is possible for an external server + # with no SHOWCASE_TLS_CERT. + # @return [GRPC::Core::ChannelCredentials] + def self.channel_credentials ca_path = @showcase_ca_path + return GRPC::Core::ChannelCredentials.new if ca_path.nil? + GRPC::Core::ChannelCredentials.new File.read ca_path + end + @showcase_id = begin server_id = nil - unless gapic_showcase_running? - tmp_dir = Dir.mktmpdir "gapic-show-case-#{Time.now.to_i}" - log_file = "#{tmp_dir}/gapic-showcase.log" + if gapic_showcase_running? + puts "Existing showcase server is available. Continuing..." if ENV["VERBOSE"] + # Tests speak TLS unconditionally, so an external server must publish its CA. + if ENV["SHOWCASE_TLS_CERT"] + @showcase_ca_path = ENV["SHOWCASE_TLS_CERT"] + TLS_ENV_KEYS.each { |key| ENV[key] = @showcase_ca_path } + else + warn "WARNING: reusing a running showcase server without SHOWCASE_TLS_CERT set; " \ + "TLS verification will fail unless it was started with a trusted certificate." + end + else + @showcase_dir = Dir.mktmpdir "gapic-show-case-#{Time.now.to_i}" + log_file = "#{@showcase_dir}/gapic-showcase.log" url = "https://github.com/googleapis/gapic-showcase/releases/download/v#{GAPIC_SHOWCASE_VERSION}/#{tar_file_name}" - _, status = Open3.capture2 "curl -sSL #{url} | tar -zx --directory #{tmp_dir}/" + _, status = Open3.capture2 "curl -sSL #{url} | tar -zx --directory #{@showcase_dir}/" raise "failed to start showcase" unless status.exitstatus.zero? - server_id = Process.spawn("#{tmp_dir}/gapic-showcase run", :out => [log_file, "w"]) + + # Showcase generates its own serving cert under --tls and publishes the + # signing CA, so the harness runs no certificate authority of its own. + @showcase_ca_path = File.join @showcase_dir, SHOWCASE_CA_FILE + server_id = spawn_showcase "#{@showcase_dir}/gapic-showcase", + port: SHOWCASE_PORT, + ca_path: @showcase_ca_path, + log_file: log_file + TLS_ENV_KEYS.each { |key| ENV[key] = @showcase_ca_path } puts "Started showcase server v#{GAPIC_SHOWCASE_VERSION} (pid: #{server_id}) > #{log_file}." if ENV["VERBOSE"] - else - puts "Existing showcase server is available. Continuing..." if ENV["VERBOSE"] end server_id @@ -137,5 +253,8 @@ def new_compliance_rest_client _, status = Open3.capture2 "kill #{@showcase_id}" raise "failed to kill showcase" unless status.exitstatus.zero? end + + @original_tls_env.each { |key, value| ENV[key] = value } + FileUtils.remove_entry @showcase_dir, true if @showcase_dir end end