train-k8s-container-mitre 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.expeditor/buildkite/coverage.sh +46 -0
- data/.expeditor/buildkite/run_linux_tests.sh +16 -0
- data/.expeditor/config.yml +61 -0
- data/.expeditor/coverage.pipeline.yml +19 -0
- data/.expeditor/update_version.sh +12 -0
- data/.expeditor/verify.pipeline.yml +44 -0
- data/.rspec +4 -0
- data/.rubocop.yml +57 -0
- data/CHANGELOG.md +158 -0
- data/CODE_OF_CONDUCT.md +13 -0
- data/CONTRIBUTING.md +161 -0
- data/DEVELOPMENT.md +315 -0
- data/Gemfile +23 -0
- data/LICENSE.md +9 -0
- data/NOTICE.md +9 -0
- data/README.md +237 -0
- data/Rakefile +37 -0
- data/SECURITY.md +100 -0
- data/VERSION +1 -0
- data/cliff.toml +80 -0
- data/docs/README.md +1 -0
- data/lib/train-k8s-container/ansi_sanitizer.rb +31 -0
- data/lib/train-k8s-container/connection.rb +102 -0
- data/lib/train-k8s-container/errors.rb +22 -0
- data/lib/train-k8s-container/kubectl_command_builder.rb +87 -0
- data/lib/train-k8s-container/kubectl_exec_client.rb +176 -0
- data/lib/train-k8s-container/kubernetes_name_validator.rb +44 -0
- data/lib/train-k8s-container/platform.rb +93 -0
- data/lib/train-k8s-container/pty_session.rb +156 -0
- data/lib/train-k8s-container/result_processor.rb +94 -0
- data/lib/train-k8s-container/retry_handler.rb +35 -0
- data/lib/train-k8s-container/session_manager.rb +95 -0
- data/lib/train-k8s-container/shell_detector.rb +198 -0
- data/lib/train-k8s-container/transport.rb +30 -0
- data/lib/train-k8s-container/version.rb +7 -0
- data/lib/train-k8s-container.rb +12 -0
- data/sonar-project.properties +17 -0
- data/train-k8s-container.gemspec +49 -0
- metadata +107 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'shell_detector'
|
|
4
|
+
|
|
5
|
+
module TrainPlugins
|
|
6
|
+
module K8sContainer
|
|
7
|
+
# Platform detection module for k8s-container transport
|
|
8
|
+
# Uses Train's built-in platform detection to identify the actual OS
|
|
9
|
+
# inside the container (e.g., ubuntu, alpine, centos) rather than
|
|
10
|
+
# returning a generic 'k8s-container' platform.
|
|
11
|
+
#
|
|
12
|
+
# Additionally adds 'kubernetes' and 'container' to the family hierarchy
|
|
13
|
+
# so users can check platform.kubernetes? and platform.container? to know
|
|
14
|
+
# they are running inside a Kubernetes container.
|
|
15
|
+
module Platform
|
|
16
|
+
# Detect platform inside the container using Train's standard detection
|
|
17
|
+
# This allows InSpec resources to work properly by knowing the actual OS
|
|
18
|
+
def platform
|
|
19
|
+
return @platform if @platform
|
|
20
|
+
|
|
21
|
+
# Use Train's built-in platform detection scanner
|
|
22
|
+
# This reads /etc/os-release, /etc/redhat-release, etc. to detect the OS
|
|
23
|
+
# Train raises PlatformDetectionFailed if it can't detect the platform
|
|
24
|
+
begin
|
|
25
|
+
@platform = Train::Platforms::Detect.scan(self)
|
|
26
|
+
rescue Train::PlatformDetectionFailed
|
|
27
|
+
# Fall back to unknown platform for distroless/minimal containers
|
|
28
|
+
@platform = nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# If detection fails, fall back to a generic unix platform
|
|
32
|
+
# This handles distroless containers where OS detection may fail
|
|
33
|
+
@platform ||= fallback_platform
|
|
34
|
+
|
|
35
|
+
# Add kubernetes and container families so users can check:
|
|
36
|
+
# - platform.kubernetes? => true
|
|
37
|
+
# - platform.container? => true
|
|
38
|
+
add_k8s_families(@platform)
|
|
39
|
+
|
|
40
|
+
@platform
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
# Register kubernetes and container families with Train and add them
|
|
46
|
+
# to the detected platform's family hierarchy
|
|
47
|
+
def add_k8s_families(plat)
|
|
48
|
+
return unless plat
|
|
49
|
+
|
|
50
|
+
# Register the families with Train if not already registered
|
|
51
|
+
# These are added as top-level families (no parent)
|
|
52
|
+
Train::Platforms.family('kubernetes') unless Train::Platforms.families['kubernetes']
|
|
53
|
+
Train::Platforms.family('container') unless Train::Platforms.families['container']
|
|
54
|
+
|
|
55
|
+
# Append to the family hierarchy so kubernetes? and container? methods work
|
|
56
|
+
plat.family_hierarchy << 'kubernetes' unless plat.family_hierarchy.include?('kubernetes')
|
|
57
|
+
plat.family_hierarchy << 'container' unless plat.family_hierarchy.include?('container')
|
|
58
|
+
|
|
59
|
+
# Re-add platform methods to include the new family? methods
|
|
60
|
+
plat.add_platform_methods
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Fallback platform when Train's detection fails (distroless, minimal containers)
|
|
64
|
+
def fallback_platform
|
|
65
|
+
detect_shell # Trigger shell detection
|
|
66
|
+
container_os = @shell_detector&.container_os || :unknown
|
|
67
|
+
|
|
68
|
+
# Create a minimal platform with appropriate families
|
|
69
|
+
plat = Train::Platforms.name('unknown')
|
|
70
|
+
|
|
71
|
+
case container_os
|
|
72
|
+
when :unix
|
|
73
|
+
plat.in_family('unix')
|
|
74
|
+
plat.in_family('linux')
|
|
75
|
+
when :windows
|
|
76
|
+
plat.in_family('windows')
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
force_platform!('unknown', release: 'unknown')
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def detect_shell
|
|
83
|
+
return unless is_a?(Train::Plugins::Transport::BaseConnection)
|
|
84
|
+
|
|
85
|
+
client = send(:kubectl_client)
|
|
86
|
+
@shell_detector ||= ShellDetector.new(client)
|
|
87
|
+
@shell_detector.detect
|
|
88
|
+
rescue NoMethodError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'pty'
|
|
4
|
+
require 'timeout'
|
|
5
|
+
require_relative 'errors'
|
|
6
|
+
require_relative 'ansi_sanitizer'
|
|
7
|
+
|
|
8
|
+
module TrainPlugins
|
|
9
|
+
module K8sContainer
|
|
10
|
+
# PTY-based persistent shell session for performance optimization
|
|
11
|
+
# Maintains a single kubectl exec session instead of spawning per command
|
|
12
|
+
class PtySession
|
|
13
|
+
class PtyError < K8sContainerError; end
|
|
14
|
+
class SessionClosedError < PtyError; end
|
|
15
|
+
class CommandTimeoutError < PtyError; end
|
|
16
|
+
|
|
17
|
+
attr_reader :session_key, :reader, :writer, :pid
|
|
18
|
+
|
|
19
|
+
DEFAULT_COMMAND_TIMEOUT = 60
|
|
20
|
+
DEFAULT_SESSION_TIMEOUT = 300
|
|
21
|
+
|
|
22
|
+
def initialize(session_key:, kubectl_cmd:, shell: '/bin/bash', timeout: DEFAULT_SESSION_TIMEOUT, logger: nil)
|
|
23
|
+
@session_key = session_key
|
|
24
|
+
@kubectl_cmd = kubectl_cmd
|
|
25
|
+
@shell = shell
|
|
26
|
+
@timeout = timeout
|
|
27
|
+
@command_timeout = DEFAULT_COMMAND_TIMEOUT
|
|
28
|
+
# Logger is optional - all logging uses safe navigation (@logger&.method)
|
|
29
|
+
@logger = logger
|
|
30
|
+
@reader = nil
|
|
31
|
+
@writer = nil
|
|
32
|
+
@pid = nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def connect
|
|
36
|
+
raise SessionClosedError, 'Session already connected' if connected?
|
|
37
|
+
|
|
38
|
+
@logger&.debug("Opening persistent session for #{@session_key} with #{@shell}")
|
|
39
|
+
@reader, @writer, @pid = PTY.spawn("#{@kubectl_cmd} -- #{@shell}")
|
|
40
|
+
@writer.sync = true
|
|
41
|
+
|
|
42
|
+
# Wait briefly for shell to be ready (no prompt expected without --tty)
|
|
43
|
+
sleep(0.1)
|
|
44
|
+
|
|
45
|
+
@logger&.debug("Persistent session established (PID: #{@pid})")
|
|
46
|
+
true
|
|
47
|
+
rescue StandardError => e
|
|
48
|
+
cleanup
|
|
49
|
+
raise PtyError, "Failed to connect: #{e.message}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def connected?
|
|
53
|
+
@reader && @writer && !@reader.closed? && !@writer.closed?
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def healthy?
|
|
57
|
+
return false unless connected?
|
|
58
|
+
|
|
59
|
+
begin
|
|
60
|
+
Process.kill(0, @pid)
|
|
61
|
+
true
|
|
62
|
+
rescue Errno::ESRCH
|
|
63
|
+
false
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def execute(command)
|
|
68
|
+
raise SessionClosedError, 'Session not connected' unless connected?
|
|
69
|
+
raise SessionClosedError, 'Session unhealthy' unless healthy?
|
|
70
|
+
|
|
71
|
+
@logger&.debug("Executing in PTY session: #{command}")
|
|
72
|
+
|
|
73
|
+
# Send command with exit code marker
|
|
74
|
+
cmd_with_marker = "#{command} 2>&1 ; echo __EXIT_CODE__=$?"
|
|
75
|
+
@writer.puts(cmd_with_marker)
|
|
76
|
+
@writer.flush
|
|
77
|
+
|
|
78
|
+
# Read output until exit code marker
|
|
79
|
+
output = read_until_marker
|
|
80
|
+
parse_output(output, command)
|
|
81
|
+
rescue Errno::EIO => e
|
|
82
|
+
raise SessionClosedError, "Connection lost: #{e.message}"
|
|
83
|
+
rescue Timeout::Error
|
|
84
|
+
raise CommandTimeoutError, "Command timed out after #{@command_timeout}s"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def disconnect
|
|
88
|
+
return unless connected?
|
|
89
|
+
|
|
90
|
+
@logger&.debug("Closing PTY session #{@session_key}")
|
|
91
|
+
begin
|
|
92
|
+
@writer.puts 'exit' unless @writer.closed?
|
|
93
|
+
@writer.close unless @writer.closed?
|
|
94
|
+
@reader.close unless @reader.closed?
|
|
95
|
+
Process.wait(@pid, Process::WNOHANG)
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
@logger&.warn("Error during disconnect: #{e.message}")
|
|
98
|
+
ensure
|
|
99
|
+
@reader = nil
|
|
100
|
+
@writer = nil
|
|
101
|
+
@pid = nil
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
alias cleanup disconnect
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def read_until_marker
|
|
110
|
+
buffer = +'' # Unfreeze string
|
|
111
|
+
|
|
112
|
+
Timeout.timeout(@command_timeout) do
|
|
113
|
+
while (line = @reader.gets)
|
|
114
|
+
buffer << line
|
|
115
|
+
break if line =~ /__EXIT_CODE__=(\d+)/
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
buffer
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def parse_output(buffer, command)
|
|
123
|
+
# Strip ANSI sequences
|
|
124
|
+
cleaned = strip_ansi_sequences(buffer)
|
|
125
|
+
|
|
126
|
+
# Extract exit code
|
|
127
|
+
exit_code = 1
|
|
128
|
+
if (match = cleaned.match(/__EXIT_CODE__=(\d+)/))
|
|
129
|
+
exit_code = match[1].to_i
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Remove exit code line
|
|
133
|
+
cleaned = cleaned.gsub(/__EXIT_CODE__=\d+.*$/, '')
|
|
134
|
+
|
|
135
|
+
# Split into lines and remove command echo
|
|
136
|
+
lines = cleaned.lines
|
|
137
|
+
# Remove command wrapper echo (exact match)
|
|
138
|
+
cmd_wrapper = "#{command} 2>&1 ; echo __EXIT_CODE__=$?"
|
|
139
|
+
lines.reject! { |l| l.strip == cmd_wrapper.strip || l.strip == command.strip }
|
|
140
|
+
|
|
141
|
+
output = lines.join
|
|
142
|
+
|
|
143
|
+
# Separate stdout/stderr based on exit code
|
|
144
|
+
if exit_code.zero?
|
|
145
|
+
Train::Extras::CommandResult.new(output.strip, '', exit_code)
|
|
146
|
+
else
|
|
147
|
+
Train::Extras::CommandResult.new('', output.strip, exit_code)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def strip_ansi_sequences(text)
|
|
152
|
+
AnsiSanitizer.sanitize(text)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ansi_sanitizer'
|
|
4
|
+
require_relative 'retry_handler'
|
|
5
|
+
|
|
6
|
+
module TrainPlugins
|
|
7
|
+
module K8sContainer
|
|
8
|
+
# Processes kubectl command results: validation, sanitization, exit code parsing
|
|
9
|
+
# Consolidates result processing logic for cleaner separation of concerns
|
|
10
|
+
class ResultProcessor
|
|
11
|
+
# Connection error patterns that trigger retries
|
|
12
|
+
# Note: Be specific to avoid false positives (e.g., "command not found" is NOT a connection error)
|
|
13
|
+
CONNECTION_ERROR_PATTERNS = [
|
|
14
|
+
'error dialing backend',
|
|
15
|
+
'connection refused',
|
|
16
|
+
'pods "', # "pods \"name\" not found" - kubectl can't find pod
|
|
17
|
+
'namespaces "', # "namespaces \"name\" not found" - kubectl can't find namespace
|
|
18
|
+
'Error from server', # kubectl API errors
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Commands that don't produce output (used for silent failure detection)
|
|
22
|
+
SILENT_COMMANDS = %w[true false touch mkdir rm sleep test].freeze
|
|
23
|
+
|
|
24
|
+
# Process a command result: validate, sanitize, and return Train::Extras::CommandResult
|
|
25
|
+
# @param result [Mixlib::ShellOut::Result] The raw command result
|
|
26
|
+
# @param command [String] The command that was executed
|
|
27
|
+
# @param logger [Logger] Logger instance for warnings
|
|
28
|
+
# @return [Train::Extras::CommandResult] Processed result
|
|
29
|
+
# @raise [RetryHandler::NetworkError] On silent failures
|
|
30
|
+
# @raise [RetryHandler::ConnectionError] On connection errors
|
|
31
|
+
def self.process(result, command, logger)
|
|
32
|
+
validate(result, command, logger)
|
|
33
|
+
sanitize(result)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Validate result for connection errors and silent failures
|
|
37
|
+
# @param result [Object] Result with exit_status, stdout, stderr
|
|
38
|
+
# @param command [String] The command that was executed
|
|
39
|
+
# @param logger [Logger] Logger instance
|
|
40
|
+
# @raise [RetryHandler::NetworkError] On silent failures
|
|
41
|
+
# @raise [RetryHandler::ConnectionError] On connection errors
|
|
42
|
+
def self.validate(result, command, logger)
|
|
43
|
+
# Detect silent network failures (kubectl returns exit 0 despite errors)
|
|
44
|
+
if result.exit_status.zero? && result.stdout.empty? && result.stderr.empty? && should_produce_output?(command)
|
|
45
|
+
logger.warn("Silent failure detected for command: #{command}")
|
|
46
|
+
raise RetryHandler::NetworkError, 'Silent failure - no output received'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Check for connection-related errors in stderr
|
|
50
|
+
if CONNECTION_ERROR_PATTERNS.any? { |pattern| result.stderr.include?(pattern) }
|
|
51
|
+
logger.warn("Connection error: #{result.stderr}")
|
|
52
|
+
raise RetryHandler::ConnectionError, result.stderr
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
result
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Sanitize result: remove ANSI sequences, parse exit codes
|
|
59
|
+
# @param result [Object] Result with stdout, stderr, exit_status
|
|
60
|
+
# @return [Train::Extras::CommandResult] Sanitized result
|
|
61
|
+
def self.sanitize(result)
|
|
62
|
+
Train::Extras::CommandResult.new(
|
|
63
|
+
AnsiSanitizer.sanitize(result.stdout),
|
|
64
|
+
AnsiSanitizer.sanitize(clean_exit_message(result.stderr)),
|
|
65
|
+
parse_exit_code(result.stderr) || result.exit_status
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Check if a command should produce output
|
|
70
|
+
# @param command [String] The command to check
|
|
71
|
+
# @return [Boolean] True if command should produce output
|
|
72
|
+
def self.should_produce_output?(command)
|
|
73
|
+
SILENT_COMMANDS.none? { |cmd| command.start_with?(cmd) }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Parse actual exit code from kubectl's stderr message
|
|
77
|
+
# kubectl sometimes appends: "command terminated with exit code N"
|
|
78
|
+
# @param stderr [String] stderr output from kubectl
|
|
79
|
+
# @return [Integer, nil] Parsed exit code or nil
|
|
80
|
+
def self.parse_exit_code(stderr)
|
|
81
|
+
match = stderr.match(/command terminated with exit code (\d+)/)
|
|
82
|
+
match[1].to_i if match
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Clean kubectl's exit message from stderr
|
|
86
|
+
# @param stderr [String] stderr output from kubectl
|
|
87
|
+
# @return [String] Cleaned stderr without kubectl messages
|
|
88
|
+
def self.clean_exit_message(stderr)
|
|
89
|
+
# Remove kubectl's "command terminated with exit code" message
|
|
90
|
+
stderr.gsub(/command terminated with exit code \d+\n?/, '')
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module TrainPlugins
|
|
6
|
+
module K8sContainer
|
|
7
|
+
# Handles retry logic with exponential backoff for transient errors
|
|
8
|
+
class RetryHandler
|
|
9
|
+
MAX_RETRIES = 3
|
|
10
|
+
BASE_DELAY = 1 # seconds
|
|
11
|
+
|
|
12
|
+
class NetworkError < K8sContainerError; end
|
|
13
|
+
class ConnectionError < K8sContainerError; end
|
|
14
|
+
|
|
15
|
+
def self.with_retry(max_retries: MAX_RETRIES, logger: nil)
|
|
16
|
+
retries = 0
|
|
17
|
+
|
|
18
|
+
begin
|
|
19
|
+
yield
|
|
20
|
+
rescue NetworkError, ConnectionError => e
|
|
21
|
+
retries += 1
|
|
22
|
+
if retries <= max_retries
|
|
23
|
+
delay = BASE_DELAY * (2**(retries - 1)) # Exponential backoff
|
|
24
|
+
logger&.warn("Transient error (attempt #{retries}/#{max_retries}): #{e.message}, retrying in #{delay}s")
|
|
25
|
+
sleep(delay)
|
|
26
|
+
retry
|
|
27
|
+
else
|
|
28
|
+
logger&.error("Failed after #{max_retries} retries: #{e.message}")
|
|
29
|
+
raise Train::TransportError, "Failed after #{max_retries} retries: #{e.message}"
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'singleton'
|
|
4
|
+
require_relative 'pty_session'
|
|
5
|
+
|
|
6
|
+
module TrainPlugins
|
|
7
|
+
module K8sContainer
|
|
8
|
+
# Thread-safe manager for PTY session pool
|
|
9
|
+
# Maintains one session per unique namespace/pod/container combination
|
|
10
|
+
class SessionManager
|
|
11
|
+
include Singleton
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@sessions = {}
|
|
15
|
+
@mutex = Mutex.new
|
|
16
|
+
@cleanup_registered = false
|
|
17
|
+
register_cleanup
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Get or create a session for the given key
|
|
21
|
+
# @param session_key [String] Unique key "namespace/pod/container"
|
|
22
|
+
# @param kubectl_cmd [String] Base kubectl exec command
|
|
23
|
+
# @param shell [String] Shell to use (/bin/bash, /bin/sh, etc.)
|
|
24
|
+
# @param timeout [Integer] Session timeout in seconds
|
|
25
|
+
# @param logger [Logger] Logger instance
|
|
26
|
+
# @return [PtySession] Active session
|
|
27
|
+
def get_session(session_key, kubectl_cmd:, shell: '/bin/bash', timeout: 300, logger: nil)
|
|
28
|
+
@mutex.synchronize do
|
|
29
|
+
unless @sessions[session_key]&.healthy?
|
|
30
|
+
# Cleanup old session if exists (must be done inside mutex, not via cleanup_session)
|
|
31
|
+
if @sessions[session_key]
|
|
32
|
+
old_session = @sessions.delete(session_key)
|
|
33
|
+
old_session&.cleanup
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
logger&.debug("Creating new PTY session for #{session_key}")
|
|
37
|
+
@sessions[session_key] = PtySession.new(
|
|
38
|
+
session_key: session_key,
|
|
39
|
+
kubectl_cmd: kubectl_cmd,
|
|
40
|
+
shell: shell,
|
|
41
|
+
timeout: timeout,
|
|
42
|
+
logger: logger
|
|
43
|
+
)
|
|
44
|
+
@sessions[session_key].connect
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
@sessions[session_key]
|
|
48
|
+
end
|
|
49
|
+
rescue PtySession::PtyError
|
|
50
|
+
# Cleanup without recursive mutex (already inside synchronize block)
|
|
51
|
+
@sessions.delete(session_key)
|
|
52
|
+
raise
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Cleanup a specific session
|
|
56
|
+
# @param session_key [String] Session to cleanup
|
|
57
|
+
def cleanup_session(session_key)
|
|
58
|
+
@mutex.synchronize do
|
|
59
|
+
session = @sessions.delete(session_key)
|
|
60
|
+
session&.cleanup
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Cleanup all sessions
|
|
65
|
+
def cleanup_all
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
@sessions.each_value(&:cleanup)
|
|
68
|
+
@sessions.clear
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get session count (for monitoring/testing)
|
|
73
|
+
def session_count
|
|
74
|
+
@sessions.size
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Get all session keys (for monitoring/testing)
|
|
78
|
+
def session_keys
|
|
79
|
+
@sessions.keys
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def register_cleanup
|
|
85
|
+
return if @cleanup_registered
|
|
86
|
+
|
|
87
|
+
at_exit do
|
|
88
|
+
cleanup_all
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
@cleanup_registered = true
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module TrainPlugins
|
|
4
|
+
module K8sContainer
|
|
5
|
+
# Detects available shell in container with tiered fallback
|
|
6
|
+
# Supports Unix shells (bash, sh, ash) and Windows shells (cmd, PowerShell)
|
|
7
|
+
class ShellDetector
|
|
8
|
+
UNIX_SHELLS = [
|
|
9
|
+
'/bin/bash', # Ubuntu, Debian, RHEL, CentOS
|
|
10
|
+
'/bin/sh', # POSIX standard, symlink in most distros
|
|
11
|
+
'/bin/ash', # Alpine, BusyBox
|
|
12
|
+
'/bin/zsh', # Less common but possible
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
WINDOWS_SHELLS = [
|
|
16
|
+
'cmd.exe', # Windows command prompt (most reliable)
|
|
17
|
+
'powershell.exe', # PowerShell 5.1 (Windows Server)
|
|
18
|
+
'pwsh.exe', # PowerShell Core 6+
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Linux distribution family mappings based on /etc/os-release ID
|
|
22
|
+
# Maps distribution IDs to Train family names
|
|
23
|
+
LINUX_FAMILY_MAP = {
|
|
24
|
+
# Debian family
|
|
25
|
+
'debian' => 'debian',
|
|
26
|
+
'ubuntu' => 'debian',
|
|
27
|
+
'linuxmint' => 'debian',
|
|
28
|
+
'raspbian' => 'debian',
|
|
29
|
+
'kali' => 'debian',
|
|
30
|
+
# RedHat family
|
|
31
|
+
'rhel' => 'redhat',
|
|
32
|
+
'centos' => 'redhat',
|
|
33
|
+
'fedora' => 'fedora',
|
|
34
|
+
'rocky' => 'redhat',
|
|
35
|
+
'almalinux' => 'redhat',
|
|
36
|
+
'ol' => 'redhat', # Oracle Linux
|
|
37
|
+
'amzn' => 'redhat', # Amazon Linux
|
|
38
|
+
# SUSE family
|
|
39
|
+
'sles' => 'suse',
|
|
40
|
+
'opensuse' => 'suse',
|
|
41
|
+
'opensuse-leap' => 'suse',
|
|
42
|
+
'opensuse-tumbleweed' => 'suse',
|
|
43
|
+
# Alpine
|
|
44
|
+
'alpine' => 'alpine',
|
|
45
|
+
# Arch
|
|
46
|
+
'arch' => 'arch',
|
|
47
|
+
'manjaro' => 'arch',
|
|
48
|
+
# Gentoo
|
|
49
|
+
'gentoo' => 'gentoo',
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
def initialize(kubectl_client)
|
|
53
|
+
@kubectl_client = kubectl_client
|
|
54
|
+
@detected_shell = :not_detected
|
|
55
|
+
@container_os = :unknown
|
|
56
|
+
@linux_family = nil
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def detect
|
|
60
|
+
return @detected_shell unless @detected_shell == :not_detected
|
|
61
|
+
|
|
62
|
+
# Detect container OS first (heuristic approach like train-docker)
|
|
63
|
+
detect_container_os
|
|
64
|
+
|
|
65
|
+
# Try appropriate shells based on OS
|
|
66
|
+
shells_to_try = case @container_os
|
|
67
|
+
when :unix then UNIX_SHELLS
|
|
68
|
+
when :windows then WINDOWS_SHELLS
|
|
69
|
+
else UNIX_SHELLS + WINDOWS_SHELLS # Try both if unknown
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
shells_to_try.each do |shell_path|
|
|
73
|
+
if shell_available?(shell_path)
|
|
74
|
+
@detected_shell = shell_path
|
|
75
|
+
return @detected_shell
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
@detected_shell = nil # No shell available (distroless)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def shell_available?(shell_path)
|
|
83
|
+
if self.class.windows_shell?(shell_path)
|
|
84
|
+
windows_shell_available?(shell_path)
|
|
85
|
+
else
|
|
86
|
+
unix_shell_available?(shell_path)
|
|
87
|
+
end
|
|
88
|
+
rescue StandardError
|
|
89
|
+
false
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Check if shell is a Windows shell (ends with .exe)
|
|
93
|
+
# @param shell_path [String] Path or name of shell
|
|
94
|
+
# @return [Boolean] True if Windows shell
|
|
95
|
+
def self.windows_shell?(shell_path)
|
|
96
|
+
shell_path.end_with?('.exe')
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def unix_shell_available?(shell_path)
|
|
100
|
+
# Use test -x to check if shell exists and is executable
|
|
101
|
+
result = @kubectl_client.execute_raw("test -x #{shell_path} && echo OK")
|
|
102
|
+
result.stdout.strip == 'OK' && result.exit_status.zero?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def windows_shell_available?(shell_path)
|
|
106
|
+
# For Windows, use 'where' command to check if shell exists
|
|
107
|
+
result = @kubectl_client.execute_raw("where #{shell_path}")
|
|
108
|
+
result.exit_status.zero? && !result.stdout.empty?
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def shell_type
|
|
112
|
+
case @detected_shell
|
|
113
|
+
when '/bin/bash' then :bash
|
|
114
|
+
when '/bin/sh' then :sh
|
|
115
|
+
when '/bin/ash' then :ash
|
|
116
|
+
when '/bin/zsh' then :zsh
|
|
117
|
+
when 'cmd.exe' then :cmd
|
|
118
|
+
when 'powershell.exe' then :powershell
|
|
119
|
+
when 'pwsh.exe' then :pwsh
|
|
120
|
+
when nil then :none
|
|
121
|
+
else :unknown
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
attr_reader :container_os, :linux_family
|
|
126
|
+
|
|
127
|
+
def windows_container?
|
|
128
|
+
@container_os == :windows
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def unix_container?
|
|
132
|
+
@container_os == :unix
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Check if container is running Linux (subset of Unix)
|
|
136
|
+
def linux_container?
|
|
137
|
+
@container_os == :unix && !@linux_family.nil?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def detect_container_os
|
|
143
|
+
# Try a simple Unix command - fails on Windows with specific error pattern
|
|
144
|
+
# Following train-docker's heuristic approach
|
|
145
|
+
result = @kubectl_client.execute_raw('echo test')
|
|
146
|
+
|
|
147
|
+
@container_os = if result.exit_status.zero? && result.stdout.strip == 'test'
|
|
148
|
+
# Unix container (echo works normally)
|
|
149
|
+
# Also detect Linux family for more specific platform matching
|
|
150
|
+
detect_linux_family
|
|
151
|
+
:unix
|
|
152
|
+
elsif result.stderr.match?(/not recognized|not found|command not found/i)
|
|
153
|
+
# Likely Windows (Unix commands fail)
|
|
154
|
+
:windows
|
|
155
|
+
else
|
|
156
|
+
# Unknown - will try both shell types
|
|
157
|
+
:unknown
|
|
158
|
+
end
|
|
159
|
+
rescue StandardError
|
|
160
|
+
@container_os = :unknown
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# Detect Linux distribution family from /etc/os-release
|
|
164
|
+
# This enables InSpec resources that require os.linux? to work
|
|
165
|
+
def detect_linux_family
|
|
166
|
+
# Try to read /etc/os-release which is standard on modern Linux
|
|
167
|
+
result = @kubectl_client.execute_raw('cat /etc/os-release 2>/dev/null')
|
|
168
|
+
return unless result.exit_status.zero?
|
|
169
|
+
|
|
170
|
+
# Parse the ID field from os-release
|
|
171
|
+
os_release = result.stdout
|
|
172
|
+
id_match = os_release.match(/^ID=["']?([^"'\n]+)["']?$/i)
|
|
173
|
+
return unless id_match
|
|
174
|
+
|
|
175
|
+
distro_id = id_match[1].downcase.strip
|
|
176
|
+
@linux_family = LINUX_FAMILY_MAP[distro_id]
|
|
177
|
+
|
|
178
|
+
# If no exact match, try ID_LIKE for derivative distros
|
|
179
|
+
return if @linux_family
|
|
180
|
+
|
|
181
|
+
id_like_match = os_release.match(/^ID_LIKE=["']?([^"'\n]+)["']?$/i)
|
|
182
|
+
return unless id_like_match
|
|
183
|
+
|
|
184
|
+
# ID_LIKE can have multiple values, try each one
|
|
185
|
+
id_like_match[1].split.each do |like_id|
|
|
186
|
+
family = LINUX_FAMILY_MAP[like_id.downcase.strip]
|
|
187
|
+
if family
|
|
188
|
+
@linux_family = family
|
|
189
|
+
break
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
rescue StandardError
|
|
193
|
+
# Ignore errors - linux_family will remain nil (defaults to generic linux)
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|