# Every service here carries `stop_grace_period: 0s`. The containers run # `sleep` as PID 1, and PID 1 discards a signal for which no handler exists. # SIGTERM therefore does nothing, so Docker waits out the full stop timeout # and then sends SIGKILL. The wait costs 10 seconds per stop on CI, and each # example stops twice. These examples assert container identity, networks and # images, never a clean shutdown, so an immediate kill changes no result. RSpec.describe Orchestration::Runner do # Unique per parallel worker so concurrent specs don't clobber each other. let(:data_path) { Rails.root.join("tmp/stack#{ENV.fetch('TEST_ENV_NUMBER', nil)}").to_s } before do allow(Rails.configuration).to receive(:data_path).and_return( data_path, ) FileUtils.mkdir_p(data_path) end after { FileUtils.rm_rf(data_path) } # `.up` and `.recreate` end with ImageCleanup. It reads the containers of # the real `solectrus` project and removes images, so it must not run while # a :docker_stack spec owns that project. The lock serializes them. describe '.up', :docker_stack do before { skip_without_docker } context 'with minimal compose file' do before { File.write(File.join(data_path, 'compose.yaml'), <<~YAML) } name: helios-test services: test: image: alpine:latest command: sleep 10 stop_grace_period: 0s YAML after { compose_down } it 'starts containers in detached mode' do result = described_class.up expect(result).to be_a(Orchestration::CommandResult) expect(result.success?).to be true end end context 'with a renamed service leaving an orphan container' do before do File.write(File.join(data_path, 'compose.yaml'), <<~YAML) name: helios-test services: old: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML docker_quietly('docker compose up -d') File.write(File.join(data_path, 'compose.yaml'), <<~YAML) name: helios-test services: new: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML end after { compose_down } it 'removes orphaned containers from the previous service definition' do described_class.up expect(running_services).to contain_exactly('new') end end end describe '.start' do before { skip_without_docker } # The import renames services ('db' to 'postgresql'), and until the # replacement is started the old container keeps running under its old # name. Both mount the same data directory, and two PostgreSQL clusters on # one directory destroy the data (discussion #5878). Compose removes the # leftover before it starts the replacement, so the two never run at once. context 'when a rename left the old container running' do before do write_compose(<<~YAML) db: image: alpine:latest command: sleep 30 stop_grace_period: 0s keeper: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML docker_quietly('docker compose up -d') write_compose(<<~YAML) postgresql: image: alpine:latest command: sleep 30 stop_grace_period: 0s keeper: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML end after { compose_down } it 'removes the old container while starting the new one' do described_class.start('postgresql') expect(running_services).to contain_exactly('postgresql', 'keeper') end end # A HELIOS restart during a stack start kills the `up` it is running, # which can leave a container behind that hangs in no network. Compose # builds a *running* one anew by itself, so the one it starts as it found # it is a container that is not running. context 'when an interrupted start left a container without a network' do before do sweep_for_real write_compose(<<~YAML) test: image: alpine:latest command: sleep 30 stop_grace_period: 0s keeper: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML docker_quietly('docker compose up -d') docker_quietly('docker compose stop test') docker_quietly('docker network disconnect helios-test_default helios-test-test-1') end after { compose_down } it 'recreates it without touching the other containers' do keeper_id = container_id('helios-test-keeper-1') expect(container_networks('helios-test-test-1')).to be_empty described_class.start('test') aggregate_failures do expect(container_networks('helios-test-test-1')).to eq(['helios-test_default']) expect(running_services).to contain_exactly('test', 'keeper') expect(container_id('helios-test-keeper-1')).to eq(keeper_id) end end end # The sweep leaves running containers alone: compose repairs those itself # once an `up` covers them, and killing one that no `up` covers would # leave the user with nothing at all. context 'when a running container lost its network' do before do sweep_for_real write_compose(<<~YAML) test: image: alpine:latest command: sleep 30 stop_grace_period: 0s keeper: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML docker_quietly('docker compose up -d') docker_quietly('docker network disconnect helios-test_default helios-test-keeper-1') end after { compose_down } it 'keeps it alive while another service starts' do described_class.start('test') expect(running_services).to include('keeper') end it 'lets compose repair it once the service itself starts' do described_class.start('keeper') expect(container_networks('helios-test-keeper-1')).to eq(['helios-test_default']) end end # Everything rests on a stopped container keeping its network entry. Were # that not so, every `up` would delete every stopped service of the stack. context 'when another service is merely stopped' do before do sweep_for_real write_compose(<<~YAML) test: image: alpine:latest command: sleep 30 stop_grace_period: 0s keeper: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML docker_quietly('docker compose up -d') docker_quietly('docker compose stop keeper') end after { compose_down } it 'leaves its container alone' do keeper_id = container_id('helios-test-keeper-1') described_class.start('test') expect(container_id('helios-test-keeper-1')).to eq(keeper_id) end end # The price of sweeping the whole project: a stopped container of a # service outside the `up` is removed and nothing brings it back, so the # service reads as "not created" until it is started. That beats keeping # a container that can never reach its dependencies. context 'when a stopped container of another service lost its network' do before do sweep_for_real write_compose(<<~YAML) test: image: alpine:latest command: sleep 30 stop_grace_period: 0s keeper: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML docker_quietly('docker compose up -d') docker_quietly('docker compose stop keeper') docker_quietly('docker network disconnect helios-test_default helios-test-keeper-1') end after { compose_down } it 'removes it and leaves it uncreated' do described_class.start('test') expect(container_id('helios-test-keeper-1')).to be_nil end end end describe '.down' do before { skip_without_docker } context 'with running containers' do before do File.write(File.join(data_path, 'compose.yaml'), <<~YAML) name: helios-test services: test: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML docker_quietly('docker compose up -d') end it 'stops and removes containers' do result = described_class.down expect(result.success?).to be true end end end describe '.recreate', :docker_stack do before { skip_without_docker } context 'when the user changes a service tag' do let(:project) { 'helios-recreate-test' } # Re-tag the already-cached alpine:latest as two distinct refs so the test # exercises an "old → new" image transition without any registry pulls. let(:old_image) { 'helios-test/recreate:v1' } let(:new_image) { 'helios-test/recreate:v2' } before do system('docker', 'pull', '-q', 'alpine:latest', out: File::NULL, err: File::NULL) system('docker', 'tag', 'alpine:latest', old_image, out: File::NULL, err: File::NULL) system('docker', 'tag', 'alpine:latest', new_image, out: File::NULL, err: File::NULL) # `pull_policy: never` keeps `docker compose pull` from hitting the # registry — these are local-only refs of alpine:latest. File.write(File.join(data_path, 'compose.yaml'), <<~YAML) name: #{project} services: test: image: #{old_image} pull_policy: never command: sleep 30 stop_grace_period: 0s YAML described_class.up File.write(File.join(data_path, 'compose.yaml'), <<~YAML) name: #{project} services: test: image: #{new_image} pull_policy: never command: sleep 30 stop_grace_period: 0s YAML end after do compose_down system('docker', 'image', 'rm', new_image, out: File::NULL, err: File::NULL) end it 'removes the previously deployed image' do previous = instance_double(Orchestration::Container, image: old_image) allow(Orchestration::Container).to receive(:find).with('test').and_return(previous) expect(image_exists?(old_image)).to be(true) described_class.recreate('test') expect(image_exists?(old_image)).to be(false) expect(image_exists?(new_image)).to be(true) end end # Recreating the replacement of a renamed service is the other way its # container comes into being, so it clears the leftover just as #start does. context 'when a rename left the old container running' do before do write_compose(<<~YAML) db: image: alpine:latest command: sleep 30 stop_grace_period: 0s keeper: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML docker_quietly('docker compose up -d') write_compose(<<~YAML) postgresql: image: alpine:latest pull_policy: never command: sleep 30 stop_grace_period: 0s keeper: image: alpine:latest command: sleep 30 stop_grace_period: 0s YAML end after { compose_down } it 'removes the old container while creating the new one' do described_class.recreate('postgresql') expect(running_services).to contain_exactly('postgresql', 'keeper') end end def image_exists?(image) system('docker', 'image', 'inspect', image, out: File::NULL, err: File::NULL) end end def write_compose(services) File.write( File.join(data_path, 'compose.yaml'), "name: helios-test\nservices:\n#{services.indent(2)}", ) end def compose_down docker_quietly('docker compose down -v') end # spec/support/detached_containers.rb stubs the sweep out for every spec, so # that a real `compose up` here cannot touch the developer's own stack. The # examples about the sweep want it back, scoped to their own project. def sweep_for_real stub_const('Orchestration::PROJECT_NAME', 'helios-test') allow(Orchestration::DetachedContainers).to receive(:sweep).and_call_original end def docker_quietly(command) system(command, chdir: data_path, out: File::NULL, err: File::NULL) end # Names of the networks a container is connected to. def container_networks(name) Orchestration::DockerCli.inspect_container(name)&.dig('NetworkSettings', 'Networks')&.keys || [] end def container_id(name) Orchestration::DockerCli.inspect_container(name)&.fetch('Id') end # Service names of the containers currently running for the test project. def running_services format = '{{.Label "com.docker.compose.service"}}' `docker ps --filter label=com.docker.compose.project=helios-test --format '#{format}'` .split("\n") end describe '.ps' do before { skip_without_docker } context 'with compose file' do before { File.write(File.join(data_path, 'compose.yaml'), <<~YAML) } name: helios-test services: test: image: alpine:latest YAML it 'lists container status' do result = described_class.ps expect(result.success?).to be true end end end describe '.config_hashes' do # Compose resolves the bare `environment: - KEY` form from the process # environment before it consults --env-file. HELIOS' own container carries # an ADMIN_PASSWORD frozen at creation time, which used to shadow .env: the # hash never moved, so a password change was invisible to the drift # detection and the service was never flagged for restart. before do skip_without_docker File.write(File.join(data_path, 'compose.yaml'), <<~YAML) name: helios-test services: dashboard: image: alpine:latest environment: - ADMIN_PASSWORD YAML end around do |example| previous = ENV.fetch('ADMIN_PASSWORD', nil) example.run ensure ENV['ADMIN_PASSWORD'] = previous end def hash_for(password) File.write(File.join(data_path, '.env'), "ADMIN_PASSWORD=#{password}\n") described_class.config_hashes.fetch('dashboard') end it 'reflects a changed .env value despite a stale process environment' do ENV['ADMIN_PASSWORD'] = 'frozen-at-container-creation' expect(hash_for('first')).not_to eq(hash_for('second')) end it 'ignores the value inherited from the process environment' do ENV['ADMIN_PASSWORD'] = 'stale-a' with_stale_a = hash_for('current') ENV['ADMIN_PASSWORD'] = 'stale-b' with_stale_b = hash_for('current') expect(with_stale_a).to eq(with_stale_b) end end end