CMDB 资产管理(5):阿里云 ECS、MySQL 与最终验收
28 阿里云 ECS 适配器的范围、边界与非目标
28.1 本篇从哪里继续
前 3 篇已经完成账号、云厂商、地域、可用区、计算实例、标签、同步执行、生命周期、页面、API、CSV 和权限闭环。本篇不重新设计这些基础层,而是在现有的 Provider 规范化快照之上接入阿里云 ECS,再完成生产配置、验证证据和源码发布收尾。
阿里云适配器的入口文件是 devopsX/cmdb/providers/aliyun.py。同步服务通过云厂商代码 aliyun 选择该适配器;适配器只负责读取云端数据并转换成统一的发现快照,不直接创建、更新或删除 ORM 对象。数据库事务、快照校验、变化记录和生命周期处理仍由 devopsX/cmdb/services/sync.py 负责。
28.2 v1.0.0 明确支持的阿里云范围
| 范围 | v1.0.0 行为 | 边界 |
|---|---|---|
| 云产品 | 只读取 ECS 资产。 | 不读取 RDS、SLB、OSS、容器、云盘清单或安全组清单。 |
| 地域 | 调用 DescribeRegions,支持全部地域或账号地域白名单。 | 白名单中的地域必须真实出现在地域接口结果中。 |
| 可用区 | 逐地域调用 DescribeZones。 | 只保存可用区 ID、所属地域和名称。 |
| 实例 | 逐地域分页调用 DescribeInstances,每页 100 条。 | 只映射 CMDB v1 模型已经定义的计算实例字段。 |
| 标签 | 读取实例响应中的标签并映射成厂商标签。 | 不写回云端,也不覆盖本地人工标签。 |
| 网络地址 | 合并经典网络私网地址、VPC 私网地址、实例公网地址和 EIP。 | 不把 VPC、交换机、网卡建成独立拓扑对象。 |
| 凭据 | 数据库只存环境变量前缀,进程从环境变量读取密钥。 | 不把 AccessKey、Secret 或 Token 写入数据库、CSV、API 或页面。 |
| 写操作 | 没有调用启动、停止、重启、创建、修改或删除接口。 | 这是只读发现适配器,不是云资源编排器。 |
28.3 本版明确不做什么
- 不实现跨云统一控制面,也不从 CMDB 反向修改阿里云资源;
- 不实现常驻事件订阅、云审计事件消费或增量变更流;
- 不实现后台任务队列,页面触发同步仍在 Web 请求中同步执行;
- 不把 SDK 响应对象、数据库 ORM 对象和页面 JSON 混成同一种结构;
- 不承诺 SDK 默认重试、代理、超时或跨境网络行为已经适配所有生产网络;
- 不把 mock 测试通过表述成真实账号已经联通。
28.4 一次阿里云同步经过哪些边界
- 页面、API 或管理命令选中一个
CloudAccount; - 同步服务根据
CloudProvider.code选择AliyunEcsProvider; - 适配器根据账号的
credential_profile读取进程环境变量; - SDK 客户端读取地域、可用区和实例分页;
- 映射函数把厂商字段变成不可变的规范化数据类;
- 适配器返回完整或部分
DiscoverySnapshot; - 同步服务先校验快照身份、引用、状态和范围,再在事务中写入数据库;
- 只有完整快照才允许把本次未发现的既有实例标记为缺失;部分快照只能写入已经安全发现的内容。
28.5 本章证据边界
已验证事实:阿里云适配器的 18 项 mock/contract 与 SDK 响应结构测试通过。
未验证外部条件:当前没有可用的真实只读阿里云凭据,因此没有执行真实账号 smoke test。本文不会声称地域、RAM、网络出口、API 配额和真实账号数据已经联通。
29 凭据环境变量边界与阿里云 SDK v2 客户端
29.1 credential profile 不是密钥
credential_profile 是一个环境变量前缀,例如账号记录中保存 ALIYUN_DEMO,适配器便查找下面 3 个变量。数据库保存的是定位名称,不是认证材料。
| 环境变量 | 是否必需 | 用途 | 是否进入数据库 |
|---|---|---|---|
ALIYUN_DEMO_ACCESS_KEY_ID | 必需 | AccessKey ID | 否 |
ALIYUN_DEMO_ACCESS_KEY_SECRET | 必需 | AccessKey Secret | 否 |
ALIYUN_DEMO_SECURITY_TOKEN | 可选 | STS 临时安全令牌 | 否 |
模型校验要求前缀匹配“大写字母开头,后续只能是大写字母、数字或下划线”。这既减少变量名歧义,也避免把空格、连字符和 shell 特殊字符带入查找过程。CSV 导入只接受 credential_profile,会拒绝常见密钥列;账号 API 也只返回前缀,不返回密钥值。
这个边界能避免最直接的明文入库,但它不是完整密钥管理系统。生产环境仍应由进程管理器、容器 Secret、主机密钥服务或组织批准的凭据系统注入变量,并落实最小 RAM 只读权限、轮换、审计和进程访问隔离。
29.2 本地环境变量模板的完整文件
devopsX/.env.example 只声明变量名和本地教学默认值。真实 devopsX/.env 不属于源码包,不应提交或分发。
相对路径:devopsX/.env.example
# 仅用于本地教学开发;生产环境必须设置 DEBUG=false 并使用独立安全配置。
DEVOPSX_SECRET_KEY=
DEVOPSX_DEBUG=true
DEVOPSX_ALLOWED_HOSTS=127.0.0.1,localhost
DEVOPSX_CSRF_TRUSTED_ORIGINS=
# 仅当受信任反向代理会覆盖 X-Forwarded-Proto 时设为 true。
DEVOPSX_TRUST_X_FORWARDED_PROTO=false
DEVOPSX_DB_ENGINE=sqlite
DEVOPSX_DB_NAME=
DEVOPSX_DB_USER=
DEVOPSX_DB_PASSWORD=
DEVOPSX_DB_HOST=127.0.0.1
DEVOPSX_DB_PORT=3306
ALIYUN_DEMO_ACCESS_KEY_ID=
ALIYUN_DEMO_ACCESS_KEY_SECRET=
ALIYUN_DEMO_SECURITY_TOKEN=
逐组理解:
- 第 1 行强调该文件只服务本地教学开发,生产必须关闭 DEBUG 并使用独立安全配置;
- 第 2~5 行是 Django 密钥、调试、主机名和 CSRF 来源;示例密钥保持空值,避免把公共示例误当成生产密钥;
- 第 6~11 行定义 SQLite 或 MySQL 连接参数;默认 SQLite 只用于本地教学和自动化测试;
- 第 13~15 行是阿里云凭据变量名,全部空置;Security Token 只在采用 STS 临时凭据时填写。
常见错误:直接把 devopsX/.env.example 改成带真实密钥的文件后又放入压缩包。正确做法是复制为仅本机可见的 devopsX/.env,并在发布清单中明确排除它。
29.3 基础依赖与阿里云可选依赖
基础依赖文件保持最小集合:
相对路径:devopsX/requirements.txt
Django==5.2.17
python-dotenv==1.2.3
Django==5.2.17固定本教程实际验证的 Django 版本;python-dotenv==1.2.3让设置模块可以从项目根目录加载本地devopsX/.env。
阿里云适配器采用独立可选依赖文件,避免只学习 Fake Provider 或只跑 SQLite 测试时被迫安装云 SDK:
相对路径:devopsX/requirements-aliyun.txt
-r requirements.txt
alibabacloud-ecs20140526==7.11.5
- 第 1 行先复用完整的
devopsX/requirements.txt; - 第 2 行固定阿里云 ECS 2014-05-26 SDK v2 包版本
7.11.5;该包会带入 Tea OpenAPI 和 Tea Util 所需依赖。
安装命令如下。它是准备真实阿里云运行环境的命令,不是本次 mock 测试已经完成真实云联通的证据。
执行目录:devopsX/
python -m pip install -r requirements-aliyun.txt
预期结果:pip 成功解析并安装基础依赖与 alibabacloud-ecs20140526==7.11.5。如果安装源无法访问,应先修复组织代理或内部镜像配置,不能删除版本固定来掩盖问题。
29.4 阿里云适配器完整文件
下面是当前 v1.0.0 的完整实现。代码块与实际文件一致,后文按职责组解释,不在代码块内插入教学注释。
相对路径:devopsX/cmdb/providers/aliyun.py
import logging
import os
from datetime import datetime
from .base import (
CloudProviderAdapter,
DiscoveredAvailabilityZone,
DiscoveredComputeInstance,
DiscoveredRegion,
DiscoveredTag,
DiscoveryScope,
DiscoverySnapshot,
ProviderError,
)
logger = logging.getLogger(__name__)
_MISSING = object()
STATUS_MAP = {
"Pending": "starting",
"Running": "running",
"Stopped": "stopped",
"Starting": "starting",
"Stopping": "stopping",
}
def _value(obj, name, default=None):
if isinstance(obj, dict):
return obj.get(name, default)
return getattr(obj, name, default)
def _nested_list(obj, *names):
current = obj
for name in names:
current = _value(current, name)
if current is None:
return []
return list(current or [])
def _invalid_response():
return ProviderError(
"ALIYUN_INVALID_RESPONSE",
"阿里云接口返回了不完整的响应结构,当前快照不完整。",
)
def _required_nested_list(obj, *names):
current = obj
for name in names:
current = _value(current, name, _MISSING)
if current is _MISSING or current is None:
raise _invalid_response()
if not isinstance(current, (list, tuple)):
raise _invalid_response()
return list(current)
def _required_int(obj, name):
value = _value(obj, name, _MISSING)
if not isinstance(value, int) or isinstance(value, bool):
raise _invalid_response()
return value
def _parse_cloud_time(value):
if not value:
return None
if not isinstance(value, str):
raise ProviderError(
"ALIYUN_INVALID_TIME",
"阿里云实例返回了无效创建时间,当前快照不完整。",
)
normalized = value.replace("Z", "+00:00")
try:
return datetime.fromisoformat(normalized)
except ValueError as exc:
raise ProviderError(
"ALIYUN_INVALID_TIME",
"阿里云实例返回了无效创建时间,当前快照不完整。",
) from exc
def _normalize_status(value):
return STATUS_MAP.get(value, "unknown")
def map_region(raw_region):
return DiscoveredRegion(
provider_resource_id=_value(raw_region, "region_id", ""),
name=_value(raw_region, "local_name", "")
or _value(raw_region, "region_id", ""),
endpoint=_value(raw_region, "region_endpoint", ""),
)
def map_zone(raw_zone, region_id):
zone_id = _value(raw_zone, "zone_id", "")
return DiscoveredAvailabilityZone(
provider_resource_id=zone_id,
region_provider_resource_id=region_id,
name=_value(raw_zone, "local_name", "") or zone_id,
)
def map_instance(raw_instance, region_id):
provider_status = _value(raw_instance, "status", "")
inner_ips = _nested_list(raw_instance, "inner_ip_address", "ip_address")
vpc_ips = _nested_list(
raw_instance,
"vpc_attributes",
"private_ip_address",
"ip_address",
)
private_ips = list(dict.fromkeys(inner_ips + vpc_ips))
public_ips = _nested_list(raw_instance, "public_ip_address", "ip_address")
eip_address = _value(
_value(raw_instance, "eip_address", {}),
"ip_address",
"",
)
if isinstance(eip_address, str):
eip_ips = [eip_address] if eip_address else []
else:
eip_ips = list(eip_address or [])
public_ips = list(dict.fromkeys(public_ips + eip_ips))
tags = tuple(
DiscoveredTag(
key=_value(raw_tag, "tag_key", ""),
value=_value(raw_tag, "tag_value", ""),
)
for raw_tag in _nested_list(raw_instance, "tags", "tag")
if _value(raw_tag, "tag_key", "")
)
return DiscoveredComputeInstance(
provider_resource_id=_value(raw_instance, "instance_id", ""),
region_provider_resource_id=region_id,
availability_zone_provider_resource_id=(
_value(raw_instance, "zone_id", "") or None
),
name=_value(raw_instance, "instance_name", ""),
instance_type=_value(raw_instance, "instance_type", ""),
vcpu=int(_value(raw_instance, "cpu", 0) or 0),
memory_mb=int(_value(raw_instance, "memory", 0) or 0),
os_name=_value(raw_instance, "osname", ""),
provider_status=provider_status,
normalized_status=_normalize_status(provider_status),
private_ips=tuple(private_ips),
public_ips=tuple(public_ips),
cloud_created_at=_parse_cloud_time(_value(raw_instance, "creation_time", "")),
tags=tags,
)
def _safe_provider_error(exc):
code = str(
getattr(exc, "code", "")
or getattr(exc, "error_code", "")
or "ALIYUN_REQUEST_FAILED"
)
status_code = getattr(exc, "status_code", None)
normalized = code.lower()
if status_code == 403 or "forbidden" in normalized or "accessdenied" in normalized:
provider_error = ProviderError(
"ALIYUN_ACCESS_DENIED",
"阿里云拒绝了只读资产请求,请检查 RAM 权限。",
)
elif "throttl" in normalized or status_code == 429:
provider_error = ProviderError(
"ALIYUN_THROTTLED",
"阿里云接口触发限流,请稍后重试。",
)
elif isinstance(exc, (ConnectionError, TimeoutError)):
provider_error = ProviderError(
"ALIYUN_NETWORK_ERROR",
"连接阿里云接口失败,请检查网络。",
)
else:
provider_error = ProviderError(
"ALIYUN_REQUEST_FAILED",
"阿里云资产发现失败,请查看服务日志。",
)
logger.warning(
"Aliyun SDK request failed mapped_code=%s exception_type=%s status_code=%s.",
provider_error.code,
type(exc).__name__,
status_code,
)
return provider_error
class AliyunSdkClient:
def __init__(self, access_key_id, access_key_secret, security_token=""):
try:
from alibabacloud_ecs20140526.client import Client as EcsClient
from alibabacloud_ecs20140526 import models as ecs_models
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_tea_util import models as util_models
except ImportError as exc:
raise ProviderError(
"ALIYUN_SDK_NOT_INSTALLED",
"未安装阿里云 ECS SDK,请先安装 requirements-aliyun.txt。",
) from exc
self.ecs_client_class = EcsClient
self.ecs_models = ecs_models
self.open_api_models = open_api_models
self.runtime = util_models.RuntimeOptions()
self.access_key_id = access_key_id
self.access_key_secret = access_key_secret
self.security_token = security_token
def _client(self, endpoint):
config = self.open_api_models.Config(
access_key_id=self.access_key_id,
access_key_secret=self.access_key_secret,
security_token=self.security_token or None,
)
config.endpoint = endpoint
return self.ecs_client_class(config)
def describe_regions(self):
client = self._client("ecs.cn-hangzhou.aliyuncs.com")
request = self.ecs_models.DescribeRegionsRequest(resource_type="instance")
response = client.describe_regions_with_options(request, self.runtime)
return _required_nested_list(
_value(response, "body"),
"regions",
"region",
)
def describe_zones(self, region_id):
client = self._client("ecs.%s.aliyuncs.com" % region_id)
request = self.ecs_models.DescribeZonesRequest(region_id=region_id)
response = client.describe_zones_with_options(request, self.runtime)
return _required_nested_list(
_value(response, "body"),
"zones",
"zone",
)
def describe_instances_page(self, region_id, page_number, page_size):
client = self._client("ecs.%s.aliyuncs.com" % region_id)
request = self.ecs_models.DescribeInstancesRequest(
region_id=region_id,
page_number=page_number,
page_size=page_size,
)
response = client.describe_instances_with_options(request, self.runtime)
body = _value(response, "body")
return (
_required_nested_list(body, "instances", "instance"),
_required_int(body, "total_count"),
)
class AliyunEcsProvider(CloudProviderAdapter):
page_size = 100
def __init__(self, client_factory=None):
self.client_factory = client_factory or AliyunSdkClient
def _client_for_account(self, account):
profile = account.credential_profile.strip()
if not profile:
raise ProviderError(
"MISSING_CREDENTIAL_PROFILE",
"阿里云账号未配置凭据环境变量前缀。",
)
access_key_id = os.environ.get("%s_ACCESS_KEY_ID" % profile, "")
access_key_secret = os.environ.get("%s_ACCESS_KEY_SECRET" % profile, "")
security_token = os.environ.get("%s_SECURITY_TOKEN" % profile, "")
if not access_key_id or not access_key_secret:
raise ProviderError(
"ALIYUN_CREDENTIALS_NOT_FOUND",
"未找到该凭据前缀对应的阿里云环境变量。",
)
return self.client_factory(access_key_id, access_key_secret, security_token)
def discover(self, account):
client = self._client_for_account(account)
regions = []
zones = []
instances = []
try:
raw_regions = client.describe_regions()
if not raw_regions:
raise _invalid_response()
except Exception as exc:
if isinstance(exc, ProviderError):
logger.warning("Aliyun discovery failed code=%s.", exc.code)
raise
raise _safe_provider_error(exc) from exc
allowlist = set(account.region_allowlist or [])
discovered_region_ids = {
_value(raw_region, "region_id", "") for raw_region in raw_regions
}
unknown_regions = allowlist - discovered_region_ids
if unknown_regions:
raise ProviderError(
"ALIYUN_UNKNOWN_REGION",
"地域白名单包含阿里云未返回的地域:%s"
% ", ".join(sorted(unknown_regions)),
)
selected_raw_regions = [
raw_region
for raw_region in raw_regions
if not allowlist or _value(raw_region, "region_id", "") in allowlist
]
selected_region_ids = [
_value(raw_region, "region_id", "") for raw_region in selected_raw_regions
]
for raw_region in selected_raw_regions:
region = map_region(raw_region)
regions.append(region)
try:
raw_zones = client.describe_zones(region.provider_resource_id)
if not raw_zones:
raise _invalid_response()
zones.extend(
map_zone(raw_zone, region.provider_resource_id)
for raw_zone in raw_zones
)
page_number = 1
region_instance_count = 0
expected_total = None
while True:
raw_instances, total_count = client.describe_instances_page(
region.provider_resource_id,
page_number,
self.page_size,
)
if total_count < 0:
raise ProviderError(
"ALIYUN_INCONSISTENT_PAGINATION",
"阿里云实例分页返回了无效总数,当前快照不完整。",
)
if expected_total is None:
expected_total = total_count
elif total_count != expected_total:
raise ProviderError(
"ALIYUN_INCONSISTENT_PAGINATION",
"阿里云实例分页总数在请求期间发生变化,当前快照不完整。",
)
if raw_instances and expected_total == 0:
raise ProviderError(
"ALIYUN_INCONSISTENT_PAGINATION",
"阿里云实例分页返回的数据与总数不一致,当前快照不完整。",
)
instances.extend(
map_instance(raw_instance, region.provider_resource_id)
for raw_instance in raw_instances
)
region_instance_count += len(raw_instances)
if region_instance_count > expected_total:
raise ProviderError(
"ALIYUN_INCONSISTENT_PAGINATION",
"阿里云实例分页数量超过总数,当前快照不完整。",
)
if region_instance_count == expected_total:
break
if not raw_instances:
raise ProviderError(
"ALIYUN_INCOMPLETE_PAGINATION",
"阿里云实例分页提前结束,当前快照不完整。",
)
page_number += 1
except Exception as exc:
provider_error = exc if isinstance(exc, ProviderError) else _safe_provider_error(exc)
logger.warning(
"Aliyun discovery incomplete code=%s.",
provider_error.code,
)
if regions or zones or instances:
return DiscoverySnapshot(
regions=tuple(regions),
availability_zones=tuple(zones),
instances=tuple(instances),
complete=False,
error_code=provider_error.code,
error_message=provider_error.message,
scope=DiscoveryScope(
mode="allowlist" if allowlist else "all",
region_ids=(
tuple(selected_region_ids) if allowlist else ()
),
),
)
if isinstance(exc, ProviderError):
raise
raise provider_error from exc
return DiscoverySnapshot(
regions=tuple(regions),
availability_zones=tuple(zones),
instances=tuple(instances),
complete=True,
scope=DiscoveryScope(
mode="allowlist" if allowlist else "all",
region_ids=tuple(selected_region_ids) if allowlist else (),
),
)
29.5 文件前半部分逐组解释
| 行范围 | 职责 | 关键行为 |
|---|---|---|
| 1~13 | 导入 | 只在模块顶层导入标准库和 Provider 规范;云 SDK 本身采用延迟导入。 |
| 16~21 | 状态映射 | 把 Running、Stopped、Starting、Stopping 转为 CMDB 标准状态,其他值落到 unknown。 |
| 24~36 | 统一取值 | _value() 同时支持字典和 SDK 模型对象;_nested_list() 安全读取嵌套列表。 |
| 39~46 | 时间解析 | 把末尾 Z 转成 +00:00 后交给 datetime.fromisoformat();空值或非法值返回 None。 |
| 49~117 | 资源映射 | 依次构造地域、可用区、实例和标签的规范化不可变数据。 |
| 120~134 | 错误净化 | 按拒绝、限流、网络和一般失败分类,只返回固定安全消息。 |
| 137~155 | SDK 延迟导入 | 只有真正实例化 SDK 客户端时才导入云 SDK;缺包转换为受控 ProviderError。 |
| 157~164 | 区域客户端 | 每次按 endpoint 构造 ECS Client,并传入 AccessKey 与可选 Security Token。 |
| 166~189 | SDK v2 调用 | 使用 describe_*_with_options() 和统一 RuntimeOptions。 |
| 192~213 | 账号凭据解析 | 只用前缀查找环境变量;前缀空或密钥对不完整立即失败。 |
| 215~330 | 发现流程 | 读取地域、校验白名单、逐地域抓取可用区和实例分页,校验 total_count 一致性后返回完整或部分快照。 |
29.6 SDK v2 客户端是怎样创建的
AliyunSdkClient.__init__()延迟导入 ECS Client、ECS models、OpenAPI models 和 Tea Util models;- 导入失败时抛出代码
ALIYUN_SDK_NOT_INSTALLED,页面和命令只看到可操作的中文提示; _client(endpoint)创建Config,放入 AccessKey ID、AccessKey Secret 和可选 Security Token;- Security Token 为空字符串时传入
None,兼容长期 AccessKey 和 STS 临时凭据两种方式; - 地域列表固定从
ecs.cn-hangzhou.aliyuncs.com请求,地域内接口使用ecs.<region_id>.aliyuncs.com; - 所有请求使用同一个默认
RuntimeOptions对象。本版没有自定义连接超时、读取超时、代理和重试次数,这些属于真实部署必须根据网络环境补充验证的外部条件。
29.7 凭据边界的失败方式
| 条件 | 错误代码 | 用户可见信息是否含密钥 |
|---|---|---|
| 账号前缀为空 | MISSING_CREDENTIAL_PROFILE | 否,只提示未配置前缀。 |
| ID 或 Secret 缺失 | ALIYUN_CREDENTIALS_NOT_FOUND | 否,只提示找不到对应环境变量。 |
| SDK 未安装 | ALIYUN_SDK_NOT_INSTALLED | 否,只提示安装依赖文件。 |
| RAM 拒绝 | ALIYUN_ACCESS_DENIED | 否,只提示检查只读权限。 |
不要把异常对象原文直接拼到页面、API 响应或同步记录中。厂商异常可能携带请求参数、endpoint、Request ID、签名上下文或底层网络细节。详细堆栈只应进入受控服务日志,当前用户界面只保存固定错误代码和净化消息。
30 Regions、Zones、Instances、Tags 与网络地址映射
30.1 为什么先映射再写数据库
阿里云 SDK 返回的是厂商模型,Fake Provider 返回的是教学快照,Django ORM 保存的是本地关系模型。三者职责不同。适配器先构造统一数据类,能让同步服务只处理一种输入,也能在写库前验证重复 ID、空 ID、地域引用、可用区引用和标准状态。
映射函数既接受字典,也接受 SDK 模型对象。这样 mock 测试可以用清楚的字典表达厂商响应,而真实客户端仍可直接把 SDK 对象交给同一套映射函数。
30.2 地域与可用区映射
| 规范化字段 | 阿里云字段 | 回退规则 |
|---|---|---|
| 地域资源 ID | region_id | 没有额外回退;后续快照校验会拒绝空 ID。 |
| 地域名称 | local_name | 为空时使用 region_id。 |
| 地域 endpoint | region_endpoint | 为空时保存空字符串。 |
| 可用区资源 ID | zone_id | 没有额外回退;快照校验拒绝空 ID。 |
| 可用区所属地域 | 当前分页循环的 region_id | 不信任另一个隐式上下文。 |
| 可用区名称 | local_name | 为空时使用 zone_id。 |
这里保留厂商原始资源 ID,因为它是账号范围内幂等同步的稳定身份。显示名称可以变化,不能作为唯一键。同步层最终使用“账号 + 厂商资源 ID”的唯一约束定位地域、可用区和实例。
30.3 实例字段映射
| CMDB 字段 | 阿里云来源 | 转换 |
|---|---|---|
provider_resource_id | instance_id | 字符串原样保存。 |
region_provider_resource_id | 当前地域循环 | 显式写入发现上下文。 |
availability_zone_provider_resource_id | zone_id | 空字符串转为 None。 |
name | instance_name | 缺失时为空字符串。 |
instance_type | instance_type | 缺失时为空字符串。 |
vcpu | cpu | 空值按 0,再转整数。 |
memory_mb | memory | 空值按 0,再转整数;模型含义是 MiB。 |
os_name | osname | 缺失时为空字符串。 |
provider_status | status | 保留厂商原始状态。 |
normalized_status | status | 已知状态映射,未知状态统一为 unknown。 |
cloud_created_at | creation_time | ISO 时间解析失败时为 None。 |
同时保存原始状态和标准状态很重要。标准状态方便跨云筛选;原始状态保留厂商语义,出现新状态时不会完全失去上下文。新厂商状态在更新映射和测试前先落入 unknown,比猜测成 running 或 stopped 更安全。
30.4 经典网络私网 IP、VPC 私网 IP 与 EIP
阿里云实例响应可能把地址放在不同嵌套结构中。本版采用“合并、去重、保持首次出现顺序”的规则:
- 先读取
inner_ip_address.ip_address; - 再读取
vpc_attributes.private_ip_address.ip_address; - 两组地址合并成
private_ips,通过有序字典键去重; - 先读取
public_ip_address.ip_address; - 再读取
eip_address.ip_address; - EIP 若是字符串,转换成单元素列表;若已经是列表,则按列表处理;
- 两组公网地址合并成
public_ips并去重。
| 输入情形 | 规范化结果 |
|---|---|
| 只有经典网络私网地址 | private_ips 包含经典网络地址。 |
| 只有 VPC 私网地址 | private_ips 包含 VPC 地址。 |
| 两处出现同一私网地址 | 只保留第一次出现的一项。 |
| 普通公网地址加 EIP | public_ips 同时包含两者。 |
| EIP 与普通公网地址相同 | 去重后只保留一项。 |
| 所有地址字段为空 | 得到空元组,写库时转为空列表。 |
本版只保存地址值,不保存网卡 ID、主私网 IP 标记、VPC ID、交换机 ID、EIP 资源 ID、带宽或计费属性。因此它适合资产检索,不足以替代完整网络 CMDB。
30.5 标签、创建时间和状态的容错
- 标签从
tags.tag读取,只接收非空tag_key;空值允许保存为空字符串; - 适配器返回标签元组,同步服务只更新来源为 provider 的标签,本地 manual 标签不会被云同步删除;
- 创建时间为空时返回 None;非空但类型或格式非法时抛出
ALIYUN_INVALID_TIME,把快照标记为不完整,避免静默吞掉损坏数据; - 状态表显式接受
Pending、Running、Stopped、Starting和Stopping;其中 Pending 与 Starting 都归一为 starting,未知状态仍为 unknown; - 字段级容错不等于资源身份可以容错:空地域 ID、空可用区 ID、空实例 ID 仍由同步快照校验拒绝。
30.6 映射层的非目标与扩展点
映射层不发数据库查询、不管理事务、不决定实例缺失、不记录变化历史,也不把 SDK 原对象传出 Provider 边界。以后新增字段时,应先扩展统一数据类和模型,再扩展阿里云映射与合同测试,最后修改同步写入;不能只在模板里临时读取 SDK 字段。
常见错误:直接用实例名称作为唯一身份、把 EIP 当成唯一公网地址、忽略 VPC 私网字段、把未知状态默认为 running,或者让映射函数直接 save()。这些做法会破坏幂等、漏资产或绕过快照校验。
31 分页、部分失败与错误净化
31.1 地域选择与白名单校验顺序
- 先调用地域接口取得当前账号可见的全部地域;
- 把账号的
region_allowlist转成集合;空列表表示全部地域; - 计算“白名单减去厂商返回地域”的差集;只要差集非空,立即抛出
ALIYUN_UNKNOWN_REGION; - 白名单有效后才筛选地域并进入逐地域可用区和实例发现;
- 快照的 scope 明确写成 all 或 allowlist,使同步服务知道“本次没有发现”究竟覆盖哪些地域。
先验证白名单再抓取实例,避免拼写错误的地域被静默忽略。静默忽略会制造“同步成功但实际没有覆盖预期地域”的假象。
31.2 实例分页终止条件
每个地域从页码 1 开始,固定页大小为 100。第 1 页建立本轮期望的 total_count,后续页面必须返回同一个总数。适配器累计本地域已经拿到的数量,并执行下面的完整性检查:
total_count不能为负数;- 后续页面的
total_count不能在请求期间变化; - 如果返回了实例,期望总数不能是 0;
- 累计实例数不能超过期望总数;
- 只有累计实例数恰好等于期望总数,当前地域分页才算完整结束;
- 累计数尚未达到期望总数时出现空页,返回
ALIYUN_INCOMPLETE_PAGINATION,不能把它当成完整成功。
不一致的负数、总数变化、零总数却有数据或累计数超过总数,统一返回 ALIYUN_INCONSISTENT_PAGINATION。合同测试明确验证杭州地域会请求第 2 页,调用参数为 (cn-hangzhou, 2, 100),并验证提前空页会形成部分快照。
本版没有基于游标的恢复点,也没有把某一页持久化到独立 staging 表。请求中断后,下次同步从地域列表和第 1 页重新开始,这是 v1 的有意简化。
31.3 完整快照、部分快照和失败的区别
| 情形 | 适配器结果 | 同步执行状态 | 是否允许标记既有实例缺失 |
|---|---|---|---|
| 地域接口在任何数据产生前失败 | 抛出净化后的 ProviderError | failed | 否 |
| 某地域的可用区、实例请求或分页完整性检查失败,已经有地域或资产数据 | 返回 complete=False 的 DiscoverySnapshot | partial | 否 |
| 所有选中地域均达到稳定且精确的 total_count | 返回 complete=True 的 DiscoverySnapshot | succeeded | 是,但只在声明的 scope 内 |
| 白名单包含未知地域 | 抛出 ALIYUN_UNKNOWN_REGION | failed | 否 |
部分快照仍可写入已经发现的地域、可用区和实例,但同步服务不会把未出现的旧实例标记为 missing,也不会更新账号的最后成功同步时间。这一规则优先避免误删语义:网络超时不能被解释成云端实例已经消失。
白名单快照即使部分失败,也会携带完整配置范围;因为 complete=False,同步校验不会要求本次数据已经覆盖每一个白名单地域。只有完整快照才必须覆盖声明范围。
31.4 错误净化规则
| 底层信号 | 公开错误代码 | 公开消息 |
|---|---|---|
| HTTP 403、代码含 forbidden 或 accessdenied | ALIYUN_ACCESS_DENIED | 阿里云拒绝了只读资产请求,请检查 RAM 权限。 |
| 错误代码含 throttl 或 HTTP 429 | ALIYUN_THROTTLED | 阿里云接口触发限流,请稍后重试。 |
| ConnectionError 或 TimeoutError | ALIYUN_NETWORK_ERROR | 连接阿里云接口失败,请检查网络。 |
| 累计未达 total_count 却提前空页 | ALIYUN_INCOMPLETE_PAGINATION | 阿里云实例分页提前结束,当前快照不完整。 |
| total_count 为负、变化或与返回数量矛盾 | ALIYUN_INCONSISTENT_PAGINATION | 返回固定的分页不一致消息,当前快照不完整。 |
| 其他异常 | ALIYUN_REQUEST_FAILED | 阿里云资产发现失败,请查看服务日志。 |
合同测试故意让第二页抛出包含内部细节的 TimeoutError,再断言公开错误消息和服务日志都不出现 timeout 原文。日志只记录映射后错误码、异常类型、受控状态码、账号内部 ID 和同步 ID,不记录 str(exc)、AccessKey Secret、Security Token、请求载荷或签名。
31.5 SDK 响应结构与空集合保护
地域、可用区和实例集合不是可选展示字段,而是决定快照是否权威的结构节点。_required_nested_list() 要求节点真实存在且类型为 list/tuple;_required_int() 要求 total_count 是非布尔整数。缺失、None、字符串总数或错误容器统一返回 ALIYUN_INVALID_RESPONSE。
- 地域接口返回空集合时拒绝整个发现,不能把它解释为“账号没有任何地域”。
- 已选地域返回空可用区集合时形成失败或部分快照,绝不进入完整 missing 对账。
- 实例页显式返回
instances=[]且total_count=0是合法空页。 - 损坏实例响应不能把现有实例标记为 missing。
标签、IP 等真正可选的嵌套字段仍由宽容的 _nested_list() 读取。必须区分“可选业务字段缺失”和“权威集合结构缺失”,否则 SDK 反序列化变化可能被误判成完整空快照。
31.6 管理命令形式的真实 smoke test
只有在 SDK 已安装、账号记录存在、凭据由安全渠道注入、RAM 只读权限已经批准后,才运行下面命令:
执行目录:devopsX/
python manage.py sync_cloud_account aliyun-demo --provider aliyun
成功时预期:输出状态、发现、新建、更新、未变化、缺失和恢复数量。失败时预期:命令以受控错误代码和净化消息结束,不回显 Secret。
当前事实:没有可用的真实只读阿里云凭据,因此这条真实账号 smoke test 没有执行。mock/contract 测试通过不能替代这一步。
31.7 常见错误定位
| 现象 | 先检查什么 | 不要怎么处理 |
|---|---|---|
ALIYUN_SDK_NOT_INSTALLED | 是否在当前 Python 环境安装 devopsX/requirements-aliyun.txt。 | 不要捕获后返回空快照。 |
MISSING_CREDENTIAL_PROFILE | 账号记录是否填写合法前缀。 | 不要把 Secret 填进前缀字段。 |
ALIYUN_CREDENTIALS_NOT_FOUND | 运行 Django 的同一进程是否收到 ID 和 Secret 两个变量。 | 不要把真实值打印到终端截图或日志。 |
ALIYUN_ACCESS_DENIED | RAM 身份、策略、资源范围和临时令牌是否匹配。 | 不要直接授予管理写权限来绕过。 |
ALIYUN_UNKNOWN_REGION | 白名单拼写及账号可见地域。 | 不要静默删除报错地域后声称全量成功。 |
ALIYUN_THROTTLED | 调用频率、账号配额和并发同步。 | 不要把部分数据标记成完整快照。 |
ALIYUN_INCOMPLETE_PAGINATION | 厂商是否在累计数达到 total_count 前返回空页。 | 不要用空页直接结束并声称完整。 |
ALIYUN_INCONSISTENT_PAGINATION | 分页期间总数是否变化,返回数量是否与总数矛盾。 | 不要忽略矛盾数据继续做缺失判定。 |
ALIYUN_INVALID_RESPONSE | 地域、可用区、实例集合或 total_count 是否缺失、为空或类型错误。 | 不要把损坏结构降级成权威空快照。 |
ALIYUN_INVALID_TIME | 非空 creation_time 的类型和 ISO 8601 格式。 | 不要静默改成 None 后继续完整对账。 |
ALIYUN_NETWORK_ERROR | DNS、HTTPS 出口、代理、证书和超时。 | 不要把网络失败解释为资产已删除。 |
32 阿里云适配器 mock/contract 测试
32.1 为什么合同测试不访问真实云
合同测试验证“适配器如何调用客户端、如何映射字段、如何处理分页、白名单和异常”。它通过依赖注入传入 FakeAliyunClient,不会发网络请求,也不要求 SDK 包或真实凭据。这样测试稳定、快速,并且可以精确制造第二页超时。
真实账号 smoke test验证的是另一层:SDK 安装、RAM、endpoint、DNS、TLS、出口、配额和真实响应结构。两类测试互补,不能互相冒充。
32.2 测试文件完整内容
相对路径:devopsX/cmdb/tests/test_aliyun_provider.py
import os
from types import SimpleNamespace
from unittest.mock import patch
from django.test import SimpleTestCase, TestCase
from cmdb.models import CloudAccount, CloudProvider, ComputeInstance, SyncRun
from cmdb.providers.aliyun import AliyunEcsProvider, AliyunSdkClient, map_instance
from cmdb.providers.base import ProviderError
from cmdb.services.sync import sync_account
class FakeAliyunClient:
def __init__(self, fail_second_page=False, truncate_second_page=False):
self.fail_second_page = fail_second_page
self.truncate_second_page = truncate_second_page
self.instance_calls = []
def describe_regions(self):
return [
{
"region_id": "cn-hangzhou",
"local_name": "杭州",
"region_endpoint": "ecs.cn-hangzhou.aliyuncs.com",
},
{
"region_id": "cn-shanghai",
"local_name": "上海",
"region_endpoint": "ecs.cn-shanghai.aliyuncs.com",
},
]
def describe_zones(self, region_id):
return [
{
"zone_id": "%s-a" % region_id,
"local_name": "%s 可用区 A" % region_id,
}
]
def describe_instances_page(self, region_id, page_number, page_size):
self.instance_calls.append((region_id, page_number, page_size))
if self.fail_second_page and region_id == "cn-hangzhou" and page_number == 2:
raise TimeoutError("timeout containing details that must not be shown")
if self.truncate_second_page and region_id == "cn-hangzhou" and page_number == 2:
return [], 2
if region_id == "cn-hangzhou":
if page_number == 1:
return [self._instance("i-hz-001", region_id, "Running")], 2
if page_number == 2:
return [self._instance("i-hz-002", region_id, "Stopped")], 2
if region_id == "cn-shanghai" and page_number == 1:
return [self._instance("i-sh-001", region_id, "Running")], 1
return [], 0
def _instance(self, instance_id, region_id, status):
return {
"instance_id": instance_id,
"instance_name": "%s-name" % instance_id,
"zone_id": "%s-a" % region_id,
"instance_type": "ecs.g7.large",
"cpu": 2,
"memory": 8192,
"osname": "Alibaba Cloud Linux 3",
"status": status,
"creation_time": "2026-01-10T08:00:00Z",
"inner_ip_address": {"ip_address": ["10.0.0.10"]},
"public_ip_address": {"ip_address": ["203.0.113.20"]},
"tags": {"tag": [{"tag_key": "environment", "tag_value": "demo"}]},
}
class AliyunSdkResponseTests(SimpleTestCase):
def _client(self, response):
transport = SimpleNamespace(
describe_regions_with_options=lambda request, runtime: response,
describe_zones_with_options=lambda request, runtime: response,
describe_instances_with_options=lambda request, runtime: response,
)
client = AliyunSdkClient.__new__(AliyunSdkClient)
client.ecs_models = SimpleNamespace(
DescribeRegionsRequest=lambda **kwargs: kwargs,
DescribeZonesRequest=lambda **kwargs: kwargs,
DescribeInstancesRequest=lambda **kwargs: kwargs,
)
client.runtime = object()
client._client = lambda endpoint: transport
return client
def test_describe_regions_rejects_missing_collection_node(self):
client = self._client(SimpleNamespace(body={}))
with self.assertRaises(ProviderError) as context:
client.describe_regions()
self.assertEqual(context.exception.code, "ALIYUN_INVALID_RESPONSE")
def test_describe_zones_rejects_missing_collection_node(self):
client = self._client(SimpleNamespace(body={"zones": {}}))
with self.assertRaises(ProviderError) as context:
client.describe_zones("cn-hangzhou")
self.assertEqual(context.exception.code, "ALIYUN_INVALID_RESPONSE")
def test_describe_instances_rejects_missing_list_or_total(self):
responses = (
SimpleNamespace(body={"total_count": 0}),
SimpleNamespace(body={"instances": {"instance": []}}),
SimpleNamespace(
body={
"instances": {"instance": []},
"total_count": "0",
}
),
)
for response in responses:
with self.subTest(body=response.body):
client = self._client(response)
with self.assertRaises(ProviderError) as context:
client.describe_instances_page("cn-hangzhou", 1, 100)
self.assertEqual(context.exception.code, "ALIYUN_INVALID_RESPONSE")
def test_describe_instances_accepts_explicit_empty_page(self):
client = self._client(
SimpleNamespace(
body={
"instances": {"instance": []},
"total_count": 0,
}
)
)
instances, total_count = client.describe_instances_page(
"cn-hangzhou",
1,
100,
)
self.assertEqual(instances, [])
self.assertEqual(total_count, 0)
class AliyunMapperTests(SimpleTestCase):
def test_instance_mapper_normalizes_fields(self):
raw = FakeAliyunClient()._instance("i-demo", "cn-hangzhou", "Running")
instance = map_instance(raw, "cn-hangzhou")
self.assertEqual(instance.provider_resource_id, "i-demo")
self.assertEqual(instance.normalized_status, "running")
self.assertEqual(instance.private_ips, ("10.0.0.10",))
self.assertEqual(instance.public_ips, ("203.0.113.20",))
self.assertEqual(instance.tags[0].key, "environment")
self.assertIsNotNone(instance.cloud_created_at)
def test_instance_mapper_maps_pending_to_starting(self):
raw = FakeAliyunClient()._instance("i-pending", "cn-hangzhou", "Pending")
instance = map_instance(raw, "cn-hangzhou")
self.assertEqual(instance.normalized_status, "starting")
def test_instance_mapper_rejects_invalid_nonempty_creation_time(self):
raw = FakeAliyunClient()._instance("i-invalid-time", "cn-hangzhou", "Running")
raw["creation_time"] = "not-a-cloud-time"
with self.assertRaises(ProviderError) as context:
map_instance(raw, "cn-hangzhou")
self.assertEqual(context.exception.code, "ALIYUN_INVALID_TIME")
def test_instance_mapper_reads_vpc_private_addresses(self):
raw = FakeAliyunClient()._instance("i-vpc", "cn-hangzhou", "Running")
raw["inner_ip_address"] = {"ip_address": []}
raw["vpc_attributes"] = {
"private_ip_address": {"ip_address": ["10.0.1.20"]}
}
instance = map_instance(raw, "cn-hangzhou")
self.assertEqual(instance.private_ips, ("10.0.1.20",))
def test_instance_mapper_reads_public_and_eip_addresses(self):
raw = FakeAliyunClient()._instance("i-eip", "cn-hangzhou", "Running")
raw["eip_address"] = {"ip_address": "198.51.100.25"}
instance = map_instance(raw, "cn-hangzhou")
self.assertEqual(
instance.public_ips,
("203.0.113.20", "198.51.100.25"),
)
class AliyunProviderTests(TestCase):
def setUp(self):
provider = CloudProvider.objects.create(code="aliyun", name="阿里云")
self.account = CloudAccount.objects.create(
provider=provider,
account_key="aliyun-demo",
name="阿里云演示账号",
credential_profile="ALIYUN_DEMO",
)
self.environment = {
"ALIYUN_DEMO_ACCESS_KEY_ID": "test-id",
"ALIYUN_DEMO_ACCESS_KEY_SECRET": "test-secret",
}
def _provider(self, fake_client):
return AliyunEcsProvider(
client_factory=lambda access_key_id, access_key_secret, security_token: fake_client
)
def test_discover_supports_multiple_regions_and_pages(self):
fake_client = FakeAliyunClient()
with patch.dict(os.environ, self.environment, clear=False):
snapshot = self._provider(fake_client).discover(self.account)
self.assertTrue(snapshot.complete)
self.assertEqual(len(snapshot.regions), 2)
self.assertEqual(len(snapshot.availability_zones), 2)
self.assertEqual(len(snapshot.instances), 3)
self.assertIn(("cn-hangzhou", 2, 100), fake_client.instance_calls)
def test_empty_region_collection_is_rejected(self):
class EmptyRegionClient(FakeAliyunClient):
def describe_regions(self):
return []
with self.assertLogs("cmdb.providers.aliyun", level="WARNING"):
with patch.dict(os.environ, self.environment, clear=False):
with self.assertRaises(ProviderError) as context:
self._provider(EmptyRegionClient()).discover(self.account)
self.assertEqual(context.exception.code, "ALIYUN_INVALID_RESPONSE")
def test_empty_zone_collection_cannot_mark_existing_assets_missing(self):
class EmptyZoneClient(FakeAliyunClient):
def describe_zones(self, region_id):
return []
with patch.dict(os.environ, self.environment, clear=False):
first_run = sync_account(
self.account,
adapter=self._provider(FakeAliyunClient()),
)
second_run = sync_account(
self.account,
adapter=self._provider(EmptyZoneClient()),
)
self.assertEqual(first_run.status, SyncRun.Status.SUCCEEDED)
self.assertEqual(second_run.status, SyncRun.Status.PARTIAL)
self.assertEqual(second_run.error_code, "ALIYUN_INVALID_RESPONSE")
self.assertFalse(
ComputeInstance.objects.filter(
account=self.account,
lifecycle_state=ComputeInstance.LifecycleState.MISSING,
).exists()
)
def test_region_allowlist_limits_discovery(self):
self.account.region_allowlist = ["cn-shanghai"]
self.account.save(update_fields=["region_allowlist"])
fake_client = FakeAliyunClient()
with patch.dict(os.environ, self.environment, clear=False):
snapshot = self._provider(fake_client).discover(self.account)
self.assertEqual([region.provider_resource_id for region in snapshot.regions], ["cn-shanghai"])
self.assertEqual(len(snapshot.instances), 1)
self.assertFalse(
any(call[0] == "cn-hangzhou" for call in fake_client.instance_calls)
)
def test_second_page_failure_returns_partial_snapshot(self):
fake_client = FakeAliyunClient(fail_second_page=True)
with self.assertLogs("cmdb.providers.aliyun", level="WARNING") as captured:
with patch.dict(os.environ, self.environment, clear=False):
snapshot = self._provider(fake_client).discover(self.account)
logs = "\n".join(captured.output)
self.assertFalse(snapshot.complete)
self.assertEqual(snapshot.error_code, "ALIYUN_NETWORK_ERROR")
self.assertEqual(len(snapshot.instances), 1)
self.assertNotIn("timeout", snapshot.error_message.lower())
self.assertIn("ALIYUN_NETWORK_ERROR", logs)
self.assertNotIn("timeout containing details", logs)
def test_invalid_instance_response_cannot_mark_existing_assets_missing(self):
class InvalidInstanceResponseClient(FakeAliyunClient):
def describe_instances_page(self, region_id, page_number, page_size):
raise ProviderError(
"ALIYUN_INVALID_RESPONSE",
"阿里云接口返回了不完整的响应结构,当前快照不完整。",
)
with patch.dict(os.environ, self.environment, clear=False):
first_run = sync_account(
self.account,
adapter=self._provider(FakeAliyunClient()),
)
second_run = sync_account(
self.account,
adapter=self._provider(InvalidInstanceResponseClient()),
)
self.assertEqual(first_run.status, SyncRun.Status.SUCCEEDED)
self.assertEqual(second_run.status, SyncRun.Status.PARTIAL)
self.assertEqual(second_run.error_code, "ALIYUN_INVALID_RESPONSE")
self.assertEqual(ComputeInstance.objects.filter(account=self.account).count(), 3)
self.assertFalse(
ComputeInstance.objects.filter(
account=self.account,
lifecycle_state=ComputeInstance.LifecycleState.MISSING,
).exists()
)
def test_premature_empty_page_returns_partial_snapshot(self):
fake_client = FakeAliyunClient(truncate_second_page=True)
with patch.dict(os.environ, self.environment, clear=False):
snapshot = self._provider(fake_client).discover(self.account)
self.assertFalse(snapshot.complete)
self.assertEqual(snapshot.error_code, "ALIYUN_INCOMPLETE_PAGINATION")
self.assertEqual(len(snapshot.instances), 1)
def test_unknown_allowlist_region_is_rejected(self):
self.account.region_allowlist = ["cn-does-not-exist"]
self.account.save(update_fields=["region_allowlist"])
with patch.dict(os.environ, self.environment, clear=False):
with self.assertRaisesMessage(ProviderError, "地域白名单"):
self._provider(FakeAliyunClient()).discover(self.account)
def test_missing_environment_credentials_are_reported_without_secret(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(ProviderError) as context:
self._provider(FakeAliyunClient()).discover(self.account)
self.assertEqual(context.exception.code, "ALIYUN_CREDENTIALS_NOT_FOUND")
self.assertNotIn("ACCESS_KEY_SECRET", context.exception.message)
32.3 测试文件逐组解释
AliyunSdkResponseTests直接构造 Tea SDK 风格响应,验证地域、可用区、实例集合和total_count缺失或类型错误时必须返回ALIYUN_INVALID_RESPONSE;显式空列表加整数 0 仍是合法空页。AliyunMapperTests验证基础字段、VPC 私网地址、普通公网地址、EIP、Pending -> starting以及非空非法时间拒绝。AliyunProviderTests验证多地域多分页、空地域拒绝、空可用区和损坏实例响应不能误标 missing、白名单、部分快照、分页提前结束、未知地域与凭据缺失。- 日志断言只允许稳定错误码、异常类型和状态码,不允许底层 timeout 细节、请求载荷或凭据进入日志。
32.4 18 项合同方法完整清单
devopsX/cmdb/tests/test_aliyun_provider.py:18 项
AliyunSdkResponseTests(4 项)
test_describe_regions_rejects_missing_collection_nodetest_describe_zones_rejects_missing_collection_nodetest_describe_instances_rejects_missing_list_or_totaltest_describe_instances_accepts_explicit_empty_page
AliyunMapperTests(5 项)
test_instance_mapper_normalizes_fieldstest_instance_mapper_maps_pending_to_startingtest_instance_mapper_rejects_invalid_nonempty_creation_timetest_instance_mapper_reads_vpc_private_addressestest_instance_mapper_reads_public_and_eip_addresses
AliyunProviderTests(9 项)
test_discover_supports_multiple_regions_and_pagestest_empty_region_collection_is_rejectedtest_empty_zone_collection_cannot_mark_existing_assets_missingtest_region_allowlist_limits_discoverytest_second_page_failure_returns_partial_snapshottest_invalid_instance_response_cannot_mark_existing_assets_missingtest_premature_empty_page_returns_partial_snapshottest_unknown_allowlist_region_is_rejectedtest_missing_environment_credentials_are_reported_without_secret
32.5 执行合同测试
执行目录:devopsX/
python manage.py test cmdb.tests.test_aliyun_provider
当前实际结果:Found 18 test(s).,Ran 18 tests,OK,并且 System check identified no issues (0 silenced).
这项结果证明映射、Tea SDK 响应结构和适配器合同在本地边界内通过;SDK 包已经按锁定版本安装并核对实际模型字段,但没有真实只读凭据,因此不证明真实账号、RAM、网络和配额已经联通。
32.6 测试失败时的排查顺序
- 确认从外层
devopsX/执行,而不是从devopsX/cmdb/执行; - 确认基础依赖版本与
devopsX/requirements.txt一致; - 如果分页断言失败,检查 page_size、total_count 累计和终止条件;
- 如果白名单断言失败,检查是否先验证地域集合再进行筛选;
- 如果错误消息泄漏底层异常,检查是否绕过
_safe_provider_error(); - 如果测试意外访问网络,检查客户端工厂是否仍通过依赖注入传入假客户端。
33 严格生产设置、数据库边界与静态文件检查
33.1 当前设置文件完整内容
相对路径:devopsX/devopsX/settings.py
import os
import secrets
from pathlib import Path
from django.core.exceptions import ImproperlyConfigured
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv(BASE_DIR / ".env")
def _env_bool(name, default):
value = os.environ.get(name)
if value is None:
return default
normalized = value.strip().lower()
if normalized not in {"true", "false"}:
raise ImproperlyConfigured("%s 必须是 true 或 false。" % name)
return normalized == "true"
def _required_env(name):
value = os.environ.get(name, "").strip()
if not value:
raise ImproperlyConfigured("必须配置 %s。" % name)
return value
def _env_nonnegative_int(name, default):
value = os.environ.get(name, str(default))
try:
parsed = int(value)
except ValueError as exc:
raise ImproperlyConfigured("%s 必须是非负整数。" % name) from exc
if parsed < 0:
raise ImproperlyConfigured("%s 必须是非负整数。" % name)
return parsed
DEBUG = _env_bool("DEVOPSX_DEBUG", False)
SECRET_KEY = os.environ.get("DEVOPSX_SECRET_KEY", "")
if not SECRET_KEY:
if DEBUG:
SECRET_KEY = secrets.token_urlsafe(50)
else:
raise ImproperlyConfigured(
"生产模式必须配置 DEVOPSX_SECRET_KEY。"
)
allowed_hosts_value = os.environ.get("DEVOPSX_ALLOWED_HOSTS", "")
if allowed_hosts_value:
ALLOWED_HOSTS = [
host.strip() for host in allowed_hosts_value.split(",") if host.strip()
]
if "*" in ALLOWED_HOSTS:
raise ImproperlyConfigured(
"DEVOPSX_ALLOWED_HOSTS 不能使用通配符 *。"
)
elif DEBUG:
ALLOWED_HOSTS = ["127.0.0.1", "localhost", "testserver"]
else:
raise ImproperlyConfigured(
"生产模式必须配置 DEVOPSX_ALLOWED_HOSTS。"
)
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"accounts.apps.AccountsConfig",
"cmdb.apps.CmdbConfig",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "devopsX.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [BASE_DIR / "templates"],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "devopsX.wsgi.application"
database_engine_value = os.environ.get("DEVOPSX_DB_ENGINE")
if database_engine_value is None:
if DEBUG:
database_engine = "sqlite"
else:
raise ImproperlyConfigured(
"生产模式必须配置 DEVOPSX_DB_ENGINE。"
)
else:
database_engine = database_engine_value.strip().lower()
if database_engine == "mysql":
database_port = _required_env("DEVOPSX_DB_PORT")
if not database_port.isdecimal() or not 1 <= int(database_port) <= 65535:
raise ImproperlyConfigured(
"DEVOPSX_DB_PORT 必须是 1 到 65535 之间的整数。"
)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.mysql",
"NAME": _required_env("DEVOPSX_DB_NAME"),
"USER": _required_env("DEVOPSX_DB_USER"),
"PASSWORD": _required_env("DEVOPSX_DB_PASSWORD"),
"HOST": _required_env("DEVOPSX_DB_HOST"),
"PORT": database_port,
"OPTIONS": {
"charset": "utf8mb4",
"init_command": "SET sql_mode='STRICT_TRANS_TABLES'",
},
}
}
elif database_engine == "sqlite":
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
"OPTIONS": {"timeout": 20},
}
}
else:
raise ImproperlyConfigured(
"DEVOPSX_DB_ENGINE 只能是 sqlite 或 mysql。"
)
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
LANGUAGE_CODE = "zh-hans"
TIME_ZONE = "Asia/Shanghai"
USE_I18N = True
USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
if _env_bool("DEVOPSX_TRUST_X_FORWARDED_PROTO", False):
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = _env_bool(
"DEVOPSX_SECURE_SSL_REDIRECT",
not DEBUG,
)
SESSION_COOKIE_SECURE = _env_bool(
"DEVOPSX_SESSION_COOKIE_SECURE",
not DEBUG,
)
CSRF_COOKIE_SECURE = _env_bool(
"DEVOPSX_CSRF_COOKIE_SECURE",
not DEBUG,
)
SECURE_HSTS_SECONDS = _env_nonnegative_int(
"DEVOPSX_SECURE_HSTS_SECONDS",
0 if DEBUG else 31536000,
)
SECURE_HSTS_INCLUDE_SUBDOMAINS = not DEBUG
SECURE_HSTS_PRELOAD = not DEBUG
if not DEBUG and (
not SECURE_SSL_REDIRECT
or not SESSION_COOKIE_SECURE
or not CSRF_COOKIE_SECURE
or SECURE_HSTS_SECONDS == 0
):
raise ImproperlyConfigured(
"生产模式必须启用 HTTPS 重定向、安全 Cookie 和 HSTS。"
)
csrf_trusted_origins_value = os.environ.get("DEVOPSX_CSRF_TRUSTED_ORIGINS", "")
CSRF_TRUSTED_ORIGINS = [
origin.strip()
for origin in csrf_trusted_origins_value.split(",")
if origin.strip()
]
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
AUTH_USER_MODEL = "accounts.User"
LOGIN_URL = "accounts:login"
LOGIN_REDIRECT_URL = "accounts:home"
33.2 设置文件逐组解释
| 行范围 | 职责 | 失败关闭行为 |
|---|---|---|
| 1~11 | 基础导入与 .env 加载 | 只从项目根加载 devopsX/.env;生产应由部署系统注入或提供受控文件。 |
| 14~28 | 布尔值与必填变量 | 布尔值只接受 true/false;_required_env() 对空值立即拒绝。 |
| 31~39 | 非负整数 | HSTS 秒数必须可解析且不能为负数。 |
| 42~50 | DEBUG 与 SECRET_KEY | 生产模式没有 Secret Key 立即拒绝启动;调试模式缺失时生成进程级随机值。 |
| 52~66 | ALLOWED_HOSTS | 生产必须显式配置,任何模式都拒绝通配符 *。 |
| 68~107 | Django 应用、中间件、模板和 WSGI | 启用 CSRF、认证、会话、安全和点击劫持中间件。 |
| 108~150 | 数据库 | 生产必须显式选择引擎;MySQL 名称、用户、密码、主机、端口全部必填,端口必须在 1~65535。 |
| 152~165 | 密码验证器 | 启用相似度、最小长度、常见密码和纯数字密码验证。 |
| 167~173 | 语言、时区和静态文件 | 静态文件收集到 devopsX/staticfiles/。 |
| 175~201 | HTTPS、Cookie 与 HSTS | 生产中任一关键开关关闭或 HSTS 为 0,立即拒绝启动。 |
| 203~208 | CSRF 可信来源 | 按逗号拆分;需要跨源 HTTPS 表单时必须显式配置。 |
| 210~213 | 用户模型与登录跳转 | 使用 accounts.User,并把登录后默认入口设为 CMDB 首页。 |
33.3 生产模式的 fail-closed 边界
| 配置问题 | 启动结果 |
|---|---|
DEVOPSX_SECRET_KEY 为空 | 抛出 ImproperlyConfigured,拒绝启动。 |
DEVOPSX_ALLOWED_HOSTS 为空 | 抛出 ImproperlyConfigured,拒绝启动。 |
允许主机包含 * | 抛出 ImproperlyConfigured,拒绝启动。 |
DEVOPSX_DB_ENGINE 未配置 | 生产模式拒绝启动。 |
| 数据库引擎不是 sqlite 或 mysql | 拒绝启动。 |
选择 mysql 但任一 DEVOPSX_DB_* 为空 | 拒绝启动,不再回退到 root、空密码或默认库名。 |
| MySQL 端口不是 1~65535 的十进制整数 | 拒绝启动。 |
| HTTPS 重定向关闭 | 生产模式拒绝启动。 |
| Session Cookie 或 CSRF Cookie 不安全 | 生产模式拒绝启动。 |
| HSTS 秒数为 0 | 生产模式拒绝启动。 |
| 布尔变量拼写不是 true/false | 拒绝启动,不按“非空即真”猜测。 |
当前设置已经对 MySQL 名称、用户、密码、主机和端口全部执行必填校验,并启用 Django 内置的 4 类密码验证器。严格边界仍不等于所有生产问题都被自动解决:设置文件没有配置反向代理头,真实入口若在 TLS 终止代理之后,必须根据已经确认的代理链路补充并验证;日志脱敏、备份恢复、密钥轮换、监控告警和组织级密码参数也仍属于部署验收。不能把 check --deploy 通过解释成所有外部安全控制已经完成。
33.4 SQLite 与 MySQL 的配置差异
相对路径:devopsX/requirements-mysql.txt
-r requirements.txt
mysqlclient==2.2.7
- 第 1 行复用基础依赖;
- 第 2 行固定
mysqlclient==2.2.7,它是 Django MySQL 后端使用的 Python 驱动。
| 项目 | SQLite | MySQL |
|---|---|---|
| 引擎值 | sqlite | mysql |
| 数据库位置 | devopsX/db.sqlite3 | DB_NAME、DB_USER、DB_PASSWORD、DB_HOST、DB_PORT 全部必须显式配置。 |
| 字符集 | SQLite 默认行为 | utf8mb4。 |
| 严格 SQL 模式 | 不适用 | STRICT_TRANS_TABLES。 |
| 并发锁语义 | 不等同于生产行锁 | 可验证 select_for_update() 等真实事务行为。 |
| 本教程定位 | 本地教学与完整自动化回归 | 生产目标配置,但当前未完成认证验证。 |
33.5 MySQL 当前证据必须怎样表述
当前可见环境为 Python 3.10.8、Django 5.2.17。MySQL 客户端版本和服务器握手版本均可见为 8.0.46,TCP
127.0.0.1:3306可达;但是 root 无密码登录被拒绝,并且没有配置任何DEVOPSX_DB_*环境变量。因此,当前没有执行经过认证的 MySQL migration,也没有执行 MySQL 测试。
| 检查项 | 当前结果 | 能证明什么 | 不能证明什么 |
|---|---|---|---|
| MySQL 客户端版本 | 8.0.46 可见 | 客户端程序存在。 | 不能证明 Django 能认证。 |
| MySQL 服务器握手 | 8.0.46 可见 | 目标端口返回 MySQL 握手。 | 不能证明目标库和账号权限正确。 |
| TCP 连接 | 127.0.0.1:3306 可达 | 网络监听可达。 | 不能证明 SQL 可执行。 |
| root 无密码登录 | ERROR 1045,using password: NO | 服务器拒绝无密码认证。 | 不能据此猜测正确密码。 |
DEVOPSX_DB_* | 未配置 | 当前 Django 没有可用 MySQL连接参数。 | 不能运行认证迁移和测试。 |
| MySQL migration/tests | 未运行 | 证据边界清楚。 | 不能声称 MySQL 已验收。 |
获得经批准的数据库名、最小权限用户和密码后,按顺序执行下面 3 条命令。每条命令都必须在同一组 MySQL 环境变量下运行。
执行目录:devopsX/
python manage.py migrate --plan
预期结果:显示将要执行的迁移计划;当前未在认证 MySQL 上运行。
执行目录:devopsX/
python manage.py migrate
预期结果:全部迁移应用成功;当前未在认证 MySQL 上运行。
执行目录:devopsX/
python manage.py test
预期结果:测试数据库可创建、105 项测试通过并销毁测试库;当前只在 SQLite 完整执行,未在认证 MySQL 上执行。
33.6 生产风格部署检查
先由部署系统显式提供生产 Secret Key、Allowed Hosts、数据库引擎、HTTPS Cookie、HSTS 和 CSRF 来源,再执行:
执行目录:devopsX/
python manage.py check --deploy
当前实际结果:在显式安全设置、DEBUG=false 和显式 SQLite 引擎的生产风格检查环境中,输出 System check identified no issues (0 silenced).
这里选择 SQLite 只用于检查设置和 Django 部署安全项,不是生产数据库并发验收。MySQL 认证失败边界仍按上一节如实保留。
33.7 静态文件收集检查
先用 dry-run 核对来源、目标和冲突,不写入部署目录:
执行目录:devopsX/
python manage.py collectstatic --dry-run --noinput --verbosity 1
当前实际结果:dry-run 枚举 128 个静态文件目标,其中包含 Django Admin 静态资源和 devopsX/cmdb/static/cmdb/style.css,没有报告缺失或冲突。命令文本最后使用“copied”汇总是 collectstatic 的输出格式;因为启用了 --dry-run,当前验证没有实际写入 devopsX/staticfiles/。
正式部署需要实际收集时运行:
执行目录:devopsX/
python manage.py collectstatic --noinput
预期结果:静态资源写入 devopsX/staticfiles/,随后由 Web 服务器或静态资源服务提供。当前文章证据记录的是 dry-run,不能把它改写成已完成生产静态发布。
常见错误:生产使用 SQLite、遗漏实际 collectstatic、把 devopsX/staticfiles/ 放进源码 ZIP、用 * 绕过 Allowed Hosts,或为了通过检查而关闭 HSTS。正确做法是补齐环境条件,而不是削弱 fail-closed 设置。
34 完整自动化测试与浏览器验收矩阵
34.1 当前验证环境与结论
| 测试文件 | 数量 | 主要覆盖 |
|---|---|---|
devopsX/accounts/tests.py | 5 | 公开首页、登录、站内 next、外部 next 拒绝和 403 回退。 |
devopsX/cmdb/tests/test_aliyun_provider.py | 18 | SDK 响应结构、字段映射、分页、地域白名单、空地域/空可用区保护、部分失败与日志脱敏。 |
devopsX/cmdb/tests/test_api.py | 9 | 账号与实例 API、账号分页、同步状态码、JSON 边界和上游完整性错误映射。 |
devopsX/cmdb/tests/test_csv.py | 13 | 敏感列拒绝、严格表头、签名预览、原子导入、大小限制、BOM 与公式中和。 |
devopsX/cmdb/tests/test_models.py | 8 | 稳定身份、关系一致性、模型验证、标签来源和排序。 |
devopsX/cmdb/tests/test_sync.py | 33 | 幂等、快照校验、缺失/恢复/退役、范围、并发占用、fencing、标签和审计。 |
devopsX/cmdb/tests/test_views.py | 19 | 权限隔离、Admin 只读边界、POST-only、搜索分页、拓扑和模板入口。 |
| 合计 | 105 | 最终 SQLite 完整回归。 |
| 项目 | 当前证据 |
|---|---|
| Python | 3.10.8,64 位 |
| Django / python-dotenv | 5.2.17 / 1.2.3 |
| SQLite 完整测试 | 105 项通过 |
| 随机顺序复核 | 四进程完整通过,最终 seed 2147873966 |
| 迁移漂移 | No changes detected |
| 生产风格检查 | check --deploy 无问题 |
| 静态文件 | 128 个文件实际 collectstatic 成功 |
| 浏览器主要路由与权限 | 通过,无 console/server/failed network 错误 |
| 真实阿里云只读 smoke test | 未运行:无真实只读凭据 |
| 认证 MySQL migration/tests | 未运行:服务可达但无认证信息 |
34.2 运行完整 SQLite 测试套件
执行目录:devopsX/
python manage.py test
当前实际结果:Found 105 test(s).,Ran 105 tests,OK,测试数据库创建和销毁成功,系统检查为 0 silenced。
34.3 105 项测试方法完整清单
下面的文件数量与方法名直接从最终冻结测试源码解析,合计必须等于 105。
devopsX/accounts/tests.py:5 项
AccountViewTests(5 项)
test_home_is_publictest_login_redirects_authorized_user_to_cmdbtest_login_without_cmdb_view_permission_redirects_to_public_hometest_permission_denied_without_cmdb_view_links_to_public_hometest_login_honors_safe_next_and_rejects_external_next
devopsX/cmdb/tests/test_aliyun_provider.py:18 项
AliyunSdkResponseTests(4 项)
test_describe_regions_rejects_missing_collection_nodetest_describe_zones_rejects_missing_collection_nodetest_describe_instances_rejects_missing_list_or_totaltest_describe_instances_accepts_explicit_empty_page
AliyunMapperTests(5 项)
test_instance_mapper_normalizes_fieldstest_instance_mapper_maps_pending_to_startingtest_instance_mapper_rejects_invalid_nonempty_creation_timetest_instance_mapper_reads_vpc_private_addressestest_instance_mapper_reads_public_and_eip_addresses
AliyunProviderTests(9 项)
test_discover_supports_multiple_regions_and_pagestest_empty_region_collection_is_rejectedtest_empty_zone_collection_cannot_mark_existing_assets_missingtest_region_allowlist_limits_discoverytest_second_page_failure_returns_partial_snapshottest_invalid_instance_response_cannot_mark_existing_assets_missingtest_premature_empty_page_returns_partial_snapshottest_unknown_allowlist_region_is_rejectedtest_missing_environment_credentials_are_reported_without_secret
devopsX/cmdb/tests/test_api.py:9 项
CmdbApiTests(9 项)
test_account_list_api_never_returns_secret_valuestest_account_list_api_paginates_resultstest_instance_list_and_detail_apitest_api_sync_requires_post_and_returns_created_runtest_api_partial_sync_returns_207_and_error_detailstest_api_sync_maps_business_and_upstream_errorstest_api_maps_snapshot_integrity_failures_to_bad_gatewaytest_api_rejects_invalid_json_and_page_sizetest_api_rejects_non_object_json_and_non_string_scenario
devopsX/cmdb/tests/test_csv.py:13 项
CloudAccountCsvTests(13 项)
test_sensitive_header_is_rejectedtest_preview_then_import_creates_accounttest_csv_validates_credential_profile_and_deduplicates_regionstest_csv_import_is_atomic_when_a_later_row_failstest_csv_update_preserves_omitted_optional_columnstest_csv_update_applies_explicit_optional_columnstest_csv_update_preserves_last_successful_sync_timestamptest_csv_reports_extra_columns_without_server_errortest_csv_rejects_oversized_field_without_server_errortest_csv_reports_duplicate_account_identitytest_csv_rejects_unsupported_blank_and_duplicate_headerstest_instance_export_neutralizes_spreadsheet_formulastest_instance_export_contains_utf8_bom_and_headers
devopsX/cmdb/tests/test_models.py:8 项
CmdbModelTests(8 项)
test_provider_and_account_string_values_are_readabletest_account_identity_is_unique_inside_providertest_same_account_key_can_exist_for_another_providertest_account_validates_credential_profile_and_region_allowlisttest_account_form_applies_region_allowlist_validatortest_zone_and_instance_accounts_must_match_their_relationstest_invalid_relation_ids_remain_validation_errorstest_tag_sources_are_independent
devopsX/cmdb/tests/test_sync.py:33 项
SyncAccountTests(33 项)
test_first_sync_creates_two_instances_and_changestest_identical_snapshot_is_idempotenttest_changed_provider_field_creates_one_updatetest_complete_empty_snapshot_marks_instances_missingtest_repeated_empty_snapshot_does_not_duplicate_missing_changestest_restored_change_contains_lifecycle_fieldstest_instance_zone_must_belong_to_instance_regiontest_missing_instances_are_restoredtest_partial_snapshot_never_marks_existing_instances_missingtest_provider_failure_records_failed_run_without_changing_assetstest_unexpected_provider_failure_is_logged_and_sanitizedtest_duplicate_region_is_rejected_before_writing_snapshottest_provider_sync_does_not_delete_manual_tagstest_provider_tag_only_change_is_audited_as_updatetest_allowlist_scope_does_not_mark_excluded_region_missingtest_unscoped_account_rejects_allowlist_snapshottest_stale_running_sync_is_failed_and_does_not_block_new_synctest_superseded_sync_cannot_apply_its_snapshottest_configuration_change_during_discovery_fences_snapshottest_disabled_provider_prevents_synctest_fake_provider_filters_configured_region_allowlisttest_fake_provider_rejects_unknown_allowlist_regiontest_unknown_fake_scenario_is_rejectedtest_fresh_running_sync_still_blocks_new_synctest_empty_resource_identity_and_invalid_status_are_rejectedtest_partial_allowlist_snapshot_rejects_out_of_scope_regiontest_complete_allowlist_snapshot_must_cover_its_scopetest_bootstrap_preserves_disabled_providertest_sync_command_reports_partial_as_failuretest_sync_command_requires_provider_when_account_key_is_ambiguoustest_retirement_rolls_back_when_audit_creation_failstest_retiring_missing_instance_clears_missing_state_in_audittest_retired_instance_is_not_automatically_restored
devopsX/cmdb/tests/test_views.py:19 项
CmdbViewTests(19 项)
test_anonymous_user_is_redirected_to_logintest_authenticated_user_sees_home_countstest_superuser_can_open_all_read_pagestest_synchronized_models_are_read_only_in_admintest_authenticated_user_without_permission_gets_403test_home_hides_account_create_without_account_view_permissiontest_provider_reader_does_not_see_account_countstest_sync_run_reader_does_not_see_instance_changestest_account_detail_hides_instances_and_sync_runs_without_permissionstest_authorized_get_on_sync_endpoint_gets_405test_sync_permission_without_view_permission_is_rejectedtest_user_with_sync_permission_can_start_synctest_instance_detail_does_not_link_account_without_account_view_permissiontest_instance_filter_searches_name_and_iptest_instance_filter_rejects_malformed_relation_ids_without_500test_partial_sync_displays_warningtest_instance_change_history_is_paginatedtest_topology_shows_instances_without_availability_zonetest_manual_tag_and_retire_are_post_only
34.4 检查迁移是否漂移
执行目录:devopsX/
python manage.py makemigrations --check --dry-run
当前实际结果:No changes detected。这证明当前模型状态已经被现有迁移文件覆盖,不代表迁移已在认证 MySQL 上执行。
34.5 浏览器主要路由矩阵
浏览器验收使用实际渲染页面检查主要导航、数据展示、表单动作和权限隔离。动态主键和 UUID 由当前测试数据产生,表中用“实例主键”和“同步公开 UUID”描述,不伪造固定链接。
| 路由 | 角色或状态 | 预期与当前结果 |
|---|---|---|
/ | 匿名 | 公开首页可打开。 |
/login/ | 匿名 | 登录表单可用;安全 next 被接受,外部 next 被拒绝。 |
/cmdb/ | 有至少一项 CMDB 查看权限 | 首页按权限显示统计和最近同步。 |
/cmdb/providers/ | 云厂商查看权限 | 厂商列表可打开;无账号权限时不泄露账号数量。 |
/cmdb/accounts/ | 云账号查看权限 | 账号列表可打开。 |
/cmdb/accounts/实例主键/ | 云账号查看权限 | 账号详情可打开;实例和同步记录按各自权限隐藏。 |
/cmdb/accounts/import/ | 账号导入权限 | CSV 预览和确认入口可打开。 |
/cmdb/instances/ | 实例查看权限 | 筛选、排序、分页和 IP 关键字搜索可用。 |
/cmdb/instances/实例主键/ | 实例查看权限 | 实例、标签和变化历史可显示。 |
/cmdb/sync-runs/ | 同步记录查看权限 | 同步执行列表可打开。 |
/cmdb/sync-runs/同步公开 UUID/ | 同步记录查看权限 | 详情可打开;无实例权限时隐藏变化实例。 |
/cmdb/topology/ | 实例查看权限 | 厂商、账号、地域、可用区和未分配可用区实例可显示。 |
| 同步、人工标签、退役入口 | 相应写权限 | POST 可执行,GET 返回 405。 |
| 任一受保护页面 | 已登录但无对应权限 | 返回 403,不降级成数据泄露页面。 |
34.6 权限回归矩阵
| 权限边界 | 验证结果 |
|---|---|
| 匿名访问 CMDB 首页 | 跳转到登录页并携带安全 next。 |
| 已登录但没有任何 CMDB 权限 | CMDB 首页和实例列表均返回 403。 |
| 只有云厂商查看权限 | 可看厂商,但看不到云账号数量。 |
| 只有云账号查看权限 | 账号详情不显示实例、同步 UUID 或实例数量。 |
| 只有同步记录查看权限 | 同步详情不显示实例变化和实例 ID。 |
| 只有同步权限 | 可以触发同步,SyncRun 正确记录请求用户。 |
| 写操作方法约束 | 同步、人工标签和退役均为 POST-only。 |
| API 账号列表 | 不返回 password 或 secret 字段。 |
| CSV 输入 | 拒绝常见密钥表头。 |
| CSV 输出 | 中和电子表格公式前缀。 |
34.7 浏览器错误面板结果
当前浏览器主要路由和权限回归通过;验收期间没有发现浏览器控制台错误、服务端错误或失败网络请求。这个结论针对已经走过的主要页面与操作,不表示所有真实云端和真实 MySQL 条件已经覆盖。
34.8 已测试与未测试不能混写
| 结论 | 状态 | 正确表述 |
|---|---|---|
| SQLite 业务回归 | 已测试 | 105 项通过。 |
| 迁移文件一致性 | 已测试 | 无迁移漂移。 |
| 生产风格 Django 检查 | 已测试 | 显式安全设置下无问题。 |
| 静态文件来源检查 | 已测试 | dry-run 枚举 128 个目标,无错误。 |
| 主要页面和权限 | 已测试 | 通过,且无 console/server/network 错误。 |
| 阿里云适配器合同 | 已测试 | 18 项 mock/contract 与 SDK 响应结构测试通过。 |
| 真实阿里云账号 | 未测试 | 无真实只读凭据,smoke test 未运行。 |
| MySQL 认证迁移与测试 | 未测试 | 只有版本、握手和端口证据,认证被拒绝。 |
| 最终发布 ZIP 新鲜解压 | 已测试 | 69 条目、78,816 字节、固定 SHA-256,解压副本 105 项通过。 |
35 最终源码清单、ZIP、SHA-256 与新鲜解压验证
35.1 包含与排除边界
最终包包含 67 个项目源码文件,以及根目录中的 SOURCE_FILE_LIST.txt 和 SOURCE_SHA256SUMS.txt 两个清单,共 69 个 ZIP 条目。包内路径直接从项目根开始,例如 manage.py、accounts/ 和 cmdb/;没有额外的顶层目录前缀。
| 类别 | 处理 |
|---|---|
.env.example | 包含,真实凭据值保持空。 |
.env、db.sqlite3 | 排除,避免发布运行配置和本地数据。 |
__pycache__/、*.pyc、staticfiles/ | 排除,它们都是可重新生成的产物。 |
.venv/、.git/、.claude/ | 排除,不属于可移植应用源码。 |
| 迁移、测试、模板、静态源文件和依赖文件 | 全部包含,以便新环境建库和复验。 |
35.2 最终 69 个 ZIP 条目
devopsX/.env.exampledevopsX/SOURCE_FILE_LIST.txtdevopsX/SOURCE_SHA256SUMS.txtdevopsX/accounts/__init__.pydevopsX/accounts/admin.pydevopsX/accounts/apps.pydevopsX/accounts/forms.pydevopsX/accounts/migrations/0001_initial.pydevopsX/accounts/migrations/__init__.pydevopsX/accounts/models.pydevopsX/accounts/tests.pydevopsX/accounts/urls.pydevopsX/accounts/views.pydevopsX/cmdb/__init__.pydevopsX/cmdb/admin.pydevopsX/cmdb/api_urls.pydevopsX/cmdb/api_views.pydevopsX/cmdb/apps.pydevopsX/cmdb/forms.pydevopsX/cmdb/management/__init__.pydevopsX/cmdb/management/commands/__init__.pydevopsX/cmdb/management/commands/bootstrap_cmdb.pydevopsX/cmdb/management/commands/sync_cloud_account.pydevopsX/cmdb/migrations/0001_initial.pydevopsX/cmdb/migrations/0002_alter_cloudaccount_credential_profile_and_more.pydevopsX/cmdb/migrations/__init__.pydevopsX/cmdb/models.pydevopsX/cmdb/providers/__init__.pydevopsX/cmdb/providers/aliyun.pydevopsX/cmdb/providers/base.pydevopsX/cmdb/providers/fake.pydevopsX/cmdb/services/__init__.pydevopsX/cmdb/services/csv_io.pydevopsX/cmdb/services/sync.pydevopsX/cmdb/static/cmdb/style.cssdevopsX/cmdb/templates/cmdb/_pagination.htmldevopsX/cmdb/templates/cmdb/account_csv_import.htmldevopsX/cmdb/templates/cmdb/account_detail.htmldevopsX/cmdb/templates/cmdb/account_form.htmldevopsX/cmdb/templates/cmdb/account_list.htmldevopsX/cmdb/templates/cmdb/home.htmldevopsX/cmdb/templates/cmdb/instance_detail.htmldevopsX/cmdb/templates/cmdb/instance_list.htmldevopsX/cmdb/templates/cmdb/provider_list.htmldevopsX/cmdb/templates/cmdb/sync_run_detail.htmldevopsX/cmdb/templates/cmdb/sync_run_list.htmldevopsX/cmdb/templates/cmdb/topology.htmldevopsX/cmdb/tests/__init__.pydevopsX/cmdb/tests/test_aliyun_provider.pydevopsX/cmdb/tests/test_api.pydevopsX/cmdb/tests/test_csv.pydevopsX/cmdb/tests/test_models.pydevopsX/cmdb/tests/test_sync.pydevopsX/cmdb/tests/test_views.pydevopsX/cmdb/urls.pydevopsX/cmdb/views.pydevopsX/devopsX/__init__.pydevopsX/devopsX/asgi.pydevopsX/devopsX/settings.pydevopsX/devopsX/urls.pydevopsX/devopsX/wsgi.pydevopsX/manage.pydevopsX/requirements-aliyun.txtdevopsX/requirements-mysql.txtdevopsX/requirements.txtdevopsX/templates/403.htmldevopsX/templates/accounts/home.htmldevopsX/templates/accounts/login.htmldevopsX/templates/base.html
35.3 最终归档证据
| ZIP 文件名 | devopsX-CMDB资产管理-v1.0.0.zip |
|---|---|
| 源码文件 | 67 |
| ZIP 条目 | 69 |
| ZIP 精确字节数 | 78,816 |
| ZIP SHA-256 | 2a2c9b44c4c63207ac65644947c4c40166ae8f871519ed035bb8001b62c8823c |
| 路径形式 | 项目根相对路径,无额外顶层前缀 |
SOURCE_FILE_LIST.txt 记录 69 个条目的序号、字节数和路径;SOURCE_SHA256SUMS.txt 记录除自身之外其余 68 个条目的 SHA-256,避免自引用。归档级 SHA-256 覆盖整个 ZIP,包括清单自身。
35.4 独立复核归档哈希
执行目录:包含 deliverables/ 的工作区。
python -c "from pathlib import Path; import hashlib; p=Path('deliverables/devopsX-CMDB资产管理-v1.0.0.zip'); print('%s %s' % (hashlib.sha256(p.read_bytes()).hexdigest(), p.name))"
输出的哈希必须严格等于 2a2c9b44c4c63207ac65644947c4c40166ae8f871519ed035bb8001b62c8823c,字节数必须是 78816。只要归档内容重新生成,即使源码逻辑相同,ZIP 哈希也可能变化,因此公开哈希必须针对实际交付文件计算。
35.5 新鲜解压验证
把 ZIP 解压到此前不存在的空目录,并把该目录作为进程工作目录;不要在权威源码目录中覆盖解压,否则残留文件会掩盖漏包。
python -m zipfile -e "deliverables/devopsX-CMDB资产管理-v1.0.0.zip" "release/fresh-cmdb-v1.0.0"
执行目录:release/fresh-cmdb-v1.0.0/。
python -m pip install -r requirements.txt
python manage.py migrate
python manage.py makemigrations --check --dry-run
python manage.py test
最终新鲜解压验证已经实际完成:依赖可安装,迁移和系统检查通过,无迁移漂移,105 项测试全部通过。第一次尝试若仍从原源码目录启动,Django 会发现两份 accounts.tests;把子进程工作目录切换到解压根后,冷包测试完整通过。这是验证方法修正,不需要重新生成 ZIP。
35.6 不提供虚构下载地址
最终 ZIP 已通过当前交付渠道直接提供。本文只记录可核对的文件名、字节数、条目数和 SHA-256,不上传第三方,也不猜测一个可能不存在的公开下载 URL。
36 v1.0.0 限制、扩展路线与收尾
36.1 已完成的可运行闭环
- 云厂商、账号、地域、可用区、实例、双来源标签、同步执行和变化历史模型完整落库。
- Fake Provider 可重复演示创建、幂等、更新、missing、restored、partial、failed 与退役边界。
- 阿里云 ECS 适配器支持地域、可用区、实例、Provider 标签、经典/VPC 私网 IP、普通公网 IP 和 EIP。
- 严格 SDK 响应结构、分页和快照范围校验阻止损坏或不完整响应触发破坏性对账。
- 页面、Admin 只读边界、CSV、版本化 JSON API、Session、权限、POST 与 CSRF 形成完整入口。
- 105 项 SQLite 测试、迁移漂移检查、生产风格设置检查、静态收集和主要浏览器验收均通过。
- 最终 67 个源码文件、69 个 ZIP 条目、清单、哈希和新鲜解压回归已经完成。
36.2 必须明确接受的 v1 运行限制
| 限制 | 当前实现 | 后续方向 |
|---|---|---|
| 内存快照 | 每次发现把地域、可用区和实例保存在进程内存。 | 持久化 staging、流式页处理和可恢复批次。 |
| ORM 逐行写入 | 存在 N+1 和逐行 get/create/save。 | 在审计语义不变前提下预取与批量写。 |
| 缓冲式 CSV | HttpResponse 持有完整导出。 | StreamingHttpResponse 或离线任务。 |
| 拓扑全量加载 | 一次加载当前可见树。 | 按层懒加载、搜索和分页。 |
| 同步详情全量变化 | 一次读取该 run 的全部变化。 | 变化分页与摘要。 |
| 同步 Web 内执行 | 页面和 API 请求占用 worker 直到结束。 | 任务队列、进度、取消和幂等任务 ID。 |
| SQLite 并发 | 只用于教学与确定性回归。 | 认证 MySQL 上迁移、回归和并发测试。 |
| 无分布式锁 | 只有数据库事务和账号级 admission。 | 按部署拓扑选择数据库锁或分布式协调。 |
| 无同步心跳 | 运行依靠固定 stale 超时。 | Worker 心跳和租约续期。 |
| 超时替代运行 | 合法长任务超过 stale 超时可能被替代并重复请求云端;旧运行仍被 fencing 阻止写入。 | 心跳、租约和可取消 Provider 调用。 |
| 固定资源模型 | 只有计算实例,没有动态 CI、自定义关系、VPC/SLB/RDS/Kubernetes。 | 按独立资源模型与 Adapter 合同逐步扩展。 |
| 基础授权 | 无多租户、对象级权限、DRF、GraphQL 或外部 Token API。 | 先确认租户和接口契约再扩展。 |
36.3 外部环境仍未完成的验证
- 没有真实只读阿里云凭据,因此真实账号 smoke test 未运行;没有把 mock 或 SDK 模型检查冒充真实云联通。
- MySQL 8.0.46 客户端、服务握手、
mysqlclient==2.2.7导入和127.0.0.1:3306可达均已确认,但没有认证凭据,因此 MySQL migration/tests 未运行。 - 生产反向代理、TLS 终止、密码策略、日志汇聚、备份恢复、监控告警和密钥轮换必须结合真实部署环境验收。
36.4 推荐扩展顺序
- 先用批准的最小权限 MySQL 用户和只读 RAM 身份补齐外部证据。
- 再把同步改为异步任务,并增加心跳、租约和进度。
- 再持久化发现批次,控制超大账号内存和重试成本。
- 再测量 SQL 和页面体积,优化逐行 ORM、变化分页、拓扑和 CSV。
- 再按同一 Provider 合同增加云盘、VPC、网卡、EIP、负载均衡、数据库和其他云厂商。
- 最后按实际组织需求引入多租户、对象权限、审计报表和集中密钥系统。
36.5 v1.0.0 发布判定
| 判定项 | 状态 | 证据 |
|---|---|---|
| 源码、模型与迁移一致 | 通过 | 无迁移漂移。 |
| SQLite 完整回归 | 通过 | 105 项测试通过,并完成四进程随机顺序复核。 |
| 阿里云适配器合同 | 通过 | 18 项测试和实际 SDK 模型形状检查通过。 |
| 生产风格设置与静态文件 | 通过 | check --deploy 无问题,收集 128 个静态文件。 |
| 浏览器主要路由与权限 | 通过 | 无 console、server 或 failed network 错误。 |
| 最终 ZIP | 通过 | 69 条目,78,816 字节,SHA-256 为 2a2c9b44c4c63207ac65644947c4c40166ae8f871519ed035bb8001b62c8823c,新鲜解压 105 项通过。 |
| 真实阿里云 smoke test | 未执行 | 无真实只读凭据。 |
| 认证 MySQL 验证 | 未执行 | 无数据库认证信息。 |
36.6 本篇结束与源码汇总
CMDB v1.0.0 已达到本地教学、SQLite 验证、受控阿里云 Adapter 扩展和源码交付标准。最终源码文件、清单和归档证据请继续查看:CMDB 资产管理(6):v1.0.0 源码汇总(上篇)。

浙公网安备 33010602011771号