require "spec_helper"

module CC::Analyzer
  describe Container do
    describe "#run" do
      it "spawns docker run with the image, name, and options given" do
        container = Container.new(image: "codeclimate/foo", name: "name")

        expect_spawn(%w[ docker run --name name -i -t codeclimate/foo ])

        container.run(%w[ -i -t ])
      end

      it "spawns the command if present" do
        container = Container.new(image: "codeclimate/foo", command: "bar", name: "name")

        expect_spawn(%w[ docker run --name name codeclimate/foo bar ])

        container.run
      end

      it "spawns an array command if given" do
        container = Container.new(image: "codeclimate/foo", command: %w[ bar baz ], name: "name")

        expect_spawn(%w[ docker run --name name codeclimate/foo bar baz ])

        container.run
      end

      it "spawns an array command with spaces" do
        container = Container.new(
          image: "codeclimate/foo",
          command: %w[ bar baz\ bat ],
          name: "name",
        )

        expect_spawn(%w[ docker run --name name codeclimate/foo bar baz\ bat ])

        container.run
      end

      it "sends output to the defined handler splitting on the defined delimiter" do
        collected_output = []
        container = Container.new(image: "codeclimate/foo", name: "name")
        container.on_output("\0") { |output| collected_output << output }

        out = StringIO.new
        out.write("foo\0bar\0")
        out.rewind
        stub_spawn(out: out)

        container.run

        expect(collected_output).to eq %w[ foo bar ]
      end

      it "returns a result object" do
        container = Container.new(image: "codeclimate/foo", name: "name")
        stub_spawn
        result = container.run
        expect(result.exit_status).to eq 0
        expect(result.timed_out?).to eq false
        expect(result.duration).to be_between(-1, 2_000)
        expect(result.stderr).to eq ""
      end

      # N.B. these specs actually docker-runs things. This logic is critical and
      # so the real-world interaction is valuable.
      describe "stopping containers", slow: true do
        before do
          @name = "codeclimate-container-test"
          system("docker kill #{@name} &>/dev/null")
          system("docker rm #{@name} &>/dev/null")
        end

        it "can be stopped" do
          container = Container.new(
            image: "alpine",
            command: %w[sleep 10],
            name: @name,
          )

          run_container(container) do |c|
            # it needs a second to boot before stop will work
            sleep 2
            c.stop
          end

          assert_container_stopped
          expect(@container_result.timed_out?).to eq false
          expect(@container_result.exit_status).to be_present
          expect(@container_result.duration).to be_between(-1, 10_000)
        end

        it "times out slow containers" do
          with_timeout(1) do
            container = Container.new(
              image: "alpine",
              command: %w[sleep 10],
              name: @name,
            )

            run_container(container)

            assert_container_stopped
            expect(@container_result.timed_out?).to eq true
            expect(@container_result.exit_status).to be_present
            expect(@container_result.duration).to be_between(-1, 2_000)
          end
        end

        it "waits for IO parsing to finish" do
          stdout_lines = []
          container = Container.new(
            image: "alpine",
            command: ["echo", "line1\nline2\nline3"],
            name: @name,
          )
          container.on_output do |str|
            sleep 0.5
            stdout_lines << str
          end

          run_container(container)

          assert_container_stopped
          expect(@container_result.timed_out?).to eq false
          expect(stdout_lines).to eq %w[line1 line2 line3]
        end

        it "does not wait for IO when timed out" do
          with_timeout(1) do
            container = Container.new(
              image: "alpine",
              #command: %w[sleep 10],
              command: ["echo", "line1\nline2\nline3"],
              name: @name,
            )
            container.on_output do |str|
              sleep 10 and raise "Reader thread was not killed"
            end

            run_container(container)

            assert_container_stopped
          end
        end

        it "stops containers that emit more than the configured maximum output bytes" do
          begin
            ENV["CONTAINER_MAXIMUM_OUTPUT_BYTES"] = "4"
            container = Container.new(
              image: "alpine",
              command: ["echo", "hello"],
              name: @name,
            )

            run_container(container)

            assert_container_stopped
            expect(@container_result.maximum_output_exceeded?).to eq true
            expect(@container_result.timed_out?).to eq false
            expect(@container_result.exit_status).to be_present
            expect(@container_result.output_byte_count).to be > 4
          ensure
            ENV.delete("CONTAINER_MAXIMUM_OUTPUT_BYTES")
          end
        end

        it "rescues and records metrics when containers fail to stop" do
          with_timeout(1) do
            name = "cc-engines-rubocop-stable-abc-123"
            container = Container.new(
              image: "alpine",
              command: %w[sleep 10],
              name: "cc-engines-rubocop-stable-abc-123",
            )

            allow(POSIX::Spawn::Child).to receive(:new).
              and_raise(POSIX::Spawn::TimeoutExceeded)

            expect(CC::Analyzer.logger).to receive(:error)
            expect(CC::Analyzer.statsd).to receive(:increment).
              with("container.zombie")
            expect(CC::Analyzer.statsd).to receive(:increment).
              with("container.zombie.engine.rubocop.stable")

            begin
              run_container(container)
            ensure
              # Cleanup manually
              system("docker stop #{name} >/dev/null")
              system("docker rm #{name} >/dev/null")
            end
          end
        end

        def run_container(container)
          thread = Thread.new { @container_result = container.run }

          if block_given?
            yield container
          else
            container
          end
        ensure
          thread&.join
        end

        def assert_container_stopped
          expect(`docker ps --quiet --filter name=#{@name}`.strip).to eq ""
        end
      end
    end

    describe "#run when the process exits with a non-zero status" do
      before do
        @container = Container.new(image: "codeclimate/foo", name: "name")
        err = StringIO.new
        err.puts("error one")
        err.puts("error two")
        err.rewind
        status = double("Process::Status", exitstatus: 123)
        stub_spawn(status: status, err: err)
      end

      it "returns a result object" do
        result = @container.run
        expect(result.exit_status).to eq 123
        expect(result.timed_out?).to eq false
        expect(result.duration).to be_present
        expect(result.stderr).to eq "error one\nerror two\n"
      end
    end

    def stub_spawn(status: nil, out: StringIO.new, err: StringIO.new)
      pid = 42
      status ||= double("Process::Status", exitstatus: 0)

      allow(POSIX::Spawn).to receive(:popen4).and_return([pid, nil, out, err])
      allow(Process).to receive(:waitpid2).with(pid).and_return([nil, status])

      return [pid, out, err]
    end

    def expect_spawn(args, status: nil, out: StringIO.new, err: StringIO.new)
      pid = 42
      status ||= double("Process::Status", exitstatus: 0)

      expect(POSIX::Spawn).to receive(:popen4).with(*args).and_return([pid, nil, out, err])
      expect(Process).to receive(:waitpid2).with(pid).and_return([nil, status])

      return [pid, out, err]
    end

    def with_timeout(timeout)
      old_timeout = ENV["CONTAINER_TIMEOUT_SECONDS"]
      ENV["CONTAINER_TIMEOUT_SECONDS"] = timeout.to_s
      yield
    ensure
      ENV["CONTAINER_TIMEOUT_SECONDS"] = old_timeout
    end
  end
end
