diff --git a/instance_manager/docker/instance_manager.go b/instance_manager/docker/instance_manager.go index 46c3870..03ff4a4 100644 --- a/instance_manager/docker/instance_manager.go +++ b/instance_manager/docker/instance_manager.go @@ -4,22 +4,43 @@ import ( "context" "encoding/json" "fmt" + "io" "log" "net" "strings" instancemanager "git.acooldomain.co/server-manager/backend-kubernetes-go/instance_manager" "git.acooldomain.co/server-manager/backend-kubernetes-go/models" + "github.com/buildkite/shellwords" + "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/mount" + "github.com/docker/docker/api/types/network" "github.com/docker/docker/api/types/volume" "github.com/docker/docker/client" "github.com/docker/go-connections/nat" + v1 "github.com/opencontainers/image-spec/specs-go/v1" ) type InstanceManager struct { instancemanager.InstanceManager client client.Client + config models.DockerInstanceManagerConfig +} + +func (self *InstanceManager) containerList(ctx context.Context, labels ContainerLabels, all bool) ([]types.Container, error) { + filters, err := convertLabelsToFilter(labels) + if err != nil { + return nil, err + } + + containers, err := self.client.ContainerList(ctx, container.ListOptions{Filters: *filters, All: all}) + if err != nil { + return nil, err + } + + return containers, nil } func (self *InstanceManager) getVolume(ctx context.Context, serverId string) (*volume.Volume, error) { @@ -27,14 +48,8 @@ func (self *InstanceManager) getVolume(ctx context.Context, serverId string) (*v if err != nil { return nil, err } - var labels VolumeLabels - rawLabels, err := json.Marshal(volume.Labels) - if err != nil { - return nil, err - } - - err = json.Unmarshal(rawLabels, &labels) + labels, err := convertVolumeLabelsToStruct(volume.Labels) if err != nil { return nil, err } @@ -46,30 +61,6 @@ func (self *InstanceManager) getVolume(ctx context.Context, serverId string) (*v return &volume, err } -func convertImagePortsToPorts(rawPorts nat.PortSet) []instancemanager.Port { - ports := make([]instancemanager.Port, len(rawPorts)) - i := 0 - for imagePort := range rawPorts { - portNumber := imagePort.Int() - var protocol models.PortProtocol - switch imagePort.Proto() { - case "TCP": - protocol = models.TCP - case "UDP": - protocol = models.UDP - default: - log.Default().Println(fmt.Sprintf("Unknown port protocol %s using TCP", imagePort.Proto())) - protocol = models.TCP - } - ports[i] = instancemanager.Port{ - Number: uint16(portNumber), - Protocol: protocol, - } - i++ - } - return ports -} - // General // Read Only func (self *InstanceManager) ListImages(ctx context.Context) ([]instancemanager.Image, error) { @@ -108,17 +99,12 @@ func (self *InstanceManager) GetServer(ctx context.Context, serverId string) (*i if err != nil { return nil, err } - - filterArgs, err := convertLabelsToFilter(ContainerLabels{ - VolumeId: volume.Name, - Type: Game, - }) - - serverContainers, err := self.client.ContainerList( + serverContainers, err := self.containerList( ctx, - container.ListOptions{ - Filters: *filterArgs, + ContainerLabels{ + VolumeId: serverId, }, + true, ) if err != nil { return nil, err @@ -136,17 +122,6 @@ func (self *InstanceManager) GetServer(ctx context.Context, serverId string) (*i serverContainer := serverContainers[0] - var containerLabels ContainerLabels - - rawContainerLabels, err := json.Marshal(serverContainer.Labels) - if err != nil { - return nil, err - } - - if err = json.Unmarshal(rawContainerLabels, &containerLabels); err != nil { - return nil, err - } - running := serverContainer.State == "running" runningCommand := serverContainer.Command @@ -167,11 +142,6 @@ func (self *InstanceManager) ListServers(ctx context.Context) ([]instancemanager return nil, err } - containerFilters, err := convertLabelsToFilter(ContainerLabels{Type: Game}) - if err != nil { - return nil, err - } - volumes, err := self.client.VolumeList(ctx, volume.ListOptions{Filters: *volumeFilter}) if err != nil { return nil, err @@ -189,7 +159,7 @@ func (self *InstanceManager) ListServers(ctx context.Context) ([]instancemanager } } - containers, err := self.client.ContainerList(ctx, container.ListOptions{Filters: *containerFilters}) + containers, err := self.containerList(ctx, ContainerLabels{Type: Game}, false) for _, container := range containers { rawLabels, err := json.Marshal(container.Labels) @@ -227,52 +197,358 @@ func (self *InstanceManager) ListServers(ctx context.Context) ([]instancemanager } // State Changing -func (self *InstanceManager) StartServer(ctx context.Context, serverId string, command string, ports []models.Port) error { +func (self *InstanceManager) StartServer(ctx context.Context, serverId string, image instancemanager.Image, command string, ports []models.Port) error { + server, err := self.GetServer(ctx, serverId) + if err != nil { + return err + } + + if server.Running { + return fmt.Errorf("Server %s already running", serverId) + } + + imageId := image.Registry + ":" + image.Tag + containerLabels := ContainerLabels{ + VolumeId: server.Id, + Type: "GAME", + } + + volumes := make(map[string]struct{}) + + var portMapping nat.PortMap = make(nat.PortMap) + + if len(ports) == 0 { + for _, port := range image.Ports { + dockerPort, err := nat.NewPort(string(port.Protocol), fmt.Sprint(port.Number)) + if err != nil { + return err + } + + portMapping[dockerPort] = []nat.PortBinding{{HostIP: "0.0.0.0"}} + } + } else { + for _, portCouple := range ports { + portMapping[nat.Port(fmt.Sprintf("%d/%s", portCouple.ContainerPort, portCouple.Protocol))] = []nat.PortBinding{{HostIP: "0.0.0.0", HostPort: fmt.Sprint(portCouple.PublicPort)}} + } + } + + rawImage, _, err := self.client.ImageInspectWithRaw(ctx, imageId) + + if command == "" { + command = image.Command + } + + words, err := shellwords.Split(command) + if err != nil { + return err + } + + if len(words) == 0 { + words = nil + } + + portSet := make(nat.PortSet) + for port := range portMapping { + portSet[port] = struct{}{} + } + + labels, err := convertLabelsToMap(containerLabels) + if err != nil { + return err + } + + createdContainer, err := self.client.ContainerCreate( + context.TODO(), + &container.Config{ + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + Tty: true, + OpenStdin: true, + StdinOnce: false, + Image: imageId, + Volumes: volumes, + Labels: *labels, + Cmd: words, + ExposedPorts: portSet, + }, + &container.HostConfig{ + AutoRemove: true, + Mounts: []mount.Mount{{Source: server.Id, Target: rawImage.Config.WorkingDir, Type: "volume"}}, + PortBindings: portMapping, + ConsoleSize: [2]uint{1000, 1000}, + }, + &network.NetworkingConfig{}, + &v1.Platform{}, + "", + ) + + if err != nil { + return err + } + + err = self.client.ContainerStart(ctx, createdContainer.ID, container.StartOptions{}) + if err != nil { + return err + } return nil } func (self *InstanceManager) StopServer(ctx context.Context, serverId string) error { + runningContainers, err := self.containerList(ctx, ContainerLabels{VolumeId: serverId, Type: Game}, false) + + for _, rawContainer := range runningContainers { + err = self.client.ContainerStop(ctx, rawContainer.ID, container.StopOptions{}) + if err != nil { + return err + } + } + return nil } -func (self *InstanceManager) CreateServer(ctx context.Context, image models.Image) (*instancemanager.Server, error) { +func (self *InstanceManager) CreateServer(ctx context.Context) (*instancemanager.Server, error) { + labels, err := convertLabelsToMap(VolumeLabels{ + Type: Game, + }) + if err != nil { + return nil, err + } - return nil, nil + volume, err := self.client.VolumeCreate(ctx, volume.CreateOptions{ + Labels: *labels, + }) + + if err != nil { + return nil, err + } + + return &instancemanager.Server{ + Id: volume.Name, + Running: false, + RunningImage: nil, + RunningCommand: "", + }, nil } + func (self *InstanceManager) DeleteServer(ctx context.Context, serverId string) error { + server, err := self.GetServer(ctx, serverId) + if err != nil { + return err + } + + if server.Running { + err = self.StopServer(ctx, server.Id) + if err != nil { + return err + } + } + + err = self.StopFileBrowser(ctx, server.Id) + if err != nil { + return err + } + + err = self.client.VolumeRemove(ctx, server.Id, true) + if err != nil { + return err + } + return nil } // Terminal - -// Read Only -func (self *InstanceManager) GetLogs(ctx context.Context, serverId string) (string, error) { - return "", nil -} - // Status Changing func (self *InstanceManager) InteractiveTerminal(ctx context.Context, serverId string) (*net.Conn, error) { - return nil, nil -} -func (self *InstanceManager) RunCommand(ctx context.Context, serverId string, command string) (string, error) { + server, err := self.GetServer(ctx, serverId) + if err != nil { + return nil, err + } - return "", nil + if !server.Running { + return nil, fmt.Errorf("Server %s not running", server.Id) + } + + rawContainers, err := self.containerList(ctx, ContainerLabels{VolumeId: serverId, Type: Game}, false) + if err != nil { + return nil, err + } + + if len(rawContainers) == 0 { + return nil, fmt.Errorf("Server %s not running and wasn't caught", server.Id) + } + + rawContainer := rawContainers[0] + + attach, err := self.client.ContainerAttach(ctx, + rawContainer.ID, + container.AttachOptions{Stream: true, + Stdin: true, + Stdout: true, + Stderr: true, + Logs: true, + }, + ) + + if err != nil { + return nil, err + } + + return &attach.Conn, nil +} + +func (self *InstanceManager) ResizeTerminal(ctx context.Context, serverId string, width uint, height uint) error { + containers, err := self.containerList(ctx, ContainerLabels{VolumeId: serverId, Type: Game}, false) + if err != nil { + return err + } + + if len(containers) == 0 { + return fmt.Errorf("Server %s not running", serverId) + } + + err = self.client.ContainerResize(context.TODO(), containers[0].ID, container.ResizeOptions{Height: height, Width: width}) + + return err } // File Browser // Read Only func (self *InstanceManager) GetFileBrowser(ctx context.Context, serverId string) (*models.FileBrowser, error) { - return nil, nil + containers, err := self.containerList(ctx, ContainerLabels{VolumeId: serverId, Type: FileBrowser}, false) + if err != nil { + return nil, err + } + + if len(containers) == 0 { + return nil, fmt.Errorf("File Browser for server %s is not running", serverId) + } + + rawContainer := containers[0] + + containerLabels, err := convertContainerLabelsToStruct(rawContainer.Labels) + if err != nil { + return nil, err + } + + return &models.FileBrowser{ + Url: containerLabels.VolumeId[:12] + "." + self.config.BrowsersDomain, + Id: rawContainer.ID, + }, nil } + func (self *InstanceManager) ListFileBrowsers(ctx context.Context) ([]models.FileBrowser, error) { - return nil, nil + containers, err := self.containerList(ctx, ContainerLabels{Type: FileBrowser}, false) + if err != nil { + return nil, err + } + + fileBrowsers := make([]models.FileBrowser, len(containers)) + + for i, rawContainer := range containers { + containerLabels, err := convertContainerLabelsToStruct(rawContainer.Labels) + if err != nil { + return nil, err + } + + fileBrowsers[i] = models.FileBrowser{ + Url: containerLabels.VolumeId[:12] + "." + self.config.BrowsersDomain, + Id: rawContainer.ID, + } + + } + + return fileBrowsers, nil } // Status Changing func (self *InstanceManager) StartFileBrowser(ctx context.Context, serverId string) (*models.FileBrowser, error) { - return nil, nil + server, err := self.GetServer(ctx, serverId) + + if err != nil { + return nil, err + } + + labelId := serverId[:12] + + browserLabels := make(map[string]string) + browserLabels["traefik.enable"] = "true" + browserLabels[fmt.Sprintf("traefik.http.routers.%s.rule", labelId)] = fmt.Sprintf("Host(`%s.browsers.%s`)", labelId, self.config.BrowsersDomain) + browserLabels[fmt.Sprintf("traefik.http.routers.%s.entrypoints", labelId)] = "websecure" + browserLabels[fmt.Sprintf("traefik.http.routers.%s.middlewares", labelId)] = "games@docker" + browserLabels[fmt.Sprintf("traefik.http.routers.%s.tls.domains[0].main", labelId)] = fmt.Sprintf("%s.%s", "browsers", self.config.BrowsersDomain) + browserLabels[fmt.Sprintf("traefik.http.routers.%s.tls.domains[0].sans", labelId)] = fmt.Sprintf("*.%s.%s", "browsers", self.config.BrowsersDomain) + browserLabels[fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", labelId)] = "myresolver" + + containerConfig, err := convertLabelsToMap(ContainerLabels{VolumeId: serverId, Type: FileBrowser}) + if err != nil { + return nil, err + } + + for key, value := range *containerConfig { + browserLabels[key] = value + } + + command := self.config.FileBrowser.Command + + if command == "" { + command = "--noauth -r /tmp/data" + } + + splitCommand, err := shellwords.Split(command) + if err != nil { + return nil, err + } + + ContainerResponse, err := self.client.ContainerCreate( + context.TODO(), + &container.Config{ + Cmd: splitCommand, + Image: fmt.Sprintf("%s:%s", self.config.FileBrowser.Image.Registry, self.config.FileBrowser.Image.Tag), + Labels: browserLabels, + Tty: true, + }, + &container.HostConfig{ + Mounts: []mount.Mount{{Source: server.Id, Target: "/tmp/data", Type: "volume"}}, + AutoRemove: true, + ConsoleSize: [2]uint{1000, 1000}, + }, + &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{"browsers": { + NetworkID: self.config.FileBrowser.Network, + }}, + }, + &v1.Platform{}, + "", + ) + if err != nil { + return nil, err + } + + err = self.client.ContainerStart(context.TODO(), ContainerResponse.ID, container.StartOptions{}) + if err != nil { + return nil, err + } + + return &models.FileBrowser{ + Url: serverId[:12] + "." + self.config.BrowsersDomain, + Id: ContainerResponse.ID, + }, nil } + func (self *InstanceManager) StopFileBrowser(ctx context.Context, serverId string) error { + containers, err := self.containerList(ctx, ContainerLabels{Type: FileBrowser}, false) + if err != nil { + return err + } + + for _, rawContainer := range containers { + err := self.client.ContainerStop(ctx, rawContainer.ID, container.StopOptions{}) + if err != nil { + return err + } + } + return nil } diff --git a/instance_manager/docker/labels.go b/instance_manager/docker/labels.go index 5a9ef3c..ffb84b1 100644 --- a/instance_manager/docker/labels.go +++ b/instance_manager/docker/labels.go @@ -8,16 +8,18 @@ const ( ) type ContainerLabels struct { - OwnerId string `json:"user_id,omitempty"` - ImageId string `json:"image_id,omitempty"` VolumeId string `json:"volume_id,omitempty"` Type ContainerType `json:"type,omitempty"` } +type BrowserLabels struct { + ContainerLabels + UrlRule string + Enabled bool +} + type VolumeLabels struct { - OwnerId string `json:"user_id,omitempty"` - ImageId string `json:"image_id,omitempty"` - Type ContainerType `json:"type,omitempty"` + Type ContainerType `json:"type,omitempty"` } type ImageLabels struct { diff --git a/instance_manager/docker/utils.go b/instance_manager/docker/utils.go index a8a747c..50046b5 100644 --- a/instance_manager/docker/utils.go +++ b/instance_manager/docker/utils.go @@ -5,51 +5,54 @@ import ( "fmt" "log" "reflect" + "strings" + instancemanager "git.acooldomain.co/server-manager/backend-kubernetes-go/instance_manager" "git.acooldomain.co/server-manager/backend-kubernetes-go/models" "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/filters" + "github.com/docker/go-connections/nat" ) func convertLabelsToFilter(labels any) (*filters.Args, error) { args := filters.NewArgs() - raw, err := json.Marshal(labels) + + labelMap, err := convertLabelsToMap(labels) if err != nil { return nil, err } - - var unflattenedMap map[string]any - - err = json.Unmarshal(raw, &unflattenedMap) - if err != nil { - return nil, err - } - flatMap := flattenMap(unflattenedMap) - - for key, value := range flatMap { - args.Add(key, value) + for key, value := range *labelMap { + args.Add("label", fmt.Sprintf("%s=%s", key, value)) } return &args, nil } -func flattenMap(m map[string]any, previous ...string) map[string]string { - flattenedMap := make(map[string]string) - for key, value := range m { - v := reflect.ValueOf(value) - switch v.Kind() { - case reflect.Map: - inner := flattenMap(v.Interface().(map[string]any), append(previous, key)...) - for key, value := range inner { - flattenedMap[key] = value - } +func convertLabelsToMap(labels any) (*map[string]string, error) { - default: - flattenedMap[key] = fmt.Sprintf("%v", value) - } + raw, err := json.Marshal(labels) + if err != nil { + return nil, err } - return flattenedMap + var rawMap map[string]any + + err = json.Unmarshal(raw, &rawMap) + if err != nil { + return nil, err + } + stringifiedMap := stringifyMap(rawMap) + + return &stringifiedMap, nil +} + +func stringifyMap(m map[string]any) map[string]string { + stringifiedMap := make(map[string]string) + for key, value := range m { + stringifiedMap[key] = fmt.Sprintf("%v", value) + } + + return stringifiedMap } func convertContainerPortsToPorts(ports []types.Port) []models.Port { @@ -87,3 +90,75 @@ func convertContainerImageToImage(image string) models.Image { Tag: imageTag, } } + +func convertContainerLabelsToStruct(labels map[string]string) (*ContainerLabels, error) { + var containerLabels ContainerLabels + + rawLabels, err := json.Marshal(labels) + if err != nil { + return nil, err + } + + err = json.Unmarshal(rawLabels, &labels) + + if err != nil { + return nil, err + } + return &containerLabels, nil +} + +func convertVolumeLabelsToStruct(labels map[string]string) (*VolumeLabels, error) { + var volumeLabels VolumeLabels + + rawLabels, err := json.Marshal(labels) + if err != nil { + return nil, err + } + + err = json.Unmarshal(rawLabels, &labels) + + if err != nil { + return nil, err + } + return &volumeLabels, nil +} + +func convertImageLabelsToStruct(labels map[string]string) (*ImageLabels, error) { + var imageLabels ImageLabels + + rawLabels, err := json.Marshal(labels) + if err != nil { + return nil, err + } + + err = json.Unmarshal(rawLabels, &labels) + + if err != nil { + return nil, err + } + return &imageLabels, nil +} + +func convertImagePortsToPorts(rawPorts nat.PortSet) []instancemanager.Port { + ports := make([]instancemanager.Port, len(rawPorts)) + i := 0 + for imagePort := range rawPorts { + portNumber := imagePort.Int() + var protocol models.PortProtocol + switch imagePort.Proto() { + case "TCP": + protocol = models.TCP + case "UDP": + protocol = models.UDP + default: + log.Default().Println(fmt.Sprintf("Unknown port protocol %s using TCP", imagePort.Proto())) + protocol = models.TCP + } + ports[i] = instancemanager.Port{ + Number: uint16(portNumber), + Protocol: protocol, + } + i++ + } + return ports +} diff --git a/instance_manager/instance_manager.go b/instance_manager/instance_manager.go index fc88554..b9bebfd 100644 --- a/instance_manager/instance_manager.go +++ b/instance_manager/instance_manager.go @@ -35,20 +35,17 @@ type InstanceManager interface { ListServers(ctx context.Context) ([]Server, error) // State Changing - StartServer(ctx context.Context, serverId string, command string, ports []models.Port) error + StartServer(ctx context.Context, serverId string, image Image, command string, ports []models.Port) error StopServer(ctx context.Context, serverId string) error - CreateServer(ctx context.Context, image models.Image) (*Server, error) + CreateServer(ctx context.Context) (*Server, error) DeleteServer(ctx context.Context, serverId string) error // Terminal - // Read Only - GetLogs(ctx context.Context, serverId string) (string, error) - // Status Changing InteractiveTerminal(ctx context.Context, serverId string) (*net.Conn, error) - RunCommand(ctx context.Context, serverId string, command string) (string, error) + ResizeTerminal(ctx context.Context, serverId string, width uint, height uint) error // File Browser diff --git a/models/config.go b/models/config.go index 3deff6a..35fe971 100644 --- a/models/config.go +++ b/models/config.go @@ -42,7 +42,19 @@ type UsersDatabaseConfig struct { Mongo *MongoDBConfig `yaml:"mongo"` } -type ServersInstanceManagerConfig struct { +type FileBrowserConfig struct { + Image Image `yaml:"image"` + Command string `yaml:"command"` + Network string `yaml:"network"` +} + +type DockerInstanceManagerConfig struct { + BrowsersDomain string `yaml:"browsers_domain"` + CertificateResolver string `yaml:"certificate_resolver"` + FileBrowser FileBrowserConfig `yaml:"file_browser"` +} + +type InstanceManagerConfig struct { Type InstanceManagerType } @@ -58,10 +70,11 @@ type ServersAuthorizationDatabaseConfig struct { type GlobalConfig struct { // Features Configs - Email EmailConfig `yaml:"email"` - Domain string `yaml:"domain"` - Signing SigningConfig `yaml:"signing"` - Authentication AuthenticationConfig `yaml:"authentication"` + Email EmailConfig `yaml:"email"` + Domain string `yaml:"domain"` + Signing SigningConfig `yaml:"signing"` + Authentication AuthenticationConfig `yaml:"authentication"` + InstanceManager InstanceManagerConfig `yaml:"instance_manager"` // Database Configs ServersDatabase ServersDatabaseConfig `yaml:"servers_database"`