diff --git a/AGENTS.md b/AGENTS.md index a368c13..7ec27ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,6 +79,8 @@ ## 当前范围与权威入口 +- **2026-10-10 SaaS 就绪延期授权**:此次任务回执更新部署中,真实任务接口 HTTP 200 返回 `code=500` 错误正文而非任务列表;使用者明确批准暂不等待 SaaS 修复、继续部署。新增显式 `deploy --defer-saas-readiness`,只延期已通过 MQ 独立登录/关闭、HTTP 200 合同验证失败的 SaaS 就绪检查,默认关闭;不修改运行 Schema、不启用旧配置、Mock 或业务准入。探测保留安全路径/原因与延期标记,Dispatcher 必须实际 auto-restart 且最新错误确认为 SaaS 配置校验,不能把 failed、数据库/证书/未知故障算完成。业务始终标记未 ready,原生 Asterisk、数据、真实呼叫授权与 Debug/Capture 边界不变。规则见 [`deploys/production/README.md`](deploys/production/README.md)。 + - **2026-10-10 任务控制回执恢复**:共享合同 `e750fe4` 已推送,项目合并提交 `60fe840` 已进入 main。成功回执必须携带任务编号、动作、处理结果、原因代码和实际任务状态;结构拒绝仍逐字段返回有效身份与动作,不因整条 Schema 失败丢失有效字段,未知字段省略、不填 null,不信请求自带状态。状态读取故障须显式报错;补齐字段后的出站回执仍禁止作为入站命令执行。根因修复测试日志缓冲的并发读写在 `7357eae`,不改业务日志行为。完整检查、实际隔离 MQ/HTTPS/mTLS、72.6% 覆盖率及 Python21通过。使用者已批准 main 推送后的固定环境普通重部署,保持 Debug 开启、Capture 关闭,不 reset、不重启 Asterisk、不主动投递/试拨;现场事实以 `.local/task-control-deployment/` 的受控记录为准,不以本地通过代签 SaaS 收到或生产。合同与本地证据见 [`docs/evidence/task-control-receipt-fields-20261010.md`](docs/evidence/task-control-receipt-fields-20261010.md)。 - **2026-10-10 07:40 全部改动 main Debug 部署**:使用者明确包含业务提交 `09e273b`,合并提交 `a2cafd419b56a2ddf60322b813b4e22f2d62a6b9` 已推送并安装;共享合同 `8e6c332` 经 contracts-update 核验,保留全部最新日志隐私修复与新增拒绝反馈/SIP 来源快照。冲突解决保留实际收件/确认日志、持久拒绝与拒绝身份幂等;新增两模式合并回归先红后绿。完整检查/固定提交发布检查/Python21通过,业务覆盖率72.6%。Agent2904634/D2904645 enabled+active/running、0重启、Debug开启/Capture关闭;实际SHA256 `bf81d096a46ddecede59329afa0fbae12aadd74bb919fc6ae9fc4d139a7d1a1e` 经相同部署构建独立复现,Asterisk777461不变、通道/tcpdump0;实际14HTTP/各8RPC安全日志核验。无reset/旧库迁移/Asterisk重启/抓包/试拨/主动消息/额外AI OSS。无效MQ/拒绝反馈实际SaaS接收、通话和生产仍未验证,原独立probe saas_pending/business_ready=false/real_reboot_verified=false保留。权威为干净隔离main,根工作区旧日志及并行内容不覆盖或清理;见 [`docs/evidence/all-changes-main-debug-deployment-20261010.md`](docs/evidence/all-changes-main-debug-deployment-20261010.md)。 diff --git a/deploys/production/README.md b/deploys/production/README.md index 205b00b..c81cf2f 100644 --- a/deploys/production/README.md +++ b/deploys/production/README.md @@ -48,6 +48,8 @@ 验收包括三项服务安装、启用并启动,Asterisk/Agent `enabled+active`,外部 RabbitMQ 登录/通道、Agent监听、Asterisk基础运行及版本/hash。SaaS 未启动不阻断部署:Dispatcher可明确等待或自动重试,新执行准入保持关闭;SaaS读取、其业务队列供给、业务会话及新SIP加载延期,不把此状态写成业务已就绪。证书、配置、MQ登录及Asterisk故障不得归为SaaS不可用。没有HTTP响应须按真实错误类型判定,证书或未知传输错误阻断部署;等待状态必须是有最新对应错误的 `activating/auto-restart`,`failed` 不算成功。无获批任务、未接通或AI未调用时如实记录未验证。 +使用者明确批准暂不等待 SaaS 配置修复时,可增加 `--defer-saas-readiness`:仅对已通过 MQ 登录/关闭检查、SaaS HTTP 200 但当前合同验证失败的探测显式延期。该参数只影响部署就绪判定,不修改服务内 Schema、归属或准入校验;Dispatcher 必须实际重试且最新错误确认为 SaaS 配置验证失败,数据库、证书及其他传输故障仍失败。报告保留 `saas_readiness_deferred=true`、安全字段路径/原因及 `business_ready=false`。参数默认关闭,只允许用于 deploy。 + ## 日志模式 部署入口的 `--debug` 同时开启 Dispatcher、Agent 的详细收发日志,不启用抓包。省略时使用工作流程日志;Agent 抓包独立为 `--capture`,本入口不自动设置。敏感内容排除及查看命令见 [日志说明](../../docs/logging.md)。 @@ -56,6 +58,8 @@ ```bash python3 deploys/production/production.py deploy --debug +# 只有明确批准延期 SaaS 就绪时使用;不放宽服务内配置校验: +python3 deploys/production/production.py deploy --debug --defer-saas-readiness python3 deploys/production/production.py status python3 deploys/production/production.py stop python3 deploys/production/production.py start diff --git a/deploys/production/probe.go b/deploys/production/probe.go index 58e1959..d6ac7dd 100644 --- a/deploys/production/probe.go +++ b/deploys/production/probe.go @@ -11,11 +11,13 @@ import ( "net" "net/http" "os" + "strings" "syscall" "time" "git.ipao.vip/rogee/go-sip/internal/config" "git.ipao.vip/rogee/go-sip/internal/configread" + "git.ipao.vip/rogee/go-sip/internal/contract" "git.ipao.vip/rogee/go-sip/internal/rpc" amqp "github.com/rabbitmq/amqp091-go" "github.com/santhosh-tekuri/jsonschema/v6" @@ -41,6 +43,7 @@ type report struct { ErrorClass string `json:"error_class,omitempty"` TransportFailure string `json:"transport_failure,omitempty"` SchemaLocations []string `json:"schema_locations,omitempty"` + SchemaReasons []string `json:"schema_reasons,omitempty"` Trunks int `json:"trunks"` Providers int `json:"providers"` Tasks int `json:"tasks"` @@ -83,6 +86,13 @@ func transportFailure(err error) string { func rejected(r *report, err error) { r.Success = false r.ErrorClass = fmt.Sprintf("%T", err) + var contractFailure *contract.ValidationFailure + if errors.As(err, &contractFailure) { + // ValidationFailure deliberately retains raw bytes separately from its + // safe path/keyword-only Reason. Never serialize Raw or a generic error. + r.SchemaReasons = strings.Split(contractFailure.Reason, "; ") + return + } var validation *jsonschema.ValidationError if errors.As(err, &validation) { var visit func(*jsonschema.ValidationError) diff --git a/deploys/production/probe_test.go b/deploys/production/probe_test.go index 4d0dd69..4cc726e 100644 --- a/deploys/production/probe_test.go +++ b/deploys/production/probe_test.go @@ -11,6 +11,8 @@ import ( "strings" "syscall" "testing" + + "git.ipao.vip/rogee/go-sip/internal/contract" ) func TestEnvironmentRejectsMissingConfigurationWithoutNetworking(t *testing.T) { @@ -86,6 +88,33 @@ func TestTransportReportTracksOnlyLatestRequest(t *testing.T) { } } +func TestDiagnosticReportsWrappedContractFailureWithoutRawValues(t *testing.T) { + cause := contract.ValidateCurrent("task-discovery", []byte(`{"private":"synthetic-sensitive-value"}`)) + if cause == nil { + t.Fatal("invalid fixture accepted") + } + r := report{Phase: "tasks"} + rejected(&r, fmt.Errorf("context: %w", cause)) + encoded, err := json.Marshal(r) + if err != nil { + t.Fatal(err) + } + var safe map[string]any + if err := json.Unmarshal(encoded, &safe); err != nil { + t.Fatal(err) + } + details, ok := safe["schema_reasons"].([]any) + if !ok || len(details) == 0 { + t.Fatalf("wrapped contract failure lost safe reasons: %s", encoded) + } + if !strings.Contains(string(encoded), "required field missing") { + t.Fatalf("missing concrete failure kind: %s", encoded) + } + if strings.Contains(string(encoded), "synthetic-sensitive-value") || strings.Contains(string(encoded), `\"private\"`) { + t.Fatal("diagnostic leaked rejected raw bytes") + } +} + func TestDiagnosticNeverSerializesErrorContents(t *testing.T) { r := report{Phase: "sip"} rejected(&r, errors.New("synthetic-sensitive-value")) diff --git a/deploys/production/production.py b/deploys/production/production.py index c868cb9..a11e993 100644 --- a/deploys/production/production.py +++ b/deploys/production/production.py @@ -118,6 +118,7 @@ def main(): parser.add_argument('--known-hosts', type=Path, default=REPO / '.local/agent-call-known_hosts') parser.add_argument('--source-dir', type=Path, default=REPO) parser.add_argument('--debug', action='store_true', help='Enable detailed redacted logs for both roles without packet capture') + parser.add_argument('--defer-saas-readiness', action='store_true', help='Explicitly defer invalid SaaS configuration readiness; runtime validation and independent checks remain mandatory') parser.add_argument('--reset-state', action='store_true') parser.add_argument('--confirm-reset', action='store_true', help='explicit current operator confirmation; never automatic') args = parser.parse_args() @@ -125,6 +126,8 @@ def main(): parser.error('this deployment is authorized only for server.sip') if args.debug and args.action != 'deploy': parser.error('--debug is only valid with deploy') + if args.defer_saas_readiness and args.action != 'deploy': + parser.error('--defer-saas-readiness is only valid with deploy') if args.reset_state and (args.action != 'deploy' or not args.confirm_reset): parser.error('--reset-state requires deploy and --confirm-reset') if not args.known_hosts.is_file(): @@ -134,7 +137,7 @@ def main(): tool_dir = '/home/rogee/.local/share/go-sip-tools' ssh("python3 -c \"from pathlib import Path;import os;p=Path('" + tool_dir + "');p.mkdir(mode=0o700,exist_ok=True);os.chmod(p,0o700)\"") checked(['scp', '-q', *opts, str(HERE / 'remote.py'), args.host + ':' + tool_dir + '/production-remote.py'], timeout=30) - payload = {'action': args.action, 'reset_state': args.reset_state, 'confirm_reset': args.confirm_reset, 'debug': args.debug} + payload = {'action': args.action, 'reset_state': args.reset_state, 'confirm_reset': args.confirm_reset, 'debug': args.debug, 'defer_saas_readiness': args.defer_saas_readiness} if args.action in ('deploy', 'bootstrap', 'retire-mocks'): payload['source'] = sources(args.source_dir) if args.action == 'deploy': diff --git a/deploys/production/remote.py b/deploys/production/remote.py index dbacd94..6192b2f 100644 --- a/deploys/production/remote.py +++ b/deploys/production/remote.py @@ -120,6 +120,9 @@ def dependency_state(report): if report.get('success'): return 'verified' if report.get('phase') in ('sip', 'providers', 'tasks', 'task_and_quota'): + if (report.get('saas_readiness_deferred') is True and report.get('http_status') == 200 + and report.get('error_class') == '*contract.ValidationFailure' and report.get('schema_reasons')): + return 'saas_pending' if report.get('http_status') in (404, 502, 503, 504): return 'saas_pending' if report.get('http_status') == 0 and report.get('transport_failure') == 'unavailable': @@ -367,6 +370,8 @@ def install(payload, p): debug = payload.get('debug', False) if not isinstance(debug, bool): raise ValueError('debug must be a boolean') + if not isinstance(payload.get('defer_saas_readiness', False), bool): + raise ValueError('defer_saas_readiness must be an explicit boolean') facts = host_check(p) source = payload['source'] # Changing ownership of existing durable data is never a normal deployment. @@ -418,6 +423,8 @@ def install(payload, p): dependency = json.loads(probe.stdout) except json.JSONDecodeError: raise ValueError('redacted external probe failed') from None + if payload.get('defer_saas_readiness') is True and not dependency.get('success'): + dependency['saas_readiness_deferred'] = True state = dependency_state(dependency) if state not in ('verified', 'saas_pending') or not dependency.get('mq_connected') or not dependency.get('mq_connection_closed'): raise ValueError('independent dependency failure; not attributable to unavailable SaaS') @@ -434,6 +441,21 @@ def install(payload, p): return result +def saas_waiting_reason(dependency, logs): + if dependency_state(dependency) != 'saas_pending': + return None + if re.search(r'HTTP(?: status)?[ :]+(?:404|502|503|504)', logs): + return 'saas_http_unavailable' + if 'NOT_FOUND' in logs and ('queue' in logs or 'exchange' in logs): + return 'saas_topology_pending' + if '/internal/v1/dispatcher/' in logs and any(v in logs.lower() for v in ('timeout', 'connection refused', 'no such host')): + return 'saas_network_unavailable' + if dependency.get('saas_readiness_deferred') is True and re.search( + r'\b(?:http (?:tasks|task|sip|providers|quota)|task-discovery|task-snapshot|sip|ai-providers|quota) schema validation:', logs): + return 'saas_schema_invalid' + return None + + def dispatcher_acceptable(dispatcher, waiting_reason): if dispatcher.get('UnitFileState') != 'enabled': return False @@ -456,18 +478,14 @@ def status(p): except OSError: pass # Report an explicit false, never substitute a successful listener. dep = p['config'] / 'dependency-status.json' - dependency = dependency_state(json.loads(private_text(dep))) if dep.exists() else 'not_checked' + dependency_report = json.loads(private_text(dep)) if dep.exists() else {} + dependency = dependency_state(dependency_report) if dependency_report else 'not_checked' healthy = matches and listen and all(units[u]['ActiveState'] == 'active' and units[u]['UnitFileState'] == 'enabled' for u in UNITS[:2]) dispatcher = units[UNITS[2]] waiting_reason = None if dependency == 'saas_pending': logs = run(['journalctl', '--user', '-u', 'go-sip-dispatcher.service', '_COMM=sip-go-agent', '-n', '1', '--no-pager', '-o', 'cat']) - if re.search(r'HTTP(?: status)?[ :]+(?:404|502|503|504)', logs): - waiting_reason = 'saas_http_unavailable' - elif 'NOT_FOUND' in logs and ('queue' in logs or 'exchange' in logs): - waiting_reason = 'saas_topology_pending' - elif '/internal/v1/dispatcher/' in logs and any(v in logs.lower() for v in ('timeout', 'connection refused', 'no such host')): - waiting_reason = 'saas_network_unavailable' + waiting_reason = saas_waiting_reason(dependency_report, logs) acceptable = dispatcher_acceptable(dispatcher, waiting_reason) return {'ok': healthy and acceptable, 'services': units, 'artifact_hashes_match': matches, 'agent_listener': listen, 'active_channels': len(ari(p['home'])), 'dependency_state': dependency, 'business_ready': dependency == 'verified' and dispatcher['ActiveState'] == 'active', 'commit': release['commit'] if release else None, 'waiting_reason': waiting_reason, 'real_reboot_verified': False} diff --git a/deploys/production/test_lifecycle.py b/deploys/production/test_lifecycle.py index 048fdab..a282d85 100644 --- a/deploys/production/test_lifecycle.py +++ b/deploys/production/test_lifecycle.py @@ -50,6 +50,34 @@ class LifecycleTest(unittest.TestCase): remote.mkdir(self.p['state']) return {'agent': {}, 'dispatcher': {}} + def test_deferred_saas_never_skips_mq_login_or_closure(self): + payload=dict(self.payload,defer_saas_readiness=True) + for connected,closed in ((False,True),(True,False)): + report={'success':False,'phase':'tasks','http_status':200,'error_class':'*contract.ValidationFailure','schema_reasons':['/tasks: required field missing'],'mq_connected':connected,'mq_connection_closed':closed} + result=subprocess.CompletedProcess([],1,json.dumps(report)) + with patch.object(remote.subprocess,'run',return_value=result),patch.object(remote,'status') as status: + with self.assertRaisesRegex(ValueError,'independent dependency failure'): + remote.install(payload,self.p) + status.assert_not_called() + self.assertEqual(self.state.read_bytes(),b'original recording') + + def test_explicit_saas_deferral_installs_without_claiming_business_ready(self): + report={'success':False,'phase':'tasks','http_status':200,'error_class':'*contract.ValidationFailure','schema_reasons':['/tasks: required field missing'],'mq_connected':True,'mq_connection_closed':True} + with patch.object(remote.subprocess,'run',return_value=subprocess.CompletedProcess([],1,json.dumps(report))),patch.object(remote,'backup_dir') as backup: + result=remote.install(dict(self.payload,defer_saas_readiness=True),self.p) + backup.assert_not_called() + self.assertEqual(result['dependency_state'],'saas_pending') + self.assertFalse(result['business_ready']) + stored=json.loads((self.p['config']/'dependency-status.json').read_text()) + self.assertIs(stored['saas_readiness_deferred'],True) + self.assertEqual(stored['schema_reasons'],report['schema_reasons']) + self.assertEqual(self.state.read_bytes(),b'original recording') + + def test_deferral_option_must_be_an_explicit_boolean(self): + with self.assertRaisesRegex(ValueError,'explicit boolean'): + remote.install(dict(self.payload,defer_saas_readiness='true'),self.p) + self.assertEqual(self.calls,[]) + def test_saas_down_installs_and_repeat_preserves_original_state(self): with patch.object(remote, 'backup_dir') as backup: first = remote.install(self.payload, self.p) diff --git a/deploys/production/test_production.py b/deploys/production/test_production.py index 91b8d17..cced5dc 100644 --- a/deploys/production/test_production.py +++ b/deploys/production/test_production.py @@ -95,7 +95,7 @@ class EntryPointTest(unittest.TestCase): with tempfile.TemporaryDirectory() as tmp: known_hosts = Path(tmp) / 'known_hosts' known_hosts.touch() - argv = ['production.py', 'deploy', '--known-hosts', str(known_hosts)] + argv = ['production.py', 'deploy', '--known-hosts', str(known_hosts), '--defer-saas-readiness'] with patch.object(sys, 'argv', argv), patch.object(m, 'sources', return_value={'synthetic': True}), patch.object(m, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): m.main() @@ -103,6 +103,7 @@ class EntryPointTest(unittest.TestCase): self.assertEqual([args[-1] for args in builds], ['./cmd/sip-go-agent', './deploys/production']) self.assertEqual([Path(args[args.index('-o') + 1]).name for args in builds], ['sip-go-agent', 'production-probe']) self.assertEqual(set(payloads[0]['hashes']), {'sip-go-agent', 'production-probe'}) + self.assertIs(payloads[0]['defer_saas_readiness'], True) self.assertTrue(any(args[0] == 'scp' and args[-1].endswith('/production-remote.py') for args in calls)) self.assertFalse(any('preprod' in arg for args in calls for arg in args)) @@ -132,6 +133,22 @@ class SafetyTest(unittest.TestCase): self.assertEqual(self.m.dependency_state({'success': False, 'phase': 'sip', 'http_status': 0, 'transport_failure': kind}), 'dependency_failed') self.assertEqual(self.m.dependency_state({'success': False, 'phase': 'sip', 'http_status': 0, 'transport_failure': 'unavailable'}), 'saas_pending') + def test_saas_schema_readiness_can_only_be_explicitly_deferred(self): + failure = {'success': False, 'phase': 'tasks', 'http_status': 200, 'error_class': '*contract.ValidationFailure', 'schema_reasons': ['/tasks: required field missing']} + self.assertEqual(self.m.dependency_state(failure), 'dependency_failed') + self.assertEqual(self.m.dependency_state(dict(failure, saas_readiness_deferred=True)), 'saas_pending') + self.assertEqual(self.m.dependency_state(dict(failure, saas_readiness_deferred='true')), 'dependency_failed') + for change in ({'phase': 'mq_connect'}, {'phase': 'agent'}, {'http_status': 0, 'transport_failure': 'tls_failed'}, {'error_class': '*url.Error'}, {'schema_reasons': []}): + invalid = dict(failure, saas_readiness_deferred=True, **change) + self.assertNotEqual(self.m.dependency_state(invalid), 'saas_pending') + + def test_deferred_state_requires_actual_saas_failure_to_accept_retry(self): + failure = {'success': False, 'phase': 'tasks', 'http_status': 200, 'error_class': '*contract.ValidationFailure', 'schema_reasons': ['/tasks: required field missing'], 'saas_readiness_deferred': True} + self.assertEqual(self.m.saas_waiting_reason(failure, 'Error: task-discovery schema validation: /tasks: required field missing'), 'saas_schema_invalid') + self.assertIsNone(self.m.saas_waiting_reason(failure, 'Error: database could not be opened')) + self.assertIsNone(self.m.saas_waiting_reason(dict(failure, saas_readiness_deferred=False), 'Error: task-discovery schema validation: missing')) + self.assertEqual(self.m.saas_waiting_reason({'success': False,'phase':'sip','http_status':503}, 'Error: HTTP status 503'), 'saas_http_unavailable') + def test_role_rendering_overrides_old_mock_business_source(self): source = {'dispatcher_id': 'new-owner', 'saas_url': 'https://real.example.invalid', 'secret': 'new-secret', 'mq_url': 'amqp://synthetic:placeholder@mq.example.invalid/test'} p = self.m.paths(Path('/home/synthetic')) diff --git a/docs/evidence/task-control-receipt-fields-20261010.md b/docs/evidence/task-control-receipt-fields-20261010.md index cfe32e9..41f7574 100644 --- a/docs/evidence/task-control-receipt-fields-20261010.md +++ b/docs/evidence/task-control-receipt-fields-20261010.md @@ -6,6 +6,14 @@ 本轮固定环境部署采用干净、已推送 main、现有 `nonprod-real`、Debug 开启/Capture 关闭,先关闭 Dispatcher 新准入并核对零活动通话/未确认执行,再调用原部署入口,不 reset、不重启 Asterisk、不生成试拨或额外消息。最终部署来源、两个制品 SHA-256、原生通道、服务进程与稳定性、持久任务/执行占用、恢复目录及日志摘要保存于私密 `.local/task-control-deployment/`;没有把部署计划或本地测试写成现场成功。下文的未合入/未部署表述是功能分支交付时的历史边界,不是此后 main 的状态。 +## SaaS 就绪延期的后续授权与限制 + +首轮现场部署的只读探测确认 MQ 正常,但 SaaS 的 tasks 请求返回 HTTP 200、顶层仅 code/msg 且 code=500,不是任务列表。未修改 SaaS 或放宽 Schema,部署在该门禁处停止,Asterisk 保持原进程。使用者随后明确批准忽略当前 SaaS 问题、继续部署。 + +因此部署入口增加默认关闭的 `--defer-saas-readiness`,只对已通过独立 MQ 检查且被当前合同拒绝的 SaaS HTTP 200 配置响应显式延期;仍保留字段路径/安全原因与延期标记,运行时 Schema、任务归属、持久准入和独立错误检查不变,业务状态仍为未 ready。最新实际 Dispatcher 错误必须对应 SaaS 配置验证,且服务实际自动重试,不以任意 failed 状态代签。 + +探测器原先只报告错误类型,补充从 `ValidationFailure.Reason` 提取安全路径/规则错误,不输出其原始正文或普通错误内容;安全诊断先失败后通过。新增默认拒绝、显式延期、错误类型/阶段/TLS限制、真实等待日志判定、MQ 登录/关闭不可跳过、数据不重置测试。部署规则及示例只在 `deploys/production/README.md` 维护。 + ## 范围与来源 - 使用者确认恢复任务控制成功回执的五个字段,覆盖 start/pause/resume/stop/edit。