attempt to update vhd on-the-fly

This is not currently working correctly — when setting the limit to 0 all existing files are destroyed. When setting it back to a non-zero value the old files are returned from the allocated disk.

This at least starts getting the code pathways in place to handle it and introduces some additional lock safety on the VHD management code.
This commit is contained in:
DaneEveritt 2022-10-29 14:06:29 -07:00
parent daa0ab75b4
commit b00d328107
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
6 changed files with 168 additions and 71 deletions

View File

@ -2,23 +2,24 @@ package vhd
import ( import (
"context" "context"
"emperror.dev/errors"
"fmt" "fmt"
"github.com/pterodactyl/wings/config"
"github.com/spf13/afero"
"os" "os"
"os/exec" "os/exec"
"path" "path"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"emperror.dev/errors"
"github.com/pterodactyl/wings/config"
"github.com/spf13/afero"
) )
var ( var (
ErrInvalidDiskPathTarget = errors.Sentinel("vhd: disk path is a directory or symlink") ErrInvalidDiskPathTarget = errors.Sentinel("vhd: disk path is a directory or symlink")
ErrMountPathNotDirectory = errors.Sentinel("vhd: mount point is not a directory") ErrMountPathNotDirectory = errors.Sentinel("vhd: mount point is not a directory")
ErrFilesystemMounted = errors.Sentinel("vhd: filesystem is already mounted") ErrFilesystemMounted = errors.Sentinel("vhd: filesystem is already mounted")
ErrFilesystemNotMounted = errors.Sentinel("vhd: filesystem is not mounted")
ErrFilesystemExists = errors.Sentinel("vhd: filesystem already exists on disk") ErrFilesystemExists = errors.Sentinel("vhd: filesystem already exists on disk")
) )
@ -51,6 +52,7 @@ type CfgOption func(d *Disk) *Disk
// Disk represents the underlying virtual disk for the instance. // Disk represents the underlying virtual disk for the instance.
type Disk struct { type Disk struct {
mu sync.RWMutex
// The total size of the disk allowed in bytes. // The total size of the disk allowed in bytes.
size int64 size int64
// The path where the disk image should be created. // The path where the disk image should be created.
@ -123,6 +125,8 @@ func (d *Disk) MountPath() string {
// the presence of the disk image, not the validity of it. An error is returned // the presence of the disk image, not the validity of it. An error is returned
// if the path exists but the destination is not a file or is a symlink. // if the path exists but the destination is not a file or is a symlink.
func (d *Disk) Exists() (bool, error) { func (d *Disk) Exists() (bool, error) {
d.mu.RLock()
defer d.mu.RUnlock()
st, err := d.fs.Stat(d.diskPath) st, err := d.fs.Stat(d.diskPath)
if err != nil && os.IsNotExist(err) { if err != nil && os.IsNotExist(err) {
return false, nil return false, nil
@ -158,59 +162,18 @@ func (d *Disk) IsMounted(ctx context.Context) (bool, error) {
// returned to the caller. If the disk is already mounted an ErrFilesystemMounted // returned to the caller. If the disk is already mounted an ErrFilesystemMounted
// error is returned to the caller. // error is returned to the caller.
func (d *Disk) Mount(ctx context.Context) error { func (d *Disk) Mount(ctx context.Context) error {
if isMounted, err := d.IsMounted(ctx); err != nil { d.mu.Lock()
return errors.WithStackIf(err) defer d.mu.Unlock()
} else if isMounted { return d.mount(ctx)
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
} }
// Unmount attempts to unmount the disk from the system. If the disk is not // Unmount attempts to unmount the disk from the system. If the disk is not
// currently mounted this function is a no-op and no error is returned. Any // currently mounted this function is a no-op and ErrFilesystemNotMounted is
// other error encountered while unmounting will return an error to the caller. // returned to the caller.
func (d *Disk) Unmount(ctx context.Context) error { func (d *Disk) Unmount(ctx context.Context) error {
cmd := d.commander(ctx, "umount", d.mountAt) d.mu.Lock()
if err := cmd.Run(); err != nil { defer d.mu.Unlock()
if v, ok := err.(hasExitCode); !ok || v.ExitCode() != 32 { return d.unmount(ctx)
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 {
if useDdAllocation {
return d.commander(ctx, "dd", "if=/dev/zero", fmt.Sprintf("of=%s", d.diskPath), fmt.Sprintf("bs=%dk", d.size/1024), "count=1")
}
return d.commander(ctx, "fallocate", "-l", fmt.Sprintf("%dK", d.size/1024), d.diskPath)
} }
// Allocate executes the "fallocate" command on the disk. This will first unmount // Allocate executes the "fallocate" command on the disk. This will first unmount
@ -220,6 +183,8 @@ func (d *Disk) allocationCmd(ctx context.Context) Commander {
// DANGER! This will unmount the disk from the machine while performing this // DANGER! This will unmount the disk from the machine while performing this
// action, use caution when calling it during normal processes. // action, use caution when calling it during normal processes.
func (d *Disk) Allocate(ctx context.Context) error { func (d *Disk) Allocate(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
if exists, err := d.Exists(); exists { if exists, err := d.Exists(); exists {
// If the disk currently exists attempt to unmount the mount point before // If the disk currently exists attempt to unmount the mount point before
// allocating space. // allocating space.
@ -253,11 +218,30 @@ func (d *Disk) Allocate(ctx context.Context) error {
return errors.WithStack(d.fs.Chmod(d.diskPath, 0600)) return errors.WithStack(d.fs.Chmod(d.diskPath, 0600))
} }
// 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))
}
// MakeFilesystem will attempt to execute the "mkfs" command against the disk on // 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 // 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 // ErrFilesystemExists error to the caller. You should manually unmount the disk
// if it shouldn't be mounted at this point. // if it shouldn't be mounted at this point.
func (d *Disk) MakeFilesystem(ctx context.Context) error { func (d *Disk) MakeFilesystem(ctx context.Context) error {
d.mu.Lock()
defer d.mu.Unlock()
// If no error is returned when mounting DO NOT execute this command as it will // If no error is returned when mounting DO NOT execute this command as it will
// completely destroy the data stored at that location. // completely destroy the data stored at that location.
err := d.Mount(ctx) err := d.Mount(ctx)
@ -285,3 +269,62 @@ func (d *Disk) MakeFilesystem(ctx context.Context) error {
} }
return nil return nil
} }
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)
}

View File

@ -1,6 +1,8 @@
package filesystem package filesystem
import ( import (
"context"
"github.com/pterodactyl/wings/internal/vhd"
"sync" "sync"
"sync/atomic" "sync/atomic"
"syscall" "syscall"
@ -35,14 +37,42 @@ func (ult *usageLookupTime) Get() time.Time {
return ult.value return ult.value
} }
// Returns the maximum amount of disk space that this Filesystem instance is allowed to use. // MaxDisk returns the maximum amount of disk space that this Filesystem
// instance is allowed to use.
func (fs *Filesystem) MaxDisk() int64 { func (fs *Filesystem) MaxDisk() int64 {
return atomic.LoadInt64(&fs.diskLimit) return atomic.LoadInt64(&fs.diskLimit)
} }
// Sets the disk space limit for this Filesystem instance. // SetDiskLimit sets the disk space limit for this Filesystem instance. This
func (fs *Filesystem) SetDiskLimit(i int64) { // logic will also handle mounting or unmounting a virtual disk if it is being
atomic.SwapInt64(&fs.diskLimit, i) // used currently.
func (fs *Filesystem) SetDiskLimit(ctx context.Context, i int64) error {
// Do nothing if this method is called but the limit is not changing.
if atomic.LoadInt64(&fs.diskLimit) == i {
return nil
}
if vhd.Enabled() {
if i == 0 && fs.IsVirtual() {
fs.log().Debug("disk limit changed to 0, destroying virtual disk")
// Remove the VHD if it is mounted so that we're just storing files directly on the system
// since we cannot have a virtual disk with a space limit enforced like that.
if err := fs.vhd.Destroy(ctx); err != nil {
return errors.WithStackIf(err)
}
fs.vhd = nil
}
// If we're setting a disk size go ahead and mount the VHD if it isn't already mounted,
// and then allocate the new space to the disk.
if i > 0 {
fs.log().Debug("disk limit updated, allocating new space to virtual disk")
if err := fs.ConfigureDisk(ctx, i); err != nil {
return errors.WithStackIf(err)
}
}
}
fs.log().WithField("limit", i).Debug("disk limit updated")
atomic.StoreInt64(&fs.diskLimit, i)
return nil
} }
// HasSpaceErr is the same concept as HasSpaceAvailable however this will return // HasSpaceErr is the same concept as HasSpaceAvailable however this will return
@ -76,8 +106,7 @@ func (fs *Filesystem) HasSpaceErr(allowStaleValue bool) error {
func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool { func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool {
size, err := fs.DiskUsage(allowStaleValue) size, err := fs.DiskUsage(allowStaleValue)
if err != nil { if err != nil {
log.WithField("root", fs.root).WithField("error", err). fs.log().WithField("error", err).Warn("failed to determine root fs directory size")
Warn("failed to determine root fs directory size")
} }
return fs.MaxDisk() == 0 || size <= fs.MaxDisk() return fs.MaxDisk() == 0 || size <= fs.MaxDisk()
} }
@ -125,8 +154,7 @@ func (fs *Filesystem) DiskUsage(allowStaleValue bool) (int64, error) {
if !fs.lookupInProgress.Load() { if !fs.lookupInProgress.Load() {
go func(fs *Filesystem) { go func(fs *Filesystem) {
if _, err := fs.updateCachedDiskUsage(); err != nil { if _, err := fs.updateCachedDiskUsage(); err != nil {
log.WithField("root", fs.root).WithField("error", err). fs.log().WithField("error", err).Warn("failed to update fs disk usage from within routine")
Warn("failed to update fs disk usage from within routine")
} }
}(fs) }(fs)
} }
@ -249,3 +277,7 @@ func (fs *Filesystem) addDisk(i int64) int64 {
return atomic.AddInt64(&fs.diskUsed, i) return atomic.AddInt64(&fs.diskUsed, i)
} }
func (fs *Filesystem) log() *log.Entry {
return log.WithField("server", fs.uuid).WithField("root", fs.root)
}

View File

@ -38,6 +38,7 @@ type Filesystem struct {
// The root data directory path for this Filesystem instance. // The root data directory path for this Filesystem instance.
root string root string
uuid string
isTest bool isTest bool
} }
@ -46,6 +47,7 @@ type Filesystem struct {
func New(uuid string, size int64, denylist []string) *Filesystem { func New(uuid string, size int64, denylist []string) *Filesystem {
root := filepath.Join(config.Get().System.Data, uuid) root := filepath.Join(config.Get().System.Data, uuid)
fs := Filesystem{ fs := Filesystem{
uuid: uuid,
root: root, root: root,
diskLimit: size, diskLimit: size,
diskCheckInterval: time.Duration(config.Get().System.DiskCheckInterval), diskCheckInterval: time.Duration(config.Get().System.DiskCheckInterval),

View File

@ -11,13 +11,29 @@ func (fs *Filesystem) IsVirtual() bool {
return fs.vhd != nil return fs.vhd != nil
} }
// MountDisk will attempt to mount the underlying virtual disk for the server. // ConfigureDisk will attempt to create a new VHD if there is not one already
// If the disk is already mounted this is a no-op function. If the filesystem is // created for the filesystem. If there is this method will attempt to resize
// not configured for virtual disks this function will panic. // the underlying data volume. Passing a size of 0 or less will panic.
func (fs *Filesystem) MountDisk(ctx context.Context) error { func (fs *Filesystem) ConfigureDisk(ctx context.Context, size int64) error {
if !fs.IsVirtual() { if size <= 0 {
panic(errors.New("filesystem: cannot call MountDisk on Filesystem instance without VHD present")) panic("filesystem: attempt to configure disk with empty size")
} }
if fs.vhd == nil {
fs.vhd = vhd.New(size, vhd.DiskPath(fs.uuid), fs.root)
if err := fs.MountDisk(ctx); err != nil {
return errors.WithStackIf(err)
}
}
// Resize the disk now that it is for sure mounted and exists on the system.
if err := fs.vhd.Resize(ctx, size); err != nil {
return errors.WithStackIf(err)
}
return nil
}
// MountDisk will attempt to mount the underlying virtual disk for the server.
// If the disk is already mounted this is a no-op function.
func (fs *Filesystem) MountDisk(ctx context.Context) error {
err := fs.vhd.Mount(ctx) err := fs.vhd.Mount(ctx)
if errors.Is(err, vhd.ErrFilesystemMounted) { if errors.Is(err, vhd.ErrFilesystemMounted) {
return nil return nil

View File

@ -197,11 +197,11 @@ func (m *Manager) InitServer(ctx context.Context, data remote.ServerConfiguratio
return nil, errors.WithStackIf(err) return nil, errors.WithStackIf(err)
} }
s.fs = filesystem.New(s.Id(), s.DiskSpace(), s.Config().Egg.FileDenylist) s.fs = filesystem.New(s.ID(), s.DiskSpace(), s.Config().Egg.FileDenylist)
// If this is a virtuakl filesystem we need to go ahead and mount the disk // If this is a virtual filesystem we need to go ahead and mount the disk
// so that everything is accessible. // so that everything is accessible.
if s.fs.IsVirtual() && !m.skipVhdInitialization { if s.fs.IsVirtual() && !m.skipVhdInitialization {
log.WithField("server", s.Id()).Info("mounting virtual disk for server") log.WithField("server", s.ID()).Info("mounting virtual disk for server")
if err := s.fs.MountDisk(ctx); err != nil { if err := s.fs.MountDisk(ctx); err != nil {
return nil, err return nil, err
} }

View File

@ -179,6 +179,8 @@ func (s *Server) Log() *log.Entry {
// //
// This also means mass actions can be performed against servers on the Panel // This also means mass actions can be performed against servers on the Panel
// and they will automatically sync with Wings when the server is started. // and they will automatically sync with Wings when the server is started.
//
// TODO: accept a context value rather than using the server's context.
func (s *Server) Sync() error { func (s *Server) Sync() error {
cfg, err := s.client.GetServerConfiguration(s.Context(), s.ID()) cfg, err := s.client.GetServerConfiguration(s.Context(), s.ID())
if err != nil { if err != nil {
@ -194,7 +196,9 @@ func (s *Server) Sync() error {
// Update the disk space limits for the server whenever the configuration for // Update the disk space limits for the server whenever the configuration for
// it changes. // it changes.
s.fs.SetDiskLimit(s.DiskSpace()) if err := s.fs.SetDiskLimit(s.Context(), s.DiskSpace()); err != nil {
return errors.WrapIf(err, "server: failed to sync server configuration from API")
}
s.SyncWithEnvironment() s.SyncWithEnvironment()