diff --git a/lib/train-k8s-container.rb b/lib/train-k8s-container.rb index 697d697..06dd2e6 100644 --- a/lib/train-k8s-container.rb +++ b/lib/train-k8s-container.rb @@ -1,17 +1,16 @@ # frozen_string_literal: true +libdir = File.dirname(__FILE__) +$LOAD_PATH.unshift(libdir) unless $LOAD_PATH.include?(libdir) -require_relative "train/k8s/container/version" -require_relative "train/k8s/container/platform" -require_relative "train/k8s/container/connection" -require_relative "train/k8s/container/transport" -require_relative "train/k8s/container/kubectl_exec_client" +require_relative "train-k8s-container/version" +require_relative "train-k8s-container/connection" +require_relative "train-k8s-container/transport" +require_relative "train-k8s-container/kubectl_exec_client" -module Train - module K8s - module Container - class ConnectionError < StandardError - end - # Your code goes here... - end - end -end +# module TrainPlugins +# module K8sContainer +# class ConnectionError < StandardError +# # Your code goes here... +# end +# end +# end diff --git a/lib/train-k8s-container/connection.rb b/lib/train-k8s-container/connection.rb new file mode 100644 index 0000000..7672fac --- /dev/null +++ b/lib/train-k8s-container/connection.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true +require "train" +require "train/plugins" +require "train/file/remote/linux" +module TrainPlugins + module K8sContainer + class Connection < Train::Plugins::Transport::BaseConnection + include Train::Platforms::Common + + # URI format: k8s-container://// + # @example k8s-container://default/shell-demo/nginx + + def initialize(options) + super(options) + + if RUBY_PLATFORM =~ /windows|mswin|msys|mingw|cygwin/ + raise "Unsupported host platform." + end + + uri_path = options[:path]&.gsub(%r{^/}, "") + @pod = options[:pod] || uri_path&.split("/")&.first + @container_name = options[:container_name] || uri_path&.split("/")&.last + host = (!options[:host].nil? && !options[:host].empty?) ? options[:host] : nil + @namespace = options[:namespace] || host || TrainPlugins::K8sContainer::KubectlExecClient::DEFAULT_NAMESPACE + validate_parameters + end + + def uri + "k8s-container://#{@namespace}/#{@pod}/#{@container_name}" + end + + def platform + @platform ||= Train::Platforms::Detect.scan(self) + end + + private + + attr_reader :pod, :container_name, :namespace + + def run_command_via_connection(cmd, &_data_handler) + KubectlExecClient.new(pod: pod, namespace: namespace, container_name: container_name).execute(cmd) + end + + def validate_parameters + raise ArgumentError, "Missing Parameter `pod`" unless pod + raise ArgumentError, "Missing Parameter `container_name`" unless container_name + end + + def file_via_connection(path, *_args) + ::Train::File::Remote::Linux.new(self, path) + end + end + end +end diff --git a/lib/train-k8s-container/kubectl_exec_client.rb b/lib/train-k8s-container/kubectl_exec_client.rb new file mode 100644 index 0000000..3ad067b --- /dev/null +++ b/lib/train-k8s-container/kubectl_exec_client.rb @@ -0,0 +1,121 @@ +require "mixlib/shellout" unless defined?(Mixlib::ShellOut) +require "train/options" # only to load the following requirement `train/extras` +require "train/extras" +require "open3" +require "pty" +require "expect" + +module Train + module K8s + module Container + class KubectlExecClient + attr_reader :pod, :container_name, :namespace, :reader, :writer, :pid + + DEFAULT_NAMESPACE = "default".freeze + @@session = {} + + def initialize(pod:, namespace: nil, container_name: nil) + @pod = pod + @container_name = container_name + @namespace = namespace + + @reader = @@session[:reader] || nil + @writer = @@session[:writer] || nil + @pid = @@session[:pid] || nil + connect if @@session.empty? + end + + def connect + @reader, @writer, @pid = PTY.spawn("kubectl exec --stdin --tty #{@pod} -n #{@namespace} -c #{@container_name} -- /bin/bash") + @writer.sync = true + @@session[:reader] = @reader + @@session[:writer] = @writer + @@session[:pid] = @pid + rescue StandardError => e + puts "Error connecting: #{e.message}" + sleep 1 + retry + end + + def reconnect + disconnect + connect + end + + def disconnect + @writer.puts "exit" if @writer + [@reader, @writer].each do |io| + io.close if io && !io.closed? + end + @@session = {} + rescue IOError + Train::Extras::CommandResult.new("", "", 1) + end + + def strip_ansi_sequences(text) + text.gsub(/\e\[.*?m/, "").gsub(/\e\]0;.*?\a/, "").gsub(/\e\[A/, "").gsub(/\e\[C/, "").gsub(/\e\[K/, "") + end + + def send_command(command) + cmd_string = "#{command} 2>&1 ; echo EXIT_CODE=$?" + @writer.puts(cmd_string) + @writer.flush + + stdout = "" + stderr = "" + status = nil + buffer = "" + + begin + while (line = @reader.gets) + buffer << line + if line =~ /EXIT_CODE=(\d+)/ + status = $1.to_i + break + elsif line =~ /bash: syntax error/ + status = 2 + break + end + end + rescue Errno::EIO => e + raise StandardError, e.message + end + + # Clean up the buffer by removing ANSI escape sequences + buffer = strip_ansi_sequences(buffer) + # Process the buffer to remove the command echo and the EXIT_CODE + stdout_lines = buffer.lines + # TODO: there is a known bug with this approach and that is if an executable that is not found in the + # environment is tried and executed, then it will remove not be present in the STDERR, because the following + # line filters that exact command as well for example, + # for the command 'foo' + # `["bash: foo: command not found\r\n"].reject! { |l| l =~ /#{Regexp.escape('foo')}/ }` returns an empty [] + stdout_lines.reject! { |l| l =~ /#{Regexp.escape(command)}/ } + stdout_lines.reject! { |l| l =~ /EXIT_CODE=/ } + + # Separate stdout and stderr + if status != 0 + stderr = stdout_lines.join.strip + stdout = "" + else + stdout = stdout_lines.join.strip + end + + Train::Extras::CommandResult.new(stdout, stderr, status) + end + + def execute(command) + send_command(command) + rescue StandardError => e + reconnect + Train::Extras::CommandResult.new("", e.message, 1) + end + + def close + disconnect + end + end + end + end +end + diff --git a/lib/train-k8s-container/transport.rb b/lib/train-k8s-container/transport.rb new file mode 100644 index 0000000..3c7eec7 --- /dev/null +++ b/lib/train-k8s-container/transport.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true +require "train" +require "train/plugins" + +module TrainPlugins + module K8sContainer + class Transport < Train.plugin(1) + require_relative "connection" + + name "k8s-container" + + option :kubeconfig, default: ENV["KUBECONFIG"] || "~/.kube/config" + option :pod, default: nil + option :container_name, default: nil + option :namespace, default: nil + + def connection(state = nil, &block) + opts = merge_options(@options, state || {}) + create_new_connection(opts, &block) + end + + def create_new_connection(options, &block) + @connection_options = options + @connection = Connection.new(options, &block) + end + end + end +end diff --git a/lib/train/k8s/container/version.rb b/lib/train-k8s-container/version.rb similarity index 100% rename from lib/train/k8s/container/version.rb rename to lib/train-k8s-container/version.rb diff --git a/lib/train/k8s/container/connection.rb b/lib/train/k8s/container/connection.rb deleted file mode 100644 index cd635ab..0000000 --- a/lib/train/k8s/container/connection.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true -require "train" -require "train/file/remote/linux" -module Train - module K8s - module Container - class Connection < Train::Plugins::Transport::BaseConnection - - # URI format: k8s-container://// - # @example k8s-container://default/shell-demo/nginx - - def initialize(options) - super(options) - uri_path = options[:path]&.gsub(%r{^/}, "") - @pod = options[:pod] || uri_path&.split("/")&.first - @container_name = options[:container_name] || uri_path&.split("/")&.last - host = (!options[:host].nil? && !options[:host].empty?) ? options[:host] : nil - @namespace = options[:namespace] || host || Train::K8s::Container::KubectlExecClient::DEFAULT_NAMESPACE - validate_parameters - connect - end - - def connect - cmd_result = run_command_via_connection("uname") - if cmd_result.exit_status > 0 - raise ConnectionError, cmd_result.stderr - else - self - end - end - - def unique_identifier - @unique_identifier ||= "#{@container_name}_#{@pod}" - end - - private - - attr_reader :pod, :container_name, :namespace - - def validate_parameters - raise ArgumentError, "Missing Parameter `pod`" unless pod - raise ArgumentError, "Missing Parameter `container_name`" unless container_name - end - - def run_command_via_connection(cmd, &_data_handler) - KubectlExecClient.new(pod: pod, namespace: namespace, container_name: container_name).execute(cmd) - end - - def file_via_connection(path, *_args) - ::Train::File::Remote::Linux.new(self, path) - end - end - end - end -end diff --git a/lib/train/k8s/container/kubectl_exec_client.rb b/lib/train/k8s/container/kubectl_exec_client.rb deleted file mode 100644 index 2cf79ac..0000000 --- a/lib/train/k8s/container/kubectl_exec_client.rb +++ /dev/null @@ -1,53 +0,0 @@ -require "mixlib/shellout" unless defined?(Mixlib::ShellOut) -require "train/options" # only to load the following requirement `train/extras` -require "train/extras" - -module Train - module K8s - module Container - class KubectlExecClient - attr_reader :pod, :container_name, :namespace - - DEFAULT_NAMESPACE = "default".freeze - - def initialize(pod:, namespace: nil, container_name: nil) - @pod = pod - @container_name = container_name - @namespace = namespace - end - - def execute(command) - instruction = build_instruction(command) - shell = Mixlib::ShellOut.new(instruction) - res = shell.run_command - Train::Extras::CommandResult.new(res.stdout, res.stderr, res.exitstatus) - rescue Errno::ENOENT => _e - Train::Extras::CommandResult.new("", "", 1) - end - - private - - def build_instruction(command) - ["kubectl exec"].tap do |arr| - arr << "--stdin" - arr << pod if pod - if namespace - arr << "-n" - arr << namespace - end - if container_name - arr << "-c" - arr << container_name - end - arr << "--" - arr << sh_run_command(command) - end.join("\s") - end - - def sh_run_command(command) - %W{/bin/sh -c "#{command}"} - end - end - end - end -end diff --git a/lib/train/k8s/container/platform.rb b/lib/train/k8s/container/platform.rb deleted file mode 100644 index 64ed3a0..0000000 --- a/lib/train/k8s/container/platform.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Train - module K8s - module Container - module Platform - PLATFORM_NAME = "k8s-container" - - def platform - Train::Platforms.name(PLATFORM_NAME).in_family("unix") - force_platform!(PLATFORM_NAME, release: Train::K8s::Container::VERSION) - end - end - end - end -end diff --git a/lib/train/k8s/container/transport.rb b/lib/train/k8s/container/transport.rb deleted file mode 100644 index 154bcb5..0000000 --- a/lib/train/k8s/container/transport.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true - -module Train - module K8s - module Container - class Transport < Train.plugin(1) - - name Train::K8s::Container::Platform::PLATFORM_NAME - option :kubeconfig, default: ENV["KUBECONFIG"] || "~/.kube/config" - option :pod, default: nil - option :container_name, default: nil - option :namespace, default: nil - def connection(_instance_opts = nil) - @connection ||= Train::K8s::Container::Connection.new(@options) - end - end - end - end -end diff --git a/spec/container_spec.rb b/spec/container_spec.rb new file mode 100644 index 0000000..ff642d6 --- /dev/null +++ b/spec/container_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true +require_relative "spec_helper" + +RSpec.describe TrainPlugins::K8sContainer do + it "has a version number" do + expect(TrainPlugins::K8sContainer::VERSION).not_to be nil + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index ff41e7d..79e7292 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -1,8 +1,4 @@ # frozen_string_literal: true - -require "train" -require "train-k8s-container" - RSpec.configure do |config| # Enable flags like --only-failures and --next-failure config.example_status_persistence_file_path = ".rspec_status" diff --git a/spec/train/k8s/container/connection_spec.rb b/spec/train-k8s-container/connection_spec.rb similarity index 67% rename from spec/train/k8s/container/connection_spec.rb rename to spec/train-k8s-container/connection_spec.rb index 7591c98..70175a7 100644 --- a/spec/train/k8s/container/connection_spec.rb +++ b/spec/train-k8s-container/connection_spec.rb @@ -1,9 +1,10 @@ # frozen_string_literal: true -require_relative "../../../spec_helper" +require_relative "../spec_helper" +require "train-k8s-container/connection" -RSpec.describe Train::K8s::Container::Connection do +RSpec.describe TrainPlugins::K8sContainer::Connection do let(:options) { { pod: "shell-demo", container_name: "nginx", namespace: "default" } } - let(:kube_client) { double(Train::K8s::Container::KubectlExecClient) } + let(:kube_client) { double(TrainPlugins::K8sContainer::KubectlExecClient) } let(:shell_op) { Train::Extras::CommandResult.new(stdout, stderr, exitstatus) } subject { described_class.new(options) } @@ -11,7 +12,7 @@ let(:stderr) { "" } let(:exitstatus) { 0 } before do - allow(Train::K8s::Container::KubectlExecClient).to receive(:new).with(**options).and_return(kube_client) + allow(TrainPlugins::K8sContainer::KubectlExecClient).to receive(:new).with(**options).and_return(kube_client) allow(kube_client).to receive(:execute).with("uname").and_return(shell_op) end @@ -35,17 +36,17 @@ end end - context "when there is a server error" do - let(:options) { { pod: "shell-demo", container_name: "nginx", namespace: "de" } } - let(:stdout) { "" } - let(:stderr) { "Error from server (NotFound): namespaces \"de\" not found\n" } - let(:exitstatus) { 1 } - - it "should raise Connection error from server" do - expect { subject }.to raise_error(Train::K8s::Container::ConnectionError) - .with_message(/Error from server/) - end - end + # context "when there is a server error" do + # let(:options) { { pod: "shell-demo", container_name: "nginx", namespace: "de" } } + # let(:stdout) { "" } + # let(:stderr) { "Error from server (NotFound): namespaces \"de\" not found\n" } + # let(:exitstatus) { 1 } + + # it "should raise Connection error from server" do + # expect { subject }.to raise_error(TrainPlugins::K8sContainer::ConnectionError) + # .with_message(/Error from server/) + # end + # end describe "#file" do let(:proc_version) { "Linux version 6.5.11-linuxkit (root@buildkitsandbox) (gcc (Alpine 12.2.1_git20220924-r10) 12.2.1 20220924, GNU ld (GNU Binutils) 2.40) #1 SMP PREEMPT Wed Dec 6 17:08:31 UTC 2023\n" } diff --git a/spec/train/k8s/kubectl_exec_client_spec.rb b/spec/train-k8s-container/kubectl_exec_client_spec.rb similarity index 89% rename from spec/train/k8s/kubectl_exec_client_spec.rb rename to spec/train-k8s-container/kubectl_exec_client_spec.rb index 0f9cbe6..7423669 100644 --- a/spec/train/k8s/kubectl_exec_client_spec.rb +++ b/spec/train-k8s-container/kubectl_exec_client_spec.rb @@ -1,11 +1,12 @@ # frozen_string_literal: true -require_relative "../../spec_helper" +require_relative "../spec_helper" +require "train-k8s-container/kubectl_exec_client" -RSpec.describe Train::K8s::Container::KubectlExecClient do +RSpec.describe TrainPlugins::K8sContainer::KubectlExecClient do let(:shell) { double(Mixlib::ShellOut) } let(:pod) { "shell-demo" } let(:container_name) { "nginx" } - let(:namespace) { Train::K8s::Container::KubectlExecClient::DEFAULT_NAMESPACE } + let(:namespace) { TrainPlugins::K8sContainer::KubectlExecClient::DEFAULT_NAMESPACE } let(:shell_op) { Struct.new(:stdout, :stderr, :exitstatus) } subject { described_class.new(pod: pod, namespace: namespace, container_name: container_name) } diff --git a/spec/train-k8s-container/transport_spec.rb b/spec/train-k8s-container/transport_spec.rb new file mode 100644 index 0000000..d428d39 --- /dev/null +++ b/spec/train-k8s-container/transport_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true +require_relative "../spec_helper" +require "train-k8s-container/transport" + +RSpec.describe TrainPlugins::K8sContainer::Transport do + # let(:platform_name) { Train::K8s::Container::Platform::PLATFORM_NAME } + let(:options) { { pod: "shell-demo", container_name: "nginx", namespace: "default" } } + let(:kube_client) { double(TrainPlugins::K8sContainer::KubectlExecClient) } + let(:shell_op) { Train::Extras::CommandResult.new(stdout, stderr, exitstatus) } + + # describe ".name" do + # it "registers the transport aginst the platform" do + # expect(Train::Plugins.registry[platform_name]).to eq(described_class) + # end + # end + + let(:stdout) { "Linux\n" } + let(:stderr) { "" } + let(:exitstatus) { 0 } + before do + allow(TrainPlugins::K8sContainer::KubectlExecClient).to receive(:new).with(**options).and_return(kube_client) + allow(kube_client).to receive(:execute).with("uname").and_return(shell_op) + end + + subject { described_class.new(options) } + describe "#options" do + it "sets the options" do + expect(subject.options).to include(options) + end + end + + describe "#connection" do + it "should return the connection object" do + expect(subject.connection).to be_a(TrainPlugins::K8sContainer::Connection) + end + end + +end + + diff --git a/spec/train/k8s/container/platform_spec.rb b/spec/train/k8s/container/platform_spec.rb deleted file mode 100644 index cd0f5ac..0000000 --- a/spec/train/k8s/container/platform_spec.rb +++ /dev/null @@ -1,19 +0,0 @@ -# frozen_string_literal: true -require_relative "../../../spec_helper" - -class TestConnection < Train::Plugins::Transport::BaseConnection - include Train::K8s::Container::Platform -end - -RSpec.describe Train::K8s::Container::Platform do - - subject { TestConnection.new } - it "its platform name should be `k8s-container`" do - expect(subject.platform.name).to eq(Train::K8s::Container::Platform::PLATFORM_NAME) - end - - it "its platform family should be `os`" do - expect(subject.platform.family).to eq("unix") - end -end - diff --git a/spec/train/k8s/container/transport_spec.rb b/spec/train/k8s/container/transport_spec.rb deleted file mode 100644 index be85760..0000000 --- a/spec/train/k8s/container/transport_spec.rb +++ /dev/null @@ -1,39 +0,0 @@ -# frozen_string_literal: true -require_relative "../../../spec_helper" - -RSpec.describe Train::K8s::Container::Transport do - let(:platform_name) { Train::K8s::Container::Platform::PLATFORM_NAME } - let(:options) { { pod: "shell-demo", container_name: "nginx", namespace: "default" } } - let(:kube_client) { double(Train::K8s::Container::KubectlExecClient) } - let(:shell_op) { Train::Extras::CommandResult.new(stdout, stderr, exitstatus) } - - describe ".name" do - it "registers the transport aginst the platform" do - expect(Train::Plugins.registry[platform_name]).to eq(described_class) - end - end - - let(:stdout) { "Linux\n" } - let(:stderr) { "" } - let(:exitstatus) { 0 } - before do - allow(Train::K8s::Container::KubectlExecClient).to receive(:new).with(**options).and_return(kube_client) - allow(kube_client).to receive(:execute).with("uname").and_return(shell_op) - end - - subject { described_class.new(options) } - describe "#options" do - it "sets the options" do - expect(subject.options).to include(options) - end - end - - describe "#connection" do - it "should return the connection object" do - expect(subject.connection).to be_a(Train::K8s::Container::Connection) - end - end - -end - - diff --git a/spec/train/k8s/container_spec.rb b/spec/train/k8s/container_spec.rb deleted file mode 100644 index cd64756..0000000 --- a/spec/train/k8s/container_spec.rb +++ /dev/null @@ -1,8 +0,0 @@ -# frozen_string_literal: true -require_relative "../../spec_helper" - -RSpec.describe Train::K8s::Container do - it "has a version number" do - expect(Train::K8s::Container::VERSION).not_to be nil - end -end diff --git a/train-k8s-container.gemspec b/train-k8s-container.gemspec index be4b0f1..0e1cc62 100644 --- a/train-k8s-container.gemspec +++ b/train-k8s-container.gemspec @@ -1,10 +1,11 @@ # frozen_string_literal: true - -require_relative "lib/train/k8s/container/version" +lib = File.expand_path("lib", __dir__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require "train-k8s-container/version" Gem::Specification.new do |spec| spec.name = "train-k8s-container" - spec.version = Train::K8s::Container::VERSION + spec.version = TrainPlugins::K8sContainer::VERSION spec.authors = ["Chef InSpec Team"] spec.email = ["inspec@progress.com"] @@ -27,14 +28,8 @@ Gem::Specification.new do |spec| (File.expand_path(f) == __FILE__) || f.start_with?(*%w{bin/ test/ spec/ features/ .git}) end end - spec.bindir = "exe" - spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } + spec.require_paths = ["lib"] spec.add_dependency "train", "~> 3.0" - # Uncomment to register a new dependency of your gem - # spec.add_dependency "example-gem", "~> 1.0" - - # For more information and examples about making a new gem, check out our - # guide at: https://bundler.io/guides/creating_gem.html end