Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/installer_test.yml
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions spec/.rspec
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
--format documentation
--color
-I .
--require spec_helper
5 changes: 5 additions & 0 deletions spec/Gemfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
source "https://rubygems.org"

gem "rspec", "~> 3.13"
gem "serverspec", "~> 2.43"
gem "webrick", "~> 1.9"
51 changes: 51 additions & 0 deletions spec/Gemfile.lock
Original file line number Diff line number Diff line change
@@ -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
13 changes: 13 additions & 0 deletions spec/installer_spec.rb
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions spec/serverspec_spec.rb
Original file line number Diff line number Diff line change
@@ -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
177 changes: 177 additions & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
@@ -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
Loading