vagrant-xapi 0.0.1 → 1.0.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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +83 -0
  3. data/LICENSE +21 -0
  4. data/README.md +460 -4
  5. data/docs/architecture.md +1420 -0
  6. data/docs/operations.md +531 -0
  7. data/lib/vagrant-xapi/action/base.rb +103 -0
  8. data/lib/vagrant-xapi/action/cloud_init.rb +130 -0
  9. data/lib/vagrant-xapi/action/create.rb +127 -0
  10. data/lib/vagrant-xapi/action/destroy.rb +68 -0
  11. data/lib/vagrant-xapi/action/network.rb +229 -0
  12. data/lib/vagrant-xapi/action/package.rb +103 -0
  13. data/lib/vagrant-xapi/action/power.rb +185 -0
  14. data/lib/vagrant-xapi/action/snapshots.rb +43 -0
  15. data/lib/vagrant-xapi/action.rb +317 -0
  16. data/lib/vagrant-xapi/api/client.rb +393 -0
  17. data/lib/vagrant-xapi/api/errors.rb +98 -0
  18. data/lib/vagrant-xapi/api/http_transfer.rb +332 -0
  19. data/lib/vagrant-xapi/api/redactor.rb +77 -0
  20. data/lib/vagrant-xapi/api/session_registry.rb +59 -0
  21. data/lib/vagrant-xapi/api/task.rb +156 -0
  22. data/lib/vagrant-xapi/api/transport.rb +249 -0
  23. data/lib/vagrant-xapi/cap.rb +90 -0
  24. data/lib/vagrant-xapi/command/adopt.rb +92 -0
  25. data/lib/vagrant-xapi/command/base.rb +79 -0
  26. data/lib/vagrant-xapi/command/console.rb +87 -0
  27. data/lib/vagrant-xapi/command/diff.rb +267 -0
  28. data/lib/vagrant-xapi/command/hosts.rb +63 -0
  29. data/lib/vagrant-xapi/command/info.rb +94 -0
  30. data/lib/vagrant-xapi/command/marked.rb +85 -0
  31. data/lib/vagrant-xapi/command/orphans.rb +96 -0
  32. data/lib/vagrant-xapi/command/root.rb +55 -0
  33. data/lib/vagrant-xapi/command/templates.rb +91 -0
  34. data/lib/vagrant-xapi/config.rb +333 -0
  35. data/lib/vagrant-xapi/errors.rb +113 -0
  36. data/lib/vagrant-xapi/extensions.rb +80 -0
  37. data/lib/vagrant-xapi/glue.rb +330 -0
  38. data/lib/vagrant-xapi/legacy/plugin.rb +61 -0
  39. data/lib/vagrant-xapi/legacy/xenapi.rb +129 -0
  40. data/lib/vagrant-xapi/plugin.rb +58 -0
  41. data/lib/vagrant-xapi/provider.rb +135 -0
  42. data/lib/vagrant-xapi/services/adoption.rb +73 -0
  43. data/lib/vagrant-xapi/services/base.rb +242 -0
  44. data/lib/vagrant-xapi/services/box_source.rb +218 -0
  45. data/lib/vagrant-xapi/services/console.rb +115 -0
  46. data/lib/vagrant-xapi/services/content_diff.rb +96 -0
  47. data/lib/vagrant-xapi/services/destroyer.rb +104 -0
  48. data/lib/vagrant-xapi/services/diff_disks.rb +346 -0
  49. data/lib/vagrant-xapi/services/differ.rb +382 -0
  50. data/lib/vagrant-xapi/services/disk_image.rb +122 -0
  51. data/lib/vagrant-xapi/services/disks.rb +281 -0
  52. data/lib/vagrant-xapi/services/exporter.rb +128 -0
  53. data/lib/vagrant-xapi/services/file_diff.rb +249 -0
  54. data/lib/vagrant-xapi/services/guest_files.rb +730 -0
  55. data/lib/vagrant-xapi/services/hosts.rb +55 -0
  56. data/lib/vagrant-xapi/services/image_upload.rb +172 -0
  57. data/lib/vagrant-xapi/services/inventory.rb +225 -0
  58. data/lib/vagrant-xapi/services/ip_detector.rb +107 -0
  59. data/lib/vagrant-xapi/services/lock.rb +293 -0
  60. data/lib/vagrant-xapi/services/lookup.rb +181 -0
  61. data/lib/vagrant-xapi/services/networks.rb +248 -0
  62. data/lib/vagrant-xapi/services/power.rb +108 -0
  63. data/lib/vagrant-xapi/services/rfb.rb +129 -0
  64. data/lib/vagrant-xapi/services/snapshots.rb +81 -0
  65. data/lib/vagrant-xapi/services/template_cache.rb +263 -0
  66. data/lib/vagrant-xapi/services/tunnels.rb +217 -0
  67. data/lib/vagrant-xapi/services/vm_builder.rb +215 -0
  68. data/lib/vagrant-xapi/version.rb +7 -0
  69. data/lib/vagrant-xapi/xenserver/config.rb +69 -0
  70. data/lib/vagrant-xapi/xenserver/migrate.rb +103 -0
  71. data/lib/vagrant-xapi/xenserver/plugin.rb +96 -0
  72. data/lib/vagrant-xapi/xenserver/provider.rb +22 -0
  73. data/lib/vagrant-xapi/xenserver/takeover.rb +79 -0
  74. data/lib/vagrant-xapi.rb +21 -2
  75. data/lib/xenapi.rb +22 -0
  76. data/locales/en.yml +517 -0
  77. data/locales/legacy.en.yml +6 -0
  78. metadata +86 -5
@@ -0,0 +1,1420 @@
1
+ # Architecture
2
+
3
+ This document describes what vagrant-xapi is, how it is built, and why. It
4
+ is the reference for design decisions; together with the
5
+ [README](../README.md) (user-facing options) and
6
+ [operations.md](operations.md) (running, troubleshooting, developing) it is
7
+ the complete description of the plugin.
8
+
9
+ ## 1. Purpose and scope
10
+
11
+ vagrant-xapi is a Vagrant provider (gem `vagrant-xapi`, provider `:xapi`)
12
+ that manages VMs on XenServer 8 and XCP-ng through XAPI. It supports every
13
+ action and provider capability Vagrant core asks for. It replaces
14
+ [xapi-project/vagrant-xenserver](https://github.com/xapi-project/vagrant-xenserver)
15
+ 0.0.18, which only supports the basic lifecycle and `public_network`.
16
+
17
+ ### Goals
18
+
19
+ - All actions Vagrant core asks a provider for: `up`, `halt`, `destroy`,
20
+ `reload`, `suspend`, `resume`, `provision`, `ssh`, `ssh_run`, `package`,
21
+ `snapshot_save`, `snapshot_restore`, `snapshot_delete` (plus
22
+ `sync_folders`).
23
+ - All provider capabilities core uses: `snapshot_list`, `forwarded_ports`,
24
+ `public_address`, `nic_mac_addresses`, `configure_disks`, `cleanup_disks`,
25
+ `validate_disk_ext`, `default_disk_exts`, `set_default_disk_ext`.
26
+ - Works against a standalone host and against a pool, on every SR type XAPI
27
+ supports.
28
+ - `vagrant up --provider=xapi` works with only a host and credentials.
29
+ - Boxes of vagrant-xenserver and of libvirt work without manual conversion.
30
+ - Vagrantfiles of vagrant-xenserver (`config.vm.provider :xenserver` with
31
+ `xs_*` options) keep working, with the machines that plugin created
32
+ (section 17).
33
+
34
+ ### Supported versions
35
+
36
+ - Vagrant 2.4.x (Ruby 3.0 up to, not including, 3.5, as Vagrant's gemspec
37
+ requires); the tests run against Vagrant 2.4.9 (pinned in the Gemfile).
38
+ - XenServer 8 and XCP-ng 8.3; only the current releases are supported.
39
+ Optional XAPI features (qcow2 import, vTPM) are detected at runtime. If one is missing, the user only gets an error when
40
+ they ask for that feature.
41
+
42
+ ### Non-goals
43
+
44
+ - No dependency on Xen Orchestra, not even an optional one.
45
+ - No PV guests (pygrub); only HVM with BIOS or UEFI. PV boxes are switched to
46
+ HVM boot on import, with a warning.
47
+ - No Go plugin: vagrant-go was removed from Vagrant (GH-13622), although the
48
+ Go pages are still in the 2.4.9 docs.
49
+ - No management of the pool itself (SRs, bonds, VLANs), except internal
50
+ networks for `private_network`.
51
+
52
+ ## 2. Compared with vagrant-xenserver 0.0.18
53
+
54
+ vagrant-xapi does everything vagrant-xenserver does, and more. PV guests are
55
+ dropped. Vagrantfiles written for it, with the provider name `:xenserver`
56
+ and its `xs_*` options, keep working (section 17); `:xapi` is the provider
57
+ for new Vagrantfiles. `use_himn` lives on as `ip_detection: [:himn]` with
58
+ `ssh_via_host = true`. The left column comes from the old plugin's source
59
+ (master, November 2024).
60
+
61
+ | Area | vagrant-xenserver 0.0.18 | vagrant-xapi |
62
+ | --- | --- | --- |
63
+ | API transport | XML-RPC via gem `xenapi` (2011) and `xmlrpc`; load error in issue #54 | JSON-RPC with only the stdlib, no runtime gems |
64
+ | Connection | Default `http` on port 80, password unencrypted; uploads with `curl --insecure` and `session_id` in the URL | HTTPS on 443 with certificate check or fingerprint pin; session as cookie |
65
+ | Sessions | Never logged out | One shared session per process, logout at exit |
66
+ | Pool | No handling of a pool member (issue #60) | Redirect to the master on `HOST_IS_SLAVE` |
67
+ | Machine id and state | OpaqueRef; state as XAPI string | VM UUID; state as symbol, including `:paused` |
68
+ | `up`, `halt`, `destroy`, `suspend`, `resume`, `provision`, `ssh` | Yes; `halt` only `clean_shutdown` without fallback | Yes; graceful, then clean, then hard shutdown |
69
+ | `reload` | Halt and boot; config changes are not applied | Re-applies CPU, memory, firmware, VIFs and disks |
70
+ | Boxes | `xenserver` box with XVA, XVA URL or VHD | Also qcow2 and raw; besides `xenserver` also `xapi` and `libvirt` boxes |
71
+ | Template cache | Per box name and version, in-process mutex only; VHD reuse on md5 of the first MB | Per SHA-256 and SR, with a lock that works across processes and users |
72
+ | CPUs | Not configurable (issue #42) | `cpus`, `cores_per_socket` |
73
+ | Memory | Static | Static plus dynamic minimum |
74
+ | Firmware | PV with pygrub or the template default | BIOS, UEFI, Secure Boot, vTPM; no PV |
75
+ | Storage | Only the pool's default SR | `sr`, `template_sr`, SR per disk; `:fast` or `:full` clone |
76
+ | Host placement | No | `affinity_host` |
77
+ | `public_network` | Only by bridge name, and required unless HIMN | By name, UUID or bridge, with MAC, MTU and static IP; without one a default VIF |
78
+ | `private_network` | No | Internal network or existing network |
79
+ | `forwarded_port`, `vagrant port` | No | SSH tunnels |
80
+ | IP detection | `0/ip` from guest metrics, or HIMN | Configurable order: guest metrics (`ipv4/N`), static via cloud-init, HIMN |
81
+ | SSH through dom0 | Only with HIMN, host key checking off | Optional, host key checking on |
82
+ | Hostname | Ignored (issue #43) | `SetHostname` |
83
+ | Synced folders | rsync and NFS; host IP via `ip route` or `route get` | rsync, NFS and SMB; host IP in Ruby |
84
+ | `config.vm.disk` | No | Disk, DVD/ISO, growing the primary disk |
85
+ | `config.vm.cloud_init` | No | Yes, including `network-config` |
86
+ | Snapshots | No | save, restore, delete, list, push, pop; optionally with memory |
87
+ | `vagrant package` | No | XVA, VHD or qcow2 |
88
+ | Own commands | No | `vagrant xapi info`, `console`, `adopt`, `templates`, `orphans` |
89
+ | Error messages | Partly raw XAPI errors and stack traces (issue #20) | Always a `VagrantError` with readable text |
90
+ | Cleanup on `destroy` | All disk VDIs of the VM | Only marked objects, including snapshots, cloud-init VDI and internal networks |
91
+ | Tests | None | rspec against a fake XAPI, and acceptance scenarios for XCP-ng and XenServer |
92
+
93
+ ## 3. Constraints from Vagrant
94
+
95
+ The plugin follows the rules of the
96
+ [Vagrant 2.4.9 plugin documentation](https://developer.hashicorp.com/vagrant/docs/plugins)
97
+ (all 15 pages, including the
98
+ [source on GitHub](https://github.com/hashicorp/web-unified-docs/tree/main/content/vagrant/v2.4.9/content/docs/plugins)).
99
+ Where the docs point to the source code, Vagrant main of November 2026
100
+ applies.
101
+
102
+ | Rule | Consequence for vagrant-xapi | Source |
103
+ | --- | --- | --- |
104
+ | A plugin is a RubyGem, set up with `bundle gem` | Gemfile with `vagrant` from git (pinned to a release tag) in group `:development` and the plugin in group `:plugins` (as `gem "vagrant-xapi", path: "."`: `gemspec` inside a group block ignores the group); test with `bundle exec vagrant` | [Development basics](https://developer.hashicorp.com/vagrant/docs/plugins/development-basics) |
105
+ | The gemspec must not depend on the gem `vagrant` | Only our own runtime code; no runtime gems | [Packaging](https://developer.hashicorp.com/vagrant/docs/plugins/packaging) |
106
+ | The plugin definition must always load; load components lazily in their block | `plugin.rb` only holds registrations, with `require_relative` inside each block | [Development basics](https://developer.hashicorp.com/vagrant/docs/plugins/development-basics) |
107
+ | A stack trace is a bug | Every error is a subclass of `Vagrant::Errors::VagrantError` with text from `locales/en.yml`; XAPI errors are translated | [Development basics](https://developer.hashicorp.com/vagrant/docs/plugins/development-basics) |
108
+ | No `puts`/`gets` | All output through `env[:ui]` or `machine.ui`; progress with `ui.clear_line` and `ui.detail` | [Development basics](https://developer.hashicorp.com/vagrant/docs/plugins/development-basics) |
109
+ | Config with `UNSET_VALUE`, `finalize!`, `validate(machine)` | Defaults only in `finalize!`; `validate` starts with `_detected_errors` and returns an empty list when valid; own `merge` for lists and hashes | [Configuration](https://developer.hashicorp.com/vagrant/docs/plugins/configuration) |
110
+ | The provider config is named after the provider | `config("xapi", :provider)`; works without a provider block once host and login are known (also from environment variables) | [Providers](https://developer.hashicorp.com/vagrant/docs/plugins/providers) |
111
+ | The provider class documents itself in [provider.rb](https://github.com/hashicorp/vagrant/blob/main/lib/vagrant/plugin/v2/provider.rb) | `action(name)`, `state` (a `MachineState` with a symbol id), `ssh_info` (nil while SSH is impossible), class methods `usable?` and `installed?`, `machine_id_changed` | provider.rb |
112
+ | `:not_created` clears the data_dir | A state lookup that does not find the VM returns `:not_created`; the plugin removes its own files in `data_dir` on destroy | provider.rb, [Providers](https://developer.hashicorp.com/vagrant/docs/plugins/providers) |
113
+ | Box: `metadata.json` has `provider` equal to the provider name | Our own boxes have `"provider": "xapi"`; the rest of the box format is ours | [Providers](https://developer.hashicorp.com/vagrant/docs/plugins/providers) |
114
+ | Parallel only with thread-safe action stacks | `provider("xapi", parallel: true, box_format: [...])`; no shared mutable state except the session cache and locks | [Providers](https://developer.hashicorp.com/vagrant/docs/plugins/providers) |
115
+ | Use built-in middleware | Stacks are built on `HandleBox`, `ConfigValidate`, `Provision`, `SyncedFolders`, `SyncedFolderCleanup`, `WaitForCommunicator` (in a subclass that also retries a broken pipe, see below), `SetHostname`, `Disk`, `CleanupDisks`, `CloudInitSetup`, `CloudInitWait`, `GracefulHalt`, `HandleForwardedPortCollisions`, `ProvisionerCleanup`, `DestroyConfirm` | [builtin](https://github.com/hashicorp/vagrant/tree/main/lib/vagrant/action/builtin) |
116
+ | Only public action hooks are stable | No hooks on internal Vagrant actions | [Action hooks](https://developer.hashicorp.com/vagrant/docs/plugins/action-hooks) |
117
+ | Commands use `parse_options` and `with_target_vms` | The subcommands under `vagrant xapi` follow that pattern | [Commands](https://developer.hashicorp.com/vagrant/docs/plugins/commands) |
118
+ | Test with vagrant-spec | Unit tests, and acceptance scenarios of our own against a real pool instead of vagrant-spec (section 15) | [Packaging](https://developer.hashicorp.com/vagrant/docs/plugins/packaging) |
119
+
120
+ Not in the docs but in the source, and relevant: the provider options
121
+ `box_format` (list of box providers the provider accepts), `priority` and
122
+ `defaultable`; `config.vm.disk` and `config.vm.cloud_init` are no longer
123
+ experimental; `CloudInitSetup` builds an ISO through the host capability
124
+ `create_iso` and adds it as a `:dvd` disk, which the provider attaches in
125
+ `configure_disks`.
126
+
127
+ ## 4. Layers
128
+
129
+ The plugin has three layers. Only the client talks to the pool; only the
130
+ integration layer knows Vagrant.
131
+
132
+ ```mermaid
133
+ flowchart TB
134
+ subgraph local["Local machine (runs Vagrant)"]
135
+ subgraph integration["Vagrant integration (knows Vagrant)"]
136
+ plugin["plugin.rb: registrations"]
137
+ provider["provider.rb: state, ssh_info"]
138
+ actions["action.rb + action/*: middleware stacks"]
139
+ caps["cap.rb: capabilities"]
140
+ commands["command/*: vagrant xapi ..."]
141
+ config["config.rb, errors.rb, glue.rb, locales/en.yml"]
142
+ end
143
+ subgraph services["Services (XAPI logic, plain Ruby)"]
144
+ svc["template_cache, image_upload, vm_builder, networks,<br/>disks, snapshots, power, ip_detector, destroyer,<br/>exporter, inventory, console, lock, tunnels,<br/>differ, diff_disks, guest_files, file_diff"]
145
+ end
146
+ subgraph api["XAPI client (stdlib only)"]
147
+ client["client.rb: JSON-RPC, sessions, features"]
148
+ transport["transport.rb: TLS, pin, connection pool"]
149
+ task["task.rb: Async tasks via event.from"]
150
+ http["http_transfer.rb: /import, /export"]
151
+ end
152
+ end
153
+ pool[("XAPI pool master<br/>https://host/jsonrpc")]
154
+ guest[("Guest VM")]
155
+ integration --> services --> api --> pool
156
+ actions -. "ssh, tunnels (ssh -N -L)" .-> guest
157
+ ```
158
+
159
+ SSH and the port-forward tunnels bypass XAPI: they go straight to the guest,
160
+ or through dom0 with `ssh_via_host`.
161
+
162
+ ### Code map
163
+
164
+ ```
165
+ lib/vagrant-xapi.rb entry point: plugin + i18n
166
+ lib/vagrant-xapi/
167
+ plugin.rb registrations only (provider, config, caps, command)
168
+ extensions.rb hook points for the layers below (section 17)
169
+ xenserver/ compatibility with vagrant-xenserver Vagrantfiles (section 17)
170
+ legacy/ temporary XenApi::Client stand-in for their triggers (section 17)
171
+ provider.rb action lookup, state, ssh_info
172
+ config.rb provider options, defaults, validation
173
+ action.rb the action stacks
174
+ action/ our middleware (create, power, network, destroy,
175
+ snapshots, package, cloud_init, base)
176
+ cap.rb provider capabilities
177
+ command/ vagrant xapi info|console|adopt|templates|orphans|
178
+ marked|hosts|diff
179
+ glue.rb Vagrant <-> services: client, context, VmSpec, VIF plan
180
+ errors.rb translation of client/service errors to VagrantErrors
181
+ api/ XAPI client: client, transport, task, http_transfer,
182
+ session_registry, redactor, errors
183
+ services/ XAPI logic, one class per concern
184
+ locales/en.yml every user-facing text
185
+ spec/ unit tests and the fake XAPI
186
+ test/acceptance/ scenarios against a real pool
187
+ ```
188
+
189
+ ### Services contract
190
+
191
+ Services get a `Services::Context` with:
192
+
193
+ - `client`: the logged-in `Api::Client`;
194
+ - `ui`: an object with `info/detail/warn(key, **params)`,
195
+ `progress(key, done, total, **params)` and `clear_progress`. Keys are
196
+ message keys under `vagrant_xapi.messages` in `locales/en.yml`.
197
+ `Glue::UiAdapter` maps them to the Vagrant UI;
198
+ - `cancel`: a callable that returns true once the user pressed Ctrl-C
199
+ (`env[:interrupted]` in actions);
200
+ - `logger`: a Log4r logger.
201
+
202
+ Services raise `Services::Error(key, **params)` (key under
203
+ `vagrant_xapi.errors`) or `Api::*` errors. They never raise Vagrant errors.
204
+
205
+ ## 5. Design decisions
206
+
207
+ 1. **XAPI client.** JSON-RPC 2.0 on `https://<host>/jsonrpc` with `net/http`
208
+ and `json` from the stdlib. No `xmlrpc` or `xenapi` gem: they caused load
209
+ errors in the old plugin (issue #54), and the gem `xenapi` is from 2011.
210
+ `client.call("VM.get_record", ref)` prepends the session. Capitalized
211
+ classes also have a proxy (`client.VM.get_record(ref)`); lowercase ones
212
+ (`host`, `pool`, ...) do not, because they would clash with the client's
213
+ own methods.
214
+ 2. **Pool master.** A `HOST_IS_SLAVE` error at login carries the master's
215
+ address; the client reconnects to it (at most two redirects). This was
216
+ issue #60 in the old plugin. With a single `tls_fingerprint` the
217
+ redirect is refused with a clear message instead (the pin belongs to the
218
+ member, not to the master): point `host` at the master. With several
219
+ pins (the certificates of all hosts) the redirect is followed and the
220
+ master's certificate must be one of them, so any host can be `host`,
221
+ also after the master changed.
222
+ 3. **Sessions.** One session per Vagrant process per pool, user and
223
+ timeouts (`Api::SessionRegistry`), shared between threads (Vagrant runs
224
+ machines in parallel). Login is behind a mutex and builds the session and
225
+ the feature list in local variables before swapping them in, so other
226
+ threads never see a half state. Each call takes the session once. On
227
+ `SESSION_INVALID` the client logs in again, unless another thread already
228
+ did, and retries the call once. All sessions are logged out in `at_exit`.
229
+ Login sends originator `vagrant-xapi/<version>`.
230
+ 4. **Long operations.** Always the `Async.` variant, waiting for the task with
231
+ `event.from` (5 s per call) and progress to the UI. If `event.from` fails
232
+ (RBAC, `EVENTS_LOST`) the client polls `task.get_record` every
233
+ second. A dropped connection or read timeout while waiting is retried
234
+ for up to 60 s: giving up would destroy the task record while XAPI goes
235
+ on with the work, e.g. a `VM.copy` whose VM nobody would then mark or
236
+ clean up. On Ctrl-C the task is cancelled with `task.cancel`; on timeout
237
+ too. Tasks are always destroyed afterwards.
238
+ 5. **HTTP transfers.** Uploads and downloads (`/import`, `/import_raw_vdi`,
239
+ `/export`, `/export_raw_vdi`) stream in Ruby with `net/http`. The
240
+ `session_id` travels as a cookie, never in the URL. The handlers answer
241
+ an expired or evicted session with 401; as for `SESSION_INVALID`, the
242
+ client then logs in again once and starts the transfer (or the qcow2
243
+ probe) over. There is no `curl`
244
+ subprocess, so no secrets in `ps`. Uploads that need a result create a task
245
+ with `task.create` and pass it as `task_id`. On Ctrl-C a watcher thread
246
+ cancels the task and closes the socket; a task that is never waited for is
247
+ cancelled and destroyed.
248
+ - **Which host.** XAPI answers these handlers with a 302 to another host
249
+ when the receiving host cannot reach the SR (e.g. a local SR of a pool
250
+ member). A streamed PUT cannot follow that, so the plugin picks the host
251
+ first: for an SR, a host with a `currently_attached` PBD (the master if
252
+ it is one of them); for an export, a host that sees all the VM's SRs
253
+ (master first, then the host the VM runs on). Downloads also follow
254
+ 302s.
255
+ - **No `Expect: 100-continue`.** XAPI never answers it with
256
+ `100 Continue`, and Net::HTTP would then wait for a body it never sent.
257
+ - **Probing.** Where a handler must be asked whether it accepts something
258
+ (qcow2), an empty PUT is sent and only the status line is read.
259
+ 6. **Machine id.** The VM UUID, not the OpaqueRef. Every action looks the VM
260
+ up with `VM.get_by_uuid`; not found means `:not_created`.
261
+ 7. **Marking.** Every object the plugin creates carries a marking (section
262
+ 6). Cleanup only ever touches marked objects; the subcommands use the
263
+ marking to find orphaned VMs.
264
+ 8. **Locks across processes.** A lock is a key `vagrant-xapi.lock.<name>` in
265
+ the pool's `other_config`, set with `pool.add_to_other_config`. XAPI refuses
266
+ an existing key with `MAP_DUPLICATE_KEY`, so taking it is atomic. The value
267
+ is JSON with owner (`user@hostname:pid:random`) and expiry. After adding the
268
+ key, the owner is read back; only a lock that carries our owner counts as
269
+ held. A lock is only released by its owner. Times come from the pool
270
+ master's clock (`host.get_servertime`), so machines with skewed clocks
271
+ agree. If that time cannot be read (RBAC, an unparsable value), the lock
272
+ falls back to the local machine's clock and logs a warning; then clock skew
273
+ between machines matters again.
274
+ - **Heartbeat.** While the lock is held, a thread refreshes
275
+ `vagrant-xapi.lock.<name>.alive.0` and `.alive.1` in turn every TTL/4,
276
+ so one of them is always fresh. A lock only counts as abandoned when its
277
+ own expiry and every heartbeat of the same owner have passed. A long
278
+ import (a slow upload plus a conversion) therefore never loses its lock,
279
+ and the TTL can stay short (15 minutes for templates): it only decides
280
+ how quickly the lock of a crashed process is taken over. While holding
281
+ the lock, a heartbeat replaces whatever is in its slot (e.g. a crashed
282
+ holder's key) and logs a warning if it cannot write. The heartbeat
283
+ thread is stopped and joined before the lock is released, so no beat
284
+ lands afterwards. A successful takeover also removes the crashed
285
+ holder's heartbeat keys; an expired takeover guard is only removed if it
286
+ still has the value that was read.
287
+ - **Taking over an expired lock.** XAPI has no compare-and-swap, so two
288
+ processes that both see the same stale value could both remove it and
289
+ each think they hold the lock. A takeover therefore first takes a second
290
+ key, `vagrant-xapi.lock.<name>.takeover` (atomic in the same way,
291
+ expires after 60 s), checks that the lock still has the same stale
292
+ value and is still abandoned, replaces it, and removes the guard.
293
+ - **Waiting** for a busy template lock lasts up to 2 × `task_timeout` +
294
+ 25 minutes, which covers a live import and the takeover of a crashed
295
+ one.
296
+ - **Within one process** threads first serialize on a mutex per lock name,
297
+ so they do not poll XAPI against each other. Waiting for it has the same
298
+ deadline and reacts to Ctrl-C. An in-process mutex alone is not enough
299
+ (the old plugin had only that).
300
+ 9. **Feature detection.** At first login the client reads the API version and
301
+ product of the master and checks optional classes (`VTPM.get_all`).
302
+ `MESSAGE_METHOD_UNKNOWN` means "not available". qcow2 import is probed on
303
+ first use (see section 7) and remembered per session.
304
+ 10. **Errors.** `Errors.translate` turns every `Api::*` and `Services::Error`
305
+ into a `VagrantError` with text from `locales/en.yml`. Common XAPI codes
306
+ have their own text (`SESSION_AUTHENTICATION_FAILED`,
307
+ `RBAC_PERMISSION_DENIED`, `SR_FULL`, `SR_BACKEND_FAILURE_44`,
308
+ `HOST_NOT_ENOUGH_FREE_MEMORY`, `NO_HOSTS_AVAILABLE`, `VM_BAD_POWER_STATE`,
309
+ `LICENCE_RESTRICTION`, `VM_REQUIRES_SR`, `VM_REQUIRES_NETWORK`,
310
+ `OPERATION_NOT_ALLOWED`, `OTHER_OPERATION_IN_PROGRESS`,
311
+ `VM_MISSING_PV_DRIVERS`, `VDI_IN_USE`, `HOST_OFFLINE`); others show the
312
+ raw code, its parameters and the call. Every action stack starts with a
313
+ `TranslateErrors` middleware; capabilities, provider methods and commands
314
+ wrap their work in `Errors.wrap`. A missing interpolation value never
315
+ raises (it shows `?`), and messages pass through the redactor.
316
+ 11. **Secrets.** `Api::Redactor` keeps a process-wide list of secrets (the
317
+ password, whatever its length, and every session id) and removes them,
318
+ plus any `session_id=...` and any `user:password@` in a URL (e.g. an
319
+ `xva_url`), from every log line, error message and UI message. Login
320
+ parameters are logged as `[user], [REDACTED]`. `Config#inspect`, `to_s`
321
+ and `to_json` hide the password.
322
+ 12. **TLS.** Certificate checks are always on. A `tls_fingerprint` replaces
323
+ CA and hostname checks: the handshake's verify callback accepts the leaf
324
+ certificate only when its SHA-256 matches, and the peer certificate is
325
+ checked again after connecting. It may be a list (or a string with
326
+ commas), e.g. the certificates of all hosts of the pool; a certificate
327
+ is accepted when it matches one of them. `tls_ca_file` replaces the default trust
328
+ store. There is no "insecure" option; `tls_verify = false` without a
329
+ fingerprint is a validation error.
330
+ - **Other pool hosts** (transfers and consoles on a member) have their
331
+ own certificates, which a pin for the master or a hostname check
332
+ against an IP address would not match. The plugin asks the already
333
+ verified master for the member's certificate
334
+ (`host.get_server_certificate`) and pins exactly that one. Only if that
335
+ call fails are the configured settings used.
336
+
337
+ ## 6. Marking reference
338
+
339
+ | Object | Key (in `other_config` unless noted) | Value |
340
+ | --- | --- | --- |
341
+ | VM | tag `vagrant` | |
342
+ | VM | `vagrant-xapi.machine` | Vagrant machine name |
343
+ | VM | `vagrant-xapi.project` | Project directory (where the Vagrantfile is) |
344
+ | VM | `vagrant-xapi.user` | Login name on the local machine |
345
+ | VM | `vagrant-xapi.client` | Hostname of the local machine |
346
+ | VM | `vagrant-xapi.client_id` | Random id of the Vagrant installation (`~/.vagrant.d/data/vagrant-xapi/client-id`) |
347
+ | VM | `vagrant-xapi.version` | Plugin version that created it |
348
+ | VM | `vagrant-xapi.template` | UUID of the template it was cloned from |
349
+ | VM | `vagrant-xapi.vtpm` | `true` when the plugin created its vTPM; with `vtpm` turned off, `reload` removes that vTPM (not one that came with the box or an adopted VM) |
350
+ | Template | `vagrant-xapi.box`, `.box_version`, `.box_provider` | Box name, version, provider |
351
+ | Template | `vagrant-xapi.box_digest` | SHA-256 of the box content |
352
+ | Template | `vagrant-xapi.sr` | UUID of the SR the template is on |
353
+ | Template VDI | `vagrant-xapi.of_template` | Template UUID |
354
+ | VM's own VDI | `vagrant-xapi.vm` | VM UUID (disks of the clone, extra disks, ISOs, snapshot disks) |
355
+ | VDI from `config.vm.disk` | `vagrant-xapi.disk` | Disk name from the Vagrantfile |
356
+ | Uploaded ISO | `vagrant-xapi.sha256` | SHA-256 of the ISO, to detect changes |
357
+ | CD drive (VBD) for an ISO from an ISO SR | `vagrant-xapi.iso` | Name or UUID from the `isos` option |
358
+ | cloud-init ISO | `vagrant-xapi.cloud_init` | `true` |
359
+ | Snapshot | `vagrant-xapi.snapshot_of` | VM UUID |
360
+ | Clone of a snapshot disk (`vagrant xapi diff`) | `vagrant-xapi.vm`, `vagrant-xapi.diff`; name `vagrant-xapi diff of <vm uuid>` | VM UUID; id of the run |
361
+ | VBD of such a clone | `vagrant-xapi.diff` | Id of the run |
362
+ | Internal network | `vagrant-xapi.project`, `vagrant-xapi.network` | Project directory, network name |
363
+ | Pool | `vagrant-xapi.lock.<name>` | Lock (section 5, item 8) |
364
+ | Pool | `vagrant-xapi.lock.<name>.alive.0`, `.alive.1`, `.takeover` | Heartbeats and takeover guard of that lock |
365
+
366
+ A VM counts as marked when it has both `vagrant-xapi.machine` and
367
+ `vagrant-xapi.project`. The keys `vagrant-xapi.*` are reserved: the
368
+ `other_config` option refuses them.
369
+
370
+ ## 7. Boxes and the template cache
371
+
372
+ The provider accepts three kinds of boxes
373
+ (`box_format: ["xapi", "xenserver", "libvirt"]`) and imports every box exactly
374
+ once per SR as an XAPI template. Every VM is then a clone or copy of that
375
+ template.
376
+
377
+ | Box provider | Content | Import |
378
+ | --- | --- | --- |
379
+ | `xapi` | `metadata.json` with `provider`, `format` (`xva`, `vhd`, `qcow2` or `raw`) and `architecture: "amd64"`; one file `box.<format>` (without `format`, the first `box.*` found); optionally a Vagrantfile with provider defaults such as `firmware` | XVA via `/import`, disk image via `/import_raw_vdi` |
380
+ | `xenserver` | Box of the old plugin: `box.xva`, `box.vhd`, or only a Vagrantfile with `xva_url` | Same paths; `xva_url` via `Async.VM.import` by the host itself |
381
+ | `libvirt` | `box.img` (qcow2), or several disks in the newer libvirt format (`metadata.json` `disks: [{path: ...}]`) | `/import_raw_vdi?format=qcow2` when XAPI knows it and can read the image (no compressed clusters); otherwise `qemu-img convert -f qcow2 -O vpc -o subformat=dynamic` on the local machine and import as VHD, with a clear error when `qemu-img` is missing |
382
+
383
+ The `xva_url` of an old-plugin box is read from the box Vagrantfile's
384
+ `:xenserver` provider block (which Vagrant keeps as a `DummyConfig`), with a
385
+ text match on `xva_url = "..."` as fallback. The provider option `xva_url`
386
+ takes precedence. An `http://` URL gives a warning.
387
+
388
+ Every file a box names (`box.<format>`, the paths in a libvirt
389
+ `metadata.json`) must be a regular file inside the box directory, also after
390
+ resolving symlinks; anything else is refused (`box_path_outside`). qcow2
391
+ images that refer to other files (a backing file, or an external data file)
392
+ are refused too: converting them would make `qemu-img` read files on the
393
+ local machine and copy them into the image. Vagrant already runs a box's own
394
+ Vagrantfile as Ruby, so this is defence in depth for users who set
395
+ `config.vm.ignore_box_vagrantfile = true`, not the main trust boundary.
396
+
397
+ ### Importing a disk image
398
+
399
+ 1. Read the format and virtual size from the header: VHD footer (current size
400
+ at offset 48), qcow2 header (magic `QFI\xFB`, size at offset 24, 8 bytes
401
+ big endian), or the file size for raw. No `qemu-img` needed. The format
402
+ comes from the content, not the extension (`box.img` is usually qcow2).
403
+ 2. `VDI.create` on the target SR with that size.
404
+ 3. Stream to `/import_raw_vdi?vdi=<ref>&format=<fmt>` on the host that has
405
+ the SR attached, with progress in the UI. For qcow2 the plugin first asks
406
+ whether the pool can import it: it creates a 1 MiB scratch VDI, sends an
407
+ empty `PUT /import_raw_vdi?vdi=<scratch>&format=qcow2` and reads only the
408
+ status (XAPI rejects an unknown format before it reads the body; a known
409
+ one gets a 200), then removes the scratch VDI. The answer is remembered for
410
+ the session. If qcow2 is not supported, or there is no answer within 15 s,
411
+ the image is converted on the local machine with `qemu-img convert -f qcow2 -O vpc` and
412
+ uploaded as VHD. The answer is kept per transfer host, because pool
413
+ members can run different versions; if a host still refuses qcow2 after a
414
+ positive probe (a 4xx other than 401/403, or the connection closed while
415
+ the image is still being sent), the plugin converts, recreates the VDI
416
+ (the refused upload may have written part of it) and uploads the VHD. A
417
+ timeout, an unreachable host or a 5xx is raised as it is, not taken for
418
+ a refusal. XAPI's qcow2 import
419
+ (`qcow-stream-tool`) cannot read compressed clusters and fails only after
420
+ the whole upload with `VDI_IO_ERROR ... Compressed_unsupported`. Most
421
+ cloud images and many libvirt boxes use them, so before uploading the
422
+ plugin reads the L1 and L2 tables (a few MB) and converts an image with
423
+ compressed clusters, or with incompatible features other than the dirty
424
+ bit (zstd, extended L2 entries), straight away. A `VDI_IO_ERROR` from
425
+ `qcow-stream-tool` is also converted and retried, without marking the host
426
+ as unable to import qcow2. The same applies to qcow2 images in
427
+ `config.vm.disk`.
428
+ 4. Clone the template `Other install media`, remove its `disks` and
429
+ `install-methods` keys from `other_config` (otherwise `VM.provision` would
430
+ add an empty disk), attach the VDIs as `userdevice 0..n` (0 bootable), set
431
+ HVM boot policy `BIOS order`, boot order `cd` and firmware `bios`, and
432
+ raise memory to 1 GiB when it has less: "Other install media" has 256
433
+ MiB, too little for a current distribution, and under UEFI GRUB cannot
434
+ load the initramfs with it. The template is shared by everyone who uses
435
+ the box, so nothing from one user's configuration is baked into it:
436
+ `firmware` (also a default from the box's Vagrantfile) and `memory` are
437
+ set on each VM.
438
+ 5. `VM.set_is_a_template(true)`, switch PV boxes to HVM, set name, description
439
+ and the marking.
440
+
441
+ ### Cache key and marking
442
+
443
+ A template carries `vagrant-xapi.box`, `.box_version`, `.box_provider`,
444
+ `.box_digest` (SHA-256 of the box content; for `xva_url` boxes of the URL;
445
+ for multi-disk boxes of the per-disk digests) and `.sr`. The digest is
446
+ computed once and cached on the local machine in
447
+ `~/.vagrant.d/data/vagrant-xapi/digests.json`, keyed on path, size and mtime.
448
+ The environment variable `VAGRANT_XAPI_TEMPLATE_SALT`, when set, is mixed
449
+ into the digest, so the same box imports as a new template; the acceptance
450
+ scenarios use it to test imports on a pool that already has the box. The
451
+ template is named `vagrant-xapi: <box> <version>`. Lookup first tries
452
+ that name, then any marked template with the same digest and SR (a renamed
453
+ box).
454
+
455
+ ### Concurrency
456
+
457
+ Lookup and import run under the lock `template.<digest>.<sr-uuid>` (TTL
458
+ 15 minutes, kept alive by the heartbeat while the import runs). A second
459
+ process or user waits ("Waiting for lock ... held by ...") and then uses the
460
+ same template. An interrupted import leaves no half template: the VDIs and
461
+ the template are only marked once the import succeeded, and on any error
462
+ (including Ctrl-C) the lock owner removes everything it created.
463
+
464
+ ### Clone or copy
465
+
466
+ `clone_mode: :fast` does `VM.clone`: fast, copy-on-write, on the template's
467
+ SR. `:full` does `VM.copy` to `sr`, needed when VM and template are on
468
+ different SRs. Both are followed by `VM.provision`. Removing a template is
469
+ safe while clones exist: the SM layer keeps the shared base VHD.
470
+
471
+ ### Architecture
472
+
473
+ XCP-ng and XenServer only run x86_64. With `box_architecture = :auto`,
474
+ Vagrant picks the local machine's architecture, so on Apple silicon an amd64 box is
475
+ not found. Therefore:
476
+
477
+ - the README tells users to set `config.vm.box_architecture = "amd64"`;
478
+ - `validate` warns (once per process) when the local machine is not amd64 and
479
+ `box_architecture` is `:auto`;
480
+ - the import warns when a box's metadata names another architecture;
481
+ - docs and scripts add local box files with `vagrant box add --architecture
482
+ amd64`: Vagrant stores a local box file under the local machine's architecture and
483
+ ignores `architecture` in its `metadata.json`;
484
+ - boxes made by `vagrant package` set `box_architecture = "amd64"` in their
485
+ Vagrantfile.
486
+
487
+ ## 8. Actions and machine states
488
+
489
+ Every action Vagrant asks for is a stack of built-in middleware plus our own
490
+ steps; the order follows the VirtualBox provider. Every stack starts with
491
+ `TranslateErrors`, `ConfigValidate` and `ConnectXapi` (which puts the shared
492
+ session in `env[:xapi]` and a services context in `env[:xapi_ctx]`). `up`
493
+ handles the box before validating, like VirtualBox, because the box
494
+ Vagrantfile may supply provider defaults. The stacks that only act on an
495
+ existing VM (`halt`, `reload`, `destroy`, `suspend`, `resume`, `provision`,
496
+ `package` and the snapshots) first check whether it exists, and validate and
497
+ connect only then. A machine without an id is `:not_created` without asking
498
+ the pool, so for a machine that was never created or is destroyed they only
499
+ say so; its Vagrantfile may not reach the pool, for example when `vagrant
500
+ destroy` runs under the user's `VAGRANT_DEFAULT_PROVIDER`.
501
+
502
+ ### States
503
+
504
+ `state` returns a `MachineState` with a symbol id (the old plugin returned
505
+ XAPI strings).
506
+
507
+ | XAPI | Vagrant id |
508
+ | --- | --- |
509
+ | VM not found, or no id | `:not_created` |
510
+ | `Halted` | `:halted` |
511
+ | `Running` | `:running` |
512
+ | `Suspended` | `:suspended` |
513
+ | `Paused` | `:paused` |
514
+
515
+ When `current_operations` is not empty, the long description names the
516
+ running operation, e.g. `clean_shutdown`.
517
+
518
+ ### Stacks
519
+
520
+ | Action | Steps | XAPI |
521
+ | --- | --- | --- |
522
+ | `up` (new) | `HandleBox`, `ConfigValidate`, import template, create VM, then *start* | `VM.clone` or `VM.copy`, `VM.provision`, setters, `VIF.create` |
523
+ | *create VM* | Clone/copy, provision, mark, apply CPU/memory/firmware/platform/vTPM/affinity/tags, create VIFs. All or nothing: on failure the VM and any new internal network are removed again | |
524
+ | *start* | `running`: message, `Provision` (for `--provision`), and bring the port-forward tunnels in line with the Vagrantfile (restart dead or changed ones, e.g. after the local machine rebooted); `suspended`: *resume*; `paused`: unpause and the same for the tunnels; `halted`: *boot* | |
525
+ | *boot* | `Provision`, stop leftover tunnels, `HandleForwardedPortCollisions`, `SyncedFolderCleanup`, `SyncedFolders`, `PrepareNFSSettings`, `SetHostname`, `CloudInitSetup`, `CleanupDisks`, `Disk`, remove disks an interrupted `vagrant xapi diff` left (section 12, under the diff lock of the VM), start VM (on an internal network: under the project's placement lock), wait for IP, `WaitForCommunicator`, configure static IPs in the guest, `CloudInitWait`, start tunnels. `Provision`, `SyncedFolders` and `SetHostname` do their work after the rest returns, so the effective order ends with hostname, synced folders, provisioning | `Async.VM.start` (or `VM.start_on` the host of an internal network) |
526
+ | `halt` | Stop tunnels; `GracefulHalt` through the guest; otherwise clean shutdown, and hard shutdown after `shutdown_timeout` or straight away with `--force` | `Async.VM.clean_shutdown`, `Async.VM.hard_shutdown` |
527
+ | `reload` | `halt`, re-apply CPU, memory, firmware, platform, tags and rebuild the VIFs, then *start* (disks through `Disk`/`CleanupDisks`) | as `halt` and `up` |
528
+ | `destroy` | `DestroyConfirm`, check that the VM is marked for this machine and project (before anything touches it), `ProvisionerCleanup`, stop tunnels, hard shutdown, remove disks an interrupted `vagrant xapi diff` left, snapshots and own VDIs, remove the VM, remove unused internal networks, `SyncedFolderCleanup`, remove our files from `data_dir` | `VM.destroy`, `VDI.destroy` (never VDIs of a template) |
529
+ | `suspend` | Only when running: stop tunnels, suspend; missing PV drivers or no suspend SR become a readable error | `Async.VM.suspend` |
530
+ | `resume` | Only when suspended: stop leftover tunnels, port collision check (with repair, since the tunnels are set up anew), resume (on an internal network: on the host of a running peer, under the project's placement lock), wait for IP, `WaitForCommunicator`, start tunnels | `Async.VM.resume` (or `VM.resume_on` that host) |
531
+ | `provision` | Only when running: `Provision` | none |
532
+ | `ssh`, `ssh_run` | Check running, `SSHExec` / `SSHRun` | none |
533
+ | `package` | See section 11 | `/export`, `/export_raw_vdi` |
534
+ | `snapshot_save`, `_restore`, `_delete` | See section 11 | `VM.snapshot`, `VM.checkpoint`, `VM.revert` |
535
+ | `sync_folders` | `SyncedFolders`, `PrepareNFSSettings` | none |
536
+
537
+ ### Cleanup rule
538
+
539
+ The plugin only removes objects with this machine's marking: the VM (it must
540
+ be marked with this machine's name and this project directory, otherwise
541
+ `destroy` stops with an error; so a copied project directory cannot destroy
542
+ the original's VM, and a moved project first re-adopts its VM with `vagrant
543
+ xapi adopt`), the VDIs of its VBDs that
544
+ carry `vagrant-xapi.vm` = this VM and are not used by any of our templates,
545
+ its snapshots (with their disks and suspend image), its cloud-init ISO, its
546
+ suspend image, and the snapshot clones of an interrupted `vagrant xapi diff`
547
+ (marked with `vagrant-xapi.diff` and this VM). Unknown objects always stay.
548
+ The one exception is the compatibility with vagrant-xenserver (section 17):
549
+ for a `:xenserver` machine, `destroy` first marks the VM's exclusive disks
550
+ as its own, as vagrant-xenserver removed every disk of the VM.
551
+
552
+ ### ssh_info
553
+
554
+ `nil` while the VM is not running or has no IP. Otherwise: the IP from
555
+ *IP detection* (one look, no waiting), port 22 (or `config.ssh.guest_port`),
556
+ the user from `config.ssh`, and with `ssh_via_host` a `proxy_command` through
557
+ dom0 of the host the VM runs on: `ssh -W %h:%p -l <host_ssh_user> -o
558
+ BatchMode=yes <host address>`, with normal host key checking. The address and
559
+ user are checked against a strict character set before they go into the
560
+ command.
561
+
562
+ ## 9. Networks, IP detection and SSH
563
+
564
+ XAPI has no NAT and no DHCP. Every VIF hangs on a real network of the pool,
565
+ the IP comes from the guest itself, and port forwarding runs over SSH
566
+ tunnels.
567
+
568
+ **VIF order.** If the Vagrantfile has a `public_network`, the first one
569
+ becomes device 0. Otherwise device 0 is a VIF on `default_network` (from the
570
+ Vagrantfile, else `VAGRANT_XAPI_DEFAULT_NETWORK`, else the network of the
571
+ master's management PIF). The other networks follow in
572
+ Vagrantfile order. A HIMN VIF comes last (only when `ip_detection` contains
573
+ `:himn` and `ssh_via_host` is on). `reload` rebuilds all VIFs; the MAC
574
+ address per device is kept unless one is configured, so a cloud-init
575
+ `network-config` that matches on MAC stays valid.
576
+
577
+ | Vagrantfile | XAPI | Options |
578
+ | --- | --- | --- |
579
+ | `public_network` | VIF on an existing network | `network:` (UUID, name, or bridge, looked up in that order), `bridge:` (e.g. `xenbr0`, as in the old plugin), `mac:`, `mtu:` (default: the network's), `ip:` with `netmask:` and `gateway:` for a static address, `nameservers:`, `xapi__locking_mode:` |
580
+ | `private_network` with `network:` or `bridge:` | VIF on an existing network (e.g. a VLAN) | as `public_network` |
581
+ | `private_network` without `network:` | Internal network without PIF, created when missing (under the lock `network.<name>`), marked with the project; removed with the last VM that uses it. A network with that name that is not marked for this project is never joined (`internal_network_taken`) | `name:` (default `vagrant-xapi-<first 10 hex of SHA-256 of the project path>`), `ip:` required, because there is no DHCP |
582
+ | `forwarded_port` | SSH tunnel from the local machine | `host`, `guest`, `host_ip` (default `127.0.0.1`), `guest_ip` (default `127.0.0.1`); TCP only |
583
+
584
+ An internal network only exists per host. All VMs of a project on the same
585
+ internal network therefore run on the same host. When a VM with internal
586
+ networks starts, the plugin looks for another VM on those networks that is
587
+ running and starts the new one with `VM.start_on` its host; with no such VM
588
+ it starts normally and XAPI places it. This happens under the lock
589
+ `placement.<project hash>`, so parallel machines of one project start one
590
+ after the other and the second sees where the first runs. Nothing is stored,
591
+ so a host that went offline is not used again once no VM runs there. Static addresses on devices other than 0 are set in
592
+ the guest after `WaitForCommunicator` through the guest capability
593
+ `configure_networks`, as the VirtualBox provider does: IPv4 as `:static`
594
+ with a dotted netmask, IPv6 as `:static6` with a prefix length (default 64;
595
+ `netmask:` may be a prefix or a dotted mask). A network with
596
+ `auto_config: false` is left to the user: no `configure_networks` and no
597
+ cloud-init `network-config` for it.
598
+
599
+ ### IP detection
600
+
601
+ `ip_detection` sets the order of sources (default
602
+ `[:guest_metrics, :static, :himn]`). Waiting stops after
603
+ `config.vm.boot_timeout`, or earlier with an error when the VM stops.
604
+
605
+ The values come from the guest agent and are untrusted: they end up in the
606
+ SSH ProxyCommand (`%h`) and in NFS exports. Only a bare IPv4 or IPv6 literal
607
+ is accepted (characters `0-9a-f:.`, parsed with `IPAddr`); loopback and
608
+ unspecified addresses are ignored, and link-local ones too, except for HIMN.
609
+
610
+ 1. `:guest_metrics`: `VM_guest_metrics.networks`, key `0/ipv4/0`, with `0/ip`
611
+ as fallback, for VIF device 0. Works with xe-guest-utilities and
612
+ xen-guest-agent.
613
+ 2. `:static`: the `ip:` of the first VIF. The plugin puts the address in the
614
+ guest itself through a `network-config` in the cloud-init config drive
615
+ (section 10). That also works for boxes without a guest agent, such as
616
+ libvirt boxes.
617
+ 3. `:himn`: an extra VIF on the host internal management network, IP from
618
+ `network.assigned_ips` (a link-local 169.254.x.x lease, which is accepted
619
+ for this source). Only usable with `ssh_via_host`, because that
620
+ network is only reachable from dom0; `ip_detection = [:himn]` without
621
+ `ssh_via_host` is a validation error.
622
+
623
+ ### SSH
624
+
625
+ Straight to the IP. With `ssh_via_host` the connection goes through dom0 as
626
+ ProxyCommand (`ssh -W %h:%p`), with normal host key checking; the old plugin
627
+ turned that off.
628
+
629
+ ### Port forwarding
630
+
631
+ Per `forwarded_port` a background process
632
+ `ssh -N -T -L <host_ip>:<host>:<guest_ip>:<guest>` runs through the guest,
633
+ like [vagrant-libvirt](https://github.com/vagrant-libvirt/vagrant-libvirt)
634
+ does, with `ExitOnForwardFailure=yes`, keep-alives, `BatchMode=yes`, the
635
+ machine's SSH keys and, with `ssh_via_host`, the same ProxyCommand. Host key
636
+ checking for the guest is off, as Vagrant does for guests. The processes are
637
+ started with an argv array (no shell) in their own process group; their PIDs
638
+ are in `data_dir/forwarded_ports.json`, their stderr in
639
+ `data_dir/tunnels.log`. A tunnel that dies within a second is an error.
640
+ Stopping checks on Linux that the PID still belongs to an `ssh` process.
641
+
642
+ **Waiting for SSH.** The plugin's `WaitForCommunicator` is Vagrant's, with
643
+ one addition. net-ssh raises `Errno::EPIPE` when sshd closes a connection
644
+ right after accepting it. OpenSSH 9.8 and newer do that for a while after
645
+ failed logins from the same address (`PerSourcePenalties`), e.g. while
646
+ cloud-init on the first boot is still creating the `vagrant` user. Vagrant
647
+ 2.4 lists that error as retryable, but its `wait_for_ready` connects once and
648
+ does not rescue it, so `vagrant up` ended with a stack trace. Within
649
+ `boot_timeout` the plugin treats it as "not ready yet": it waits 5 seconds
650
+ and tries again.
651
+
652
+ Tunnels start after `WaitForCommunicator` and stop on `halt`, `suspend`,
653
+ `destroy` and `package`, and before the collision check at boot and resume
654
+ (for tunnels left over when the VM was stopped outside Vagrant).
655
+
656
+ `vagrant up` of a running VM brings the tunnels in line with the
657
+ Vagrantfile: tunnels that died or whose forward changed (address or port)
658
+ are restarted, tunnels of removed forwards are stopped, and the others are
659
+ not touched. A forward with `auto_correct: true` whose host port in the
660
+ Vagrantfile is unchanged keeps the port it was auto-corrected to at boot;
661
+ `forwarded_ports.json` stores both ports for that. Vagrant's collision
662
+ check only runs at boot, so the plugin checks the host ports of the tunnels
663
+ it is about to start itself, before it stops anything: a taken port moves
664
+ to a free one from `usable_port_range` with `auto_correct`, and is
665
+ Vagrant's collision error without, with all tunnels left as they were.
666
+ IPv6 addresses go into the `-L` spec in brackets. `HandleForwardedPortCollisions` and the capability
667
+ `forwarded_ports` make `vagrant port` and `auto_correct` work. Rules:
668
+
669
+ - The default forward with id `ssh` (22 to 2222) is skipped unless
670
+ `forward_ssh_port = true`. It is also marked disabled, so Vagrant's
671
+ collision check ignores it and a taken port 2222 on the local machine
672
+ never blocks `up` or `resume`.
673
+ - Host ports below 1024 and `protocol: "udp"` are validation errors.
674
+ - Tunnels bind to `127.0.0.1` unless `host_ip:` says otherwise (VirtualBox
675
+ binds to all interfaces; here the safer default was chosen).
676
+
677
+ ### Capabilities
678
+
679
+ `public_address` returns the detected IP, used for example by `vagrant rdp`.
680
+ `nic_mac_addresses` returns the MAC per VIF in VirtualBox format: adapter
681
+ number (device + 1) to the MAC in upper case without separators.
682
+ `forwarded_ports` returns host port to guest port for the running tunnels.
683
+
684
+ ## 10. Disks, cloud-init and synced folders
685
+
686
+ `config.vm.disk` and `config.vm.cloud_init` work through the capabilities
687
+ `configure_disks` and `cleanup_disks`. Synced folders use rsync, NFS or SMB,
688
+ because Xen has no shared folders like virtiofs or 9p.
689
+
690
+ ### Disks
691
+
692
+ | Vagrantfile | XAPI |
693
+ | --- | --- |
694
+ | `:disk, primary: true, size:` | `VDI.resize` of the VDI on `userdevice 0` while the VM is halted; only growing, shrinking is an error. The filesystem in the guest does not grow along (cloud-init `growpart` usually does) |
695
+ | `:disk, name:, size:` | `VDI.create` on `sr` or on `xapi__sr: "..."`, `VBD.create` on the first free `userdevice` (`VM.get_allowed_VBD_devices`), plugged when the VM runs; marking `vagrant-xapi.disk = <name>`; if the disk exists and `size` is larger, resize |
696
+ | `:disk, name:, file:` | Import the image through `/import_raw_vdi`; the format comes from the header (`vhd`, `qcow2`, or raw for `raw`/`img`); qcow2 is probed and converted when needed, as for boxes (section 7) |
697
+ | `:dvd, file:` (local ISO) | Upload the ISO as raw VDI on `sr` (size rounded up to whole MiB, to the host that has the SR), attach as VBD type `CD` in a free CD slot (see below); if XAPI refuses a CD for a VDI on a normal SR, or no CD slot is free, attach it as a read-only disk instead (cloud-init's NoCloud finds the `cidata` volume on any block device). Without a free CD slot that gives a warning, except for the cloud-init ISO. When the ISO's SHA-256 changes, the VDI is replaced; the checksum comes from the digest cache (path, size, mtime), so an unchanged ISO is not hashed again on every boot |
698
+ | `:floppy` | Not supported; validation error |
699
+
700
+ `configure_disks` returns the disks with their VDI UUID, so Vagrant keeps them
701
+ in its disk metadata (`data_dir/disk_meta`). `cleanup_disks` detaches and
702
+ removes disks that left the Vagrantfile; it only destroys VDIs with this VM's
703
+ marking (other disks are only detached). `validate_disk_ext`
704
+ accepts `vhd`, `qcow2`, `raw` and `img`; the default is `vhd`; for new empty
705
+ disks the SR decides the actual format. Disks larger than 2 TiB give a
706
+ warning, because VHD-based SRs have that limit.
707
+
708
+ **ISOs from an ISO SR.** Vagrant requires a local file for every `:dvd` disk,
709
+ so ISOs that already live on an ISO SR are a provider option instead:
710
+ `xapi.isos = ["debian-12.iso"]` (names or UUIDs). On create and on `reload`
711
+ each one is attached as a CD drive whose VBD carries `vagrant-xapi.iso`;
712
+ drives the plugin attached for ISOs that are no longer listed are removed.
713
+ The ISO VDIs themselves are never destroyed, and CD drives the plugin did
714
+ not attach are left alone.
715
+
716
+ **CD slots.** Xen emulates four IDE devices for an HVM guest, in device
717
+ slots (`userdevice`) 0-3, and a guest only sees CD drives there: the PV
718
+ drivers leave CD drives to the emulation (Linux's xen-blkfront skips them in
719
+ an HVM guest). XAPI accepts a CD drive in slot 4 or later, but the guest never
720
+ sees it. So the plugin puts a CD drive only in a free slot 0-3. Slot 0 is the
721
+ boot disk, extra disks take the first free slot, and many boxes bring an
722
+ empty CD drive of their own, usually in slot 3 (`vagrant package` removes CD
723
+ drives, so its boxes do not), which leaves one to three CD drives. An uploaded
724
+ ISO without a free CD slot becomes a read-only disk (above); an ISO from
725
+ `isos` without one is an error that lists what takes the slots. Extra disks
726
+ keep the first free slot: moving them to slot 4 and up would change their
727
+ names in the guest (`xvde` instead of `xvdb`). `vagrant xapi info` lists the
728
+ slots.
729
+
730
+ ### Cloud-init
731
+
732
+ 1. The plugin uses a subclass of the built-in `CloudInitSetup` and runs it in
733
+ the *boot* steps, like VirtualBox. It builds the NoCloud ISO (label
734
+ `cidata`) with `user-data`, `meta-data` (`instance-id` from the machine id)
735
+ and, when VIF device 0 has a static `ip:`, a `network-config`, through the
736
+ host capability `create_iso`. With only network config and no user-data,
737
+ it still builds the ISO with an empty `#cloud-config`.
738
+ 2. `network-config` is version 2, matched on MAC: device 0 gets its static
739
+ address, `routes` to the `gateway` and `nameservers`; the other non-internal
740
+ VIFs without static IP get `dhcp4: true, optional: true` (cloud-init's
741
+ fallback DHCP is off once a network-config exists). Static addresses on
742
+ other devices are left to `configure_networks`.
743
+ 3. The ISO is written to `data_dir/vagrant-xapi-cidata-<sha>.iso` and reused
744
+ while its content (SHA-256 of all files) is unchanged
745
+ (`data_dir/xapi_cloud_init_iso`).
746
+ 4. `configure_disks` receives the ISO as `:dvd` named
747
+ `vagrant-cloud_init-disk`, uploads it and attaches it; when the content
748
+ changed, the VDI is replaced. The VDI belongs to the machine and is
749
+ removed on `destroy`.
750
+ 5. After boot the built-in `CloudInitWait` waits for
751
+ `cloud-init status --wait` (only with `config.vm.cloud_init` configs).
752
+ Vagrant's `cloud_init_first_boot_only` applies: after the first boot the
753
+ ISO is no longer in the config and `CleanupDisks` removes it.
754
+
755
+ This works without Xen Orchestra, and with boxes without guest agent, as long
756
+ as the box has cloud-init.
757
+
758
+ ### Synced folders
759
+
760
+ - rsync always works, provided `rsync` is on the local machine; Vagrant installs it
761
+ in the guest when it can.
762
+ - NFS needs the guest to reach the local machine. `PrepareNFSSettings` takes the
763
+ host IP as the source address of the route to the guest IP, found with a
764
+ UDP socket in Ruby (no `ip route` or `route get` as in the old plugin).
765
+ `PrepareNFSValidIds` returns the UUIDs of all VMs marked with this
766
+ installation's client id on this pool, plus the VM UUIDs of all xapi
767
+ machines in Vagrant's machine index (other pools, older markings), so
768
+ their exports are kept. That reads every VM record of the pool, so it
769
+ only runs for a machine with NFS folders (now or at its last boot);
770
+ without the list Vagrant's NFS cleanup prunes nothing.
771
+ - SMB works when the guest can reach the local machine.
772
+
773
+ For a folder without a type Vagrant picks the usable type with the highest
774
+ priority. SMB (7) beats rsync and NFS (5) on every macOS and Windows host;
775
+ it asks for credentials on each `up`, fails without a terminal, and needs
776
+ the guest to reach the local machine. So when the Vagrantfile does not set
777
+ `config.vm.allowed_synced_folder_types`, the provider sets it to
778
+ `[:rsync, :nfs, :smb]` as soon as Vagrant loads the machine
779
+ (`Provider#initialize`), before any command computes the types; rsync is
780
+ then the default, also for vagrant-xenserver Vagrantfiles. A folder with an
781
+ explicit type keeps it, and a Vagrantfile that sets the list keeps its own.
782
+ Boxes made by `vagrant package` set the same list in their Vagrantfile.
783
+
784
+ ## 11. Snapshots and package
785
+
786
+ All `vagrant snapshot` subcommands work, including `push` and `pop`.
787
+ `vagrant package` makes an `xapi` box the plugin can import again.
788
+
789
+ | Command | Behaviour | XAPI |
790
+ | --- | --- | --- |
791
+ | `snapshot save NAME` | Snapshot of the disks. With `snapshot_memory = true` and a running VM: checkpoint including memory. The snapshot gets `vagrant-xapi.snapshot_of = <vm uuid>`, its disks `vagrant-xapi.vm`. An existing name is an error | `Async.VM.snapshot` or `Async.VM.checkpoint` |
792
+ | `snapshot list` and capability `snapshot_list` | Names from the VM's snapshots, sorted on `snapshot_time` (push and pop depend on that order) | `VM.get_snapshots` |
793
+ | `snapshot restore NAME` | Like VirtualBox: halt first (forced), then revert. With `env[:snapshot_delete]` (pop) then delete; with `env[:snapshot_start]` (default) the *start* steps. A checkpoint comes back as `suspended` and is resumed | `Async.VM.revert` |
794
+ | `snapshot delete NAME` | First the snapshot's VDIs (and suspend image), then the snapshot object; coalescing the VHD chain is done by the SM layer in the background | `VDI.destroy`, `VM.destroy` |
795
+
796
+ `destroy` removes all snapshots of the machine.
797
+
798
+ ### Package
799
+
800
+ The stack follows the VirtualBox provider and uses Vagrant's general package
801
+ middleware (`PackageSetupFolders`, `PackageSetupFiles`, `Package`):
802
+
803
+ 1. Stop the VM (graceful, then hard), stop tunnels, `SyncedFolderCleanup`,
804
+ and remove disks an interrupted `vagrant xapi diff` left attached; that
805
+ and the export run under the diff lock of the VM.
806
+ 2. Make a temporary clone with `VM.clone`. From the clone remove CD drives and
807
+ uploaded ISOs (including the cloud-init ISO), all VIFs, all
808
+ `vagrant-xapi.*` keys of the VM and its disks, and the tag `vagrant`, so the
809
+ box takes nothing of this pool along.
810
+ 3. Export according to `package_format`:
811
+ - `:xva` (default): `GET /export?uuid=<clone>&use_compression=zstd`.
812
+ - `:vhd` or `:qcow2`: only disk 0, through
813
+ `GET /export_raw_vdi?vdi=<ref>&format=<fmt>`. Smaller, and usable outside
814
+ XAPI. A host that refuses the format gives a readable error.
815
+
816
+ Both get a `task_id`, and the task is waited for once the body is in: the
817
+ end of the stream does not say that the export on the host succeeded. A
818
+ body shorter than its `Content-Length` is an error too (Net::HTTP ignores
819
+ an early end by default).
820
+ 4. Remove the clone and its disks (never the original's).
821
+ 5. Assemble the box: `metadata.json` (`provider: "xapi"`, `format`,
822
+ `architecture: "amd64"`), `box.<format>`, and a Vagrantfile with
823
+ `firmware`, `cpus` and `memory` (read from the VM) as provider defaults,
824
+ plus `box_architecture = "amd64"` and
825
+ `allowed_synced_folder_types = [:rsync, :nfs, :smb]`. Vagrant core copies a
826
+ `--vagrantfile` to `include/_Vagrantfile` and `--include` files to
827
+ `include/`; the generated Vagrantfile loads `include/_Vagrantfile` at the
828
+ end, so it can override the defaults.
829
+ 6. Compress it with `bsdtar -czf`, as Vagrant does, but check the exit
830
+ status: Vagrant's own step ignores it, so a failure (e.g. a full disk) left
831
+ a truncated box and only `vagrant box add` noticed. On failure the box is
832
+ removed and the error shows bsdtar's message. The local disk needs room for
833
+ the exported image and the box together.
834
+
835
+ Cleaning the guest for publication (SSH keys, machine-id) stays the user's
836
+ job, as with other providers.
837
+
838
+ ## 12. The `vagrant xapi` command
839
+
840
+ One command `vagrant xapi` with subcommands for what Vagrant core does not
841
+ cover: insight, console, and cleanup of templates and orphaned VMs. It is
842
+ registered with `command("xapi", primary: false)` (not shown in `vagrant -h`;
843
+ the README documents it), uses `parse_options` and `with_target_vms`, and
844
+ asks for confirmation before anything that removes, unless `--force` is
845
+ given.
846
+
847
+ | Subcommand | Purpose |
848
+ | --- | --- |
849
+ | `vagrant xapi info [vm]` | UUID, name, state, host, affinity, CPUs, memory, firmware, template, disks with SR, VIFs with IPs, snapshots (issue #57 of the old plugin) |
850
+ | `vagrant xapi console [vm] [--port N] [--no-open]` | VNC console through the XAPI console tunnel (HTTP `CONNECT` to the console location, session as cookie) on `127.0.0.1`, and open the viewer (`open vnc://:<password>@127.0.0.1:<port>` on macOS; `xdg-open` with the URL without password on Linux, where the viewer asks for it). The proxy does the RFB handshake on both sides (`Services::Rfb`): towards the viewer it offers only VNC authentication with a one-time password of 8 characters, and only a viewer that knows it gets a connection to XAPI; towards XAPI, which offers no authentication of its own, it chooses None. After that the bytes pass through. For when the VM boots but SSH does not work |
851
+ | `vagrant xapi adopt [vm] UUID [--force]` | Attach an existing VM to a machine that has none, or re-adopt the machine's own VM after the project moved; marks the VM and the disks that only it uses (a sharable disk, or one also attached to another VM, is never marked, so `vagrant destroy` cannot delete it). `--force` is needed for a VM without marking, and for a VM marked for another machine, project or installation (taking over someone else's VM) (issue #56) |
852
+ | `vagrant xapi templates list` | Marked templates with box, version, provider, SR, size and number of clones |
853
+ | `vagrant xapi templates prune [--keep N]` | Per box, provider and SR (by UUID: local SRs usually share the name "Local storage") keep only the newest N versions (default 1) |
854
+ | `vagrant xapi templates remove NAME\|UUID` | Remove one template (by UUID, name, or `"<box> <version>"`) |
855
+ | `vagrant xapi hosts [--plain\|--json\|--pem]` | The pool's hosts (`host.get_all_records`) with address, role (master or member), enabled, and the SHA-256 fingerprint of the certificate from `host.get_server_certificate`, so read through the verified connection to the master; ready to paste into `tls_fingerprint`. `--pem` prints the certificates. Used by acceptance scenario A5 to find a member and its fingerprints |
856
+ | `vagrant xapi marked [--mine] [--project DIR] [--vm UUID] [--box NAME] [--plain\|--json]` | Every object in the pool with the plugin's marking (VMs, snapshots, templates, VDIs, internal networks, locks), of all users; the filters (repeatable, combined with "or") narrow it to this installation's VMs (`--mine`: same client id, or for older VMs the same login and hostname; with their snapshots, disks and the internal networks of their projects; not the shared templates), a project, a VM with its snapshots and disks, or a box's templates. Read through XAPI with the normal connection settings, so neither operators nor the acceptance scenarios need dom0 access |
857
+ | `vagrant xapi diff [vm] --from SNAPSHOT [--to SNAPSHOT] \| --with MACHINE` | Files that differ between a snapshot and the running guest, two snapshots, or two running machines; see below |
858
+ | `vagrant xapi orphans [--destroy]` | VMs of this Vagrant installation whose machine no longer exists: no machine in Vagrant's machine index (which knows custom `VAGRANT_DOTFILE_PATH`s) and no `.vagrant/machines/<name>/xapi/id` in the project has that VM's UUID. "This installation" means the same `vagrant-xapi.client_id`; VMs created before that key existed match on login name and local hostname. `--destroy` shuts each one down with a task, like `vagrant destroy`; one that cannot be removed is reported, the others still go, and the command exits with 1 |
859
+
860
+ ### `vagrant xapi diff`
861
+
862
+ `vagrant xapi diff [vm] --from SNAPSHOT [--to SNAPSHOT]` lists the files
863
+ that differ between a snapshot and the running guest, or between two
864
+ snapshots; `--with MACHINE` compares two running machines (both of
865
+ vagrant-xapi: another provider is refused). Snapshots are made
866
+ with `vagrant snapshot save`, and the names are those of `vagrant snapshot
867
+ list`. Vagrant only knows snapshots by name (save, restore, delete, list), so
868
+ reading a snapshot's files goes through XAPI. Services: `Differ`
869
+ (orchestration), `DiffDisks` (XAPI), `GuestFiles` (commands in the guest),
870
+ `FileDiff` (comparison); the guest is reached through `Glue::GuestShell`,
871
+ Vagrant's communicator with `sudo`.
872
+
873
+ 1. The machine must be running and its communicator ready: the files are
874
+ read in the guest. Everything from here on runs under the lock
875
+ `diff.<vm uuid>`, so a run never removes what another run on the VM is
876
+ using; `--with` takes the locks of both VMs, in the order of their names,
877
+ so that two runs on the same VMs cannot deadlock (one waits for the
878
+ other). The guest needs GNU findutils, coreutils and util-linux; a missing
879
+ tool is named. A temporary directory `/tmp/vagrant-xapi-diff.XXXXXXXXXX`
880
+ (0700, owned by the SSH user so that upload and download work; that user
881
+ can sudo anyway) holds everything the run writes.
882
+ 2. Before anything is attached, snapshot mounts and LVM devices an
883
+ interrupted run left in the guest are removed, with the directories of
884
+ interrupted runs (also those without a mount left), and then their disks
885
+ in the pool (see Leftovers). A directory only counts as a run's when it
886
+ is a real directory, not a symlink, owned by root or the SSH user, as the
887
+ run made it; `/tmp` is sticky, so after that check no other user can
888
+ rename or replace it. What another user of the guest made under such a
889
+ name is left alone, with its mounts. Only after that, so that a
890
+ leftover copy cannot block every later run, the live layout is read
891
+ (`findmnt -r`: mount point, device, type, file system UUID, mount options;
892
+ no bind mounts, loop devices, media or snapshot mounts of runs; of two
893
+ mounts on one mount point only the one mounted last, which hides the
894
+ other, so a file system below a tmpfs, bind or loop mount is not
895
+ compared at all), and
896
+ storage whose identifiers must not appear twice and cannot be renamed is
897
+ refused (`lsblk`: LUKS, btrfs, ZFS, RAID, other device-mapper targets).
898
+ With LVM, the LVM tools are checked here, before anything is attached.
899
+ Then a temporary udev rule (`/run/udev/rules.d/99-vagrant-xapi-diff.rules`)
900
+ keeps udev and systemd on the guest's own devices: a copy has their file
901
+ system and GPT partition UUIDs, so udev could point
902
+ `/dev/disk/by-uuid/...` at the copy and systemd tie the device unit of
903
+ that name to it, and a mount from fstab by `UUID=` (`/boot`,
904
+ `/boot/efi`, `/home`) is bound to its device unit (`BindsTo=`, or
905
+ `StopPropagatedFrom=` in newer systemd), so it could be unmounted when
906
+ the copy is detached. The rule gives every new block device with one of
907
+ the live identifiers (`lsblk -o KNAME,UUID,PARTUUID`, strictly checked)
908
+ `SYSTEMD_READY=0`, so systemd ignores it, and `link_priority=-100`, so
909
+ the links stay on the guest's device. The copies are mounted by kernel
910
+ name, and systemd binds no mount it only learns from
911
+ `/proc/self/mountinfo` to its device. Without `udevadm` nothing is done;
912
+ a failure is reported and the run goes on. The rule goes after the
913
+ copies (step 10), or with the next run's cleanup.
914
+ 3. Every disk of the snapshot with file systems is cloned with `VDI.clone`
915
+ (copy-on-write on VHD and qcow2 SRs). CD drives, read-only disks and
916
+ uploaded ISOs (such as the cloud-init ISO) are skipped. The clone is not
917
+ cancellable: a clone cancelled half-way could exist without marking;
918
+ Ctrl-C takes effect right after it. When waiting for it fails anyway
919
+ (`task_timeout`, or a lost connection), XAPI may still finish the clone
920
+ without marking. The VDIs that were not there before and look like a
921
+ clone of the source (its name, SR and `other_config`, no VBD, not a
922
+ snapshot) are then marked and removed; when there is none yet, the user
923
+ is told how to find a clone that appears later. A clone is named first
924
+ (`vagrant-xapi diff of <vm uuid>`), because the name is how a clone
925
+ without VBD is found again, and gets `vagrant-xapi.diff` right after
926
+ that, so a run that dies from then on leaves a clone that the next run or
927
+ `destroy` finds. `VDI.clone` copies `other_config`, so the clone usually
928
+ carries `vagrant-xapi.vm` of this VM already; it is only written when it
929
+ differs, so it is never missing in between, and a clone with the name and
930
+ `vagrant-xapi.diff` but no `vagrant-xapi.vm` yet (a run that died while
931
+ marking it) counts as a leftover of this VM too. The clone's other
932
+ `vagrant-xapi.*` keys (disk name, cloud-init, checksum) are removed;
933
+ otherwise it could be taken for one of the VM's own disks.
934
+ 4. The clone is attached read-write and plugged into the running VM. XAPI
935
+ refuses read-only disks for HVM guests
936
+ (`DISK_VBD_MUST_BE_READWRITE_FOR_HVM`), and a writable device lets the
937
+ guest replay the journal of the crash-consistent snapshot. Plugging into a
938
+ running VM needs PV drivers reported by a guest agent. The device name
939
+ comes from `VBD.device`, which XAPI fills in from the xenopsd event; for
940
+ an HVM guest that may be `hdX` or `sdX` while Linux's blkfront shows the
941
+ disk as `xvdX`, so both names are tried.
942
+ 5. In the guest the plugin waits for every attached disk and checks its size
943
+ against the VDI (it never mounts a device it cannot identify). A snapshot
944
+ disk with LUKS, btrfs, ZFS or RAID (the layout may have changed since the
945
+ snapshot) stops the run. LVM on the copies is imported (see LVM below).
946
+ Then the file systems on the disks and on the copies' logical volumes are
947
+ read with `lsblk -r`. Each is paired with the live one with the same UUID,
948
+ which says where it belongs, and mounted below the temporary directory
949
+ with `ro,nosuid,nodev,noexec` (it is only read; old setuid binaries and
950
+ device nodes of the snapshot stay unusable), XFS also with `nouuid`.
951
+ vfat stores no owners or permissions, and the names it shows depend on
952
+ the mount options, so a vfat copy gets the `uid`, `gid`, `umask`, `fmask`,
953
+ `dmask`, `codepage`, `iocharset`, `shortname`, `utf8` and `uni_xlate`
954
+ options of its live partner (values from the guest, so only those that
955
+ pass a strict check); otherwise every file in `/boot/efi` would show a
956
+ mode change, and a name with other characters a removal and an addition.
957
+ File systems without a live
958
+ partner, of another type than ext2/3/4, XFS and vfat, or with a UUID
959
+ already seen on another disk of the snapshot are skipped with a warning.
960
+ So is, the other way round, a live file system the snapshot does not
961
+ have at all (e.g. a disk added and mounted since), and with two
962
+ snapshots one that neither has: it is not compared, and its mount point
963
+ stays left out of the tree above it.
964
+ A logical volume with extents on two copies is listed under both disks
965
+ and taken once, without a warning. Two snapshots are both paired with the
966
+ live layout.
967
+ 6. Both sides are listed with `find -xdev -printf` (type, mode, owner, size,
968
+ mtime, link target and path, NUL-separated) into files that are
969
+ downloaded: Vagrant's SSH communicator strips escape sequences from
970
+ stdout. The current state is the live tree, not a new snapshot, because a
971
+ snapshot misses writes still in the page cache. A snapshot file system is
972
+ mounted on its own, so it shows files the running guest has hidden below
973
+ another mount point (e.g. under `/home` when `/home` is a file system of
974
+ its own). A live mount point nested in a tree, and what lies below it, is
975
+ therefore left out of that tree on every side: the entry of the mount
976
+ point differs per side (live `find -xdev` shows the root of the mounted
977
+ file system, e.g. `/proc` as 0555; the snapshot the empty directory below
978
+ it), and a live tree would show the content of a bind mount of the same
979
+ device. The root of each file system (find's `.`) is compared under its
980
+ mount point, so a mode or owner change of `/` or of `/home` on its own
981
+ file system is reported. A full `/tmp` in the guest cuts a listing off at
982
+ any point, and every file after the cut would show as added or removed.
983
+ So a listing ends with an end field written after find, which a full
984
+ `/tmp` cuts off as well, also when the cut falls between two records;
985
+ find runs in the C locale and must not report a write error, nor fail
986
+ without naming a path it could not read. Otherwise the run stops
987
+ (`diff_listing_invalid`); paths find names on stderr are counted as read
988
+ errors and reported with a warning.
989
+ 7. With `--path`, find starts at that path inside the file system it belongs
990
+ to (the deepest mount point above it), and only for paths that pass a
991
+ strict character check; other paths list the whole file system and the
992
+ filter narrows the result. So does a path with a symlink in a directory
993
+ above it (its `realpath` in the guest is not where the path says): the
994
+ kernel would follow it, and in a snapshot an absolute link points into
995
+ the running system. As without `--path`, nothing below a symlink matches;
996
+ a path that is a symlink itself is compared as the link.
997
+ 8. The comparison runs on the local machine. As in rsync's quick check, files
998
+ with the same size and mtime count as equal; same-size files with another
999
+ mtime are hashed in the guest (`sha256sum`, the paths in an uploaded
1000
+ file), so a file that was only touched is not reported. Right before
1001
+ hashing, a shell loop of builtins keeps only the paths that are still a
1002
+ regular file and not a symlink, and one `sha256sum` per batch reads them:
1003
+ a path that changed type since it was listed is neither followed nor
1004
+ waited on. `sha256sum -z` (coreutils 8.30 and later) keeps names as they
1005
+ are; older ones escape them. `--checksum` hashes every regular file.
1006
+ Changes: `A` added, `D` removed, `M` other
1007
+ content (link target for symlinks; possibly also mode or owner), `m` mode
1008
+ or owner only, `T` another type. Device nodes are compared by type, mode
1009
+ and owner only: find prints no device numbers, so a changed major or
1010
+ minor number is not reported.
1011
+ With `--content`, while the snapshot copies are still mounted, the guest
1012
+ copies both versions of every added, removed or changed regular file up
1013
+ to `--content-limit` (default 1 MiB) as root into the temporary
1014
+ directory: the paths come in an uploaded NUL-separated file and reach the
1015
+ shell only as arguments (`xargs -0 sh -c '...' dir limit budget`),
1016
+ symlinks are not followed (`[ -f ] && [ ! -h ]`), and `head -c` caps each
1017
+ copy at one byte more than the limit: a file that grew past the limit
1018
+ since it was listed is not shown cut off but named with its size. A file
1019
+ with a NUL byte in its first 8000 bytes counts as binary:
1020
+ the guest only marks it and does not copy it. Each side copies at most
1021
+ 16 MiB in all (`ContentDiff::BUDGET`, or one file of the limit if that is
1022
+ larger), in the order of the list; xargs may start `sh` more than once,
1023
+ so the bytes used so far are kept in a file. Without that budget a diff
1024
+ after a large upgrade would fill the guest's `/tmp`, often RAM. One tar
1025
+ archive per side is downloaded and read with RubyGems'
1026
+ `Gem::Package::TarReader`; the copies are removed as soon as the archive
1027
+ is made, also when tar fails, and the cleanup of the temporary directory
1028
+ removes those of an interrupted run. The local machine's `diff -u` makes
1029
+ the unified diff (labels `a/<path>` and `b/<path>`, `/dev/null` for an
1030
+ added or removed file). The output goes to a terminal, so invalid bytes
1031
+ and control and format characters other than tab and newline are written
1032
+ as `\xHH` or `\u{HHHH}`, as in paths: a file in the guest cannot send
1033
+ escape sequences, also not 8-bit C1 ones such as U+009B, and cannot
1034
+ reorder the output with bidi characters. Larger, binary, unreadable and
1035
+ files beyond the budget are named with the reason, and so is a file whose
1036
+ two copies turn out the same (when hashing it failed on one side). A
1037
+ local `diff` that fails or is killed by a signal stops the run instead of
1038
+ showing what it wrote so far. With two machines each version is copied in
1039
+ its own guest.
1040
+ 9. `--path DIR` limits the comparison; `.` and `..` in it are resolved
1041
+ lexically, as a listing has none. `--exclude PATTERN` leaves out a path
1042
+ when the path itself or one of its parent directories matches the glob
1043
+ (`*` also matches `/`). A pattern that does not start with `/` matches at
1044
+ any depth, as in rsync: `node_modules` leaves out every file or directory
1045
+ of that name. Default excludes: `/tmp`, `/var/tmp`, `/var/cache`,
1046
+ `/var/log` (logs change all the time, e.g. `lastlog` and `wtmp` at every
1047
+ login), `/var/lib/systemd/random-seed` and `/usr/src` (kernel headers and
1048
+ module sources that every kernel update replaces);
1049
+ `--no-default-excludes` compares them too. The temporary directory is
1050
+ always left out.
1051
+ 10. Afterwards, also after errors and Ctrl-C: unmount (lazily as fallback),
1052
+ deactivate the copies' volume groups, unplug, destroy VBD and clone,
1053
+ remove the udev rule, and remove the temporary directory (only
1054
+ plain files in it and empty directories, so never anything below a
1055
+ mount). Unplug runs as a task without cancel and with a timeout of two
1056
+ minutes; when the guest refuses or the unplug does not finish, the clone
1057
+ is forced off (`VBD.unplug_force`): it is a throwaway copy, and a copy
1058
+ left attached is worse.
1059
+ 11. With `--with`, nothing is attached: what interrupted runs left in either
1060
+ guest is removed as in step 2, and both machines' live file systems are
1061
+ paired by mount point and listed in place. The mount points of both
1062
+ machines are left out on both sides (step 6): a path that is a file
1063
+ system of its own on only one machine is named and not compared, instead
1064
+ of showing as all added on one side.
1065
+
1066
+ **LVM.** A copy of a PV has the PV and VG UUIDs and the VG name of the
1067
+ running guest's, so LVM would see duplicates and could not activate it.
1068
+ After all disks of a snapshot are attached (a VG may span disks), the plugin
1069
+ gives each VG on the copies new identifiers and a name of its own,
1070
+ `vagrant_xapi_diff_<run>_<side><n>`, with `vgimportclone` (all PVs of a VG
1071
+ together), and activates it. The copies' file systems keep their UUIDs, so
1072
+ they pair with the running guest's logical volumes as before. Rules:
1073
+
1074
+ - Every LVM command only sees the copies: `--devices` for each of their PVs
1075
+ (lvm2 2.03.12 and later; it also bypasses a devices file such as RHEL's
1076
+ `system.devices`), and always a `filter` and `global_filter` that accept only
1077
+ those PVs (a restrictive `global_filter` in `lvm.conf` would hide them, and
1078
+ older lvm2 has no `--devices`). The running guest's PVs are never read or
1079
+ written.
1080
+ - No trace in the running system: no metadata backups or archives
1081
+ (`backup { backup = 0 archive = 0 }`, otherwise `/etc/lvm/backup` and
1082
+ `/etc/lvm/archive` would change, and show up in the next diff), no change
1083
+ to the devices file (no `--importdevices`), and activation limited to the
1084
+ copy's VG (`activation { volume_list = [...] }`, which also overrides a
1085
+ `volume_list` in `lvm.conf` that would refuse it).
1086
+ - udev may activate a copy when it appears, if the running guest has no VG
1087
+ of that name. Before importing, the plugin removes the device-mapper
1088
+ devices that sit on the copies' disks (from `lsblk` of those disks), never
1089
+ by VG name, so never a logical volume of the running guest.
1090
+ - Cleanup deactivates the copies' VGs (`vgchange -an`) before the disks are
1091
+ detached; a device that is still open is removed with `dmsetup remove
1092
+ --deferred` as soon as it is closed. Device-mapper devices with the prefix
1093
+ `vagrant_xapi_diff_` that a killed run left are removed by the next run,
1094
+ under the lock.
1095
+ - Needs lvm2 in the guest (`lvm`, `pvs`, `vgchange`, `vgimportclone`,
1096
+ `dmsetup`); a missing tool is named. Shared VGs (lvmlockd) fail with the
1097
+ error of `vgimportclone`.
1098
+
1099
+ **Leftovers.** A killed run leaves marked clones. While one is attached, the
1100
+ guest has two disks with the same file system UUIDs, so a reboot from inside
1101
+ the guest could mount the copy. They are removed by the next `diff` (under
1102
+ the lock), before every boot (`RemoveDiffDisks` in the boot steps, before the
1103
+ disk steps, so these never number around a leftover; only VBDs of the VM, no
1104
+ pool-wide search), before `package` exports, and on `destroy` (also clones
1105
+ without VBD, found by name and checked by marking; best effort, so that a
1106
+ destroy or the rollback of a failed create never fails on this search). Boot
1107
+ and `package` take the diff lock of the VM for it, waiting at most five
1108
+ minutes: a diff still running there (the VM was halted under it) keeps its
1109
+ disks, and none is attached while a box is made. A VBD marked
1110
+ `vagrant-xapi.diff` is always removed; its VDI only when that is marked as a
1111
+ diff clone of this VM. A copy that is still attached afterwards (XAPI refused
1112
+ to unplug or destroy it) stops the boot and `package` with an error that says
1113
+ how to remove it: the guest could mount or boot from it, and a box would take
1114
+ it along without marking. `destroy` tries a copy it could not remove again
1115
+ once the VM and its VBDs are gone; nothing looks for a copy of a destroyed VM
1116
+ later, so one that still cannot be removed is named with its UUID, how
1117
+ `vagrant xapi marked --vm` finds it and how `xe vdi-destroy` removes it.
1118
+
1119
+ **Guest data.** Paths, link targets and error output come from the guest.
1120
+ Control characters, format characters (zero-width, bidirectional, soft
1121
+ hyphen, byte order mark), line and paragraph separators and invalid UTF-8 are
1122
+ escaped before they reach the terminal or JSON, unambiguously: `\xHH` is an
1123
+ ASCII control character or a byte that is not UTF-8, `\u{H}` a character,
1124
+ `\\` a backslash. Only values the plugin created or checked go into commands
1125
+ in the guest: the temporary directory, kernel device names, file system
1126
+ types, VG names (the copies' own, and those `pvs` reports, checked against
1127
+ LVM's character set), and mount points and `--path` values that pass a
1128
+ strict character check (live mount points that do not are skipped with a
1129
+ warning).
1130
+ File paths never go into a command; they travel in files. `--plain` prints
1131
+ `kind<TAB>path<TAB>detail`; `--json` an object with `compared` (mount
1132
+ points), `read_errors` (the number of paths that could not be read, so a
1133
+ script can tell an incomplete result) and `changes`. With either, progress is dropped and
1134
+ warnings go to stderr.
1135
+
1136
+ Templates are shared by all users of a pool. `prune` and `remove` therefore
1137
+ show which other users have clones of a template before asking for
1138
+ confirmation. Templates and orphans need a connection: they use the first
1139
+ machine of the Vagrantfile in the current directory that uses the xapi
1140
+ provider.
1141
+
1142
+ ## 13. Security
1143
+
1144
+ The plugin talks to a hypervisor pool, often with root rights. These rules
1145
+ apply without exception:
1146
+
1147
+ - **TLS.** Certificate checks are on by default and cannot be turned off.
1148
+ Self-signed certificates go through `tls_fingerprint` or `tls_ca_file`.
1149
+ There is no `insecure` option.
1150
+ - **Secrets.** Password and `session_id` never appear in logs, error messages,
1151
+ URLs or process arguments. The client also filters them from
1152
+ `VAGRANT_LOG=debug`. Credentials may come from environment variables, so
1153
+ they are not in a Vagrantfile in git.
1154
+ - **Box content.** Files named by a box must stay inside the box directory
1155
+ (symlinks resolved), and qcow2 images with a backing file or external data
1156
+ file are refused, so a box cannot make the plugin upload files from the
1157
+ local machine. A box's own Vagrantfile is Ruby that Vagrant runs anyway, so boxes
1158
+ remain trusted code; this protects users who ignore box Vagrantfiles.
1159
+ - **Subprocesses.** External commands (`ssh` for tunnels, `qemu-img`,
1160
+ `open`/`xdg-open`) always run with an argv array (`Process.spawn` or
1161
+ `Vagrant::Util::Subprocess`), never through a shell string. The old plugin
1162
+ used backticks with paths in them. The SSH ProxyCommand (run by ssh through
1163
+ a shell) only contains a host address and user that passed a strict
1164
+ character check.
1165
+ - **Console.** `vagrant xapi console` listens on `127.0.0.1` only and asks
1166
+ viewers for a one-time VNC password before it opens the console tunnel,
1167
+ so other local users cannot watch. VNC authentication is weak (DES), but
1168
+ it only guards a local port for as long as the command runs. The password
1169
+ is shown in the terminal. On macOS it is also passed to `open` in the URL
1170
+ for Screen Sharing; `open` exits at once, so it is only briefly in the
1171
+ process list. On Linux it stays out of the URL: `xdg-open` starts the
1172
+ viewer with the URL in its command line for the whole session, readable
1173
+ by every local user, so the viewer asks for it instead. A Ruby without
1174
+ 3DES gets a warning and a console without password.
1175
+ - **Cleanup.** Destroy, prune and orphans only touch objects with the plugin's
1176
+ marking. `adopt` of an unmarked VM needs `--force`.
1177
+ - **dom0.** SSH to dom0 (`ssh_via_host`, HIMN) uses normal host key checking.
1178
+ - **Guest file systems.** `vagrant xapi diff` mounts a snapshot's file
1179
+ systems only in the guest they came from, never in dom0 or on the local
1180
+ machine. Names from the guest are escaped before they are shown, and only
1181
+ checked values go into the commands it runs in the guest.
1182
+ - **Downloads.** An `xva_url` with `http://` gives a warning, because the host
1183
+ then downloads the image unencrypted.
1184
+ - **Least privilege.** `pool-admin` is enough; the README says what
1185
+ `vm-admin` may lack.
1186
+ - **Supply chain.** No runtime gems. Development gems (Vagrant, rspec,
1187
+ webmock) are only in the Gemfile.
1188
+
1189
+ ## 14. Testing and acceptance
1190
+
1191
+ A change is done when the unit tests are green and the acceptance scenarios
1192
+ it affects pass on a XenServer 8 or XCP-ng 8.3 pool.
1193
+
1194
+ ### Layers
1195
+
1196
+ - **Unit (rspec)**, `spec/unit`, against `spec/support/fake_xapi.rb`: an
1197
+ in-memory pool behind WebMock that answers JSON-RPC (generic accessors for
1198
+ every class plus the specific calls the plugin uses) and the HTTP transfer
1199
+ handlers, and can inject XAPI errors.
1200
+ - `api/`: login, `HOST_IS_SLAVE` (also refused with a pin), `SESSION_INVALID`
1201
+ (one re-login for many threads), error format, tasks (event.from and
1202
+ polling, cancel, timeout, progress), secrets in logs and URLs, session
1203
+ registry; transfer host selection, member certificate pinning, following
1204
+ 302s on downloads, the qcow2 probe, no `Expect` header, untrusted guest
1205
+ IPs; real TLS against a local server for CA, pin and wrong pin.
1206
+ - `services/`: disk image headers, box sources, digest cache, lock
1207
+ semantics (including takeover races and in-process waits), template cache (one import, qcow2 and fallback, interrupted
1208
+ import, two processes racing), VM building, network planning and internal
1209
+ networks, IP detection, power, snapshots, destroy (cleanup rule), export,
1210
+ inventory, disks, tunnels, file comparison, and `diff` (snapshot clones,
1211
+ guest commands, cleanup after errors and Ctrl-C, leftovers) against an
1212
+ in-memory guest (`spec/support/fake_guest.rb`).
1213
+ - `vagrant/`: config defaults and validation, error texts for every key,
1214
+ and the real Vagrant action stacks (`up`, `halt`, `reload`, `suspend`,
1215
+ `resume`, snapshots, `package`, `destroy`, disks and cloud-init) in a
1216
+ real `Vagrant::Environment`, plus the subcommands. Only the SSH
1217
+ communicator and the guest are stubbed.
1218
+ - **Acceptance scenarios** against a real pool, as a script
1219
+ (`test/acceptance/scenarios.sh`), not vagrant-spec (section 15).
1220
+
1221
+ ### Acceptance scenarios
1222
+
1223
+ | # | Scenario |
1224
+ | --- | --- |
1225
+ | A1 | `vagrant up --provider=xapi` with only host and login, and an XVA box of the old plugin: SSH works within `boot_timeout` |
1226
+ | A2 | A libvirt box without guest agent, with static IP via cloud-init: SSH works |
1227
+ | A3 | Two processes run `vagrant up` at the same time with the same new box: one imports, the other waits for the template lock and then uses that template; exactly one template is created |
1228
+ | A4 | Ctrl-C during the template import: no half template, and no VM or VDI in the pool that was not there before (an interrupted import is not marked yet); the next `up` succeeds |
1229
+ | A5 | Connecting to a pool member: with a pin on its certificate only a readable refusal naming the master; with the pins of all hosts automatically on to the master; `vagrant xapi console` of a VM on the member returns its screen |
1230
+ | A6 | `halt`, `reload` with changed CPUs and memory, `suspend`, `resume`, `halt`, `up`: the state is right each time, the change shows in the guest, and `vagrant ssh` fails while the VM is suspended or halted |
1231
+ | A7 | Extra disk, bigger primary disk and three ISOs via `config.vm.disk`; after `reload` all are visible in the guest, the first ISO as a CD drive; an ISO without a free CD slot (0-3) is a read-only disk with a warning, and no CD drive is in slot 4 or later |
1232
+ | A8 | `snapshot push`, change, `snapshot pop`: the change is gone |
1233
+ | A9 | `package`, `box add`, `up` from the new box; then the same again with that box, packaged as qcow2 |
1234
+ | A10 | UEFI with Secure Boot and vTPM, from a box built for UEFI: the guest boots with UEFI, Secure Boot is on and it sees the TPM |
1235
+ | A11 | Multi-machine on a `private_network`: the machines ping each other |
1236
+ | A12 | `forwarded_port` 8080 to 80 with `auto_correct`: `curl` on the port `vagrant port` shows works, after `halt` the tunnel is gone |
1237
+ | A13 | `destroy` leaves no marked objects (checked with `vagrant xapi marked --project <dir> --vm <uuid>`) |
1238
+ | A14 | Wrong password, unreachable host, full SR, unknown network: each gives a readable message, never a stack trace |
1239
+ | A15 | An unchanged vagrant-xenserver Vagrantfile (provider `:xenserver` with `xs_*` options, VirtualBox as second choice, triggers that use `XenApi::Client`): `vagrant up` picks `:xenserver` and says the compatibility is in use and the XenApi stand-in temporary, the trigger's extra disk shows in the guest and is gone after `destroy`; then the Vagrantfile moves to `:xapi`, `vagrant xapi migrate` moves the machine, and `destroy` removes the trigger's disk too |
1240
+ | A16 | `vagrant xapi diff`: a snapshot against the running guest and against a second snapshot lists exactly the added, changed and removed files and no mount points such as `/proc`; `--content` shows the line added to `/etc/hosts`; afterwards no marked object, disk, mount or udev rule of the diff is left, and the guest has the same mounts as before; optionally on another SR (`XAPI_DIFF_SR`) |
1241
+ | A17 | `vagrant xapi diff` on a guest with its root file system on LVM, with `XAPI_BOX_LVM`: the change is listed; afterwards the guest's volume groups are unchanged, and no LVM device or metadata backup of the copy is left |
1242
+ | A18 | A Vagrantfile as `vagrant init` writes it: `up` without a terminal syncs the default `/vagrant` folder with rsync and asks nothing; the guest can write to it, `vagrant rsync` brings a change across, after a reboot of the guest SSH comes back with the folder intact; `vagrant xapi info` shows the VM |
1243
+ | A19 | Optional, with `XAPI_XVA_URL`: a box of vagrant-xenserver with only `xva_url`; the host imports the XVA from the URL |
1244
+
1245
+ A3, A4, A13 and A14 are also covered by unit tests against the fake XAPI;
1246
+ the scenarios prove them against real XAPI.
1247
+
1248
+ ## 15. Decisions
1249
+
1250
+ | Decision | Reason |
1251
+ | --- | --- |
1252
+ | Support both XenServer 8 and XCP-ng | Both use XAPI; differences are detected at runtime |
1253
+ | The gem is published on rubygems.org; the repository stays private for now | `vagrant plugin install vagrant-xapi` and `vagrant plugin update` work as for any plugin, without the GitHub CLI or access to the repository. The source is public through the gem (MIT). While the repository is private the gemspec has no homepage or source links, which would only lead outsiders to a 404, and `docs/*.md` ship in the gem, so that the README and the code comments that point there still lead somewhere; add the links when the repository becomes public. Version 0.0.1 is a placeholder without code that held the name. GitHub Packages was not an option: it needs a classic personal access token per user, even to install, and Vagrant keeps the source URL with that token in plain text in `~/.vagrant.d/plugins.json` |
1254
+ | No boxes of our own | Teams use existing `xapi`, `xenserver` or `libvirt` boxes or build one; `docs/operations.md` describes building one with packer-plugin-xenserver |
1255
+ | The console proxy asks viewers for a one-time VNC password | XAPI's console has no authentication of its own; without a password any local user could connect to the forwarded port, and macOS Screen Sharing always asks for one. The password goes into the vnc:// URL |
1256
+ | VNC's DES through OpenSSL's 3DES with the key three times | OpenSSL 3 keeps single DES in its legacy provider, which Vagrant's Ruby does not load; 3DES with one key three times is the same cipher and is in the default provider |
1257
+ | No Xen Orchestra, not even optional | No extra component or dependency for users |
1258
+ | Name: gem `vagrant-xapi`, provider `:xapi` | Neutral for both platforms; free on rubygems; no clash with `:xenserver` |
1259
+ | Ruby plugin, no Go | vagrant-go was removed from Vagrant (GH-13622) |
1260
+ | No runtime gems | Avoid the old plugin's load problems and keep the supply chain small |
1261
+ | License MIT | Like vagrant-xenserver and Vagrant itself |
1262
+ | `vagrant xapi` hidden from `vagrant -h` (`primary: false`) | The README documents it |
1263
+ | Migration guide in the README (`xs_*` to the new options, `use_himn` to `ip_detection`) | Users of vagrant-xenserver must be able to switch without the old docs |
1264
+ | Provider registered with `defaultable: false` | It needs a remote pool and credentials; Vagrant should only pick it when the Vagrantfile configures `:xapi` or `--provider=xapi` is given |
1265
+ | `CloudInitSetup` runs in the boot steps, not only on the first `up` | Same as VirtualBox, so `cloud_init_first_boot_only = false` works; the ISO is cached on its content |
1266
+ | MAC addresses are kept per device when VIFs are rebuilt | A cloud-init `network-config` matches on MAC; a new MAC after `reload` would break the static IP |
1267
+ | Forwarded ports bind to `127.0.0.1` by default | Safer than all interfaces; `host_ip:` can widen it |
1268
+ | `tls_verify = false` is only accepted together with a fingerprint | There is deliberately no insecure mode; a pin is the safe alternative for self-signed certificates |
1269
+ | The HIMN VIF is only added with `ssh_via_host` | HIMN is only reachable from dom0; without the proxy it is useless and only adds a NIC |
1270
+ | Uploaded ISOs are attached as CD, with a read-only disk as fallback | Not every SR may accept a CD VBD for one of its VDIs; NoCloud finds `cidata` on any block device |
1271
+ | qcow2 support is probed with an empty PUT on a scratch VDI | XAPI has no API to ask for it. Trying the real upload does not work: XAPI never answers `Expect: 100-continue`, and without it a refused upload is only noticed after streaming the whole file |
1272
+ | HTTP transfers go to a host that has the SR attached | XAPI redirects (302) otherwise, and a streamed PUT cannot follow a redirect |
1273
+ | Pool members are reached with the certificate the master reports for them | A pin or a hostname check made for the master does not match another host |
1274
+ | `tls_fingerprint` may hold several pins | One pin cannot cover a redirect from a member to the master, nor a change of master; the self-signed certificates of XCP-ng and XenServer do not contain the IP addresses XAPI redirects to, so a CA file does not help either |
1275
+ | Guest-reported IPs must be bare IP literals | They are written by the guest and end up in a shell (ProxyCommand) and in NFS exports |
1276
+ | `destroy` checks that the VM is marked with this machine and project | A copied project directory must not destroy the original project's VM |
1277
+ | Orphans match on a random installation id, not only on login and hostname | Login and hostname collide across containers, CI runners and identically named machines |
1278
+ | ISOs from an ISO SR are a provider option (`isos`), not a `:dvd` disk | Vagrant's config validation requires a local file for every `:dvd` disk |
1279
+ | PV boxes are switched to HVM on import instead of refused | Old-plugin boxes often only used pygrub for convenience; the warning explains the risk |
1280
+ | `up` of a running VM runs `Provision` after the message | So `vagrant up --provision` works, like VirtualBox |
1281
+ | Internal networks place their VMs with `VM.start_on` the host of a running peer, not affinity, and store no host | Affinity is only a preference, and VMs on different hosts could not reach each other; a stored host goes stale when it goes offline |
1282
+ | Locks are kept alive with heartbeats and use the master's clock | An import may outlast any fixed TTL; the clocks of the machines running Vagrant differ |
1283
+ | Templates get no user configuration (firmware is set per VM) | A template is shared; one user's choice must not become another user's default |
1284
+ | `destroy` checks ownership before halting; `adopt` of another owner's VM needs `--force` | A copied project or a mistyped UUID must not stop or take over someone else's VM |
1285
+ | `destroy` refuses an unmarked VM | Even with an id file pointing at it, an object the plugin did not create or adopt must stay |
1286
+ | `vagrant xapi diff` mounts snapshot disks in the guest itself | The guest wrote these file systems; parsing them in dom0 or on the local machine would cross a trust boundary. No helper VM, box or download is needed |
1287
+ | `diff` reads snapshots through XAPI, not by `vagrant snapshot restore` | Restoring halts and boots the VM twice, and the boot steps write to the guest (synced folders, hostname, cloud-init), which pollutes the result |
1288
+ | The current state in `diff` is the live tree, not a temporary snapshot | A snapshot misses writes still in the page cache; the live tree is what `vagrant ssh` shows |
1289
+ | Snapshot clones are attached read-write | XAPI refuses read-only disk VBDs for HVM guests; a writable throwaway device lets the guest replay the journal of a crash-consistent snapshot |
1290
+ | `diff` renames LVM on the copy with `vgimportclone` | Its identifiers would appear twice next to the running system; the copy is a throwaway clone, so changing it costs nothing, and LVM limited to the copy's devices never touches the running guest's |
1291
+ | `diff` refuses LUKS, btrfs, ZFS and RAID | Their identifiers would appear twice next to the running system, and the plugin does not rename them |
1292
+ | `diff` deactivates a copy that udev activated by its devices, not by VG name | With a name, a copy and an active logical volume of the running guest cannot be told apart; the devices on the copy's disks can |
1293
+ | `diff` downloads listings as files instead of reading stdout | Vagrant's SSH communicator strips escape sequences from stdout |
1294
+ | `diff` takes the lock of the VM (with `--with` of both VMs) before it touches the guest | Leftover mounts and directories in the guest can only be removed safely when no other run on that VM is using them |
1295
+ | `diff` forces a clone off when a clean unplug fails or hangs | It is a throwaway copy; left attached, the guest has two disks with the same file system UUIDs |
1296
+ | Acceptance tests are our own scenarios, not vagrant-spec | On a real pool 8 of vagrant-spec's 20 provider tests failed. Seven assume a hypervisor on the local machine or a particular box: VirtualBox's device names (`/dev/sda`, `/dev/sr3`), a guest reachable from the local machine on a private network, Python 2's `SimpleHTTPServer` in the box, a guest that is back 10 seconds after a reboot. `disk/dvd` found the box's own empty CD drive at `/dev/sr0`, and `disk/dvd_multiple` that a guest sees at most three CD drives, which showed that the plugin put CD drives where the guest cannot see them (section 10, "CD slots"; fixed). What they covered beyond our scenarios is now in A18, A6 and A9: the default synced folder (where they found Vagrant choosing SMB), `vagrant ssh` while the VM is suspended or halted, and packaging a packaged box. vagrant-spec has no releases and needed two workarounds to run on Ruby 3 |
1297
+
1298
+ ## 16. Verified against a real pool
1299
+
1300
+ The calls, parameters, record fields, the JSON-RPC error format
1301
+ (`error.message` as code, `error.data` as parameters), int64 values as JSON
1302
+ numbers, `event.from` on a single task, `task_id` and cookie authentication
1303
+ on the HTTP handlers, the format names `raw`/`vhd`/`qcow2`,
1304
+ `use_compression=zstd` and the redirect behaviour of the handlers were first
1305
+ checked against the xen-api source (`api_server.ml`, `xapi_http.ml`,
1306
+ `import.ml`, `import_raw_vdi.ml`, `export.ml`, `importexport.ml`). The
1307
+ acceptance scenarios A1-A18 have since all passed in one full run on an
1308
+ XCP-ng 8.3 pool of two (nested) hosts. Besides the normal
1309
+ lifecycle that covers the upload of a qcow2 disk image of a libvirt box
1310
+ (A2), the CD slots (A7: with a box that has its own empty CD drive in slot 3
1311
+ and a data disk in slot 1, the first ISO was a CD drive in slot 2,
1312
+ `/dev/sr0`, and the two without a slot read-only disks in slots 4 and 5,
1313
+ `/dev/xvde` and `/dev/xvdf`, all three found by their label),
1314
+ `host.get_server_certificate` and the console `CONNECT` through a member
1315
+ host (A5: a VNC handshake through `vagrant xapi console` with a VM on that
1316
+ member), the default `/vagrant` folder over rsync and a guest reboot (A18),
1317
+ packaging a packaged box, also as qcow2 through
1318
+ `/export_raw_vdi?format=qcow2` and up again from that box (A9), and for
1319
+ `vagrant xapi diff` the plug of a cloned snapshot disk into a running guest
1320
+ with its device name, on an `lvm` and an `nfs` SR (A16, once with
1321
+ `XAPI_DIFF_SR`) and with LVM on one guest (A17). Since `package` waits for
1322
+ the export's task, A9 has passed again: `/export` and `/export_raw_vdi`
1323
+ finish the task passed as `task_id` once the body is complete. A4 has passed
1324
+ again with the comparison of all VMs and VDIs of the pool: the interrupted
1325
+ import left nothing behind.
1326
+
1327
+ ## 17. Compatibility with vagrant-xenserver
1328
+
1329
+ Vagrantfiles written for vagrant-xenserver keep working, with everything
1330
+ that plugin did. That is permanent: teams switch plugins without touching
1331
+ their Vagrantfiles, and `:xapi` is for new Vagrantfiles and the options
1332
+ vagrant-xenserver never had. Only the stand-in for the `xenapi` gem, which
1333
+ triggers in such Vagrantfiles use, is temporary.
1334
+
1335
+ ### Permanent: lib/vagrant-xapi/xenserver
1336
+
1337
+ | Part | File | Behaviour |
1338
+ | --- | --- | --- |
1339
+ | Provider `:xenserver` | `xenserver/plugin.rb`, `xenserver/provider.rb` | The `:xapi` provider under the old name, with the same capabilities and box formats. Not defaultable, like `:xapi`; Vagrant still picks it when a Vagrantfile configures it (`config.vm.provider "xenserver"` before `"virtualbox"`). Says once per run that the compatibility is in use. Not registered when vagrant-xenserver is installed too |
1340
+ | `xs_*` options | `xenserver/config.rb` | `xs_host`, `xs_username`, `xs_password`, `name`, `memory`, `api_timeout`, `xva_url` map to the new options; every `:xapi` option works as well. HTTPS always: the old default port 80 becomes 443, `xs_use_ssl` is ignored. `use_himn` becomes `ip_detection = [:himn]` with `ssh_via_host`. `pv` is ignored with a warning. After finalize the `xs_*` readers return the values in effect (`xs_use_ssl` true, `xs_port` the HTTPS port), for code in Vagrantfiles that reads them |
1341
+ | Takeover | `xenserver/takeover.rb` | A machine whose id is an OpaqueRef was created by vagrant-xenserver. The first `state` looks the VM up by that ref, gives it and its exclusive disks this machine's marking (like `vagrant xapi adopt`) and stores the UUID. A ref XAPI no longer knows (`HANDLE_INVALID`) means not created, as with vagrant-xenserver; other errors go up. Never by name: another VM with that name, e.g. a colleague's from the same Vagrantfile, would be marked as this machine's and later destroyed. A VM marked for another machine stays as it is; this machine is not created, with a warning that points to `vagrant xapi adopt --force`, which replaces an id that is no VM UUID |
1342
+ | `destroy` | `xenserver/plugin.rb` | vagrant-xenserver removed every disk of the VM, also disks that triggers attached (`extra_disks`). For `:xenserver` machines the VM's exclusive disks are marked as its own before `destroy`; sharable disks and disks of other VMs stay |
1343
+ | `vagrant xapi migrate` | `xenserver/migrate.rb` | For moving a project to `:xapi` after `:xenserver` became `:xapi` in its Vagrantfile: locks the machine's entry in Vagrant's machine index (a machine another process holds is left alone), moves `.vagrant/machines/<name>/xenserver` to `xapi` and updates the entry, so Vagrant does not create new VMs, and marks the VM's exclusive disks as its own, so `destroy` under `:xapi` still removes disks that triggers attached. Machines with an OpaqueRef as id are left alone until a command took them over |
1344
+ | Texts | `locales/en.yml` | `vagrant_xapi.xenserver`, plus the `migrate_*` and `xenserver_*` keys |
1345
+
1346
+ TLS needs one setting where vagrant-xenserver needed none:
1347
+ `VAGRANT_XAPI_TLS_FINGERPRINT` (the pins of all hosts, separated by commas)
1348
+ or `VAGRANT_XAPI_TLS_CA_FILE`, unless the local machine trusts the pool's
1349
+ certificates. vagrant-xenserver hardly used TLS. Its API calls, the login
1350
+ with the password included, went through the `xenapi` gem, which uses TLS
1351
+ only on port 443 (`XMLRPC::Client.new(..., @uri.port == 443, ...)`) and
1352
+ ignores the scheme. `xs_port` defaulted to 80, so even with
1353
+ `xs_use_ssl = true` they went over plain HTTP. Box uploads went to
1354
+ `https://<host>/` with `curl --insecure`, with the session id in the URL.
1355
+
1356
+ ### Temporary: the XenApi::Client stand-in in lib/vagrant-xapi/legacy
1357
+
1358
+ Triggers in Vagrantfiles of vagrant-xenserver may `require "xenapi"`, a gem
1359
+ vagrant-xenserver brought along, to do what that plugin could not (extra
1360
+ disks, vCPUs). `legacy/xenapi.rb`, loaded through `lib/xenapi.rb`, stands in
1361
+ for its client: `XenApi::Client.new(uri, timeout)`, `login_with_password`,
1362
+ `xc.<Class>.<method>(...)` for any XAPI call and `xc.session.logout`, on
1363
+ vagrant-xapi's own client. That means HTTPS with the port and TLS settings
1364
+ of the provider config for that host (`legacy/plugin.rb` learns them through
1365
+ `Extensions.on_config_finalized`) or `VAGRANT_XAPI_TLS_*`, its own session,
1366
+ and `HOST_IS_SLAVE` handling. XAPI errors become
1367
+ `XenApi::Errors::<CamelCaseCode>`. Machine ids are VM UUIDs where
1368
+ vagrant-xenserver used OpaqueRefs; code that uses `machine.id` as a
1369
+ reference gets `HANDLE_INVALID` and, as written for stale references, looks
1370
+ the VM up by name. The first `XenApi::Client` of a run says that the
1371
+ stand-in is temporary and points to the provider's own options (`cpus`,
1372
+ `config.vm.disk`). Its text is in `locales/legacy.en.yml`. When
1373
+ vagrant-xenserver is installed too, `lib/xenapi.rb` loads the real gem
1374
+ instead, also when it comes first on the load path: vagrant-xapi leaves the
1375
+ old plugin alone.
1376
+
1377
+ ### Hook points
1378
+
1379
+ The core does not know either layer. `extensions.rb` offers hook points
1380
+ that do nothing without them:
1381
+
1382
+ - `Extensions.provider_names`: the providers whose machines the plugin runs
1383
+ (data dirs, machine index, `vagrant xapi`); `xenserver/` adds `:xenserver`.
1384
+ - `Extensions.on_foreign_id`: called from `Provider#state` when the machine
1385
+ id is not a VM UUID; `xenserver/` takes over machines of vagrant-xenserver.
1386
+ - `Extensions.on_before_destroy`: called by `DestroyVm`; `xenserver/` marks
1387
+ the exclusive disks of `:xenserver` machines.
1388
+ - `Extensions.on_config_finalized`: called at the end of `Config#finalize!`;
1389
+ `legacy/` remembers the connection settings per host.
1390
+ - `Extensions.add_subcommand`: extra `vagrant xapi` subcommands;
1391
+ `xenserver/` adds `migrate`.
1392
+
1393
+ `spec/unit/legacy/boundary_spec.rb` fails when anything outside
1394
+ `lib/vagrant-xapi/legacy` and `lib/xenapi.rb` refers to the stand-in,
1395
+ `xenserver/` included, except the one `require_relative` in `plugin.rb`.
1396
+
1397
+ ### Removing the stand-in
1398
+
1399
+ 1. Announce it in the CHANGELOG one release ahead; the stand-in already says
1400
+ on every run that uses it that it is temporary. Remove it in a new major
1401
+ version (2.0.0), because Vagrantfiles whose triggers use it stop working.
1402
+ 2. Delete `lib/vagrant-xapi/legacy/`, `lib/xenapi.rb`,
1403
+ `locales/legacy.en.yml` and `spec/unit/legacy/`.
1404
+ 3. Delete the `require_relative "legacy/plugin"` line at the end of
1405
+ `lib/vagrant-xapi/plugin.rb`.
1406
+ 4. In scenario A15 of `test/acceptance/scenarios.sh`, drop the triggers and
1407
+ the checks of the extra disk; keep the rest (provider `:xenserver`,
1408
+ migrate).
1409
+ 5. Delete what documents it:
1410
+ - here: the "Temporary" subsection above, the `legacy/` line in the code
1411
+ map (section 4) and `legacy/` in the `on_config_finalized` hook point;
1412
+ - README: the paragraph on triggers that call XAPI themselves;
1413
+ - docs/operations.md: the sentence on the stand-in in section 2 and the
1414
+ troubleshooting rows "a trigger uses XenApi::Client" and "Stale VM
1415
+ reference";
1416
+ - AGENTS.md: the `legacy/` line in the layout and the paragraph on the
1417
+ stand-in;
1418
+ - test/acceptance/README.md: the triggers in the A15 row;
1419
+ - CHANGELOG.md: a line that the stand-in is gone.
1420
+ 6. `Extensions.on_config_finalized` may stay; it costs nothing.