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.
@@ -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,6 +35,23 @@ 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
 
@@ -33,11 +60,11 @@ module TrainPlugins
33
60
  host: { env: 'JUNIPER_HOST' },
34
61
  user: { env: 'JUNIPER_USER' },
35
62
  password: { env: 'JUNIPER_PASSWORD' },
36
- port: { env: 'JUNIPER_PORT', type: :int, default: 22 },
63
+ port: { env: 'JUNIPER_PORT', type: :int, default: Constants::DEFAULT_SSH_PORT },
37
64
  timeout: { env: 'JUNIPER_TIMEOUT', type: :int, default: 30 },
38
65
  bastion_host: { env: 'JUNIPER_BASTION_HOST' },
39
66
  bastion_user: { env: 'JUNIPER_BASTION_USER' },
40
- bastion_port: { env: 'JUNIPER_BASTION_PORT', type: :int, default: 22 },
67
+ bastion_port: { env: 'JUNIPER_BASTION_PORT', type: :int, default: Constants::DEFAULT_SSH_PORT },
41
68
  bastion_password: { env: 'JUNIPER_BASTION_PASSWORD' },
42
69
  proxy_command: { env: 'JUNIPER_PROXY_COMMAND' }
43
70
  }.freeze
@@ -93,8 +120,12 @@ module TrainPlugins
93
120
  super(@options)
94
121
 
95
122
  # Establish SSH connection to Juniper device (unless in mock mode or skip_connect)
96
- @logger.debug('Attempting to connect to Juniper device...')
97
- 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
98
129
  end
99
130
 
100
131
  # Secure string representation (never expose credentials)
@@ -102,6 +133,8 @@ module TrainPlugins
102
133
  "#<#{self.class.name}:0x#{object_id.to_s(16)} @host=#{@options[:host]} @user=#{@options[:user]}>"
103
134
  end
104
135
 
136
+ # Secure inspect method that uses to_s
137
+ # @return [String] Secure string representation
105
138
  def inspect
106
139
  to_s
107
140
  end
@@ -127,7 +160,7 @@ module TrainPlugins
127
160
  # @raise [NotImplementedError] Always raises as uploads are not supported
128
161
  # @note Network devices use command-based configuration instead of file uploads
129
162
  def upload(locals, remote)
130
- raise NotImplementedError, "#{self.class} does not implement #upload() - network devices use command-based configuration"
163
+ raise NotImplementedError, Constants::UPLOAD_NOT_SUPPORTED
131
164
  end
132
165
 
133
166
  # Download files from Juniper device (not supported)
@@ -136,88 +169,9 @@ module TrainPlugins
136
169
  # @raise [NotImplementedError] Always raises as downloads are not supported
137
170
  # @note Use run_command() to retrieve configuration data instead
138
171
  def download(remotes, local)
139
- raise NotImplementedError, "#{self.class} does not implement #download() - use run_command() to retrieve configuration data"
140
- end
141
-
142
- # Execute commands on Juniper device via SSH
143
- # @param cmd [String] The JunOS command to execute
144
- # @return [CommandResult] Result object with stdout, stderr, and exit status
145
- # @raise [Train::ClientError] If command contains dangerous characters
146
- # @example
147
- # result = connection.run_command('show version')
148
- # puts result.stdout
149
- def run_command_via_connection(cmd)
150
- # Sanitize command to prevent injection
151
- safe_cmd = sanitize_command(cmd)
152
-
153
- return mock_command_result(safe_cmd) if @options[:mock]
154
-
155
- begin
156
- # Ensure we're connected
157
- connect unless connected?
158
-
159
- @logger.debug("Executing command: #{safe_cmd}")
160
-
161
- # Execute command via SSH session
162
- output = @ssh_session.exec!(safe_cmd)
163
-
164
- @logger.debug("Command output: #{output}")
165
-
166
- # Format JunOS result
167
- format_junos_result(output, safe_cmd)
168
- rescue StandardError => e
169
- @logger.error("Command execution failed: #{e.message}")
170
- # Handle connection errors gracefully
171
- CommandResult.new('', e.message, 1)
172
- end
172
+ raise NotImplementedError, Constants::DOWNLOAD_NOT_SUPPORTED
173
173
  end
174
174
 
175
- # JunOS error patterns organized by type
176
- JUNOS_ERRORS = {
177
- configuration: [/^error:/i, /configuration database locked/i],
178
- syntax: [/syntax error/i],
179
- command: [/invalid command/i, /unknown command/i],
180
- argument: [/missing argument/i]
181
- }.freeze
182
-
183
- # Flattened error patterns for quick matching
184
- JUNOS_ERROR_PATTERNS = JUNOS_ERRORS.values.flatten.freeze
185
-
186
- # SSH option mapping configuration
187
- SSH_OPTION_MAPPING = {
188
- port: :port,
189
- password: :password,
190
- timeout: :timeout,
191
- keepalive: :keepalive,
192
- keepalive_interval: :keepalive_interval,
193
- keys: ->(opts) { Array(opts[:key_files]) if opts[:key_files] },
194
- keys_only: ->(opts) { opts[:keys_only] if opts[:key_files] }
195
- }.freeze
196
-
197
- # Default SSH options for Juniper connections
198
- # @note verify_host_key is set to :never for network device compatibility
199
- SSH_DEFAULTS = {
200
- verify_host_key: :never
201
- }.freeze
202
-
203
- # Mock response configuration
204
- MOCK_RESPONSES = {
205
- 'show version' => :mock_show_version_output,
206
- 'show chassis hardware' => :mock_chassis_output,
207
- 'show configuration' => "interfaces {\n ge-0/0/0 {\n unit 0;\n }\n}",
208
- 'show route' => "inet.0: 5 destinations, 5 routes\n0.0.0.0/0 *[Static/5] 00:00:01\n",
209
- 'show system information' => "Hardware: SRX240H2\nOS: JUNOS 12.1X47-D15.4\n",
210
- 'show interfaces' => "Physical interface: ge-0/0/0, Enabled, Physical link is Up\n"
211
- }.freeze
212
-
213
- # Command sanitization patterns
214
- # Note: Pipe (|) is allowed as it's commonly used in JunOS commands
215
- DANGEROUS_COMMAND_PATTERNS = [
216
- /[;&<>$`]/, # Shell metacharacters (excluding pipe)
217
- /\n|\r/, # Newlines that could inject commands
218
- /\\(?![nrt])/ # Escape sequences (except valid ones like \n, \r, \t)
219
- ].freeze
220
-
221
175
  # Check connection health
222
176
  # @return [Boolean] true if connection is healthy, false otherwise
223
177
  # @example
@@ -245,349 +199,6 @@ module TrainPlugins
245
199
  @logger.debug("Juniper connection initialized with options: #{safe_options.inspect}")
246
200
  @logger.debug("Environment: JUNIPER_BASTION_USER=#{env_value('JUNIPER_BASTION_USER')} -> bastion_user=#{@options[:bastion_user]}")
247
201
  end
248
-
249
- # Sanitize command to prevent injection attacks
250
- def sanitize_command(cmd)
251
- cmd_str = cmd.to_s.strip
252
-
253
- if DANGEROUS_COMMAND_PATTERNS.any? { |pattern| cmd_str.match?(pattern) }
254
- raise Train::ClientError, "Invalid characters in command: #{cmd_str.inspect}"
255
- end
256
-
257
- cmd_str
258
- end
259
-
260
- # Establish SSH connection to Juniper device
261
- def connect
262
- return if connected?
263
-
264
- begin
265
- # Use direct SSH connection (network device pattern)
266
- # Defensive loading - only require if not fully loaded
267
- require 'net/ssh' unless defined?(Net::SSH) && Net::SSH.respond_to?(:start)
268
-
269
- @logger.debug('Establishing SSH connection to Juniper device')
270
-
271
- ssh_options = build_ssh_options
272
-
273
- # Add bastion host support if configured
274
- if @options[:bastion_host]
275
- require 'net/ssh/proxy/jump' unless defined?(Net::SSH::Proxy::Jump)
276
-
277
- # Build proxy jump string from bastion options
278
- bastion_user = @options[:bastion_user] || @options[:user] # Use explicit bastion user or fallback to main user
279
- bastion_port = @options[:bastion_port]
280
-
281
- proxy_jump = if bastion_port == 22
282
- "#{bastion_user}@#{@options[:bastion_host]}"
283
- else
284
- "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
285
- end
286
-
287
- @logger.debug("Using bastion host: #{proxy_jump}")
288
-
289
- # Set up automated password authentication via SSH_ASKPASS
290
- bastion_password = @options[:bastion_password] || @options[:password] # Use explicit bastion password or fallback
291
- if bastion_password
292
- @ssh_askpass_script = create_ssh_askpass_script(bastion_password)
293
- ENV['SSH_ASKPASS'] = @ssh_askpass_script
294
- ENV['SSH_ASKPASS_REQUIRE'] = 'force' # Force use of SSH_ASKPASS even with terminal
295
- @logger.debug('Configured SSH_ASKPASS for automated bastion authentication')
296
- end
297
-
298
- ssh_options[:proxy] = Net::SSH::Proxy::Jump.new(proxy_jump)
299
- end
300
-
301
- @logger.debug("Connecting to #{@options[:host]}:#{@options[:port]} as #{@options[:user]}")
302
-
303
- # Direct SSH connection
304
- @ssh_session = Net::SSH.start(@options[:host], @options[:user], ssh_options)
305
- @logger.debug('SSH connection established successfully')
306
-
307
- # Configure JunOS session for automation
308
- test_and_configure_session
309
- rescue StandardError => e
310
- @logger.error("SSH connection failed: #{e.message}")
311
-
312
- # Provide helpful error messages for common authentication issues
313
- if (e.message.include?('Permission denied') || e.message.include?('command failed')) && @options[:bastion_host]
314
- raise Train::TransportError, <<~ERROR
315
- Failed to connect to Juniper device #{@options[:host]} via bastion #{@options[:bastion_host]}: #{e.message}
316
-
317
- SSH bastion authentication with passwords is not supported due to ProxyCommand limitations.
318
- Please use one of these alternatives:
319
-
320
- 1. SSH Key Authentication (Recommended):
321
- Use --key-files option to specify SSH private key files
322
- #{' '}
323
- 2. SSH Agent:
324
- Ensure your SSH agent has the required keys loaded
325
- #{' '}
326
- 3. Direct Connection:
327
- Connect directly to the device if network allows (remove bastion options)
328
-
329
- For more details, see: https://mitre.github.io/train-juniper/troubleshooting/#bastion-authentication
330
- ERROR
331
- else
332
- raise Train::TransportError, "Failed to connect to Juniper device #{@options[:host]}: #{e.message}"
333
- end
334
- end
335
- end
336
-
337
- # Check if SSH connection is active
338
- def connected?
339
- return true if @options[:mock]
340
-
341
- !@ssh_session.nil?
342
- rescue StandardError
343
- false
344
- end
345
-
346
- # Check if running in mock mode
347
- def mock?
348
- @options[:mock] == true
349
- end
350
-
351
- # Test connection and configure JunOS session
352
- def test_and_configure_session
353
- @logger.debug('Testing SSH connection and configuring JunOS session')
354
-
355
- # Test connection first
356
- @ssh_session.exec!('echo "connection test"')
357
- @logger.debug('SSH connection test successful')
358
-
359
- # Optimize CLI for automation
360
- @ssh_session.exec!('set cli screen-length 0')
361
- @ssh_session.exec!('set cli screen-width 0')
362
- @ssh_session.exec!('set cli complete-on-space off') if @options[:disable_complete_on_space]
363
-
364
- @logger.debug('JunOS session configured successfully')
365
- rescue StandardError => e
366
- @logger.warn("Failed to configure JunOS session: #{e.message}")
367
- end
368
-
369
- # Format JunOS command results (from implementation plan)
370
- def format_junos_result(output, cmd)
371
- # Parse JunOS-specific error patterns
372
- if junos_error?(output)
373
- CommandResult.new('', output, 1)
374
- else
375
- CommandResult.new(clean_output(output, cmd), '', 0)
376
- end
377
- end
378
-
379
- # Check for JunOS error patterns (from implementation plan)
380
- def junos_error?(output)
381
- JUNOS_ERROR_PATTERNS.any? { |pattern| output.match?(pattern) }
382
- end
383
-
384
- # Clean command output
385
- def clean_output(output, cmd)
386
- # Handle nil output gracefully
387
- return '' if output.nil?
388
-
389
- # Remove command echo and prompts
390
- lines = output.to_s.split("\n")
391
- lines.reject! { |line| line.strip == cmd.strip }
392
-
393
- # Remove JunOS prompt patterns from the end
394
- lines.pop while lines.last && lines.last.strip.match?(/^[%>$#]+\s*$/)
395
-
396
- lines.join("\n")
397
- end
398
-
399
- # Helper method to safely get environment variable value
400
- # Returns nil if env var is not set or is empty string
401
- def env_value(key)
402
- value = ENV.fetch(key, nil)
403
- return nil if value.nil? || value.empty?
404
-
405
- value
406
- end
407
-
408
- # Helper method to get environment variable as integer
409
- # Returns nil if env var is not set, empty, or not a valid integer
410
- def env_int(key)
411
- value = env_value(key)
412
- return nil unless value
413
-
414
- value.to_i
415
- rescue ArgumentError
416
- nil
417
- end
418
-
419
- # Build SSH connection options from @options
420
- def build_ssh_options
421
- SSH_DEFAULTS.merge(
422
- SSH_OPTION_MAPPING.each_with_object({}) do |(ssh_key, option_key), opts|
423
- value = option_key.is_a?(Proc) ? option_key.call(@options) : @options[option_key]
424
- opts[ssh_key] = value unless value.nil?
425
- end
426
- )
427
- end
428
-
429
- # Validate all connection options
430
- def validate_connection_options!
431
- validate_required_options!
432
- validate_option_types!
433
- validate_proxy_options!
434
- end
435
-
436
- # Validate required options are present
437
- def validate_required_options!
438
- raise Train::ClientError, 'Host is required' unless @options[:host]
439
- raise Train::ClientError, 'User is required' unless @options[:user]
440
- end
441
-
442
- # Validate option types and ranges
443
- def validate_option_types!
444
- validate_port! if @options[:port]
445
- validate_timeout! if @options[:timeout]
446
- validate_bastion_port! if @options[:bastion_port]
447
- end
448
-
449
- # Validate port is in valid range
450
- def validate_port!
451
- port = @options[:port].to_i
452
- raise Train::ClientError, "Invalid port: #{@options[:port]} (must be 1-65535)" unless port.between?(1, 65_535)
453
- end
454
-
455
- # Validate timeout is positive number
456
- def validate_timeout!
457
- timeout = @options[:timeout]
458
- raise Train::ClientError, "Invalid timeout: #{timeout} (must be positive number)" unless timeout.is_a?(Numeric) && timeout.positive?
459
- end
460
-
461
- # Validate bastion port is in valid range
462
- def validate_bastion_port!
463
- port = @options[:bastion_port].to_i
464
- raise Train::ClientError, "Invalid bastion_port: #{@options[:bastion_port]} (must be 1-65535)" unless port.between?(1, 65_535)
465
- end
466
-
467
- # Validate proxy configuration options (Train standard)
468
- def validate_proxy_options!
469
- # Cannot use both bastion_host and proxy_command simultaneously
470
- if @options[:bastion_host] && @options[:proxy_command]
471
- raise Train::ClientError, 'Cannot specify both bastion_host and proxy_command'
472
- end
473
- end
474
-
475
- # Create temporary SSH_ASKPASS script for automated password authentication
476
- def create_ssh_askpass_script(password)
477
- require 'tempfile'
478
-
479
- script = Tempfile.new(['ssh_askpass', '.sh'])
480
- script.write("#!/bin/bash\necho '#{password}'\n")
481
- script.close
482
- File.chmod(0o755, script.path)
483
-
484
- @logger.debug("Created SSH_ASKPASS script at #{script.path}")
485
- script.path
486
- end
487
-
488
- # Generate SSH proxy command for bastion host using ProxyJump (-J)
489
- def generate_bastion_proxy_command(bastion_user, bastion_port)
490
- args = ['ssh']
491
-
492
- # SSH options for connection
493
- args += ['-o', 'UserKnownHostsFile=/dev/null']
494
- args += ['-o', 'StrictHostKeyChecking=no']
495
- args += ['-o', 'LogLevel=ERROR']
496
- args += ['-o', 'ForwardAgent=no']
497
-
498
- # Use ProxyJump (-J) which handles password authentication properly
499
- jump_host = if bastion_port == 22
500
- "#{bastion_user}@#{@options[:bastion_host]}"
501
- else
502
- "#{bastion_user}@#{@options[:bastion_host]}:#{bastion_port}"
503
- end
504
- args += ['-J', jump_host]
505
-
506
- # Add SSH keys if specified
507
- if @options[:key_files]
508
- Array(@options[:key_files]).each do |key_file|
509
- args += ['-i', key_file]
510
- end
511
- end
512
-
513
- # Target connection - %h and %p will be replaced by Net::SSH
514
- args += ['%h', '-p', '%p']
515
-
516
- args.join(' ')
517
- end
518
-
519
- # Mock command execution for testing
520
- def mock_command_result(cmd)
521
- response = MOCK_RESPONSES.find { |pattern, _| cmd.match?(/#{pattern}/) }
522
-
523
- if response
524
- output = response[1].is_a?(Symbol) ? send(response[1]) : response[1]
525
- CommandResult.new(output, '', 0)
526
- else
527
- CommandResult.new("% Unknown command: #{cmd}", '', 1)
528
- end
529
- end
530
-
531
- # Mock JunOS version output for testing
532
- def mock_show_version_output
533
- <<~OUTPUT
534
- Hostname: lab-srx
535
- Model: SRX240H2
536
- Junos: 12.1X47-D15.4
537
- JUNOS Software Release [12.1X47-D15.4]
538
- OUTPUT
539
- end
540
-
541
- # Mock chassis output for testing
542
- def mock_chassis_output
543
- <<~OUTPUT
544
- Hardware inventory:
545
- Item Version Part number Serial number Description
546
- Chassis JN123456 SRX240H2
547
- OUTPUT
548
- end
549
- end
550
-
551
- # File abstraction for Juniper configuration and operational data
552
- class JuniperFile
553
- # Initialize a new JuniperFile
554
- # @param connection [Connection] The Juniper connection instance
555
- # @param path [String] The virtual file path
556
- def initialize(connection, path)
557
- @connection = connection
558
- @path = path
559
- end
560
-
561
- # Get the content of the virtual file
562
- # @return [String] The command output based on the path
563
- # @example
564
- # file = connection.file('/config/interfaces')
565
- # file.content # Returns output of 'show configuration interfaces'
566
- def content
567
- # For Juniper devices, translate file paths to appropriate commands
568
- case @path
569
- when %r{/config/(.*)}
570
- # Configuration sections: /config/interfaces -> show configuration interfaces
571
- section = ::Regexp.last_match(1)
572
- result = @connection.run_command("show configuration #{section}")
573
- result.stdout
574
- when %r{/operational/(.*)}
575
- # Operational data: /operational/interfaces -> show interfaces
576
- section = ::Regexp.last_match(1)
577
- result = @connection.run_command("show #{section}")
578
- result.stdout
579
- else
580
- # Default to treating path as a show command
581
- result = @connection.run_command("show #{@path}")
582
- result.stdout
583
- end
584
- end
585
-
586
- def exist?
587
- !content.empty?
588
- rescue StandardError
589
- false
590
- end
591
202
  end
592
203
  end
593
204
  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
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TrainPlugins
4
+ module Juniper
5
+ # Helper methods for safely handling environment variables
6
+ module Environment
7
+ # Helper method to safely get environment variable value
8
+ # Returns nil if env var is not set or is empty string
9
+ # @param key [String] The environment variable name
10
+ # @return [String, nil] The value or nil if not set/empty
11
+ def env_value(key)
12
+ value = ENV.fetch(key, nil)
13
+ return nil if value.nil? || value.empty?
14
+
15
+ value
16
+ end
17
+
18
+ # Helper method to get environment variable as integer
19
+ # Returns nil if env var is not set, empty, or not a valid integer
20
+ # @param key [String] The environment variable name
21
+ # @return [Integer, nil] The integer value or nil if not valid
22
+ def env_int(key)
23
+ value = env_value(key)
24
+ return nil unless value
25
+
26
+ value.to_i
27
+ end
28
+ end
29
+ end
30
+ end