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 一次阿里云同步经过哪些边界

  1. 页面、API 或管理命令选中一个 CloudAccount;
  2. 同步服务根据 CloudProvider.code 选择 AliyunEcsProvider;
  3. 适配器根据账号的 credential_profile 读取进程环境变量;
  4. SDK 客户端读取地域、可用区和实例分页;
  5. 映射函数把厂商字段变成不可变的规范化数据类;
  6. 适配器返回完整或部分 DiscoverySnapshot;
  7. 同步服务先校验快照身份、引用、状态和范围,再在事务中写入数据库;
  8. 只有完整快照才允许把本次未发现的既有实例标记为缺失;部分快照只能写入已经安全发现的内容。

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~155SDK 延迟导入只有真正实例化 SDK 客户端时才导入云 SDK;缺包转换为受控 ProviderError。
157~164区域客户端每次按 endpoint 构造 ECS Client,并传入 AccessKey 与可选 Security Token。
166~189SDK v2 调用使用 describe_*_with_options() 和统一 RuntimeOptions。
192~213账号凭据解析只用前缀查找环境变量;前缀空或密钥对不完整立即失败。
215~330发现流程读取地域、校验白名单、逐地域抓取可用区和实例分页,校验 total_count 一致性后返回完整或部分快照。

29.6 SDK v2 客户端是怎样创建的

  1. AliyunSdkClient.__init__() 延迟导入 ECS Client、ECS models、OpenAPI models 和 Tea Util models;
  2. 导入失败时抛出代码 ALIYUN_SDK_NOT_INSTALLED,页面和命令只看到可操作的中文提示;
  3. _client(endpoint) 创建 Config,放入 AccessKey ID、AccessKey Secret 和可选 Security Token;
  4. Security Token 为空字符串时传入 None,兼容长期 AccessKey 和 STS 临时凭据两种方式;
  5. 地域列表固定从 ecs.cn-hangzhou.aliyuncs.com 请求,地域内接口使用 ecs.<region_id>.aliyuncs.com;
  6. 所有请求使用同一个默认 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 地域与可用区映射

规范化字段阿里云字段回退规则
地域资源 IDregion_id没有额外回退;后续快照校验会拒绝空 ID。
地域名称local_name为空时使用 region_id。
地域 endpointregion_endpoint为空时保存空字符串。
可用区资源 IDzone_id没有额外回退;快照校验拒绝空 ID。
可用区所属地域当前分页循环的 region_id不信任另一个隐式上下文。
可用区名称local_name为空时使用 zone_id。

这里保留厂商原始资源 ID,因为它是账号范围内幂等同步的稳定身份。显示名称可以变化,不能作为唯一键。同步层最终使用“账号 + 厂商资源 ID”的唯一约束定位地域、可用区和实例。

30.3 实例字段映射

CMDB 字段阿里云来源转换
provider_resource_idinstance_id字符串原样保存。
region_provider_resource_id当前地域循环显式写入发现上下文。
availability_zone_provider_resource_idzone_id空字符串转为 None。
nameinstance_name缺失时为空字符串。
instance_typeinstance_type缺失时为空字符串。
vcpucpu空值按 0,再转整数。
memory_mbmemory空值按 0,再转整数;模型含义是 MiB。
os_nameosname缺失时为空字符串。
provider_statusstatus保留厂商原始状态。
normalized_statusstatus已知状态映射,未知状态统一为 unknown。
cloud_created_atcreation_timeISO 时间解析失败时为 None。

同时保存原始状态和标准状态很重要。标准状态方便跨云筛选;原始状态保留厂商语义,出现新状态时不会完全失去上下文。新厂商状态在更新映射和测试前先落入 unknown,比猜测成 running 或 stopped 更安全。

30.4 经典网络私网 IP、VPC 私网 IP 与 EIP

阿里云实例响应可能把地址放在不同嵌套结构中。本版采用“合并、去重、保持首次出现顺序”的规则:

  1. 先读取 inner_ip_address.ip_address;
  2. 再读取 vpc_attributes.private_ip_address.ip_address;
  3. 两组地址合并成 private_ips,通过有序字典键去重;
  4. 先读取 public_ip_address.ip_address;
  5. 再读取 eip_address.ip_address;
  6. EIP 若是字符串,转换成单元素列表;若已经是列表,则按列表处理;
  7. 两组公网地址合并成 public_ips 并去重。
输入情形规范化结果
只有经典网络私网地址private_ips 包含经典网络地址。
只有 VPC 私网地址private_ips 包含 VPC 地址。
两处出现同一私网地址只保留第一次出现的一项。
普通公网地址加 EIPpublic_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 地域选择与白名单校验顺序

  1. 先调用地域接口取得当前账号可见的全部地域;
  2. 把账号的 region_allowlist 转成集合;空列表表示全部地域;
  3. 计算“白名单减去厂商返回地域”的差集;只要差集非空,立即抛出 ALIYUN_UNKNOWN_REGION;
  4. 白名单有效后才筛选地域并进入逐地域可用区和实例发现;
  5. 快照的 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 完整快照、部分快照和失败的区别

情形适配器结果同步执行状态是否允许标记既有实例缺失
地域接口在任何数据产生前失败抛出净化后的 ProviderErrorfailed否
某地域的可用区、实例请求或分页完整性检查失败,已经有地域或资产数据返回 complete=False 的 DiscoverySnapshotpartial否
所有选中地域均达到稳定且精确的 total_count返回 complete=True 的 DiscoverySnapshotsucceeded是,但只在声明的 scope 内
白名单包含未知地域抛出 ALIYUN_UNKNOWN_REGIONfailed否

部分快照仍可写入已经发现的地域、可用区和实例,但同步服务不会把未出现的旧实例标记为 missing,也不会更新账号的最后成功同步时间。这一规则优先避免误删语义:网络超时不能被解释成云端实例已经消失。

白名单快照即使部分失败,也会携带完整配置范围;因为 complete=False,同步校验不会要求本次数据已经覆盖每一个白名单地域。只有完整快照才必须覆盖声明范围。

31.4 错误净化规则

底层信号公开错误代码公开消息
HTTP 403、代码含 forbidden 或 accessdeniedALIYUN_ACCESS_DENIED阿里云拒绝了只读资产请求,请检查 RAM 权限。
错误代码含 throttl 或 HTTP 429ALIYUN_THROTTLED阿里云接口触发限流,请稍后重试。
ConnectionError 或 TimeoutErrorALIYUN_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_DENIEDRAM 身份、策略、资源范围和临时令牌是否匹配。不要直接授予管理写权限来绕过。
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_ERRORDNS、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_node
  • test_describe_zones_rejects_missing_collection_node
  • test_describe_instances_rejects_missing_list_or_total
  • test_describe_instances_accepts_explicit_empty_page

AliyunMapperTests(5 项)

  • test_instance_mapper_normalizes_fields
  • test_instance_mapper_maps_pending_to_starting
  • test_instance_mapper_rejects_invalid_nonempty_creation_time
  • test_instance_mapper_reads_vpc_private_addresses
  • test_instance_mapper_reads_public_and_eip_addresses

AliyunProviderTests(9 项)

  • test_discover_supports_multiple_regions_and_pages
  • test_empty_region_collection_is_rejected
  • test_empty_zone_collection_cannot_mark_existing_assets_missing
  • test_region_allowlist_limits_discovery
  • test_second_page_failure_returns_partial_snapshot
  • test_invalid_instance_response_cannot_mark_existing_assets_missing
  • test_premature_empty_page_returns_partial_snapshot
  • test_unknown_allowlist_region_is_rejected
  • test_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 测试失败时的排查顺序

  1. 确认从外层 devopsX/ 执行,而不是从 devopsX/cmdb/ 执行;
  2. 确认基础依赖版本与 devopsX/requirements.txt 一致;
  3. 如果分页断言失败,检查 page_size、total_count 累计和终止条件;
  4. 如果白名单断言失败,检查是否先验证地域集合再进行筛选;
  5. 如果错误消息泄漏底层异常,检查是否绕过 _safe_provider_error();
  6. 如果测试意外访问网络,检查客户端工厂是否仍通过依赖注入传入假客户端。

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~50DEBUG 与 SECRET_KEY生产模式没有 Secret Key 立即拒绝启动;调试模式缺失时生成进程级随机值。
52~66ALLOWED_HOSTS生产必须显式配置,任何模式都拒绝通配符 *。
68~107Django 应用、中间件、模板和 WSGI启用 CSRF、认证、会话、安全和点击劫持中间件。
108~150数据库生产必须显式选择引擎;MySQL 名称、用户、密码、主机、端口全部必填,端口必须在 1~65535。
152~165密码验证器启用相似度、最小长度、常见密码和纯数字密码验证。
167~173语言、时区和静态文件静态文件收集到 devopsX/staticfiles/。
175~201HTTPS、Cookie 与 HSTS生产中任一关键开关关闭或 HSTS 为 0,立即拒绝启动。
203~208CSRF 可信来源按逗号拆分;需要跨源 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 驱动。
项目SQLiteMySQL
引擎值sqlitemysql
数据库位置devopsX/db.sqlite3DB_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.py5公开首页、登录、站内 next、外部 next 拒绝和 403 回退。
devopsX/cmdb/tests/test_aliyun_provider.py18SDK 响应结构、字段映射、分页、地域白名单、空地域/空可用区保护、部分失败与日志脱敏。
devopsX/cmdb/tests/test_api.py9账号与实例 API、账号分页、同步状态码、JSON 边界和上游完整性错误映射。
devopsX/cmdb/tests/test_csv.py13敏感列拒绝、严格表头、签名预览、原子导入、大小限制、BOM 与公式中和。
devopsX/cmdb/tests/test_models.py8稳定身份、关系一致性、模型验证、标签来源和排序。
devopsX/cmdb/tests/test_sync.py33幂等、快照校验、缺失/恢复/退役、范围、并发占用、fencing、标签和审计。
devopsX/cmdb/tests/test_views.py19权限隔离、Admin 只读边界、POST-only、搜索分页、拓扑和模板入口。
合计105最终 SQLite 完整回归。
项目当前证据
Python3.10.8,64 位
Django / python-dotenv5.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_public
  • test_login_redirects_authorized_user_to_cmdb
  • test_login_without_cmdb_view_permission_redirects_to_public_home
  • test_permission_denied_without_cmdb_view_links_to_public_home
  • test_login_honors_safe_next_and_rejects_external_next
devopsX/cmdb/tests/test_aliyun_provider.py:18 项

AliyunSdkResponseTests(4 项)

  • test_describe_regions_rejects_missing_collection_node
  • test_describe_zones_rejects_missing_collection_node
  • test_describe_instances_rejects_missing_list_or_total
  • test_describe_instances_accepts_explicit_empty_page

AliyunMapperTests(5 项)

  • test_instance_mapper_normalizes_fields
  • test_instance_mapper_maps_pending_to_starting
  • test_instance_mapper_rejects_invalid_nonempty_creation_time
  • test_instance_mapper_reads_vpc_private_addresses
  • test_instance_mapper_reads_public_and_eip_addresses

AliyunProviderTests(9 项)

  • test_discover_supports_multiple_regions_and_pages
  • test_empty_region_collection_is_rejected
  • test_empty_zone_collection_cannot_mark_existing_assets_missing
  • test_region_allowlist_limits_discovery
  • test_second_page_failure_returns_partial_snapshot
  • test_invalid_instance_response_cannot_mark_existing_assets_missing
  • test_premature_empty_page_returns_partial_snapshot
  • test_unknown_allowlist_region_is_rejected
  • test_missing_environment_credentials_are_reported_without_secret
devopsX/cmdb/tests/test_api.py:9 项

CmdbApiTests(9 项)

  • test_account_list_api_never_returns_secret_values
  • test_account_list_api_paginates_results
  • test_instance_list_and_detail_api
  • test_api_sync_requires_post_and_returns_created_run
  • test_api_partial_sync_returns_207_and_error_details
  • test_api_sync_maps_business_and_upstream_errors
  • test_api_maps_snapshot_integrity_failures_to_bad_gateway
  • test_api_rejects_invalid_json_and_page_size
  • test_api_rejects_non_object_json_and_non_string_scenario
devopsX/cmdb/tests/test_csv.py:13 项

CloudAccountCsvTests(13 项)

  • test_sensitive_header_is_rejected
  • test_preview_then_import_creates_account
  • test_csv_validates_credential_profile_and_deduplicates_regions
  • test_csv_import_is_atomic_when_a_later_row_fails
  • test_csv_update_preserves_omitted_optional_columns
  • test_csv_update_applies_explicit_optional_columns
  • test_csv_update_preserves_last_successful_sync_timestamp
  • test_csv_reports_extra_columns_without_server_error
  • test_csv_rejects_oversized_field_without_server_error
  • test_csv_reports_duplicate_account_identity
  • test_csv_rejects_unsupported_blank_and_duplicate_headers
  • test_instance_export_neutralizes_spreadsheet_formulas
  • test_instance_export_contains_utf8_bom_and_headers
devopsX/cmdb/tests/test_models.py:8 项

CmdbModelTests(8 项)

  • test_provider_and_account_string_values_are_readable
  • test_account_identity_is_unique_inside_provider
  • test_same_account_key_can_exist_for_another_provider
  • test_account_validates_credential_profile_and_region_allowlist
  • test_account_form_applies_region_allowlist_validator
  • test_zone_and_instance_accounts_must_match_their_relations
  • test_invalid_relation_ids_remain_validation_errors
  • test_tag_sources_are_independent
devopsX/cmdb/tests/test_sync.py:33 项

SyncAccountTests(33 项)

  • test_first_sync_creates_two_instances_and_changes
  • test_identical_snapshot_is_idempotent
  • test_changed_provider_field_creates_one_update
  • test_complete_empty_snapshot_marks_instances_missing
  • test_repeated_empty_snapshot_does_not_duplicate_missing_changes
  • test_restored_change_contains_lifecycle_fields
  • test_instance_zone_must_belong_to_instance_region
  • test_missing_instances_are_restored
  • test_partial_snapshot_never_marks_existing_instances_missing
  • test_provider_failure_records_failed_run_without_changing_assets
  • test_unexpected_provider_failure_is_logged_and_sanitized
  • test_duplicate_region_is_rejected_before_writing_snapshot
  • test_provider_sync_does_not_delete_manual_tags
  • test_provider_tag_only_change_is_audited_as_update
  • test_allowlist_scope_does_not_mark_excluded_region_missing
  • test_unscoped_account_rejects_allowlist_snapshot
  • test_stale_running_sync_is_failed_and_does_not_block_new_sync
  • test_superseded_sync_cannot_apply_its_snapshot
  • test_configuration_change_during_discovery_fences_snapshot
  • test_disabled_provider_prevents_sync
  • test_fake_provider_filters_configured_region_allowlist
  • test_fake_provider_rejects_unknown_allowlist_region
  • test_unknown_fake_scenario_is_rejected
  • test_fresh_running_sync_still_blocks_new_sync
  • test_empty_resource_identity_and_invalid_status_are_rejected
  • test_partial_allowlist_snapshot_rejects_out_of_scope_region
  • test_complete_allowlist_snapshot_must_cover_its_scope
  • test_bootstrap_preserves_disabled_provider
  • test_sync_command_reports_partial_as_failure
  • test_sync_command_requires_provider_when_account_key_is_ambiguous
  • test_retirement_rolls_back_when_audit_creation_fails
  • test_retiring_missing_instance_clears_missing_state_in_audit
  • test_retired_instance_is_not_automatically_restored
devopsX/cmdb/tests/test_views.py:19 项

CmdbViewTests(19 项)

  • test_anonymous_user_is_redirected_to_login
  • test_authenticated_user_sees_home_counts
  • test_superuser_can_open_all_read_pages
  • test_synchronized_models_are_read_only_in_admin
  • test_authenticated_user_without_permission_gets_403
  • test_home_hides_account_create_without_account_view_permission
  • test_provider_reader_does_not_see_account_counts
  • test_sync_run_reader_does_not_see_instance_changes
  • test_account_detail_hides_instances_and_sync_runs_without_permissions
  • test_authorized_get_on_sync_endpoint_gets_405
  • test_sync_permission_without_view_permission_is_rejected
  • test_user_with_sync_permission_can_start_sync
  • test_instance_detail_does_not_link_account_without_account_view_permission
  • test_instance_filter_searches_name_and_ip
  • test_instance_filter_rejects_malformed_relation_ids_without_500
  • test_partial_sync_displays_warning
  • test_instance_change_history_is_paginated
  • test_topology_shows_instances_without_availability_zone
  • test_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 条目

  1. devopsX/.env.example
  2. devopsX/SOURCE_FILE_LIST.txt
  3. devopsX/SOURCE_SHA256SUMS.txt
  4. devopsX/accounts/__init__.py
  5. devopsX/accounts/admin.py
  6. devopsX/accounts/apps.py
  7. devopsX/accounts/forms.py
  8. devopsX/accounts/migrations/0001_initial.py
  9. devopsX/accounts/migrations/__init__.py
  10. devopsX/accounts/models.py
  11. devopsX/accounts/tests.py
  12. devopsX/accounts/urls.py
  13. devopsX/accounts/views.py
  14. devopsX/cmdb/__init__.py
  15. devopsX/cmdb/admin.py
  16. devopsX/cmdb/api_urls.py
  17. devopsX/cmdb/api_views.py
  18. devopsX/cmdb/apps.py
  19. devopsX/cmdb/forms.py
  20. devopsX/cmdb/management/__init__.py
  21. devopsX/cmdb/management/commands/__init__.py
  22. devopsX/cmdb/management/commands/bootstrap_cmdb.py
  23. devopsX/cmdb/management/commands/sync_cloud_account.py
  24. devopsX/cmdb/migrations/0001_initial.py
  25. devopsX/cmdb/migrations/0002_alter_cloudaccount_credential_profile_and_more.py
  26. devopsX/cmdb/migrations/__init__.py
  27. devopsX/cmdb/models.py
  28. devopsX/cmdb/providers/__init__.py
  29. devopsX/cmdb/providers/aliyun.py
  30. devopsX/cmdb/providers/base.py
  31. devopsX/cmdb/providers/fake.py
  32. devopsX/cmdb/services/__init__.py
  33. devopsX/cmdb/services/csv_io.py
  34. devopsX/cmdb/services/sync.py
  35. devopsX/cmdb/static/cmdb/style.css
  36. devopsX/cmdb/templates/cmdb/_pagination.html
  37. devopsX/cmdb/templates/cmdb/account_csv_import.html
  38. devopsX/cmdb/templates/cmdb/account_detail.html
  39. devopsX/cmdb/templates/cmdb/account_form.html
  40. devopsX/cmdb/templates/cmdb/account_list.html
  41. devopsX/cmdb/templates/cmdb/home.html
  42. devopsX/cmdb/templates/cmdb/instance_detail.html
  43. devopsX/cmdb/templates/cmdb/instance_list.html
  44. devopsX/cmdb/templates/cmdb/provider_list.html
  45. devopsX/cmdb/templates/cmdb/sync_run_detail.html
  46. devopsX/cmdb/templates/cmdb/sync_run_list.html
  47. devopsX/cmdb/templates/cmdb/topology.html
  48. devopsX/cmdb/tests/__init__.py
  49. devopsX/cmdb/tests/test_aliyun_provider.py
  50. devopsX/cmdb/tests/test_api.py
  51. devopsX/cmdb/tests/test_csv.py
  52. devopsX/cmdb/tests/test_models.py
  53. devopsX/cmdb/tests/test_sync.py
  54. devopsX/cmdb/tests/test_views.py
  55. devopsX/cmdb/urls.py
  56. devopsX/cmdb/views.py
  57. devopsX/devopsX/__init__.py
  58. devopsX/devopsX/asgi.py
  59. devopsX/devopsX/settings.py
  60. devopsX/devopsX/urls.py
  61. devopsX/devopsX/wsgi.py
  62. devopsX/manage.py
  63. devopsX/requirements-aliyun.txt
  64. devopsX/requirements-mysql.txt
  65. devopsX/requirements.txt
  66. devopsX/templates/403.html
  67. devopsX/templates/accounts/home.html
  68. devopsX/templates/accounts/login.html
  69. devopsX/templates/base.html

35.3 最终归档证据

ZIP 文件名devopsX-CMDB资产管理-v1.0.0.zip
源码文件67
ZIP 条目69
ZIP 精确字节数78,816
ZIP SHA-2562a2c9b44c4c63207ac65644947c4c40166ae8f871519ed035bb8001b62c8823c
路径形式项目根相对路径,无额外顶层前缀

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。在审计语义不变前提下预取与批量写。
缓冲式 CSVHttpResponse 持有完整导出。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 推荐扩展顺序

  1. 先用批准的最小权限 MySQL 用户和只读 RAM 身份补齐外部证据。
  2. 再把同步改为异步任务,并增加心跳、租约和进度。
  3. 再持久化发现批次,控制超大账号内存和重试成本。
  4. 再测量 SQL 和页面体积,优化逐行 ORM、变化分页、拓扑和 CSV。
  5. 再按同一 Provider 合同增加云盘、VPC、网卡、EIP、负载均衡、数据库和其他云厂商。
  6. 最后按实际组织需求引入多租户、对象权限、审计报表和集中密钥系统。

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 源码汇总(上篇)。

posted @ 2026-09-16 23:38  小家电维修  阅读(9)  评论(0)    收藏  举报