201 lines
7.4 KiB
Python
201 lines
7.4 KiB
Python
# -*- 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()
|