From 064d6e648781ba229f00deeda2c25632f5000f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20=C5=A0trauch?= Date: Fri, 10 Jul 2020 00:27:23 +0200 Subject: [PATCH] Implementation of Docker --- apps/main.go | 32 +++++-- apps/types.go | 56 ++++++++++-- docker/docker.go | 222 +++++++++++++++++++++++++++++++++++++++++++++++ docker/du.go | 50 +++++++++++ docker/main.go | 1 + docker/types.go | 58 +++++++++++++ go.mod | 2 + main.go | 6 ++ types.go | 4 +- 9 files changed, 416 insertions(+), 15 deletions(-) create mode 100644 docker/docker.go create mode 100644 docker/du.go create mode 100644 docker/main.go create mode 100644 docker/types.go diff --git a/apps/main.go b/apps/main.go index 0f794ca..8fb157f 100644 --- a/apps/main.go +++ b/apps/main.go @@ -37,7 +37,7 @@ func List() (*[]App, error) { } // New creates new record about application in the database -func New(name string, SSHPort int, HTTPPort int, image string, CPU string, memory int) error { +func New(name string, SSHPort int, HTTPPort int, image string, CPU int, memory int) error { app := App{ Name: name, SSHPort: SSHPort, @@ -49,6 +49,13 @@ func New(name string, SSHPort int, HTTPPort int, image string, CPU string, memor db := common.GetDBConnection() + validationErrors := app.Validate() + if len(validationErrors) != 0 { + return ValidationError{ + Errors: validationErrors, + } + } + if err := db.Create(app).Error; err != nil { return err } @@ -57,7 +64,7 @@ func New(name string, SSHPort int, HTTPPort int, image string, CPU string, memor } // Update changes value about app in the database -func Update(name string, SSHPort int, HTTPPort int, image string, CPU string, memory int) error { +func Update(name string, SSHPort int, HTTPPort int, image string, CPU int, memory int) error { var app App db := common.GetDBConnection() @@ -67,13 +74,20 @@ func Update(name string, SSHPort int, HTTPPort int, image string, CPU string, me return err } - err = db.Model(&app).Updates(App{ - SSHPort: SSHPort, - HTTPPort: HTTPPort, - Image: image, - CPU: CPU, - Memory: memory, - }).Error + app.SSHPort = SSHPort + app.HTTPPort = HTTPPort + app.Image = image + app.CPU = CPU + app.Memory = memory + + validationErrors := app.Validate() + if len(validationErrors) != 0 { + return ValidationError{ + Errors: validationErrors, + } + } + + err = db.Update(&app).Error return err } diff --git a/apps/types.go b/apps/types.go index 1b40e19..6ff1f00 100644 --- a/apps/types.go +++ b/apps/types.go @@ -1,6 +1,20 @@ package apps -import "github.com/jinzhu/gorm" +import ( + "regexp" + "strings" + + "github.com/jinzhu/gorm" +) + +// ValidationError is error that holds multiple validation error messages +type ValidationError struct { + Errors []string +} + +func (v ValidationError) Error() string { + return strings.Join(v.Errors, "\n") +} // Label holds metadata about the application type Label struct { @@ -15,12 +29,44 @@ type App struct { SSHPort int `json:"ssh_port"` HTTPPort int `json:"http_port"` Image string `json:"image"` - Status string `json:"status"` // running, data (no container, data only), stopped - CPU string `json:"cpu"` + CPU int `json:"cpu"` // percentage, 200 means two CPU Memory int `json:"memory"` // Limit in MB Labels []Label `json:"labels"` // username:cx or user_id:1 - MemoryUsage int `json:"memory_usage"` // Usage in MB - DiskUsage int `json:"disk_usage"` // Usage in MB + Status string `json:"status"` // running, data-only (no container, data only), stopped + MemoryUsage int `json:"memory_usage"` // Usage in MB + DiskUsage int `json:"disk_usage"` // Usage in MB } + +// Validate do basic checks of the struct values +func (a *App) Validate() []string { + var errors []string + + nameRegExp := regexp.MustCompile(`^[a-z0-9_]{3,48}$`) + if !nameRegExp.MatchString(a.Name) { + errors = append(errors, "name can contain only characters, numbers and underscores and has to be between 3 and 48 characters") + } + + if a.SSHPort < 0 && a.SSHPort > 65536 { + errors = append(errors, "SSH port has to be between 0 and 65536, where 0 means disabled") + } + + if a.HTTPPort < 1 && a.HTTPPort > 65536 { + errors = append(errors, "HTTP port has to be between 1 and 65536") + } + + if a.Image == "" { + errors = append(errors, "image cannot be empty") + } + + if a.CPU < 10 && a.CPU > 800 { + errors = append(errors, "CPU value has be between 10 and 800") + } + + if a.Memory < 32 && a.Memory > 16384 { + errors = append(errors, "Memory value has be between 32 and 16384") + } + + return errors +} diff --git a/docker/docker.go b/docker/docker.go new file mode 100644 index 0000000..4de3086 --- /dev/null +++ b/docker/docker.go @@ -0,0 +1,222 @@ +package docker + +import ( + "context" + "errors" + "log" + "strconv" + "strings" + "time" + + "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + dockerClient "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" +) + +// Docker timeout +const dockerTimeout = 10 + +// DOCKER_SOCK tells where to connect to docker, it will be always local sock +const dockerSock = "unix:///var/run/docker.sock" + +// DOCKER_API_VERSION set API version of Docker, 1.40 belongs to Docker 19.03.11 +const dockerAPIVersion = "1.40" + +// Driver keeps everything for connection to Docker +type Driver struct{} + +func (d *Driver) getClient() (*dockerClient.Client, error) { + cli, err := dockerClient.NewClient(dockerSock, dockerAPIVersion, nil, nil) + return cli, err +} + +// ConnectionStatus checks connection to the Docker daemon +func (d *Driver) ConnectionStatus() (bool, error) { + cli, err := d.getClient() + if err != nil { + return false, err + } + + _, err = cli.ServerVersion(context.TODO()) + if err != nil { + return false, err + } + + return true, nil +} + +func (d *Driver) nameToID(name string) (string, error) { + containerIDs, err := d.IsExist(name) + if err != nil { + return "", err + } + + if len(containerIDs) == 0 { + return "", errors.New("no container found") + } + if len(containerIDs) > 1 { + return "", errors.New("multiple containers with the same name") + } + + return containerIDs[0], nil +} + +// Remove removes container represented by containerID +func (d *Driver) Remove(name string) error { + log.Println("Removing container " + name) + cli, err := d.getClient() + if err != nil { + return err + } + + containerID, err := d.nameToID(name) + if err != nil { + return err + } + + timeout := time.Duration(dockerTimeout * time.Second) + err = cli.ContainerStop(context.TODO(), containerID, &timeout) + if err != nil { + return err + } + + err = cli.ContainerRemove(context.TODO(), containerID, types.ContainerRemoveOptions{}) + + return err +} + +// Start starts container represented by containerID +func (d *Driver) Start(name string) error { + log.Println("Starting container " + name) + cli, err := d.getClient() + if err != nil { + return err + } + + containerID, err := d.nameToID(name) + if err != nil { + return err + } + + err = cli.ContainerStart(context.TODO(), containerID, types.ContainerStartOptions{}) + + return err +} + +// Stop stops container represented by containerID +func (d *Driver) Stop(name string) error { + log.Println("Stopping container " + name) + cli, err := d.getClient() + if err != nil { + return err + } + + containerID, err := d.nameToID(name) + if err != nil { + return err + } + + timeout := time.Duration(dockerTimeout * time.Second) + err = cli.ContainerStop(context.TODO(), containerID, &timeout) + + return err +} + +// IsExist checks existence of the container based on container name +// Returns container IDs in case of existence. Otherwise +// empty slice. +func (d *Driver) IsExist(name string) ([]string, error) { + var containerIDs = make([]string, 0) + + cli, err := d.getClient() + if err != nil { + return containerIDs, err + } + + containers, err := cli.ContainerList(context.TODO(), types.ContainerListOptions{}) + if err != nil { + return containerIDs, err + } + + // We go through the containers and pick the ones which match the task name + for _, containerObject := range containers { + for _, name := range containerObject.Names { + name = strings.Trim(name, "/") + if strings.Split(name, ".")[0] == name { + containerIDs = append(containerIDs, containerObject.ID) + } + } + } + + return containerIDs, nil +} + +// Create creates the container +// image - docker image +// cmd - string slice of command and its arguments +// volumePath - host's directory to mount into the container +// returns container ID +func (d *Driver) Create(name string, image string, volumePath string, HTTPPort int, SSHPort int, CPU int, memory int, cmd []string) (string, error) { + log.Println("Creating container " + name) + cli, err := d.getClient() + if err != nil { + return "", err + } + + portmaps := make(nat.PortMap, 1) + + portbindingsHTTP := make([]nat.PortBinding, 1) + portbindingsHTTP[0] = nat.PortBinding{ + HostPort: strconv.Itoa(HTTPPort) + "/tcp", + } + portmaps["8000/tcp"] = portbindingsHTTP + + if SSHPort != 0 { + portbindingsSSH := make([]nat.PortBinding, 1) + portbindingsSSH[0] = nat.PortBinding{ + HostPort: strconv.Itoa(SSHPort) + "/tcp", + } + portmaps["22/tcp"] = portbindingsSSH + } + + createdContainer, err := cli.ContainerCreate( + context.TODO(), + &container.Config{ + Hostname: name, + Env: []string{}, + Image: image, + Cmd: cmd, + }, + &container.HostConfig{ + Resources: container.Resources{ + CPUPeriod: 100, + CPUQuota: int64(CPU), + Memory: int64(memory*110/100)*1024 ^ 2, // Allow 10 % more memory so we have space for MemoryReservation + MemoryReservation: int64(memory)*1024 ^ 2, // This should provide softer way how to limit the memory of our containers + }, + PortBindings: portmaps, + AutoRemove: false, + RestartPolicy: container.RestartPolicy{ + Name: "unless-stopped", + MaximumRetryCount: 3, + }, + Binds: []string{ + volumePath + ":/srv", + }, + }, + &network.NetworkingConfig{}, + name, + ) + if err != nil { + return "", err + } + + containerID := createdContainer.ID + + // I dunno if we want this + // err = cli.ContainerStart(context.TODO(), createdContainer.ID, types.ContainerStartOptions{}) + + return containerID, nil +} diff --git a/docker/du.go b/docker/du.go new file mode 100644 index 0000000..e4668ad --- /dev/null +++ b/docker/du.go @@ -0,0 +1,50 @@ +package docker + +import ( + "bytes" + "os/exec" + "strconv" + "strings" +) + +// Return bytes, inodes occupied by a directory and/or error if there is any +func du(path string) (int, int, error) { + space, inodes := 0, 0 + + // Occupied space + var out bytes.Buffer + command := exec.Command("/usr/bin/du -m -s " + path) + command.Stdout = &out + err := command.Run() + if err != nil { + return space, inodes, err + } + fields := strings.Fields(strings.TrimSpace(out.String())) + out.Reset() + + if len(fields) == 2 { + space, err = strconv.Atoi(fields[0]) + if err != nil { + return space, inodes, err + } + } + + // Occupied inodes + command = exec.Command("/usr/bin/du --inodes -s " + path) + command.Stdout = &out + err = command.Run() + if err != nil { + return space, inodes, err + } + fields = strings.Fields(strings.TrimSpace(out.String())) + out.Reset() + + if len(fields) == 2 { + space, err = strconv.Atoi(fields[0]) + if err != nil { + return inodes, inodes, err + } + } + + return space, inodes, nil +} diff --git a/docker/main.go b/docker/main.go new file mode 100644 index 0000000..1cdc3ff --- /dev/null +++ b/docker/main.go @@ -0,0 +1 @@ +package docker diff --git a/docker/types.go b/docker/types.go new file mode 100644 index 0000000..a88c3e9 --- /dev/null +++ b/docker/types.go @@ -0,0 +1,58 @@ +package docker + +import ( + "path" + + "github.com/rosti-cz/apps-api/apps" +) + +// Container extends App struct from App +type Container struct { + App apps.App +} + +// volumeHostPath each container has one volume mounted into it, +func (c *Container) volumeHostPath() string { + return path.Join("/srv", c.App.Name) +} + +// Status returns state of the container +// Possible values: running, stopped, data-only +func (c *Container) Status() (string, error) { + return "", nil +} + +// DiskSpace returns number of MB and inodes used by the container in it's mounted volume +func (c *Container) DiskSpace() (int, int, error) { + return du(c.volumeHostPath()) +} + +// Create creates the container +func (c *Container) Create() error { + return nil +} + +// Start starts the container +func (c *Container) Start() error { + return nil +} + +// Stop stops the container +func (c *Container) Stop() error { + return nil +} + +// Reset restarts the container +func (c *Container) Reset() error { + return nil +} + +// Destroy removes the container but keeps the data so it can be created again +func (c *Container) Destroy() error { + return nil +} + +// Delete removes both data and the container +func (c *Container) Delete() error { + return nil +} diff --git a/go.mod b/go.mod index 5f75b66..77d8260 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,6 @@ require ( github.com/labstack/echo v3.3.10+incompatible github.com/labstack/gommon v0.3.0 // indirect golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 // indirect + github.com/docker/docker v1.13.1 + github.com/docker/go-connections v0.4.0 ) diff --git a/main.go b/main.go index 6167659..40d71b0 100644 --- a/main.go +++ b/main.go @@ -53,6 +53,9 @@ func main() { err = apps.New(app.Name, app.SSHPort, app.HTTPPort, app.Image, app.CPU, app.Memory) if err != nil { + if validationError, ok := err.(apps.ValidationError); ok { + return c.JSONPretty(http.StatusBadRequest, Message{Errors: validationError.Errors}, JSONIndent) + } return c.JSONPretty(http.StatusInternalServerError, Message{Message: err.Error()}, JSONIndent) } @@ -71,6 +74,9 @@ func main() { err = apps.Update(name, app.SSHPort, app.HTTPPort, app.Image, app.CPU, app.Memory) if err != nil { + if validationError, ok := err.(apps.ValidationError); ok { + return c.JSONPretty(http.StatusBadRequest, Message{Errors: validationError.Errors}, JSONIndent) + } return c.JSONPretty(http.StatusInternalServerError, Message{Message: err.Error()}, JSONIndent) } diff --git a/types.go b/types.go index 1751eee..8d59c67 100644 --- a/types.go +++ b/types.go @@ -1,5 +1,7 @@ package main +// Message represents response with information about results of something type Message struct { - Message string `json:"message"` + Message string `json:"message,omitempty"` + Errors []string `json:"errors,omitempty"` }