net-connector 0.17.0 → 0.17.2
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 +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +4 -0
- data/lib/net/connector/config.rb +10 -347
- data/lib/net/connector/engine/known_hosts.rb +1 -1
- data/lib/net/connector/netdisco/cli/options.rb +5 -15
- data/lib/net/connector/netdisco/cli/setup.rb +2 -1
- data/lib/net/connector/netdisco/config/initializer.rb +7 -55
- data/lib/net/connector/netdisco/report/store.rb +3 -1
- data/lib/net/connector/netdisco/ssh/archive/client.rb +4 -2
- data/lib/net/connector/netdisco/tftp/archive.rb +8 -4
- data/lib/net/connector/storage/batch_directory.rb +3 -2
- data/lib/net/connector/storage/directory.rb +28 -0
- data/lib/net/connector/storage.rb +1 -0
- data/lib/net/connector/vendor/h3c/topology.rb +1 -1
- data/lib/net/connector/vendor/hillstone/tftp.rb +1 -1
- data/lib/net/connector/version.rb +1 -1
- metadata +17 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: aadffbf2db023105e9b507fb732a89bd710207adf28456b2edb5184eb245fedf
|
|
4
|
+
data.tar.gz: 72cc71886fd9c6dce77171af3c8300f5f84af7c7e8192acd335e132c942b2b3c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4d59d23ae05999d469f96837baf6b5f2eca3518104b8769947c36c860788627f3f9cb35c4e05151e364e51f159f0b171905f64fbfd30b963d096bf176cbf35d5
|
|
7
|
+
data.tar.gz: 0b0137d959ecc3be69fd72fbed2bcc562e95c615ab13a786c4485dcf107ff9de30376e2c731051edbac6f297b496be1cdc97c2ec321066c955c9a3a88ef369f6
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# 更新记录
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.17.2 - 2026-10-23
|
|
6
|
+
|
|
7
|
+
- Migrate shared CLI entrypoints to ctx_cli 0.2.0 names without legacy aliases.
|
|
8
|
+
- Synchronize new directory ancestry across batch reports and local/SSH archives; synchronize uploaded-file cleanup.
|
|
9
|
+
- Keep host-key comparison and vendor parsing independent of host field separators.
|
|
10
|
+
- Reuse ctx_cli configuration, initialization, terminal and option boundaries without changing backup/TFTP/topology policies.
|
|
11
|
+
|
|
12
|
+
## 0.17.1 - 2026-10-22
|
|
13
|
+
|
|
14
|
+
- Delegate shared configuration, private initialization, option groups and terminal interaction to ctx_cli 0.1.0.
|
|
15
|
+
|
|
3
16
|
## 0.17.0 - 2026-10-22
|
|
4
17
|
|
|
5
18
|
- Centralize inventory selection and CLI filters in Net::Connector::Inventory; add vendor/model inclusion, exclusion and matching modes.
|
data/README.md
CHANGED
|
@@ -62,3 +62,7 @@ script/ci
|
|
|
62
62
|
The gate scans secrets, lints Ruby and workflows, runs tests and local PTY benchmarks, and verifies isolated gem installs. See [CONTRIBUTING](CONTRIBUTING.md), [security reporting](SECURITY.md), and the [changelog](CHANGELOG.md). Issues and pull requests may be written in English or Chinese.
|
|
63
63
|
|
|
64
64
|
Licensed under [MIT](LICENSE).
|
|
65
|
+
|
|
66
|
+
## ctx_cli 公共入口
|
|
67
|
+
|
|
68
|
+
宿主配置文件读取、私有文件初始化、公共参数与终端交互由 ctx_cli 维护。原 `Net::Connector::Config`、配置路径、优先级和 backup/TFTP/topology 策略继续保留。源码联调使用 `CTX_CLI_PATH=../ctx_cli`;隔离安装可通过 `CTX_CLI_GEM=/absolute/path/ctx_cli-0.1.0.gem` 传入精确候选。
|
data/lib/net/connector/config.rb
CHANGED
|
@@ -1,366 +1,29 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "ctx_cli/configuration"
|
|
4
4
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
7
|
-
#
|
|
8
|
-
class Config
|
|
7
|
+
# 保留原配置入口和路径政策;通用发现、来源与快照由 ctx_cli 维护。
|
|
8
|
+
class Config < CtxCli::Configuration
|
|
9
9
|
NAMES = %w[vxlan wireless backup topology].freeze
|
|
10
10
|
COMMON_KEYS = %w[netdisco inventory ssh].freeze
|
|
11
11
|
CONFIG_ENV = { "vxlan" => "VXLAN_CONFIG", "wireless" => "WIRELESS_CONFIG",
|
|
12
12
|
"backup" => "NC_CONFIG", "topology" => "NC_CONFIG" }.freeze
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
:config_files, :environment_files, :sources
|
|
16
|
-
|
|
17
|
-
def initialize(name:, env: ENV, script: $PROGRAM_NAME, cwd: Dir.pwd, home: Dir.home,
|
|
18
|
-
directories: nil, config: nil, env_file: nil, config_env: nil,
|
|
19
|
-
path_fields: [], env_path_keys: [], path_literals: {})
|
|
20
|
-
@name = name.to_s
|
|
21
|
-
raise ArgumentError, "unsupported configuration name: #{@name}" unless NAMES.include?(@name)
|
|
22
|
-
|
|
23
|
-
@cwd = File.expand_path(cwd)
|
|
24
|
-
@home = File.expand_path(home)
|
|
25
|
-
@config_env = config_env || CONFIG_ENV.fetch(@name)
|
|
26
|
-
@env_path_keys = (env_path_keys.map(&:to_s) + [@config_env]).uniq
|
|
27
|
-
@path_literals = self.class.freeze_tree(self.class.copy(path_literals.transform_keys(&:to_s)))
|
|
28
|
-
@process_environment = self.class.copy(env.to_h)
|
|
29
|
-
resolve_environment_paths!(@process_environment, @cwd)
|
|
30
|
-
self.class.freeze_tree(@process_environment)
|
|
31
|
-
@user_directory = File.join(user_config_home, "net-connector")
|
|
32
|
-
@directories = (directories || ["/etc/net-connector", "/usr/local/etc/net-connector", @user_directory,
|
|
33
|
-
File.dirname(File.expand_path(script, @cwd)), @cwd]).map do |path|
|
|
34
|
-
File.expand_path(path, @cwd)
|
|
35
|
-
end.uniq
|
|
36
|
-
@explicit_config = absolute(config, @cwd)
|
|
37
|
-
@explicit_env_file = absolute(env_file, @cwd)
|
|
38
|
-
@path_fields = path_fields.map { |path| path.to_s.split(".") }
|
|
39
|
-
@data = @specific = @file_environment = @environment = {}.freeze
|
|
40
|
-
@config_files = @environment_files = [].freeze
|
|
41
|
-
@sources = { "configuration_files" => @config_files, "environment_files" => @environment_files, "fields" => {}.freeze }.freeze
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
# Read environment files without mutating ENV or evaluating any YAML.
|
|
45
|
-
def load_environment(allow_missing: false)
|
|
46
|
-
return self if @environment_loaded
|
|
47
|
-
|
|
48
|
-
files = candidates(".env", @explicit_env_file)
|
|
49
|
-
values = {}
|
|
50
|
-
loaded = []
|
|
51
|
-
files.each do |path|
|
|
52
|
-
next unless readable_file?(path, explicit: path == @explicit_env_file, allow_missing: allow_missing)
|
|
53
|
-
|
|
54
|
-
parsed = parse_environment(path, values.merge(@process_environment))
|
|
55
|
-
resolve_environment_paths!(parsed, File.dirname(path))
|
|
56
|
-
values.merge!(parsed)
|
|
57
|
-
loaded << path
|
|
58
|
-
end
|
|
59
|
-
@file_environment = self.class.freeze_tree(values)
|
|
60
|
-
@environment = self.class.freeze_tree(values.merge(self.class.copy(@process_environment)))
|
|
61
|
-
@environment_files = self.class.freeze_tree(loaded)
|
|
62
|
-
# Empty selectors are absent, including an empty original process variable.
|
|
63
|
-
selector = present(@process_environment[@config_env]) || present(values[@config_env])
|
|
64
|
-
@selected_config = @explicit_config || absolute(selector, @cwd)
|
|
65
|
-
@environment_loaded = true
|
|
66
|
-
update_sources({})
|
|
67
|
-
self
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
# Freeze one batch snapshot; repeated calls do not re-read files.
|
|
71
|
-
def load(allow_missing: false)
|
|
72
|
-
return self if @loaded
|
|
73
|
-
|
|
74
|
-
load_environment(allow_missing: allow_missing)
|
|
75
|
-
merged = {}
|
|
76
|
-
specific = {}
|
|
77
|
-
fields = {}
|
|
78
|
-
loaded = []
|
|
79
|
-
@applicable = false
|
|
80
|
-
paths = @directories.flat_map { |directory| [File.join(directory, "net-connector.yml"), File.join(directory, "#{@name}.yml")] }
|
|
81
|
-
paths = final_candidates(paths, @selected_config)
|
|
82
|
-
paths.each do |path|
|
|
83
|
-
next unless readable_file?(path, explicit: path == @selected_config, allow_missing: allow_missing)
|
|
84
|
-
|
|
85
|
-
document = read_yaml(path)
|
|
86
|
-
if File.basename(path) == "net-connector.yml"
|
|
87
|
-
common, native, applicable = shared_sections(document, path)
|
|
88
|
-
merged = merge_layer(merged, common, path, fields)
|
|
89
|
-
@applicable ||= applicable
|
|
90
|
-
else
|
|
91
|
-
native = document
|
|
92
|
-
validate_string_keys!(native, path)
|
|
93
|
-
@applicable = true
|
|
94
|
-
end
|
|
95
|
-
merged = merge_layer(merged, native, path, fields)
|
|
96
|
-
specific = self.class.merge(specific, resolve_paths(native, path))
|
|
97
|
-
loaded << path
|
|
98
|
-
end
|
|
99
|
-
@data = self.class.freeze_tree(merged)
|
|
100
|
-
@specific = self.class.freeze_tree(specific)
|
|
101
|
-
@config_files = self.class.freeze_tree(loaded)
|
|
102
|
-
update_sources(fields)
|
|
103
|
-
@loaded = true
|
|
104
|
-
self
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def config
|
|
108
|
-
@selected_config || @explicit_config || @config_files.last || init_config
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
def env_file
|
|
112
|
-
@explicit_env_file || @environment_files.last || init_env_file
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def init_config
|
|
116
|
-
@selected_config || @explicit_config || File.join(@user_directory, "#{@name}.yml")
|
|
117
|
-
end
|
|
118
|
-
|
|
119
|
-
def init_env_file
|
|
120
|
-
@explicit_env_file || File.join(@user_directory, ".env")
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
def missing?
|
|
124
|
-
!@applicable
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# Serialize native values using the same target-file schema as discovery.
|
|
128
|
-
def self.dump(document, name:, path:)
|
|
14
|
+
def initialize(name:, config_env: nil, **options)
|
|
129
15
|
name = name.to_s
|
|
130
16
|
raise ArgumentError, "unsupported configuration name: #{name}" unless NAMES.include?(name)
|
|
131
17
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
# Recursive, non-destructive replacement: arrays, false and nil are values.
|
|
136
|
-
def self.merge(*hashes)
|
|
137
|
-
hashes.reduce({}) do |result, hash|
|
|
138
|
-
hash.each do |key, value|
|
|
139
|
-
result[copy(key)] = result[key].is_a?(Hash) && value.is_a?(Hash) ? merge(result[key], value) : copy(value)
|
|
140
|
-
end
|
|
141
|
-
result
|
|
142
|
-
end
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def self.copy(value)
|
|
146
|
-
case value
|
|
147
|
-
when Hash then value.to_h { |key, entry| [copy(key), copy(entry)] }
|
|
148
|
-
when Array then value.map { |entry| copy(entry) }
|
|
149
|
-
when String then value.dup
|
|
150
|
-
else value
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def self.freeze_tree(value)
|
|
155
|
-
case value
|
|
156
|
-
when Hash then value.each { |key, entry| freeze_tree(key); freeze_tree(entry) }
|
|
157
|
-
when Array then value.each { |entry| freeze_tree(entry) }
|
|
158
|
-
end
|
|
159
|
-
value.freeze
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
private
|
|
163
|
-
|
|
164
|
-
def present(value)
|
|
165
|
-
value unless value.nil? || value.to_s.strip.empty?
|
|
166
|
-
end
|
|
167
|
-
|
|
168
|
-
def absolute(path, directory)
|
|
169
|
-
File.expand_path(path.to_s, directory) if present(path)
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def user_config_home
|
|
173
|
-
absolute(present(@process_environment["XDG_CONFIG_HOME"]), @cwd) || File.join(@home, ".config")
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
def candidates(filename, explicit)
|
|
177
|
-
final_candidates(@directories.map { |directory| File.join(directory, filename) }, explicit)
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def final_candidates(paths, explicit)
|
|
181
|
-
paths = paths.reject { |path| path == explicit } + [explicit] if explicit
|
|
182
|
-
paths.uniq
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def readable_file?(path, explicit:, allow_missing:)
|
|
186
|
-
unless File.exist?(path)
|
|
187
|
-
raise ArgumentError, "configuration file does not exist: #{path}" if explicit && !allow_missing
|
|
188
|
-
|
|
189
|
-
return false
|
|
190
|
-
end
|
|
191
|
-
return true if File.file?(path) && File.readable?(path)
|
|
192
|
-
|
|
193
|
-
raise ArgumentError, "configuration path is not a readable regular file: #{path}"
|
|
194
|
-
end
|
|
195
|
-
|
|
196
|
-
def read_yaml(path)
|
|
197
|
-
contents = File.read(path)
|
|
198
|
-
validate_yaml_structure!(contents, path)
|
|
199
|
-
document = YAML.safe_load(contents, permitted_classes: [], permitted_symbols: [], aliases: false)
|
|
200
|
-
document = {} if document.nil?
|
|
201
|
-
raise ArgumentError, "configuration must contain a mapping: #{path}" unless document.is_a?(Hash)
|
|
202
|
-
|
|
203
|
-
document
|
|
204
|
-
rescue Psych::Exception, SystemCallError, IOError => error
|
|
205
|
-
raise ArgumentError, "cannot read configuration file: #{path} (#{error.class})", cause: nil
|
|
206
|
-
end
|
|
207
|
-
|
|
208
|
-
# Overrides are explicit file layers; one file cannot hide duplicate keys
|
|
209
|
-
# or silently discard later documents before the tool validates its scope.
|
|
210
|
-
def validate_yaml_structure!(contents, path)
|
|
211
|
-
stream = YAML.parse_stream(contents)
|
|
212
|
-
raise ArgumentError, "configuration must contain a single YAML document: #{path}" if stream.children.size > 1
|
|
213
|
-
|
|
214
|
-
stream.each do |node|
|
|
215
|
-
next unless node.is_a?(Psych::Nodes::Mapping)
|
|
216
|
-
|
|
217
|
-
keys = {}
|
|
218
|
-
node.children.each_slice(2) do |key, _|
|
|
219
|
-
next unless key.is_a?(Psych::Nodes::Scalar)
|
|
220
|
-
if keys.key?(key.value)
|
|
221
|
-
raise ArgumentError, "duplicate configuration key at line #{key.start_line + 1}: #{path}"
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
keys[key.value] = true
|
|
225
|
-
end
|
|
226
|
-
end
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def validate_string_keys!(value, path)
|
|
230
|
-
case value
|
|
231
|
-
when Hash
|
|
232
|
-
raise ArgumentError, "configuration keys must be strings: #{path}" unless value.keys.all?(String)
|
|
233
|
-
|
|
234
|
-
value.each_value { |entry| validate_string_keys!(entry, path) }
|
|
235
|
-
when Array
|
|
236
|
-
value.each { |entry| validate_string_keys!(entry, path) }
|
|
237
|
-
end
|
|
18
|
+
super(name: name, config_env: config_env || CONFIG_ENV.fetch(name), namespace: "net-connector",
|
|
19
|
+
names: NAMES, common_keys: COMMON_KEYS, **options)
|
|
238
20
|
end
|
|
239
21
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
raise ArgumentError, "
|
|
243
|
-
|
|
244
|
-
common = document.fetch("common", {})
|
|
245
|
-
native = document.fetch(@name, {})
|
|
246
|
-
unless common.is_a?(Hash) && native.is_a?(Hash)
|
|
247
|
-
raise ArgumentError, "common and #{@name} configuration must contain mappings: #{path}"
|
|
248
|
-
end
|
|
249
|
-
unknown_common = common.keys - COMMON_KEYS
|
|
250
|
-
unless unknown_common.empty?
|
|
251
|
-
raise ArgumentError, "unsupported common configuration fields in #{path}: #{unknown_common.join(", ")}"
|
|
252
|
-
end
|
|
253
|
-
validate_string_keys!(common, path)
|
|
254
|
-
validate_string_keys!(native, path)
|
|
255
|
-
[common, native, document.key?(@name)]
|
|
256
|
-
end
|
|
257
|
-
|
|
258
|
-
def resolve_paths(document, path)
|
|
259
|
-
result = self.class.copy(document)
|
|
260
|
-
@path_fields.each { |segments| resolve_path!(result, segments, File.dirname(path), segments.join(".")) }
|
|
261
|
-
result
|
|
262
|
-
end
|
|
263
|
-
|
|
264
|
-
def resolve_path!(value, segments, directory, field)
|
|
265
|
-
return unless value.is_a?(Hash) && !segments.empty?
|
|
266
|
-
|
|
267
|
-
key, *rest = segments
|
|
268
|
-
keys = key == "*" ? value.keys : [key]
|
|
269
|
-
keys.each do |entry|
|
|
270
|
-
next unless value.key?(entry)
|
|
271
|
-
|
|
272
|
-
if rest.empty?
|
|
273
|
-
value[entry] = declared_path(value[entry], directory, field) if value[entry].is_a?(String) && !value[entry].empty?
|
|
274
|
-
else
|
|
275
|
-
resolve_path!(value[entry], rest, directory, field)
|
|
276
|
-
end
|
|
277
|
-
end
|
|
278
|
-
end
|
|
279
|
-
|
|
280
|
-
# Declared paths may identify remote archive roots as well as local files.
|
|
281
|
-
def declared_path(value, directory, field)
|
|
282
|
-
raise ArgumentError if value.to_s.include?("\0")
|
|
283
|
-
|
|
284
|
-
return value if @path_literals.fetch(field, []).include?(value)
|
|
285
|
-
|
|
286
|
-
value.to_s.match?(%r{\A[A-Za-z][A-Za-z0-9+.-]*://}) ? value : absolute(value, directory)
|
|
287
|
-
rescue ArgumentError
|
|
288
|
-
raise ArgumentError, "invalid path #{field}", cause: nil
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
def resolve_environment_paths!(values, directory)
|
|
292
|
-
@env_path_keys.each do |key|
|
|
293
|
-
values[key] = declared_path(values[key], directory, key) if present(values[key])
|
|
294
|
-
end
|
|
295
|
-
end
|
|
296
|
-
|
|
297
|
-
def merge_layer(previous, document, path, fields)
|
|
298
|
-
resolved = resolve_paths(document, path)
|
|
299
|
-
record_fields(resolved, path, fields, previous: previous)
|
|
300
|
-
self.class.merge(previous, resolved)
|
|
301
|
-
end
|
|
302
|
-
|
|
303
|
-
def record_fields(document, source, fields, prefix = nil, previous: {})
|
|
304
|
-
document.each do |key, value|
|
|
305
|
-
name = [prefix, key].compact.join(".")
|
|
306
|
-
before = previous.is_a?(Hash) ? previous[key] : nil
|
|
307
|
-
next if value.is_a?(Hash) && value.empty? && before.is_a?(Hash) && !before.empty?
|
|
308
|
-
|
|
309
|
-
if value.is_a?(Hash) && !value.empty?
|
|
310
|
-
fields.delete(name)
|
|
311
|
-
record_fields(value, source, fields, name, previous: before)
|
|
312
|
-
else
|
|
313
|
-
fields.delete_if { |field, _| field == name || field.start_with?("#{name}.") }
|
|
314
|
-
fields[name] = source
|
|
315
|
-
end
|
|
316
|
-
end
|
|
317
|
-
end
|
|
318
|
-
|
|
319
|
-
def update_sources(fields)
|
|
320
|
-
@sources = self.class.freeze_tree({ "configuration_files" => @config_files,
|
|
321
|
-
"environment_files" => @environment_files, "fields" => fields })
|
|
322
|
-
end
|
|
323
|
-
|
|
324
|
-
# Dotenv's grammar is reused, but substitutions are deliberately isolated:
|
|
325
|
-
# its default implementation reads real ENV and executes shell commands.
|
|
326
|
-
def parse_environment(path, inherited)
|
|
327
|
-
require "dotenv"
|
|
328
|
-
process = @process_environment
|
|
329
|
-
variable_pattern = Dotenv::Substitutions::Variable.singleton_class.const_get(:VARIABLE)
|
|
330
|
-
parser_class = Class.new(Dotenv::Parser) do
|
|
331
|
-
define_singleton_method(:substitutions) do
|
|
332
|
-
[lambda do |value, local|
|
|
333
|
-
raise ArgumentError, "command substitution is unsupported" if value.include?("$(")
|
|
22
|
+
# 共享文件按工具分区;各专用 YAML 保持原有字段布局。
|
|
23
|
+
def self.dump(document, name:, path:)
|
|
24
|
+
raise ArgumentError, "unsupported configuration name: #{name}" unless NAMES.include?(name.to_s)
|
|
334
25
|
|
|
335
|
-
|
|
336
|
-
match = Regexp.last_match
|
|
337
|
-
if match[1] == "\\"
|
|
338
|
-
variable[1..]
|
|
339
|
-
elsif match[3]
|
|
340
|
-
process.fetch(match[3]) { local.fetch(match[3]) { inherited.fetch(match[3], "") } }
|
|
341
|
-
else
|
|
342
|
-
variable
|
|
343
|
-
end
|
|
344
|
-
end
|
|
345
|
-
end]
|
|
346
|
-
end
|
|
347
|
-
define_method(:expand_newlines) do |value|
|
|
348
|
-
if (@hash["DOTENV_LINEBREAK_MODE"] || inherited["DOTENV_LINEBREAK_MODE"]) == "legacy"
|
|
349
|
-
value.gsub('\\n', "\n").gsub('\\r', "\r")
|
|
350
|
-
else
|
|
351
|
-
value.gsub('\\n', "\\\\\\n").gsub('\\r', "\\\\\\r")
|
|
352
|
-
end
|
|
353
|
-
end
|
|
354
|
-
private :expand_newlines
|
|
355
|
-
end
|
|
356
|
-
contents = File.read(path)
|
|
357
|
-
remainder = contents.gsub(Dotenv::Parser::LINE, "")
|
|
358
|
-
unless remainder.lines.all? { |line| line.strip.empty? || line.lstrip.start_with?("#") }
|
|
359
|
-
raise ArgumentError, "invalid environment syntax"
|
|
360
|
-
end
|
|
361
|
-
parser_class.call(contents, overwrite: true)
|
|
362
|
-
rescue Dotenv::FormatError, ArgumentError, SystemCallError, IOError => error
|
|
363
|
-
raise ArgumentError, "cannot read environment file: #{path} (#{error.class})"
|
|
26
|
+
super(document, name: name, path: path, namespace: "net-connector")
|
|
364
27
|
end
|
|
365
28
|
end
|
|
366
29
|
end
|
|
@@ -159,7 +159,7 @@ module Net
|
|
|
159
159
|
output, status = Open3.capture2e("ssh-keygen", "-f", path, "-F", @host)
|
|
160
160
|
raise IOError, "reading device host key failed" unless [0, 1].include?(status.exitstatus)
|
|
161
161
|
|
|
162
|
-
output.lines("\n").reject { |line| line.start_with?("#") }.map { |line| line.split[1, 2] }.uniq
|
|
162
|
+
output.lines("\n").reject { |line| line.start_with?("#") }.map { |line| line.split(" ")[1, 2] }.uniq
|
|
163
163
|
end
|
|
164
164
|
|
|
165
165
|
# 从指定快照中移除当前主机条目,不直接修改共享信任文件。
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "optparse"
|
|
4
|
+
require "ctx_cli/options"
|
|
5
|
+
require "ctx_cli/terminal"
|
|
4
6
|
require_relative "../settings"
|
|
5
7
|
require_relative "../planner"
|
|
6
8
|
require_relative "../config"
|
|
@@ -66,13 +68,9 @@ module Net
|
|
|
66
68
|
def self.add_terminal_options(opts, values)
|
|
67
69
|
opts.on("--ask-password", "隐藏输入设备密码") { values[:ask_password] = true }
|
|
68
70
|
opts.on("--ask-netdisco-password", "隐藏输入 Netdisco 密码") { values[:ask_netdisco_password] = true }
|
|
69
|
-
|
|
70
|
-
opts.on("--verbose", "显示逐条登录和命令事件") { values[:verbose] = true }
|
|
71
|
-
opts.on("--json", "在 stdout 输出 JSON;默认显示可读摘要") { values[:json] = true }
|
|
71
|
+
CtxCli::Options.output(opts, values, quiet: :no_progress)
|
|
72
72
|
opts.on("--stdin-credentials", "从标准输入读取凭据并等待 RUN") { values[:stdin_credentials] = true }
|
|
73
|
-
|
|
74
|
-
opts.on("--interactive", "缺少文件时询问是否创建,并可交互填写") { values[:interactive] = true }
|
|
75
|
-
opts.on("--non-interactive", "禁用问答;创建文件须同时指定 --init") { values[:interactive] = false }
|
|
73
|
+
CtxCli::Options.initialization(opts, values)
|
|
76
74
|
end
|
|
77
75
|
|
|
78
76
|
# 登记互斥的拓扑执行模式和格式化参数,应用模式必须显式选择。
|
|
@@ -257,15 +255,7 @@ module Net
|
|
|
257
255
|
return unless values[flag]
|
|
258
256
|
|
|
259
257
|
raise ArgumentError, "密码提示与显式密码不能同时使用" if values[:environment].key?(key)
|
|
260
|
-
|
|
261
|
-
require "io/console"
|
|
262
|
-
output.print "#{label}:"
|
|
263
|
-
output.flush
|
|
264
|
-
password = input.noecho { |terminal| terminal.gets("\n") }&.chomp("\n")
|
|
265
|
-
output.puts
|
|
266
|
-
raise ArgumentError, "密码不能为空" if password.nil? || password.empty?
|
|
267
|
-
|
|
268
|
-
values[:environment][key] = password
|
|
258
|
+
values[:environment][key] = CtxCli::Terminal.new(input: input, out: output).password(label: label)
|
|
269
259
|
end
|
|
270
260
|
private_class_method :read_password, :parser_for
|
|
271
261
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "ctx_cli/terminal"
|
|
4
5
|
require_relative "../config/initializer"
|
|
5
6
|
require_relative "../config/defaults"
|
|
6
7
|
|
|
@@ -98,7 +99,7 @@ module Net
|
|
|
98
99
|
|
|
99
100
|
# 标准输入凭据协议独占输入;只有真实终端才允许配置问答。
|
|
100
101
|
def terminal?
|
|
101
|
-
!@options[:stdin_credentials] &&
|
|
102
|
+
!@options[:stdin_credentials] && CtxCli::Terminal.tty?(@input, @output)
|
|
102
103
|
end
|
|
103
104
|
|
|
104
105
|
# 创建和定制为独立选择;显式 --init 默认直接生成已识别值。
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "yaml"
|
|
4
|
-
require "
|
|
4
|
+
require "ctx_cli/initializer"
|
|
5
|
+
require "ctx_cli/terminal"
|
|
5
6
|
require_relative "../settings"
|
|
6
7
|
require_relative "../../config"
|
|
7
8
|
|
|
@@ -14,6 +15,7 @@ module Net
|
|
|
14
15
|
# 绑定配置问答的输入输出,保留调用方对 IO 的所有权。
|
|
15
16
|
def initialize(input: $stdin, output: $stderr)
|
|
16
17
|
@input, @output = input, output
|
|
18
|
+
@writer = CtxCli::Initializer.new(prefix: ".net-connector-config-")
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
# 完成默认值校验与可选问答后独占发布新配置,不覆盖已存在文件。
|
|
@@ -26,7 +28,7 @@ module Net
|
|
|
26
28
|
|
|
27
29
|
values = YAML.safe_load(YAML.dump(document), aliases: false)
|
|
28
30
|
validate!(values)
|
|
29
|
-
interactive =
|
|
31
|
+
interactive = CtxCli::Terminal.tty?(@input, @output) if interactive.nil?
|
|
30
32
|
configure(values, mode: mode, inherited_parallel: inherited_parallel) if interactive && answer("进入交互配置?[y/N]", "n").casecmp?("y")
|
|
31
33
|
validate!(values)
|
|
32
34
|
name = mode == :topology ? "topology" : "backup"
|
|
@@ -43,14 +45,7 @@ module Net
|
|
|
43
45
|
private
|
|
44
46
|
|
|
45
47
|
# 判断已有配置是否为普通文件;符号链接或其他类型不作为可用配置。
|
|
46
|
-
def existing?(path)
|
|
47
|
-
stat = File.lstat(path)
|
|
48
|
-
raise ArgumentError, "config path must be a regular file" unless stat.file?
|
|
49
|
-
|
|
50
|
-
true
|
|
51
|
-
rescue Errno::ENOENT
|
|
52
|
-
false
|
|
53
|
-
end
|
|
48
|
+
def existing?(path) = @writer.existing?(path)
|
|
54
49
|
|
|
55
50
|
# 通过完整策略快照校验候选文档,拒绝生成不可执行的配置。
|
|
56
51
|
def validate!(document)
|
|
@@ -118,51 +113,8 @@ module Net
|
|
|
118
113
|
end
|
|
119
114
|
end
|
|
120
115
|
|
|
121
|
-
#
|
|
122
|
-
def
|
|
123
|
-
Thread.handle_interrupt(Exception => :never) do
|
|
124
|
-
error = nil
|
|
125
|
-
[-> { parent&.close }, -> { file&.close! }].each do |close|
|
|
126
|
-
begin
|
|
127
|
-
close.call
|
|
128
|
-
rescue Exception => failure # rubocop:disable Lint/RescueException -- 两个资源均尝试关闭,保留首个故障。
|
|
129
|
-
error ||= failure
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
raise error if error && !primary
|
|
133
|
-
end
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
# 先写入并同步私有临时文件,再以独占硬链接发布完整配置并同步目录。
|
|
137
|
-
# 最终路径只在内容写全并同步后出现;已有目标由独占链接保护,取消也不能留下半份配置。
|
|
138
|
-
def write(path, contents)
|
|
139
|
-
directory = File.realpath(File.dirname(File.expand_path(path)))
|
|
140
|
-
destination = File.join(directory, File.basename(path))
|
|
141
|
-
file = parent = nil
|
|
142
|
-
begin
|
|
143
|
-
Thread.handle_interrupt(Exception => :never) { file = Tempfile.new([".net-connector-config-", ".tmp"], directory) }
|
|
144
|
-
file.binmode
|
|
145
|
-
file.chmod(0o600)
|
|
146
|
-
file.write(contents)
|
|
147
|
-
file.flush
|
|
148
|
-
file.fsync
|
|
149
|
-
begin
|
|
150
|
-
File.link(file.path, destination)
|
|
151
|
-
rescue Errno::EEXIST
|
|
152
|
-
return :exists if existing?(destination)
|
|
153
|
-
|
|
154
|
-
raise
|
|
155
|
-
end
|
|
156
|
-
Thread.handle_interrupt(Exception => :never) { parent = File.open(directory, File::RDONLY | File::NOFOLLOW) }
|
|
157
|
-
parent.fsync
|
|
158
|
-
:created
|
|
159
|
-
rescue Exception => failure # rubocop:disable Lint/RescueException -- 清理不能覆盖输入取消或发布故障。
|
|
160
|
-
primary = failure
|
|
161
|
-
raise
|
|
162
|
-
ensure
|
|
163
|
-
close_files(file, parent, primary)
|
|
164
|
-
end
|
|
165
|
-
end
|
|
116
|
+
# 公开文档已校验;排他发布、同步与清理由公共初始化器负责。
|
|
117
|
+
def write(path, contents) = @writer.create(path: path, contents: contents)
|
|
166
118
|
end
|
|
167
119
|
end
|
|
168
120
|
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../../storage/directory"
|
|
4
|
+
|
|
3
5
|
require "json"
|
|
4
6
|
require_relative "../report"
|
|
5
7
|
require "fileutils"
|
|
@@ -17,7 +19,7 @@ module Net
|
|
|
17
19
|
# 以临时文件和重命名原子写入私有 JSON 报告。
|
|
18
20
|
def write(report, directory:)
|
|
19
21
|
destination = File.expand_path(directory)
|
|
20
|
-
|
|
22
|
+
Storage::Directory.prepare(destination)
|
|
21
23
|
filename = "netdisco-#{report.mode}-#{report.started_at.strftime("%Y%m%dT%H%M%SZ")}-#{SecureRandom.hex(4)}.json"
|
|
22
24
|
path = File.join(destination, filename)
|
|
23
25
|
Storage::AtomicFile.write(path, JSON.pretty_generate(report.summary)).path
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../../../storage/directory"
|
|
4
|
+
|
|
3
5
|
require "digest"
|
|
4
6
|
require "json"
|
|
5
7
|
require "open3"
|
|
@@ -68,7 +70,7 @@ module Net
|
|
|
68
70
|
end
|
|
69
71
|
@password = password&.dup&.freeze
|
|
70
72
|
@report_directory = File.expand_path(directory)
|
|
71
|
-
|
|
73
|
+
Storage::Directory.prepare(@report_directory)
|
|
72
74
|
@batch = "#{Time.now.getlocal("+08:00").strftime("%Y-%m-%d_%H-%M-%S")}-ssh-#{SecureRandom.hex(8)}"
|
|
73
75
|
@launcher = launcher || method(:open_ssh)
|
|
74
76
|
@custom_launcher = !launcher.nil?
|
|
@@ -179,7 +181,7 @@ module Net
|
|
|
179
181
|
archive_path: remote_path(proof.fetch("archive_path")))
|
|
180
182
|
outcome = outcome.with(backup: receipt)
|
|
181
183
|
path = File.join(@report_directory, "tftp", archive_name)
|
|
182
|
-
|
|
184
|
+
Storage::Directory.prepare(File.dirname(path))
|
|
183
185
|
Storage::AtomicFile.write(path, bytes)
|
|
184
186
|
outcome = outcome.with(backup: receipt.with(local_path: path))
|
|
185
187
|
raise Tftp::Archive::Failure unless Storage::FileReader.fingerprint(path).sha256 == digest
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../../storage/directory"
|
|
4
|
+
|
|
3
5
|
require "fileutils"
|
|
4
6
|
require "tempfile"
|
|
5
7
|
require "digest"
|
|
@@ -50,7 +52,7 @@ module Net
|
|
|
50
52
|
def initialize(directory:, root: nil)
|
|
51
53
|
@report_directory = File.expand_path(directory)
|
|
52
54
|
@server_root = root && File.realpath(root)
|
|
53
|
-
|
|
55
|
+
Storage::Directory.prepare(@report_directory)
|
|
54
56
|
@server_archive_directory = create_server_archive_directory if @server_root
|
|
55
57
|
@remote_suffix = reserve_remote_suffix unless @server_root
|
|
56
58
|
@verifier = Tftp::Verifier.new(root: @server_root)
|
|
@@ -113,7 +115,7 @@ module Net
|
|
|
113
115
|
return unless bytes
|
|
114
116
|
|
|
115
117
|
previous_directory = File.join(@server_archive_directory, "previous")
|
|
116
|
-
|
|
118
|
+
Storage::Directory.prepare(previous_directory)
|
|
117
119
|
archive_path = File.join(previous_directory, archive_filename(device, File.extname(remote_filename)))
|
|
118
120
|
Storage::AtomicFile.write(archive_path, bytes)
|
|
119
121
|
previous
|
|
@@ -126,7 +128,7 @@ module Net
|
|
|
126
128
|
parent = File.join(@server_root, "archive")
|
|
127
129
|
raise ArgumentError, "TFTP archive directory must not be a symlink" if File.symlink?(parent)
|
|
128
130
|
|
|
129
|
-
|
|
131
|
+
Storage::Directory.prepare(parent)
|
|
130
132
|
raise ArgumentError, "TFTP archive directory escaped its root" unless File.realpath(parent) == parent
|
|
131
133
|
|
|
132
134
|
base = batch_name
|
|
@@ -136,6 +138,7 @@ module Net
|
|
|
136
138
|
path = File.join(parent, candidate)
|
|
137
139
|
begin
|
|
138
140
|
Dir.mkdir(path, 0o700)
|
|
141
|
+
Storage::Directory.sync(parent)
|
|
139
142
|
return path
|
|
140
143
|
rescue Errno::EEXIST
|
|
141
144
|
number += 1
|
|
@@ -171,7 +174,7 @@ module Net
|
|
|
171
174
|
receipt = outcome.backup
|
|
172
175
|
saved_paths = { local_path: nil, archive_path: nil }
|
|
173
176
|
batch_directory = File.join(@report_directory, "tftp")
|
|
174
|
-
|
|
177
|
+
Storage::Directory.prepare(batch_directory)
|
|
175
178
|
extension = File.extname(receipt.path)
|
|
176
179
|
filename = archive_filename(outcome.device, extension)
|
|
177
180
|
batch_archive_path = File.join(batch_directory, filename)
|
|
@@ -192,6 +195,7 @@ module Net
|
|
|
192
195
|
raise Failure.new(host: outcome.device.host) unless Storage::FileReader.same_entry?(uploaded_path, uploaded_fingerprint)
|
|
193
196
|
|
|
194
197
|
File.unlink(uploaded_path)
|
|
198
|
+
Storage::Directory.sync(File.dirname(uploaded_path))
|
|
195
199
|
end
|
|
196
200
|
outcome.with(backup: receipt.with(**saved_paths))
|
|
197
201
|
rescue StandardError => error
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "directory"
|
|
4
4
|
|
|
5
5
|
module Net
|
|
6
6
|
module Connector
|
|
@@ -9,7 +9,7 @@ module Net
|
|
|
9
9
|
class BatchDirectory
|
|
10
10
|
# 固定 UTC+8,不依赖运行主机时区;mkdir 原子防止并发任务覆盖。
|
|
11
11
|
def self.create(root, time: Time.now)
|
|
12
|
-
|
|
12
|
+
Directory.prepare(root)
|
|
13
13
|
stamp = time.getlocal("+08:00").strftime("%Y-%m-%d_%H-%M-%S")
|
|
14
14
|
suffix = 0
|
|
15
15
|
loop do
|
|
@@ -17,6 +17,7 @@ module Net
|
|
|
17
17
|
path = File.join(root, name)
|
|
18
18
|
begin
|
|
19
19
|
Dir.mkdir(path, 0o700)
|
|
20
|
+
Directory.sync(root)
|
|
20
21
|
return path
|
|
21
22
|
rescue Errno::EEXIST
|
|
22
23
|
suffix += 1
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Net
|
|
6
|
+
module Connector
|
|
7
|
+
module Storage
|
|
8
|
+
# 新建目录与已有目录的文件发布共用持久化边界。
|
|
9
|
+
module Directory
|
|
10
|
+
def self.prepare(path)
|
|
11
|
+
path = File.expand_path(path)
|
|
12
|
+
missing, parent = [], path
|
|
13
|
+
until File.directory?(parent)
|
|
14
|
+
missing << parent
|
|
15
|
+
parent = File.dirname(parent)
|
|
16
|
+
end
|
|
17
|
+
FileUtils.mkdir_p(path, mode: 0o700)
|
|
18
|
+
missing.reverse_each { |created| sync(File.dirname(created)) }
|
|
19
|
+
path
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.sync(path)
|
|
23
|
+
File.open(File.realpath(path), File::RDONLY | File::NOFOLLOW) { |directory| directory.fsync }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -4,6 +4,7 @@ module Net
|
|
|
4
4
|
module Connector
|
|
5
5
|
# 文件存储不依赖设备连接;备份、离线导出和批量报告共享这些原语。
|
|
6
6
|
module Storage
|
|
7
|
+
autoload :Directory, File.expand_path("storage/directory", __dir__)
|
|
7
8
|
autoload :BatchDirectory, File.expand_path("storage/batch_directory", __dir__)
|
|
8
9
|
autoload :BackupLock, File.expand_path("storage/backup_lock", __dir__)
|
|
9
10
|
autoload :AtomicFile, File.expand_path("storage/atomic_file", __dir__)
|
|
@@ -43,7 +43,7 @@ module Net
|
|
|
43
43
|
local_column = output.match?(/^[ \t]*System Name[ \t]+Local Interface\b/i) ? 1 : 0
|
|
44
44
|
lines = neighbor_lines(output)
|
|
45
45
|
return unless lines.all? do |line|
|
|
46
|
-
interface = line.split.fetch(local_column, nil)
|
|
46
|
+
interface = line.split(" ").fetch(local_column, nil)
|
|
47
47
|
interface&.match?(/\A(?:[A-Za-z][A-Za-z-]*\d+(?:\/\d+)+|(?:Bridge-Aggregation|BAGG|Route-Aggregation|RAGG)\d+)(?:[:.]\d+)*\z/i)
|
|
48
48
|
end
|
|
49
49
|
|
metadata
CHANGED
|
@@ -1,14 +1,28 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: net-connector
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.17.
|
|
4
|
+
version: 0.17.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- net-connector contributors
|
|
8
8
|
bindir: bin
|
|
9
9
|
cert_chain: []
|
|
10
|
-
date: 2026-10-
|
|
10
|
+
date: 2026-10-22 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: ctx_cli
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.2.0
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.2.0
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: expect-pty
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -477,6 +491,7 @@ files:
|
|
|
477
491
|
- lib/net/connector/storage/atomic_file.rb
|
|
478
492
|
- lib/net/connector/storage/backup_lock.rb
|
|
479
493
|
- lib/net/connector/storage/batch_directory.rb
|
|
494
|
+
- lib/net/connector/storage/directory.rb
|
|
480
495
|
- lib/net/connector/storage/file_reader.rb
|
|
481
496
|
- lib/net/connector/storage/saved_config.rb
|
|
482
497
|
- lib/net/connector/textfsm.rb
|