Files
groot 47c523dd98
All checks were successful
release-tag / release-image (push) Successful in 3m51s
RC-14
2026-08-14 06:17:30 +02:00

401 lines
13 KiB
Go

package customer
import (
"archive/tar"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path/filepath"
"strings"
"time"
)
type DockerClient struct {
hc *http.Client
base string
}
func NewDockerClient(raw string) (*DockerClient, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
raw = "unix:///var/run/docker.sock"
}
if strings.HasPrefix(raw, "unix://") {
sock := strings.TrimPrefix(raw, "unix://")
tr := &http.Transport{DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", sock)
}}
return &DockerClient{hc: &http.Client{Transport: tr, Timeout: 30 * time.Second}, base: "http://docker"}, nil
}
u, err := url.Parse(raw)
if err != nil {
return nil, err
}
if u.Scheme != "http" && u.Scheme != "https" {
return nil, errors.New("DOCKER_HOST must be unix://, http:// or https://")
}
return &DockerClient{hc: &http.Client{Timeout: 30 * time.Second}, base: strings.TrimRight(raw, "/")}, nil
}
func (d *DockerClient) req(ctx context.Context, method, path string, in, out any) error {
var body io.Reader
if in != nil {
b, err := json.Marshal(in)
if err != nil {
return err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, d.base+path, body)
if err != nil {
return err
}
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := d.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if readErr != nil {
return readErr
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("docker API %s %s HTTP %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(b)))
}
if out != nil && len(bytes.TrimSpace(b)) > 0 {
return json.Unmarshal(b, out)
}
return nil
}
func (d *DockerClient) Ping(ctx context.Context) error {
return d.req(ctx, http.MethodGet, "/_ping", nil, nil)
}
// ImageExists checks the local Docker image cache without pulling anything.
func (d *DockerClient) ImageExists(ctx context.Context, image string) (bool, error) {
image = strings.TrimSpace(image)
if image == "" {
return false, errors.New("worker image is empty")
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.base+"/images/"+url.PathEscape(image)+"/json", nil)
if err != nil {
return false, err
}
resp, err := d.hc.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode == http.StatusNotFound {
return false, nil
}
if resp.StatusCode/100 != 2 {
return false, fmt.Errorf("docker image inspect HTTP %d", resp.StatusCode)
}
return true, nil
}
// PullImage asks Docker Engine to pull a public/configured registry image.
// Private-registry credentials are passed as Docker's X-Registry-Auth header.
// The stream is inspected for daemon-side pull errors.
func (d *DockerClient) PullImage(ctx context.Context, image, registryAuth string) error {
image = strings.TrimSpace(image)
if image == "" {
return errors.New("worker image is empty")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, d.base+"/images/create?fromImage="+url.QueryEscape(image), nil)
if err != nil {
return err
}
if strings.TrimSpace(registryAuth) != "" {
req.Header.Set("X-Registry-Auth", strings.TrimSpace(registryAuth))
}
pullClient := *d.hc
pullClient.Timeout = 10 * time.Minute
resp, err := pullClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
return fmt.Errorf("docker image pull HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
dec := json.NewDecoder(io.LimitReader(resp.Body, 32<<20))
for {
var msg struct {
Error string `json:"error"`
}
if err := dec.Decode(&msg); err != nil {
if errors.Is(err, io.EOF) {
break
}
return fmt.Errorf("docker image pull stream: %w", err)
}
if strings.TrimSpace(msg.Error) != "" {
return fmt.Errorf("docker image pull: %s", strings.TrimSpace(msg.Error))
}
}
return nil
}
// RegistryAuthHeader builds Docker Engine's X-Registry-Auth value. Use a
// registry-scoped read-only deploy token instead of a personal password.
func RegistryAuthHeader(username, password, serverAddress string) (string, error) {
username = strings.TrimSpace(username)
password = strings.TrimSpace(password)
serverAddress = strings.TrimSpace(serverAddress)
if username == "" && password == "" && serverAddress == "" {
return "", nil
}
if username == "" || password == "" {
return "", errors.New("both worker registry username and password/token are required")
}
payload := map[string]string{"username": username, "password": password}
if serverAddress != "" {
payload["serveraddress"] = serverAddress
}
b, err := json.Marshal(payload)
if err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(b), nil
}
func (d *DockerClient) EnsureImage(ctx context.Context, image string, autoPull bool, registryAuth string) error {
ok, err := d.ImageExists(ctx, image)
if err != nil {
return err
}
if ok {
return nil
}
if !autoPull {
return fmt.Errorf("worker image %q is not present on the Docker host and CS_WORKER_AUTO_PULL is disabled", image)
}
if err := d.PullImage(ctx, image, registryAuth); err != nil {
return fmt.Errorf("pull worker image %q: %w", image, err)
}
ok, err = d.ImageExists(ctx, image)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("worker image %q is still unavailable after pull", image)
}
return nil
}
func (d *DockerClient) CreateVolume(ctx context.Context, name string) error {
var out map[string]any
return d.req(ctx, http.MethodPost, "/volumes/create", map[string]any{"Name": name, "Labels": map[string]string{"neuralhunt.managed": "true"}}, &out)
}
type WorkerContainerConfig struct {
Image, Entrypoint, Network, GameURL, RegisterURL, WorkerID, RegisterToken, TaskID, BeaconPath, Volume, Name string
}
func (d *DockerClient) CreateWorker(ctx context.Context, c WorkerContainerConfig) (string, error) {
name := url.QueryEscape(c.Name)
body := map[string]any{
"Image": c.Image,
// Named Docker volumes are root-owned when first mounted, and identities
// created by older releases may be owned by a different UID. The dedicated
// worker image starts as uid 0 only for its tiny ownership-normalization
// entrypoint and immediately drops to the unprivileged app user.
"User": "0:0",
"Cmd": []string{"-url", c.GameURL, "-identity", "/identity/identity.json", "-non-interactive", "-quiet", "-task", c.TaskID, "-beacon-path", c.BeaconPath},
"Env": []string{
"NEURALHUNT_WORKER_REGISTER_URL=" + c.RegisterURL,
"NEURALHUNT_WORKER_LEASE_URL=" + strings.TrimSuffix(c.RegisterURL, "/register") + "/lease",
"NEURALHUNT_WORKER_REGISTER_TOKEN=" + c.RegisterToken,
"NEURALHUNT_WORKER_ID=" + c.WorkerID,
},
"Labels": map[string]string{"neuralhunt.managed": "true", "neuralhunt.worker_id": c.WorkerID},
"HostConfig": map[string]any{
"Mounts": []map[string]any{{"Type": "volume", "Source": c.Volume, "Target": "/identity"}},
"NetworkMode": c.Network,
"ReadonlyRootfs": true,
// A managed unattended worker must survive process crashes and temporary
// game/network outages. Docker will restart it automatically, while an
// explicit Docker Stop from the Customer Service keeps it stopped.
"RestartPolicy": map[string]any{"Name": "unless-stopped", "MaximumRetryCount": 0},
"CapDrop": []string{"ALL"},
// Bootstrap-only capabilities: the image entrypoint fixes ownership of
// /identity and then su-exec permanently switches to uid/gid app.
"CapAdd": []string{"CHOWN", "DAC_OVERRIDE", "SETUID", "SETGID"},
"SecurityOpt": []string{"no-new-privileges"},
"PidsLimit": 128,
"Memory": 256 * 1024 * 1024,
"NanoCpus": int64(1_000_000_000),
},
}
// A dedicated worker image already declares /app/neuralhunt-client as its
// ENTRYPOINT. Leaving Entrypoint unset makes CS_WORKER_IMAGE genuinely
// pluggable. CS_WORKER_ENTRYPOINT exists only as a compatibility override
// for older monolithic images.
if strings.TrimSpace(c.Entrypoint) != "" {
body["Entrypoint"] = []string{strings.TrimSpace(c.Entrypoint)}
}
var out struct {
ID string `json:"Id"`
}
if err := d.req(ctx, http.MethodPost, "/containers/create?name="+name, body, &out); err != nil {
return "", err
}
if out.ID == "" {
return "", errors.New("docker returned empty container id")
}
return out.ID, nil
}
func (d *DockerClient) Start(ctx context.Context, id string) error {
return d.req(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/start", nil, nil)
}
func (d *DockerClient) Stop(ctx context.Context, id string, seconds int) error {
if id == "" {
return nil
}
if seconds < 1 {
seconds = 10
}
return d.req(ctx, http.MethodPost, "/containers/"+url.PathEscape(id)+"/stop?t="+fmt.Sprint(seconds), nil, nil)
}
func (d *DockerClient) Remove(ctx context.Context, id string) error {
if id == "" {
return nil
}
err := d.req(ctx, http.MethodDelete, "/containers/"+url.PathEscape(id)+"?force=true&v=false", nil, nil)
if err != nil && strings.Contains(err.Error(), "404") {
return nil
}
return err
}
func (d *DockerClient) Running(ctx context.Context, id string) (bool, error) {
var out struct {
State struct {
Running bool `json:"Running"`
} `json:"State"`
}
if err := d.req(ctx, http.MethodGet, "/containers/"+url.PathEscape(id)+"/json", nil, &out); err != nil {
return false, err
}
return out.State.Running, nil
}
func (d *DockerClient) GetFile(ctx context.Context, containerID, path string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, d.base+"/containers/"+url.PathEscape(containerID)+"/archive?path="+url.QueryEscape(path), nil)
if err != nil {
return nil, err
}
resp, err := d.hc.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("docker archive HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
tr := tar.NewReader(io.LimitReader(resp.Body, 8<<20))
for {
h, err := tr.Next()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, err
}
if filepath.Base(h.Name) == filepath.Base(path) && h.Typeflag == tar.TypeReg {
return io.ReadAll(io.LimitReader(tr, 2<<20))
}
}
return nil, errors.New("identity file not found in container volume")
}
func (d *DockerClient) PutFile(ctx context.Context, containerID, dir, name string, data []byte) error {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(data)), ModTime: time.Now()}); err != nil {
return err
}
if _, err := tw.Write(data); err != nil {
return err
}
if err := tw.Close(); err != nil {
return err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, d.base+"/containers/"+url.PathEscape(containerID)+"/archive?path="+url.QueryEscape(dir), bytes.NewReader(buf.Bytes()))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/x-tar")
resp, err := d.hc.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return fmt.Errorf("docker put archive HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
return nil
}
func (d *DockerClient) RemoveVolume(ctx context.Context, name string) error {
if name == "" {
return nil
}
err := d.req(ctx, http.MethodDelete, "/volumes/"+url.PathEscape(name)+"?force=true", nil, nil)
if err != nil && strings.Contains(err.Error(), "404") {
return nil
}
return err
}
type ManagedContainer struct {
ID string `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
State string `json:"state"`
Status string `json:"status"`
WorkerID string `json:"worker_id"`
Running bool `json:"running"`
}
// ManagedContainers lists only containers created by Neural Hunt. It is used by
// the Service Controller emergency UI and never exposes unrelated Docker
// workloads on the host.
func (d *DockerClient) ManagedContainers(ctx context.Context) ([]ManagedContainer, error) {
q := url.QueryEscape(`{"label":["neuralhunt.managed=true"]}`)
var raw []struct {
ID string `json:"Id"`
Names []string `json:"Names"`
Image string `json:"Image"`
State string `json:"State"`
Status string `json:"Status"`
Labels map[string]string `json:"Labels"`
}
if err := d.req(ctx, http.MethodGet, "/containers/json?all=true&filters="+q, nil, &raw); err != nil {
return nil, err
}
out := make([]ManagedContainer, 0, len(raw))
for _, x := range raw {
name := ""
if len(x.Names) > 0 {
name = strings.TrimPrefix(x.Names[0], "/")
}
out = append(out, ManagedContainer{ID: x.ID, Name: name, Image: x.Image, State: x.State, Status: x.Status, WorkerID: x.Labels["neuralhunt.worker_id"], Running: x.State == "running"})
}
return out, nil
}