CMDB 资产管理(4):权限、搜索、拓扑、导入导出与 API
本篇是“CMDB 资产管理”连续系列的第 4 篇,承接前两篇实现教程已经完成的数据模型、Provider Adapter 与同步服务,只讨论当前 Django CMDB v1.0.0 中已经落盘并通过测试的 Web 与接口层。章节连续编号为 19—27;示例路径均从项目目录 devopsX/ 开始。
19 Django 权限与最小权限
19.1 先区分认证与授权
- 认证(Authentication)
- 回答“当前请求是谁发出的”。本项目使用 Django Session:登录成功后,浏览器持有会话 Cookie,服务端通过会话恢复
request.user。 - 授权(Authorization)
- 回答“这个用户是否允许执行当前操作”。用户已经登录,并不代表可以查看云账号、实例、同步历史或执行同步。
- 模型权限(Model permission)
- Django 为模型生成
add、change、delete、view权限;模型的Meta.permissions还能声明业务动作权限。权限由应用标签、动作代号和模型名共同组成,例如cmdb.view_computeinstance。 - 最小权限(Least privilege)
- 只授予完成职责所需的最少权限。只读云厂商的用户不应顺带看到云账号数量;只读云账号的用户不应顺带看到实例和同步执行;只读同步执行的用户不应顺带看到实例变化。
19.2 当前模型声明了哪些业务动作权限
CloudAccount 除 Django 自动生成的权限外,增加同步与导入权限;ComputeInstance 增加导出与退役权限。下面是当前模型中的原样连续片段。
相对路径:devopsX/cmdb/models.py(当前源码第 79—92 行的连续片段)
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 = "云账号"
第 79—86 行定义默认排序和“同一云厂商内账号键唯一”的约束;第 87—90 行声明 sync_cloudaccount 与 import_cloudaccount。执行迁移后,Django 会为这些代号创建权限记录。
相对路径:devopsX/cmdb/models.py(当前源码第 229—249 行的连续片段)
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 = "计算实例"
第 229—243 行是实例排序、唯一约束和查询索引;第 244—247 行声明 export_computeinstance 与 retire_computeinstance。这些权限表达业务动作,而不是把“修改实例”泛化成所有写操作。
19.3 权限矩阵
| 页面或动作 | 权限 | 无权限结果 |
|---|---|---|
| CMDB 首页 | 四类读取权限至少一个 | 四类都没有时为 403 |
| 云厂商列表 | cmdb.view_cloudprovider | 403 |
| 云账号列表与详情 | cmdb.view_cloudaccount | 403 |
| 创建云账号 | cmdb.add_cloudaccount | 403 |
| 同步云账号 | cmdb.sync_cloudaccount | 403;方法不对且权限已满足时为 405 |
| 实例列表、详情、拓扑 | cmdb.view_computeinstance | 403 |
| 保存人工标签 | cmdb.change_computeinstance | 403;仅接受 POST |
| 退役实例 | cmdb.retire_computeinstance | 403;仅接受 POST |
| 同步历史 | cmdb.view_syncrun | 403 |
| 导入云账号 CSV | cmdb.import_cloudaccount | 403 |
| 导出实例 CSV | cmdb.export_computeinstance | 403 |
19.4 装饰器不是导航隐藏的替代品
模板中的 perms.cmdb.<权限代号> 只负责不展示无权按钮,真正的安全边界位于视图装饰器。用户即使手工输入 URL,也必须通过 login_required 和 permission_required("权限代号", raise_exception=True)。
装饰器从下向上组装、从外向内执行。当前写法让认证先于权限,权限先于 HTTP 方法检查:匿名用户先跳登录;已登录但无权限者得到 403;权限满足但用 GET 调用只允许 POST 的动作时得到 405。
19.5 在管理后台按职责授权
- 以管理员进入 Django 管理后台,创建普通用户或用户组。
- “云厂商只读”只分配
Can view 云厂商。 - “云账号只读”只分配
Can view 云账号。 - “同步审计只读”只分配
Can view 同步执行。 - 运维执行角色按职责组合读取权限与
可以同步云账号,不要因为需要同步就授予超级用户。
检查点:没有任何 CMDB 权限的已登录用户访问 /cmdb/ 应看到自定义 403 页面,而不是空白首页或全部数据。
20 登录、next 安全、POST-only 与 CSRF
20.1 登录后的 next 为什么必须校验
next 表示登录成功后的回跳地址。若直接信任用户提交的完整 URL,攻击者可以构造本站登录链接,把用户登录后重定向到外部钓鱼站点,这叫开放重定向。当前实现只接受协议与主机均符合要求的地址;HTTPS 请求还要求目标同样使用 HTTPS。
相对路径: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")
逐组解释如下:
- 导入组引入消息、登录与退出、405 响应、重定向/渲染以及 Django 的安全 URL 判定函数。
home是公开入口,只渲染学习平台首页。login_view先从 POST、再从 GET 读取next;安全判定把允许主机限制为当前请求主机,并在安全请求上要求 HTTPS。- 已认证用户再次打开登录页时,安全
next才会生效,否则回到 CMDB 首页。 AuthenticationForm校验身份;成功后写入 Session、显示一次性消息,并执行同样的安全回跳。- 渲染表单时,不安全的
next被替换为空字符串,不会进入隐藏字段。 logout_view拒绝非 POST;成功退出后清理 Session、写消息并回公开首页。
相对路径: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 %}
第 8—13 行形成 POST 表单:CSRF 令牌、认证表单、安全时才出现的隐藏 next 字段,以及提交按钮。模板不重新判断安全性,只使用视图已经净化的值。
20.2 403、405 与 CSRF 分别表示什么
| 结果 | 含义 | 典型场景 |
|---|---|---|
| 403 Forbidden | 服务器理解请求,但当前身份无权执行,或 CSRF 校验失败 | 缺少模型权限;POST 缺少有效 CSRF 令牌 |
| 405 Method Not Allowed | 资源存在,但当前 HTTP 方法不允许 | 用 GET 调用同步、保存标签、退役或退出 |
| 302 Redirect | 响应要求浏览器跳转 | 匿名用户跳登录;成功写操作后采用 POST/Redirect/GET |
CSRF(Cross-Site Request Forgery,跨站请求伪造)是借助用户现有登录 Cookie,从其他站点诱导浏览器向本系统提交写请求。Session Cookie 会自动随请求发送,所以写操作还必须验证由本站页面产生的 CSRF 令牌。
20.3 POST-only 动作与 CSRF 双重约束
同步、标签与退役都有副作用,不能由可预取、可收藏、可被爬虫访问的 GET 触发。
相对路径:devopsX/cmdb/views.py(当前源码第 166—209 行的连续片段)
@login_required
@permission_required("cmdb.sync_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)
account_sync 的三层边界是:必须登录、必须具备同步权限、必须使用 POST。表单场景无效时不执行同步;非 Fake 云账号强制回到默认场景;Provider 错误以脱敏消息反馈;部分成功用 warning 展示错误代码和脱敏信息;完整成功展示统计,最后重定向。
相对路径:devopsX/cmdb/views.py(当前源码第 305—328 行的连续片段)
@login_required
@permission_required("cmdb.change_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", 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)
manual_tag_save 需要实例修改权限并只收 POST;合法表单按“实例、来源、键”更新或创建。instance_retire 使用更窄的退役权限;重复退役不会重复写历史。
相对路径:devopsX/templates/base.html(当前源码第 13—27 行的连续片段)
<nav>
{% if user.is_authenticated %}
<a href="{% url 'cmdb:home' %}">CMDB</a>
{% 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 %}
退出操作是 POST 表单而不是链接,并包含 CSRF。其余链接是否展示由模板权限控制,但后端装饰器仍是最终裁决者。
相对路径:devopsX/cmdb/templates/cmdb/account_detail.html(当前源码第 6—15 行的连续片段)
<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>
账号同步按钮只对有同步权限者出现;表单使用 POST 并带 CSRF。Fake Provider 才展示教学场景选择,真实 Provider 不接受前端伪造的 Fake 场景。
相对路径:devopsX/cmdb/templates/cmdb/instance_detail.html(当前源码第 6—16 行的连续片段)
<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><a href="{% url 'cmdb:account_detail' instance.account.pk %}">{{ instance.account.name }}</a></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 instance.changes.all %}<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 %}</article>
退役与人工标签表单都带 CSRF;退役按钮还要求退役权限且实例尚未退役。浏览器确认框只改善误操作体验,不构成安全边界。
20.4 自定义 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 %}
该模板明确区分认证和授权。若用户连 CMDB 首页的四类读取权限都没有,点击返回后仍会得到 403,这是最小权限的正常结果。
20.5 本章检查点与常见错误
- 站内
/cmdb/instances/作为 next 时应生效;外部地址应被忽略并回 CMDB 首页。 - 具备权限者用 GET 访问同步、标签或退役端点应得到 405。
- 正常页面提交应成功;删除 CSRF 令牌后用真实浏览器提交应得到 403。
- Django 测试客户端默认不强制 CSRF,普通 Client 的成功 POST 不能单独证明浏览器 CSRF 链路正确。
21 首页、云厂商、云账号与同步详情的权限边界
21.1 权限边界必须同时约束查询与输出
只在模板里隐藏 HTML 不够稳妥。视图应先判断权限,只执行允许的统计或关联预取,再把明确的布尔标志交给模板,减少越权数据进入上下文。
21.2 首页按权限分别统计
相对路径:devopsX/cmdb/views.py(当前源码第 49—100 行的连续片段)
@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)
- 四类读取能力分别计算,不用“已登录”替代业务授权。
- 四类都没有时抛出
PermissionDenied。 - 每个统计查询由对应权限保护;无权限时值为
None,不会执行计数。 - 最近同步只在拥有同步执行读取权限时查询,并一次取出账号与云厂商。
相对路径: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 %}
统计卡片逐项受 can_view_* 控制。空状态只有在同时允许查看账号和实例时才判断;最近同步区只向同步执行读者出现。
21.3 云厂商只读者不能看到云账号数量
相对路径:devopsX/cmdb/views.py(当前源码第 103—114 行的连续片段)
@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"),
},
)
相对路径: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 %}
视图单独传入 can_view_accounts;模板只有在为真时才渲染账号计数。因此只拥有 Provider 读取权限者看不到“云账号数量”。这是当前回归测试锁定的边界。
21.4 云账号只读者不能看到实例和同步执行
相对路径:devopsX/cmdb/views.py(当前源码第 138—163 行的连续片段)
@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)
相对路径: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 %}
- 账号详情本身要求账号读取权限。
- 实例只在实例读取权限为真时查询,否则为
None。 - 同步执行只在同步执行读取权限为真时查询,否则为
None。 - 实例数量、实例表格和同步历史分别受对应标志保护。
所以 account-only 用户能看账号配置、地域和可用区,却看不到实例 ID、实例数量和同步执行 UUID。
21.5 同步执行只读者不能看到实例变化
相对路径:devopsX/cmdb/views.py(当前源码第 343—362 行的连续片段)
@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,
},
)
相对路径:devopsX/cmdb/templates/cmdb/sync_run_detail.html(完整文件,内容与当前源码一致)
{% extends "base.html" %}
{% block title %}同步 {{ sync_run.public_id }} - CMDB{% endblock %}
{% block content %}
<section class="page-heading"><p class="eyebrow">{{ sync_run.account.name }}</p><h1>同步执行详情</h1><p><code>{{ sync_run.public_id }}</code></p></section>
<section class="stats-grid"><article class="stat-card"><strong>{{ sync_run.discovered_count }}</strong><span>发现</span></article><article class="stat-card"><strong>{{ sync_run.created_count }}</strong><span>新建</span></article><article class="stat-card"><strong>{{ sync_run.updated_count }}</strong><span>更新</span></article></section>
<section class="detail-grid"><article class="panel"><h2>执行信息</h2><dl class="detail-list"><dt>状态</dt><dd><span class="badge badge-{{ sync_run.status }}">{{ sync_run.get_status_display }}</span></dd><dt>触发方式</dt><dd>{{ sync_run.get_trigger_display }}</dd><dt>请求用户</dt><dd>{{ sync_run.requested_by|default:"系统" }}</dd><dt>开始时间</dt><dd>{{ sync_run.started_at|date:"Y-m-d H:i:s" }}</dd><dt>结束时间</dt><dd>{{ sync_run.finished_at|date:"Y-m-d H:i:s"|default:"—" }}</dd><dt>错误代码</dt><dd><code>{{ sync_run.error_code|default:"—" }}</code></dd><dt>错误信息</dt><dd>{{ sync_run.error_message|default:"—" }}</dd></dl></article><article class="panel"><h2>统计</h2><dl class="detail-list"><dt>发现</dt><dd>{{ sync_run.discovered_count }}</dd><dt>新建</dt><dd>{{ sync_run.created_count }}</dd><dt>更新</dt><dd>{{ sync_run.updated_count }}</dd><dt>未变化</dt><dd>{{ sync_run.unchanged_count }}</dd><dt>缺失</dt><dd>{{ sync_run.missing_count }}</dd><dt>恢复</dt><dd>{{ sync_run.restored_count }}</dd><dt>退役</dt><dd>{{ sync_run.retired_count }}</dd></dl></article></section>
{% if can_view_instances %}<section class="panel"><h2>本次变化</h2><div class="table-wrap"><table><thead><tr><th>实例</th><th>动作</th><th>字段</th><th>时间</th></tr></thead><tbody>{% for change in sync_run.changes.all %}<tr><td><a href="{% url 'cmdb:instance_detail' change.instance.pk %}">{{ change.instance }}</a></td><td>{{ change.get_action_display }}</td><td>{{ change.changed_fields|join:", " }}</td><td>{{ change.created_at|date:"Y-m-d H:i:s" }}</td></tr>{% empty %}<tr><td colspan="4" class="muted">该次同步没有资产变化。</td></tr>{% endfor %}</tbody></table></div></section>{% endif %}
{% endblock %}
执行详情允许同步审计者读取执行本身;只有同时拥有实例读取权限时才预取变化关联,模板才渲染“本次变化”。sync-run-only 用户看不到实例名称、实例 ID 或变化明细。
21.6 边界检查表
| 角色 | 应看到 | 不应看到 |
|---|---|---|
| 云厂商只读 | Provider 名称、代码、启停状态 | 云账号数量 |
| 云账号只读 | 账号元数据、地域、可用区 | 实例数量、实例表格、同步 UUID |
| 同步执行只读 | 执行状态、统计、错误代码与脱敏消息 | 本次实例变化与实例标识 |
| 实例只读 | 实例列表、详情、拓扑 | 导入、同步、退役,除非另有授权 |
常见错误是只测试超级用户。超级用户绕过普通权限判断,无法证明最小权限边界;必须为每一种单权限角色写回归测试。
22 实例搜索、筛选、允许列表排序与分页
22.1 先定义四个概念
- 搜索
- 用一个关键词在多个文本字段中做模糊匹配。
- 筛选
- 对云厂商、账号、地域、状态和生命周期施加精确条件。
- 允许列表排序(allowlisted sorting)
- 客户端只能选择后端预先定义的排序键;不能把任意字符串直接传给 ORM 的
order_by。 - 分页(pagination)
- 把查询结果切成固定大小页面,同时保留筛选参数并容忍无效页码。
22.2 查询实现
相对路径:devopsX/cmdb/views.py(当前源码第 212—283 行的连续片段)
@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)
逐组说明:
- 基础 QuerySet 用
select_related合并账号、Provider、地域和可用区外键查询,用prefetch_related批量取得标签。 - 所有 GET 参数先取字符串并
strip,空值不参与筛选。 - 关键词覆盖厂商实例 ID、名称、操作系统、私网 IP 和公网 IP。IP 位于 JSONField,先
Cast为文本,再做大小写不敏感包含匹配。 - Provider 使用稳定代码筛选;账号和地域只接受十进制主键。畸形关联 ID 会把 QuerySet 置空而不是把非法文本传给整数外键查询,因此不会触发 500;状态与生命周期使用模型枚举值。
ordering_map是排序允许列表。非法sort不会进入 SQL 字段表达式,而是回退到-last_seen_at。- 第二排序键固定为
id,避免主排序值相同时页面顺序漂移。 - 每页 20 条;
get_page容忍非整数和越界页码。 - 复制查询参数后删除
page,让分页链接保留搜索、筛选与排序。
22.3 筛选模板
相对路径: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 参数恢复输入框与下拉框。后端允许列表还支持创建时间与更多降序键;即使界面只暴露常用项,直接构造参数也只能落入后端允许列表。表格外层的 table-wrap 在窄屏上提供横向滚动。
相对路径: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 %}
分页组件只在总页数大于 1 时出现。它将已 URL 编码且移除旧页码的 query_without_page 放回链接,再附加目标页码,所以翻页不会丢掉关键词或筛选条件。
22.4 查询检查点
- 搜索
203.0.113.10时,Fake 数据中只返回“订单服务-演示”。 - 传入未知排序值时,页面应正常返回,并按最后发现时间降序回退。
- 传入
page=not-a-number时应回到有效页面;超大页码应落到最后一页。 - 翻页后,地址栏仍保留关键词、Provider、账号、地域、状态、生命周期与排序参数。
常见错误是把 request.GET["sort"] 直接交给 order_by。即使 ORM 不执行原始 SQL,这也会暴露未计划字段、关联排序和异常行为;允许列表更容易审计。
23 树形拓扑、无可用区实例与双来源标签
23.1 拓扑是什么
拓扑是资产之间显式关系的层级视图,而不是一张“看起来像树”的扁平表。本项目按 Provider → Account → Region → Availability Zone → Instance 展示。可用区允许为空,因此还必须把“有地域但没有可用区”的实例放入可见分支,不能让它们从树中消失。
23.2 服务端预取整棵树
相对路径:devopsX/cmdb/views.py(当前源码第 365—394 行的连续片段)
@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})
- 可用区预取未退役实例,并按名称、厂商实例 ID 稳定排序。
- 地域预取可用区;同时单独预取
availability_zone__isnull=True的未退役实例,通过to_attr="unassigned_instances"存入专用属性。 - 账号预取地域,Provider 再预取账号,避免模板逐层访问形成 N+1 查询。
- 整个拓扑端点要求实例读取权限,因为叶子节点就是实例资产。
相对路径:devopsX/cmdb/templates/cmdb/topology.html(完整文件,内容与当前源码一致)
{% extends "base.html" %}
{% block title %}资产拓扑 - CMDB{% endblock %}
{% block content %}
<section class="page-heading"><p class="eyebrow">Topology</p><h1>资产拓扑</h1><p>服务端按 Provider → Account → Region → Zone → Instance 展示显式关系。</p></section>
<section class="panel topology-tree">
{% for provider in providers %}
<details open><summary><strong>{{ provider.name }}</strong> <code>{{ provider.code }}</code></summary>
{% for account in provider.accounts.all %}
<details open><summary><a href="{% url 'cmdb:account_detail' account.pk %}">{{ account.name }}</a> <code>{{ account.account_key }}</code></summary>
{% for region in account.regions.all %}
<details open><summary>{{ region.name }} <code>{{ region.provider_resource_id }}</code></summary>
{% for zone in region.availability_zones.all %}
<details open><summary>{{ zone.name }} <code>{{ zone.provider_resource_id }}</code></summary>
<ul>{% for instance in zone.compute_instances.all %}<li><a href="{% url 'cmdb:instance_detail' instance.pk %}">{{ instance.name|default:instance.provider_resource_id }}</a> <span class="badge badge-{{ instance.lifecycle_state }}">{{ instance.get_lifecycle_state_display }}</span></li>{% empty %}<li class="muted">该可用区暂无实例。</li>{% endfor %}</ul>
</details>
{% empty %}<p class="muted">该地域暂无可用区。</p>{% endfor %}
{% if region.unassigned_instances %}
<details open><summary>未分配可用区</summary>
<ul>{% for instance in region.unassigned_instances %}<li><a href="{% url 'cmdb:instance_detail' instance.pk %}">{{ instance.name|default:instance.provider_resource_id }}</a> <span class="badge badge-{{ instance.lifecycle_state }}">{{ instance.get_lifecycle_state_display }}</span></li>{% endfor %}</ul>
</details>
{% endif %}
</details>
{% empty %}<p class="muted">该账号尚未发现地域。</p>{% endfor %}
</details>
{% empty %}<p class="muted">该云厂商暂无账号。</p>{% endfor %}
</details>
{% empty %}<p class="muted">暂无拓扑数据。</p>{% endfor %}
</section>
{% endblock %}
模板使用原生 details/summary 形成可折叠树。第 19—23 行专门输出“未分配可用区”,这是对可空外键的业务表达,不是把数据错误地挂到任意可用区。退役实例已在查询层排除。
23.3 云厂商标签与人工标签为什么必须分来源
同一个标签键可以同时存在云厂商值和本地人工值。例如 Provider 返回 owner=provider-team,运维人员也可以维护 owner=local-team。唯一约束包含 source,所以两个值可以共存;同步只管理 Provider 来源,不能删除人工来源。
相对路径:devopsX/cmdb/models.py(当前源码第 266—295 行的连续片段)
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)
Source 只有 provider 与 manual 两类;唯一约束为“实例 + 来源 + 键”。删除实例会级联删除其标签,但正常同步不会删除实例。
相对路径:devopsX/cmdb/services/sync.py(当前源码第 262—286 行的连续片段)
def _provider_tag_state(tags):
return {tag.key: tag.value for tag in sorted(tags, key=lambda tag: tag.key)}
def _upsert_provider_tags(instance, tags):
incoming = {tag.key: tag.value for tag in tags}
existing = set(
ComputeInstanceTag.objects.filter(
instance=instance,
source=ComputeInstanceTag.Source.PROVIDER,
).values_list("key", flat=True)
)
for key in existing - set(incoming):
ComputeInstanceTag.objects.filter(
instance=instance,
source=ComputeInstanceTag.Source.PROVIDER,
key=key,
).delete()
for key, value in incoming.items():
ComputeInstanceTag.objects.update_or_create(
instance=instance,
source=ComputeInstanceTag.Source.PROVIDER,
key=key,
defaults={"value": value},
)
_provider_tag_state 按键排序后生成稳定标签快照,供变更前后数据比较。同步写标签时先把 incoming Provider 标签转换成键值映射;只查询 source=PROVIDER 的已有键;消失的 Provider 键只从 Provider 来源中删除;其余键用 update_or_create 写回。人工标签完全不在这些查询中,因此不会被云端快照覆盖或清理。
相对路径:devopsX/cmdb/forms.py(当前源码第 69—82 行的连续片段)
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()},
)
人工标签表单限制键和值长度,键在保存前去除首尾空白;保存时固定来源为 MANUAL。同一人工键再次提交会更新其值,而不是创建重复行。
23.4 详情页同时呈现标签、变更与退役
相对路径: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 %}
详情页将云上属性与生命周期分栏,将标签来源用不同 class 表达,并把人工标签表单限制在实例修改权限内。变更历史保留前后快照。退役是 POST 动作,服务层写入生命周期、退役时间与一条 RETIRED 变更;后续 Provider 再次发现该实例也不会自动恢复退役状态。
23.5 拓扑与标签检查点
- 将一台实例的可用区置空后,拓扑中应出现“未分配可用区”,该实例仍可点击。
- 添加人工
owner标签,再执行不含对应 Provider 标签的同步;人工标签应保留,消失的 Provider 标签应删除。 - 用 GET 访问标签保存和退役 URL,应得到 405;正常页面 POST 后应重定向回详情。
- 退役实例不应出现在拓扑中;详情与历史仍然保留。
24 云账号 CSV 预览、签名、原子导入与安全导出
24.1 先定义四个安全概念
- 预览
- 先解析、规范化并校验上传文件,只展示将要写入的行,不立即修改数据库。
- 签名
- 使用服务端密钥为预览结果生成完整性凭据,确认导入时验证内容未被客户端篡改。签名不是加密,客户端仍可能看到载荷。
- 原子性(atomicity)
- 多行导入要么全部成功,要么全部回滚;后面一行失败时,前面已处理的行不能残留。
- 公式注入
- 电子表格可能把以
=、+、-、@等字符开头的单元格当作公式执行。导出不能只做 CSV 引号转义,还要中和公式前缀。
24.2 上传表单与确认表单
相对路径:devopsX/cmdb/forms.py(当前源码第 85—114 行的连续片段)
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 失败"),
),
)
- 上传表单限制扩展名为
.csv、大小不超过 1 MiB。 - 确认表单只有隐藏的
signed_payload,不信任浏览器重新提交的逐字段行数据。 - 同一片段末尾的同步表单与 CSV 无关;它列出页面同步允许的四个 Fake 场景。
24.3 严格表头、行校验、签名与事务
相对路径:devopsX/cmdb/services/csv_io.py(完整文件,内容与当前源码一致)
import csv
import io
from django.core import signing
from django.core.exceptions import ValidationError
from django.db import connection, transaction
from django.db.models import F
from cmdb.models import CloudAccount, CloudProvider
REQUIRED_HEADERS = {"provider_code", "account_key", "name"}
OPTIONAL_HEADERS = {
"credential_profile",
"region_allowlist",
"is_active",
"sync_enabled",
}
ALLOWED_HEADERS = REQUIRED_HEADERS | OPTIONAL_HEADERS
SENSITIVE_HEADERS = {
"access_key",
"access_key_id",
"access_key_secret",
"accesskey",
"accesskeyid",
"accesskeysecret",
"password",
"secret",
"secret_key",
"token",
}
PAYLOAD_SALT = "cmdb-cloud-account-csv-v1"
def _parse_boolean(value, field_name, row_number):
normalized = value.strip().lower()
if normalized in {"", "1", "true", "yes", "y"}:
return True
if normalized in {"0", "false", "no", "n"}:
return False
raise ValueError("第 %s 行的 %s 必须是 true 或 false。" % (row_number, field_name))
def _provided_optional_fields(row):
if "provided_fields" in row:
return set(row["provided_fields"])
return OPTIONAL_HEADERS & set(row)
def _validated_account(row, provider, instance=None):
account = instance or CloudAccount(
provider=provider,
account_key=row["account_key"],
)
account.provider = provider
account.account_key = row["account_key"]
account.name = row["name"]
for field_name in _provided_optional_fields(row):
setattr(account, field_name, row[field_name])
try:
account.full_clean(validate_unique=False, validate_constraints=False)
except ValidationError as exc:
raise ValueError(";".join(exc.messages)) from exc
return account
def parse_cloud_account_csv(uploaded_file):
try:
content = uploaded_file.read().decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise ValueError("CSV 必须使用 UTF-8 编码。") from exc
reader = csv.DictReader(io.StringIO(content))
try:
fieldnames = reader.fieldnames
except csv.Error as exc:
raise ValueError("CSV 字段过长或格式无效。") from exc
if not fieldnames:
raise ValueError("CSV 缺少表头。")
normalized_headers = []
for header in fieldnames:
if header is None or not header.strip():
raise ValueError("CSV 表头不能包含空字段名。")
normalized_headers.append(header.strip().lower())
if len(set(normalized_headers)) != len(normalized_headers):
raise ValueError("CSV 表头不能包含重复字段。")
headers = set(normalized_headers)
sensitive = headers & SENSITIVE_HEADERS
if sensitive:
raise ValueError(
"CSV 禁止包含密钥字段:%s。请只填写 credential_profile。"
% ", ".join(sorted(sensitive))
)
unsupported = headers - ALLOWED_HEADERS
if unsupported:
raise ValueError(
"CSV 包含不支持的字段:%s。"
% ", ".join(sorted(unsupported))
)
missing = REQUIRED_HEADERS - headers
if missing:
raise ValueError("CSV 缺少字段:%s。" % ", ".join(sorted(missing)))
providers = {
provider.code: provider for provider in CloudProvider.objects.all()
}
normalized_rows = []
errors = []
seen_identities = set()
try:
raw_rows = list(reader)
except csv.Error as exc:
raise ValueError("CSV 字段过长或格式无效。") from exc
for row_number, raw_row in enumerate(raw_rows, start=2):
if None in raw_row:
errors.append("第 %s 行:字段数量超过 CSV 表头。" % row_number)
continue
row = {
key.strip().lower(): (value or "").strip()
for key, value in raw_row.items()
}
if not any(row.values()):
continue
try:
provider_code = row["provider_code"]
account_key = row["account_key"]
name = row["name"]
if not provider_code or not account_key or not name:
raise ValueError("provider_code、account_key 和 name 不能为空。")
provider = providers.get(provider_code)
if provider is None:
raise ValueError("云厂商不存在:%s。" % provider_code)
identity = (provider_code, account_key)
if identity in seen_identities:
raise ValueError(
"CSV 内重复云账号身份:%s / %s。"
% (provider_code, account_key)
)
region_allowlist = []
seen_region_ids = set()
for item in row.get("region_allowlist", "").split("|"):
region_id = item.strip()
if region_id and region_id not in seen_region_ids:
region_allowlist.append(region_id)
seen_region_ids.add(region_id)
normalized_row = {
"provider_code": provider_code,
"account_key": account_key,
"name": name,
"credential_profile": row.get("credential_profile", ""),
"region_allowlist": region_allowlist,
"is_active": _parse_boolean(
row.get("is_active", "true"),
"is_active",
row_number,
),
"sync_enabled": _parse_boolean(
row.get("sync_enabled", "true"),
"sync_enabled",
row_number,
),
"provided_fields": sorted(headers & OPTIONAL_HEADERS),
}
_validated_account(normalized_row, provider)
normalized_rows.append(normalized_row)
seen_identities.add(identity)
except ValueError as exc:
errors.append("第 %s 行:%s" % (row_number, exc))
if not normalized_rows and not errors:
errors.append("CSV 没有可导入的数据行。")
return normalized_rows, errors
def sign_rows(rows):
return signing.dumps(rows, salt=PAYLOAD_SALT, compress=True)
def load_signed_rows(payload):
try:
return signing.loads(payload, salt=PAYLOAD_SALT, max_age=1800)
except signing.BadSignature as exc:
raise ValueError("预览数据已经失效,请重新上传 CSV。") from exc
@transaction.atomic
def import_cloud_accounts(rows):
provider_codes = {row["provider_code"] for row in rows}
provider_queryset = CloudProvider.objects.filter(
code__in=provider_codes
).order_by("pk")
if connection.vendor == "sqlite":
provider_queryset.update(updated_at=F("updated_at"))
providers = {
provider.code: provider
for provider in provider_queryset.select_for_update()
}
missing_providers = provider_codes - set(providers)
if missing_providers:
raise ValueError(
"云厂商不存在:%s。" % ", ".join(sorted(missing_providers))
)
created_count = 0
updated_count = 0
for row_number, row in enumerate(rows, start=1):
provider = providers[row["provider_code"]]
existing = CloudAccount.objects.filter(
provider=provider,
account_key=row["account_key"],
).first()
try:
validated = _validated_account(row, provider, instance=existing)
except ValueError as exc:
raise ValueError("第 %s 行:%s" % (row_number, exc)) from exc
provided_fields = _provided_optional_fields(row)
defaults = {"name": validated.name}
for field_name in provided_fields:
defaults[field_name] = getattr(validated, field_name)
account, created = CloudAccount.objects.update_or_create(
provider=provider,
account_key=validated.account_key,
defaults=defaults,
)
if created:
created_count += 1
else:
updated_count += 1
return created_count, updated_count
该文件按小组解释如下:
- 导入组使用标准库
csv/io、Django signing、模型校验与数据库事务。 REQUIRED_HEADERS要求 Provider 代码、账号键、名称;可选集合明确允许凭据前缀、地域白名单和两个布尔字段。SENSITIVE_HEADERS拒绝常见 AccessKey、Secret、密码和 Token 列。CSV 只能保存环境变量前缀,不能携带凭据值。_parse_boolean规范化常见真/假值,其他文本产生带行号错误。_validated_account同时服务预览与正式导入,并调用模型full_clean。- 文件以
utf-8-sig解码,既接受普通 UTF-8,也接受 UTF-8 BOM;解码失败转为明确错误。 - 表头先去空白并转小写,再拒绝空字段名和规范化后的重复字段,因此
name与Name也算重复。 - 随后拒绝敏感字段、未知字段和缺失必填字段;拼错的
sync_enable不会被静默忽略。 DictReader在数据列多于表头时使用None键;代码将其转换为逐行错误,不触发 500。- 空行跳过;必填值不能为空;Provider 必须存在;地域以竖线分隔并按首次出现顺序去重。
- 预览结果用固定 salt 签名并压缩;确认时限制 1800 秒有效期。
import_cloud_accounts被transaction.atomic包裹。Provider 先按主键排序并加行锁;SQLite 先执行无值变化的更新以取得写锁,再重读锁定结果。随后使用update_or_create写账号,任意一行失败都会回滚全部行。- 更新账号的 defaults 不包含
last_successful_sync_at,所以 CSV 更新不会清空已有成功同步时间。
24.4 两阶段导入视图
相对路径:devopsX/cmdb/views.py(当前源码第 397—445 行的连续片段)
@login_required
@permission_required("cmdb.import_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)
同一个 URL 通过隐藏的 action 区分 preview 与 import。预览阶段读取上传文件,错误回填到文件字段;全部行通过才签名。确认阶段只接收签名载荷,验证后调用原子导入;成功采用 POST/Redirect/GET 回账号列表,避免刷新重复提交。
相对路径:devopsX/cmdb/templates/cmdb/account_csv_import.html(完整文件,内容与当前源码一致)
{% extends "base.html" %}
{% block title %}CSV 导入云账号 - CMDB{% endblock %}
{% block content %}
<section class="page-heading"><p class="eyebrow">CSV Import</p><h1>CSV 导入云账号</h1><p>先预览、再确认导入;CSV 禁止包含密钥、密码和 Token。</p></section>
<section class="panel"><h2>1. 上传并预览</h2><p class="callout"><code>provider_code,account_key,name,credential_profile,region_allowlist,is_active,sync_enabled</code><br>地域使用竖线分隔,例如 <code>cn-hangzhou|cn-shanghai</code>。更新已有账号时,未提供的可选列保持原值;已提供的空单元格按该列的空值或默认值处理。</p><form method="post" enctype="multipart/form-data">{% csrf_token %}<input type="hidden" name="action" value="preview">{{ upload_form.as_p }}<button class="button" type="submit">预览 CSV</button></form></section>
{% if preview_rows %}<section class="panel"><h2>2. 核对并导入</h2><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 row in preview_rows %}<tr><td>{{ row.provider_code }}</td><td><code>{{ row.account_key }}</code></td><td>{{ row.name }}</td><td>{% if "credential_profile" in row.provided_fields %}<code>{{ row.credential_profile|default:"—" }}</code>{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td><td>{% if "region_allowlist" in row.provided_fields %}{{ row.region_allowlist|join:", "|default:"全部" }}{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td><td>{% if "is_active" in row.provided_fields %}{{ row.is_active|yesno:"是,否" }}{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td><td>{% if "sync_enabled" in row.provided_fields %}{{ row.sync_enabled|yesno:"是,否" }}{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td></tr>{% endfor %}</tbody></table></div><p class="muted">新建账号未提供可选列时使用模型默认值;更新已有账号时保留数据库中的当前值。</p><form method="post">{% csrf_token %}<input type="hidden" name="action" value="import">{{ confirm_form }}<button class="button" type="submit">确认导入 {{ preview_rows|length }} 行</button></form></section>{% endif %}
{% if confirm_form.errors %}<section class="panel"><h2>导入失败</h2>{{ confirm_form.errors }}</section>{% endif %}
{% endblock %}
第一段始终显示严格列结构与上传表单;只有 preview_rows 存在时才出现核对表格和确认按钮。两个 POST 表单都带 CSRF。页面明确禁止密钥、密码与 Token。
24.5 当前严格 CSV 结构
| 列 | 必填 | 规则 |
|---|---|---|
provider_code | 是 | 必须匹配已有 Provider 代码 |
account_key | 是 | 非空;与 Provider 共同构成账号身份 |
name | 是 | 非空显示名称 |
credential_profile | 否 | 大写字母开头,只含大写字母、数字、下划线;不是凭据值 |
region_allowlist | 否 | 地域 ID 用竖线分隔,重复值去重 |
is_active | 否 | 支持 true/false、1/0、yes/no、y/n;空值默认真 |
sync_enabled | 否 | 与 is_active 相同 |
严格模式还拒绝未知表头、空表头、重复表头、敏感表头、数据行额外列、没有有效数据行以及非 UTF-8 文件。
24.6 电子表格安全导出
相对路径:devopsX/cmdb/views.py(当前源码第 41—46 行的连续片段)
def _csv_safe(value):
text = str(value)
candidate = text.lstrip(" ")
if candidate and candidate[0] in "=+-@\t\r\n":
return "'%s" % text
return text
相对路径:devopsX/cmdb/views.py(当前源码第 448—498 行的连续片段)
@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
_csv_safe先转换成字符串,再忽略开头普通空格检查首个有效字符;遇到危险前缀就在原文本前增加单引号。- 危险集合包含
= + - @以及制表符、回车和换行。 - 响应声明 UTF-8 CSV,并先写 BOM,改善常见桌面表格软件识别中文编码的体验。
csv.writer负责字段引号与换行;每个导出值在进入 writer 前都经过_csv_safe。- IP 使用竖线连接;可用区为空时导出空字符串;时间使用 ISO 8601。
常见错误是只依赖 csv.writer。CSV 引号保持文件结构,却不会阻止表格软件把单元格当公式;结构转义和公式中和是两层防护。
24.7 CSV 检查点
- 未知、空或重复表头应在预览页报错,不进入确认步骤。
- 数据行比表头多一列时,应显示“字段数量超过 CSV 表头”,不应返回 500。
- 篡改或过期签名载荷时,应要求重新上传预览。
- 两行导入的第二行校验失败时,第一行也不应留在数据库。
- 实例名称以危险公式字符开头时,导出单元格应以前置单引号中和;文件应以 UTF-8 BOM 开始。
25 版本化 JsonResponse API、Session 认证与正文校验
25.1 API 版本与 Session 认证
API 版本化是在 URL 和响应中显式标识契约版本,使未来不兼容变更可以进入新版本而不悄悄破坏旧客户端。本项目同时使用 /api/v1/ 路径和响应字段 api_version: v1。
Session 认证表示 API 复用 Django 登录会话:浏览器登录后携带 Session Cookie,login_required 恢复用户;随后仍由模型权限授权。它不是无状态 Token API。由于 POST 仍经过 CsrfViewMiddleware,浏览器 Session API 的写请求也必须携带 CSRF 令牌。
相对路径:devopsX/cmdb/api_urls.py(完整文件,内容与当前源码一致)
from django.urls import path
from . import api_views
app_name = "api"
urlpatterns = [
path("accounts/", api_views.account_list, name="account_list"),
path("accounts/<int:pk>/sync/", api_views.account_sync, name="account_sync"),
path("instances/", api_views.instance_list, name="instance_list"),
path("instances/<int:pk>/", api_views.instance_detail, name="instance_detail"),
]
四条 v1 路由分别提供账号列表、账号同步、实例列表和实例详情;它们由主 CMDB URL 挂载到 /cmdb/api/v1/。
相对路径:devopsX/cmdb/api_views.py(完整文件,内容与当前源码一致)
import json
from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import require_GET, require_POST
from .models import CloudAccount, ComputeInstance, SyncRun
from .providers.base import ProviderError
from .services.sync import sync_account
API_VERSION = "v1"
CONFLICT_ERROR_CODES = {
"ACCOUNT_DISABLED",
"PROVIDER_DISABLED",
"SYNC_ALREADY_RUNNING",
"SYNC_CONFIGURATION_CHANGED",
"SYNC_RUN_SUPERSEDED",
}
UPSTREAM_ERROR_CODES = {
"ALIYUN_ACCESS_DENIED",
"ALIYUN_INCOMPLETE_PAGINATION",
"ALIYUN_INCONSISTENT_PAGINATION",
"ALIYUN_INVALID_RESPONSE",
"ALIYUN_INVALID_TIME",
"ALIYUN_NETWORK_ERROR",
"ALIYUN_REQUEST_FAILED",
"ALIYUN_SDK_NOT_INSTALLED",
"ALIYUN_THROTTLED",
"DUPLICATE_INSTANCE",
"DUPLICATE_REGION",
"DUPLICATE_ZONE",
"EMPTY_INSTANCE_ID",
"EMPTY_REGION_ID",
"EMPTY_ZONE_ID",
"INCOMPLETE_DISCOVERY_SCOPE",
"INSTANCE_ZONE_REGION_MISMATCH",
"INVALID_DISCOVERY_SCOPE",
"INVALID_NORMALIZED_STATUS",
"OUT_OF_SCOPE_DISCOVERY_REGION",
"PROVIDER_UNAVAILABLE",
"UNEXPECTED_ERROR",
"UNKNOWN_INSTANCE_REGION",
"UNKNOWN_INSTANCE_ZONE",
"UNKNOWN_ZONE_REGION",
}
def _provider_error_status(code):
if code in CONFLICT_ERROR_CODES:
return 409
if code in UPSTREAM_ERROR_CODES:
return 502
return 422
def _json_response(data, status=200):
return JsonResponse(
data,
status=status,
json_dumps_params={"ensure_ascii": False},
)
def _instance_payload(instance):
return {
"id": instance.id,
"provider": instance.account.provider.code,
"account_key": instance.account.account_key,
"provider_resource_id": instance.provider_resource_id,
"name": instance.name,
"region": instance.region.provider_resource_id,
"availability_zone": (
instance.availability_zone.provider_resource_id
if instance.availability_zone
else None
),
"instance_type": instance.instance_type,
"vcpu": instance.vcpu,
"memory_mb": instance.memory_mb,
"os_name": instance.os_name,
"provider_status": instance.provider_status,
"normalized_status": instance.normalized_status,
"lifecycle_state": instance.lifecycle_state,
"private_ips": instance.private_ips,
"public_ips": instance.public_ips,
"cloud_created_at": (
instance.cloud_created_at.isoformat() if instance.cloud_created_at else None
),
"first_seen_at": instance.first_seen_at.isoformat(),
"last_seen_at": instance.last_seen_at.isoformat(),
"missing_since": (
instance.missing_since.isoformat() if instance.missing_since else None
),
"retired_at": instance.retired_at.isoformat() if instance.retired_at else None,
"tags": [
{"key": tag.key, "value": tag.value, "source": tag.source}
for tag in instance.tags.all()
],
}
@login_required
@permission_required("cmdb.view_cloudaccount", raise_exception=True)
@require_GET
def account_list(request):
accounts = CloudAccount.objects.select_related("provider").order_by(
"provider__code",
"account_key",
)
try:
page_size = min(max(int(request.GET.get("page_size", 50)), 1), 100)
except ValueError:
return _json_response(
{"api_version": API_VERSION, "error": "page_size 必须是整数。"},
status=400,
)
page_obj = Paginator(accounts, page_size).get_page(request.GET.get("page"))
data = [
{
"id": account.id,
"provider": account.provider.code,
"account_key": account.account_key,
"name": account.name,
"credential_profile": account.credential_profile,
"region_allowlist": account.region_allowlist,
"is_active": account.is_active,
"sync_enabled": account.sync_enabled,
"last_successful_sync_at": (
account.last_successful_sync_at.isoformat()
if account.last_successful_sync_at
else None
),
}
for account in page_obj
]
return _json_response(
{
"api_version": API_VERSION,
"count": page_obj.paginator.count,
"page": page_obj.number,
"pages": page_obj.paginator.num_pages,
"results": data,
}
)
@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
@require_GET
def instance_list(request):
instances = ComputeInstance.objects.select_related(
"account",
"account__provider",
"region",
"availability_zone",
).prefetch_related("tags")
keyword = request.GET.get("q", "").strip()
if keyword:
instances = instances.filter(
Q(provider_resource_id__icontains=keyword)
| Q(name__icontains=keyword)
| Q(os_name__icontains=keyword)
)
lifecycle = request.GET.get("lifecycle", "").strip()
if lifecycle:
instances = instances.filter(lifecycle_state=lifecycle)
instances = instances.order_by("id")
try:
page_size = min(max(int(request.GET.get("page_size", 50)), 1), 100)
except ValueError:
return _json_response(
{"api_version": API_VERSION, "error": "page_size 必须是整数。"},
status=400,
)
page_obj = Paginator(instances, page_size).get_page(request.GET.get("page"))
return _json_response(
{
"api_version": API_VERSION,
"count": page_obj.paginator.count,
"page": page_obj.number,
"pages": page_obj.paginator.num_pages,
"results": [_instance_payload(instance) for instance in page_obj],
}
)
@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
@require_GET
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,
)
return _json_response(
{"api_version": API_VERSION, "result": _instance_payload(instance)}
)
@login_required
@permission_required("cmdb.sync_cloudaccount", raise_exception=True)
@require_POST
def account_sync(request, pk):
account = get_object_or_404(
CloudAccount.objects.select_related("provider"),
pk=pk,
)
try:
body = json.loads(request.body or b"{}")
except (UnicodeDecodeError, json.JSONDecodeError):
return _json_response(
{"api_version": API_VERSION, "error": "请求正文不是有效 JSON。"},
status=400,
)
if not isinstance(body, dict):
return _json_response(
{"api_version": API_VERSION, "error": "请求正文必须是 JSON 对象。"},
status=400,
)
scenario = body.get("scenario", "default")
if not isinstance(scenario, str) or scenario not in {
"default",
"empty",
"partial",
"failed",
}:
return _json_response(
{"api_version": API_VERSION, "error": "未知 Fake 场景。"},
status=400,
)
if account.provider.code != "fake":
scenario = "default"
try:
sync_run = sync_account(
account,
requested_by=request.user,
trigger=SyncRun.Trigger.API,
scenario=scenario,
)
except ProviderError as exc:
return _json_response(
{
"api_version": API_VERSION,
"error": exc.message,
"error_code": exc.code,
},
status=_provider_error_status(exc.code),
)
return _json_response(
{
"api_version": API_VERSION,
"result": {
"public_id": str(sync_run.public_id),
"status": sync_run.status,
"discovered_count": sync_run.discovered_count,
"created_count": sync_run.created_count,
"updated_count": sync_run.updated_count,
"unchanged_count": sync_run.unchanged_count,
"missing_count": sync_run.missing_count,
"restored_count": sync_run.restored_count,
"error_code": sync_run.error_code,
"error_message": sync_run.error_message,
},
},
status=207 if sync_run.status == SyncRun.Status.PARTIAL else 201,
)
完整文件逐组解释如下:
- 导入组使用标准库 JSON、Django 认证/授权装饰器、分页、查询表达式、JsonResponse、404 与方法装饰器。
API_VERSION是统一响应版本;_json_response设置状态码并关闭 ASCII 转义,使中文错误可直接阅读。_instance_payload明确列出实例字段与标签,不把 ORM 对象或任意模型字段直接序列化;可空时间与可用区显式输出null。- 账号列表要求账号读取权限,稳定排序后返回显式字段。
credential_profile只是环境变量前缀,不是凭据值。 - 实例列表要求实例读取权限,支持 ID、名称、操作系统关键词和生命周期筛选;按 ID 稳定排序。
page_size必须是整数,再限制到 1—100;无效文本返回 400,不产生 500。- 实例详情复用 payload 生成器并由
get_object_or_404返回标准 404。 - 账号同步要求同步权限与 POST。
json.loads接受 bytes;无效 UTF-8 抛UnicodeDecodeError,语法错误抛JSONDecodeError,当前实现同时捕获并返回 400。 - JSON 解析成功后还必须是对象。
null、数组、字符串和数字都被拒绝,避免后续body.get触发属性错误。 scenario必须是字符串且属于四个 Fake 场景;非 Fake Provider 强制使用默认场景。CONFLICT_ERROR_CODES把账号或厂商停用、同步占用、配置变化和运行被替代映射为 409;UPSTREAM_ERROR_CODES把阿里云请求失败、响应结构不完整、分页不一致以及通用快照完整性错误映射为 502;其余客户端或配置语义错误返回 422。- 账号列表和实例列表都使用 Paginator;页大小必须是整数并限制在 1—100,账号分页不会在跨页时重复或漏掉结果。
- 完整成功返回 201;部分快照返回 207,并在结果中携带状态、错误代码、脱敏错误信息与统计。
25.2 API 状态码契约
| 状态 | 场景 |
|---|---|
| 200 | 列表或详情读取成功 |
| 201 | API 成功触发一次完整同步执行 |
| 207 | API 应用部分快照,返回 PARTIAL 状态和脱敏错误详情 |
| 302 | 匿名请求被 Session 登录装饰器重定向到登录页 |
| 400 | page_size 非整数、JSON 非法、UTF-8 非法、正文不是对象或场景非法 |
| 403 | 缺少模型权限,或 Session POST 未通过 CSRF |
| 404 | 目标账号或实例不存在 |
| 405 | 用 GET 调用同步端点或用非 GET 调用只读端点 |
| 409 | 账号/厂商停用、已有有效同步、配置变化或运行被替代 |
| 422 | 不属于冲突或上游故障的客户端/账号配置语义错误 |
| 502 | 云接口、SDK、网络、分页、响应结构或标准快照完整性失败 |
25.3 浏览器与测试检查点
- 在同一浏览器登录,再打开
/cmdb/api/v1/accounts/;有账号读取权限时看到 JSON,没有权限时为 403。 page_size=0被夹到 1,超过 100 被夹到 100,非整数返回 400。- 同步正文为损坏 JSON 或非法 UTF-8 时都返回 400 和统一错误,不泄露解码异常。
- 正文为
null、数组、字符串、数字,或scenario为数组时返回 400,不触发 500。 - 账号结果可以包含凭据环境变量前缀,但不得出现真实凭据值。
常见错误是认为“返回 JsonResponse”就自动变成 Token API,或者认为 JSON POST 不需要 CSRF。当前端点仍是 Django Session 认证模型,认证 Cookie 与 CSRF 防护必须配套理解。
26 模板、静态样式与移动端行为
26.1 基础模板承担什么职责
相对路径: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>
- 文档声明、语言、UTF-8 与 viewport 位于 head;viewport 是移动端按设备宽度布局的前提。
- 静态样式通过 Django
static标签加载,不把页面样式散落到每个模板。 - 导航按认证状态和模型权限显示入口;管理后台入口还要求
is_staff。 - 退出使用带 CSRF 的内联 POST 表单。
- 消息框统一显示登录、同步、导入、标签与退役反馈。
- 子模板只覆盖标题与内容块,避免重复页面骨架。
再次强调:导航隐藏是可用性设计,视图装饰器才是授权边界。
26.2 页面语义与复用
| 模板 | 主要语义结构 | 关键行为 |
|---|---|---|
devopsX/cmdb/templates/cmdb/home.html | 页头、统计卡片、最近同步表格 | 按权限分别显示 |
devopsX/cmdb/templates/cmdb/instance_list.html | GET 筛选表单、结果表格、分页导航 | 保留查询条件 |
devopsX/cmdb/templates/cmdb/instance_detail.html | 定义列表、标签、details 变更记录 | 写表单受权限与 CSRF 保护 |
devopsX/cmdb/templates/cmdb/topology.html | 嵌套 details/summary 与列表 | 无 JavaScript 也可折叠 |
devopsX/cmdb/templates/cmdb/account_csv_import.html | 上传、预览表格、确认表单 | 两阶段提交 |
devopsX/cmdb/templates/cmdb/_pagination.html | 分页 nav | 多个列表复用 |
26.3 当前静态样式完整文件
相对路径: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; }
}
样式按功能组解释如下:
:root、通配符、body、链接与等宽字体建立全局基线。- 站点头部使用 Flex 并允许导航换行;页面容器使用视口相关的弹性尺寸。
- hero、panel、页头、空状态和统计卡片共享边框、圆角与阴影。
split-heading、actions、button 系列组织标题与动作;危险按钮使用独立颜色。- 统计、卡片和详情使用 Grid;表格容器允许横向滚动,长字段不会撑破详情定义列表。
- badge 根据同步、状态和生命周期复用语义颜色;人工标签拥有独立样式。
- 表单控件统一触控尺寸;筛选区桌面四列,操作按钮与行内字段使用 Flex。
- 变更前后使用双列网格;
pre支持换行和滚动;拓扑通过边线表达层级。 - 900px 以下统计与筛选减少列数;700px 以下页头/详情/卡片改为块级,统计与筛选变单列,变更对比改为上下排列。
26.4 移动端浏览器检查点
- 在浏览器开发者工具切换到约 375px 宽的移动视口并刷新。
- 确认导航可以换行,页面没有整体横向溢出。
- 确认统计卡片与筛选表单为单列,按钮仍可点击。
- 确认多列表格只在自己的容器内横向滚动,页面标题和其他卡片不随之溢出。
- 确认实例详情双栏与变更前后双栏改为上下排列。
- 确认拓扑层级可展开、可收起,“未分配可用区”仍可见。
常见错误是为了让桌面表格完整塞入手机而缩小字体。当前实现保留可读字号,把横向滚动限制在表格容器,其他内容继续响应式重排。
27 自动化测试与浏览器验收
27.1 测试覆盖地图
| 测试模块 | 当前重点 |
|---|---|
devopsX/accounts/tests.py | 公开首页、登录默认跳转、安全站内 next、拒绝外部 next |
devopsX/cmdb/tests/test_views.py | 匿名跳转、首页权限、普通用户 403、三类残余读取边界、POST-only、搜索、畸形关联 ID、部分同步警告、无可用区拓扑、标签与退役 |
devopsX/cmdb/tests/test_csv.py | 敏感/未知/空/重复表头、额外列、预览签名导入、模型校验、事务回滚、保留成功同步时间、BOM 与公式中和 |
devopsX/cmdb/tests/test_api.py | v1 契约、权限读取、POST 同步、部分同步 207、非法 JSON、非法 UTF-8、非对象 JSON、page_size |
devopsX/cmdb/tests/test_sync.py | 同步幂等、完整/部分快照、缺失/恢复/退役、标签隔离、范围校验、并发阻塞与陈旧执行 |
devopsX/cmdb/tests/test_aliyun_provider.py | 使用 Fake 客户端验证映射、分页、地域白名单、部分快照与脱敏错误;不是对真实阿里云的冒烟测试 |
27.2 先运行 Django 系统检查
在项目根目录执行;每个命令单独运行,不与迁移、启动服务或其他命令串联。
执行目录:devopsX/
python manage.py check
预期:System check identified no issues (0 silenced).
常见错误:如果生产模式环境变量尚未配置,设置模块会主动报错;在本地教学环境应使用前文已经建立的本地环境配置,不要为了通过检查而削弱生产安全默认值。
27.3 运行完整测试套件
仍在项目根目录执行。该命令会创建隔离测试数据库,不应把测试数据写入开发数据库。
执行目录:devopsX/
python manage.py test
本次实际结果:发现 105 项测试,全部通过;Django 系统检查无问题。核心输出为:
输出来源:devopsX/(命令输出)
Found 105 test(s). System check identified no issues (0 silenced). ---------------------------------------------------------------------- Ran 105 tests OK
耗时会随机器和数据库环境变化,因此验收重点是数量、无系统检查错误和最终 OK,不应把固定秒数写成通过条件。
27.4 分模块定位失败
权限、方法与页面边界失败时,只运行视图测试。
执行目录:devopsX/
python manage.py test cmdb.tests.test_views.CmdbViewTests
预期:该测试类通过;尤其锁定 Provider-only 不见账号数量、account-only 不见实例与同步执行、sync-run-only 不见实例变化,以及三个写动作的 POST-only 行为。
CSV 预览、表头、事务或导出失败时,只运行 CSV 测试。
执行目录:devopsX/
python manage.py test cmdb.tests.test_csv.CloudAccountCsvTests
预期:严格表头、额外列、签名导入、原子回滚、UTF-8 BOM 与公式中和全部通过。
JSON、版本、分页或 Session 权限失败时,只运行 API 测试。
执行目录:devopsX/
python manage.py test cmdb.tests.test_api.CmdbApiTests
预期:v1 列表与详情通过;同步 GET 为 405、完整成功为 201、部分同步为 207;非法 JSON、非法 UTF-8、非对象正文和非法 page_size 均为 400;上游响应结构或快照完整性失败为 502。
登录与安全回跳失败时,只运行账号视图测试。
执行目录:devopsX/
python manage.py test accounts.tests.AccountViewTests
预期:公开首页可访问;合法站内 next 生效;外部 next 被拒绝并回到 CMDB 首页。
27.5 启动本地服务器进行浏览器验收
确认系统检查与测试均通过后,在项目根目录启动开发服务器。该命令持续占用当前终端,停止时按 Ctrl+C。
执行目录:devopsX/
python manage.py runserver 127.0.0.1:8000
预期:终端显示开发服务器监听本机地址;浏览器打开站点首页可登录。若端口已占用,应停止旧进程或明确换用另一个本地端口,不要同时启动多个写同一开发数据库的服务。
27.6 最小权限浏览器验收清单
- 匿名访问 CMDB 首页:应跳到登录页,地址带站内
next。 - 无 CMDB 权限的普通用户:访问 CMDB 首页应为 403。
- Provider-only 用户:Provider 页面可打开,但没有“云账号数量”。
- account-only 用户:账号详情可打开,但没有实例数量、实例 ID、同步 UUID。
- sync-run-only 用户:同步详情可打开,但没有“本次变化”和实例标识。
- instance-only 用户:实例搜索、筛选、分页、详情和拓扑可用;同步、导入、导出或退役取决于额外权限。
- 对同步、标签、退役端点发 GET:权限满足时应为 405。
- 从正常页面 POST 同步、标签、退役:CSRF 通过后执行并重定向;缺失令牌请求应失败。
- CSV 导入:未知、空、重复表头和额外列均在预览阶段被拒绝;合法文件必须先预览再确认。
- 登录后打开 v1 API:读取结果包含
api_version;非法 JSON/UTF-8/非对象正文返回 400。 - 切换移动视口:筛选和卡片单列,表格局部滚动,拓扑与详情可读。
27.7 验收结论与下一篇
至此,CMDB v1.0.0 的浏览器层与 JsonResponse API 已形成可验证闭环:Session 完成认证,模型权限落实最小授权,写动作由 POST 与 CSRF 保护;资产列表具备安全排序与分页;拓扑不丢失无可用区实例;标签区分 Provider 与人工来源;CSV 导入具备严格结构、签名预览和事务原子性;CSV 导出中和电子表格公式;v1 API 对非法 UTF-8、非法 JSON 和非对象正文给出稳定 400。
本篇验证止于自动化测试和本地浏览器检查,不把 SQLite 结果外推为 MySQL 认证,也不把 Fake 阿里云客户端测试描述为真实阿里云冒烟。

浙公网安备 33010602011771号