Class: Terraspace::Shell

Inherits:
Object
  • Object
show all
Includes:
Util::Logging
Defined in:
lib/terraspace/shell.rb,
lib/terraspace/shell/error.rb

Defined Under Namespace

Classes: Error

Constant Summary collapse

BLOCK_SIZE =
Integer(ENV['TS_BUFFER_BLOCK_SIZE'] || 102400)
BUFFER_TIMEOUT =

3600s = 1h

Integer(ENV['TS_BUFFER_TIMEOUT'] || 3600)

Instance Method Summary collapse

Methods included from Util::Logging

#logger

Constructor Details

#initialize(mod, command, options = {}) ⇒ Shell

Returns a new instance of Shell.



7
8
9
# File 'lib/terraspace/shell.rb', line 7

def initialize(mod, command, options={})
  @mod, @command, @options = mod, command, options
end

Instance Method Details

#all_eof?(files) ⇒ Boolean

Returns:

  • (Boolean)


137
138
139
# File 'lib/terraspace/shell.rb', line 137

def all_eof?(files)
  files.find { |f| !f.eof }.nil?
end

#exit_status(status) ⇒ Object



141
142
143
144
145
146
147
148
149
150
# File 'lib/terraspace/shell.rb', line 141

def exit_status(status)
  return if status == 0

  exit_on_fail = @options[:exit_on_fail].nil? ? true : @options[:exit_on_fail]
  if @error && @error.known?
    raise @error.instance
  elsif exit_on_fail
    raise ShellError.new("Error running command: #{@command}")
  end
end

#handle_input(stdin, line) ⇒ Object



103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
# File 'lib/terraspace/shell.rb', line 103

def handle_input(stdin, line)
  # Edge case: "value:" chopped off "Enter a" and "value" prompt
  #   This means "Enter a value:" is not needed but leaving it for now
  patterns = [
    /^ value:/,
    "Enter a value:",
    "\e[0m\e[1mvar.", # prompts for variable input. can happen on plan or apply. looking for bold marker also in case "var." shows up somewhere else
  ]
  matched = patterns.any? do |pattern|
    if pattern.is_a?(String)
      line.include?(pattern)
    else # Regexp
      line.match(pattern)
    end
  end
  if matched
    answer = $stdin.gets
    logger.stdin_capture(answer.strip)
    stdin.write_nonblock(answer)
  end
end

#handle_stderr(line) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
# File 'lib/terraspace/shell.rb', line 125

def handle_stderr(line)
  @error ||= Error.new
  @error.lines << line # aggregate all error lines

  return if @error.known?
  # Sometimes may print a "\e[31m\n" which like during dependencies fetcher init
  # suppress it so dont get a bunch of annoying "newlines"
  return if line == "\e[31m\n" && @options[:suppress_error_color]

  logger.error(line)
end

#handle_stdout(line, newline: true) ⇒ Object



92
93
94
95
96
97
98
99
100
101
# File 'lib/terraspace/shell.rb', line 92

def handle_stdout(line, newline: true)
  # Terraspace logger has special stdout method so original terraform output
  # can be piped to jq. IE:
  #   terraspace show demo --json | jq
  if logger.respond_to?(:stdout) && !@options[:log_to_stderr]
    logger.stdout(line, newline: newline)
  else
    logger.info(line)
  end
end

#handle_streams(stdin, stdout, stderr) ⇒ Object



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/terraspace/shell.rb', line 50

def handle_streams(stdin, stdout, stderr)
  # note: t=0 and t=nil means no timeout. See: https://bit.ly/2PURlCX
  t = BUFFER_TIMEOUT.to_i unless BUFFER_TIMEOUT.nil?
  Timeout::timeout(t) do
    files = [stdout, stderr]
    until all_eof?(files) do
      ready = IO.select(files)
      next unless ready

      readable = ready[0]
      readable.each do |f|
        buffer = f.read_nonblock(BLOCK_SIZE, exception: false)
        next unless buffer

        lines = buffer.split("\n")
        lines.each do |line|
          if f.fileno == stdout.fileno
            handle_stdout(line, newline: !suppress_newline(line))
            handle_input(stdin, line)
          else
            handle_stderr(line)
          end
        end
      end
    end
  end
end

#popen3(env) ⇒ Object



35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/terraspace/shell.rb', line 35

def popen3(env)
  Open3.popen3(env, @command, chdir: @mod.cache_dir) do |stdin, stdout, stderr, wait_thread|
    # interesting. simply handling the trap and doing nothing works
    # Think its because ctrl-c is sent to both processes.
    # 1. we do nothing in here in the parent process
    # 2. in the child process the ctrl-c gets sent directly to the terraform command
    Signal.trap("INT") { }
    handle_streams(stdin, stdout, stderr)
    status = wait_thread.value.exitstatus
    exit_status(status)
  end
end

#runObject

requires @mod to be set quiet useful for RemoteState::Fetcher



13
14
15
16
17
18
# File 'lib/terraspace/shell.rb', line 13

def run
  msg = "=> #{@command}"
  @options[:quiet] ? logger.debug(msg) : logger.info(msg)
  return if ENV['TS_TEST']
  shell
end

#shellObject



20
21
22
23
24
25
26
27
28
# File 'lib/terraspace/shell.rb', line 20

def shell
  env = @options[:env] || {}
  env.stringify_keys!
  if system?
    system(env, @command, chdir: @mod.cache_dir)
  else
    popen3(env)
  end
end

#suppress_newline(line) ⇒ Object



78
79
80
81
82
83
84
85
86
87
88
89
90
# File 'lib/terraspace/shell.rb', line 78

def suppress_newline(line)
  # Regular prompt
  line.include?("Enter a value:") ||
  # Edge case: When buffer is very large buffer.split("\n") only gives 8192 chars at a time
  line.size == 8192 && line[-1] != "\n" ||
  # Edge case: "value:" chopped off "Enter a" and "value" prompt
  # Very hard to reproduce. Happens 1/5 times on terraspace up autoscaling example.
  # Sometimes lines come in as:
  #   [...,"  Only 'yes' will be accepted to approve.", "", "  \e[1mEnter a"]
  #   [" value:\e[0m \e[0m"]
  line.match(/Enter a$/) || line.match(/^ value:/) # chopped off prompt
  # line.include?(" value:") && lines.last.match(/Enter a$/) # chopped off prompt
end

#system?Boolean

Returns:

  • (Boolean)


30
31
32
33
# File 'lib/terraspace/shell.rb', line 30

def system?
  @options[:shell] == "system" || # terraspace console
  ENV['TS_RUNNER_SYSTEM'] # allow manual override
end