Add support for getting a server's log file via the API

This commit is contained in:
Dane Everitt 2019-04-06 12:27:44 -07:00
parent b6bc9adf29
commit 7d67be8382
No known key found for this signature in database
GPG Key ID: EEA66103B3D71F53
5 changed files with 152 additions and 1 deletions

2
go.mod
View File

@ -21,7 +21,7 @@ require (
github.com/onsi/gomega v1.5.0 // indirect
github.com/opencontainers/go-digest v1.0.0-rc1 // indirect
github.com/opencontainers/image-spec v1.0.1 // indirect
github.com/pkg/errors v0.8.0 // indirect
github.com/pkg/errors v0.8.0
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remeh/sizedwaitgroup v0.0.0-20180822144253-5e7302b12cce
github.com/sirupsen/logrus v1.0.5 // indirect

22
http.go
View File

@ -9,6 +9,7 @@ import (
"io"
"net/http"
"os"
"strconv"
"strings"
)
@ -169,6 +170,25 @@ func (rt *Router) routeServerPower(w http.ResponseWriter, r *http.Request, ps ht
w.WriteHeader(http.StatusAccepted)
}
// Return the last 1Kb of the server log file.
func (rt *Router) routeServerLogs(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
s := rt.Servers.Get(ps.ByName("server"))
l, _ := strconv.ParseInt(r.URL.Query().Get("size"), 10, 64)
if l <= 0 {
l = 2048
}
out, err := s.ReadLogfile(l)
if err != nil {
zap.S().Errorw("failed to read server log file", zap.Error(err))
http.Error(w, "failed to read log", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(struct{Data []string `json:"data"`}{Data: out })
}
// Configures the router and all of the associated routes.
func (rt *Router) ConfigureRouter() *httprouter.Router {
router := httprouter.New()
@ -176,6 +196,8 @@ func (rt *Router) ConfigureRouter() *httprouter.Router {
router.GET("/", rt.routeIndex)
router.GET("/api/servers", rt.AuthenticateToken("i:servers", rt.routeAllServers))
router.GET("/api/servers/:server", rt.AuthenticateToken("s:view", rt.AuthenticateServer(rt.routeServer)))
router.GET("/api/servers/:server/logs", rt.AuthenticateToken("s:logs", rt.AuthenticateServer(rt.routeServerLogs)))
router.POST("/api/servers/:server/power", rt.AuthenticateToken("s:power", rt.AuthenticateServer(rt.routeServerPower)))
return router

View File

@ -1,12 +1,16 @@
package server
import (
"io"
"os"
)
// Defines the basic interface that all environments need to implement so that
// a server can be properly controlled.
type Environment interface {
// Returns the name of the environment.
Type() string
// Starts a server instance. If the server instance is not in a state where it
// can be started an error should be returned.
Start() error
@ -28,4 +32,12 @@ type Environment interface {
// in the Docker environment create will create a new container instance for the
// server.
Create() error
// Attaches to the server console environment and allows piping the output to a
// websocket or other internal tool to monitor output.
Attach() (io.ReadCloser, error)
// Reads the log file for the process from the end backwards until the provided
// number of bytes is met.
Readlog(int64) ([]string, error)
}

View File

@ -1,6 +1,9 @@
package server
import (
"bufio"
"bytes"
"encoding/json"
"fmt"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
@ -8,7 +11,9 @@ import (
"github.com/docker/docker/client"
"github.com/docker/docker/daemon/logger/jsonfilelog"
"github.com/docker/go-connections/nat"
"github.com/pkg/errors"
"golang.org/x/net/context"
"io"
"os"
"strconv"
"strings"
@ -77,6 +82,11 @@ func NewDockerEnvironment(cfg DockerConfiguration) (*DockerEnvironment, error) {
// from the base environment interface.
var _ Environment = (*DockerEnvironment)(nil)
// Returns the name of the environment.
func (d *DockerEnvironment) Type() string {
return "docker"
}
// Determines if the container exists in this environment.
func (d *DockerEnvironment) Exists() bool {
_, err := d.Client.ContainerInspect(context.Background(), d.Server.Uuid)
@ -127,6 +137,31 @@ func (d *DockerEnvironment) Terminate(signal os.Signal) error {
return d.Client.ContainerKill(ctx, d.Server.Uuid, signal.String())
}
// Contrary to the name, this doesn't actually attach to the Docker container itself,
// but rather attaches to the log for the container and then pipes that output to
// a websocket.
//
// This avoids us missing cruicial output that happens in the split seconds before the
// code moves from 'Starting' to 'Attaching' on the process.
//
// @todo add throttle code
func (d *DockerEnvironment) Attach() (io.ReadCloser, error) {
if !d.Exists() {
return nil, errors.New(fmt.Sprintf("no such container: %s", d.Server.Uuid))
}
ctx := context.Background()
opts := types.ContainerLogsOptions{
ShowStderr: true,
ShowStdout: true,
Follow: true,
}
r, err := d.Client.ContainerLogs(ctx, d.Server.Uuid, opts)
return r, err
}
// Creates a new container for the server using all of the data that is currently
// available for it. If the container already exists it will be returned.
func (d *DockerEnvironment) Create() error {
@ -239,6 +274,83 @@ func (d *DockerEnvironment) Create() error {
return nil
}
// Reads the log file for the server. This does not care if the server is running or not, it will
// simply try to read the last X bytes of the file and return them.
func (d *DockerEnvironment) Readlog(len int64) ([]string, error) {
ctx := context.Background()
j, err := d.Client.ContainerInspect(ctx, d.Server.Uuid)
if err != nil {
return nil, err
}
if j.LogPath == "" {
return nil, errors.New("empty log path defined for server")
}
f, err := os.Open(j.LogPath)
if err != nil {
return nil, err
}
defer f.Close()
// Check if the length of the file is smaller than the amount of data that was requested
// for reading. If so, adjust the length to be the total length of the file. If this is not
// done an error is thrown since we're reading backwards, and not forwards.
if stat, err := os.Stat(j.LogPath); err != nil {
return nil, err
} else if stat.Size() < len {
len = stat.Size()
}
// Seed to the end of the file and then move backwards until the length is met to avoid
// reading the entirety of the file into memory.
if _, err := f.Seek(-len, io.SeekEnd); err != nil {
return nil, err
}
b := make([]byte, len)
if _, err := f.Read(b); err != nil && err != io.EOF {
return nil, err
}
return d.parseLogToStrings(b)
}
type dockerLogLine struct {
Log string `json:"log"`
}
// Docker stores the logs for server output in a JSON format. This function will iterate over the JSON
// that was read from the log file and parse it into a more human readable format.
func (d *DockerEnvironment) parseLogToStrings(b []byte) ([]string, error) {
var hasError = false
var out []string
scanner := bufio.NewScanner(bytes.NewReader(b))
for scanner.Scan() {
var l dockerLogLine
// Unmarshal the contents and allow up to a single error before bailing out of the process. We
// do this because if you're arbitrarily reading a length of the file you'll likely end up
// with the first line in the output being improperly formatted JSON. In those cases we want to
// just skip over it. However if we see another error we're going to bail out because that is an
// abnormal situation.
if err := json.Unmarshal([]byte(scanner.Text()), &l); err != nil {
if hasError {
return nil, err
}
hasError = true
continue
}
out = append(out, l.Log)
}
return out, nil
}
// Returns the environment variables for a server in KEY="VALUE" form.
func (d *DockerEnvironment) environmentVariables() []string {
var out = []string{

View File

@ -191,6 +191,11 @@ func FromConfiguration(data []byte, cfg DockerConfiguration) (*Server, error) {
return s, nil
}
// Reads the log file for a server up to a specified number of bytes.
func (s *Server) ReadLogfile(len int64) ([]string, error) {
return s.Environment().Readlog(len)
}
func (s *Server) Environment() Environment {
return s.environment
}