# -*- coding: utf-8 -*- """部署编排引擎测试(Issue #60)。 覆盖: 1. 默认 Helm 计划构造(4 步:pre_check/deploy/health_check/post_check); 2. 步骤顺序约束校验(违例 → DeployError); 3. 全成功执行(succeeded + 无需回滚); 4. deploy 步骤失败(needs_rollback + failed_step); 5. pre_check 失败(needs_rollback=False,非回滚检查点); 6. 非必需 post_check 失败(继续编排,succeeded); 7. action 异常(视为失败); 8. dry_run(全部 SKIPPED); 9. 空计划(succeeded=False); 10. HealthCheckContract 校验; 11. 报告序列化。 """ import unittest import _bootstrap # noqa: F401 (sys.path 挂载) from deploy_plan import ( DEFAULT_AVAILABILITY_TARGET, DeployError, DeployOrchestrator, DeployOutcome, DeployPlan, DeployStep, DeployStepKind, DeployStatus, HealthCheckContract, ) def _action(ok: bool, detail: str = ""): """构造固定返回值的 action。""" def _fn(ctx): return ok, (detail or ("成功" if ok else "失败")) return _fn def _raising_action(exc_type=RuntimeError): def _fn(ctx): raise exc_type("boom") return _fn class TestDeployPlanModel(unittest.TestCase): """部署计划模型与顺序约束。""" def test_default_helm_release_has_four_steps(self): plan = DeployPlan.default_helm_release() kinds = [s.kind for s in plan.steps] self.assertEqual(kinds, [ DeployStepKind.PRE_CHECK, DeployStepKind.DEPLOY, DeployStepKind.HEALTH_CHECK, DeployStepKind.POST_CHECK, ]) # post_check 默认非必需 self.assertFalse(plan.steps[-1].required) def test_step_order_violation_rejected(self): # health_check 排在 deploy 前 → 违例 with self.assertRaises(DeployError): DeployPlan( name="bad", steps=[ DeployStep(DeployStepKind.HEALTH_CHECK, "hc"), DeployStep(DeployStepKind.DEPLOY, "d"), ]) def test_empty_step_name_rejected(self): with self.assertRaises(DeployError): DeployStep(DeployStepKind.DEPLOY, "") def test_plan_name_required(self): with self.assertRaises(DeployError): DeployPlan(name="") class TestOrchestratorSuccess(unittest.TestCase): """全成功编排。""" def test_all_steps_succeed(self): plan = DeployPlan.default_helm_release() report = DeployOrchestrator(plan).execute() self.assertTrue(report.succeeded) self.assertFalse(report.outcome.needs_rollback) self.assertEqual(len(report.results), 4) self.assertEqual( [r.status for r in report.results], [DeployStatus.SUCCESS] * 4) # 日志含 START/RUN/OK self.assertTrue(any("[START]" in ln for ln in report.log_lines)) self.assertTrue(any("[OK]" in ln for ln in report.log_lines)) def test_context_populated(self): plan = DeployPlan.default_helm_release() report = DeployOrchestrator(plan).execute() self.assertEqual(report.context["release"], "iaop") self.assertEqual(report.context["namespace"], "iaop") self.assertIn("health", report.context) class TestOrchestratorFailures(unittest.TestCase): """失败场景 + 回滚标记。""" def test_deploy_failure_triggers_rollback(self): plan = DeployPlan.default_helm_release( step_actions={DeployStepKind.DEPLOY: _action(False, "helm 失败")}) report = DeployOrchestrator(plan).execute() self.assertFalse(report.succeeded) self.assertTrue(report.outcome.needs_rollback) # deploy 是回滚检查点 self.assertIsNotNone(report.outcome.failed_step) # deploy 之后的 health_check/post_check 未执行 executed_kinds = [r.step.kind for r in report.results] self.assertNotIn(DeployStepKind.HEALTH_CHECK, executed_kinds) self.assertNotIn(DeployStepKind.POST_CHECK, executed_kinds) def test_pre_check_failure_no_rollback(self): plan = DeployPlan.default_helm_release( step_actions={DeployStepKind.PRE_CHECK: _action(False, "集群不可达")}) report = DeployOrchestrator(plan).execute() self.assertFalse(report.succeeded) # pre_check 失败:尚未部署,无需回滚 self.assertFalse(report.outcome.needs_rollback) self.assertIn("集群不可达", report.outcome.reason) def test_non_required_post_check_failure_continues(self): plan = DeployPlan.default_helm_release( step_actions={DeployStepKind.POST_CHECK: _action(False, "烟测失败")}) report = DeployOrchestrator(plan).execute() # post_check 非必需,失败仍判成功(核心步骤都过了) self.assertTrue(report.succeeded) self.assertFalse(report.outcome.needs_rollback) # 但有 WARN 日志 self.assertTrue(any("[WARN]" in ln for ln in report.log_lines)) def test_action_exception_treated_as_failure(self): plan = DeployPlan.default_helm_release( step_actions={DeployStepKind.DEPLOY: _raising_action()}) report = DeployOrchestrator(plan).execute() self.assertFalse(report.succeeded) self.assertTrue(report.outcome.needs_rollback) self.assertIn("action 异常", report.outcome.reason) def test_health_check_failure_triggers_rollback(self): plan = DeployPlan.default_helm_release( step_actions={DeployStepKind.HEALTH_CHECK: _action(False, "/health 不可用")}) report = DeployOrchestrator(plan).execute() self.assertFalse(report.succeeded) self.assertTrue(report.outcome.needs_rollback) class TestDryRunAndEmpty(unittest.TestCase): """dry_run + 空计划。""" def test_dry_run_skips_all(self): plan = DeployPlan.default_helm_release() report = DeployOrchestrator(plan, dry_run=True).execute() self.assertTrue(report.succeeded) self.assertEqual( [r.status for r in report.results], [DeployStatus.SKIPPED] * 4) def test_empty_plan_fails(self): plan = DeployPlan(name="empty", steps=[]) report = DeployOrchestrator(plan).execute() self.assertFalse(report.succeeded) self.assertIn("无步骤", report.outcome.reason) class TestHealthContractAndReport(unittest.TestCase): """HealthCheckContract 校验 + 报告序列化。""" def test_health_contract_defaults(self): h = HealthCheckContract() self.assertEqual(h.endpoint, "http://iaop:8000/health") self.assertEqual(h.retries, 3) self.assertAlmostEqual(h.availability_target, DEFAULT_AVAILABILITY_TARGET) def test_health_contract_validation(self): with self.assertRaises(DeployError): HealthCheckContract(timeout_s=0) with self.assertRaises(DeployError): HealthCheckContract(retries=0) with self.assertRaises(DeployError): HealthCheckContract(availability_target=1.5) def test_report_to_dict(self): plan = DeployPlan.default_helm_release() report = DeployOrchestrator(plan).execute() d = report.to_dict() self.assertTrue(d["succeeded"]) self.assertEqual(len(d["steps"]), 4) self.assertEqual(d["steps"][0]["kind"], "pre_check") if __name__ == "__main__": unittest.main()