Files
wehub-resource-sync 75f3dd141c
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CI and Release / lint-frontend (push) Has been cancelled
CI and Release / dockerfile-scan (push) Has been cancelled
CI and Release / test-frontend (push) Has been cancelled
CI and Release / lint-verification-agent (push) Has been cancelled
CI and Release / test-verification-agent (push) Has been cancelled
CI and Release / e2e-verification-agent (push) Has been cancelled
CI and Release / test-backend (push) Has been cancelled
CI and Release / build-dev-image (push) Has been cancelled
CI and Release / push-dev-image (push) Has been cancelled
CI and Release / build-image (push) Has been cancelled
CI and Release / build-verification-image (push) Has been cancelled
CI and Release / determine-version (push) Has been cancelled
CI and Release / push-image (push) Has been cancelled
CI and Release / push-verification-image (12) (push) Has been cancelled
CI and Release / lint-backend (push) Has been cancelled
CI and Release / push-verification-image (17) (push) Has been cancelled
CI and Release / push-verification-image (18) (push) Has been cancelled
CI and Release / release (push) Has been cancelled
CI and Release / publish-helm-chart (push) Has been cancelled
CI and Release / push-verification-image (13) (push) Has been cancelled
CI and Release / push-verification-image (14) (push) Has been cancelled
CI and Release / push-verification-image (15) (push) Has been cancelled
CI and Release / push-verification-image (16) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:11:07 +08:00

316 lines
7.6 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/go-resty/resty/v2"
"github.com/google/uuid"
)
// reportRetryDelay returns the exponential backoff for report attempt n
// (1-based): 1s,2s,4s,8s,16s,32s, capped at maxBackoff. The exponent is
// clamped before the shift so a large attempt count cannot overflow.
func reportRetryDelay(attempt int) time.Duration {
return expBackoff(attempt - 1)
}
func expBackoff(shift int) time.Duration {
const maxShift = 5 // 2^5 s = 32s = maxBackoff
if shift < 0 {
shift = 0
}
if shift >= maxShift {
return maxBackoff
}
d := time.Duration(1<<shift) * time.Second
if d > maxBackoff {
return maxBackoff
}
return d
}
type Client struct {
json *resty.Client
noRetry *resty.Client
streamHTTP *http.Client
host string
token string
agentID string
log *slog.Logger
}
func NewClient(host, token, agentID string, log *slog.Logger) *Client {
setAuth := func(_ *resty.Client, req *resty.Request) error {
if h := bearerHeader(token); h != "" {
req.SetHeader("Authorization", h)
}
return nil
}
jsonClient := resty.New().
SetTimeout(apiCallTimeout).
SetRetryCount(maxRetryAttempts - 1).
SetRetryWaitTime(retryBaseDelay).
SetRetryMaxWaitTime(4 * retryBaseDelay).
AddRetryCondition(func(resp *resty.Response, err error) bool {
return err != nil || resp.StatusCode() >= 500
}).
SetHeaders(noCacheHeaders).
OnBeforeRequest(setAuth)
// Claim and report own their retry policy explicitly (claim: loop retries
// forever; report: bounded retry-with-deadline). A retrying client here
// would make "exactly N retries / give up at the budget" untestable.
noRetryClient := resty.New().
SetTimeout(apiCallTimeout).
SetHeaders(noCacheHeaders).
OnBeforeRequest(setAuth)
return &Client{
json: jsonClient,
noRetry: noRetryClient,
streamHTTP: &http.Client{},
host: host,
token: token,
agentID: agentID,
log: log,
}
}
func (c *Client) FetchServerVersion(ctx context.Context) (string, error) {
var ver versionResponse
httpResp, err := c.json.R().
SetContext(ctx).
SetResult(&ver).
Get(c.buildURL(versionPath))
if err != nil {
return "", err
}
if err := c.checkResponse(httpResp, "fetch server version"); err != nil {
return "", err
}
return ver.Version, nil
}
func (c *Client) DownloadVerificationAgentBinary(
ctx context.Context,
arch, destPath, fallbackVersion string,
) (string, error) {
requestURL := c.buildURL(verificationAgentBinaryPath) + "?" + url.Values{"arch": {arch}}.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return "", fmt.Errorf("create agent download request: %w", err)
}
c.setStreamHeaders(req)
resp, err := c.streamHTTP.Do(req)
if err != nil {
return "", err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("server returned %d for verification agent download", resp.StatusCode)
}
file, err := os.Create(destPath)
if err != nil {
return "", err
}
defer func() { _ = file.Close() }()
if _, err := io.Copy(file, resp.Body); err != nil {
return "", err
}
downloadedVersion := strings.TrimSpace(resp.Header.Get(agentVersionHeader))
if downloadedVersion == "" {
return fallbackVersion, nil
}
return downloadedVersion, nil
}
func (c *Client) Heartbeat(
ctx context.Context,
request HeartbeatRequest,
) (*HeartbeatResponse, error) {
var response HeartbeatResponse
httpResp, err := c.json.R().
SetContext(ctx).
SetBody(request).
SetResult(&response).
Post(c.buildURL(fmt.Sprintf(heartbeatPathFmt, c.agentID)))
if err != nil {
return nil, err
}
if err := c.checkResponse(httpResp, "heartbeat"); err != nil {
return nil, err
}
return &response, nil
}
// ClaimVerification performs a single claim attempt. 200 returns the assignment;
// 204 returns (nil, nil) meaning nothing fits; any other status is an error.
// The runner loop owns retry/backoff — this never loops.
func (c *Client) ClaimVerification(
ctx context.Context,
capacity AgentCapacity,
) (*JobAssignment, error) {
resp, err := c.noRetry.R().
SetContext(ctx).
SetBody(ClaimRequest{Capacity: capacity}).
Post(c.buildURL(fmt.Sprintf(claimPathFmt, c.agentID)))
if err != nil {
return nil, err
}
if resp.StatusCode() == http.StatusNoContent {
return nil, nil
}
if resp.StatusCode() != http.StatusOK {
return nil, &ResponseError{Op: "claim", StatusCode: resp.StatusCode(), Body: resp.String()}
}
var assignment JobAssignment
if err := json.Unmarshal(resp.Body(), &assignment); err != nil {
return nil, fmt.Errorf("decode job assignment: %w", err)
}
return &assignment, nil
}
// DownloadBackup opens the plaintext backup stream. On 200 it returns the
// response body for the caller to stream into the container. A non-2xx status
// is returned as *ResponseError so the caller can tell 410 (drop, no report)
// from other 4xx (agent-setup failure) from 5xx (retryable). A transport error
// is returned as-is (retryable).
func (c *Client) DownloadBackup(
ctx context.Context,
verificationID uuid.UUID,
) (io.ReadCloser, error) {
requestURL := c.buildURL(fmt.Sprintf(backupStreamPathFmt, c.agentID, verificationID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
if err != nil {
return nil, fmt.Errorf("create backup stream request: %w", err)
}
c.setStreamHeaders(req)
resp, err := c.streamHTTP.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
return nil, &ResponseError{
Op: "backup stream",
StatusCode: resp.StatusCode,
Body: string(body),
}
}
return resp.Body, nil
}
// Report submits the terminal result with bounded retry-with-deadline. Transport
// errors and 5xx are retried with exponential backoff under reportRetryBudget; a
// 410 returns ErrReportGone immediately (zero retries); any other 4xx is a
// non-retryable error; budget exhaustion returns ErrReportBudgetExhausted so the
// run is reclaimed by the backend on the agent's next heartbeat.
func (c *Client) Report(
ctx context.Context,
verificationID uuid.UUID,
req ReportRequest,
) error {
ctx, cancel := context.WithTimeoutCause(ctx, reportRetryBudget, ErrReportBudgetExhausted)
defer cancel()
requestURL := c.buildURL(fmt.Sprintf(reportPathFmt, c.agentID, verificationID))
for attempt := 1; ; attempt++ {
resp, err := c.noRetry.R().
SetContext(ctx).
SetBody(req).
Post(requestURL)
if err == nil {
switch {
case resp.StatusCode() == http.StatusNoContent:
return nil
case resp.StatusCode() == http.StatusGone:
return ErrReportGone
case resp.StatusCode() < 500:
return &ResponseError{
Op: "report",
StatusCode: resp.StatusCode(),
Body: resp.String(),
}
}
}
select {
case <-ctx.Done():
return context.Cause(ctx)
case <-timeAfterFn(reportRetryDelay(attempt)):
}
}
}
func (c *Client) buildURL(path string) string {
return c.host + path
}
func (c *Client) checkResponse(resp *resty.Response, method string) error {
if resp.StatusCode() >= 400 {
return fmt.Errorf("%s: server returned status %d: %s", method, resp.StatusCode(), resp.String())
}
return nil
}
func (c *Client) setStreamHeaders(req *http.Request) {
if h := bearerHeader(c.token); h != "" {
req.Header.Set("Authorization", h)
}
for name, value := range noCacheHeaders {
req.Header.Set(name, value)
}
}
func bearerHeader(token string) string {
if token == "" {
return ""
}
return "Bearer " + token
}