From 027cb33b7a949cab212401c52ed3b795c85781a1 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:14:40 +0000 Subject: [PATCH 1/5] chore(ci): replace toys kokoro-ci with a Ruby script bin/kokoro-ci is a plain-Ruby port of .toys/kokoro-ci.rb that needs only default gems, so this repo no longer depends on the toys gem in the Kokoro image. .kokoro/system_tests.sh now runs `ruby bin/kokoro-ci -v`. The steps, commands and exit codes are unchanged. bin/ joins the paths whose changes run every test, so edits to this script get a full presubmit run. --- .kokoro/system_tests.sh | 2 +- .toys/kokoro-ci.rb | 396 ---------------------------- bin/kokoro-ci | 569 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 570 insertions(+), 397 deletions(-) delete mode 100644 .toys/kokoro-ci.rb create mode 100755 bin/kokoro-ci diff --git a/.kokoro/system_tests.sh b/.kokoro/system_tests.sh index ff5bd62af..2c8210c96 100755 --- a/.kokoro/system_tests.sh +++ b/.kokoro/system_tests.sh @@ -17,4 +17,4 @@ source $KOKORO_GFILE_DIR/secrets.sh cd github/ruby-docs-samples/ # Run the CI script -toys kokoro-ci -v +ruby bin/kokoro-ci -v diff --git a/.toys/kokoro-ci.rb b/.toys/kokoro-ci.rb deleted file mode 100644 index 7659846b6..000000000 --- a/.toys/kokoro-ci.rb +++ /dev/null @@ -1,396 +0,0 @@ -desc "Run Kokoro tests" - -include :exec, e: true -include :fileutils -include :terminal, styled: true - -def run - run_setup - run_tests - report_results - exit 1 unless @failures.empty? -ensure - run_cleanup -end - -#### -# Toplevel steps -#### - -def run_setup - @failures = [] - @project = "" - @kill_on_cleanup = [] - gimme_proj - setup_env - setup_workarounds - list_products - filter_by_ruby_versions - filter_by_changed_paths if presubmit? - start_app_engine_services - start_cloud_sql_services -end - -def run_tests - configure_exec e: false - test_globals - @products.each do |dir| - test_product dir - end -end - -def report_results - if @failures.empty? - puts "ALL TESTS PASSED!", :green, :bold - return - end - puts "Failures:", :red, :bold - @failures.each do |failure| - puts failure, :red, :bold - end - puts "Search these logs for the red test names listed above to see details." -end - -def run_cleanup - exec ["gimmeproj", "-project", "cloud-samples-ruby-test-kokoro", "done", @project] unless @project.empty? - @kill_on_cleanup.each do |process| - process.kill "SIGKILL" rescue nil - end -end - -#### -# Steps related to setup -#### - -## -# Get a project from the project pool. -# -def gimme_proj - # These credentials are used by gimmeproj - ENV["GOOGLE_APPLICATION_CREDENTIALS"] = "#{keystore_dir}/71386_kokoro-cloud-samples-ruby-test-0" - puts "Installing Gimmeproj", :bold - Dir.chdir "/tmp" do - exec ["wget", "-q", "https://storage.googleapis.com/gimme-proj/linux_amd64/gimmeproj"] - mv "gimmeproj", "/bin/gimmeproj" - end - chmod "+x", "/bin/gimmeproj" - exec ["/bin/gimmeproj", "version"] - puts "Getting a project", :bold - @project = capture(["/bin/gimmeproj", "-project", "cloud-samples-ruby-test-kokoro", "lease", "60m"]).strip - error "gimmeproj lease failed" if @project.empty? - puts "Running tests in project #{@project}", :bold -end - -## -# Set up most environment variables (projets and credentials) for tests -# -def setup_env - # Primary project - ENV["GOOGLE_CLOUD_PROJECT"] = @project - ENV["GOOGLE_APPLICATION_CREDENTIALS"] = "#{keystore_dir}/71386_kokoro-#{@project}" - - # Secondary project - ENV["GOOGLE_CLOUD_PROJECT_SECONDARY"] = "cloud-samples-ruby-test-second" - ENV["GOOGLE_APPLICATION_CREDENTIALS_SECONDARY"] = "#{gfile_dir}/cloud-samples-ruby-test-second-ede32e88c59c.json" - - # Special project for firestore - ENV["FIRESTORE_PROJECT_ID"] = "ruby-firestore-ci" - ENV["E2E_GOOGLE_CLOUD_PROJECT"] = "cloud-samples-ruby-test-kokoro" - - # Database names mirror the main project name - ENV["POSTGRES_DATABASE"] = ENV["MYSQL_DATABASE"] = @project.tr "-", "_" - - # Storage bucket names mirror the main projecct name - ENV["GOOGLE_CLOUD_STORAGE_BUCKET"] = "#{@project}-cloud-samples-ruby-bucket" - ENV["ALTERNATE_GOOGLE_CLOUD_STORAGE_BUCKET"] = "#{@project}-cloud-samples-ruby-bucket-alt" - - # Single project for spanner - ENV["GOOGLE_CLOUD_SPANNER_TEST_INSTANCE"] = "ruby-test-instance" - ENV["GOOGLE_CLOUD_SPANNER_MR_TEST_INSTANCE"] = "ruby-mr-test-instance" - ENV["GOOGLE_CLOUD_SPANNER_PROJECT"] = "cloud-samples-ruby-test-0" - - # Used by E2E tests - ENV["BUILD_ID"] = @build_id = assert_env("KOKORO_BUILD_ID")[-10..] -end - -## -# A few miscellaneous workarounds for weird issues -# -def setup_workarounds - # Temporary workaround for a known bundler+docker issue: - # https://github.com/bundler/bundler/issues/6154 - ENV["BUNDLE_GEMFILE"] = "" - - # Workaround for new security feature in git 2.35.2 (see https://github.blog/2022-04-12-git-security-vulnerability-announced/) - exec ["git", "config", "--global", "--add", "safe.directory", context_directory] -end - -## -# Find all directories with tests, and set them in the `@products` variable. -# -def list_products - # Paths that look like tests for us to run but aren't. - # Currently, we include the rails tests for the run/rails tutorial. - omit_list = ["run/rails/test"] - @products = [] - (Dir.glob("*/Gemfile") + Dir.glob("*/*/Gemfile")).each do |gemfile| - dir = File.dirname gemfile - if (File.directory?("#{dir}/test") && !omit_list.include?("#{dir}/test")) || - (File.directory?("#{dir}/spec") && !omit_list.include?("#{dir}/spec")) || - (File.executable?("#{dir}/bin/run_tests") && !omit_list.include?("#{dir}/bin/run_tests")) - @products << dir - end - end - puts "Found #{@products.size} total test directories" -end - -## -# Filter out products whose tests will not run on the current Ruby version -# -def filter_by_ruby_versions - unless newest_ruby? - # Spanner tests are too slow. Run only on newest. - @products.delete "spanner" - # run/rails uses Rails 7 and requires newest Ruby. - @products.delete "run/rails" - end - if newest_ruby? - # getting-started uses an old Rails and is incompatible with newest. - @products.delete_if { |dir| dir.start_with? "getting-started/" } - # appengine tests are slow and some use an old Rails. Run only on oldest. - @products.delete_if { |dir| dir.start_with? "appengine/" } - end -end - -## -# Filter out products that have no changed files. This method should be called -# in the presubmit case but not the nightly case. -# -def filter_by_changed_paths - base_sha = capture(["git", "merge-base", "HEAD", "main"]).strip - changed_paths = capture(["git", "--no-pager", "diff", "--name-only", "HEAD", base_sha]).split("\n") - puts "Changed paths:", :bold - changed_paths.each { |changed_path| puts changed_path } - infra_dirs = ["spec/", ".kokoro/", ".toys/"] - if changed_paths.any? { |changed_path| infra_dirs.any? { |dir| changed_path.start_with? dir } } - # Spanner takes a long time, so omit it when testing infrastructure changes - @products.delete "spanner" - puts "Test drivers may have changed; running all tests except spanner.", :bold - return - end - puts "Filtering tests based on what has changed...", :bold - @products.select! do |product_dir| - keep = changed_paths.any? { |changed_path| changed_path.start_with? "#{product_dir}/" } - if keep - puts "Keeping #{product_dir}" - else - puts "Omitting #{product_dir}" - end - keep - end -end - -## -# Install and run tools needed for app engine and cloud run tests. -# -def start_app_engine_services - return unless @products.any? { |dir| dir.start_with?("run/") || dir.start_with?("appengine/") } - puts "Initializing app engine and cloud run tools", :bold - install_gcloud_cli - process = exec ["/bin/cloud_sql_proxy", "-dir=/cloudsql", "-credential_file=#{gac_path}"], - background: true, out: :inherit - ENV["CLOUD_SQL_PROXY_PROCESS_ID"] = process.pid.to_s - @kill_on_cleanup << process - start_memcached -end - -## -# Launch cloud sql proxy instances for cloud-sql tests -# -def start_cloud_sql_services - if @products.include? "cloud-sql/mysql" - puts "Starting Cloud SQL Proxy for MySQL", :bold - connection_name = assert_env "MYSQL_INSTANCE_CONNECTION_NAME" - process = exec ["/bin/cloud_sql_proxy", - "-instances=#{connection_name}=tcp:3306,#{connection_name}", - "-dir=/cloudsql", "-credential_file=#{gac_path}"], - background: true, out: :inherit - ENV["MYSQL_CLOUD_SQL_PROXY_PROCESS_ID"] = process.pid.to_s - @kill_on_cleanup << process - end - if @products.include? "cloud-sql/postgres" - puts "Starting Cloud SQL Proxy for Postgres", :bold - connection_name = assert_env "POSTGRES_INSTANCE_CONNECTION_NAME" - process = exec ["/bin/cloud_sql_proxy", - "-instances=#{connection_name}=tcp:5432,#{connection_name}", - "-dir=/cloudsql", "-credential_file=#{gac_path}"], - background: true, out: :inherit - ENV["POSTGRES_CLOUD_SQL_PROXY_PROCESS_ID"] = process.pid.to_s - @kill_on_cleanup << process - end - if @products.include? "cloud-sql/postgres" - puts "Starting Cloud SQL Proxy for SQL Server", :bold - connection_name = assert_env "SQLSERVER_INSTANCE_CONNECTION_NAME" - process = exec ["/bin/cloud_sql_proxy", - "-instances=#{connection_name}=tcp:1433", - "-credential_file=#{gac_path}"], - background: true, out: :inherit - ENV["SQLSERVER_CLOUD_SQL_PROXY_PROCESS_ID"] = process.pid.to_s - @kill_on_cleanup << process - end -end - -## -# Start memcached. -# TODO: This was used previously for an appengine/memcache test, but that -# samples seems not to exist anymore, so this may not be needed. -# -def start_memcached - exec ["service", "memcached", "start"] -end - -## -# Installs the gcloud tool -# gcloud installed from system packages https://github.com/googleapis/testing-infra-docker/pull/446 -# -def install_gcloud_cli - return if defined? @gcloud_cli_installed - exec ["gcloud", "config", "set", "disable_prompts", "True"] - exec ["gcloud", "config", "set", "project", assert_env("E2E_GOOGLE_CLOUD_PROJECT")] - exec ["gcloud", "config", "set", "app/promote_by_default", "false"] - exec ["gcloud", "auth", "activate-service-account", "--key-file", gac_path] - exec ["gcloud", "info"] - @gcloud_cli_installed = true -end - -#### -# Steps related to test running -#### - -## -# Run global bundle and global rubocop -# -def test_globals - start_time = Time.now.to_i - if test_exec "BUNDLE:global", ["bundle", "update"] - test_exec "RUBOCOP:global", ["bundle", "exec", "rubocop"] - end - finish_time = Time.now.to_i - puts "Global tests took #{finish_time - start_time} seconds" -end - -## -# Run tests in the given directory. -# -def test_product dir - is_e2e = !presubmit? && newest_ruby? || dir.start_with?("run/") || dir.start_with?("appengine/") - ENV["E2E"] = is_e2e.to_s - ENV["TEST_DIR"] = dir - start_time = Time.now.to_i - Dir.chdir dir do - if test_exec "BUNDLE:#{dir}", ["bundle", "update"] - if File.executable? "bin/run_tests" - test_exec "RUN_TESTS:#{dir}", ["bin/run_tests"] - elsif File.directory? "spec" - test_rspec dir - elsif File.directory? "test" - test_minitest dir - else - test_exec "UNKNOWN_TESTER:#{dir}" - end - end - end - exec ["bundle", "exec", "ruby", "spec/e2e_cleanup.rb", dir, @build_id] if is_e2e - finish_time = Time.now.to_i - puts "Tests for #{dir} took #{finish_time - start_time} seconds" -end - -## -# Run a rspec-based test. -# -def test_rspec dir - cmd = ["bundle", "exec", "rspec", "--format", "documentation"] - cmd += ["--format", "RspecJunitFormatter", "--out", "sponge_log.xml"] unless presubmit? - test_exec "SPEC:#{dir}", cmd -end - -## -# Run a minitest-based test. -# -def test_minitest dir - test_exec "MINITEST:#{dir}" do - test_files = Dir.glob("test/**/*_test.rb") - args = ["-Itest", "-w"] - args += ["-", "--junit", "--junit-filename=sponge_log.xml"] unless presubmit? - exec_ruby args, in: :controller do |controller| - controller.in.puts "require 'bundler/setup'" - controller.in.puts "require 'minitest/autorun'" - test_files.each do |path| - controller.in.puts "load '#{path}'" - end - end - end -end - -## -# Perform a test and report its result. -# -def test_exec name, command = nil - puts "**** RUNNING: #{name} ...", :cyan, :bold - result = - if command - exec(command) - elsif block_given? - yield - end - if result&.success? - puts "**** PASSED: #{name}", :green, :bold - true - else - puts "**** FAILURE: #{name}", :red, :bold - @failures << name - false - end -end - -#### -# Helpers -#### - -def keystore_dir - @keystore_dir ||= assert_env "KOKORO_KEYSTORE_DIR" -end - -def gfile_dir - @gfile_dir ||= assert_env "KOKORO_GFILE_DIR" -end - -def gac_path - @gac_path ||= ENV["GOOGLE_APPLICATION_CREDENTIALS"] -end - -def presubmit? - unless defined? @is_presubmit - @is_presubmit = (/system-tests/ =~ assert_env("KOKORO_BUILD_ARTIFACTS_SUBDIR")).nil? - end - @is_presubmit -end - -def newest_ruby? - unless defined? @is_newest_ruby - @is_newest_ruby = assert_env("KOKORO_RUBY_VERSION") == "newest" - end - @is_newest_ruby -end - -def assert_env name, allow_empty: false - val = ENV[name] - error "Environment variable #{name} is not set" if val.nil? - error "Environment variable #{name} is empty" if !allow_empty && val.empty? - val -end - -def error msg - puts msg, :red, :bold - exit 1 -end diff --git a/bin/kokoro-ci b/bin/kokoro-ci new file mode 100755 index 000000000..808b0525e --- /dev/null +++ b/bin/kokoro-ci @@ -0,0 +1,569 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +require "fileutils" +require "optparse" +require "rbconfig" + +## +# Runs the Kokoro tests. It leases a project from the gimmeproj pool, sets up +# credentials and services, runs the global checks and the tests in each +# selected sample directory, reports the failures, and returns the lease. +# +# This replaces `toys kokoro-ci` and takes the same flags. It uses only +# default gems, so it runs with a plain `ruby` on any Ruby in the Kokoro +# image. `.kokoro/system_tests.sh` runs it as `ruby bin/kokoro-ci -v`. +# +class KokoroCI + ROOT = File.expand_path "..", __dir__ + STYLES = { bold: 1, red: 31, green: 32, cyan: 36 }.freeze + + ## + # @param argv [Array] The command line arguments. + # + def initialize argv + @verbosity = 0 + parse_options! argv + @failures = [] + @project = "" + @kill_on_cleanup = [] + # During setup and cleanup, a failing command ends the run with that + # command's status. run_tests turns this off so that test failures are + # collected instead. + @exit_on_failure = true + end + + ## + # Run the tests, and exit with status 1 if any of them failed. The project + # lease is returned and the background processes are killed on every exit + # path: `exit` raises SystemExit and a signal such as SIGTERM raises + # SignalException, and both run the `ensure` clause. + # + def main + $stdout.sync = true + Dir.chdir ROOT + run_setup + run_tests + report_results + exit 1 unless @failures.empty? + ensure + run_cleanup + end + + private + + def parse_options! argv + parser = OptionParser.new + parser.banner = "Usage: bin/kokoro-ci [-v] [-q]\n\nRun Kokoro tests." + parser.separator "" + parser.on("-v", "--verbose", "Increase verbosity") { @verbosity += 1 } + parser.on("-q", "--quiet", "Decrease verbosity") { @verbosity -= 1 } + parser.on_tail "-h", "--help", "Show this help" do + puts parser + exit + end + # OptionParser's built-in --version exits 1; toys treated it as an unknown + # flag (exit 2). + parser.base.long.delete "version" + parser.parse! argv + raise OptionParser::ParseError, "extra arguments: #{argv.join ' '}" unless argv.empty? + rescue OptionParser::ParseError => e + warn e.message + warn "Run with --help for usage." + exit 2 + end + + #### + # Toplevel steps + #### + + def run_setup + gimme_proj + setup_env + setup_workarounds + list_products + filter_by_ruby_versions + filter_by_changed_paths if presubmit? + start_app_engine_services + start_cloud_sql_services + end + + def run_tests + @exit_on_failure = false + test_globals + @products.each do |dir| + test_product dir + end + end + + def report_results + if @failures.empty? + say "ALL TESTS PASSED!", :green, :bold + return + end + say "Failures:", :red, :bold + @failures.each do |failure| + say failure, :red, :bold + end + puts "Search these logs for the red test names listed above to see details." + end + + # If setup failed, a failing `gimmeproj done` still exits immediately and + # skips the kills, as it did in the toys tool. + def run_cleanup + run ["gimmeproj", "-project", "cloud-samples-ruby-test-kokoro", "done", @project] unless @project.empty? + @kill_on_cleanup.each do |pid| + Process.kill "SIGKILL", pid if pid + rescue StandardError + nil + end + end + + #### + # Steps related to setup + #### + + ## + # Get a project from the project pool. + # + def gimme_proj + # These credentials are used by gimmeproj + ENV["GOOGLE_APPLICATION_CREDENTIALS"] = "#{keystore_dir}/71386_kokoro-cloud-samples-ruby-test-0" + say "Installing Gimmeproj", :bold + Dir.chdir "/tmp" do + run ["wget", "-q", "https://storage.googleapis.com/gimme-proj/linux_amd64/gimmeproj"] + FileUtils.mv "gimmeproj", "/bin/gimmeproj" + end + FileUtils.chmod "+x", "/bin/gimmeproj" + run ["/bin/gimmeproj", "version"] + say "Getting a project", :bold + @project = capture(["/bin/gimmeproj", "-project", "cloud-samples-ruby-test-kokoro", "lease", "60m"]).strip + error "gimmeproj lease failed" if @project.empty? + say "Running tests in project #{@project}", :bold + end + + ## + # Set up most environment variables (projets and credentials) for tests + # + def setup_env + # Primary project + ENV["GOOGLE_CLOUD_PROJECT"] = @project + ENV["GOOGLE_APPLICATION_CREDENTIALS"] = "#{keystore_dir}/71386_kokoro-#{@project}" + + # Secondary project + ENV["GOOGLE_CLOUD_PROJECT_SECONDARY"] = "cloud-samples-ruby-test-second" + ENV["GOOGLE_APPLICATION_CREDENTIALS_SECONDARY"] = "#{gfile_dir}/cloud-samples-ruby-test-second-ede32e88c59c.json" + + # Special project for firestore + ENV["FIRESTORE_PROJECT_ID"] = "ruby-firestore-ci" + ENV["E2E_GOOGLE_CLOUD_PROJECT"] = "cloud-samples-ruby-test-kokoro" + + # Database names mirror the main project name + ENV["POSTGRES_DATABASE"] = ENV["MYSQL_DATABASE"] = @project.tr "-", "_" + + # Storage bucket names mirror the main projecct name + ENV["GOOGLE_CLOUD_STORAGE_BUCKET"] = "#{@project}-cloud-samples-ruby-bucket" + ENV["ALTERNATE_GOOGLE_CLOUD_STORAGE_BUCKET"] = "#{@project}-cloud-samples-ruby-bucket-alt" + + # Single project for spanner + ENV["GOOGLE_CLOUD_SPANNER_TEST_INSTANCE"] = "ruby-test-instance" + ENV["GOOGLE_CLOUD_SPANNER_MR_TEST_INSTANCE"] = "ruby-mr-test-instance" + ENV["GOOGLE_CLOUD_SPANNER_PROJECT"] = "cloud-samples-ruby-test-0" + + # Used by E2E tests + ENV["BUILD_ID"] = @build_id = assert_env("KOKORO_BUILD_ID")[-10..] + end + + ## + # A few miscellaneous workarounds for weird issues + # + def setup_workarounds + # Temporary workaround for a known bundler+docker issue: + # https://github.com/bundler/bundler/issues/6154 + ENV["BUNDLE_GEMFILE"] = "" + + # Workaround for new security feature in git 2.35.2 (see https://github.blog/2022-04-12-git-security-vulnerability-announced/) + run ["git", "config", "--global", "--add", "safe.directory", ROOT] + end + + ## + # Find all directories with tests, and set them in the `@products` variable. + # + def list_products + # Paths that look like tests for us to run but aren't. + # Currently, we include the rails tests for the run/rails tutorial. + omit_list = ["run/rails/test"] + @products = [] + (Dir.glob("*/Gemfile") + Dir.glob("*/*/Gemfile")).each do |gemfile| + dir = File.dirname gemfile + if (File.directory?("#{dir}/test") && !omit_list.include?("#{dir}/test")) || + (File.directory?("#{dir}/spec") && !omit_list.include?("#{dir}/spec")) || + (File.executable?("#{dir}/bin/run_tests") && !omit_list.include?("#{dir}/bin/run_tests")) + @products << dir + end + end + puts "Found #{@products.size} total test directories" + end + + ## + # Filter out products whose tests will not run on the current Ruby version + # + def filter_by_ruby_versions + unless newest_ruby? + # Spanner tests are too slow. Run only on newest. + @products.delete "spanner" + # run/rails uses Rails 7 and requires newest Ruby. + @products.delete "run/rails" + end + if newest_ruby? + # getting-started uses an old Rails and is incompatible with newest. + @products.delete_if { |dir| dir.start_with? "getting-started/" } + # appengine tests are slow and some use an old Rails. Run only on oldest. + @products.delete_if { |dir| dir.start_with? "appengine/" } + end + end + + ## + # Filter out products that have no changed files. This method should be called + # in the presubmit case but not the nightly case. + # + def filter_by_changed_paths + base_sha = capture(["git", "merge-base", "HEAD", "main"]).strip + changed_paths = capture(["git", "--no-pager", "diff", "--name-only", "HEAD", base_sha]).split("\n") + say "Changed paths:", :bold + changed_paths.each { |changed_path| puts changed_path } + # bin/ holds this script, which replaced the toys tool in .toys/. + infra_dirs = ["spec/", ".kokoro/", ".toys/", "bin/"] + if changed_paths.any? { |changed_path| infra_dirs.any? { |dir| changed_path.start_with? dir } } + # Spanner takes a long time, so omit it when testing infrastructure changes + @products.delete "spanner" + say "Test drivers may have changed; running all tests except spanner.", :bold + return + end + say "Filtering tests based on what has changed...", :bold + @products.select! do |product_dir| + keep = changed_paths.any? { |changed_path| changed_path.start_with? "#{product_dir}/" } + if keep + puts "Keeping #{product_dir}" + else + puts "Omitting #{product_dir}" + end + keep + end + end + + ## + # Install and run tools needed for app engine and cloud run tests. + # + def start_app_engine_services + return unless @products.any? { |dir| dir.start_with?("run/") || dir.start_with?("appengine/") } + say "Initializing app engine and cloud run tools", :bold + install_gcloud_cli + pid = run_background ["/bin/cloud_sql_proxy", "-dir=/cloudsql", "-credential_file=#{gac_path}"] + ENV["CLOUD_SQL_PROXY_PROCESS_ID"] = pid.to_s + @kill_on_cleanup << pid + start_memcached + end + + ## + # Launch cloud sql proxy instances for cloud-sql tests + # + def start_cloud_sql_services + if @products.include? "cloud-sql/mysql" + say "Starting Cloud SQL Proxy for MySQL", :bold + connection_name = assert_env "MYSQL_INSTANCE_CONNECTION_NAME" + pid = run_background ["/bin/cloud_sql_proxy", + "-instances=#{connection_name}=tcp:3306,#{connection_name}", + "-dir=/cloudsql", "-credential_file=#{gac_path}"] + ENV["MYSQL_CLOUD_SQL_PROXY_PROCESS_ID"] = pid.to_s + @kill_on_cleanup << pid + end + if @products.include? "cloud-sql/postgres" + say "Starting Cloud SQL Proxy for Postgres", :bold + connection_name = assert_env "POSTGRES_INSTANCE_CONNECTION_NAME" + pid = run_background ["/bin/cloud_sql_proxy", + "-instances=#{connection_name}=tcp:5432,#{connection_name}", + "-dir=/cloudsql", "-credential_file=#{gac_path}"] + ENV["POSTGRES_CLOUD_SQL_PROXY_PROCESS_ID"] = pid.to_s + @kill_on_cleanup << pid + end + # The toys tool checked for cloud-sql/postgres here, not cloud-sql/sqlserver. + # Kept as is: these cloud-sql directories are never products today, because + # their tests live one level deeper (e.g. cloud-sql/mysql/activerecord). + if @products.include? "cloud-sql/postgres" + say "Starting Cloud SQL Proxy for SQL Server", :bold + connection_name = assert_env "SQLSERVER_INSTANCE_CONNECTION_NAME" + pid = run_background ["/bin/cloud_sql_proxy", + "-instances=#{connection_name}=tcp:1433", + "-credential_file=#{gac_path}"] + ENV["SQLSERVER_CLOUD_SQL_PROXY_PROCESS_ID"] = pid.to_s + @kill_on_cleanup << pid + end + end + + ## + # Start memcached. + # TODO: This was used previously for an appengine/memcache test, but that + # samples seems not to exist anymore, so this may not be needed. + # + def start_memcached + run ["service", "memcached", "start"] + end + + ## + # Installs the gcloud tool + # gcloud installed from system packages https://github.com/googleapis/testing-infra-docker/pull/446 + # + def install_gcloud_cli + run ["gcloud", "config", "set", "disable_prompts", "True"] + run ["gcloud", "config", "set", "project", assert_env("E2E_GOOGLE_CLOUD_PROJECT")] + run ["gcloud", "config", "set", "app/promote_by_default", "false"] + run ["gcloud", "auth", "activate-service-account", "--key-file", gac_path] + run ["gcloud", "info"] + end + + #### + # Steps related to test running + #### + + ## + # Run global bundle and global rubocop + # + def test_globals + start_time = Time.now.to_i + if test_exec "BUNDLE:global", ["bundle", "update"] + test_exec "RUBOCOP:global", ["bundle", "exec", "rubocop"] + end + finish_time = Time.now.to_i + puts "Global tests took #{finish_time - start_time} seconds" + end + + ## + # Run tests in the given directory. + # + def test_product dir + is_e2e = (!presubmit? && newest_ruby?) || dir.start_with?("run/") || dir.start_with?("appengine/") + ENV["E2E"] = is_e2e.to_s + ENV["TEST_DIR"] = dir + start_time = Time.now.to_i + Dir.chdir dir do + if test_exec "BUNDLE:#{dir}", ["bundle", "update"] + if File.executable? "bin/run_tests" + test_exec "RUN_TESTS:#{dir}", ["bin/run_tests"] + elsif File.directory? "spec" + test_rspec dir + elsif File.directory? "test" + test_minitest dir + else + test_exec "UNKNOWN_TESTER:#{dir}" + end + end + end + run ["bundle", "exec", "ruby", "spec/e2e_cleanup.rb", dir, @build_id] if is_e2e + finish_time = Time.now.to_i + puts "Tests for #{dir} took #{finish_time - start_time} seconds" + end + + ## + # Run a rspec-based test. + # + def test_rspec dir + cmd = ["bundle", "exec", "rspec", "--format", "documentation"] + cmd += ["--format", "RspecJunitFormatter", "--out", "sponge_log.xml"] unless presubmit? + test_exec "SPEC:#{dir}", cmd + end + + ## + # Run a minitest-based test. + # + def test_minitest dir + test_exec "MINITEST:#{dir}" do + test_files = Dir.glob "test/**/*_test.rb" + args = ["-Itest", "-w"] + args += ["-", "--junit", "--junit-filename=sponge_log.xml"] unless presubmit? + script = ["require 'bundler/setup'", "require 'minitest/autorun'"] + script += test_files.map { |path| "load '#{path}'" } + run_ruby args, script.map { |line| "#{line}\n" }.join + end + end + + ## + # Perform a test and report its result. + # + def test_exec name, command = nil + say "**** RUNNING: #{name} ...", :cyan, :bold + result = + if command + run command + elsif block_given? + yield + end + if result&.success? + say "**** PASSED: #{name}", :green, :bold + true + else + say "**** FAILURE: #{name}", :red, :bold + @failures << name + false + end + end + + #### + # Running commands. These stand in for the toys :exec mixin. Commands are + # never run through a shell, and each inherits stdin, stdout and stderr + # unless noted. + #### + + ## + # Run a command and wait for it. + # + # @return [Process::Status, nil] The status, or nil if it could not start. + # + def run cmd + pid = start_process cmd + check_result pid && Process.wait2(pid).last + end + + ## + # Run a command and return what it wrote to stdout. + # + def capture cmd + reader, writer = IO.pipe + pid = start_process cmd, out: writer + writer.close + output = reader.read + reader.close + check_result pid && Process.wait2(pid).last + output + end + + ## + # Run Ruby with the given arguments, and write the script to its stdin. + # + # @return [Process::Status, nil] The status, or nil if it could not start. + # + def run_ruby args, script + reader, writer = IO.pipe + pid = start_process [RbConfig.ruby, *args], argv0: "ruby", log: "exec ruby: #{args.inspect}", in: reader + reader.close + writer.write script if pid + writer.close + check_result pid && Process.wait2(pid).last + end + + ## + # Start a command without waiting for it. Its stdin and stderr are + # connected to /dev/null. A detached thread reaps it when it exits. + # + # @return [Integer, nil] The pid, or nil if it could not start. + # + def run_background cmd + pid = start_process cmd, in: File::NULL, err: File::NULL + Process.detach pid if pid + pid + end + + ## + # Log a command and spawn it. As in toys, each argument is converted with + # to_s first, so a nil argument is passed (and logged) as "". + # + # @return [Integer, nil] The pid, or nil if it could not start. + # + def start_process cmd, argv0: nil, log: nil, **opts + cmd = cmd.map(&:to_s) + log_info log || "exec: #{cmd.inspect}" + begin + Process.spawn [cmd.first, argv0 || cmd.first], *cmd.drop(1), **opts + rescue SystemCallError + nil + end + end + + ## + # While @exit_on_failure is set, exit as toys did for `e: true`: with 127 if + # the command could not start, 128 plus the signal number if a signal ended + # it, or else its exit status. + # + def check_result status + if @exit_on_failure + exit 127 if status.nil? + exit 128 + status.termsig if status.signaled? + exit status.exitstatus unless status.success? + end + status + end + + #### + # Helpers + #### + + def keystore_dir + @keystore_dir ||= assert_env "KOKORO_KEYSTORE_DIR" + end + + def gfile_dir + @gfile_dir ||= assert_env "KOKORO_GFILE_DIR" + end + + def gac_path + @gac_path ||= ENV["GOOGLE_APPLICATION_CREDENTIALS"] + end + + def presubmit? + unless defined? @is_presubmit + @is_presubmit = (/system-tests/ =~ assert_env("KOKORO_BUILD_ARTIFACTS_SUBDIR")).nil? + end + @is_presubmit + end + + def newest_ruby? + unless defined? @is_newest_ruby + @is_newest_ruby = assert_env("KOKORO_RUBY_VERSION") == "newest" + end + @is_newest_ruby + end + + def assert_env name + val = ENV[name] + error "Environment variable #{name} is not set" if val.nil? + error "Environment variable #{name} is empty" if val.empty? + val + end + + def error msg + say msg, :red, :bold + exit 1 + end + + # Print a line to stdout. Styles are always applied, as they were with + # `include :terminal, styled: true` in the toys tool. + def say text, *styles + line = "#{text}\n" + line = "\e[#{styles.map { |style| STYLES.fetch style }.join ';'}m#{line}\e[0m" unless styles.empty? + $stdout.write line + end + + # With -v, log each command to stderr in the format toys used. + def log_info message + return unless @verbosity.positive? + header = "[#{Time.now.strftime '%Y-%m-%d %H:%M:%S'} INFO]" + header = "\e[96m#{header}\e[0m" if $stderr.tty? && !ENV["NO_COLOR"] + warn "#{header} #{message}" + end +end + +KokoroCI.new(ARGV).main From 1a0f294d377c304d304751d241e72587d192fb83 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Fri, 2 Oct 2026 19:14:58 +0000 Subject: [PATCH 2/5] chore: remove unused update-gemfile-locks tool Nothing calls .toys/update-gemfile-locks.rb (no Kokoro config, workflow or script refers to it). It was the last file in .toys/, so bin/kokoro-ci no longer lists .toys/ among the paths whose changes run every test. --- .toys/update-gemfile-locks.rb | 15 --------------- bin/kokoro-ci | 2 +- 2 files changed, 1 insertion(+), 16 deletions(-) delete mode 100644 .toys/update-gemfile-locks.rb diff --git a/.toys/update-gemfile-locks.rb b/.toys/update-gemfile-locks.rb deleted file mode 100644 index b24d88688..000000000 --- a/.toys/update-gemfile-locks.rb +++ /dev/null @@ -1,15 +0,0 @@ -desc "Updates all Gemfile.lock files under the current directory" - -include :fileutils -include :exec, e: true - -def run - Dir.glob "**/Gemfile.lock" do |path| - dir = File.dirname path - puts "Updating in #{dir}" - cd dir do - rm "Gemfile.lock" - exec ["bundle", "update"] - end - end -end diff --git a/bin/kokoro-ci b/bin/kokoro-ci index 808b0525e..b51633d86 100755 --- a/bin/kokoro-ci +++ b/bin/kokoro-ci @@ -247,7 +247,7 @@ class KokoroCI say "Changed paths:", :bold changed_paths.each { |changed_path| puts changed_path } # bin/ holds this script, which replaced the toys tool in .toys/. - infra_dirs = ["spec/", ".kokoro/", ".toys/", "bin/"] + infra_dirs = ["spec/", ".kokoro/", "bin/"] if changed_paths.any? { |changed_path| infra_dirs.any? { |dir| changed_path.start_with? dir } } # Spanner takes a long time, so omit it when testing infrastructure changes @products.delete "spanner" From 6dbbe3551870540b0eb16433fd1709aeb25a1d52 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:11:46 +0000 Subject: [PATCH 3/5] chore(ci): run every kokoro-ci cleanup step after a failed setup If setup failed, a failing `gimmeproj done` exited the script before it killed the background processes, as the toys tool did. Cleanup now turns off exit-on-failure first, so every step runs and the setup failure keeps its exit status. --- bin/kokoro-ci | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bin/kokoro-ci b/bin/kokoro-ci index b51633d86..dde138c0b 100755 --- a/bin/kokoro-ci +++ b/bin/kokoro-ci @@ -41,9 +41,9 @@ class KokoroCI @failures = [] @project = "" @kill_on_cleanup = [] - # During setup and cleanup, a failing command ends the run with that - # command's status. run_tests turns this off so that test failures are - # collected instead. + # During setup, a failing command ends the run with that command's + # status. run_tests turns this off so that test failures are collected + # instead, and run_cleanup turns it off so that every cleanup step runs. @exit_on_failure = true end @@ -122,9 +122,10 @@ class KokoroCI puts "Search these logs for the red test names listed above to see details." end - # If setup failed, a failing `gimmeproj done` still exits immediately and - # skips the kills, as it did in the toys tool. + # Every step runs even if an earlier one fails. If setup failed, the toys + # tool exited at a failing `gimmeproj done` and skipped the kills. def run_cleanup + @exit_on_failure = false run ["gimmeproj", "-project", "cloud-samples-ruby-test-kokoro", "done", @project] unless @project.empty? @kill_on_cleanup.each do |pid| Process.kill "SIGKILL", pid if pid From 75c10d833339b865da535e9d4cd1843c96e250c7 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:12:03 +0000 Subject: [PATCH 4/5] chore(ci): count an early Ruby exit as a test failure in kokoro-ci run_ruby writes the test script to Ruby's stdin. If Ruby exited before it read the whole script, the write raised Errno::EPIPE and ended the whole run, as the toys tool's controller write did. The write now ignores EPIPE, so Ruby's exit status marks the test as failed and the run continues. --- bin/kokoro-ci | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bin/kokoro-ci b/bin/kokoro-ci index dde138c0b..7ec92f208 100755 --- a/bin/kokoro-ci +++ b/bin/kokoro-ci @@ -454,6 +454,8 @@ class KokoroCI ## # Run Ruby with the given arguments, and write the script to its stdin. + # If Ruby exits before it reads the whole script, the write fails with + # EPIPE, and Ruby's exit status reports the failure. # # @return [Process::Status, nil] The status, or nil if it could not start. # @@ -461,8 +463,13 @@ class KokoroCI reader, writer = IO.pipe pid = start_process [RbConfig.ruby, *args], argv0: "ruby", log: "exec ruby: #{args.inspect}", in: reader reader.close - writer.write script if pid - writer.close + begin + writer.write script if pid + rescue Errno::EPIPE + nil + ensure + writer.close + end check_result pid && Process.wait2(pid).last end From ad2864a37a00dc6ec5505789c0980f1590bbe376 Mon Sep 17 00:00:00 2001 From: Torrey Payne <11740989+torreypayne@users.noreply.github.com> Date: Fri, 2 Oct 2026 20:12:28 +0000 Subject: [PATCH 5/5] chore(ci): start the SQL Server proxy for cloud-sql/sqlserver The check copied from the toys tool tested for cloud-sql/postgres. It now tests for cloud-sql/sqlserver, which matches the proxy it starts. Today's runs do not change, because no cloud-sql directory is a product: their tests live one level deeper. --- bin/kokoro-ci | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bin/kokoro-ci b/bin/kokoro-ci index 7ec92f208..fcb0f7582 100755 --- a/bin/kokoro-ci +++ b/bin/kokoro-ci @@ -302,10 +302,10 @@ class KokoroCI ENV["POSTGRES_CLOUD_SQL_PROXY_PROCESS_ID"] = pid.to_s @kill_on_cleanup << pid end - # The toys tool checked for cloud-sql/postgres here, not cloud-sql/sqlserver. - # Kept as is: these cloud-sql directories are never products today, because - # their tests live one level deeper (e.g. cloud-sql/mysql/activerecord). - if @products.include? "cloud-sql/postgres" + # The toys tool checked for cloud-sql/postgres here. These cloud-sql + # directories are never products today, because their tests live one + # level deeper (e.g. cloud-sql/mysql/activerecord). + if @products.include? "cloud-sql/sqlserver" say "Starting Cloud SQL Proxy for SQL Server", :bold connection_name = assert_env "SQLSERVER_INSTANCE_CONNECTION_NAME" pid = run_background ["/bin/cloud_sql_proxy",