# frozen_string_literal: true

require 'spec_helper'
require 'fakefs/spec_helpers'

module Beaker
  platforms = [
    'ubuntu-14.04-x86_64',
    'cumulus-2.2-x86_64',
    'fedora-22-x86_64',
    'centos-7-x86_64',
    'sles-12-x86_64',
    'archlinux-2017.12.27-x86_64',
    'amazon-2023-x86_64',
  ]

  describe Docker do
    require 'docker'

    let(:hosts) do
      the_hosts = make_hosts
      the_hosts[2]['dockeropts'] = {
        'Labels' => {
          'one' => 3,
          'two' => 4,
        },
      }
      the_hosts
    end

    let(:logger) do
      logger = instance_double(Logger)
      allow(logger).to receive(:debug)
      allow(logger).to receive(:info)
      allow(logger).to receive(:warn)
      allow(logger).to receive(:error)
      allow(logger).to receive(:notify)
      logger
    end

    let(:options) do
      {
        logger: logger,
        forward_ssh_agent: true,
        provision: true,
        dockeropts: {
          'Labels' => {
            'one' => 1,
            'two' => 2,
          },
        },
      }
    end

    let(:image) do
      image = instance_double(::Docker::Image)
      allow(image).to receive(:id).and_return('zyxwvu')
      allow(image).to receive(:tag)
      image
    end

    let(:container_mode) do
      'rootless'
    end

    let(:container_config) do
      conf = {
        'HostConfig' => {
          'NetworkMode' => 'slirp4netns',
        },
        'NetworkSettings' => {
          'IPAddress' => '192.0.2.1',
          'Ports' => {
            '22/tcp' => [
              {
                'HostIp' => '0.0.0.0',
                'HostPort' => 8022,
              },
            ],
          },
          'Gateway' => '192.0.2.254',
        },
      }

      conf['HostConfig']['NetworkMode'] = 'bridge' unless container_mode == 'rootless'

      conf
    end

    let(:container) do
      container = instance_double(::Docker::Container)
      allow(container).to receive(:id).and_return('abcdef')
      allow(container).to receive(:start)
      allow(container).to receive(:stats)
      allow(container).to receive(:info).and_return(
        *(0..2).map { |index| { 'Names' => ["/spec-container-#{index}"] } },
      )
      allow(container).to receive(:json).and_return(container_config)
      allow(container).to receive(:kill)
      allow(container).to receive(:delete)
      allow(container).to receive(:exec)
      container
    end

    let(:docker) { ::Beaker::Docker.new(hosts, options) }

    let(:docker_options) { nil }

    let(:version) { { 'ApiVersion' => '1.18', 'Arch' => 'amd64', 'GitCommit' => '4749651', 'GoVersion' => 'go1.4.2', 'KernelVersion' => '3.16.0-37-generic', 'Os' => 'linux', 'Version' => '1.6.0' } }

    before do
      allow(::Docker).to receive(:rootless?).and_return(true)
      @docker_host = ENV.fetch('DOCKER_HOST', nil)
      ENV.delete('DOCKER_HOST') if @docker_host
    end

    after do
      ENV['DOCKER_HOST'] = @docker_host if @docker_host
    end

    context 'with connection failure' do
      describe '#initialize' do
        before do
          require 'excon'
          allow(::Docker).to receive(:version).and_raise(Excon::Errors::SocketError.new(StandardError.new('oops'))).exactly(4).times
        end

        it 'fails when docker not present' do
          expect { docker }.to raise_error(RuntimeError, /Docker instance not connectable/)
          expect { docker }.to raise_error(RuntimeError, /Check your DOCKER_HOST variable has been set/)
          expect { docker }.to raise_error(RuntimeError, /If you are on OSX or Windows, you might not have Docker Machine setup correctly/)
          expect { docker }.to raise_error(RuntimeError, /Error was: oops/)
        end
      end
    end

    context 'with a working connection' do
      before do
        # Stub out all of the docker-api gem. we should never really call it from these tests
        allow(::Docker).to receive(:options).and_return(docker_options)
        allow(::Docker).to receive(:podman?).and_return(false)
        allow(::Docker).to receive(:version).and_return(version)
        allow(::Docker::Image).to receive(:build).and_return(image)
        allow(::Docker::Image).to receive(:create).and_return(image)
        allow(::Docker::Container).to receive(:create).and_return(container)
      end

      describe '#initialize' do
        it 'sets Docker options' do
          expect(::Docker).to receive(:options=).with({ write_timeout: 300, read_timeout: 300 }).once

          docker
        end

        context 'when Docker options are already set' do
          let(:docker_options) { { write_timeout: 600, foo: :bar } }

          it 'does not override Docker options' do
            expect(::Docker).to receive(:options=).with({ write_timeout: 600, read_timeout: 300, foo: :bar }).once

            docker
          end
        end

        it 'checks the Docker gem can work with the api' do
          expect { docker }.not_to raise_error
        end

        it 'hooks the Beaker logger into the Docker one' do
          expect(::Docker).to receive(:logger=).with(logger)

          docker
        end
      end

      describe '#install_ssh_components' do
        let(:test_container) { object_double(container) }
        let(:host) { hosts[0] }

        before do
          allow(docker).to receive(:dockerfile_for)
        end

        platforms.each do |platform|
          it 'calls exec at least twice' do
            host['platform'] = platform
            expect(test_container).to receive(:exec).at_least(:twice)
            docker.install_ssh_components(test_container, host)
          end
        end

        it 'accepts alpine as valid platform' do
          host['platform'] = 'alpine-3.8-x86_64'
          expect(test_container).to receive(:exec).at_least(:twice)
          docker.install_ssh_components(test_container, host)
        end

        it 'raises an error with an unsupported platform' do
          host['platform'] = 'boogeyman-2000-x86_64'
          expect { docker.install_ssh_components(test_container, host) }.to raise_error(RuntimeError, /boogeyman/)
        end
      end

      describe '#provision' do
        before do
          allow(docker).to receive(:dockerfile_for)
        end

        context 'when the host has "tag" defined' do
          before do
            hosts.each do |host|
              host['tag'] = 'my_tag'
            end
          end

          it 'tags the image with the value of the tag' do
            expect(image).to receive(:tag).with({ repo: 'my_tag' }).exactly(3).times
            docker.provision
          end
        end

        context 'when the host has "use_image_entry_point" set to true on the host' do
          before do
            hosts.each do |host|
              host['use_image_entry_point'] = true
            end
          end

          it 'does not call #dockerfile_for but run methods necessary for ssh installation' do
            expect(docker).not_to receive(:dockerfile_for)
            expect(docker).to receive(:install_ssh_components).exactly(3).times # once per host
            expect(docker).to receive(:fix_ssh).exactly(3).times # once per host
            docker.provision
          end
        end

        context 'when the host has a "dockerfile" for the host' do
          before do
            allow(docker).to receive(:buildargs_for).and_return('buildargs')
            hosts.each do |host|
              host['dockerfile'] = 'mydockerfile'
            end
          end

          it 'does not call #dockerfile_for but run methods necessary for ssh installation' do
            allow(File).to receive(:exist?).with('mydockerfile').and_return(true)
            allow(::Docker::Image).to receive(:build_from_dir).with('/', hash_including(rm: true, buildargs: 'buildargs')).and_return(image)
            expect(docker).not_to receive(:dockerfile_for)
            expect(docker).to receive(:install_ssh_components).exactly(3).times # once per host
            expect(docker).to receive(:fix_ssh).exactly(3).times # once per host
            docker.provision
          end
        end

        it 'calls image create for hosts when use_image_as_is is defined' do
          hosts.each do |host|
            host['use_image_as_is'] = true
            expect(docker).not_to receive(:install_ssh_components)
            expect(docker).not_to receive(:fix_ssh)
            expect(::Docker::Image).to receive(:create).with('fromImage' => host['image']) # once per host
            expect(::Docker::Image).not_to receive(:build)
            expect(::Docker::Image).not_to receive(:build_from_dir)
          end

          docker.provision
        end

        it 'calls dockerfile_for with all the hosts' do
          hosts.each do |host|
            allow(docker).to receive(:dockerfile_for).with(host).and_return('')
            expect(docker).not_to receive(:install_ssh_components)
            expect(docker).not_to receive(:fix_ssh)
            expect(docker).to receive(:dockerfile_for).with(host)
          end

          docker.provision
        end

        it 'passes the Dockerfile on to Docker::Image.create' do
          allow(docker).to receive(:dockerfile_for).and_return('special testing value')
          expect(::Docker::Image).to receive(:build).with('special testing value', { rm: true, buildargs: '{}' })

          docker.provision
        end

        it 'passes the buildargs from ENV DOCKER_BUILDARGS on to Docker::Image.create' do
          allow(docker).to receive(:dockerfile_for).and_return('special testing value')
          ENV['DOCKER_BUILDARGS'] = 'HTTP_PROXY=http://1.1.1.1:3128'
          expect(::Docker::Image).to receive(:build).with('special testing value', { rm: true, buildargs: '{"HTTP_PROXY":"http://1.1.1.1:3128"}' })

          docker.provision
        end

        it 'passes the multiple buildargs from ENV DOCKER_BUILDARGS on to Docker::Image.create' do
          allow(docker).to receive(:dockerfile_for).and_return('special testing value')
          ENV['DOCKER_BUILDARGS'] = 'HTTP_PROXY=http://1.1.1.1:3128	HTTPS_PROXY=https://1.1.1.1:3129'
          expect(::Docker::Image).to receive(:build).with('special testing value', { rm: true, buildargs: '{"HTTP_PROXY":"http://1.1.1.1:3128","HTTPS_PROXY":"https://1.1.1.1:3129"}' })

          docker.provision
        end

        it 'creates a container based on the Image (identified by image.id)' do
          hosts.each_with_index do |host, index|
            expect(::Docker::Container).to receive(:create).with({
                                                                   'Image' => image.id,
                                                                   'Hostname' => host.name,
                                                                   'HostConfig' => {
                                                                     'PortBindings' => {
                                                                       '22/tcp' => [{ 'HostPort' => /\b\d{4}\b/, 'HostIp' => '0.0.0.0' }],
                                                                     },
                                                                     'PublishAllPorts' => true,
                                                                     'Privileged' => true,
                                                                     'RestartPolicy' => {
                                                                       'Name' => 'always',
                                                                     },
                                                                   },
                                                                   'Labels' => {
                                                                     'one' => ((index == 2) ? 3 : 1),
                                                                     'two' => ((index == 2) ? 4 : 2),
                                                                   },
                                                                   'name' => /\Abeaker-/,
                                                                 })
          end

          docker.provision
        end

        it 'creates a named container based on the Image (identified by image.id)' do
          hosts.each_with_index do |host, index|
            container_name = "spec-container-#{index}"
            host['docker_container_name'] = container_name

            allow(::Docker::Container).to receive(:all).and_return([])
            expect(::Docker::Container).to receive(:create).with({
                                                                   'Image' => image.id,
                                                                   'Hostname' => host.name,
                                                                   'name' => container_name,
                                                                   'HostConfig' => {
                                                                     'PortBindings' => {
                                                                       '22/tcp' => [{ 'HostPort' => /\b\d{4}\b/, 'HostIp' => '0.0.0.0' }],
                                                                     },
                                                                     'PublishAllPorts' => true,
                                                                     'Privileged' => true,
                                                                     'RestartPolicy' => {
                                                                       'Name' => 'always',
                                                                     },
                                                                   },
                                                                   'Labels' => {
                                                                     'one' => ((index == 2) ? 3 : 1),
                                                                     'two' => ((index == 2) ? 4 : 2),
                                                                   },
                                                                 })
          end

          docker.provision
        end

        it 'creates a container with volumes bound' do
          hosts.each_with_index do |host, index|
            host['mount_folders'] = {
              'mount1' => {
                'host_path' => '/source_folder',
                'container_path' => '/mount_point',
              },
              'mount2' => {
                'host_path' => '/another_folder',
                'container_path' => '/another_mount',
                'opts' => 'ro',
              },
              'mount3' => {
                'host_path' => '/different_folder',
                'container_path' => '/different_mount',
                'opts' => 'rw',
              },
              'mount4' => {
                'host_path' => './',
                'container_path' => '/relative_mount',
              },
              'mount5' => {
                'host_path' => 'local_folder',
                'container_path' => '/another_relative_mount',
              },
            }

            expect(::Docker::Container).to receive(:create).with({
                                                                   'Image' => image.id,
                                                                   'Hostname' => host.name,
                                                                   'HostConfig' => {
                                                                     'Binds' => [
                                                                       '/source_folder:/mount_point:z',
                                                                       '/another_folder:/another_mount:ro',
                                                                       '/different_folder:/different_mount:rw',
                                                                       "#{File.expand_path('./')}:/relative_mount:z",
                                                                       "#{File.expand_path('local_folder')}:/another_relative_mount:z",
                                                                     ],
                                                                     'PortBindings' => {
                                                                       '22/tcp' => [{ 'HostPort' => /\b\d{4}\b/, 'HostIp' => '0.0.0.0' }],
                                                                     },
                                                                     'PublishAllPorts' => true,
                                                                     'Privileged' => true,
                                                                     'RestartPolicy' => {
                                                                       'Name' => 'always',
                                                                     },
                                                                   },
                                                                   'Labels' => {
                                                                     'one' => ((index == 2) ? 3 : 1),
                                                                     'two' => ((index == 2) ? 4 : 2),
                                                                   },
                                                                   'name' => /\Abeaker-/,
                                                                 })
          end

          docker.provision
        end

        it 'creates a container with capabilities added' do
          hosts.each_with_index do |host, index|
            host['docker_cap_add'] = %w[NET_ADMIN SYS_ADMIN]

            expect(::Docker::Container).to receive(:create).with({
                                                                   'Image' => image.id,
                                                                   'Hostname' => host.name,
                                                                   'HostConfig' => {
                                                                     'PortBindings' => {
                                                                       '22/tcp' => [{ 'HostPort' => /\b\d{4}\b/, 'HostIp' => '0.0.0.0' }],
                                                                     },
                                                                     'PublishAllPorts' => true,
                                                                     'RestartPolicy' => {
                                                                       'Name' => 'always',
                                                                     },
                                                                     'CapAdd' => %w[NET_ADMIN SYS_ADMIN],
                                                                   },
                                                                   'Labels' => {
                                                                     'one' => ((index == 2) ? 3 : 1),
                                                                     'two' => ((index == 2) ? 4 : 2),
                                                                   },
                                                                   'name' => /\Abeaker-/,
                                                                 })
          end

          docker.provision
        end

        it 'creates a container with port bindings' do
          hosts.each_with_index do |host, index|
            host['docker_port_bindings'] = {
              '8080/tcp' => [{ 'HostPort' => '8080', 'HostIp' => '0.0.0.0' }],
            }

            expect(::Docker::Container).to receive(:create).with({
                                                                   'ExposedPorts' => {
                                                                     '8080/tcp' => {},
                                                                   },
                                                                   'Image' => image.id,
                                                                   'Hostname' => host.name,
                                                                   'HostConfig' => {
                                                                     'PortBindings' => {
                                                                       '22/tcp' => [{ 'HostPort' => /\b\d{4}\b/, 'HostIp' => '0.0.0.0' }],
                                                                       '8080/tcp' => [{ 'HostPort' => '8080', 'HostIp' => '0.0.0.0' }],
                                                                     },
                                                                     'PublishAllPorts' => true,
                                                                     'Privileged' => true,
                                                                     'RestartPolicy' => {
                                                                       'Name' => 'always',
                                                                     },
                                                                   },
                                                                   'Labels' => {
                                                                     'one' => ((index == 2) ? 3 : 1),
                                                                     'two' => ((index == 2) ? 4 : 2),
                                                                   },
                                                                   'name' => /\Abeaker-/,
                                                                 })
          end

          docker.provision
        end

        it 'starts the container' do
          expect(container).to receive(:start)

          docker.provision
        end

        context 'when connecting to ssh' do
          %w[rootless privileged].each do |mode|
            context "when #{mode}" do
              let(:container_mode) do
                mode
              end

              it 'exposes port 22 to beaker' do
                docker.provision

                expect(hosts[0]['ip']).to eq '127.0.0.1'
                expect(hosts[0]['port']).to eq 8022
              end

              it 'exposes port 22 to beaker when using DOCKER_HOST' do
                ENV['DOCKER_HOST'] = 'tcp://192.0.2.2:2375'
                docker.provision

                expect(hosts[0]['ip']).to eq '192.0.2.2'
                expect(hosts[0]['port']).to eq 8022
              end

              it 'has ssh agent forwarding enabled' do
                docker.provision

                expect(hosts[0]['ip']).to eq '127.0.0.1'
                expect(hosts[0]['port']).to eq 8022
                expect(hosts[0]['ssh'][:password]).to eq 'root'
                expect(hosts[0]['ssh'][:port]).to eq 8022
                expect(hosts[0]['ssh'][:forward_agent]).to be true
              end

              it 'connects to gateway ip' do
                FakeFS do
                  FileUtils.touch('/.dockerenv')
                  docker.provision

                  expect(hosts[0]['ip']).to eq '192.0.2.254'
                  expect(hosts[0]['port']).to eq 8022
                end
              end
            end
          end
        end

        it 'generates a new /etc/hosts file referencing each host' do
          ENV['DOCKER_HOST'] = nil
          docker.provision
          hosts.each do |host|
            allow(docker).to receive(:get_domain_name).with(host).and_return('labs.lan')
            etc_hosts = <<~HOSTS
              127.0.0.1\tlocalhost localhost.localdomain
              192.0.2.1\tvm1.labs.lan vm1
              192.0.2.1\tvm2.labs.lan vm2
              192.0.2.1\tvm3.labs.lan vm3
            HOSTS
            expect(docker).to receive(:set_etc_hosts).with(host, etc_hosts).once
          end
          docker.hack_etc_hosts(hosts, options)
        end

        it 'records the image and container for later' do
          docker.provision

          expect(hosts[0]['docker_image_id']).to eq image.id
          expect(hosts[0]['docker_container_id']).to eq container.id
        end

        context 'when provision=false' do
          let(:options) do
            {
              logger: logger,
              forward_ssh_agent: true,
              provision: false,
            }
          end

          it 'fixes ssh' do
            hosts.each_with_index do |host, index|
              container_name = "spec-container-#{index}"
              host['docker_container_name'] = container_name

              allow(::Docker::Container).to receive(:all).and_return([container])
              expect(docker).to receive(:fix_ssh).once
            end
            docker.provision
          end

          it 'does not create a container if a named one already exists' do
            hosts.each_with_index do |host, index|
              container_name = "spec-container-#{index}"
              host['docker_container_name'] = container_name

              allow(::Docker::Container).to receive(:all).and_return([container])
              expect(::Docker::Container).not_to receive(:create)
            end

            docker.provision
          end
        end
      end

      describe '#cleanup' do
        before do
          # get into a state where there's something to clean
          allow(::Docker::Container).to receive(:all).and_return([container])
          allow(::Docker::Image).to receive(:remove).with(image.id)
          allow(docker).to receive(:dockerfile_for)
          docker.provision
        end

        it 'stops the containers' do
          allow(docker).to receive(:sleep).and_return(true)
          expect(container).to receive(:kill)
          docker.cleanup
        end

        it 'deletes the containers' do
          allow(docker).to receive(:sleep).and_return(true)
          expect(container).to receive(:delete)
          docker.cleanup
        end

        it 'deletes the images' do
          allow(docker).to receive(:sleep).and_return(true)
          expect(::Docker::Image).to receive(:remove).with(image.id)
          docker.cleanup
        end

        it 'does not delete the image if docker_preserve_image is set to true' do
          allow(docker).to receive(:sleep).and_return(true)
          hosts.each do |host|
            host['docker_preserve_image'] = true
          end
          expect(::Docker::Image).not_to receive(:remove)
          docker.cleanup
        end

        it 'deletes the image if docker_preserve_image is set to false' do
          allow(docker).to receive(:sleep).and_return(true)
          hosts.each do |host|
            host['docker_preserve_image'] = false
          end
          expect(::Docker::Image).to receive(:remove).with(image.id)
          docker.cleanup
        end
      end

      describe '#dockerfile_for' do
        FakeFS.deactivate!
        it 'raises on an unsupported platform' do
          expect { docker.send(:dockerfile_for, { 'platform' => 'a_sidewalk', 'image' => 'foobar' }) }.to raise_error(/platform a_sidewalk not yet supported/)
        end

        it 'sets "ENV container docker"' do
          FakeFS.deactivate!
          platforms.each do |platform|
            dockerfile = docker.send(:dockerfile_for, {
                                       'platform' => platform,
                                       'image' => 'foobar',
                                     })
            expect(dockerfile).to match(/ENV container docker/)
          end
        end

        it 'adds docker_image_first_commands as RUN statements' do
          FakeFS.deactivate!
          platforms.each do |platform|
            dockerfile = docker.send(:dockerfile_for, {
                                       'platform' => platform,
                                       'image' => 'foobar',
                                       'docker_image_first_commands' => [
                                         'special one',
                                         'special two',
                                         'special three',
                                       ],
                                     })

            expect(dockerfile).to match(/RUN special one\nRUN special two\nRUN special three/)
          end
        end

        it 'adds docker_image_commands as RUN statements' do
          FakeFS.deactivate!
          platforms.each do |platform|
            dockerfile = docker.send(:dockerfile_for, {
                                       'platform' => platform,
                                       'image' => 'foobar',
                                       'docker_image_commands' => [
                                         'special one',
                                         'special two',
                                         'special three',
                                       ],
                                     })

            expect(dockerfile).to match(/RUN special one\nRUN special two\nRUN special three/)
          end
        end

        it 'adds docker_image_entrypoint' do
          FakeFS.deactivate!
          platforms.each do |platform|
            dockerfile = docker.send(:dockerfile_for, {
                                       'platform' => platform,
                                       'image' => 'foobar',
                                       'docker_image_entrypoint' => '/bin/bash',
                                     })

            expect(dockerfile).to match(%r{ENTRYPOINT /bin/bash})
          end
        end

        it 'uses zypper on sles' do
          FakeFS.deactivate!
          dockerfile = docker.send(:dockerfile_for, {
                                     'platform' => 'sles-12-x86_64',
                                     'image' => 'foobar',
                                   })

          expect(dockerfile).to match(/zypper -n in openssh/)
        end

        (22..39).to_a.each do |fedora_release|
          it "uses dnf on fedora #{fedora_release}" do
            FakeFS.deactivate!
            dockerfile = docker.send(:dockerfile_for, {
                                       'platform' => "fedora-#{fedora_release}-x86_64",
                                       'image' => 'foobar',
                                     })

            expect(dockerfile).to match(/dnf install -y sudo/)
          end
        end

        it 'uses pacman on archlinux' do
          FakeFS.deactivate!
          dockerfile = docker.send(:dockerfile_for, {
                                     'platform' => 'archlinux-current-x86_64',
                                     'image' => 'foobar',
                                   })

          expect(dockerfile).to match(/pacman --sync --refresh --noconfirm archlinux-keyring/)
          expect(dockerfile).to match(/pacman --sync --refresh --noconfirm --sysupgrade/)
          expect(dockerfile).to match(/pacman --sync --noconfirm curl ntp net-tools openssh/)
        end
      end

      describe '#fix_ssh' do
        let(:test_container) { object_double(container) }
        let(:host) { hosts[0] }

        before do
          allow(test_container).to receive(:id).and_return('abcdef')
        end

        it 'calls exec once when called without host' do
          expect(test_container).to receive(:exec).once.with(
            include(/PermitRootLogin/) &&
            include(/PasswordAuthentication/) &&
            include(/UseDNS/) &&
            include(/MaxAuthTries/),
          )
          docker.send(:fix_ssh, test_container)
        end

        it 'execs sshd on alpine' do
          host['platform'] = 'alpine-3.8-x86_64'
          expect(test_container).to receive(:exec).with(array_including('sed'))
          expect(test_container).to receive(:exec).with(%w[/usr/sbin/sshd])
          docker.send(:fix_ssh, test_container, host)
        end

        it 'restarts ssh service on ubuntu' do
          host['platform'] = 'ubuntu-20.04-x86_64'
          expect(test_container).to receive(:exec).with(array_including('sed'))
          expect(test_container).to receive(:exec).with(%w[service ssh restart])
          docker.send(:fix_ssh, test_container, host)
        end

        it 'restarts sshd service otherwise' do
          host['platform'] = 'boogeyman-2000-x86_64'
          expect(test_container).to receive(:exec).with(array_including('sed'))
          expect(test_container).to receive(:exec).with(%w[service sshd restart])
          docker.send(:fix_ssh, test_container, host)
        end
      end
    end
  end
end