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.
Files changed (40) hide show
  1. checksums.yaml +7 -0
  2. data/.expeditor/buildkite/coverage.sh +46 -0
  3. data/.expeditor/buildkite/run_linux_tests.sh +16 -0
  4. data/.expeditor/config.yml +61 -0
  5. data/.expeditor/coverage.pipeline.yml +19 -0
  6. data/.expeditor/update_version.sh +12 -0
  7. data/.expeditor/verify.pipeline.yml +44 -0
  8. data/.rspec +4 -0
  9. data/.rubocop.yml +57 -0
  10. data/CHANGELOG.md +158 -0
  11. data/CODE_OF_CONDUCT.md +13 -0
  12. data/CONTRIBUTING.md +161 -0
  13. data/DEVELOPMENT.md +315 -0
  14. data/Gemfile +23 -0
  15. data/LICENSE.md +9 -0
  16. data/NOTICE.md +9 -0
  17. data/README.md +237 -0
  18. data/Rakefile +37 -0
  19. data/SECURITY.md +100 -0
  20. data/VERSION +1 -0
  21. data/cliff.toml +80 -0
  22. data/docs/README.md +1 -0
  23. data/lib/train-k8s-container/ansi_sanitizer.rb +31 -0
  24. data/lib/train-k8s-container/connection.rb +102 -0
  25. data/lib/train-k8s-container/errors.rb +22 -0
  26. data/lib/train-k8s-container/kubectl_command_builder.rb +87 -0
  27. data/lib/train-k8s-container/kubectl_exec_client.rb +176 -0
  28. data/lib/train-k8s-container/kubernetes_name_validator.rb +44 -0
  29. data/lib/train-k8s-container/platform.rb +93 -0
  30. data/lib/train-k8s-container/pty_session.rb +156 -0
  31. data/lib/train-k8s-container/result_processor.rb +94 -0
  32. data/lib/train-k8s-container/retry_handler.rb +35 -0
  33. data/lib/train-k8s-container/session_manager.rb +95 -0
  34. data/lib/train-k8s-container/shell_detector.rb +198 -0
  35. data/lib/train-k8s-container/transport.rb +30 -0
  36. data/lib/train-k8s-container/version.rb +7 -0
  37. data/lib/train-k8s-container.rb +12 -0
  38. data/sonar-project.properties +17 -0
  39. data/train-k8s-container.gemspec +49 -0
  40. 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