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[A-Za-z][A-Za-z ]+[A-Za-z]) (?P\\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 }