vagrant-qemu 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 35de3b54145ee419e9cada4bd6b879fa0e9f7faef93e27da8e750346dcd6c0b7
4
- data.tar.gz: 8e0069a3cce24d59ab4723108bd54199cb6019a8a47c587601795bd6a9cc91a1
3
+ metadata.gz: b0968c034faf465b2d51e1f7b785a7a1aed39a4d6c40ef96f6b34147e9ab8ebd
4
+ data.tar.gz: fff8fc272c6c99abb85ccff761137f53ee6aabe8953f4b702ffca61163ea8d27
5
5
  SHA512:
6
- metadata.gz: de578f059ba6ee08339738a906e366f8b8f33acc7b6389b05d68af4d365ceb6829e6d2ac7cf84214d7ff5f7af8510d657228ebd08fa0f444d40a962e23001cae
7
- data.tar.gz: 5f74816cd5c18bf5ad492e546fea36d972b32585ebb0f360f1bd6cf2bbe70e475ea69365f6ff228addb5d8179feaa7944d74bfd80f4982dbd80eb7d2c7605a1b
6
+ metadata.gz: ba77c0b50cd06921799dcc154f8d987a3fe1e367fd4347bbd2a45381f0819815e9f832f29be04a76818fb5e5c24abb4f009fb33350aace09486f1a20f6250012
7
+ data.tar.gz: 6438ed9c811e25f7393e59971028eea43b5f3d76ac9a11982dd2424c8b7cbd54181cbaa514875aebbb54f38520fcab12575929e5c62259f42c37cce83274c34a
data/CHANGELOG.md CHANGED
@@ -101,6 +101,14 @@
101
101
  * Add support for cloud-init and disks
102
102
  * Add support for resizing disk on vm setup
103
103
 
104
+ # 0.4.1 (2026-06-22)
105
+
106
+ * `vagrant halt` now reaps QEMU even when the guest was already halted from
107
+ inside (e.g. `sudo systemctl halt`), where the ACPI `system_powerdown` is a
108
+ no-op. Halt escalates: `system_powerdown` -> wait `graceful_timeout` -> QEMU
109
+ `quit` monitor command (clean: flushes and closes the disk images) -> wait
110
+ `graceful_timeout` -> SIGKILL as a last resort (#79)
111
+
104
112
  # 0.4.0 (2026-06-12)
105
113
 
106
114
  * Advanced networking (opt-in, `advanced_network = true`): dual-NIC `private_network`
@@ -132,3 +140,17 @@
132
140
  * Warn when private_network is configured without `advanced_network`, when the
133
141
  network backend needs sudo, and when other unsupported network types are used
134
142
  * Add test suite: unit + acceptance + e2e (`rake spec:unit|acceptance|e2e`)
143
+
144
+ # 0.5.0 (2026-07-11)
145
+
146
+ * `vagrant package` is now supported: export a running QEMU VM into a
147
+ distributable libvirt-format box. The box disk is flattened from its qcow2
148
+ overlay into a standalone qcow2 with `qemu-img convert` (the live VM disk is
149
+ never modified). A single-disk VM produces `box.img`; a multi-disk VM
150
+ produces `box_1.img`..`box_N.img` plus a `disks[]` entry in `metadata.json`.
151
+ `metadata.json` is written with `provider: libvirt` / `format: qcow2` /
152
+ `virtual_size`, and a minimal default Vagrantfile declaring the box `arch` is
153
+ bundled. `--output`/`--include`/`--info`/`--vagrantfile` are handled by the
154
+ core packaging middleware. Environment-specific state — networking, MAC,
155
+ additional disks, DVDs/cloud-init seed ISOs, and firmware — is intentionally
156
+ not baked into the box.
data/README.md CHANGED
@@ -105,7 +105,7 @@ This provider exposes a few provider-specific configuration options:
105
105
  * `firmware_format` - The format of aarch64 firmware images (`edk2-aarch64-code.fd` and `edk2-arm-vars.fd`) loaded from `qemu_dir`, default: `raw`
106
106
  * `other_default` - The other default arguments used by this plugin, default: `%W(-parallel null -monitor none -display none -vga none)`
107
107
  * `extra_image_opts` - Options passed via `-o` to `qemu-img` when the base qcow2 images are created, default: `[]`
108
- * `graceful_timeout` - Seconds to wait for the guest to shut down on `vagrant halt` before the QEMU process is force-killed, default: `60`
108
+ * `graceful_timeout` - Seconds to wait at each `vagrant halt` stage before escalating, default: `60`. Halt sends ACPI `system_powerdown`, waits up to this long, then sends QEMU's `quit` monitor command (a clean shutdown that flushes and closes the disk images), waits again, and finally SIGKILLs QEMU as a last resort — so halt still completes even when the guest was already halted from inside (e.g. `sudo systemctl halt`), where `system_powerdown` is a no-op
109
109
  * advanced networking (requires `advanced_network = true`)
110
110
  * `advanced_network` - Enable dual-NIC advanced networking with `private_network` support, default: `false`
111
111
  * `net_mode` - Network backend: `:auto` (detect by platform), `:vmnet_shared`, `:vmnet_host`, `:vmnet_bridged` (macOS), `:tap` (Linux), `:socket` (QEMU `socket` netdev — multicast or point-to-point, see `socket_opts`), default: `:auto`
@@ -0,0 +1,74 @@
1
+ require "json"
2
+ require "open3"
3
+ require "pathname"
4
+ require "log4r"
5
+
6
+ module VagrantPlugins
7
+ module QEMU
8
+ module Action
9
+ # Produces the box's disk artifacts and metadata.json in export.temp_dir:
10
+ # flattens each box-disk overlay into a standalone qcow2 and describes them
11
+ # in a libvirt-format metadata.json. Only the VM's box disks are packaged;
12
+ # additional disks, DVDs and cloud-init seed ISOs are left out.
13
+ class Export
14
+ def initialize(app, env)
15
+ @app = app
16
+ @logger = Log4r::Logger.new("vagrant_qemu::action::export")
17
+ end
18
+
19
+ def call(env)
20
+ @env = env
21
+
22
+ if env[:machine].state.id != :stopped
23
+ raise Vagrant::Errors::VMPowerOffToPackage
24
+ end
25
+
26
+ export
27
+ @app.call(env)
28
+ end
29
+
30
+ def export
31
+ driver = @env[:machine].provider.driver
32
+ temp_dir = Pathname.new(@env["export.temp_dir"])
33
+ disks = driver.box_disk_paths
34
+
35
+ if disks.empty?
36
+ raise Errors::BoxInvalid, name: @env[:machine].name, err: "No box disk found to package"
37
+ end
38
+
39
+ multi = disks.size > 1
40
+ disk_meta = []
41
+ disks.each_with_index do |src, i|
42
+ name = multi ? "box_#{i + 1}.img" : "box.img"
43
+ @env[:ui].info(I18n.t("vagrant_qemu.packaging_disk", name: name))
44
+ driver.convert_box_disk(src, temp_dir.join(name))
45
+ disk_meta << { "path" => name }
46
+ end
47
+
48
+ write_metadata(temp_dir, disk_meta, multi)
49
+ end
50
+
51
+ # libvirt box metadata: provider must be "libvirt" (the provider's
52
+ # box_format) or the packaged box won't match on `box add`.
53
+ def write_metadata(temp_dir, disk_meta, multi)
54
+ metadata = {
55
+ "provider" => "libvirt",
56
+ "format" => "qcow2",
57
+ "virtual_size" => virtual_size_gb(temp_dir.join(disk_meta.first["path"])),
58
+ }
59
+ metadata["disks"] = disk_meta if multi
60
+ File.write(temp_dir.join("metadata.json"), JSON.pretty_generate(metadata))
61
+ end
62
+
63
+ def virtual_size_gb(img)
64
+ stdout, stderr, status = Open3.capture3("qemu-img", "info", "--output=json", img.to_s)
65
+ if !status.success?
66
+ raise Errors::ExecuteError, command: "qemu-img info", stderr: stderr, stdout: stdout
67
+ end
68
+ bytes = JSON.parse(stdout)["virtual-size"]
69
+ (bytes.to_f / (1024**3)).ceil
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,37 @@
1
+ require "pathname"
2
+
3
+ module VagrantPlugins
4
+ module QEMU
5
+ module Action
6
+ # Writes a minimal default Vagrantfile into the box declaring only the
7
+ # architecture, so a consumer on a different host arch doesn't default to
8
+ # the wrong one. Networking, MAC and host-specific machine flags are left
9
+ # out on purpose — they are environment-specific and come from the
10
+ # consuming Vagrantfile. The core Package middleware appends the SSH
11
+ # private-key config to this same file afterwards.
12
+ class PackageVagrantfile
13
+ def initialize(app, env)
14
+ @app = app
15
+ end
16
+
17
+ def call(env)
18
+ @env = env
19
+ create_vagrantfile
20
+ @app.call(env)
21
+ end
22
+
23
+ def create_vagrantfile
24
+ arch = @env[:machine].provider_config.arch
25
+ path = Pathname.new(@env["export.temp_dir"]).join("Vagrantfile")
26
+ File.write(path, <<~VF)
27
+ Vagrant.configure("2") do |config|
28
+ config.vm.provider :qemu do |qe|
29
+ qe.arch = "#{arch}"
30
+ end
31
+ end
32
+ VF
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -9,8 +9,21 @@ module VagrantPlugins
9
9
  include Vagrant::Action::Builtin
10
10
 
11
11
  def self.action_package
12
- lambda do |env|
13
- raise Errors::NotSupportedError
12
+ Vagrant::Action::Builder.new.tap do |b|
13
+ b.use ConfigValidate
14
+ b.use Call, IsState, :not_created do |env, b2|
15
+ if env[:result]
16
+ b2.use MessageNotCreated
17
+ next
18
+ end
19
+
20
+ b2.use Vagrant::Action::General::PackageSetupFolders
21
+ b2.use Vagrant::Action::General::PackageSetupFiles
22
+ b2.use StopInstance
23
+ b2.use Vagrant::Action::General::Package
24
+ b2.use Export
25
+ b2.use PackageVagrantfile
26
+ end
14
27
  end
15
28
  end
16
29
 
@@ -173,6 +186,8 @@ module VagrantPlugins
173
186
  autoload :Import, action_root.join("import")
174
187
  autoload :StartInstance, action_root.join("start_instance")
175
188
  autoload :StopInstance, action_root.join("stop_instance")
189
+ autoload :Export, action_root.join("export")
190
+ autoload :PackageVagrantfile, action_root.join("package_vagrantfile")
176
191
  autoload :Destroy, action_root.join("destroy")
177
192
  autoload :CloudInitNetwork, action_root.join("cloud_init_network")
178
193
  autoload :TimedProvision, action_root.join("timed_provision") # some plugins now expect this action to exist
@@ -212,10 +212,27 @@ module VagrantPlugins
212
212
  end
213
213
 
214
214
  def stop(options)
215
- if running?
216
- send_powerdown(with_persisted_control_port(options))
217
- wait_for_shutdown(options[:graceful_timeout] || 60)
218
- end
215
+ return unless running?
216
+
217
+ opts = with_persisted_control_port(options)
218
+ timeout = options[:graceful_timeout] || 60
219
+
220
+ # 1. ACPI power button (graceful). Only works if a live guest OS is
221
+ # there to act on it -- e.g. NOT after `systemctl halt`, which leaves
222
+ # QEMU running with a halted, unresponsive guest.
223
+ send_monitor(opts, "system_powerdown")
224
+ return unless still_running_after?(timeout)
225
+
226
+ # 2. Powerdown didn't take. Ask QEMU itself to quit: it stops the VM,
227
+ # flushes and closes the qcow2 images, then exits cleanly. Does not
228
+ # depend on the guest, only on the monitor being reachable.
229
+ @logger.warn("VM did not power down within #{timeout}s; sending 'quit' to QEMU")
230
+ send_monitor(opts, "quit")
231
+ return unless still_running_after?(timeout)
232
+
233
+ # 3. Last resort: SIGKILL the QEMU process (no flush/cleanup).
234
+ @logger.warn("VM still running after 'quit'; forcing kill")
235
+ force_kill
219
236
  end
220
237
 
221
238
  private
@@ -232,35 +249,37 @@ module VagrantPlugins
232
249
  options.merge(:control_port => persisted[:control_port])
233
250
  end
234
251
 
235
- def send_powerdown(options)
252
+ # Send a single QEMU monitor command over the control channel (TCP
253
+ # control_port if set, else the unix monitor socket). Best-effort: the
254
+ # socket may already be gone (e.g. QEMU exited from a prior command), so
255
+ # connection errors are swallowed.
256
+ def send_monitor(options, command)
236
257
  if !options[:control_port].nil?
237
258
  Socket.tcp("localhost", options[:control_port], connect_timeout: 5) do |sock|
238
- sock.print "system_powerdown\n"
259
+ sock.print "#{command}\n"
239
260
  sock.close_write
240
261
  sock.read rescue nil
241
262
  end
242
263
  else
243
- id_tmp_dir = @tmp_dir.join(@vm_id)
244
- unix_socket_path = id_tmp_dir.join("qemu_socket").to_s
264
+ unix_socket_path = @tmp_dir.join(@vm_id).join("qemu_socket").to_s
245
265
  Socket.unix(unix_socket_path) do |sock|
246
- sock.print "system_powerdown\n"
266
+ sock.print "#{command}\n"
247
267
  sock.close_write
248
268
  sock.read rescue nil
249
269
  end
250
270
  end
271
+ rescue => e
272
+ @logger.debug("monitor command '#{command}' failed: #{e}") if @logger
251
273
  end
252
274
 
253
- def wait_for_shutdown(timeout)
275
+ # Poll for up to `timeout` seconds. Return false as soon as the VM has
276
+ # stopped; return true if it is still running when the timeout elapses.
277
+ def still_running_after?(timeout)
254
278
  timeout.times do
255
- return unless running?
279
+ return false unless running?
256
280
  sleep 1
257
281
  end
258
-
259
- # Still running after timeout, force kill
260
- if running?
261
- @logger.warn("VM did not shut down within #{timeout}s, forcing kill")
262
- force_kill
263
- end
282
+ running?
264
283
  end
265
284
 
266
285
  def force_kill
@@ -455,6 +474,25 @@ module VagrantPlugins
455
474
  def disk_dir
456
475
  @data_dir.join(@vm_id)
457
476
  end
477
+
478
+ # Ordered paths of the box's disk overlays, reconstructed by index to
479
+ # match start/import (glob only counts; names rebuild by index so a
480
+ # two-digit suffix can't sort ahead of a single digit).
481
+ def box_disk_paths
482
+ id_dir = disk_dir
483
+ count = id_dir.glob("linked-box*.img").count
484
+ (0...count).map do |i|
485
+ suffix = i > 0 ? "-#{i}" : ""
486
+ id_dir.join("linked-box#{suffix}.img")
487
+ end
488
+ end
489
+
490
+ # Flatten a box disk overlay into a standalone qcow2 at dst. convert reads
491
+ # the whole backing chain and writes a fresh file, so the source overlay
492
+ # (the live VM disk) is never modified.
493
+ def convert_box_disk(src, dst)
494
+ execute("qemu-img", "convert", "-O", "qcow2", src.to_s, dst.to_s)
495
+ end
458
496
  end
459
497
  end
460
498
  end
@@ -1,5 +1,5 @@
1
1
  module VagrantPlugins
2
2
  module QEMU
3
- VERSION = '0.4.0'
3
+ VERSION = '0.5.0'
4
4
  end
5
5
  end
data/locales/en.yml CHANGED
@@ -15,6 +15,8 @@ en:
15
15
  Stopping the instance...
16
16
  destroying: |-
17
17
  Destroying the instance...
18
+ packaging_disk: |-
19
+ Exporting box disk: %{name}
18
20
  warn_networks: |-
19
21
  Warning! The QEMU provider doesn't support any of the Vagrant
20
22
  high-level network configurations (`config.vm.network`). They
@@ -0,0 +1,48 @@
1
+ require_relative "helper"
2
+
3
+ # Regression for #79: `vagrant halt` must actually reap QEMU even when the
4
+ # guest OS was already halted from inside (e.g. `sudo systemctl halt`). In that
5
+ # state the ACPI `system_powerdown` is a no-op (no live guest to act on it), so
6
+ # halt has to escalate to QEMU's `quit` monitor command and, failing that, to
7
+ # SIGKILL. A short graceful_timeout keeps the escalation fast.
8
+ describe "halt reaps QEMU when the guest is already halted (#79)", :requires_qemu do
9
+ around(:each) do |example|
10
+ with_temp_dir do |dir|
11
+ @work_dir = dir.join("project")
12
+ FileUtils.mkdir_p(@work_dir)
13
+
14
+ File.write(@work_dir.join("Vagrantfile"), <<~RUBY)
15
+ Vagrant.configure("2") do |config|
16
+ config.vm.box = "#{test_box}"
17
+ config.vm.box_check_update = false
18
+ config.vm.synced_folder ".", "/vagrant", disabled: true
19
+ config.vm.provider "qemu" do |qe|
20
+ qe.memory = "2G"
21
+ qe.graceful_timeout = 5
22
+ end
23
+ end
24
+ RUBY
25
+
26
+ example.run
27
+
28
+ vagrant_destroy(@work_dir) rescue nil
29
+ end
30
+ end
31
+
32
+ it "stops the VM after the guest was halted from inside" do
33
+ vagrant_up(@work_dir)
34
+
35
+ # Halt the guest OS from inside; the SSH connection drops as it goes down,
36
+ # so ignore the result. After this, system_powerdown can no longer work.
37
+ vagrant_ssh(@work_dir, command: "sudo systemctl halt", timeout: 30) rescue nil
38
+ sleep 3
39
+
40
+ # Without the escalation, QEMU would linger and the VM would still report
41
+ # running; with it, halt reaps the process within ~graceful_timeout.
42
+ result = vagrant_halt(@work_dir, timeout: 60)
43
+ expect(result[:exit_code]).to eq 0
44
+
45
+ status = vagrant_status(@work_dir)
46
+ expect(status[:stdout]).to include(",state,stopped")
47
+ end
48
+ end
data/spec/e2e/helper.rb CHANGED
@@ -73,6 +73,18 @@ module E2EHelper
73
73
  run_vagrant_cmd("status", "--machine-readable", cwd: cwd, timeout: 15)
74
74
  end
75
75
 
76
+ def vagrant_package(cwd, output:, timeout: 600)
77
+ run_vagrant_cmd("package", "--output", output.to_s, cwd: cwd, timeout: timeout)
78
+ end
79
+
80
+ def vagrant_box_add(name, path, cwd:, timeout: 120)
81
+ run_vagrant_cmd("box", "add", name, path.to_s, "--force", cwd: cwd, timeout: timeout)
82
+ end
83
+
84
+ def vagrant_box_remove(name, cwd:, timeout: 60)
85
+ run_vagrant_cmd("box", "remove", name, "--force", cwd: cwd, timeout: timeout)
86
+ end
87
+
76
88
  private
77
89
 
78
90
  def run_vagrant_cmd(*args, cwd:, timeout: 120)
@@ -0,0 +1,95 @@
1
+ require_relative "helper"
2
+
3
+ # End-to-end round-trip for `vagrant package`. Exercises the INSTALLED plugin
4
+ # (see helper.rb), so rebuild + reinstall before running:
5
+ # bundle exec rake build && vagrant plugin install ./pkg/vagrant-qemu-<ver>.gem
6
+ # TEST_QEMU=1 bundle exec rake spec:e2e
7
+ describe "vagrant package end-to-end", :requires_qemu do
8
+ around(:each) do |example|
9
+ with_temp_dir do |dir|
10
+ @work_dir = dir.join("project")
11
+ @consume_dir = dir.join("consumer")
12
+ @box_path = dir.join("packaged.box")
13
+ FileUtils.mkdir_p(@work_dir)
14
+ FileUtils.mkdir_p(@consume_dir)
15
+ example.run
16
+ vagrant_destroy(@consume_dir) rescue nil
17
+ vagrant_destroy(@work_dir) rescue nil
18
+ vagrant_box_remove("e2e-packaged", cwd: @work_dir) rescue nil
19
+ end
20
+ end
21
+
22
+ def write_source_vagrantfile
23
+ File.write(@work_dir.join("Vagrantfile"), <<~RUBY)
24
+ Vagrant.configure("2") do |config|
25
+ config.vm.box = "#{test_box}"
26
+ config.vm.box_check_update = false
27
+ config.vm.synced_folder ".", "/vagrant", disabled: true
28
+ config.vm.provider "qemu" do |qe|
29
+ qe.memory = "2G"
30
+ end
31
+ end
32
+ RUBY
33
+ end
34
+
35
+ it "packages a running VM into a box that boots again (round-trip)" do
36
+ write_source_vagrantfile
37
+ expect(vagrant_up(@work_dir)[:exit_code]).to eq 0
38
+
39
+ result = vagrant_package(@work_dir, output: @box_path)
40
+ expect(result[:exit_code]).to eq 0
41
+ expect(File.exist?(@box_path)).to be true
42
+
43
+ # Box layout: box.img + metadata.json + Vagrantfile.
44
+ listing = `tar tzf #{@box_path}`
45
+ expect(listing).to include("box.img")
46
+ expect(listing).to include("metadata.json")
47
+ expect(listing).to include("Vagrantfile")
48
+
49
+ # box.img must be a self-contained qcow2 (overlay flattened, no backing).
50
+ Dir.mktmpdir do |ex|
51
+ system("tar xzf #{@box_path} -C #{ex} box.img")
52
+ info = `qemu-img info --output=json #{File.join(ex, "box.img")}`
53
+ expect(info).to include('"format": "qcow2"')
54
+ expect(info).not_to match(/backing-filename/)
55
+ end
56
+
57
+ # Consume the packaged box in a fresh project and boot it.
58
+ expect(vagrant_box_add("e2e-packaged", @box_path, cwd: @work_dir)[:exit_code]).to eq 0
59
+ File.write(@consume_dir.join("Vagrantfile"), <<~RUBY)
60
+ Vagrant.configure("2") do |config|
61
+ config.vm.box = "e2e-packaged"
62
+ config.vm.box_check_update = false
63
+ config.vm.synced_folder ".", "/vagrant", disabled: true
64
+ config.vm.provider "qemu" do |qe|
65
+ qe.memory = "2G"
66
+ end
67
+ end
68
+ RUBY
69
+ expect(vagrant_up(@consume_dir)[:exit_code]).to eq 0
70
+ expect(vagrant_ssh(@consume_dir, command: "echo roundtrip-ok")[:stdout]).to include("roundtrip-ok")
71
+ end
72
+
73
+ it "does not bake environment-specific network config into the box" do
74
+ File.write(@work_dir.join("Vagrantfile"), <<~RUBY)
75
+ Vagrant.configure("2") do |config|
76
+ config.vm.box = "#{test_box}"
77
+ config.vm.box_check_update = false
78
+ config.vm.synced_folder ".", "/vagrant", disabled: true
79
+ config.vm.network "forwarded_port", guest: 80, host: 8080, auto_correct: true
80
+ config.vm.provider "qemu" do |qe|
81
+ qe.memory = "2G"
82
+ end
83
+ end
84
+ RUBY
85
+ expect(vagrant_up(@work_dir)[:exit_code]).to eq 0
86
+ expect(vagrant_package(@work_dir, output: @box_path)[:exit_code]).to eq 0
87
+
88
+ Dir.mktmpdir do |ex|
89
+ system("tar xzf #{@box_path} -C #{ex} Vagrantfile")
90
+ vf = File.read(File.join(ex, "Vagrantfile"))
91
+ expect(vf).to include("qe.arch")
92
+ expect(vf).not_to match(/forwarded_port|network|8080/)
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,103 @@
1
+ require "spec_helper"
2
+ require "json"
3
+ require "vagrant-qemu/action/export"
4
+
5
+ describe VagrantPlugins::QEMU::Action::Export do
6
+ let(:vm_id) { "vq_export" }
7
+ let(:app) { lambda { |env| } }
8
+ let(:ui) { double("ui", info: nil, detail: nil) }
9
+
10
+ around(:each) do |example|
11
+ with_temp_dir do |dir|
12
+ @data_dir = dir.join("data")
13
+ @tmp_base = dir.join("tmp")
14
+ @export_dir = dir.join("export")
15
+ FileUtils.mkdir_p(@data_dir.join(vm_id))
16
+ FileUtils.mkdir_p(@tmp_base)
17
+ FileUtils.mkdir_p(@export_dir)
18
+ example.run
19
+ end
20
+ end
21
+
22
+ let(:driver) { VagrantPlugins::QEMU::Driver.new(vm_id, @data_dir, @tmp_base) }
23
+ let(:provider) { double("provider", driver: driver) }
24
+ let(:state) { double("state", id: :stopped) }
25
+ let(:machine) do
26
+ c = VagrantPlugins::QEMU::Config.new
27
+ c.arch = "aarch64"
28
+ c.finalize!
29
+ double("machine", provider: provider, provider_config: c, name: "default", state: state)
30
+ end
31
+ let(:env) { { machine: machine, ui: ui, "export.temp_dir" => @export_dir.to_s } }
32
+
33
+ before do
34
+ VagrantPlugins::QEMU::Plugin.setup_i18n
35
+ # Don't invoke real qemu-img convert; just record calls.
36
+ allow(driver).to receive(:convert_box_disk)
37
+ # virtual-size from qemu-img info: 1 GiB + 1 byte → ceil to 2 GB.
38
+ status = double("status", success?: true)
39
+ allow(Open3).to receive(:capture3)
40
+ .and_return([JSON.generate("virtual-size" => (1024**3) + 1), "", status])
41
+ end
42
+
43
+ def metadata
44
+ JSON.parse(File.read(@export_dir.join("metadata.json")))
45
+ end
46
+
47
+ context "single box disk (v1)" do
48
+ before { FileUtils.touch(@data_dir.join(vm_id, "linked-box.img")) }
49
+
50
+ it "flattens the disk to box.img and writes libvirt v1 metadata (no disks[])" do
51
+ expect(driver).to receive(:convert_box_disk)
52
+ .with(@data_dir.join(vm_id, "linked-box.img"), @export_dir.join("box.img"))
53
+ described_class.new(app, env).call(env)
54
+
55
+ expect(metadata["provider"]).to eq "libvirt"
56
+ expect(metadata["format"]).to eq "qcow2"
57
+ expect(metadata["virtual_size"]).to eq 2
58
+ expect(metadata).not_to have_key("disks")
59
+ end
60
+ end
61
+
62
+ context "multiple box disks (v2)" do
63
+ before do
64
+ %w[linked-box.img linked-box-1.img].each { |n| FileUtils.touch(@data_dir.join(vm_id, n)) }
65
+ end
66
+
67
+ it "flattens each disk to box_N.img and writes disks[]" do
68
+ expect(driver).to receive(:convert_box_disk)
69
+ .with(@data_dir.join(vm_id, "linked-box.img"), @export_dir.join("box_1.img"))
70
+ expect(driver).to receive(:convert_box_disk)
71
+ .with(@data_dir.join(vm_id, "linked-box-1.img"), @export_dir.join("box_2.img"))
72
+ described_class.new(app, env).call(env)
73
+
74
+ expect(metadata["disks"]).to eq([{ "path" => "box_1.img" }, { "path" => "box_2.img" }])
75
+ end
76
+ end
77
+
78
+ context "environment-specific artifacts present" do
79
+ before do
80
+ FileUtils.touch(@data_dir.join(vm_id, "linked-box.img"))
81
+ FileUtils.touch(@data_dir.join(vm_id, "extra-data.qcow2"))
82
+ FileUtils.touch(@data_dir.join(vm_id, "vagrant-qemu-network.iso"))
83
+ end
84
+
85
+ it "packages only the box disk, not additional disks or ISOs" do
86
+ expect(driver).to receive(:convert_box_disk).once
87
+ .with(@data_dir.join(vm_id, "linked-box.img"), @export_dir.join("box.img"))
88
+ described_class.new(app, env).call(env)
89
+ expect(metadata).not_to have_key("disks")
90
+ end
91
+ end
92
+
93
+ context "VM not powered off" do
94
+ let(:state) { double("state", id: :running) }
95
+ before { FileUtils.touch(@data_dir.join(vm_id, "linked-box.img")) }
96
+
97
+ it "raises and does not convert" do
98
+ expect(driver).not_to receive(:convert_box_disk)
99
+ expect { described_class.new(app, env).call(env) }
100
+ .to raise_error(Vagrant::Errors::VMPowerOffToPackage)
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,22 @@
1
+ require "spec_helper"
2
+
3
+ describe VagrantPlugins::QEMU::Action, "#action_package" do
4
+ it "returns a runnable builder instead of raising NotSupportedError" do
5
+ expect(described_class.action_package).to be_a(Vagrant::Action::Builder)
6
+ end
7
+
8
+ it "no longer raises (package is supported now)" do
9
+ expect { described_class.action_package }.not_to raise_error
10
+ end
11
+
12
+ it "validates config and branches on machine state at the top of the chain" do
13
+ klasses = described_class.action_package.stack.map(&:first)
14
+ expect(klasses).to include(Vagrant::Action::Builtin::ConfigValidate)
15
+ expect(klasses).to include(Vagrant::Action::Builtin::Call)
16
+ end
17
+
18
+ it "autoloads the Export and PackageVagrantfile actions" do
19
+ expect { described_class::Export }.not_to raise_error
20
+ expect { described_class::PackageVagrantfile }.not_to raise_error
21
+ end
22
+ end
@@ -0,0 +1,41 @@
1
+ require "spec_helper"
2
+ require "vagrant-qemu/action/package_vagrantfile"
3
+
4
+ describe VagrantPlugins::QEMU::Action::PackageVagrantfile do
5
+ let(:app) { lambda { |env| } }
6
+ let(:ui) { double("ui", info: nil) }
7
+
8
+ around(:each) do |example|
9
+ with_temp_dir do |dir|
10
+ @export_dir = dir.join("export")
11
+ FileUtils.mkdir_p(@export_dir)
12
+ example.run
13
+ end
14
+ end
15
+
16
+ def machine_with(arch)
17
+ c = VagrantPlugins::QEMU::Config.new
18
+ c.arch = arch
19
+ c.finalize!
20
+ double("machine", provider_config: c)
21
+ end
22
+
23
+ def run(arch)
24
+ env = { machine: machine_with(arch), ui: ui, "export.temp_dir" => @export_dir.to_s }
25
+ described_class.new(app, env).call(env)
26
+ File.read(@export_dir.join("Vagrantfile"))
27
+ end
28
+
29
+ it "writes the VM arch into the default Vagrantfile" do
30
+ expect(run("aarch64")).to include('qe.arch = "aarch64"')
31
+ end
32
+
33
+ it "carries a different arch through" do
34
+ expect(run("x86_64")).to include('qe.arch = "x86_64"')
35
+ end
36
+
37
+ it "does not bake environment-specific network/MAC/machine config" do
38
+ vf = run("aarch64")
39
+ expect(vf).not_to match(/network|base_mac|accel|machine/i)
40
+ end
41
+ end
@@ -0,0 +1,51 @@
1
+ require "spec_helper"
2
+
3
+ describe VagrantPlugins::QEMU::Driver, "package helpers" do
4
+ let(:vm_id) { "vq_pkgtest" }
5
+
6
+ around(:each) do |example|
7
+ with_temp_dir do |dir|
8
+ @data_dir = dir.join("data")
9
+ @tmp_base = dir.join("tmp")
10
+ FileUtils.mkdir_p(@data_dir.join(vm_id))
11
+ FileUtils.mkdir_p(@tmp_base)
12
+ example.run
13
+ end
14
+ end
15
+
16
+ subject { described_class.new(vm_id, @data_dir, @tmp_base) }
17
+
18
+ describe "#box_disk_paths" do
19
+ it "enumerates box disks by index, matching start/import order (not glob lexical)" do
20
+ id_dir = @data_dir.join(vm_id)
21
+ # Create out of lexical order incl. a two-digit index to catch lexical-sort bugs.
22
+ %w[linked-box.img linked-box-1.img linked-box-2.img].each { |n| FileUtils.touch(id_dir.join(n)) }
23
+
24
+ expect(subject.box_disk_paths.map { |p| File.basename(p) })
25
+ .to eq %w[linked-box.img linked-box-1.img linked-box-2.img]
26
+ end
27
+
28
+ it "returns a single box.img overlay for a single-disk VM" do
29
+ FileUtils.touch(@data_dir.join(vm_id, "linked-box.img"))
30
+ expect(subject.box_disk_paths.map { |p| File.basename(p) }).to eq %w[linked-box.img]
31
+ end
32
+
33
+ it "excludes additional disks and ISOs (only linked-box*.img are box disks)" do
34
+ id_dir = @data_dir.join(vm_id)
35
+ FileUtils.touch(id_dir.join("linked-box.img"))
36
+ FileUtils.touch(id_dir.join("extra-data.qcow2"))
37
+ FileUtils.touch(id_dir.join("vagrant-qemu-network.iso"))
38
+
39
+ expect(subject.box_disk_paths.map { |p| File.basename(p) }).to eq %w[linked-box.img]
40
+ end
41
+ end
42
+
43
+ describe "#convert_box_disk" do
44
+ it "flattens via qemu-img convert to a fresh qcow2 (never mutates the source)" do
45
+ src = @data_dir.join(vm_id, "linked-box.img")
46
+ dst = @data_dir.join(vm_id, "box.img")
47
+ expect(subject).to receive(:execute).with("qemu-img", "convert", "-O", "qcow2", src.to_s, dst.to_s)
48
+ subject.convert_box_disk(src, dst)
49
+ end
50
+ end
51
+ end
@@ -16,47 +16,55 @@ describe VagrantPlugins::QEMU::Driver, "#stop" do
16
16
  subject { described_class.new(vm_id, @data_dir, @tmp_base) }
17
17
 
18
18
  it "does nothing when not running" do
19
- expect(subject).not_to receive(:send_powerdown)
19
+ allow(subject).to receive(:running?).and_return(false)
20
+ expect(subject).not_to receive(:send_monitor)
20
21
  subject.stop(control_port: nil)
21
22
  end
22
23
 
23
- it "sends powerdown when running" do
24
+ it "sends system_powerdown first and stops there when the guest powers off" do
25
+ # running on the initial check, stopped on the first poll
24
26
  allow(subject).to receive(:running?).and_return(true, false)
25
- allow(subject).to receive(:send_powerdown)
27
+ allow(subject).to receive(:send_monitor)
26
28
 
27
- subject.stop(control_port: nil)
29
+ subject.stop(control_port: nil, graceful_timeout: 5)
28
30
 
29
- expect(subject).to have_received(:send_powerdown)
31
+ expect(subject).to have_received(:send_monitor).with(anything, "system_powerdown")
32
+ expect(subject).not_to have_received(:send_monitor).with(anything, "quit")
30
33
  end
31
34
 
32
- it "returns when VM shuts down within timeout" do
33
- call_count = 0
34
- allow(subject).to receive(:running?) do
35
- call_count += 1
36
- call_count <= 1 # running on first call, stopped on second
37
- end
38
- allow(subject).to receive(:send_powerdown)
35
+ it "escalates to 'quit' when powerdown does not stop the VM" do
36
+ allow(subject).to receive(:sleep)
37
+ # stays up through the powerdown wait, then stops during the quit wait
38
+ allow(subject).to receive(:running?).and_return(true, true, true, false)
39
+ allow(subject).to receive(:send_monitor)
40
+ expect(subject).not_to receive(:force_kill)
39
41
 
40
- subject.stop(control_port: nil, graceful_timeout: 5)
42
+ subject.stop(control_port: nil, graceful_timeout: 1)
43
+
44
+ expect(subject).to have_received(:send_monitor).with(anything, "system_powerdown").ordered
45
+ expect(subject).to have_received(:send_monitor).with(anything, "quit").ordered
41
46
  end
42
47
 
43
- it "force kills when VM does not shut down within timeout" do
44
- allow(subject).to receive(:running?).and_return(true)
45
- allow(subject).to receive(:send_powerdown)
48
+ it "force kills only after both powerdown and 'quit' fail" do
46
49
  allow(subject).to receive(:sleep)
50
+ allow(subject).to receive(:running?).and_return(true) # never stops
51
+ allow(subject).to receive(:send_monitor)
47
52
 
48
53
  pid_dir = @tmp_base.join("vagrant-qemu", vm_id)
49
54
  FileUtils.mkdir_p(pid_dir)
50
55
  File.write(pid_dir.join("qemu.pid"), "999999999")
51
-
52
56
  allow(Process).to receive(:kill).with("KILL", 999999999).and_raise(Errno::ESRCH)
53
57
 
54
- expect { subject.stop(control_port: nil, graceful_timeout: 1) }.not_to raise_error
58
+ subject.stop(control_port: nil, graceful_timeout: 1)
59
+
60
+ expect(subject).to have_received(:send_monitor).with(anything, "system_powerdown")
61
+ expect(subject).to have_received(:send_monitor).with(anything, "quit")
62
+ expect(Process).to have_received(:kill).with("KILL", 999999999)
55
63
  end
56
64
 
57
65
  it "prefers the persisted control_port over the configured one" do
58
66
  allow(subject).to receive(:running?).and_return(true, false)
59
- allow(subject).to receive(:send_powerdown)
67
+ allow(subject).to receive(:send_monitor)
60
68
 
61
69
  opts_dir = @tmp_base.join("vagrant-qemu", vm_id)
62
70
  FileUtils.mkdir_p(opts_dir)
@@ -64,29 +72,28 @@ describe VagrantPlugins::QEMU::Driver, "#stop" do
64
72
 
65
73
  subject.stop(control_port: 33333)
66
74
 
67
- expect(subject).to have_received(:send_powerdown)
68
- .with(hash_including(control_port: 44444))
75
+ expect(subject).to have_received(:send_monitor)
76
+ .with(hash_including(control_port: 44444), "system_powerdown")
69
77
  end
70
78
 
71
79
  it "keeps the configured control_port when nothing is persisted" do
72
80
  allow(subject).to receive(:running?).and_return(true, false)
73
- allow(subject).to receive(:send_powerdown)
81
+ allow(subject).to receive(:send_monitor)
74
82
 
75
83
  subject.stop(control_port: 33333)
76
84
 
77
- expect(subject).to have_received(:send_powerdown)
78
- .with(hash_including(control_port: 33333))
85
+ expect(subject).to have_received(:send_monitor)
86
+ .with(hash_including(control_port: 33333), "system_powerdown")
79
87
  end
80
88
 
81
- it "swallows ESRCH on force_kill when process already gone" do
82
- allow(subject).to receive(:running?).and_return(true)
83
- allow(subject).to receive(:send_powerdown)
89
+ it "swallows ESRCH on force_kill when the process is already gone" do
84
90
  allow(subject).to receive(:sleep)
91
+ allow(subject).to receive(:running?).and_return(true)
92
+ allow(subject).to receive(:send_monitor)
85
93
 
86
94
  pid_dir = @tmp_base.join("vagrant-qemu", vm_id)
87
95
  FileUtils.mkdir_p(pid_dir)
88
96
  File.write(pid_dir.join("qemu.pid"), "999999999")
89
-
90
97
  allow(Process).to receive(:kill).and_raise(Errno::ESRCH)
91
98
 
92
99
  expect { subject.stop(control_port: nil, graceful_timeout: 1) }.not_to raise_error
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vagrant-qemu
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - ppggff
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-22 00:00:00.000000000 Z
11
+ date: 2026-07-11 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: Enables Vagrant to manage machines with QEMU.
14
14
  email: pgf00a@gmail.com
@@ -27,10 +27,12 @@ files:
27
27
  - lib/vagrant-qemu/action.rb
28
28
  - lib/vagrant-qemu/action/cloud_init_network.rb
29
29
  - lib/vagrant-qemu/action/destroy.rb
30
+ - lib/vagrant-qemu/action/export.rb
30
31
  - lib/vagrant-qemu/action/import.rb
31
32
  - lib/vagrant-qemu/action/message_already_created.rb
32
33
  - lib/vagrant-qemu/action/message_not_created.rb
33
34
  - lib/vagrant-qemu/action/message_will_not_destroy.rb
35
+ - lib/vagrant-qemu/action/package_vagrantfile.rb
34
36
  - lib/vagrant-qemu/action/prepare_forwarded_port_collision_params.rb
35
37
  - lib/vagrant-qemu/action/read_state.rb
36
38
  - lib/vagrant-qemu/action/start_instance.rb
@@ -59,7 +61,9 @@ files:
59
61
  - spec/e2e/advanced_network_spec.rb
60
62
  - spec/e2e/disk_spec.rb
61
63
  - spec/e2e/forwarded_port_spec.rb
64
+ - spec/e2e/halt_spec.rb
62
65
  - spec/e2e/helper.rb
66
+ - spec/e2e/package_spec.rb
63
67
  - spec/e2e/provision_spec.rb
64
68
  - spec/e2e/reload_spec.rb
65
69
  - spec/e2e/smoke_spec.rb
@@ -67,7 +71,10 @@ files:
67
71
  - spec/spec_helper.rb
68
72
  - spec/unit/action/cloud_init_network_spec.rb
69
73
  - spec/unit/action/destroy_spec.rb
74
+ - spec/unit/action/export_spec.rb
70
75
  - spec/unit/action/import_spec.rb
76
+ - spec/unit/action/package_spec.rb
77
+ - spec/unit/action/package_vagrantfile_spec.rb
71
78
  - spec/unit/action/prepare_forwarded_port_collision_params_spec.rb
72
79
  - spec/unit/action/read_state_spec.rb
73
80
  - spec/unit/action/start_instance_port_spec.rb
@@ -76,6 +83,7 @@ files:
76
83
  - spec/unit/config_spec.rb
77
84
  - spec/unit/driver/delete_spec.rb
78
85
  - spec/unit/driver/options_yaml_spec.rb
86
+ - spec/unit/driver/package_spec.rb
79
87
  - spec/unit/driver/ssh_port_spec.rb
80
88
  - spec/unit/driver/start_cmd_advanced_spec.rb
81
89
  - spec/unit/driver/start_cmd_order_spec.rb