train-juniper 0.7.0 → 0.7.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: abb9487cdbf492d9b94f9b45699e2a434095509e128d5928863e5fdff3c73241
4
- data.tar.gz: 0d9d6746cb16f185eef54b199aaf02b4509e66cc9292af3475913bae89f3e36c
3
+ metadata.gz: 86bf86dd54cabe94e072c6e3a2b64ffcdfbdf774a5f33f1a3a76012829dd71bc
4
+ data.tar.gz: c2e5d2fe1ecfa259f78c2d80f76d94eaa68afb817d584c57d02bfb4c46779924
5
5
  SHA512:
6
- metadata.gz: 8c7c246e820daa1d436c03d4f95e7acab6bef59c600c9f18d99fcc94b63989a0adf00a483661fc362db634871446b469aa0bed753a6852b40d2da768709882d8
7
- data.tar.gz: db80d5fdefcb80294734f162b67f48f21219859ebed3ee294bcb1449876be0a7c7758e4ed613c8fc3e40a9d7d67c25df12316cc56e31352dc648eda2c8184c59
6
+ metadata.gz: 168356b0c2a48f7b4817acb535571c2b651d12365dc7340c96c9b2b2d6db30981516683c29965bd295459d8cbb2b2625e020a35789a3bbed4d295ce29712264e
7
+ data.tar.gz: 8a209708671652728890ff96f75b3a2da922f37f93c61896881e99b387adc15ecc1e889d6e416637dc0eee011ed1f1b9b0fcd33b99c2bacfe15f2736daaa9da8
data/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.1] - 2025-06-23
9
+
10
+ ### Added
11
+
12
+ - Implement Priority 2 DRY improvements and boost coverage to 90.88%
13
+ - Add coverage analysis utility script
14
+ - Boost test coverage to 93.13% and enhance coverage analysis tool
15
+ - **coverage**: Integrate coverage reporting into release process and CI/CD
16
+ - **docs**: Enhance coverage report with Material for MkDocs styling
17
+
18
+ ### Documentation
19
+
20
+ - Add YARD documentation for inspect method and test organization guide
21
+ - Add YARD documentation for all constants
22
+ - **roadmap**: Modernize with v0.7.1 status and Material styling
23
+
24
+ ### Fixed
25
+
26
+ - Add v0.7.0 to mkdocs and automate nav updates
27
+ - **coverage**: Properly handle SimpleCov :nocov: markers in analysis
28
+ - **docs**: Move Security Policy to About section in navigation
29
+
30
+ ### Miscellaneous Tasks
31
+
32
+ - Fix all RuboCop violations and prepare for v0.7.1 release
33
+ - Remove .rubocop_todo.yml after fixing all violations
34
+
35
+ ### Refactor
36
+
37
+ - Streamline release process for GitHub Actions
38
+ - Phase 1 modularization - extract JuniperFile, EnvironmentHelpers, and Validation
39
+ - Reorganize directory structure to follow Train plugin conventions
40
+ - Phase 2 modularization - extract CommandExecutor and ErrorHandling
41
+ - Phase 3 modularization - extract SSHSession and BastionProxy
42
+ - DRY improvements for v0.7.1
43
+ - Fix all RuboCop complexity issues without using todos
44
+ - **docs**: Reorganize navigation for better user experience
45
+
46
+ ### Testing
47
+
48
+ - Fix platform edge case test and boost coverage to 99.75%
49
+ - Achieve 100% code coverage 🎯
50
+
8
51
  ## [0.7.0] - 2025-06-23
9
52
 
10
53
  ### Added
data/Rakefile CHANGED
@@ -103,5 +103,5 @@ end
103
103
  #------------------------------------------------------------------#
104
104
  # Bundler Gem Tasks
105
105
  #------------------------------------------------------------------#
106
- # This provides the standard 'rake release' task that rubygems/release-gem expects
107
- require 'bundler/gem_tasks'
106
+ # Bundler gem tasks disabled - we use GitHub Actions for gem publication
107
+ # require 'bundler/gem_tasks'
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'train-juniper/constants'
4
+
5
+ module TrainPlugins
6
+ module Juniper
7
+ # Handles bastion host proxy configuration and authentication
8
+ module BastionProxy
9
+ # Configure bastion proxy for SSH connection
10
+ # @param ssh_options [Hash] SSH options to modify
11
+ def configure_bastion_proxy(ssh_options)
12
+ require 'net/ssh/proxy/jump' unless defined?(Net::SSH::Proxy::Jump)
13
+
14
+ # Build proxy jump string from bastion options
15
+ bastion_user = @options[:bastion_user] || @options[:user]
16
+ bastion_port = @options[:bastion_port]
17
+
18
+ proxy_jump = if bastion_port == Constants::DEFAULT_SSH_PORT
19
+ "#{bastion_user}@#{@options[:bastion_host]}"
20
+ else
21
+ "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
22
+ end
23
+
24
+ @logger.debug("Using bastion host: #{proxy_jump}")
25
+
26
+ # Set up automated password authentication via SSH_ASKPASS
27
+ setup_bastion_password_auth
28
+
29
+ ssh_options[:proxy] = Net::SSH::Proxy::Jump.new(proxy_jump)
30
+ end
31
+
32
+ # Set up SSH_ASKPASS for bastion password authentication
33
+ def setup_bastion_password_auth
34
+ bastion_password = @options[:bastion_password] || @options[:password]
35
+ return unless bastion_password
36
+
37
+ @ssh_askpass_script = create_ssh_askpass_script(bastion_password)
38
+ ENV['SSH_ASKPASS'] = @ssh_askpass_script
39
+ ENV['SSH_ASKPASS_REQUIRE'] = 'force'
40
+ @logger.debug('Configured SSH_ASKPASS for automated bastion authentication')
41
+ end
42
+
43
+ # Create temporary SSH_ASKPASS script for automated password authentication
44
+ # @param password [String] The password to use
45
+ # @return [String] Path to the created script
46
+ def create_ssh_askpass_script(password)
47
+ require 'tempfile'
48
+
49
+ script = Tempfile.new(['ssh_askpass', '.sh'])
50
+ script.write("#!/bin/bash\necho '#{password}'\n")
51
+ script.close
52
+ File.chmod(0o755, script.path)
53
+
54
+ @logger.debug("Created SSH_ASKPASS script at #{script.path}")
55
+ script.path
56
+ end
57
+
58
+ # Generate SSH proxy command for bastion host using ProxyJump (-J)
59
+ # @param bastion_user [String] Username for bastion
60
+ # @param bastion_port [Integer] Port for bastion
61
+ # @return [String] SSH command string
62
+ def generate_bastion_proxy_command(bastion_user, bastion_port)
63
+ args = ['ssh']
64
+
65
+ # SSH options for connection
66
+ Constants::STANDARD_SSH_OPTIONS.each do |key, value|
67
+ args += ['-o', "#{key}=#{value}"]
68
+ end
69
+
70
+ # Use ProxyJump (-J) which handles password authentication properly
71
+ jump_host = if bastion_port == Constants::DEFAULT_SSH_PORT
72
+ "#{bastion_user}@#{@options[:bastion_host]}"
73
+ else
74
+ "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
75
+ end
76
+ args += ['-J', jump_host]
77
+
78
+ # Add SSH keys if specified
79
+ if @options[:key_files]
80
+ Array(@options[:key_files]).each do |key_file|
81
+ args += ['-i', key_file]
82
+ end
83
+ end
84
+
85
+ # Target connection - %h and %p will be replaced by Net::SSH
86
+ args += ['%h', '-p', '%p']
87
+
88
+ args.join(' ')
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module Juniper
5
+ # Handles command execution, sanitization, and output formatting
6
+ module CommandExecutor
7
+ # Command sanitization patterns
8
+ # Note: Pipe (|) is allowed as it's commonly used in JunOS commands
9
+ DANGEROUS_COMMAND_PATTERNS = [
10
+ /[;&<>$`]/, # Shell metacharacters (excluding pipe)
11
+ /\n|\r/, # Newlines that could inject commands
12
+ /\\(?![nrt])/ # Escape sequences (except valid ones like \n, \r, \t)
13
+ ].freeze
14
+
15
+ # Execute commands on Juniper device via SSH
16
+ # @param cmd [String] The JunOS command to execute
17
+ # @return [CommandResult] Result object with stdout, stderr, and exit status
18
+ # @raise [Train::ClientError] If command contains dangerous characters
19
+ # @example
20
+ # result = connection.run_command('show version')
21
+ # puts result.stdout
22
+ def run_command_via_connection(cmd)
23
+ # Sanitize command to prevent injection
24
+ safe_cmd = sanitize_command(cmd)
25
+
26
+ return mock_command_result(safe_cmd) if @options[:mock]
27
+
28
+ begin
29
+ # :nocov: Real SSH execution cannot be tested without actual devices
30
+ # Ensure we're connected
31
+ connect unless connected?
32
+
33
+ log_command(safe_cmd)
34
+
35
+ # Execute command via SSH session
36
+ output = @ssh_session.exec!(safe_cmd)
37
+
38
+ @logger.debug("Command output: #{output}")
39
+
40
+ # Format JunOS result
41
+ format_junos_result(output, safe_cmd)
42
+ rescue StandardError => e
43
+ log_error(e, 'Command execution failed')
44
+ # Handle connection errors gracefully
45
+ error_result(e.message)
46
+ # :nocov:
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ # Sanitize command to prevent injection attacks
53
+ def sanitize_command(cmd)
54
+ cmd_str = cmd.to_s.strip
55
+
56
+ if DANGEROUS_COMMAND_PATTERNS.any? { |pattern| cmd_str.match?(pattern) }
57
+ raise Train::ClientError, "Invalid characters in command: #{cmd_str.inspect}"
58
+ end
59
+
60
+ cmd_str
61
+ end
62
+
63
+ # Format JunOS command results
64
+ def format_junos_result(output, cmd)
65
+ # Parse JunOS-specific error patterns
66
+ if junos_error?(output)
67
+ error_result(output)
68
+ else
69
+ success_result(output, cmd)
70
+ end
71
+ end
72
+
73
+ # Clean command output
74
+ def clean_output(output, cmd)
75
+ # Handle nil output gracefully
76
+ return '' if output.nil?
77
+
78
+ # Remove command echo and prompts
79
+ lines = output.to_s.split("\n")
80
+ lines.reject! { |line| line.strip == cmd.strip }
81
+
82
+ # Remove JunOS prompt patterns from the end
83
+ lines.pop while lines.last&.strip&.match?(/^[%>$#]+\s*$/)
84
+
85
+ lines.join("\n")
86
+ end
87
+
88
+ # Mock command execution for testing
89
+ def mock_command_result(cmd)
90
+ output, exit_status = MockResponses.response_for(cmd)
91
+ # For mock mode, network devices return errors as stdout
92
+ Train::Extras::CommandResult.new(output, '', exit_status)
93
+ end
94
+
95
+ # Factory method for successful command results
96
+ # @param output [String] Command output
97
+ # @param cmd [String, nil] Original command for cleaning output
98
+ # @return [Train::Extras::CommandResult] Successful result object
99
+ def success_result(output, cmd = nil)
100
+ output = clean_output(output, cmd) if cmd
101
+ Train::Extras::CommandResult.new(output, '', 0)
102
+ end
103
+
104
+ # Factory method for error command results
105
+ # @param message [String] Error message
106
+ # @return [Train::Extras::CommandResult] Error result object
107
+ def error_result(message)
108
+ Train::Extras::CommandResult.new('', message, 1)
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module Juniper
5
+ # Handles error detection, classification, and messaging
6
+ module ErrorHandling
7
+ # JunOS error patterns organized by type
8
+ JUNOS_ERRORS = {
9
+ configuration: [/^error:/i, /configuration database locked/i],
10
+ syntax: [/syntax error/i],
11
+ command: [/invalid command/i, /unknown command/i],
12
+ argument: [/missing argument/i]
13
+ }.freeze
14
+
15
+ # Flattened error patterns for quick matching
16
+ JUNOS_ERROR_PATTERNS = JUNOS_ERRORS.values.flatten.freeze
17
+
18
+ # Check for JunOS error patterns
19
+ # @param output [String] Command output to check
20
+ # @return [Boolean] true if output contains error patterns
21
+ def junos_error?(output)
22
+ return false if output.nil? || output.empty?
23
+
24
+ JUNOS_ERROR_PATTERNS.any? { |pattern| output.match?(pattern) }
25
+ end
26
+
27
+ # Handle connection errors with helpful messages
28
+ # @param error [StandardError] The error that occurred
29
+ # @raise [Train::TransportError] Always raises with formatted message
30
+ def handle_connection_error(error)
31
+ @logger.error("SSH connection failed: #{error.message}")
32
+
33
+ if bastion_auth_error?(error)
34
+ raise Train::TransportError, bastion_error_message(error)
35
+ else
36
+ raise Train::TransportError, "Failed to connect to Juniper device #{@options[:host]}: #{error.message}"
37
+ end
38
+ end
39
+
40
+ # Check if error is bastion authentication related
41
+ # @param error [StandardError] The error to check
42
+ # @return [Boolean] true if error is bastion-related
43
+ def bastion_auth_error?(error)
44
+ @options[:bastion_host] &&
45
+ (error.message.include?('Permission denied') || error.message.include?('command failed'))
46
+ end
47
+
48
+ # Build helpful bastion error message
49
+ # @param error [StandardError] The original error
50
+ # @return [String] Detailed error message with troubleshooting steps
51
+ def bastion_error_message(error)
52
+ <<~ERROR
53
+ Failed to connect to Juniper device #{@options[:host]} via bastion #{@options[:bastion_host]}: #{error.message}
54
+
55
+ Possible causes:
56
+ 1. Incorrect bastion credentials (user: #{@options[:bastion_user] || @options[:user]})
57
+ 2. Network connectivity issues to bastion host
58
+ 3. Bastion host SSH service not available on port #{@options[:bastion_port]}
59
+ 4. Target device not reachable from bastion
60
+
61
+ Authentication options:
62
+ - Password: Use --bastion-password (or JUNIPER_BASTION_PASSWORD env var)
63
+ - SSH Key: Use --key-files option to specify SSH private key files
64
+ - SSH Agent: Ensure your SSH agent has the required keys loaded
65
+
66
+ For more details, see: https://mitre.github.io/train-juniper/troubleshooting/#bastion-authentication
67
+ ERROR
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module Juniper
5
+ # Handles SSH session management and configuration
6
+ module SSHSession
7
+ # SSH option mapping configuration
8
+ SSH_OPTION_MAPPING = {
9
+ port: :port,
10
+ password: :password,
11
+ timeout: :timeout,
12
+ keepalive: :keepalive,
13
+ keepalive_interval: :keepalive_interval,
14
+ keys: ->(opts) { Array(opts[:key_files]) if opts[:key_files] },
15
+ keys_only: ->(opts) { opts[:keys_only] if opts[:key_files] }
16
+ }.freeze
17
+
18
+ # Default SSH options for Juniper connections
19
+ # @note verify_host_key is set to :never for network device compatibility
20
+ SSH_DEFAULTS = {
21
+ verify_host_key: :never
22
+ }.freeze
23
+
24
+ # Establish SSH connection to Juniper device
25
+ def connect
26
+ return if connected?
27
+
28
+ # :nocov: Real SSH connections cannot be tested without actual devices
29
+ begin
30
+ # Use direct SSH connection (network device pattern)
31
+ # Defensive loading - only require if not fully loaded
32
+ require 'net/ssh' unless defined?(Net::SSH) && Net::SSH.respond_to?(:start)
33
+
34
+ @logger.debug('Establishing SSH connection to Juniper device')
35
+
36
+ ssh_options = build_ssh_options
37
+
38
+ # Add bastion host support if configured
39
+ if @options[:bastion_host]
40
+ log_bastion_connection(@options[:bastion_host])
41
+ configure_bastion_proxy(ssh_options)
42
+ end
43
+
44
+ log_connection_attempt(@options[:host], @options[:port])
45
+ log_ssh_options(ssh_options)
46
+
47
+ # Direct SSH connection
48
+ @ssh_session = Net::SSH.start(@options[:host], @options[:user], ssh_options)
49
+ log_connection_success(@options[:host])
50
+
51
+ # Configure JunOS session for automation
52
+ test_and_configure_session
53
+ rescue StandardError => e
54
+ handle_connection_error(e)
55
+ end
56
+ # :nocov:
57
+ end
58
+
59
+ # Check if SSH connection is active
60
+ # @return [Boolean] true if connected, false otherwise
61
+ def connected?
62
+ return true if @options[:mock]
63
+
64
+ !@ssh_session.nil?
65
+ rescue StandardError
66
+ false
67
+ end
68
+
69
+ # Check if running in mock mode
70
+ # @return [Boolean] true if in mock mode
71
+ def mock?
72
+ @options[:mock] == true
73
+ end
74
+
75
+ # Test connection and configure JunOS session
76
+ def test_and_configure_session
77
+ @logger.debug('Testing SSH connection and configuring JunOS session')
78
+
79
+ # Test connection first
80
+ @ssh_session.exec!('echo "connection test"')
81
+ @logger.debug('SSH connection test successful')
82
+
83
+ # Optimize CLI for automation
84
+ @ssh_session.exec!('set cli screen-length 0')
85
+ @ssh_session.exec!('set cli screen-width 0')
86
+ @ssh_session.exec!('set cli complete-on-space off') if @options[:disable_complete_on_space]
87
+
88
+ @logger.debug('JunOS session configured successfully')
89
+ rescue StandardError => e
90
+ @logger.warn("Failed to configure JunOS session: #{e.message}")
91
+ end
92
+
93
+ private
94
+
95
+ # Build SSH connection options from @options
96
+ def build_ssh_options
97
+ SSH_DEFAULTS.merge(
98
+ SSH_OPTION_MAPPING.each_with_object({}) do |(ssh_key, option_key), opts|
99
+ value = option_key.is_a?(Proc) ? option_key.call(@options) : @options[option_key]
100
+ opts[ssh_key] = value unless value.nil?
101
+ end
102
+ )
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module Juniper
5
+ # Validation methods for connection options
6
+ module Validation
7
+ # Validate all connection options
8
+ def validate_connection_options!
9
+ validate_required_options!
10
+ validate_option_types!
11
+ validate_proxy_options!
12
+ end
13
+
14
+ # Validate required options are present
15
+ def validate_required_options!
16
+ raise Train::ClientError, 'Host is required' unless @options[:host]
17
+ raise Train::ClientError, 'User is required' unless @options[:user]
18
+ end
19
+
20
+ # Validate option types and ranges
21
+ def validate_option_types!
22
+ validate_port! if @options[:port]
23
+ validate_timeout! if @options[:timeout]
24
+ validate_bastion_port! if @options[:bastion_port]
25
+ end
26
+
27
+ # Validate port is in valid range
28
+ def validate_port!
29
+ validate_port_value!(:port)
30
+ end
31
+
32
+ # Validate timeout is positive number
33
+ def validate_timeout!
34
+ timeout = @options[:timeout]
35
+ raise Train::ClientError, "Invalid timeout: #{timeout} (must be positive number)" unless timeout.is_a?(Numeric) && timeout.positive?
36
+ end
37
+
38
+ # Validate bastion port is in valid range
39
+ def validate_bastion_port!
40
+ validate_port_value!(:bastion_port)
41
+ end
42
+
43
+ private
44
+
45
+ # DRY method for validating port values
46
+ # @param port_key [Symbol] The options key containing the port value
47
+ # @param port_name [String] The name to use in error messages (defaults to port_key)
48
+ def validate_port_value!(port_key, port_name = nil)
49
+ port_name ||= port_key.to_s.tr('_', ' ')
50
+ port = @options[port_key].to_i
51
+ raise Train::ClientError, "Invalid #{port_name}: #{@options[port_key]} (must be 1-65535)" unless port.between?(1, 65_535)
52
+ end
53
+
54
+ # Validate proxy configuration options (Train standard)
55
+ def validate_proxy_options!
56
+ # Cannot use both bastion_host and proxy_command simultaneously
57
+ if @options[:bastion_host] && @options[:proxy_command]
58
+ raise Train::ClientError, 'Cannot specify both bastion_host and proxy_command'
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end