first commit

master
Max Erenberg 3 months ago
commit 08f7371198
  1. 3
      .gitignore
  2. 40
      Makefile
  3. 11
      go.mod
  4. 33
      go.sum
  5. 26
      main.go
  6. 287
      pkg/cloudbuilder/cloudbuilder.go
  7. 33
      pkg/cloudbuilder/email-message.txt
  8. 399
      pkg/cloudstack/cloudstack.go
  9. 62
      pkg/config/config.go
  10. 139
      pkg/distros/almalinux.go
  11. 161
      pkg/distros/fedora.go
  12. 22
      pkg/distros/resources/98_csclub_disable_nm_ipv6.sh
  13. 20
      pkg/distros/resources/99_csclub_ipv6_addr.sh
  14. 8
      pkg/distros/resources/chrony-snippet
  15. 1
      pkg/distros/resources/fedora-cloud-init
  16. 9
      pkg/distros/resources/ipv6-sysctl.sh
  17. 12
      pkg/distros/resources/motd
  18. 3
      pkg/distros/resources/network-manager-snippet
  19. 12
      pkg/distros/resources/resolv.conf
  20. 2
      pkg/distros/resources/sysctl.conf
  21. 5
      pkg/distros/resources/timesyncd.conf
  22. 11
      pkg/distros/resources/ubuntu-cloud-init
  23. 526
      pkg/distros/template_manager.go
  24. 132
      pkg/distros/ubuntu.go
  25. 62
      scripts/download-deps.sh
  26. 7
      scripts/qemu.sh

3
.gitignore vendored

@ -0,0 +1,3 @@
/cloudbuild
/guestfs
*.swp

@ -0,0 +1,40 @@
DEPS_DIR = guestfs/deps
LIBRARY_PATH = $(DEPS_DIR)/usr/lib/x86_64-linux-gnu:$(DEPS_DIR)/lib/x86_64-linux-gnu
LIBGUESTFS_PATH = guestfs/appliance
LIBGUESTFS_HV = scripts/qemu.sh
APPLIANCE_VERSION = 1.46.0
DOWNLOAD_DEPS_ARGS =
# Export LIBGUESTFS_DEBUG=1 to debug
ifeq ($(GUESTFISH),1)
DOWNLOAD_DEPS_ARGS = guestfish
endif
all:
LIBRARY_PATH=$(LIBRARY_PATH) CGO_LDFLAGS='-l:libvirt.so.0 -l:libyajl.so.2' go build
run:
LD_LIBRARY_PATH=$(LIBRARY_PATH) LIBGUESTFS_PATH=$(LIBGUESTFS_PATH) LIBGUESTFS_HV=$(LIBGUESTFS_HV) LIBGUESTFS_BACKEND_SETTINGS=force_tcg ./cloudbuild
guestfish:
LD_LIBRARY_PATH=$(LIBRARY_PATH) LIBGUESTFS_PATH=$(LIBGUESTFS_PATH) LIBGUESTFS_HV=$(LIBGUESTFS_HV) LIBGUESTFS_BACKEND_SETTINGS=force_tcg PATH=guestfs/qemu-utils-deps/usr/bin:$(PATH) $(DEPS_DIR)/usr/bin/guestfish
deps:
scripts/download-deps.sh $(DOWNLOAD_DEPS_ARGS)
appliance-download:
cd guestfs && \
wget https://download.libguestfs.org/binaries/appliance/appliance-$(APPLIANCE_VERSION).tar.xz && \
tar Jxvf appliance-$(APPLIANCE_VERSION).tar.xz && \
rm appliance-$(APPLIANCE_VERSION).tar.xz
appliance: $(DEPS_DIR)usr/bin/supermin
mkdir -p /var/tmp/.guestfs-`id -u`
$(DEPS_DIR)usr/bin/supermin --build --verbose --if-newer --lock /var/tmp/.guestfs-`id -u`/lock --copy-kernel -f ext2 --host-cpu x86_64 $(DEPS_DIR)usr/lib/x86_64-linux-gnu/guestfs/supermin.d -o /var/tmp/.guestfs-`id -u`/appliance.d
mv /var/tmp/.guestfs-`id -u`/appliance.d guestfs/appliance
$(DEPS_DIR)usr/bin/supermin:
cd guestfs && apt download supermin && dpkg -x supermin_*.deb deps && rm supermin_*.deb
.PHONY: all run guestfish deps appliance-download appliance

@ -0,0 +1,11 @@
module git.csclub.uwaterloo.ca/cloud/cloudbuild
go 1.17
replace libguestfs.org/guestfs => ./guestfs
require (
github.com/rs/zerolog v1.26.1
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d
libguestfs.org/guestfs v0.0.0-00010101000000-000000000000
)

@ -0,0 +1,33 @@
github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.26.1 h1:/ihwxqH+4z8UxyI70wM1z9yCvkWcfz/a3mj48k/Zngc=
github.com/rs/zerolog v1.26.1/go.mod h1:/wSSJWX7lVrsOwlbyTRSOJvqRlc+WjWlfes+CiJ+tmc=
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d h1:20cMwl2fHAzkJMEA+8J4JgqBQcQGzbisXo31MIeenXI=
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

@ -0,0 +1,26 @@
package main
import (
"os"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"git.csclub.uwaterloo.ca/cloud/cloudbuild/pkg/cloudbuilder"
"git.csclub.uwaterloo.ca/cloud/cloudbuild/pkg/config"
)
func setupLogging() {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
}
func main() {
setupLogging()
cfg := config.New()
builder := cloudbuilder.New(cfg)
if err := builder.Start(); err != nil {
panic(err)
}
}

@ -0,0 +1,287 @@
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 = 10 * 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
}
func (c *CloudBuilder) getExistingTemplateVersions() map[string]string {
templatePattern := regexp.MustCompile("^CSC (?P<distro>[A-Za-z][A-Za-z ]+[A-Za-z]) (?P<version>\\d+(\\.\\d+)?)$")
mostRecentVersions := make(map[string]string)
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 {
mostRecentVersions[distro] = version
}
}
return mostRecentVersions
}
func (c *CloudBuilder) sendEmailNotification(
templateName string, vm *cloudstack.VirtualMachine, vmUser 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,
"vm": vm,
"vmUser": vmUser,
}
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(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) 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{
"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",
},
}
// The elements of this slice must be keys of the map above
// TODO use environment variable
distrosToCheck := []string{"AlmaLinux"}
existingVersions := c.getExistingTemplateVersions()
for _, distro := range distrosToCheck {
distroInfo := distrosInfo[distro]
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)
curVersion, ok := existingVersions[distro]
if ok && c.versionStringCompare(newVersion, curVersion) <= 0 {
log.Debug().
Str("distro", distro).
Str("version", curVersion).
Msg("Existing version is up to date, skipping")
continue
}
log.Info().Str("distro", distro).Msg("Existing template is out of date, creating a new one")
var template *cloudstack.Template
template, err = c.createNewTemplate(newVersion, codename, &distroInfo)
if err != nil {
return
}
vmName := strings.Join([]string{
strings.ReplaceAll(strings.ToLower(distro), " ", "-"),
strings.ReplaceAll(newVersion, ".", "-"),
"test",
}, "-")
var vm *cloudstack.VirtualMachine
vm, err = c.CreateVM(vmName, template.Id)
if err != nil {
return
}
if err = c.sendEmailNotification(template.Name, vm, distroInfo.user); err != nil {
return
}
}
return
}

@ -0,0 +1,33 @@
From: {{ .cfg.EmailSenderName }} <{{ .cfg.EmailSender }}>
To: {{ .cfg.EmailRecipient }}
Reply-To: {{ .cfg.EmailReplyTo }}
Subject: New VM Template: {{ .templateName }}
Date: {{ .date }}
Hello syscom,
This is an automated message from cloudbuild, the CSC VM template
builder.
A new VM template, {{ .templateName }}, has been uploaded to CloudStack.
It is not public. A new VM, {{ .vm.Name }}, has been created from this
template. You can SSH into this VM from biloba or chamomile by running
the following:
ssh -i /var/lib/cloudstack/management/.ssh/id_rsa {{ .vmUser }}@{{ (index .vm.Nic 0).IpAddress }}
Please login to the VM and verify that everything is working correctly.
Once you have done this, please login to CloudStack with the admin account
and perform the following:
1. Delete the VM (enable the "Expunge" option too).
2. Make the template public:
From the web page for the template, click the "Update Template Sharing"
circular button in the top right corner, and toggle the "Public"
and "Featured" sliders.
3. Delete the old template (if it is not being used by any VMs):
From the web page for the old template, click the "Zones" tab, then
press the red circular "Delete" button beside "{{ .cfg.CloudstackZoneName }}".
Sincerely,
cloudbuild

@ -0,0 +1,399 @@
// Package cloudstack provides utilities for interacting with a CloudStack
// management server.
package cloudstack
/*
* See https://cloudstack.apache.org/api/apidocs-4.16/ for API docs
*/
import (
"bytes"
"crypto/hmac"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"git.csclub.uwaterloo.ca/cloud/cloudbuild/pkg/config"
)
// A CloudstackClient interacts with a CloudStack management server via the
// REST API.
type CloudstackClient struct {
cfg *config.Config
cachedZoneId string
cachedServiceOfferingId string
}
// New returns a CloudStack client with the given config values.
func New(cfg *config.Config) *CloudstackClient {
return &CloudstackClient{cfg: cfg}
}
// CloudStack uses java.net.URLEncoder when calculating signatures:
// https://github.com/apache/cloudstack/blob/main/server/src/main/java/com/cloud/api/ApiServer.java
// Unfortunately java.net.URLEncoder transforms "~" into "%7E".
// This is a bug: https://bugs.openjdk.org/browse/JDK-8204530
// So, we need to match Java's behaviour so that the server does not
// reject our signature.
// (I noticed this problem when passing a `url` parameter to registerTemplate
// which contained a tilde.)
// TODO: file a GitHub issue in the cloudmonkey repo
func urlQueryEscape(value string) string {
return strings.ReplaceAll(url.QueryEscape(value), "~", "%7E")
}
// Adapted from https://github.com/apache/cloudstack-cloudmonkey/blob/main/cmd/network.go
func encodeRequestParams(params map[string]string) string {
if params == nil {
return ""
}
keys := make([]string, 0, len(params))
for key := range params {
keys = append(keys, key)
}
sort.Strings(keys)
var buf bytes.Buffer
for _, key := range keys {
value := params[key]
if buf.Len() > 0 {
buf.WriteByte('&')
}
buf.WriteString(key)
buf.WriteString("=")
buf.WriteString(urlQueryEscape(value))
}
return buf.String()
}
// See http://docs.cloudstack.apache.org/en/4.16.0.0/developersguide/dev.html#how-to-sign-an-api-call-with-python
func (client *CloudstackClient) createURL(params map[string]string) string {
cfg := client.cfg
params["apiKey"] = cfg.CloudstackApiKey
params["response"] = "json"
// adapted from https://github.com/apache/cloudstack-cloudmonkey/blob/main/cmd/network.go
encodedParams := encodeRequestParams(params)
mac := hmac.New(sha1.New, []byte(cfg.CloudstackSecretKey))
mac.Write([]byte(strings.Replace(strings.ToLower(encodedParams), "+", "%20", -1)))
signature := base64.StdEncoding.EncodeToString(mac.Sum(nil))
encodedParams += fmt.Sprintf("&signature=%s", urlQueryEscape(signature))
return fmt.Sprintf("%s?%s", cfg.CloudstackApiBaseUrl, encodedParams)
}
func getDeserializedResponse(url string, unmarshaledValue interface{}) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
panic(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
buf, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
if err = json.Unmarshal(buf, unmarshaledValue); err != nil {
panic(err)
}
}
type ErrorInfo struct {
ErrorCode int `json:"errorcode"`
ErrorText string `json:"errortext"`
}
func checkErrorInfo(data *ErrorInfo) {
if data.ErrorCode != 0 {
panic(fmt.Sprintf("Error %d: %s", data.ErrorCode, data.ErrorText))
}
}
type ListDomainsResponse struct {
ErrorInfo
Count int `json:"count"`
Domain []struct {
Name string `json:"name"`
Id string `json:"id"`
} `json:"domain"`
}
// GetDomainId returns the ID of the domain with the given name.
func (client *CloudstackClient) GetDomainId(domainName string) string {
url := client.createURL(map[string]string{
"command": "listDomains",
"details": "min",
"name": domainName,
})
responseWrapper := struct {
Response ListDomainsResponse `json:"listdomainsresponse"`
}{}
getDeserializedResponse(url, &responseWrapper)
data := &responseWrapper.Response
checkErrorInfo(&data.ErrorInfo)
if data.Count != 1 {
panic("there should be one domain found")
}
return data.Domain[0].Id
}
type Template struct {
Created string `json:"created"`
Domain string `json:"domain"`
DomainId string `json:"domainid"`
Id string `json:"id"`
Name string `json:"name"`
IsReady bool `json:"isready"`
IsFeatured bool `json:"isfeatured"`
IsPublic bool `json:"ispublic"`
Status string `json:"status"`
DownloadDetails []struct {
DownloadPercent string `json:"downloadPercent"`
DownloadState string `json:"downloadState"`
} `json:"downloaddetails"`
}
type ListTemplatesResponse struct {
ErrorInfo
Count int `json:"count"`
Template []Template `json:"template"`
}
func (client *CloudstackClient) listTemplates(templateID string) []Template {
params := map[string]string{
"command": "listTemplates",
"details": "min",
"templatefilter": "self",
}
if templateID != "" {
params["id"] = templateID
}
url := client.createURL(params)
responseWrapper := struct {
Response ListTemplatesResponse `json:"listtemplatesresponse"`
}{}
getDeserializedResponse(url, &responseWrapper)
data := &responseWrapper.Response
checkErrorInfo(&data.ErrorInfo)
return data.Template
}
func (client *CloudstackClient) ListTemplates() []Template {
return client.listTemplates("")
}
func (client *CloudstackClient) GetTemplate(templateID string) (*Template, error) {
templates := client.listTemplates(templateID)
if len(templates) == 0 {
return nil, errors.New("Template not found")
}
return &templates[0], nil
}
type ListZonesResponse struct {
ErrorInfo
Count int `json:"count"`
Zone []struct {
Id string `json:"id"`
Name string `json:"name"`
} `json:"zone"`
}
func (client *CloudstackClient) getZoneId() (string, error) {
if client.cachedZoneId != "" {
return client.cachedZoneId, nil
}
zoneName := client.cfg.CloudstackZoneName
url := client.createURL(map[string]string{
"command": "listZones",
"name": zoneName,
})
responseWrapper := struct {
Response ListZonesResponse `json:"listzonesresponse"`
}{}
getDeserializedResponse(url, &responseWrapper)
data := &responseWrapper.Response
checkErrorInfo(&data.ErrorInfo)
if data.Count != 1 {
return "", errors.New(fmt.Sprintf("Expected 1 zone for '%s'; got %d", zoneName, data.Count))
}
zoneId := data.Zone[0].Id
client.cachedZoneId = zoneId
return zoneId, nil
}
type ListServiceOfferingsResponse struct {
ErrorInfo
Count int `json:"count"`
ServiceOffering []struct {
Id string `json:"id"`
Name string `json:"name"`
} `json:"serviceoffering"`
}
func (client *CloudstackClient) getServiceOfferingId() (string, error) {
if client.cachedServiceOfferingId != "" {
return client.cachedServiceOfferingId, nil
}
serviceOfferingName := client.cfg.CloudstackServiceOfferingName
url := client.createURL(map[string]string{
"command": "listServiceOfferings",
"name": serviceOfferingName,
})
responseWrapper := struct {
Response ListServiceOfferingsResponse `json:"listserviceofferingsresponse"`
}{}
getDeserializedResponse(url, &responseWrapper)
data := &responseWrapper.Response
checkErrorInfo(&data.ErrorInfo)
if data.Count != 1 {
return "", errors.New(fmt.Sprintf("Expected 1 service offering for '%s'; got %d", serviceOfferingName, data.Count))
}
serviceOfferingId := data.ServiceOffering[0].Id
client.cachedServiceOfferingId = serviceOfferingId
return serviceOfferingId, nil
}
// Note: if an OS type you need isn't available in CloudStack, you can
// create a new one with addGuestOs
type OsType struct {
Id string `json:"id"`
Description string `json:"description"`
}
type ListOsTypesResponse struct {
ErrorInfo
Count int `json:"count"`
OsType []OsType `json:"ostype"`
}
func (client *CloudstackClient) GetOsTypeId(osDescription string) (string, error) {
url := client.createURL(map[string]string{
"command": "listOsTypes",
"description": osDescription,
})
responseWrapper := struct {
Response ListOsTypesResponse `json:"listostypesresponse"`
}{}
getDeserializedResponse(url, &responseWrapper)
data := &responseWrapper.Response
checkErrorInfo(&data.ErrorInfo)
if data.Count != 1 {
return "", errors.New(fmt.Sprintf("Expected 1 OS type for '%s'; got %d", osDescription, data.Count))
}
return data.OsType[0].Id, nil
}
type RegisterTemplateResponse struct {
ErrorInfo
Count int `json:"count"`
Template []Template `json:"template"`
}
func (client *CloudstackClient) RegisterTemplate(name string, downloadUrl string, osTypeId string) (*Template, error) {
zoneId, err := client.getZoneId()
if err != nil {
return nil, err
}
url := client.createURL(map[string]string{
"command": "registerTemplate",
"displaytext": name,
"format": "QCOW2",
"hypervisor": "KVM",
"name": name,
"url": downloadUrl,
"isextractable": "true",
"isfeatured": "false",
"ispublic": "false",
"ostypeid": osTypeId,
"requireshvm": "true",
"zoneid": zoneId,
})
responseWrapper := struct {
Response RegisterTemplateResponse `json:"registertemplateresponse"`
}{}
getDeserializedResponse(url, &responseWrapper)
data := &responseWrapper.Response
checkErrorInfo(&data.ErrorInfo)
return &data.Template[0], nil
}
type VirtualMachine struct {
Id string `json:"id"`
DisplayName string `json:"displayname"`
Name string `json:"name"`
State string `json:"state"`
Nic []struct {
Id string `json:"id"`
IpAddress string `json:"ipaddress"`
IsDefault bool `json:"isdefault"`
} `json:"nic"`
}
type DeployVirtualMachineResponse struct {
ErrorInfo
Id string `json:"id"`
JobId string `json:"jobid"`
}
func (client *CloudstackClient) DeployVirtualMachine(
name string, templateID string,
) (vmID string, err error) {
serviceOfferingId, err := client.getServiceOfferingId()
if err != nil {
return
}
zoneId, err := client.getZoneId()
if err != nil {
return
}
url := client.createURL(map[string]string{
"command": "deployVirtualMachine",
"serviceofferingid": serviceOfferingId,
"templateid": templateID,
"zoneid": zoneId,
"name": name,
"displayname": name,
"keypair": client.cfg.CloudstackKeypairName,
})
responseWrapper := struct {
Response DeployVirtualMachineResponse `json:"deployvirtualmachineresponse"`
}{}
getDeserializedResponse(url, &responseWrapper)
data := &responseWrapper.Response
checkErrorInfo(&data.ErrorInfo)
return data.Id, nil
}
type ListVirtualMachinesResponse struct {
ErrorInfo
Count int `json:"count"`
VirtualMachine []VirtualMachine `json:"virtualmachine"`
}
func (client *CloudstackClient) GetVirtualMachine(vmID string) (virtualMachine *VirtualMachine, err error) {
url := client.createURL(map[string]string{
"command": "listVirtualMachines",
"id": vmID,
})
responseWrapper := struct {
Response ListVirtualMachinesResponse `json:"listvirtualmachinesresponse"`
}{}
getDeserializedResponse(url, &responseWrapper)
data := &responseWrapper.Response
checkErrorInfo(&data.ErrorInfo)
virtualMachine = &data.VirtualMachine[0]
return
}

@ -0,0 +1,62 @@
// Package config provides utilities for reading and storing configuration
// values.
package config
import (
"os"
)
// A Config holds all of the configuration values needed for the program.
type Config struct {
CloudstackApiKey string
CloudstackSecretKey string
CloudstackApiBaseUrl string
CloudstackZoneName string
CloudstackServiceOfferingName string
CloudstackKeypairName string
UploadDirectory string
UploadBaseUrl string
MirrorHost string
EmailServer string
EmailSender string
EmailSenderName string
EmailRecipient string
EmailReplyTo string
}
// New returns a Config filled with values read from environment variables.
// It panics if a required environment variable is empty or not set.
func New() *Config {
cfg := &Config{
CloudstackApiKey: os.Getenv("CLOUDSTACK_API_KEY"),
CloudstackSecretKey: os.Getenv("CLOUDSTACK_SECRET_KEY"),
UploadDirectory: os.Getenv("UPLOAD_DIRECTORY"),
UploadBaseUrl: os.Getenv("UPLOAD_BASE_URL"),
}
if cfg.CloudstackApiKey == "" {
panic("CLOUDSTACK_API_KEY is empty or not set")
}
if cfg.CloudstackSecretKey == "" {
panic("CLOUDSTACK_SECRET_KEY is empty or not set")
}
if cfg.UploadDirectory == "" {
panic("UPLOAD_DIRECTORY is empty or not set")
}
if cfg.UploadBaseUrl == "" {
panic("UPLOAD_BASE_URL is empty or not set")
}
// These should never change
cfg.CloudstackApiBaseUrl = "https://cloud.csclub.uwaterloo.ca/client/api"
cfg.CloudstackZoneName = "Zone1"
cfg.CloudstackServiceOfferingName = "Small Instance"
cfg.CloudstackKeypairName = "management-keypair"
cfg.MirrorHost = "mirror.csclub.uwaterloo.ca"
cfg.EmailServer = "mail.csclub.uwaterloo.ca:25"
cfg.EmailSender = "cloudbuild@csclub.uwaterloo.ca"
cfg.EmailSenderName = "cloudbuild"
// TODO: change recipient to syscom
cfg.EmailRecipient = "merenber@csclub.uwaterloo.ca"
cfg.EmailReplyTo = "no-reply@csclub.uwaterloo.ca"
return cfg
}

@ -0,0 +1,139 @@
package distros
import (
"errors"
"fmt"
"math"
"net/http"
"regexp"
"strconv"
"github.com/rs/zerolog/log"
"golang.org/x/net/html"
"libguestfs.org/guestfs"
"git.csclub.uwaterloo.ca/cloud/cloudbuild/pkg/config"
)
// TODO: reduce code duplication with FedoraTemplateManager
type AlmaLinuxTemplateManager struct {
TemplateManager
}
// NewAlmaLinuxTemplateManager returns a AlmaLinuxTemplateManager with the given
// config values.
func NewAlmaLinuxTemplateManager(cfg *config.Config) *AlmaLinuxTemplateManager {
logger := log.With().Str("distro", "almalinux").Logger()
almaLinuxTemplateManager := AlmaLinuxTemplateManager{
TemplateManager{
cfg: cfg,
logger: &logger,
impl: nil,
},
}
almaLinuxTemplateManager.TemplateManager.impl = &almaLinuxTemplateManager
return &almaLinuxTemplateManager
}
var floatNumberSlashPattern *regexp.Regexp = regexp.MustCompile("^\\d+(\\.\\d)?/$")
func getMaxFloatVersionFromHtml(node *html.Node, maxVersion *float64) {
if isHyperlink(node) {
text := node.FirstChild.Data
if numberSlashPattern.FindString(text) != "" {
version, _ := strconv.ParseFloat(text[:len(text)-1], 64)
if version > *maxVersion {
*maxVersion = version
}
}
return
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
getMaxFloatVersionFromHtml(c, maxVersion)
}
}
func (mgr *AlmaLinuxTemplateManager) GetLatestVersion() (version string, codename string, err error) {
resp, err := http.Get("https://mirror.csclub.uwaterloo.ca/almalinux/")
if err != nil {
return
}
defer resp.Body.Close()
node, err := html.Parse(resp.Body)
if err != nil {
return
}
var floatVersion float64
getMaxFloatVersionFromHtml(node, &floatVersion)
if floatVersion == 0 {
err = errors.New("Could not determine latest AlmaLinux version from HTML")
} else {
version = strconv.FormatFloat(floatVersion, 'f', -1, 64)
// AlmaLinux doesn't have codenames, only numbered versions
codename = version
}
return
}
func (mgr *AlmaLinuxTemplateManager) DownloadTemplate(codename string) (path string, err error) {
// TODO: REMOVE THIS
//if true {
// return "AlmaLinux-9-GenericCloud-latest.x86_64.qcow2", nil
//}
version := codename
floatVersion, err := strconv.ParseFloat(version, 64)
if err != nil {
return
}
majorVersion := int(math.Floor(floatVersion))
filename := fmt.Sprintf("AlmaLinux-%d-GenericCloud-latest.x86_64.qcow2", majorVersion)
url := fmt.Sprintf("https://mirror.csclub.uwaterloo.ca/almalinux/%s/cloud/x86_64/images/%s", version, filename)
return mgr.DownloadTemplateGeneric(filename, url)
}
func (mgr *AlmaLinuxTemplateManager) GetIpv6ScriptsTemplateData() map[string]string {
return map[string]string{
"networkService": "NetworkManager.service",
"networkTarget": "network-online.target",
}
}
func (mgr *AlmaLinuxTemplateManager) addCloudInitSnippet(handle *guestfs.Guestfs) error {
path := "/etc/cloud/cloud.cfg.d/99_csclub.cfg"
mgr.logger.Debug().Msg("Writing to " + path)
return handle.Write(path, getResource("fedora-cloud-init"))
}
func (mgr *AlmaLinuxTemplateManager) HasSELinuxEnabled() bool {
return true
}
var almaLinuxYumRepoBaseUrlPattern *regexp.Regexp = regexp.MustCompile(
"^(?P<scheme>https?://)[A-Za-z0-9./-]+(?P<path>/almalinux/\\$releasever/[A-Za-z0-9./$-]+)$",
)
func (mgr *AlmaLinuxTemplateManager) transformAlmaLinuxYumRepoBaseUrl(url string) string {
submatches := almaLinuxYumRepoBaseUrlPattern.FindStringSubmatch(url)
if submatches != nil {
scheme, path := submatches[1], submatches[2]
url = scheme + mgr.cfg.MirrorHost + path
}
return url
}
func (mgr *AlmaLinuxTemplateManager) PerformDistroSpecificModifications(handle *guestfs.Guestfs) (err error) {
if err = mgr.setChronyOptions(handle, "/etc/chrony.conf"); err != nil {
return
}
if err = mgr.setNetworkManagerOptions(handle); err != nil {
return
}
if err = mgr.addCloudInitSnippet(handle); err != nil {
return
}
if err = mgr.replaceYumRepoMirrorUrls(handle, mgr.transformAlmaLinuxYumRepoBaseUrl); err != nil {
return
}
return
}

@ -0,0 +1,161 @@
package distros
import (
"errors"
"fmt"
"net/http"
"regexp"
"strconv"
"github.com/rs/zerolog/log"
"golang.org/x/net/html"
"libguestfs.org/guestfs"
"git.csclub.uwaterloo.ca/cloud/cloudbuild/pkg/config"
)
type FedoraTemplateManager struct {
TemplateManager
}
// NewFedoraTemplateManager returns a FedoraTemplateManager with the given
// config values.
func NewFedoraTemplateManager(cfg *config.Config) *FedoraTemplateManager {
logger := log.With().Str("distro", "fedora").Logger()
fedoraTemplateManager := FedoraTemplateManager{
TemplateManager{
cfg: cfg,
logger: &logger,
impl: nil,
},
}
fedoraTemplateManager.TemplateManager.impl = &fedoraTemplateManager
return &fedoraTemplateManager
}
var numberSlashPattern *regexp.Regexp = regexp.MustCompile("^\\d+/$")
func isHyperlink(node *html.Node) bool {
return node.Type == html.ElementNode && node.Data == "a" &&
node.FirstChild != nil && node.FirstChild.Type == html.TextNode
}
func getMatchingLinkFromHtml(node *html.Node, pattern *regexp.Regexp) string {
if isHyperlink(node) {
text := node.FirstChild.Data
return pattern.FindString(text)
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
result := getMatchingLinkFromHtml(c, pattern)
if result != "" {
return result
}
}
return ""
}
func getMaxVersionFromHtml(node *html.Node, maxVersion *int64) {
if isHyperlink(node) {
text := node.FirstChild.Data
if numberSlashPattern.FindString(text) != "" {
version, _ := strconv.ParseInt(text[:len(text)-1], 10, 64)
if version > *maxVersion {
*maxVersion = version
}
}
return
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
getMaxVersionFromHtml(c, maxVersion)
}
}
func (mgr *FedoraTemplateManager) GetLatestVersion() (version string, codename string, err error) {
resp, err := http.Get("https://mirror.csclub.uwaterloo.ca/fedora/linux/releases/")
if err != nil {
return
}
defer resp.Body.Close()
node, err := html.Parse(resp.Body)
if err != nil {
return
}
var intVersion int64
getMaxVersionFromHtml(node, &intVersion)
if intVersion == 0 {
err = errors.New("Could not determine latest Fedora version from HTML")
} else {
version = strconv.FormatInt(intVersion, 10)
// Fedora doesn't have codenames, only numbered versions
codename = version
}
return
}
func (mgr *FedoraTemplateManager) DownloadTemplate(codename string) (path string, err error) {
version := codename
pattern := regexp.MustCompile(fmt.Sprintf("^Fedora-Cloud-Base-%s-\\d+(\\.\\d+)?\\.x86_64\\.qcow2$", version))
imagesUrl := fmt.Sprintf("https://mirror.csclub.uwaterloo.ca/fedora/linux/releases/%s/Cloud/x86_64/images/", version)
resp, err := http.Get(imagesUrl)
if err != nil {
return
}
defer resp.Body.Close()
node, err := html.Parse(resp.Body)
if err != nil {
return
}
filename := getMatchingLinkFromHtml(node, pattern)
if filename == "" {
err = errors.New("Could not find hyperlink for Fedora Cloud image")
return
}
url := imagesUrl + filename
return mgr.DownloadTemplateGeneric(filename, url)
}
func (mgr *FedoraTemplateManager) GetIpv6ScriptsTemplateData() map[string]string {
return map[string]string{
"networkService": "NetworkManager.service",
"networkTarget": "network-online.target",
}
}
func (mgr *FedoraTemplateManager) addCloudInitSnippet(handle *guestfs.Guestfs) error {
path := "/etc/cloud/cloud.cfg.d/99_csclub.cfg"
mgr.logger.Debug().Msg("Writing to " + path)
return handle.Write(path, getResource("fedora-cloud-init"))
}
func (mgr *FedoraTemplateManager) HasSELinuxEnabled() bool {
return true
}
var fedoraYumRepoBaseUrlPattern *regexp.Regexp = regexp.MustCompile(
"^(?P<scheme>https?://)[A-Za-z0-9./-]+(?P<path>/fedora/linux/[A-Za-z0-9./$-]+)$",
)
func (mgr *FedoraTemplateManager) transformFedoraYumRepoBaseUrl(url string) string {
submatches := fedoraYumRepoBaseUrlPattern.FindStringSubmatch(url)
if submatches != nil {
scheme, path := submatches[1], submatches[2]
url = scheme + mgr.cfg.MirrorHost + path
}
return url
}
func (mgr *FedoraTemplateManager) PerformDistroSpecificModifications(handle *guestfs.Guestfs) (err error) {
if err = mgr.setChronyOptions(handle, "/etc/chrony.conf"); err != nil {
return
}
if err = mgr.setNetworkManagerOptions(handle); err != nil {
return
}
if err = mgr.addCloudInitSnippet(handle); err != nil {
return
}
if err = mgr.replaceYumRepoMirrorUrls(handle, mgr.transformFedoraYumRepoBaseUrl); err != nil {
return
}
return
}

@ -0,0 +1,22 @@
#!/bin/bash
set -ex
nmcli -f name -terse c | grep "^cloud-init e" | \
while read conn_name; do
iface=$(echo "$conn_name" | cut -d ' ' -f 2)
nmcli c modify "$conn_name" ipv6.method link-local
nmcli d reapply $iface
done
ip -6 -brief addr show dynamic | \
while read iface _ addrs; do
for addr in $addrs; do
ip addr del dev $iface $addr
done
done
ip -6 route show proto ra | \
while read line; do
ip route del $line
done

@ -0,0 +1,20 @@
#!/bin/bash
set -ex
INTERFACE=
IPV4_ADDRESS=
while read interface _ address; do
if ! echo $address | grep -q '^172\.19\.134\.'; then
continue
fi
INTERFACE=$interface
IPV4_ADDRESS=$address
break
done < <(ip -4 -brief addr show)
if [ -z "$INTERFACE" ]; then
echo "Could not find primary interface" >&2
exit 1
fi
NUM=$(echo $IPV4_ADDRESS | grep -oP '^172\.19\.134\.\K(\d+)')
IPV6_ADDRESS="2620:101:f000:4903::$NUM/64"
ip -6 addr add $IPV6_ADDRESS dev $INTERFACE
ip -6 route add default via 2620:101:f000:4903::1 dev $INTERFACE

@ -0,0 +1,8 @@
#server ntp.csclub.uwaterloo.ca
server 129.97.167.12
#server ntp.student.cs.uwaterloo.ca
server 129.97.167.4
#server ntp.cs.uwaterloo.ca
server 129.97.15.14
#server ntp.cscf.uwaterloo.ca
server 129.97.15.15

@ -0,0 +1 @@
manage_etc_hosts: true

@ -0,0 +1,9 @@
#!/bin/bash
set -ex
while read interface _; do
if [ $interface = lo ]; then
continue
fi
sysctl net.ipv6.conf.$interface.accept_ra=0
sysctl net.ipv6.conf.$interface.accept_dad=0
done < <(ip -brief link show)

@ -0,0 +1,12 @@
UNIVERSITY OF WATERLOO COMPUTER SCIENCE CLUB
Welcome to the CSC cloud! :)
This service was made possible thanks to donations from the Mathematics
Endowment Fund (MEF) and the Computer Science Computing Facility (CSCF).
Documentation:
https://docs.cloud.csclub.uwaterloo.ca
Machine usage agreement:
https://csclub.uwaterloo.ca/resources/machine-usage-agreement
Questions/concerns can be emailed to:
syscom@csclub.uwaterloo.ca

@ -0,0 +1,3 @@
[main]
dhcp = dhclient
dns = none

@ -0,0 +1,12 @@
search csclub.uwaterloo.ca uwaterloo.ca
options rotate timeout:1 attempts:1 ndots:2
# CSC Nameservers
nameserver 2620:101:f000:4901:c5c::4
nameserver 2620:101:f000:7300:c5c::20
nameserver 129.97.134.4
nameserver 129.97.18.20
# IST Anycast (fallback)
#nameserver 129.97.2.1
#nameserver 129.97.2.2)

@ -0,0 +1,2 @@
net.ipv6.conf.all.accept_ra = 0
net.ipv6.conf.default.accept_ra = 0

@ -0,0 +1,5 @@
[Time]
# ntp.csclub.uwaterloo.ca ntp.student.cs.uwaterloo.ca
NTP=129.97.167.12 129.97.167.4
# ntp.cs.uwaterloo.ca ntp.cscf.uwaterloo.ca
FallbackNTP=129.97.15.14 129.97.15.15

@ -0,0 +1,11 @@
network:
version: 2
ethernets:
id0:
match:
name: e*
dhcp4: true
accept-ra: false
apt_preserve_sources_list: true
manage_etc_hosts: true

@ -0,0 +1,526 @@
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 {
// 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 codename
// and returns the path to where it was downloaded
DownloadTemplate(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 {
// GetIpv6ScriptsTemplateData returns template data used by the
// IPv6 scripts. The required keys are "networkService" and
// "networkTarget"; the values should be a systemd service and target,
// respectively.
GetIpv6ScriptsTemplateData() map[string]string
// 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
}
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)