341 lines
9.4 KiB
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
|
|
}
|