CMDB 资产管理(2):从应用首页到云资产模型

1 从零重建 Django CMDB v1.0.0:目标、边界与系列导航

1.1 系列导航

这是“CMDB 资产管理”连续系列的第 2 篇,也是完整实现教程的第 1 篇。本文讲第 1 章至第 9 章:先建立环境和标准 Django 工程,再接入公开的 accounts 基线,创建与它平级的 cmdb App,定义第一批资产模型,完成迁移、Admin、列表、详情与可立即执行的检查。云厂商适配、Fake Provider、阿里云 SDK 映射、同步对账、变更记录、CSV、API 与更深入的测试将在“CMDB 资产管理(3):云资源发现、同步与变更历史”继续。

1.2 完成本文后能得到什么

  • 理解 Project、App、模型、迁移、Admin、URL、视图、模板、静态文件、ORM 与测试各自解决什么问题。
  • 建立标准的 django-admin startproject devopsX 双层目录,而不是自行发明 devopsX/backend/ 或 devopsX/config/ 包。
  • 保留公开首页、登录、POST 退出和自定义用户模型组成的 accounts 公共基线。
  • 创建与 accounts 平级的 cmdb 顶级 App。
  • 建立云厂商、云账号、地域、可用区、计算实例和标签的稳定数据关系。
  • 理解 source of truth、稳定厂商资源 ID,以及厂商负责、本地负责、对账负责三类字段的所有权。
  • 通过 Django Admin 和现有页面查看资产列表与详情,并执行系统检查、迁移漂移检查和自动化测试。

1.3 本篇明确不冒充已经验证的事情

本文本地复核使用 Python 3.10.8、Django 5.2.17 和 SQLite。完整测试结果是 105 项通过。本文没有声称 MySQL 已经实机迁移通过,也没有声称使用真实阿里云账号做过 smoke test。阿里云相关自动化测试使用的是模拟客户端,不等于真实云环境验收。

数据库中的 credential_profile 只是环境变量名称前缀,不是凭据。本文不提供任何 AccessKey、Secret、Token、PAT、数据库密码或可复制的生产密钥。

1.4 为什么先做这一小段闭环

初学者最容易犯的错误,是还没有理解数据身份和目录关系,就先写云 SDK、异步队列或复杂同步状态机。本文先完成“工程能启动、模型能迁移、后台能维护、页面能查询、测试能执行”的闭环。这样第 2 篇接入发现和对账时,每一层都有清楚的落点。

2 环境、版本与安装

2.1 先解释环境是什么

运行环境是让项目能够执行的一组条件,包括 Python 解释器、第三方包、环境变量和数据库。虚拟环境是项目独享的一套 Python 包目录,用来避免不同项目互相覆盖依赖。依赖锁定是把已经验证的包版本写进文件,让另一台机器安装相同主版本与补丁版本。

2.2 本教程的基准版本

组件版本或选择原因
Python3.10.8,教程可按 3.10.x 准备当前源码使用 Python 3.10 的联合类型语法。
Django5.2.17权威源码和迁移均由该版本生成并验证。
python-dotenv1.1.1让本地进程可以读取项目根目录的环境变量文件;本文不写入任何真实秘密。
本地数据库SQLite零额外服务,适合第一轮闭环和自动化测试。
可选生产数据库驱动mysqlclient 2.2.7仅声明可选依赖,不代表本文已经完成真实 MySQL 验收。
可选阿里云 SDKalibabacloud-ecs20140526 7.11.5“CMDB 资产管理(5):阿里云 ECS、MySQL 与最终验收”解释;本文不连接真实阿里云。

2.3 检查 Python

在准备保存项目的工作区打开 PowerShell。先只确认解释器版本,不要同时执行其他命令。

执行位置:devopsX/ 创建前的父工作区;命令不会读取项目文件

python --version

预期看到 Python 3.10.x。本次权威源码验证机输出的是下面一行。

输出对应项目:devopsX/

Python 3.10.8

如果提示找不到 python,先安装 Python 3.10 并重新打开终端;不要继续执行 Django 命令,因为后续错误只会掩盖根因。

2.4 创建项目后再建立项目内虚拟环境

第 3 章会先用已安装的 Django 创建标准目录。进入外层项目根目录后,执行下面命令。命令一次只做一件事:创建 devopsX/.venv/。

执行位置:devopsX/

python -m venv .venv

成功时通常没有输出。随后激活它。

执行位置:devopsX/

.\.venv\Scripts\Activate.ps1

PowerShell 提示符前通常会出现 (.venv)。若执行策略阻止脚本,只为当前进程调整策略,不要为了一个项目永久关闭系统保护。

执行位置:devopsX/

Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass

然后再次执行激活命令。关闭当前 PowerShell 后,这个临时策略会失效。

2.5 写入并安装基础依赖

下面是权威源码中的完整基础依赖文件。第一行锁定 Django,第二行锁定环境变量加载器。

完整文件:devopsX/requirements.txt

Django==5.2.17
python-dotenv==1.2.3

在已经激活的虚拟环境中安装它。

执行位置:devopsX/;读取文件:devopsX/requirements.txt

python -m pip install -r requirements.txt

首次安装时,末尾应出现 Django 5.2.17 和 python-dotenv 1.2.3 安装成功的信息;如果环境已经安装,pip 会报告 requirement already satisfied。两种结果都可以,关键是下一条命令返回 5.2.17。

执行位置:devopsX/

python -m django --version

预期输出对应位置:devopsX/

5.2.17

2.6 本地开发环境变量

当前设置默认按生产模式收紧:没有明确配置时,DEBUG 为假,且必须提供安全密钥、允许主机、数据库类型和 HTTPS 选项。第一轮本地练习使用 SQLite,因此在当前 PowerShell 会话中设置下面两个变量。每条命令独立执行。

执行位置:devopsX/;只影响当前 PowerShell 进程

$env:DEVOPSX_DEBUG = "true"

执行位置:devopsX/;只影响当前 PowerShell 进程

$env:DEVOPSX_DB_ENGINE = "sqlite"

开发模式下,源码会为当前进程生成临时 SECRET_KEY,因此本文不需要展示任何密钥值。重新打开终端后应重新设置这两个变量。生产环境不得依赖临时密钥,也不得把生产密钥写进教程、Git 或页面。

2.7 完整设置文件及逐组解释

下面是当前磁盘中的完整最终文件。不要把解释性注释插入代码块,代码块之后再解释。

完整文件: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"
行组作用为什么需要
导入、BASE_DIR、load_dotenv确定外层项目根目录,并在进程启动时读取 devopsX/.env。配置位置统一;敏感值仍应由部署系统安全注入。
_env_bool只接受明确的 true 或 false。避免把拼错的字符串静默当成真值。
_env_nonnegative_int解析 HSTS 秒数并拒绝负数。安全配置错误应在启动时暴露。
DEBUG、SECRET_KEY、ALLOWED_HOSTS开发模式允许本机地址;生产模式要求显式安全配置,并拒绝主机通配符。防止把学习配置误当生产配置。
INSTALLED_APPS注册 Django 内置 App、accounts 和 cmdb。未注册的 App 不会进入模型、迁移、Admin 和权限发现流程。
MIDDLEWARE按顺序启用安全、Session、CSRF、认证、消息和点击劫持防护。顺序会影响 request.user、消息和 CSRF 是否可用。
TEMPLATES同时搜索全局 devopsX/templates/ 和 App 内模板目录。公共布局放全局目录,CMDB 私有模板放 App 内。
DATABASES开发默认 SQLite;显式选择 MySQL 时读取连接环境变量。教程可以零依赖启动,同时保留生产数据库入口。
语言、时区、静态文件使用简体中文、上海时区和统一静态文件前缀。页面时间和中文 Admin 显示一致。
HTTPS、Cookie、HSTS生产模式必须启用安全传输策略。登录态和 CSRF Cookie 不应通过明文 HTTP 传输。
AUTH_USER_MODEL把项目用户模型固定为 accounts.User。必须在首次迁移前决定,后期切换代价很高。
登录地址与跳转匿名用户去登录页,登录成功进入 CMDB。让认证流程有统一入口。

3 标准 django-admin startproject devopsX 双层结构

3.1 Project 与 App 不是同一层概念

Django Project 是整个站点的配置容器,负责设置、总路由和部署入口。Django App 是站点中的业务模块,负责某一组模型、页面、权限和测试。一个 Project 可以加载多个平级 App。

3.2 创建标准工程

先确保当前 Python 环境已经能够执行 Django。回到准备放置项目的父工作区,执行标准命令。

执行位置:devopsX/ 创建前的父工作区;目标目录将是 devopsX/

django-admin startproject devopsX

成功时通常没有输出。命令创建外层项目目录和内层 Python 配置包,因此会出现两个同名层级:

目录位置:devopsX/

devopsX/
devopsX/manage.py
devopsX/devopsX/
devopsX/devopsX/__init__.py
devopsX/devopsX/settings.py
devopsX/devopsX/urls.py
devopsX/devopsX/asgi.py
devopsX/devopsX/wsgi.py

外层 devopsX/ 是项目根目录;内层 devopsX/devopsX/ 是可导入的 Python 包。它不是重复错误。本文明确不创建 devopsX/backend/,也不把内层包改名为 devopsX/config/。

3.3 manage.py 为什么存在

devopsX/manage.py 是项目管理命令入口。它先把设置模块指定为 devopsX.settings,再把终端参数交给 Django。下面是完整最终文件。

完整文件:devopsX/manage.py

#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys


def main():
    """Run administrative tasks."""
    os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devopsX.settings')
    try:
        from django.core.management import execute_from_command_line
    except ImportError as exc:
        raise ImportError(
            "Couldn't import Django. Are you sure it's installed and "
            "available on your PYTHONPATH environment variable? Did you "
            "forget to activate a virtual environment?"
        ) from exc
    execute_from_command_line(sys.argv)


if __name__ == '__main__':
    main()
  • 导入 os 是为了设置环境变量,导入 sys 是为了读取命令行参数。
  • setdefault 只在外部尚未指定设置模块时使用 devopsX.settings。
  • execute_from_command_line(sys.argv) 让 check、migrate、runserver 和 test 共用同一入口。
  • 导入失败信息提醒检查 Django、PYTHONPATH 和虚拟环境。

3.4 ASGI 与 WSGI 是部署入口

WSGI 是传统同步 Python Web 服务接口;ASGI 支持异步协议和更广的连接模型。开发服务器会自己选择合适入口,初学阶段不要删除这些生成文件。它们都加载同一个 devopsX.settings。

完整文件:devopsX/devopsX/asgi.py

"""
ASGI config for devopsX project.

It exposes the ASGI callable as a module-level variable named ``application``.

For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""

import os

from django.core.asgi import get_asgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devopsX.settings')

application = get_asgi_application()

完整文件:devopsX/devopsX/wsgi.py

"""
WSGI config for devopsX project.

It exposes the WSGI callable as a module-level variable named ``application``.

For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""

import os

from django.core.wsgi import get_wsgi_application

os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'devopsX.settings')

application = get_wsgi_application()

3.5 第一个立即检查点

此时只验证 Django 骨架,不涉及业务数据。

执行位置:devopsX/

python manage.py check

预期输出对应位置:devopsX/

System check identified no issues (0 silenced).

若出现“生产模式必须配置”一类错误,说明第 2.6 节的两个环境变量没有进入当前终端;不要修改源码绕过检查。

4 accounts 公共基线:用户、公开首页、登录与退出

4.1 为什么 CMDB 不负责登录

accounts 回答“用户是谁、是否登录”;cmdb 回答“用户能看哪些资产页面、资产是什么”。认证是平台公共能力,不应重复写进每个业务 App。CMDB 只使用 Django 已经恢复好的 request.user 和模型权限。

4.2 创建 accounts App

先进入外层项目根目录,再让 Django 生成标准 App 骨架。

执行位置:devopsX/

python manage.py startapp accounts

成功时通常没有输出,并创建 devopsX/accounts/。不要把它放进内层 devopsX/devopsX/。

4.3 App 配置

完整文件:devopsX/accounts/apps.py

from django.apps import AppConfig


class AccountsConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "accounts"
    verbose_name = "用户与权限"

AccountsConfig 继承 AppConfig。name 是 Python 包名;verbose_name 是 Admin 中显示的中文分组名;default_auto_field 让自动主键使用 64 位整数。

4.4 最小自定义用户模型

完整文件:devopsX/accounts/models.py

from django.contrib.auth.models import AbstractUser


class User(AbstractUser):
    class Meta:
        verbose_name = "用户"
        verbose_name_plural = "用户"

AbstractUser 已经提供用户名、密码哈希、邮箱、员工状态、超级用户、组和权限。当前基线只继承它,不重复声明已有字段。即使现在没有业务字段,也要在首次迁移前通过 AUTH_USER_MODEL 固定项目用户模型。

4.5 Admin 注册

完整文件:devopsX/accounts/admin.py

from django.contrib import admin
from django.contrib.auth.admin import UserAdmin

from .models import User


admin.site.register(User, UserAdmin)

UserAdmin 是 Django 为用户模型准备的管理界面。最后一行把 accounts.User 与它绑定,避免自己重写密码哈希和权限表单。

4.6 登录表单为什么直接复用 Django

完整文件:devopsX/accounts/forms.py

from django.contrib.auth.forms import AuthenticationForm


LoginForm = AuthenticationForm

这里给 AuthenticationForm 起本地别名。它会校验用户名、密码和用户是否可认证。复用框架实现比手工比较密码安全,因为 Django 会走认证后端和密码哈希验证。

4.7 公开首页、登录和 POST 退出视图

完整文件:devopsX/accounts/views.py

from django.contrib import messages
from django.contrib.auth import login, logout
from django.http import HttpResponseNotAllowed
from django.shortcuts import redirect, render
from django.utils.http import url_has_allowed_host_and_scheme

from .forms import LoginForm


CMDB_VIEW_PERMISSIONS = (
    "cmdb.view_cloudprovider",
    "cmdb.view_cloudaccount",
    "cmdb.view_computeinstance",
    "cmdb.view_syncrun",
)


def _default_login_redirect(user):
    if any(user.has_perm(permission) for permission in CMDB_VIEW_PERMISSIONS):
        return "cmdb:home"
    return "accounts:home"


def home(request):
    return render(request, "accounts/home.html")


def login_view(request):
    next_url = request.POST.get("next", "") or request.GET.get("next", "")
    next_is_safe = next_url and url_has_allowed_host_and_scheme(
        next_url,
        allowed_hosts={request.get_host()},
        require_https=request.is_secure(),
    )
    if request.user.is_authenticated:
        return redirect(
            next_url if next_is_safe else _default_login_redirect(request.user)
        )

    form = LoginForm(request, data=request.POST or None)
    if request.method == "POST" and form.is_valid():
        login(request, form.get_user())
        messages.success(request, "登录成功。")
        return redirect(
            next_url if next_is_safe else _default_login_redirect(request.user)
        )

    return render(
        request,
        "accounts/login.html",
        {"form": form, "next": next_url if next_is_safe else ""},
    )


def logout_view(request):
    if request.method != "POST":
        return HttpResponseNotAllowed(["POST"])

    logout(request)
    messages.success(request, "已经退出登录。")
    return redirect("accounts:home")
代码组解释
导入区messages 保存一次性提示;login 和 logout 管理 Session;url_has_allowed_host_and_scheme 防止外部跳转。
home不加登录装饰器,因此任何人都能打开平台介绍页。
next_url 与 next_is_safe只允许跳回当前站点,拒绝把登录成功用户送到外部恶意地址。
已登录分支已登录用户再次打开登录页时直接进入安全目标或 CMDB 首页。
表单验证分支只有 POST 且表单有效时建立 Session;失败时仍渲染同一表单及错误。
logout_view只接受 POST。GET 不能用于改变登录状态,避免外部图片或链接触发退出。

4.8 accounts URL namespace

完整文件:devopsX/accounts/urls.py

from django.urls import path

from . import views


app_name = "accounts"

urlpatterns = [
    path("", views.home, name="home"),
    path("login/", views.login_view, name="login"),
    path("logout/", views.logout_view, name="logout"),
]

app_name = "accounts" 建立命名空间,因此模板和 Python 可以写 accounts:login,不会与其他 App 的 login 重名。三个 path 分别对应公开首页、登录和退出。

5 创建与 accounts 平级的 cmdb App

5.1 先说结论:两个 App 必须平级

标准结构是 devopsX/accounts/ 与 devopsX/cmdb/ 同时位于外层项目根目录。cmdb 不是 accounts 的子目录,也不位于 devopsX/devopsX/ 内。当前项目没有 devopsX/backend/ 包,也没有 devopsX/config/ 包。

5.2 创建命令

执行位置:devopsX/

python manage.py startapp cmdb

成功时通常没有输出。此时关键目录关系如下。

目录位置:devopsX/

devopsX/manage.py
devopsX/devopsX/
devopsX/accounts/
devopsX/cmdb/
devopsX/templates/
devopsX/requirements.txt

5.3 cmdb App 配置

完整文件:devopsX/cmdb/apps.py

from django.apps import AppConfig


class CmdbConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "cmdb"
    verbose_name = "CMDB 资产管理"

三个类属性与 accounts 的含义一致,只是包名和后台显示名称属于 CMDB。随后由 INSTALLED_APPS 中的 cmdb.apps.CmdbConfig 注册。

5.4 总路由把请求分给平级 App

完整文件:devopsX/devopsX/urls.py

from django.contrib import admin
from django.urls import include, path


urlpatterns = [
    path("admin/", admin.site.urls),
    path("", include("accounts.urls")),
    path("cmdb/", include("cmdb.urls")),
]
  • admin/ 交给 Django Admin。
  • 空前缀交给 accounts.urls,所以公开首页是 /,登录页是 /login/。
  • cmdb/ 交给 cmdb.urls,所以资产页面统一位于 /cmdb/ 下。
  • include 不是复制路由,而是把后续匹配继续交给对应 App。

6 第一张页面:从 URL 到 HTML 的完整请求链

6.1 URL、视图和模板分别做什么

术语职责当前文件
URL 路由根据路径选择视图devopsX/devopsX/urls.py、devopsX/accounts/urls.py、devopsX/cmdb/urls.py
View 视图读取用户和数据库,准备上下文,返回响应devopsX/accounts/views.py、devopsX/cmdb/views.py
Template 模板把上下文变量渲染成 HTMLdevopsX/templates/ 与 devopsX/cmdb/templates/cmdb/
Static 静态文件提供 CSS 等不会按请求动态生成的资源devopsX/cmdb/static/cmdb/style.css

6.2 公共基础模板

基础模板只定义一次页面骨架、导航、消息区和内容插槽。子模板通过 extends 复用它。

完整文件:devopsX/templates/base.html

{% load static %}
<!doctype html>
<html lang="zh-Hans">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{% block title %}devopsX{% endblock %}</title>
    <link rel="stylesheet" href="{% static 'cmdb/style.css' %}">
</head>
<body>
<header class="site-header">
    <a class="brand" href="{% url 'accounts:home' %}">devopsX</a>
    <nav>
        {% if user.is_authenticated %}
            {% if perms.cmdb.view_cloudprovider or perms.cmdb.view_cloudaccount or perms.cmdb.view_computeinstance or perms.cmdb.view_syncrun %}<a href="{% url 'cmdb:home' %}">CMDB</a>{% endif %}
            {% if perms.cmdb.view_cloudaccount %}<a href="{% url 'cmdb:account_list' %}">云账号</a>{% endif %}
            {% if perms.cmdb.view_computeinstance %}<a href="{% url 'cmdb:instance_list' %}">计算实例</a>{% endif %}
            {% if perms.cmdb.view_computeinstance %}<a href="{% url 'cmdb:topology' %}">拓扑</a>{% endif %}
            {% if perms.cmdb.view_syncrun %}<a href="{% url 'cmdb:sync_run_list' %}">同步历史</a>{% endif %}
            {% if user.is_staff %}<a href="{% url 'admin:index' %}">管理后台</a>{% endif %}
            <form method="post" action="{% url 'accounts:logout' %}" class="inline-form">
                {% csrf_token %}
                <button type="submit" class="link-button">退出</button>
            </form>
        {% else %}
            <a href="{% url 'accounts:login' %}">登录</a>
        {% endif %}
    </nav>
</header>
<main class="page-shell">
    {% if messages %}
        {% for message in messages %}<p class="message message-{{ message.tags|default:'info' }}">{{ message }}</p>{% endfor %}
    {% endif %}
    {% block content %}{% endblock %}
</main>
</body>
</html>
代码组解释
load static 与 link让 Django 根据静态文件配置生成 CSS 地址。
meta viewport让页面宽度跟随移动设备,而不是按桌面宽度缩放。
导航权限判断没有模型查看权限时不显示相应入口,但真正安全边界仍在后端视图。
退出表单使用 POST 和 CSRF 令牌,与退出视图约束一致。
消息循环展示登录、创建、同步等动作产生的一次性反馈。
block content给每个具体页面填入自己的正文。

6.3 公开首页模板

完整文件:devopsX/templates/accounts/home.html

{% extends "base.html" %}

{% block title %}devopsX 学习平台{% endblock %}

{% block content %}
<section class="hero">
    <p class="eyebrow">Django DevOps 学习项目</p>
    <h1>devopsX</h1>
    <p>每个业务模块都与 accounts 平级、可以独立运行,组合后形成完整 DevOps 平台。</p>
    {% if user.is_authenticated %}
        {% if perms.cmdb.view_cloudprovider or perms.cmdb.view_cloudaccount or perms.cmdb.view_computeinstance or perms.cmdb.view_syncrun %}
            <a class="button" href="{% url 'cmdb:home' %}">进入 CMDB</a>
        {% else %}
            <p>账号已登录,但尚未获得 CMDB 查看权限。</p>
        {% endif %}
    {% else %}
        <a class="button" href="{% url 'accounts:login' %}">登录后进入 CMDB</a>
    {% endif %}
</section>
{% endblock %}

模板先继承公共布局,再覆盖标题和正文。登录用户看到“进入 CMDB”,匿名用户看到“登录后进入 CMDB”。模板判断只改变显示,不代替后端鉴权。

6.4 登录模板

完整文件:devopsX/templates/accounts/login.html

{% extends "base.html" %}

{% block title %}登录 - devopsX{% endblock %}

{% block content %}
<section class="panel narrow-panel">
    <h1>登录 devopsX</h1>
    <form method="post">
        {% csrf_token %}
        {{ form.as_p }}
        {% if next %}<input type="hidden" name="next" value="{{ next }}">{% endif %}
        <button type="submit" class="button">登录</button>
    </form>
</section>
{% endblock %}

csrf_token 防止跨站伪造 POST;form.as_p 渲染框架表单;安全的 next 值通过隐藏字段随提交返回。

6.5 CMDB 首页模板

完整文件:devopsX/cmdb/templates/cmdb/home.html

{% extends "base.html" %}

{% block title %}CMDB 资产管理 - devopsX{% endblock %}

{% block content %}
<section class="page-heading split-heading">
    <div>
        <p class="eyebrow">Configuration Management Database</p>
        <h1>CMDB 资产管理</h1>
        <p>统一记录云厂商、云账号、地域和计算实例,并保留同步与变更历史。</p>
    </div>
    <div class="actions">
        {% if perms.cmdb.add_cloudaccount and perms.cmdb.view_cloudaccount %}<a class="button" href="{% url 'cmdb:account_create' %}">添加云账号</a>{% endif %}
        {% if perms.cmdb.view_computeinstance %}<a class="button button-secondary" href="{% url 'cmdb:instance_list' %}">查看资产</a>{% endif %}
    </div>
</section>
<section class="stats-grid four-columns" aria-label="CMDB 统计">
    {% if can_view_providers %}<article class="stat-card"><strong>{{ provider_count }}</strong><span>启用云厂商</span></article>{% endif %}
    {% if can_view_accounts %}<article class="stat-card"><strong>{{ account_count }}</strong><span>启用云账号</span></article>{% endif %}
    {% if can_view_instances %}<article class="stat-card"><strong>{{ instance_count }}</strong><span>未退役实例</span></article>
    <article class="stat-card"><strong>{{ missing_count }}</strong><span>本次未发现</span></article>{% endif %}
</section>
{% if can_view_accounts and can_view_instances and not account_count and not instance_count %}
<section class="empty-state">
    <h2>当前没有云账号和资产</h2>
    <p>先运行 bootstrap_cmdb 创建内置云厂商,再添加不保存真实密钥的云账号。</p>
</section>
{% elif can_view_sync_runs %}
<section class="panel">
    <div class="section-heading">
        <h2>最近同步</h2>
        {% if perms.cmdb.view_syncrun %}<a href="{% url 'cmdb:sync_run_list' %}">查看全部</a>{% endif %}
    </div>
    {% if recent_sync_runs %}
        <div class="table-wrap">
            <table>
                <thead><tr><th>云账号</th><th>状态</th><th>发现</th><th>新建</th><th>更新</th><th>开始时间</th></tr></thead>
                <tbody>
                {% for sync_run in recent_sync_runs %}
                    <tr>
                        <td>{% if perms.cmdb.view_syncrun %}<a href="{% url 'cmdb:sync_run_detail' sync_run.public_id %}">{{ sync_run.account.name }}</a>{% else %}{{ sync_run.account.name }}{% endif %}</td>
                        <td><span class="badge badge-{{ sync_run.status }}">{{ sync_run.get_status_display }}</span></td>
                        <td>{{ sync_run.discovered_count }}</td>
                        <td>{{ sync_run.created_count }}</td>
                        <td>{{ sync_run.updated_count }}</td>
                        <td>{{ sync_run.started_at|date:"Y-m-d H:i:s" }}</td>
                    </tr>
                {% endfor %}
                </tbody>
            </table>
        </div>
    {% else %}
        <p class="muted">尚未执行同步。</p>
    {% endif %}
</section>
{% endif %}
{% endblock %}

页面显示四类统计,但每一类都先检查对应查看权限。没有账号和实例时显示明确的下一步;存在同步历史时显示最近五次执行。同步机制留到第 2 篇,本篇只理解页面读取已经落库的数据。

6.6 403 页面

完整文件:devopsX/templates/403.html

{% extends "base.html" %}

{% block title %}没有权限 - devopsX{% endblock %}

{% block content %}
<section class="empty-state"><p class="eyebrow">HTTP 403</p><h1>没有权限访问</h1><p>你已经登录,但当前账号没有执行该操作所需的 Django 权限。</p>{% if perms.cmdb.view_cloudprovider or perms.cmdb.view_cloudaccount or perms.cmdb.view_computeinstance or perms.cmdb.view_syncrun %}<a class="button button-secondary" href="{% url 'cmdb:home' %}">返回 CMDB 首页</a>{% else %}<a class="button button-secondary" href="{% url 'accounts:home' %}">返回平台首页</a>{% endif %}</section>
{% endblock %}

匿名用户由 login_required 跳转登录;已登录但没有权限的用户得到 403。两者不能混为一谈:前者是尚未认证,后者是已经认证但未获授权。

6.7 第一张页面的浏览器预期

  1. 打开 http://127.0.0.1:8000/,匿名用户应看到 devopsX 公开页和登录入口。
  2. 打开 http://127.0.0.1:8000/login/,应看到用户名、密码和登录按钮。
  3. 匿名打开 http://127.0.0.1:8000/cmdb/,应跳到带安全 next 参数的登录页。
  4. 有 CMDB 查看权限的用户登录后打开 http://127.0.0.1:8000/cmdb/,应看到 CMDB 统计首页。

7 CMDB 与云产品术语:先定义,再谈机制

7.1 CMDB 是什么

CMDB 是 Configuration Management Database,即配置管理数据库。这里的“配置项”不是只指配置文件,而是需要被识别、查询、关联和追踪的管理对象,例如云账号、地域、可用区、计算实例和标签。CMDB 不等于监控系统,不保存实时指标;也不等于跳板机,不负责 SSH;更不等于云控制台,不在本篇执行创建或删除云资源。

7.2 云厂商、云账号、地域与可用区

术语定义当前模型
Cloud Provider提供云产品和 API 的厂商或适配器类型CloudProvider
Cloud Account某个厂商下的管理边界、凭据引用和发现范围CloudAccount
Region厂商定义的地理服务区域,通常包含多个可用区CloudRegion
Availability Zone地域内相对隔离的故障域CloudAvailabilityZone
Compute Instance云厂商提供的虚拟计算实例ComputeInstance
Tag附加在实例上的键值元数据ComputeInstanceTag

7.3 Region 与 Availability Zone 为什么不能合并

Region 是较大的服务地域,Availability Zone 是该地域内部的故障隔离单元。一个实例必须属于一个 Region,但厂商数据有时不能提供 Zone,因此实例的可用区允许为空。可用区一旦存在,就必须与实例的 Region 和 Account 一致。把两者合成一个字符串,会失去拓扑关系,也无法验证跨账号或跨地域错误。

7.4 source of truth 是什么

source of truth 是某类事实的权威来源。它不是说整个模型只能由一个系统控制,而是要求每个字段都明确谁有最终解释权:

  • 云 API 是实例规格、云上状态、IP、厂商标签等字段的事实来源。
  • 本地 CMDB 是账号显示名、是否允许同步、地域白名单和人工标签的事实来源。
  • 对账服务根据多次发现结果维护首次发现、最后发现、缺失、恢复和退役等派生状态。

如果不划分所有权,下一次同步可能覆盖人工维护的数据,或者人工修改云上字段后制造假事实。

7.5 稳定厂商资源 ID 为什么比名称和 IP 可靠

实例名称可以重复和修改,IP 可能重分配,显示名可能为空,所以它们不能作为资产身份。当前模型把 provider_resource_id 与云账号组合成稳定身份:同一个账号内,同一个厂商实例 ID 只能出现一次;不同账号可以各自存在相同格式的 ID。地域和可用区采用相同原则。

稳定不代表永远不变,而是表示在该厂商资源生命周期内,由厂商分配、比名称和地址更适合作为幂等同步键。若厂商删除资源后重新创建,通常会得到新资源 ID,CMDB 才能区分新旧资产。

7.6 三类字段所有权

所有权代表字段写入规则
provider-owned地域、可用区、名称、规格、vCPU、内存、OS、厂商状态、标准状态、IP、云上创建时间、厂商标签完整或部分发现快照经过验证后由同步服务更新,页面不把它们当人工事实。
local-owned账号键、显示名、凭据前缀、地域白名单、启停开关、人工标签、权限由本地管理员、表单或受控导入维护;云同步不得删除人工标签。
reconciliation-owned首次发现、最后发现、缺失时间、生命周期、退役时间、同步统计和变化记录由对账逻辑根据当前快照与历史状态计算,不由云 SDK 直接决定。

7.7 为什么数据库只保存 credential_profile

credential_profile 保存的是大写环境变量前缀,例如“某个部署环境为某个账号选定的前缀名称”,而不是 AccessKey 本身。Provider 运行时根据前缀拼出环境变量名,再从进程环境读取值。这样做减少了真实凭据出现在数据库备份、Admin 页面、CSV、API 响应和普通查询日志中的机会。

这并不表示环境变量天然等于专业密钥系统。生产环境仍应由部署平台、Secret Manager 或等价机制注入,并控制读取权限、轮换和审计。CMDB 只保存“去哪里取”的引用,不保存“秘密是什么”。

8 CloudProvider、CloudAccount、Region、Zone、ComputeInstance 与标签模型

8.1 ORM 模型、字段、外键和约束

模型是 Python 类,Django ORM 用它描述数据库表。字段对应列,模型对象通常对应一行。ForeignKey 表示多对一关系;UniqueConstraint 让数据库拒绝重复身份;Index 为常用筛选建立索引;PROTECT 在仍被资产引用时拒绝删除上游对象;CASCADE 在删除实例时一并删除只属于该实例的标签。

8.2 完整模型文件

下面是权威树中完整的最终 devopsX/cmdb/models.py。它同时包含第 2 篇要使用的同步执行和变化模型;本篇先完整落盘以保持最终文件一致,但只深入解释本篇范围内的基础资产模型。不要删去后半部分,也不要用占位代码替代。

完整文件:devopsX/cmdb/models.py

import re
import uuid

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone


def validate_credential_profile(value):
    if value and not re.fullmatch(r"[A-Z][A-Z0-9_]*", value):
        raise ValidationError("凭据前缀只能使用大写字母、数字和下划线。")


def validate_region_allowlist(value):
    if not isinstance(value, list):
        raise ValidationError("地域白名单必须是地域 ID 列表。")
    normalized = []
    for item in value:
        if not isinstance(item, str) or not item.strip():
            raise ValidationError("地域白名单只能包含非空字符串。")
        region_id = item.strip()
        if len(region_id) > 100:
            raise ValidationError("地域 ID 不能超过 100 个字符。")
        if region_id in normalized:
            raise ValidationError("地域白名单不能包含重复地域 ID。")
        normalized.append(region_id)
    if normalized != value:
        raise ValidationError("地域 ID 首尾不能包含空格。")


class CloudProvider(models.Model):
    code = models.SlugField("代码", max_length=32, unique=True)
    name = models.CharField("名称", max_length=100)
    is_active = models.BooleanField("启用", default=True)
    created_at = models.DateTimeField("创建时间", auto_now_add=True)
    updated_at = models.DateTimeField("更新时间", auto_now=True)

    class Meta:
        ordering = ["code"]
        verbose_name = "云厂商"
        verbose_name_plural = "云厂商"

    def __str__(self):
        return "%s - %s" % (self.code, self.name)


class CloudAccount(models.Model):
    provider = models.ForeignKey(
        CloudProvider,
        on_delete=models.PROTECT,
        related_name="accounts",
        verbose_name="云厂商",
    )
    account_key = models.SlugField("账号键", max_length=100)
    name = models.CharField("显示名称", max_length=100)
    credential_profile = models.CharField(
        "凭据环境变量前缀",
        max_length=100,
        blank=True,
        validators=[validate_credential_profile],
    )
    region_allowlist = models.JSONField(
        "地域白名单",
        default=list,
        blank=True,
        validators=[validate_region_allowlist],
    )
    is_active = models.BooleanField("启用", default=True)
    sync_enabled = models.BooleanField("允许同步", default=True)
    last_successful_sync_at = models.DateTimeField(
        "最后成功同步时间",
        null=True,
        blank=True,
    )
    created_at = models.DateTimeField("创建时间", auto_now_add=True)
    updated_at = models.DateTimeField("更新时间", auto_now=True)

    class Meta:
        ordering = ["provider__code", "account_key"]
        constraints = [
            models.UniqueConstraint(
                fields=["provider", "account_key"],
                name="cmdb_unique_provider_account_key",
            )
        ]
        permissions = [
            ("sync_cloudaccount", "可以同步云账号"),
            ("import_cloudaccount", "可以导入云账号"),
        ]
        verbose_name = "云账号"
        verbose_name_plural = "云账号"

    def __str__(self):
        return "%s - %s" % (self.provider.code, self.name)


class CloudRegion(models.Model):
    account = models.ForeignKey(
        CloudAccount,
        on_delete=models.PROTECT,
        related_name="regions",
        verbose_name="云账号",
    )
    provider_resource_id = models.CharField("厂商地域 ID", max_length=100)
    name = models.CharField("名称", max_length=100)
    endpoint = models.CharField("服务端点", max_length=255, blank=True)
    is_active = models.BooleanField("当前存在", default=True)
    first_seen_at = models.DateTimeField("首次发现时间", default=timezone.now)
    last_seen_at = models.DateTimeField("最后发现时间", default=timezone.now)

    class Meta:
        ordering = ["account", "provider_resource_id"]
        constraints = [
            models.UniqueConstraint(
                fields=["account", "provider_resource_id"],
                name="cmdb_unique_account_region_id",
            )
        ]
        verbose_name = "云地域"
        verbose_name_plural = "云地域"

    def __str__(self):
        return "%s - %s" % (self.account.account_key, self.name)


class CloudAvailabilityZone(models.Model):
    account = models.ForeignKey(
        CloudAccount,
        on_delete=models.PROTECT,
        related_name="availability_zones",
        verbose_name="云账号",
    )
    region = models.ForeignKey(
        CloudRegion,
        on_delete=models.PROTECT,
        related_name="availability_zones",
        verbose_name="云地域",
    )
    provider_resource_id = models.CharField("厂商可用区 ID", max_length=100)
    name = models.CharField("名称", max_length=100)
    is_active = models.BooleanField("当前存在", default=True)
    first_seen_at = models.DateTimeField("首次发现时间", default=timezone.now)
    last_seen_at = models.DateTimeField("最后发现时间", default=timezone.now)

    class Meta:
        ordering = ["account", "region", "provider_resource_id"]
        constraints = [
            models.UniqueConstraint(
                fields=["account", "provider_resource_id"],
                name="cmdb_unique_account_zone_id",
            )
        ]
        verbose_name = "可用区"
        verbose_name_plural = "可用区"

    def clean(self):
        super().clean()
        if self.region_id and self.account_id:
            try:
                region = self.region
            except CloudRegion.DoesNotExist:
                region = None
            if region is not None and region.account_id != self.account_id:
                raise ValidationError("可用区账号必须与地域账号一致。")

    def __str__(self):
        return "%s - %s" % (self.region.provider_resource_id, self.name)


class ComputeInstance(models.Model):
    class NormalizedStatus(models.TextChoices):
        RUNNING = "running", "运行中"
        STOPPED = "stopped", "已停止"
        STARTING = "starting", "启动中"
        STOPPING = "stopping", "停止中"
        UNKNOWN = "unknown", "未知"

    class LifecycleState(models.TextChoices):
        PRESENT = "present", "当前存在"
        MISSING = "missing", "本次未发现"
        RETIRED = "retired", "已退役"

    account = models.ForeignKey(
        CloudAccount,
        on_delete=models.PROTECT,
        related_name="compute_instances",
        verbose_name="云账号",
    )
    region = models.ForeignKey(
        CloudRegion,
        on_delete=models.PROTECT,
        related_name="compute_instances",
        verbose_name="云地域",
    )
    availability_zone = models.ForeignKey(
        CloudAvailabilityZone,
        on_delete=models.PROTECT,
        related_name="compute_instances",
        verbose_name="可用区",
        null=True,
        blank=True,
    )
    provider_resource_id = models.CharField("厂商实例 ID", max_length=100)
    name = models.CharField("实例名称", max_length=255, blank=True)
    instance_type = models.CharField("实例规格", max_length=100, blank=True)
    vcpu = models.PositiveIntegerField("vCPU", default=0)
    memory_mb = models.PositiveIntegerField("内存 MiB", default=0)
    os_name = models.CharField("操作系统", max_length=255, blank=True)
    provider_status = models.CharField("厂商原始状态", max_length=100, blank=True)
    normalized_status = models.CharField(
        "标准状态",
        max_length=20,
        choices=NormalizedStatus.choices,
        default=NormalizedStatus.UNKNOWN,
    )
    lifecycle_state = models.CharField(
        "生命周期",
        max_length=20,
        choices=LifecycleState.choices,
        default=LifecycleState.PRESENT,
    )
    private_ips = models.JSONField("私网 IP", default=list, blank=True)
    public_ips = models.JSONField("公网 IP", default=list, blank=True)
    cloud_created_at = models.DateTimeField("云上创建时间", null=True, blank=True)
    first_seen_at = models.DateTimeField("首次发现时间", default=timezone.now)
    last_seen_at = models.DateTimeField("最后发现时间", default=timezone.now)
    missing_since = models.DateTimeField("开始缺失时间", null=True, blank=True)
    retired_at = models.DateTimeField("退役时间", null=True, blank=True)
    created_at = models.DateTimeField("创建时间", auto_now_add=True)
    updated_at = models.DateTimeField("更新时间", auto_now=True)

    class Meta:
        ordering = ["account", "provider_resource_id"]
        constraints = [
            models.UniqueConstraint(
                fields=["account", "provider_resource_id"],
                name="cmdb_unique_account_instance_id",
            )
        ]
        indexes = [
            models.Index(
                fields=["lifecycle_state", "normalized_status"],
                name="cmdb_inst_life_status_idx",
            ),
            models.Index(fields=["name"], name="cmdb_inst_name_idx"),
        ]
        permissions = [
            ("export_computeinstance", "可以导出计算实例"),
            ("retire_computeinstance", "可以退役计算实例"),
        ]
        verbose_name = "计算实例"
        verbose_name_plural = "计算实例"

    def clean(self):
        super().clean()
        region = None
        if self.region_id and self.account_id:
            try:
                region = self.region
            except CloudRegion.DoesNotExist:
                pass
            if region is not None and region.account_id != self.account_id:
                raise ValidationError("实例账号必须与地域账号一致。")
        if self.availability_zone_id:
            try:
                availability_zone = self.availability_zone
            except CloudAvailabilityZone.DoesNotExist:
                availability_zone = None
            if availability_zone is not None:
                if availability_zone.account_id != self.account_id:
                    raise ValidationError("实例账号必须与可用区账号一致。")
                if availability_zone.region_id != self.region_id:
                    raise ValidationError("实例地域必须与可用区所属地域一致。")

    def __str__(self):
        return "%s - %s" % (self.provider_resource_id, self.name or "未命名")


class ComputeInstanceTag(models.Model):
    class Source(models.TextChoices):
        PROVIDER = "provider", "云厂商"
        MANUAL = "manual", "人工维护"

    instance = models.ForeignKey(
        ComputeInstance,
        on_delete=models.CASCADE,
        related_name="tags",
        verbose_name="计算实例",
    )
    key = models.CharField("键", max_length=100)
    value = models.CharField("值", max_length=255, blank=True)
    source = models.CharField("来源", max_length=20, choices=Source.choices)
    created_at = models.DateTimeField("创建时间", auto_now_add=True)
    updated_at = models.DateTimeField("更新时间", auto_now=True)

    class Meta:
        ordering = ["source", "key"]
        constraints = [
            models.UniqueConstraint(
                fields=["instance", "source", "key"],
                name="cmdb_unique_instance_tag_source_key",
            )
        ]
        verbose_name = "计算实例标签"
        verbose_name_plural = "计算实例标签"

    def __str__(self):
        return "%s=%s" % (self.key, self.value)


class SyncRun(models.Model):
    class Status(models.TextChoices):
        PENDING = "pending", "等待中"
        RUNNING = "running", "运行中"
        SUCCEEDED = "succeeded", "成功"
        PARTIAL = "partial", "部分成功"
        FAILED = "failed", "失败"

    class Trigger(models.TextChoices):
        MANUAL = "manual", "页面手动触发"
        COMMAND = "command", "管理命令触发"
        API = "api", "API 触发"

    public_id = models.UUIDField("公开 ID", default=uuid.uuid4, unique=True, editable=False)
    account = models.ForeignKey(
        CloudAccount,
        on_delete=models.PROTECT,
        related_name="sync_runs",
        verbose_name="云账号",
    )
    status = models.CharField(
        "状态",
        max_length=20,
        choices=Status.choices,
        default=Status.PENDING,
    )
    trigger = models.CharField("触发方式", max_length=20, choices=Trigger.choices)
    requested_by = models.ForeignKey(
        settings.AUTH_USER_MODEL,
        on_delete=models.SET_NULL,
        related_name="cmdb_sync_runs",
        verbose_name="请求用户",
        null=True,
        blank=True,
    )
    started_at = models.DateTimeField("开始时间", null=True, blank=True)
    finished_at = models.DateTimeField("结束时间", null=True, blank=True)
    discovered_count = models.PositiveIntegerField("发现数量", default=0)
    created_count = models.PositiveIntegerField("新建数量", default=0)
    updated_count = models.PositiveIntegerField("更新数量", default=0)
    unchanged_count = models.PositiveIntegerField("未变化数量", default=0)
    missing_count = models.PositiveIntegerField("标记缺失数量", default=0)
    restored_count = models.PositiveIntegerField("恢复数量", default=0)
    retired_count = models.PositiveIntegerField("退役数量", default=0)
    error_code = models.CharField("错误代码", max_length=100, blank=True)
    error_message = models.TextField("错误信息", blank=True)
    created_at = models.DateTimeField("创建时间", auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]
        verbose_name = "同步执行"
        verbose_name_plural = "同步执行"

    def __str__(self):
        return "%s - %s" % (self.account.account_key, self.public_id)


class ComputeInstanceChange(models.Model):
    class Action(models.TextChoices):
        CREATED = "created", "创建"
        UPDATED = "updated", "更新"
        MARKED_MISSING = "marked_missing", "标记缺失"
        RESTORED = "restored", "恢复"
        RETIRED = "retired", "退役"

    sync_run = models.ForeignKey(
        SyncRun,
        on_delete=models.PROTECT,
        related_name="changes",
        verbose_name="同步执行",
        null=True,
        blank=True,
    )
    instance = models.ForeignKey(
        ComputeInstance,
        on_delete=models.PROTECT,
        related_name="changes",
        verbose_name="计算实例",
    )
    action = models.CharField("动作", max_length=30, choices=Action.choices)
    changed_fields = models.JSONField("变化字段", default=list, blank=True)
    before_data = models.JSONField("变化前", default=dict, blank=True)
    after_data = models.JSONField("变化后", default=dict, blank=True)
    created_at = models.DateTimeField("创建时间", auto_now_add=True)

    class Meta:
        ordering = ["-created_at"]
        verbose_name = "计算实例变化"
        verbose_name_plural = "计算实例变化"

    def __str__(self):
        return "%s - %s" % (self.instance.provider_resource_id, self.action)

8.3 校验函数逐组解释

  • validate_credential_profile 使用完整匹配,只允许以大写字母开头,后续为大写字母、数字或下划线。空字符串表示暂未配置。
  • validate_region_allowlist 要求值本身是列表,每一项是去除首尾空格后的非空字符串,长度不超过 100,且不能重复。
  • 模型字段验证不会在普通 save() 时自动执行,所以表单、CSV 服务和测试会显式调用验证;数据库唯一约束则始终由数据库兜底。

8.4 CloudProvider 逐组解释

  • code 是程序稳定识别的短代码,唯一且适合 URL、配置和适配器选择。
  • name 是给人看的名称,可以调整而不改变代码身份。
  • is_active 控制厂商是否可用于同步,不通过物理删除表达停用。
  • created_at 只在创建时写入,updated_at 每次保存更新。
  • ordering 让默认查询按代码排序;__str__ 让 Admin 下拉框可读。

8.5 CloudAccount 逐组解释

  • provider 使用 PROTECT:已有账号时不能删除厂商。related_name="accounts" 允许从厂商反查账号。
  • account_key 是平台内稳定账号键;name 是显示名。唯一约束作用在 provider + account_key,因此不同厂商可以使用相同账号键。
  • credential_profile 只保存凭据环境变量前缀,并挂接前述验证器。
  • region_allowlist 是 JSON 列表。空列表表示发现全部地域;非空列表限制发现范围。
  • is_active 表示账号是否启用,sync_enabled 单独控制是否允许发现同步。
  • last_successful_sync_at 只记录完整成功快照,部分结果不能冒充成功。
  • 自定义权限分别控制同步和导入;Django 仍自动创建 add、change、delete、view 权限。

8.6 CloudRegion 与 CloudAvailabilityZone 逐组解释

  • 地域身份是 account + provider_resource_id,不是中文名称。
  • endpoint 保存厂商返回的服务端点;它是发现数据,不是凭据。
  • is_active 表示当前快照是否仍存在,不立即删除历史对象。
  • first_seen_at 和 last_seen_at 属于对账字段。
  • 可用区同时保存 account 和 region,便于查询并建立明确账号边界。
  • clean() 检查可用区账号必须等于地域账号,阻止逻辑上不可能的跨账号关系。

8.7 ComputeInstance 逐组解释

字段组意义
NormalizedStatus把不同厂商状态归一为运行、停止、启动、停止中和未知。
LifecycleState区分本次存在、本次未发现和人工退役,不把“云上停止”误当“资产消失”。
账号、地域、可用区实例必须属于账号和地域;可用区允许为空,但存在时必须与账号、地域一致。
provider_resource_id厂商实例稳定 ID,与账号共同形成唯一身份。
规格和操作系统保存实例类型、vCPU、MiB 内存和 OS 名称。
双状态provider_status 保留原始值,normalized_status 支持跨云统一筛选。
IP JSON 列表一台实例可能有多个私网或公网地址,不能压成单个字符串。
时间字段区分云上创建、首次发现、最后发现、开始缺失、本地创建更新和退役时间。
索引生命周期与标准状态组合索引服务常用筛选,名称索引服务排序和部分查询。
clean()逐层验证账号、地域和可用区的一致性。

8.8 ComputeInstanceTag 逐组解释

标签键值本身不足以表达所有权,因此增加 source。同一实例可以同时存在厂商来源的 owner 和人工来源的 owner,唯一约束是 instance + source + key。同步只替换 provider 来源标签,不能删除 manual 来源标签。标签依赖实例存在,因此外键使用 CASCADE。

8.9 SyncRun 与 ComputeInstanceChange 在本篇的位置

SyncRun 记录一次发现的公开 UUID、状态、触发方式、统计和脱敏错误;ComputeInstanceChange 记录一次资产变化前后。本篇保留最终模型以确保迁移一致,但不提前把同步算法塞进模型。第 2 篇会解释 Provider Adapter、完整与部分快照、幂等、缺失、恢复、退役和并发保护。

9 迁移、Admin、列表、详情、检查点与测试

9.1 迁移是什么,为什么不能只看 models.py

迁移是数据库结构变更历史。devopsX/cmdb/models.py 描述当前目标状态,迁移文件描述数据库如何一步步到达该状态。团队已经发布的迁移不应随意删除、改名或重写,否则其他环境的迁移历史会分叉。

当前 v1.0.0 有两个 CMDB 迁移:0001_initial 创建模型、关系、索引和约束;0002 把凭据前缀和地域白名单验证器写入迁移状态。下面两份文件均为 Django 5.2.17 实际生成的完整内容。

9.2 初始迁移完整文件

完整文件:devopsX/cmdb/migrations/0001_initial.py

# Generated by Django 5.2.17 on 2026-09-16 10:10

import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

    initial = True

    dependencies = [
        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
    ]

    operations = [
        migrations.CreateModel(
            name='CloudProvider',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('code', models.SlugField(max_length=32, unique=True, verbose_name='代码')),
                ('name', models.CharField(max_length=100, verbose_name='名称')),
                ('is_active', models.BooleanField(default=True, verbose_name='启用')),
                ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
                ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
            ],
            options={
                'verbose_name': '云厂商',
                'verbose_name_plural': '云厂商',
                'ordering': ['code'],
            },
        ),
        migrations.CreateModel(
            name='CloudAccount',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('account_key', models.SlugField(max_length=100, verbose_name='账号键')),
                ('name', models.CharField(max_length=100, verbose_name='显示名称')),
                ('credential_profile', models.CharField(blank=True, max_length=100, verbose_name='凭据环境变量前缀')),
                ('region_allowlist', models.JSONField(blank=True, default=list, verbose_name='地域白名单')),
                ('is_active', models.BooleanField(default=True, verbose_name='启用')),
                ('sync_enabled', models.BooleanField(default=True, verbose_name='允许同步')),
                ('last_successful_sync_at', models.DateTimeField(blank=True, null=True, verbose_name='最后成功同步时间')),
                ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
                ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
                ('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accounts', to='cmdb.cloudprovider', verbose_name='云厂商')),
            ],
            options={
                'verbose_name': '云账号',
                'verbose_name_plural': '云账号',
                'ordering': ['provider__code', 'account_key'],
                'permissions': [('sync_cloudaccount', '可以同步云账号'), ('import_cloudaccount', '可以导入云账号')],
            },
        ),
        migrations.CreateModel(
            name='CloudRegion',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('provider_resource_id', models.CharField(max_length=100, verbose_name='厂商地域 ID')),
                ('name', models.CharField(max_length=100, verbose_name='名称')),
                ('endpoint', models.CharField(blank=True, max_length=255, verbose_name='服务端点')),
                ('is_active', models.BooleanField(default=True, verbose_name='当前存在')),
                ('first_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='首次发现时间')),
                ('last_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='最后发现时间')),
                ('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='regions', to='cmdb.cloudaccount', verbose_name='云账号')),
            ],
            options={
                'verbose_name': '云地域',
                'verbose_name_plural': '云地域',
                'ordering': ['account', 'provider_resource_id'],
            },
        ),
        migrations.CreateModel(
            name='CloudAvailabilityZone',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('provider_resource_id', models.CharField(max_length=100, verbose_name='厂商可用区 ID')),
                ('name', models.CharField(max_length=100, verbose_name='名称')),
                ('is_active', models.BooleanField(default=True, verbose_name='当前存在')),
                ('first_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='首次发现时间')),
                ('last_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='最后发现时间')),
                ('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='availability_zones', to='cmdb.cloudaccount', verbose_name='云账号')),
                ('region', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='availability_zones', to='cmdb.cloudregion', verbose_name='云地域')),
            ],
            options={
                'verbose_name': '可用区',
                'verbose_name_plural': '可用区',
                'ordering': ['account', 'region', 'provider_resource_id'],
            },
        ),
        migrations.CreateModel(
            name='ComputeInstance',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('provider_resource_id', models.CharField(max_length=100, verbose_name='厂商实例 ID')),
                ('name', models.CharField(blank=True, max_length=255, verbose_name='实例名称')),
                ('instance_type', models.CharField(blank=True, max_length=100, verbose_name='实例规格')),
                ('vcpu', models.PositiveIntegerField(default=0, verbose_name='vCPU')),
                ('memory_mb', models.PositiveIntegerField(default=0, verbose_name='内存 MiB')),
                ('os_name', models.CharField(blank=True, max_length=255, verbose_name='操作系统')),
                ('provider_status', models.CharField(blank=True, max_length=100, verbose_name='厂商原始状态')),
                ('normalized_status', models.CharField(choices=[('running', '运行中'), ('stopped', '已停止'), ('starting', '启动中'), ('stopping', '停止中'), ('unknown', '未知')], default='unknown', max_length=20, verbose_name='标准状态')),
                ('lifecycle_state', models.CharField(choices=[('present', '当前存在'), ('missing', '本次未发现'), ('retired', '已退役')], default='present', max_length=20, verbose_name='生命周期')),
                ('private_ips', models.JSONField(blank=True, default=list, verbose_name='私网 IP')),
                ('public_ips', models.JSONField(blank=True, default=list, verbose_name='公网 IP')),
                ('cloud_created_at', models.DateTimeField(blank=True, null=True, verbose_name='云上创建时间')),
                ('first_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='首次发现时间')),
                ('last_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='最后发现时间')),
                ('missing_since', models.DateTimeField(blank=True, null=True, verbose_name='开始缺失时间')),
                ('retired_at', models.DateTimeField(blank=True, null=True, verbose_name='退役时间')),
                ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
                ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
                ('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='compute_instances', to='cmdb.cloudaccount', verbose_name='云账号')),
                ('availability_zone', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='compute_instances', to='cmdb.cloudavailabilityzone', verbose_name='可用区')),
                ('region', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='compute_instances', to='cmdb.cloudregion', verbose_name='云地域')),
            ],
            options={
                'verbose_name': '计算实例',
                'verbose_name_plural': '计算实例',
                'ordering': ['account', 'provider_resource_id'],
                'permissions': [('export_computeinstance', '可以导出计算实例'), ('retire_computeinstance', '可以退役计算实例')],
            },
        ),
        migrations.CreateModel(
            name='ComputeInstanceTag',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('key', models.CharField(max_length=100, verbose_name='键')),
                ('value', models.CharField(blank=True, max_length=255, verbose_name='值')),
                ('source', models.CharField(choices=[('provider', '云厂商'), ('manual', '人工维护')], max_length=20, verbose_name='来源')),
                ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
                ('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
                ('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='cmdb.computeinstance', verbose_name='计算实例')),
            ],
            options={
                'verbose_name': '计算实例标签',
                'verbose_name_plural': '计算实例标签',
                'ordering': ['source', 'key'],
            },
        ),
        migrations.CreateModel(
            name='SyncRun',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='公开 ID')),
                ('status', models.CharField(choices=[('pending', '等待中'), ('running', '运行中'), ('succeeded', '成功'), ('partial', '部分成功'), ('failed', '失败')], default='pending', max_length=20, verbose_name='状态')),
                ('trigger', models.CharField(choices=[('manual', '页面手动触发'), ('command', '管理命令触发'), ('api', 'API 触发')], max_length=20, verbose_name='触发方式')),
                ('started_at', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')),
                ('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')),
                ('discovered_count', models.PositiveIntegerField(default=0, verbose_name='发现数量')),
                ('created_count', models.PositiveIntegerField(default=0, verbose_name='新建数量')),
                ('updated_count', models.PositiveIntegerField(default=0, verbose_name='更新数量')),
                ('unchanged_count', models.PositiveIntegerField(default=0, verbose_name='未变化数量')),
                ('missing_count', models.PositiveIntegerField(default=0, verbose_name='标记缺失数量')),
                ('restored_count', models.PositiveIntegerField(default=0, verbose_name='恢复数量')),
                ('retired_count', models.PositiveIntegerField(default=0, verbose_name='退役数量')),
                ('error_code', models.CharField(blank=True, max_length=100, verbose_name='错误代码')),
                ('error_message', models.TextField(blank=True, verbose_name='错误信息')),
                ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
                ('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sync_runs', to='cmdb.cloudaccount', verbose_name='云账号')),
                ('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cmdb_sync_runs', to=settings.AUTH_USER_MODEL, verbose_name='请求用户')),
            ],
            options={
                'verbose_name': '同步执行',
                'verbose_name_plural': '同步执行',
                'ordering': ['-created_at'],
            },
        ),
        migrations.CreateModel(
            name='ComputeInstanceChange',
            fields=[
                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
                ('action', models.CharField(choices=[('created', '创建'), ('updated', '更新'), ('marked_missing', '标记缺失'), ('restored', '恢复'), ('retired', '退役')], max_length=30, verbose_name='动作')),
                ('changed_fields', models.JSONField(blank=True, default=list, verbose_name='变化字段')),
                ('before_data', models.JSONField(blank=True, default=dict, verbose_name='变化前')),
                ('after_data', models.JSONField(blank=True, default=dict, verbose_name='变化后')),
                ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
                ('instance', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='changes', to='cmdb.computeinstance', verbose_name='计算实例')),
                ('sync_run', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='changes', to='cmdb.syncrun', verbose_name='同步执行')),
            ],
            options={
                'verbose_name': '计算实例变化',
                'verbose_name_plural': '计算实例变化',
                'ordering': ['-created_at'],
            },
        ),
        migrations.AddConstraint(
            model_name='cloudaccount',
            constraint=models.UniqueConstraint(fields=('provider', 'account_key'), name='cmdb_unique_provider_account_key'),
        ),
        migrations.AddConstraint(
            model_name='cloudregion',
            constraint=models.UniqueConstraint(fields=('account', 'provider_resource_id'), name='cmdb_unique_account_region_id'),
        ),
        migrations.AddConstraint(
            model_name='cloudavailabilityzone',
            constraint=models.UniqueConstraint(fields=('account', 'provider_resource_id'), name='cmdb_unique_account_zone_id'),
        ),
        migrations.AddIndex(
            model_name='computeinstance',
            index=models.Index(fields=['lifecycle_state', 'normalized_status'], name='cmdb_inst_life_status_idx'),
        ),
        migrations.AddIndex(
            model_name='computeinstance',
            index=models.Index(fields=['name'], name='cmdb_inst_name_idx'),
        ),
        migrations.AddConstraint(
            model_name='computeinstance',
            constraint=models.UniqueConstraint(fields=('account', 'provider_resource_id'), name='cmdb_unique_account_instance_id'),
        ),
        migrations.AddConstraint(
            model_name='computeinstancetag',
            constraint=models.UniqueConstraint(fields=('instance', 'source', 'key'), name='cmdb_unique_instance_tag_source_key'),
        ),
    ]
操作组作用
swappable_dependency先依赖当前项目用户模型,因为同步执行会引用请求用户。
六个基础与两个历史模型按外键依赖顺序创建厂商、账号、地域、可用区、实例、标签、同步执行和变化记录。
AddConstraint在数据库层固定账号、地域、可用区、实例和标签身份唯一性。
AddIndex为实例生命周期、标准状态和名称建立索引。

9.3 验证器迁移完整文件

完整文件:devopsX/cmdb/migrations/0002_alter_cloudaccount_credential_profile_and_more.py

# Generated by Django 5.2.17 on 2026-09-16 11:35

import cmdb.models
from django.db import migrations, models


class Migration(migrations.Migration):

    dependencies = [
        ('cmdb', '0001_initial'),
    ]

    operations = [
        migrations.AlterField(
            model_name='cloudaccount',
            name='credential_profile',
            field=models.CharField(blank=True, max_length=100, validators=[cmdb.models.validate_credential_profile], verbose_name='凭据环境变量前缀'),
        ),
        migrations.AlterField(
            model_name='cloudaccount',
            name='region_allowlist',
            field=models.JSONField(blank=True, default=list, validators=[cmdb.models.validate_region_allowlist], verbose_name='地域白名单'),
        ),
    ]

这个迁移依赖 0001_initial,通过两个 AlterField 把 Python 验证器纳入 Django 的迁移状态。验证器主要由表单和 full_clean() 执行;它不是数据库 CHECK 约束。

9.4 迁移命令与预期结果

先检查当前模型是否相对已提交迁移发生漂移。--check 在发现漂移时返回非零状态,--dry-run 不写新文件。

执行位置:devopsX/;检查 devopsX/accounts/migrations/ 与 devopsX/cmdb/migrations/

python manage.py makemigrations --check --dry-run

预期输出对应位置:devopsX/

No changes detected

然后应用迁移。

执行位置:devopsX/;SQLite 文件将位于 devopsX/db.sqlite3

python manage.py migrate

输出会按依赖顺序列出 Django 内置迁移、accounts.0001_initial、cmdb.0001_initial 和 cmdb.0002,每个成功项以 OK 结束。若提示表已存在,不要直接删除迁移文件;先确认是否在错误数据库中运行,或者数据库与迁移记录是否被手工改动。

9.5 Admin 完整配置

完整文件:devopsX/cmdb/admin.py

from django.contrib import admin

from .models import (
    CloudAccount,
    CloudAvailabilityZone,
    CloudProvider,
    CloudRegion,
    ComputeInstance,
    ComputeInstanceChange,
    ComputeInstanceTag,
    SyncRun,
)


class ReadOnlyAdminMixin:
    def has_add_permission(self, request):
        return False

    def has_change_permission(self, request, obj=None):
        return False

    def has_delete_permission(self, request, obj=None):
        return False

    def get_readonly_fields(self, request, obj=None):
        return tuple(field.name for field in self.model._meta.concrete_fields)


@admin.register(CloudProvider)
class CloudProviderAdmin(admin.ModelAdmin):
    list_display = ("code", "name", "is_active", "updated_at")
    list_filter = ("is_active",)
    search_fields = ("code", "name")


@admin.register(CloudAccount)
class CloudAccountAdmin(admin.ModelAdmin):
    list_display = (
        "account_key",
        "name",
        "provider",
        "is_active",
        "sync_enabled",
        "last_successful_sync_at",
    )
    list_filter = ("provider", "is_active", "sync_enabled")
    search_fields = ("account_key", "name", "credential_profile")


@admin.register(CloudRegion)
class CloudRegionAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
    list_display = (
        "provider_resource_id",
        "name",
        "account",
        "is_active",
        "last_seen_at",
    )
    list_filter = ("account__provider", "account", "is_active")
    search_fields = ("provider_resource_id", "name")


@admin.register(CloudAvailabilityZone)
class CloudAvailabilityZoneAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
    list_display = (
        "provider_resource_id",
        "name",
        "region",
        "account",
        "is_active",
    )
    list_filter = ("account__provider", "account", "region", "is_active")
    search_fields = ("provider_resource_id", "name")


@admin.register(ComputeInstance)
class ComputeInstanceAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
    list_display = (
        "provider_resource_id",
        "name",
        "account",
        "region",
        "normalized_status",
        "lifecycle_state",
        "last_seen_at",
    )
    list_filter = (
        "account__provider",
        "account",
        "region",
        "normalized_status",
        "lifecycle_state",
    )
    search_fields = ("provider_resource_id", "name", "private_ips", "public_ips")


@admin.register(ComputeInstanceTag)
class ComputeInstanceTagAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
    list_display = ("instance", "key", "value", "source", "updated_at")
    list_filter = ("source",)
    search_fields = ("key", "value", "instance__provider_resource_id")


@admin.register(SyncRun)
class SyncRunAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
    list_display = (
        "public_id",
        "account",
        "status",
        "trigger",
        "discovered_count",
        "created_count",
        "updated_count",
        "created_at",
    )
    list_filter = ("status", "trigger", "account__provider", "account")
    search_fields = ("public_id", "account__account_key", "error_code")
    readonly_fields = (
        "public_id",
        "started_at",
        "finished_at",
        "created_at",
    )


@admin.register(ComputeInstanceChange)
class ComputeInstanceChangeAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
    list_display = ("instance", "action", "sync_run", "created_at")
    list_filter = ("action",)
    search_fields = ("instance__provider_resource_id", "instance__name")
    readonly_fields = (
        "sync_run",
        "instance",
        "action",
        "changed_fields",
        "before_data",
        "after_data",
        "created_at",
    )
配置组作用
register 装饰器把每个模型与对应 ModelAdmin 绑定。
list_display定义列表列,优先展示稳定 ID、归属、状态和最后发现时间。
list_filter在右侧生成厂商、账号、地域和状态筛选。
search_fields允许按 ID、名称、IP 或标签搜索。
ReadOnlyAdminMixin统一关闭新增、修改和删除,并把模型全部具体字段设为只读。
可配置对象CloudProvider 与 CloudAccount 仍可由管理员维护启用状态、显示名、凭据前缀和地域白名单。
同步所有对象地域、可用区、实例、Provider 标签、同步执行和变化历史只能查看,不能从 Admin 绕过同步与审计边界修改。
人工标签不再使用 Admin 内联维护;必须通过实例详情页的受控 POST 表单和权限检查维护。

9.6 创建超级用户并打开 Admin 列表与详情

创建超级用户时,终端会交互式要求用户名、邮箱和密码。密码只在本机输入,不要写进教程、命令历史或源码。

执行位置:devopsX/

python manage.py createsuperuser

启动开发服务器。

执行位置:devopsX/

python manage.py runserver

预期关键输出对应位置:devopsX/

Watching for file changes with StatReloader
System check identified no issues (0 silenced).
Starting development server at http://127.0.0.1:8000/

打开 http://127.0.0.1:8000/admin/。在“CMDB 资产管理”分组中应看到云厂商、云账号、云地域、可用区、计算实例、计算实例标签、同步执行和计算实例变化。云厂商和云账号仍可配置;地域、可用区、实例、标签、同步执行与变化历史的详情页只提供查看,不显示保存、另存、删除或新增入口。

9.7 内置云厂商初始化命令

为了避免每次手工创建基础厂商,项目提供幂等命令。幂等表示重复执行不会重复插入同一厂商,而是按稳定代码创建或更新名称。

完整文件:devopsX/cmdb/management/commands/bootstrap_cmdb.py

from django.core.management.base import BaseCommand

from cmdb.models import CloudProvider


class Command(BaseCommand):
    help = "创建 CMDB v1 内置云厂商"

    def handle(self, *args, **options):
        providers = (
            ("fake", "Fake 教学云"),
            ("aliyun", "阿里云"),
        )
        for code, name in providers:
            provider, created = CloudProvider.objects.update_or_create(
                code=code,
                defaults={"name": name},
            )
            action = "创建" if created else "更新"
            self.stdout.write("%s云厂商:%s" % (action, provider))

导入 BaseCommand 是为了定义 Django 管理命令;providers 保存稳定代码和显示名;update_or_create 按代码查找并写入名称;最后使用百分号格式化输出结果。命令只创建厂商元数据,不连接阿里云。

执行位置:devopsX/

python manage.py bootstrap_cmdb

第一次执行应报告创建 fake 和 aliyun;再次执行应报告更新,不会产生重复行。这里出现阿里云厂商记录不代表真实阿里云连通性已经验证。

9.8 页面表单、列表和详情的完整后端文件

当前 v1.0.0 的页面层已经同时接入“CMDB 资产管理(3):云资源发现、同步与变更历史”要讲的同步、CSV 和 API 支撑模块。为了保证展示的文件与磁盘最终状态完全一致,下面保留完整最终文件,不提供删减版。若正在逐篇手工重建,请从 CMDB 资产管理(6):v1.0.0 源码汇总(上篇)取得同一版本的 devopsX/cmdb/providers/、devopsX/cmdb/services/、devopsX/cmdb/api_views.py 和 devopsX/cmdb/api_urls.py;“CMDB 资产管理(3):云资源发现、同步与变更历史”会逐文件解释这些依赖。不要用空函数让导入暂时通过,因为那会制造一个与 v1.0.0 不一致的项目。

9.8.1 表单

完整文件:devopsX/cmdb/forms.py

import re

from django import forms
from django.core.exceptions import ValidationError

from .models import CloudAccount, ComputeInstanceTag, validate_region_allowlist


class CloudAccountForm(forms.ModelForm):
    region_allowlist_text = forms.CharField(
        label="地域白名单",
        required=False,
        help_text="多个地域用英文逗号分隔;留空表示发现全部地域。",
    )

    class Meta:
        model = CloudAccount
        fields = (
            "provider",
            "account_key",
            "name",
            "credential_profile",
            "region_allowlist_text",
            "is_active",
            "sync_enabled",
        )
        help_texts = {
            "account_key": "平台内稳定账号键,例如 aliyun-production。",
            "credential_profile": (
                "只填写环境变量前缀,例如 ALIYUN_PRODUCTION;"
                "不要填写 AccessKey、Secret 或 Token。"
            ),
        }

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        if self.instance.pk:
            self.fields["region_allowlist_text"].initial = ", ".join(
                self.instance.region_allowlist
            )

    def clean_credential_profile(self):
        value = self.cleaned_data["credential_profile"].strip()
        if value and not re.fullmatch(r"[A-Z][A-Z0-9_]*", value):
            raise forms.ValidationError("凭据前缀只能使用大写字母、数字和下划线。")
        return value

    def clean_region_allowlist_text(self):
        value = self.cleaned_data["region_allowlist_text"]
        regions = []
        for item in value.split(","):
            region = item.strip()
            if region and region not in regions:
                regions.append(region)
        try:
            validate_region_allowlist(regions)
        except ValidationError as exc:
            raise forms.ValidationError(exc.messages) from exc
        return regions

    def save(self, commit=True):
        instance = super().save(commit=False)
        instance.region_allowlist = self.cleaned_data["region_allowlist_text"]
        if commit:
            instance.save()
        return instance


class ManualTagForm(forms.Form):
    key = forms.CharField(label="标签键", max_length=100)
    value = forms.CharField(label="标签值", max_length=255, required=False)

    def clean_key(self):
        return self.cleaned_data["key"].strip()

    def save(self, instance):
        return ComputeInstanceTag.objects.update_or_create(
            instance=instance,
            source=ComputeInstanceTag.Source.MANUAL,
            key=self.cleaned_data["key"],
            defaults={"value": self.cleaned_data["value"].strip()},
        )


class CloudAccountCsvForm(forms.Form):
    csv_file = forms.FileField(
        label="云账号 CSV 文件",
        help_text="只接受 UTF-8 CSV,最大 1 MiB。",
    )

    def clean_csv_file(self):
        uploaded_file = self.cleaned_data["csv_file"]
        if uploaded_file.size > 1024 * 1024:
            raise forms.ValidationError("CSV 文件不能超过 1 MiB。")
        if not uploaded_file.name.lower().endswith(".csv"):
            raise forms.ValidationError("请选择 .csv 文件。")
        return uploaded_file


class CloudAccountCsvConfirmForm(forms.Form):
    signed_payload = forms.CharField(widget=forms.HiddenInput)


class SyncAccountForm(forms.Form):
    scenario = forms.ChoiceField(
        label="Fake 教学场景",
        required=False,
        choices=(
            ("default", "默认:发现两台实例"),
            ("empty", "完整空快照:演示缺失"),
            ("partial", "部分快照:不标记缺失"),
            ("failed", "Provider 失败"),
        ),
    )
  • CloudAccountForm 把 JSON 地域列表转换为逗号分隔的可编辑文本,并再次验证凭据前缀。
  • 帮助文本明确禁止填写 AccessKey、Secret 或 Token。
  • ManualTagForm 只写 manual 来源标签,使用 update_or_create 保证同键更新而非重复。
  • CSV 与同步场景表单属于第 2 篇范围,但保留在完整最终文件中。

9.8.2 视图

完整文件:devopsX/cmdb/views.py

import csv

from django.contrib import messages
from django.contrib.auth.decorators import login_required, permission_required
from django.core import signing
from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator
from django.db.models import Prefetch, Q, TextField
from django.db.models.functions import Cast
from django.http import HttpResponse, HttpResponseNotAllowed
from django.shortcuts import get_object_or_404, redirect, render
from django.views.decorators.http import require_GET, require_POST

from .forms import (
    CloudAccountCsvConfirmForm,
    CloudAccountCsvForm,
    CloudAccountForm,
    ManualTagForm,
    SyncAccountForm,
)
from .models import (
    CloudAccount,
    CloudAvailabilityZone,
    CloudProvider,
    CloudRegion,
    ComputeInstance,
    ComputeInstanceChange,
    ComputeInstanceTag,
    SyncRun,
)
from .providers.base import ProviderError
from .services.csv_io import (
    import_cloud_accounts,
    load_signed_rows,
    parse_cloud_account_csv,
    sign_rows,
)
from .services.sync import retire_instance, sync_account


def _csv_safe(value):
    text = str(value)
    candidate = text.lstrip(" ")
    if candidate and candidate[0] in "=+-@\t\r\n":
        return "'%s" % text
    return text


@login_required
def home(request):
    can_view_providers = request.user.has_perm("cmdb.view_cloudprovider")
    can_view_accounts = request.user.has_perm("cmdb.view_cloudaccount")
    can_view_instances = request.user.has_perm("cmdb.view_computeinstance")
    can_view_sync_runs = request.user.has_perm("cmdb.view_syncrun")
    if not any(
        (
            can_view_providers,
            can_view_accounts,
            can_view_instances,
            can_view_sync_runs,
        )
    ):
        raise PermissionDenied

    context = {
        "can_view_providers": can_view_providers,
        "can_view_accounts": can_view_accounts,
        "can_view_instances": can_view_instances,
        "can_view_sync_runs": can_view_sync_runs,
        "provider_count": (
            CloudProvider.objects.filter(is_active=True).count()
            if can_view_providers
            else None
        ),
        "account_count": (
            CloudAccount.objects.filter(is_active=True).count()
            if can_view_accounts
            else None
        ),
        "instance_count": (
            ComputeInstance.objects.exclude(
                lifecycle_state=ComputeInstance.LifecycleState.RETIRED
            ).count()
            if can_view_instances
            else None
        ),
        "missing_count": (
            ComputeInstance.objects.filter(
                lifecycle_state=ComputeInstance.LifecycleState.MISSING
            ).count()
            if can_view_instances
            else None
        ),
        "recent_sync_runs": (
            SyncRun.objects.select_related("account", "account__provider")[:5]
            if can_view_sync_runs
            else None
        ),
    }
    return render(request, "cmdb/home.html", context)


@login_required
@permission_required("cmdb.view_cloudprovider", raise_exception=True)
def provider_list(request):
    providers = CloudProvider.objects.prefetch_related("accounts")
    return render(
        request,
        "cmdb/provider_list.html",
        {
            "providers": providers,
            "can_view_accounts": request.user.has_perm("cmdb.view_cloudaccount"),
        },
    )


@login_required
@permission_required("cmdb.view_cloudaccount", raise_exception=True)
def account_list(request):
    accounts = CloudAccount.objects.select_related("provider").order_by(
        "provider__code",
        "account_key",
    )
    return render(request, "cmdb/account_list.html", {"accounts": accounts})


@login_required
@permission_required(
    ("cmdb.add_cloudaccount", "cmdb.view_cloudaccount"),
    raise_exception=True,
)
def account_create(request):
    form = CloudAccountForm(request.POST or None)
    if request.method == "POST" and form.is_valid():
        account = form.save()
        messages.success(request, "云账号已创建:%s" % account.name)
        return redirect("cmdb:account_detail", pk=account.pk)
    return render(request, "cmdb/account_form.html", {"form": form})


@login_required
@permission_required("cmdb.view_cloudaccount", raise_exception=True)
def account_detail(request, pk):
    account = get_object_or_404(
        CloudAccount.objects.select_related("provider"),
        pk=pk,
    )
    can_view_instances = request.user.has_perm("cmdb.view_computeinstance")
    can_view_sync_runs = request.user.has_perm("cmdb.view_syncrun")
    context = {
        "account": account,
        "regions": account.regions.prefetch_related("availability_zones"),
        "instances": (
            account.compute_instances.select_related(
                "region",
                "availability_zone",
            )[:10]
            if can_view_instances
            else None
        ),
        "sync_runs": account.sync_runs.all()[:10] if can_view_sync_runs else None,
        "can_view_instances": can_view_instances,
        "can_view_sync_runs": can_view_sync_runs,
        "sync_form": SyncAccountForm(initial={"scenario": "default"}),
    }
    return render(request, "cmdb/account_detail.html", context)


@login_required
@permission_required(
    ("cmdb.sync_cloudaccount", "cmdb.view_cloudaccount"),
    raise_exception=True,
)
@require_POST
def account_sync(request, pk):
    account = get_object_or_404(
        CloudAccount.objects.select_related("provider"),
        pk=pk,
    )
    form = SyncAccountForm(request.POST)
    if not form.is_valid():
        messages.error(request, "同步参数无效。")
        return redirect("cmdb:account_detail", pk=account.pk)
    scenario = form.cleaned_data["scenario"] or "default"
    if account.provider.code != "fake":
        scenario = "default"
    try:
        sync_run = sync_account(
            account,
            requested_by=request.user,
            trigger=SyncRun.Trigger.MANUAL,
            scenario=scenario,
        )
    except ProviderError as exc:
        messages.error(request, "同步失败(%s):%s" % (exc.code, exc.message))
    else:
        if sync_run.status == SyncRun.Status.PARTIAL:
            messages.warning(
                request,
                "同步部分完成(%s):%s"
                % (sync_run.error_code, sync_run.error_message),
            )
        else:
            messages.success(
                request,
                "同步完成:发现 %s,新建 %s,更新 %s,缺失 %s,恢复 %s。"
                % (
                    sync_run.discovered_count,
                    sync_run.created_count,
                    sync_run.updated_count,
                    sync_run.missing_count,
                    sync_run.restored_count,
                ),
            )
    return redirect("cmdb:account_detail", pk=account.pk)


@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
def instance_list(request):
    instances = ComputeInstance.objects.select_related(
        "account",
        "account__provider",
        "region",
        "availability_zone",
    ).prefetch_related("tags")

    keyword = request.GET.get("q", "").strip()
    provider = request.GET.get("provider", "").strip()
    account = request.GET.get("account", "").strip()
    region = request.GET.get("region", "").strip()
    status = request.GET.get("status", "").strip()
    lifecycle = request.GET.get("lifecycle", "").strip()
    if keyword:
        instances = instances.annotate(
            private_ips_text=Cast("private_ips", output_field=TextField()),
            public_ips_text=Cast("public_ips", output_field=TextField()),
        ).filter(
            Q(provider_resource_id__icontains=keyword)
            | Q(name__icontains=keyword)
            | Q(os_name__icontains=keyword)
            | Q(private_ips_text__icontains=keyword)
            | Q(public_ips_text__icontains=keyword)
        )
    if provider:
        instances = instances.filter(account__provider__code=provider)
    if account:
        if account.isdecimal():
            instances = instances.filter(account_id=account)
        else:
            instances = instances.none()
    if region:
        if region.isdecimal():
            instances = instances.filter(region_id=region)
        else:
            instances = instances.none()
    if status:
        instances = instances.filter(normalized_status=status)
    if lifecycle:
        instances = instances.filter(lifecycle_state=lifecycle)

    ordering_map = {
        "name": "name",
        "-name": "-name",
        "status": "normalized_status",
        "-status": "-normalized_status",
        "last_seen": "last_seen_at",
        "-last_seen": "-last_seen_at",
        "created": "cloud_created_at",
        "-created": "-cloud_created_at",
    }
    selected_sort = request.GET.get("sort", "-last_seen")
    instances = instances.order_by(ordering_map.get(selected_sort, "-last_seen_at"), "id")

    paginator = Paginator(instances, 20)
    page_obj = paginator.get_page(request.GET.get("page"))
    query_without_page = request.GET.copy()
    query_without_page.pop("page", None)
    context = {
        "page_obj": page_obj,
        "providers": CloudProvider.objects.filter(is_active=True),
        "accounts": CloudAccount.objects.select_related("provider"),
        "regions": CloudRegion.objects.select_related("account"),
        "status_choices": ComputeInstance.NormalizedStatus.choices,
        "lifecycle_choices": ComputeInstance.LifecycleState.choices,
        "selected_sort": selected_sort,
        "query_without_page": query_without_page.urlencode(),
    }
    return render(request, "cmdb/instance_list.html", context)


@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
def instance_detail(request, pk):
    instance = get_object_or_404(
        ComputeInstance.objects.select_related(
            "account",
            "account__provider",
            "region",
            "availability_zone",
        ).prefetch_related("tags"),
        pk=pk,
    )
    change_page = Paginator(
        instance.changes.select_related("sync_run"),
        20,
    ).get_page(request.GET.get("change_page"))
    return render(
        request,
        "cmdb/instance_detail.html",
        {
            "instance": instance,
            "tag_form": ManualTagForm(),
            "change_page": change_page,
        },
    )


@login_required
@permission_required(
    ("cmdb.change_computeinstance", "cmdb.view_computeinstance"),
    raise_exception=True,
)
@require_POST
def manual_tag_save(request, pk):
    instance = get_object_or_404(ComputeInstance, pk=pk)
    form = ManualTagForm(request.POST)
    if form.is_valid():
        form.save(instance)
        messages.success(request, "人工标签已保存。")
    else:
        messages.error(request, "人工标签未保存,请检查输入。")
    return redirect("cmdb:instance_detail", pk=instance.pk)


@login_required
@permission_required(
    ("cmdb.retire_computeinstance", "cmdb.view_computeinstance"),
    raise_exception=True,
)
@require_POST
def instance_retire(request, pk):
    instance = get_object_or_404(ComputeInstance, pk=pk)
    if retire_instance(instance):
        messages.success(request, "实例已标记为退役,历史记录仍然保留。")
    else:
        messages.info(request, "实例已经是退役状态。")
    return redirect("cmdb:instance_detail", pk=instance.pk)


@login_required
@permission_required("cmdb.view_syncrun", raise_exception=True)
def sync_run_list(request):
    sync_runs = SyncRun.objects.select_related(
        "account",
        "account__provider",
        "requested_by",
    )
    page_obj = Paginator(sync_runs, 30).get_page(request.GET.get("page"))
    return render(request, "cmdb/sync_run_list.html", {"page_obj": page_obj})


@login_required
@permission_required("cmdb.view_syncrun", raise_exception=True)
def sync_run_detail(request, public_id):
    can_view_instances = request.user.has_perm("cmdb.view_computeinstance")
    sync_runs = SyncRun.objects.select_related(
        "account",
        "account__provider",
        "requested_by",
    )
    if can_view_instances:
        sync_runs = sync_runs.prefetch_related("changes__instance")
    sync_run = get_object_or_404(sync_runs, public_id=public_id)
    return render(
        request,
        "cmdb/sync_run_detail.html",
        {
            "sync_run": sync_run,
            "can_view_instances": can_view_instances,
        },
    )


@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
def topology(request):
    zones = CloudAvailabilityZone.objects.prefetch_related(
        Prefetch(
            "compute_instances",
            queryset=ComputeInstance.objects.exclude(
                lifecycle_state=ComputeInstance.LifecycleState.RETIRED
            ).order_by("name", "provider_resource_id"),
        )
    ).order_by("provider_resource_id")
    regions = CloudRegion.objects.prefetch_related(
        Prefetch("availability_zones", queryset=zones),
        Prefetch(
            "compute_instances",
            queryset=ComputeInstance.objects.filter(
                availability_zone__isnull=True
            ).exclude(
                lifecycle_state=ComputeInstance.LifecycleState.RETIRED
            ).order_by("name", "provider_resource_id"),
            to_attr="unassigned_instances",
        ),
    ).order_by("provider_resource_id")
    accounts = CloudAccount.objects.prefetch_related(
        Prefetch("regions", queryset=regions)
    ).order_by("account_key")
    providers = CloudProvider.objects.prefetch_related(
        Prefetch("accounts", queryset=accounts)
    )
    return render(request, "cmdb/topology.html", {"providers": providers})


@login_required
@permission_required(
    ("cmdb.import_cloudaccount", "cmdb.view_cloudaccount"),
    raise_exception=True,
)
def account_csv_import(request):
    preview_rows = None
    signed_payload = ""
    upload_form = CloudAccountCsvForm()
    confirm_form = CloudAccountCsvConfirmForm()

    if request.method == "POST" and request.POST.get("action") == "preview":
        upload_form = CloudAccountCsvForm(request.POST, request.FILES)
        if upload_form.is_valid():
            try:
                preview_rows, errors = parse_cloud_account_csv(
                    upload_form.cleaned_data["csv_file"]
                )
            except ValueError as exc:
                upload_form.add_error("csv_file", str(exc))
            else:
                if errors:
                    for error in errors:
                        upload_form.add_error("csv_file", error)
                else:
                    signed_payload = sign_rows(preview_rows)
                    confirm_form = CloudAccountCsvConfirmForm(
                        initial={"signed_payload": signed_payload}
                    )

    if request.method == "POST" and request.POST.get("action") == "import":
        confirm_form = CloudAccountCsvConfirmForm(request.POST)
        if confirm_form.is_valid():
            try:
                rows = load_signed_rows(confirm_form.cleaned_data["signed_payload"])
                created_count, updated_count = import_cloud_accounts(rows)
            except ValueError as exc:
                confirm_form.add_error("signed_payload", str(exc))
            else:
                messages.success(
                    request,
                    "导入完成:新建 %s,更新 %s。" % (created_count, updated_count),
                )
                return redirect("cmdb:account_list")

    context = {
        "upload_form": upload_form,
        "confirm_form": confirm_form,
        "preview_rows": preview_rows,
        "signed_payload": signed_payload,
    }
    return render(request, "cmdb/account_csv_import.html", context)


@login_required
@permission_required("cmdb.export_computeinstance", raise_exception=True)
@require_GET
def instance_csv_export(request):
    response = HttpResponse(content_type="text/csv; charset=utf-8")
    response["Content-Disposition"] = 'attachment; filename="cmdb-instances.csv"'
    response.write("")
    writer = csv.writer(response)
    writer.writerow(
        [
            "provider_code",
            "account_key",
            "instance_id",
            "name",
            "region_id",
            "zone_id",
            "status",
            "lifecycle",
            "private_ips",
            "public_ips",
            "last_seen_at",
        ]
    )
    instances = ComputeInstance.objects.select_related(
        "account",
        "account__provider",
        "region",
        "availability_zone",
    ).order_by("account__provider__code", "account__account_key", "provider_resource_id")
    for instance in instances:
        writer.writerow(
            [
                _csv_safe(value)
                for value in (
                    instance.account.provider.code,
                    instance.account.account_key,
                    instance.provider_resource_id,
                    instance.name,
                    instance.region.provider_resource_id,
                    instance.availability_zone.provider_resource_id
                    if instance.availability_zone
                    else "",
                    instance.normalized_status,
                    instance.lifecycle_state,
                    "|".join(instance.private_ips),
                    "|".join(instance.public_ips),
                    instance.last_seen_at.isoformat(),
                )
            ]
        )
    return response
函数组本篇应掌握的机制
home先分别检查四类查看权限;没有任何权限时返回 403;只查询用户有权看到的统计。
provider_list预取账号,避免模板逐个厂商重复查询;账号数量仍受权限控制。
account_listselect_related("provider") 用一次关联查询读取厂商,按稳定代码和账号键排序。
account_createGET 显示表单;有效 POST 保存后用消息提示并跳转详情,避免刷新重复提交。
account_detail一次读取账号和厂商,预取地域与可用区,只在有权限时查询实例和同步执行。
instance_list关联读取账号、厂商、地域、可用区,预取标签;按白名单字段搜索、筛选、排序并每页显示 20 条。
搜索 IPJSON 字段先转换为文本再做包含查询,适合当前 SQLite/MySQL 兼容教学边界。
排序白名单用户只能选择 ordering_map 中的键,不能把任意字段名直接送入 order_by。
instance_detail预先读取归属关系、标签和变化记录;不存在时返回 404。
写操作人工标签、退役和同步均要求 POST、登录和明确权限。
CMDB 资产管理(3):云资源发现、同步与变更历史同步历史、拓扑、CSV 导入导出和同步执行将在下一篇按完整机制解释。

9.8.3 App 路由

完整文件:devopsX/cmdb/urls.py

from django.urls import include, path

from . import views


app_name = "cmdb"

urlpatterns = [
    path("", views.home, name="home"),
    path("providers/", views.provider_list, name="provider_list"),
    path("accounts/", views.account_list, name="account_list"),
    path("accounts/new/", views.account_create, name="account_create"),
    path("accounts/import/", views.account_csv_import, name="account_csv_import"),
    path("accounts/<int:pk>/", views.account_detail, name="account_detail"),
    path("accounts/<int:pk>/sync/", views.account_sync, name="account_sync"),
    path("instances/", views.instance_list, name="instance_list"),
    path("instances/export/", views.instance_csv_export, name="instance_csv_export"),
    path("instances/<int:pk>/", views.instance_detail, name="instance_detail"),
    path("instances/<int:pk>/tags/", views.manual_tag_save, name="manual_tag_save"),
    path("instances/<int:pk>/retire/", views.instance_retire, name="instance_retire"),
    path("sync-runs/", views.sync_run_list, name="sync_run_list"),
    path("sync-runs/<uuid:public_id>/", views.sync_run_detail, name="sync_run_detail"),
    path("topology/", views.topology, name="topology"),
    path("api/v1/", include("cmdb.api_urls")),
]

静态路径必须放在动态 <int:pk> 路径之前,例如 accounts/new/ 和 instances/export/。每条路由都有稳定名称,模板不硬编码 URL。最后一条把 api/v1/ 交给 API 子路由,并使用嵌套 namespace。

9.9 云厂商、云账号、实例列表与详情模板

9.9.1 云厂商列表

完整文件:devopsX/cmdb/templates/cmdb/provider_list.html

{% extends "base.html" %}

{% block title %}云厂商 - CMDB{% endblock %}

{% block content %}
<section class="page-heading">
    <p class="eyebrow">Provider</p>
    <h1>云厂商</h1>
    <p>云厂商决定使用哪个 Provider Adapter,账号负责保存发现范围和凭据前缀。</p>
</section>
<section class="card-grid">
    {% for provider in providers %}
        <article class="panel">
            <div class="section-heading">
                <h2>{{ provider.name }}</h2>
                <span class="badge {% if provider.is_active %}badge-succeeded{% else %}badge-failed{% endif %}">{% if provider.is_active %}已启用{% else %}已停用{% endif %}</span>
            </div>
            <p><code>{{ provider.code }}</code></p>
            {% if can_view_accounts %}<p class="muted">云账号数量:{{ provider.accounts.count }}</p>{% endif %}
        </article>
    {% empty %}
        <section class="empty-state"><h2>暂无云厂商</h2><p>运行 <code>python manage.py bootstrap_cmdb</code> 创建 Fake 和阿里云。</p></section>
    {% endfor %}
</section>
{% endblock %}

每个厂商是一张卡片;启停状态使用不同徽标;只有拥有账号查看权限时才显示账号数量。

9.9.2 云账号列表

完整文件:devopsX/cmdb/templates/cmdb/account_list.html

{% extends "base.html" %}

{% block title %}云账号 - CMDB{% endblock %}

{% block content %}
<section class="page-heading split-heading">
    <div><p class="eyebrow">Cloud Account</p><h1>云账号</h1><p>数据库只保存账号元数据和凭据环境变量前缀。</p></div>
    <div class="actions">
        {% if perms.cmdb.import_cloudaccount %}<a class="button button-secondary" href="{% url 'cmdb:account_csv_import' %}">CSV 导入</a>{% endif %}
        {% if perms.cmdb.add_cloudaccount %}<a class="button" href="{% url 'cmdb:account_create' %}">添加云账号</a>{% endif %}
    </div>
</section>
<section class="panel">
    <div class="table-wrap">
        <table>
            <thead><tr><th>厂商</th><th>账号键</th><th>显示名称</th><th>凭据前缀</th><th>地域白名单</th><th>同步</th><th>最后成功</th></tr></thead>
            <tbody>
            {% for account in accounts %}
                <tr>
                    <td>{{ account.provider.name }}</td>
                    <td><a href="{% url 'cmdb:account_detail' account.pk %}"><code>{{ account.account_key }}</code></a></td>
                    <td>{{ account.name }}</td>
                    <td><code>{{ account.credential_profile|default:"—" }}</code></td>
                    <td>{{ account.region_allowlist|join:", "|default:"全部" }}</td>
                    <td>{% if account.sync_enabled %}允许{% else %}停用{% endif %}</td>
                    <td>{{ account.last_successful_sync_at|date:"Y-m-d H:i:s"|default:"从未" }}</td>
                </tr>
            {% empty %}
                <tr><td colspan="7" class="muted">暂无云账号。</td></tr>
            {% endfor %}
            </tbody>
        </table>
    </div>
</section>
{% endblock %}

表格只展示凭据前缀,不展示秘密值。地域白名单为空时显示“全部”,账号键链接到详情。

9.9.3 云账号详情

完整文件:devopsX/cmdb/templates/cmdb/account_detail.html

{% extends "base.html" %}

{% block title %}{{ account.name }} - CMDB{% endblock %}

{% block content %}
<section class="page-heading split-heading">
    <div><p class="eyebrow">{{ account.provider.name }}</p><h1>{{ account.name }}</h1><p><code>{{ account.account_key }}</code></p></div>
    {% if perms.cmdb.sync_cloudaccount %}
    <form method="post" action="{% url 'cmdb:account_sync' account.pk %}" class="sync-form">
        {% csrf_token %}
        {% if account.provider.code == "fake" %}{{ sync_form.as_p }}{% endif %}
        <button class="button" type="submit">立即同步</button>
    </form>
    {% endif %}
</section>
<section class="detail-grid">
    <article class="panel"><h2>账号配置</h2><dl class="detail-list"><dt>云厂商</dt><dd>{{ account.provider.name }}</dd><dt>凭据前缀</dt><dd><code>{{ account.credential_profile|default:"未配置" }}</code></dd><dt>地域白名单</dt><dd>{{ account.region_allowlist|join:", "|default:"全部地域" }}</dd><dt>状态</dt><dd>{% if account.is_active %}已启用{% else %}已停用{% endif %}</dd><dt>最后成功同步</dt><dd>{{ account.last_successful_sync_at|date:"Y-m-d H:i:s"|default:"从未" }}</dd></dl></article>
    <article class="panel"><h2>发现范围</h2><p>地域 {{ account.regions.count }} 个</p><p>可用区 {{ account.availability_zones.count }} 个</p>{% if can_view_instances %}<p>实例 {{ account.compute_instances.count }} 台</p>{% endif %}</article>
</section>
{% if can_view_instances %}
<section class="panel">
    <div class="section-heading"><h2>实例</h2><a href="{% url 'cmdb:instance_list' %}?account={{ account.pk }}">查看全部</a></div>
    <div class="table-wrap"><table><thead><tr><th>实例 ID</th><th>名称</th><th>地域</th><th>状态</th><th>生命周期</th></tr></thead><tbody>{% for instance in instances %}<tr><td><a href="{% url 'cmdb:instance_detail' instance.pk %}"><code>{{ instance.provider_resource_id }}</code></a></td><td>{{ instance.name }}</td><td>{{ instance.region.name }}</td><td>{{ instance.get_normalized_status_display }}</td><td>{{ instance.get_lifecycle_state_display }}</td></tr>{% empty %}<tr><td colspan="5" class="muted">尚未发现实例。</td></tr>{% endfor %}</tbody></table></div>
</section>
{% endif %}
{% if can_view_sync_runs %}
<section class="panel">
    <div class="section-heading"><h2>同步历史</h2><a href="{% url 'cmdb:sync_run_list' %}">查看全部</a></div>
    <div class="table-wrap"><table><thead><tr><th>执行 ID</th><th>状态</th><th>发现</th><th>新建</th><th>更新</th><th>缺失</th><th>时间</th></tr></thead><tbody>{% for sync_run in sync_runs %}<tr><td><a href="{% url 'cmdb:sync_run_detail' sync_run.public_id %}"><code>{{ sync_run.public_id }}</code></a></td><td><span class="badge badge-{{ sync_run.status }}">{{ sync_run.get_status_display }}</span></td><td>{{ sync_run.discovered_count }}</td><td>{{ sync_run.created_count }}</td><td>{{ sync_run.updated_count }}</td><td>{{ sync_run.missing_count }}</td><td>{{ sync_run.created_at|date:"Y-m-d H:i:s" }}</td></tr>{% empty %}<tr><td colspan="7" class="muted">尚无同步历史。</td></tr>{% endfor %}</tbody></table></div>
</section>
{% endif %}
{% endblock %}

账号配置、发现范围、实例和同步历史分别展示。模板再次根据权限隐藏区块,但后端视图已经先决定是否查询这些数据。

9.9.4 计算实例列表

完整文件:devopsX/cmdb/templates/cmdb/instance_list.html

{% extends "base.html" %}

{% block title %}计算实例 - CMDB{% endblock %}

{% block content %}
<section class="page-heading split-heading">
    <div><p class="eyebrow">Compute Instance</p><h1>计算实例</h1><p>以云账号和厂商实例 ID 作为稳定身份,不使用名称或 IP 去重。</p></div>
    {% if perms.cmdb.export_computeinstance %}<a class="button button-secondary" href="{% url 'cmdb:instance_csv_export' %}">导出 CSV</a>{% endif %}
</section>
<section class="panel">
<form method="get" class="filter-grid">
    <label>关键词<input name="q" value="{{ request.GET.q }}" placeholder="名称、ID、IP、操作系统"></label>
    <label>云厂商<select name="provider"><option value="">全部</option>{% for item in providers %}<option value="{{ item.code }}" {% if request.GET.provider == item.code %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label>
    <label>云账号<select name="account"><option value="">全部</option>{% for item in accounts %}<option value="{{ item.pk }}" {% if request.GET.account == item.pk|stringformat:"s" %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label>
    <label>地域<select name="region"><option value="">全部</option>{% for item in regions %}<option value="{{ item.pk }}" {% if request.GET.region == item.pk|stringformat:"s" %}selected{% endif %}>{{ item.name }}</option>{% endfor %}</select></label>
    <label>标准状态<select name="status"><option value="">全部</option>{% for value, label in status_choices %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
    <label>生命周期<select name="lifecycle"><option value="">全部</option>{% for value, label in lifecycle_choices %}<option value="{{ value }}" {% if request.GET.lifecycle == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
    <label>排序<select name="sort"><option value="-last_seen" {% if selected_sort == "-last_seen" %}selected{% endif %}>最后发现:新到旧</option><option value="last_seen" {% if selected_sort == "last_seen" %}selected{% endif %}>最后发现:旧到新</option><option value="name" {% if selected_sort == "name" %}selected{% endif %}>名称:升序</option><option value="-name" {% if selected_sort == "-name" %}selected{% endif %}>名称:降序</option><option value="status" {% if selected_sort == "status" %}selected{% endif %}>状态:升序</option></select></label>
    <div class="filter-actions"><button class="button" type="submit">筛选</button><a class="button button-secondary" href="{% url 'cmdb:instance_list' %}">重置</a></div>
</form>
</section>
<section class="panel">
    <p class="muted">共 {{ page_obj.paginator.count }} 台实例。</p>
    <div class="table-wrap"><table><thead><tr><th>实例</th><th>账号</th><th>地域 / 可用区</th><th>规格</th><th>IP</th><th>状态</th><th>生命周期</th><th>最后发现</th></tr></thead><tbody>{% for instance in page_obj %}<tr><td><a href="{% url 'cmdb:instance_detail' instance.pk %}">{{ instance.name|default:"未命名" }}</a><br><code>{{ instance.provider_resource_id }}</code></td><td>{{ instance.account.name }}</td><td>{{ instance.region.name }}<br><span class="muted">{{ instance.availability_zone.name|default:"无可用区" }}</span></td><td>{{ instance.instance_type }}<br><span class="muted">{{ instance.vcpu }} vCPU / {{ instance.memory_mb }} MiB</span></td><td>{{ instance.private_ips|join:", "|default:"—" }}<br><span class="muted">{{ instance.public_ips|join:", "|default:"—" }}</span></td><td><span class="badge badge-{{ instance.normalized_status }}">{{ instance.get_normalized_status_display }}</span></td><td><span class="badge badge-{{ instance.lifecycle_state }}">{{ instance.get_lifecycle_state_display }}</span></td><td>{{ instance.last_seen_at|date:"Y-m-d H:i:s" }}</td></tr>{% empty %}<tr><td colspan="8" class="muted">没有符合条件的实例。</td></tr>{% endfor %}</tbody></table></div>
    {% include "cmdb/_pagination.html" %}
</section>
{% endblock %}

GET 表单把筛选条件留在 URL 中,便于刷新和分享。实例名称链接到详情,稳定实例 ID 同时显示。厂商原始状态不直接作为跨云筛选项,页面使用标准状态。

9.9.5 分页组件

完整文件:devopsX/cmdb/templates/cmdb/_pagination.html

{% if page_obj.paginator.num_pages > 1 %}
<nav class="pagination" aria-label="分页">
    {% if page_obj.has_previous %}<a href="?{% if query_without_page %}{{ query_without_page }}&{% endif %}page={{ page_obj.previous_page_number }}">上一页</a>{% endif %}
    <span>第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span>
    {% if page_obj.has_next %}<a href="?{% if query_without_page %}{{ query_without_page }}&{% endif %}page={{ page_obj.next_page_number }}">下一页</a>{% endif %}
</nav>
{% endif %}

只有超过一页时才渲染导航。query_without_page 保留搜索、筛选和排序参数,只替换页码。

9.9.6 计算实例详情

完整文件:devopsX/cmdb/templates/cmdb/instance_detail.html

{% extends "base.html" %}

{% block title %}{{ instance.name|default:instance.provider_resource_id }} - CMDB{% endblock %}

{% block content %}
<section class="page-heading split-heading">
    <div><p class="eyebrow">{{ instance.account.provider.name }} / {{ instance.account.name }}</p><h1>{{ instance.name|default:"未命名实例" }}</h1><p><code>{{ instance.provider_resource_id }}</code></p></div>
    {% if perms.cmdb.retire_computeinstance and instance.lifecycle_state != "retired" %}<form method="post" action="{% url 'cmdb:instance_retire' instance.pk %}" onsubmit="return confirm('确认将该实例标记为退役吗?不会删除历史记录。');">{% csrf_token %}<button class="button button-danger" type="submit">标记退役</button></form>{% endif %}
</section>
<section class="detail-grid">
<article class="panel"><h2>云上属性</h2><dl class="detail-list"><dt>云账号</dt><dd>{% if perms.cmdb.view_cloudaccount %}<a href="{% url 'cmdb:account_detail' instance.account.pk %}">{{ instance.account.name }}</a>{% else %}{{ instance.account.name }}{% endif %}</dd><dt>地域</dt><dd>{{ instance.region.name }}({{ instance.region.provider_resource_id }})</dd><dt>可用区</dt><dd>{{ instance.availability_zone.name|default:"未提供" }}</dd><dt>实例规格</dt><dd>{{ instance.instance_type|default:"未提供" }}</dd><dt>计算资源</dt><dd>{{ instance.vcpu }} vCPU / {{ instance.memory_mb }} MiB</dd><dt>操作系统</dt><dd>{{ instance.os_name|default:"未提供" }}</dd><dt>厂商状态</dt><dd>{{ instance.provider_status|default:"未提供" }}</dd><dt>标准状态</dt><dd>{{ instance.get_normalized_status_display }}</dd></dl></article>
<article class="panel"><h2>发现与生命周期</h2><dl class="detail-list"><dt>生命周期</dt><dd>{{ instance.get_lifecycle_state_display }}</dd><dt>私网 IP</dt><dd>{{ instance.private_ips|join:", "|default:"无" }}</dd><dt>公网 IP</dt><dd>{{ instance.public_ips|join:", "|default:"无" }}</dd><dt>云上创建</dt><dd>{{ instance.cloud_created_at|date:"Y-m-d H:i:s"|default:"未提供" }}</dd><dt>首次发现</dt><dd>{{ instance.first_seen_at|date:"Y-m-d H:i:s" }}</dd><dt>最后发现</dt><dd>{{ instance.last_seen_at|date:"Y-m-d H:i:s" }}</dd><dt>开始缺失</dt><dd>{{ instance.missing_since|date:"Y-m-d H:i:s"|default:"—" }}</dd><dt>退役时间</dt><dd>{{ instance.retired_at|date:"Y-m-d H:i:s"|default:"—" }}</dd></dl></article>
</section>
<section class="detail-grid">
<article class="panel"><h2>标签</h2><div class="tag-list">{% for tag in instance.tags.all %}<span class="tag tag-{{ tag.source }}">{{ tag.key }}={{ tag.value }} <small>{{ tag.get_source_display }}</small></span>{% empty %}<span class="muted">暂无标签。</span>{% endfor %}</div>{% if perms.cmdb.change_computeinstance %}<form method="post" action="{% url 'cmdb:manual_tag_save' instance.pk %}" class="inline-fields">{% csrf_token %}{{ tag_form.as_p }}<button class="button" type="submit">保存人工标签</button></form>{% endif %}</article>
<article class="panel"><h2>变更历史</h2>{% for change in change_page %}<details><summary>{{ change.get_action_display }} · {{ change.created_at|date:"Y-m-d H:i:s" }}</summary><p>字段:{{ change.changed_fields|join:", "|default:"无字段变化" }}</p><div class="change-grid"><div><h3>变化前</h3><pre>{{ change.before_data }}</pre></div><div><h3>变化后</h3><pre>{{ change.after_data }}</pre></div></div></details>{% empty %}<p class="muted">暂无变更记录。</p>{% endfor %}{% if change_page.paginator.num_pages > 1 %}<nav class="pagination" aria-label="变更历史分页">{% if change_page.has_previous %}<a href="?change_page={{ change_page.previous_page_number }}">上一页</a>{% endif %}<span>第 {{ change_page.number }} / {{ change_page.paginator.num_pages }} 页</span>{% if change_page.has_next %}<a href="?change_page={{ change_page.next_page_number }}">下一页</a>{% endif %}</nav>{% endif %}</article>
</section>
{% endblock %}

详情把 provider-owned 云上属性与 reconciliation-owned 生命周期分成两张卡片。标签显示来源,人工标签表单只在有修改权限时出现;退役按钮只在有专用权限且实例未退役时出现,并通过 POST 提交。

9.10 响应式样式文件

业务页面 CSS 属于应用静态文件。它使用流式宽度、可横向滚动的表格、CSS Grid 和媒体查询,没有固定页面宽度,也不依赖前端框架。

完整文件:devopsX/cmdb/static/cmdb/style.css

:root {
    color-scheme: light;
    font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
    color: #172033;
    background: #f4f7fb;
}

* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #f4f7fb; }
a { color: #2457d6; text-decoration: none; }
code, pre { font-family: Consolas, "SFMono-Regular", monospace; }

.site-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 24px;
    padding: 18px max(24px, calc((100vw - 1120px) / 2));
    background: #ffffff;
    border-bottom: 1px solid #dfe6f0;
}
.brand { color: #172033; font-size: 22px; font-weight: 800; }
.site-header nav { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; }
.inline-form { display: inline; }
.link-button { padding: 0; border: 0; color: #2457d6; background: transparent; font: inherit; cursor: pointer; }
.page-shell { width: min(1180px, calc(100% - 32px)); margin: 42px auto; }

.hero, .panel, .page-heading, .empty-state, .stat-card {
    background: #ffffff;
    border: 1px solid #dfe6f0;
    border-radius: 16px;
    box-shadow: 0 12px 32px rgba(23, 32, 51, 0.06);
}
.hero, .page-heading, .empty-state, .panel { padding: 28px; }
.page-heading { margin-bottom: 22px; }
.page-heading h1, .hero h1 { margin: 8px 0 12px; font-size: clamp(30px, 5vw, 48px); }
.page-heading p, .hero p { color: #58708f; line-height: 1.7; }
.split-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
.eyebrow { margin: 0; color: #58708f; font-size: 13px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.button { display: inline-block; padding: 10px 16px; border: 0; border-radius: 9px; color: #fff; background: #2457d6; font: inherit; cursor: pointer; }
.button-secondary { color: #2457d6; background: #e8eefc; }
.button-danger { background: #c63b4a; }

.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 20px 0; }
.four-columns { grid-template-columns: repeat(4, 1fr); }
.stat-card { padding: 22px; }
.stat-card strong, .stat-card span { display: block; }
.stat-card strong { font-size: 34px; }
.stat-card span { margin-top: 6px; color: #58708f; }
.empty-state { text-align: center; margin: 22px 0; }
.empty-state h1, .empty-state h2 { margin-top: 0; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.section-heading h2, .panel h2 { margin-top: 0; }
.card-grid, .detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.detail-grid { margin: 18px 0; }

.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 760px; }
th, td { padding: 12px 10px; border-bottom: 1px solid #e6ebf2; text-align: left; vertical-align: top; }
th { color: #58708f; font-size: 13px; white-space: nowrap; }
tr:last-child td { border-bottom: 0; }
.muted { color: #71839c; }
.callout { padding: 14px 16px; border-left: 4px solid #2457d6; background: #eef3ff; line-height: 1.7; }
.message { padding: 12px 16px; border-radius: 8px; background: #e8f5ec; color: #25613b; }
.message-error { background: #fff0f0; color: #9b2737; }
.message-warning { background: #fff8e6; color: #805b00; }

.badge, .tag { display: inline-block; padding: 3px 8px; border-radius: 999px; background: #edf1f7; color: #40536d; font-size: 12px; white-space: nowrap; }
.badge-succeeded, .badge-present, .badge-running { background: #dff4e5; color: #25613b; }
.badge-partial, .badge-missing, .badge-starting, .badge-stopping { background: #fff2cf; color: #805b00; }
.badge-failed, .badge-retired, .badge-stopped { background: #ffe3e3; color: #9b2737; }
.tag { margin: 0 6px 8px 0; }
.tag-manual { background: #e8eefc; color: #2457d6; }
.tag small { margin-left: 4px; opacity: .75; }

.detail-list { display: grid; grid-template-columns: minmax(120px, .7fr) 1.3fr; gap: 11px 18px; margin: 0; }
.detail-list dt { color: #58708f; }
.detail-list dd { margin: 0; overflow-wrap: anywhere; }
.form-panel { max-width: 720px; margin: 0 auto; }
form p { margin: 16px 0; }
label { display: block; color: #40536d; font-weight: 600; }
input, select, textarea { display: block; width: 100%; margin-top: 6px; padding: 10px 12px; border: 1px solid #b9c7da; border-radius: 8px; background: #fff; color: #172033; font: inherit; }
input[type="checkbox"] { display: inline-block; width: auto; margin-right: 6px; }
.helptext { display: block; margin-top: 5px; color: #71839c; font-size: 13px; font-weight: 400; }
.errorlist { padding: 0; color: #9b2737; list-style: none; font-size: 13px; }
.filter-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); align-items: end; gap: 14px; }
.filter-actions { display: flex; gap: 8px; align-items: end; }
.sync-form { min-width: 260px; }
.sync-form p { margin: 0 0 10px; }
.sync-form label { font-size: 13px; }
.inline-fields { display: flex; align-items: end; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.inline-fields p { margin: 0; }
.inline-fields input { min-width: 160px; }

.pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 22px; }
.pagination a { padding: 7px 12px; border-radius: 7px; background: #e8eefc; }
.change-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
pre { overflow-x: auto; padding: 12px; border-radius: 8px; background: #f3f6fa; font-size: 12px; white-space: pre-wrap; }
.topology-tree details { margin: 8px 0 8px 16px; padding: 8px 0 0 14px; border-left: 2px solid #dfe6f0; }
.topology-tree > details { margin-left: 0; padding-left: 0; border-left: 0; }
.topology-tree summary { cursor: pointer; line-height: 1.8; }
.topology-tree ul { margin: 8px 0; padding-left: 24px; }

@media (max-width: 900px) {
    .four-columns { grid-template-columns: repeat(2, 1fr); }
    .filter-grid { grid-template-columns: repeat(2, minmax(140px, 1fr)); }
}
@media (max-width: 700px) {
    .site-header { align-items: flex-start; padding: 16px; }
    .page-shell { width: min(100% - 20px, 1180px); margin: 24px auto; }
    .split-heading, .detail-grid, .card-grid { display: block; }
    .split-heading .actions, .split-heading .sync-form { margin-top: 18px; }
    .stats-grid, .four-columns { grid-template-columns: 1fr; }
    .filter-grid { grid-template-columns: 1fr; }
    .hero, .page-heading, .empty-state, .panel { padding: 20px; }
    .change-grid { display: block; }
}

桌面端使用多列布局;宽度低于 900 像素时减少列数;低于 700 像素时切换为单列。表格外层使用 overflow-x: auto,小屏不会把整页撑出视口。

9.11 用受控入口建立第一组无秘密演示数据

  1. 先执行 python manage.py bootstrap_cmdb,确认存在 fake 厂商。
  2. 在 Admin 只新建云账号:厂商选择 Fake 教学云,账号键填写 fake-demo,显示名自定,凭据前缀留空,地域白名单可留空。
  3. 回到项目根目录执行 python manage.py sync_cloud_account fake-demo --provider fake --scenario default。
  4. 打开 CMDB 实例列表,应看到 Fake Provider 发现的地域、可用区与两台实例。
  5. 需要人工标签时,在实例详情页通过受权限与 CSRF 保护的表单提交;不要在 Admin 直接修改同步所有字段。

地域、可用区、实例、Provider 标签、同步执行和变化历史都属于同步或审计所有对象。Admin 对这些对象只读,防止人工修改绕过稳定身份、生命周期和变化记录。整个流程不需要真实云凭据。

9.12 页面验收清单

地址预期结果
http://127.0.0.1:8000/公开首页可访问。
http://127.0.0.1:8000/login/登录表单可提交,外部 next 地址不会被接受。
http://127.0.0.1:8000/admin/超级用户可配置厂商和账号;同步与审计对象只读。
http://127.0.0.1:8000/cmdb/providers/显示启用状态和稳定厂商代码。
http://127.0.0.1:8000/cmdb/accounts/显示账号元数据和凭据前缀,不显示真实凭据。
http://127.0.0.1:8000/cmdb/instances/支持关键词、厂商、账号、地域、状态、生命周期、排序和分页。
实例详情地址显示云上属性、生命周期、标签和变化历史;无权限用户返回 403。

9.13 自动化检查与测试

先运行 Django 系统检查。

执行位置:devopsX/

python manage.py check

预期输出对应位置:devopsX/

System check identified no issues (0 silenced).

再执行全部测试。测试数据库由 Django 临时创建并销毁,不会把测试行写入开发数据库。

执行位置:devopsX/;测试包位于 devopsX/accounts/tests.py 与 devopsX/cmdb/tests/

python manage.py test

本次对权威源码的实际结果如下。测试顺序中的点表示通过的测试用例。

实际验证输出对应源码:devopsX/

Found 105 test(s).
System check identified no issues (0 silenced).
Ran 105 tests
OK

机器性能不同,耗时会变化;最终冻结版本的 105 项用例、无系统检查错误和最终 OK 才是关键。本结果包括 SQLite 自动化测试和模拟阿里云客户端测试,不代表真实 MySQL 或真实阿里云 smoke test 已通过。

9.14 本篇应理解的测试边界

  • 模型测试验证稳定身份唯一、跨账号关系拒绝、凭据前缀与地域白名单校验,以及人工标签和厂商标签互不覆盖。
  • 视图测试验证匿名跳转、权限 403、只读页面、列表搜索、POST-only 写操作和权限隔离。
  • 同步、CSV、API 与阿里云映射测试虽然已经存在于 v1.0.0,但具体机制在第 2 篇讲解。
  • 自动化测试不能代替真实 MySQL 连接、生产 HTTPS 配置和真实云账号只读权限验收。

9.15 常见错误与修复

现象原因修复
启动时要求生产密钥或允许主机当前终端没有设置开发环境变量重新执行第 2.6 节两条 PowerShell 命令。
No module named accounts把 App 建在了内层配置包,或不在外层根目录执行命令确认 devopsX/accounts/ 与 devopsX/cmdb/ 平级。
AUTH_USER_MODEL 相关迁移冲突先迁移默认用户,后来才切换自定义用户学习环境重新建库并从正确迁移历史开始;已有生产库必须单独制定迁移方案。
访问 CMDB 得到 302尚未登录先登录,并检查跳转目标为站内地址。
访问 CMDB 得到 403已登录但缺少对应模型权限在 Admin 为用户或组授予最小必要权限。
模板不存在模板目录层级或 namespace 写错确认路径以 devopsX/templates/ 或 devopsX/cmdb/templates/cmdb/ 开始。
静态样式 404CSS 未放在 App 静态 namespace确认文件是 devopsX/cmdb/static/cmdb/style.css。
唯一约束错误同一账号重复使用厂商资源 ID使用厂商稳定 ID,不能用名称或 IP 代替身份。
账号、地域、可用区不一致建立了跨账号或跨地域关系从 Provider、Account、Region、Zone 到 Instance 逐层核对。
No changes detected 没有出现模型与已提交迁移不一致先比较实际源码,不要盲目生成第三个迁移掩盖误改。

9.16 本篇结束与下一篇

到这里,第 1 篇已经完成标准工程、公共认证基线、平级 CMDB App、第一张页面、基础资产术语、数据所有权、核心模型、迁移、Admin、列表、详情和即时测试闭环。下一篇将从 Provider Adapter 开始,解释 Fake Provider、阿里云映射、完整与部分快照、幂等写入、缺失与恢复、退役、变化历史、CSV、API 和对应测试。

继续阅读:CMDB 资产管理(3):云资源发现、同步与变更历史。返回系列起点:CMDB 资产管理(1):系列总览与学习路线。完整源码:CMDB 资产管理(6):v1.0.0 源码汇总(上篇)。

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