Ctrl K
ComunidadGuías…

spec/integration/orchestration/runner_spec.rb

Rama develop · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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