Files
DoGaMa-serv/internal/agentclient/client.go
T

224 lines
7.4 KiB
Go

// Package agentclient calls the restricted same-host Docker agent.
package agentclient
import (
"bytes"
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/agentwire"
)
const maxResponseBytes = 64 << 10
// Client is an authenticated client for the private agent API.
type Client struct {
baseURL *url.URL
secret []byte
http *http.Client
clock func() time.Time
random io.Reader
}
// DiskInfo is the bounded filesystem information returned for a requested path.
type DiskInfo struct {
Path string `json:"path"`
BytesAvailable uint64 `json:"bytes_available"`
BytesTotal uint64 `json:"bytes_total"`
}
// RegisteredInstance is an agent-owned registration binding.
type RegisteredInstance struct {
InstanceID string `json:"instance_id"`
ContainerID string `json:"container_id"`
PlanDigest string `json:"plan_digest"`
}
// ProblemError is a typed, non-sensitive error returned by the agent.
type ProblemError struct {
Status int
Code string
Message string
}
func (e *ProblemError) Error() string {
return fmt.Sprintf("agent request failed: %s (HTTP %d)", e.Code, e.Status)
}
// New constructs a client. The base URL must not contain credentials, a query
// or a path beyond an optional trailing slash.
func New(baseURL string, secret []byte, httpClient *http.Client) (*Client, error) {
parsed, err := url.Parse(baseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") {
return nil, errors.New("invalid agent URL")
}
if parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" || (parsed.Path != "" && parsed.Path != "/") {
return nil, errors.New("agent URL must contain only scheme and authority")
}
if len(secret) < 32 {
return nil, errors.New("agent secret must contain at least 32 bytes")
}
if httpClient == nil {
httpClient = &http.Client{Timeout: 5 * time.Second}
}
parsed.Path = strings.TrimSuffix(parsed.Path, "/")
return &Client{
baseURL: parsed,
secret: append([]byte(nil), secret...),
http: httpClient,
clock: time.Now,
random: rand.Reader,
}, nil
}
// Health verifies both the authenticated API and Docker daemon reachability.
func (c *Client) Health(ctx context.Context) error {
return c.do(ctx, http.MethodGet, "/v1/health", nil, nil)
}
// CheckDisk returns filesystem capacity only for paths accepted by the agent.
func (c *Client) CheckDisk(ctx context.Context, paths []string) ([]DiskInfo, error) {
var response struct {
Paths []DiskInfo `json:"paths"`
}
if err := c.do(ctx, http.MethodPost, "/v1/check-disk", struct {
Paths []string `json:"paths"`
}{Paths: paths}, &response); err != nil {
return nil, err
}
return response.Paths, nil
}
// ListRegisteredInstances returns only the agent's authenticated registry.
func (c *Client) ListRegisteredInstances(ctx context.Context) ([]RegisteredInstance, error) {
var response struct {
Instances []RegisteredInstance `json:"instances"`
}
if err := c.do(ctx, http.MethodGet, "/v1/instances", nil, &response); err != nil {
return nil, err
}
return response.Instances, nil
}
func (c *Client) CheckPorts(ctx context.Context, ports []agentwire.PlanPort) error {
return c.do(ctx, http.MethodPost, "/v1/check-ports", struct {
Ports []agentwire.PlanPort `json:"ports"`
}{Ports: ports}, nil)
}
func (c *Client) CreateInstance(ctx context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPost, "/v1/instances", plan, &state)
return state, err
}
func (c *Client) ReplaceInstance(ctx context.Context, plan agentwire.DeploymentPlan) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPut, instancePath(plan.InstanceID), plan, &state)
return state, err
}
func (c *Client) InspectInstance(ctx context.Context, instanceID string) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodGet, instancePath(instanceID), nil, &state)
return state, err
}
func (c *Client) StartInstance(ctx context.Context, instanceID string) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPost, instancePath(instanceID)+"/start", nil, &state)
return state, err
}
func (c *Client) StopInstance(ctx context.Context, instanceID string, timeoutSeconds int) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPost, instancePath(instanceID)+"/stop", struct {
TimeoutSeconds int `json:"timeout_seconds"`
}{TimeoutSeconds: timeoutSeconds}, &state)
return state, err
}
func (c *Client) RestartInstance(ctx context.Context, instanceID string, timeoutSeconds int) (agentwire.InstanceState, error) {
var state agentwire.InstanceState
err := c.do(ctx, http.MethodPost, instancePath(instanceID)+"/restart", struct {
TimeoutSeconds int `json:"timeout_seconds"`
}{TimeoutSeconds: timeoutSeconds}, &state)
return state, err
}
func (c *Client) DeleteContainer(ctx context.Context, instanceID string) error {
return c.do(ctx, http.MethodDelete, instancePath(instanceID), nil, nil)
}
func (c *Client) GetInstanceStats(ctx context.Context, instanceID string) (agentwire.InstanceStats, error) {
var stats agentwire.InstanceStats
err := c.do(ctx, http.MethodGet, instancePath(instanceID)+"/stats", nil, &stats)
return stats, err
}
func instancePath(instanceID string) string {
return "/v1/instances/" + url.PathEscape(instanceID)
}
func (c *Client) do(ctx context.Context, method, path string, input, output any) error {
var body []byte
var err error
if input != nil {
body, err = json.Marshal(input)
if err != nil {
return fmt.Errorf("encode agent request: %w", err)
}
}
nonceBytes := make([]byte, 24)
if _, err := io.ReadFull(c.random, nonceBytes); err != nil {
return fmt.Errorf("generate agent nonce: %w", err)
}
nonce := base64.RawURLEncoding.EncodeToString(nonceBytes)
timestamp := c.clock().UTC().Format(time.RFC3339Nano)
requestURL := *c.baseURL
requestURL.Path = path
request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), bytes.NewReader(body))
if err != nil {
return fmt.Errorf("create agent request: %w", err)
}
request.Header.Set("Authorization", agentwire.AuthorizationScheme+" "+agentwire.Signature(c.secret, method, requestURL.EscapedPath(), timestamp, nonce, body))
request.Header.Set(agentwire.HeaderTimestamp, timestamp)
request.Header.Set(agentwire.HeaderNonce, nonce)
if input != nil {
request.Header.Set("Content-Type", "application/json")
}
response, err := c.http.Do(request)
if err != nil {
return fmt.Errorf("call agent: %w", err)
}
defer response.Body.Close()
limited := io.LimitReader(response.Body, maxResponseBytes+1)
responseBody, err := io.ReadAll(limited)
if err != nil {
return fmt.Errorf("read agent response: %w", err)
}
if len(responseBody) > maxResponseBytes {
return errors.New("agent response exceeded size limit")
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
problem := &ProblemError{Status: response.StatusCode, Code: "agent_error", Message: "The agent request failed."}
_ = json.Unmarshal(responseBody, problem)
return problem
}
if output != nil && len(responseBody) != 0 {
if err := json.Unmarshal(responseBody, output); err != nil {
return fmt.Errorf("decode agent response: %w", err)
}
}
return nil
}