2021-06-02 04:12:56 +00:00
|
|
|
package vhd
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
2022-10-29 21:06:29 +00:00
|
|
|
"emperror.dev/errors"
|
2021-06-02 04:12:56 +00:00
|
|
|
"fmt"
|
2022-10-29 21:06:29 +00:00
|
|
|
"github.com/pterodactyl/wings/config"
|
|
|
|
"github.com/spf13/afero"
|
2021-06-02 04:12:56 +00:00
|
|
|
"os"
|
|
|
|
"os/exec"
|
2021-07-04 19:25:38 +00:00
|
|
|
"path"
|
2022-10-16 20:34:24 +00:00
|
|
|
"path/filepath"
|
2021-06-02 04:12:56 +00:00
|
|
|
"strings"
|
2022-10-16 21:01:52 +00:00
|
|
|
"sync"
|
2022-10-29 21:06:29 +00:00
|
|
|
"sync/atomic"
|
2021-06-02 04:12:56 +00:00
|
|
|
)
|
|
|
|
|
|
|
|
var (
|
2021-07-04 20:23:45 +00:00
|
|
|
ErrInvalidDiskPathTarget = errors.Sentinel("vhd: disk path is a directory or symlink")
|
|
|
|
ErrMountPathNotDirectory = errors.Sentinel("vhd: mount point is not a directory")
|
|
|
|
ErrFilesystemMounted = errors.Sentinel("vhd: filesystem is already mounted")
|
2022-10-29 21:06:29 +00:00
|
|
|
ErrFilesystemNotMounted = errors.Sentinel("vhd: filesystem is not mounted")
|
2021-07-04 20:23:45 +00:00
|
|
|
ErrFilesystemExists = errors.Sentinel("vhd: filesystem already exists on disk")
|
2021-06-02 04:12:56 +00:00
|
|
|
)
|
|
|
|
|
2022-10-16 21:01:52 +00:00
|
|
|
var useDdAllocation bool
|
|
|
|
var setDdAllocator sync.Once
|
|
|
|
|
2021-06-04 03:51:03 +00:00
|
|
|
// hasExitCode allows this code to test the response error to see if there is
|
|
|
|
// an exit code available from the command call that can be used to determine if
|
|
|
|
// something went wrong.
|
|
|
|
type hasExitCode interface {
|
|
|
|
ExitCode() int
|
|
|
|
}
|
2021-06-02 04:12:56 +00:00
|
|
|
|
2021-06-04 03:51:03 +00:00
|
|
|
// Commander defines an interface that must be met for executing commands on the
|
|
|
|
// underlying OS. By default the vhd package will use Go's exec.Cmd type for
|
|
|
|
// execution. This interface allows stubbing out on tests, or potentially custom
|
|
|
|
// setups down the line.
|
|
|
|
type Commander interface {
|
|
|
|
Run() error
|
|
|
|
Output() ([]byte, error)
|
|
|
|
String() string
|
|
|
|
}
|
|
|
|
|
|
|
|
// CommanderProvider is a function that provides a struct meeting the Commander
|
|
|
|
// interface requirements.
|
|
|
|
type CommanderProvider func(ctx context.Context, name string, args ...string) Commander
|
|
|
|
|
|
|
|
// CfgOption is a configuration option callback for the Disk.
|
|
|
|
type CfgOption func(d *Disk) *Disk
|
|
|
|
|
|
|
|
// Disk represents the underlying virtual disk for the instance.
|
|
|
|
type Disk struct {
|
2022-10-29 21:06:29 +00:00
|
|
|
mu sync.RWMutex
|
2021-07-04 19:12:32 +00:00
|
|
|
// The total size of the disk allowed in bytes.
|
2021-07-04 20:23:45 +00:00
|
|
|
size int64
|
|
|
|
// The path where the disk image should be created.
|
|
|
|
diskPath string
|
|
|
|
// The point at which this disk should be made available on the system. This
|
|
|
|
// is where files can be read/written to.
|
2021-06-04 03:51:03 +00:00
|
|
|
mountAt string
|
|
|
|
fs afero.Fs
|
|
|
|
commander CommanderProvider
|
2021-06-02 04:12:56 +00:00
|
|
|
}
|
|
|
|
|
2022-10-16 20:34:24 +00:00
|
|
|
// DiskPath returns the underlying path that contains the virtual disk for the server
|
|
|
|
// identified by its UUID.
|
|
|
|
func DiskPath(uuid string) string {
|
|
|
|
return filepath.Join(config.Get().System.Data, ".vhd/", uuid+".img")
|
|
|
|
}
|
|
|
|
|
2022-10-29 19:40:49 +00:00
|
|
|
// Enabled returns true when VHD support is enabled on the instance.
|
|
|
|
func Enabled() bool {
|
|
|
|
return config.Get().Servers.Filesystem.Driver == config.FSDriverVHD
|
|
|
|
}
|
|
|
|
|
2021-06-02 04:12:56 +00:00
|
|
|
// New returns a new Disk instance. The "size" parameter should be provided in
|
2021-07-04 19:12:32 +00:00
|
|
|
// bytes of space allowed for the disk. An additional slice of option callbacks
|
|
|
|
// can be provided to programatically swap out the underlying filesystem
|
2021-06-04 03:51:03 +00:00
|
|
|
// implementation or the underlying command exection engine.
|
|
|
|
func New(size int64, diskPath string, mountAt string, opts ...func(*Disk)) *Disk {
|
2021-06-02 04:12:56 +00:00
|
|
|
if diskPath == "" || mountAt == "" {
|
|
|
|
panic("vhd: cannot specify an empty disk or mount path")
|
|
|
|
}
|
2021-06-04 03:51:03 +00:00
|
|
|
d := Disk{
|
|
|
|
size: size,
|
|
|
|
diskPath: diskPath,
|
|
|
|
mountAt: mountAt,
|
|
|
|
fs: afero.NewOsFs(),
|
|
|
|
commander: func(ctx context.Context, name string, args ...string) Commander {
|
|
|
|
return exec.CommandContext(ctx, name, args...)
|
|
|
|
},
|
|
|
|
}
|
|
|
|
for _, opt := range opts {
|
|
|
|
opt(&d)
|
|
|
|
}
|
|
|
|
return &d
|
|
|
|
}
|
|
|
|
|
|
|
|
// WithFs allows for a different underlying filesystem to be provided to the
|
|
|
|
// virtual disk manager.
|
|
|
|
func WithFs(fs afero.Fs) func(*Disk) {
|
|
|
|
return func(d *Disk) {
|
|
|
|
d.fs = fs
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// WithCommander allows a different Commander provider to be provided.
|
|
|
|
func WithCommander(c CommanderProvider) func(*Disk) {
|
|
|
|
return func(d *Disk) {
|
|
|
|
d.commander = c
|
|
|
|
}
|
2021-06-02 04:12:56 +00:00
|
|
|
}
|
|
|
|
|
2021-07-04 21:07:03 +00:00
|
|
|
func (d *Disk) Path() string {
|
|
|
|
return d.diskPath
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d *Disk) MountPath() string {
|
|
|
|
return d.mountAt
|
|
|
|
}
|
|
|
|
|
2021-06-02 04:12:56 +00:00
|
|
|
// Exists reports if the disk exists on the system yet or not. This only verifies
|
2021-06-04 03:51:03 +00:00
|
|
|
// the presence of the disk image, not the validity of it. An error is returned
|
2021-07-04 20:23:45 +00:00
|
|
|
// if the path exists but the destination is not a file or is a symlink.
|
2021-06-02 04:12:56 +00:00
|
|
|
func (d *Disk) Exists() (bool, error) {
|
2022-10-29 21:06:29 +00:00
|
|
|
d.mu.RLock()
|
|
|
|
defer d.mu.RUnlock()
|
2021-06-04 03:51:03 +00:00
|
|
|
st, err := d.fs.Stat(d.diskPath)
|
|
|
|
if err != nil && os.IsNotExist(err) {
|
|
|
|
return false, nil
|
|
|
|
} else if err != nil {
|
|
|
|
return false, errors.WithStack(err)
|
2021-06-02 04:12:56 +00:00
|
|
|
}
|
2021-07-04 20:23:45 +00:00
|
|
|
if !st.IsDir() && st.Mode()&os.ModeSymlink == 0 {
|
2021-06-04 03:51:03 +00:00
|
|
|
return true, nil
|
|
|
|
}
|
2021-07-04 20:23:45 +00:00
|
|
|
return false, errors.WithStack(ErrInvalidDiskPathTarget)
|
2021-06-02 04:12:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// IsMounted checks to see if the given disk is currently mounted.
|
|
|
|
func (d *Disk) IsMounted(ctx context.Context) (bool, error) {
|
|
|
|
find := d.mountAt + " ext4"
|
2021-06-04 03:51:03 +00:00
|
|
|
cmd := d.commander(ctx, "grep", "-qs", find, "/proc/mounts")
|
2021-06-02 04:12:56 +00:00
|
|
|
if err := cmd.Run(); err != nil {
|
2021-06-04 03:51:03 +00:00
|
|
|
if v, ok := err.(hasExitCode); ok {
|
2021-06-02 04:12:56 +00:00
|
|
|
if v.ExitCode() == 1 {
|
|
|
|
return false, nil
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return false, errors.Wrap(err, "vhd: failed to execute grep for mount existence")
|
|
|
|
}
|
|
|
|
return true, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// Mount attempts to mount the disk as configured. If it does not exist or the
|
|
|
|
// mount command fails an error will be returned to the caller. This does not
|
|
|
|
// attempt to create the disk if it is missing from the filesystem.
|
|
|
|
//
|
|
|
|
// Attempting to mount a disk which does not exist will result in an error being
|
|
|
|
// returned to the caller. If the disk is already mounted an ErrFilesystemMounted
|
|
|
|
// error is returned to the caller.
|
|
|
|
func (d *Disk) Mount(ctx context.Context) error {
|
2022-10-29 21:06:29 +00:00
|
|
|
d.mu.Lock()
|
|
|
|
defer d.mu.Unlock()
|
|
|
|
return d.mount(ctx)
|
2021-06-02 04:12:56 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// Unmount attempts to unmount the disk from the system. If the disk is not
|
2022-10-29 21:06:29 +00:00
|
|
|
// currently mounted this function is a no-op and ErrFilesystemNotMounted is
|
|
|
|
// returned to the caller.
|
2021-06-02 04:12:56 +00:00
|
|
|
func (d *Disk) Unmount(ctx context.Context) error {
|
2022-10-29 21:06:29 +00:00
|
|
|
d.mu.Lock()
|
|
|
|
defer d.mu.Unlock()
|
|
|
|
return d.unmount(ctx)
|
2022-10-16 21:01:52 +00:00
|
|
|
}
|
|
|
|
|
2021-06-02 04:12:56 +00:00
|
|
|
// Allocate executes the "fallocate" command on the disk. This will first unmount
|
|
|
|
// the disk from the system before attempting to actually allocate the space. If
|
|
|
|
// this disk already exists on the machine it will be resized accordingly.
|
|
|
|
//
|
|
|
|
// DANGER! This will unmount the disk from the machine while performing this
|
|
|
|
// action, use caution when calling it during normal processes.
|
|
|
|
func (d *Disk) Allocate(ctx context.Context) error {
|
2022-10-29 21:06:29 +00:00
|
|
|
d.mu.Lock()
|
|
|
|
defer d.mu.Unlock()
|
2021-06-02 04:12:56 +00:00
|
|
|
if exists, err := d.Exists(); exists {
|
|
|
|
// If the disk currently exists attempt to unmount the mount point before
|
|
|
|
// allocating space.
|
|
|
|
if err := d.Unmount(ctx); err != nil {
|
|
|
|
return errors.WithStackIf(err)
|
|
|
|
}
|
|
|
|
} else if err != nil {
|
|
|
|
return errors.Wrap(err, "vhd: failed to check for existence of root disk")
|
|
|
|
}
|
2021-07-04 19:25:38 +00:00
|
|
|
trim := path.Base(d.diskPath)
|
2022-10-16 21:01:52 +00:00
|
|
|
if err := d.fs.MkdirAll(strings.TrimSuffix(d.diskPath, trim), 0700); err != nil {
|
2021-07-04 19:25:38 +00:00
|
|
|
return errors.Wrap(err, "vhd: failed to create base vhd disk directory")
|
|
|
|
}
|
2022-10-16 21:13:45 +00:00
|
|
|
cmd := d.allocationCmd(ctx)
|
2021-06-02 04:12:56 +00:00
|
|
|
if _, err := cmd.Output(); err != nil {
|
2022-10-16 21:01:52 +00:00
|
|
|
msg := "vhd: failed to execute space allocation command"
|
2021-06-02 04:12:56 +00:00
|
|
|
if v, ok := err.(*exec.ExitError); ok {
|
2022-10-16 21:01:52 +00:00
|
|
|
stderr := strings.Trim(string(v.Stderr), ".\n")
|
|
|
|
if !useDdAllocation && strings.HasSuffix(stderr, "not supported") {
|
|
|
|
// Try again: fallocate is not supported on some filesystems so we'll fall
|
|
|
|
// back to making use of dd for subsequent operations.
|
|
|
|
setDdAllocator.Do(func() {
|
|
|
|
useDdAllocation = true
|
|
|
|
})
|
|
|
|
return d.Allocate(ctx)
|
|
|
|
}
|
|
|
|
msg = msg + ": " + stderr
|
2021-06-02 04:12:56 +00:00
|
|
|
}
|
|
|
|
return errors.Wrap(err, msg)
|
|
|
|
}
|
2022-10-16 21:13:45 +00:00
|
|
|
return errors.WithStack(d.fs.Chmod(d.diskPath, 0600))
|
2021-06-02 04:12:56 +00:00
|
|
|
}
|
|
|
|
|
2022-10-29 21:06:29 +00:00
|
|
|
// Resize will change the internal disk size limit and then allocate the new
|
|
|
|
// space to the disk automatically.
|
|
|
|
func (d *Disk) Resize(ctx context.Context, size int64) error {
|
|
|
|
atomic.StoreInt64(&d.size, size)
|
|
|
|
return d.Allocate(ctx)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Destroy removes the underlying allocated disk image and unmounts the disk.
|
|
|
|
func (d *Disk) Destroy(ctx context.Context) error {
|
|
|
|
d.mu.Lock()
|
|
|
|
defer d.mu.Unlock()
|
|
|
|
if err := d.unmount(ctx); err != nil {
|
|
|
|
return errors.WithStackIf(err)
|
|
|
|
}
|
|
|
|
return errors.WithStackIf(d.fs.RemoveAll(d.mountAt))
|
|
|
|
}
|
|
|
|
|
2021-06-02 04:12:56 +00:00
|
|
|
// MakeFilesystem will attempt to execute the "mkfs" command against the disk on
|
|
|
|
// the machine. If the disk has already been created this command will return an
|
|
|
|
// ErrFilesystemExists error to the caller. You should manually unmount the disk
|
|
|
|
// if it shouldn't be mounted at this point.
|
|
|
|
func (d *Disk) MakeFilesystem(ctx context.Context) error {
|
2022-10-29 21:06:29 +00:00
|
|
|
d.mu.Lock()
|
|
|
|
defer d.mu.Unlock()
|
2021-06-02 04:12:56 +00:00
|
|
|
// If no error is returned when mounting DO NOT execute this command as it will
|
|
|
|
// completely destroy the data stored at that location.
|
|
|
|
err := d.Mount(ctx)
|
|
|
|
if err == nil || errors.Is(err, ErrFilesystemMounted) {
|
|
|
|
// If it wasn't already mounted try to clean up at this point and unmount
|
|
|
|
// the disk. If this fails just ignore it for now.
|
|
|
|
if err != nil {
|
|
|
|
_ = d.Unmount(ctx)
|
|
|
|
}
|
|
|
|
return ErrFilesystemExists
|
|
|
|
}
|
2021-06-02 04:24:57 +00:00
|
|
|
if !strings.Contains(err.Error(), "can't find in /etc/fstab") && !strings.Contains(err.Error(), "exit status 32") {
|
2021-06-02 04:12:56 +00:00
|
|
|
return errors.WrapIf(err, "vhd: unexpected error from mount command")
|
|
|
|
}
|
|
|
|
// As long as we got an error back that was because we couldn't find thedisk
|
|
|
|
// in the /etc/fstab file we're good. Otherwise it means the disk probably exists
|
|
|
|
// or something else went wrong.
|
|
|
|
//
|
|
|
|
// Because this is a destructive command and non-tty based exection of it implies
|
|
|
|
// "-F" (force), we need to only run it when we can guarantee it doesn't already
|
|
|
|
// exist. No vague "maybe that error is expected" allowed here.
|
2021-06-04 03:51:03 +00:00
|
|
|
cmd := d.commander(ctx, "mkfs", "-t", "ext4", d.diskPath)
|
2021-06-02 04:12:56 +00:00
|
|
|
if err := cmd.Run(); err != nil {
|
|
|
|
return errors.Wrap(err, "vhd: failed to make filesystem for disk")
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
2022-10-29 21:06:29 +00:00
|
|
|
|
|
|
|
func (d *Disk) mount(ctx context.Context) error {
|
|
|
|
if isMounted, err := d.IsMounted(ctx); err != nil {
|
|
|
|
return errors.WithStackIf(err)
|
|
|
|
} else if isMounted {
|
|
|
|
return ErrFilesystemMounted
|
|
|
|
}
|
|
|
|
|
|
|
|
if st, err := d.fs.Stat(d.mountAt); err != nil && !os.IsNotExist(err) {
|
|
|
|
return errors.Wrap(err, "vhd: failed to stat mount path")
|
|
|
|
} else if os.IsNotExist(err) {
|
|
|
|
if err := d.fs.MkdirAll(d.mountAt, 0700); err != nil {
|
|
|
|
return errors.Wrap(err, "vhd: failed to create mount path")
|
|
|
|
}
|
|
|
|
} else if !st.IsDir() {
|
|
|
|
return errors.WithStack(ErrMountPathNotDirectory)
|
|
|
|
}
|
|
|
|
|
|
|
|
u := config.Get().System.User
|
|
|
|
if err := d.fs.Chown(d.mountAt, u.Uid, u.Gid); err != nil {
|
|
|
|
return errors.Wrap(err, "vhd: failed to chown mount point")
|
|
|
|
}
|
|
|
|
|
|
|
|
cmd := d.commander(ctx, "mount", "-t", "auto", "-o", "loop", d.diskPath, d.mountAt)
|
|
|
|
if _, err := cmd.Output(); err != nil {
|
|
|
|
msg := "vhd: failed to mount disk"
|
|
|
|
if v, ok := err.(*exec.ExitError); ok {
|
|
|
|
msg = msg + ": " + strings.Trim(string(v.Stderr), ".\n")
|
|
|
|
}
|
|
|
|
return errors.Wrap(err, msg)
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (d *Disk) unmount(ctx context.Context) error {
|
|
|
|
cmd := d.commander(ctx, "umount", d.mountAt)
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
|
|
v, ok := err.(hasExitCode)
|
|
|
|
if ok && v.ExitCode() == 32 {
|
|
|
|
return ErrFilesystemNotMounted
|
|
|
|
}
|
|
|
|
return errors.Wrap(err, "vhd: failed to execute unmount command for disk")
|
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// allocationCmd returns the command to allocate the disk image. This will attempt to
|
|
|
|
// use the fallocate command if available, otherwise it will fall back to dd if the
|
|
|
|
// fallocate command has previously failed.
|
|
|
|
//
|
|
|
|
// We use 1024 as the multiplier for all of the disk space logic within the application.
|
|
|
|
// Passing "K" (/1024) is the same as "KiB" for fallocate, but is different than "KB" (/1000).
|
|
|
|
func (d *Disk) allocationCmd(ctx context.Context) Commander {
|
|
|
|
s := atomic.LoadInt64(&d.size) / 1024
|
|
|
|
if useDdAllocation {
|
|
|
|
return d.commander(ctx, "dd", "if=/dev/zero", fmt.Sprintf("of=%s", d.diskPath), fmt.Sprintf("bs=%dk", s), "count=1")
|
|
|
|
}
|
|
|
|
return d.commander(ctx, "fallocate", "-l", fmt.Sprintf("%dK", s), d.diskPath)
|
|
|
|
}
|