train-juniper 0.6.2 → 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.
@@ -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
@@ -14,8 +14,18 @@
14
14
  require 'train'
15
15
  require 'logger'
16
16
 
17
- # Juniper-specific platform detection
17
+ # Juniper-specific modules
18
+ require 'train-juniper/constants'
18
19
  require 'train-juniper/platform'
20
+ require 'train-juniper/connection/validation'
21
+ require 'train-juniper/connection/command_executor'
22
+ require 'train-juniper/connection/error_handling'
23
+ require 'train-juniper/connection/ssh_session'
24
+ require 'train-juniper/connection/bastion_proxy'
25
+ require 'train-juniper/helpers/environment'
26
+ require 'train-juniper/helpers/logging'
27
+ require 'train-juniper/helpers/mock_responses'
28
+ require 'train-juniper/file_abstraction/juniper_file'
19
29
 
20
30
  # Using Train's SSH transport for connectivity
21
31
 
@@ -25,25 +35,71 @@ module TrainPlugins
25
35
  class Connection < Train::Plugins::Transport::BaseConnection
26
36
  # Include Juniper-specific platform detection
27
37
  include TrainPlugins::Juniper::Platform
38
+ # Include environment variable helpers
39
+ include TrainPlugins::Juniper::Environment
40
+ # Include validation methods
41
+ include TrainPlugins::Juniper::Validation
42
+ # Include command execution methods
43
+ include TrainPlugins::Juniper::CommandExecutor
44
+ # Include error handling methods
45
+ include TrainPlugins::Juniper::ErrorHandling
46
+ # Include SSH session management
47
+ include TrainPlugins::Juniper::SSHSession
48
+ # Include bastion proxy support
49
+ include TrainPlugins::Juniper::BastionProxy
50
+ # Include logging helpers
51
+ include TrainPlugins::Juniper::Logging
52
+
53
+ # Alias for Train CommandResult for backward compatibility
54
+ CommandResult = Train::Extras::CommandResult
28
55
 
29
56
  attr_reader :ssh_session
30
57
 
58
+ # Configuration mapping for environment variables
59
+ ENV_CONFIG = {
60
+ host: { env: 'JUNIPER_HOST' },
61
+ user: { env: 'JUNIPER_USER' },
62
+ password: { env: 'JUNIPER_PASSWORD' },
63
+ port: { env: 'JUNIPER_PORT', type: :int, default: Constants::DEFAULT_SSH_PORT },
64
+ timeout: { env: 'JUNIPER_TIMEOUT', type: :int, default: 30 },
65
+ bastion_host: { env: 'JUNIPER_BASTION_HOST' },
66
+ bastion_user: { env: 'JUNIPER_BASTION_USER' },
67
+ bastion_port: { env: 'JUNIPER_BASTION_PORT', type: :int, default: Constants::DEFAULT_SSH_PORT },
68
+ bastion_password: { env: 'JUNIPER_BASTION_PASSWORD' },
69
+ proxy_command: { env: 'JUNIPER_PROXY_COMMAND' }
70
+ }.freeze
71
+
72
+ # Initialize a new Juniper connection
73
+ # @param options [Hash] Connection options
74
+ # @option options [String] :host The hostname or IP address of the Juniper device
75
+ # @option options [String] :user The username for authentication
76
+ # @option options [String] :password The password for authentication (optional if using key_files)
77
+ # @option options [Integer] :port The SSH port (default: 22)
78
+ # @option options [Integer] :timeout Connection timeout in seconds (default: 30)
79
+ # @option options [String] :bastion_host Jump/bastion host for connection
80
+ # @option options [String] :proxy_command SSH proxy command
81
+ # @option options [Logger] :logger Custom logger instance
82
+ # @option options [Boolean] :mock Enable mock mode for testing
31
83
  def initialize(options)
32
84
  # Configure SSH connection options for Juniper devices
33
85
  # Support environment variables for authentication (following train-vsphere pattern)
34
86
  @options = options.dup
35
- @options[:host] ||= ENV.fetch('JUNIPER_HOST', nil)
36
- @options[:user] ||= ENV.fetch('JUNIPER_USER', nil)
37
- @options[:password] ||= ENV.fetch('JUNIPER_PASSWORD', nil)
38
- @options[:port] ||= ENV['JUNIPER_PORT']&.to_i || 22
39
- @options[:timeout] ||= ENV['JUNIPER_TIMEOUT']&.to_i || 30
40
87
 
41
- # Proxy/bastion environment variables (Train standard)
42
- # Only set from environment if not explicitly provided
43
- @options[:bastion_host] = @options[:bastion_host] || ENV.fetch('JUNIPER_BASTION_HOST', nil)
44
- @options[:bastion_user] = @options[:bastion_user] || ENV['JUNIPER_BASTION_USER'] || 'root'
45
- @options[:bastion_port] = @options[:bastion_port] || ENV['JUNIPER_BASTION_PORT']&.to_i || 22
46
- @options[:proxy_command] = @options[:proxy_command] || ENV.fetch('JUNIPER_PROXY_COMMAND', nil)
88
+ # Apply environment variable configuration using DRY approach
89
+ ENV_CONFIG.each do |key, config|
90
+ # Skip if option already has a value from command line
91
+ next if @options[key]
92
+
93
+ # Get value from environment
94
+ env_val = config[:type] == :int ? env_int(config[:env]) : env_value(config[:env])
95
+
96
+ # Only apply env value if it exists, otherwise use default (but not for nil CLI values)
97
+ if env_val
98
+ @options[key] = env_val
99
+ elsif !@options.key?(key) && config[:default]
100
+ @options[key] = config[:default]
101
+ end
102
+ end
47
103
 
48
104
  @options[:keepalive] = true
49
105
  @options[:keepalive_interval] = 60
@@ -55,20 +111,21 @@ module TrainPlugins
55
111
  @cli_prompt = /[%>$#]\s*$/
56
112
  @config_prompt = /[%#]\s*$/
57
113
 
58
- # Log connection info without exposing credentials
59
- safe_options = @options.except(:password, :proxy_command, :key_files)
60
- @logger.debug("Juniper connection initialized with options: #{safe_options.inspect}")
61
- @logger.debug("Environment: JUNIPER_BASTION_USER=#{ENV.fetch('JUNIPER_BASTION_USER',
62
- nil)} -> bastion_user=#{@options[:bastion_user]}")
114
+ # Log connection info safely
115
+ log_connection_info
63
116
 
64
- # Validate proxy configuration early (Train standard)
65
- validate_proxy_options
117
+ # Validate all connection options
118
+ validate_connection_options!
66
119
 
67
120
  super(@options)
68
121
 
69
122
  # Establish SSH connection to Juniper device (unless in mock mode or skip_connect)
70
- @logger.debug('Attempting to connect to Juniper device...')
71
- connect unless @options[:mock] || @options[:skip_connect]
123
+ if @options[:mock]
124
+ log_mock_mode
125
+ elsif !@options[:skip_connect]
126
+ @logger.debug('Attempting to connect to Juniper device...')
127
+ connect
128
+ end
72
129
  end
73
130
 
74
131
  # Secure string representation (never expose credentials)
@@ -76,339 +133,71 @@ module TrainPlugins
76
133
  "#<#{self.class.name}:0x#{object_id.to_s(16)} @host=#{@options[:host]} @user=#{@options[:user]}>"
77
134
  end
78
135
 
136
+ # Secure inspect method that uses to_s
137
+ # @return [String] Secure string representation
79
138
  def inspect
80
139
  to_s
81
140
  end
82
141
 
83
- # File operations for Juniper configuration files
84
- # Supports reading configuration files and operational data
142
+ # Access Juniper configuration and operational data as pseudo-files
143
+ # @param path [String] The pseudo-file path to access
144
+ # @return [JuniperFile] A file-like object for accessing Juniper data
145
+ # @example Access interface configuration
146
+ # file = connection.file('/config/interfaces')
147
+ # puts file.content
148
+ # @example Access operational data
149
+ # file = connection.file('/operational/interfaces')
150
+ # puts file.content
85
151
  def file_via_connection(path)
86
152
  # For Juniper devices, "files" are typically configuration sections
87
153
  # or operational command outputs rather than traditional filesystem paths
88
154
  JuniperFile.new(self, path)
89
155
  end
90
156
 
91
- # File transfer operations (following network device pattern)
92
- # Network devices don't support traditional file upload/download
93
- # Use run_command() for configuration management instead
157
+ # Upload files to Juniper device (not supported)
158
+ # @param locals [String, Array<String>] Local file path(s)
159
+ # @param remote [String] Remote destination path
160
+ # @raise [NotImplementedError] Always raises as uploads are not supported
161
+ # @note Network devices use command-based configuration instead of file uploads
94
162
  def upload(locals, remote)
95
- raise NotImplementedError, "#{self.class} does not implement #upload() - network devices use command-based configuration"
163
+ raise NotImplementedError, Constants::UPLOAD_NOT_SUPPORTED
96
164
  end
97
165
 
166
+ # Download files from Juniper device (not supported)
167
+ # @param remotes [String, Array<String>] Remote file path(s)
168
+ # @param local [String] Local destination path
169
+ # @raise [NotImplementedError] Always raises as downloads are not supported
170
+ # @note Use run_command() to retrieve configuration data instead
98
171
  def download(remotes, local)
99
- raise NotImplementedError, "#{self.class} does not implement #download() - use run_command() to retrieve configuration data"
100
- end
101
-
102
- # Execute commands on Juniper device via SSH
103
- def run_command_via_connection(cmd)
104
- return mock_command_result(cmd) if @options[:mock]
105
-
106
- begin
107
- # Ensure we're connected
108
- connect unless connected?
109
-
110
- @logger.debug("Executing command: #{cmd}")
111
-
112
- # Execute command via SSH session
113
- output = @ssh_session.exec!(cmd)
114
-
115
- @logger.debug("Command output: #{output}")
116
-
117
- # Format JunOS result
118
- format_junos_result(output, cmd)
119
- rescue StandardError => e
120
- @logger.error("Command execution failed: #{e.message}")
121
- # Handle connection errors gracefully
122
- CommandResult.new('', e.message, 1)
123
- end
124
- end
125
-
126
- # JunOS error patterns from implementation plan
127
- JUNOS_ERROR_PATTERNS = [
128
- /^error:/i,
129
- /syntax error/i,
130
- /invalid command/i,
131
- /unknown command/i,
132
- /missing argument/i
133
- ].freeze
134
-
135
- private
136
-
137
- # Establish SSH connection to Juniper device
138
- def connect
139
- return if connected?
140
-
141
- begin
142
- # Use direct SSH connection (network device pattern)
143
- # Defensive loading - only require if not fully loaded
144
- require 'net/ssh' unless defined?(Net::SSH) && Net::SSH.respond_to?(:start)
145
-
146
- @logger.debug('Establishing SSH connection to Juniper device')
147
-
148
- ssh_options = {
149
- port: @options[:port] || 22,
150
- password: @options[:password],
151
- timeout: @options[:timeout] || 30,
152
- verify_host_key: :never,
153
- keepalive: @options[:keepalive],
154
- keepalive_interval: @options[:keepalive_interval]
155
- }
156
-
157
- # Add SSH key authentication if specified
158
- if @options[:key_files]
159
- ssh_options[:keys] = Array(@options[:key_files])
160
- ssh_options[:keys_only] = @options[:keys_only]
161
- end
162
-
163
- # Add bastion host support if configured
164
- if @options[:bastion_host]
165
- require 'net/ssh/proxy/jump' unless defined?(Net::SSH::Proxy::Jump)
166
-
167
- # Build proxy jump string from bastion options
168
- bastion_user = @options[:bastion_user] || 'root'
169
- bastion_port = @options[:bastion_port] || 22
170
-
171
- proxy_jump = if bastion_port == 22
172
- "#{bastion_user}@#{@options[:bastion_host]}"
173
- else
174
- "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
175
- end
176
-
177
- @logger.debug("Using bastion host: #{proxy_jump}")
178
-
179
- # Set up automated password authentication via SSH_ASKPASS
180
- bastion_password = @options[:bastion_password] || @options[:password] # Use explicit bastion password or fallback
181
- if bastion_password
182
- @ssh_askpass_script = create_ssh_askpass_script(bastion_password)
183
- ENV['SSH_ASKPASS'] = @ssh_askpass_script
184
- ENV['SSH_ASKPASS_REQUIRE'] = 'force' # Force use of SSH_ASKPASS even with terminal
185
- @logger.debug('Configured SSH_ASKPASS for automated bastion authentication')
186
- end
187
-
188
- ssh_options[:proxy] = Net::SSH::Proxy::Jump.new(proxy_jump)
189
- end
190
-
191
- @logger.debug("Connecting to #{@options[:host]}:#{@options[:port]} as #{@options[:user]}")
192
-
193
- # Direct SSH connection
194
- @ssh_session = Net::SSH.start(@options[:host], @options[:user], ssh_options)
195
- @logger.debug('SSH connection established successfully')
196
-
197
- # Configure JunOS session for automation
198
- test_and_configure_session
199
- rescue StandardError => e
200
- @logger.error("SSH connection failed: #{e.message}")
201
-
202
- # Provide helpful error messages for common authentication issues
203
- if (e.message.include?('Permission denied') || e.message.include?('command failed')) && @options[:bastion_host]
204
- raise Train::TransportError, <<~ERROR
205
- Failed to connect to Juniper device #{@options[:host]} via bastion #{@options[:bastion_host]}: #{e.message}
206
-
207
- SSH bastion authentication with passwords is not supported due to ProxyCommand limitations.
208
- Please use one of these alternatives:
209
-
210
- 1. SSH Key Authentication (Recommended):
211
- Use --key-files option to specify SSH private key files
212
- #{' '}
213
- 2. SSH Agent:
214
- Ensure your SSH agent has the required keys loaded
215
- #{' '}
216
- 3. Direct Connection:
217
- Connect directly to the device if network allows (remove bastion options)
218
-
219
- For more details, see: https://mitre.github.io/train-juniper/troubleshooting/#bastion-authentication
220
- ERROR
221
- else
222
- raise Train::TransportError, "Failed to connect to Juniper device #{@options[:host]}: #{e.message}"
223
- end
224
- end
172
+ raise NotImplementedError, Constants::DOWNLOAD_NOT_SUPPORTED
225
173
  end
226
174
 
227
- # Check if SSH connection is active
228
- def connected?
229
- return true if @options[:mock]
175
+ # Check connection health
176
+ # @return [Boolean] true if connection is healthy, false otherwise
177
+ # @example
178
+ # if connection.healthy?
179
+ # puts "Connection is healthy"
180
+ # end
181
+ def healthy?
182
+ return false unless connected?
230
183
 
231
- !@ssh_session.nil?
184
+ result = run_command_via_connection('show version')
185
+ result.exit_status.zero?
232
186
  rescue StandardError
233
187
  false
234
188
  end
235
189
 
236
- # Check if running in mock mode
237
- def mock?
238
- @options[:mock] == true
239
- end
240
-
241
- # Test connection and configure JunOS session
242
- def test_and_configure_session
243
- @logger.debug('Testing SSH connection and configuring JunOS session')
244
-
245
- # Test connection first
246
- @ssh_session.exec!('echo "connection test"')
247
- @logger.debug('SSH connection test successful')
248
-
249
- # Optimize CLI for automation
250
- @ssh_session.exec!('set cli screen-length 0')
251
- @ssh_session.exec!('set cli screen-width 0')
252
- @ssh_session.exec!('set cli complete-on-space off') if @options[:disable_complete_on_space]
253
-
254
- @logger.debug('JunOS session configured successfully')
255
- rescue StandardError => e
256
- @logger.warn("Failed to configure JunOS session: #{e.message}")
257
- end
258
-
259
- # Format JunOS command results (from implementation plan)
260
- def format_junos_result(output, cmd)
261
- # Parse JunOS-specific error patterns
262
- if junos_error?(output)
263
- CommandResult.new('', output, 1)
264
- else
265
- CommandResult.new(clean_output(output, cmd), '', 0)
266
- end
267
- end
268
-
269
- # Check for JunOS error patterns (from implementation plan)
270
- def junos_error?(output)
271
- JUNOS_ERROR_PATTERNS.any? { |pattern| output.match?(pattern) }
272
- end
273
-
274
- # Clean command output
275
- def clean_output(output, cmd)
276
- # Handle nil output gracefully
277
- return '' if output.nil?
278
-
279
- # Remove command echo and prompts
280
- lines = output.to_s.split("\n")
281
- lines.reject! { |line| line.strip == cmd.strip }
282
-
283
- # Remove JunOS prompt patterns from the end
284
- lines.pop while lines.last && lines.last.strip.match?(/^[%>$#]+\s*$/)
285
-
286
- lines.join("\n")
287
- end
288
-
289
- # Validate proxy configuration options (Train standard)
290
- def validate_proxy_options
291
- # Cannot use both bastion_host and proxy_command simultaneously
292
- if @options[:bastion_host] && @options[:proxy_command]
293
- raise Train::ClientError, 'Cannot specify both bastion_host and proxy_command'
294
- end
295
- end
296
-
297
- # Create temporary SSH_ASKPASS script for automated password authentication
298
- def create_ssh_askpass_script(password)
299
- require 'tempfile'
300
-
301
- script = Tempfile.new(['ssh_askpass', '.sh'])
302
- script.write("#!/bin/bash\necho '#{password}'\n")
303
- script.close
304
- File.chmod(0o755, script.path)
305
-
306
- @logger.debug("Created SSH_ASKPASS script at #{script.path}")
307
- script.path
308
- end
309
-
310
- # Generate SSH proxy command for bastion host using ProxyJump (-J)
311
- def generate_bastion_proxy_command(bastion_user, bastion_port)
312
- args = ['ssh']
313
-
314
- # SSH options for connection
315
- args += ['-o', 'UserKnownHostsFile=/dev/null']
316
- args += ['-o', 'StrictHostKeyChecking=no']
317
- args += ['-o', 'LogLevel=ERROR']
318
- args += ['-o', 'ForwardAgent=no']
319
-
320
- # Use ProxyJump (-J) which handles password authentication properly
321
- jump_host = if bastion_port == 22
322
- "#{bastion_user}@#{@options[:bastion_host]}"
323
- else
324
- "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
325
- end
326
- args += ['-J', jump_host]
327
-
328
- # Add SSH keys if specified
329
- if @options[:key_files]
330
- Array(@options[:key_files]).each do |key_file|
331
- args += ['-i', key_file]
332
- end
333
- end
334
-
335
- # Target connection - %h and %p will be replaced by Net::SSH
336
- args += ['%h', '-p', '%p']
337
-
338
- args.join(' ')
339
- end
340
-
341
- # Mock command execution for testing
342
- def mock_command_result(cmd)
343
- case cmd
344
- when /show version/
345
- CommandResult.new(mock_show_version_output, '', 0)
346
- when /show chassis hardware/
347
- CommandResult.new(mock_chassis_output, '', 0)
348
- when /show configuration/
349
- CommandResult.new("interfaces {\n ge-0/0/0 {\n unit 0;\n }\n}", '', 0)
350
- when /show route/
351
- CommandResult.new("inet.0: 5 destinations, 5 routes\n0.0.0.0/0 *[Static/5] 00:00:01\n", '', 0)
352
- when /show system information/
353
- CommandResult.new("Hardware: SRX240H2\nOS: JUNOS 12.1X47-D15.4\n", '', 0)
354
- when /show interfaces/
355
- CommandResult.new("Physical interface: ge-0/0/0, Enabled, Physical link is Up\n", '', 0)
356
- else
357
- CommandResult.new("% Unknown command: #{cmd}", '', 1)
358
- end
359
- end
360
-
361
- # Mock JunOS version output for testing
362
- def mock_show_version_output
363
- <<~OUTPUT
364
- Hostname: lab-srx
365
- Model: SRX240H2
366
- Junos: 12.1X47-D15.4
367
- JUNOS Software Release [12.1X47-D15.4]
368
- OUTPUT
369
- end
370
-
371
- # Mock chassis output for testing
372
- def mock_chassis_output
373
- <<~OUTPUT
374
- Hardware inventory:
375
- Item Version Part number Serial number Description
376
- Chassis JN123456 SRX240H2
377
- OUTPUT
378
- end
379
- end
380
-
381
- # File abstraction for Juniper configuration and operational data
382
- class JuniperFile
383
- def initialize(connection, path)
384
- @connection = connection
385
- @path = path
386
- end
190
+ # List of sensitive option keys to redact in logs
191
+ SENSITIVE_OPTIONS = %i[password bastion_password key_files proxy_command].freeze
192
+ private_constant :SENSITIVE_OPTIONS
387
193
 
388
- def content
389
- # For Juniper devices, translate file paths to appropriate commands
390
- case @path
391
- when %r{/config/(.*)}
392
- # Configuration sections: /config/interfaces -> show configuration interfaces
393
- section = ::Regexp.last_match(1)
394
- result = @connection.run_command("show configuration #{section}")
395
- result.stdout
396
- when %r{/operational/(.*)}
397
- # Operational data: /operational/interfaces -> show interfaces
398
- section = ::Regexp.last_match(1)
399
- result = @connection.run_command("show #{section}")
400
- result.stdout
401
- else
402
- # Default to treating path as a show command
403
- result = @connection.run_command("show #{@path}")
404
- result.stdout
405
- end
406
- end
194
+ private
407
195
 
408
- def exist?
409
- !content.empty?
410
- rescue StandardError
411
- false
196
+ # Log connection info without exposing sensitive data
197
+ def log_connection_info
198
+ safe_options = @options.except(*SENSITIVE_OPTIONS)
199
+ @logger.debug("Juniper connection initialized with options: #{safe_options.inspect}")
200
+ @logger.debug("Environment: JUNIPER_BASTION_USER=#{env_value('JUNIPER_BASTION_USER')} -> bastion_user=#{@options[:bastion_user]}")
412
201
  end
413
202
  end
414
203
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module Juniper
5
+ # Common constants used across the plugin
6
+ module Constants
7
+ # SSH Configuration
8
+ # @return [Integer] Default SSH port
9
+ DEFAULT_SSH_PORT = 22
10
+ # @return [Range] Valid port range for SSH connections
11
+ PORT_RANGE = (1..65_535)
12
+
13
+ # Standard SSH Options for network devices
14
+ STANDARD_SSH_OPTIONS = {
15
+ 'UserKnownHostsFile' => '/dev/null',
16
+ 'StrictHostKeyChecking' => 'no',
17
+ 'LogLevel' => 'ERROR',
18
+ 'ForwardAgent' => 'no'
19
+ }.freeze
20
+
21
+ # JunOS CLI Prompt Patterns
22
+ # @return [Regexp] Pattern matching JunOS CLI prompts
23
+ CLI_PROMPT = /[%>$#]\s*$/
24
+ # @return [Regexp] Pattern matching JunOS configuration mode prompts
25
+ CONFIG_PROMPT = /[%#]\s*$/
26
+
27
+ # File Path Patterns
28
+ # @return [Regexp] Pattern for configuration file paths
29
+ CONFIG_PATH_PATTERN = %r{/config/(.*)}
30
+ # @return [Regexp] Pattern for operational data paths
31
+ OPERATIONAL_PATH_PATTERN = %r{/operational/(.*)}
32
+
33
+ # Error Messages
34
+ # @return [String] Error message for unsupported upload operations
35
+ UPLOAD_NOT_SUPPORTED = 'File operations not supported for Juniper devices - use command-based configuration'
36
+ # @return [String] Error message for unsupported download operations
37
+ DOWNLOAD_NOT_SUPPORTED = 'File operations not supported for Juniper devices - use run_command() to retrieve data'
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'train-juniper/constants'
4
+
5
+ module TrainPlugins
6
+ module Juniper
7
+ # File abstraction for Juniper configuration and operational data
8
+ class JuniperFile
9
+ # Initialize a new JuniperFile
10
+ # @param connection [Connection] The Juniper connection instance
11
+ # @param path [String] The virtual file path
12
+ def initialize(connection, path)
13
+ @connection = connection
14
+ @path = path
15
+ end
16
+
17
+ # Get the content of the virtual file
18
+ # @return [String] The command output based on the path
19
+ # @example
20
+ # file = connection.file('/config/interfaces')
21
+ # file.content # Returns output of 'show configuration interfaces'
22
+ def content
23
+ # For Juniper devices, translate file paths to appropriate commands
24
+ case @path
25
+ when Constants::CONFIG_PATH_PATTERN
26
+ # Configuration sections: /config/interfaces -> show configuration interfaces
27
+ section = ::Regexp.last_match(1)
28
+ result = @connection.run_command("show configuration #{section}")
29
+ result.stdout
30
+ when Constants::OPERATIONAL_PATH_PATTERN
31
+ # Operational data: /operational/interfaces -> show interfaces
32
+ section = ::Regexp.last_match(1)
33
+ result = @connection.run_command("show #{section}")
34
+ result.stdout
35
+ else
36
+ # Default to treating path as a show command
37
+ result = @connection.run_command("show #{@path}")
38
+ result.stdout
39
+ end
40
+ end
41
+
42
+ # Check if the file exists (has content)
43
+ # @return [Boolean] true if the file has content, false otherwise
44
+ def exist?
45
+ !content.empty?
46
+ rescue StandardError
47
+ false
48
+ end
49
+
50
+ # Return string representation of file path
51
+ # @return [String] the file path
52
+ def to_s
53
+ @path
54
+ end
55
+
56
+ # File upload not supported for network devices
57
+ # @raise [NotImplementedError] always raises as upload is not supported
58
+ def upload(_content)
59
+ raise NotImplementedError, Constants::UPLOAD_NOT_SUPPORTED
60
+ end
61
+
62
+ # File download not supported for network devices
63
+ # @raise [NotImplementedError] always raises as download is not supported
64
+ def download(_local_path)
65
+ raise NotImplementedError, Constants::DOWNLOAD_NOT_SUPPORTED
66
+ end
67
+ end
68
+ end
69
+ end