From b59e44f0b11917a1e796a82f6ae487aa63c4c102 Mon Sep 17 00:00:00 2001
From: Gabriel Mazetto <gabriel@gitlab.com>
Date: Wed, 14 Feb 2024 06:02:13 +0100
Subject: [PATCH] Introduce Shell::Pipeline

---
 .../lib/gitlab/backup/cli/shell.rb            |  1 +
 .../lib/gitlab/backup/cli/shell/pipeline.rb   | 58 +++++++++++++
 .../gitlab/backup/cli/shell/pipeline_spec.rb  | 83 +++++++++++++++++++
 3 files changed, 142 insertions(+)
 create mode 100644 gems/gitlab-backup-cli/lib/gitlab/backup/cli/shell/pipeline.rb
 create mode 100644 gems/gitlab-backup-cli/spec/gitlab/backup/cli/shell/pipeline_spec.rb

diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/shell.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/shell.rb
index 23d63ca90ae58..6107e14333f07 100644
--- a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/shell.rb
+++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/shell.rb
@@ -7,6 +7,7 @@ module Backup
     module Cli
       module Shell
         autoload :Command, 'gitlab/backup/cli/shell/command'
+        autoload :Pipeline, 'gitlab/backup/cli/shell/pipeline'
       end
     end
   end
diff --git a/gems/gitlab-backup-cli/lib/gitlab/backup/cli/shell/pipeline.rb b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/shell/pipeline.rb
new file mode 100644
index 0000000000000..6a18c54ff5932
--- /dev/null
+++ b/gems/gitlab-backup-cli/lib/gitlab/backup/cli/shell/pipeline.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Backup
+    module Cli
+      module Shell
+        class Pipeline
+          # Result data structure from running a pipeline
+          #
+          # @attr [String] stderr
+          # @attr [Array<Process::Status>] status
+          # @attr [Float] duration
+          Result = Struct.new(:stderr, :status_list, :duration, keyword_init: true)
+
+          attr_reader :shell_commands
+
+          # @param [Array<Shell::Command>] shell_commands
+          def initialize(*shell_commands)
+            @shell_commands = shell_commands
+          end
+
+          # Run commands in pipeline with optional input or output redirection
+          #
+          # @param [IO|String|Array] input stdin redirection
+          # @param [IO|String|Array] output stdout redirection
+          # @return [Pipeline::Result]
+          def run_pipeline!(input: nil, output: nil)
+            start = Time.now
+            # Open3 writes on `err_write` and we receive from `err_read`
+            err_read, err_write = IO.pipe
+
+            # Pipeline accepts custom {Process.spawn} options
+            # stderr capture is always performed, stdin and stdout redirection
+            # are performed only when either `input` or `output` are present
+            options = { err: err_write } # redirect stderr to IO pipe
+            options[:in] = input if input # redirect stdin
+            options[:out] = output if output # redirect stdout
+
+            status_list = Open3.pipeline(*build_command_list, **options)
+            duration = Time.now - start
+
+            err_write.close # close the pipe before reading
+            stderr = err_read.read
+            err_read.close # close after reading to avoid leaking file descriptors
+
+            Result.new(stderr: stderr, status_list: status_list, duration: duration)
+          end
+
+          private
+
+          def build_command_list
+            @shell_commands.map(&:cmd_args)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/gems/gitlab-backup-cli/spec/gitlab/backup/cli/shell/pipeline_spec.rb b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/shell/pipeline_spec.rb
new file mode 100644
index 0000000000000..ad2837af557c7
--- /dev/null
+++ b/gems/gitlab-backup-cli/spec/gitlab/backup/cli/shell/pipeline_spec.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Backup::Cli::Shell::Pipeline do
+  let(:command) { Gitlab::Backup::Cli::Shell::Command }
+  let(:printf_command) { command.new('printf "3\n2\n1"') }
+  let(:sort_command) { command.new('sort') }
+
+  subject(:pipeline) { described_class }
+
+  it { respond_to :shell_commands }
+
+  describe '#initialize' do
+    it 'accept single argument' do
+      expect { pipeline.new(printf_command) }.not_to raise_exception
+    end
+
+    it 'accepts multiple arguments' do
+      expect { pipeline.new(printf_command, sort_command) }.not_to raise_exception
+    end
+  end
+
+  describe '#run_pipeline!' do
+    it 'returns a Pipeline::Status' do
+      true_command = command.new('true')
+
+      result = pipeline.new(true_command, true_command).run_pipeline!
+
+      expect(result).to be_a(Gitlab::Backup::Cli::Shell::Pipeline::Result)
+    end
+
+    context 'with Pipeline::Status' do
+      it 'includes stderr from the executed pipeline' do
+        expected_output = 'my custom error content'
+        err_command = command.new("echo #{expected_output} > /dev/stderr")
+
+        result = pipeline.new(err_command).run_pipeline!
+
+        expect(result.stderr.chomp).to eq(expected_output)
+      end
+
+      it 'includes a list of Process::Status from the executed pipeline' do
+        true_command = command.new('true')
+
+        result = pipeline.new(true_command, true_command).run_pipeline!
+
+        expect(result.status_list).to all be_a(Process::Status)
+        expect(result.status_list).to all respond_to(:exited?, :termsig, :stopsig, :exitstatus, :success?, :pid)
+      end
+
+      it 'includes a list of Process::Status that handles exit signals' do
+        false_command = command.new('false')
+
+        result = pipeline.new(false_command, false_command).run_pipeline!
+
+        expect(result.status_list).to all satisfy { |status| !status.success? }
+        expect(result.status_list).to all satisfy { |status| status.exitstatus == 1 }
+      end
+    end
+
+    it 'accepts stdin and stdout redirection' do
+      echo_command = command.new(%(ruby -e "print 'stdin is : ' + STDIN.readline"))
+      input_r, input_w = IO.pipe
+      input_w.sync = true
+      input_w.print 'my custom content'
+      input_w.close
+
+      output_r, output_w = IO.pipe
+
+      result = pipeline.new(echo_command).run_pipeline!(input: input_r, output: output_w)
+
+      input_r.close
+      output_w.close
+      output = output_r.read
+      output_r.close
+
+      expect(result.status_list.size).to eq(1)
+      expect(result.status_list[0]).to be_success
+      expect(output).to match(/stdin is : my custom content/)
+    end
+  end
+end
-- 
GitLab