/
install.go
525 lines (436 loc) · 15.8 KB
/
install.go
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
package server
import (
"bufio"
"bytes"
"context"
"github.com/apex/log"
"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/avatag-host/claws/api"
"github.com/avatag-host/claws/config"
"github.com/avatag-host/claws/environment"
"golang.org/x/sync/semaphore"
"html/template"
"io"
"os"
"path/filepath"
"strconv"
"time"
)
// 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.
//
// Pass true as the first argument in order to execute a server sync before the process to
// ensure the latest information is used.
func (s *Server) Install(sync bool) error {
if sync {
s.Log().Info("syncing server state with remote source before executing installation process")
if err := s.Sync(); err != nil {
return err
}
}
var err error
if !s.Config().SkipEggScripts {
// Send the start event so the Panel can automatically update. We don't send this unless the process
// is actually going to run, otherwise all sorts of weird rapid UI behavior happens since there isn't
// an actual install process being executed.
s.Events().Publish(InstallStartedEvent, "")
err = s.internalInstall()
} else {
s.Log().Info("server configured to skip running installation scripts for this egg, not executing process")
}
s.Log().Debug("notifying panel of server install state")
if serr := s.SyncInstallState(err == nil); serr != nil {
l := s.Log().WithField("was_successful", err == nil)
// If the request was successful but there was an error with this request, attach the
// error to this log entry. Otherwise ignore it in this log since whatever is calling
// this function should handle the error and will end up logging the same one.
if err == nil {
l.WithField("error", serr)
}
l.Warn("failed to notify panel of server install state")
}
// Ensure that the server is marked as offline at this point, otherwise you end up
// with a blank value which is a bit confusing.
s.SetState(environment.ProcessOfflineState)
// Push an event to the websocket so we can auto-refresh the information in the panel once
// the install is completed.
s.Events().Publish(InstallCompletedEvent, "")
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.GetState() != environment.ProcessOfflineState {
s.Log().Debug("waiting for server instance to enter a stopped state")
if err := s.Environment.WaitForStop(10, true); err != nil {
return err
}
}
return s.Install(true)
}
// Internal installation function used to simplify reporting back to the Panel.
func (s *Server) internalInstall() error {
script, err := api.New().GetInstallationScript(s.Id())
if err != nil {
if !api.IsRequestError(err) {
return errors.WithStack(err)
}
return errors.New(err.Error())
}
p, err := NewInstallationProcess(s, &script)
if err != nil {
return errors.WithStack(err)
}
s.Log().Info("beginning installation process for server")
if err := p.Run(); err != nil {
return err
}
s.Log().Info("completed installation process for server")
return nil
}
type InstallationProcess struct {
Server *Server
Script *api.InstallationScript
client *client.Client
context context.Context
}
// 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,
}
ctx, cancel := context.WithCancel(context.Background())
s.installer.cancel = &cancel
if c, err := environment.DockerClient(); err != nil {
return nil, errors.WithStack(err)
} else {
proc.client = c
proc.context = ctx
}
return proc, nil
}
// Try to obtain an exclusive lock on the installation process for the server. Waits up to 10
// seconds before aborting with a context timeout.
func (s *Server) acquireInstallationLock() error {
if s.installer.sem == nil {
s.installer.sem = semaphore.NewWeighted(1)
}
ctx, _ := context.WithTimeout(context.Background(), time.Second*10)
return s.installer.sem.Acquire(ctx, 1)
}
// Determines if the server is actively running the installation process by checking the status
// of the semaphore lock.
func (s *Server) IsInstalling() bool {
if s.installer.sem == nil {
return false
}
if s.installer.sem.TryAcquire(1) {
// If we made it into this block it means we were able to obtain an exclusive lock
// on the semaphore. In that case, go ahead and release that lock immediately, and
// return false.
s.installer.sem.Release(1)
return false
}
return true
}
// Aborts the server installation process by calling the cancel function on the installer
// context.
func (s *Server) AbortInstallation() {
if !s.IsInstalling() {
return
}
if s.installer.cancel != nil {
cancel := *s.installer.cancel
s.Log().Warn("aborting running installation process")
cancel()
}
}
// Removes the installer container for the server.
func (ip *InstallationProcess) RemoveContainer() {
err := ip.client.ContainerRemove(ip.context, ip.Server.Id()+"_installer", types.ContainerRemoveOptions{
RemoveVolumes: true,
Force: true,
})
if err != nil && !client.IsErrNotFound(err) {
ip.Server.Log().WithField("error", errors.WithStack(err)).Warn("failed to delete server install container")
}
}
// Runs the installation process, this is done as in a background 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 {
ip.Server.Log().Debug("acquiring installation process lock")
if err := ip.Server.acquireInstallationLock(); err != nil {
return err
}
// We now have an exclusive lock on this installation process. Ensure that whenever this
// process is finished that the semaphore is released so that other processes and be executed
// without encountering a wait timeout.
defer func() {
ip.Server.Log().Debug("releasing installation process lock")
ip.Server.installer.sem.Release(1)
ip.Server.installer.cancel = nil
}()
if err := ip.BeforeExecute(); err != nil {
return errors.WithStack(err)
}
cid, err := ip.Execute()
if err != nil {
ip.RemoveContainer()
return errors.WithStack(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 {
ip.Server.Log().WithField("error", err).Warn("failed to complete after-execute step of installation process")
}
return nil
}
// Returns the location of the temporary data for the installation process.
func (ip *InstallationProcess) tempDir() string {
return filepath.Join(os.TempDir(), "pterodactyl/", ip.Server.Id())
}
// 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() error {
// Make sure the temp directory root exists before trying to make a directory within it. The
// ioutil.TempDir call expects this base to exist, it won't create it for you.
if err := os.MkdirAll(ip.tempDir(), 0700); err != nil {
return errors.Wrap(err, "could not create temporary directory for install process")
}
f, err := os.OpenFile(filepath.Join(ip.tempDir(), "install.sh"), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return errors.Wrap(err, "failed to write server installation script to disk before mount")
}
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 nil
}
// Pulls the docker image to be used for the installation container.
func (ip *InstallationProcess) pullInstallationImage() error {
r, err := ip.client.ImagePull(ip.context, 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() {
log.Debug(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() error {
if err := ip.writeScriptToDisk(); err != nil {
return errors.Wrap(err, "failed to write installation script to disk")
}
if err := ip.pullInstallationImage(); err != nil {
return errors.Wrap(err, "failed to pull updated installation container image for server")
}
opts := types.ContainerRemoveOptions{
RemoveVolumes: true,
Force: true,
}
if err := ip.client.ContainerRemove(ip.context, ip.Server.Id()+"_installer", opts); err != nil {
if !client.IsErrNotFound(err) {
return errors.Wrap(err, "failed to remove existing install container for server")
}
}
return nil
}
// Returns the log path for the installation process.
func (ip *InstallationProcess) GetLogPath() string {
return filepath.Join(config.Get().System.GetInstallLogPath(), ip.Server.Id()+".log")
}
// 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 {
defer ip.RemoveContainer()
ip.Server.Log().WithField("container_id", containerId).Debug("pulling installation logs for server")
reader, err := ip.client.ContainerLogs(ip.context, containerId, types.ContainerLogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: false,
})
if err != nil && !client.IsErrNotFound(err) {
return errors.WithStack(err)
}
f, err := os.OpenFile(ip.GetLogPath(), 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. We'll also include the environment
// variables passed into the container to make debugging things a little easier.
ip.Server.Log().WithField("path", ip.GetLogPath()).Debug("writing most recent installation logs to disk")
tmpl, err := template.New("header").Parse(`Pterodactyl Server Installation Log
|
| Details
| ------------------------------
Server UUID: {{.Server.Id}}
Container Image: {{.Script.ContainerImage}}
Container Entrypoint: {{.Script.Entrypoint}}
|
| Environment Variables
| ------------------------------
{{ range $key, $value := .Server.GetEnvironmentVariables }} {{ $value }}
{{ end }}
|
| Script Output
| ------------------------------
`)
if err != nil {
return errors.WithStack(err)
}
if err := tmpl.Execute(f, ip); err != nil {
return errors.WithStack(err)
}
if _, err := io.Copy(f, reader); err != nil {
return errors.WithStack(err)
}
return nil
}
// Executes the installation process inside a specially created docker container.
func (ip *InstallationProcess) Execute() (string, error) {
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",
},
}
tmpfsSize := strconv.Itoa(int(config.Get().Docker.TmpfsSize))
hostConf := &container.HostConfig{
Mounts: []mount.Mount{
{
Target: "/mnt/server",
Source: ip.Server.Filesystem().Path(),
Type: mount.TypeBind,
ReadOnly: false,
},
{
Target: "/mnt/install",
Source: ip.tempDir(),
Type: mount.TypeBind,
ReadOnly: false,
},
},
Tmpfs: map[string]string{
"/tmp": "rw,exec,nosuid,size=" + tmpfsSize + "M",
},
DNS: config.Get().Docker.Network.Dns,
LogConfig: container.LogConfig{
Type: "local",
Config: map[string]string{
"max-size": "5m",
"max-file": "1",
"compress": "false",
},
},
Privileged: true,
NetworkMode: container.NetworkMode(config.Get().Docker.Network.Mode),
}
ip.Server.Log().WithField("install_script", ip.tempDir()+"/install.sh").Info("creating install container for server process")
// Remove the temporary directory when the installation process finishes for this server container.
defer func() {
if err := os.RemoveAll(ip.tempDir()); err != nil {
if !os.IsNotExist(err) {
ip.Server.Log().WithField("error", err).Warn("failed to remove temporary data directory after install process")
}
}
}()
r, err := ip.client.ContainerCreate(ip.context, conf, hostConf, nil, ip.Server.Id()+"_installer")
if err != nil {
return "", errors.WithStack(err)
}
ip.Server.Log().WithField("container_id", r.ID).Info("running installation script for server in container")
if err := ip.client.ContainerStart(ip.context, 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 {
ip.Server.Log().WithField("error", err).Error("error while handling output stream for server install process")
}
ip.Server.Events().Publish(DaemonMessageEvent, "Installation process completed.")
}(r.ID)
sChan, eChan := ip.client.ContainerWait(ip.context, r.ID, container.WaitConditionNotRunning)
select {
case err := <-eChan:
if err != nil {
return "", errors.WithStack(err)
}
case <-sChan:
}
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(ip.context, 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 {
ip.Server.Log().WithFields(log.Fields{
"container_id": id,
"error": errors.WithStack(err),
}).Warn("error processing scanner line in installation output for server")
}
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 {
err := api.New().SendInstallationStatus(s.Id(), successful)
if err != nil {
if !api.IsRequestError(err) {
return errors.WithStack(err)
}
return errors.New(err.Error())
}
return nil
}