net-connector 0.4.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 (109) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +82 -0
  3. data/LICENSE +21 -0
  4. data/README.md +277 -0
  5. data/docs/RELEASING.md +81 -0
  6. data/docs/VERIFICATION.md +73 -0
  7. data/docs/architecture.md +268 -0
  8. data/exe/net-connector-backup +6 -0
  9. data/lib/net/connector/device/base.rb +368 -0
  10. data/lib/net/connector/device/interface_description.rb +41 -0
  11. data/lib/net/connector/device/interface_name.rb +56 -0
  12. data/lib/net/connector/device/profile.rb +446 -0
  13. data/lib/net/connector/device/running_config/strategy.rb +29 -0
  14. data/lib/net/connector/device/running_config.rb +69 -0
  15. data/lib/net/connector/engine/authentication.rb +77 -0
  16. data/lib/net/connector/engine/base.rb +4 -0
  17. data/lib/net/connector/engine/command.rb +101 -0
  18. data/lib/net/connector/engine/configuration.rb +131 -0
  19. data/lib/net/connector/engine/core.rb +6 -0
  20. data/lib/net/connector/engine/dialogue.rb +223 -0
  21. data/lib/net/connector/engine/errors.rb +183 -0
  22. data/lib/net/connector/engine/execution.rb +64 -0
  23. data/lib/net/connector/engine/log.rb +227 -0
  24. data/lib/net/connector/engine/log_messages.rb +47 -0
  25. data/lib/net/connector/engine/profile.rb +4 -0
  26. data/lib/net/connector/engine/recovery.rb +57 -0
  27. data/lib/net/connector/engine/result.rb +67 -0
  28. data/lib/net/connector/engine/session.rb +333 -0
  29. data/lib/net/connector/engine/terminal_renderer.rb +158 -0
  30. data/lib/net/connector/engine/transport.rb +184 -0
  31. data/lib/net/connector/engine.rb +4 -0
  32. data/lib/net/connector/netdisco/batch.rb +71 -0
  33. data/lib/net/connector/netdisco/cli.rb +142 -0
  34. data/lib/net/connector/netdisco/client.rb +148 -0
  35. data/lib/net/connector/netdisco/config_file.rb +87 -0
  36. data/lib/net/connector/netdisco/device.rb +75 -0
  37. data/lib/net/connector/netdisco/fleet.rb +258 -0
  38. data/lib/net/connector/netdisco/planner.rb +77 -0
  39. data/lib/net/connector/netdisco/result_store.rb +43 -0
  40. data/lib/net/connector/netdisco/rules.rb +131 -0
  41. data/lib/net/connector/netdisco/settings.rb +183 -0
  42. data/lib/net/connector/netdisco/worker.rb +75 -0
  43. data/lib/net/connector/netdisco.rb +14 -0
  44. data/lib/net/connector/operations/local_backup.rb +49 -0
  45. data/lib/net/connector/operations/parse_output.rb +60 -0
  46. data/lib/net/connector/operations/private_file.rb +25 -0
  47. data/lib/net/connector/operations/running_config/cisco.rb +14 -0
  48. data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
  49. data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
  50. data/lib/net/connector/operations/running_config/strategy.rb +3 -0
  51. data/lib/net/connector/operations/running_config.rb +16 -0
  52. data/lib/net/connector/operations/saved_config.rb +72 -0
  53. data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
  54. data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
  55. data/lib/net/connector/operations/tftp/h3c.rb +11 -0
  56. data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
  57. data/lib/net/connector/operations/tftp/huawei.rb +11 -0
  58. data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
  59. data/lib/net/connector/operations/tftp/radware.rb +11 -0
  60. data/lib/net/connector/operations/tftp/strategy.rb +77 -0
  61. data/lib/net/connector/operations/tftp_backup.rb +120 -0
  62. data/lib/net/connector/operations/topology/cisco.rb +11 -0
  63. data/lib/net/connector/operations/topology/h3c.rb +11 -0
  64. data/lib/net/connector/operations/topology/hillstone.rb +11 -0
  65. data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
  66. data/lib/net/connector/operations/topology/radware.rb +11 -0
  67. data/lib/net/connector/operations/topology/strategy.rb +53 -0
  68. data/lib/net/connector/operations/topology.rb +192 -0
  69. data/lib/net/connector/operations.rb +20 -0
  70. data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
  71. data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
  72. data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
  73. data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
  74. data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
  75. data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
  76. data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
  77. data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
  78. data/lib/net/connector/templates/index +8 -0
  79. data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
  80. data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
  81. data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
  82. data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
  83. data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
  84. data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
  85. data/lib/net/connector/vendor/cisco_ios.rb +48 -0
  86. data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
  87. data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
  88. data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
  89. data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
  90. data/lib/net/connector/vendor/h3c/topology.rb +56 -0
  91. data/lib/net/connector/vendor/h3c.rb +77 -0
  92. data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
  93. data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
  94. data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
  95. data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
  96. data/lib/net/connector/vendor/hillstone.rb +43 -0
  97. data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
  98. data/lib/net/connector/vendor/huawei.rb +64 -0
  99. data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
  100. data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
  101. data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
  102. data/lib/net/connector/vendor/palo_alto.rb +43 -0
  103. data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
  104. data/lib/net/connector/vendor/radware/topology.rb +14 -0
  105. data/lib/net/connector/vendor/radware.rb +62 -0
  106. data/lib/net/connector/version.rb +7 -0
  107. data/lib/net/connector.rb +48 -0
  108. data/lib/net-connector.rb +3 -0
  109. metadata +517 -0
@@ -0,0 +1,268 @@
1
+ # Device operations model
2
+
3
+ The public connector object represents one device session. It owns connection state,
4
+ command execution, and the vendor profile. Callers continue to use
5
+ `device.running_config`, `device.backup(path:)`, and `device.tftp_backup(...)`.
6
+ Running configuration is a basic device capability: `device/` owns the facade,
7
+ immutable profile, and shared collection flow. `Net::Connector::Operations`
8
+ groups optional workflows over that device: backup, parsing, neighbor discovery,
9
+ and interface description plans. Saved-file export is a local operation and
10
+ does not open a device session.
11
+
12
+ | Layer | Responsibility | Location |
13
+ | --- | --- | --- |
14
+ | Device session | Login, command dialogue, script execution, logging | `engine/` |
15
+ | Device | Public facade, immutable profile, collection and interface naming helpers | `device/` |
16
+ | Vendor assembly | Static CLI prompts, commands, interactions, strategy bindings and session hooks | `vendor/<name>.rb` |
17
+ | Configuration collection | Shared execution, completeness checks and hook adaptation | `device/running_config.rb`, `device/running_config/strategy.rb` |
18
+ | Vendor collection | Configuration cleanup, view transitions and response checks | `vendor/<name>/running_config.rb` |
19
+ | Interface text | Name matching, optional abbreviations, description formatting and common interface-view commands | `device/interface_name.rb`, `device/interface_description.rb` |
20
+ | TextFSM parsing | Select a template and parse command output or saved configuration into records | `operations/parse_output.rb`, `templates/` |
21
+ | Topology and description plan | Read CDP/LLDP neighbors and current descriptions, prepare commands, recheck evidence before confirmed execution | `operations/topology.rb` |
22
+ | Local backup | Collect, compare hashes, atomically save, report change | `operations/local_backup.rb` |
23
+ | Saved configuration export | Find and export an existing local backup without inventory access | `operations/saved_config.rb` |
24
+ | Private file write | Atomically replace local backup and export files with mode `0600` | `operations/private_file.rb` |
25
+ | TFTP backup | Validate target, run export, check transfer evidence, report result | `operations/tftp_backup.rb` |
26
+ | TFTP vendor strategy | Export command, prompts, source file, success evidence, remote name | `vendor/<name>/tftp_backup.rb` |
27
+ | Topology vendor strategy | Discovery commands, parsing evidence, configuration views and command exceptions | `vendor/<name>/topology.rb` |
28
+ | Inventory plan | Select ready devices, apply per-vendor limits, record skip reasons | `netdisco/planner.rb` |
29
+ | Batch worker | Dispatch independent device jobs and isolate callback failures | `netdisco/worker.rb` |
30
+ | Batch result | Summarize outcomes and preserve report failures | `netdisco/batch.rb` |
31
+ | Fleet | Load inventory, run local or TFTP tasks, write reports | `netdisco/fleet.rb` |
32
+ | Settings | Read environment and YAML overrides for CLI and fleet | `netdisco/settings.rb` |
33
+
34
+ ## Business contracts
35
+
36
+ The connector facade owns one session. `Session` serializes login and scripts,
37
+ closes the transport on failures, and never replays a device command. Each
38
+ `Result` retains completed steps on failure. `RunningConfig` executes one
39
+ collection script with one fresh strategy per call. Response checks, step
40
+ selection and cleanup share that strategy. Selection and cleanup run through
41
+ the existing facade hooks while the session lock is held; overridden methods
42
+ can call `super`. The temporary binding is released even when cleanup fails,
43
+ and is not available to other Fibers performing offline cleanup. Collection
44
+ commands use the current session's complete prompt line, not a generic trailing
45
+ `#`, `>` or `]`. PAN-OS view transitions preserve the authenticated prompt identity.
46
+ A missing final prompt fails collection and leaves the previous backup untouched. Missing or blank configuration, including a response containing
47
+ only a prompt or command echo, is `:incomplete_configuration`,
48
+ not a successful empty backup. PAN-OS checks the candidate diff both before and
49
+ after its `show` command.
50
+
51
+ `LocalBackup` collects before replacing a private file and preserves the old
52
+ file on failure. TFTP reports only device-side completion. Its strategies check
53
+ explicit completion lines after removing command, reply and prompt echoes;
54
+ filenames and future-tense progress do not establish success. Failure evidence
55
+ from raw and rendered output takes precedence over completion messages. `Topology` reads
56
+ neighbors and descriptions, freezes a plan, requires explicit confirmation,
57
+ rechecks evidence, rebuilds the commands, and reads the configuration back.
58
+ The complete recheck/write/readback sequence holds a session operation lease.
59
+ Sequential scripts from its owning thread and Fiber may execute; other callers,
60
+ close attempts and reentrant script callbacks receive `SessionBusy`. This lease
61
+ is local to one connector instance, not a device-side or cross-process lock.
62
+ `Fleet` keeps skipped, failed, successful, and saved-with-close-error outcomes
63
+ distinct; callback and report errors remain visible without discarding device
64
+ results. Neighbor table headers alone can establish an empty table, but unknown
65
+ or partly parsed rows cannot establish a complete discovery result. Diagnostics
66
+ are checked against both original output and terminal-rendered text so that
67
+ color sequences cannot hide failures and carriage returns cannot erase them.
68
+ Plan revalidation compares the complete discovered neighbor, including chassis
69
+ ID when available, without changing the public evidence hash shape. PAN-OS
70
+ configuration collection preserves multiline quoted values, but the interface
71
+ description template supports single-line comments only; incomplete quoted
72
+ comments raise `ParsingError` instead of becoming truncated plan evidence.
73
+
74
+ ## Expect semantics and Ruby boundaries
75
+
76
+ The [Tcl Expect manual](https://core.tcl-lang.org/expect/doc/trunk/expect.man)
77
+ defines ordered matching, `exp_continue -continue_timer`, buffer consumption,
78
+ EOF, and separate close/wait responsibilities. Its
79
+ [matching loop](https://github.com/tcltk-depot/expect/blob/main/expect.c)
80
+ keeps the deadline when `EXP_CONTINUE_TIMER` is returned; its
81
+ [process handling](https://github.com/tcltk-depot/expect/blob/main/exp_command.c)
82
+ waits for the owned child and retries interrupted waits. These are useful
83
+ reference semantics; `net-connector` is a device operations library, not a Tcl
84
+ interpreter or a complete Expect API port.
85
+
86
+ | Concern | Connector contract | Owner |
87
+ | --- | --- | --- |
88
+ | Matching | Connection failures precede interactions, then the final prompt; prompt matches must consume bytes | `ResponseReader` |
89
+ | Time | One monotonic deadline includes command writes and prompt replies; progress and pagination never extend it | `Session`, `ResponseReader` |
90
+ | Buffers | Preserve collected output up to `max_output_bytes`; retain 32 KiB of unmatched tail while streaming larger output | `Transports::Pty`, `ResponseReader` |
91
+ | EOF | Return `ConnectionClosed`, preserve earlier completed steps, close the session | `ResponseReader`, `Execution`, `Session` |
92
+ | Resources | Own the child through `expect-pty#hard_close`; release log files even after setup or flush errors | `Transports::Pty`, `Log` |
93
+ | Interactive use | A manual interaction ends the automated session; subsequent work creates a fresh connection | `Session` |
94
+ | Concurrency | One script or multi-script operation owns a device session; callbacks cannot reenter it; independent jobs use a bounded worker pool | `Session`, `Netdisco::Worker` |
95
+
96
+ Patterns are Ruby regular expressions. Keep custom prompts and interaction
97
+ markers short enough to fit the unmatched tail; a pattern requiring an
98
+ arbitrarily long transcript is not supported by the streaming adapter. Output
99
+ collection and the matching window have different limits. The terminal renderer
100
+ handles common line-editing controls, not a full screen terminal emulator.
101
+ `Profile#terminal_size` uses `[width, height]`; the PTY adapter converts it to
102
+ Ruby's `[rows, columns]` convention.
103
+
104
+ Ruby objects retain explicit resource ownership and keyword arguments.
105
+ `Profile` provides finite declarations; strategies contain vendor behavior.
106
+ Private guards use direct names such as `check_block!` and
107
+ `check_change_support!`. Public methods, vendor hooks, result objects, and CLI
108
+ JSON fields remain stable. There is no runtime method injection or workflow DSL.
109
+
110
+ ## Vendor capabilities
111
+
112
+ `device.supports?(capability)` reads the profile and method-based collection
113
+ commands without opening a transport or constructing a strategy. Accepted
114
+ capabilities are `:running_config`, `:save_config`, `:backup`, `:tftp_backup`,
115
+ `:neighbors`, `:interface_descriptions`, and
116
+ `:interface_description_changes`; unknown names return `false`. This reports
117
+ implementation support, not device authorization or firmware compatibility.
118
+
119
+ | Vendor | Collect / local backup | Save | TFTP | Neighbors | Descriptions | Plan and apply descriptions |
120
+ | --- | --- | --- | --- | --- | --- | --- |
121
+ | H3C | Yes | Yes | Yes | Yes | Yes | Yes |
122
+ | H3C wireless | Yes | Yes | Yes | Yes | Yes | Yes |
123
+ | Cisco IOS / IOS XE | Yes | Yes | Yes | Yes | Yes | Yes |
124
+ | Cisco NX-OS | Yes | Yes | Yes | Yes | Yes | Yes |
125
+ | Radware Alteon | Yes | Yes | Yes | No | Yes | No |
126
+ | PAN-OS | Yes | No | Yes | Yes | Yes | Yes |
127
+ | Huawei | Yes | Yes | Yes | No | No | No |
128
+ | Hillstone | Yes | Yes | Yes | Yes | Yes | Yes |
129
+
130
+ The `Profile` DSL declares static command, prompt, interaction, and strategy
131
+ bindings. Configuration, TFTP and topology rules live beside their vendor under
132
+ `vendor/<name>/`. The shared collection flow and operations own validation,
133
+ execution and result semantics. Identical rules are reused directly: NX-OS uses
134
+ IOS topology rules, H3C wireless inherits H3C, and H3C/Huawei/Radware use the common
135
+ rendered configuration strategy. A directory is not a reason to copy a rule. A subclass inherits and may replace these bindings.
136
+ Setting a TFTP or topology binding to `nil` disables that capability. A `nil`
137
+ collection binding uses the default strategy; an empty collection command list
138
+ disables collection. For example, a model variant can replace its transfer flow:
139
+
140
+ ```ruby
141
+ require "net/connector"
142
+ require "net/connector/vendor/cisco_ios"
143
+
144
+ class VariantTransfer < Net::Connector::CiscoIos::TftpBackup
145
+ # Override only the methods needed by this model; preserve the Strategy API.
146
+ end
147
+
148
+ class VariantRouter < Net::Connector.vendor_class(:cisco_ios)
149
+ profile do
150
+ tftp_strategy VariantTransfer
151
+ end
152
+ end
153
+
154
+ router = VariantRouter.new(host: "192.0.2.10", username: "operator")
155
+ router.supports?(:tftp_backup) # => true, no connection attempted
156
+ ```
157
+
158
+ For collection, bind `running_config_strategy` to a subclass of
159
+ `Net::Connector::RunningConfig::Strategy`. The strategy defines cleaning, result-step
160
+ selection, expected view transitions and per-response validation. PAN-OS candidate
161
+ diff checks apply only to collection; a direct `execute("show config diff")` still
162
+ returns the requested output. Static command lists stay in the profile. The
163
+ facade's `collect_config`, `clean_config` and protected `config_result_step` hooks
164
+ remain available for existing subclasses. `Base` runs generic scripts and locked
165
+ callbacks; it does not decide whether a configuration is complete.
166
+
167
+ For topology, bind a subclass of `Operations::Topology::Strategy` (or an
168
+ existing vendor strategy) with `topology_strategy YourStrategy`. Its static
169
+ `supports?` declares which of the three topology operations it implements;
170
+ the instance methods provide commands, templates, output completeness, and
171
+ interface spelling. A vendor using a custom inventory label must also provide
172
+ a TextFSM template through `neighbor_template`, because the bundled index
173
+ matches the existing vendor keys. Keep confirmation, evidence checks, and
174
+ readback in `Topology`.
175
+
176
+ Device operations are constructed for one connector and have a `call` method. TFTP
177
+ strategies contain only device-specific transfer behavior; the operation owns
178
+ the shared success and failure rules. Each vendor binds its strategies in its
179
+ profile, so adding a backup operation does not add methods to every vendor
180
+ connector. Generated TFTP filenames share `TftpTarget` validation and length
181
+ limits. Scoped IPv6 addresses become safe ASCII filename tokens; an unusually
182
+ long address uses a stable SHA-256 token. Existing valid filenames retain their
183
+ spelling, and inventory labels are shortened only when the complete name would
184
+ exceed the target limit.
185
+
186
+ The facade keeps the existing device API while allowing batch workers and
187
+ single-device callers to share the same operations. The TFTP result means the
188
+ device reported upload completion; checking the server file remains the
189
+ caller's responsibility.
190
+
191
+ The acceptance gate is `script/ci` (also `bundle exec rake release:check`). It
192
+ checks source, available Git history and gem contents for sensitive data; runs
193
+ Ruby and workflow lint and the full test suite; and installs the gem into both
194
+ an isolated gem home and a minimal Bundler application. A local PTY smoke checks
195
+ vendor loading, configuration collection, packaged TextFSM templates and the CLI.
196
+ RuboCop checks Ruby lint, security, whitespace, frozen string comments and the
197
+ project's double-quoted string convention. No real device is contacted. Initial
198
+ dependency and tool downloads require internet access; see
199
+ [verification](VERIFICATION.md) and [release instructions](RELEASING.md).
200
+
201
+ ## Loading and compatibility
202
+
203
+ `require "net/connector"` loads the device API and engine, without loading vendor
204
+ rules or TextFSM. Each vendor entry point assembles only its own rules and shared
205
+ parents. Parsing loads when a parsing or topology workflow is used. Lower-level
206
+ callers can load `net/connector/engine/core` without device definitions, business
207
+ operations or vendor rules. `engine/base`, `engine/profile`, and `engine` remain
208
+ forwarding entry points for existing callers.
209
+
210
+ Old `Operations::RunningConfig`, `Operations::RunningConfig::<Vendor>`,
211
+ `Operations::Tftp::<Vendor>` and `Operations::Topology::<Vendor>` constants and
212
+ require paths forward to the same classes, rather than maintaining duplicate
213
+ implementations. Existing public result constants also remain available through
214
+ autoload. New vendor code uses the vendor-owned classes directly.
215
+
216
+ ## Interface description policy
217
+
218
+ Raw `Neighbor` fields and plan evidence retain the exact discovered values.
219
+ `InterfaceName.key` matches local aliases to running configuration names;
220
+ `InterfaceName.configuration` preserves the established CLI expansion rules.
221
+ `InterfaceName.short` is exclusively a display policy for the remote port in a
222
+ description. It never replaces the local interface used in commands.
223
+
224
+ `InterfaceDescription.format(neighbor, abbreviate: true, lowercase: false)` is
225
+ shared by every vendor's default plan. It retains the neighbor device name and
226
+ produces `To <name> <port>`. Known families shorten as follows, preserving lower,
227
+ upper or initial-capital case: Ethernet/Eth → Eth, GigabitEthernet/GE/Gi → Gi,
228
+ Ten-GigabitEthernet/TenGigabitEthernet/XGE/Te → Te, FastEthernet/Fa → Fa, and
229
+ port-channel/Po → Po. Port numbers and subinterface suffixes stay intact. Unknown
230
+ forms such as `ge-0/0/1`, `100GE1/0/1` and `Port 12` stay unchanged by default;
231
+ this is a finite mapping, not a claim to recognize all vendors or interface types.
232
+
233
+ This changes the default proposal from `To peer Ethernet1/2` to
234
+ `To peer Eth1/2`. Use `plan_interface_descriptions(abbreviate: false)` to keep the
235
+ previous spelling, or `lowercase: true` to lowercase only the remote interface.
236
+ An explicit formatter block receives the original neighbor and controls the
237
+ complete description. All outputs still pass the same 80-byte and character
238
+ validation, evidence recheck, confirmation and readback requirements.
239
+
240
+ `InterfaceDescription.commands(interface:, description:, leave: "exit")` builds
241
+ separate `interface`, `description` and exit commands for IOS/NX-OS and Hillstone;
242
+ H3C supplies `leave: "quit"`. Entering configuration mode and saving remain
243
+ vendor responsibilities. PAN-OS retains `set network interface ... comment`,
244
+ `commit`, and its extended commit timeout. The common builder is pure: it does
245
+ not send commands or bypass the reviewed topology plan.
246
+
247
+ ## Sensitive command lifetime
248
+
249
+ One temporary redaction scope spans preparation, exchange, vendor postprocessing,
250
+ user callbacks and error normalization. Follow-up queries share the enclosing
251
+ scope; `ensure` removes temporary secrets afterward. Sensitive errors preserve
252
+ type, code, phase and completed steps but hide arbitrary messages, output and
253
+ underlying backtraces that could contain partial secrets. Explicit result data
254
+ remains raw. Direct and streaming redaction prioritize actual secrets, including
255
+ secrets containing the literal `[REDACTED]`, before preserving existing markers.
256
+
257
+ ## Local backup identity and legacy files
258
+
259
+ Fleet backups use the normalized management IP alone (`<IP>.txt`, with IPv6 `:`
260
+ replaced by `_`). Inventory names remain result metadata and TFTP labels.
261
+ `SavedConfig` prefers this canonical file. If absent, it accepts exactly one
262
+ legacy `<name>-<IP>.txt` file; multiple matches fail rather than choosing by mtime.
263
+ The first successful canonical backup compares against the unique legacy digest,
264
+ retains the legacy file, and reports `changed` or `unchanged` accordingly. Failed
265
+ collection creates no canonical file. Symlinks and non-regular candidates are
266
+ rejected before connecting. Existing ambiguous legacy sets need operator review:
267
+ retain the originals and place the verified current configuration at the canonical
268
+ path before resuming backup/export.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "net/connector/netdisco"
5
+
6
+ exit Net::Connector::Netdisco::CLI.new.run
@@ -0,0 +1,368 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "English"
4
+ require "forwardable"
5
+ require_relative "../engine/core"
6
+ require_relative "profile"
7
+ require_relative "running_config"
8
+ require_relative "interface_description"
9
+ require_relative "../operations"
10
+
11
+ module Net
12
+ module Connector
13
+ # 设备连接门面;厂商子类提供语法和钩子,组合对象负责实际执行。
14
+ class Base
15
+ extend Forwardable
16
+
17
+ attr_reader :configuration, :command_timeout
18
+
19
+ # 将连接状态查询委托给当前设备会话。
20
+ def_delegators :@session, :connected?, :privileged?, :state
21
+ # 将设备地址、账号和日志路径委托给连接配置。
22
+ def_delegators :@configuration, :host, :username, :log_file
23
+
24
+ class << self
25
+ # 读取或声明连接器的厂商标识。
26
+ def vendor(key = nil)
27
+ return @vendor = key if key
28
+
29
+ @vendor || (superclass.vendor if superclass.respond_to?(:vendor))
30
+ end
31
+
32
+ # 在子类中声明设备规则;没有块时返回当前类继承到的档案。
33
+ def profile(&definition)
34
+ return inherited_profile unless definition
35
+
36
+ @profile = Profile.define(parent: inherited_profile, &definition)
37
+ end
38
+
39
+ # 让子类共享不可变父档案,子类声明时再生成自己的副本。
40
+ def inherited(subclass)
41
+ super
42
+ subclass.instance_variable_set(:@profile, profile)
43
+ end
44
+
45
+ private
46
+
47
+ # 查找当前类或最近父类的设备档案。
48
+ def inherited_profile
49
+ return @profile if instance_variable_defined?(:@profile)
50
+
51
+ parent = superclass
52
+ parent.respond_to?(:profile) ? parent.profile : nil
53
+ end
54
+ end
55
+
56
+ # 连接设备并执行代码块;无论正常返回、break、异常还是中断,都会释放会话资源。
57
+ def self.open(**)
58
+ raise ArgumentError, "open requires a block" unless block_given?
59
+
60
+ device = new(**)
61
+ begin
62
+ device.connect
63
+ yield device
64
+ ensure
65
+ active_error = $ERROR_INFO
66
+ begin
67
+ device.close
68
+ rescue StandardError
69
+ raise unless active_error
70
+ end
71
+ end
72
+ end
73
+
74
+ # 校验连接配置,创建对话语法、传输层、恢复器和会话对象。
75
+ def initialize(configuration: nil, transport: nil, **settings)
76
+ if configuration
77
+ raise ArgumentError, "configuration cannot be combined with settings" unless settings.empty?
78
+ raise ArgumentError, "configuration must be a Configuration" unless configuration.is_a?(Configuration)
79
+ end
80
+ @configuration = configuration || Configuration.new(**settings)
81
+ @command_timeout = @configuration.command_timeout || default_command_timeout
82
+ @dialogue = build_dialogue
83
+ transport ||= Transports.build(@configuration, terminal_size: terminal_size)
84
+ @session = Session.new(configuration: @configuration, dialogue: @dialogue, transport: transport,
85
+ recovery: Recovery.new(@configuration, legacy_arguments: legacy_ssh_arguments),
86
+ after_login: method(:after_login))
87
+ end
88
+
89
+ # 返回当前连接器使用的不可变设备档案,便于检查厂商声明。
90
+ def profile = self.class.profile || Profile.default
91
+
92
+ # 读取或声明连接器的厂商标识。
93
+ def vendor = self.class.vendor
94
+
95
+ # 只查询实现能力,不连接设备;不代表现场权限或固件验证成功。
96
+ def supports?(capability)
97
+ capability = capability.to_sym if capability.is_a?(String)
98
+ case capability
99
+ when :running_config, :backup
100
+ !config_commands.empty?
101
+ when :save_config
102
+ !save_commands.empty?
103
+ when :tftp_backup
104
+ !profile.tftp_strategy.nil?
105
+ when :neighbors, :interface_descriptions, :interface_description_changes
106
+ !!profile.topology_strategy&.supports?(capability)
107
+ else
108
+ false
109
+ end
110
+ rescue NotImplementedError
111
+ false
112
+ end
113
+
114
+ # 建立会话并返回当前设备对象。
115
+ def connect
116
+ @session.connect
117
+ self
118
+ end
119
+
120
+ # 关闭会话及其传输资源。
121
+ def close = @session.close
122
+
123
+ # 将一条文本命令包装成脚本并执行。
124
+ def execute(text, **, &)
125
+ execute_script(Script.new([Command.new(text, **)]), &)
126
+ end
127
+
128
+ # 接收脚本或命令数组,统一交给单次会话执行。
129
+ def execute_script(script, &)
130
+ script = Script.new(script) unless script.is_a?(Script)
131
+ perform_script(script, &)
132
+ end
133
+
134
+ # 使用简短名称执行脚本。
135
+ alias run execute_script
136
+
137
+ # 读取运行配置并返回清理后的配置结果。
138
+ def running_config
139
+ RunningConfig.new(self).call
140
+ end
141
+
142
+ # 执行命令并按厂商、命令或显式 TextFSM 模板返回结构化记录。
143
+ def parse_command(command, template: nil, template_dir: nil)
144
+ parser = Operations::ParseOutput.new(template_dir: template_dir)
145
+ parser.call(execute(command).value!, template: template, vendor: vendor, command: command, host: host)
146
+ end
147
+
148
+ # 采集运行配置并使用指定 TextFSM 模板提取结构化记录。
149
+ def parse_config(template:, template_dir: nil)
150
+ parser = Operations::ParseOutput.new(template_dir: template_dir)
151
+ parser.call(running_config.value!, template: template, host: host)
152
+ end
153
+
154
+ # 执行 CDP 或 LLDP 查询并返回统一的链路邻居记录。
155
+ def neighbors = Operations::Topology.new(self).neighbors
156
+
157
+ # 读取运行配置中的接口描述或端口名称。
158
+ def interface_descriptions = Operations::Topology.new(self).descriptions
159
+
160
+ # 以邻居和现有配置为证据,生成待确认的接口描述变更计划。
161
+ def plan_interface_descriptions(abbreviate: true, lowercase: false, &formatter)
162
+ Operations::Topology.new(self).plan_descriptions(abbreviate: abbreviate, lowercase: lowercase, &formatter)
163
+ end
164
+
165
+ # 明确确认且现场证据未变化时执行接口描述计划。
166
+ def apply_interface_descriptions(plan, confirmed: false)
167
+ Operations::Topology.new(self).apply(plan, confirmed: confirmed)
168
+ end
169
+
170
+ # 多步骤业务操作独占当前会话,内部脚本仍禁止回调重入。
171
+ def with_operation(name, &block) = @session.with_operation(name, &block)
172
+
173
+ # 采集配置并以原子方式保存为私有文件。
174
+ # 采集失败时保留已有备份文件。
175
+ def backup(path:)
176
+ Operations::LocalBackup.new(self).call(path: path)
177
+ end
178
+
179
+ # 要求设备直接向 TFTP 服务器导出原生配置。
180
+ # 完成仅表示设备报告传输成功,未读取服务器端文件。
181
+ def tftp_backup(host:, path: nil, source_file: nil, vrf: nil)
182
+ Operations::TftpBackup.new(self).call(host: host, path: path, source_file: source_file, vrf: vrf)
183
+ end
184
+
185
+ # 配置采集是设备的基础能力,两个公共入口共享同一流程。
186
+ def collect_config = RunningConfig.new(self).call
187
+
188
+ # 业务层可扩展脚本准备、响应校验和最终结果,所有钩子均在会话锁内执行。
189
+ def execute_operation(script, name:, prompt: nil, after_command: nil, privilege: true, &finalize)
190
+ perform_script(script, operation: name, prompt: prompt, after_command: after_command,
191
+ privilege: privilege, finalize: finalize)
192
+ end
193
+
194
+ # 返回已确认的提示,供业务层为只读采集绑定准确的结束条件。
195
+ def current_prompt = @session.prompt
196
+
197
+ # 将业务事件写入当前设备会话日志。
198
+ def record_event(name, **details)
199
+ @session.log_event(name, **details)
200
+ end
201
+
202
+ # 执行厂商保存配置命令;不支持时返回显式失败结果。
203
+ def save_config
204
+ if save_commands.empty?
205
+ return Result.new(error: @session.error(UnsupportedOperation, "saving configuration is not supported",
206
+ phase: :save))
207
+ end
208
+
209
+ execute_script(save_commands)
210
+ end
211
+
212
+ # 进入特权模式,并把当前提示符记录到会话。
213
+ def enable
214
+ @session.perform(:enable) { @session.enable(enable_command, enable_prompt) }
215
+ self
216
+ end
217
+
218
+ # 将会话交给人工交互;交互结束后会话按终端语义关闭。
219
+ def interact(input: $stdin, output: $stdout, escape: "\x1d".b, timeout: nil)
220
+ @session.interact(input: input, output: output, escape: escape, timeout: timeout)
221
+ end
222
+
223
+ # 返回读取运行配置所需的设备命令;厂商必须实现。
224
+ def config_commands
225
+ commands = profile.config_commands
226
+ return commands if commands
227
+
228
+ raise NotImplementedError, "#{self.class} must define running configuration commands"
229
+ end
230
+
231
+ # 返回保存配置命令;空数组表示设备不支持保存。
232
+ def save_commands = profile.save_commands
233
+
234
+ # 清理运行配置文本;厂商可移除设备回显噪声。
235
+ def clean_config(text) = config_strategy.clean(text)
236
+
237
+ # 返回不包含凭据的连接状态摘要。
238
+ def inspect = "#<#{self.class} host=#{host.inspect} state=#{state}>"
239
+
240
+ protected
241
+
242
+ # 默认取最后一个已完成步骤;厂商可选择配置所在的业务步骤。
243
+ def config_result_step(result) = config_strategy.result_step(result)
244
+
245
+ # 匹配设备分页提示,供对话层自动发送翻页响应。
246
+ def pager_pattern = profile.pager_pattern
247
+
248
+ # 返回分页提示对应的响应字节。
249
+ def pager_response = profile.pager_response
250
+
251
+ # 匹配密码输入提示。
252
+ def password_prompt = profile.password_prompt
253
+
254
+ # 匹配用户名输入提示。
255
+ def username_prompt = profile.username_prompt
256
+
257
+ # 返回认证失败的设备输出模式。
258
+ def authentication_error_patterns = profile.authentication_error_patterns
259
+
260
+ # 返回命令失败的设备输出模式。
261
+ def command_error_patterns = profile.command_error_patterns
262
+
263
+ # 返回命令期间需要自动应答的确认对话。
264
+ def confirmation_dialogues = profile.confirmation_interactions
265
+
266
+ # 返回登录期间需要自动应答的附加对话。
267
+ def login_dialogues = profile.login_interactions
268
+
269
+ # 返回旧版 SSH 恢复使用的协商参数。
270
+ def legacy_ssh_arguments = profile.legacy_ssh_arguments
271
+
272
+ # 返回进入特权模式的命令;nil 表示不支持。
273
+ def enable_command = profile.privilege_command
274
+
275
+ # 匹配特权模式提示符。
276
+ def enable_prompt = profile.privilege_prompt
277
+
278
+ # 返回默认单条命令超时秒数。
279
+ def default_command_timeout = profile.command_timeout
280
+
281
+ # 返回可选终端大小。
282
+ def terminal_size = profile.terminal_size
283
+
284
+ # 返回登录完成提示;厂商必须实现。
285
+ def login_prompt
286
+ prompt = profile.login_prompt
287
+ return prompt if prompt
288
+
289
+ raise NotImplementedError, "#{self.class} must define login_prompt"
290
+ end
291
+
292
+ # 返回普通命令提示;厂商必须实现。
293
+ def command_prompt
294
+ prompt = profile.command_prompt
295
+ return prompt if prompt
296
+
297
+ raise NotImplementedError, "#{self.class} must define command_prompt"
298
+ end
299
+
300
+ # 登录成功后执行厂商初始化钩子。
301
+ def after_login(_session, _response) end
302
+
303
+ # 脚本开始前执行厂商批处理准备钩子。
304
+ def before_batch(execution)
305
+ return unless enable_command && execution.context.fetch(:privilege, true)
306
+
307
+ execution.enable(enable_command, enable_prompt)
308
+ end
309
+
310
+ # 调整单条命令或返回 nil 跳过该命令。
311
+ def prepare_command(command, _execution) = command
312
+
313
+ # 单条命令完成后执行厂商后处理钩子。
314
+ def after_command(_command, _response, _execution) end
315
+
316
+ private
317
+
318
+ # 采集器仅在持有会话锁的结果处理阶段绑定策略,让旧方法钩子的 super
319
+ # 复用响应校验状态。其他 Fiber 的离线清理仍使用自己的临时策略。
320
+ def with_config_strategy(strategy)
321
+ previous = @config_strategy_scope
322
+ @config_strategy_scope = [Fiber.current, strategy]
323
+ yield
324
+ ensure
325
+ @config_strategy_scope = previous
326
+ end
327
+
328
+ def config_strategy
329
+ scope = @config_strategy_scope
330
+ scope && scope.first.equal?(Fiber.current) ? scope.last : RunningConfig.strategy(self)
331
+ end
332
+
333
+ # 将厂商提示、失败模式和对话钩子组装成不可变对话语法。
334
+ def build_dialogue
335
+ Dialogue.new(
336
+ login_prompt: login_prompt, command_prompt: command_prompt,
337
+ password_prompt: password_prompt, username_prompt: username_prompt, enable_prompt: enable_prompt,
338
+ authentication_errors: authentication_error_patterns, command_errors: command_error_patterns,
339
+ login_interactions: login_dialogues,
340
+ command_interactions: [Interaction.new(pager_pattern, pager_response, capture: false),
341
+ *confirmation_dialogues]
342
+ )
343
+ end
344
+
345
+ # 在会话锁内运行脚本及业务回调,保留已完成步骤和统一错误边界。
346
+ def perform_script(script, operation: nil, prompt: nil, after_command: nil, privilege: true,
347
+ finalize: nil, &on_step)
348
+ return Result.new if script.empty?
349
+
350
+ finish_step = lambda do |command, response, context|
351
+ self.after_command(command, response, context)
352
+ after_command&.call(command, response, context)
353
+ end
354
+ execution = Execution.new(session: @session, timeout: command_timeout,
355
+ prepare: method(:prepare_command), after_command: finish_step, prompt: prompt)
356
+ execution.context[:operation] = operation if operation
357
+ execution.context[:privilege] = privilege
358
+ @session.perform(:script) do
359
+ before_batch(execution)
360
+ result = execution.execute(script, &on_step)
361
+ finalize ? finalize.call(result) : result
362
+ end
363
+ rescue Error => error
364
+ Result.new(steps: execution ? execution.steps : [], error: error)
365
+ end
366
+ end
367
+ end
368
+ end