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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +82 -0
- data/LICENSE +21 -0
- data/README.md +277 -0
- data/docs/RELEASING.md +81 -0
- data/docs/VERIFICATION.md +73 -0
- data/docs/architecture.md +268 -0
- data/exe/net-connector-backup +6 -0
- data/lib/net/connector/device/base.rb +368 -0
- data/lib/net/connector/device/interface_description.rb +41 -0
- data/lib/net/connector/device/interface_name.rb +56 -0
- data/lib/net/connector/device/profile.rb +446 -0
- data/lib/net/connector/device/running_config/strategy.rb +29 -0
- data/lib/net/connector/device/running_config.rb +69 -0
- data/lib/net/connector/engine/authentication.rb +77 -0
- data/lib/net/connector/engine/base.rb +4 -0
- data/lib/net/connector/engine/command.rb +101 -0
- data/lib/net/connector/engine/configuration.rb +131 -0
- data/lib/net/connector/engine/core.rb +6 -0
- data/lib/net/connector/engine/dialogue.rb +223 -0
- data/lib/net/connector/engine/errors.rb +183 -0
- data/lib/net/connector/engine/execution.rb +64 -0
- data/lib/net/connector/engine/log.rb +227 -0
- data/lib/net/connector/engine/log_messages.rb +47 -0
- data/lib/net/connector/engine/profile.rb +4 -0
- data/lib/net/connector/engine/recovery.rb +57 -0
- data/lib/net/connector/engine/result.rb +67 -0
- data/lib/net/connector/engine/session.rb +333 -0
- data/lib/net/connector/engine/terminal_renderer.rb +158 -0
- data/lib/net/connector/engine/transport.rb +184 -0
- data/lib/net/connector/engine.rb +4 -0
- data/lib/net/connector/netdisco/batch.rb +71 -0
- data/lib/net/connector/netdisco/cli.rb +142 -0
- data/lib/net/connector/netdisco/client.rb +148 -0
- data/lib/net/connector/netdisco/config_file.rb +87 -0
- data/lib/net/connector/netdisco/device.rb +75 -0
- data/lib/net/connector/netdisco/fleet.rb +258 -0
- data/lib/net/connector/netdisco/planner.rb +77 -0
- data/lib/net/connector/netdisco/result_store.rb +43 -0
- data/lib/net/connector/netdisco/rules.rb +131 -0
- data/lib/net/connector/netdisco/settings.rb +183 -0
- data/lib/net/connector/netdisco/worker.rb +75 -0
- data/lib/net/connector/netdisco.rb +14 -0
- data/lib/net/connector/operations/local_backup.rb +49 -0
- data/lib/net/connector/operations/parse_output.rb +60 -0
- data/lib/net/connector/operations/private_file.rb +25 -0
- data/lib/net/connector/operations/running_config/cisco.rb +14 -0
- data/lib/net/connector/operations/running_config/hillstone.rb +12 -0
- data/lib/net/connector/operations/running_config/palo_alto.rb +12 -0
- data/lib/net/connector/operations/running_config/strategy.rb +3 -0
- data/lib/net/connector/operations/running_config.rb +16 -0
- data/lib/net/connector/operations/saved_config.rb +72 -0
- data/lib/net/connector/operations/tftp/cisco_ios.rb +11 -0
- data/lib/net/connector/operations/tftp/cisco_nxos.rb +11 -0
- data/lib/net/connector/operations/tftp/h3c.rb +11 -0
- data/lib/net/connector/operations/tftp/hillstone.rb +11 -0
- data/lib/net/connector/operations/tftp/huawei.rb +11 -0
- data/lib/net/connector/operations/tftp/palo_alto.rb +11 -0
- data/lib/net/connector/operations/tftp/radware.rb +11 -0
- data/lib/net/connector/operations/tftp/strategy.rb +77 -0
- data/lib/net/connector/operations/tftp_backup.rb +120 -0
- data/lib/net/connector/operations/topology/cisco.rb +11 -0
- data/lib/net/connector/operations/topology/h3c.rb +11 -0
- data/lib/net/connector/operations/topology/hillstone.rb +11 -0
- data/lib/net/connector/operations/topology/palo_alto.rb +11 -0
- data/lib/net/connector/operations/topology/radware.rb +11 -0
- data/lib/net/connector/operations/topology/strategy.rb +53 -0
- data/lib/net/connector/operations/topology.rb +192 -0
- data/lib/net/connector/operations.rb +20 -0
- data/lib/net/connector/templates/cisco_cdp_neighbors_detail.textfsm +7 -0
- data/lib/net/connector/templates/cisco_ios_running_config_interfaces.textfsm +12 -0
- data/lib/net/connector/templates/cisco_ios_show_ip_interface_brief.textfsm +7 -0
- data/lib/net/connector/templates/h3c_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/h3c_lldp_local_first.textfsm +8 -0
- data/lib/net/connector/templates/h3c_lldp_name_first.textfsm +8 -0
- data/lib/net/connector/templates/hillstone_interface_descriptions.textfsm +11 -0
- data/lib/net/connector/templates/hillstone_lldp_neighbors.textfsm +7 -0
- data/lib/net/connector/templates/index +8 -0
- data/lib/net/connector/templates/palo_alto_interface_descriptions.textfsm +6 -0
- data/lib/net/connector/templates/palo_alto_lldp_neighbors.textfsm +11 -0
- data/lib/net/connector/templates/radware_port_names.textfsm +13 -0
- data/lib/net/connector/vendor/cisco_ios/running_config.rb +19 -0
- data/lib/net/connector/vendor/cisco_ios/tftp_backup.rb +32 -0
- data/lib/net/connector/vendor/cisco_ios/topology.rb +33 -0
- data/lib/net/connector/vendor/cisco_ios.rb +48 -0
- data/lib/net/connector/vendor/cisco_nxos/running_config.rb +22 -0
- data/lib/net/connector/vendor/cisco_nxos/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/cisco_nxos.rb +55 -0
- data/lib/net/connector/vendor/h3c/tftp_backup.rb +43 -0
- data/lib/net/connector/vendor/h3c/topology.rb +56 -0
- data/lib/net/connector/vendor/h3c.rb +77 -0
- data/lib/net/connector/vendor/h3c_wireless.rb +14 -0
- data/lib/net/connector/vendor/hillstone/running_config.rb +13 -0
- data/lib/net/connector/vendor/hillstone/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/hillstone/topology.rb +48 -0
- data/lib/net/connector/vendor/hillstone.rb +43 -0
- data/lib/net/connector/vendor/huawei/tftp_backup.rb +34 -0
- data/lib/net/connector/vendor/huawei.rb +64 -0
- data/lib/net/connector/vendor/palo_alto/running_config.rb +70 -0
- data/lib/net/connector/vendor/palo_alto/tftp_backup.rb +36 -0
- data/lib/net/connector/vendor/palo_alto/topology.rb +63 -0
- data/lib/net/connector/vendor/palo_alto.rb +43 -0
- data/lib/net/connector/vendor/radware/tftp_backup.rb +40 -0
- data/lib/net/connector/vendor/radware/topology.rb +14 -0
- data/lib/net/connector/vendor/radware.rb +62 -0
- data/lib/net/connector/version.rb +7 -0
- data/lib/net/connector.rb +48 -0
- data/lib/net-connector.rb +3 -0
- 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,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
|