diff --git a/Makefile b/Makefile index 3a8f915c896..34a4333b682 100644 --- a/Makefile +++ b/Makefile @@ -186,6 +186,12 @@ native: clean limactl limactl-plugins helpers native-guestagent templates templa ################################################################################ # These configs were once customizable but should no longer be changed. CONFIG_GUESTAGENT_OS_LINUX=y +CONFIG_GUESTAGENT_OS_DARWIN= +ifeq ($(GOOS),darwin) +ifeq ($(GOARCH),arm64) +CONFIG_GUESTAGENT_OS_DARWIN=y +endif +endif CONFIG_GUESTAGENT_ARCH_X8664=y CONFIG_GUESTAGENT_ARCH_AARCH64=y CONFIG_GUESTAGENT_ARCH_ARMV7L=y @@ -341,15 +347,20 @@ MKDIR_TARGETS += _output/bin ################################################################################ # _output/share/lima/lima-guestagent LINUX_GUESTAGENT_PATH_COMMON = _output/share/lima/lima-guestagent.Linux- +DARWIN_GUESTAGENT_PATH_COMMON = _output/share/lima/lima-guestagent.Darwin- # How to add architecture specific guestagent: # 1. Add the architecture to GUESTAGENT_ARCHS # 2. Add ENVS_$(*_GUESTAGENT_PATH_COMMON) to set GOOS, GOARCH, and other necessary environment variables LINUX_GUESTAGENT_ARCHS = aarch64 armv7l ppc64le riscv64 s390x x86_64 +DARWIN_GUESTAGENT_ARCHS = aarch64 ifeq ($(CONFIG_GUESTAGENT_OS_LINUX),y) ALL_GUESTAGENTS_NOT_COMPRESSED += $(addprefix $(LINUX_GUESTAGENT_PATH_COMMON),$(LINUX_GUESTAGENT_ARCHS)) endif +ifeq ($(CONFIG_GUESTAGENT_OS_DARWIN),y) +ALL_GUESTAGENTS_NOT_COMPRESSED += $(addprefix $(DARWIN_GUESTAGENT_PATH_COMMON),$(DARWIN_GUESTAGENT_ARCHS)) +endif ifeq ($(CONFIG_GUESTAGENT_COMPRESS),y) gz=.gz endif @@ -367,6 +378,13 @@ NATIVE_GUESTAGENT = $(call guestagent_path,LINUX,$(NATIVE_GUESTAGENT_ARCH)) ADDITIONAL_GUESTAGENT_ARCHS = $(filter-out $(NATIVE_GUESTAGENT_ARCH),$(LINUX_GUESTAGENT_ARCHS)) ADDITIONAL_GUESTAGENTS = $(call guestagent_path,LINUX,$(ADDITIONAL_GUESTAGENT_ARCHS)) endif +ifeq ($(CONFIG_GUESTAGENT_OS_DARWIN),y) +ifeq ($(GOARCH),arm64) +NATIVE_GUESTAGENT_ARCH = aarch64 +NATIVE_GUESTAGENT += $(call guestagent_path,DARWIN,$(NATIVE_GUESTAGENT_ARCH)) +endif +endif +# No ADDITIONAL_GUESTAGENTS for Darwin, as only one architecture is supported. # config_guestagent_arch returns expanded value of CONFIG_GUESTAGENT_ARCH_ # $(1): architecture @@ -381,6 +399,9 @@ ifeq ($(CONFIG_GUESTAGENT_OS_LINUX),y) # apply CONFIG_GUESTAGENT_ARCH_* GUESTAGENTS += $(foreach arch,$(LINUX_GUESTAGENT_ARCHS),$(call guestagent_path_enabled_by_config,LINUX,$(arch))) endif +ifeq ($(CONFIG_GUESTAGENT_OS_DARWIN),y) +GUESTAGENTS += $(call guestagent_path,DARWIN,aarch64) +endif .PHONY: guestagents native-guestagent additional-guestagents guestagents: $(GUESTAGENTS) @@ -388,6 +409,7 @@ native-guestagent: $(NATIVE_GUESTAGENT) additional-guestagents: $(ADDITIONAL_GUESTAGENTS) %-guestagent: @[ "$(findstring $(*),$(LINUX_GUESTAGENT_ARCHS))" == "$(*)" ] && make $(call guestagent_path,LINUX,$*) + @[ "$(findstring $(*),$(DARWIN_GUESTAGENT_ARCHS))" == "$(*)" ] && make $(call guestagent_path,DARWIN,$*) # environment variables for linux-guestagent. these variable are used for checking force build. ENVS_$(LINUX_GUESTAGENT_PATH_COMMON)aarch64 = CGO_ENABLED=0 GOOS=linux GOARCH=arm64 @@ -396,11 +418,14 @@ ENVS_$(LINUX_GUESTAGENT_PATH_COMMON)ppc64le = CGO_ENABLED=0 GOOS=linux GOARCH=pp ENVS_$(LINUX_GUESTAGENT_PATH_COMMON)riscv64 = CGO_ENABLED=0 GOOS=linux GOARCH=riscv64 ENVS_$(LINUX_GUESTAGENT_PATH_COMMON)s390x = CGO_ENABLED=0 GOOS=linux GOARCH=s390x ENVS_$(LINUX_GUESTAGENT_PATH_COMMON)x86_64 = CGO_ENABLED=0 GOOS=linux GOARCH=amd64 +ENVS_$(DARWIN_GUESTAGENT_PATH_COMMON)aarch64 = CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 $(ALL_GUESTAGENTS_NOT_COMPRESSED): $(call dependencies_for_cmd,lima-guestagent) $$(call force_build_with_gunzip,$$@) | _output/share/lima $(ENVS_$@) $(GO_BUILD) -o $@ ./cmd/lima-guestagent chmod 644 $@ $(LINUX_GUESTAGENT_PATH_COMMON)%.gz: $(LINUX_GUESTAGENT_PATH_COMMON)% $$(call force_build_with_gunzip,$$@) @set -x; gzip -n $< +$(DARWIN_GUESTAGENT_PATH_COMMON)%.gz: $(DARWIN_GUESTAGENT_PATH_COMMON)% $$(call force_build_with_gunzip,$$@) + @set -x; gzip -n $< MKDIR_TARGETS += _output/share/lima diff --git a/cmd/lima-guestagent/fake_cloud_init_darwin.go b/cmd/lima-guestagent/fake_cloud_init_darwin.go new file mode 100644 index 00000000000..22dd3af066f --- /dev/null +++ b/cmd/lima-guestagent/fake_cloud_init_darwin.go @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + _ "embed" + + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/v2/pkg/guestagent/fakecloudinit" +) + +func newFakeCloudInitCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "fake-cloud-init", + Short: "Run fake cloud-init", + RunE: fakeCloudInitAction, + } + return cmd +} + +func fakeCloudInitAction(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + return fakecloudinit.Run(ctx) +} diff --git a/cmd/lima-guestagent/main_linux.go b/cmd/lima-guestagent/main.go similarity index 92% rename from cmd/lima-guestagent/main_linux.go rename to cmd/lima-guestagent/main.go index 2e001e9eecb..0928eca472f 100644 --- a/cmd/lima-guestagent/main_linux.go +++ b/cmd/lima-guestagent/main.go @@ -38,9 +38,6 @@ func newApp() *cobra.Command { } return nil } - rootCmd.AddCommand( - newDaemonCommand(), - newInstallSystemdCommand(), - ) + addRootCommands(rootCmd) return rootCmd } diff --git a/cmd/lima-guestagent/root_darwin.go b/cmd/lima-guestagent/root_darwin.go new file mode 100644 index 00000000000..1948b692783 --- /dev/null +++ b/cmd/lima-guestagent/root_darwin.go @@ -0,0 +1,14 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "github.com/spf13/cobra" +) + +func addRootCommands(rootCmd *cobra.Command) { + rootCmd.AddCommand( + newFakeCloudInitCommand(), + ) +} diff --git a/cmd/lima-guestagent/root_linux.go b/cmd/lima-guestagent/root_linux.go new file mode 100644 index 00000000000..da801df12a5 --- /dev/null +++ b/cmd/lima-guestagent/root_linux.go @@ -0,0 +1,15 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "github.com/spf13/cobra" +) + +func addRootCommands(rootCmd *cobra.Command) { + rootCmd.AddCommand( + newDaemonCommand(), + newInstallSystemdCommand(), + ) +} diff --git a/cmd/lima-guestagent/root_others.go b/cmd/lima-guestagent/root_others.go new file mode 100644 index 00000000000..970c2798b63 --- /dev/null +++ b/cmd/lima-guestagent/root_others.go @@ -0,0 +1,14 @@ +//go:build !linux && !darwin + +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "github.com/spf13/cobra" +) + +func addRootCommands(_ *cobra.Command) { + // NOP +} diff --git a/pkg/cidata/cidata.TEMPLATE.d/boot.sh b/pkg/cidata/cidata.TEMPLATE.d/boot.sh index 7d127721b4b..8f95270dd27 100644 --- a/pkg/cidata/cidata.TEMPLATE.d/boot.sh +++ b/pkg/cidata/cidata.TEMPLATE.d/boot.sh @@ -13,6 +13,14 @@ WARNING() { echo "LIMA $(date -Iseconds)| WARNING: $*" } +UNAME="$(uname -s)" + +RUN="/run" +if [ "${UNAME}" != "Linux" ]; then + RUN="/var/run" +fi +rm -f "${RUN}/lima-boot-done" + # shellcheck disable=SC2163 while read -r line; do [ -n "$line" ] && export "$line"; done <"${LIMA_CIDATA_MNT}"/lima.env # shellcheck disable=SC2163 @@ -43,13 +51,20 @@ CODE=0 if [ "$LIMA_CIDATA_PLAIN" = "1" ]; then INFO "Plain mode. Skipping to run boot scripts. Provisioning scripts will be still executed. Guest agent will not be running." else - for f in "${LIMA_CIDATA_MNT}"/boot/*; do - INFO "Executing $f" - if ! "$f"; then - WARNING "Failed to execute $f" - CODE=1 - fi - done + boot="${LIMA_CIDATA_MNT}/boot" + if [ "${UNAME}" != "Linux" ]; then + # TODO: rename boot to boot.Linux + boot="${boot}.${UNAME}" + fi + if [ -e "${boot}" ]; then + for f in "${LIMA_CIDATA_MNT}"/boot/*; do + INFO "Executing $f" + if ! "$f"; then + WARNING "Failed to execute $f" + CODE=1 + fi + done + fi fi # indirect variable lookup, like ${!var} in bash @@ -172,7 +187,10 @@ if [ -d "${LIMA_CIDATA_MNT}"/provision.user ]; then cp "$f" "${USER_SCRIPT}" chown "${LIMA_CIDATA_USER}" "${USER_SCRIPT}" chmod 755 "${USER_SCRIPT}" - if ! sudo -iu "${LIMA_CIDATA_USER}" "--preserve-env=${params}" "XDG_RUNTIME_DIR=/run/user/${LIMA_CIDATA_UID}" "${USER_SCRIPT}"; then + if [ "${UNAME}" != "Linux" ]; then + WARNING "Provisioning user scripts are not supported on non-Linux platforms" + CODE=1 + elif ! sudo -iu "${LIMA_CIDATA_USER}" "--preserve-env=${params}" "XDG_RUNTIME_DIR=/run/user/${LIMA_CIDATA_UID}" "${USER_SCRIPT}"; then WARNING "Failed to execute $f (as user ${LIMA_CIDATA_USER})" CODE=1 fi @@ -182,7 +200,7 @@ fi # Signal that provisioning is done. The instance-id in the meta-data file changes on every boot, # so any copy from a previous boot cycle will have different content. -cp "${LIMA_CIDATA_MNT}"/meta-data /run/lima-boot-done +cp "${LIMA_CIDATA_MNT}"/meta-data "${RUN}/lima-boot-done" INFO "Exiting with code $CODE" exit "$CODE" diff --git a/pkg/cidata/cidata.TEMPLATE.d/user-data b/pkg/cidata/cidata.TEMPLATE.d/user-data index 3a587c40b65..78ff852ae89 100644 --- a/pkg/cidata/cidata.TEMPLATE.d/user-data +++ b/pkg/cidata/cidata.TEMPLATE.d/user-data @@ -35,8 +35,11 @@ users: {{- end }} homedir: "{{.Home}}" shell: {{.Shell}} +{{- if ne .OS "Darwin" }} +{{/* On macOS, the password is not locked so as to allow GUI login */}} sudo: ALL=(ALL) NOPASSWD:ALL lock_passwd: true +{{- end }} ssh-authorized-keys: {{- range $val := .SSHPubKeys }} - {{ printf "%q" $val }} @@ -48,12 +51,25 @@ write_files: #!/bin/sh set -eux LIMA_CIDATA_MNT="/mnt/lima-cidata" - LIMA_CIDATA_DEV="/dev/disk/by-label/cidata" - mkdir -p -m 700 "${LIMA_CIDATA_MNT}" - mount -o ro,mode=0700,dmode=0700,overriderockperm,exec,uid=0 "${LIMA_CIDATA_DEV}" "${LIMA_CIDATA_MNT}" + UNAME="$(uname -s)" + if [ "${UNAME}" = "Darwin" ]; then + LIMA_CIDATA_MNT="/Volumes/cidata" + # Should have been mounted automatically + elif [ "${UNAME}" = "Linux" ]; then + LIMA_CIDATA_DEV="/dev/disk/by-label/cidata" + mkdir -p -m 700 "${LIMA_CIDATA_MNT}" + mount -o ro,mode=0700,dmode=0700,overriderockperm,exec,uid=0 "${LIMA_CIDATA_DEV}" "${LIMA_CIDATA_MNT}" + else + echo "Unsupported OS: ${UNAME}" >&2 + exit 1 + fi export LIMA_CIDATA_MNT exec "${LIMA_CIDATA_MNT}"/boot.sh +{{- if eq .OS "Darwin" }} + owner: root:wheel +{{- else }} owner: root:root +{{- end }} path: /var/lib/cloud/scripts/per-boot/00-lima.boot.sh permissions: '0755' {{- end }} diff --git a/pkg/cidata/cidata.TEMPLATE.d/util/timeout.sh b/pkg/cidata/cidata.TEMPLATE.d/util/timeout.sh new file mode 100755 index 00000000000..5de1d6d3b7d --- /dev/null +++ b/pkg/cidata/cidata.TEMPLATE.d/util/timeout.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# SPDX-FileCopyrightText: Copyright The Lima Authors +# SPDX-License-Identifier: Apache-2.0 + +# Reimplementation of `timeout` command for non-GNU operating systems, +# such as macOS. + +set -eu -o pipefail + +if [ "$#" -lt 2 ]; then + echo "Usage: $0 DURATION COMMAND [ARG]..." >&2 + exit 1 +fi + +timeout() { + local time=$1 + shift + "$@" & + local pid=$! + ( + sleep "$time" + kill -TERM "$pid" 2>/dev/null + ) & + local killer=$! + wait "$pid" + local status=$? + kill -TERM "$killer" 2>/dev/null + return $status +} + +timeout "$@" diff --git a/pkg/cidata/cidata.go b/pkg/cidata/cidata.go index aa80682dc25..24f740874f6 100644 --- a/pkg/cidata/cidata.go +++ b/pkg/cidata/cidata.go @@ -126,6 +126,7 @@ func templateArgs(ctx context.Context, bootScripts bool, instDir, name string, i archive := "nerdctl-full.tgz" args := TemplateArgs{ Debug: debugutil.Debug, + OS: *instConfig.OS, BootScripts: bootScripts, Name: name, Hostname: hostname.FromInstName(name), // TODO: support customization @@ -471,7 +472,12 @@ func GenerateISO9660(ctx context.Context, drv driver.Driver, instDir, name strin return writeCIDataDir(filepath.Join(instDir, filenames.CIDataISODir), layout) } - return iso9660util.Write(filepath.Join(instDir, filenames.CIDataISO), "cidata", layout) + var iso9660Options []iso9660util.WriteOpt + if instConfig.OS != nil && *instConfig.OS == limatype.DARWIN { + // Without Joliet, all the file names will be in upper case, and the hiphen will be replaced with underscore + iso9660Options = append(iso9660Options, iso9660util.WithJoliet()) + } + return iso9660util.Write(filepath.Join(instDir, filenames.CIDataISO), "cidata", layout, iso9660Options...) } func removeControlChars(s string) string { diff --git a/pkg/cidata/cloudinittypes/metadata.go b/pkg/cidata/cloudinittypes/metadata.go new file mode 100644 index 00000000000..2da9db7cb5d --- /dev/null +++ b/pkg/cidata/cloudinittypes/metadata.go @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package cloudinittypes + +type MetaData struct { + InstanceID string `yaml:"instance-id,omitempty"` + LocalHostname string `yaml:"local-hostname,omitempty"` +} diff --git a/pkg/cidata/cloudinittypes/userdata.go b/pkg/cidata/cloudinittypes/userdata.go new file mode 100644 index 00000000000..bcf5c862a4a --- /dev/null +++ b/pkg/cidata/cloudinittypes/userdata.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package cloudinittypes + +type UserData struct { + Growpart *Growpart `yaml:"growpart,omitempty"` + + PackageUpdate bool `yaml:"package_update,omitempty"` + PackageUpgrade bool `yaml:"package_upgrade,omitempty"` + PackageRebootIfRequired bool `yaml:"package_reboot_if_required,omitempty"` + + Mounts [][]string `yaml:"mounts,omitempty"` + + Timezone string `yaml:"timezone,omitempty"` + + Users []User `yaml:"users,omitempty"` + + WriteFiles []WriteFile `yaml:"write_files,omitempty"` + + ManageResolvConf bool `yaml:"manage_resolv_conf,omitempty"` + ResolvConf *ResolvConf `yaml:"resolv_conf,omitempty"` + + CACerts *CACerts `yaml:"ca_certs,omitempty"` + + BootCmd []string `yaml:"bootcmd,omitempty"` +} + +type Growpart struct { + Mode string `yaml:"mode"` + Devices []string `yaml:"devices"` +} + +type User struct { + Name string `yaml:"name,omitempty"` + Gecos string `yaml:"gecos,omitempty"` + UID string `yaml:"uid,omitempty"` // TODO: check if int is allowed too + Homedir string `yaml:"homedir,omitempty"` + Shell string `yaml:"shell,omitempty"` + Sudo string `yaml:"sudo,omitempty"` + LockPasswd string `yaml:"lock_passwd,omitempty"` + SSHAuthorizedKeys []string `yaml:"ssh-authorized-keys,omitempty"` +} + +type WriteFile struct { + Content string `yaml:"content"` + Owner string `yaml:"owner,omitempty"` + Path string `yaml:"path"` + Permissions string `yaml:"permissions,omitempty"` // TODO: check if int is allowed too +} + +type ResolvConf struct { + Nameservers []string `yaml:"nameservers,omitempty"` +} + +type CACerts struct { + RemoveDefaults bool `yaml:"remove_defaults,omitempty"` + Trusted []string `yaml:"trusted,omitempty"` +} diff --git a/pkg/cidata/template.go b/pkg/cidata/template.go index 84dfafce86d..b70bc66338f 100644 --- a/pkg/cidata/template.go +++ b/pkg/cidata/template.go @@ -13,6 +13,7 @@ import ( "github.com/lima-vm/lima/v2/pkg/identifiers" "github.com/lima-vm/lima/v2/pkg/iso9660util" + "github.com/lima-vm/lima/v2/pkg/limatype" "github.com/lima-vm/lima/v2/pkg/textutil" ) @@ -75,6 +76,7 @@ type Disk struct { } type TemplateArgs struct { Debug bool + OS limatype.OS Name string // instance name Hostname string // instance hostname IID string // instance id diff --git a/pkg/driver/vz/errors_darwin.go b/pkg/driver/vz/errors_darwin.go index aa180c70255..4dd5a01512e 100644 --- a/pkg/driver/vz/errors_darwin.go +++ b/pkg/driver/vz/errors_darwin.go @@ -7,8 +7,9 @@ package vz import "errors" -//nolint:revive,staticcheck // false positives with proper nouns +//nolint:revive,staticcheck,unused // false positives with proper nouns and GOARCH check var ( - errRosettaUnsupported = errors.New("Rosetta is unsupported on non-ARM64 hosts") - errUnimplemented = errors.New("unimplemented") + errRosettaUnsupported = errors.New("Rosetta is unsupported on non-ARM64 hosts") + errMacOSGuestUnsupported = errors.New("macOS guest is unsupported on non-ARM64 hosts") + errUnimplemented = errors.New("unimplemented") ) diff --git a/pkg/driver/vz/vm_darwin.go b/pkg/driver/vz/vm_darwin.go index 13a3bca96f6..577ee9a10a5 100644 --- a/pkg/driver/vz/vm_darwin.go +++ b/pkg/driver/vz/vm_darwin.go @@ -256,6 +256,47 @@ func createVM(ctx context.Context, inst *limatype.Instance) (*vz.VirtualMachine, return vz.NewVirtualMachine(vmConfig) } +// createVMForMacInstaller is similar to createVM but only used for VZMacOSInstaller. +// - Only the primary disk is attached. +// - No network. +func createVMForMacInstaller(_ context.Context, inst *limatype.Instance) (*vz.VirtualMachine, error) { + vmConfig, err := createInitialConfig(inst) + if err != nil { + return nil, err + } + + if err = attachPlatformConfig(inst, vmConfig); err != nil { + return nil, err + } + + // Only attach the primary disk here. cidata.iso is not existent at this point. + disk := filepath.Join(inst.Dir, filenames.Disk) + diffDiskAttachment, err := vz.NewDiskImageStorageDeviceAttachmentWithCacheAndSync(disk, false, diskImageCachingMode, vz.DiskImageSynchronizationModeFsync) + if err != nil { + return nil, err + } + diskConfig, err := vz.NewVirtioBlockDeviceConfiguration(diffDiskAttachment) + if err != nil { + return nil, err + } + vmConfig.SetStorageDevicesVirtualMachineConfiguration([]vz.StorageDeviceConfiguration{diskConfig}) + + if err = attachDisplay(inst, vmConfig); err != nil { + return nil, err + } + + if err = attachOtherDevices(inst, vmConfig); err != nil { + return nil, err + } + + validated, err := vmConfig.Validate() + if !validated || err != nil { + return nil, err + } + + return vz.NewVirtualMachine(vmConfig) +} + func createInitialConfig(inst *limatype.Instance) (*vz.VirtualMachineConfiguration, error) { bootLoader, err := bootLoader(inst) if err != nil { @@ -278,13 +319,27 @@ func createInitialConfig(inst *limatype.Instance) (*vz.VirtualMachineConfigurati return vmConfig, nil } -func attachPlatformConfig(inst *limatype.Instance, vmConfig *vz.VirtualMachineConfiguration) error { - machineIdentifier, err := getMachineIdentifier(inst) +func newPlatformConfiguration(inst *limatype.Instance) (vz.PlatformConfiguration, error) { + if inst.Config.OS != nil && *inst.Config.OS == limatype.DARWIN { + return newMacPlatformConfiguration(inst) + } + + identifierFile := filepath.Join(inst.Dir, filenames.VzIdentifier) + + machineIdentifier, err := getGenericMachineIdentifier(identifierFile) if err != nil { - return err + return nil, err } platformConfig, err := vz.NewGenericPlatformConfiguration(vz.WithGenericMachineIdentifier(machineIdentifier)) + if err != nil { + return nil, err + } + return platformConfig, nil +} + +func attachPlatformConfig(inst *limatype.Instance, vmConfig *vz.VirtualMachineConfiguration) error { + platformConfig, err := newPlatformConfiguration(inst) if err != nil { return err } @@ -304,7 +359,12 @@ func attachPlatformConfig(inst *limatype.Instance, vmConfig *vz.VirtualMachineCo return errors.New("nested virtualization is not supported on this device") } - if err := platformConfig.SetNestedVirtualizationEnabled(true); err != nil { + genericPlatformConfig, ok := platformConfig.(*vz.GenericPlatformConfiguration) + if !ok { + return errors.New("failed to cast platform configuration to generic platform configuration") + } + + if err := genericPlatformConfig.SetNestedVirtualizationEnabled(true); err != nil { return fmt.Errorf("cannot enable nested virtualization: %w", err) } } @@ -574,15 +634,25 @@ func attachDisks(ctx context.Context, inst *limatype.Instance, vmConfig *vz.Virt func attachDisplay(inst *limatype.Instance, vmConfig *vz.VirtualMachineConfiguration) error { switch *inst.Config.Video.Display { case "vz", "default": - graphicsDeviceConfiguration, err := vz.NewVirtioGraphicsDeviceConfiguration() - if err != nil { - return err - } - scanoutConfiguration, err := vz.NewVirtioGraphicsScanoutConfiguration(1920, 1200) - if err != nil { - return err + var graphicsDeviceConfiguration vz.GraphicsDeviceConfiguration + if inst.Config.OS != nil && *inst.Config.OS == limatype.DARWIN { + var err error + graphicsDeviceConfiguration, err = newMacGraphicsDeviceConfiguration(1920, 1200, 80) + if err != nil { + return err + } + } else { + var err error + graphicsDeviceConfiguration, err = vz.NewVirtioGraphicsDeviceConfiguration() + if err != nil { + return err + } + scanoutConfiguration, err := vz.NewVirtioGraphicsScanoutConfiguration(1920, 1200) + if err != nil { + return err + } + graphicsDeviceConfiguration.(*vz.VirtioGraphicsDeviceConfiguration).SetScanouts(scanoutConfiguration) } - graphicsDeviceConfiguration.SetScanouts(scanoutConfiguration) vmConfig.SetGraphicsDevicesVirtualMachineConfiguration([]vz.GraphicsDeviceConfiguration{ graphicsDeviceConfiguration, @@ -669,7 +739,7 @@ func attachAudio(inst *limatype.Instance, config *vz.VirtualMachineConfiguration } } -func attachOtherDevices(_ *limatype.Instance, vmConfig *vz.VirtualMachineConfiguration) error { +func attachOtherDevices(inst *limatype.Instance, vmConfig *vz.VirtualMachineConfiguration) error { entropyConfig, err := vz.NewVirtioEntropyDeviceConfiguration() if err != nil { return err @@ -724,7 +794,12 @@ func attachOtherDevices(_ *limatype.Instance, vmConfig *vz.VirtualMachineConfigu }) // Set pointing device - pointingDeviceConfig, err := vz.NewUSBScreenCoordinatePointingDeviceConfiguration() + var pointingDeviceConfig vz.PointingDeviceConfiguration + if inst.Config.OS != nil && *inst.Config.OS == limatype.DARWIN { + pointingDeviceConfig, err = newMacPointingDeviceConfiguration() + } else { + pointingDeviceConfig, err = vz.NewUSBScreenCoordinatePointingDeviceConfiguration() + } if err != nil { return err } @@ -733,7 +808,12 @@ func attachOtherDevices(_ *limatype.Instance, vmConfig *vz.VirtualMachineConfigu }) // Set keyboard device - keyboardDeviceConfig, err := vz.NewUSBKeyboardConfiguration() + var keyboardDeviceConfig vz.KeyboardConfiguration + if inst.Config.OS != nil && *inst.Config.OS == limatype.DARWIN { + keyboardDeviceConfig, err = newMacKeyboardConfiguration() + } else { + keyboardDeviceConfig, err = vz.NewUSBKeyboardConfiguration() + } if err != nil { return err } @@ -743,8 +823,11 @@ func attachOtherDevices(_ *limatype.Instance, vmConfig *vz.VirtualMachineConfigu return nil } -func getMachineIdentifier(inst *limatype.Instance) (*vz.GenericMachineIdentifier, error) { - identifier := filepath.Join(inst.Dir, filenames.VzIdentifier) +type machineIdentifier interface { + DataRepresentation() []byte +} + +func getGenericMachineIdentifier(identifier string) (*vz.GenericMachineIdentifier, error) { // Empty VzIdentifier can be created on cloning an instance. if st, err := os.Stat(identifier); err != nil || (st != nil && st.Size() == 0) { if err != nil && !errors.Is(err, fs.ErrNotExist) { @@ -764,6 +847,9 @@ func getMachineIdentifier(inst *limatype.Instance) (*vz.GenericMachineIdentifier } func bootLoader(inst *limatype.Instance) (vz.BootLoader, error) { + if inst.Config.OS != nil && *inst.Config.OS == limatype.DARWIN { + return newMacOSBootLoader() + } linuxBootLoader, err := linuxBootLoader(inst) if linuxBootLoader != nil { return linuxBootLoader, nil @@ -843,3 +929,20 @@ func createSockPair() (server, client *os.File, _ error) { vmNetworkFiles = append(vmNetworkFiles, server, client) return server, client, nil } + +func ensureIPSW(instDir string) error { + ipsw := filepath.Join(instDir, filenames.ImageIPSW) + if osutil.FileExists(ipsw) { + return nil + } + ipswBase := filepath.Join(instDir, filenames.Image) + if _, err := os.Stat(ipswBase); err != nil { + return err + } + // The installer wants the file to have ".ipsw" suffix. + // The link is created as a hard link, as the installer does not accept symlinks. + if err := os.Link(ipswBase, ipsw); err != nil { + return fmt.Errorf("failed to create hard link from %q to %q: %w", ipswBase, ipsw, err) + } + return nil +} diff --git a/pkg/driver/vz/vm_darwin_amd64.go b/pkg/driver/vz/vm_darwin_amd64.go new file mode 100644 index 00000000000..d449c50bc2c --- /dev/null +++ b/pkg/driver/vz/vm_darwin_amd64.go @@ -0,0 +1,41 @@ +//go:build darwin && !no_vz + +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package vz + +import ( + "context" + + "github.com/Code-Hex/vz/v3" + "github.com/lima-vm/lima/v2/pkg/limatype" +) + +func getMacMachineIdentifier(_ string) (machineIdentifier, error) { + return nil, errMacOSGuestUnsupported +} + +func newMacPlatformConfiguration(_ *limatype.Instance) (vz.PlatformConfiguration, error) { + return nil, errMacOSGuestUnsupported +} + +func newMacGraphicsDeviceConfiguration(_, _, _ int64) (vz.GraphicsDeviceConfiguration, error) { + return nil, errMacOSGuestUnsupported +} + +func newMacPointingDeviceConfiguration() (vz.PointingDeviceConfiguration, error) { + return nil, errMacOSGuestUnsupported +} + +func newMacKeyboardConfiguration() (vz.KeyboardConfiguration, error) { + return nil, errMacOSGuestUnsupported +} + +func newMacOSBootLoader() (vz.BootLoader, error) { + return nil, errMacOSGuestUnsupported +} + +func installMacOS(_ context.Context, _ *vz.VirtualMachine, _ string) error { + return errMacOSGuestUnsupported +} diff --git a/pkg/driver/vz/vm_darwin_arm64.go b/pkg/driver/vz/vm_darwin_arm64.go new file mode 100644 index 00000000000..0a3fc0b62a4 --- /dev/null +++ b/pkg/driver/vz/vm_darwin_arm64.go @@ -0,0 +1,177 @@ +//go:build darwin && !no_vz + +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package vz + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "time" + + "github.com/Code-Hex/vz/v3" + "github.com/sirupsen/logrus" + + "github.com/lima-vm/lima/v2/pkg/limatype" + "github.com/lima-vm/lima/v2/pkg/limatype/filenames" + "github.com/lima-vm/lima/v2/pkg/osutil" + "github.com/lima-vm/lima/v2/pkg/progressbar" +) + +func getMacMachineIdentifier(identifier string) (machineIdentifier, error) { + // Empty VzIdentifier can be created on cloning an instance. + if st, err := os.Stat(identifier); err != nil || (st != nil && st.Size() == 0) { + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return nil, err + } + machineIdentifier, err := vz.NewMacMachineIdentifier() + if err != nil { + return nil, err + } + err = os.WriteFile(identifier, machineIdentifier.DataRepresentation(), 0o666) + if err != nil { + return nil, err + } + return machineIdentifier, nil + } + return vz.NewMacMachineIdentifierWithDataPath(identifier) +} + +func newMacPlatformConfiguration(inst *limatype.Instance) (vz.PlatformConfiguration, error) { + identifierFile := filepath.Join(inst.Dir, filenames.VzIdentifier) + machineIdentifier, err := getMacMachineIdentifier(identifierFile) + if err != nil { + return nil, err + } + + var hwModelData []byte + hwModelFile := filepath.Join(inst.Dir, filenames.VzHwModel) + if osutil.FileExists(hwModelFile) { + hwModelData, err = os.ReadFile(hwModelFile) + if err != nil { + return nil, err + } + } else { + if err = ensureIPSW(inst.Dir); err != nil { + return nil, err + } + ipsw := filepath.Join(inst.Dir, filenames.ImageIPSW) + ipswImage, err := vz.LoadMacOSRestoreImageFromPath(ipsw) + if err != nil { + return nil, err + } + ipswMostFeatureFul := ipswImage.MostFeaturefulSupportedConfiguration() + hwModelData = ipswMostFeatureFul.HardwareModel().DataRepresentation() + if err = os.WriteFile(hwModelFile, hwModelData, 0o666); err != nil { + return nil, err + } + } + hwModel, err := vz.NewMacHardwareModelWithData(hwModelData) + if err != nil { + return nil, err + } + + auxFile := filepath.Join(inst.Dir, filenames.VzAux) + var auxOps []vz.NewMacAuxiliaryStorageOption + if !osutil.FileExists(auxFile) { + auxOps = append(auxOps, vz.WithCreatingMacAuxiliaryStorage(hwModel)) + } + aux, err := vz.NewMacAuxiliaryStorage(auxFile, auxOps...) + if err != nil { + return nil, err + } + + platformConfig, err := vz.NewMacPlatformConfiguration( + vz.WithMacMachineIdentifier(machineIdentifier.(*vz.MacMachineIdentifier)), + vz.WithMacHardwareModel(hwModel), + vz.WithMacAuxiliaryStorage(aux), + ) + if err != nil { + return nil, err + } + return platformConfig, nil +} + +func newMacGraphicsDeviceConfiguration(x, y, pixelsPerInch int64) (vz.GraphicsDeviceConfiguration, error) { + graphicsDeviceConfiguration, err := vz.NewMacGraphicsDeviceConfiguration() + if err != nil { + return nil, err + } + scanoutConfiguration, err := vz.NewMacGraphicsDisplayConfiguration(x, y, pixelsPerInch) + if err != nil { + return nil, err + } + graphicsDeviceConfiguration.SetDisplays(scanoutConfiguration) + return graphicsDeviceConfiguration, nil +} + +func newMacPointingDeviceConfiguration() (vz.PointingDeviceConfiguration, error) { + return vz.NewMacTrackpadConfiguration() +} + +func newMacKeyboardConfiguration() (vz.KeyboardConfiguration, error) { + return vz.NewMacKeyboardConfiguration() +} + +func newMacOSBootLoader() (vz.BootLoader, error) { + return vz.NewMacOSBootLoader() +} + +func installMacOS(ctx context.Context, vm *vz.VirtualMachine, ipsw string) error { + installer, err := vz.NewMacOSInstaller(vm, ipsw) + if err != nil { + return err + } + + const barResolution = 100 + bar, err := progressbar.New(barResolution) + if err != nil { + return err + } + bar.Start() + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + ticker := time.NewTicker(3 * time.Second) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + logrus.WithError(ctx.Err()).Info("cancelling macOS installation") + return + case <-installer.Done(): + logrus.WithError(ctx.Err()).Info("macOS installer exited") + bar.SetCurrent(barResolution) + return + case <-ticker.C: + progress := installer.FractionCompleted() * barResolution + bar.SetCurrent(int64(progress)) + } + } + }() + + err = installer.Install(ctx) + bar.Finish() + if err != nil { + return err + } + + stopped, err := vm.RequestStop() + if err != nil { + return fmt.Errorf("failed to stop VM after installation: %w", err) + } + if !stopped { + logrus.WithError(err).Warn("VM did not stop after installation") + if err = vm.Stop(); err != nil { + return fmt.Errorf("failed to force stop VM after installation: %w", err) + } + } + return nil +} diff --git a/pkg/driver/vz/vz_driver_darwin.go b/pkg/driver/vz/vz_driver_darwin.go index 53eb0e957f9..49dc6568475 100644 --- a/pkg/driver/vz/vz_driver_darwin.go +++ b/pkg/driver/vz/vz_driver_darwin.go @@ -11,12 +11,14 @@ import ( "errors" "fmt" "net" + "os" "path/filepath" "runtime" "time" "github.com/Code-Hex/vz/v3" "github.com/coreos/go-semver/semver" + "github.com/docker/go-units" "github.com/lima-vm/go-qcow2reader/image" "github.com/lima-vm/go-qcow2reader/image/asif" "github.com/lima-vm/go-qcow2reader/image/raw" @@ -24,8 +26,11 @@ import ( "github.com/lima-vm/lima/v2/pkg/driver" "github.com/lima-vm/lima/v2/pkg/driverutil" + "github.com/lima-vm/lima/v2/pkg/guestpatch/macos" "github.com/lima-vm/lima/v2/pkg/hostagent/events" + "github.com/lima-vm/lima/v2/pkg/imgutil/nativeimgutil/asifutil" "github.com/lima-vm/lima/v2/pkg/limatype" + "github.com/lima-vm/lima/v2/pkg/limatype/filenames" "github.com/lima-vm/lima/v2/pkg/limayaml" "github.com/lima-vm/lima/v2/pkg/osutil" "github.com/lima-vm/lima/v2/pkg/ptr" @@ -330,14 +335,80 @@ func validateConfig(_ context.Context, cfg *limatype.LimaYAML) error { } func (l *LimaVzDriver) Create(_ context.Context) error { - _, err := getMachineIdentifier(l.Instance) + idenfierFile := filepath.Join(l.Instance.Dir, filenames.VzIdentifier) + if l.Instance.Config.OS != nil && *l.Instance.Config.OS == limatype.DARWIN { + _, err := getMacMachineIdentifier(idenfierFile) + return err + } + _, err := getGenericMachineIdentifier(idenfierFile) return err } func (l *LimaVzDriver) CreateDisk(ctx context.Context) error { + if l.Instance.Config.OS != nil && *l.Instance.Config.OS == limatype.DARWIN { + disk := filepath.Join(l.Instance.Dir, filenames.Disk) + if !osutil.FileExists(disk) { + if err := l.createDiskMacOSGuest(ctx); err != nil { + return err + } + } + + // Wait for a few seconds to avoid "Failed to lock auxiliary storage" error + time.Sleep(3 * time.Second) + + patchedMarker := disk + ".patched" // empty file + if !osutil.FileExists(patchedMarker) { + logrus.Infof("Patching macOS disk %q", disk) + if err := macos.Patch(ctx, disk); err != nil { + return err + } + if err := os.WriteFile(patchedMarker, []byte{}, 0o644); err != nil { + return err + } + } + } return driverutil.EnsureDisk(ctx, l.Instance.Dir, *l.Instance.Config.Disk, l.diskImageFormat) } +// createDiskMacOSGuest creates `disk` and installs macOS from `image` on it. +// The function must not be called if `disk` already exists. +// +// The function creates the following files: +// - `image.ipsw`: hardlink to `image` (".ipsw" suffix is required by VZMacOSInstaller) +// - `disk`: ASIF disk +// +// TODO: consider removing IPSW after successful installation. +func (l *LimaVzDriver) createDiskMacOSGuest(ctx context.Context) error { + disk := filepath.Join(l.Instance.Dir, filenames.Disk) + + diskSize, err := units.RAMInBytes(*l.Instance.Config.Disk) + if err != nil { + return fmt.Errorf("invalid disk size %q: %w", *l.Instance.Config.Disk, err) + } + if err := asifutil.NewASIF(disk, diskSize); err != nil { + return err + } + + if err = ensureIPSW(l.Instance.Dir); err != nil { + return err + } + ipsw := filepath.Join(l.Instance.Dir, filenames.ImageIPSW) + + vm, err := createVMForMacInstaller(ctx, l.Instance) + if err != nil { + return err + } + + logrus.Info("Running macOS installer (takes a few minutes)") + // FIXME: do we need to run the installer for every new instance, + // or can we safely reuse the installed disk image? + if err := installMacOS(ctx, vm, ipsw); err != nil { + return fmt.Errorf("failed to install macOS: %w", err) + } + + return nil +} + func (l *LimaVzDriver) Start(ctx context.Context) (chan error, error) { logrus.Infof("Starting VZ (hint: to watch the boot progress, see %q)", filepath.Join(l.Instance.Dir, "serial*.log")) vm, waitSSHLocalPortAccessible, errCh, err := startVM(ctx, l.Instance, l.SSHLocalPort, l.onVsockEvent) diff --git a/pkg/guestagent/fakecloudinit/fakecloudinit_darwin.go b/pkg/guestagent/fakecloudinit/fakecloudinit_darwin.go new file mode 100644 index 00000000000..f44ed7e006c --- /dev/null +++ b/pkg/guestagent/fakecloudinit/fakecloudinit_darwin.go @@ -0,0 +1,331 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package fakecloudinit is a fake cloud-init implementation for macOS. +// +// TODO: support other OS that does not have cloud-init. +package fakecloudinit + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "github.com/goccy/go-yaml" + "github.com/sethvargo/go-password/password" + "github.com/sirupsen/logrus" + + "github.com/lima-vm/lima/v2/pkg/cidata/cloudinittypes" + "github.com/lima-vm/lima/v2/pkg/osutil" +) + +func Run(ctx context.Context) error { + const mnt = "/Volumes/cidata" + var errs []error + if err := enableSSHD(ctx); err != nil { + errs = append(errs, fmt.Errorf("failed to enable SSHD: %w", err)) + } + if err := processMetaData(ctx, mnt); err != nil { + errs = append(errs, fmt.Errorf("failed to process meta data: %w", err)) + } + if err := processUserData(ctx, mnt); err != nil { + errs = append(errs, fmt.Errorf("failed to process user data: %w", err)) + } + if err := runBootScripts(ctx); err != nil { + errs = append(errs, fmt.Errorf("failed to run boot scripts: %w", err)) + } + return errors.Join(errs...) +} + +func enableSSHD(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "/bin/launchctl", "load", "-w", "/System/Library/LaunchDaemons/ssh.plist") + logrus.Infof("Executing command: %v", cmd.Args) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to execute command %v: %w (output=%q)", cmd.Args, err, output) + } + return nil +} + +func processMetaData(ctx context.Context, mnt string) error { + metaDataPath := filepath.Join(mnt, "meta-data") + metaDataB, err := os.ReadFile(metaDataPath) + if err != nil { + return fmt.Errorf("failed to read meta data file %q: %w", metaDataPath, err) + } + var metaData cloudinittypes.MetaData + if err = yaml.Unmarshal(metaDataB, &metaData); err != nil { + return fmt.Errorf("failed to unmarshal meta data YAML: %w", err) + } + + var errs []error + logrus.Infof("Instance ID: %q", metaData.InstanceID) + if metaData.LocalHostname != "" { + if err = setLocalHostname(ctx, metaData.LocalHostname); err != nil { + errs = append(errs, fmt.Errorf("failed to set local hostname: %w", err)) + } + } + return errors.Join(errs...) +} + +func setLocalHostname(ctx context.Context, hostname string) error { + cmd := exec.CommandContext(ctx, "scutil", "--set", "LocalHostName", hostname) + logrus.Infof("Executing command: %v", cmd.Args) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to execute command %v: %w (output=%q)", cmd.Args, err, output) + } + return nil +} + +func processUserData(ctx context.Context, mnt string) error { + userDataPath := filepath.Join(mnt, "user-data") + userDataB, err := os.ReadFile(userDataPath) + if err != nil { + return fmt.Errorf("failed to read user data file %q: %w", userDataPath, err) + } + var userData cloudinittypes.UserData + if err = yaml.Unmarshal(userDataB, &userData); err != nil { + return fmt.Errorf("failed to unmarshal user data YAML: %w", err) + } + + var errs []error + if userData.Growpart != nil { + logrus.Warn("growpart is not implemented") + } + if userData.PackageUpdate { + logrus.Warn("package_update is not implemented") + } + if userData.PackageUpgrade { + logrus.Warn("package_upgrade is not implemented") + } + if userData.PackageRebootIfRequired { + logrus.Warn("package_reboot_if_required is not implemented") + } + if len(userData.Mounts) > 0 { + logrus.Warn("mounts is not implemented") + } + if userData.Timezone != "" { + if err = setTimezone(ctx, userData.Timezone); err != nil { + errs = append(errs, fmt.Errorf("failed to set timezone: %w", err)) + } + } + for _, u := range userData.Users { + if err := createUser(ctx, &u); err != nil { + errs = append(errs, fmt.Errorf("failed to create user %q: %w", u.Name, err)) + } + } + for _, entry := range userData.WriteFiles { + if err := writeFiles(ctx, entry); err != nil { + errs = append(errs, fmt.Errorf("failed to write file for path %q: %w", entry.Path, err)) + } + } + if userData.ManageResolvConf && userData.ResolvConf != nil { + if err = setResolvConf(ctx, userData.ResolvConf); err != nil { + errs = append(errs, fmt.Errorf("failed to apply DNS configuration: %w", err)) + } + } + if userData.CACerts != nil { + logrus.Warn("ca_certs is not implemented") + } + if len(userData.BootCmd) > 0 { + logrus.Warn("bootcmd is not implemented") + } + return errors.Join(errs...) +} + +func setTimezone(ctx context.Context, timezone string) error { + cmd := exec.CommandContext(ctx, "systemsetup", "-settimezone", timezone) + logrus.Infof("Executing command: %v", cmd.Args) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to execute command %v: %w (output=%q)", cmd.Args, err, output) + } + return nil +} + +func generatePassword() (string, error) { + const pwLen = 16 + // Avoid special characters to minimize potential keyboard layout issue in GUI + pw, err := password.Generate(pwLen, pwLen/4, 0, false, false) + if err != nil { + return "", fmt.Errorf("failed to generate password: %w", err) + } + return pw, nil +} + +func populateHomeDir(ctx context.Context, uid int, homedir string) error { + cmds := [][]string{ + {"ditto", "--noqtn", "/System/Library/User Template/English.lproj", homedir}, + {"chown", "-R", fmt.Sprintf("%d:staff", uid), homedir}, + {"chmod", "700", homedir}, + } + for _, args := range cmds { + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + logrus.Infof("Executing command: %v", cmd.Args) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to execute command %v: %w (output=%q)", cmd.Args, err, output) + } + } + return nil +} + +func createUser(ctx context.Context, u *cloudinittypes.User) error { + homedir := u.Homedir + if homedir == "" { + return fmt.Errorf("homedir is required for user %q", u.Name) + } + if osutil.FileExists(homedir) { + logrus.Debugf("homedir %q already exists, skipping user creation for user %q", homedir, u.Name) + return nil + } + if u.UID == "" { + return fmt.Errorf("uid is required for user %q", u.Name) + } + uid, err := strconv.Atoi(u.UID) + if err != nil { + return fmt.Errorf("invalid uid %q for user %q: %w", u.UID, u.Name, err) + } + pw, err := generatePassword() + if err != nil { + return fmt.Errorf("failed to generate password for user %q: %w", u.Name, err) + } + args := []string{ + "-addUser", u.Name, + "-UID", u.UID, + "-password", "-", + "-home", homedir, + "-admin", + } + if u.Gecos != "" { + args = append(args, "-fullName", u.Gecos) + } + if u.Shell != "" { + args = append(args, "-shell", u.Shell) + } + if u.Sudo != "" { + logrus.Warn("sudo field is not implemented") + } + if u.LockPasswd != "" { + logrus.Warn("lock_passwd field is not implemented") + } + cmd := exec.CommandContext(ctx, "/usr/sbin/sysadminctl", args...) + cmd.Stdin = strings.NewReader(pw) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + logrus.Infof("Executing command: %v", cmd.Args) + if err = cmd.Run(); err != nil { + return fmt.Errorf("failed to execute command %v: %w", cmd.Args, err) + } + + // sysadminctl does not create the custom home directory + if err = populateHomeDir(ctx, uid, homedir); err != nil { + return fmt.Errorf("failed to populate home directory for user %q: %w", u.Name, err) + } + + cmd = exec.CommandContext(ctx, "chmod", "700", homedir) + logrus.Infof("Executing command: %v", cmd.Args) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to execute command %v: %w (output=%q)", cmd.Args, err, output) + } + + pwPath := filepath.Join(homedir, "password") + if err = os.WriteFile(pwPath, []byte(pw+"\n"), 0o400); err != nil { + return fmt.Errorf("failed to write password file for user %q: %w", u.Name, err) + } + logrus.Infof("Created user %q. The password is stored in %q", u.Name, pwPath) + + dotSSHPath := filepath.Join(homedir, ".ssh") + if err = os.MkdirAll(dotSSHPath, 0o700); err != nil { + return fmt.Errorf("failed to create .ssh directory for user %q: %w", u.Name, err) + } + authKeysPath := filepath.Join(dotSSHPath, "authorized_keys") + authKeysContent := strings.Join(u.SSHAuthorizedKeys, "\n") + if err = os.WriteFile(authKeysPath, []byte(authKeysContent), 0o600); err != nil { + return fmt.Errorf("failed to write authorized_keys file for user %q: %w", u.Name, err) + } + for _, f := range []string{pwPath, dotSSHPath, authKeysPath} { + if err = os.Chown(f, uid, -1); err != nil { + return fmt.Errorf("failed to chown %q for user %q: %w", f, u.Name, err) + } + } + return nil +} + +func writeFiles(ctx context.Context, entry cloudinittypes.WriteFile) error { + if entry.Path == "" { + return errors.New("path is required for write_files entry") + } + perm := os.FileMode(0o644) + if entry.Permissions != "" { + p, err := strconv.ParseUint(entry.Permissions, 8, 32) + if err != nil { + return fmt.Errorf("invalid permissions %q for path %q: %w", entry.Permissions, entry.Path, err) + } + perm = os.FileMode(p) + } + if err := os.MkdirAll(filepath.Dir(entry.Path), 0o755); err != nil { + return fmt.Errorf("failed to create parent directory for path %q: %w", entry.Path, err) + } + if err := os.WriteFile(entry.Path, []byte(entry.Content), perm); err != nil { + return fmt.Errorf("failed to write file for path %q: %w", entry.Path, err) + } + if entry.Owner != "" { + cmd := exec.CommandContext(ctx, "chown", entry.Owner, entry.Path) + logrus.Infof("Executing command: %v", cmd.Args) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to execute command %v: %w (output=%q)", cmd.Args, err, output) + } + } + return nil +} + +func setResolvConf(ctx context.Context, resolvConf *cloudinittypes.ResolvConf) error { + // FIXME: avoid hardcoding the primary network name + const primaryNetwork = "Ethernet" + cmd := exec.CommandContext(ctx, "networksetup", append([]string{"-setdnsservers", primaryNetwork}, resolvConf.Nameservers...)...) + logrus.Infof("Executing command: %v", cmd.Args) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to execute command %v: %w (output=%q)", cmd.Args, err, output) + } + return nil +} + +func runBootScripts(ctx context.Context) error { + dirs := []string{ + "/var/lib/cloud/scripts/per-boot", + } + var errs []error + for _, dir := range dirs { + dirEntries, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("failed to read boot scripts directory %q: %w", dir, err) + } + for _, entry := range dirEntries { + if entry.IsDir() { + continue + } + scriptPath := filepath.Join(dir, entry.Name()) + // entry.Type().Mode() does not seem to contain permission bits + entryInfo, err := entry.Info() + if err != nil { + logrus.Warnf("Skipping boot script %q due to stat error: %v", scriptPath, err) + continue + } + if entryInfo.Mode().Perm()&0o111 == 0 { + logrus.Warnf("Skipping non-executable boot script %q (%v)", scriptPath, entryInfo.Mode().Perm()) + continue + } + cmd := exec.CommandContext(ctx, scriptPath) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + logrus.Infof("Executing command: %v", cmd.Args) + if err := cmd.Run(); err != nil { + errs = append(errs, fmt.Errorf("failed to execute command %v: %w", cmd.Args, err)) + } + } + } + return errors.Join(errs...) +} diff --git a/pkg/guestpatch/macos/macos_darwin.go b/pkg/guestpatch/macos/macos_darwin.go new file mode 100644 index 00000000000..df0c1e50422 --- /dev/null +++ b/pkg/guestpatch/macos/macos_darwin.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package macos + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/sirupsen/logrus" + + "github.com/lima-vm/lima/v2/pkg/imgutil/nativeimgutil/asifutil" + "github.com/lima-vm/lima/v2/pkg/limatype/dirnames" + "github.com/lima-vm/lima/v2/pkg/lockutil/mntlockutil" + "github.com/lima-vm/lima/v2/pkg/osutil" +) + +func Patch(ctx context.Context, disk string) error { + attached, err := asifutil.DiskutilImageAttachNoMount(ctx, disk) + if err != nil { + return fmt.Errorf("failed to attach disk: %w", err) + } + defer func() { + // Just detaching the data slice is enough to let the system detach the whole ASIF. + if err := asifutil.DetachASIF(attached.DataSlice); err != nil { + logrus.WithError(err).Warnf("failed to detach %q (%q)", attached.DataSlice, disk) + } + }() + + limaMntDir, err := dirnames.LimaMntDir() + if err != nil { + return fmt.Errorf("failed to get Lima mount directory: %w", err) + } + + mnt, mntRelease, err := mntlockutil.AcquireSlot(limaMntDir) + if err != nil { + return fmt.Errorf("failed to acquire mount slot: %w", err) + } + defer func() { + if mntReleaseErr := mntRelease(); mntReleaseErr != nil { + logrus.WithError(mntReleaseErr).Warnf("failed to release mount slot %q", mnt) + } + }() + + if err = patchMacOSDiskUnprivilegedPhase(ctx, attached.DataSlice, mnt); err != nil { + return fmt.Errorf("failed to patch macOS disk (phase 1): %w", err) + } + + // Fix up the file ownership inside the disk. + // Invokes sudo. + if err = patchMacOSDiskPrivilegedPhase(ctx, attached.DataSlice, mnt); err != nil { + return fmt.Errorf("failed to patch macOS disk (phase 2): %w", err) + } + + return nil +} + +func patchMacOSDiskUnprivilegedPhase(ctx context.Context, dataSliceDevice, mnt string) error { + // Enable "noowners" to allow non-root users to write to the mounted volume. + // The ownership is fixed up in patchMacOSDiskPhase2. + if err := osutil.Mount(ctx, "apfs", dataSliceDevice, mnt, []string{"noowners"}); err != nil { + return err + } + defer func() { + if err := osutil.Umount(ctx, mnt); err != nil { + logrus.WithError(err).Warnf("failed to unmount %q", mnt) + } + }() + + filesToTouch := []string{ + filepath.Join(mnt, "private/var/db/.AppleSetupDone"), + filepath.Join(mnt, "Library/User Template/.skipbuddy"), + } + for _, file := range filesToTouch { + if err := osutil.Touch(file); err != nil { + return fmt.Errorf("failed to touch %q: %w", file, err) + } + } + + const initSh = `#!/bin/bash +set -eux +#!/bin/sh +set -eux +date +if [ ! -e /Volumes/cidata ]; then + echo "/Volumes/cidata is not mounted" >&2 + exit 1 +fi +exec /Volumes/cidata/lima-guestagent fake-cloud-init +` + if err := os.MkdirAll(filepath.Join(mnt, "usr/local/sbin"), 0o755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(mnt, "usr/local/sbin/lima-macos-init.sh"), []byte(initSh), 0o755); err != nil { + return err + } + + const plist = ` + + + + Label + io.lima-vm.lima-macos-init + ProgramArguments + + /usr/local/sbin/lima-macos-init.sh + + RunAtLoad + + StandardOutPath + /dev/tty.virtio + StandardErrorPath + /dev/tty.virtio + + +` + if err := os.WriteFile(filepath.Join(mnt, "Library/LaunchDaemons/io.lima-vm.lima-macos-init.plist"), []byte(plist), 0o755); err != nil { + return err + } + return nil +} + +func patchMacOSDiskPrivilegedPhase(ctx context.Context, dataSliceDevice, mnt string) error { + if err := osutil.Mount(ctx, "apfs", dataSliceDevice, mnt, nil); err != nil { + return err + } + defer func() { + if err := osutil.Umount(ctx, mnt); err != nil { + logrus.WithError(err).Warnf("failed to unmount %q", mnt) + } + }() + chownCmd := chownCommand(mnt) + cmd := exec.CommandContext(ctx, "sudo", chownCmd...) + logrus.Infof("Executing command (chowning the newly installed files to root:wheel): \n%v", cmd.Args) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to execute command %v: %w (output=%q)", cmd.Args, err, output) + } + return nil +} + +func chownCommand(mnt string) []string { + return []string{ + "/usr/sbin/chown", + "root:wheel", + filepath.Join(mnt, "private/var/db/.AppleSetupDone"), + filepath.Join(mnt, "Library/User Template/.skipbuddy"), + filepath.Join(mnt, "usr/local/sbin"), + filepath.Join(mnt, "usr/local/sbin/lima-macos-init.sh"), + filepath.Join(mnt, "Library/LaunchDaemons/io.lima-vm.lima-macos-init.plist"), + } +} + +// PrivilegedCommands returns a list of possible privileged commands to be executed on the host to patch the macOS disk. +// To be used by `limactl sudoers`. +func PrivilegedCommands() ([][]string, error) { + limaMntDir, err := dirnames.LimaMntDir() + if err != nil { + return nil, fmt.Errorf("failed to get Lima mount directory: %w", err) + } + var res [][]string + slotIDs := mntlockutil.PossibleSlotIDs() + for _, slotID := range slotIDs { + mnt := filepath.Join(limaMntDir, slotID) + res = append(res, chownCommand(mnt)) + } + return res, nil +} diff --git a/pkg/hostagent/requirements.go b/pkg/hostagent/requirements.go index b0d796e195c..b5a83a5173b 100644 --- a/pkg/hostagent/requirements.go +++ b/pkg/hostagent/requirements.go @@ -79,14 +79,22 @@ func (a *HostAgent) waitForRequirements(label string, requirements []requirement // // An earlier implementation used $'…' for quoting, but that isn't supported if the // user switched the default shell to fish. -func prefixExportParam(script string) (string, error) { +func prefixExportParam(script string, guestOS *limatype.OS) (string, error) { interpreter, err := ssh.ParseScriptInterpreter(script) if err != nil { return "", err } // TODO we should have a symbolic constant for `/mnt/lima-cidata` - exportParam := `param_env="$(sudo cat /mnt/lima-cidata/param.env)"; while read -r line; do [ -n "$line" ] && export "$line"; done</dev/null; do sleep 3; done"; then +timeout=timeout +sudo=sudo +A=/run/lima-boot-done +B=/mnt/lima-cidata/meta-data +if [ "$(uname)" = "Darwin" ]; then + timeout=/Volumes/cidata/util/timeout.sh + # On macOS, /Volumes/cidata is not mounted as "root access only" + # FIXME: The cidata does not need to be root-only on Linux, either? + sudo= + A=/var/run/lima-boot-done + B=/Volumes/cidata/meta-data +fi +if ! "$timeout" 30s bash -c "until $sudo diff -q $A $B 2>/dev/null; do sleep 3; done"; then echo >&2 "boot scripts have not finished" exit 1 fi `, debugHint: `All boot scripts, provisioning scripts, and readiness probes must finish before the instance is considered "ready". -Check "/var/log/cloud-init-output.log" in the guest to see where the process is blocked! +Check "` + logLocation + `" to see where the process is blocked! `, }) return req diff --git a/pkg/imgutil/nativeimgutil/asifutil/asif_darwin.go b/pkg/imgutil/nativeimgutil/asifutil/asif_darwin.go index ad9ab3f7415..b228bef60e6 100644 --- a/pkg/imgutil/nativeimgutil/asifutil/asif_darwin.go +++ b/pkg/imgutil/nativeimgutil/asifutil/asif_darwin.go @@ -4,20 +4,39 @@ package asifutil import ( + "bytes" "context" + "errors" "fmt" "os" "os/exec" + "strconv" "strings" ) +// NewASIF creates a new ASIF image file at the specified path with the given size. +func NewASIF(path string, size int64) error { + createArgs := []string{"image", "create", "blank", "--fs", "none", "--format", "ASIF", "--size", strconv.FormatInt(size, 10), path} + if err := exec.CommandContext(context.Background(), "diskutil", createArgs...).Run(); err != nil { + return fmt.Errorf("failed to create ASIF image %q: %w", path, err) + } + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + if _, err2 := os.Stat(path + ".asif"); !errors.Is(err2, os.ErrNotExist) { + // diskutil may create the file with .asif suffix + if err3 := os.Rename(path+".asif", path); err3 != nil { + return fmt.Errorf("failed to rename ASIF image from %q to %q: %w", path+".asif", path, err3) + } + } + } + return nil +} + // NewAttachedASIF creates a new ASIF image file at the specified path with the given size // and attaches it, returning the attached device path and an open file handle. // The caller is responsible for detaching the ASIF image device when done. func NewAttachedASIF(path string, size int64) (string, *os.File, error) { - createArgs := []string{"image", "create", "blank", "--fs", "none", "--format", "ASIF", "--size", fmt.Sprintf("%d", size), path} - if err := exec.CommandContext(context.Background(), "diskutil", createArgs...).Run(); err != nil { - return "", nil, fmt.Errorf("failed to create ASIF image %q: %w", path, err) + if err := NewASIF(path, size); err != nil { + return "", nil, err } attachArgs := []string{"image", "attach", "--noMount", path} out, err := exec.CommandContext(context.Background(), "diskutil", attachArgs...).Output() @@ -49,3 +68,75 @@ func ResizeASIF(path string, size int64) error { } return nil } + +type AttachedDisk struct { + DataSlice string // e.g. "/dev/disk7s5" +} + +// parseDiskutilImageAttachOutput parses the output of `diskutil image attach -nomount ` +// and returns the attached disk information. +// +// Example input: +// +// /dev/disk4 GUID_partition_scheme +// /dev/disk4s1 Apple_APFS_ISC +// /dev/disk5 Apple_APFS_Container +// /dev/disk5s1 Apple_APFS_Volume +// /dev/disk5s2 Apple_APFS_Volume +// /dev/disk5s3 Apple_APFS_Volume +// /dev/disk5s4 Apple_APFS_Volume +// /dev/disk4s2 Apple_APFS +// /dev/disk7 Apple_APFS_Container +// /dev/disk7s1 Apple_APFS_Volume +// /dev/disk7s2 Apple_APFS_Volume +// /dev/disk7s3 Apple_APFS_Volume +// /dev/disk7s4 Apple_APFS_Volume +// /dev/disk7s5 Apple_APFS_Volume +// /dev/disk4s3 Apple_APFS_Recovery +// /dev/disk6 Apple_APFS_Container +// /dev/disk6s1 Apple_APFS_Volume +// +// Example output: +// +// &AttachedDisk{ +// DataSlice: "/dev/disk7s5", +// } +func parseDiskutilImageAttachOutput(output string) (*AttachedDisk, error) { + var seenContainers int + + for line := range strings.SplitSeq(output, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + device := fields[0] + typ := strings.Join(fields[1:], " ") + if typ == "Apple_APFS_Container" { + seenContainers++ + // FIXME: not robust? + if seenContainers == 2 { + return &AttachedDisk{ + DataSlice: device + "s5", + }, nil + } + } + } + + return nil, errors.New("no APFS container found in output") +} + +// DiskutilImageAttachNoMount executes `diskutil image attach -nomount `. +func DiskutilImageAttachNoMount(ctx context.Context, disk string) (*AttachedDisk, error) { + cmd := exec.CommandContext(ctx, "diskutil", "image", "attach", "-nomount", disk) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed to execute %v: %w (stderr: %q)", cmd.Args, err, stderr.String()) + } + return parseDiskutilImageAttachOutput(stdout.String()) +} diff --git a/pkg/imgutil/nativeimgutil/asifutil/asif_darwin_test.go b/pkg/imgutil/nativeimgutil/asifutil/asif_darwin_test.go new file mode 100644 index 00000000000..0abca326517 --- /dev/null +++ b/pkg/imgutil/nativeimgutil/asifutil/asif_darwin_test.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package asifutil + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestParseDiskutilImageAttachOutput(t *testing.T) { + const input = `/dev/disk4 GUID_partition_scheme +/dev/disk4s1 Apple_APFS_ISC +/dev/disk5 Apple_APFS_Container +/dev/disk5s1 Apple_APFS_Volume +/dev/disk5s2 Apple_APFS_Volume +/dev/disk5s3 Apple_APFS_Volume +/dev/disk5s4 Apple_APFS_Volume +/dev/disk4s2 Apple_APFS +/dev/disk7 Apple_APFS_Container +/dev/disk7s1 Apple_APFS_Volume +/dev/disk7s2 Apple_APFS_Volume +/dev/disk7s3 Apple_APFS_Volume +/dev/disk7s4 Apple_APFS_Volume +/dev/disk7s5 Apple_APFS_Volume +/dev/disk4s3 Apple_APFS_Recovery +/dev/disk6 Apple_APFS_Container +/dev/disk6s1 Apple_APFS_Volume +` + expected := &AttachedDisk{ + DataSlice: "/dev/disk7s5", + } + res, err := parseDiskutilImageAttachOutput(input) + assert.NilError(t, err) + assert.DeepEqual(t, res, expected) +} diff --git a/pkg/imgutil/nativeimgutil/asifutil/asif_others.go b/pkg/imgutil/nativeimgutil/asifutil/asif_others.go index 7298a5e7164..e348bff1ece 100644 --- a/pkg/imgutil/nativeimgutil/asifutil/asif_others.go +++ b/pkg/imgutil/nativeimgutil/asifutil/asif_others.go @@ -12,6 +12,10 @@ import ( var ErrASIFNotSupported = errors.New("ASIF is only supported on macOS") +func NewASIF(_ string, _ int64) error { + return ErrASIFNotSupported +} + func NewAttachedASIF(_ string, _ int64) (string, *os.File, error) { return "", nil, ErrASIFNotSupported } diff --git a/pkg/instance/start.go b/pkg/instance/start.go index e327501b5ed..70aa10162c7 100644 --- a/pkg/instance/start.go +++ b/pkg/instance/start.go @@ -48,7 +48,10 @@ type Prepared struct { // Prepare ensures the disk, the nerdctl archive, etc. func Prepare(ctx context.Context, inst *limatype.Instance, guestAgent string) (*Prepared, error) { - if !*inst.Config.Plain && guestAgent == "" { + isDarwin := inst.Config.OS != nil && *inst.Config.OS == limatype.DARWIN + // macOS guests always need the guest agent for running fake-cloud-init + needsGuestAgent := !*inst.Config.Plain || isDarwin + if needsGuestAgent && guestAgent == "" { var err error guestAgent, err = usrlocal.GuestAgentBinary(*inst.Config.OS, *inst.Config.Arch) if err != nil { diff --git a/pkg/iso9660util/iso9660util.go b/pkg/iso9660util/iso9660util.go index 45b82982b3f..3e92f71ea1f 100644 --- a/pkg/iso9660util/iso9660util.go +++ b/pkg/iso9660util/iso9660util.go @@ -19,7 +19,27 @@ type Entry struct { Reader io.Reader } -func Write(isoPath, label string, layout []Entry) error { +type WriteOptions struct { + Joliet bool +} + +type WriteOpt func(*WriteOptions) + +func WithJoliet() WriteOpt { + return func(opts *WriteOptions) { + opts.Joliet = true + } +} + +func Write(isoPath, label string, layout []Entry, opts ...WriteOpt) error { + options := &WriteOptions{} + for _, opt := range opts { + opt(options) + } + if options.Joliet { + return writeJoliet(isoPath, label, layout) + } + if err := os.RemoveAll(isoPath); err != nil { return err } diff --git a/pkg/iso9660util/joliet_darwin.go b/pkg/iso9660util/joliet_darwin.go new file mode 100644 index 00000000000..e690d4f3e22 --- /dev/null +++ b/pkg/iso9660util/joliet_darwin.go @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package iso9660util + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + + "github.com/sirupsen/logrus" +) + +func writeJoliet(isoPath, label string, layout []Entry) error { + if err := os.RemoveAll(isoPath); err != nil { + return err + } + tmpDir, err := os.MkdirTemp("", "joliet") + if err != nil { + return err + } + defer os.RemoveAll(tmpDir) + for _, entry := range layout { + path := filepath.Join(tmpDir, entry.Path) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + f, err := os.Create(path) + if err != nil { + return err + } + if _, err := io.Copy(f, entry.Reader); err != nil { + f.Close() + return err + } + f.Close() + } + + ctx := context.TODO() + cmd := exec.CommandContext(ctx, "hdiutil", "makehybrid", "-o", isoPath, "-iso", "-joliet", "-default-volume-name", label, tmpDir) + logrus.Debugf("Executing %v", cmd.Args) + b, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("failed to run %v: %w (output=%q)", cmd.Args, err, string(b)) + } + return nil +} diff --git a/pkg/iso9660util/joliet_others.go b/pkg/iso9660util/joliet_others.go new file mode 100644 index 00000000000..3d0d4bbcbc4 --- /dev/null +++ b/pkg/iso9660util/joliet_others.go @@ -0,0 +1,15 @@ +//go:build !darwin + +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package iso9660util + +import ( + "errors" + "runtime" +) + +func writeJoliet(_, _ string, _ []Entry) error { + return errors.New("joliet is not supported on " + runtime.GOOS) +} diff --git a/pkg/limainfo/limainfo.go b/pkg/limainfo/limainfo.go index a976e5a60ad..0205358147c 100644 --- a/pkg/limainfo/limainfo.go +++ b/pkg/limainfo/limainfo.go @@ -107,17 +107,24 @@ func New(ctx context.Context) (*LimaInfo, error) { } info.IdentityFile = filepath.Join(configDir, filenames.UserPrivateKey) for _, arch := range limatype.ArchTypes { - bin, err := usrlocal.GuestAgentBinary(limatype.LINUX, arch) - if err != nil { - if errors.Is(err, fs.ErrNotExist) { - logrus.WithError(err).Debugf("Failed to resolve the guest agent binary for %q", arch) - } else { - logrus.WithError(err).Warnf("Failed to resolve the guest agent binary for %q", arch) + for _, os := range limatype.OSTypes { + bin, err := usrlocal.GuestAgentBinary(os, arch) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + logrus.WithError(err).Debugf("Failed to resolve the guest agent binary for %q-%q", os, arch) + } else { + logrus.WithError(err).Warnf("Failed to resolve the guest agent binary for %q-%q", os, arch) + } + continue + } + key := arch + // For the historical reason, the key does not have "Linux-" prefix + if os != limatype.LINUX { + key = os + "-" + arch + } + info.GuestAgents[key] = GuestAgent{ + Location: bin, } - continue - } - info.GuestAgents[arch] = GuestAgent{ - Location: bin, } } diff --git a/pkg/limatype/dirnames/dirnames.go b/pkg/limatype/dirnames/dirnames.go index 670de2273f3..7b8d18f69c7 100644 --- a/pkg/limatype/dirnames/dirnames.go +++ b/pkg/limatype/dirnames/dirnames.go @@ -77,6 +77,15 @@ func LimaTemplatesDir() (string, error) { return filepath.Join(limaDir, filenames.TemplatesDir), nil } +// LimaMntDir returns the path of the mount points directory, $LIMA_HOME/_mnt. +func LimaMntDir() (string, error) { + limaDir, err := LimaDir() + if err != nil { + return "", err + } + return filepath.Join(limaDir, filenames.MntDir), nil +} + // InstanceDir returns the instance dir. // InstanceDir does not check whether the instance exists. func InstanceDir(name string) (string, error) { diff --git a/pkg/limatype/filenames/filenames.go b/pkg/limatype/filenames/filenames.go index 218dd54331b..d0dd4bf1166 100644 --- a/pkg/limatype/filenames/filenames.go +++ b/pkg/limatype/filenames/filenames.go @@ -15,6 +15,7 @@ const ( DisksDir = "_disks" // disks are stored here NetworksDir = "_networks" // network log files are stored here TemplatesDir = "_templates" // user templates are stored here + MntDir = "_mnt" // mount points ("0", "1", ...). May need root access. ) // Filenames used inside the ConfigDir @@ -36,11 +37,12 @@ const ( CIDataISO = "cidata.iso" CIDataISODir = "cidata" CloudConfig = "cloud-config.yaml" - Image = "image" // downloaded VM image; renamed to Disk or ISO during setup - Disk = "disk" // VM disk (or symlink to DiffDiskLegacy for migrated instances) - ISO = "iso" // optional CDROM image (or symlink to BaseDiskLegacy for migrated instances) - BaseDiskLegacy = "basedisk" // legacy name for Image; may remain as qcow2 backing file - DiffDiskLegacy = "diffdisk" // legacy name for Disk + Image = "image" // downloaded VM image; renamed to Disk or ISO during setup + ImageIPSW = "image.ipsw" // hardlink to Image for macOS guests + Disk = "disk" // VM disk (or symlink to DiffDiskLegacy for migrated instances) + ISO = "iso" // optional CDROM image (or symlink to BaseDiskLegacy for migrated instances) + BaseDiskLegacy = "basedisk" // legacy name for Image; may remain as qcow2 backing file + DiffDiskLegacy = "diffdisk" // legacy name for Disk Kernel = "kernel" KernelCmdline = "kernel.cmdline" Initrd = "initrd" @@ -64,6 +66,8 @@ const ( HostAgentStderrLog = "ha.stderr.log" ExternalDriverStderrLog = "driver.stderr.log" VzIdentifier = "vz-identifier" + VzHwModel = "vz-hwmodel" // macOS guests only + VzAux = "vz-aux" // macOS guests only VzEfi = "vz-efi" // efi variable store QemuEfiCodeFD = "qemu-efi-code.fd" // efi code; not always created QemuEfiFullFD = "qemu-efi-full.fd" // concatenated efi vars and code; not always created diff --git a/pkg/limatype/lima_yaml.go b/pkg/limatype/lima_yaml.go index fc48766cc85..7dcdf6e36b0 100644 --- a/pkg/limatype/lima_yaml.go +++ b/pkg/limatype/lima_yaml.go @@ -77,7 +77,8 @@ type ( type CPUType = map[Arch]string const ( - LINUX OS = "Linux" + LINUX OS = "Linux" + DARWIN OS = "Darwin" X8664 Arch = "x86_64" AARCH64 Arch = "aarch64" @@ -97,7 +98,7 @@ const ( ) var ( - OSTypes = []OS{LINUX} + OSTypes = []OS{LINUX, DARWIN} ArchTypes = []Arch{X8664, AARCH64, ARMV7L, PPC64LE, RISCV64, S390X} MountTypes = []MountType{REVSSHFS, NINEP, VIRTIOFS, WSLMount} VMTypes = []VMType{QEMU, VZ, WSL2} @@ -255,7 +256,7 @@ type Provision struct { type ProvisionData struct { Content *string `yaml:"content,omitempty" json:"content,omitempty" jsonschema:"nullable"` Overwrite *bool `yaml:"overwrite,omitempty" json:"overwrite,omitempty" jsonschema:"nullable"` - Owner *string `yaml:"owner,omitempty" json:"owner,omitempty"` // any owner string supported by `chown`, defaults to "root:root" + Owner *string `yaml:"owner,omitempty" json:"owner,omitempty"` // any owner string supported by `chown`, defaults to "root:root" on Linux Path *string `yaml:"path,omitempty" json:"path,omitempty"` Permissions *string `yaml:"permissions,omitempty" json:"permissions,omitempty"` } @@ -344,6 +345,8 @@ func NewOS(osname string) OS { switch osname { case "linux": return LINUX + case "darwin": + return DARWIN default: logrus.Warnf("Unknown os: %s", osname) return osname diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go index 7916fabf7a4..e80d9121a78 100644 --- a/pkg/limayaml/defaults.go +++ b/pkg/limayaml/defaults.go @@ -147,6 +147,15 @@ func FillDefault(ctx context.Context, y, d, o *limatype.LimaYAML, filePath strin existingLimaVersion := ExistingLimaVersion(instDir) + // OS has to be resolved before User + if y.OS == nil { + y.OS = d.OS + } + if o.OS != nil { + y.OS = o.OS + } + y.OS = ptr.Of(ResolveOS(y.OS)) + if y.User.Name == nil { y.User.Name = d.User.Name } @@ -178,22 +187,26 @@ func FillDefault(ctx context.Context, y, d, o *limatype.LimaYAML, filePath strin y.User.UID = o.User.UID } if y.User.Name == nil { - y.User.Name = ptr.Of(osutil.LimaUser(ctx, existingLimaVersion, warn).Username) + y.User.Name = ptr.Of(osutil.LimaUser(ctx, existingLimaVersion, warn, y.OS).Username) warn = false } if y.User.Comment == nil { - y.User.Comment = ptr.Of(osutil.LimaUser(ctx, existingLimaVersion, warn).Name) + y.User.Comment = ptr.Of(osutil.LimaUser(ctx, existingLimaVersion, warn, y.OS).Name) warn = false } if y.User.Home == nil { - y.User.Home = ptr.Of(osutil.LimaUser(ctx, existingLimaVersion, warn).HomeDir) + y.User.Home = ptr.Of(osutil.LimaUser(ctx, existingLimaVersion, warn, y.OS).HomeDir) warn = false } if y.User.Shell == nil { - y.User.Shell = ptr.Of("/bin/bash") + if *y.OS == limatype.DARWIN { + y.User.Shell = ptr.Of("/bin/zsh") + } else { + y.User.Shell = ptr.Of("/bin/bash") + } } if y.User.UID == nil { - uidString := osutil.LimaUser(ctx, existingLimaVersion, warn).Uid + uidString := osutil.LimaUser(ctx, existingLimaVersion, warn, y.OS).Uid if uid, err := strconv.ParseUint(uidString, 10, 32); err == nil { y.User.UID = ptr.Of(uint32(uid)) } else { @@ -216,13 +229,6 @@ func FillDefault(ctx context.Context, y, d, o *limatype.LimaYAML, filePath strin y.VMType = o.VMType } - if y.OS == nil { - y.OS = d.OS - } - if o.OS != nil { - y.OS = o.OS - } - y.OS = ptr.Of(ResolveOS(y.OS)) if y.Arch == nil { y.Arch = d.Arch } @@ -447,6 +453,9 @@ func FillDefault(ctx context.Context, y, d, o *limatype.LimaYAML, filePath strin if provision.Mode == limatype.ProvisionModeData || provision.Mode == limatype.ProvisionModeYQ { if provision.Owner == nil { provision.Owner = ptr.Of("root:root") + if *y.OS == limatype.DARWIN { + provision.Owner = ptr.Of("root:wheel") + } } else { if out, err := executeGuestTemplate(*provision.Owner, instDir, y.User, y.Param); err == nil { provision.Owner = ptr.Of(out.String()) diff --git a/pkg/limayaml/defaults_test.go b/pkg/limayaml/defaults_test.go index 64afcffade5..65f0e68e6f8 100644 --- a/pkg/limayaml/defaults_test.go +++ b/pkg/limayaml/defaults_test.go @@ -64,7 +64,7 @@ func TestFillDefault(t *testing.T) { assert.NilError(t, err) limaHome, err := dirnames.LimaDir() assert.NilError(t, err) - user := osutil.LimaUser(t.Context(), "0.0.0", false) + user := osutil.LimaUser(t.Context(), "0.0.0", false, nil) user.HomeDir = fmt.Sprintf("/home/%s.guest", user.Username) uid, err := strconv.ParseUint(user.Uid, 10, 32) assert.NilError(t, err) diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go index c03ca5e7d6b..b9fba990e4b 100644 --- a/pkg/limayaml/validate.go +++ b/pkg/limayaml/validate.go @@ -50,9 +50,9 @@ func Validate(y *limatype.LimaYAML, warn bool) error { } switch *y.OS { - case limatype.LINUX: + case limatype.LINUX, limatype.DARWIN: default: - errs = errors.Join(errs, fmt.Errorf("field `os` must be %q; got %q", limatype.LINUX, *y.OS)) + errs = errors.Join(errs, fmt.Errorf("field `os` must be one of %q; got %q", limatype.OSTypes, *y.OS)) } if !slices.Contains(limatype.ArchTypes, *y.Arch) { errs = errors.Join(errs, fmt.Errorf("field `arch` must be one of %v; got %q", limatype.ArchTypes, *y.Arch)) diff --git a/pkg/limayaml/validate_test.go b/pkg/limayaml/validate_test.go index b992610875e..db0774f4ff6 100644 --- a/pkg/limayaml/validate_test.go +++ b/pkg/limayaml/validate_test.go @@ -369,7 +369,7 @@ provision: err = Validate(y, false) t.Logf("Validation errors: %v", err) - assert.Error(t, err, "field `os` must be \"Linux\"; got \"windows\"\n"+ + assert.Error(t, err, "field `os` must be one of [\"Linux\" \"Darwin\"]; got \"windows\"\n"+ "field `arch` must be one of [x86_64 aarch64 armv7l ppc64le riscv64 s390x]; got \"unsupported_arch\"\n"+ "field `images` must be set\n"+ "field `provision[0].mode` must one of \"system\", \"user\", \"boot\", \"data\", \"dependency\", \"ansible\", or \"yq\"\n"+ diff --git a/pkg/lockutil/mntlockutil/mntlockutil_unix.go b/pkg/lockutil/mntlockutil/mntlockutil_unix.go new file mode 100644 index 00000000000..8e5a8c6f350 --- /dev/null +++ b/pkg/lockutil/mntlockutil/mntlockutil_unix.go @@ -0,0 +1,64 @@ +//go:build !windows + +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +// From https://github.com/containerd/nerdctl/blob/v0.13.0/pkg/lockutil/lockutil_unix.go +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package mntlockutil + +import ( + "fmt" + "os" + "path/filepath" + + "golang.org/x/sys/unix" + + "github.com/lima-vm/lima/v2/pkg/lockutil" +) + +// PossibleSlotIDs returns the possible slot IDs for mount points. +func PossibleSlotIDs() []string { + return []string{"0"} +} + +// AcquireSlot acquires a mount slot (currently always "0") under limaMntDir. +// Returns filepath.Join(limaMntDir, slotID) and a release function that releases the slot. +func AcquireSlot(limaMntDir string) (dir string, release func() error, err error) { + slotIDs := PossibleSlotIDs() + slotID := slotIDs[0] + dir = filepath.Join(limaMntDir, slotID) + if err = os.MkdirAll(dir, 0o700); err != nil { + return "", nil, err + } + dirFile, err := os.Open(dir) + if err != nil { + return "", nil, err + } + if err = lockutil.Flock(dirFile, unix.LOCK_EX); err != nil { + _ = dirFile.Close() + return "", nil, fmt.Errorf("failed to acquire lock for %q: %w", dir, err) + } + release = func() error { + if unlockErr := lockutil.Flock(dirFile, unix.LOCK_UN); unlockErr != nil { + return fmt.Errorf("failed to release lock for %q: %w", dir, unlockErr) + } + return dirFile.Close() + } + return dir, release, nil +} diff --git a/pkg/osutil/file.go b/pkg/osutil/file.go index 25cdb8503f9..35a8ae29dea 100644 --- a/pkg/osutil/file.go +++ b/pkg/osutil/file.go @@ -14,3 +14,12 @@ func FileExists(path string) bool { _, err := os.Stat(path) return !errors.Is(err, os.ErrNotExist) } + +// Touch touches a file. +func Touch(path string) error { + f, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE, 0o644) + if err != nil { + return err + } + return f.Close() +} diff --git a/pkg/osutil/mount_darwin.go b/pkg/osutil/mount_darwin.go new file mode 100644 index 00000000000..0fcdc52b7ba --- /dev/null +++ b/pkg/osutil/mount_darwin.go @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package osutil + +import ( + "context" + "fmt" + "os/exec" + "strings" + + "github.com/sirupsen/logrus" +) + +// Mount mounts the device. +// Root privileges it not necessary. +func Mount(ctx context.Context, fs, dev, mnt string, options []string) error { + args := []string{"-t", fs} + if len(options) > 0 { + args = append(args, "-o", strings.Join(options, ",")) + } + args = append(args, dev, mnt) + cmd := exec.CommandContext(ctx, "mount", args...) + logrus.Debugf("Executing command: %v", cmd.Args) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to mount %q on %q: %w (output=%q)", dev, mnt, err, output) + } + return nil +} + +func Umount(ctx context.Context, mnt string) error { + cmd := exec.CommandContext(ctx, "umount", mnt) + logrus.Debugf("Executing command: %v", cmd.Args) + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to unmount %q: %w (output=%q)", mnt, err, output) + } + return nil +} diff --git a/pkg/osutil/user.go b/pkg/osutil/user.go index 688f6201f61..25dfe8939f8 100644 --- a/pkg/osutil/user.go +++ b/pkg/osutil/user.go @@ -16,6 +16,7 @@ import ( "github.com/sirupsen/logrus" + "github.com/lima-vm/lima/v2/pkg/limatype" . "github.com/lima-vm/lima/v2/pkg/must" "github.com/lima-vm/lima/v2/pkg/version/versionutil" ) @@ -102,7 +103,7 @@ var ( warnings []string ) -func LimaUser(ctx context.Context, limaVersion string, warn bool) *user.User { +func LimaUser(ctx context.Context, limaVersion string, warn bool, guestOS *limatype.OS) *user.User { once.Do(func() { limaUser = currentUser if !regexUsername.MatchString(limaUser.Username) { @@ -150,7 +151,9 @@ func LimaUser(ctx context.Context, limaVersion string, warn bool) *user.User { // Make sure we return a pointer to a COPY of limaUser u := *limaUser limaVersionUnknown := limaVersion == "" || limaVersion == "" - if limaVersionUnknown || versionutil.GreaterEqual(limaVersion, "2.1.0") { + if guestOS != nil && *guestOS == limatype.DARWIN { + u.HomeDir = "/Users/{{.User}}.guest" + } else if limaVersionUnknown || versionutil.GreaterEqual(limaVersion, "2.1.0") { u.HomeDir = "/home/{{.User}}.guest" // boot script symlinks "/home/{{.User}}.linux" to "{{.User}}.guest" for compatibility } else { diff --git a/pkg/osutil/user_test.go b/pkg/osutil/user_test.go index 1acf8290d9d..8a958d71a8f 100644 --- a/pkg/osutil/user_test.go +++ b/pkg/osutil/user_test.go @@ -18,7 +18,7 @@ const limaVersion = "1.0.0" func TestLimaUserAdminNew(t *testing.T) { currentUser.Username = "admin" once = new(sync.Once) - user := LimaUser(t.Context(), limaVersion, false) + user := LimaUser(t.Context(), limaVersion, false, nil) assert.Equal(t, user.Username, fallbackUser) } @@ -26,21 +26,21 @@ func TestLimaUserAdminNew(t *testing.T) { func TestLimaUserAdminOld(t *testing.T) { currentUser.Username = "admin" once = new(sync.Once) - user := LimaUser(t.Context(), "0.23.0", false) + user := LimaUser(t.Context(), "0.23.0", false, nil) assert.Equal(t, user.Username, "admin") } func TestLimaUserInvalid(t *testing.T) { currentUser.Username = "use@example.com" once = new(sync.Once) - user := LimaUser(t.Context(), limaVersion, false) + user := LimaUser(t.Context(), limaVersion, false, nil) assert.Equal(t, user.Username, fallbackUser) } func TestLimaUserUid(t *testing.T) { currentUser.Username = fallbackUser once = new(sync.Once) - user := LimaUser(t.Context(), limaVersion, false) + user := LimaUser(t.Context(), limaVersion, false, nil) _, err := strconv.Atoi(user.Uid) assert.NilError(t, err) } @@ -48,7 +48,7 @@ func TestLimaUserUid(t *testing.T) { func TestLimaUserGid(t *testing.T) { currentUser.Username = fallbackUser once = new(sync.Once) - user := LimaUser(t.Context(), limaVersion, false) + user := LimaUser(t.Context(), limaVersion, false, nil) _, err := strconv.Atoi(user.Gid) assert.NilError(t, err) } @@ -56,7 +56,7 @@ func TestLimaUserGid(t *testing.T) { func TestLimaHomeDir(t *testing.T) { currentUser.Username = fallbackUser once = new(sync.Once) - user := LimaUser(t.Context(), limaVersion, false) + user := LimaUser(t.Context(), limaVersion, false, nil) // check for absolute unix path (/home) assert.Assert(t, path.IsAbs(user.HomeDir), user.HomeDir) } diff --git a/templates/README.md b/templates/README.md index 53e4747494a..e3c9546bc11 100644 --- a/templates/README.md +++ b/templates/README.md @@ -10,7 +10,7 @@ To open a shell, run `limactl shell fedora bash` or `LIMA_INSTANCE=fedora lima b Default: [`default`](./default.yaml) (⭐Ubuntu, with containerd/nerdctl) -Distro: +Linux distributions: - [`almalinux-8`](./almalinux-8.yaml): AlmaLinux 8 - [`almalinux-9`](./almalinux-9.yaml): AlmaLinux 9 - [`almalinux-10`](./almalinux-10.yaml), `almalinux.yaml`: AlmaLinux 10 @@ -41,6 +41,9 @@ Distro: - [`experimental/opensuse-tumbleweed`](./experimental/opensuse-tumbleweed.yaml): [experimental] openSUSE Tumbleweed - [`experimental/debian-sid`](./experimental/debian-sid.yaml): [experimental] Debian Sid +Non-Linux: +- [`macos-26`](./macos-26.yaml), `macos.yaml`: macOS 26 (Tahoe) + Alternative package managers: - [`linuxbrew.yaml`](./linuxbrew.yaml): [Homebrew](https://brew.sh) on Linux (Ubuntu) diff --git a/templates/_images/macos-26.yaml b/templates/_images/macos-26.yaml new file mode 100644 index 00000000000..4fae27c95dd --- /dev/null +++ b/templates/_images/macos-26.yaml @@ -0,0 +1,13 @@ +# Apple Software License Agreements: https://www.apple.com/legal/sla/ +# (macOS users should have already accepted the agreements on their host machines) + +images: +- location: "https://updates.cdn-apple.com/2026WinterFCS/fullrestores/047-60229/6D5DBEA5-75A0-4BEF-ACC9-5ACF9B8DF6B7/UniversalMac_26.3_25D125_Restore.ipsw" + arch: aarch64 + digest: "sha256:7d22296ca6ab46f255bed8f5fc80845edab14fb781f845d7cae9427a22970b53" +os: Darwin +arch: aarch64 +# vz is the only supported VM type for macOS guests +vmType: vz +ssh: + overVsock: false diff --git a/templates/_images/macos.yaml b/templates/_images/macos.yaml new file mode 120000 index 00000000000..0e06dbf78cd --- /dev/null +++ b/templates/_images/macos.yaml @@ -0,0 +1 @@ +macos-26.yaml \ No newline at end of file diff --git a/templates/default.yaml b/templates/default.yaml index d51d99729db..bb46b9814de 100644 --- a/templates/default.yaml +++ b/templates/default.yaml @@ -259,7 +259,7 @@ containerd: # # The file and directory creation will be performed as the specified owner. # # If the existing file is not writable by the specified owner, the operation will fail. # # `path` and `expression` are required. -# # `owner` and `permissions` are optional. Defaults to "root:root" and 644. +# # `owner` and `permissions` are optional. Defaults to "root:root" (on Linux) and 644. # - mode: yq # path: "{{.Home}}/.config/docker/daemon.json" # expression: ".features.containerd-snapshotter = {{.Param.containerdSnapshotter}}" diff --git a/templates/macos-26.yaml b/templates/macos-26.yaml new file mode 100644 index 00000000000..e4619e3b538 --- /dev/null +++ b/templates/macos-26.yaml @@ -0,0 +1,16 @@ +minimumLimaVersion: 2.1.0 + +base: +- template:_images/macos-26 +# - template:_default/mounts + +# No support for mounts, port forwards, etc. yet +plain: true + +# The installer seems to require the display device to be present. +video: + display: "default" + +message: | + The user password for GUI session is stored in the `~/password` file in the guest. + Consider changing it after the first login. diff --git a/templates/macos.yaml b/templates/macos.yaml new file mode 120000 index 00000000000..0e06dbf78cd --- /dev/null +++ b/templates/macos.yaml @@ -0,0 +1 @@ +macos-26.yaml \ No newline at end of file diff --git a/website/content/en/docs/_index.md b/website/content/en/docs/_index.md index d95e8a2f3ba..669c2c2e699 100644 --- a/website/content/en/docs/_index.md +++ b/website/content/en/docs/_index.md @@ -23,6 +23,8 @@ Lima launches Linux virtual machines with automatic file sharing and port forwar ✅ Various guest Linux distributions: [AlmaLinux](./templates/almalinux.yaml), [Alpine](./templates/alpine.yaml), [Arch Linux](./templates/archlinux.yaml), [Debian](./templates/debian.yaml), [Fedora](./templates/fedora.yaml), [openSUSE](./templates/opensuse.yaml), [Oracle Linux](./templates/oraclelinux.yaml), [Rocky](./templates/rocky.yaml), [Ubuntu](./templates/ubuntu.yaml) (default), ... +✅ Experimental support for non-Linux guests: [macOS](./usage/guests/macos.md) + Related project: [sshocker (ssh with file sharing and port forwarding)](https://github.com/lima-vm/sshocker) This project is unrelated to [The Lima driver project (driver for ARM Mali GPUs)](https://gitlab.freedesktop.org/lima). diff --git a/website/content/en/docs/dev/internals.md b/website/content/en/docs/dev/internals.md index 047e78ef79d..a35ded44396 100644 --- a/website/content/en/docs/dev/internals.md +++ b/website/content/en/docs/dev/internals.md @@ -45,6 +45,7 @@ Ansible: disk: - `image`: the downloaded VM image; renamed to `disk` or `iso` during setup +- `image.ipsw`: hardlink to `image`, created for running `VZMacOSInstaller` that requires the image file to have the `.ipsw` suffix - `disk`: the VM disk (can be a symlink to legacy `diffdisk`) - `iso`: optional CDROM image for ISO-based installations (can be a symlink to legacy `basedisk`) - `basedisk`: legacy name for the downloaded image (pre-v2.1 instances; may remain as a qcow2 backing file) @@ -63,6 +64,8 @@ QEMU: VZ: - `vz.pid`: VZ PID - `vz-identifier`: Unique machine identifier file for a VM +- `vz-hwmodel`: Hardware model information for a Mac VM +- `vz-aux`: Auxiliary storage for a Mac VM - `vz-efi`: EFIVariable store file for a VM Serial: diff --git a/website/content/en/docs/faq/_index.md b/website/content/en/docs/faq/_index.md index de526b310c1..79fd3c4b617 100644 --- a/website/content/en/docs/faq/_index.md +++ b/website/content/en/docs/faq/_index.md @@ -47,9 +47,11 @@ weight: 6 - Port forwarding: [`ssh -L`](../config/port), automated by watching `/proc/net/tcp` and `iptables` events in the guest #### "What's my login password?" -Password is disabled and locked by default. +For Linux guests, the password is disabled and locked by default. You have to use `limactl shell bash` (or `lima bash`) to open a shell. +For macOS guests, the password is randomly generated and stored as `~/password` in the guest. + {{% fixlinks %}} Try virtiofs. See [`Usage » SSH`]({{< ref "/docs/usage/ssh" >}}) {{% /fixlinks %}} @@ -63,7 +65,9 @@ AlmaLinux, Alpine, Arch Linux, Debian, Fedora, openSUSE, Oracle Linux, and Rocky See [`./templates/`](./templates/). {{% /fixlinks %}} -An image has to satisfy the following requirements: +Starting with Lima v2.1, [macOS guests](../usage/guests/macos.md) are experimentally supported too. + +An image for Linux guests has to satisfy the following requirements: - systemd or OpenRC - cloud-init - The following binaries to be preinstalled: diff --git a/website/content/en/docs/releases/experimental.md b/website/content/en/docs/releases/experimental.md index 7cc4a5a6a6f..8a567eb7eff 100644 --- a/website/content/en/docs/releases/experimental.md +++ b/website/content/en/docs/releases/experimental.md @@ -15,6 +15,7 @@ The following features are experimental and subject to change: - `External drivers`: building and using drivers as separate executables (see [Virtual Machine Drivers](../dev/drivers)) - [`vmType: krunkit`](../config/vmtype/krunkit.md) - [`github` URL scheme](../templates/github.md): referencing templates on GitHub with `github:` URLs +- [macOS guests](../usage/guests/macos.md) The following commands are experimental and subject to change: diff --git a/website/content/en/docs/usage/_index.md b/website/content/en/docs/usage/_index.md index 87e9244ccf1..3d150f17aaa 100644 --- a/website/content/en/docs/usage/_index.md +++ b/website/content/en/docs/usage/_index.md @@ -60,8 +60,9 @@ To make the host mount writable, run `limactl start` with `--mount-writable`. To disable the mount, `limactl start` with `--mount-none` or `--plain`. The guest home directory exists independently on the following path: -- `/home/${USER}.guest` (since Lima v2.1) -- `/home/${USER}.linux` (prior to Lima v2.1) +- `/Users/${USER}.guest` (on macOS hosts) +- `/home/${USER}.guest` (on other hosts, since Lima v2.1) +- `/home/${USER}.linux` (prior to Lima v2.1) ### Shell completion - To enable bash completion, add `source <(limactl completion bash)` to `~/.bash_profile`. diff --git a/website/content/en/docs/usage/guests/_index.md b/website/content/en/docs/usage/guests/_index.md new file mode 100644 index 00000000000..05617631e56 --- /dev/null +++ b/website/content/en/docs/usage/guests/_index.md @@ -0,0 +1,4 @@ +--- +title: Guest OS +weight: 10 +--- diff --git a/website/content/en/docs/usage/guests/linux.md b/website/content/en/docs/usage/guests/linux.md new file mode 100644 index 00000000000..b468ce6b562 --- /dev/null +++ b/website/content/en/docs/usage/guests/linux.md @@ -0,0 +1,9 @@ +--- +title: Linux +weight: 1 +--- + +Linux is the default guest operating system. + +## See also +- [Templates](../../templates/_index.md) \ No newline at end of file diff --git a/website/content/en/docs/usage/guests/macos.md b/website/content/en/docs/usage/guests/macos.md new file mode 100644 index 00000000000..12da2311c49 --- /dev/null +++ b/website/content/en/docs/usage/guests/macos.md @@ -0,0 +1,40 @@ +--- +title: macOS +weight: 2 +--- + +| ⚡ Requirement | Lima >= 2.1, macOS, ARM | +|-------------------|-----------------------------| + +Running macOS guests is experimentally supported since Lima v2.1. + +```bash +limactl start template:macos +``` + +A password prompt is shown during creating an instance, so as to run +the `chown root:wheel ~/.lima/_mnt/0/...` command on the host. +This password is not used for setting up the user account in the VM. + +The user password is randomly generated and stored in the `~/password` file in the VM. +Consider changing it after the first login. + +```bash +limactl shell macos cat /Users/${USER}.guest/password +``` + +## Difference from Linux guests +- Password login is enabled +- Password-less sudo is disabled +- Several features are not implemented yet. See [Caveats](#caveats) below. + +## Caveats +- No support for graceful `limactl stop`. + Shutdown the VM from the GUI, or use `limactl stop -f` with caution. +- No support for turning off the video display. +- No support for mounting host directories. + Use `limactl cp` or `limactl shell --sync` to share files with the host. +- No support for automatic port forwarding. + Use `ssh -L` to manually set up port forwarding, or, + use the [`vzNAT`](../../config/network/vmnet.md#vznat) network to access the guest by its IP. +- No support for installing custom `caCerts` \ No newline at end of file