Incus: are we macOS yet?

So as I wrote here, turns out I was very wrong and my NIC remapping method actually works well. I’m quite upset I didn’t accuse the kernel before and to be fair, I’m surprised by the tight coupling between QEMU and the kernel in this particular case. Oh well, I’ve wasted a lot of time on that, but it was quite informative.

Anyway, now is time for a status update: I’m getting closer to a working macOS support. And with some of my contributions to Incus in the previous months, I can now propose a scriptlet-only solution (although, please note that it’s still an early WIP).

The scriptlet

# Some devices don’t make a lot of sense in the macOS world, at least for now
DELETED_DEVICES = {
  'chardev': ['spice-usb-chardev1', 'spice-usb-chardev2', 'spice-usb-chardev3'],
  'device': ['gpu', 'keyboard', 'spice-usb1', 'spice-usb2', 'spice-usb3', 'tablet', 'usb']
}

# On the other hand, a few devices need to be added
ADDED_DEVICES = {
  'device': {'apple_smc': {'driver': 'isa-applesmc', 'osk': '<REPLACE THIS WITH APPLE OSK>'},
             'qemu_sata': {'driver': 'ich9-ahci'},
             'qemu_vga': {'driver': 'VGA'},
             'qemu_usb': {'driver': 'qemu-xhci'},
             'usb_keyboard': {'driver': 'usb-kbd', 'bus': 'qemu_usb.0'},
             'usb_tablet': {'driver': 'usb-tablet', 'bus': 'qemu_usb.0'}}
}


def remap_storage(dev, drive):
  """
  Remap a storage device onto a SATA port
  :param dev: The dictionary representing the original device
  :param drive: The SATA drive
  """
  # Get data from the device
  qdev = dev['qdev']
  inserted = dev['inserted']
  fdset = 'fdset{}'.format(inserted['file'].split('/')[-1])

  log_info('[macOS scriptlet] Remapping disk {} to {}'.format(inserted['node-name'], drive))

  # Add a blockdev with the same FDset
  run_qmp({'execute': 'blockdev-add',
           'arguments': {'aio': 'native',
                         'cache': {'direct': True, 'no-flush': False},
                         'discard': 'unmap',
                         'driver': inserted['drv'],
                         'filename': inserted['file'],
                         'locking': 'off',
                         'node-name': fdset,
                         'read-only': inserted['ro']}})

  # Attach this blockdev to the SATA drive
  qom_set(path='/machine/peripheral/{}'.format(drive), property='drive', value=fdset)

  # Unplug the original device
  if qdev.endswith('/virtio-backend'):
    qdev = qdev[:-15]
  log_info('[macOS scriptlet] Unplugging {}'.format(qdev))
  device_del(id=qdev)


def hmp(command):
  """
  Run an HMP command
  :param command: The command to execute
  """
  return run_qmp({'execute': 'human-monitor-command',
                  'arguments': {'command-line': command}})['return'].strip().split('\r\n')


def remap_network(netdev, dev_name, net_id, fds):
  """
  Remap a network device onto a USB card
  :param netdev: The original netdev name
  :param dev_name: The original device name
  :param net_id: The USB card number
  :param fds: The TAP FDs
  """
  # Get data from the device
  mac = qom_get(path='/machine/peripheral/{}'.format(dev_name), property='mac')
  name = 'net{}'.format(net_id)

  log_warn('[macOS scriptlet] Remapping NIC {} [{}] to {}; consider setting `io.bus: usb`'
           .format(netdev, mac, name))

  # Add a netdev with the same FDs
  netdev_add(type='tap', id=name, fds=':'.join(fds))

  # Attach this netdev to a new USB card
  device_add(driver='usb-net', id=name, netdev=name, mac=mac, bus='qemu_usb.0')

  # Unplug the original device
  run_command('set_link', name=dev_name, up=False)
  log_info('[macOS scriptlet] Unplugging {}'.format(dev_name))
  device_del(id=dev_name)


def patch_config(devices):
  """
  Patch QEMU configuration
  :param devices: The expanded devices dictionary
  """
  log_info('[macOS scriptlet] Reconfiguring QEMU')

  # Initialize a dummy block device for hot-remapping purposes
  set_qemu_cmdline(get_qemu_cmdline() +
                   ['-blockdev', 'node-name=devzero,driver=raw,'+
                                 'file.driver=host_device,file.filename=/dev/zero'])

  # Get initial QEMU configuration
  initial_conf = get_qemu_conf()
  conf = []

  # Remove a few unusable devices
  deleted = ['{} "qemu_{}"'.format(prefix, name)
             for (prefix, devices) in DELETED_DEVICES.items() for name in devices]
  for device in initial_conf:
    name = device['name']
    if name in deleted:
      continue
    conf.append(device)

  # Add necessary devices
  added = {'{} "{}"'.format(prefix, name): value
           for (prefix, devices) in ADDED_DEVICES.items()
           for (name, value) in devices.items()}
  for (name, entries) in added.items():
    conf.append({'name': name, 'entries': entries})

  # Add placeholder SATA disks
  sata_count = 0
  for device in devices.values():
    if device['type'] == 'disk':
      conf.append({'name': 'device "sata{}"'.format(sata_count),
                   'comment': 'Automatically generated SATA disk',
                   'entries': {'driver': 'virtio-blk-pci', 'drive': 'devzero',
                               'share-rw': 'on'}})
      sata_count += 1

  # Set the new configuration
  set_qemu_conf(conf)


def remap_devices():
  """Remap QEMU devices"""
  log_info('[macOS scriptlet] Remapping devices')

  # Initialize device numbers
  sata_id = 0
  net_id = 0

  # For each block device
  for dev in run_command('query-block'):
    # If the device is a non-CD-ROM Incus disk
    if dev['inserted']['node-name'].startswith('incus_') and 'tray_open' not in dev:
      # Remap it
      remap_storage(dev, 'sata{}'.format(sata_id))
      sata_id += 1

  # Scan the network FDs
  fds = {}
  for line in hmp('info network'):
    if line.startswith(' \\ '):
      netdev = line.split(':')[0][3:]
      if netdev not in fds:
        fds[netdev] = []
      if 'fd=' in line:
        fds[netdev].append(line.split('fd=')[1])

  # For each device
  for dev in qom_list(path='/machine/peripheral'):
    # If the device is a VirtIO PCI network device
    if dev['type'] == 'child<virtio-net-pci>':
      dev_name = dev['name']
      # Get its backend netdev
      netdev = qom_get(path='/machine/peripheral/{}'.format(dev_name), property='netdev')
      # And remap it
      remap_network(netdev, dev_name, net_id, fds[netdev])
      net_id += 1


def qemu_hook(instance, stage):
  if stage == 'config':
    patch_config(instance.expanded_devices)
  elif stage == 'pre-start':
    remap_devices()

I’ve basically kept everything, and ported the code to the latest Incus version, using a single scriptlet without polluting other raw keys. There’s still quite a bit of work to do, but the project is back on track!

What’s next?

I can now install macOS without any issue, so I’ll need to test interacting with the OS, plugging devices and debug SPICE features. Then, some packaging work will be required, as getting macOS running is a bit trickier than just inserting an ISO and clicking “next”. I hope to give more news soon!

4 Likes