vagrant-docker-hosts-manager 0.2.0 → 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.
@@ -1,11 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "base64"
3
4
  require "tempfile"
4
5
  require "time"
5
6
  require "open3"
6
7
 
7
8
  require_relative "../helpers"
8
9
  require_relative "docker"
10
+ require_relative "verbose"
9
11
 
10
12
  module VagrantDockerHostsManager
11
13
  module Util
@@ -15,6 +17,10 @@ module VagrantDockerHostsManager
15
17
  WIN_SYSNATIVE_PATH = "C:/Windows/Sysnative/drivers/etc/hosts"
16
18
  WIN_SYSWOW64_PATH = "C:/Windows/SysWOW64/drivers/etc/hosts"
17
19
 
20
+ BLOCK_START_RE = /^\s*#\s*>>>\s*vagrant-docker-hosts-manager\s+(.+?)\s*\(managed\)\s*>>>\s*$/i
21
+ BLOCK_STOP_RE = /^\s*#\s*<<<\s*vagrant-docker-hosts-manager\s+(.+?)\s*\(managed\)\s*<<<\s*$/i
22
+ ENTRY_RE = /\A\s*(\d{1,3}(?:\.\d{1,3}){3})\s+([^\s#]+)/
23
+
18
24
  def initialize(env, owner_id:)
19
25
  @env = env || {}
20
26
  @owner_id = owner_id.to_s
@@ -27,8 +33,13 @@ module VagrantDockerHostsManager
27
33
  end
28
34
 
29
35
  def path_candidates
30
- return [POSIX_PATH] unless Gem.win_platform?
36
+ # VDHM_HOSTS_PATH overrides the default location on every platform
37
+ # (used by tests and power users); honor it before anything else.
31
38
  return [override_path] if override_path
39
+ return [POSIX_PATH] unless Gem.win_platform?
40
+
41
+ # Sysnative lets a 32-bit Ruby process reach the real System32 hosts
42
+ # file on 64-bit Windows; keep it before System32/SysWOW64.
32
43
  [WIN_SYSNATIVE_PATH, WIN_SYS32_PATH, WIN_SYSWOW64_PATH]
33
44
  end
34
45
 
@@ -62,7 +73,11 @@ module VagrantDockerHostsManager
62
73
 
63
74
  def compose_block(entries, newline: "\n")
64
75
  start, stop = block_markers
65
- ts = (Time.now.utc.iso8601 rescue Time.now.utc.to_s)
76
+ ts = begin
77
+ Time.now.utc.iso8601
78
+ rescue StandardError
79
+ Time.now.utc.to_s
80
+ end
66
81
 
67
82
  header = [
68
83
  start,
@@ -98,11 +113,20 @@ module VagrantDockerHostsManager
98
113
  str.end_with?(nl) ? str : (str + nl)
99
114
  end
100
115
 
116
+ # Applies managed host entries to the hosts file.
117
+ #
118
+ # Rewrites only this plugin's managed block and preserves unmanaged lines.
119
+ # Entries are normalized and sorted so repeated runs converge to the same
120
+ # file content.
121
+ #
122
+ # @param entries [Hash{String=>String}] Mapping of FQDN to IP address.
123
+ # @return [Integer] Number of managed entries present after applying changes.
124
+ # @raise [RuntimeError] When elevated writes fail.
101
125
  def apply(entries)
102
126
  entries = normalize_entries(entries)
103
127
  if entries.empty?
104
128
  UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
105
- ::I18n.t("messages.no_entries", default: "No hosts entries configured."))
129
+ ::I18n.t("vdhm.messages.no_entries", default: "No hosts entries configured."))
106
130
  return 0
107
131
  end
108
132
 
@@ -132,7 +156,7 @@ module VagrantDockerHostsManager
132
156
 
133
157
  if added.empty? && updated.empty?
134
158
  UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
135
- ::I18n.t("messages.no_change", default: "Nothing to apply. Already up-to-date."))
159
+ ::I18n.t("vdhm.messages.no_change", default: "Nothing to apply. Already up-to-date."))
136
160
  return existing_map.size
137
161
  end
138
162
 
@@ -141,14 +165,21 @@ module VagrantDockerHostsManager
141
165
  write(content)
142
166
 
143
167
  UiHelpers.say(@ui, "#{UiHelpers.e(:success)} " +
144
- ::I18n.t("messages.applied", default: "Hosts entries applied."))
168
+ ::I18n.t("vdhm.messages.applied", default: "Hosts entries applied."))
145
169
  UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
146
- ::I18n.t("messages.apply_summary",
170
+ ::I18n.t("vdhm.messages.apply_summary",
147
171
  default: "Added: %{a}, Updated: %{u}, Unchanged: %{s}",
148
172
  a: added.size, u: updated.size, s: unchanged.size))
149
173
  merged.size
150
174
  end
151
175
 
176
+ # Removes matching entries from the current owner's managed block.
177
+ #
178
+ # With no filters this falls back to removing the whole current-owner block.
179
+ #
180
+ # @param ips [Array<String>] IP addresses to remove.
181
+ # @param domains [Array<String>] Domain names to remove.
182
+ # @return [Integer] Number of removed entries, or 1 when a whole block was removed.
152
183
  def remove_entries!(ips: [], domains: [])
153
184
  ips = Array(ips).map(&:to_s).reject(&:empty?)
154
185
  domains = Array(domains).map(&:to_s).reject(&:empty?)
@@ -165,7 +196,7 @@ module VagrantDockerHostsManager
165
196
  removed_count = before - filtered.length
166
197
  if removed_count <= 0
167
198
  UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
168
- ::I18n.t("messages.remove_none",
199
+ ::I18n.t("vdhm.messages.remove_none",
169
200
  default: "No matching entry to remove."))
170
201
  return 0
171
202
  end
@@ -187,7 +218,7 @@ module VagrantDockerHostsManager
187
218
  @ui,
188
219
  "#{UiHelpers.e(:broom)} " +
189
220
  ::I18n.t(
190
- "messages.removed_count",
221
+ "vdhm.messages.removed_count",
191
222
  default: "%{count} entries removed.",
192
223
  count: removed_count
193
224
  )
@@ -195,6 +226,9 @@ module VagrantDockerHostsManager
195
226
  removed_count
196
227
  end
197
228
 
229
+ # Removes the current owner's managed hosts block.
230
+ #
231
+ # @return [Boolean] Whether a block was removed.
198
232
  def remove!
199
233
  content = read
200
234
  newc = remove_block_from(content)
@@ -202,12 +236,44 @@ module VagrantDockerHostsManager
202
236
  write(newc) if removed
203
237
 
204
238
  UiHelpers.say(@ui, removed ?
205
- "#{UiHelpers.e(:broom)} " + ::I18n.t("messages.cleaned", default: "Managed hosts entries removed.") :
206
- "#{UiHelpers.e(:info)} " + ::I18n.t("messages.nothing_to_clean", default: "Nothing to clean."))
239
+ "#{UiHelpers.e(:broom)} " + ::I18n.t("vdhm.messages.cleaned", default: "Managed hosts entries removed.") :
240
+ "#{UiHelpers.e(:info)} " + ::I18n.t("vdhm.messages.nothing_to_clean", default: "Nothing to clean."))
241
+ removed
242
+ end
243
+
244
+ # Removes every block managed by this plugin, regardless of owner.
245
+ #
246
+ # @return [Boolean] Whether any managed block was removed.
247
+ def remove_all_managed!
248
+ content = read
249
+ newc = strip_managed_blocks(content)
250
+ removed = (newc != content)
251
+ write(newc) if removed
252
+
253
+ UiHelpers.say(@ui, removed ?
254
+ "#{UiHelpers.e(:broom)} " + ::I18n.t("vdhm.messages.cleaned_all", default: "All managed hosts blocks removed.") :
255
+ "#{UiHelpers.e(:info)} " + ::I18n.t("vdhm.messages.nothing_to_clean", default: "Nothing to clean."))
207
256
  removed
208
257
  end
209
258
 
259
+ def strip_managed_blocks(content)
260
+ removing = false
261
+ content.lines.reject do |line|
262
+ if line.match?(BLOCK_START_RE)
263
+ removing = true
264
+ true
265
+ elsif line.match?(BLOCK_STOP_RE)
266
+ removing = false
267
+ true
268
+ else
269
+ removing
270
+ end
271
+ end.join
272
+ end
273
+
210
274
  def read
275
+ # Hosts files are often edited by Windows tools with BOMs or legacy
276
+ # encodings; normalize to UTF-8 before parsing managed blocks.
211
277
  pth = real_path
212
278
  UiHelpers.debug(@ui, "read(#{pth})")
213
279
 
@@ -216,7 +282,7 @@ module VagrantDockerHostsManager
216
282
  begin
217
283
  data = File.binread(pth)
218
284
  UiHelpers.debug(@ui, "File.binread ok, bytes=#{data.bytesize}, encoding=#{data.encoding}")
219
- rescue => e
285
+ rescue StandardError => e
220
286
  UiHelpers.debug(@ui, "File.binread error: #{e.class}: #{e.message}")
221
287
  data = nil
222
288
  end
@@ -224,6 +290,7 @@ module VagrantDockerHostsManager
224
290
  if (data.nil? || data.empty?) && Gem.win_platform?
225
291
  ps_path = pth.gsub("'", "''")
226
292
  ps_cmd = "Get-Content -LiteralPath '#{ps_path}' -Raw"
293
+ Verbose.log("powershell -Command #{ps_cmd}")
227
294
  out, err, st = Open3.capture3("powershell", "-NoProfile", "-NonInteractive", "-Command", ps_cmd)
228
295
  if st.success?
229
296
  data = out
@@ -253,7 +320,7 @@ module VagrantDockerHostsManager
253
320
  .force_encoding("Windows-1252")
254
321
  .encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "")
255
322
  UiHelpers.debug(@ui, "Transcoded via Windows-1252 -> UTF-8, encoding=#{data.encoding}")
256
- rescue => e2
323
+ rescue StandardError => e2
257
324
  UiHelpers.debug(@ui, "Windows-1252 fallback failed: #{e2.class}: #{e2.message}")
258
325
  data = data.force_encoding(Encoding::UTF_8)
259
326
  end
@@ -265,34 +332,33 @@ module VagrantDockerHostsManager
265
332
  end
266
333
 
267
334
  data
268
- rescue => e
335
+ rescue StandardError => e
269
336
  UiHelpers.debug(@ui, "read() fatal: #{e.class}: #{e.message}")
270
337
  ""
271
338
  end
272
339
 
273
- def list_pairs(scope = :all)
274
- content = read
275
- return [] if content.to_s.empty?
340
+ def each_managed_entry(scope = :all)
341
+ # A single scanner powers both current-owner and all-owner cleanup paths
342
+ # so marker parsing rules stay identical.
343
+ return enum_for(:each_managed_entry, scope) unless block_given?
276
344
 
277
- start_re = /^\s*#\s*>>>\s*vagrant-docker-hosts-manager\s+(.+?)\s*\(managed\)\s*>>>\s*$/i
278
- stop_re = /^\s*#\s*<<<\s*vagrant-docker-hosts-manager\s+(.+?)\s*\(managed\)\s*<<<\s*$/i
279
- ip_re = /^\s*(\d{1,3}(?:\.\d{1,3}){3})\s+([^\s#]+)/
345
+ content = read
346
+ return if content.to_s.empty?
280
347
 
281
- out = []
282
348
  in_block = false
283
349
  owner = nil
284
350
 
285
351
  content.each_line.with_index(1) do |raw, idx|
286
352
  line = raw.delete_suffix("\n").delete_suffix("\r")
287
353
 
288
- if (m = line.match(start_re))
354
+ if (m = line.match(BLOCK_START_RE))
289
355
  in_block = true
290
356
  owner = m[1].to_s.strip
291
357
  UiHelpers.debug(@ui, "start block(owner=#{owner}) at line #{idx}")
292
358
  next
293
359
  end
294
360
 
295
- if (m = line.match(stop_re))
361
+ if line.match?(BLOCK_STOP_RE)
296
362
  UiHelpers.debug(@ui, "stop block(owner=#{owner}) at line #{idx}") if in_block
297
363
  in_block = false
298
364
  owner = nil
@@ -302,103 +368,25 @@ module VagrantDockerHostsManager
302
368
  next unless in_block
303
369
  next unless scope == :all || owner == @owner_id
304
370
 
305
- if (m = line.match(ip_re))
306
- ip, fqdn = m[1], m[2]
307
- out << [ip, fqdn, owner]
308
- UiHelpers.debug(@ui, " ip line: #{ip} #{fqdn} (owner=#{owner})")
371
+ if (m = line.match(ENTRY_RE))
372
+ UiHelpers.debug(@ui, " ip line: #{m[1]} #{m[2]} (owner=#{owner})")
373
+ yield m[1], m[2], owner
309
374
  end
310
375
  end
311
-
312
- UiHelpers.debug(@ui, "list_pairs found #{out.length} pair(s)")
313
- out
314
376
  end
315
377
 
316
- def entries_in_blocks(scope = :current)
317
- content = read
318
- return {} if content.to_s.empty?
319
-
320
- start_prefix = "# >>> vagrant-docker-hosts-manager "
321
- stop_prefix = "# <<< vagrant-docker-hosts-manager "
322
-
323
- in_block = false
324
- owner = nil
325
- out = {}
326
-
327
- content.each_line do |raw|
328
- line = raw.sub(/\r?\n\z/, "")
329
- lstr = line.lstrip
330
-
331
- if lstr.start_with?(start_prefix)
332
- in_block = true
333
- tail = lstr[start_prefix.length..-1].to_s
334
- owner = tail.split(" (managed)").first.to_s.strip
335
- next
336
- end
337
-
338
- if lstr.start_with?(stop_prefix)
339
- in_block = false
340
- owner = nil
341
- next
342
- end
343
-
344
- next unless in_block
345
- next unless scope == :all || owner == @owner_id
346
-
347
- if lstr =~ /\A\s*(\d{1,3}(?:\.\d{1,3}){3})\s+([^\s#]+)/
348
- ip, fqdn = $1, $2
349
- if out.key?(fqdn)
350
- arr = out[fqdn].is_a?(Array) ? out[fqdn] : [out[fqdn]]
351
- arr << ip unless arr.include?(ip)
352
- out[fqdn] = arr
353
- else
354
- out[fqdn] = [ip]
355
- end
356
- end
357
- end
358
-
359
- out
378
+ def list_pairs(scope = :all)
379
+ pairs = []
380
+ each_managed_entry(scope) { |ip, fqdn, owner| pairs << [ip, fqdn, owner] }
381
+ UiHelpers.debug(@ui, "list_pairs found #{pairs.length} pair(s)")
382
+ pairs
360
383
  end
361
384
 
362
- def managed_blocks_dump(scope = :all)
363
- content = read
364
- return "" if content.to_s.empty?
365
-
366
- start_re = /^\s*#\s*>>>\s*vagrant-docker-hosts-manager\s+(.+?)\s*\(managed\)\s*>>>\s*$/i
367
- stop_re = /^\s*#\s*<<<\s*vagrant-docker-hosts-manager\s+(.+?)\s*\(managed\)\s*<<<\s*$/i
368
-
369
- buff = []
370
- cur = []
371
- in_block = false
372
- owner = nil
373
-
374
- content.each_line do |raw|
375
- line = raw.delete_suffix("\n").delete_suffix("\r")
376
-
377
- if (m = line.match(start_re))
378
- in_block = true
379
- owner = m[1].to_s.strip
380
- cur = [line]
381
- next
382
- end
383
-
384
- if in_block
385
- cur << line
386
- if line.match?(stop_re)
387
- if scope == :all || owner == @owner_id
388
- buff << cur.join("\n")
389
- end
390
- in_block = false
391
- owner = nil
392
- cur = []
393
- end
394
- end
385
+ def entries_in_blocks(scope = :current)
386
+ each_managed_entry(scope).with_object({}) do |(ip, fqdn, _owner), out|
387
+ arr = (out[fqdn] ||= [])
388
+ arr << ip unless arr.include?(ip)
395
389
  end
396
-
397
- buff.join("\n\n\n")
398
- end
399
-
400
- def current_entries
401
- entries_in_blocks(:current)
402
390
  end
403
391
 
404
392
  def remove_block_from(content)
@@ -424,12 +412,13 @@ module VagrantDockerHostsManager
424
412
  [Security.Principal.WindowsIdentity]::GetCurrent()
425
413
  )).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
426
414
  }.strip
415
+ Verbose.log("powershell -Command (check administrator role)")
427
416
  out, _err, st = Open3.capture3("powershell", "-NoProfile", "-NonInteractive", "-Command", cmd)
428
417
  st.success? && out.to_s.strip.downcase == "true"
429
418
  else
430
419
  begin
431
420
  Process.euid == 0
432
- rescue
421
+ rescue StandardError
433
422
  false
434
423
  end
435
424
  end
@@ -446,31 +435,50 @@ module VagrantDockerHostsManager
446
435
  begin
447
436
  tf.binmode
448
437
  tf.write(content); tf.flush
449
- system("sudo cp #{tf.path} #{real_path}") || raise("sudo copy failed")
438
+ Verbose.log("sudo", "cp", tf.path, real_path)
439
+ system("sudo", "cp", tf.path, real_path) || raise("sudo copy failed")
450
440
  ensure
451
441
  tf.close!
452
442
  end
453
443
  end
454
444
 
455
445
  def write_windows(content)
456
- tf = Tempfile.new("vdhm-hosts")
457
- begin
458
- tf.binmode
459
- tf.write(content); tf.flush
460
- ps = <<~POW
461
- $ErrorActionPreference = "Stop"
462
- Copy-Item -LiteralPath '#{tf.path.gsub("'", "''")}' -Destination '#{real_path.gsub("'", "''")}' -Force
463
- POW
464
- system(%{powershell -NoProfile -NonInteractive -Command #{Util::Docker.shell_escape(ps)}}) ||
465
- system(
466
- %{
467
- powershell -NoProfile -NonInteractive -Command
468
- "Start-Process PowerShell -Verb RunAs -ArgumentList #{Util::Docker.shell_escape(ps)}"
469
- }.strip
470
- ) || raise("elevated copy failed")
471
- ensure
472
- tf.close!
473
- end
446
+ # Try a direct write first; fall back to UAC only when the hosts file
447
+ # rejects it. Base64/UTF-16LE keeps PowerShell quoting predictable.
448
+ b64 = Base64.strict_encode64(
449
+ content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "")
450
+ )
451
+ dest = real_path.gsub("'", "''")
452
+
453
+ ps = <<~POW
454
+ $ErrorActionPreference = "Stop"
455
+ try {
456
+ $bytes = [System.Convert]::FromBase64String('#{b64}')
457
+ [System.IO.File]::WriteAllBytes('#{dest}', $bytes)
458
+ exit 0
459
+ } catch {
460
+ exit 1
461
+ }
462
+ POW
463
+ encoded = Base64.strict_encode64(ps.encode("UTF-16LE"))
464
+
465
+ Verbose.log("powershell -EncodedCommand (write hosts file: #{real_path})")
466
+ _out, _err, st = Open3.capture3("powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded)
467
+ return if st.success?
468
+
469
+ elev_ps = <<~POW
470
+ $ErrorActionPreference = 'Stop'
471
+ try {
472
+ $p = Start-Process PowerShell -Verb RunAs -Wait -PassThru -ArgumentList '-NonInteractive','-NoProfile','-EncodedCommand','#{encoded}'
473
+ exit $p.ExitCode
474
+ } catch {
475
+ exit 1
476
+ }
477
+ POW
478
+ elev_encoded = Base64.strict_encode64(elev_ps.encode("UTF-16LE"))
479
+ Verbose.log("powershell -EncodedCommand (elevated write hosts file via RunAs: #{real_path})")
480
+ system("powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", elev_encoded) ||
481
+ raise("elevated write failed")
474
482
  end
475
483
  end
476
484
  end
@@ -1,34 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "i18n"
4
+ require_relative "../helpers"
4
5
 
5
6
  module VagrantDockerHostsManager
6
7
  module Util
7
8
  module I18n
8
- SUPPORTED = [:en, :fr].freeze
9
9
  @json = false
10
10
 
11
11
  module_function
12
12
 
13
13
  def setup!(env, forced: nil)
14
- ::I18n.enforce_available_locales = false
14
+ UiHelpers.setup_i18n!
15
15
 
16
- gem_root = File.expand_path("../..", __dir__)
17
- paths = Dir[File.join(gem_root, "locales", "*.yml")]
18
- ::I18n.load_path |= paths
19
- ::I18n.available_locales = SUPPORTED
20
- ::I18n.backend.load_translations
16
+ if forced
17
+ begin
18
+ UiHelpers.set_locale!(forced)
19
+ rescue UiHelpers::UnsupportedLocaleError
20
+ UiHelpers.set_locale!("en")
21
+ end
22
+ end
21
23
 
22
- lang = forced || ENV["VDHM_LANG"] || ENV["LANG"] || "en"
23
- sym = lang.to_s[0, 2].downcase.to_sym
24
- ::I18n.locale = SUPPORTED.include?(sym) ? sym : :en
24
+ begin
25
+ cfg = env[:machine]&.config&.docker_hosts
26
+ if cfg&.respond_to?(:locale) && !cfg.locale.to_s.strip.empty?
27
+ UiHelpers.setup_locale_from_config!(cfg)
28
+ end
29
+ rescue StandardError
30
+ nil
31
+ end
25
32
 
26
33
  begin
27
- if env[:ui] && env[:machine] && env[:machine].config.docker_hosts.respond_to?(:verbose) &&
34
+ if env[:ui] && env[:machine]&.config&.docker_hosts&.respond_to?(:verbose) &&
28
35
  env[:machine].config.docker_hosts.verbose
29
- env[:ui].info(::I18n.t("messages.lang_set", lang: ::I18n.locale))
36
+ env[:ui].info(::I18n.t("vdhm.messages.lang_set", lang: ::I18n.locale))
30
37
  end
31
38
  rescue StandardError
39
+ nil
32
40
  end
33
41
  end
34
42
 
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module VagrantDockerHostsManager
6
+ module Util
7
+ module Verbose
8
+ module_function
9
+
10
+ def enabled?
11
+ ENV["VDHM_VERBOSE"].to_s == "1"
12
+ end
13
+
14
+ def log(*args)
15
+ return unless enabled?
16
+
17
+ line = args.length == 1 && args.first.is_a?(String) ? args.first : args.map(&:to_s).shelljoin
18
+ warn("[VDHM] #{line}")
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,10 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module VagrantDockerHostsManager
4
- VERSION = begin
5
- path = File.expand_path("VERSION", __dir__)
6
- File.exist?(path) ? File.read(path).strip : "0.0.0"
7
- rescue
8
- "0.0.0"
4
+ unless defined?(VERSION)
5
+ VERSION = begin
6
+ path = File.expand_path("VERSION", __dir__)
7
+ File.exist?(path) ? File.read(path).strip : "0.0.0"
8
+ rescue StandardError
9
+ "0.0.0"
10
+ end
9
11
  end
10
12
  end