331 lines
8.5 KiB
Go
331 lines
8.5 KiB
Go
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
|
|
}
|