vagrant-docker-hosts-manager 0.2.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.
@@ -0,0 +1,477 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+ require "time"
5
+ require "open3"
6
+
7
+ require_relative "../helpers"
8
+ require_relative "docker"
9
+
10
+ module VagrantDockerHostsManager
11
+ module Util
12
+ class HostsFile
13
+ POSIX_PATH = "/etc/hosts"
14
+ WIN_SYS32_PATH = "C:/Windows/System32/drivers/etc/hosts"
15
+ WIN_SYSNATIVE_PATH = "C:/Windows/Sysnative/drivers/etc/hosts"
16
+ WIN_SYSWOW64_PATH = "C:/Windows/SysWOW64/drivers/etc/hosts"
17
+
18
+ def initialize(env, owner_id:)
19
+ @env = env || {}
20
+ @owner_id = owner_id.to_s
21
+ @ui = env[:ui]
22
+ end
23
+
24
+ def override_path
25
+ p = ENV["VDHM_HOSTS_PATH"].to_s.strip
26
+ p.empty? ? nil : p
27
+ end
28
+
29
+ def path_candidates
30
+ return [POSIX_PATH] unless Gem.win_platform?
31
+ return [override_path] if override_path
32
+ [WIN_SYSNATIVE_PATH, WIN_SYS32_PATH, WIN_SYSWOW64_PATH]
33
+ end
34
+
35
+ def real_path
36
+ cand = path_candidates
37
+ UiHelpers.debug(@ui, "Candidates: #{cand.inspect}")
38
+ found = cand.find { |p| File.exist?(p) } || cand.first
39
+ UiHelpers.debug(@ui, "Selected path: #{found}")
40
+ found
41
+ end
42
+
43
+ def printable_path
44
+ p = real_path
45
+ Gem.win_platform? ? p.tr("/", "\\") : p
46
+ end
47
+
48
+ def block_name
49
+ "vagrant-docker-hosts-manager #{@owner_id}"
50
+ end
51
+
52
+ def block_markers
53
+ start = "# >>> #{block_name} (managed) >>>"
54
+ stop = "# <<< #{block_name} (managed) <<<"
55
+ [start, stop]
56
+ end
57
+
58
+ def detect_newline(str)
59
+ return "\r\n" if Gem.win_platform?
60
+ str.include?("\r\n") ? "\r\n" : "\n"
61
+ end
62
+
63
+ def compose_block(entries, newline: "\n")
64
+ start, stop = block_markers
65
+ ts = (Time.now.utc.iso8601 rescue Time.now.utc.to_s)
66
+
67
+ header = [
68
+ start,
69
+ "# Managed by Vagrant - do not edit manually",
70
+ "# Timestamp: #{ts}"
71
+ ]
72
+ body = entries.map { |d, ip| "#{ip} #{d}" }
73
+
74
+ (header + body + [stop]).join(newline) + newline
75
+ end
76
+
77
+ def pairs_to_hash(pairs)
78
+ h = {}
79
+ pairs.each do |ip, fqdn, _owner|
80
+ ip = ip.to_s.strip
81
+ fqdn = fqdn.to_s.strip
82
+ next if ip.empty? || fqdn.empty?
83
+ h[fqdn] = ip
84
+ end
85
+ h
86
+ end
87
+
88
+ def normalize_entries(entries)
89
+ entries
90
+ .each_with_object({}) { |(d, ip), h| h[d.to_s.strip] = ip.to_s.strip }
91
+ .reject { |d, ip| d.empty? || ip.empty? }
92
+ .sort_by { |d, _ip| d }
93
+ .to_h
94
+ end
95
+
96
+ def ensure_trailing_newline(str, nl)
97
+ return "" if str.nil? || str.empty?
98
+ str.end_with?(nl) ? str : (str + nl)
99
+ end
100
+
101
+ def apply(entries)
102
+ entries = normalize_entries(entries)
103
+ if entries.empty?
104
+ UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
105
+ ::I18n.t("messages.no_entries", default: "No hosts entries configured."))
106
+ return 0
107
+ end
108
+
109
+ base = read
110
+ newline = detect_newline(base)
111
+ cleaned = remove_block_from(base)
112
+ cleaned = ensure_trailing_newline(cleaned, newline)
113
+
114
+ existing_map = pairs_to_hash(list_pairs(:current))
115
+
116
+ added = {}
117
+ updated = {}
118
+ unchanged = {}
119
+
120
+ entries.each do |fqdn, ip|
121
+ prev = existing_map[fqdn]
122
+ if prev.nil?
123
+ added[fqdn] = ip
124
+ existing_map[fqdn] = ip
125
+ elsif prev == ip
126
+ unchanged[fqdn] = ip
127
+ else
128
+ updated[fqdn] = { from: prev, to: ip }
129
+ existing_map[fqdn] = ip
130
+ end
131
+ end
132
+
133
+ if added.empty? && updated.empty?
134
+ UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
135
+ ::I18n.t("messages.no_change", default: "Nothing to apply. Already up-to-date."))
136
+ return existing_map.size
137
+ end
138
+
139
+ merged = normalize_entries(existing_map)
140
+ content = cleaned + compose_block(merged, newline: newline)
141
+ write(content)
142
+
143
+ UiHelpers.say(@ui, "#{UiHelpers.e(:success)} " +
144
+ ::I18n.t("messages.applied", default: "Hosts entries applied."))
145
+ UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
146
+ ::I18n.t("messages.apply_summary",
147
+ default: "Added: %{a}, Updated: %{u}, Unchanged: %{s}",
148
+ a: added.size, u: updated.size, s: unchanged.size))
149
+ merged.size
150
+ end
151
+
152
+ def remove_entries!(ips: [], domains: [])
153
+ ips = Array(ips).map(&:to_s).reject(&:empty?)
154
+ domains = Array(domains).map(&:to_s).reject(&:empty?)
155
+ return (remove! ? 1 : 0) if ips.empty? && domains.empty?
156
+
157
+ pairs = list_pairs(:current)
158
+ before = pairs.length
159
+ return 0 if before.zero?
160
+
161
+ filtered = pairs.reject do |ip, fqdn, _|
162
+ ips.include?(ip.to_s) || domains.include?(fqdn.to_s)
163
+ end
164
+
165
+ removed_count = before - filtered.length
166
+ if removed_count <= 0
167
+ UiHelpers.say(@ui, "#{UiHelpers.e(:info)} " +
168
+ ::I18n.t("messages.remove_none",
169
+ default: "No matching entry to remove."))
170
+ return 0
171
+ end
172
+
173
+ base = read
174
+ newline = detect_newline(base)
175
+ cleaned = remove_block_from(base)
176
+
177
+ if filtered.empty?
178
+ write(cleaned)
179
+ else
180
+ cleaned = ensure_trailing_newline(cleaned, newline)
181
+ remaining_map = normalize_entries(pairs_to_hash(filtered))
182
+ content = cleaned + compose_block(remaining_map, newline: newline)
183
+ write(content)
184
+ end
185
+
186
+ UiHelpers.say(
187
+ @ui,
188
+ "#{UiHelpers.e(:broom)} " +
189
+ ::I18n.t(
190
+ "messages.removed_count",
191
+ default: "%{count} entries removed.",
192
+ count: removed_count
193
+ )
194
+ )
195
+ removed_count
196
+ end
197
+
198
+ def remove!
199
+ content = read
200
+ newc = remove_block_from(content)
201
+ removed = (newc != content)
202
+ write(newc) if removed
203
+
204
+ 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."))
207
+ removed
208
+ end
209
+
210
+ def read
211
+ pth = real_path
212
+ UiHelpers.debug(@ui, "read(#{pth})")
213
+
214
+ data = nil
215
+
216
+ begin
217
+ data = File.binread(pth)
218
+ UiHelpers.debug(@ui, "File.binread ok, bytes=#{data.bytesize}, encoding=#{data.encoding}")
219
+ rescue => e
220
+ UiHelpers.debug(@ui, "File.binread error: #{e.class}: #{e.message}")
221
+ data = nil
222
+ end
223
+
224
+ if (data.nil? || data.empty?) && Gem.win_platform?
225
+ ps_path = pth.gsub("'", "''")
226
+ ps_cmd = "Get-Content -LiteralPath '#{ps_path}' -Raw"
227
+ out, err, st = Open3.capture3("powershell", "-NoProfile", "-NonInteractive", "-Command", ps_cmd)
228
+ if st.success?
229
+ data = out
230
+ UiHelpers.debug(@ui, "Fallback PS read ok, bytes=#{data.bytesize}, encoding=#{data.encoding}")
231
+ else
232
+ UiHelpers.debug(@ui, "Fallback PS read failed: #{err}")
233
+ end
234
+ end
235
+
236
+ return "" if data.nil?
237
+
238
+ data = data.dup
239
+ data.force_encoding(Encoding::BINARY)
240
+
241
+ if data.start_with?("\xEF\xBB\xBF".b)
242
+ UiHelpers.debug(@ui, "BOM detected, stripping")
243
+ data = data.byteslice(3, data.bytesize - 3) || "".b
244
+ end
245
+
246
+ begin
247
+ data = data.encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "")
248
+ UiHelpers.debug(@ui, "Transcoded to UTF-8 (direct), encoding=#{data.encoding}")
249
+ rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError => e
250
+ UiHelpers.debug(@ui, "Direct UTF-8 encode failed: #{e.class}: #{e.message}, trying Windows-1252")
251
+ begin
252
+ data = data
253
+ .force_encoding("Windows-1252")
254
+ .encode(Encoding::UTF_8, invalid: :replace, undef: :replace, replace: "")
255
+ UiHelpers.debug(@ui, "Transcoded via Windows-1252 -> UTF-8, encoding=#{data.encoding}")
256
+ rescue => e2
257
+ UiHelpers.debug(@ui, "Windows-1252 fallback failed: #{e2.class}: #{e2.message}")
258
+ data = data.force_encoding(Encoding::UTF_8)
259
+ end
260
+ end
261
+
262
+ if UiHelpers.debug_enabled?
263
+ head = data.lines.first(8) rescue []
264
+ UiHelpers.debug(@ui, "Head(8):\n" + head.join)
265
+ end
266
+
267
+ data
268
+ rescue => e
269
+ UiHelpers.debug(@ui, "read() fatal: #{e.class}: #{e.message}")
270
+ ""
271
+ end
272
+
273
+ def list_pairs(scope = :all)
274
+ content = read
275
+ return [] if content.to_s.empty?
276
+
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#]+)/
280
+
281
+ out = []
282
+ in_block = false
283
+ owner = nil
284
+
285
+ content.each_line.with_index(1) do |raw, idx|
286
+ line = raw.delete_suffix("\n").delete_suffix("\r")
287
+
288
+ if (m = line.match(start_re))
289
+ in_block = true
290
+ owner = m[1].to_s.strip
291
+ UiHelpers.debug(@ui, "start block(owner=#{owner}) at line #{idx}")
292
+ next
293
+ end
294
+
295
+ if (m = line.match(stop_re))
296
+ UiHelpers.debug(@ui, "stop block(owner=#{owner}) at line #{idx}") if in_block
297
+ in_block = false
298
+ owner = nil
299
+ next
300
+ end
301
+
302
+ next unless in_block
303
+ next unless scope == :all || owner == @owner_id
304
+
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})")
309
+ end
310
+ end
311
+
312
+ UiHelpers.debug(@ui, "list_pairs found #{out.length} pair(s)")
313
+ out
314
+ end
315
+
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
360
+ end
361
+
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
395
+ end
396
+
397
+ buff.join("\n\n\n")
398
+ end
399
+
400
+ def current_entries
401
+ entries_in_blocks(:current)
402
+ end
403
+
404
+ def remove_block_from(content)
405
+ start, stop = block_markers
406
+ removing = false
407
+ content.lines.reject do |line|
408
+ if line.start_with?(start)
409
+ removing = true
410
+ true
411
+ elsif line.start_with?(stop)
412
+ removing = false
413
+ true
414
+ else
415
+ removing
416
+ end
417
+ end.join
418
+ end
419
+
420
+ def elevated?
421
+ if Gem.win_platform?
422
+ cmd = %q{
423
+ (New-Object Security.Principal.WindowsPrincipal(
424
+ [Security.Principal.WindowsIdentity]::GetCurrent()
425
+ )).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
426
+ }.strip
427
+ out, _err, st = Open3.capture3("powershell", "-NoProfile", "-NonInteractive", "-Command", cmd)
428
+ st.success? && out.to_s.strip.downcase == "true"
429
+ else
430
+ begin
431
+ Process.euid == 0
432
+ rescue
433
+ false
434
+ end
435
+ end
436
+ end
437
+
438
+ def write(content)
439
+ Gem.win_platform? ? write_windows(content) : write_posix(content)
440
+ end
441
+
442
+ def write_posix(content)
443
+ File.binwrite(real_path, content)
444
+ rescue Errno::EACCES
445
+ tf = Tempfile.new("vdhm-hosts")
446
+ begin
447
+ tf.binmode
448
+ tf.write(content); tf.flush
449
+ system("sudo cp #{tf.path} #{real_path}") || raise("sudo copy failed")
450
+ ensure
451
+ tf.close!
452
+ end
453
+ end
454
+
455
+ 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
474
+ end
475
+ end
476
+ end
477
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "i18n"
4
+
5
+ module VagrantDockerHostsManager
6
+ module Util
7
+ module I18n
8
+ SUPPORTED = [:en, :fr].freeze
9
+ @json = false
10
+
11
+ module_function
12
+
13
+ def setup!(env, forced: nil)
14
+ ::I18n.enforce_available_locales = false
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
21
+
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
25
+
26
+ begin
27
+ if env[:ui] && env[:machine] && env[:machine].config.docker_hosts.respond_to?(:verbose) &&
28
+ env[:machine].config.docker_hosts.verbose
29
+ env[:ui].info(::I18n.t("messages.lang_set", lang: ::I18n.locale))
30
+ end
31
+ rescue StandardError
32
+ end
33
+ end
34
+
35
+ def set_json_mode(v) = (@json = !!v)
36
+ def json? = !!@json
37
+ def env_flag(name) = ENV[name].to_s == "1"
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module VagrantDockerHostsManager
6
+ module Util
7
+ module Json
8
+ def self.emit(obj)
9
+ return true unless Util::I18n.json?
10
+ puts JSON.generate(obj)
11
+ true
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
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"
9
+ end
10
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "vagrant-docker-hosts-manager/plugin"
data/locales/en.yml ADDED
@@ -0,0 +1,114 @@
1
+ en:
2
+ help:
3
+ title: "Vagrant Docker Hosts Manager"
4
+ usage: "Usage: vagrant hosts <apply|remove|view|help|version> [options]"
5
+ commands_header: "Commands:"
6
+ options_header: "Options:"
7
+ topics_header: "Help topics:"
8
+ topic_header: "Help: vagrant hosts %{topic}"
9
+ usage_label: "Usage:"
10
+ description_label: "Description:"
11
+ options_label: "Options:"
12
+ examples_label: "Examples:"
13
+ commands:
14
+ apply: "apply [IP] [DOMAIN] Add/update entries in hosts file"
15
+ remove: "remove [IP|DOMAIN] Remove entries or the whole managed block"
16
+ view: "view [DOMAIN] Show planned/managed hosts entries"
17
+ version: "version Display plugin version"
18
+ help: "help [DOMAIN] Show help or subcommands help"
19
+ options:
20
+ json: "--json Machine-readable JSON output"
21
+ lang: "--lang LANG Force language (en|fr)"
22
+ no_emoji: "--no-emoji Disable emoji in CLI output"
23
+ help: "-h, --help Show help"
24
+ topic:
25
+ apply:
26
+ title: "apply"
27
+ usage: "vagrant hosts apply <IP> <DOMAIN>"
28
+ description: >
29
+ Add or update the given mapping in the managed block for the target VM.
30
+ If no mapping is given, values from the Vagrantfile (docker_hosts) are used.
31
+ The command validates both parameters: IPv4 (e.g. 172.28.100.2) and DOMAIN (e.g. example.test).
32
+ options:
33
+ json: "--json Output machine-readable JSON"
34
+ dryrun: "--dry-run Do not modify hosts file"
35
+ lang: "--lang <en|fr> Force language"
36
+ noemoji: "--no-emoji Disable emoji in CLI output"
37
+ examples:
38
+ - "vagrant hosts apply 172.28.100.2 flowfind.noesi.local"
39
+ - "vagrant hosts apply 172.28.100.3 flowfind3.noesi.local default"
40
+ remove:
41
+ title: "remove"
42
+ usage: "vagrant hosts remove [<IP>|<DOMAIN>]"
43
+ description: >
44
+ Remove one mapping by IP or DOMAIN. If no parameter is provided, you will be asked
45
+ to confirm removing ALL managed entries for the VM (prompt: yes/No).
46
+ The parameter format is validated (IPv4 or DOMAIN).
47
+ options:
48
+ json: "--json Output machine-readable JSON"
49
+ dryrun: "--dry-run Do not modify hosts file"
50
+ lang: "--lang <en|fr> Force language"
51
+ noemoji: "--no-emoji Disable emoji in CLI output"
52
+ examples:
53
+ - "vagrant hosts remove 172.28.100.2"
54
+ - "vagrant hosts remove flowfind.noesi.local"
55
+ - "vagrant hosts remove # will ask for confirmation to remove ALL"
56
+ view:
57
+ title: "view"
58
+ usage: "vagrant hosts view"
59
+ description: >
60
+ Display planned entries (from Vagrantfile) and managed entries currently present
61
+ in the hosts file. Shows unique pairs IP → DOMAIN.
62
+ options:
63
+ json: "--json Output machine-readable JSON"
64
+ dryrun: "--dry-run Do not modify hosts file"
65
+ lang: "--lang <en|fr> Force language"
66
+ noemoji: "--no-emoji Disable emoji in CLI output"
67
+ examples:
68
+ - "vagrant hosts view"
69
+ - "vagrant hosts view default"
70
+ version:
71
+ title: "version"
72
+ usage: "vagrant hosts version"
73
+ description: "Print the plugin version."
74
+ options: {}
75
+ examples:
76
+ - "vagrant hosts version"
77
+ help:
78
+ title: "help"
79
+ usage: "vagrant hosts help [apply|remove|view|version|help]"
80
+ description: "Show general help or detailed help for a specific subcommand."
81
+ options: {}
82
+ examples:
83
+ - "vagrant hosts help"
84
+ - "vagrant hosts help apply"
85
+
86
+ messages:
87
+ no_entries: "No hosts entries configured."
88
+ applied: "Hosts entries applied."
89
+ removed_count: "%{count} entries removed."
90
+ cleaned: "Managed hosts entries removed."
91
+ nothing_to_clean: "Nothing to clean."
92
+ missing_ip_for: "No IP configured for %{domain}; skipping."
93
+ detected_ip: "Detected IP for %{domain}: %{ip}."
94
+ no_ip_found: "No IP found for %{domain} (container: %{container})."
95
+ lang_set: "Language set to %{lang}."
96
+ no_change: "Nothing to apply. Already up-to-date."
97
+ invalid_ip: "Invalid IPv4 address: %{ip}"
98
+ invalid_host: "Invalid host/DOMAIN: %{host}"
99
+ missing_mapping: "Provide both IP and DOMAIN (e.g. `vagrant hosts apply 1.2.3.4 example.test`)."
100
+ invalid_key: "Invalid IP or DOMAIN: %{key}"
101
+ view_header: "Planned hosts entries:"
102
+ view_managed_header: "Managed hosts entries:"
103
+ apply_summary: "Added: %{a}, Updated: %{u}, Unchanged: %{s}"
104
+ remove_expected_format: "Expected IPv4 (e.g. 172.28.100.2) or DOMAIN (e.g. example.test)."
105
+ confirm_remove_all: "This will remove ALL managed hosts entries for %{vm}. Continue? (yes/NO)"
106
+ remove_all_cancelled: "Cancelled. Nothing removed."
107
+ remove_none: "No matching entry to remove."
108
+
109
+ errors:
110
+ no_machine: "No target machine found. Run this inside a Vagrant project or pass a VM name."
111
+ not_admin: "Administrator/root privileges required to modify hosts at %{path}."
112
+
113
+ log:
114
+ version_line: "vagrant-docker-hosts-manager version %{version}"