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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 90a6a5b049e33dce80821b66d57d4c4ca4edd35d7e51f1125d96f4211adb3eb1
4
+ data.tar.gz: 634910fe60c21185160b1269bdc6b3cbb77e0d6e6e7df8d5a2cb6eb6115762e1
5
+ SHA512:
6
+ metadata.gz: a1523d6ca1643bac26e2040020b9207a62d6e1b1328b89f56eae7e6d33ad69e6f2c136cee1c2c8e4824388ec126be962ad898107ab08cac66aa310f1a33f7c7b
7
+ data.tar.gz: da1215b0b930905713b6d9e202035e6523dd1878d9fbdf535a55bbfbc6706200a006c8bc2935ea4c2213efaed23584d7a98a6d5ce31fe7c168cf59ff5e01471a
data/CHANGELOG.md ADDED
@@ -0,0 +1,8 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0](https://github.com/julienpoirou/vagrant-docker-hosts-manager/compare/v0.1.0...v0.2.0) (2025-08-20)
4
+
5
+
6
+ ### Fonctionnalités ✨
7
+
8
+ * **init:** V1.0.0 ([32b9d53](https://github.com/julienpoirou/vagrant-docker-hosts-manager/commit/32b9d53654859f07e2f1f55f2acfcb2fd0322a0b))
data/LICENSE.md ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+ ===========
3
+
4
+ Copyright (c) 2025 Julien Poirou <julienpoirou@protonmail.com>
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the “Software”), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in
14
+ all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
22
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,196 @@
1
+ # vagrant-docker-hosts-manager
2
+
3
+ [![CI](https://github.com/julienpoirou/vagrant-docker-hosts-manager/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/julienpoirou/vagrant-docker-hosts-manager/actions/workflows/ci.yml)
4
+ [![CodeQL](https://github.com/julienpoirou/vagrant-docker-hosts-manager/actions/workflows/codeql.yml/badge.svg)](https://github.com/julienpoirou/vagrant-docker-hosts-manager/actions/workflows/codeql.yml)
5
+ [![Release](https://img.shields.io/github/v/release/julienpoirou/vagrant-docker-hosts-manager?include_prereleases&sort=semver)](https://github.com/julienpoirou/vagrant-docker-hosts-manager/releases)
6
+ [![RubyGems](https://img.shields.io/gem/v/vagrant-docker-hosts-manager.svg)](https://rubygems.org/gems/vagrant-docker-hosts-manager)
7
+ [![License](https://img.shields.io/github/license/julienpoirou/vagrant-docker-hosts-manager.svg)](LICENSE.md)
8
+ [![Conventional Commits](https://img.shields.io/badge/Conventional%20Commits-1.0.0-%23FE5196.svg)](https://www.conventionalcommits.org)
9
+ [![Renovate](https://img.shields.io/badge/Renovate-enabled-brightgreen.svg)](https://renovatebot.com)
10
+
11
+ Vagrant plugin to **manage custom hostnames** by updating the local `hosts` file (Windows/Linux/macOS) for Docker‑based environments.
12
+
13
+ - Adds/removes host entries on `vagrant up` / `vagrant destroy`
14
+ - Optional **auto‑detect** of a Docker container IP for a single domain
15
+ - Safe, idempotent updates: replaces previous entries for configured domains
16
+ - Works across platforms (uses `sudo` on Unix, UAC elevation on Windows)
17
+
18
+ > Requirements: **Vagrant ≥ 2.2**, **Ruby ≥ 3.1**, **Docker (CLI + daemon) for auto‑detect**
19
+
20
+ ---
21
+
22
+ ## Table of contents
23
+
24
+ - [Why this plugin?](#why-this-plugin)
25
+ - [Installation](#installation)
26
+ - [Quick start](#quick-start)
27
+ - [Vagrantfile configuration](#vagrantfile-configuration)
28
+ - [CLI usage](#cli-usage)
29
+ - [How it works](#how-it-works)
30
+ - [Permissions & OS notes](#permissions--os-notes)
31
+ - [Troubleshooting](#troubleshooting)
32
+ - [Contributing & Development](#contributing--development)
33
+ - [License](#license)
34
+
35
+ > 🇫🇷 **Français :** voir [README.fr.md](README.fr.md)
36
+
37
+ ---
38
+
39
+ ## Why this plugin?
40
+
41
+ When working with Docker + Vagrant, you often need **friendly hostnames** (e.g. `app.local`) that resolve to a container IP. Doing this manually in `/etc/hosts` or `C:\Windows\System32\drivers\etc\hosts` is error‑prone and not portable across teammates.
42
+
43
+ This plugin:
44
+ - **Writes** the required entries during `vagrant up`.
45
+ - **Cleans** them safely on `vagrant destroy`.
46
+ - Can **auto‑resolve** a container IP (`docker inspect`) if you only provide one `domain` and a `container_name`.
47
+
48
+ ---
49
+
50
+ ## Installation
51
+
52
+ From RubyGems (once published):
53
+
54
+ ```bash
55
+ vagrant plugin install vagrant-docker-hosts-manager
56
+ ```
57
+
58
+ From source (local path):
59
+
60
+ ```bash
61
+ git clone https://github.com/julienpoirou/vagrant-docker-hosts-manager
62
+ cd vagrant-docker-hosts-manager
63
+ bundle install
64
+ rake
65
+ vagrant plugin install . # install from the local gemspec
66
+ ```
67
+
68
+ Check it’s available:
69
+
70
+ ```bash
71
+ vagrant hosts --help
72
+ ```
73
+
74
+ ---
75
+
76
+ ## Quick start
77
+
78
+ ### Minimal Vagrantfile (auto‑detect single container IP)
79
+
80
+ ```ruby
81
+ Vagrant.configure("2") do |config|
82
+ config.vm.box = "hashicorp/bionic64"
83
+
84
+ # Auto-detect the IP of a running Docker container and map it
85
+ # to a single domain (e.g. http://app.local)
86
+ config.docker_hosts.domain = "app.local"
87
+ config.docker_hosts.container_name = "my_app_container" # docker ps --format '{{.Names}}'
88
+ end
89
+ ```
90
+
91
+ - On `vagrant up`, the plugin runs
92
+ `docker inspect -f "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" my_app_container`
93
+ and adds the entry `X.X.X.X app.local` to your hosts file.
94
+
95
+ ### Multiple static domains
96
+
97
+ ```ruby
98
+ Vagrant.configure("2") do |config|
99
+ config.vm.box = "hashicorp/bionic64"
100
+
101
+ # Explicit mappings (no Docker inspect)
102
+ config.docker_hosts.domains = {
103
+ "api.local" => "172.28.10.10",
104
+ "web.local" => "172.28.10.11",
105
+ "db.local" => "172.28.10.12"
106
+ }
107
+ end
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Vagrantfile configuration
113
+
114
+ All options:
115
+
116
+ | Key | Type | Default | Notes |
117
+ |-------------------|--------|----------------------|-------|
118
+ | `domains` | Hash | `{}` | Map of `domain => ip`. When set, **no auto‑detect** is performed. |
119
+ | `domain` | String | `nil` | Single domain to map via **auto‑detect** (requires `container_name`). |
120
+ | `container_name` | String | `"noesi-flowfind"` | Docker container name used for auto‑detect. |
121
+
122
+ **Rules**
123
+
124
+ - If `domains` is **empty** and `domain` is **set**, the plugin will try to **inspect the container IP** and map it to `domain`.
125
+ - If `domains` has entries, they are used **as is**; `domain`/`container_name` are ignored.
126
+
127
+ ---
128
+
129
+ ## CLI usage
130
+
131
+ ```
132
+ vagrant hosts <command>
133
+
134
+ Commands:
135
+ add # Add configured entries to the hosts file
136
+ remove # Remove configured entries from the hosts file
137
+ view # Print configured entries (from Vagrantfile)
138
+ ```
139
+
140
+ Examples:
141
+
142
+ ```bash
143
+ vagrant hosts view
144
+ vagrant hosts add
145
+ vagrant hosts remove
146
+ ```
147
+
148
+ ---
149
+
150
+ ## How it works
151
+
152
+ - **During `vagrant up`**
153
+ The plugin reads `config.docker_hosts`. If only `domain` is provided, it runs `docker inspect` to get an IP. Then it **removes any previous lines** containing those domains from the hosts file and **appends** new lines (`<ip> <domain>`).
154
+
155
+ - **During `vagrant destroy`**
156
+ The plugin **removes** any hosts lines that contain your configured domain names.
157
+
158
+ > The update is **idempotent**: running `add` multiple times won’t duplicate entries for the same domain.
159
+
160
+ ---
161
+
162
+ ## Permissions & OS notes
163
+
164
+ - **Linux / macOS**: modifying `/etc/hosts` requires privileges. The plugin pipes through `sudo tee -a` when appending, and writes the file when removing. You may be prompted for your password.
165
+ - **Windows**: the plugin uses **PowerShell elevation** (`Start-Process -Verb RunAs`) when needed to append or rewrite the hosts file.
166
+
167
+ > If your shell is already elevated (root/Admin), no prompts appear.
168
+
169
+ ---
170
+
171
+ ## Troubleshooting
172
+
173
+ - **Container IP empty with auto‑detect**: Ensure the Docker container `container_name` is running and attached to a network with an IP (check `docker inspect` output).
174
+ - **Permission denied**: Run your terminal as Administrator (Windows) or ensure `sudo` is available (Linux/macOS).
175
+ - **Entry not removed**: The cleanup matches **by domain substring**. If your hosts line contains extra comments or formatting, make sure the domain string appears on the line.
176
+
177
+ ---
178
+
179
+ ## Contributing & Development
180
+
181
+ ```bash
182
+ git clone https://github.com/julienpoirou/vagrant-docker-hosts-manager
183
+ cd vagrant-docker-hosts-manager
184
+ bundle install
185
+ rake # runs RSpec
186
+ ```
187
+
188
+ - Conventional Commits enforced in PRs.
189
+ - CI runs RuboCop, tests, and builds the gem.
190
+ - See `docs/en/CONTRIBUTING.md` and `docs/en/DEVELOPMENT.md` if present.
191
+
192
+ ---
193
+
194
+ ## License
195
+
196
+ MIT © 2025 [Julien Poirou](mailto:julienpoirou@protonmail.com)
@@ -0,0 +1 @@
1
+ 0.2.0
@@ -0,0 +1,457 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+ require_relative "helpers"
5
+
6
+ module VagrantDockerHostsManager
7
+ class Command < Vagrant.plugin("2", :command)
8
+ def execute
9
+ opts = {
10
+ json: false,
11
+ dry: false,
12
+ lang: nil,
13
+ no_emoji: false
14
+ }
15
+
16
+ raw_argv = @argv.dup
17
+ help_topic = begin
18
+ i = raw_argv.index { |x| x =~ /\A(?:help|--help|-h)\z/ }
19
+ cand = (i && raw_argv[i + 1]) ? raw_argv[i + 1] : nil
20
+ (cand && cand !~ /\A-/) ? cand : nil
21
+ rescue
22
+ nil
23
+ end
24
+
25
+ parser = OptionParser.new do |o|
26
+ o.banner = "Usage: vagrant hosts <apply|remove|view|help|version> [options]"
27
+ o.on("--json", "Machine-readable JSON output") { opts[:json] = true }
28
+ o.on("--lang LANG", "Force language (en|fr)") { |v| opts[:lang] = v }
29
+ o.on("--no-emoji", "Disable emoji in CLI output") { opts[:no_emoji] = true }
30
+ o.on("-h", "--help", "Show help") { print_help(@env.ui); return 0 }
31
+ end
32
+
33
+ argv = parse_options(parser)
34
+ return 0 unless argv
35
+
36
+ action = argv.shift
37
+ env = @env
38
+
39
+ Util::I18n.setup!(env, forced: opts[:lang])
40
+ Util::I18n.set_json_mode(opts[:json])
41
+ ENV["VDHM_DRY_RUN"] = "1" if opts[:dry]
42
+ ENV["VDHM_NO_EMOJI"] = "1" if opts[:no_emoji]
43
+
44
+ case action
45
+ when nil, "", "help", "--help", "-h"
46
+ topic = help_topic || argv.shift
47
+ print_help(@env.ui, topic: topic, no_emoji: opts[:no_emoji])
48
+ return 0
49
+ when "version"
50
+ if opts[:json]
51
+ Util::Json.emit(action: "version", status: "success", data: { version: VagrantDockerHostsManager::VERSION })
52
+ else
53
+ emoji = UiHelpers.e(:version, no_emoji: opts[:no_emoji])
54
+ line = ::I18n.t("log.version_line",
55
+ default: "vagrant-docker-hosts-manager version %{version}",
56
+ version: VagrantDockerHostsManager::VERSION)
57
+ UiHelpers.say(@env.ui, "#{emoji} #{line}")
58
+ end
59
+ return 0
60
+ end
61
+
62
+ machine = nil
63
+
64
+ case action
65
+ when "apply"
66
+ begin
67
+ ip_for_apply, host_for_apply =
68
+ parse_strict_mapping_from_argv!(argv, @env.ui, opts[:no_emoji], json: opts[:json])
69
+ rescue ArgumentError => e
70
+ Util::Json.emit(action: "apply", status: "error", error: e.message) if opts[:json]
71
+ return 1
72
+ end
73
+
74
+ with_target_vms(argv, single_target: true) { |m| machine = m }
75
+
76
+ unless machine
77
+ UiHelpers.error(
78
+ @env.ui,
79
+ "#{UiHelpers.e(:error, no_emoji: opts[:no_emoji])} " +
80
+ ::I18n.t(
81
+ "errors.no_machine",
82
+ default: "No target machine found. Run this inside a Vagrant project or pass a VM name."
83
+ )
84
+ )
85
+ Util::Json.emit(action: "apply", status: "error", error: "No target machine found") if opts[:json]
86
+ return 1
87
+ end
88
+
89
+ if ip_for_apply || host_for_apply
90
+ unless ip_for_apply && host_for_apply
91
+ UiHelpers.error(@env.ui, "#{UiHelpers.e(:error, no_emoji: opts[:no_emoji])} " +
92
+ ::I18n.t("messages.missing_mapping",
93
+ default: "Provide both IP and FQDN (e.g. `vagrant hosts apply 1.2.3.4 example.test`)."))
94
+ return 1
95
+ end
96
+ unless ipv4?(ip_for_apply)
97
+ UiHelpers.error(@env.ui, "#{UiHelpers.e(:error, no_emoji: opts[:no_emoji])} " +
98
+ ::I18n.t("messages.invalid_ip", default: "Invalid IPv4 address: %{ip}", ip: ip_for_apply))
99
+ return 1
100
+ end
101
+ unless fqdn?(host_for_apply)
102
+ UiHelpers.error(@env.ui, "#{UiHelpers.e(:error, no_emoji: opts[:no_emoji])} " +
103
+ ::I18n.t("messages.invalid_host", default: "Invalid host/FQDN: %{host}", host: host_for_apply))
104
+ return 1
105
+ end
106
+ end
107
+
108
+ cfg = machine.config.docker_hosts
109
+ mid = machine.id || "unknown"
110
+ hoster = Util::HostsFile.new({ machine: machine, ui: @env.ui }, owner_id: mid)
111
+
112
+ unless ENV["VDHM_DRY_RUN"] == "1" || hoster.elevated?
113
+ UiHelpers.error(@env.ui, "#{UiHelpers.e(:error, no_emoji: opts[:no_emoji])} " +
114
+ ::I18n.t("errors.not_admin",
115
+ default: "Administrator/root privileges required to modify hosts at %{path}.",
116
+ path: hoster.printable_path))
117
+ return 1
118
+ end
119
+
120
+ entries = compute_entries(machine, cfg)
121
+ entries[host_for_apply] = ip_for_apply if ip_for_apply && host_for_apply
122
+
123
+ if entries.empty?
124
+ UiHelpers.say(@env.ui, "#{UiHelpers.e(:info, no_emoji: opts[:no_emoji])} " +
125
+ ::I18n.t("messages.no_entries", default: "No hosts entries configured."))
126
+ Util::Json.emit(action: "apply", status: "noop", data: { reason: "no entries" })
127
+ return 0
128
+ end
129
+
130
+ return 0 if opts[:dry] and Util::Json.emit(action: "apply", status: "dry-run", data: { entries: entries })
131
+
132
+ hoster.apply(entries)
133
+ Util::Json.emit(action: "apply", status: "success", data: { entries: entries })
134
+ return 0
135
+
136
+ when "remove"
137
+ remove_key = parse_remove_key_from_argv!(argv)
138
+
139
+ with_target_vms(argv, single_target: true) { |m| machine = m }
140
+
141
+ unless machine
142
+ UiHelpers.error(
143
+ @env.ui,
144
+ "#{UiHelpers.e(:error, no_emoji: opts[:no_emoji])} " +
145
+ ::I18n.t(
146
+ "errors.no_machine",
147
+ default: "No target machine found. Run this inside a Vagrant project or pass a VM name."
148
+ )
149
+ )
150
+ Util::Json.emit(action: "remove", status: "error", error: "No target machine found") if opts[:json]
151
+ return 1
152
+ end
153
+
154
+ if remove_key && !(ipv4?(remove_key) || fqdn?(remove_key))
155
+ UiHelpers.error(@env.ui, "#{UiHelpers.e(:error, no_emoji: opts[:no_emoji])} " +
156
+ ::I18n.t("messages.invalid_key",
157
+ default: "Invalid IP or FQDN: %{key}", key: remove_key))
158
+ UiHelpers.error(@env.ui, " " + ::I18n.t("messages.remove_expected_format",
159
+ default: "Expected IPv4 (e.g. 172.28.100.2) or FQDN (e.g. example.test)."))
160
+ Util::Json.emit(action: "remove", status: "error",
161
+ error: "invalid parameter format") if opts[:json]
162
+ return 1
163
+ end
164
+
165
+ mid = machine.id || "unknown"
166
+ hoster = Util::HostsFile.new({ machine: machine, ui: @env.ui }, owner_id: mid)
167
+
168
+ unless ENV["VDHM_DRY_RUN"] == "1" || hoster.elevated?
169
+ UiHelpers.error(@env.ui, "#{UiHelpers.e(:error, no_emoji: opts[:no_emoji])} " +
170
+ ::I18n.t("errors.not_admin",
171
+ default: "Administrator/root privileges required to modify hosts at %{path}.",
172
+ path: hoster.printable_path))
173
+ return 1
174
+ end
175
+
176
+ if remove_key
177
+ return 0 if opts[:dry] && Util::Json.emit(action: "remove", status: "dry-run",
178
+ data: { owner: mid, key: remove_key })
179
+
180
+ removed = if ipv4?(remove_key)
181
+ hoster.remove_entries!(ips: [remove_key], domains: [])
182
+ else
183
+ hoster.remove_entries!(ips: [], domains: [remove_key])
184
+ end
185
+ Util::Json.emit(action: "remove", status: "success",
186
+ data: { removed: removed, mode: "filtered" })
187
+ return 0
188
+ end
189
+
190
+ return 0 if opts[:dry] && Util::Json.emit(action: "remove", status: "dry-run", data: { owner: mid })
191
+
192
+ vm_label = (machine.name rescue nil) || mid
193
+ unless confirm_all_removal?(@env.ui, vm_label, no_emoji: opts[:no_emoji])
194
+ UiHelpers.say(@env.ui, "#{UiHelpers.e(:info, no_emoji: opts[:no_emoji])} " +
195
+ ::I18n.t("messages.remove_all_cancelled", default: "Cancelled. Nothing removed."))
196
+ Util::Json.emit(action: "remove", status: "cancelled", data: { owner: mid })
197
+ return 0
198
+ end
199
+
200
+ removed_block = hoster.remove!
201
+ Util::Json.emit(action: "remove", status: "success", data: { removed: removed_block })
202
+ return 0
203
+
204
+ when "view"
205
+ hoster = Util::HostsFile.new({ ui: @env.ui }, owner_id: "any")
206
+ managed_map = hoster.entries_in_blocks(:all)
207
+ planned_map = {}
208
+
209
+ begin
210
+ vm_for_view = nil
211
+ with_target_vms(argv.dup, single_target: true) { |m| vm_for_view = m }
212
+ if vm_for_view
213
+ cfg = vm_for_view.config.docker_hosts
214
+ planned_map = compute_entries(vm_for_view, cfg)
215
+ end
216
+ rescue StandardError
217
+ planned_map = {}
218
+ end
219
+
220
+ pairs = []
221
+ seen = {}
222
+
223
+ planned_map.each do |fqdn, ip|
224
+ next if fqdn.to_s.empty? || ip.to_s.empty?
225
+ key = "#{ip}\0#{fqdn}"
226
+ next if seen[key]
227
+ pairs << [ip.to_s, fqdn.to_s]
228
+ seen[key] = true
229
+ end
230
+
231
+ managed_map.each do |fqdn, ips|
232
+ next if fqdn.to_s.empty?
233
+ Array(ips).each do |ip|
234
+ next if ip.to_s.empty?
235
+ key = "#{ip}\0#{fqdn}"
236
+ next if seen[key]
237
+ pairs << [ip.to_s, fqdn.to_s]
238
+ seen[key] = true
239
+ end
240
+ end
241
+
242
+ if opts[:json]
243
+ Util::Json.emit(
244
+ action: "view",
245
+ status: "success",
246
+ data: { entries: pairs.map { |ip, fqdn| { ip: ip, host: fqdn } } }
247
+ )
248
+ return 0
249
+ end
250
+
251
+ emoji_info = UiHelpers.e(:info, no_emoji: opts[:no_emoji])
252
+
253
+ if pairs.empty?
254
+ UiHelpers.say(@env.ui, "#{emoji_info} " + ::I18n.t("messages.no_entries",
255
+ default: "No hosts entries configured."))
256
+ return 0
257
+ end
258
+
259
+ header =
260
+ if planned_map.any?
261
+ ::I18n.t("messages.view_header", default: "Planned hosts entries:")
262
+ else
263
+ ::I18n.t("messages.view_managed_header", default: "Managed hosts entries:")
264
+ end
265
+
266
+ UiHelpers.say(@env.ui, "#{emoji_info} #{header}")
267
+ pad = pairs.map { |ip, _| ip.length }.max || 0
268
+ pairs.each do |ip, fqdn|
269
+ UiHelpers.say(@env.ui, " • #{ip.ljust(pad)} -> #{fqdn}")
270
+ end
271
+ return 0
272
+
273
+ else
274
+ print_help(@env.ui, no_emoji: opts[:no_emoji])
275
+ return 0
276
+ end
277
+ rescue => e
278
+ Util::Json.emit(action: "command", status: "error", error: e.message)
279
+ 1
280
+ end
281
+
282
+ private
283
+
284
+ def confirm_all_removal?(ui, vm_label, no_emoji:)
285
+ prompt = ::I18n.t(
286
+ "messages.confirm_remove_all",
287
+ default: "This will remove ALL managed hosts entries for %{vm}. Continue? (yes/No)",
288
+ vm: vm_label.to_s
289
+ )
290
+
291
+ line = "#{UiHelpers.e(:question, no_emoji: no_emoji)} #{prompt} "
292
+
293
+ begin
294
+ $stdout.print(line)
295
+ $stdout.flush
296
+ answer = ($stdin.gets || "").to_s
297
+ rescue
298
+ answer = ""
299
+ ensure
300
+ $stdout.puts ""
301
+ end
302
+
303
+ %w[y yes].include?(answer.strip.downcase)
304
+ end
305
+
306
+ def parse_strict_mapping_from_argv!(argv, ui, no_emoji, json: false)
307
+ return [nil, nil] if argv.empty?
308
+
309
+ if argv.length >= 2
310
+ cand_ip, cand_host = argv[0], argv[1]
311
+
312
+ unless ipv4?(cand_ip)
313
+ msg = ::I18n.t("messages.invalid_ip", default: "Invalid IPv4 address: %{ip}", ip: cand_ip)
314
+ UiHelpers.error(ui, "#{UiHelpers.e(:error, no_emoji: no_emoji)} #{msg}")
315
+ raise ArgumentError, msg
316
+ end
317
+
318
+ unless fqdn?(cand_host)
319
+ msg = ::I18n.t("messages.invalid_host", default: "Invalid host/FQDN: %{host}", host: cand_host)
320
+ UiHelpers.error(ui, "#{UiHelpers.e(:error, no_emoji: no_emoji)} #{msg}")
321
+ raise ArgumentError, msg
322
+ end
323
+
324
+ argv.shift(2)
325
+ return [cand_ip, cand_host]
326
+ end
327
+
328
+ if ipv4?(argv[0])
329
+ msg = ::I18n.t("messages.missing_mapping",
330
+ default: "Provide both IP and FQDN (e.g. `vagrant hosts apply 1.2.3.4 example.test`).")
331
+ UiHelpers.error(ui, "#{UiHelpers.e(:error, no_emoji: no_emoji)} #{msg}")
332
+ raise ArgumentError, msg
333
+ end
334
+
335
+ [nil, nil]
336
+ end
337
+
338
+ def ipv4?(s)
339
+ return false unless s.is_a?(String) && s =~ /\A\d{1,3}(?:\.\d{1,3}){3}\z/
340
+ s.split(".").all? { |x| (0..255).cover?(x.to_i) }
341
+ end
342
+
343
+ def fqdn?(s)
344
+ return false unless s.is_a?(String)
345
+ s = s.strip
346
+ return false if s.empty? || s.size > 253 || s.start_with?(".") || s.end_with?(".")
347
+ s.split(".").all? { |lab| lab =~ /\A[a-zA-Z0-9](?:[a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\z/ }
348
+ end
349
+
350
+ def parse_mapping_from_argv!(argv)
351
+ return [nil, nil] if argv.length < 2
352
+ cand_ip, cand_host = argv[0], argv[1]
353
+ if ipv4?(cand_ip) && cand_host !~ /\A-/
354
+ argv.shift(2)
355
+ [cand_ip, cand_host]
356
+ else
357
+ [nil, nil]
358
+ end
359
+ end
360
+
361
+ def parse_remove_key_from_argv!(argv)
362
+ return nil if argv.empty?
363
+ cand = argv[0]
364
+ if ipv4?(cand) || cand =~ /\A[a-zA-Z0-9\.\-]+\z/
365
+ argv.shift(1)
366
+ cand
367
+ else
368
+ nil
369
+ end
370
+ end
371
+
372
+ def compute_entries(machine, cfg)
373
+ entries = {}
374
+
375
+ (cfg.domains || {}).each do |domain, ip|
376
+ next if domain.to_s.strip.empty?
377
+ next if ip.to_s.strip.empty?
378
+ entries[domain] = ip
379
+ end
380
+
381
+ if cfg.domain && !cfg.domain.strip.empty? && !entries.key?(cfg.domain)
382
+ ip = cfg.ip.to_s.strip
383
+ ip = Util::Docker.ip_for_container(cfg.container_name).to_s.strip if ip.empty? && !cfg.container_name.to_s.strip.empty?
384
+ entries[cfg.domain] = ip unless ip.empty?
385
+ end
386
+
387
+ entries
388
+ end
389
+
390
+ def print_help(ui, topic: nil, no_emoji: false)
391
+ return print_topic_help(ui, topic.to_s.downcase.strip, no_emoji: no_emoji) if topic && !topic.to_s.strip.empty?
392
+
393
+ emoji_info = UiHelpers.e(:info, no_emoji: no_emoji)
394
+ title = ::I18n.t("help.title", default: "Vagrant Docker Hosts Manager")
395
+ UiHelpers.say(ui, "#{emoji_info} #{title}")
396
+
397
+ UiHelpers.say(ui, ::I18n.t("help.usage",
398
+ default: "Usage: vagrant hosts <apply|remove|view|help|version> [options]"))
399
+ UiHelpers.say(ui, "")
400
+
401
+ UiHelpers.say(ui, ::I18n.t("help.commands_header", default: "Commands:"))
402
+ cmds = ::I18n.t("help.commands", default: {})
403
+ cmds = {} unless cmds.is_a?(Hash)
404
+ cmds.each_value { |line| UiHelpers.say(ui, " #{line}") }
405
+
406
+ UiHelpers.say(ui, "")
407
+ UiHelpers.say(ui, ::I18n.t("help.options_header", default: "Options:"))
408
+ optsh = ::I18n.t("help.options", default: {})
409
+ optsh = {} unless optsh.is_a?(Hash)
410
+ optsh.each_value { |line| UiHelpers.say(ui, " #{line}") }
411
+
412
+ topics = (::I18n.t("help.topic", default: {}).is_a?(Hash) ? ::I18n.t("help.topic").keys.map(&:to_s) : [])
413
+ topics = %w[apply remove view version help] if topics.empty?
414
+
415
+ UiHelpers.say(ui, "")
416
+ UiHelpers.say(ui, ::I18n.t("help.topics_header", default: "Help topics:"))
417
+ UiHelpers.say(ui, " vagrant hosts help <#{topics.join('|')}>")
418
+ end
419
+
420
+ def print_topic_help(ui, topic, no_emoji: false)
421
+ base = "help.topic.#{topic}"
422
+ wrench = UiHelpers.e(:info, no_emoji: no_emoji)
423
+
424
+ title = ::I18n.t("#{base}.title", default: topic)
425
+ usage = ::I18n.t("#{base}.usage", default: nil)
426
+ desc = ::I18n.t("#{base}.description", default: nil)
427
+ opts_hash = ::I18n.t("#{base}.options", default: {})
428
+ examples = ::I18n.t("#{base}.examples", default: [])
429
+
430
+ t_head = ::I18n.t("help.topic_header", default: "Help: vagrant hosts %{topic}", topic: topic)
431
+ t_usage = ::I18n.t("help.usage_label", default: "Usage:")
432
+ t_desc = ::I18n.t("help.description_label", default: "Description:")
433
+ t_opts = ::I18n.t("help.options_label", default: "Options:")
434
+ t_exs = ::I18n.t("help.examples_label", default: "Examples:")
435
+
436
+ UiHelpers.say(ui, "#{wrench} #{t_head}")
437
+
438
+ UiHelpers.say(ui, " #{t_usage}")
439
+ UiHelpers.say(ui, " #{usage || "vagrant hosts #{topic} [options]"}")
440
+
441
+ if desc && !desc.strip.empty?
442
+ UiHelpers.say(ui, " #{t_desc}")
443
+ UiHelpers.say(ui, " #{desc}")
444
+ end
445
+
446
+ if opts_hash.is_a?(Hash) && !opts_hash.empty?
447
+ UiHelpers.say(ui, " #{t_opts}")
448
+ opts_hash.each_value { |line| UiHelpers.say(ui, " #{line}") }
449
+ end
450
+
451
+ if examples.is_a?(Array) && !examples.empty?
452
+ UiHelpers.say(ui, " #{t_exs}")
453
+ examples.each { |ex| UiHelpers.say(ui, " #{ex}") }
454
+ end
455
+ end
456
+ end
457
+ end