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" } }