package docker import ( "bytes" "context" "encoding/binary" "fmt" "io" "strconv" "strings" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" "github.com/docker/go-connections/nat" ) const MinecraftImage = "itzg/minecraft-server:java21" // MinecraftServerSpec describes a Minecraft container to create. type MinecraftServerSpec struct { ServerID string MinecraftVersion string SoftwareFamily string HostPort int RamMb int DataPath string EulaAccepted bool } // ContainerState summarizes a Docker container's runtime status. type ContainerState struct { ID string Status string Running bool } // Client wraps the Docker Engine API for game server containers. type Client struct { cli *client.Client } // New creates a Docker client connected via the given socket URL. func New(socket string) (*Client, error) { cli, err := client.NewClientWithOpts( client.FromEnv, client.WithHost(socket), client.WithAPIVersionNegotiation(), ) if err != nil { return nil, fmt.Errorf("create docker client: %w", err) } return &Client{cli: cli}, nil } // Close releases the underlying Docker client. func (c *Client) Close() error { return c.cli.Close() } // Ping verifies connectivity to the Docker daemon. func (c *Client) Ping(ctx context.Context) error { _, err := c.cli.Ping(ctx) return err } // CreateMinecraftServer creates a non-privileged Minecraft container and returns its ID. func (c *Client) CreateMinecraftServer(ctx context.Context, spec MinecraftServerSpec) (string, error) { if !spec.EulaAccepted { return "", fmt.Errorf("eula must be accepted before provisioning") } if spec.HostPort <= 0 { return "", fmt.Errorf("hostPort must be positive") } if strings.TrimSpace(spec.DataPath) == "" { return "", fmt.Errorf("dataPath is required") } if spec.RamMb <= 0 { return "", fmt.Errorf("ramMb must be positive") } containerPort := nat.Port("25565/tcp") portBindings := nat.PortMap{ containerPort: []nat.PortBinding{{ HostIP: "0.0.0.0", HostPort: strconv.Itoa(spec.HostPort), }}, } version := strings.TrimSpace(spec.MinecraftVersion) if version == "" { version = "LATEST" } resp, err := c.cli.ContainerCreate(ctx, &container.Config{ Image: MinecraftImage, Env: []string{ "EULA=TRUE", "VERSION=" + version, "TYPE=" + mapSoftwareFamily(spec.SoftwareFamily), fmt.Sprintf("MEMORY=%dM", spec.RamMb), }, ExposedPorts: nat.PortSet{containerPort: struct{}{}}, }, &container.HostConfig{ PortBindings: portBindings, Mounts: []mount.Mount{{ Type: mount.TypeBind, Source: spec.DataPath, Target: "/data", }}, Resources: container.Resources{ Memory: int64(spec.RamMb) * 1024 * 1024, }, Privileged: false, }, nil, nil, containerName(spec.ServerID), ) if err != nil { return "", fmt.Errorf("create container: %w", err) } return resp.ID, nil } // StartContainer starts a container by ID. func (c *Client) StartContainer(ctx context.Context, containerID string) error { if err := c.cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { return fmt.Errorf("start container: %w", err) } return nil } // StopContainer stops a running container gracefully. func (c *Client) StopContainer(ctx context.Context, containerID string) error { timeout := 30 if err := c.cli.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout}); err != nil { return fmt.Errorf("stop container: %w", err) } return nil } // RemoveContainer removes a container, forcing removal if still running. func (c *Client) RemoveContainer(ctx context.Context, containerID string) error { if err := c.cli.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: true}); err != nil { return fmt.Errorf("remove container: %w", err) } return nil } // GetContainerState inspects a container and returns its current state. func (c *Client) GetContainerState(ctx context.Context, containerID string) (*ContainerState, error) { inspect, err := c.cli.ContainerInspect(ctx, containerID) if err != nil { return nil, fmt.Errorf("inspect container: %w", err) } return &ContainerState{ ID: inspect.ID, Status: inspect.State.Status, Running: inspect.State.Running, }, nil } // ListManagedContainers returns container IDs labeled for a given server ID. func (c *Client) ListManagedContainers(ctx context.Context, serverID string) ([]string, error) { containers, err := c.cli.ContainerList(ctx, container.ListOptions{ All: true, Filters: filters.NewArgs(filters.Arg("name", containerName(serverID))), }) if err != nil { return nil, fmt.Errorf("list containers: %w", err) } ids := make([]string, 0, len(containers)) for _, ctr := range containers { ids = append(ids, ctr.ID) } return ids, nil } func containerName(serverID string) string { return "hexahost-" + serverID } func mapSoftwareFamily(family string) string { switch strings.ToUpper(strings.TrimSpace(family)) { case "PAPER": return "PAPER" case "FABRIC": return "FABRIC" case "PURPUR": return "PURPUR" default: return "VANILLA" } } // StreamLogs streams container logs and invokes callback for each line. func (c *Client) StreamLogs(ctx context.Context, containerID string, tail int, follow bool, callback func(stream, line string)) error { opts := container.LogsOptions{ ShowStdout: true, ShowStderr: true, Follow: follow, Timestamps: false, } if tail > 0 { opts.Tail = strconv.Itoa(tail) } else { opts.Tail = "all" } reader, err := c.cli.ContainerLogs(ctx, containerID, opts) if err != nil { return fmt.Errorf("container logs: %w", err) } defer reader.Close() return readMultiplexedLogLines(ctx, reader, callback) } // ExecCommand runs a command inside a container and returns stdout/stderr. func (c *Client) ExecCommand(ctx context.Context, containerID string, cmd []string) (stdout, stderr string, err error) { execResp, err := c.cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{ Cmd: cmd, AttachStdout: true, AttachStderr: true, }) if err != nil { return "", "", fmt.Errorf("exec create: %w", err) } attachResp, err := c.cli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{}) if err != nil { return "", "", fmt.Errorf("exec attach: %w", err) } defer attachResp.Close() var outBuf, errBuf bytes.Buffer if _, err := stdcopy.StdCopy(&outBuf, &errBuf, attachResp.Reader); err != nil { return outBuf.String(), errBuf.String(), fmt.Errorf("exec copy: %w", err) } inspect, err := c.cli.ContainerExecInspect(ctx, execResp.ID) if err != nil { return outBuf.String(), errBuf.String(), fmt.Errorf("exec inspect: %w", err) } if inspect.ExitCode != 0 { msg := strings.TrimSpace(errBuf.String()) if msg == "" { msg = strings.TrimSpace(outBuf.String()) } if msg == "" { msg = fmt.Sprintf("exit code %d", inspect.ExitCode) } return outBuf.String(), errBuf.String(), fmt.Errorf("exec failed: %s", msg) } return outBuf.String(), errBuf.String(), nil } // ExecRcon runs a Minecraft console command via rcon-cli inside the container. func (c *Client) ExecRcon(ctx context.Context, containerID, command string) (stdout, stderr string, err error) { return c.ExecCommand(ctx, containerID, []string{"rcon-cli", command}) } func readMultiplexedLogLines(ctx context.Context, r io.Reader, callback func(stream, line string)) error { header := make([]byte, 8) var partial []byte var partialStream string for { if err := ctx.Err(); err != nil { return err } _, err := io.ReadFull(r, header) if err == io.EOF { break } if err != nil { return err } stream := "stdout" if header[0] == 2 { stream = "stderr" } size := binary.BigEndian.Uint32(header[4:8]) payload := make([]byte, size) if _, err := io.ReadFull(r, payload); err != nil { return err } if partialStream != "" && partialStream != stream { if len(partial) > 0 { callback(partialStream, string(partial)) partial = nil } } partialStream = stream partial = append(partial, payload...) for { idx := bytes.IndexByte(partial, '\n') if idx < 0 { break } line := string(partial[:idx]) partial = partial[idx+1:] line = strings.TrimSuffix(line, "\r") if line != "" { callback(stream, line) } } } if len(partial) > 0 { stream := partialStream if stream == "" { stream = "stdout" } callback(stream, string(partial)) } return nil }