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

166 lines
6.0 KiB
Go

package customer
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// WorkerRuntime is the minimal worker-host API used by Customer Service. A
// local Docker Engine and a remote Service Controller both implement the same
// contract, so public/customer/game APIs do not need to know where a worker is
// actually running.
type WorkerRuntime interface {
Ping(context.Context) error
EnsureImage(context.Context, string, bool, string) error
PullImage(context.Context, string, string) error
CreateVolume(context.Context, string) error
CreateWorker(context.Context, WorkerContainerConfig) (string, error)
Start(context.Context, string) error
Stop(context.Context, string, int) error
Remove(context.Context, string) error
Running(context.Context, string) (bool, error)
GetFile(context.Context, string, string) ([]byte, error)
PutFile(context.Context, string, string, string, []byte) error
RemoveVolume(context.Context, string) error
}
// RemoteControllerClient speaks the private Service-Controller API. It exposes
// the same logical methods as DockerClient while never granting Customer
// Service access to the remote host's docker.sock.
type RemoteControllerClient struct {
base string
secret string
hc *http.Client
}
func NewRemoteControllerClient(baseURL, secret string) (*RemoteControllerClient, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, errors.New("service controller base URL is empty")
}
u, err := url.Parse(baseURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return nil, fmt.Errorf("invalid service controller URL %q", baseURL)
}
if len(strings.TrimSpace(secret)) < 24 {
return nil, errors.New("service controller shared secret missing/too short")
}
return &RemoteControllerClient{base: baseURL, secret: strings.TrimSpace(secret), hc: &http.Client{Timeout: 45 * time.Second}}, nil
}
func (c *RemoteControllerClient) 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, c.base+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.secret)
if in != nil {
req.Header.Set("Content-Type", "application/json")
}
client := c.hc
if strings.Contains(path, "/image/pull") {
clone := *c.hc
clone.Timeout = 11 * time.Minute
client = &clone
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("service controller %s: %w", c.base, err)
}
defer resp.Body.Close()
b, readErr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
if readErr != nil {
return readErr
}
if resp.StatusCode/100 != 2 {
var e struct {
Error string `json:"error"`
}
_ = json.Unmarshal(b, &e)
if strings.TrimSpace(e.Error) != "" {
return fmt.Errorf("service controller HTTP %d: %s", resp.StatusCode, strings.TrimSpace(e.Error))
}
return fmt.Errorf("service controller HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
if out != nil && len(bytes.TrimSpace(b)) > 0 {
if err := json.Unmarshal(b, out); err != nil {
return err
}
}
return nil
}
func (c *RemoteControllerClient) Ping(ctx context.Context) error {
return c.req(ctx, http.MethodGet, "/internal/health", nil, nil)
}
func (c *RemoteControllerClient) EnsureImage(ctx context.Context, image string, autoPull bool, auth string) error {
return c.req(ctx, http.MethodPost, "/internal/image/ensure", map[string]any{"image": image, "auto_pull": autoPull, "registry_auth": auth}, nil)
}
func (c *RemoteControllerClient) PullImage(ctx context.Context, image, auth string) error {
return c.req(ctx, http.MethodPost, "/internal/image/pull", map[string]any{"image": image, "registry_auth": auth}, nil)
}
func (c *RemoteControllerClient) CreateVolume(ctx context.Context, name string) error {
return c.req(ctx, http.MethodPost, "/internal/volumes", map[string]string{"name": name}, nil)
}
func (c *RemoteControllerClient) CreateWorker(ctx context.Context, cfg WorkerContainerConfig) (string, error) {
var out struct {
ID string `json:"id"`
}
if err := c.req(ctx, http.MethodPost, "/internal/workers", cfg, &out); err != nil {
return "", err
}
if strings.TrimSpace(out.ID) == "" {
return "", errors.New("service controller returned empty container id")
}
return out.ID, nil
}
func (c *RemoteControllerClient) Start(ctx context.Context, id string) error {
return c.req(ctx, http.MethodPost, "/internal/workers/"+url.PathEscape(id)+"/start", nil, nil)
}
func (c *RemoteControllerClient) Stop(ctx context.Context, id string, seconds int) error {
return c.req(ctx, http.MethodPost, "/internal/workers/"+url.PathEscape(id)+"/stop", map[string]int{"seconds": seconds}, nil)
}
func (c *RemoteControllerClient) Remove(ctx context.Context, id string) error {
return c.req(ctx, http.MethodDelete, "/internal/workers/"+url.PathEscape(id), nil, nil)
}
func (c *RemoteControllerClient) Running(ctx context.Context, id string) (bool, error) {
var out struct {
Running bool `json:"running"`
}
if err := c.req(ctx, http.MethodGet, "/internal/workers/"+url.PathEscape(id)+"/running", nil, &out); err != nil {
return false, err
}
return out.Running, nil
}
func (c *RemoteControllerClient) GetFile(ctx context.Context, id, path string) ([]byte, error) {
var out struct {
Data []byte `json:"data"`
}
if err := c.req(ctx, http.MethodPost, "/internal/workers/"+url.PathEscape(id)+"/file/get", map[string]string{"path": path}, &out); err != nil {
return nil, err
}
return out.Data, nil
}
func (c *RemoteControllerClient) PutFile(ctx context.Context, id, dir, name string, data []byte) error {
return c.req(ctx, http.MethodPost, "/internal/workers/"+url.PathEscape(id)+"/file/put", map[string]any{"dir": dir, "name": name, "data": data}, nil)
}
func (c *RemoteControllerClient) RemoveVolume(ctx context.Context, name string) error {
return c.req(ctx, http.MethodDelete, "/internal/volumes/"+url.PathEscape(name), nil, nil)
}