diff --git a/internal/vhd/vhd.go b/internal/vhd/vhd.go index b385f6e..a928f84 100644 --- a/internal/vhd/vhd.go +++ b/internal/vhd/vhd.go @@ -2,23 +2,24 @@ package vhd import ( "context" + "emperror.dev/errors" "fmt" + "github.com/pterodactyl/wings/config" + "github.com/spf13/afero" "os" "os/exec" "path" "path/filepath" "strings" "sync" - - "emperror.dev/errors" - "github.com/pterodactyl/wings/config" - "github.com/spf13/afero" + "sync/atomic" ) var ( 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") + ErrFilesystemNotMounted = errors.Sentinel("vhd: filesystem is not mounted") 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. type Disk struct { + mu sync.RWMutex // The total size of the disk allowed in bytes. size int64 // 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 // if the path exists but the destination is not a file or is a symlink. func (d *Disk) Exists() (bool, error) { + d.mu.RLock() + defer d.mu.RUnlock() st, err := d.fs.Stat(d.diskPath) if err != nil && os.IsNotExist(err) { 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 // error is returned to the caller. 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 + d.mu.Lock() + defer d.mu.Unlock() + return d.mount(ctx) } // 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 -// other error encountered while unmounting will return an error to the caller. +// currently mounted this function is a no-op and ErrFilesystemNotMounted is +// returned to the caller. func (d *Disk) Unmount(ctx context.Context) error { - cmd := d.commander(ctx, "umount", d.mountAt) - if err := cmd.Run(); err != nil { - if v, ok := err.(hasExitCode); !ok || v.ExitCode() != 32 { - 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) + d.mu.Lock() + defer d.mu.Unlock() + return d.unmount(ctx) } // 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 // action, use caution when calling it during normal processes. func (d *Disk) Allocate(ctx context.Context) error { + d.mu.Lock() + defer d.mu.Unlock() if exists, err := d.Exists(); exists { // If the disk currently exists attempt to unmount the mount point before // allocating space. @@ -253,11 +218,30 @@ func (d *Disk) Allocate(ctx context.Context) error { 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 // 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 { + d.mu.Lock() + defer d.mu.Unlock() // 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) @@ -285,3 +269,62 @@ func (d *Disk) MakeFilesystem(ctx context.Context) error { } 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) +} diff --git a/server/filesystem/disk_space.go b/server/filesystem/disk_space.go index db36a8f..2794fa2 100644 --- a/server/filesystem/disk_space.go +++ b/server/filesystem/disk_space.go @@ -1,6 +1,8 @@ package filesystem import ( + "context" + "github.com/pterodactyl/wings/internal/vhd" "sync" "sync/atomic" "syscall" @@ -35,14 +37,42 @@ func (ult *usageLookupTime) Get() time.Time { 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 { return atomic.LoadInt64(&fs.diskLimit) } -// Sets the disk space limit for this Filesystem instance. -func (fs *Filesystem) SetDiskLimit(i int64) { - atomic.SwapInt64(&fs.diskLimit, i) +// SetDiskLimit sets the disk space limit for this Filesystem instance. This +// logic will also handle mounting or unmounting a virtual disk if it is being +// 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 @@ -76,8 +106,7 @@ func (fs *Filesystem) HasSpaceErr(allowStaleValue bool) error { func (fs *Filesystem) HasSpaceAvailable(allowStaleValue bool) bool { size, err := fs.DiskUsage(allowStaleValue) if err != nil { - log.WithField("root", fs.root).WithField("error", err). - Warn("failed to determine root fs directory size") + fs.log().WithField("error", err).Warn("failed to determine root fs directory size") } return fs.MaxDisk() == 0 || size <= fs.MaxDisk() } @@ -125,8 +154,7 @@ func (fs *Filesystem) DiskUsage(allowStaleValue bool) (int64, error) { if !fs.lookupInProgress.Load() { go func(fs *Filesystem) { if _, err := fs.updateCachedDiskUsage(); err != nil { - log.WithField("root", fs.root).WithField("error", err). - Warn("failed to update fs disk usage from within routine") + fs.log().WithField("error", err).Warn("failed to update fs disk usage from within routine") } }(fs) } @@ -249,3 +277,7 @@ func (fs *Filesystem) addDisk(i int64) int64 { return atomic.AddInt64(&fs.diskUsed, i) } + +func (fs *Filesystem) log() *log.Entry { + return log.WithField("server", fs.uuid).WithField("root", fs.root) +} diff --git a/server/filesystem/filesystem.go b/server/filesystem/filesystem.go index 3243f62..5153e45 100644 --- a/server/filesystem/filesystem.go +++ b/server/filesystem/filesystem.go @@ -38,6 +38,7 @@ type Filesystem struct { // The root data directory path for this Filesystem instance. root string + uuid string isTest bool } @@ -46,6 +47,7 @@ type Filesystem struct { func New(uuid string, size int64, denylist []string) *Filesystem { root := filepath.Join(config.Get().System.Data, uuid) fs := Filesystem{ + uuid: uuid, root: root, diskLimit: size, diskCheckInterval: time.Duration(config.Get().System.DiskCheckInterval), diff --git a/server/filesystem/virtual.go b/server/filesystem/virtual.go index 32e11a5..4906388 100644 --- a/server/filesystem/virtual.go +++ b/server/filesystem/virtual.go @@ -11,13 +11,29 @@ func (fs *Filesystem) IsVirtual() bool { return fs.vhd != 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. If the filesystem is -// not configured for virtual disks this function will panic. -func (fs *Filesystem) MountDisk(ctx context.Context) error { - if !fs.IsVirtual() { - panic(errors.New("filesystem: cannot call MountDisk on Filesystem instance without VHD present")) +// ConfigureDisk will attempt to create a new VHD if there is not one already +// created for the filesystem. If there is this method will attempt to resize +// the underlying data volume. Passing a size of 0 or less will panic. +func (fs *Filesystem) ConfigureDisk(ctx context.Context, size int64) error { + if size <= 0 { + 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) if errors.Is(err, vhd.ErrFilesystemMounted) { return nil diff --git a/server/manager.go b/server/manager.go index 96071cb..cff1a2b 100644 --- a/server/manager.go +++ b/server/manager.go @@ -197,11 +197,11 @@ func (m *Manager) InitServer(ctx context.Context, data remote.ServerConfiguratio return nil, errors.WithStackIf(err) } - 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 + s.fs = filesystem.New(s.ID(), s.DiskSpace(), s.Config().Egg.FileDenylist) + // If this is a virtual filesystem we need to go ahead and mount the disk // so that everything is accessible. 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 { return nil, err } diff --git a/server/server.go b/server/server.go index 010fd9c..b5201a9 100644 --- a/server/server.go +++ b/server/server.go @@ -179,6 +179,8 @@ func (s *Server) Log() *log.Entry { // // 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. +// +// TODO: accept a context value rather than using the server's context. func (s *Server) Sync() error { cfg, err := s.client.GetServerConfiguration(s.Context(), s.ID()) if err != nil { @@ -194,7 +196,9 @@ func (s *Server) Sync() error { // Update the disk space limits for the server whenever the configuration for // 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()