Initial commit
This commit is contained in:
commit
dcfd877bfd
15 changed files with 953 additions and 0 deletions
18
README.md
Normal file
18
README.md
Normal file
|
@ -0,0 +1,18 @@
|
|||
# mkvm
|
||||
|
||||
An opinionated cloud VM creation tool: create VMs on libvirt.
|
||||
|
||||
|
||||
```
|
||||
Usage:
|
||||
mkvm name [name [name]] [flags]
|
||||
|
||||
Flags:
|
||||
-c, --cpu int the number of vCPU cores to assign to the VM (default 2)
|
||||
-d, --disk int disk size (in GB) (default 25)
|
||||
-h, --help help for mkvm
|
||||
--image string URL of the image to download (default "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2")
|
||||
-m, --memory int amount of memory (in MB) to assign to the VM (default 1024)
|
||||
-p, --packages stringArray packages to install on the VM
|
||||
-s, --ssh-keys stringArray SSH key(s) authorzed to access the VM
|
||||
```
|
11
config/config.go
Normal file
11
config/config.go
Normal file
|
@ -0,0 +1,11 @@
|
|||
package config
|
||||
|
||||
type Config struct {
|
||||
DiskStoragePool string `default:"default"`
|
||||
Network string `default:"default"`
|
||||
}
|
||||
|
||||
var C = Config{
|
||||
DiskStoragePool: "default",
|
||||
Network: "default",
|
||||
}
|
23
go.mod
Normal file
23
go.mod
Normal file
|
@ -0,0 +1,23 @@
|
|||
module mkvm
|
||||
|
||||
go 1.22.7
|
||||
|
||||
replace entanglement.garden/common => /home/finn/src/codeberg.org/EntanglementGarden/common
|
||||
|
||||
require (
|
||||
entanglement.garden/common v0.0.0-20231208204916-4a8c20e806f1
|
||||
github.com/sirupsen/logrus v1.9.3
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
libvirt.org/go/libvirt v1.10009.0
|
||||
libvirt.org/go/libvirtxml v1.10009.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/stretchr/testify v1.9.0 // indirect
|
||||
golang.org/x/sys v0.13.0 // indirect
|
||||
)
|
45
go.sum
Normal file
45
go.sum
Normal file
|
@ -0,0 +1,45 @@
|
|||
entanglement.garden/common v0.0.0-20231208204916-4a8c20e806f1 h1:P1luzpLw0JYb1VutrcFdvh8AhyzZ3QyKgWQk1CZhquM=
|
||||
entanglement.garden/common v0.0.0-20231208204916-4a8c20e806f1/go.mod h1:LEJBK4jjAkrW+BWqE/EUnZ1oRj+UmJwPMPMEO8OdBds=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8=
|
||||
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
|
||||
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
|
||||
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
libvirt.org/go/libvirt v1.10009.0 h1:Lf3jktPJwrOF/lIb6fZN/TNUPhNVyS70wAk8lI2dGj8=
|
||||
libvirt.org/go/libvirt v1.10009.0/go.mod h1:1WiFE8EjZfq+FCVog+rvr1yatKbKZ9FaFMZgEqxEJqQ=
|
||||
libvirt.org/go/libvirtxml v1.10009.0 h1:y60vA65jOAZSJedRoil1s2myVIy0NOfz0E6zhmYiN2g=
|
||||
libvirt.org/go/libvirtxml v1.10009.0/go.mod h1:7Oq2BLDstLr/XtoQD8Fr3mfDNrzlI3utYKySXF2xkng=
|
97
http.go
Normal file
97
http.go
Normal file
|
@ -0,0 +1,97 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"entanglement.garden/common/cloudinit"
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
var (
|
||||
httpStaticContent = map[string][]byte{}
|
||||
)
|
||||
|
||||
func runHTTPServer(bind string) {
|
||||
http.ListenAndServe(bind, httphandler{})
|
||||
}
|
||||
|
||||
type httphandler struct{}
|
||||
|
||||
func (httphandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
log := logrus.WithFields(logrus.Fields{
|
||||
"method": r.Method,
|
||||
"path": r.URL,
|
||||
"remote_addr": r.RemoteAddr,
|
||||
})
|
||||
|
||||
log.Debug("handling HTTP request")
|
||||
|
||||
switch r.Method {
|
||||
case http.MethodGet:
|
||||
resp, ok := httpStaticContent[r.URL.Path]
|
||||
if !ok {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if resp == nil {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
_, err := w.Write(resp)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error serving HTTP response")
|
||||
}
|
||||
|
||||
case http.MethodPost:
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
log.WithError(err).Error("error reading body")
|
||||
}
|
||||
r.Body.Close()
|
||||
|
||||
log.Debug(string(body))
|
||||
|
||||
log.Info("VM booted")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
wg.Done()
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte("not found"))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func buildCloudConfig(i int, name string, phoneHomeURL string) error {
|
||||
userdata, err := yaml.Marshal(cloudinit.UserData{
|
||||
Packages: argPackages,
|
||||
SSHAuthorizedKeys: argSSHKeys,
|
||||
PhoneHome: cloudinit.PhoneHome{
|
||||
URL: phoneHomeURL,
|
||||
Post: []string{"pub_key_dsa"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
httpStaticContent[fmt.Sprintf("/%d/user-data", i)] = append([]byte("#cloud-config\n"), userdata...)
|
||||
|
||||
metadata, err := yaml.Marshal(cloudinit.MetaData{
|
||||
InstanceID: name,
|
||||
LocalHostname: name,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
httpStaticContent[fmt.Sprintf("/%d/meta-data", i)] = metadata
|
||||
|
||||
httpStaticContent[fmt.Sprintf("/%d/vendor-data", i)] = nil
|
||||
|
||||
return nil
|
||||
}
|
14
libvirtx/connect.go
Normal file
14
libvirtx/connect.go
Normal file
|
@ -0,0 +1,14 @@
|
|||
// Copyright Entanglement Garden Developers
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package libvirtx
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"libvirt.org/go/libvirt"
|
||||
)
|
||||
|
||||
func New() (*libvirt.Connect, error) {
|
||||
logrus.Debug("connecting to libvirt")
|
||||
return libvirt.NewConnect("qemu:///system")
|
||||
}
|
29
libvirtx/errors.go
Normal file
29
libvirtx/errors.go
Normal file
|
@ -0,0 +1,29 @@
|
|||
// Copyright Entanglement Garden Developers
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package libvirtx
|
||||
|
||||
import (
|
||||
"github.com/sirupsen/logrus"
|
||||
"libvirt.org/go/libvirt"
|
||||
)
|
||||
|
||||
func IsErrorCode(err error, number libvirt.ErrorNumber) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
lerr, ok := err.(libvirt.Error)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return lerr.Code == number
|
||||
}
|
||||
|
||||
func Free(freeable interface{ Free() error }) {
|
||||
err := freeable.Free()
|
||||
if err != nil {
|
||||
logrus.Error("uncaught error freeing libvirt resource: ", err)
|
||||
}
|
||||
}
|
20
libvirtx/stream.go
Normal file
20
libvirtx/stream.go
Normal file
|
@ -0,0 +1,20 @@
|
|||
// Copyright Entanglement Garden Developers
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package libvirtx
|
||||
|
||||
import (
|
||||
"libvirt.org/go/libvirt"
|
||||
)
|
||||
|
||||
type Stream struct {
|
||||
*libvirt.Stream
|
||||
}
|
||||
|
||||
func (s Stream) Read(buf []byte) (int, error) {
|
||||
return s.Stream.Recv(buf)
|
||||
}
|
||||
|
||||
func (s Stream) Close() error {
|
||||
return s.Stream.Free()
|
||||
}
|
84
main.go
Normal file
84
main.go
Normal file
|
@ -0,0 +1,84 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"mkvm/libvirtx"
|
||||
)
|
||||
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
rootCmd = cobra.Command{
|
||||
Use: "mkvm name [name [name]]",
|
||||
Short: "create virtual machine(s) via libvirt",
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
logrus.SetLevel(logrus.DebugLevel)
|
||||
|
||||
conn, err := libvirtx.New()
|
||||
if err != nil {
|
||||
logrus.WithError(err).Fatal("error connecting to libvirt")
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
network, err := getNetwork(conn)
|
||||
if err != nil {
|
||||
logrus.WithError(err).Fatal("error finding requested network")
|
||||
}
|
||||
|
||||
serverBindIP := network.IPs[0].Address
|
||||
port := rand.Intn(65535-1025) + 1025
|
||||
serverBind := fmt.Sprintf("%s:%d", serverBindIP, port)
|
||||
|
||||
go runHTTPServer(serverBind)
|
||||
|
||||
for i, name := range args {
|
||||
metadataURL := fmt.Sprintf("http://%s/%d", serverBind, i)
|
||||
if err := buildCloudConfig(i, name, metadataURL); err != nil {
|
||||
logrus.WithError(err).WithField("vm", name).Error("error building cloudconfig for vm")
|
||||
continue
|
||||
}
|
||||
|
||||
wg.Add(1)
|
||||
if err := mkvm(conn, metadataURL, name); err != nil {
|
||||
logrus.WithError(err).Error("unexpected error building VM")
|
||||
wg.Done()
|
||||
}
|
||||
}
|
||||
|
||||
logrus.Info("waiting for VM(s) to finish provisioning")
|
||||
wg.Wait()
|
||||
},
|
||||
}
|
||||
|
||||
argMemoryMB int
|
||||
argCPUs int
|
||||
argImage string
|
||||
argDiskSizeGB int
|
||||
|
||||
// cloudinit args
|
||||
argSSHKeys []string
|
||||
argPackages []string
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := rootCmd.Execute(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.Flags().IntVarP(&argMemoryMB, "memory", "m", 1024, "amount of memory (in MB) to assign to the VM")
|
||||
rootCmd.Flags().IntVarP(&argCPUs, "cpu", "c", 2, "the number of vCPU cores to assign to the VM")
|
||||
rootCmd.Flags().StringVarP(&argImage, "image", "", "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2", "URL of the image to download")
|
||||
rootCmd.Flags().IntVarP(&argDiskSizeGB, "disk", "d", 25, "disk size (in GB)")
|
||||
|
||||
rootCmd.Flags().StringArrayVarP(&argSSHKeys, "ssh-keys", "s", nil, "SSH key(s) authorzed to access the VM")
|
||||
rootCmd.Flags().StringArrayVarP(&argPackages, "packages", "p", nil, "packages to install on the VM")
|
||||
}
|
112
mkvm.go
Normal file
112
mkvm.go
Normal file
|
@ -0,0 +1,112 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"libvirt.org/go/libvirt"
|
||||
"libvirt.org/go/libvirtxml"
|
||||
|
||||
"mkvm/config"
|
||||
"mkvm/volumes"
|
||||
"mkvm/volumes/pools"
|
||||
)
|
||||
|
||||
func mkvm(conn *libvirt.Connect, metadataURL string, name string) error {
|
||||
logger := logrus.WithField("vm", name)
|
||||
logger.Debug("creating vm")
|
||||
|
||||
pool, err := pools.GetPool(conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = volumes.Create(conn, pool, argDiskSizeGB, argImage, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
disks := []libvirtxml.DomainDisk{pool.GetDomainDiskXML(name)}
|
||||
|
||||
smbios := map[int]map[string]string{
|
||||
1: {"serial": fmt.Sprintf("ds=nocloud-net;s=%s/", metadataURL)},
|
||||
}
|
||||
|
||||
qemuArgs := []libvirtxml.DomainQEMUCommandlineArg{}
|
||||
for smbiosType, values := range smbios {
|
||||
arg := libvirtxml.DomainQEMUCommandlineArg{
|
||||
Value: fmt.Sprintf("type=%d", smbiosType),
|
||||
}
|
||||
for key, value := range values {
|
||||
arg.Value = fmt.Sprintf("%s,%s=%s", arg.Value, key, value)
|
||||
}
|
||||
qemuArgs = append(qemuArgs, libvirtxml.DomainQEMUCommandlineArg{Value: "-smbios"}, arg)
|
||||
}
|
||||
|
||||
interfaces := []libvirtxml.DomainInterface{
|
||||
{
|
||||
Model: &libvirtxml.DomainInterfaceModel{Type: "virtio"},
|
||||
// MAC: &libvirtxml.DomainInterfaceMAC{Address: nic.Mac},
|
||||
Source: &libvirtxml.DomainInterfaceSource{
|
||||
Network: &libvirtxml.DomainInterfaceSourceNetwork{Network: config.C.Network},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create the domain
|
||||
domainXML := &libvirtxml.Domain{
|
||||
Type: "kvm",
|
||||
Name: name,
|
||||
Memory: &libvirtxml.DomainMemory{Value: uint(argMemoryMB), Unit: "MiB"},
|
||||
VCPU: &libvirtxml.DomainVCPU{Value: uint(argCPUs)},
|
||||
OS: &libvirtxml.DomainOS{
|
||||
Type: &libvirtxml.DomainOSType{Arch: "x86_64", Type: "hvm"},
|
||||
BootDevices: []libvirtxml.DomainBootDevice{{Dev: "hd"}},
|
||||
},
|
||||
Features: &libvirtxml.DomainFeatureList{
|
||||
ACPI: &libvirtxml.DomainFeature{},
|
||||
APIC: &libvirtxml.DomainFeatureAPIC{},
|
||||
VMPort: &libvirtxml.DomainFeatureState{State: "off"},
|
||||
},
|
||||
CPU: &libvirtxml.DomainCPU{Mode: "host-model"},
|
||||
Devices: &libvirtxml.DomainDeviceList{
|
||||
Emulator: "/usr/bin/kvm",
|
||||
Disks: disks,
|
||||
Channels: []libvirtxml.DomainChannel{
|
||||
{
|
||||
Source: &libvirtxml.DomainChardevSource{
|
||||
UNIX: &libvirtxml.DomainChardevSourceUNIX{Path: "/var/lib/libvirt/qemu/f16x86_64.agent", Mode: "bind"},
|
||||
},
|
||||
Target: &libvirtxml.DomainChannelTarget{
|
||||
VirtIO: &libvirtxml.DomainChannelTargetVirtIO{Name: "org.qemu.guest_agent.0"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Consoles: []libvirtxml.DomainConsole{{Target: &libvirtxml.DomainConsoleTarget{}}},
|
||||
Serials: []libvirtxml.DomainSerial{{}},
|
||||
Interfaces: interfaces,
|
||||
},
|
||||
QEMUCommandline: &libvirtxml.DomainQEMUCommandline{Args: qemuArgs},
|
||||
}
|
||||
|
||||
domainXMLString, err := domainXML.Marshal()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Debug("defining domain from xml")
|
||||
domain, err := conn.DomainDefineXML(domainXMLString)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error defining domain from xml description: %v", err)
|
||||
}
|
||||
|
||||
logger.Debug("booting domain")
|
||||
err = domain.Create()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating domain: %v", err)
|
||||
}
|
||||
|
||||
logger.Debug("domain booted")
|
||||
|
||||
return nil
|
||||
}
|
30
networks.go
Normal file
30
networks.go
Normal file
|
@ -0,0 +1,30 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"mkvm/config"
|
||||
|
||||
"libvirt.org/go/libvirt"
|
||||
"libvirt.org/go/libvirtxml"
|
||||
)
|
||||
|
||||
func getNetwork(conn *libvirt.Connect) (libvirtxml.Network, error) {
|
||||
var net libvirtxml.Network
|
||||
|
||||
libvirtnet, err := conn.LookupNetworkByName(config.C.Network)
|
||||
if err != nil {
|
||||
return net, err
|
||||
}
|
||||
|
||||
xmlstr, err := libvirtnet.GetXMLDesc(0)
|
||||
if err != nil {
|
||||
return net, err
|
||||
}
|
||||
|
||||
if err := xml.Unmarshal([]byte(xmlstr), &net); err != nil {
|
||||
return net, err
|
||||
}
|
||||
|
||||
return net, nil
|
||||
|
||||
}
|
94
volumes/pools/generic.go
Normal file
94
volumes/pools/generic.go
Normal file
|
@ -0,0 +1,94 @@
|
|||
package pools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"libvirt.org/go/libvirt"
|
||||
"libvirt.org/go/libvirtxml"
|
||||
)
|
||||
|
||||
const (
|
||||
StoragePoolTypeGeneric StoragePoolType = "generic"
|
||||
ImageFormatRaw ImageFormat = "raw"
|
||||
)
|
||||
|
||||
// GenericPool is a StoragePool of most pool types other than dir (lvm, gluster, etc)
|
||||
type GenericPool struct {
|
||||
pool *libvirt.StoragePool
|
||||
name string
|
||||
}
|
||||
|
||||
func (p GenericPool) Type() StoragePoolType {
|
||||
return StoragePoolTypeGeneric
|
||||
}
|
||||
|
||||
func (p GenericPool) ImageFormat() ImageFormat {
|
||||
return ImageFormatRaw
|
||||
}
|
||||
|
||||
func (p GenericPool) CreateVolume(name string, sizeGB uint64) (*libvirt.StorageVol, error) {
|
||||
volumeXML := &libvirtxml.StorageVolume{
|
||||
Name: p.GetVolumeName(name),
|
||||
Capacity: &libvirtxml.StorageVolumeSize{
|
||||
Unit: "GB",
|
||||
Value: sizeGB,
|
||||
},
|
||||
}
|
||||
|
||||
xmlstr, err := volumeXML.Marshal()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p.pool.StorageVolCreateXML(xmlstr, 0)
|
||||
}
|
||||
|
||||
func (p GenericPool) DeleteVolume(name string) error {
|
||||
vol, err := p.pool.LookupStorageVolByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return vol.Delete(libvirt.STORAGE_VOL_DELETE_NORMAL)
|
||||
}
|
||||
|
||||
func (p GenericPool) GetVolumeName(name string) string {
|
||||
return fmt.Sprintf("rhyzome-%s", name)
|
||||
}
|
||||
|
||||
func (p GenericPool) ResizeVolume(name string, newDiskSizeGB uint64) error {
|
||||
vol, err := p.pool.LookupStorageVolByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return vol.Resize(newDiskSizeGB*1024*1024*1024, 0)
|
||||
}
|
||||
|
||||
func (p GenericPool) GetDomainDiskXML(name string) libvirtxml.DomainDisk {
|
||||
return libvirtxml.DomainDisk{
|
||||
Device: "disk",
|
||||
Driver: &libvirtxml.DomainDiskDriver{Name: "qemu", Type: "raw"},
|
||||
Source: &libvirtxml.DomainDiskSource{
|
||||
Volume: &libvirtxml.DomainDiskSourceVolume{Pool: p.name, Volume: p.GetVolumeName(name)},
|
||||
},
|
||||
Target: &libvirtxml.DomainDiskTarget{Dev: "vda"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p GenericPool) LookupVolume(name string) (*libvirt.StorageVol, error) {
|
||||
return p.pool.LookupStorageVolByName(p.GetVolumeName(name))
|
||||
}
|
||||
|
||||
func (p GenericPool) Free() error {
|
||||
return p.pool.Free()
|
||||
}
|
||||
|
||||
// NewGenericPool creates a wrapper for a generic pool
|
||||
func NewGenericPool(pool *libvirt.StoragePool) (GenericPool, error) {
|
||||
name, err := pool.GetName()
|
||||
if err != nil {
|
||||
return GenericPool{}, err
|
||||
}
|
||||
|
||||
return GenericPool{pool: pool, name: name}, nil
|
||||
}
|
108
volumes/pools/qcow2.go
Normal file
108
volumes/pools/qcow2.go
Normal file
|
@ -0,0 +1,108 @@
|
|||
// Copyright Entanglement Garden Developers
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package pools
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"libvirt.org/go/libvirt"
|
||||
"libvirt.org/go/libvirtxml"
|
||||
)
|
||||
|
||||
const (
|
||||
StoragePoolTypeDir StoragePoolType = "dir"
|
||||
ImageFormatQcow2 ImageFormat = "qcow2"
|
||||
)
|
||||
|
||||
// DirPool is a StoragePool of type "dir"
|
||||
type DirPool struct {
|
||||
pool *libvirt.StoragePool
|
||||
name string
|
||||
}
|
||||
|
||||
func (p DirPool) Type() StoragePoolType {
|
||||
return StoragePoolTypeDir
|
||||
}
|
||||
|
||||
func (p DirPool) ImageFormat() ImageFormat {
|
||||
return ImageFormatQcow2
|
||||
}
|
||||
|
||||
func (p DirPool) CreateVolume(name string, sizeGB uint64) (*libvirt.StorageVol, error) {
|
||||
volumeXML := &libvirtxml.StorageVolume{
|
||||
Name: p.GetVolumeName(name),
|
||||
Capacity: &libvirtxml.StorageVolumeSize{
|
||||
Unit: "GB",
|
||||
Value: sizeGB,
|
||||
},
|
||||
Target: &libvirtxml.StorageVolumeTarget{
|
||||
Format: &libvirtxml.StorageVolumeTargetFormat{Type: "qcow2"},
|
||||
},
|
||||
}
|
||||
|
||||
xmlstr, err := volumeXML.Marshal()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return p.pool.StorageVolCreateXML(xmlstr, 0)
|
||||
}
|
||||
|
||||
func (p DirPool) DeleteVolume(name string) error {
|
||||
vol, err := p.lookupVolumeByName(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return vol.Delete(libvirt.STORAGE_VOL_DELETE_NORMAL)
|
||||
}
|
||||
|
||||
func (p DirPool) GetVolumeName(name string) string {
|
||||
return fmt.Sprintf("%s.qcow2", name)
|
||||
}
|
||||
|
||||
func (p DirPool) ResizeVolume(name string, newDiskSizeGB uint64) error {
|
||||
vol, err := p.lookupVolumeByName(p.GetVolumeName(name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return vol.Resize((newDiskSizeGB*1000*1000*1000)+512, 0)
|
||||
}
|
||||
|
||||
func (p DirPool) lookupVolumeByName(name string) (*libvirt.StorageVol, error) {
|
||||
return p.pool.LookupStorageVolByName(name)
|
||||
}
|
||||
|
||||
func (p DirPool) GetDomainDiskXML(name string) libvirtxml.DomainDisk {
|
||||
return libvirtxml.DomainDisk{
|
||||
Device: "disk",
|
||||
Driver: &libvirtxml.DomainDiskDriver{Name: "qemu", Type: "qcow2"},
|
||||
Source: &libvirtxml.DomainDiskSource{
|
||||
Volume: &libvirtxml.DomainDiskSourceVolume{Pool: p.name, Volume: p.GetVolumeName(name)},
|
||||
},
|
||||
Target: &libvirtxml.DomainDiskTarget{Dev: "vda"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p DirPool) LookupVolume(name string) (*libvirt.StorageVol, error) {
|
||||
return p.pool.LookupStorageVolByName(p.GetVolumeName(name))
|
||||
}
|
||||
|
||||
func (p DirPool) Free() error {
|
||||
return p.pool.Free()
|
||||
}
|
||||
|
||||
// NewDirPool creates a storage pool of type dir
|
||||
func NewDirPool(pool *libvirt.StoragePool) (StoragePool, error) {
|
||||
name, err := pool.GetName()
|
||||
if err != nil {
|
||||
return DirPool{}, err
|
||||
}
|
||||
|
||||
return DirPool{pool: pool, name: name}, nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
drivers["dir"] = NewDirPool
|
||||
}
|
55
volumes/pools/storage.go
Normal file
55
volumes/pools/storage.go
Normal file
|
@ -0,0 +1,55 @@
|
|||
// Copyright Entanglement Garden Developers
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
package pools
|
||||
|
||||
import (
|
||||
"libvirt.org/go/libvirt"
|
||||
"libvirt.org/go/libvirtxml"
|
||||
|
||||
"mkvm/config"
|
||||
)
|
||||
|
||||
type StoragePoolType string
|
||||
type ImageFormat string
|
||||
|
||||
// StoragePool is an interface for the functionality around a type of libvirt.StoragePool
|
||||
type StoragePool interface {
|
||||
CreateVolume(string, uint64) (*libvirt.StorageVol, error)
|
||||
DeleteVolume(string) error
|
||||
GetVolumeName(string) string
|
||||
ResizeVolume(string, uint64) error
|
||||
GetDomainDiskXML(string) libvirtxml.DomainDisk
|
||||
LookupVolume(string) (*libvirt.StorageVol, error)
|
||||
Type() StoragePoolType
|
||||
ImageFormat() ImageFormat
|
||||
Free() error
|
||||
}
|
||||
|
||||
var drivers = map[string]func(*libvirt.StoragePool) (StoragePool, error){}
|
||||
|
||||
// GetPool retrieves the configured Storage Pool from libvirt
|
||||
func GetPool(conn *libvirt.Connect) (StoragePool, error) {
|
||||
pool, err := conn.LookupStoragePoolByName(config.C.DiskStoragePool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
xmldescription, err := pool.GetXMLDesc(0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
p := libvirtxml.StoragePool{}
|
||||
err = p.Unmarshal(xmldescription)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
driver, ok := drivers[p.Type]
|
||||
if !ok {
|
||||
return NewGenericPool(pool)
|
||||
}
|
||||
|
||||
return driver(pool)
|
||||
}
|
213
volumes/volumes.go
Normal file
213
volumes/volumes.go
Normal file
|
@ -0,0 +1,213 @@
|
|||
package volumes
|
||||
|
||||
import (
|
||||
"compress/bzip2"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"github.com/xi2/xz"
|
||||
"libvirt.org/go/libvirt"
|
||||
|
||||
"mkvm/libvirtx"
|
||||
"mkvm/volumes/pools"
|
||||
)
|
||||
|
||||
func Create(conn *libvirt.Connect, pool pools.StoragePool, sizeGB int, imageURL string, name string) error {
|
||||
volume, err := pool.CreateVolume(name, uint64(sizeGB))
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating volume: %v", err)
|
||||
}
|
||||
defer libvirtx.Free(volume)
|
||||
|
||||
var reader io.Reader
|
||||
var contentLength int64
|
||||
start := time.Now()
|
||||
|
||||
parsedImageURL, err := url.Parse(imageURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing URL %s: %v", imageURL, err)
|
||||
}
|
||||
|
||||
// parse everything after the # in the URL as a query, so chain arguments with &
|
||||
fragmentQuery, err := url.ParseQuery(parsedImageURL.Fragment)
|
||||
if err != nil && parsedImageURL.Fragment != "" {
|
||||
return err
|
||||
}
|
||||
|
||||
var actualHash hash.Hash
|
||||
var expectedHash string
|
||||
|
||||
// this is fully untested, but the idea is something like:
|
||||
// https://example.whatever/img.qcow2#hash=sha256:e8c7c3c983718ebc78d8738f562d55bfa77c4cf6f08241d246861d5ea9eb9cd2
|
||||
// sha256 and sha512 supported
|
||||
fragmenthash := strings.SplitN(fragmentQuery.Get("hash"), ":", 2)
|
||||
if len(fragmenthash) == 2 {
|
||||
switch fragmenthash[0] {
|
||||
case "sha256":
|
||||
actualHash = sha256.New()
|
||||
case "sha512":
|
||||
actualHash = sha512.New()
|
||||
default:
|
||||
return fmt.Errorf("unknown hash format: %s", fragmenthash[0])
|
||||
}
|
||||
expectedHash = fragmenthash[1]
|
||||
} else if strings.HasPrefix(imageURL, "https://") {
|
||||
logrus.Warnf("downloading image without verifying the hash from %s", imageURL)
|
||||
}
|
||||
|
||||
if actualHash == nil {
|
||||
actualHash = sha256.New()
|
||||
}
|
||||
|
||||
if parsedImageURL.Scheme == "https" || parsedImageURL.Scheme == "http" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, imageURL, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error building request to %s: %v", imageURL, err)
|
||||
}
|
||||
|
||||
req = req.WithContext(ctx)
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
logrus.Warn("error fetching image: ", resp.Status)
|
||||
return fmt.Errorf("error fetching base image: %s", resp.Status)
|
||||
}
|
||||
|
||||
reader = resp.Body
|
||||
contentLength = resp.ContentLength
|
||||
} else if parsedImageURL.Scheme == "" {
|
||||
logrus.Debugf("reading local file %s for root volume", parsedImageURL.Path)
|
||||
|
||||
localfile, err := os.Open(parsedImageURL.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer localfile.Close()
|
||||
|
||||
stat, err := localfile.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
reader = localfile
|
||||
contentLength = stat.Size()
|
||||
} else {
|
||||
return fmt.Errorf("unsupported image URL scheme %s", parsedImageURL.Scheme)
|
||||
}
|
||||
|
||||
if actualHash != nil {
|
||||
reader = io.TeeReader(reader, actualHash)
|
||||
}
|
||||
|
||||
// check for known compression suffixes and decompress
|
||||
// can also be enabled by adding to the URL fragment:
|
||||
// https://example.whatever/img.qcow2#compression=xz
|
||||
fragmentCompression := fragmentQuery.Get("compression")
|
||||
if strings.HasSuffix(parsedImageURL.Path, ".xz") || fragmentCompression == "xz" {
|
||||
logrus.Debug("image is xz compressed, decompressing")
|
||||
reader, err = xz.NewReader(reader, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if strings.HasSuffix(parsedImageURL.Path, ".gz") || fragmentCompression == "gzip" {
|
||||
logrus.Debug("image is gz compressed, decompressing")
|
||||
reader, err = gzip.NewReader(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else if strings.HasSuffix(parsedImageURL.Path, ".bz2") || fragmentCompression == "bzip2" {
|
||||
logrus.Debug("image is bzip2 compressed, decompressing")
|
||||
reader = bzip2.NewReader(reader)
|
||||
}
|
||||
|
||||
stream, err := conn.NewStream(0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer libvirtx.Free(stream)
|
||||
|
||||
err = volume.Upload(stream, 0, 0, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logrus.Debug("uploading to volume")
|
||||
totalRead := 0
|
||||
nextLog := time.Now()
|
||||
err = stream.SendAll(func(s *libvirt.Stream, i int) ([]byte, error) {
|
||||
out := []byte{}
|
||||
|
||||
for len(out) < i {
|
||||
buf := make([]byte, i-len(out))
|
||||
// fill the buffer of i bytes from the reader
|
||||
read, err := reader.Read(buf)
|
||||
if read > 0 {
|
||||
out = append(out, buf[:read]...)
|
||||
totalRead += read
|
||||
}
|
||||
if (contentLength > 0 && time.Until(nextLog) <= 0) || (int64(totalRead) == contentLength && read > 0) {
|
||||
logrus.Debug("transfer progress: ", totalRead, "/", contentLength, " (", int((float64(totalRead)/float64(contentLength))*100), "%) ", time.Since(start).Round(time.Millisecond))
|
||||
nextLog = time.Now().Add(time.Second * 5)
|
||||
}
|
||||
if err == io.EOF {
|
||||
if read > 0 {
|
||||
// partial read before hitting EOF, return buffer
|
||||
return out, nil
|
||||
}
|
||||
// Go's io interface raise an io.EOF error to indicate the end of the stream,
|
||||
// but libvirt indicates EOF with a zero-length response
|
||||
return make([]byte, 0), nil
|
||||
}
|
||||
if err != nil {
|
||||
logrus.Println("Error while reading buffer", err.Error())
|
||||
return out, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hashstr := fmt.Sprintf("%x", actualHash.Sum(nil))
|
||||
|
||||
if expectedHash != "" {
|
||||
if hashstr != expectedHash {
|
||||
return fmt.Errorf("got bad hash when downloading %s: got %s expected %s", imageURL, hashstr, expectedHash)
|
||||
}
|
||||
logrus.Debug("hash of downloaded image matched expected")
|
||||
} else {
|
||||
logrus.Infof("downloaded %s (hash: %s)", imageURL, hashstr)
|
||||
}
|
||||
|
||||
// qcow2 quirk: because we download a qcow2 file instead of the raw file, the server-provided metadata about the virtual
|
||||
// volume is left in tact, which sets the virtual disk size to the size the server wants it to be, ignoring our requested
|
||||
// size. To work around this, we resize the virtual disk size before first boot.
|
||||
if pool.Type() == pools.StoragePoolTypeDir {
|
||||
if err := pool.ResizeVolume(name, uint64(sizeGB)); err != nil {
|
||||
logrus.WithField("volume", name).Error("error resizing qcow2 volume after download")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Loading…
Add table
Reference in a new issue