diff --git a/.github/workflows/installer_test.yml b/.github/workflows/installer_test.yml new file mode 100644 index 00000000..52fb55a8 --- /dev/null +++ b/.github/workflows/installer_test.yml @@ -0,0 +1,36 @@ +name: Installer Integration Test + +on: + push: + branches: + - main + - master + pull_request: + workflow_dispatch: + +jobs: + integration-test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.3" + bundler-cache: true + working-directory: spec + + - name: Pre-pull Decidim image from GHCR + run: docker pull ghcr.io/decidim/decidim:latest + + - name: Build deploy.zip + run: | + cd install + zip -r ../deploy.zip . + + - name: Run installer integration tests + working-directory: spec + run: bundle exec rspec + timeout-minutes: 30 diff --git a/spec/.rspec b/spec/.rspec new file mode 100644 index 00000000..81e7b165 --- /dev/null +++ b/spec/.rspec @@ -0,0 +1,4 @@ +--format documentation +--color +-I . +--require spec_helper diff --git a/spec/Gemfile b/spec/Gemfile new file mode 100644 index 00000000..5b68b984 --- /dev/null +++ b/spec/Gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gem "rspec", "~> 3.13" +gem "serverspec", "~> 2.43" +gem "webrick", "~> 1.9" diff --git a/spec/Gemfile.lock b/spec/Gemfile.lock new file mode 100644 index 00000000..1b39036f --- /dev/null +++ b/spec/Gemfile.lock @@ -0,0 +1,51 @@ +GEM + remote: https://rubygems.org/ + specs: + base64 (0.3.0) + diff-lcs (1.6.2) + multi_json (1.21.1) + net-scp (4.1.0) + net-ssh (>= 2.6.5, < 8.0.0) + net-ssh (7.3.3) + net-telnet (0.2.0) + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-its (2.0.0) + rspec-core (>= 3.13.0) + rspec-expectations (>= 3.13.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-support (3.13.7) + serverspec (2.43.0) + multi_json + rspec (~> 3.0) + rspec-its + specinfra (~> 2.72) + sfl (2.3) + specinfra (2.95.1) + base64 + net-scp + net-ssh (>= 2.7) + net-telnet + sfl + webrick (1.9.2) + +PLATFORMS + ruby + x86_64-linux + +DEPENDENCIES + rspec (~> 3.13) + serverspec (~> 2.43) + webrick (~> 1.9) + +BUNDLED WITH + 2.6.9 diff --git a/spec/installer_spec.rb b/spec/installer_spec.rb new file mode 100644 index 00000000..f4b7c33c --- /dev/null +++ b/spec/installer_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Decidim interactive installer" do + it "answers all prompts and produces a working install directory" do + expect($install_dir).not_to be_nil + expect(File).to exist(File.join($install_dir, ".env")) + expect(File).to exist(File.join($install_dir, "docker-compose.yml")) + expect(File).to exist(File.join($install_dir, "Gemfile.wrapper")) + expect(File).to exist(File.join($install_dir, "Gemfile.local")) + end +end diff --git a/spec/serverspec_spec.rb b/spec/serverspec_spec.rb new file mode 100644 index 00000000..f261470c --- /dev/null +++ b/spec/serverspec_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe "Generated build artifacts" do + describe file(File.join($install_dir, ".env")) do + it { should exist } + its(:content) { should match(/DECIDIM_DOMAIN=localhost/) } + its(:content) { should match(/COMPOSE_PROFILES=db/) } + its(:content) { should match(/DATABASE_HOST=db/) } + its(:content) { should match(/SMTP_ADDRESS=smtp\.example\.com/) } + its(:content) { should match(/SMTP_PORT=587/) } + end + + describe file(File.join($install_dir, "docker-compose.yml")) do + it { should exist } + its(:content) { should match(/services:/) } + its(:content) { should match(/image: \$\{DECIDIM_IMAGE/) } + end + + describe file(File.join($install_dir, "Gemfile.wrapper")) do + it { should exist } + its(:content) { should match(/eval_gemfile "Gemfile"/) } + its(:content) { should match(/eval_gemfile "Gemfile\.local"/) } + end + + describe file(File.join($install_dir, "Gemfile.local")) do + it { should exist } + its(:content) { should match(/gem "sidekiq-cron"/) } + end +end + +RSpec.describe "Docker containers" do + describe command("docker ps --filter name=^decidim-app-1$ --filter status=running --format '{{.Names}}'") do + its(:stdout) { should match(/decidim-app-1/) } + its(:exit_status) { should eq 0 } + end + + describe command("docker ps --filter name=^decidim-db-1$ --filter status=running --format '{{.Names}}'") do + its(:stdout) { should match(/decidim-db-1/) } + its(:exit_status) { should eq 0 } + end + + describe command("docker ps --filter name=^decidim-cache-1$ --filter status=running --format '{{.Names}}'") do + its(:stdout) { should match(/decidim-cache-1/) } + its(:exit_status) { should eq 0 } + end +end + +RSpec.describe "Rails /up endpoint" do + describe command("docker exec decidim-app-1 curl -sf http://localhost:3000/up") do + its(:exit_status) { should eq 0 } + end +end + +RSpec.describe "Known container_name mismatch bug" do + # create_system_admin.sh hardcodes container names 'decidim' and 'decidim-db', + # but docker-compose.yml never sets container_name, so Docker Compose generates + # decidim-app-1 and decidim-db-1. The suite remains green while the bug is exposed. + + describe command("docker ps --filter name=^decidim$ --filter status=running --format '{{.Names}}'") do + its(:stdout) { should eq "" } + its(:exit_status) { should eq 0 } + end + + describe command("docker ps --filter name=^decidim-db$ --filter status=running --format '{{.Names}}'") do + its(:stdout) { should eq "" } + its(:exit_status) { should eq 0 } + end + + describe command("docker ps --filter name=^decidim-app-1$ --filter status=running --format '{{.Names}}'") do + its(:stdout) { should match(/decidim-app-1/) } + its(:exit_status) { should eq 0 } + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000..b5707344 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "rspec" +require "serverspec" +require "pty" +require "expect" +require "webrick" +require "fileutils" +require "shellwords" + +# Serverspec backend configuration +set :backend, :exec + +# Shared test state populated by the before(:suite) hook. +$install_dir = nil + +def project_root + File.expand_path("..", __dir__) +end + +# Build deploy.zip from the local install/ folder so the mock server can serve it. +def build_deploy_zip + install_folder = File.join(project_root, "install") + deploy_zip = File.join(project_root, "deploy.zip") + + puts "Building deploy.zip from #{install_folder}..." + unless system("cd #{install_folder.shellescape} && zip -r #{deploy_zip.shellescape} . > /dev/null 2>&1") + raise "Failed to build deploy.zip" + end + + deploy_zip +end + +# Start a lightweight WEBrick server that mocks the GitHub Releases download endpoint. +def start_mock_server(deploy_zip, port = 8765) + server = WEBrick::HTTPServer.new( + Port: port, + Logger: WEBrick::Log.new("/dev/null"), + AccessLog: [] + ) + + server.mount_proc "/releases/download/latest/deploy.zip" do |_req, res| + res.content_type = "application/zip" + res.body = File.read(deploy_zip, mode: "rb") + end + + Thread.new { server.start } + server +end + +# Drive install.sh through a PTY, answer every interactive prompt with the expect +# stdlib, and kill the process once the Docker app container is running and the +# Rails /up endpoint responds. +def run_installer! + install_dir = "/tmp/decidim" + FileUtils.rm_rf(install_dir) + FileUtils.mkdir_p(install_dir) + + repo_url = "http://127.0.0.1:8765" + + env = { + "REPOSITORY_PATH" => install_dir, + "REPOSITORY_URL" => repo_url, + "DECIDIM_IMAGE" => "ghcr.io/decidim/decidim:latest", + "TERM" => "dumb" + } + + script = File.join(project_root, "install", "install.sh") + + # Prompts emitted by install.sh and its sourced dependencies, in order. + # Each hash contains the substring we match on and the text we send back. + prompts = [ + { pattern: "Press enter to continue with the download", response: "", timeout: 30 }, + # docker pull happens here; on a cold cache it can take several minutes. + { pattern: "Can we proceed openning ports", response: "N", timeout: 600 }, + { pattern: "Press Enter to continue...", response: "", timeout: 30 }, + { pattern: "name of your organization", response: "Test Organization", timeout: 30 }, + { pattern: "domain:", response: "localhost", timeout: 30 }, + { pattern: "Do you have an external database already set up", response: "N", timeout: 30 }, + { pattern: "SMTP_USERNAME:", response: "test", timeout: 30 }, + { pattern: "SMTP_PASSWORD:", response: "test", timeout: 30 }, + { pattern: "SMTP_ADDRESS", response: "smtp.example.com", timeout: 30 }, + { pattern: "SMTP_DOMAIN", response: "localhost", timeout: 30 }, + { pattern: "SMTP_PORT", response: "587", timeout: 30 }, + { pattern: "Do you have an external S3-compatible bucket", response: "N", timeout: 30 }, + { pattern: "HERE API KEY:", response: "test", timeout: 30 } + ] + + # Background thread that polls Docker until the app container is running and + # responding to the Rails /up endpoint. + watcher = Thread.new do + app_up = false + 180.times do # 180 * 5 seconds = 15 minutes maximum + sleep 5 + + app_name = `docker ps --filter name=^decidim-app-1$ --filter status=running --format '{{.Names}}' 2>/dev/null`.strip + if app_name == "decidim-app-1" + `docker exec decidim-app-1 curl -sf http://localhost:3000/up 2>/dev/null`.strip + if $?.success? + app_up = true + puts "[watcher] App container is up and /up responded" + break + end + end + end + app_up + end + + env_prefix = env.map { |k, v| "#{k}=#{v.shellescape}" }.join(" ") + cmd = "#{env_prefix} bash #{script.shellescape}" + + installer_pid = nil + + PTY.spawn(cmd) do |r, w, pid| + installer_pid = pid + prompts.each do |prompt| + begin + result = r.expect(prompt[:pattern], prompt[:timeout]) + if result + w.puts prompt[:response] + puts "[expect] Answered prompt: #{prompt[:pattern][0..50]}..." + else + puts "[expect] TIMEOUT waiting for prompt: #{prompt[:pattern]}" + break + end + rescue Errno::EIO + puts "[expect] PTY closed unexpectedly" + break + rescue => e + puts "[expect] Error: #{e.message}" + break + end + end + end + + app_up = watcher.value + + if installer_pid + begin + Process.kill("TERM", installer_pid) + sleep 2 + Process.kill("KILL", installer_pid) + rescue Errno::ESRCH + # Process already exited + end + begin + Process.waitpid(installer_pid) + rescue Errno::ECHILD + # Already reaped + end + end + + unless app_up + raise "App container did not become ready within the timeout" + end + + install_dir +end + +RSpec.configure do |config| + config.before(:suite) do + deploy_zip = build_deploy_zip + @mock_server = start_mock_server(deploy_zip) + + puts "Running interactive installer (this may take several minutes)..." + $install_dir = run_installer! + puts "Installer finished. Install dir: #{$install_dir}" + end + + config.after(:suite) do + @mock_server&.shutdown if @mock_server.respond_to?(:shutdown) + + if $install_dir && File.exist?(File.join($install_dir, "docker-compose.yml")) + system("cd #{File.join($install_dir).shellescape} && docker compose down -v --remove-orphans > /dev/null 2>&1") + end + end +end