package distros import ( "embed" "errors" "fmt" "io" "net/http" "os" "regexp" "strings" "text/template" "github.com/rs/zerolog" "libguestfs.org/guestfs" "git.csclub.uwaterloo.ca/cloud/cloudbuild/pkg/config" ) //go:embed resources var res embed.FS func getResource(filename string) []byte { data, err := res.ReadFile("resources/" + filename) if err != nil { panic(err) } return data } func getTemplateResource(filename string) *template.Template { tmpl, err := template.ParseFS(res, "resources/"+filename) if err != nil { panic(err) } return tmpl } // A TemplateManager downloads and modifies VM templates for a distro. type TemplateManager struct { cfg *config.Config logger *zerolog.Logger impl IDistroSpecificTemplateManager } type ITemplateManager interface { IDistroSpecificTemplateManager // ModifyTemplate makes custom modifications to the downloaded VM template ModifyTemplate(filename string) error } // 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) // DownloadTemplate downloads a VM template for a given OS version and // codename, and returns the path to where it was downloaded DownloadTemplate(version, codename string) (path string, err error) // PerformDistroSpecificModifications is called after // performDistroAgnosticModifications to modify a template in a // distro-specific way. PerformDistroSpecificModifications(handle *guestfs.Guestfs) error // CommandToUpdatePackageCache returns the command which updates // (but does not upgrade) the packages for this distro, e.g. // "sudo apt update". CommandToUpdatePackageCache() []string } func (mgr *TemplateManager) NeedsDynamicResolvConf() bool { // Assume false by default. Individual distros should override this // method if necessary. return false } func (mgr *TemplateManager) DownloadTemplateGeneric(filename, url string) (path string, err error) { if mgr.cfg.SkipDownload { mgr.logger.Debug().Str("url", url).Msg("SKIP_DOWNLOAD was set, skipping download") return filename, nil } mgr.logger.Debug().Str("url", url).Msg("Downloading template") resp, err := http.Get(url) if err != nil { return } defer resp.Body.Close() // TODO: download to somewhere in /tmp path = filename out, err := os.Create(path) if err != nil { return } defer out.Close() _, err = io.Copy(out, resp.Body) return } func fedoraCommandToUpdatePackageCache() []string { return []string{"sudo", "dnf", "makecache"} } func (mgr *TemplateManager) performDistroAgnosticModifications(handle *guestfs.Guestfs) (err error) { if err = mgr.setupCommonNetworkConfigs(handle); err != nil { return } if err = mgr.setupIpv6Scripts(handle); err != nil { return } if err = mgr.setResolvConf(handle); err != nil { return } if err = mgr.setupCommonNTPConfigs(handle); err != nil { return } if err = mgr.setMotd(handle); err != nil { return } if err = mgr.updateSshdConfig(handle); err != nil { return } return } func (mgr *TemplateManager) ModifyTemplate(filename string) (err error) { handle, err := mgr.getGuestfsMountedHandle(filename) if err != nil { return } defer mgr.unmountAndCloseGuestfsHandle(handle) hasSELinuxEnabled, err := hasSELinuxEnabled(handle) if err != nil { return } if hasSELinuxEnabled { availErr := handle.Available([]string{"selinuxrelabel"}) if availErr != nil { return fmt.Errorf("This appliance does not support selinuxrelabel: %w", availErr) } } if err = mgr.createAugeasHandle(handle); err != nil { return } defer mgr.closeAugeasHandle(handle) if err = mgr.performDistroAgnosticModifications(handle); err != nil { return } if err = mgr.impl.PerformDistroSpecificModifications(handle); err != nil { return } if hasSELinuxEnabled { if err = mgr.selinuxRelabelDirectories(handle); err != nil { return } } return mgr.saveAugeasValues(handle) } func (mgr *TemplateManager) getGuestfsMountedHandle(filename string) (handle *guestfs.Guestfs, err error) { log := mgr.logger handle, err = guestfs.Create() if err != nil { return } log.Debug().Msg("Adding drive " + filename) if err = handle.Add_drive(filename, nil); err != nil { return } log.Debug().Msg("Launching VM") if err = handle.Launch(); err != nil { return } log.Debug().Msg("Inspecting OS") partitions, err := handle.Inspect_os() if err != nil { return } if len(partitions) != 1 { return nil, errors.New(fmt.Sprintf("Expected 1 root partition, found %d", len(partitions))) } rootPartition := partitions[0] log.Debug().Msg(fmt.Sprintf("Mounting root filesystem %s on /", rootPartition)) if err = handle.Mount(rootPartition, "/"); err != nil { return } return } func (mgr *TemplateManager) unmountGuestfsDrive(handle *guestfs.Guestfs) { if err := handle.Umount("/", nil); err != nil { mgr.logger.Error().Err(err).Msg("") } } func (mgr *TemplateManager) unmountAndCloseGuestfsHandle(handle *guestfs.Guestfs) { mgr.unmountGuestfsDrive(handle) handle.Close() } func hasSELinuxEnabled(handle *guestfs.Guestfs) (bool, error) { return handle.Is_file("/usr/bin/sestatus", nil) } func getSelinuxType(handle *guestfs.Guestfs) (selinuxType string, err error) { lines, err := handle.Grep("^SELINUXTYPE=", "/etc/selinux/config", nil) if err != nil { return } if len(lines) != 1 { err = errors.New(fmt.Sprintf("Expected 1 line containing SELINUXTYPE, found %d", len(lines))) return } selinuxType = strings.Split(lines[0], "=")[1] return } func getSelinuxDefaultSpecfile(handle *guestfs.Guestfs) (specfile string, err error) { selinuxType, err := getSelinuxType(handle) if err != nil { return } specfile = fmt.Sprintf("/etc/selinux/%s/contexts/files/file_contexts", selinuxType) return } func (mgr *TemplateManager) selinuxRelabel(handle *guestfs.Guestfs, specfile, dir string) (err error) { mgr.logger.Debug(). Str("specfile", specfile). Str("path", dir). Msg("Relabeling SELinux security context") return handle.Selinux_relabel(specfile, dir, nil) } func (mgr *TemplateManager) selinuxRelabelDirectories(handle *guestfs.Guestfs) (err error) { specfile, err := getSelinuxDefaultSpecfile(handle) if err != nil { return } for _, dir := range []string{"/etc", "/var/lib/cloud"} { if err = mgr.selinuxRelabel(handle, specfile, dir); err != nil { return } } return } func (mgr *TemplateManager) maskSystemdUnit(handle *guestfs.Guestfs, unit string) error { mgr.logger.Debug().Msg("Masking systemd unit " + unit) return handle.Ln_sf("/dev/null", "/etc/systemd/system/"+unit) } func isSameFile(handle *guestfs.Guestfs, path1, path2 string) (bool, error) { if path1 == path2 { return true, nil } stat1, err := handle.Stat(path1) if err != nil { return false, fmt.Errorf("Could not stat %s: %w", path1, err) } stat2, err := handle.Stat(path2) if err != nil { return false, fmt.Errorf("Could not stat %s: %w", path2, err) } return stat1.Ino == stat2.Ino, nil } func systemdServiceIsEnabled(handle *guestfs.Guestfs, unit string) (bool, error) { dirPrefixes := []string{"/etc", "/lib"} unitPath := "" for _, dirPrefix := range dirPrefixes { possibleUnitPath := dirPrefix + "/systemd/system/" + unit exists, err := handle.Is_file(possibleUnitPath, nil) if err != nil { return false, fmt.Errorf("could not determine if %s is a file: %w", possibleUnitPath, err) } if exists { unitPath = possibleUnitPath break } } if unitPath == "" { return false, nil } content, err := handle.Cat(unitPath) if err != nil { return false, fmt.Errorf("could not read %s: %w", unitPath, err) } lines := strings.Split(content, "\n") wantedBy := "" for _, line := range lines { if strings.HasPrefix(line, "WantedBy=") { wantedBy = line[len("WantedBy="):] break } } if wantedBy == "" { return false, fmt.Errorf("could not find WantedBy property for %s", unitPath) } linkName := "" for _, dirPrefix := range dirPrefixes { possibleLinkName := dirPrefix + "/systemd/system/" + wantedBy + ".wants/" + unit isSymlink, err := handle.Is_symlink(possibleLinkName) if err != nil { return false, fmt.Errorf("could not determine if %s is a symlink: %w", possibleLinkName, err) } if isSymlink { linkName = possibleLinkName break } } if linkName == "" { return false, nil } target, err := handle.Readlink(linkName) if err != nil { return false, 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. return isSameFile(handle, target, unitPath) } // setChronyOptions sets custom NTP server URLs in a chrony config file. func (mgr *TemplateManager) setChronyOptions(handle *guestfs.Guestfs) (err error) { possiblePaths := []string{"/etc/chrony.conf", "/etc/chrony/chrony.conf"} var exists bool var path string var newLines []string for _, path = range possiblePaths { exists, err = handle.Is_file(path, nil) if err != nil { return } if !exists { continue } // comment out any lines beginning with "pool" var oldLines []string oldLines, err = handle.Read_lines(path) if err != nil { return } changed := false newLines = make([]string, 0, len(oldLines)) for _, line := range oldLines { if strings.HasPrefix(line, "pool ") { newLines = append(newLines, "#"+line) changed = true } else { newLines = append(newLines, line) } } if changed { newContent := strings.Join(newLines, "\n") mgr.logger.Debug().Msg("Writing new content to " + path) err = handle.Write(path, []byte(newContent)) if err != nil { return } } break } snippet := getResource("chrony-snippet") // e.g. Debian exists, err = handle.Is_dir("/etc/chrony/sources.d", nil) if err != nil { return } if exists { path := "/etc/chrony/sources.d/csclub.sources" mgr.logger.Debug().Msg("Writing to " + path) return handle.Write(path, snippet) } // e.g. OpenSUSE Tumbleweed exists, err = handle.Is_dir("/etc/chrony.d", nil) if err != nil { return } if exists { mgr.logger.Debug().Msg("Removing /etc/chrony.d/pool.conf") err = handle.Rm_f("/etc/chrony.d/pool.conf") if err != nil { return } path := "/etc/chrony.d/csclub.conf" mgr.logger.Debug().Msg("Writing to " + path) return handle.Write(path, snippet) } // Otherwise, assume we need to modify chrony.conf directly // e.g. Fedora if newLines == nil { return errors.New("could not find chrony.conf") } serverDirectiveExists := false for _, line := range newLines { if strings.HasPrefix(line, "server ") { serverDirectiveExists = true break } } if serverDirectiveExists { // Assume that this was inserted by us during a previous run return } newContent := string(snippet) + "\n" + strings.Join(newLines, "\n") mgr.logger.Debug().Msg("Writing new content to " + path) return handle.Write(path, []byte(newContent)) } func (mgr *TemplateManager) setTimesyncdConf(handle *guestfs.Guestfs) error { if err := handle.Mkdir_p("/etc/systemd/timesyncd.conf.d"); err != nil { return fmt.Errorf("could not create /etc/systemd/timesyncd.conf.d: %w", err) } mgr.logger.Debug().Msg("Writing to /etc/systemd/timesyncd.conf.d/csclub.conf") return handle.Write("/etc/systemd/timesyncd.conf.d/csclub.conf", getResource("timesyncd.conf")) } func 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") if err != nil { return false, err } if chronyIsEnabled { return true, nil } return systemdServiceIsEnabled(handle, "chronyd.service") } func (mgr *TemplateManager) setupCommonNTPConfigs(handle *guestfs.Guestfs) error { usesChrony, err := usesChrony(handle) if err != nil { return err } if usesChrony { return mgr.setChronyOptions(handle) } usesSystemdTimesyncd, err := systemdServiceIsEnabled(handle, "systemd-timesyncd.service") if err != nil { return err } if usesSystemdTimesyncd { return mgr.setTimesyncdConf(handle) } return errors.New("could not determine NTP backend") } func (mgr *TemplateManager) setNetworkManagerOptions(handle *guestfs.Guestfs) (err error) { if err = handle.Mkdir_p("/etc/NetworkManager/conf.d"); err != nil { return } path := "/etc/NetworkManager/conf.d/99_csclub.conf" mgr.logger.Debug().Msg("Writing to " + path) return handle.Write(path, getResource("network-manager-snippet")) } func usesNetworkManager(handle *guestfs.Guestfs) (bool, error) { return systemdServiceIsEnabled(handle, "NetworkManager.service") } func usesEtcNetworkInterfaces(handle *guestfs.Guestfs) (bool, error) { configPath := "/etc/network/interfaces" configExists, err := handle.Is_file(configPath, nil) if err != nil { return false, fmt.Errorf("Could not determine if "+configPath+" is a file: %w", err) } 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 } func (mgr *TemplateManager) addSystemdNetworkdCloudInitSnippet(handle *guestfs.Guestfs) error { // Use 98 so that each distro can use 99 for extra customization path := "/etc/cloud/cloud.cfg.d/98_csclub_network.cfg" mgr.logger.Debug().Msg("Writing to " + path) return handle.Write(path, getResource("systemd-networkd-cloud-init")) } func (mgr *TemplateManager) setupCommonNetworkConfigs(handle *guestfs.Guestfs) error { usesNetworkManager, err := usesNetworkManager(handle) if err != nil { return err } if usesNetworkManager { return mgr.setNetworkManagerOptions(handle) } usesSystemdNetworkd, err := systemdServiceIsEnabled(handle, "systemd-networkd.service") if err != nil { return err } if usesSystemdNetworkd { return mgr.addSystemdNetworkdCloudInitSnippet(handle) } usesEtcNetworkInterfaces, err := usesEtcNetworkInterfaces(handle) if err != nil { return err } if usesEtcNetworkInterfaces { return mgr.setDhclientOptions(handle) } usesSysconfigNetwork, err := usesSysconfigNetwork(handle) if err != nil { return err } if usesSysconfigNetwork { // Handled separately in opensuse_tumbleweed.go return nil } return fmt.Errorf("Could not determine network backend") } func (mgr *TemplateManager) setupIpv6Scripts(handle *guestfs.Guestfs) (err error) { log := mgr.logger scripts := []string{"99_csclub_ipv6_addr.sh"} usesNetworkManager, err := usesNetworkManager(handle) if err != nil { return } if usesNetworkManager { scripts = append(scripts, "98_csclub_disable_nm_ipv6.sh") } else { scripts = append(scripts, "98_csclub_disable_ipv6_ra.sh") } scriptDir := "/var/lib/cloud/scripts/per-boot" if err = handle.Mkdir_p(scriptDir); err != nil { return } for _, filename := range scripts { path := scriptDir + "/" + filename log.Debug().Msg("Writing to " + path) if err = handle.Write(path, getResource(filename)); err != nil { return } if err = handle.Chmod(0755, path); err != nil { return } } sysctlPath := "/etc/sysctl.d/csclub.conf" log.Debug().Msg("Writing to " + sysctlPath) return handle.Write(sysctlPath, getResource("sysctl.conf")) } 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")) } } func (mgr *TemplateManager) createAugeasHandle(handle *guestfs.Guestfs) (err error) { mgr.logger.Debug().Msg("Creating a new Augeas handle") return handle.Aug_init("/", 0) } func (mgr *TemplateManager) closeAugeasHandle(handle *guestfs.Guestfs) { if err := handle.Aug_close(); err != nil { mgr.logger.Error().Err(err).Msg("") } } func (mgr *TemplateManager) saveAugeasValues(handle *guestfs.Guestfs) error { mgr.logger.Debug().Msg("Saving Augeas values") return handle.Aug_save() } // requires an Augeas handle to be open func (mgr *TemplateManager) setDhclientOptions(handle *guestfs.Guestfs) (err error) { mgr.logger.Debug().Msg("Retrieving dhclient request options") dhclientRequestOptionNodes, err := handle.Aug_ls("/files/etc/dhcp/dhclient.conf/request") if err != nil { return } optionsToRemove := map[string]bool{ "domain-name": true, "domain-name-servers": true, "domain-search": true, "dhcp6.name-servers": true, "dhcp6.domain-search": true, "dhcp6.fqdn": true, "dhcp6.sntp-servers": true, "ntp-servers": true, "netbios-name-servers": true, } for _, optionPath := range dhclientRequestOptionNodes { // optionPath will look something like /files/etc/dhcp/dhclient.conf/request/5 var optionValue string optionValue, err = handle.Aug_get(optionPath) if err != nil { return } if _, ok := optionsToRemove[optionValue]; !ok { continue } mgr.logger.Debug().Msg("Removing dhclient request option " + optionValue) if _, err = handle.Aug_rm(optionPath); err != nil { return } } return } func getNamedRegexGroup(re *regexp.Regexp, submatches []string, groupName string) string { var value string for i, subexpName := range re.SubexpNames() { if subexpName == groupName { value = submatches[i] break } } if value == "" { panic("Could not find regex group " + groupName) } return value } func getNumAugeasComments(handle *guestfs.Guestfs, parentNode string) (numComments int, err error) { keyPaths, err := handle.Aug_ls(parentNode) if err != nil { return 0, fmt.Errorf("aug_ls(%s) failed: %w", parentNode, err) } for _, keyPath := range keyPaths { key, err := handle.Aug_label(keyPath) if err != nil { return 0, fmt.Errorf("aug_label(%s) failed: %w", keyPath, err) } if strings.HasPrefix(key, "#comment") { numComments += 1 } } return } func addAugeasComment( handle *guestfs.Guestfs, numExistingComments int, parentNode string, comment string, ) error { return handle.Aug_set( parentNode+fmt.Sprintf("/#comment[%d]", numExistingComments), comment, ) } func (mgr *TemplateManager) commentOutAugeasPath( handle *guestfs.Guestfs, numExistingComments int, augeasPath string, commentedValue string, ) error { lastSlashIndex := strings.LastIndex(augeasPath, "/") if lastSlashIndex == -1 { // sanity check panic("augeasPath must have a slash") } parentNode := augeasPath[:lastSlashIndex] mgr.logger.Debug().Msg("Commenting out " + augeasPath) if _, err := handle.Aug_rm(augeasPath); err != nil { return fmt.Errorf("Could not remove %s: %w", augeasPath, err) } if err := addAugeasComment(handle, numExistingComments, parentNode, commentedValue); err != nil { return fmt.Errorf("Could not insert comment in %s: %w", parentNode, err) } return nil } // replaceYumRepoMirrorUrls comments out the metalink and mirrorlist URLs and // replaces the baseurl URLs for each repo in /etc/yum.repos.d. // It assumes that the baseurl will be present or commented. // // transformBaseUrl accepts a repo base URL and replaces the host (and // optionally parts of the path) as appropriate. It should return the input // if the URL should not be modified. func (mgr *TemplateManager) replaceYumMirrorUrls( handle *guestfs.Guestfs, transformBaseurl func(string) string, ) error { repoPaths, err := handle.Aug_ls("/files/etc/yum.repos.d") if err != nil { return fmt.Errorf("Could not enumerate yum repos: %w", err) } // A repoPath looks like e.g. /files/etc/yum.repos.d/almalinux-appstream.repo for _, repoPath := range repoPaths { subrepoPaths, err := handle.Aug_ls(repoPath) if err != nil { return fmt.Errorf("Could not enumerate subrepos for %s: %w", repoPath, err) } // A subrepoPath looks like e.g. /files/etc/yum.repos.d/almalinux-appstream.repo/appstream for _, subrepoPath := range subrepoPaths { if err = mgr.replaceYumMirrorUrlsForSingleSubrepo(handle, transformBaseurl, subrepoPath); err != nil { return fmt.Errorf("Could not replace mirror URLs for %s: %w", subrepoPath, err) } } } return nil } func (mgr *TemplateManager) replaceYumMirrorUrlsForSingleSubrepo( handle *guestfs.Guestfs, transformBaseurl func(string) string, subrepoPath string, ) (err error) { log := mgr.logger // subrepoPath looks like e.g. /files/etc/yum.repos.d/almalinux-appstream.repo/appstream keyPaths, err := handle.Aug_ls(subrepoPath) if err != nil { return fmt.Errorf("Could not enumerate keys for %s: %w", subrepoPath, err) } var ( numComments int commentedBaseurl string mirrorlist string metalink string uncommentedBaseurl string ) // A keyPath looks like e.g. /files/etc/yum.repos.d/almalinux-appstream.repo/appstream/mirrorlist for _, keyPath := range keyPaths { // extract the last part of the node path key, err := handle.Aug_label(keyPath) if err != nil { return fmt.Errorf("Could not get label of %s: %w", keyPath, err) } value, err := handle.Aug_get(keyPath) if err != nil { return fmt.Errorf("Could not get %s: %w", keyPath, err) } if strings.HasPrefix(key, "#comment") { numComments += 1 if strings.HasPrefix(value, "baseurl=") { commentedBaseurl = strings.Split(value, "=")[1] } } else if key == "mirrorlist" { mirrorlist = value } else if key == "metalink" { metalink = value } else if key == "baseurl" { uncommentedBaseurl = value } } var baseurl string if uncommentedBaseurl != "" { baseurl = transformBaseurl(uncommentedBaseurl) } else if commentedBaseurl != "" { baseurl = transformBaseurl(commentedBaseurl) } if baseurl == "" { return } log.Debug().Msg(fmt.Sprintf("Setting %s to %s", subrepoPath+"/baseurl", baseurl)) if err = handle.Aug_set(subrepoPath+"/baseurl", baseurl); err != nil { return fmt.Errorf("Could not set baseurl for %s: %w", subrepoPath, err) } if mirrorlist != "" { if err = mgr.commentOutAugeasPath(handle, numComments, subrepoPath+"/mirrorlist", "mirrorlist="+mirrorlist); err != nil { return } numComments += 1 } if metalink != "" { if err = mgr.commentOutAugeasPath(handle, numComments, subrepoPath+"/metalink", "metalink="+metalink); err != nil { return } numComments += 1 } return } func (mgr *TemplateManager) dnfRemoveUnnecessaryPackages(handle *guestfs.Guestfs) (err error) { // SSSD is unnecessary in single-user environments and consumes a lot of resources. // 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) 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) return err } // requires an Augeas handle to be open func (mgr *TemplateManager) updateSshdConfig(handle *guestfs.Guestfs) error { sshdConfigExists, err := handle.Is_file("/etc/ssh/sshd_config", nil) if err != nil { return err } if !sshdConfigExists { // On some distros, like OpenSUSE Tumbleweed, sshd_config does not exist, // and creating a new file will actually prevent cloud-init from copying // the user's SSH key to the default user's authorized_keys. return nil } mgr.logger.Debug().Msg("Setting PrintLastLog=no in sshd_config") return handle.Aug_set("/files/etc/ssh/sshd_config/PrintLastLog", "no") } func (mgr *TemplateManager) setJournaldConf(handle *guestfs.Guestfs) (err error) { mgr.logger.Debug().Msg("Writing custom journald.conf") if err = handle.Mkdir_p("/etc/systemd/journald.conf.d"); err != nil { return } return handle.Write("/etc/systemd/journald.conf.d/csclub.conf", getResource("journald.conf")) } func (mgr *TemplateManager) setMotd(handle *guestfs.Guestfs) error { mgr.logger.Debug().Msg("Writing to /etc/motd") return handle.Write("/etc/motd", getResource("motd")) }