Skip to content

Add forge host-setup and macOS/Apple Silicon support - #757

Open
Odilhao wants to merge 5 commits into
theforeman:masterfrom
Odilhao:macos-qemu-dev-support
Open

Add forge host-setup and macOS/Apple Silicon support#757
Odilhao wants to merge 5 commits into
theforeman:masterfrom
Odilhao:macos-qemu-dev-support

Conversation

@Odilhao

@Odilhao Odilhao commented Aug 17, 2026

Copy link
Copy Markdown
Member

Why are you introducing these changes? (Problem description, related links)

Closes #685. There was no automated way to bootstrap a host for forge, and no way to run it on Apple Silicon at all: foremanctl's containers are x86_64-only, and vagrant-qemu needs a different box format and networking setup than vagrant-libvirt.

What are the changes introduced in this pull request?

  • forge bootstrap-vagrant, a new command that installs Vagrant and a hypervisor provider (libvirt on Linux, vagrant-qemu on macOS) through forklift's vagrant role, see Add macOS support to the vagrant role forklift#1981
  • On Apple Silicon, the Vagrantfile boots a native arm64 CentOS Stream 10 guest through vagrant-qemu instead of emulating the whole VM
  • socket_vmnet gives the VM a real IP the same way libvirt does on Linux, and vagrant-hostmanager keeps /etc/hosts in sync so the UI is reachable by hostname
  • A new container_binfmt role registers qemu-user-static on the guest so it can still run the existing x86_64 container images
  • Fixed setup-environment, which called python instead of python3 and never worked on macOS at all

This depends on theforeman/forklift#1981, which isn't merged yet, so CI/local testing against a fresh checkout won't pick up the new vagrant role until that lands.

How to test this pull request

Steps to reproduce:

  • On an Apple Silicon Mac, run ./forge bootstrap-vagrant once
  • ./forge vms start to bring up the quadlet VM
  • ./foremanctl deploy --initial-admin-password=changeme --tuning development
  • Confirm https://quadlet.example.com loads in a browser

Timed a fresh run on an M-series Mac, box image already cached: forge vms start took about 1m20s, foremanctl deploy about 16m, UI reachable right after.

Only the quadlet VM works this way for now, client/database/proxy still need libvirt. Also worth flagging: ./forge test against the qemu VM currently fails several API tests, because the test suite assumes the Foreman API is reachable at the same address as SSH, which is true for libvirt but not for vagrant-qemu's separate NAT and vmnet interfaces. That's a known gap, not something this PR fixes.

Checklist

  • Tests added/updated (if applicable)
  • Documentation updated (if applicable)

Comment thread development/playbooks/bootstrap-vagrant/metadata.obsah.yaml
Comment thread development/playbooks/container_binfmt.yaml Outdated
Comment thread development/playbooks/container_binfmt.yaml
Comment thread docs/developer/development-environment.md Outdated
Comment thread Vagrantfile
Vagrant.configure("2") do |config|
config.vm.synced_folder ".", "/vagrant"

if ENV['VAGRANT_DEFAULT_PROVIDER'] == 'qemu' && Vagrant.has_plugin?('vagrant-hostmanager')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you expand on why this is being added? In a previous PR, this plugin was discussed and not implemented. Here are the details to help: #500 (review)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This part is quite complicated on Mac, we don't have the libvirtd option, I tried exploring with libkrun but I did not found anything for DNS. I had one idea during the implementation of having a small container in the mac host running a dnsServer, but it felt too hacky.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CC @ekohl given you had strong thoughts about the plugin.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was the only working method that I found to keep vagrant as part of the provisioning on M-series, we have https://github.com/libkrun/libkrun, that will use HVF instead of KVM, I could do some exploration to check how the network part would work on HVF, vagrant-qemu also run with HVF but with no libvirt DNS backend 😢

Comment thread Vagrantfile
macOS doesn't ship a plain "python" binary, only python3, so
setup-environment failed before getting anywhere near installing
dependencies.
foremanctl's containers are x86_64-only, and there was no way to run
forge on Apple Silicon at all: no host bootstrap step (theforeman#685), and
vagrant-qemu needs a different box format and networking setup than
vagrant-libvirt.

Adds `forge host-setup`, which installs Vagrant and a hypervisor
provider (libvirt on Linux, vagrant-qemu on macOS) through forklift's
vagrant role. On Apple Silicon the Vagrantfile now boots a native
arm64 CentOS Stream 10 guest through vagrant-qemu instead of emulating
the whole VM, with socket_vmnet giving it a real IP the way libvirt
does on Linux, and vagrant-hostmanager keeping /etc/hosts in sync so
the UI is reachable by hostname like it already is on Linux. A new
container_binfmt role registers qemu-user-static on the guest so it
can still run the existing x86_64 container images.

Only the quadlet VM is supported this way for now; client/database/proxy
still need libvirt. Running ./forge test against the qemu VM currently
fails several API tests, because the suite assumes the Foreman API is
reachable at the same address as SSH, which holds for libvirt but not
for vagrant-qemu's separate NAT and vmnet interfaces. Not fixed here.

Depends on theforeman/forklift#1981.
@Odilhao
Odilhao force-pushed the macos-qemu-dev-support branch from ecff58b to 8c1ab1d Compare August 20, 2026 21:50
Rename forge host-setup to forge bootstrap-vagrant, since host-setup
could be read as something broader than what it actually does.

container_binfmt.yaml was a thin wrapper around the container_binfmt
role plus some extra tasks; move all of it into the role itself and
apply the architecture check once, on the role inclusion, instead of
on each task. Also drop the systemd reload handler in favor of just
running it as the next task, since this is a one-shot provisioning
playbook rather than something that benefits from handler semantics.

Restructure the development-environment.md prerequisites: a target
host over SSH is what's actually required, Vagrant is one way to get
one. Vagrant-specific setup, including bootstrap-vagrant and the
macOS/qemu details, now lives under "Using Vagrant VMs" instead of
being stated as a blanket requirement.

Cleaned up the quadlet VM definition in the Vagrantfile: the qemu and
libvirt box/network setup is now a single if/else instead of separate
unless/if blocks with the hostname assignment sandwiched between them.

Verified by destroying and recreating the quadlet VM from scratch,
redeploying, and rerunning the test suite; no change in behavior.
client now shares quadlet's qemu setup (image, keypair, network,
cloud-init) via a new configure_qemu_guest helper, sized down to match
its libvirt memory/cpu. No binfmt on client, it doesn't run
foremanctl's containers.

Two things needed once there are two qemu guests instead of one:
ssh_auto_correct, since both default to SSH port 50022 and would
otherwise collide, and hostmanager's manage_guest, so client can
resolve quadlet.example.com at all.

That surfaced a real conflict: forklift's etc_hosts role also writes
guest-to-guest /etc/hosts entries, falling back to each host's
ansible_host when it hasn't gathered facts for it yet. On libvirt
ansible_host is the real VM IP, so this is fine; on qemu it's
127.0.0.1 (the NAT ssh address), so it was pointing every other
guest's hostname at the guest's own loopback. Skipped that provisioner
on the qemu path, since hostmanager's manage_guest already covers it
correctly.

Verified by bringing up quadlet and client together, confirming
guest-to-guest ping/DNS resolve correctly, deploying quadlet with
remote-execution enabled, and running
tests/feature/katello/client_test.py (the same file
.github/workflows/test.yml uses for client). Both tests fail during
fixture setup with the same pre-existing Connection refused to
127.0.0.1:443 already documented on this PR, not a new client-specific
issue; the SSH-based part of that fixture chain succeeds.
Both docs still said only quadlet was supported on macOS. database
and proxy remain libvirt-only.
@Odilhao
Odilhao force-pushed the macos-qemu-dev-support branch from f0b5933 to 257af30 Compare August 21, 2026 14:42
@Odilhao
Odilhao requested a review from ehelms August 21, 2026 17:30

@ekohl ekohl left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had kind of hoped that with EL 10 we would also start building ARM packages and avoid the need for all of that emulation.

Right now this feels very complex.

At least all of the code that uses VAGRANT_DEFAULT_PROVIDER needs to be using the proper integration per https://developer.hashicorp.com/vagrant/docs/providers/configuration.

Comment thread Vagrantfile
Comment on lines +13 to +20
def fetch_qemu_guest_image
image_path = File.join(FOREMANCTL_CACHE_DIR, 'CentOS-Stream-GenericCloud-10-latest.aarch64.qcow2')
unless File.exist?(image_path)
FileUtils.mkdir_p(FOREMANCTL_CACHE_DIR)
system('curl', '-fL', '-o', image_path, CENTOS_STREAM10_AARCH64_IMAGE_URL) || raise("Failed to download #{CENTOS_STREAM10_AARCH64_IMAGE_URL}")
end
image_path
end

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why can't you just pass in the native file path and let Vagrant do its thing?

Comment thread Vagrantfile
next unless vm.ssh_info && vm.ssh_info[:host]

result = ''
vm.communicate.execute('ip addr show eth0') { |type, data| result << data if type == :stdout }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

result is a string and << relies on mutable strings. Why not:

Suggested change
vm.communicate.execute('ip addr show eth0') { |type, data| result << data if type == :stdout }
vm.communicate.execute('ip addr show eth0') { |type, data| result = data if type == :stdout }

Comment thread Vagrantfile
config.vm.provision("etc_hosts", type: 'ansible') do |ansible|
ansible.playbook = "development/playbooks/etc_host.yml"
ansible.compatibility_mode = "2.0"
if ENV['VAGRANT_DEFAULT_PROVIDER'] == 'qemu' && Vagrant.has_plugin?('vagrant-hostmanager')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Normally I'd think you'd use:

Suggested change
if ENV['VAGRANT_DEFAULT_PROVIDER'] == 'qemu' && Vagrant.has_plugin?('vagrant-hostmanager')
config.vm.provider "qemu" do |qemu|
if Vagrant.has_plugin?('vagrant-hostmanager')

Comment thread Vagrantfile
end
end

if ENV['VAGRANT_DEFAULT_PROVIDER'] != 'qemu'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love to get rid of this and mandate https://community.theforeman.org/t/setting-up-libvirt-with-dns-resolution-for-vagrant/46567 but otherwise:

Suggested change
if ENV['VAGRANT_DEFAULT_PROVIDER'] != 'qemu'
config.vm.provider "libvirt" do |libvirt, provider|

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add forge subcommand to bootstrap host prerequisites (Vagrant, libvirt / vagrant-qemu)

3 participants