cloudbuild/pkg/distros/template_manager.go

584 lines
17 KiB
Go

package distros
import (
"embed"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"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
// 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)
// 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 {
// UsesNetworkManager returns whether the distro uses NetworkManager
UsesNetworkManager() bool
// PerformDistroSpecificModifications is called after
// performDistroAgnosticModifications to modify a template in a
// distro-specific way.
PerformDistroSpecificModifications(handle *guestfs.Guestfs) error
// HasSELinuxEnabled returns whether SELinux is enabled for this distro
HasSELinuxEnabled() bool
// 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) DownloadTemplateGeneric(filename, url string) (path string, err error) {
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 debianCommandToUpdatePackageCache() []string {
return []string{"sudo", "apt", "update"}
}
func fedoraCommandToUpdatePackageCache() []string {
return []string{"sudo", "dnf", "makecache"}
}
func (mgr *TemplateManager) performDistroAgnosticModifications(handle *guestfs.Guestfs) (err error) {
if err = mgr.setupIpv6Scripts(handle); err != nil {
return
}
if err = mgr.setResolvConf(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)
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 mgr.impl.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 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
}
// setChronyOptions sets custom NTP server URLs in a chrony config file.
// It assumes that a line beginning with "pool" will already be present.
func (mgr *TemplateManager) setChronyOptions(handle *guestfs.Guestfs, path string) (err error) {
oldLines, err := handle.Read_lines(path)
if err != nil {
return
}
snippet := string(getResource("chrony-snippet"))
snippetLines := strings.Split(snippet, "\n")
wroteSnippet := false
newLines := make([]string, 0, len(oldLines)+len(snippetLines))
for _, line := range oldLines {
if strings.HasPrefix(line, "pool ") {
newLines = append(newLines, "#"+line)
if !wroteSnippet {
newLines = append(newLines, snippetLines...)
wroteSnippet = true
}
} else {
newLines = append(newLines, line)
}
}
newContent := strings.Join(newLines, "\n")
mgr.logger.Debug().Msg("Writing new content to " + path)
return handle.Write(path, []byte(newContent))
}
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 (mgr *TemplateManager) setupIpv6Scripts(handle *guestfs.Guestfs) (err error) {
log := mgr.logger
scripts := []string{"99_csclub_ipv6_addr.sh"}
if mgr.impl.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) {
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
}
// requires an Augeas handle to be open
func (mgr *TemplateManager) replaceDebianMirrorUrls(handle *guestfs.Guestfs) (err error) {
log := mgr.logger
// Some Augeas nodes under /files/etc/apt/sources.list are comments,
// so we use /*/uri to make sure that we only get the actual entries
sourcesListEntries, err := handle.Aug_match("/files/etc/apt/sources.list/*/uri")
if err != nil {
return
}
for _, uriPath := range sourcesListEntries {
var (
uriValue string
parsedUrl *url.URL
distValue string
typeValue string
)
if uriValue, err = handle.Aug_get(uriPath); err != nil {
return
}
parsedUrl, err = url.Parse(uriValue)
if err != nil {
return
}
parsedUrl.Host = mgr.cfg.MirrorHost
newUriValue := parsedUrl.String()
// strip off the "/uri" from the node path
entryPath := uriPath[:len(uriPath)-4]
typePath := entryPath + "/type"
if typeValue, err = handle.Aug_get(typePath); err != nil {
return
}
distPath := entryPath + "/distribution"
if distValue, err = handle.Aug_get(distPath); err != nil {
return
}
if typeValue == "deb-src" {
log.Debug().
Str("URL", uriValue).
Str("distribution", distValue).
Msg("Removing deb-src entry")
if _, err = handle.Aug_rm(entryPath); err != nil {
return
}
continue
}
if uriValue == newUriValue {
continue
}
log.Debug().
Str("distribution", distValue).
Str("oldURL", uriValue).
Str("newURL", newUriValue).
Msg("Replacing URL in sources.list")
if err = handle.Aug_set(uriPath, newUriValue); err != nil {
return
}
}
return
}
// requires an Augeas handle to be open
func (mgr *TemplateManager) updateSshdConfig(handle *guestfs.Guestfs) error {
mgr.logger.Debug().Msg("Setting PrintLastLog=no in sshd_config")
return handle.Aug_set("/files/etc/ssh/sshd_config/PrintLastLog", "no")
}
func (mgr *TemplateManager) setTimesyncdConf(handle *guestfs.Guestfs) (err error) {
mgr.logger.Debug().Msg("Writing custom timesyncd.conf")
if err = handle.Mkdir_p("/etc/systemd/timesyncd.conf.d"); err != nil {
return
}
return handle.Write("/etc/systemd/timesyncd.conf.d/csclub.conf", getResource("timesyncd.conf"))
}
func (mgr *TemplateManager) setMotd(handle *guestfs.Guestfs) error {
mgr.logger.Debug().Msg("Writing to /etc/motd")
return handle.Write("/etc/motd", getResource("motd"))
}