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