Local VM image building

This commit is contained in:
AlexSSD7 2023-08-30 12:39:38 +01:00
commit e7605dc289
13 changed files with 359 additions and 239 deletions

30
cmd/build.go Normal file
View file

@ -0,0 +1,30 @@
package cmd
import (
"log/slog"
"os"
"github.com/spf13/cobra"
)
var buildCmd = &cobra.Command{
Use: "build",
Short: "Build (set up) a VM image for local use. This needs to be run after the initial installation.",
Run: func(cmd *cobra.Command, args []string) {
store := createStore()
err := store.BuildVMImageWithInterruptHandler(vmDebugFlag, buildOverwriteFlag)
if err != nil {
slog.Error("Failed to build VM image", "error", err.Error())
os.Exit(1)
}
slog.Info("VM image built successfully", "path", store.GetVMImagePath())
},
}
var buildOverwriteFlag bool
func init() {
buildCmd.Flags().BoolVar(&buildOverwriteFlag, "overwrite", false, "Specifies whether the VM image should be overwritten with the build.")
}

View file

@ -12,11 +12,12 @@ import (
var cleanCmd = &cobra.Command{ var cleanCmd = &cobra.Command{
Use: "clean", Use: "clean",
Short: "Remove all downloaded VM images.", Short: "Remove the Linsk data directory.",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
store := createStore() store := createStore()
fmt.Fprintf(os.Stderr, "Will delete all VM images in the data directory. Proceed? (y/n) > ") rmPath := store.DataDirPath()
fmt.Fprintf(os.Stderr, "Will permanently remove '"+rmPath+"'. Proceed? (y/n) > ")
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
answer, err := reader.ReadBytes('\n') answer, err := reader.ReadBytes('\n')
@ -30,12 +31,12 @@ var cleanCmd = &cobra.Command{
os.Exit(2) os.Exit(2)
} }
deleted, err := store.CleanImages(false) err = os.RemoveAll(rmPath)
if err != nil { if err != nil {
slog.Error("Failed to clean images", "error", err.Error()) slog.Error("Failed to remove all", "error", err.Error(), "path", rmPath)
os.Exit(1) os.Exit(1)
} }
slog.Info("Successful VM image cleanup", "deleted", deleted) slog.Info("Deleted data directory", "path", rmPath)
}, },
} }

View file

@ -1,49 +0,0 @@
package main
import (
"log/slog"
"os"
"path/filepath"
"github.com/AlexSSD7/linsk/cmd/imgbuilder/builder"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "imgbuilder",
Short: "Build an Alpine Linux image for Linsk. A base Alpine VM disc image is required.",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
baseISOPath := filepath.Clean(args[0])
outImagePath := filepath.Clean(args[1])
bc, err := builder.NewBuildContext(slog.With("caller", "build-context"), baseISOPath, outImagePath, vmDebugFlag)
if err != nil {
slog.Error("Failed to create a new build context", "error", err.Error())
os.Exit(1)
}
err = bc.BuildWithInterruptHandler()
if err != nil {
slog.Error("Failed to build an image", "error", err.Error())
os.Exit(1)
}
slog.Info("Success")
},
}
var vmDebugFlag bool
func init() {
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
rootCmd.PersistentFlags().BoolVar(&vmDebugFlag, "vmdebug", false, "Enable VM debug mode. This will open an accessible VM monitor. You can log in with root user and no password.")
}
func main() {
err := rootCmd.Execute()
if err != nil {
os.Exit(1)
}
}

View file

@ -42,6 +42,7 @@ func init() {
rootCmd.AddCommand(runCmd) rootCmd.AddCommand(runCmd)
rootCmd.AddCommand(shellCmd) rootCmd.AddCommand(shellCmd)
rootCmd.AddCommand(cleanCmd) rootCmd.AddCommand(cleanCmd)
rootCmd.AddCommand(buildCmd)
rootCmd.PersistentFlags().BoolVar(&vmDebugFlag, "vmdebug", false, "Enables the VM debug mode. This will open an accessible VM monitor. You can log in with root user and no password.") rootCmd.PersistentFlags().BoolVar(&vmDebugFlag, "vmdebug", false, "Enables the VM debug mode. This will open an accessible VM monitor. You can log in with root user and no password.")
rootCmd.PersistentFlags().BoolVar(&unrestrictedNetworkingFlag, "unrestricted-networking", false, "Enables unrestricted networking. This will allow the VM to connect to the internet.") rootCmd.PersistentFlags().BoolVar(&unrestrictedNetworkingFlag, "unrestricted-networking", false, "Enables unrestricted networking. This will allow the VM to connect to the internet.")

View file

@ -66,9 +66,15 @@ func createStore() *storage.Storage {
func runVM(passthroughArg string, fn func(context.Context, *vm.VM, *vm.FileManager) int, forwardPortsRules []vm.PortForwardingRule, unrestrictedNetworking bool) int { func runVM(passthroughArg string, fn func(context.Context, *vm.VM, *vm.FileManager) int, forwardPortsRules []vm.PortForwardingRule, unrestrictedNetworking bool) int {
store := createStore() store := createStore()
_, err := store.ValidateImageHashOrDownload()
vmImagePath, err := store.CheckVMImageExists()
if err != nil { if err != nil {
slog.Error("Failed to validate image hash or download image", "error", err.Error()) slog.Error("Failed to check whether VM image exists", "error", err.Error())
os.Exit(1)
}
if vmImagePath == "" {
slog.Error("VM image does not exist. You need to build it first before attempting to start Linsk. Please run `linsk build` first.")
os.Exit(1) os.Exit(1)
} }
@ -81,7 +87,7 @@ func runVM(passthroughArg string, fn func(context.Context, *vm.VM, *vm.FileManag
vmCfg := vm.VMConfig{ vmCfg := vm.VMConfig{
Drives: []vm.DriveConfig{{ Drives: []vm.DriveConfig{{
Path: store.GetLocalImagePath(), Path: vmImagePath,
SnapshotMode: true, SnapshotMode: true,
}}, }},

14
constants/arch.go Normal file
View file

@ -0,0 +1,14 @@
package constants
import "runtime"
func GetUnixWorkArch() string {
arch := "x86_64"
if runtime.GOOS == "arm64" {
arch = "arm64"
}
// CPU architectures other than amd64 and arm64 are not yet natively supported.
// Running on a non-officially-supported arch will result in use of x86_64 VM.
return arch
}

51
constants/image.go Normal file
View file

@ -0,0 +1,51 @@
package constants
import (
"runtime"
"github.com/AlexSSD7/linsk/utils"
)
const baseAlpineVersionMajor = "3.18"
const baseAlpineVersionMinor = "3"
const baseAlpineVersionCombined = baseAlpineVersionMajor + "." + baseAlpineVersionMinor
const LinskVMImageVersion = "1"
var baseAlpineArch string
var baseImageURL string
var alpineBaseImageHash []byte
func init() {
baseAlpineArch = "x86_64"
alpineBaseImageHash = utils.MustDecodeHex("925f6bc1039a0abcd0548d2c3054d54dce31cfa03c7eeba22d10d85dc5817c98")
if runtime.GOOS == "arm64" {
baseAlpineArch = "aarch64"
alpineBaseImageHash = utils.MustDecodeHex("c94593729e4577650d9e73ada28e3cbe56964ab2a27240364f8616e920ed6d4e")
}
baseImageURL = "https://dl-cdn.alpinelinux.org/alpine/v" + baseAlpineVersionMajor + "/releases/" + baseAlpineArch + "/alpine-virt-" + baseAlpineVersionCombined + "-" + baseAlpineArch + ".iso"
}
func GetAlpineBaseImageURL() string {
return baseImageURL
}
func GetAlpineBaseImageTags() string {
return baseAlpineVersionCombined + "-" + baseAlpineArch
}
func GetVMImageTags() string {
return GetAlpineBaseImageTags() + "-linsk" + LinskVMImageVersion
}
func GetAlpineBaseImageFileName() string {
return "alpine-" + GetAlpineBaseImageTags() + ".img"
}
func GetAlpineBaseImageHash() []byte {
// Making a copy so that remote caller cannot modify the original variable.
tmp := make([]byte, len(alpineBaseImageHash))
copy(tmp, alpineBaseImageHash)
return tmp
}

View file

@ -1,4 +1,4 @@
package builder package imgbuilder
import ( import (
"bytes" "bytes"
@ -152,7 +152,7 @@ func (bc *BuildContext) BuildWithInterruptHandler() error {
defer func() { _ = sc.Close() }() defer func() { _ = sc.Close() }()
bc.logger.Info("Installation in progress") bc.logger.Info("VM OS installation in progress")
err = runAlpineSetupCmd(sc, []string{"openssh", "lvm2", "util-linux", "cryptsetup", "vsftpd"}) err = runAlpineSetupCmd(sc, []string{"openssh", "lvm2", "util-linux", "cryptsetup", "vsftpd"})
if err != nil { if err != nil {

117
storage/download.go Normal file
View file

@ -0,0 +1,117 @@
package storage
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"hash"
"io"
"math"
"net/http"
"os"
"github.com/dustin/go-humanize"
"github.com/pkg/errors"
)
func (s *Storage) download(url string, hash []byte, out string) error {
var created, success bool
defer func() {
if created && !success {
_ = os.Remove(out)
}
}()
_, err := os.Stat(out)
if err == nil {
return errors.Wrap(err, "file already exists")
} else {
if !errors.Is(err, os.ErrNotExist) {
return errors.Wrap(err, "stat out path")
}
}
f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY, 0400)
if err != nil {
return errors.Wrap(err, "open file")
}
created = true
defer func() { _ = f.Close() }()
s.logger.Info("Starting to download file", "from", url, "to", out)
resp, err := http.Get(url)
if err != nil {
return errors.Wrap(err, "http get")
}
defer func() { _ = resp.Body.Close() }()
_, err = copyWithProgressAndHash(f, resp.Body, 1024, resp.ContentLength, hash, func(i int, f float64) {
s.logger.Info("Downloading file", "out", out, "percent", math.Round(f*100*100)/100, "size", humanize.Bytes(uint64(resp.ContentLength)))
})
if err != nil {
return errors.Wrap(err, "copy resp to file")
}
s.logger.Info("Successfully downloaded file", "from", url, "to", out)
success = true
return nil
}
func copyWithProgressAndHash(dst io.Writer, src io.Reader, blockSize int, length int64, wantHash []byte, report func(int, float64)) (int, error) {
block := make([]byte, blockSize)
var h hash.Hash
if wantHash != nil {
h = sha256.New()
}
var progress int
for {
read, err := src.Read(block)
if read > 0 {
written, err := dst.Write(block[:read])
if err != nil {
return progress, errors.Wrap(err, "write")
}
if h != nil {
h.Write(block[:read])
}
progress += written
}
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return progress, errors.Wrap(err, "read")
}
if progress%1000000 == 0 {
var percent float64
if length != 0 {
percent = float64(progress) / float64(length)
}
report(progress, percent)
}
}
if h != nil {
sum := h.Sum(nil)
if !bytes.Equal(sum, wantHash) {
return progress, fmt.Errorf("hash mismach: want '%v', have '%v'", hex.EncodeToString(wantHash), hex.EncodeToString(sum))
}
}
return progress, nil
}

7
storage/errors.go Normal file
View file

@ -0,0 +1,7 @@
package storage
import "errors"
var (
ErrImageAlreadyExists = errors.New("image already exists")
)

45
storage/hash.go Normal file
View file

@ -0,0 +1,45 @@
package storage
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"github.com/pkg/errors"
)
func validateFileHash(path string, hash []byte) error {
f, err := os.OpenFile(path, os.O_RDONLY, 0400)
if err != nil {
return errors.Wrap(err, "open file")
}
defer func() { _ = f.Close() }()
h := sha256.New()
block := make([]byte, 1024)
for {
read, err := f.Read(block)
if read > 0 {
h.Write(block[:read])
}
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return errors.Wrap(err, "read file block")
}
}
sum := h.Sum(nil)
if !bytes.Equal(sum, hash) {
return fmt.Errorf("hash mismatch: want '%v', have '%v'", hex.EncodeToString(hash), hex.EncodeToString(sum))
}
return nil
}

View file

@ -1,26 +1,16 @@
package storage package storage
import ( import (
"bytes"
"crypto/sha512"
"encoding/hex"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"math"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/dustin/go-humanize" "github.com/AlexSSD7/linsk/constants"
"github.com/AlexSSD7/linsk/imgbuilder"
"github.com/pkg/errors" "github.com/pkg/errors"
) )
const imageURL = "http://localhost:8000/linsk-base.qcow2"
var imageHash = [64]byte{70, 23, 243, 131, 146, 197, 41, 223, 67, 223, 41, 243, 128, 147, 82, 238, 34, 24, 123, 246, 251, 117, 120, 72, 72, 64, 96, 146, 227, 199, 49, 169, 164, 33, 205, 217, 98, 255, 109, 18, 130, 203, 126, 83, 34, 4, 229, 108, 173, 22, 107, 37, 181, 17, 84, 13, 129, 110, 25, 126, 158, 50, 135, 9}
type Storage struct { type Storage struct {
logger *slog.Logger logger *slog.Logger
@ -42,192 +32,87 @@ func NewStorage(logger *slog.Logger, dataDir string) (*Storage, error) {
}, nil }, nil
} }
func (s *Storage) CleanImages(retainLatest bool) (int, error) { func (s *Storage) CheckDownloadBaseImage() (string, error) {
dirEntries, err := os.ReadDir(s.path) baseImagePath := filepath.Join(s.path, constants.GetAlpineBaseImageFileName())
_, err := os.Stat(baseImagePath)
if err != nil { if err != nil {
return 0, errors.Wrap(err, "read dir")
}
localImagePath := s.GetLocalImagePath()
var deleted int
for _, entry := range dirEntries {
fullPath := filepath.Join(s.path, entry.Name())
if !strings.HasSuffix(fullPath, ".qcow2") {
continue
}
if !(retainLatest && fullPath == localImagePath) {
s.logger.Warn("Removing VM image", "path", fullPath)
err = os.Remove(fullPath)
if err != nil {
return deleted, errors.Wrapf(err, "remove vm image (path '%v')", fullPath)
}
deleted++
}
}
return deleted, nil
}
func (s *Storage) GetLocalImagePath() string {
return filepath.Join(s.path, hex.EncodeToString(imageHash[:])+".qcow2")
}
func (s *Storage) DownloadImage() error {
localImagePath := s.GetLocalImagePath()
var created, success bool
defer func() {
if created && !success {
_ = os.Remove(localImagePath)
}
}()
_, err := os.Stat(localImagePath)
if err == nil {
err = os.Remove(localImagePath)
if err != nil {
return errors.Wrap(err, "remove existing image")
}
} else {
if !errors.Is(err, os.ErrNotExist) { if !errors.Is(err, os.ErrNotExist) {
return errors.Wrap(err, "stat local image path") return "", errors.Wrap(err, "stat base image path")
} }
}
f, err := os.OpenFile(localImagePath, os.O_CREATE|os.O_WRONLY, 0400) // Image doesn't exist. Download one.
if err != nil { err := s.download(constants.GetAlpineBaseImageURL(), constants.GetAlpineBaseImageHash(), baseImagePath)
return errors.Wrap(err, "open file")
}
created = true
defer func() { _ = f.Close() }()
s.logger.Info("Starting to download the VM image", "path", localImagePath)
resp, err := http.Get(imageURL)
if err != nil {
return errors.Wrap(err, "http get image")
}
defer func() { _ = resp.Body.Close() }()
_, err = copyWithProgress(f, resp.Body, 1024, resp.ContentLength, func(i int, f float64) {
s.logger.Info("Downloading the VM image", "url", imageURL, "percent", math.Round(f*100*100)/100, "content-length", humanize.Bytes(uint64(resp.ContentLength)))
})
if err != nil {
return errors.Wrap(err, "copy resp to file")
}
err = s.ValidateImageHash()
if err != nil {
return errors.Wrap(err, "validate image hash")
}
s.logger.Info("Successfully downloaded the VM image", "dst", localImagePath)
success = true
return nil
}
func (s *Storage) ValidateImageHash() error {
localImagePath := s.GetLocalImagePath()
f, err := os.OpenFile(localImagePath, os.O_RDONLY, 0400)
if err != nil {
return errors.Wrap(err, "open file")
}
defer func() { _ = f.Close() }()
h := sha512.New()
block := make([]byte, 1024)
for {
read, err := f.Read(block)
if read > 0 {
h.Write(block[:read])
}
if err != nil { if err != nil {
if errors.Is(err, io.EOF) { return "", errors.Wrap(err, "download base alpine image")
break
}
return errors.Wrap(err, "read file block")
} }
return baseImagePath, nil
} }
sum := h.Sum(nil) // Image exists. Ensure that the hash is correct.
err = validateFileHash(baseImagePath, constants.GetAlpineBaseImageHash())
if !bytes.Equal(sum, imageHash[:]) { if err != nil {
return fmt.Errorf("hash mismatch: want '%v', have '%v'", hex.EncodeToString(imageHash[:]), hex.EncodeToString(sum)) return "", errors.Wrap(err, "validate hash of existing image")
} }
s.logger.Info("Validated the VM image hash", "path", localImagePath) return baseImagePath, nil
return nil
} }
func (s *Storage) ValidateImageHashOrDownload() (bool, error) { func (s *Storage) GetVMImagePath() string {
var downloaded bool return filepath.Join(s.path, constants.GetVMImageTags()+".qcow2")
err := s.ValidateImageHash() }
if err != nil {
if errors.Is(err, os.ErrNotExist) {
err = s.DownloadImage()
if err != nil {
return false, errors.Wrap(err, "download iamge")
}
downloaded = true func (s *Storage) BuildVMImageWithInterruptHandler(showBuilderVMDisplay bool, overwrite bool) error {
} else { var overwriting bool
return false, errors.Wrap(err, "validate image hash") vmImagePath := s.GetVMImagePath()
_, err := os.Stat(vmImagePath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return errors.Wrap(err, "stat vm image path")
} }
}
deletedImagesCount, err := s.CleanImages(true)
if err != nil {
s.logger.Warn("Failed to prune old VM images", "error", err, "deleted", deletedImagesCount)
} else { } else {
s.logger.Info("Pruned old VM images", "deleted", deletedImagesCount) if overwrite {
} overwriting = true
err = os.Remove(vmImagePath)
return downloaded, err
}
func copyWithProgress(dst io.Writer, src io.Reader, blockSize int, length int64, report func(int, float64)) (int, error) {
block := make([]byte, blockSize)
var progress int
for {
read, err := src.Read(block)
if read > 0 {
written, err := dst.Write(block[:read])
if err != nil { if err != nil {
return progress, errors.Wrap(err, "write") return errors.Wrap(err, "remove existing vm image")
} }
progress += written } else {
} return ErrImageAlreadyExists
if err != nil {
if errors.Is(err, io.EOF) {
break
}
return progress, errors.Wrap(err, "read")
}
if progress%1000000 == 0 {
var percent float64
if length != 0 {
percent = float64(progress) / float64(length)
}
report(progress, percent)
} }
} }
return progress, nil baseImagePath, err := s.CheckDownloadBaseImage()
if err != nil {
return errors.Wrap(err, "check download base image")
}
s.logger.Info("Building VM image", "tags", constants.GetAlpineBaseImageTags(), "overwriting", overwriting, "dst", vmImagePath)
buildCtx, err := imgbuilder.NewBuildContext(s.logger.With("subcaller", "imgbuilder"), baseImagePath, vmImagePath, showBuilderVMDisplay)
if err != nil {
return errors.Wrap(err, "create new img build context")
}
return errors.Wrap(buildCtx.BuildWithInterruptHandler(), "build")
}
func (s *Storage) CheckVMImageExists() (string, error) {
p := s.GetVMImagePath()
_, err := os.Stat(p)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return "", errors.Wrap(err, "stat vm image path")
}
// Image doesn't exist.
return "", nil
}
// Image exists. Returning the full path.
return p, nil
}
func (s *Storage) DataDirPath() string {
return s.path
} }

12
utils/hex.go Normal file
View file

@ -0,0 +1,12 @@
package utils
import "encoding/hex"
func MustDecodeHex(s string) []byte {
b, err := hex.DecodeString(s)
if err != nil {
panic(err)
}
return b
}