192 lines
5.0 KiB
Go
192 lines
5.0 KiB
Go
package docker
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"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/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"
|
|
default:
|
|
return "VANILLA"
|
|
}
|
|
}
|