cloudbuild/pkg/cloudbuilder/cloudbuilder.go

341 lines
9.4 KiB
Go

package cloudbuilder
import (
"bytes"
_ "embed"
"errors"
"fmt"
"net/smtp"
"os"
"path"
"regexp"
"strconv"
"strings"
"text/template"
"time"
"github.com/rs/zerolog/log"
"git.csclub.uwaterloo.ca/cloud/cloudbuild/pkg/cloudstack"
"git.csclub.uwaterloo.ca/cloud/cloudbuild/pkg/config"
"git.csclub.uwaterloo.ca/cloud/cloudbuild/pkg/distros"
)
//go:embed email-message.txt
var emailMessageTemplate string
type CloudBuilder struct {
cfg *config.Config
client *cloudstack.CloudstackClient
}
func New(cfg *config.Config) *CloudBuilder {
return &CloudBuilder{
cfg: cfg,
client: cloudstack.New(cfg),
}
}
func (c *CloudBuilder) moveTemplateToUploadArea(templatePath string) (newPath string, err error) {
newPath = path.Join(c.cfg.UploadDirectory, path.Base(templatePath))
log.Debug().Msg(fmt.Sprintf("Moving %s to %s", templatePath, newPath))
err = os.Rename(templatePath, newPath)
return
}
func (c *CloudBuilder) UploadTemplate(
templatePath string, templateName string, osDescription string,
) (template *cloudstack.Template, err error) {
templatePath, err = c.moveTemplateToUploadArea(templatePath)
if err != nil {
return
}
osTypeId, err := c.client.GetOsTypeId(osDescription)
if err != nil {
return
}
uploadUrl := strings.TrimRight(c.cfg.UploadBaseUrl, "/") + "/" + path.Base(templatePath)
log.Debug().Msg(fmt.Sprintf("Uploading new template from %s", uploadUrl))
template, err = c.client.RegisterTemplate(templateName, uploadUrl, osTypeId)
if err != nil {
return
}
const pollInterval = 5 * time.Second
// Due to clock drift, we may end up waiting a bit longer than this
const maxTimeToWait = 5 * time.Minute
var timeWaited time.Duration = 0
for !template.IsReady {
// TODO: make sure that DownloadDetails[0] exists
log.Debug().
Str("templateName", template.Name).
Str("status", template.Status).
Str("downloadPercent", template.DownloadDetails[0].DownloadPercent).
Str("downloadState", template.DownloadDetails[0].DownloadState).
Msg("Template is not ready yet...")
time.Sleep(pollInterval)
timeWaited += pollInterval
if timeWaited >= maxTimeToWait {
err = errors.New("Timed out waiting for template to be ready")
return
}
template, err = c.client.GetTemplate(template.Id)
if err != nil {
return
}
}
log.Debug().Msg("Deleting " + templatePath)
err = os.Remove(templatePath)
return
}
func (c *CloudBuilder) CreateVM(
name string, templateID string,
) (virtualMachine *cloudstack.VirtualMachine, err error) {
vmID, err := c.client.DeployVirtualMachine(name, templateID)
if err != nil {
return
}
virtualMachine, err = c.client.GetVirtualMachine(vmID)
if err != nil {
return
}
const pollInterval = 20 * time.Second
// Due to clock drift, we may end up waiting a bit longer than this
const maxTimeToWait = 30 * time.Minute
var timeWaited time.Duration = 0
log.Debug().Str("templateID", templateID).Msg("Creating new VM")
for virtualMachine.State != "Running" {
log.Debug().
Str("name", name).
Str("id", virtualMachine.Id).
Str("state", virtualMachine.State).
Msg("VM is not ready yet...")
time.Sleep(pollInterval)
timeWaited += pollInterval
if timeWaited >= maxTimeToWait {
err = errors.New("Timed out waiting for VM to be ready")
return
}
virtualMachine, err = c.client.GetVirtualMachine(vmID)
if err != nil {
return
}
}
log.Debug().Msg("VM successfully created")
return
}
type DistroInfo struct {
name string
manager distros.ITemplateManager
osDescription string
user string
}
type TemplateVersionInfo struct {
TemplateId string
Version string
}
func (c *CloudBuilder) getExistingTemplateVersions() map[string]TemplateVersionInfo {
templatePattern := regexp.MustCompile("^CSC (?P<distro>[A-Za-z][A-Za-z ]+[A-Za-z]) (?P<version>\\d+(\\.\\d+)?)$")
mostRecentVersions := make(map[string]TemplateVersionInfo)
templates := c.client.ListTemplates()
for _, template := range templates {
submatches := templatePattern.FindStringSubmatch(template.Name)
if submatches == nil {
log.Debug().Msg(fmt.Sprintf("Template '%s' did not match pattern, skipping", template.Name))
continue
}
distro := submatches[1]
version := submatches[2]
log.Debug().Str("distro", distro).Str("version", version).Msg("Found template")
otherVersion, ok := mostRecentVersions[distro]
if !ok || version > otherVersion.Version {
mostRecentVersions[distro] = TemplateVersionInfo{
TemplateId: template.Id,
Version: version,
}
}
}
return mostRecentVersions
}
func (c *CloudBuilder) sendEmailNotification(templateName string) (err error) {
tmpl := template.Must(template.New("email-message").Parse(emailMessageTemplate))
data := map[string]interface{}{
"cfg": c.cfg,
"date": time.Now().Format(time.RFC1123Z),
"templateName": templateName,
}
var buf bytes.Buffer
if err = tmpl.Execute(&buf, data); err != nil {
return
}
// The lines of the body need to be CRLF terminated
// See https://pkg.go.dev/net/smtp#SendMail
// Make sure that email-message.txt uses Unix-style LF endings
msg := bytes.ReplaceAll(buf.Bytes(), []byte("\n"), []byte("\r\n"))
log.Debug().
Str("to", c.cfg.EmailRecipient).
Msg("sending email notification")
return smtp.SendMail(
c.cfg.EmailServer,
nil, /* auth */
c.cfg.EmailSender,
[]string{c.cfg.EmailRecipient},
msg,
)
}
func (c *CloudBuilder) createNewTemplate(
newVersion, codename string, distroInfo *DistroInfo,
) (template *cloudstack.Template, err error) {
distroManager := distroInfo.manager
templatePath, err := distroManager.DownloadTemplate(newVersion, codename)
if err != nil {
return
}
if err = distroManager.ModifyTemplate(templatePath); err != nil {
return
}
templateName := fmt.Sprintf("CSC %s %s", distroInfo.name, newVersion)
template, err = c.UploadTemplate(templatePath, templateName, distroInfo.osDescription)
return
}
func (c *CloudBuilder) destroyVirtualMachine(vmID string) error {
log.Info().Str("id", vmID).Msg("Deleting VM")
deletionJobID, err := c.client.DestroyVirtualMachine(vmID)
if err != nil {
return err
}
return c.client.WaitForJobToComplete(deletionJobID)
}
func (c *CloudBuilder) versionStringCompare(version1, version2 string) int {
f1, err := strconv.ParseFloat(version1, 32)
if err != nil {
panic(err)
}
f2, err := strconv.ParseFloat(version2, 32)
if err != nil {
panic(err)
}
if f1 < f2 {
return -1
} else if f1 > f2 {
return 1
}
return 0
}
func (c *CloudBuilder) Start() (err error) {
distrosInfo := map[string]DistroInfo{
"debian": DistroInfo{
name: "Debian",
manager: distros.NewDebianTemplateManager(c.cfg),
// This is the latest version available in CloudStack 4.16;
// using a custom OS type caused issues with the ethernet
// interface not being detected
osDescription: "Debian GNU/Linux 11 (64-bit)",
user: "debian",
},
"ubuntu": DistroInfo{
name: "Ubuntu",
manager: distros.NewUbuntuTemplateManager(c.cfg),
osDescription: "Other Ubuntu (64-bit)",
user: "ubuntu",
},
"fedora": DistroInfo{
name: "Fedora",
manager: distros.NewFedoraTemplateManager(c.cfg),
osDescription: "Fedora Linux (64 bit)",
user: "fedora",
},
"almalinux": DistroInfo{
name: "AlmaLinux",
manager: distros.NewAlmaLinuxTemplateManager(c.cfg),
osDescription: "Other CentOS (64-bit)",
user: "almalinux",
},
}
existingTemplates := c.getExistingTemplateVersions()
newVersions := make(map[string]string)
for _, distroLower := range c.cfg.DistrosToCheck {
distroInfo, ok := distrosInfo[distroLower]
if !ok {
return errors.New("Distro not found: " + distroLower)
}
distro := distroInfo.name
distroManager := distroInfo.manager
var newVersion, codename string
newVersion, codename, err = distroManager.GetLatestVersion()
if err != nil {
return
}
log.Debug().Str("newVersion", newVersion).Str("codename", codename).Msg(distro)
curTemplate, ok := existingTemplates[distro]
curVersion := curTemplate.Version
if ok && c.versionStringCompare(newVersion, curVersion) <= 0 {
log.Debug().
Str("distro", distro).
Str("version", curVersion).
Msg("Existing version is up to date, skipping")
continue
}
newVersions[distro] = newVersion
log.Info().
Str("distro", distroLower).
Msg("Template is nonexistent or out of date, creating a new one")
var template *cloudstack.Template
template, err = c.createNewTemplate(newVersion, codename, &distroInfo)
if err != nil {
return
}
// Create a VM using the new template
vmName := strings.Join([]string{
strings.ReplaceAll(distroLower, " ", "-"),
strings.ReplaceAll(newVersion, ".", "-"),
"test",
}, "-")
var vm *cloudstack.VirtualMachine
vm, err = c.CreateVM(vmName, template.Id)
if err != nil {
return
}
// Make sure that everything is working properly in the VM
verifier := NewVMVerifier(c.cfg, distroInfo.user, vm.Nic[0].IpAddress, vmName, distroInfo.manager)
if err = verifier.Verify(); err != nil {
return
}
// Since we don't need the VM anymore, delete it
if err = c.destroyVirtualMachine(vm.Id); err != nil {
return
}
vm = nil
// When we originally created the template, it wasn't public,
// so make it public now
if err = c.client.MakeTemplatePublicAndFeatured(template.Id); err != nil {
return
}
if err = c.sendEmailNotification(template.Name); err != nil {
return
}
}
if c.cfg.DeleteOldTemplates {
if err = c.DeleteOldTemplates(existingTemplates, newVersions); err != nil {
return
}
}
return
}