Apply approved SIP snapshots through isolated Dispatcher and Agent channel

This commit is contained in:
2026-10-03 12:45:11 +08:00
parent b68711be9c
commit a52fe5a741
32 changed files with 1437 additions and 128 deletions
+284
View File
@@ -0,0 +1,284 @@
package asterisk
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"git.ipao.vip/rogee/go-sip/internal/configread"
)
// Loader writes only the Agent-owned endpoint include. A reviewed static
// pjsip.conf owns the transport, whose reload can interrupt live calls.
type Loader struct {
ConfigDir string
Asterisk string
LibraryDir string
}
type loadedTrunk struct {
ID string `json:"id"`
Host string `json:"host"`
Port int `json:"port"`
}
type appliedState struct {
Revision int64 `json:"revision"`
Hash string `json:"hash"`
SnapshotHash string `json:"snapshot_hash"`
Trunks []loadedTrunk `json:"trunks"`
}
var endpointLine = regexp.MustCompile(`(?m)^\s*Endpoint:\s+(\S+)`)
func (l Loader) paths() (string, string, error) {
if l.ConfigDir == "" || l.Asterisk == "" || l.LibraryDir == "" {
return "", "", errors.New("native Asterisk executable, library and config paths are required")
}
base, err := os.ReadFile(filepath.Join(l.ConfigDir, "pjsip.conf"))
if err != nil {
return "", "", fmt.Errorf("read management-owned PJSIP base: %w", err)
}
if !bytes.Contains(base, []byte("#tryinclude go-sip-managed.conf")) || !bytes.Contains(base, []byte("[go-sip-udp]")) {
return "", "", errors.New("PJSIP base is missing the approved static transport and managed include")
}
return filepath.Join(l.ConfigDir, "go-sip-managed.conf"), filepath.Join(l.ConfigDir, "go-sip-applied.json"), nil
}
func (l Loader) command(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Env = append(os.Environ(), "LD_LIBRARY_PATH="+l.LibraryDir)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("native Asterisk command %q failed: %w (output_sha256=%x)", filepath.Base(name), err, sha256.Sum256(out))
}
return out, nil
}
func (l Loader) cli(ctx context.Context, command string) ([]byte, error) {
return l.command(ctx, l.Asterisk, "-C", filepath.Join(l.ConfigDir, "asterisk.conf"), "-rx", command)
}
func (l Loader) observe(ctx context.Context, state appliedState) error {
if state.Revision < 1 || state.SnapshotHash == "" || len(state.Trunks) == 0 {
return errors.New("no verified active SIP trunks")
}
transport, err := l.cli(ctx, "pjsip show transports")
if err != nil {
return fmt.Errorf("inspect native UDP transport: %w", err)
}
approvedTransport := false
for _, line := range strings.Split(string(transport), "\n") {
fields := strings.Fields(line)
if len(fields) >= 6 && fields[0] == "Transport:" && fields[1] == "go-sip-udp" && fields[2] == "udp" && fields[len(fields)-1] == "0.0.0.0:5060" {
approvedTransport = true
}
}
if !approvedTransport {
return errors.New("approved native UDP transport/address is not loaded")
}
all, err := l.cli(ctx, "pjsip show endpoints")
if err != nil {
return err
}
expected := make(map[string]loadedTrunk, len(state.Trunks))
for _, t := range state.Trunks {
expected[t.ID] = t
}
seen := make(map[string]bool)
for _, match := range endpointLine.FindAllSubmatch(all, -1) {
id := string(match[1])
if !trunkName.MatchString(id) { // skip Asterisk's <Endpoint/CID> heading
continue
}
if _, ok := expected[id]; !ok {
return fmt.Errorf("unapproved Asterisk endpoint %q is loaded", id)
}
seen[id] = true
}
if len(seen) != len(expected) {
return errors.New("approved SIP endpoint set is not loaded")
}
for _, t := range state.Trunks {
endpoint, err := l.cli(ctx, "pjsip show endpoint "+t.ID)
if err != nil || !bytes.Contains(endpoint, []byte(t.ID+"-aor")) || !bytes.Contains(endpoint, []byte("alaw")) || !bytes.Contains(endpoint, []byte("go-sip-udp")) || !bytes.Contains(endpoint, []byte("go-sip-no-inbound")) {
return fmt.Errorf("SIP endpoint %q did not load its approved AOR/codec", t.ID)
}
aor, err := l.cli(ctx, "pjsip show aor "+t.ID+"-aor")
if err != nil || !bytes.Contains(aor, []byte(fmt.Sprintf("sip:%s:%d", t.Host, t.Port))) {
return fmt.Errorf("SIP AOR %q did not load its approved contact", t.ID)
}
}
return nil
}
// LoadedSIP never trusts a persisted revision alone: it also checks the file
// hash and live PJSIP objects after every Agent boot or Dispatcher query.
func (l Loader) LoadedSIP(ctx context.Context) (map[string]int64, error) {
config, statePath, err := l.paths()
if err != nil {
return nil, err
}
raw, err := os.ReadFile(statePath)
if err != nil {
return nil, fmt.Errorf("read last verified SIP revision: %w", err)
}
var state appliedState
if err := json.Unmarshal(raw, &state); err != nil {
return nil, fmt.Errorf("decode last verified SIP revision: %w", err)
}
data, err := os.ReadFile(config)
if err != nil {
return nil, err
}
hash := sha256.Sum256(data)
if hex.EncodeToString(hash[:]) != state.Hash {
return nil, errors.New("SIP config no longer matches last verified revision")
}
if err := l.observe(ctx, state); err != nil {
return nil, err
}
loaded := make(map[string]int64, len(state.Trunks))
for _, t := range state.Trunks {
loaded[t.ID] = state.Revision
}
return loaded, nil
}
// Apply changes no transport, routes or existing call. It records a revision
// only after the native module reload and readback succeed.
func (l Loader) Apply(ctx context.Context, approvedJSON []byte) (map[string]int64, error) {
config, statePath, err := l.paths()
if err != nil {
return nil, err
}
var sip configread.SIP
if err := json.Unmarshal(approvedJSON, &sip); err != nil {
return nil, err
}
// The same renderer is used by RPC validation and disk application.
text, err := Render(sip)
if err != nil {
return nil, err
}
var trunks []struct {
ID string `json:"trunk_id"`
Host string `json:"server_host"`
Port int `json:"server_port"`
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(sip.Trunks, &trunks); err != nil {
return nil, err
}
canonical, err := json.Marshal(sip)
if err != nil {
return nil, err
}
state := appliedState{Revision: sip.Revision, Hash: fmt.Sprintf("%x", sha256.Sum256([]byte(text))), SnapshotHash: fmt.Sprintf("%x", sha256.Sum256(canonical))}
for _, t := range trunks {
if t.Enabled {
state.Trunks = append(state.Trunks, loadedTrunk{ID: t.ID, Host: t.Host, Port: t.Port})
}
}
sort.Slice(state.Trunks, func(i, j int) bool { return state.Trunks[i].ID < state.Trunks[j].ID })
recoverExisting := false
if existing, err := os.ReadFile(statePath); err == nil {
var previous appliedState
if err := json.Unmarshal(existing, &previous); err != nil {
return nil, err
}
if sip.Revision < previous.Revision || (sip.Revision == previous.Revision && (state.Hash != previous.Hash || state.SnapshotHash != previous.SnapshotHash)) {
return nil, errors.New("approved SIP revision regressed or changed content")
}
if sip.Revision == previous.Revision {
return l.LoadedSIP(ctx)
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
} else if current, err := os.ReadFile(config); err == nil {
if sha256.Sum256(current) != sha256.Sum256([]byte(text)) {
return nil, errors.New("unverified managed SIP config does not match the approved snapshot")
}
recoverExisting = true
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if !recoverExisting {
if err := writeAtomic(config, []byte(text)); err != nil {
return nil, err
}
if _, err := l.command(ctx, "systemctl", "--user", "reload", "go-sip-asterisk.service"); err != nil {
return nil, err
}
}
if err := l.waitObserved(ctx, state); err != nil {
return nil, err
}
encoded, err := json.Marshal(state)
if err != nil {
return nil, err
}
if err := writeAtomic(statePath, encoded); err != nil {
return nil, err
}
return l.LoadedSIP(ctx)
}
func (l Loader) waitObserved(ctx context.Context, state appliedState) error {
deadline := time.NewTimer(15 * time.Second)
defer deadline.Stop()
var last error
for attempts := 1; ; attempts++ {
if last = l.observe(ctx, state); last == nil {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("native SIP readback cancelled after %d attempts: %w", attempts, ctx.Err())
case <-deadline.C:
return fmt.Errorf("native SIP readback timed out after %d attempts: %w", attempts, last)
case <-time.After(250 * time.Millisecond):
}
}
}
func writeAtomic(path string, data []byte) error {
file, err := os.CreateTemp(filepath.Dir(path), ".go-sip-stage-*")
if err != nil {
return err
}
defer os.Remove(file.Name())
defer file.Close()
if err := file.Chmod(0600); err != nil {
return err
}
if _, err := file.Write(data); err != nil {
return err
}
if err := file.Sync(); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
if err := os.Rename(file.Name(), path); err != nil {
return err
}
dir, err := os.Open(filepath.Dir(path))
if err != nil {
return err
}
defer dir.Close()
return dir.Sync()
}
+100
View File
@@ -0,0 +1,100 @@
package asterisk
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoaderPersistsOnlyVerifiedNativeReload(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "pjsip.conf"), []byte("[go-sip-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\n#tryinclude go-sip-managed.conf\n"), 0600); err != nil {
t.Fatal(err)
}
fake := filepath.Join(dir, "asterisk")
script := `#!/bin/sh
case "$*" in
*"pjsip show transports"*) echo 'Transport: go-sip-udp udp 0 0 0.0.0.0:5060' ;;
*"pjsip show endpoints"*) echo 'Endpoint: <Endpoint/CID> <State> <Channels>'; echo 'Endpoint: trunk-shuqi Not in use' ;;
*"pjsip show endpoint trunk-shuqi"*) echo 'Aor: trunk-shuqi-aor allow: alaw transport: go-sip-udp context: go-sip-no-inbound' ;;
*"pjsip show aor trunk-shuqi-aor"*) echo 'Contact: sip:61.132.228.221:5060' ;;
*) exit 9 ;;
esac
`
if err := os.WriteFile(fake, []byte(script), 0700); err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "bin")
if err := os.Mkdir(bin, 0700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(bin, "systemctl"), []byte("#!/bin/sh\n[ \"$*\" = '--user reload go-sip-asterisk.service' ]\n"), 0700); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+":"+os.Getenv("PATH"))
loader := Loader{ConfigDir: dir, Asterisk: fake, LibraryDir: dir}
sip := testSIP(t)
body, err := json.Marshal(sip)
if err != nil {
t.Fatal(err)
}
// Recover an interrupted first apply only when the approved file and
// native PJSIP objects both agree with the complete snapshot.
rendered, err := Render(sip)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "go-sip-managed.conf"), []byte(rendered), 0600); err != nil {
t.Fatal(err)
}
loaded, err := loader.Apply(context.Background(), body)
if err != nil || loaded["trunk-shuqi"] != 9 {
t.Fatalf("real command path did not verify SIP revision: %v %v", loaded, err)
}
if _, err := loader.Apply(context.Background(), body); err != nil {
t.Fatalf("same revision should observe without rewriting: %v", err)
}
if err := os.WriteFile(fake, []byte(strings.Replace(script, "Transport: go-sip-udp udp", "Transport: go-sip-udp tcp", 1)), 0700); err != nil {
t.Fatal(err)
}
if _, err := loader.LoadedSIP(context.Background()); err == nil {
t.Fatal("accepted native TCP transport as approved UDP")
}
if err := os.WriteFile(fake, []byte(script), 0700); err != nil {
t.Fatal(err)
}
changed := testSIP(t)
changed.Trunks = []byte(strings.Replace(string(changed.Trunks), "BD93205882", "BD93205883", 1))
changedBody, err := json.Marshal(changed)
if err != nil {
t.Fatal(err)
}
if _, err := loader.Apply(context.Background(), changedBody); err == nil {
t.Fatal("same revision changed non-rendered caller authorization")
}
p := filepath.Join(dir, "go-sip-managed.conf")
if err := os.WriteFile(p, []byte("[tampered]"), 0600); err != nil {
t.Fatal(err)
}
if _, err := loader.LoadedSIP(context.Background()); err == nil || !strings.Contains(err.Error(), "no longer matches") {
t.Fatalf("tampered config was accepted: %v", err)
}
}
func TestLoaderFailsClosedWithoutApprovedStaticTransport(t *testing.T) {
dir := t.TempDir()
loader := Loader{ConfigDir: dir, Asterisk: "/bin/true", LibraryDir: dir}
body, err := json.Marshal(testSIP(t))
if err != nil {
t.Fatal(err)
}
if _, err := loader.Apply(context.Background(), body); err == nil {
t.Fatal("applied SIP without management-owned base transport")
}
if _, err := os.Stat(filepath.Join(dir, "go-sip-managed.conf")); !os.IsNotExist(err) {
t.Fatalf("mutated disk before static transport check: %v", err)
}
}
+27 -4
View File
@@ -28,11 +28,14 @@ type AgentEnvironment struct {
PeerFingerprints map[string]struct{}
MockScenarioFile string
MockAppliedSIPFile string
AsteriskConfigDir string
AsteriskBin string
AsteriskLibraryDir string
}
func LoadAgentEnvironment(mode string) (AgentEnvironment, error) {
if mode != "mock" {
return AgentEnvironment{}, errors.New("Agent accepts only isolated Mock mode")
if mode != "mock" && mode != "sip-only" {
return AgentEnvironment{}, errors.New("Agent accepts only isolated Mock or SIP-only mode")
}
get := func(name string) (string, error) {
value := os.Getenv(name)
@@ -54,8 +57,6 @@ func LoadAgentEnvironment(mode string) (AgentEnvironment, error) {
{"DISPATCHER_GRPC_SERVER_NAME", &settings.DispatcherServerName},
{"MTLS_CA_FILE", &settings.CAFile}, {"MTLS_CERT_FILE", &settings.CertFile},
{"MTLS_KEY_FILE", &settings.KeyFile},
{"AGENT_MOCK_SCENARIO_FILE", &settings.MockScenarioFile},
{"AGENT_MOCK_APPLIED_SIP_FILE", &settings.MockAppliedSIPFile},
} {
value, err := get(field.name)
if err != nil {
@@ -63,6 +64,28 @@ func LoadAgentEnvironment(mode string) (AgentEnvironment, error) {
}
*field.value = value
}
var modeFields []struct {
name string
value *string
}
if mode == "mock" {
modeFields = []struct {
name string
value *string
}{{"AGENT_MOCK_SCENARIO_FILE", &settings.MockScenarioFile}, {"AGENT_MOCK_APPLIED_SIP_FILE", &settings.MockAppliedSIPFile}}
} else {
modeFields = []struct {
name string
value *string
}{{"ASTERISK_CONFIG_DIR", &settings.AsteriskConfigDir}, {"ASTERISK_BIN", &settings.AsteriskBin}, {"ASTERISK_LIBRARY_DIR", &settings.AsteriskLibraryDir}}
}
for _, field := range modeFields {
value, err := get(field.name)
if err != nil {
return AgentEnvironment{}, err
}
*field.value = value
}
if tenant.ValidateDispatcherID(settings.DispatcherID) != nil {
return AgentEnvironment{}, errors.New("DISPATCHER_ID must be a canonical UUID v4")
}
+17
View File
@@ -40,6 +40,23 @@ func TestLoadAgentEnvironmentRefusesNonMockBeforeResources(t *testing.T) {
}
}
func TestLoadSIPOnlyAgentEnvironmentDoesNotRequireMockCalls(t *testing.T) {
setAgentEnvironment(t)
t.Setenv("AGENT_MOCK_SCENARIO_FILE", "")
t.Setenv("AGENT_MOCK_APPLIED_SIP_FILE", "")
for name, value := range map[string]string{
"ASTERISK_CONFIG_DIR": "/tmp/asterisk-config",
"ASTERISK_BIN": "/tmp/asterisk",
"ASTERISK_LIBRARY_DIR": "/tmp/asterisk-libraries",
} {
t.Setenv(name, value)
}
settings, err := LoadAgentEnvironment("sip-only")
if err != nil || settings.AsteriskConfigDir != "/tmp/asterisk-config" || settings.MockScenarioFile != "" {
t.Fatalf("SIP-only Agent requires real native paths, not Mock fixtures: %+v %v", settings, err)
}
}
func TestLoadAgentEnvironmentRequiresExplicitDeploymentValues(t *testing.T) {
for _, name := range []string{
"AGENT_ID", "CELL_ID", "AGENT_GRPC_LISTEN", "AGENT_SESSION_PATH", "AGENT_RECOVERY_ROOT",
+9 -2
View File
@@ -37,9 +37,7 @@ func LoadDispatcherRuntimeEnvironment(mode string) (DispatcherRuntimeEnvironment
name string
value *string
}{
{"DISPATCHER_GRPC_LISTEN", &settings.Listen},
{"DISPATCHER_AGENT_ENDPOINTS_FILE", &settings.AgentEndpointsFile},
{"DISPATCHER_OSS_CONFIG_FILE", &settings.OSSConfigFile},
{"MTLS_CA_FILE", &settings.CAFile},
{"MTLS_CERT_FILE", &settings.CertFile},
{"MTLS_KEY_FILE", &settings.KeyFile},
@@ -50,6 +48,15 @@ func LoadDispatcherRuntimeEnvironment(mode string) (DispatcherRuntimeEnvironment
}
*field.value = value
}
if mode == "sip-only" {
return settings, nil
}
if settings.Listen, err = get("DISPATCHER_GRPC_LISTEN"); err != nil {
return DispatcherRuntimeEnvironment{}, err
}
if settings.OSSConfigFile, err = get("DISPATCHER_OSS_CONFIG_FILE"); err != nil {
return DispatcherRuntimeEnvironment{}, err
}
if !localGRPCAddress(settings.Listen, true) {
return DispatcherRuntimeEnvironment{}, errors.New("DISPATCHER_GRPC_LISTEN must be an isolated local Mock address")
}
@@ -25,6 +25,17 @@ func setCurrentDispatcherRuntimeEnvironment(t *testing.T) string {
return database
}
func TestLoadSIPOnlyDispatcherRequiresNoRecordingOrCallListener(t *testing.T) {
setCurrentDispatcherRuntimeEnvironment(t)
t.Setenv("DISPATCHER_GRPC_LISTEN", "")
t.Setenv("DISPATCHER_OSS_CONFIG_FILE", "")
t.Setenv("MTLS_PEER_CERT_FINGERPRINTS", "")
settings, err := LoadDispatcherRuntimeEnvironment("sip-only")
if err != nil || settings.AgentEndpointsFile == "" || settings.OSSConfigFile != "" || settings.Listen != "" {
t.Fatalf("SIP-only process inherited Mock business listeners: %+v %v", settings, err)
}
}
func TestLoadDispatcherRuntimeEnvironmentRequiresExplicitMockDeployment(t *testing.T) {
database := setCurrentDispatcherRuntimeEnvironment(t)
settings, err := LoadDispatcherRuntimeEnvironment("mock")
+3 -3
View File
@@ -22,10 +22,10 @@ type DispatcherEnvironment struct {
}
// LoadDispatcherEnvironment is pure inspection: it opens no database, queue or
// network connection. The current executable accepts only isolated Mock mode.
// network connection. Only isolated Mock or SIP-only mode may start.
func LoadDispatcherEnvironment(mode string) (DispatcherEnvironment, error) {
if mode != "mock" {
return DispatcherEnvironment{}, errors.New("Dispatcher accepts only isolated Mock mode")
if mode != "mock" && mode != "sip-only" {
return DispatcherEnvironment{}, errors.New("Dispatcher accepts only isolated Mock or SIP-only mode")
}
get := func(name string) (string, error) {
value := os.Getenv(name)
+54 -3
View File
@@ -3,6 +3,7 @@ package dispatcher
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
@@ -18,6 +19,7 @@ import (
// transport or an MQ execution fallback.
type ApprovedAgentRPC interface {
GetLoadedSIP(context.Context, *agentpb.GetLoadedSIPRequest, ...grpc.CallOption) (*agentpb.GetLoadedSIPResponse, error)
ApplySIP(context.Context, *agentpb.ApplySIPRequest, ...grpc.CallOption) (*agentpb.ApplySIPResponse, error)
ExecuteApproved(context.Context, *agentpb.ExecuteApprovedRequest, ...grpc.CallOption) (*agentpb.ExecuteApprovedResponse, error)
ApplyApprovedTaskControl(context.Context, *agentpb.ApplyApprovedTaskControlRequest, ...grpc.CallOption) (*agentpb.ApplyApprovedTaskControlResponse, error)
}
@@ -68,6 +70,49 @@ func (o *ApprovedOriginator) LoadedTrunks(ctx context.Context) (map[string]int64
return loaded, nil
}
// ApplySIP sends the complete approved partition through the pinned, active
// Agent session. The response must describe the exact revision and trunk set.
func (o *ApprovedOriginator) ApplySIP(ctx context.Context, sip configread.SIP) error {
if o == nil || sip.DispatcherID != o.DispatcherID || sip.Revision <= 0 {
return errors.New("approved SIP snapshot does not belong to this Dispatcher")
}
meta, err := o.activeMeta(ctx)
if err != nil {
return err
}
body, err := json.Marshal(sip)
if err != nil {
return fmt.Errorf("encode approved SIP snapshot: %w", err)
}
hash := sha256.Sum256(body)
meta.OperationId = fmt.Sprintf("sip-%d", sip.Revision)
meta.IdempotencyKey = fmt.Sprintf("sip-%d-%x", sip.Revision, hash[:8])
response, err := o.Client.ApplySIP(ctx, &agentpb.ApplySIPRequest{Meta: meta, ApprovedSnapshotJson: body})
if err != nil {
return fmt.Errorf("Agent SIP apply revision %d is unconfirmed: %w", sip.Revision, err)
}
var trunks []struct {
ID string `json:"trunk_id"`
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(sip.Trunks, &trunks); err != nil {
return err
}
expected := 0
for _, trunk := range trunks {
if trunk.Enabled {
expected++
if response == nil || response.TrunkRevision[trunk.ID] != sip.Revision {
return fmt.Errorf("Agent did not confirm approved SIP trunk %q", trunk.ID)
}
}
}
if response == nil || len(response.TrunkRevision) != expected {
return errors.New("Agent confirmed a different SIP trunk set")
}
return nil
}
// VerifySIP requires an exact match between the approved entire SIP partition
// and the Agent's actual loaded set; a single matching trunk is insufficient.
func (o *ApprovedOriginator) VerifySIP(ctx context.Context, sip configread.SIP) error {
@@ -76,6 +121,7 @@ func (o *ApprovedOriginator) VerifySIP(ctx context.Context, sip configread.SIP)
}
var trunks []struct {
TrunkID string `json:"trunk_id"`
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(sip.Trunks, &trunks); err != nil || len(trunks) == 0 {
return errors.New("approved SIP trunk list is invalid")
@@ -84,14 +130,19 @@ func (o *ApprovedOriginator) VerifySIP(ctx context.Context, sip configread.SIP)
if err != nil {
return err
}
if len(loaded) != len(trunks) {
return fmt.Errorf("Agent loaded %d trunks; approved snapshot has %d", len(loaded), len(trunks))
}
active := 0
for _, trunk := range trunks {
if !trunk.Enabled {
continue
}
active++
if trunk.TrunkID == "" || loaded[trunk.TrunkID] != sip.Revision {
return fmt.Errorf("approved SIP trunk %q revision %d is not loaded", trunk.TrunkID, sip.Revision)
}
}
if len(loaded) != active {
return fmt.Errorf("Agent loaded %d trunks; approved snapshot enables %d", len(loaded), active)
}
return nil
}
@@ -23,6 +23,9 @@ type fakeApprovedAgentRPC struct {
controlResponse *agentpb.ApplyApprovedTaskControlResponse
controlErr error
controlCalls int
sipRequest *agentpb.ApplySIPRequest
sipResponse *agentpb.ApplySIPResponse
sipErr error
}
func (f *fakeApprovedAgentRPC) ExecuteApproved(_ context.Context, req *agentpb.ExecuteApprovedRequest, _ ...grpc.CallOption) (*agentpb.ExecuteApprovedResponse, error) {
@@ -45,6 +48,17 @@ func (f *fakeApprovedAgentRPC) ApplyApprovedTaskControl(_ context.Context, req *
return &agentpb.ApplyApprovedTaskControlResponse{Accepted: true}, nil
}
func (f *fakeApprovedAgentRPC) ApplySIP(_ context.Context, req *agentpb.ApplySIPRequest, _ ...grpc.CallOption) (*agentpb.ApplySIPResponse, error) {
f.sipRequest = req
if f.sipErr != nil {
return nil, f.sipErr
}
if f.sipResponse != nil {
return f.sipResponse, nil
}
return &agentpb.ApplySIPResponse{TrunkRevision: f.loaded}, nil
}
func (f *fakeApprovedAgentRPC) GetLoadedSIP(_ context.Context, _ *agentpb.GetLoadedSIPRequest, _ ...grpc.CallOption) (*agentpb.GetLoadedSIPResponse, error) {
if f.loadedErr != nil {
return nil, f.loadedErr
@@ -72,6 +86,49 @@ func approvedOriginatorFixture(t *testing.T, client *fakeApprovedAgentRPC) (*App
return orig, spec
}
func TestApprovedOriginatorAppliesFullSIPSnapshotBeforeReportingLoaded(t *testing.T) {
fake := &fakeApprovedAgentRPC{loaded: map[string]int64{"trunk-mock": 8}}
orig, spec := approvedOriginatorFixture(t, fake)
if err := orig.ApplySIP(context.Background(), spec.Snapshot.SIP); err != nil {
t.Fatal(err)
}
if fake.sipRequest == nil || fake.sipRequest.Meta.IdempotencyKey == "" {
t.Fatal("SIP apply omitted operation identity")
}
var sent configread.SIP
if err := json.Unmarshal(fake.sipRequest.ApprovedSnapshotJson, &sent); err != nil || sent.Revision != 8 || sent.DispatcherID != orig.DispatcherID {
t.Fatal("SIP apply lost the approved full snapshot")
}
fake.sipResponse = &agentpb.ApplySIPResponse{TrunkRevision: map[string]int64{"trunk-mock": 7}}
if err := orig.ApplySIP(context.Background(), spec.Snapshot.SIP); err == nil {
t.Fatal("accepted a stale Agent revision")
}
}
func TestApprovedOriginatorIgnoresDisabledTrunksDuringNativeReadback(t *testing.T) {
fake := &fakeApprovedAgentRPC{loaded: map[string]int64{"trunk-mock": 8}}
orig, spec := approvedOriginatorFixture(t, fake)
var trunks []map[string]any
if err := json.Unmarshal(spec.Snapshot.SIP.Trunks, &trunks); err != nil {
t.Fatal(err)
}
disabled := make(map[string]any)
for key, value := range trunks[0] {
disabled[key] = value
}
disabled["trunk_id"] = "disabled-line"
disabled["enabled"] = false
trunks = append(trunks, disabled)
data, err := json.Marshal(trunks)
if err != nil {
t.Fatal(err)
}
spec.Snapshot.SIP.Trunks = data
if err := orig.VerifySIP(context.Background(), spec.Snapshot.SIP); err != nil {
t.Fatal(err)
}
}
func TestApprovedOriginatorSendsExactImmutableAgentInstruction(t *testing.T) {
fake := &fakeApprovedAgentRPC{loaded: map[string]int64{"trunk-mock": 8}}
orig, spec := approvedOriginatorFixture(t, fake)
+6
View File
@@ -16,6 +16,7 @@ type Bootstrap struct {
Client *configread.Client
Store *store.Store
DispatcherID string
ApplySIP func(context.Context, configread.SIP) error // nil for isolated Mock; real SIP-only must apply before verifying
VerifySIP func(context.Context, configread.SIP) error
DrainControls func(context.Context) error
Cursor *string // memory-only; restart always starts from a full snapshot
@@ -46,6 +47,11 @@ func (b Bootstrap) Run(ctx context.Context) error {
if sip.DispatcherID != b.DispatcherID {
return errors.New("SIP snapshot belongs to another Dispatcher")
}
if b.ApplySIP != nil {
if err := b.ApplySIP(ctx, sip); err != nil {
return fmt.Errorf("apply approved SIP revision %d on Agent/Asterisk: %w", sip.Revision, err)
}
}
if err := b.VerifySIP(ctx, sip); err != nil {
return fmt.Errorf("verify SIP revision %d loaded by Agent/Asterisk: %w", sip.Revision, err)
}
+15 -2
View File
@@ -61,13 +61,20 @@ func TestBootstrapRequiresSIPLoadingAndControlDrain(t *testing.T) {
t.Fatal(err)
}
defer db.Close()
verifierCalled, drained := false, false
applied, verifierCalled, drained := false, false, false
var cursor string
var approvedSIP configread.SIP
bootstrap := Bootstrap{
Client: client, Store: db, DispatcherID: id, Cursor: &cursor, SIP: &approvedSIP,
ApplySIP: func(_ context.Context, sip configread.SIP) error {
applied = sip.Revision == 8
return nil
},
VerifySIP: func(_ context.Context, sip configread.SIP) error {
verifierCalled = true
if !applied {
t.Fatal("verified Agent load before applying SIP")
}
if sip.Revision != 8 {
t.Fatalf("verified wrong SIP revision %d", sip.Revision)
}
@@ -102,7 +109,7 @@ func TestBootstrapRequiresSIPLoadingAndControlDrain(t *testing.T) {
func TestBootstrapFailsClosedOnVerifierOrDrainError(t *testing.T) {
id := "c046b893-8628-4589-ae50-619d049248a6"
for _, failure := range []string{"verify", "drain"} {
for _, failure := range []string{"apply", "verify", "drain"} {
t.Run(failure, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var response []byte
@@ -139,6 +146,12 @@ func TestBootstrapFailsClosedOnVerifierOrDrainError(t *testing.T) {
defer db.Close()
b := Bootstrap{
Client: client, Store: db, DispatcherID: id,
ApplySIP: func(context.Context, configread.SIP) error {
if failure == "apply" {
return errors.New("native SIP application rejected")
}
return nil
},
VerifySIP: func(context.Context, configread.SIP) error {
if failure == "verify" {
return errors.New("applied SIP revision unavailable")
+83
View File
@@ -0,0 +1,83 @@
package dispatcher
import (
"context"
"encoding/json"
"errors"
"fmt"
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/contract"
"git.ipao.vip/rogee/go-sip/internal/store"
)
// SIPOnly is a distinct update lane: it never discovers tasks or enables
// call admission, even when native Asterisk confirms the entire snapshot.
type SIPOnly struct {
DispatcherID string
Store *store.Store
Client *configread.Client
ApplySIP func(context.Context, configread.SIP) error
VerifySIP func(context.Context, configread.SIP) error
}
// HandleNotification persists the assigned SIP revision before the broker ACK.
// Non-SIP controls must be requeued for a business Dispatcher, never swallowed.
func (s SIPOnly) HandleNotification(_ context.Context, route string, body []byte) error {
if s.Store == nil || s.DispatcherID == "" || route != "d."+s.DispatcherID+".control.in" {
return errors.New("SIP-only notification has no approved queue owner")
}
if err := contract.ValidateCurrent("mq", body); err != nil {
return fmt.Errorf("SIP-only notification violates current contract: %w", err)
}
var notice struct {
EventType string `json:"event_type"`
DispatcherID string `json:"dispatcher_id"`
Payload struct {
Revision int64 `json:"revision"`
} `json:"payload"`
}
if err := json.Unmarshal(body, &notice); err != nil {
return err
}
if notice.DispatcherID != s.DispatcherID || notice.EventType != "sip.config" || notice.Payload.Revision <= 0 {
return errors.New("SIP-only cannot consume another Dispatcher or business control")
}
return s.Store.NoteSIPChange(s.DispatcherID, notice.Payload.Revision)
}
// Sync fetches only the complete SIP snapshot; it never reads task, quota or
// AI config. The Agent revision and actual native load must both agree before
// the durable pending revision is cleared, while admission remains closed.
func (s SIPOnly) Sync(ctx context.Context) error {
if s.Store == nil || s.Client == nil || s.DispatcherID == "" || s.ApplySIP == nil || s.VerifySIP == nil {
return errors.New("SIP-only sync requires a bound client, durable store and native Agent")
}
sip, err := s.Client.ReadSIP(ctx)
if err != nil {
return fmt.Errorf("read approved SIP-only snapshot: %w", err)
}
if sip.DispatcherID != s.DispatcherID || sip.Revision <= 0 {
return errors.New("SIP-only snapshot owner or revision is invalid")
}
applied, pending, err := s.Store.SIPState(s.DispatcherID)
if err != nil {
return err
}
if sip.Revision < pending || sip.Revision < applied {
return fmt.Errorf("approved SIP snapshot revision %d is behind durable applied=%d pending=%d", sip.Revision, applied, pending)
}
if err := s.Store.NoteSIPChange(s.DispatcherID, sip.Revision); err != nil {
return err
}
if err := s.ApplySIP(ctx, sip); err != nil {
return fmt.Errorf("native SIP apply revision %d: %w", sip.Revision, err)
}
if err := s.VerifySIP(ctx, sip); err != nil {
return fmt.Errorf("native SIP load revision %d: %w", sip.Revision, err)
}
if err := s.Store.MarkSIPOnlyVerified(s.DispatcherID, sip.Revision); err != nil {
return err
}
return nil
}
+67
View File
@@ -0,0 +1,67 @@
package dispatcher
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"git.ipao.vip/rogee/go-sip/internal/configread"
"git.ipao.vip/rogee/go-sip/internal/store"
)
func TestSIPOnlyNotificationCheckpointsNativeLoadWithoutOpeningBusiness(t *testing.T) {
const id = "c046b893-8628-4589-ae50-619d049248a6"
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/internal/v1/dispatcher/sip" {
t.Errorf("SIP-only fetched business config %s", r.URL.Path)
w.WriteHeader(404)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(configExample(t, "config-read-sip"))
}))
defer server.Close()
client, err := configread.NewClient(server.URL, id, "test-secret", server.Client())
if err != nil {
t.Fatal(err)
}
db, err := store.Open(filepath.Join(t.TempDir(), "sip-only.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
applied := false
syncer := SIPOnly{DispatcherID: id, Store: db, Client: client,
ApplySIP: func(_ context.Context, sip configread.SIP) error {
applied = true
if sip.Revision != 8 {
t.Fatal("unexpected SIP revision")
}
return nil
},
VerifySIP: func(_ context.Context, sip configread.SIP) error {
if !applied {
t.Fatal("verified before native apply")
}
return nil
},
}
body := configExample(t, "mq-sip-change")
if err := syncer.HandleNotification(context.Background(), "d."+id+".control.in", body); err != nil {
t.Fatal(err)
}
if appliedRev, pending, err := db.SIPState(id); err != nil || appliedRev != 0 || pending != 8 {
t.Fatalf("notification was not durably fenced: %d %d %v", appliedRev, pending, err)
}
if err := syncer.Sync(context.Background()); err != nil {
t.Fatal(err)
}
if appliedRev, pending, err := db.SIPState(id); err != nil || appliedRev != 8 || pending != 0 {
t.Fatalf("verified readback was not recorded: %d %d %v", appliedRev, pending, err)
}
if err := syncer.HandleNotification(context.Background(), "d."+id+".control.in", configExample(t, "mq-control")); err == nil {
t.Fatal("SIP-only consumed a business control event")
}
}
+6
View File
@@ -54,6 +54,12 @@ func (r *Runtime) refreshSIP(ctx context.Context, approvedSIP *configread.SIP) e
r.Logger.Debug("SaaS SIP snapshot is behind durable SIP notification; admission stays closed", "dispatcher_id", r.Bootstrap.DispatcherID, "pending_revision", pending, "available_revision", current.Revision)
return nil
}
if r.Bootstrap.ApplySIP != nil {
if err := r.Bootstrap.ApplySIP(ctx, current); err != nil {
r.Logger.Warn("approved SIP update remains fenced until native reload confirms", "dispatcher_id", r.Bootstrap.DispatcherID, "revision", current.Revision, "error", err)
return nil
}
}
if err := r.Bootstrap.VerifySIP(ctx, current); err != nil {
r.Logger.Debug("SIP reload waits for actual Agent/Asterisk revision", "dispatcher_id", r.Bootstrap.DispatcherID, "pending_revision", pending, "error", err)
return nil
+18 -1
View File
@@ -42,13 +42,27 @@ func TestSIPNotificationPersistsBarrierAndWaitsForLoadedFullSnapshot(t *testing.
t.Fatal(err)
}
var loaded atomic.Bool
var applied atomic.Int32
verify := func(_ context.Context, sip configread.SIP) error {
if applied.Load() == 0 {
return errors.New("SIP load checked before native apply")
}
if sip.Revision != 9 || !loaded.Load() {
return errors.New("Agent and Asterisk have not applied SIP revision 9")
}
return nil
}
runtime := &Runtime{Bootstrap: Bootstrap{DispatcherID: executor.DispatcherID, Client: client, Store: s, VerifySIP: verify}, Logger: slog.Default()}
runtime := &Runtime{Bootstrap: Bootstrap{DispatcherID: executor.DispatcherID, Client: client, Store: s,
ApplySIP: func(_ context.Context, sip configread.SIP) error {
if sip.Revision != 9 {
return errors.New("wrong SIP revision sent to Agent")
}
applied.Add(1)
if !loaded.Load() {
return errors.New("native Asterisk SIP reload is still unavailable")
}
return nil
}, VerifySIP: verify}, Logger: slog.Default()}
body := []byte(strings.Replace(string(configExample(t, "mq-sip-change")), `"revision":8`, `"revision":9`, 1))
if err := runtime.handleControl(context.Background(), "", body); err != nil {
@@ -66,6 +80,9 @@ func TestSIPNotificationPersistsBarrierAndWaitsForLoadedFullSnapshot(t *testing.
if admitted, err := s.CanAdmit(executor.DispatcherID, 1001, "task-asr"); err != nil || admitted {
t.Fatalf("unloaded SIP reopened admission: %v %v", admitted, err)
}
if applied.Load() == 0 {
t.Fatal("notification never sent to Agent")
}
loaded.Store(true)
if err := runtime.refreshSIP(context.Background(), &approved); err != nil {
t.Fatal(err)
+6 -1
View File
@@ -32,8 +32,10 @@ type ServerOptions struct {
PeerAgentIDs map[string]string
PeerCertificateFingerprints map[string]struct{}
StatePath string
// LoadedSIP reports the revision the mock Agent actually loaded; nil fails closed.
// LoadedSIP reports the revision the Agent actually observed in Asterisk; nil fails closed.
LoadedSIP func(context.Context) (map[string]int64, error)
// ApplySIP writes and reloads only a validated, Dispatcher-approved full snapshot.
ApplySIP func(context.Context, []byte) (map[string]int64, error)
// The mock may have issued a call even if its outcome is unknown.
MockApprovedOriginate func(context.Context, ApprovedExecution) error
// ApprovedTaskCalls is shared with the approved call runner; nil rejects task controls.
@@ -53,6 +55,8 @@ type Server struct {
peerAgentIDs map[string]string
peerCertificateFingerprints map[string]struct{}
loadedSIP func(context.Context) (map[string]int64, error)
applySIP func(context.Context, []byte) (map[string]int64, error)
sipMu sync.Mutex
mockApprovedOriginate func(context.Context, ApprovedExecution) error
approvedTaskCalls *agent.TaskCalls
sessions *SessionRegistry
@@ -87,6 +91,7 @@ func NewServer(options ServerOptions) *Server {
peerAgentIDs: cloneStringMap(options.PeerAgentIDs),
peerCertificateFingerprints: cloneSet(options.PeerCertificateFingerprints),
loadedSIP: options.LoadedSIP,
applySIP: options.ApplySIP,
mockApprovedOriginate: options.MockApprovedOriginate,
approvedTaskCalls: options.ApprovedTaskCalls,
sessions: NewSessionRegistry(options.StatePath),
+1 -1
View File
@@ -15,7 +15,7 @@ func TestAgentControlServiceOnlyExposesApprovedMethods(t *testing.T) {
}
want := []string{
"GetAgentStatus", "ActivateAgent",
"ExecuteApproved", "ApplyApprovedTaskControl", "GetLoadedSIP",
"ExecuteApproved", "ApplyApprovedTaskControl", "GetLoadedSIP", "ApplySIP",
"RequestRecordingUpload", "ReportCallEnded", "ReportCallResult",
}
if !reflect.DeepEqual(got, want) {
+70
View File
@@ -0,0 +1,70 @@
package rpc
import (
"context"
"encoding/json"
"log/slog"
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
"git.ipao.vip/rogee/go-sip/internal/asterisk"
"git.ipao.vip/rogee/go-sip/internal/configread"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// ApplySIP is deliberately unavailable on the Mock call server. The real
// SIP-only Agent accepts a complete Dispatcher-owned snapshot, then reports
// only revisions it actually applied and inspected in native Asterisk.
func (s *Server) ApplySIP(ctx context.Context, req *agentpb.ApplySIPRequest) (*agentpb.ApplySIPResponse, error) {
if req == nil || req.Meta == nil || len(req.ApprovedSnapshotJson) == 0 || len(req.ApprovedSnapshotJson) > 1<<20 {
return nil, status.Error(codes.InvalidArgument, "approved SIP request or bounded snapshot is required")
}
if err := s.authorize(ctx, req.Meta); err != nil {
return nil, err
}
dispatcherID, err := s.sessions.ApprovedDispatcher(req.Meta, s.now())
if err != nil {
return nil, err
}
if s.mode != "sip-only" || s.applySIP == nil {
return nil, status.Error(codes.FailedPrecondition, "real SIP apply is unavailable on this Agent")
}
var approved configread.SIP
if err := json.Unmarshal(req.ApprovedSnapshotJson, &approved); err != nil || approved.DispatcherID != dispatcherID || approved.Revision <= 0 {
return nil, status.Error(codes.InvalidArgument, "SIP snapshot owner or content is invalid")
}
if _, err := asterisk.Render(approved); err != nil {
return nil, status.Error(codes.InvalidArgument, "SIP snapshot is not approved for native reload")
}
var trunks []struct {
ID string `json:"trunk_id"`
Enabled bool `json:"enabled"`
}
if err := json.Unmarshal(approved.Trunks, &trunks); err != nil {
return nil, status.Error(codes.InvalidArgument, "SIP trunk list is invalid")
}
expected := make(map[string]bool)
for _, trunk := range trunks {
if trunk.Enabled {
expected[trunk.ID] = true
}
}
s.sipMu.Lock()
defer s.sipMu.Unlock()
loaded, err := s.applySIP(ctx, req.ApprovedSnapshotJson)
if err != nil {
slog.Error("native SIP apply failed", "dispatcher_id", dispatcherID, "revision", approved.Revision, "error", err)
return nil, status.Error(codes.Unavailable, "native Asterisk SIP reload failed")
}
if len(loaded) != len(expected) {
slog.Error("native SIP loaded-set mismatch", "dispatcher_id", dispatcherID, "revision", approved.Revision, "expected_count", len(expected), "loaded_count", len(loaded))
return nil, status.Error(codes.Unavailable, "native Asterisk SIP loaded-set mismatch")
}
for trunk, revision := range loaded {
if !expected[trunk] || revision != approved.Revision {
slog.Error("native SIP loaded-revision mismatch", "dispatcher_id", dispatcherID, "revision", approved.Revision, "trunk_id", trunk, "observed_revision", revision)
return nil, status.Error(codes.Unavailable, "native Asterisk SIP loaded-revision mismatch")
}
}
return &agentpb.ApplySIPResponse{TrunkRevision: loaded}, nil
}
+55
View File
@@ -0,0 +1,55 @@
package rpc
import (
"context"
"os"
"strings"
"testing"
"time"
agentpb "git.ipao.vip/rogee/go-sip/gen/agent"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestApplySIPOnlyAllowsActivatedSIPService(t *testing.T) {
now := time.Date(2026, 10, 3, 9, 0, 0, 0, time.UTC)
const id = "c046b893-8628-4589-ae50-619d049248a6"
attempts := 0
apply := func(_ context.Context, _ []byte) (map[string]int64, error) {
attempts++
return map[string]int64{"trunk-mock": 8}, nil
}
server := NewServer(ServerOptions{
Mode: "sip-only", Now: func() time.Time { return now },
Status: &agentpb.AgentStatus{AgentId: "agent-1", CellId: "cell-1", BootId: "boot-1"},
ApplySIP: apply,
})
_, err := server.ActivateAgent(context.Background(), &agentpb.ActivateAgentRequest{
Meta: testMeta("activate-sip", "", 0),
Binding: &agentpb.AgentBinding{AgentId: "agent-1", CellId: "cell-1", ExpectedBootId: "boot-1", DispatcherEpoch: "epoch-1", SessionGeneration: 1, DispatcherId: id},
ActivationOperationId: "activate-sip",
})
if err != nil {
t.Fatal(err)
}
body, err := os.ReadFile("../../contracts/local/examples/config-read-sip.json")
if err != nil {
t.Fatal(err)
}
body = []byte(strings.ReplaceAll(string(body), `"transport":null`, `"transport":"udp"`))
body = []byte(strings.ReplaceAll(string(body), `"auth_mode":null`, `"auth_mode":"ip"`))
body = []byte(strings.ReplaceAll(string(body), `"registration_required":null`, `"registration_required":false`))
body = []byte(strings.ReplaceAll(string(body), `"server_host":"sip.example.invalid"`, `"server_host":"127.0.0.1"`))
req := &agentpb.ApplySIPRequest{Meta: testMeta("apply-sip", "apply-sip", 1), ApprovedSnapshotJson: body}
if _, err := server.ApplySIP(context.Background(), req); err != nil {
t.Fatal(err)
}
if attempts != 1 {
t.Fatalf("apply attempts = %d", attempts)
}
server.mode = "mock"
if _, err := server.ApplySIP(context.Background(), req); status.Code(err) != codes.FailedPrecondition || attempts != 1 {
t.Fatalf("mock boundary allowed real SIP apply: attempts=%d err=%v", attempts, err)
}
}
+22
View File
@@ -45,6 +45,28 @@ func (s *Store) NoteSIPChange(dispatcherID string, revision int64) error {
return nil
}
// MarkSIPOnlyVerified checkpoints native Asterisk readback without ever
// enabling business call admission. A newer durable notification wins races.
func (s *Store) MarkSIPOnlyVerified(dispatcherID string, revision int64) error {
if dispatcherID == "" || revision <= 0 {
return errors.New("SIP-only checkpoint requires identity and positive verified revision")
}
result, err := s.db.Exec(`UPDATE dispatcher_state
SET applied_sip_revision=?,pending_sip_revision=0,discovery_ready=0
WHERE dispatcher_id=? AND applied_sip_revision<=? AND pending_sip_revision<=?`, revision, dispatcherID, revision, revision)
if err != nil {
return fmt.Errorf("checkpoint verified native SIP: %w", err)
}
count, err := result.RowsAffected()
if err != nil {
return err
}
if count != 1 {
return errors.New("SIP-only checkpoint conflicts with a newer durable notification or loaded revision")
}
return nil
}
func (s *Store) SIPState(dispatcherID string) (applied, pending int64, err error) {
err = s.db.QueryRow(`SELECT applied_sip_revision,pending_sip_revision FROM dispatcher_state WHERE dispatcher_id=?`, dispatcherID).Scan(&applied, &pending)
if err != nil {
+27
View File
@@ -6,6 +6,33 @@ import (
"testing"
)
func TestSIPOnlyVerifiedNeverOpensCallAdmission(t *testing.T) {
s, err := Open(filepath.Join(t.TempDir(), "sip-only.db"))
if err != nil {
t.Fatal(err)
}
defer s.Close()
if err := s.NoteSIPChange(currentDispatcherID, 9); err != nil {
t.Fatal(err)
}
if err := s.MarkSIPOnlyVerified(currentDispatcherID, 9); err != nil {
t.Fatal(err)
}
if applied, pending, err := s.SIPState(currentDispatcherID); err != nil || applied != 9 || pending != 0 {
t.Fatalf("SIP-only checkpoint: %d %d %v", applied, pending, err)
}
var admitted int
if err := s.db.QueryRow(`SELECT discovery_ready FROM dispatcher_state WHERE dispatcher_id=?`, currentDispatcherID).Scan(&admitted); err != nil || admitted != 0 {
t.Fatalf("SIP-only opened call admission: %d %v", admitted, err)
}
if err := s.NoteSIPChange(currentDispatcherID, 10); err != nil {
t.Fatal(err)
}
if err := s.MarkSIPOnlyVerified(currentDispatcherID, 9); err == nil {
t.Fatal("stale native SIP confirmation discarded newer notification")
}
}
func TestSIPNotificationRequiresFullDrainAndExactLoadedRevision(t *testing.T) {
s := preparedCurrentCallStore(t)
if err := s.MarkReadyForSIP(currentDispatcherID, 8); err != nil {