Files

345 lines
8.8 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,
PidsLimit: ptrInt64(256),
},
Privileged: false,
CapDrop: []string{"ALL"},
SecurityOpt: []string{"no-new-privileges:true"},
LogConfig: container.LogConfig{
Type: "json-file",
Config: map[string]string{
"max-size": "10m",
"max-file": "3",
},
},
},
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"
}
}
func ptrInt64(value int64) *int64 {
return &value
}
// 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
}