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

226 lines
7.8 KiB
Go

package configread
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"git.ipao.vip/rogee/go-sip/internal/contract"
)
// Snapshot carries the four independently validated configuration
// resources. Agent.Raw preserves explicit zero/false and immutable AI fields.
type Snapshot struct {
SIP SIP
Task Task
Quota Quota
Providers map[string]Provider
}
type SIP struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
Revision int64 `json:"revision,omitempty"`
Trunks json.RawMessage `json:"trunks"`
}
// Provider contains connection information only. Role and model come from the task.
type Provider struct {
ProviderRef string `json:"provider_id"`
Code string `json:"provider_code"`
Name string `json:"name,omitempty"`
Endpoint string `json:"api_endpoint,omitempty"`
WSEndpoint string `json:"ws_endpoint,omitempty"`
Credential string `json:"api_key"`
ExtraConfig json.RawMessage `json:"extra_config,omitempty"`
}
type providerResponse struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
Providers []Provider `json:"providers"`
}
type Agent struct {
Mode string `json:"mode"`
ASR struct {
ProviderRef string `json:"provider_ref"`
} `json:"asr"`
LLM struct {
ProviderRef string `json:"provider_ref"`
} `json:"llm"`
TTS struct {
ProviderRef string `json:"provider_ref"`
} `json:"tts"`
Raw json.RawMessage `json:"-"`
}
func (a *Agent) UnmarshalJSON(raw []byte) error {
type plain Agent
var parsed plain
if err := json.Unmarshal(raw, &parsed); err != nil {
return err
}
*a = Agent(parsed)
a.Raw = append([]byte(nil), raw...)
return nil
}
type AllowedTrunk struct {
TrunkID string `json:"trunk_id"`
Concurrency int64 `json:"concurrency"`
}
type Task struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
TenantID int64 `json:"tenant_id"`
TaskID string `json:"task_id"`
TaskRevision int64 `json:"task_revision"`
Status string `json:"status"`
Name string `json:"name"`
MaxConcurrentCalls int64 `json:"max_concurrent_calls"`
RingTimeoutMS int64 `json:"ring_timeout_ms"`
MaxCallDurationMS int64 `json:"max_call_duration_ms"`
RoutePolicyID string `json:"route_policy_id"`
AllowedTrunks []AllowedTrunk `json:"allowed_trunk_ids"`
Schedule json.RawMessage `json:"schedule"`
Agent Agent `json:"agent"`
Raw json.RawMessage `json:"-"`
}
func (t *Task) UnmarshalJSON(raw []byte) error {
type plain Task
var parsed plain
if err := json.Unmarshal(raw, &parsed); err != nil {
return err
}
decoded := Task(parsed)
if _, err := decoded.TrunkLimits(); err != nil {
return err
}
*t = decoded
t.Raw = append([]byte(nil), raw...)
return nil
}
// TrunkLimits validates the task's per-line limits without changing them.
// Zero disables that line for this task; duplicate IDs are ambiguous even
// when the complete objects differ and therefore pass JSON Schema uniqueness.
func (t Task) TrunkLimits() (map[string]int64, error) {
if len(t.AllowedTrunks) == 0 {
return nil, errors.New("task allowed_trunk_ids is empty")
}
limits := make(map[string]int64, len(t.AllowedTrunks))
for _, trunk := range t.AllowedTrunks {
if trunk.TrunkID == "" || trunk.Concurrency < 0 {
return nil, errors.New("task trunk ID or concurrency is invalid")
}
if _, exists := limits[trunk.TrunkID]; exists {
return nil, errors.New("task allowed_trunk_ids contains duplicate trunk IDs")
}
limits[trunk.TrunkID] = trunk.Concurrency
}
return limits, nil
}
type Quota struct {
Resource string `json:"resource"`
DispatcherID string `json:"dispatcher_id"`
TenantID int64 `json:"tenant_id"`
QuotaRevision int64 `json:"quota_revision"`
MaxConcurrentCalls int64 `json:"max_concurrent_calls"`
}
// ReadSIP is always called at Dispatcher startup, even with no tasks.
// Revision is not a claim that Agent/Asterisk actually loaded this snapshot;
// the caller must verify applied state before opening admission.
func (c *Client) ReadSIP(ctx context.Context) (SIP, error) {
var sip SIP
if err := c.readResource(ctx, configReadPath+"/sip", "sip_config", &sip); err != nil {
return SIP{}, fmt.Errorf("read SIP configuration: %w", err)
}
if sip.DispatcherID != c.dispatcherID {
return SIP{}, errors.New("SIP configuration dispatcher owner mismatch")
}
return sip, nil
}
// ReadProviders reads the complete provider catalog once per Dispatcher boot.
// Running tasks use this immutable catalog until the next restart.
func (c *Client) ReadProviders(ctx context.Context) (map[string]Provider, error) {
var response providerResponse
if err := c.readResource(ctx, configReadPath+"/ai-providers", "ai_providers", &response); err != nil {
return nil, fmt.Errorf("read AI providers: %w", err)
}
if response.DispatcherID != c.dispatcherID {
return nil, errors.New("AI providers dispatcher owner mismatch")
}
providers := make(map[string]Provider, len(response.Providers))
for _, provider := range response.Providers {
if _, exists := providers[provider.Code]; exists {
return nil, fmt.Errorf("duplicate AI provider reference %q", provider.Code)
}
providers[provider.Code] = provider
}
return providers, nil
}
// ReadTask reads current task and quota resources using the SIP and provider
// snapshots approved at boot. No task operation fetches the global resources.
func (c *Client) ReadTask(ctx context.Context, taskID string, tenantID int64, sip SIP, providers map[string]Provider) (Snapshot, error) {
if taskID == "" || tenantID <= 0 {
return Snapshot{}, errors.New("task ID and positive tenant ID are required")
}
if sip.DispatcherID != c.dispatcherID || sip.Revision <= 0 || len(providers) == 0 {
return Snapshot{}, errors.New("task requires approved SIP and AI provider snapshots")
}
result := Snapshot{SIP: sip, Providers: providers}
if err := c.readResource(ctx, configReadPath+"/task/"+url.PathEscape(taskID), "task_config", &result.Task); err != nil {
return Snapshot{}, fmt.Errorf("read task configuration: %w", err)
}
if err := c.readResource(ctx, configReadPath+"/tenant/"+strconv.FormatInt(tenantID, 10)+"/quota", "tenant_quota", &result.Quota); err != nil {
return Snapshot{}, fmt.Errorf("read tenant quota: %w", err)
}
if result.SIP.DispatcherID != c.dispatcherID || result.Task.DispatcherID != c.dispatcherID || result.Quota.DispatcherID != c.dispatcherID || result.Task.TenantID != tenantID || result.Quota.TenantID != tenantID || result.Task.TaskID != taskID {
return Snapshot{}, errors.New("configuration dispatcher, task, or tenant owner mismatch")
}
for _, expected := range []struct{ ref, role string }{
{result.Task.Agent.ASR.ProviderRef, "asr"},
{result.Task.Agent.LLM.ProviderRef, "llm"},
{result.Task.Agent.TTS.ProviderRef, "tts"},
} {
if expected.ref == "" {
continue
}
provider, found := result.Providers[expected.ref]
if !found || provider.Code != expected.ref {
return Snapshot{}, fmt.Errorf("%s provider_ref is missing from the approved connection catalog", expected.role)
}
}
return result, nil
}
func (c *Client) readResource(ctx context.Context, path, resource string, dst any) error {
body, err := c.get(ctx, path)
if err != nil {
return err
}
if err := contract.ValidateCurrent("config-read", body); err != nil {
return err
}
var identity struct {
Resource string `json:"resource"`
}
if err := json.Unmarshal(body, &identity); err != nil {
return err
}
if identity.Resource != resource {
return fmt.Errorf("expected %s, got %s", resource, identity.Resource)
}
return json.Unmarshal(body, dst)
}