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