491 lines
21 KiB
Go
491 lines
21 KiB
Go
package api
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"browser.local/run/protocol"
|
|
)
|
|
|
|
type PlatformClient struct {
|
|
baseURL string
|
|
httpClient *http.Client
|
|
serverClockOffsetNanos *atomic.Int64
|
|
serverClockOffsetActive *atomic.Bool
|
|
}
|
|
|
|
type PlatformRequestError struct {
|
|
Status int
|
|
Path string
|
|
Code string
|
|
Details []string
|
|
}
|
|
|
|
func (err PlatformRequestError) Error() string {
|
|
parts := []string{fmt.Sprintf("status=%d", err.Status)}
|
|
if strings.TrimSpace(err.Path) != "" {
|
|
parts = append(parts, "path="+strings.TrimSpace(err.Path))
|
|
}
|
|
if strings.TrimSpace(err.Code) != "" {
|
|
parts = append(parts, "code="+strings.TrimSpace(err.Code))
|
|
}
|
|
if len(err.Details) > 0 {
|
|
parts = append(parts, "details="+strings.Join(err.Details, "; "))
|
|
}
|
|
return "platform request failed: " + strings.Join(parts, " ")
|
|
}
|
|
|
|
func (err PlatformRequestError) HTTPStatus() int {
|
|
return err.Status
|
|
}
|
|
|
|
// SessionInvalid reports the one legacy validation response that means a Run
|
|
// session must be renewed. Component-authenticated sessions return 401 for the
|
|
// same condition; older endpoint sessions return this safe 400 response.
|
|
func (err PlatformRequestError) SessionInvalid() bool {
|
|
if err.Status == http.StatusUnauthorized {
|
|
return true
|
|
}
|
|
if err.Status != http.StatusBadRequest || err.Code != "validation_failed" {
|
|
return false
|
|
}
|
|
for _, detail := range err.Details {
|
|
if strings.TrimSpace(detail) == "sessionToken is invalid" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (err PlatformRequestError) LogBatchSequenceGap() bool {
|
|
if err.Status != http.StatusBadRequest || err.Code != "validation_failed" {
|
|
return false
|
|
}
|
|
for _, detail := range err.Details {
|
|
if strings.TrimSpace(detail) == "log batch firstSeq must follow latest acknowledged sequence" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (err PlatformRequestError) LogBatchAcknowledgedRangeConflict() bool {
|
|
if err.Status != http.StatusBadRequest || err.Code != "validation_failed" {
|
|
return false
|
|
}
|
|
for _, detail := range err.Details {
|
|
if strings.TrimSpace(detail) == "log batch conflicts with acknowledged range" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (err PlatformRequestError) LogBatchLegacySessionMetadata() bool {
|
|
if err.Status != http.StatusBadRequest || err.Code != "validation_failed" {
|
|
return false
|
|
}
|
|
for _, detail := range err.Details {
|
|
if strings.TrimSpace(detail) == "logSessionId and sessionStartedAt must be provided together" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (err PlatformRequestError) LogBatchSessionMetadataMismatch() bool {
|
|
if err.Status != http.StatusBadRequest || err.Code != "validation_failed" {
|
|
return false
|
|
}
|
|
for _, detail := range err.Details {
|
|
if strings.TrimSpace(detail) == "log session metadata must match stream" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func NewPlatformClient(rawURL string) (PlatformClient, error) {
|
|
return NewPlatformClientWithHTTPClient(rawURL, http.DefaultClient)
|
|
}
|
|
|
|
func NewPlatformClientWithHTTPClient(rawURL string, httpClient *http.Client) (PlatformClient, error) {
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil {
|
|
return PlatformClient{}, err
|
|
}
|
|
if parsed.Scheme == "" || parsed.Host == "" {
|
|
return PlatformClient{}, fmt.Errorf("platform URL must include scheme and host")
|
|
}
|
|
if httpClient == nil {
|
|
httpClient = http.DefaultClient
|
|
}
|
|
|
|
return PlatformClient{baseURL: strings.TrimRight(parsed.String(), "/"), httpClient: httpClient, serverClockOffsetNanos: &atomic.Int64{}, serverClockOffsetActive: &atomic.Bool{}}, nil
|
|
}
|
|
|
|
func (c PlatformClient) BaseURL() string {
|
|
return c.baseURL
|
|
}
|
|
|
|
func (c PlatformClient) Hello(ctx context.Context, request protocol.RunHelloRequest) (protocol.RunHelloResponse, error) {
|
|
return postPlatformJSON[protocol.RunHelloRequest, protocol.RunHelloResponse](ctx, c, "/api/v1/run/control/hello", request)
|
|
}
|
|
|
|
func (c PlatformClient) Heartbeat(ctx context.Context, request protocol.RunHeartbeatRequest) (protocol.RunHeartbeatResponse, error) {
|
|
return postPlatformJSON[protocol.RunHeartbeatRequest, protocol.RunHeartbeatResponse](ctx, c, "/api/v1/run/control/heartbeat", request)
|
|
}
|
|
|
|
func (c PlatformClient) StreamControlEvents(ctx context.Context, request protocol.RunControlStreamRequest, handle func(protocol.RunControlEvent) error) error {
|
|
if handle == nil {
|
|
return fmt.Errorf("control event handler is required")
|
|
}
|
|
startedAt := time.Now()
|
|
path := "/api/v1/run/control/events"
|
|
log.Printf("RUN platform stream status=starting method=POST base=%s path=%s", diagnosticLogValue(c.baseURL), path)
|
|
var body bytes.Buffer
|
|
if err := json.NewEncoder(&body).Encode(request); err != nil {
|
|
return fmt.Errorf("encode control stream request: %w", err)
|
|
}
|
|
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, &body)
|
|
if err != nil {
|
|
return fmt.Errorf("build control stream request: %w", err)
|
|
}
|
|
httpRequest.Header.Set("Content-Type", "application/json")
|
|
httpRequest.Header.Set("Accept", "text/event-stream")
|
|
signatureSummary, err := signRunRequest(httpRequest, body.Bytes(), c.signatureTime())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
log.Printf("RUN platform stream status=signed method=POST base=%s path=%s endpoint=%s timestamp=%s nonce=%s bodyHash=%s signature=%s", diagnosticLogValue(c.baseURL), path, diagnosticLogValue(signatureSummary.RunEndpointID), signatureSummary.Timestamp, shortDiagnosticValue(signatureSummary.Nonce), shortDiagnosticValue(signatureSummary.BodyHash), shortDiagnosticValue(signatureSummary.Signature))
|
|
httpResponse, err := c.httpClient.Do(httpRequest)
|
|
if err != nil {
|
|
log.Printf("RUN platform stream status=send_error method=POST base=%s path=%s durationMs=%d error=%s", diagnosticLogValue(c.baseURL), path, time.Since(startedAt).Milliseconds(), err)
|
|
return fmt.Errorf("send control stream request: %w", err)
|
|
}
|
|
defer httpResponse.Body.Close()
|
|
log.Printf("RUN platform stream status=response method=POST base=%s path=%s httpStatus=%d durationMs=%d", diagnosticLogValue(c.baseURL), path, httpResponse.StatusCode, time.Since(startedAt).Milliseconds())
|
|
if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices {
|
|
var failure struct {
|
|
Code string `json:"code"`
|
|
Details []string `json:"details"`
|
|
}
|
|
_ = json.NewDecoder(io.LimitReader(httpResponse.Body, 64<<10)).Decode(&failure)
|
|
return PlatformRequestError{Status: httpResponse.StatusCode, Path: path, Code: failure.Code, Details: failure.Details}
|
|
}
|
|
reader := bufio.NewReader(httpResponse.Body)
|
|
var eventName string
|
|
var dataLines []string
|
|
for {
|
|
line, err := reader.ReadString('\n')
|
|
if err != nil && len(line) == 0 {
|
|
if errors.Is(err, io.EOF) || ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return fmt.Errorf("read control stream: %w", err)
|
|
}
|
|
line = strings.TrimRight(line, "\r\n")
|
|
if line == "" {
|
|
if len(dataLines) > 0 {
|
|
var event protocol.RunControlEvent
|
|
if err := json.Unmarshal([]byte(strings.Join(dataLines, "\n")), &event); err != nil {
|
|
return fmt.Errorf("decode control event: %w", err)
|
|
}
|
|
if event.Type == "" {
|
|
event.Type = eventName
|
|
}
|
|
if err := handle(event); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
eventName = ""
|
|
dataLines = nil
|
|
} else if strings.HasPrefix(line, "event:") {
|
|
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
|
} else if strings.HasPrefix(line, "data:") {
|
|
dataLines = append(dataLines, strings.TrimSpace(strings.TrimPrefix(line, "data:")))
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) || ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return fmt.Errorf("read control stream: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c PlatformClient) ReportLifecycle(ctx context.Context, request protocol.RunLifecycleReportRequest) (protocol.RunLifecycleReportResponse, error) {
|
|
return postPlatformJSON[protocol.RunLifecycleReportRequest, protocol.RunLifecycleReportResponse](ctx, c, "/api/v1/run/lifecycle/report", request)
|
|
}
|
|
|
|
func (c PlatformClient) ClaimJob(ctx context.Context, request protocol.RunJobClaimRequest) (protocol.RunJobClaimResponse, error) {
|
|
response, err := postPlatformJSON[protocol.RunJobClaimRequest, protocol.RunJobClaimResponse](ctx, c, "/api/v1/run/jobs/claim", request)
|
|
if request.WaitSeconds > 0 && claimWaitUnsupported(err) {
|
|
request.WaitSeconds = 0
|
|
return postPlatformJSON[protocol.RunJobClaimRequest, protocol.RunJobClaimResponse](ctx, c, "/api/v1/run/jobs/claim", request)
|
|
}
|
|
return response, err
|
|
}
|
|
|
|
func claimWaitUnsupported(err error) bool {
|
|
var requestErr PlatformRequestError
|
|
if !errors.As(err, &requestErr) || requestErr.Status != http.StatusBadRequest || requestErr.Code != "bad_request" {
|
|
return false
|
|
}
|
|
for _, detail := range requestErr.Details {
|
|
if strings.Contains(detail, "waitSeconds") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (c PlatformClient) AckJob(ctx context.Context, request protocol.RunJobAckRequest) (protocol.RunJobAckResponse, error) {
|
|
return postPlatformJSON[protocol.RunJobAckRequest, protocol.RunJobAckResponse](ctx, c, "/api/v1/run/jobs/ack", request)
|
|
}
|
|
|
|
func (c PlatformClient) UpdateJobProgress(ctx context.Context, request protocol.RunJobProgressRequest) (protocol.RunJobProgressResponse, error) {
|
|
return postPlatformJSON[protocol.RunJobProgressRequest, protocol.RunJobProgressResponse](ctx, c, "/api/v1/run/jobs/progress", request)
|
|
}
|
|
|
|
func (c PlatformClient) CompleteJob(ctx context.Context, request protocol.RunJobResultRequest) (protocol.RunJobResultResponse, error) {
|
|
return postPlatformJSON[protocol.RunJobResultRequest, protocol.RunJobResultResponse](ctx, c, "/api/v1/run/jobs/result", request)
|
|
}
|
|
|
|
func (c PlatformClient) GetDistributionBuildInput(ctx context.Context, request protocol.DistributionBuildInputRequest) (protocol.DistributionBuildInputResponse, error) {
|
|
return postPlatformJSON[protocol.DistributionBuildInputRequest, protocol.DistributionBuildInputResponse](ctx, c, "/api/v1/run/jobs/build-input", request)
|
|
}
|
|
|
|
func (c PlatformClient) GetDependencyExecutionInput(ctx context.Context, request protocol.DependencyExecutionInputRequest) (protocol.DependencyExecutionInputResponse, error) {
|
|
return postPlatformJSON[protocol.DependencyExecutionInputRequest, protocol.DependencyExecutionInputResponse](ctx, c, "/api/v1/run/jobs/dependency-input", request)
|
|
}
|
|
|
|
func (c PlatformClient) GetSourceRCONExecutionInput(ctx context.Context, request protocol.SourceRCONExecutionInputRequest) (protocol.SourceRCONExecutionInputResponse, error) {
|
|
return postPlatformJSON[protocol.SourceRCONExecutionInputRequest, protocol.SourceRCONExecutionInputResponse](ctx, c, "/api/v1/run/jobs/source-rcon-input", request)
|
|
}
|
|
|
|
func (c PlatformClient) GetProtectedRequestExecutionInput(ctx context.Context, request protocol.ProtectedRequestExecutionInputRequest) (protocol.ProtectedRequestExecutionInputResponse, error) {
|
|
return postPlatformJSON[protocol.ProtectedRequestExecutionInputRequest, protocol.ProtectedRequestExecutionInputResponse](ctx, c, "/api/v1/run/jobs/protected-request-input", request)
|
|
}
|
|
|
|
func (c PlatformClient) GetRunUpdateInput(ctx context.Context, request protocol.RunUpdateInputRequest) (protocol.RunUpdateInputResponse, error) {
|
|
return postPlatformJSON[protocol.RunUpdateInputRequest, protocol.RunUpdateInputResponse](ctx, c, "/api/v1/run/jobs/update-input", request)
|
|
}
|
|
|
|
func (c PlatformClient) ReadRunUpdateChunk(ctx context.Context, request protocol.RunUpdateChunkRequest) (protocol.RunUpdateChunkResponse, error) {
|
|
return postPlatformJSON[protocol.RunUpdateChunkRequest, protocol.RunUpdateChunkResponse](ctx, c, "/api/v1/run/jobs/update-chunk", request)
|
|
}
|
|
|
|
func (c PlatformClient) ReportRunUpdateHealth(ctx context.Context, request protocol.RunUpdateHealthRequest) (protocol.RunUpdateHealthResponse, error) {
|
|
return postPlatformJSON[protocol.RunUpdateHealthRequest, protocol.RunUpdateHealthResponse](ctx, c, "/api/v1/run/jobs/update-health", request)
|
|
}
|
|
|
|
func (c PlatformClient) PollJobCancel(ctx context.Context, request protocol.RunJobCancelPollRequest) (protocol.RunJobCancelPollResponse, error) {
|
|
return postPlatformJSON[protocol.RunJobCancelPollRequest, protocol.RunJobCancelPollResponse](ctx, c, "/api/v1/run/jobs/cancel", request)
|
|
}
|
|
|
|
func (c PlatformClient) ReconcileJobs(ctx context.Context, request protocol.RunJobReconcileRequest) (protocol.RunJobReconcileResponse, error) {
|
|
return postPlatformJSON[protocol.RunJobReconcileRequest, protocol.RunJobReconcileResponse](ctx, c, "/api/v1/run/jobs/reconcile", request)
|
|
}
|
|
|
|
func (c PlatformClient) IngestLogBatch(ctx context.Context, request protocol.LogBatchIngestRequest) (protocol.LogBatchIngestResponse, error) {
|
|
return postPlatformJSON[protocol.LogBatchIngestRequest, protocol.LogBatchIngestResponse](ctx, c, "/api/v1/run/logs/batches", request)
|
|
}
|
|
|
|
func (c PlatformClient) GetRunLogStreamProgress(ctx context.Context, request protocol.RunLogStreamProgressRequest) (protocol.RunLogStreamProgressResponse, error) {
|
|
return postPlatformJSON[protocol.RunLogStreamProgressRequest, protocol.RunLogStreamProgressResponse](ctx, c, "/api/v1/run/logs/progress", request)
|
|
}
|
|
|
|
func (c PlatformClient) IngestMetricBatch(ctx context.Context, request protocol.MetricBatchIngestRequest) (protocol.MetricBatchIngestResponse, error) {
|
|
return postPlatformJSON[protocol.MetricBatchIngestRequest, protocol.MetricBatchIngestResponse](ctx, c, "/api/v1/run/metrics/batches", request)
|
|
}
|
|
|
|
func (c PlatformClient) OpenArtifactTransfer(ctx context.Context, request protocol.ArtifactTransferOpenRequest) (protocol.ArtifactTransferOpenResponse, error) {
|
|
return postPlatformJSON[protocol.ArtifactTransferOpenRequest, protocol.ArtifactTransferOpenResponse](ctx, c, "/api/v1/run/artifacts/open", request)
|
|
}
|
|
|
|
func (c PlatformClient) UploadArtifactChunk(ctx context.Context, request protocol.ArtifactChunkUploadRequest) (protocol.ArtifactChunkUploadResponse, error) {
|
|
return postPlatformJSON[protocol.ArtifactChunkUploadRequest, protocol.ArtifactChunkUploadResponse](ctx, c, "/api/v1/run/artifacts/chunks", request)
|
|
}
|
|
|
|
func (c PlatformClient) QueryArtifactTransferStatus(ctx context.Context, request protocol.ArtifactTransferStatusRequest) (protocol.ArtifactTransferStatusResponse, error) {
|
|
return postPlatformJSON[protocol.ArtifactTransferStatusRequest, protocol.ArtifactTransferStatusResponse](ctx, c, "/api/v1/run/artifacts/status", request)
|
|
}
|
|
|
|
func (c PlatformClient) CompleteArtifactTransfer(ctx context.Context, request protocol.ArtifactTransferCompleteRequest) (protocol.ArtifactTransferCompleteResponse, error) {
|
|
return postPlatformJSON[protocol.ArtifactTransferCompleteRequest, protocol.ArtifactTransferCompleteResponse](ctx, c, "/api/v1/run/artifacts/complete", request)
|
|
}
|
|
|
|
func postPlatformJSON[Request any, Response any](ctx context.Context, client PlatformClient, path string, request Request) (Response, error) {
|
|
var response Response
|
|
startedAt := time.Now()
|
|
log.Printf("RUN platform request status=starting method=POST base=%s path=%s", diagnosticLogValue(client.baseURL), path)
|
|
|
|
var body bytes.Buffer
|
|
if err := json.NewEncoder(&body).Encode(request); err != nil {
|
|
log.Printf("RUN platform request status=encode_error method=POST base=%s path=%s durationMs=%d error=%s", diagnosticLogValue(client.baseURL), path, time.Since(startedAt).Milliseconds(), err)
|
|
return response, fmt.Errorf("encode platform request: %w", err)
|
|
}
|
|
|
|
httpRequest, err := http.NewRequestWithContext(ctx, http.MethodPost, client.baseURL+path, &body)
|
|
if err != nil {
|
|
log.Printf("RUN platform request status=build_error method=POST base=%s path=%s durationMs=%d error=%s", diagnosticLogValue(client.baseURL), path, time.Since(startedAt).Milliseconds(), err)
|
|
return response, fmt.Errorf("build platform request: %w", err)
|
|
}
|
|
httpRequest.Header.Set("Content-Type", "application/json")
|
|
httpRequest.Header.Set("Accept", "application/json")
|
|
if path != "/api/v1/run/control/hello" {
|
|
signatureSummary, err := signRunRequest(httpRequest, body.Bytes(), client.signatureTime())
|
|
if err != nil {
|
|
log.Printf("RUN platform request status=sign_error method=POST base=%s path=%s durationMs=%d error=%s", diagnosticLogValue(client.baseURL), path, time.Since(startedAt).Milliseconds(), err)
|
|
return response, err
|
|
}
|
|
log.Printf("RUN platform request status=signed method=POST base=%s path=%s endpoint=%s timestamp=%s nonce=%s bodyHash=%s signature=%s", diagnosticLogValue(client.baseURL), path, diagnosticLogValue(signatureSummary.RunEndpointID), signatureSummary.Timestamp, shortDiagnosticValue(signatureSummary.Nonce), shortDiagnosticValue(signatureSummary.BodyHash), shortDiagnosticValue(signatureSummary.Signature))
|
|
}
|
|
|
|
httpResponse, err := client.httpClient.Do(httpRequest)
|
|
if err != nil {
|
|
log.Printf("RUN platform request status=send_error method=POST base=%s path=%s durationMs=%d error=%s", diagnosticLogValue(client.baseURL), path, time.Since(startedAt).Milliseconds(), err)
|
|
return response, fmt.Errorf("send platform request: %w", err)
|
|
}
|
|
defer httpResponse.Body.Close()
|
|
log.Printf("RUN platform request status=response method=POST base=%s path=%s httpStatus=%d durationMs=%d", diagnosticLogValue(client.baseURL), path, httpResponse.StatusCode, time.Since(startedAt).Milliseconds())
|
|
|
|
if httpResponse.StatusCode < http.StatusOK || httpResponse.StatusCode >= http.StatusMultipleChoices {
|
|
var failure struct {
|
|
Code string `json:"code"`
|
|
Details []string `json:"details"`
|
|
}
|
|
_ = json.NewDecoder(io.LimitReader(httpResponse.Body, 64<<10)).Decode(&failure)
|
|
return response, PlatformRequestError{Status: httpResponse.StatusCode, Path: path, Code: failure.Code, Details: failure.Details}
|
|
}
|
|
if err := json.NewDecoder(httpResponse.Body).Decode(&response); err != nil {
|
|
return response, fmt.Errorf("decode platform response: %w", err)
|
|
}
|
|
client.observeResponseServerTime(response)
|
|
return response, nil
|
|
}
|
|
|
|
type runRequestEnvelope struct {
|
|
RunEndpointID string `json:"runEndpointId"`
|
|
SessionToken string `json:"sessionToken"`
|
|
}
|
|
|
|
type runRequestSignatureSummary struct {
|
|
RunEndpointID string
|
|
Timestamp string
|
|
Nonce string
|
|
BodyHash string
|
|
Signature string
|
|
}
|
|
|
|
func signRunRequest(request *http.Request, body []byte, stamp time.Time) (runRequestSignatureSummary, error) {
|
|
var envelope runRequestEnvelope
|
|
if err := json.Unmarshal(body, &envelope); err != nil {
|
|
return runRequestSignatureSummary{}, fmt.Errorf("decode Run signing envelope: %w", err)
|
|
}
|
|
if strings.TrimSpace(envelope.RunEndpointID) == "" || strings.TrimSpace(envelope.SessionToken) == "" {
|
|
return runRequestSignatureSummary{}, fmt.Errorf("Run signing envelope requires endpoint and session token")
|
|
}
|
|
nonceBytes := make([]byte, 16)
|
|
if _, err := rand.Read(nonceBytes); err != nil {
|
|
return runRequestSignatureSummary{}, fmt.Errorf("create Run request nonce: %w", err)
|
|
}
|
|
timestamp := strconv.FormatInt(stamp.UTC().Unix(), 10)
|
|
nonce := hex.EncodeToString(nonceBytes)
|
|
bodyHash := sha256.Sum256(body)
|
|
bodyHashHex := hex.EncodeToString(bodyHash[:])
|
|
canonical := strings.Join([]string{request.Method, request.URL.Path, timestamp, nonce, hex.EncodeToString(bodyHash[:])}, "\n")
|
|
mac := hmac.New(sha256.New, []byte(envelope.SessionToken))
|
|
_, _ = mac.Write([]byte(canonical))
|
|
signature := hex.EncodeToString(mac.Sum(nil))
|
|
request.Header.Set("X-Run-Endpoint", envelope.RunEndpointID)
|
|
request.Header.Set("X-Run-Timestamp", timestamp)
|
|
request.Header.Set("X-Run-Nonce", nonce)
|
|
request.Header.Set("X-Run-Signature", signature)
|
|
return runRequestSignatureSummary{RunEndpointID: envelope.RunEndpointID, Timestamp: timestamp, Nonce: nonce, BodyHash: bodyHashHex, Signature: signature}, nil
|
|
}
|
|
|
|
func (c PlatformClient) signatureTime() time.Time {
|
|
now := time.Now().UTC()
|
|
if c.serverClockOffsetActive != nil && c.serverClockOffsetActive.Load() && c.serverClockOffsetNanos != nil {
|
|
return now.Add(time.Duration(c.serverClockOffsetNanos.Load()))
|
|
}
|
|
return now
|
|
}
|
|
|
|
func (c PlatformClient) observeResponseServerTime(response any) {
|
|
serverTime, ok := responseServerTime(response)
|
|
if !ok || serverTime.IsZero() || c.serverClockOffsetNanos == nil || c.serverClockOffsetActive == nil {
|
|
return
|
|
}
|
|
observedAt := time.Now().UTC()
|
|
offset := serverTime.UTC().Sub(observedAt)
|
|
previousActive := c.serverClockOffsetActive.Load()
|
|
previous := time.Duration(c.serverClockOffsetNanos.Load())
|
|
c.serverClockOffsetNanos.Store(int64(offset))
|
|
c.serverClockOffsetActive.Store(true)
|
|
if !previousActive || absDuration(offset-previous) > time.Second {
|
|
log.Printf("RUN platform clock status=calibrated offsetMs=%d serverTime=%s", offset.Milliseconds(), serverTime.UTC().Format(time.RFC3339))
|
|
}
|
|
}
|
|
|
|
func responseServerTime(response any) (time.Time, bool) {
|
|
value := reflect.ValueOf(response)
|
|
for value.Kind() == reflect.Pointer || value.Kind() == reflect.Interface {
|
|
if value.IsNil() {
|
|
return time.Time{}, false
|
|
}
|
|
value = value.Elem()
|
|
}
|
|
if value.Kind() != reflect.Struct {
|
|
return time.Time{}, false
|
|
}
|
|
field := value.FieldByName("ServerTime")
|
|
if !field.IsValid() || !field.CanInterface() {
|
|
return time.Time{}, false
|
|
}
|
|
serverTime, ok := field.Interface().(time.Time)
|
|
return serverTime, ok
|
|
}
|
|
|
|
func absDuration(value time.Duration) time.Duration {
|
|
if value < 0 {
|
|
return -value
|
|
}
|
|
return value
|
|
}
|
|
|
|
func shortDiagnosticValue(value string) string {
|
|
value = diagnosticLogValue(value)
|
|
if value == "" {
|
|
return "-"
|
|
}
|
|
if len(value) <= 16 {
|
|
return value
|
|
}
|
|
return value[:12] + "..." + value[len(value)-4:]
|
|
}
|
|
|
|
func diagnosticLogValue(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
return strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace(value)
|
|
}
|