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

465 lines
18 KiB
Go

package configread
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"net/url"
"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"
taskDiscoverySnapshot = "snapshot"
)
type Client struct {
baseURL string
dispatcherID string
secret string
httpClient *http.Client
}
type Snapshot struct {
TaskID string
TenantID string
TenantKey string
Discovery TaskDiscovery
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
DiscoveryCursor string
FetchedAt time.Time
ExpiresAt time.Time
SIP json.RawMessage
Task json.RawMessage
Tasks json.RawMessage
TenantQuota json.RawMessage
}
type TaskDiscovery struct {
DispatcherID string
Mode string
Cursor string
FromCursor string
NextCursor string
Tasks []DiscoveredTask
Changes []TaskDiscoveryChange
Body json.RawMessage
Recovered bool
}
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 TaskDiscoveryChange struct {
Cursor string `json:"cursor"`
Operation string `json:"operation"`
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")
}
discovery, err := c.ReadTaskDiscovery(ctx, "")
if err != nil {
return Snapshot{}, err
}
if discovery.DispatcherID != c.dispatcherID || discovery.Mode != taskDiscoverySnapshot || discovery.Cursor == "" {
return Snapshot{}, errors.New("task discovery snapshot identity or mode does not match the request")
}
var discovered *DiscoveredTask
for i := range discovery.Tasks {
if discovery.Tasks[i].TaskID == taskID {
if discovered != nil {
return Snapshot{}, errors.New("task discovery contains duplicate task IDs")
}
discovered = &discovery.Tasks[i]
}
}
if discovered == nil || discovered.TenantID != tenantID {
return Snapshot{}, errors.New("task discovery does not bind the requested task to the requested tenant")
}
tasksBody := append(json.RawMessage(nil), discovery.Body...)
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 discovery")
}
if discovered.TenantKey != task.TenantKey || discovered.TaskRevision != task.TaskRevision || discovered.Status != task.Status {
return Snapshot{}, errors.New("task discovery and task configuration snapshots disagree")
}
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, Discovery: discovery,
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,
DiscoveryCursor: discovery.Cursor,
SIP: sipBody, Task: taskBody, Tasks: tasksBody, 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
}
// ReadTaskDiscovery accepts exactly one complete snapshot or change response.
// On HTTP 410 cursor_expired it fetches one full snapshot instead of advancing.
func (c *Client) ReadTaskDiscovery(ctx context.Context, after string) (TaskDiscovery, error) {
return c.readTaskDiscovery(ctx, after, false)
}
func (c *Client) readTaskDiscovery(ctx context.Context, after string, recovered bool) (TaskDiscovery, error) {
path := configReadPath + "/tasks"
if after != "" {
path += "?" + url.Values{"after": {after}}.Encode()
}
body, err := c.getTaskDiscovery(ctx, path)
if err != nil {
var statusErr *HTTPError
if !recovered && after != "" && errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusGone && statusErr.Code == "cursor_expired" {
full, recoveryErr := c.readTaskDiscovery(ctx, "", true)
if recoveryErr != nil {
return TaskDiscovery{}, fmt.Errorf("recover expired task-discovery cursor with full snapshot: %w", recoveryErr)
}
return full, nil
}
return TaskDiscovery{}, err
}
var response taskDiscoveryResponse
if err := json.Unmarshal(body, &response); err != nil {
return TaskDiscovery{}, fmt.Errorf("decode task discovery: %w", err)
}
if response.DispatcherID != c.dispatcherID {
return TaskDiscovery{}, errors.New("task-discovery dispatcher identity does not match the request")
}
result := TaskDiscovery{DispatcherID: response.DispatcherID, FromCursor: after, Body: append(json.RawMessage(nil), body...), Recovered: recovered}
tenantByID, idByTenant := make(map[string]string), make(map[string]string)
if after == "" {
result.Mode, result.Cursor, result.Tasks = taskDiscoverySnapshot, response.Cursor, response.Tasks
seen := make(map[string]struct{}, len(result.Tasks))
for _, task := range result.Tasks {
if _, duplicate := seen[task.TaskID]; duplicate {
return TaskDiscovery{}, fmt.Errorf("task discovery contains duplicate task ID %q", task.TaskID)
}
seen[task.TaskID] = struct{}{}
if err := validateTenantBinding(tenantByID, idByTenant, task.TenantID, task.TenantKey); err != nil {
return TaskDiscovery{}, err
}
}
} else {
result.Mode, result.NextCursor, result.Changes = "changes", response.NextCursor, response.Changes
if (len(result.Changes) == 0 && result.NextCursor != after) || (len(result.Changes) != 0 && result.NextCursor == after) {
return TaskDiscovery{}, errors.New("task discovery change cursor does not match the complete response")
}
for _, change := range result.Changes {
if err := validateTenantBinding(tenantByID, idByTenant, change.TenantID, change.TenantKey); err != nil {
return TaskDiscovery{}, err
}
}
}
return result, 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) getTaskDiscovery(ctx context.Context, path string) (json.RawMessage, error) {
body, err := c.get(ctx, path)
if err != nil {
return nil, err
}
if err := contract.ValidateLocalTaskDiscovery(body); err != nil {
return nil, fmt.Errorf("validate task-discovery 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 taskDiscoveryResponse struct {
DispatcherID string `json:"dispatcher_id"`
Cursor string `json:"cursor"`
NextCursor string `json:"next_cursor"`
Tasks []DiscoveredTask `json:"tasks"`
Changes []TaskDiscoveryChange `json:"changes"`
}
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"`
}