224 lines
7.4 KiB
Go
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
|
|
}
|