375 lines
12 KiB
Go
375 lines
12 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// CoordinatorError is a non-retriable coordinator response.
|
|
type CoordinatorError struct{ msg string }
|
|
|
|
func (e *CoordinatorError) Error() string { return e.msg }
|
|
|
|
// TransientError is a timeout, connection error, or 5xx response.
|
|
type TransientError struct{ msg string }
|
|
|
|
func (e *TransientError) Error() string { return e.msg }
|
|
|
|
// ConflictError means the worker no longer owns the task lease.
|
|
type ConflictError struct{ msg string }
|
|
|
|
func (e *ConflictError) Error() string { return e.msg }
|
|
|
|
// Client speaks the v1 worker contract over HTTP with a token provider.
|
|
//
|
|
// API calls never follow redirects (a redirect is a contract violation); the
|
|
// artifact download follows redirects but strips the Authorization header on
|
|
// cross-origin hops, matching the Python worker's SameOriginAuthRedirectHandler.
|
|
// A 401 response refreshes the token exactly once and retries, so a lapsed JWT
|
|
// does not fail an in-flight task.
|
|
type Client struct {
|
|
baseURL string
|
|
tokens TokenProvider
|
|
timeout time.Duration
|
|
apiClient *http.Client
|
|
dlClient *http.Client
|
|
}
|
|
|
|
func NewClient(baseURL string, tokens TokenProvider, timeout time.Duration) *Client {
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
tokens: tokens,
|
|
timeout: timeout,
|
|
apiClient: &http.Client{
|
|
Timeout: timeout,
|
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
|
},
|
|
dlClient: &http.Client{
|
|
Timeout: timeout,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 10 {
|
|
return fmt.Errorf("too many redirects")
|
|
}
|
|
// Go strips Authorization on cross-host redirects by default;
|
|
// strip it explicitly on any origin change to be safe.
|
|
if len(via) > 0 && origin(req.URL) != origin(via[0].URL) {
|
|
req.Header.Del("Authorization")
|
|
}
|
|
return nil
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
func origin(u *url.URL) string {
|
|
return u.Scheme + "://" + u.Host
|
|
}
|
|
|
|
func (c *Client) authHeaders() (map[string]string, error) {
|
|
token, err := c.tokens.Token()
|
|
if err != nil {
|
|
return nil, &CoordinatorError{msg: "token refresh failed: " + err.Error()}
|
|
}
|
|
if token == "" {
|
|
return map[string]string{}, nil
|
|
}
|
|
return map[string]string{"Authorization": "Bearer " + token}, nil
|
|
}
|
|
|
|
func (c *Client) refreshAndRetry() bool {
|
|
return c.tokens.Refresh() == nil
|
|
}
|
|
|
|
// Register advertises the worker and returns its identity and heartbeat policy.
|
|
func (c *Client) Register(name string, capabilities []string, cpuCount int, memoryMB int) (*RegisteredWorker, error) {
|
|
payload := map[string]any{
|
|
"name": name,
|
|
"capabilities": capabilities,
|
|
"cpu_count": cpuCount,
|
|
}
|
|
if memoryMB > 0 {
|
|
payload["memory_mb"] = memoryMB
|
|
}
|
|
status, body, err := c.requestJSON("POST", "/workers/register", payload)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status != http.StatusCreated {
|
|
return nil, &CoordinatorError{msg: fmt.Sprintf("worker registration rejected with status %d", status)}
|
|
}
|
|
return ParseRegistered(body)
|
|
}
|
|
|
|
// Claim leases one compatible task, or returns nil when the queue is empty.
|
|
func (c *Client) Claim(workerID string, capabilities []string) (*Task, error) {
|
|
status, body, err := c.requestJSON("POST", "/tasks/claim", map[string]any{
|
|
"worker_id": workerID,
|
|
"capabilities": capabilities,
|
|
"max_concurrency": 1,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status == http.StatusNoContent {
|
|
return nil, nil
|
|
}
|
|
if status != http.StatusOK {
|
|
return nil, &CoordinatorError{msg: fmt.Sprintf("unexpected claim status %d", status)}
|
|
}
|
|
return ParseTask(body)
|
|
}
|
|
|
|
// Heartbeat renews the lease and returns the new deadline.
|
|
func (c *Client) Heartbeat(task *Task, workerID string) (time.Time, error) {
|
|
status, body, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/heartbeat", map[string]any{
|
|
"worker_id": workerID,
|
|
"attempt": task.Attempt,
|
|
})
|
|
if err != nil {
|
|
return time.Time{}, err
|
|
}
|
|
if status != http.StatusOK {
|
|
if status == http.StatusConflict {
|
|
return time.Time{}, &ConflictError{msg: "heartbeat rejected because the task lease was lost"}
|
|
}
|
|
return time.Time{}, &CoordinatorError{msg: fmt.Sprintf("heartbeat rejected with status %d", status)}
|
|
}
|
|
raw, ok := body["lease_expires_at"].(string)
|
|
if !ok {
|
|
return time.Time{}, &CoordinatorError{msg: "heartbeat response is missing lease_expires_at"}
|
|
}
|
|
lease, err := time.Parse(time.RFC3339, raw)
|
|
if err != nil {
|
|
return time.Time{}, &CoordinatorError{msg: "heartbeat returned an invalid lease_expires_at"}
|
|
}
|
|
task.LeaseExpiresAt = lease
|
|
task.leaseExpiresRaw = raw
|
|
return lease, nil
|
|
}
|
|
|
|
// Submit completes a task with the uploaded coordinator-owned artifact.
|
|
func (c *Client) Submit(task *Task, workerID string, uploaded *Uploaded, metrics map[string]any) error {
|
|
status, _, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/result", map[string]any{
|
|
"worker_id": workerID,
|
|
"attempt": task.Attempt,
|
|
"result": map[string]any{"artifact_id": uploaded.ArtifactID},
|
|
"metrics": metrics,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != http.StatusOK && status != http.StatusCreated && status != http.StatusAccepted {
|
|
if status == http.StatusConflict {
|
|
return &ConflictError{msg: "result rejected because the task lease was lost"}
|
|
}
|
|
return &CoordinatorError{msg: fmt.Sprintf("result rejected with status %d", status)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Fail reports a sanitized failure.
|
|
func (c *Client) Fail(task *Task, workerID string, code, message string, retryable bool) error {
|
|
status, _, err := c.requestJSON("POST", "/tasks/"+task.TaskID+"/failure", map[string]any{
|
|
"worker_id": workerID,
|
|
"attempt": task.Attempt,
|
|
"error_code": code,
|
|
"error_message": message,
|
|
"retryable": retryable,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status != http.StatusOK && status != http.StatusCreated && status != http.StatusAccepted {
|
|
if status == http.StatusConflict {
|
|
return &ConflictError{msg: "failure rejected because the task lease was lost"}
|
|
}
|
|
return &CoordinatorError{msg: fmt.Sprintf("failure report rejected with status %d", status)}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Download streams the task input to destination and returns its SHA-256.
|
|
func (c *Client) Download(uri, destination string) (string, error) {
|
|
resolved, err := url.Parse(uri)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid input URI: %w", err)
|
|
}
|
|
if !resolved.IsAbs() {
|
|
base, parseErr := url.Parse(c.baseURL)
|
|
if parseErr != nil {
|
|
return "", fmt.Errorf("invalid coordinator URL")
|
|
}
|
|
resolved = base.ResolveReference(resolved)
|
|
}
|
|
if resolved.Scheme != "http" && resolved.Scheme != "https" {
|
|
return "", fmt.Errorf("input URI must be an HTTP(S) URL")
|
|
}
|
|
request, err := http.NewRequestWithContext(context.Background(), http.MethodGet, resolved.String(), nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
headers, err := c.authHeaders()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for name, value := range headers {
|
|
request.Header.Set(name, value)
|
|
}
|
|
response, err := c.dlClient.Do(request)
|
|
if err != nil {
|
|
return "", &TransientError{msg: "input download failed"}
|
|
}
|
|
defer func() { _ = response.Body.Close() }()
|
|
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
|
|
return c.Download(uri, destination)
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
return "", &CoordinatorError{msg: fmt.Sprintf("input download rejected with status %d", response.StatusCode)}
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
|
|
return "", err
|
|
}
|
|
// #nosec G304 -- destination is the worker's own attempt directory file.
|
|
target, err := os.OpenFile(destination, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
digest := sha256.New()
|
|
_, copyErr := io.Copy(io.MultiWriter(target, digest), response.Body)
|
|
closeErr := target.Close()
|
|
if copyErr != nil {
|
|
_ = os.Remove(destination)
|
|
return "", &TransientError{msg: "input download interrupted"}
|
|
}
|
|
if closeErr != nil {
|
|
return "", closeErr
|
|
}
|
|
return hex.EncodeToString(digest.Sum(nil)), nil
|
|
}
|
|
|
|
// Upload streams a partial artifact and verifies the returned metadata.
|
|
func (c *Client) Upload(task *Task, workerID string, path, contentType string) (*Uploaded, error) {
|
|
// #nosec G304 -- the upload path is this worker's own artifact file.
|
|
file, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := file.Stat()
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return nil, err
|
|
}
|
|
digest := sha256.New()
|
|
if _, err := io.Copy(digest, file); err != nil {
|
|
_ = file.Close()
|
|
return nil, err
|
|
}
|
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
|
_ = file.Close()
|
|
return nil, err
|
|
}
|
|
localSHA := hex.EncodeToString(digest.Sum(nil))
|
|
uploadURL := c.baseURL + "/tasks/" + url.PathEscape(task.TaskID) + "/artifacts/" + url.PathEscape(filepath.Base(path))
|
|
request, err := http.NewRequestWithContext(context.Background(), http.MethodPut, uploadURL, file)
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return nil, err
|
|
}
|
|
request.ContentLength = info.Size()
|
|
request.Header.Set("Content-Type", contentType)
|
|
request.Header.Set("X-Worker-ID", workerID)
|
|
request.Header.Set("X-Task-Attempt", strconv.Itoa(task.Attempt))
|
|
headers, err := c.authHeaders()
|
|
if err != nil {
|
|
_ = file.Close()
|
|
return nil, err
|
|
}
|
|
for name, value := range headers {
|
|
request.Header.Set(name, value)
|
|
}
|
|
response, err := c.apiClient.Do(request)
|
|
_ = file.Close()
|
|
if err != nil {
|
|
return nil, &TransientError{msg: "artifact upload failed"}
|
|
}
|
|
defer func() { _ = response.Body.Close() }()
|
|
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
|
if err != nil {
|
|
return nil, &TransientError{msg: "artifact upload interrupted"}
|
|
}
|
|
if response.StatusCode == http.StatusConflict {
|
|
return nil, &ConflictError{msg: "artifact upload rejected because the task lease was lost"}
|
|
}
|
|
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
|
|
return c.Upload(task, workerID, path, contentType)
|
|
}
|
|
if response.StatusCode != http.StatusOK {
|
|
return nil, &CoordinatorError{msg: fmt.Sprintf("artifact upload rejected with status %d", response.StatusCode)}
|
|
}
|
|
var payload map[string]any
|
|
if err := json.Unmarshal(raw, &payload); err != nil {
|
|
return nil, &CoordinatorError{msg: "artifact upload returned invalid metadata"}
|
|
}
|
|
uploaded, err := ParseUploaded(payload)
|
|
if err != nil {
|
|
return nil, &CoordinatorError{msg: "artifact upload returned invalid metadata"}
|
|
}
|
|
if uploaded.SHA256 != localSHA || uploaded.SizeBytes != info.Size() {
|
|
return nil, &CoordinatorError{msg: "artifact upload metadata does not match local artifact"}
|
|
}
|
|
return uploaded, nil
|
|
}
|
|
|
|
func (c *Client) requestJSON(method, path string, payload any) (int, map[string]any, error) {
|
|
body, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
request, err := http.NewRequestWithContext(context.Background(), method, c.baseURL+path, bytes.NewReader(body))
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
request.Header.Set("Content-Type", "application/json")
|
|
headers, err := c.authHeaders()
|
|
if err != nil {
|
|
return 0, nil, err
|
|
}
|
|
for name, value := range headers {
|
|
request.Header.Set(name, value)
|
|
}
|
|
response, err := c.apiClient.Do(request)
|
|
if err != nil {
|
|
return 0, nil, &TransientError{msg: "coordinator request failed"}
|
|
}
|
|
defer func() { _ = response.Body.Close() }()
|
|
raw, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
|
|
if err != nil {
|
|
return 0, nil, &TransientError{msg: "coordinator request interrupted"}
|
|
}
|
|
if response.StatusCode == http.StatusUnauthorized && c.refreshAndRetry() {
|
|
return c.requestJSON(method, path, payload)
|
|
}
|
|
if response.StatusCode >= 500 {
|
|
return response.StatusCode, nil, &TransientError{msg: fmt.Sprintf("coordinator returned %d", response.StatusCode)}
|
|
}
|
|
var decoded map[string]any
|
|
if len(raw) > 0 {
|
|
if err := json.Unmarshal(raw, &decoded); err != nil {
|
|
return response.StatusCode, nil, &CoordinatorError{msg: "coordinator returned invalid JSON"}
|
|
}
|
|
}
|
|
return response.StatusCode, decoded, nil
|
|
}
|