Files
go-sip/internal/store/local_origination.go
T

165 lines
7.7 KiB
Go

package store
import (
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
)
var ErrLocalOriginationNotAuthorized = errors.New("local execution is not authorized for origination")
type LocalOriginationCandidate struct {
DispatcherID string
ExecutionID string
TenantID string
TenantKey string
TaskID string
CommandID string
Callee string
CommandBody json.RawMessage
Snapshot LocalExecutionConfigSnapshot
SnapshotSHA256 string
}
// LoadLocalOrigination checks current task, reservation, control, discovery and
// assignment state. Its caller must still make the final time/policy decision
// against the immutable bound snapshot and atomically claim the task below.
func (s *Store) LoadLocalOrigination(dispatcherID, executionID string) (LocalOriginationCandidate, error) {
s.mu.Lock()
defer s.mu.Unlock()
if dispatcherID == "" || executionID == "" {
return LocalOriginationCandidate{}, ErrLocalOriginationNotAuthorized
}
candidate := LocalOriginationCandidate{DispatcherID: dispatcherID, ExecutionID: executionID}
var taskStatus, inboxStatus, inboxHash, reservationState, admissionState string
var removed, ready int
var snapshotBody []byte
err := s.db.QueryRow(`SELECT t.tenant_id,t.tenant_key,t.task_id,t.task_item_id,t.callee,t.status,
i.body,i.body_hash,i.status,c.body,c.content_sha256,r.state,a.admission_state,a.removed,d.ready
FROM tasks t
JOIN inbox i ON i.command_id=t.task_item_id AND i.command_type='call.execute'
JOIN local_v01_execution_configs c ON c.execution_id=t.execution_id
JOIN reservations r ON r.execution_id=t.execution_id
JOIN local_v01_task_assignments a ON a.dispatcher_id=? AND a.task_id=t.task_id
JOIN local_v04_task_discovery_state d ON d.dispatcher_id=a.dispatcher_id
WHERE t.execution_id=?`, dispatcherID, executionID).Scan(&candidate.TenantID, &candidate.TenantKey, &candidate.TaskID, &candidate.CommandID, &candidate.Callee, &taskStatus,
&candidate.CommandBody, &inboxHash, &inboxStatus, &snapshotBody, &candidate.SnapshotSHA256, &reservationState, &admissionState, &removed, &ready)
if errors.Is(err, sql.ErrNoRows) {
return LocalOriginationCandidate{}, ErrLocalOriginationNotAuthorized
}
if err != nil {
return LocalOriginationCandidate{}, fmt.Errorf("load local origination facts: %w", err)
}
if taskStatus != "reserved" || inboxStatus != "persisted" || reservationState != "held" || admissionState != "running" || removed != 0 || ready != 1 {
return LocalOriginationCandidate{}, fmt.Errorf("%w: task=%s inbox=%s reservation=%s admission=%s removed=%d discovery_ready=%d", ErrLocalOriginationNotAuthorized,
taskStatus, inboxStatus, reservationState, admissionState, removed, ready)
}
if err := validateLocalOriginationCandidate(&candidate, snapshotBody, inboxHash); err != nil {
return LocalOriginationCandidate{}, err
}
return candidate, nil
}
func validateLocalOriginationCandidate(candidate *LocalOriginationCandidate, snapshotBody []byte, inboxHash string) error {
commandDigest := sha256.Sum256(candidate.CommandBody)
snapshotDigest := sha256.Sum256(snapshotBody)
if hex.EncodeToString(commandDigest[:]) != inboxHash || hex.EncodeToString(snapshotDigest[:]) != candidate.SnapshotSHA256 {
return fmt.Errorf("%w: persisted command or snapshot digest mismatch", ErrLocalOriginationNotAuthorized)
}
if err := json.Unmarshal(snapshotBody, &candidate.Snapshot); err != nil {
return fmt.Errorf("decode bound execution snapshot: %w", err)
}
if candidate.Snapshot.SchemaVersion != "execution-config-snapshot.v0.3" || candidate.Snapshot.DispatcherID != candidate.DispatcherID ||
candidate.Snapshot.TaskID != candidate.TaskID || candidate.Snapshot.TenantID != candidate.TenantID || candidate.Snapshot.TenantKey != candidate.TenantKey ||
candidate.Snapshot.SelectedTrunkID == "" {
return fmt.Errorf("%w: execution snapshot identity or selected trunk mismatch", ErrLocalOriginationNotAuthorized)
}
return nil
}
// ClaimLocalOrigination commits the one-shot dial decision and its Agent
// control route together. A crash between them must not leave an unaddressable
// in-flight call. The admission reservation is not debited a second time.
func (s *Store) ClaimLocalOrigination(dispatcherID, executionID, agentID string, binding *agentpb.ExecutionBinding, decidedAt time.Time) error {
if decidedAt.IsZero() || binding == nil || binding.ExecutionId != executionID || binding.AttemptId != executionID || agentID == "" {
return ErrLocalOriginationNotAuthorized
}
s.mu.Lock()
defer s.mu.Unlock()
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if err := bindExecutionAgentTx(tx, agentID, binding); err != nil {
return fmt.Errorf("bind local origination Agent: %w", err)
}
result, err := tx.Exec(`INSERT INTO local_v02_origination_decisions(execution_id,dispatcher_id,decision,reason,decided_at)
SELECT t.execution_id,?,'issued','',? FROM tasks t WHERE t.execution_id=? AND t.status='reserved'
AND EXISTS (SELECT 1 FROM reservations r WHERE r.execution_id=t.execution_id AND r.state='held')
AND EXISTS (SELECT 1 FROM local_v01_task_assignments a WHERE a.dispatcher_id=? AND a.task_id=t.task_id AND a.tenant_id=t.tenant_id AND a.tenant_key=t.tenant_key AND a.removed=0 AND a.admission_state='running')
AND EXISTS (SELECT 1 FROM local_v04_task_discovery_state d WHERE d.dispatcher_id=? AND d.ready=1)
AND NOT EXISTS (SELECT 1 FROM controls c WHERE c.execution_id=t.execution_id AND c.action IN ('pause','drain','stop','hangup'))
AND NOT EXISTS (SELECT 1 FROM local_v02_origination_decisions old WHERE old.execution_id=t.execution_id)`,
dispatcherID, decidedAt.UTC().Format(time.RFC3339Nano), executionID, dispatcherID, dispatcherID)
if err != nil {
return fmt.Errorf("claim local origination: %w", err)
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count != 1 {
return ErrLocalOriginationNotAuthorized
}
return tx.Commit()
}
// RefuseLocalOrigination records a failed final gate, not just a transient
// return value. A duplicate command or a clock change cannot revive it. It
// never changes an already issued decision.
func (s *Store) RefuseLocalOrigination(dispatcherID, executionID, reason string, decidedAt time.Time) error {
switch reason {
case "policy_denied", "control_closed", "invalid_binding":
default:
return fmt.Errorf("invalid local origination refusal reason %q", reason)
}
if dispatcherID == "" || executionID == "" || decidedAt.IsZero() {
return ErrLocalOriginationNotAuthorized
}
s.mu.Lock()
defer s.mu.Unlock()
result, err := s.db.Exec(`INSERT INTO local_v02_origination_decisions(execution_id,dispatcher_id,decision,reason,decided_at)
SELECT t.execution_id,?,'refused',?,? FROM tasks t WHERE t.execution_id=? AND t.status='reserved'
AND EXISTS (SELECT 1 FROM local_v01_task_assignments a WHERE a.dispatcher_id=? AND a.task_id=t.task_id AND a.tenant_id=t.tenant_id AND a.tenant_key=t.tenant_key)
AND NOT EXISTS (SELECT 1 FROM local_v02_origination_decisions old WHERE old.execution_id=t.execution_id)`,
dispatcherID, reason, decidedAt.UTC().Format(time.RFC3339Nano), executionID, dispatcherID)
if err != nil {
return fmt.Errorf("persist local origination refusal: %w", err)
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count == 1 {
return nil
}
var previous string
if err := s.db.QueryRow(`SELECT decision FROM local_v02_origination_decisions WHERE execution_id=? AND dispatcher_id=?`, executionID, dispatcherID).Scan(&previous); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return ErrLocalOriginationNotAuthorized
}
return fmt.Errorf("read local origination decision: %w", err)
}
if previous == "refused" {
return nil
}
return ErrLocalOriginationNotAuthorized
}