wings/server/install.go
2020-04-03 13:43:13 -07:00

424 lines
11 KiB
Go

package server
import (
"bufio"
"bytes"
"context"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/mount"
"github.com/docker/docker/client"
"github.com/pkg/errors"
"github.com/pterodactyl/wings/api"
"go.uber.org/zap"
"io"
"io/ioutil"
"os"
"path/filepath"
"sync"
)
// Executes the installation stack for a server process. Bubbles any errors up to the calling
// function which should handle contacting the panel to notify it of the server state.
func (s *Server) Install() error {
err := s.internalInstall()
zap.S().Debugw("notifying panel of server install state", zap.String("server", s.Uuid))
if serr := s.SyncInstallState(err == nil); serr != nil {
zap.S().Warnw(
"failed to notify panel of server install state",
zap.String("server", s.Uuid),
zap.Bool("was_successful", err == nil),
zap.Error(serr),
)
}
return err
}
// Reinstalls a server's software by utilizing the install script for the server egg. This
// does not touch any existing files for the server, other than what the script modifies.
func (s *Server) Reinstall() error {
if s.State != ProcessOfflineState {
zap.S().Debugw("waiting for server instance to enter a stopped state", zap.String("server", s.Uuid))
if err := s.Environment.WaitForStop(10, true); err != nil {
return err
}
}
return s.Install()
}
// Internal installation function used to simplify reporting back to the Panel.
func (s *Server) internalInstall() error {
script, rerr, err := api.NewRequester().GetInstallationScript(s.Uuid)
if err != nil || rerr != nil {
if err != nil {
return err
}
return errors.New(rerr.String())
}
p, err := NewInstallationProcess(s, &script)
if err != nil {
return errors.WithStack(err)
}
zap.S().Infow("beginning installation process for server", zap.String("server", s.Uuid))
if err := p.Run(); err != nil {
return err
}
zap.S().Infow("completed installation process for server", zap.String("server", s.Uuid))
return nil
}
type InstallationProcess struct {
Server *Server
Script *api.InstallationScript
client *client.Client
mutex *sync.Mutex
}
// Generates a new installation process struct that will be used to create containers,
// and otherwise perform installation commands for a server.
func NewInstallationProcess(s *Server, script *api.InstallationScript) (*InstallationProcess, error) {
proc := &InstallationProcess{
Script: script,
Server: s,
mutex: &sync.Mutex{},
}
if c, err := client.NewClientWithOpts(client.FromEnv); err != nil {
return nil, errors.WithStack(err)
} else {
proc.client = c
}
return proc, nil
}
// Runs the installation process, this is done as a backgrounded thread. This will configure
// the required environment, and then spin up the installation container.
//
// Once the container finishes installing the results will be stored in an installation
// log in the server's configuration directory.
func (ip *InstallationProcess) Run() error {
installPath, err := ip.BeforeExecute()
if err != nil {
return err
}
cid, err := ip.Execute(installPath)
if err != nil {
return err
}
// If this step fails, log a warning but don't exit out of the process. This is completely
// internal to the daemon's functionality, and does not affect the status of the server itself.
if err := ip.AfterExecute(cid); err != nil {
zap.S().Warnw("failed to complete after-execute step of installation process", zap.String("server", ip.Server.Uuid), zap.Error(err))
}
return nil
}
// Writes the installation script to a temporary file on the host machine so that it
// can be properly mounted into the installation container and then executed.
func (ip *InstallationProcess) writeScriptToDisk() (string, error) {
d, err := ioutil.TempDir("", "pterodactyl")
if err != nil {
return "", errors.WithStack(err)
}
f, err := os.OpenFile(filepath.Join(d, "install.sh"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return "", errors.WithStack(err)
}
defer f.Close()
w := bufio.NewWriter(f)
scanner := bufio.NewScanner(bytes.NewReader([]byte(ip.Script.Script)))
for scanner.Scan() {
w.WriteString(scanner.Text() + "\n")
}
if err := scanner.Err(); err != nil {
return "", errors.WithStack(err)
}
w.Flush()
return d, nil
}
// Pulls the docker image to be used for the installation container.
func (ip *InstallationProcess) pullInstallationImage() error {
r, err := ip.client.ImagePull(context.Background(), ip.Script.ContainerImage, types.ImagePullOptions{})
if err != nil {
return errors.WithStack(err)
}
// Block continuation until the image has been pulled successfully.
scanner := bufio.NewScanner(r)
for scanner.Scan() {
zap.S().Debugw(scanner.Text())
}
if err := scanner.Err(); err != nil {
return errors.WithStack(err)
}
return nil
}
// Runs before the container is executed. This pulls down the required docker container image
// as well as writes the installation script to the disk. This process is executed in an async
// manner, if either one fails the error is returned.
func (ip *InstallationProcess) BeforeExecute() (string, error) {
wg := sync.WaitGroup{}
wg.Add(3)
var e []error
var fileName string
go func() {
defer wg.Done()
name, err := ip.writeScriptToDisk()
if err != nil {
e = append(e, err)
return
}
fileName = name
}()
go func() {
defer wg.Done()
if err := ip.pullInstallationImage(); err != nil {
e = append(e, err)
}
}()
go func() {
defer wg.Done()
opts := types.ContainerRemoveOptions{
RemoveVolumes: true,
Force: true,
}
if err := ip.client.ContainerRemove(context.Background(), ip.Server.Uuid+"_installer", opts); err != nil {
if !client.IsErrNotFound(err) {
e = append(e, err)
}
}
}()
wg.Wait()
// Maybe a better way to handle this, but if there is at least one error
// just bail out of the process now.
if len(e) > 0 {
return "", errors.WithStack(e[0])
}
return fileName, nil
}
// Cleans up after the execution of the installation process. This grabs the logs from the
// process to store in the server configuration directory, and then destroys the associated
// installation container.
func (ip *InstallationProcess) AfterExecute(containerId string) error {
ctx := context.Background()
zap.S().Debugw("pulling installation logs for server", zap.String("server", ip.Server.Uuid), zap.String("container_id", containerId))
reader, err := ip.client.ContainerLogs(ctx, containerId, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: false,
})
if err != nil && !client.IsErrNotFound(err) {
return errors.WithStack(err)
}
f, err := os.OpenFile(filepath.Join("data/install_logs/", ip.Server.Uuid+".log"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return errors.WithStack(err)
}
defer f.Close()
// We write the contents of the container output to a more "permanent" file so that they
// can be referenced after this container is deleted.
if _, err := io.Copy(f, reader); err != nil {
return errors.WithStack(err)
}
zap.S().Debugw("removing server installation container", zap.String("server", ip.Server.Uuid), zap.String("container_id", containerId))
rErr := ip.client.ContainerRemove(ctx, containerId, types.ContainerRemoveOptions{
RemoveVolumes: true,
RemoveLinks: false,
Force: true,
})
if rErr != nil && !client.IsErrNotFound(rErr) {
return errors.WithStack(rErr)
}
return nil
}
// Executes the installation process inside a specially created docker container.
func (ip *InstallationProcess) Execute(installPath string) (string, error) {
ctx := context.Background()
zap.S().Debugw(
"creating server installer container",
zap.String("server", ip.Server.Uuid),
zap.String("script_path", installPath+"/install.sh"),
)
conf := &container.Config{
Hostname: "installer",
AttachStdout: true,
AttachStderr: true,
AttachStdin: true,
OpenStdin: true,
Tty: true,
Cmd: []string{ip.Script.Entrypoint, "./mnt/install/install.sh"},
Image: ip.Script.ContainerImage,
Env: ip.Server.GetEnvironmentVariables(),
Labels: map[string]string{
"Service": "Pterodactyl",
"ContainerType": "server_installer",
},
}
hostConf := &container.HostConfig{
Mounts: []mount.Mount{
{
Target: "/mnt/server",
Source: ip.Server.Filesystem.Path(),
Type: mount.TypeBind,
ReadOnly: false,
},
{
Target: "/mnt/install",
Source: installPath,
Type: mount.TypeBind,
ReadOnly: false,
},
},
Tmpfs: map[string]string{
"/tmp": "rw,exec,nosuid,size=50M",
},
DNS: []string{"1.1.1.1", "8.8.8.8"},
LogConfig: container.LogConfig{
Type: "local",
Config: map[string]string{
"max-size": "5m",
"max-file": "1",
"compress": "false",
},
},
Privileged: true,
NetworkMode: "pterodactyl_nw",
}
zap.S().Infow("creating installer container for server process", zap.String("server", ip.Server.Uuid))
r, err := ip.client.ContainerCreate(ctx, conf, hostConf, nil, ip.Server.Uuid+"_installer")
if err != nil {
return "", errors.WithStack(err)
}
zap.S().Infow(
"running installation script for server in container",
zap.String("server", ip.Server.Uuid),
zap.String("container_id", r.ID),
)
if err := ip.client.ContainerStart(ctx, r.ID, types.ContainerStartOptions{}); err != nil {
return "", err
}
go func(id string) {
ip.Server.Events().Publish(DaemonMessageEvent, "Starting installation process, this could take a few minutes...")
if err := ip.StreamOutput(id); err != nil {
zap.S().Errorw(
"error handling streaming output for server install process",
zap.String("container_id", id),
zap.Error(err),
)
}
ip.Server.Events().Publish(DaemonMessageEvent, "Installation process completed.")
}(r.ID)
sChann, eChann := ip.client.ContainerWait(ctx, r.ID, container.WaitConditionNotRunning)
select {
case err := <-eChann:
if err != nil {
return "", errors.WithStack(err)
}
case <-sChann:
}
return r.ID, nil
}
// Streams the output of the installation process to a log file in the server configuration
// directory, as well as to a websocket listener so that the process can be viewed in
// the panel by administrators.
func (ip *InstallationProcess) StreamOutput(id string) error {
reader, err := ip.client.ContainerLogs(context.Background(), id, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
})
if err != nil {
return errors.WithStack(err)
}
defer reader.Close()
s := bufio.NewScanner(reader)
for s.Scan() {
ip.Server.Events().Publish(InstallOutputEvent, s.Text())
}
if err := s.Err(); err != nil {
zap.S().Warnw(
"error processing scanner line in installation output for server",
zap.String("server", ip.Server.Uuid),
zap.String("container_id", id),
zap.Error(errors.WithStack(err)),
)
}
return nil
}
// Makes a HTTP request to the Panel instance notifying it that the server has
// completed the installation process, and what the state of the server is. A boolean
// value of "true" means everything was successful, "false" means something went
// wrong and the server must be deleted and re-created.
func (s *Server) SyncInstallState(successful bool) error {
r := api.NewRequester()
rerr, err := r.SendInstallationStatus(s.Uuid, successful)
if rerr != nil || err != nil {
if err != nil {
return errors.WithStack(err)
}
return errors.New(rerr.String())
}
return nil
}