Files
go-sip/internal/configread/client.go
T

354 lines
13 KiB
Go

package configread
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.ipao.vip/rogee/go-sip/internal/contract"
)
const dispatcherIDHeader = "X-DISPATCHER-id"
const dispatcherSecretHeader = "X-DISPATCHER-SECRET-KEY"
const configReadPath = "/internal/v1/dispatcher"
const (
resourceSIPConfig = "sip_config"
resourceTaskConfig = "task_config"
resourceTenantQuota = "tenant_quota"
)
type Client struct {
baseURL string
dispatcherID string
secret string
httpClient *http.Client
}
type Snapshot struct {
TaskID string
TenantID string
TenantKey string
SIPRevision int64
SIPSnapshotSHA256 string
SIPCellID string
SIPArtifactRevision int64
SIPArtifactConfigSHA256 string
TaskRevision int64
TaskStatus string
TaskMaxConcurrentCalls int64
TaskRingTimeoutMS int64
TaskMaxCallDurationMS int64
TaskRoutePolicyID string
TaskCallerProfileID string
TaskAllowedTrunkIDs []string
AgentVersionID string
AgentAuthorizationID string
AgentAuthorizationExpiresAt time.Time
QuotaRevision int64
TenantMaxConcurrentCalls int64
QuotaValidUntil time.Time
FetchedAt time.Time
ExpiresAt time.Time
SIP json.RawMessage
Task json.RawMessage
TenantQuota json.RawMessage
}
type TaskDiscovery struct {
DispatcherID string
FromCursor string
NextCursor string
Tasks []DiscoveredTask
Body json.RawMessage
}
type DiscoveredTask struct {
TaskID string `json:"task_id"`
TenantID string `json:"tenant_id"`
TenantKey string `json:"tenant_key"`
Status string `json:"status"`
TaskRevision int64 `json:"task_revision"`
}
type HTTPError struct {
StatusCode int
Code string
}
func (e *HTTPError) Error() string {
if e.Code == "" {
return fmt.Sprintf("configuration service returned HTTP %d", e.StatusCode)
}
return fmt.Sprintf("configuration service returned HTTP %d (%s)", e.StatusCode, e.Code)
}
func NewClient(baseURL, dispatcherID, secret string, httpClient *http.Client) (*Client, error) {
parsed, err := url.ParseRequestURI(baseURL)
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("configuration service URL must be an absolute HTTP(S) URL without credentials, query, or fragment")
}
if strings.TrimSpace(dispatcherID) == "" || strings.TrimSpace(secret) == "" {
return nil, errors.New("dispatcher ID and secret are required")
}
if strings.ContainsAny(dispatcherID+secret, "\r\n") {
return nil, errors.New("dispatcher credentials contain invalid header characters")
}
if httpClient == nil {
httpClient = http.DefaultClient
}
return &Client{baseURL: strings.TrimRight(baseURL, "/"), dispatcherID: dispatcherID, secret: secret, httpClient: httpClient}, nil
}
func (c *Client) DispatcherID() string { return c.dispatcherID }
func (c *Client) ReadTask(ctx context.Context, taskID, tenantID string) (Snapshot, error) {
if taskID == "" || tenantID == "" {
return Snapshot{}, errors.New("task ID and tenant ID are required")
}
sipBody, err := c.getConfig(ctx, configReadPath+"/sip")
if err != nil {
return Snapshot{}, err
}
var sip sipConfigResponse
if err := json.Unmarshal(sipBody, &sip); err != nil {
return Snapshot{}, fmt.Errorf("decode SIP configuration identity: %w", err)
}
if sip.Resource != resourceSIPConfig || sip.DispatcherID != c.dispatcherID || sip.Revision <= 0 || sip.SnapshotSHA256 == "" ||
sip.Artifact.CellID == "" || sip.Artifact.Revision <= 0 || sip.Artifact.ConfigSHA256 == "" {
return Snapshot{}, errors.New("SIP configuration identity or revision does not match the request")
}
taskPath := configReadPath + "/task/" + url.PathEscape(taskID)
taskBody, err := c.getConfig(ctx, taskPath)
if err != nil {
return Snapshot{}, err
}
var task taskConfigResponse
if err := json.Unmarshal(taskBody, &task); err != nil {
return Snapshot{}, fmt.Errorf("decode task configuration identity: %w", err)
}
if task.Resource != resourceTaskConfig || task.DispatcherID != c.dispatcherID || task.TaskID != taskID || task.TenantID != tenantID ||
task.TenantKey == "" || task.TaskRevision <= 0 || task.MaxConcurrentCalls < 0 || task.RingTimeoutMS <= 0 ||
task.MaxCallDurationMS <= 0 || task.Agent.AgentVersionID == "" || task.Agent.AgentVersionID != task.Agent.Config.AgentVersionID {
return Snapshot{}, errors.New("task configuration identity or limits do not match the request")
}
authorizationExpiresAt, err := time.Parse(time.RFC3339, task.Agent.AuthorizationExpiresAt)
if err != nil {
return Snapshot{}, errors.New("task configuration has an invalid Agent authorization expiry")
}
quotaPath := configReadPath + "/tenant/" + url.PathEscape(tenantID) + "/quota"
quotaBody, err := c.getConfig(ctx, quotaPath)
if err != nil {
return Snapshot{}, err
}
var quota tenantQuotaResponse
if err := json.Unmarshal(quotaBody, &quota); err != nil {
return Snapshot{}, fmt.Errorf("decode tenant quota identity: %w", err)
}
if quota.Resource != resourceTenantQuota || quota.DispatcherID != c.dispatcherID || quota.TenantID != tenantID || quota.TenantKey != task.TenantKey || quota.QuotaRevision <= 0 {
return Snapshot{}, errors.New("tenant quota identity does not match task configuration")
}
quotaValidUntil, err := time.Parse(time.RFC3339, quota.ValidUntil)
if err != nil {
return Snapshot{}, errors.New("tenant quota has an invalid validity deadline")
}
return Snapshot{
TaskID: taskID, TenantID: tenantID, TenantKey: task.TenantKey,
SIPRevision: sip.Revision, SIPSnapshotSHA256: sip.SnapshotSHA256,
SIPCellID: sip.Artifact.CellID, SIPArtifactRevision: sip.Artifact.Revision,
SIPArtifactConfigSHA256: sip.Artifact.ConfigSHA256,
TaskRevision: task.TaskRevision, TaskStatus: task.Status,
TaskMaxConcurrentCalls: int64(task.MaxConcurrentCalls), TaskRingTimeoutMS: int64(task.RingTimeoutMS),
TaskMaxCallDurationMS: int64(task.MaxCallDurationMS), TaskRoutePolicyID: task.RoutePolicyID,
TaskCallerProfileID: task.CallerProfileID, TaskAllowedTrunkIDs: append([]string(nil), task.AllowedTrunkIDs...),
AgentVersionID: task.Agent.AgentVersionID, AgentAuthorizationID: task.Agent.AuthorizationID,
AgentAuthorizationExpiresAt: authorizationExpiresAt, QuotaRevision: quota.QuotaRevision,
TenantMaxConcurrentCalls: int64(quota.MaxConcurrentCalls), QuotaValidUntil: quotaValidUntil,
SIP: sipBody, Task: taskBody, TenantQuota: quotaBody,
}, nil
}
// TaskStatus is the authoritative task identity and control status read from
// the task resource without requiring unrelated SIP or quota resources.
type TaskStatus struct {
DispatcherID string
TaskID string
TenantID string
TenantKey string
Status string
TaskRevision int64
}
// ReadTaskStatus fetches only one task resource. Control handling uses this
// read even when execution configuration or quota is unavailable.
func (c *Client) ReadTaskStatus(ctx context.Context, taskID, tenantID, tenantKey string) (TaskStatus, error) {
if taskID == "" || tenantID == "" || tenantKey == "" {
return TaskStatus{}, errors.New("task ID, tenant ID, and tenant key are required")
}
path := configReadPath + "/task/" + url.PathEscape(taskID)
body, err := c.getConfig(ctx, path)
if err != nil {
return TaskStatus{}, err
}
var task taskConfigResponse
if err := json.Unmarshal(body, &task); err != nil {
return TaskStatus{}, fmt.Errorf("decode task control status: %w", err)
}
if task.Resource != resourceTaskConfig || task.DispatcherID != c.dispatcherID || task.TaskID != taskID ||
task.TenantID != tenantID || task.TenantKey != tenantKey || task.TaskRevision <= 0 {
return TaskStatus{}, errors.New("task control status identity does not match the request")
}
switch task.Status {
case "running", "paused", "stopped", "finished":
default:
return TaskStatus{}, fmt.Errorf("unsupported authoritative task status %q", task.Status)
}
return TaskStatus{
DispatcherID: task.DispatcherID, TaskID: task.TaskID, TenantID: task.TenantID,
TenantKey: task.TenantKey, Status: task.Status, TaskRevision: task.TaskRevision,
}, nil
}
func parseEventCursor(cursor string) (uint64, error) {
value, err := strconv.ParseUint(cursor, 10, 64)
if err != nil || strconv.FormatUint(value, 10) != cursor {
return 0, errors.New("event cursor must be a canonical decimal uint64")
}
return value, nil
}
func validateTenantBinding(tenantByID, idByTenant map[string]string, tenantID, tenantKey string) error {
if len([]byte(tenantKey)) > 196 {
return errors.New("task discovery tenant_key exceeds the 196-byte limit")
}
if existing, ok := tenantByID[tenantID]; ok && existing != tenantKey {
return errors.New("task discovery changes the tenant_key bound to a tenant_id")
}
if existing, ok := idByTenant[tenantKey]; ok && existing != tenantID {
return errors.New("task discovery binds one tenant_key to multiple tenant IDs")
}
tenantByID[tenantID] = tenantKey
idByTenant[tenantKey] = tenantID
return nil
}
func (c *Client) getConfig(ctx context.Context, path string) (json.RawMessage, error) {
body, err := c.get(ctx, path)
if err != nil {
return nil, err
}
if err := contract.ValidateLocalConfigRead(body); err != nil {
return nil, fmt.Errorf("validate configuration response: %w", err)
}
return body, nil
}
func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) {
relative, err := url.Parse(path)
if err != nil {
return nil, fmt.Errorf("parse configuration path: %w", err)
}
base, err := url.Parse(c.baseURL)
if err != nil {
return nil, fmt.Errorf("parse configuration base URL: %w", err)
}
base = base.JoinPath(relative.Path)
base.RawQuery = relative.RawQuery
request, err := http.NewRequestWithContext(ctx, http.MethodGet, base.String(), nil)
if err != nil {
return nil, fmt.Errorf("build configuration request: %w", err)
}
request.Header.Set("Accept", "application/json")
request.Header.Set(dispatcherIDHeader, c.dispatcherID)
request.Header.Set(dispatcherSecretHeader, c.secret)
response, err := c.httpClient.Do(request)
if err != nil {
return nil, fmt.Errorf("request configuration service: %w", err)
}
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
return nil, fmt.Errorf("read configuration response: %w", err)
}
if response.StatusCode != http.StatusOK {
return nil, &HTTPError{StatusCode: response.StatusCode, Code: responseErrorCode(body)}
}
mediaType, _, err := mime.ParseMediaType(response.Header.Get("Content-Type"))
if err != nil || mediaType != "application/json" {
return nil, errors.New("configuration service response must use application/json")
}
if !json.Valid(body) {
return nil, errors.New("configuration service returned invalid JSON")
}
return json.RawMessage(body), nil
}
func responseErrorCode(body []byte) string {
var response struct {
Error struct {
Code string `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(body, &response); err != nil {
return ""
}
return response.Error.Code
}
type sipConfigResponse struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
Revision int64 `json:"revision"`
SnapshotSHA256 string `json:"snapshot_sha256"`
Artifact struct {
CellID string `json:"cell_id"`
Revision int64 `json:"revision"`
ConfigSHA256 string `json:"config_sha256"`
} `json:"artifact"`
}
type taskConfigResponse struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
TenantID string `json:"tenant_id"`
TenantKey string `json:"tenant_key"`
TaskID string `json:"task_id"`
TaskRevision int64 `json:"task_revision"`
Status string `json:"status"`
MaxConcurrentCalls int `json:"max_concurrent_calls"`
RingTimeoutMS int `json:"ring_timeout_ms"`
MaxCallDurationMS int `json:"max_call_duration_ms"`
RoutePolicyID string `json:"route_policy_id"`
CallerProfileID string `json:"caller_profile_id"`
AllowedTrunkIDs []string `json:"allowed_trunk_ids"`
Agent struct {
AgentVersionID string `json:"agent_version_id"`
AuthorizationID string `json:"authorization_id"`
AuthorizationExpiresAt string `json:"authorization_expires_at"`
Config struct {
AgentVersionID string `json:"agent_version_id"`
} `json:"config"`
} `json:"agent"`
}
type tenantQuotaResponse struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
TenantID string `json:"tenant_id"`
TenantKey string `json:"tenant_key"`
QuotaRevision int64 `json:"quota_revision"`
MaxConcurrentCalls int `json:"max_concurrent_calls"`
ValidUntil string `json:"valid_until"`
}