run systemctl commands in VM

This commit is contained in:
Max Erenberg 2023-11-14 13:23:22 -05:00
parent c0ca2cd1c5
commit fec402c2f8
4 changed files with 84 additions and 373 deletions

View File

@ -1,15 +1,8 @@
package distros
import (
"compress/gzip"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/rs/zerolog/log"
"libguestfs.org/guestfs"
@ -46,154 +39,27 @@ func (mgr *OpensuseTumbleweedTemplateManager) DownloadTemplate(version, codename
)
}
const ossPackagesBaseURL = "https://download.opensuse.org/tumbleweed/repo/oss"
func (mgr *OpensuseTumbleweedTemplateManager) getSystemdNetworkPackageURL() (string, error) {
const indexURL = ossPackagesBaseURL + "/INDEX.gz"
mgr.logger.Debug().Msg("Downloading " + indexURL)
resp, err := http.Get(indexURL)
func (mgr *OpensuseTumbleweedTemplateManager) InstallNetworkManager(handle *guestfs.Guestfs) error {
args := []string{"zypper", "--non-interactive", "install", "NetworkManager"}
_, err := mgr.logAndRunCommand(handle, args)
if err != nil {
return "", err
return fmt.Errorf("Could not install NetworkManager: %w", err)
}
defer resp.Body.Close()
gzipReader, err := gzip.NewReader(resp.Body)
// Make sure dhcp-client is installed because we use it as the
// DHCP backend
dhclientIsPresent, err := handle.Is_file("/sbin/dhclient", nil)
if err != nil {
return "", err
return fmt.Errorf("Could not determine if /sbin/dhclient is a file: %w", err)
}
defer gzipReader.Close()
body, err := io.ReadAll(gzipReader)
if err != nil {
return "", err
if !dhclientIsPresent {
return errors.New("dhclient is not present")
}
pattern := regexp.MustCompile(`(?m)^\./x86_64/systemd-network-(?:[\d.-]+)\.x86_64\.rpm$`)
match := pattern.Find(body)
if match == nil {
return "", errors.New("Could not find systemd-network package in OpenSUSE Tumbleweed index")
}
packageURL := ossPackagesBaseURL + "/" + string(match[2:])
return packageURL, nil
}
func (mgr *OpensuseTumbleweedTemplateManager) downloadSystemdNetworkPackage() (string, error) {
packageURL, err := mgr.getSystemdNetworkPackageURL()
if err != nil {
return "", err
}
mgr.logger.Debug().Msg("Downloading " + packageURL)
resp, err := http.Get(packageURL)
if err != nil {
return "", err
}
defer resp.Body.Close()
// TODO: download to somewhere in /tmp
const path = "./systemd-network.rpm"
out, err := os.Create(path)
if err != nil {
return "", err
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
if err != nil {
return "", err
}
return path, nil
}
func (mgr *OpensuseTumbleweedTemplateManager) logAndRunCommand(handle *guestfs.Guestfs, args []string) error {
mgr.logger.Debug().Msg("Running command `" + strings.Join(args, " ") + "`")
_, err := handle.Command(args)
return err
}
func (mgr *OpensuseTumbleweedTemplateManager) addSystemUserAndGroup(handle *guestfs.Guestfs, name, uid, gid string) error {
matches, err := handle.Grep("^"+name, "/etc/group", nil)
if err != nil {
return fmt.Errorf("Could not grep /etc/group: %w", err)
}
if len(matches) == 0 {
err = mgr.logAndRunCommand(handle, []string{
"groupadd",
"--system",
"--gid", gid,
name,
})
if err != nil {
return err
}
}
matches, err = handle.Grep("^"+name, "/etc/passwd", nil)
if err != nil {
return fmt.Errorf("Could not grep /etc/passwd: %w", err)
}
if len(matches) == 0 {
err = mgr.logAndRunCommand(handle, []string{
"useradd",
"--system",
"--no-user-group",
"--uid", uid,
"--gid", gid,
"--home-dir", "/run/systemd",
"--shell", "/usr/sbin/nologin",
name,
})
if err != nil {
return err
}
}
return nil
}
// Workaround for bug in VM image: systemd-network user and group are not
// present, and are not created when the systemd-network package is installed
// Also see: https://github.com/lima-vm/lima/issues/1496
//
// https://bugzilla.opensuse.org/show_bug.cgi?id=1203393
func (mgr *OpensuseTumbleweedTemplateManager) addSystemdNetworkUsersAndGroups(handle *guestfs.Guestfs) error {
// UIDs and GIDs were determined by installing systemd-network in
// a VM booted from a live ISO image
if err := mgr.addSystemUserAndGroup(handle, "systemd-network", "470", "470"); err != nil {
return err
}
if err := mgr.addSystemUserAndGroup(handle, "systemd-resolve", "469", "469"); err != nil {
return err
}
return nil
}
func (mgr *OpensuseTumbleweedTemplateManager) InstallSystemdNetworkd(handle *guestfs.Guestfs) error {
localPath, err := mgr.downloadSystemdNetworkPackage()
if err != nil {
return err
}
mgr.logger.Debug().Msg("Copying " + localPath + " to /tmp in VM")
err = handle.Copy_in(localPath, "/tmp")
if err != nil {
return err
}
mgr.logger.Debug().Msg("Deleting " + localPath)
err = os.Remove(localPath)
if err != nil {
return err
}
vmPath := "/tmp/" + filepath.Base(localPath)
args := []string{"zypper", "--no-remote", "--non-interactive", "install", vmPath}
err = mgr.logAndRunCommand(handle, args)
if err != nil {
return err
}
mgr.logger.Debug().Msg("Deleting " + vmPath + " in VM")
err = handle.Rm(vmPath)
if err != nil {
return err
}
return mgr.addSystemdNetworkUsersAndGroups(handle)
}
func (mgr *OpensuseTumbleweedTemplateManager) NeedsDynamicResolvConf() bool {
// cloud-init won't work if we overwrite /etc/resolv.conf.
// I'm not sure why it works on other distros but not on this one.
return true
// Workaround for bug in cloud-init which prevents the NetworkManager
// backend from being selected if NetworkManager.conf does not exist
// https://github.com/canonical/cloud-init/blob/23.1.x/cloudinit/net/network_manager.py#L381
const cfgFilePath = "/etc/NetworkManager/NetworkManager.conf"
mgr.logger.Debug().Msg("Creating " + cfgFilePath)
return handle.Touch(cfgFilePath)
}
func (mgr *OpensuseTumbleweedTemplateManager) removeWelcomeMessage(handle *guestfs.Guestfs) error {
@ -203,17 +69,16 @@ func (mgr *OpensuseTumbleweedTemplateManager) removeWelcomeMessage(handle *guest
}
func (mgr *OpensuseTumbleweedTemplateManager) CommandToUpdatePackageCache() []string {
return []string{"sudo", "zypper", "refresh"}
return []string{"sudo", "zypper", "refresh", "--force"}
}
func (mgr *OpensuseTumbleweedTemplateManager) PerformDistroSpecificModifications(handle *guestfs.Guestfs) (err error) {
unitPath, err := getSystemdUnitPath(handle, "wickedd.service")
func (mgr *OpensuseTumbleweedTemplateManager) PerformDistroSpecificModifications(handle *guestfs.Guestfs) error {
// Newer Tumbleweed images shouldn't be using wickedd anymore
wickeddExists, err := handle.Is_file("/lib/systemd/system/wickedd.service", nil)
if err != nil {
return
return err
}
if unitPath != "" {
// OpenSUSE Tumbleweed images should be using NetworkManager
// or systemd-networkd (after we install it)
if wickeddExists {
return errors.New("wickedd.service was unexpectedly found")
}
return mgr.removeWelcomeMessage(handle)

View File

@ -1,12 +0,0 @@
#!/bin/bash
set -ex
RESOLV_CONF=/run/netconfig/resolv.conf
sed -i '/^search .*/d' $RESOLV_CONF
sed -i '/^options .*/d' $RESOLV_CONF
cat <<EOF >> $RESOLV_CONF
search csclub.uwaterloo.ca uwaterloo.ca
options ndots:2
EOF

View File

@ -52,10 +52,6 @@ type ITemplateManager interface {
// An IDistroSpecificTemplateManager performs the distro-specific tasks
// when modifying a VM template. It is used by the generic TemplateManager.
type IDistroSpecificTemplateManager interface {
// On some distros, like OpenSUSE Tumbleweed, we cannot overwrite
// /etc/resolv.conf or else cloud-init will fail. Such distros should
// override this method to return true.
NeedsDynamicResolvConf() bool
// GetLatestVersion returns the version number and codename of the
// latest version of particular OS (e.g. version="22.04", codename="jammy")
GetLatestVersion() (version string, codename string, err error)
@ -70,21 +66,14 @@ type IDistroSpecificTemplateManager interface {
// (but does not upgrade) the packages for this distro, e.g.
// "sudo apt update".
CommandToUpdatePackageCache() []string
// InstallSystemdNetworkd installs the OS-specific package for
// systemd-networkd. Since the VM does not have network access, this
// will require downloading the package and copying it into the VM.
InstallSystemdNetworkd(handle *guestfs.Guestfs) error
// InstallNetworkManager installs the OS-specific package for
// NetworkManager.
InstallNetworkManager(handle *guestfs.Guestfs) error
}
func (mgr *TemplateManager) NeedsDynamicResolvConf() bool {
// Assume false by default. Individual distros should override this
// method if necessary.
return false
}
func (mgr *TemplateManager) InstallSystemdNetworkd(handle *guestfs.Guestfs) error {
func (mgr *TemplateManager) InstallNetworkManager(handle *guestfs.Guestfs) error {
// Individual distros should override this method if necessary
return errors.New("InstallSystemdNetworkd: not implemented for this distro")
return errors.New("InstallNetworkManager: not implemented for this distro")
}
func (mgr *TemplateManager) DownloadTemplateGeneric(filename, url string) (path string, err error) {
@ -175,6 +164,12 @@ func (mgr *TemplateManager) getGuestfsMountedHandle(filename string) (handle *gu
if err != nil {
return
}
// Needed to install packages in VM
log.Debug().Msg("Enabling network access")
err = handle.Set_network(true)
if err != nil {
return
}
log.Debug().Msg("Adding drive " + filename)
if err = handle.Add_drive(filename, nil); err != nil {
return
@ -279,141 +274,37 @@ func isSameFile(handle *guestfs.Guestfs, path1, path2 string) (bool, error) {
var systemdDirPrefixes = []string{"/etc", "/lib"}
func getSystemdUnitPath(handle *guestfs.Guestfs, unit string) (string, error) {
for _, dirPrefix := range systemdDirPrefixes {
possibleUnitPath := dirPrefix + "/systemd/system/" + unit
exists, err := handle.Is_file(possibleUnitPath, nil)
if err != nil {
return "", fmt.Errorf("could not determine if %s is a file: %w", possibleUnitPath, err)
}
if exists {
return possibleUnitPath, nil
}
}
return "", nil
}
func getSystemdServiceWantedBy(handle *guestfs.Guestfs, unitPath string) (systemdUnitInstallInfo, error) {
installInfo := systemdUnitInstallInfo{}
content, err := handle.Cat(unitPath)
if err != nil {
return installInfo, fmt.Errorf("could not read %s: %w", unitPath, err)
}
lines := strings.Split(content, "\n")
for _, line := range lines {
if strings.HasPrefix(line, "WantedBy=") {
if installInfo.WantedBy != "" {
return installInfo, fmt.Errorf("multiple WantedBy directives found in %s", unitPath)
}
installInfo.WantedBy = line[len("WantedBy="):]
} else if strings.HasPrefix(line, "Also=") {
installInfo.Also = append(installInfo.Also, line[len("Also="):])
}
}
return installInfo, nil
}
type systemdUnitInstallInfo struct {
WantedBy string
Also []string
}
type systemdUnitInfo struct {
UnitPath string
Enabled bool
systemdUnitInstallInfo
}
// Returns (nil, nil) if the unit does not exist
func getSystemdUnitInfo(handle *guestfs.Guestfs, unit string) (*systemdUnitInfo, error) {
unitPath, err := getSystemdUnitPath(handle, unit)
if err != nil || unitPath == "" {
return nil, err
}
installInfo, err := getSystemdServiceWantedBy(handle, unitPath)
if err != nil {
return nil, err
}
if installInfo.WantedBy == "" {
return nil, fmt.Errorf("could not find WantedBy property for %s", unitPath)
}
linkName := ""
for _, dirPrefix := range systemdDirPrefixes {
possibleLinkName := dirPrefix + "/systemd/system/" + installInfo.WantedBy + ".wants/" + unit
isSymlink, err := handle.Is_symlink(possibleLinkName)
if err != nil {
return nil, fmt.Errorf("could not determine if %s is a symlink: %w", possibleLinkName, err)
}
if isSymlink {
linkName = possibleLinkName
break
}
}
unitInfo := &systemdUnitInfo{
UnitPath: unitPath,
systemdUnitInstallInfo: installInfo,
}
if linkName != "" {
target, err := handle.Readlink(linkName)
if err != nil {
return nil, fmt.Errorf("could not read link at %s: %w", linkName, err)
}
// The target might be in /usr/lib, which will likely be a symlink
// to /lib. So we can't compare the paths directly.
targetIsUnitFile, err := isSameFile(handle, target, unitPath)
if err != nil {
return nil, err
}
if !targetIsUnitFile {
return nil, fmt.Errorf("%s is not the same as %s", target, unitPath)
}
unitInfo.Enabled = true
}
return unitInfo, nil
func (mgr *TemplateManager) logAndRunCommand(handle *guestfs.Guestfs, args []string) (string, error) {
mgr.logger.Debug().Msg("Running command `" + strings.Join(args, " ") + "`")
return handle.Command(args)
}
func (mgr *TemplateManager) enableSystemdUnit(handle *guestfs.Guestfs, unit string) error {
unitInfo, err := getSystemdUnitInfo(handle, unit)
if err != nil {
return err
}
if unitInfo == nil {
return fmt.Errorf("Unit %s does not exist", unit)
}
if unitInfo.Enabled {
return nil
}
if unitInfo.WantedBy == "" {
return fmt.Errorf("%s does not have WantedBy directive", unit)
}
wantsDir := "/etc/systemd/system/" + unitInfo.WantedBy + ".wants"
mgr.logger.Debug().Msg("mkdir -p " + wantsDir)
err = handle.Mkdir_p(wantsDir)
if err != nil {
return err
}
linkPath := wantsDir + "/" + unit
mgr.logger.Debug().Msg("ln -sf " + unitInfo.UnitPath + " " + linkPath)
err = handle.Ln_sf(unitInfo.UnitPath, linkPath)
if err != nil {
return err
}
unitInfo.Enabled = true
for _, also := range unitInfo.Also {
err = mgr.enableSystemdUnit(handle, also)
if err != nil {
return err
}
}
return nil
_, err := mgr.logAndRunCommand(handle, []string{
"systemctl", "enable", unit,
})
return err
}
func systemdServiceIsEnabled(handle *guestfs.Guestfs, unit string) (bool, error) {
unitInfo, err := getSystemdUnitInfo(handle, unit)
if err != nil || unitInfo == nil {
func (mgr *TemplateManager) systemdServiceIsEnabled(handle *guestfs.Guestfs, unit string) (bool, error) {
// Use '|| true' because the exit code will be non-zero if the unit
// is not enabled or does not exist
command := "systemctl is-enabled " + unit + " || true"
mgr.logger.Debug().Msg(`Running sh -c "` + command + `"`)
output, err := handle.Sh(command)
if err != nil {
return false, err
}
return unitInfo.Enabled, nil
// Strip newline, if there is one
if len(output) > 0 && output[len(output)-1] == '\n' {
output = output[:len(output)-1]
}
if output == "enabled" {
return true, nil
} else if output == "disabled" || output == "not-found" {
return false, nil
}
return false, errors.New("Unexpected output '" + output + "'")
}
// setChronyOptions sets custom NTP server URLs in a chrony config file.
@ -515,28 +406,28 @@ func (mgr *TemplateManager) setTimesyncdConf(handle *guestfs.Guestfs) error {
return handle.Write("/etc/systemd/timesyncd.conf.d/csclub.conf", getResource("timesyncd.conf"))
}
func usesChrony(handle *guestfs.Guestfs) (bool, error) {
func (mgr *TemplateManager) usesChrony(handle *guestfs.Guestfs) (bool, error) {
// The systemd service for chrony is called on chrony.service on e.g. Debian,
// but is called chronyd.service on e.g. Fedora
chronyIsEnabled, err := systemdServiceIsEnabled(handle, "chrony.service")
chronyIsEnabled, err := mgr.systemdServiceIsEnabled(handle, "chrony.service")
if err != nil {
return false, err
}
if chronyIsEnabled {
return true, nil
}
return systemdServiceIsEnabled(handle, "chronyd.service")
return mgr.systemdServiceIsEnabled(handle, "chronyd.service")
}
func (mgr *TemplateManager) setupCommonNTPConfigs(handle *guestfs.Guestfs) error {
usesChrony, err := usesChrony(handle)
usesChrony, err := mgr.usesChrony(handle)
if err != nil {
return err
}
if usesChrony {
return mgr.setChronyOptions(handle)
}
usesSystemdTimesyncd, err := systemdServiceIsEnabled(handle, "systemd-timesyncd.service")
usesSystemdTimesyncd, err := mgr.systemdServiceIsEnabled(handle, "systemd-timesyncd.service")
if err != nil {
return err
}
@ -555,11 +446,11 @@ func (mgr *TemplateManager) setNetworkManagerOptions(handle *guestfs.Guestfs) (e
return handle.Write(path, getResource("network-manager-snippet"))
}
func usesNetworkManager(handle *guestfs.Guestfs) (bool, error) {
return systemdServiceIsEnabled(handle, "NetworkManager.service")
func (mgr *TemplateManager) usesNetworkManager(handle *guestfs.Guestfs) (bool, error) {
return mgr.systemdServiceIsEnabled(handle, "NetworkManager.service")
}
func usesEtcNetworkInterfaces(handle *guestfs.Guestfs) (bool, error) {
func (mgr *TemplateManager) usesEtcNetworkInterfaces(handle *guestfs.Guestfs) (bool, error) {
configPath := "/etc/network/interfaces"
configExists, err := handle.Is_file(configPath, nil)
if err != nil {
@ -568,16 +459,7 @@ func usesEtcNetworkInterfaces(handle *guestfs.Guestfs) (bool, error) {
if !configExists {
return false, nil
}
return systemdServiceIsEnabled(handle, "networking.service")
}
func usesSysconfigNetwork(handle *guestfs.Guestfs) (bool, error) {
configPath := "/etc/sysconfig/network/config"
configExists, err := handle.Is_file(configPath, nil)
if err != nil {
err = fmt.Errorf("Could not determine if "+configPath+" is a file: %w", err)
}
return configExists, err
return mgr.systemdServiceIsEnabled(handle, "networking.service")
}
func (mgr *TemplateManager) addSystemdNetworkdCloudInitSnippet(handle *guestfs.Guestfs) error {
@ -587,62 +469,54 @@ func (mgr *TemplateManager) addSystemdNetworkdCloudInitSnippet(handle *guestfs.G
return handle.Write(path, getResource("systemd-networkd-cloud-init"))
}
func (mgr *TemplateManager) installAndEnableSystemdNetworkd(handle *guestfs.Guestfs) error {
isInstalled, err := handle.Is_file("/lib/systemd/system/systemd-networkd.service", nil)
func (mgr *TemplateManager) installAndEnableNetworkManager(handle *guestfs.Guestfs) error {
isInstalled, err := handle.Is_file("/lib/systemd/system/NetworkManager.service", nil)
if err != nil {
return err
}
if !isInstalled {
err = mgr.impl.InstallSystemdNetworkd(handle)
err = mgr.impl.InstallNetworkManager(handle)
if err != nil {
return err
}
}
return mgr.enableSystemdUnit(handle, "systemd-networkd.service")
return mgr.enableSystemdUnit(handle, "NetworkManager.service")
}
func (mgr *TemplateManager) setupCommonNetworkConfigs(handle *guestfs.Guestfs) error {
usesNetworkManager, err := usesNetworkManager(handle)
usesNetworkManager, err := mgr.usesNetworkManager(handle)
if err != nil {
return err
}
if usesNetworkManager {
return mgr.setNetworkManagerOptions(handle)
}
usesSystemdNetworkd, err := systemdServiceIsEnabled(handle, "systemd-networkd.service")
usesSystemdNetworkd, err := mgr.systemdServiceIsEnabled(handle, "systemd-networkd.service")
if err != nil {
return err
}
if usesSystemdNetworkd {
return mgr.addSystemdNetworkdCloudInitSnippet(handle)
}
usesEtcNetworkInterfaces, err := usesEtcNetworkInterfaces(handle)
usesEtcNetworkInterfaces, err := mgr.usesEtcNetworkInterfaces(handle)
if err != nil {
return err
}
if usesEtcNetworkInterfaces {
return mgr.setDhclientOptions(handle)
}
usesSysconfigNetwork, err := usesSysconfigNetwork(handle)
mgr.logger.Info().Msg("Could not determine network backend, resorting to installing NetworkManager")
err = mgr.installAndEnableNetworkManager(handle)
if err != nil {
return err
}
if usesSysconfigNetwork {
// Handled separately in opensuse_tumbleweed.go
return nil
}
mgr.logger.Info().Msg("Could not determine network backend, resorting to installing systemd-networkd")
err = mgr.installAndEnableSystemdNetworkd(handle)
if err != nil {
return err
}
return mgr.addSystemdNetworkdCloudInitSnippet(handle)
return mgr.setNetworkManagerOptions(handle)
}
func (mgr *TemplateManager) setupIpv6Scripts(handle *guestfs.Guestfs) (err error) {
log := mgr.logger
scripts := []string{"99_csclub_ipv6_addr.sh"}
usesNetworkManager, err := usesNetworkManager(handle)
usesNetworkManager, err := mgr.usesNetworkManager(handle)
if err != nil {
return
}
@ -671,25 +545,11 @@ func (mgr *TemplateManager) setupIpv6Scripts(handle *guestfs.Guestfs) (err error
}
func (mgr *TemplateManager) setResolvConf(handle *guestfs.Guestfs) (err error) {
if mgr.impl.NeedsDynamicResolvConf() {
scriptDir := "/var/lib/cloud/scripts/per-boot"
if err = handle.Mkdir_p(scriptDir); err != nil {
return
}
filename := "97_csclub_resolv_conf.sh"
path := scriptDir + "/" + filename
mgr.logger.Debug().Msg("Writing to " + path)
if err = handle.Write(path, getResource(filename)); err != nil {
return
}
return handle.Chmod(0755, path)
} else {
mgr.logger.Debug().Msg("Writing to /etc/resolv.conf")
if err = handle.Rm_f("/etc/resolv.conf"); err != nil {
return
}
return handle.Write("/etc/resolv.conf", getResource("resolv.conf"))
mgr.logger.Debug().Msg("Writing to /etc/resolv.conf")
if err = handle.Rm_f("/etc/resolv.conf"); err != nil {
return
}
return handle.Write("/etc/resolv.conf", getResource("resolv.conf"))
}
func (mgr *TemplateManager) createAugeasHandle(handle *guestfs.Guestfs) (err error) {
@ -906,16 +766,14 @@ func (mgr *TemplateManager) dnfRemoveUnnecessaryPackages(handle *guestfs.Guestfs
// auditd spams the system log and uses lots of disk IO.
// bluez is unnecessary on servers.
args := []string{"dnf", "-C", "remove", "-y", "sssd-common", "audit", "bluez"}
mgr.logger.Debug().Msg("Running '" + strings.Join(args, " ") + "'")
_, err = handle.Command(args)
_, err = mgr.logAndRunCommand(handle, args)
if err != nil {
return
}
// Now that we removed SSSD, we also have to make sure that it's not being used in PAM.
// The way to do this on Fedora (and likely other RHEL-based distros) is with authselect.
args = []string{"authselect", "select", "minimal"}
mgr.logger.Debug().Msg("Running '" + strings.Join(args, " ") + "'")
_, err = handle.Command(args)
_, err = mgr.logAndRunCommand(handle, args)
return err
}

View File

@ -9,4 +9,4 @@ if ! [ -e /usr/bin/qemu-img ]; then
export PATH="$DEPS_DIR/usr/bin:$PATH"
fi
set -x
exec $GUESTFISH
exec $GUESTFISH --network