diff --git a/cmd/sip-go-agent/current_dispatcher_setup.go b/cmd/sip-go-agent/current_dispatcher_setup.go new file mode 100644 index 0000000..2126228 --- /dev/null +++ b/cmd/sip-go-agent/current_dispatcher_setup.go @@ -0,0 +1,22 @@ +package main + +import ( + "net/http" + + "git.ipao.vip/rogee/go-sip/internal/config" + "git.ipao.vip/rogee/go-sip/internal/configread" +) + +// dispatcherConfigurationClient binds the deployment-owned Dispatcher identity +// to the sole read-only SaaS configuration client. It opens no external resource. +func dispatcherConfigurationClient(mode string, httpClient *http.Client) (config.DispatcherEnvironment, *configread.Client, error) { + settings, err := config.LoadDispatcherEnvironment(mode) + if err != nil { + return config.DispatcherEnvironment{}, nil, err + } + reader, err := configread.NewClient(settings.SaaSBaseURL, settings.DispatcherID, settings.SecretKey, httpClient) + if err != nil { + return config.DispatcherEnvironment{}, nil, err + } + return settings, reader, nil +} diff --git a/cmd/sip-go-agent/current_dispatcher_setup_test.go b/cmd/sip-go-agent/current_dispatcher_setup_test.go new file mode 100644 index 0000000..f23f95f --- /dev/null +++ b/cmd/sip-go-agent/current_dispatcher_setup_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" +) + +func TestDispatcherConfigurationClientReadsApprovedSIPWithExplicitIdentity(t *testing.T) { + const dispatcherID = "c046b893-8628-4589-ae50-619d049248a6" + const secret = "synthetic-placeholder-not-a-credential" + fixture, err := os.ReadFile(filepath.Join("..", "..", "contracts", "local", "examples", "config-read-sip.json")) + if err != nil { + t.Fatal(err) + } + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/internal/v1/dispatcher/sip" || r.Header.Get("X-DISPATCHER-id") != dispatcherID || r.Header.Get("X-DISPATCHER-SECRET-KEY") != secret { + t.Errorf("unexpected SaaS read method, path or Dispatcher headers: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(fixture) + })) + defer server.Close() + path := filepath.Join(t.TempDir(), "dispatcher.sqlite") + t.Setenv("DISPATCHER_ID", dispatcherID) + t.Setenv("DISPATCHER_SECRET_KEY", secret) + t.Setenv("SAAS_BASE_URL", server.URL) + t.Setenv("RABBITMQ_URL", "amqp://127.0.0.1:5672/%2Fmock") + t.Setenv("DISPATCHER_SQLITE_PATH", path) + + settings, reader, err := dispatcherConfigurationClient("mock", server.Client()) + if err != nil { + t.Fatal(err) + } + if settings.DispatcherID != dispatcherID || settings.SQLitePath != path { + t.Fatal("dispatcher startup changed approved deployment identity or SQLite path") + } + sip, err := reader.ReadCurrentSIP(context.Background()) + if err != nil { + t.Fatal(err) + } + if sip.DispatcherID != dispatcherID || sip.Revision != 8 || requests.Load() != 1 { + t.Fatalf("wrong approved SIP snapshot or HTTP request count: revision=%d requests=%d", sip.Revision, requests.Load()) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("HTTP client assembly opened SQLite: %v", err) + } +} + +func TestDispatcherConfigurationClientNeverUsesLegacyHTTPSetting(t *testing.T) { + t.Setenv("DISPATCHER_ID", "c046b893-8628-4589-ae50-619d049248a6") + t.Setenv("DISPATCHER_SECRET_KEY", "synthetic-placeholder-not-a-credential") + t.Setenv("SAAS_BASE_URL", "") + t.Setenv("DISPATCHER_CONFIG_READ_BASE_URL", "http://127.0.0.1:8080") + t.Setenv("RABBITMQ_URL", "amqp://127.0.0.1:5672/%2Fmock") + t.Setenv("DISPATCHER_SQLITE_PATH", filepath.Join(t.TempDir(), "dispatcher.sqlite")) + if _, _, err := dispatcherConfigurationClient("mock", nil); err == nil || !strings.Contains(err.Error(), "SAAS_BASE_URL") { + t.Fatalf("legacy HTTP source silently became a fallback: %v", err) + } +} diff --git a/docs/evidence/saas-dispatcher-implementation.md b/docs/evidence/saas-dispatcher-implementation.md index 283ad15..281f81a 100644 --- a/docs/evidence/saas-dispatcher-implementation.md +++ b/docs/evidence/saas-dispatcher-implementation.md @@ -37,6 +37,7 @@ ## P03:HTTP 读取分批改造(未整体签收) - `contract.ValidateCurrent` 与 `configread` 按当前 Schema 读取 SIP、provider、task、quota 和 cursor 任务发现;严格检查数字 tenant_id、本 D 归属及不可变配置。provider 凭据原值只保留在内存快照,不写日志;Agent 参数中的显式 0/false 保真;无旧 Schema/旧配置回退。 +- CLI 组装辅助 `dispatcherConfigurationClient` 将严格 Mock 环境预检与当前 HTTP Client 绑定;本机 HTTP 隔离测试实际读取 SIP,核对固定 `/internal/v1/dispatcher/sip`、D 身份/密钥 Header、revision 与不打开 SQLite;旧 `DISPATCHER_CONFIG_READ_BASE_URL` 不可充当缺失的当前地址。`go test ./cmd/sip-go-agent -run '^TestDispatcherConfigurationClient' -count=1` 通过;主 `dispatcher` 命令尚未调用该辅助,不能声称启动已切换。 - `store.OpenCurrent` 新建数字租户 SQLite 状态;旧表、旧版当前布局、残缺布局均在写入前拒绝并保留原记录;不执行旧数据迁移或自动清理。启动时完整发现同一快照一次提交,分页增量逐页持久提交后才推进**内存** cursor;失败关闭准入,重启重新取完整快照。HTTP 的旧 running 不能解除 MQ 暂停/终止,同 revision 异内容及跨任务 SIP/租户额度冲突拒绝。 - `CurrentBootstrap` 先关闭准入,核验 SIP 全量与 Agent/Asterisk 已加载 revision、读取任务和 provider/额度,再排空 MQ 控制积压,最后依据已验证 SIP revision 开准入;有更新的持久 SIP 通知时保持关闭但控制与结果处理仍可继续。`CurrentDiscoveryFollower` 逐页绑定任务快照;HTTP 错误、失效或授权不一致只失败,不回退旧读取。**目前只在隔离运行组件中调用,尚未接入 `cmd/sip-go-agent/main.go`;provider 向真实 Agent 交付及真实加载尚待 P05/P07。** - TDD 与回归:`go test ./internal/configread ./internal/tenant ./internal/store ./internal/dispatcher -count=1`、`bash scripts/check-current-contracts.sh`、已提交 `a0118e3` 的干净归档测试通过;旧布局行/表原样保留由 `TestCurrentStoreRefusesPreviousCurrentLayoutBeforeModifyingDatabase` 覆盖。