Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ group :development do
gem "rspec", "~> 3.0"
gem "rubocop", "~> 1.21"
gem "vagrant", git: "https://github.com/hashicorp/vagrant.git", tag: "v2.4.1"
gem "vagrant-spec", git: "https://github.com/hashicorp/vagrant-spec.git"
end

group :plugins do
Expand Down
31 changes: 25 additions & 6 deletions Rakefile
Original file line number Diff line number Diff line change
@@ -1,12 +1,31 @@
# frozen_string_literal: true

require "bundler/gem_tasks"
require "rspec/core/rake_task"
begin
require "bundler/gem_tasks"
require "rspec/core/rake_task"
RSpec::Core::RakeTask.new(:spec)

RSpec::Core::RakeTask.new(:spec)
require "rubocop/rake_task"
RuboCop::RakeTask.new

require "rubocop/rake_task"
task default: %i[spec rubocop]
rescue LoadError
# Allow rake to work without full dev dependencies
end

RuboCop::RakeTask.new
namespace :test do
desc "Run unit tests"
task :unit do
sh "bundle exec rspec spec/"
end

task default: %i[spec rubocop]
desc "Run acceptance tests (requires macOS + UTM + macOS box)"
task :acceptance do
sh "bundle exec rspec test/acceptance/"
end

desc "Run macOS acceptance tests (shell script)"
task :macos do
sh "test/acceptance/macos/run.sh"
end
end
2 changes: 2 additions & 0 deletions lib/vagrant_utm/action.rb
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ module Action # rubocop:disable Metrics/ModuleLength
autoload :PrepareNFSSettings, action_root.join("prepare_nfs_settings")
autoload :PrepareNFSValidIds, action_root.join("prepare_nfs_valid_ids")
autoload :PrepareForwardedPortCollisionParams, action_root.join("prepare_forwarded_port_collision_params")
autoload :PromptVirtioFS, action_root.join("prompt_virtiofs")
autoload :Resume, action_root.join("resume")
autoload :SetId, action_root.join("set_id")
autoload :SetName, action_root.join("set_name")
Expand Down Expand Up @@ -75,6 +76,7 @@ def self.action_boot # rubocop:disable Metrics/AbcSize
b.use ForwardPorts
b.use SetHostname
b.use Customize, "pre-boot"
b.use PromptVirtioFS
b.use Boot
b.use Customize, "post-boot"

Expand Down
91 changes: 91 additions & 0 deletions lib/vagrant_utm/action/prompt_virtiofs.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# frozen_string_literal: true

module VagrantPlugins
module Utm
module Action
# Prompts user to manually configure VirtioFS shared folders in UTM GUI
# for Apple Virtualization VMs. This is required because macOS security-scoped
# bookmarks cannot be created programmatically.
class PromptVirtioFS
include Vagrant::Action::Builtin::MixinSyncedFolders

def initialize(app, _env)
@app = app
end

def call(env)
machine = env[:machine]

# Only prompt for Apple Virtualization VMs with synced folders
folders = get_synced_folders(machine, env)
if apple_vm?(machine) && folders.any?
prompt_for_virtiofs(machine, env, folders)
end

@app.call(env)
end

private

def apple_vm?(machine)
machine.provider_config.skip_directory_share_mode
end

def get_synced_folders(machine, env)
opts = {
cached: !env[:synced_folders_cached].nil?,
config: env[:synced_folders_config],
disable_usable_check: !env[:test].nil?
}
all_folders = synced_folders(machine, **opts)

# Collect non-disabled folder paths
paths = []
all_folders.each do |_type, type_folders|
type_folders.each do |_id, data|
next if data[:disabled]

hostpath = File.expand_path(data[:hostpath], machine.env.root_path)
paths << { hostpath: hostpath, guestpath: data[:guestpath] }
end
end
paths
rescue StandardError
[]
end

def prompt_for_virtiofs(machine, env, folders)
ui = env[:ui]
vm_name = machine.provider.driver.read_vm_name rescue nil
vm_name ||= machine.name.to_s

ui.warn("")
ui.warn("=" * 70)
ui.warn(" MANUAL STEP REQUIRED: VirtioFS Shared Folders")
ui.warn("=" * 70)
ui.warn("")
ui.warn("Apple Virtualization VMs require manual shared folder setup.")
ui.warn("macOS security restrictions prevent automated configuration.")
ui.warn("")
ui.warn("Please complete these steps in UTM:")
ui.warn("")
ui.warn(" 1. Open UTM application")
ui.warn(" 2. Right-click '#{vm_name}' -> Edit")
ui.warn(" 3. Go to 'Sharing' tab")
ui.warn(" 4. Remove existing entries (if any), then re-add these paths:")
ui.warn("")
folders.each do |folder|
ui.warn(" -> #{folder[:hostpath]}")
ui.warn(" (mounts at: /Volumes/My Shared Files/#{File.basename(folder[:hostpath])}/)")
end
ui.warn("")
ui.warn(" 5. Save the VM settings")
ui.warn("")
ui.warn("=" * 70)
ui.warn("")
ui.ask("Press ENTER when done to continue booting the VM...")
end
end
end
end
end
11 changes: 8 additions & 3 deletions lib/vagrant_utm/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ class Config < Vagrant.plugin("2", :config)
# @return [Integer]
attr_accessor :wait_time

# If true, skips auto-setting directory_share_mode to 'virtFS'.
# Set this to true for Apple Virtualization VMs which don't use QEMU directory sharing.
#
# @return [Boolean]
attr_accessor :skip_directory_share_mode

# Initialize the configuration with unset values.
def initialize
super
Expand Down Expand Up @@ -128,9 +134,8 @@ def directory_share_mode=(mode)
def finalize!
# By default, we check for guest additions (qemu-ga)
@check_guest_additions = true if @check_guest_additions == UNSET_VALUE
# Always set the directory share mode to 'virtFS'
# default share folder implementation in utm plugin
self.directory_share_mode = "virtFS"
# Set virtFS as default for QEMU VMs, skip for Apple Virtualization
self.directory_share_mode = "virtFS" unless @skip_directory_share_mode
# By default, we assume the VM supports virtio 9p filesystems
@functional_9pfs = true if @functional_9pfs == UNSET_VALUE
# The default name is just nothing, and we default it
Expand Down
9 changes: 9 additions & 0 deletions lib/vagrant_utm/driver/version_4_5.rb
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,15 @@ def read_forwarded_ports(uuid = nil)
def read_guest_ip
output = execute("ip-address", @uuid)
output.strip.split("\n")
rescue Errors::UtmctlError => e
# Apple Virtualization VMs don't support ip-address command
# Return empty array so callers can handle gracefully
if e.message.include?("Operation not supported by the backend")
@logger.warn("ip-address not supported (Apple Virtualization VM), returning empty")
[]
else
raise
end
end

def read_network_interfaces
Expand Down
10 changes: 6 additions & 4 deletions lib/vagrant_utm/driver/version_4_6.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ def initialize(uuid)
def clear_shared_folders
# Get the list of shared folders
shared_folders = read_shared_folders
return if shared_folders.nil? || shared_folders.empty?

# Get the args to remove the shared folders
script_path = @script_path.join("read_shared_folders_args.js")
cmd = ["osascript", script_path.to_s, @uuid, "--ids", shared_folders.join(",")]
cmd = ["osascript", "-l", "JavaScript", script_path.to_s, @uuid, "--ids", shared_folders.join(",")]
output = execute_shell(*cmd)
result = JSON.parse(output)
return unless result["status"]
Expand Down Expand Up @@ -59,7 +61,7 @@ def export(path)
def read_shared_folders
@logger.debug("Reading shared folders")
script_path = @script_path.join("read_shared_folders.js")
cmd = ["osascript", script_path.to_s, @uuid]
cmd = ["osascript", "-l", "JavaScript", script_path.to_s, @uuid]
output = execute_shell(*cmd)
result = JSON.parse(output)
return unless result["status"]
Expand Down Expand Up @@ -88,10 +90,10 @@ def share_folders(folders)
end

def unshare_folders(folders)
@logger.debug("Unsharing folder: #{folder[:name]}")
@logger.debug("Unsharing folders: #{folders}")
# Get the args to remove the shared folders
script_path = @script_path.join("read_shared_folders_args.js")
cmd = ["osascript", script_path.to_s, @uuid, "--ids", folders.join(",")]
cmd = ["osascript", "-l", "JavaScript", script_path.to_s, @uuid, "--ids", folders.join(",")]
output = execute_shell(*cmd)
result = JSON.parse(output)
return unless result["status"]
Expand Down
33 changes: 32 additions & 1 deletion lib/vagrant_utm/synced_folder.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,40 @@ def usable?(machine, _raise_errors = false) # rubocop:disable Style/OptionalBool
# This is called before VM Boot to prepare the synced folders.
# Add required configs to the VM.
def prepare(machine, folders, _opts)
share_folders(machine, folders)
# Skip if no folders to share
return if folders.empty?

# For Apple Virtualization VMs, skip automated setup - it doesn't work
# because macOS security-scoped bookmarks cannot be created programmatically.
# The PromptVirtioFS action will prompt user to add folders via UTM GUI.
return if apple_vm?(machine)

# For QEMU VMs, configure 9pfs sharing
begin
share_folders(machine, folders)
rescue StandardError => e
machine.ui.warn("Could not configure shared folders: #{e.message}")
end
end

# This is called after VM Boot to mount the synced folders.
# Mount the shared folders inside the VM.
def enable(machine, folders, _opts) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength,Metrics/PerceivedComplexity
# For Apple Virtualization, VirtioFS auto-mounts at /Volumes/My Shared Files/
# User configures shared folders manually via UTM GUI (prompted before boot)
if apple_vm?(machine)
machine.ui.info("VirtioFS shared folders auto-mount at /Volumes/My Shared Files/")
return
end

# For QEMU VMs, configure and mount via 9pfs
share_folders(machine, folders)

# Check if guest supports mounting
unless machine.guest.capability?(:mount_virtualbox_shared_folder)
return
end

# sort guestpaths first, so we don't step on ourselves
folders = folders.sort_by do |_id, data|
if data[:guestpath]
Expand Down Expand Up @@ -103,6 +129,11 @@ def os_friendly_id(id)
id.gsub(%r{[\s/\\]}, "_").sub(/^_/, "")
end

# Check if this is an Apple Virtualization VM (opt-in via config)
def apple_vm?(machine)
machine.provider_config.skip_directory_share_mode
end

# share_folders sets up the shared folder definitions on the
# UTM VM.
#
Expand Down
4 changes: 4 additions & 0 deletions test/acceptance/base.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# frozen_string_literal: true

require "vagrant-spec/acceptance"
require_relative "shared/context_utm"
43 changes: 43 additions & 0 deletions test/acceptance/macos/Vagrantfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# -*- mode: ruby -*-
# frozen_string_literal: true

# Apple Virtualization macOS VMs use shared networking (bridged-like)
# No NAT port forwarding - SSH directly to mDNS hostname

# Derive paths from synced folder source
HOST_PATH = File.expand_path(".")
FOLDER_NAME = File.basename(HOST_PATH)
GUEST_PATH = HOST_PATH.sub(%r{^/Users/[^/]+}, "/Users/vagrant")
VIRTFS_MOUNT = "/Volumes/My Shared Files/#{FOLDER_NAME}"

Vagrant.configure("2") do |config|
config.vm.box = ENV["MACOS_BOX"] || "macOS26"
config.vm.box_url = ENV["MACOS_BOX_URL"] if ENV["MACOS_BOX_URL"]

# Direct SSH to VM (no port forwarding for Apple Virtualization)
config.ssh.host = ENV["MACOS_SSH_HOST"] || "vagrant-macos.local"
config.ssh.port = 22
config.ssh.username = "vagrant"
config.ssh.insert_key = false

# Disable default SSH port forwarding
config.vm.network "forwarded_port", id: "ssh", guest: 22, host: 2222, disabled: true

# VirtioFS synced folder - user will be prompted to add via UTM GUI
# Mounts at /Volumes/My Shared Files/<folder_name>/
config.vm.synced_folder ".", "/vagrant"

config.vm.provider :utm do |utm|
utm.memory = 4096
utm.cpus = 2
utm.check_guest_additions = false
# Apple Virtualization: skip QEMU directory_share_mode (uses VirtioFS instead)
utm.skip_directory_share_mode = true
end

# Create symlink to mirror host path structure under vagrant user's home
config.vm.provision "shell", inline: <<~SHELL
mkdir -p "#{File.dirname(GUEST_PATH)}"
ln -sfn "#{VIRTFS_MOUNT}" "#{GUEST_PATH}"
SHELL
end
28 changes: 28 additions & 0 deletions test/acceptance/macos/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
#!/bin/bash
set -e

cd "$(dirname "$0")"

GUEST_PATH="/Users/vagrant/${PWD#/Users/*/}"

echo "==> Starting VM..."
vagrant up --provider=utm

echo "==> Verifying SSH..."
vagrant ssh -c 'uname -a'

echo "==> Verifying Homebrew..."
vagrant ssh -c 'brew --version'

echo "==> Verifying symlinked path..."
vagrant ssh -c "test -f ${GUEST_PATH}/Vagrantfile"

echo "==> Verifying bidirectional sync..."
rm -f README.md
vagrant ssh -c "echo '# Test' > ${GUEST_PATH}/README.md"
test -f README.md
rm -f README.md

echo "==> ALL TESTS PASSED"

vagrant destroy -f
Loading