package docker import ( "context" "encoding/json" "fmt" "log" "maps" "strings" instancemanager "git.acooldomain.co/server-manager/backend/instancemanager" "git.acooldomain.co/server-manager/backend/models" "github.com/buildkite/shellwords" "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 browsersSubDomain string siteDomain string } func (im *InstanceManager) containerList(ctx context.Context, labels ContainerLabels, all bool) ([]container.Summary, error) { filters, err := convertLabelsToFilter(labels) if err != nil { return nil, err } containers, err := im.client.ContainerList(ctx, container.ListOptions{Filters: *filters, All: all}) if err != nil { return nil, err } return containers, nil } func (im *InstanceManager) getVolume(ctx context.Context, serverId string) (*volume.Volume, error) { volume, err := im.client.VolumeInspect(ctx, serverId) if err != nil { return nil, err } labels, err := convertVolumeLabelsToStruct(volume.Labels) if err != nil { return nil, err } if labels.Type != Game { return nil, fmt.Errorf("server not found") } return &volume, err } // General // Read Only func (im *InstanceManager) GetImage(ctx context.Context, imageId string) (*instancemanager.Image, error) { imageInspect, err := im.client.ImageInspect(ctx, imageId) if err != nil { return nil, err } imageLabels, err := convertImageLabelsToStruct(imageInspect.Config.Labels) if err != nil { return nil, err } if imageLabels.Type != Game { return nil, fmt.Errorf("image not found") } image := convertImageInspectToInstanceImage(imageInspect) return &image, nil } func (im *InstanceManager) ListImages(ctx context.Context) ([]instancemanager.Image, error) { imageFilters, err := convertLabelsToFilter(ImageLabels{Type: Game}) if err != nil { return nil, err } rawImages, err := im.client.ImageList(ctx, image.ListOptions{ Filters: *imageFilters, }) if err != nil { return nil, err } images := make([]instancemanager.Image, len(rawImages)) for i, rawImage := range rawImages { imageInspect, err := im.client.ImageInspect(ctx, rawImage.ID) if err != nil { return nil, err } images[i] = convertImageInspectToInstanceImage(imageInspect) } return images, nil } func (im *InstanceManager) GetServer(ctx context.Context, serverId string) (*instancemanager.Server, error) { volume, err := im.getVolume(ctx, serverId) if err != nil { return nil, err } serverContainers, err := im.containerList( ctx, ContainerLabels{ VolumeId: serverId, }, true, ) if err != nil { return nil, err } if len(serverContainers) == 0 { return &instancemanager.Server{ Id: volume.Name, Running: false, RunningCommand: "", RunningImage: nil, Ports: nil, Domain: im.siteDomain, }, nil } serverContainer := serverContainers[0] running := serverContainer.State == "running" runningCommand := serverContainer.Command image := convertImageStringToModelsImage(serverContainer.Image) return &instancemanager.Server{ Id: volume.Name, Running: running, RunningCommand: runningCommand, Ports: convertContainerPortsToPorts(serverContainer.Ports), RunningImage: &image, Domain: im.siteDomain, }, nil } func (im *InstanceManager) ListServers(ctx context.Context) ([]instancemanager.Server, error) { volumeFilter, err := convertLabelsToFilter(VolumeLabels{Type: Game}) if err != nil { return nil, err } volumes, err := im.client.VolumeList(ctx, volume.ListOptions{Filters: *volumeFilter}) if err != nil { return nil, err } serverStatus := make(map[string]*instancemanager.Server) for _, volume := range volumes.Volumes { serverStatus[volume.Name] = &instancemanager.Server{ Id: volume.Name, Running: false, RunningCommand: "", Ports: nil, RunningImage: nil, Domain: im.siteDomain, } } containers, err := im.containerList(ctx, ContainerLabels{Type: Game}, false) if err != nil { return nil, err } for _, container := range containers { rawLabels, err := json.Marshal(container.Labels) if err != nil { return nil, err } var containerLabels ContainerLabels err = json.Unmarshal(rawLabels, &containerLabels) if err != nil { return nil, err } if container.State != "running" { continue } image := convertImageStringToModelsImage(container.Image) serverStatus[containerLabels.VolumeId].Ports = convertContainerPortsToPorts(container.Ports) serverStatus[containerLabels.VolumeId].Running = true serverStatus[containerLabels.VolumeId].RunningImage = &image serverStatus[containerLabels.VolumeId].RunningCommand = container.Command } servers := make([]instancemanager.Server, len(serverStatus)) i := 0 for _, value := range serverStatus { servers[i] = *value i++ } return servers, nil } // State Changing func (im *InstanceManager) StartServer(ctx context.Context, serverId string, imageId string, command string, ports []models.Port, ) error { server, err := im.GetServer(ctx, serverId) if err != nil { return err } if server.Running { return fmt.Errorf("server %s already running", serverId) } containerLabels := ContainerLabels{ VolumeId: server.Id, Type: "GAME", } volumes := make(map[string]struct{}) portMapping := make(nat.PortMap) image, err := im.GetImage(ctx, imageId) if err != nil { return err } 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)}} } } 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 := im.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: image.WorkingDir, Type: "volume"}}, PortBindings: portMapping, ConsoleSize: [2]uint{1000, 1000}, }, &network.NetworkingConfig{}, &v1.Platform{}, "", ) if err != nil { return err } err = im.client.ContainerStart(ctx, createdContainer.ID, container.StartOptions{}) if err != nil { return err } return nil } func (im *InstanceManager) StopServer(ctx context.Context, serverId string) error { runningContainers, err := im.containerList(ctx, ContainerLabels{VolumeId: serverId, Type: Game}, false) if err != nil { return err } for _, rawContainer := range runningContainers { err = im.client.ContainerStop(ctx, rawContainer.ID, container.StopOptions{}) if err != nil { return err } } return nil } func (im *InstanceManager) createVolume(ctx context.Context, volumeLabels VolumeLabels) (volume.Volume, error) { labels, err := convertLabelsToMap(volumeLabels) if err != nil { return volume.Volume{}, err } volume, err := im.client.VolumeCreate(ctx, volume.CreateOptions{ Labels: *labels, }) return volume, err } func (im *InstanceManager) CreateServer(ctx context.Context) (*instancemanager.Server, error) { volume, err := im.createVolume(ctx, VolumeLabels{Type: Game}) if err != nil { return nil, err } return &instancemanager.Server{ Id: volume.Name, Running: false, RunningImage: nil, RunningCommand: "", Domain: im.siteDomain, }, nil } func (im *InstanceManager) DeleteServer(ctx context.Context, serverId string) error { server, err := im.GetServer(ctx, serverId) if err != nil { return err } if server.Running { err = im.StopServer(ctx, server.Id) if err != nil { return err } } err = im.StopFileBrowser(ctx, server.Id) if err != nil { return err } err = im.client.VolumeRemove(ctx, server.Id, true) if err != nil { return err } return nil } // Terminal // Status Changing func (im *InstanceManager) InteractiveTerminal(ctx context.Context, serverId string) (*instancemanager.TerminalConnection, error) { server, err := im.GetServer(ctx, serverId) if err != nil { return nil, err } if !server.Running { return nil, fmt.Errorf("server %s not running", server.Id) } rawContainers, err := im.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 := im.client.ContainerAttach(ctx, rawContainer.ID, container.AttachOptions{ Stream: true, Stdin: true, Stdout: true, Stderr: true, Logs: true, }, ) if err != nil { return nil, err } return &instancemanager.TerminalConnection{ Conn: attach.Conn, ResizerFunc: func(width uint, height uint) error { return im.ResizeTerminal(ctx, serverId, width, height) }, }, nil } func (im *InstanceManager) ResizeTerminal(ctx context.Context, serverId string, width uint, height uint) error { containers, err := im.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 = im.client.ContainerResize(context.TODO(), containers[0].ID, container.ResizeOptions{Height: height, Width: width}) return err } // File Browser // Read Only func (im *InstanceManager) GetFileBrowser(ctx context.Context, serverId string) (*models.FileBrowser, error) { containers, err := im.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: fmt.Sprintf("%s/browsers/%s/", im.siteDomain, containerLabels.VolumeId), ServerId: containerLabels.VolumeId, Id: rawContainer.ID, }, nil } func (im *InstanceManager) ListFileBrowsers(ctx context.Context) ([]models.FileBrowser, error) { containers, err := im.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: fmt.Sprintf("%s/browsers/%s/", im.siteDomain, containerLabels.VolumeId), ServerId: containerLabels.VolumeId, Id: rawContainer.ID, } } return fileBrowsers, nil } // Status Changing func (im *InstanceManager) StartFileBrowser(ctx context.Context, serverId string) (*models.FileBrowser, error) { server, err := im.GetServer(ctx, serverId) if err != nil { return nil, err } labelId := server.Id volumeLabels := VolumeLabels{ Type: FileBrowser, VolumeId: server.Id, } containers, err := im.containerList(ctx, ContainerLabels{VolumeId: serverId, Type: FileBrowser}, false) if err != nil { return nil, err } if len(containers) > 0 { return &models.FileBrowser{ Url: fmt.Sprintf("%s/browsers/%s/", im.siteDomain, server.Id), ServerId: server.Id, Id: containers[0].ID, }, nil } volume, err := im.createVolume(ctx, volumeLabels) if err != nil { return nil, err } containerConfig, err := convertLabelsToMap(ContainerLabels{VolumeId: serverId, Type: FileBrowser}) if err != nil { return nil, err } PrepareDatabaseContainer, err := im.client.ContainerCreate( ctx, &container.Config{ Entrypoint: []string{"/bin/sh"}, Cmd: []string{"-c", "/filebrowser config init -d /tmp/database/database.db && /filebrowser config set -d /tmp/database/database.db --auth.method=proxy --auth.header=X-Auth-Username"}, Image: fmt.Sprintf("%s:%s", im.config.FileBrowser.Image.Registry, im.config.FileBrowser.Image.Tag), Labels: *containerConfig, }, &container.HostConfig{ Mounts: []mount.Mount{{Source: volume.Name, Target: "/tmp/database", Type: mount.TypeVolume}}, AutoRemove: true, }, &network.NetworkingConfig{}, &v1.Platform{}, "", ) if err != nil { return nil, err } err = im.client.ContainerStart(ctx, PrepareDatabaseContainer.ID, container.StartOptions{}) if err != nil { log.Printf("Failed to wait %s", err) } responseChan, _ := im.client.ContainerWait(ctx, PrepareDatabaseContainer.ID, container.WaitConditionNotRunning) response := <-responseChan log.Printf("%#v\n", response) browserLabels := make(map[string]string) browserLabels["traefik.enable"] = "true" browserLabels[fmt.Sprintf("traefik.http.routers.%s.rule", labelId)] = fmt.Sprintf("Host(`%s`) && PathPrefix(`/browsers/%s/`)", im.siteDomain, labelId) browserLabels[fmt.Sprintf("traefik.http.routers.%s.entrypoints", labelId)] = im.config.FileBrowser.ReverseProxy.Entrypoint browserLabels[fmt.Sprintf("traefik.http.routers.%s.middlewares", labelId)] = strings.Join(im.config.FileBrowser.ReverseProxy.Middlewares, ",") if im.config.FileBrowser.ReverseProxy.Tls { browserLabels[fmt.Sprintf("traefik.http.routers.%s.tls.certresolver", labelId)] = im.config.FileBrowser.ReverseProxy.TlsResolver } if err != nil { return nil, err } maps.Copy(browserLabels, *containerConfig) command := im.config.FileBrowser.Command command += fmt.Sprintf("-d /tmp/database/database.db -r /tmp/data -b /browsers/%s/", labelId) splitCommand, err := shellwords.Split(command) if err != nil { return nil, err } ContainerResponse, err := im.client.ContainerCreate( ctx, &container.Config{ Cmd: splitCommand, Image: fmt.Sprintf("%s:%s", im.config.FileBrowser.Image.Registry, im.config.FileBrowser.Image.Tag), Labels: browserLabels, Tty: true, }, &container.HostConfig{ Mounts: []mount.Mount{{Source: server.Id, Target: "/tmp/data", Type: "volume"}, {Source: volume.Name, Target: "/tmp/database/", Type: mount.TypeVolume}}, AutoRemove: true, ConsoleSize: [2]uint{1000, 1000}, }, &network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{"browsers": { NetworkID: im.config.FileBrowser.Network, }}, }, &v1.Platform{}, "", ) if err != nil { return nil, err } err = im.client.ContainerStart(ctx, ContainerResponse.ID, container.StartOptions{}) if err != nil { return nil, err } return &models.FileBrowser{ Url: fmt.Sprintf("%s/browsers/%s/", im.siteDomain, server.Id), ServerId: server.Id, Id: volume.Name, }, nil } func (im *InstanceManager) StopFileBrowser(ctx context.Context, serverId string) error { containers, err := im.containerList(ctx, ContainerLabels{Type: FileBrowser}, false) if err != nil { return err } for _, rawContainer := range containers { stopChan, _ := im.client.ContainerWait(ctx, rawContainer.ID, container.WaitConditionRemoved) err := im.client.ContainerStop(ctx, rawContainer.ID, container.StopOptions{}) if err != nil { return err } <-stopChan for _, mount := range rawContainer.Mounts { if mount.Destination == "/tmp/database" { err := im.client.VolumeRemove(ctx, mount.Name, true) if err != nil { return err } } } } return nil } func (im *InstanceManager) GetFileBrowserFromUrl(ctx context.Context, url string) (*models.FileBrowser, error) { return im.GetFileBrowser(ctx, strings.Split(url, "/")[1]) } func NewInstanceManager(config models.DockerInstanceManagerConfig, siteDomain string) (*InstanceManager, error) { apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { return nil, err } defer apiClient.Close() return &InstanceManager{ config: config, client: apiClient, siteDomain: siteDomain, }, nil }