CMDB 资产管理(7):v1.0.0 源码汇总(下篇)
2 CMDB 资产管理(7):v1.0.0 源码汇总(下篇)
2.1 导航与连续性
本篇从最终 ZIP 第 38 项继续到第 69 项。每个文件都在完整边界展示;空 __init__.py 会明确标记为空文件,不用“同上”“略”或省略号替代内容。
2.2 完整源码(第 38 至 69 项)
2.2.1 devopsX/cmdb/templates/cmdb/account_detail.html
清单第 38 项;ZIP 内路径: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 %}
2.2.2 devopsX/cmdb/templates/cmdb/account_form.html
清单第 39 项;ZIP 内路径:cmdb/templates/cmdb/account_form.html。
{% extends "base.html" %}
{% block title %}添加云账号 - CMDB{% endblock %}
{% block content %}
<section class="panel form-panel">
<p class="eyebrow">Cloud Account</p>
<h1>添加云账号</h1>
<p class="callout">这里没有 AccessKey、Secret、密码或 Token 输入框。真实凭据只存放在环境变量中。</p>
<form method="post">
{% csrf_token %}
{{ form.as_p }}
<div class="actions"><button class="button" type="submit">保存云账号</button><a class="button button-secondary" href="{% url 'cmdb:account_list' %}">取消</a></div>
</form>
</section>
{% endblock %}
2.2.3 devopsX/cmdb/templates/cmdb/account_list.html
清单第 40 项;ZIP 内路径: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 %}
2.2.4 devopsX/cmdb/templates/cmdb/home.html
清单第 41 项;ZIP 内路径: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.2.5 devopsX/cmdb/templates/cmdb/instance_detail.html
清单第 42 项;ZIP 内路径: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 %}
2.2.6 devopsX/cmdb/templates/cmdb/instance_list.html
清单第 43 项;ZIP 内路径: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 %}
2.2.7 devopsX/cmdb/templates/cmdb/provider_list.html
清单第 44 项;ZIP 内路径: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 %}
2.2.8 devopsX/cmdb/templates/cmdb/sync_run_detail.html
清单第 45 项;ZIP 内路径: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 %}
2.2.9 devopsX/cmdb/templates/cmdb/sync_run_list.html
清单第 46 项;ZIP 内路径:cmdb/templates/cmdb/sync_run_list.html。
{% extends "base.html" %}
{% block title %}同步历史 - CMDB{% endblock %}
{% block content %}
<section class="page-heading"><p class="eyebrow">Sync Run</p><h1>同步历史</h1><p>每一次发现都保留状态、统计和脱敏错误,不把运行过程伪装成异步任务。</p></section>
<section class="panel"><div class="table-wrap"><table><thead><tr><th>执行 ID</th><th>云账号</th><th>触发</th><th>状态</th><th>发现</th><th>新建</th><th>更新</th><th>未变化</th><th>缺失</th><th>恢复</th><th>时间</th></tr></thead><tbody>{% for sync_run in page_obj %}<tr><td><a href="{% url 'cmdb:sync_run_detail' sync_run.public_id %}"><code>{{ sync_run.public_id }}</code></a></td><td>{{ sync_run.account.name }}</td><td>{{ sync_run.get_trigger_display }}</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.unchanged_count }}</td><td>{{ sync_run.missing_count }}</td><td>{{ sync_run.restored_count }}</td><td>{{ sync_run.created_at|date:"Y-m-d H:i:s" }}</td></tr>{% empty %}<tr><td colspan="11" class="muted">尚无同步记录。</td></tr>{% endfor %}</tbody></table></div>{% include "cmdb/_pagination.html" %}</section>
{% endblock %}
2.2.10 devopsX/cmdb/templates/cmdb/topology.html
清单第 47 项;ZIP 内路径: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 %}
2.2.11 devopsX/cmdb/tests/__init__.py
清单第 48 项;ZIP 内路径:cmdb/tests/__init__.py。
这是有意保留的空文件;空内容本身就是完整内容,用于声明 Python 包或迁移包。
2.2.12 devopsX/cmdb/tests/test_aliyun_provider.py
清单第 49 项;ZIP 内路径:cmdb/tests/test_aliyun_provider.py。
import os
from types import SimpleNamespace
from unittest.mock import patch
from django.test import SimpleTestCase, TestCase
from cmdb.models import CloudAccount, CloudProvider, ComputeInstance, SyncRun
from cmdb.providers.aliyun import AliyunEcsProvider, AliyunSdkClient, map_instance
from cmdb.providers.base import ProviderError
from cmdb.services.sync import sync_account
class FakeAliyunClient:
def __init__(self, fail_second_page=False, truncate_second_page=False):
self.fail_second_page = fail_second_page
self.truncate_second_page = truncate_second_page
self.instance_calls = []
def describe_regions(self):
return [
{
"region_id": "cn-hangzhou",
"local_name": "杭州",
"region_endpoint": "ecs.cn-hangzhou.aliyuncs.com",
},
{
"region_id": "cn-shanghai",
"local_name": "上海",
"region_endpoint": "ecs.cn-shanghai.aliyuncs.com",
},
]
def describe_zones(self, region_id):
return [
{
"zone_id": "%s-a" % region_id,
"local_name": "%s 可用区 A" % region_id,
}
]
def describe_instances_page(self, region_id, page_number, page_size):
self.instance_calls.append((region_id, page_number, page_size))
if self.fail_second_page and region_id == "cn-hangzhou" and page_number == 2:
raise TimeoutError("timeout containing details that must not be shown")
if self.truncate_second_page and region_id == "cn-hangzhou" and page_number == 2:
return [], 2
if region_id == "cn-hangzhou":
if page_number == 1:
return [self._instance("i-hz-001", region_id, "Running")], 2
if page_number == 2:
return [self._instance("i-hz-002", region_id, "Stopped")], 2
if region_id == "cn-shanghai" and page_number == 1:
return [self._instance("i-sh-001", region_id, "Running")], 1
return [], 0
def _instance(self, instance_id, region_id, status):
return {
"instance_id": instance_id,
"instance_name": "%s-name" % instance_id,
"zone_id": "%s-a" % region_id,
"instance_type": "ecs.g7.large",
"cpu": 2,
"memory": 8192,
"osname": "Alibaba Cloud Linux 3",
"status": status,
"creation_time": "2026-01-10T08:00:00Z",
"inner_ip_address": {"ip_address": ["10.0.0.10"]},
"public_ip_address": {"ip_address": ["203.0.113.20"]},
"tags": {"tag": [{"tag_key": "environment", "tag_value": "demo"}]},
}
class AliyunSdkResponseTests(SimpleTestCase):
def _client(self, response):
transport = SimpleNamespace(
describe_regions_with_options=lambda request, runtime: response,
describe_zones_with_options=lambda request, runtime: response,
describe_instances_with_options=lambda request, runtime: response,
)
client = AliyunSdkClient.__new__(AliyunSdkClient)
client.ecs_models = SimpleNamespace(
DescribeRegionsRequest=lambda **kwargs: kwargs,
DescribeZonesRequest=lambda **kwargs: kwargs,
DescribeInstancesRequest=lambda **kwargs: kwargs,
)
client.runtime = object()
client._client = lambda endpoint: transport
return client
def test_describe_regions_rejects_missing_collection_node(self):
client = self._client(SimpleNamespace(body={}))
with self.assertRaises(ProviderError) as context:
client.describe_regions()
self.assertEqual(context.exception.code, "ALIYUN_INVALID_RESPONSE")
def test_describe_zones_rejects_missing_collection_node(self):
client = self._client(SimpleNamespace(body={"zones": {}}))
with self.assertRaises(ProviderError) as context:
client.describe_zones("cn-hangzhou")
self.assertEqual(context.exception.code, "ALIYUN_INVALID_RESPONSE")
def test_describe_instances_rejects_missing_list_or_total(self):
responses = (
SimpleNamespace(body={"total_count": 0}),
SimpleNamespace(body={"instances": {"instance": []}}),
SimpleNamespace(
body={
"instances": {"instance": []},
"total_count": "0",
}
),
)
for response in responses:
with self.subTest(body=response.body):
client = self._client(response)
with self.assertRaises(ProviderError) as context:
client.describe_instances_page("cn-hangzhou", 1, 100)
self.assertEqual(context.exception.code, "ALIYUN_INVALID_RESPONSE")
def test_describe_instances_accepts_explicit_empty_page(self):
client = self._client(
SimpleNamespace(
body={
"instances": {"instance": []},
"total_count": 0,
}
)
)
instances, total_count = client.describe_instances_page(
"cn-hangzhou",
1,
100,
)
self.assertEqual(instances, [])
self.assertEqual(total_count, 0)
class AliyunMapperTests(SimpleTestCase):
def test_instance_mapper_normalizes_fields(self):
raw = FakeAliyunClient()._instance("i-demo", "cn-hangzhou", "Running")
instance = map_instance(raw, "cn-hangzhou")
self.assertEqual(instance.provider_resource_id, "i-demo")
self.assertEqual(instance.normalized_status, "running")
self.assertEqual(instance.private_ips, ("10.0.0.10",))
self.assertEqual(instance.public_ips, ("203.0.113.20",))
self.assertEqual(instance.tags[0].key, "environment")
self.assertIsNotNone(instance.cloud_created_at)
def test_instance_mapper_maps_pending_to_starting(self):
raw = FakeAliyunClient()._instance("i-pending", "cn-hangzhou", "Pending")
instance = map_instance(raw, "cn-hangzhou")
self.assertEqual(instance.normalized_status, "starting")
def test_instance_mapper_rejects_invalid_nonempty_creation_time(self):
raw = FakeAliyunClient()._instance("i-invalid-time", "cn-hangzhou", "Running")
raw["creation_time"] = "not-a-cloud-time"
with self.assertRaises(ProviderError) as context:
map_instance(raw, "cn-hangzhou")
self.assertEqual(context.exception.code, "ALIYUN_INVALID_TIME")
def test_instance_mapper_reads_vpc_private_addresses(self):
raw = FakeAliyunClient()._instance("i-vpc", "cn-hangzhou", "Running")
raw["inner_ip_address"] = {"ip_address": []}
raw["vpc_attributes"] = {
"private_ip_address": {"ip_address": ["10.0.1.20"]}
}
instance = map_instance(raw, "cn-hangzhou")
self.assertEqual(instance.private_ips, ("10.0.1.20",))
def test_instance_mapper_reads_public_and_eip_addresses(self):
raw = FakeAliyunClient()._instance("i-eip", "cn-hangzhou", "Running")
raw["eip_address"] = {"ip_address": "198.51.100.25"}
instance = map_instance(raw, "cn-hangzhou")
self.assertEqual(
instance.public_ips,
("203.0.113.20", "198.51.100.25"),
)
class AliyunProviderTests(TestCase):
def setUp(self):
provider = CloudProvider.objects.create(code="aliyun", name="阿里云")
self.account = CloudAccount.objects.create(
provider=provider,
account_key="aliyun-demo",
name="阿里云演示账号",
credential_profile="ALIYUN_DEMO",
)
self.environment = {
"ALIYUN_DEMO_ACCESS_KEY_ID": "test-id",
"ALIYUN_DEMO_ACCESS_KEY_SECRET": "test-secret",
}
def _provider(self, fake_client):
return AliyunEcsProvider(
client_factory=lambda access_key_id, access_key_secret, security_token: fake_client
)
def test_discover_supports_multiple_regions_and_pages(self):
fake_client = FakeAliyunClient()
with patch.dict(os.environ, self.environment, clear=False):
snapshot = self._provider(fake_client).discover(self.account)
self.assertTrue(snapshot.complete)
self.assertEqual(len(snapshot.regions), 2)
self.assertEqual(len(snapshot.availability_zones), 2)
self.assertEqual(len(snapshot.instances), 3)
self.assertIn(("cn-hangzhou", 2, 100), fake_client.instance_calls)
def test_empty_region_collection_is_rejected(self):
class EmptyRegionClient(FakeAliyunClient):
def describe_regions(self):
return []
with self.assertLogs("cmdb.providers.aliyun", level="WARNING"):
with patch.dict(os.environ, self.environment, clear=False):
with self.assertRaises(ProviderError) as context:
self._provider(EmptyRegionClient()).discover(self.account)
self.assertEqual(context.exception.code, "ALIYUN_INVALID_RESPONSE")
def test_empty_zone_collection_cannot_mark_existing_assets_missing(self):
class EmptyZoneClient(FakeAliyunClient):
def describe_zones(self, region_id):
return []
with patch.dict(os.environ, self.environment, clear=False):
first_run = sync_account(
self.account,
adapter=self._provider(FakeAliyunClient()),
)
second_run = sync_account(
self.account,
adapter=self._provider(EmptyZoneClient()),
)
self.assertEqual(first_run.status, SyncRun.Status.SUCCEEDED)
self.assertEqual(second_run.status, SyncRun.Status.PARTIAL)
self.assertEqual(second_run.error_code, "ALIYUN_INVALID_RESPONSE")
self.assertFalse(
ComputeInstance.objects.filter(
account=self.account,
lifecycle_state=ComputeInstance.LifecycleState.MISSING,
).exists()
)
def test_region_allowlist_limits_discovery(self):
self.account.region_allowlist = ["cn-shanghai"]
self.account.save(update_fields=["region_allowlist"])
fake_client = FakeAliyunClient()
with patch.dict(os.environ, self.environment, clear=False):
snapshot = self._provider(fake_client).discover(self.account)
self.assertEqual([region.provider_resource_id for region in snapshot.regions], ["cn-shanghai"])
self.assertEqual(len(snapshot.instances), 1)
self.assertFalse(
any(call[0] == "cn-hangzhou" for call in fake_client.instance_calls)
)
def test_second_page_failure_returns_partial_snapshot(self):
fake_client = FakeAliyunClient(fail_second_page=True)
with self.assertLogs("cmdb.providers.aliyun", level="WARNING") as captured:
with patch.dict(os.environ, self.environment, clear=False):
snapshot = self._provider(fake_client).discover(self.account)
logs = "\n".join(captured.output)
self.assertFalse(snapshot.complete)
self.assertEqual(snapshot.error_code, "ALIYUN_NETWORK_ERROR")
self.assertEqual(len(snapshot.instances), 1)
self.assertNotIn("timeout", snapshot.error_message.lower())
self.assertIn("ALIYUN_NETWORK_ERROR", logs)
self.assertNotIn("timeout containing details", logs)
def test_invalid_instance_response_cannot_mark_existing_assets_missing(self):
class InvalidInstanceResponseClient(FakeAliyunClient):
def describe_instances_page(self, region_id, page_number, page_size):
raise ProviderError(
"ALIYUN_INVALID_RESPONSE",
"阿里云接口返回了不完整的响应结构,当前快照不完整。",
)
with patch.dict(os.environ, self.environment, clear=False):
first_run = sync_account(
self.account,
adapter=self._provider(FakeAliyunClient()),
)
second_run = sync_account(
self.account,
adapter=self._provider(InvalidInstanceResponseClient()),
)
self.assertEqual(first_run.status, SyncRun.Status.SUCCEEDED)
self.assertEqual(second_run.status, SyncRun.Status.PARTIAL)
self.assertEqual(second_run.error_code, "ALIYUN_INVALID_RESPONSE")
self.assertEqual(ComputeInstance.objects.filter(account=self.account).count(), 3)
self.assertFalse(
ComputeInstance.objects.filter(
account=self.account,
lifecycle_state=ComputeInstance.LifecycleState.MISSING,
).exists()
)
def test_premature_empty_page_returns_partial_snapshot(self):
fake_client = FakeAliyunClient(truncate_second_page=True)
with patch.dict(os.environ, self.environment, clear=False):
snapshot = self._provider(fake_client).discover(self.account)
self.assertFalse(snapshot.complete)
self.assertEqual(snapshot.error_code, "ALIYUN_INCOMPLETE_PAGINATION")
self.assertEqual(len(snapshot.instances), 1)
def test_unknown_allowlist_region_is_rejected(self):
self.account.region_allowlist = ["cn-does-not-exist"]
self.account.save(update_fields=["region_allowlist"])
with patch.dict(os.environ, self.environment, clear=False):
with self.assertRaisesMessage(ProviderError, "地域白名单"):
self._provider(FakeAliyunClient()).discover(self.account)
def test_missing_environment_credentials_are_reported_without_secret(self):
with patch.dict(os.environ, {}, clear=True):
with self.assertRaises(ProviderError) as context:
self._provider(FakeAliyunClient()).discover(self.account)
self.assertEqual(context.exception.code, "ALIYUN_CREDENTIALS_NOT_FOUND")
self.assertNotIn("ACCESS_KEY_SECRET", context.exception.message)
2.2.13 devopsX/cmdb/tests/test_api.py
清单第 50 项;ZIP 内路径:cmdb/tests/test_api.py。
import json
from unittest.mock import patch
from django.contrib.auth import get_user_model
from django.test import TestCase
from django.urls import reverse
from cmdb.models import CloudAccount, CloudProvider, ComputeInstance
from cmdb.providers.base import ProviderError
from cmdb.providers.fake import FakeProvider
from cmdb.services.sync import sync_account
class CmdbApiTests(TestCase):
def setUp(self):
provider = CloudProvider.objects.create(code="fake", name="Fake 教学云")
self.account = CloudAccount.objects.create(
provider=provider,
account_key="fake-demo",
name="Fake 演示账号",
)
user_model = get_user_model()
self.superuser = user_model.objects.create_superuser(
username="admin",
password="test-pass-123",
email="admin@example.invalid",
)
self.client.force_login(self.superuser)
def test_account_list_api_never_returns_secret_values(self):
response = self.client.get(reverse("cmdb:api:account_list"))
self.assertEqual(response.status_code, 200)
payload = response.json()
self.assertEqual(payload["api_version"], "v1")
self.assertEqual(payload["count"], 1)
self.assertNotIn("password", payload["results"][0])
self.assertNotIn("secret", payload["results"][0])
def test_account_list_api_paginates_results(self):
CloudAccount.objects.create(
provider=self.account.provider,
account_key="second-demo",
name="第二个演示账号",
)
first_response = self.client.get(
reverse("cmdb:api:account_list"),
{"page_size": 1},
)
second_response = self.client.get(
reverse("cmdb:api:account_list"),
{"page_size": 1, "page": 2},
)
first_payload = first_response.json()
second_payload = second_response.json()
self.assertEqual(first_response.status_code, 200)
self.assertEqual(first_payload["count"], 2)
self.assertEqual(first_payload["page"], 1)
self.assertEqual(first_payload["pages"], 2)
self.assertEqual(len(first_payload["results"]), 1)
self.assertEqual(second_response.status_code, 200)
self.assertEqual(second_payload["page"], 2)
self.assertEqual(len(second_payload["results"]), 1)
self.assertNotEqual(
first_payload["results"][0]["id"],
second_payload["results"][0]["id"],
)
def test_instance_list_and_detail_api(self):
sync_account(self.account, adapter=FakeProvider())
instance = ComputeInstance.objects.first()
list_response = self.client.get(reverse("cmdb:api:instance_list"))
detail_response = self.client.get(
reverse("cmdb:api:instance_detail", args=[instance.pk])
)
self.assertEqual(list_response.status_code, 200)
self.assertEqual(list_response.json()["count"], 2)
self.assertEqual(detail_response.status_code, 200)
self.assertEqual(
detail_response.json()["result"]["provider_resource_id"],
instance.provider_resource_id,
)
def test_api_sync_requires_post_and_returns_created_run(self):
url = reverse("cmdb:api:account_sync", args=[self.account.pk])
get_response = self.client.get(url)
post_response = self.client.post(
url,
data=json.dumps({"scenario": "default"}),
content_type="application/json",
)
self.assertEqual(get_response.status_code, 405)
self.assertEqual(post_response.status_code, 201)
self.assertEqual(post_response.json()["result"]["created_count"], 2)
def test_api_partial_sync_returns_207_and_error_details(self):
url = reverse("cmdb:api:account_sync", args=[self.account.pk])
response = self.client.post(
url,
data=json.dumps({"scenario": "partial"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 207)
result = response.json()["result"]
self.assertEqual(result["status"], "partial")
self.assertEqual(result["error_code"], "PAGINATION_INCOMPLETE")
self.assertTrue(result["error_message"])
def test_api_sync_maps_business_and_upstream_errors(self):
url = reverse("cmdb:api:account_sync", args=[self.account.pk])
self.account.is_active = False
self.account.save(update_fields=["is_active", "updated_at"])
disabled_response = self.client.post(
url,
data=json.dumps({"scenario": "default"}),
content_type="application/json",
)
self.assertEqual(disabled_response.status_code, 409)
self.assertEqual(disabled_response.json()["error_code"], "ACCOUNT_DISABLED")
self.account.is_active = True
self.account.save(update_fields=["is_active", "updated_at"])
failed_response = self.client.post(
url,
data=json.dumps({"scenario": "failed"}),
content_type="application/json",
)
self.assertEqual(failed_response.status_code, 502)
self.assertEqual(
failed_response.json()["error_code"],
"PROVIDER_UNAVAILABLE",
)
def test_api_maps_snapshot_integrity_failures_to_bad_gateway(self):
url = reverse("cmdb:api:account_sync", args=[self.account.pk])
for error_code in ("ALIYUN_INVALID_RESPONSE", "DUPLICATE_INSTANCE"):
with self.subTest(error_code=error_code):
with patch(
"cmdb.api_views.sync_account",
side_effect=ProviderError(
error_code,
"提供商返回的快照不完整。",
),
):
response = self.client.post(
url,
data=json.dumps({"scenario": "default"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 502)
self.assertEqual(response.json()["error_code"], error_code)
def test_api_rejects_invalid_json_and_page_size(self):
sync_url = reverse("cmdb:api:account_sync", args=[self.account.pk])
account_list_url = reverse("cmdb:api:account_list")
list_url = reverse("cmdb:api:instance_list")
invalid_json = self.client.post(
sync_url,
data="{not-json",
content_type="application/json",
)
invalid_utf8 = self.client.post(
sync_url,
data=b"\xff",
content_type="application/json",
)
invalid_account_page_size = self.client.get(
account_list_url,
{"page_size": "invalid"},
)
invalid_page_size = self.client.get(list_url, {"page_size": "invalid"})
self.assertEqual(invalid_json.status_code, 400)
self.assertEqual(invalid_utf8.status_code, 400)
self.assertEqual(invalid_account_page_size.status_code, 400)
self.assertEqual(invalid_page_size.status_code, 400)
def test_api_rejects_non_object_json_and_non_string_scenario(self):
url = reverse("cmdb:api:account_sync", args=[self.account.pk])
for body in (None, [], "default", 1, {"scenario": []}):
with self.subTest(body=body):
response = self.client.post(
url,
data=json.dumps(body),
content_type="application/json",
)
self.assertEqual(response.status_code, 400)
2.2.14 devopsX/cmdb/tests/test_csv.py
清单第 51 项;ZIP 内路径:cmdb/tests/test_csv.py。
from datetime import timedelta
from django.contrib.auth import get_user_model
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from cmdb.models import CloudAccount, CloudProvider, ComputeInstance
from cmdb.providers.fake import FakeProvider
from cmdb.services.csv_io import import_cloud_accounts, parse_cloud_account_csv
from cmdb.services.sync import sync_account
class CloudAccountCsvTests(TestCase):
def setUp(self):
self.provider = CloudProvider.objects.create(code="fake", name="Fake 教学云")
user_model = get_user_model()
self.superuser = user_model.objects.create_superuser(
username="admin",
password="test-pass-123",
email="admin@example.invalid",
)
self.client.force_login(self.superuser)
def test_sensitive_header_is_rejected(self):
uploaded_file = SimpleUploadedFile(
"accounts.csv",
b"provider_code,account_key,name,access_key_secret\nfake,demo,Demo,secret\n",
content_type="text/csv",
)
with self.assertRaisesMessage(ValueError, "CSV 禁止包含密钥字段"):
parse_cloud_account_csv(uploaded_file)
def test_preview_then_import_creates_account(self):
uploaded_file = SimpleUploadedFile(
"accounts.csv",
(
"provider_code,account_key,name,credential_profile,region_allowlist,is_active,sync_enabled\n"
"fake,csv-demo,CSV 演示账号,,cn-hangzhou|cn-shanghai,true,true\n"
).encode("utf-8"),
content_type="text/csv",
)
preview_response = self.client.post(
reverse("cmdb:account_csv_import"),
{"action": "preview", "csv_file": uploaded_file},
)
self.assertEqual(preview_response.status_code, 200)
self.assertContains(preview_response, "CSV 演示账号")
signed_payload = preview_response.context["confirm_form"].initial[
"signed_payload"
]
import_response = self.client.post(
reverse("cmdb:account_csv_import"),
{"action": "import", "signed_payload": signed_payload},
)
self.assertRedirects(import_response, reverse("cmdb:account_list"))
account = CloudAccount.objects.get(account_key="csv-demo")
self.assertEqual(account.region_allowlist, ["cn-hangzhou", "cn-shanghai"])
def test_csv_validates_credential_profile_and_deduplicates_regions(self):
uploaded_file = SimpleUploadedFile(
"accounts.csv",
(
"provider_code,account_key,name,credential_profile,region_allowlist\n"
"fake,csv-demo,CSV 演示账号,invalid-profile,cn-hangzhou|cn-hangzhou\n"
).encode("utf-8"),
content_type="text/csv",
)
rows, errors = parse_cloud_account_csv(uploaded_file)
self.assertEqual(rows, [])
self.assertTrue(any("凭据前缀只能使用" in error for error in errors))
valid_file = SimpleUploadedFile(
"accounts.csv",
(
"provider_code,account_key,name,credential_profile,region_allowlist\n"
"fake,csv-demo,CSV 演示账号,,cn-hangzhou|cn-hangzhou\n"
).encode("utf-8"),
content_type="text/csv",
)
rows, errors = parse_cloud_account_csv(valid_file)
self.assertEqual(errors, [])
self.assertEqual(rows[0]["region_allowlist"], ["cn-hangzhou"])
def test_csv_import_is_atomic_when_a_later_row_fails(self):
rows = [
{
"provider_code": "fake",
"account_key": "first",
"name": "第一行",
"credential_profile": "",
"region_allowlist": [],
"is_active": True,
"sync_enabled": True,
},
{
"provider_code": "fake",
"account_key": "second",
"name": "第二行",
"credential_profile": "invalid-profile",
"region_allowlist": [],
"is_active": True,
"sync_enabled": True,
},
]
with self.assertRaisesMessage(ValueError, "凭据前缀只能使用"):
import_cloud_accounts(rows)
self.assertFalse(CloudAccount.objects.filter(account_key="first").exists())
def test_csv_update_preserves_omitted_optional_columns(self):
account = CloudAccount.objects.create(
provider=self.provider,
account_key="existing-minimal",
name="旧名称",
credential_profile="ALIYUN_DEMO",
region_allowlist=["cn-hangzhou"],
is_active=False,
sync_enabled=False,
)
uploaded_file = SimpleUploadedFile(
"accounts.csv",
(
"provider_code,account_key,name\n"
"fake,existing-minimal,新名称\n"
).encode("utf-8"),
content_type="text/csv",
)
rows, errors = parse_cloud_account_csv(uploaded_file)
created_count, updated_count = import_cloud_accounts(rows)
account.refresh_from_db()
self.assertEqual(errors, [])
self.assertEqual(created_count, 0)
self.assertEqual(updated_count, 1)
self.assertEqual(account.name, "新名称")
self.assertEqual(account.credential_profile, "ALIYUN_DEMO")
self.assertEqual(account.region_allowlist, ["cn-hangzhou"])
self.assertFalse(account.is_active)
self.assertFalse(account.sync_enabled)
def test_csv_update_applies_explicit_optional_columns(self):
account = CloudAccount.objects.create(
provider=self.provider,
account_key="existing-explicit",
name="旧名称",
credential_profile="ALIYUN_DEMO",
region_allowlist=["cn-hangzhou"],
is_active=False,
sync_enabled=False,
)
uploaded_file = SimpleUploadedFile(
"accounts.csv",
(
"provider_code,account_key,name,credential_profile,region_allowlist,is_active,sync_enabled\n"
"fake,existing-explicit,新名称,,,true,true\n"
).encode("utf-8"),
content_type="text/csv",
)
rows, errors = parse_cloud_account_csv(uploaded_file)
import_cloud_accounts(rows)
account.refresh_from_db()
self.assertEqual(errors, [])
self.assertEqual(account.credential_profile, "")
self.assertEqual(account.region_allowlist, [])
self.assertTrue(account.is_active)
self.assertTrue(account.sync_enabled)
def test_csv_update_preserves_last_successful_sync_timestamp(self):
last_sync = timezone.now() - timedelta(hours=1)
account = CloudAccount.objects.create(
provider=self.provider,
account_key="existing",
name="旧名称",
last_successful_sync_at=last_sync,
)
rows = [
{
"provider_code": "fake",
"account_key": "existing",
"name": "新名称",
"credential_profile": "",
"region_allowlist": [],
"is_active": True,
"sync_enabled": True,
}
]
created_count, updated_count = import_cloud_accounts(rows)
account.refresh_from_db()
self.assertEqual(created_count, 0)
self.assertEqual(updated_count, 1)
self.assertEqual(account.name, "新名称")
self.assertEqual(account.last_successful_sync_at, last_sync)
def test_csv_reports_extra_columns_without_server_error(self):
uploaded_file = SimpleUploadedFile(
"accounts.csv",
(
"provider_code,account_key,name\n"
"fake,csv-demo,CSV 演示账号,unexpected\n"
).encode("utf-8"),
content_type="text/csv",
)
rows, errors = parse_cloud_account_csv(uploaded_file)
self.assertEqual(rows, [])
self.assertEqual(errors, ["第 2 行:字段数量超过 CSV 表头。"])
def test_csv_rejects_oversized_field_without_server_error(self):
uploaded_file = SimpleUploadedFile(
"accounts.csv",
(
"provider_code,account_key,name\n"
"fake,csv-demo,%s\n" % ("x" * 200000)
).encode("utf-8"),
content_type="text/csv",
)
with self.assertRaisesMessage(ValueError, "字段过长或格式无效"):
parse_cloud_account_csv(uploaded_file)
def test_csv_reports_duplicate_account_identity(self):
uploaded_file = SimpleUploadedFile(
"accounts.csv",
(
"provider_code,account_key,name\n"
"fake,csv-demo,第一行\n"
"fake,csv-demo,第二行\n"
).encode("utf-8"),
content_type="text/csv",
)
rows, errors = parse_cloud_account_csv(uploaded_file)
self.assertEqual(len(rows), 1)
self.assertEqual(
errors,
["第 3 行:CSV 内重复云账号身份:fake / csv-demo。"],
)
def test_csv_rejects_unsupported_blank_and_duplicate_headers(self):
cases = (
(
"provider_code,account_key,name,sync_enable\n"
"fake,csv-demo,CSV 演示账号,false\n",
"不支持的字段:sync_enable",
),
(
"provider_code,account_key,name,\n"
"fake,csv-demo,CSV 演示账号,\n",
"空字段名",
),
(
"provider_code,account_key,name,Name\n"
"fake,csv-demo,CSV 演示账号,重复名称\n",
"重复字段",
),
)
for content, message in cases:
with self.subTest(message=message):
uploaded_file = SimpleUploadedFile(
"accounts.csv",
content.encode("utf-8"),
content_type="text/csv",
)
with self.assertRaisesMessage(ValueError, message):
parse_cloud_account_csv(uploaded_file)
def test_instance_export_neutralizes_spreadsheet_formulas(self):
sync_account(
CloudAccount.objects.create(
provider=self.provider,
account_key="formula-demo",
name="公式测试账号",
),
adapter=FakeProvider(),
)
instance = ComputeInstance.objects.get(
account__account_key="formula-demo",
provider_resource_id="i-fake-hz-001",
)
instance.name = "=HYPERLINK(\"https://example.invalid\")"
instance.save(update_fields=["name", "updated_at"])
response = self.client.get(reverse("cmdb:instance_csv_export"))
decoded = response.content.decode("utf-8-sig")
self.assertIn("'=HYPERLINK", decoded)
self.assertNotIn(",=HYPERLINK", decoded)
def test_instance_export_contains_utf8_bom_and_headers(self):
response = self.client.get(reverse("cmdb:instance_csv_export"))
self.assertEqual(response.status_code, 200)
self.assertTrue(response.content.startswith(b"\xef\xbb\xbf"))
decoded = response.content.decode("utf-8-sig")
self.assertIn("provider_code,account_key,instance_id", decoded)
2.2.15 devopsX/cmdb/tests/test_models.py
清单第 52 项;ZIP 内路径:cmdb/tests/test_models.py。
from django.core.exceptions import ValidationError
from django.db import IntegrityError, transaction
from django.test import TestCase
from cmdb.forms import CloudAccountForm
from cmdb.models import (
CloudAccount,
CloudAvailabilityZone,
CloudProvider,
CloudRegion,
ComputeInstance,
ComputeInstanceTag,
)
class CmdbModelTests(TestCase):
def setUp(self):
self.provider = CloudProvider.objects.create(code="fake", name="Fake 教学云")
self.account = CloudAccount.objects.create(
provider=self.provider,
account_key="fake-demo",
name="Fake 演示账号",
)
def test_provider_and_account_string_values_are_readable(self):
self.assertEqual(str(self.provider), "fake - Fake 教学云")
self.assertEqual(str(self.account), "fake - Fake 演示账号")
def test_account_identity_is_unique_inside_provider(self):
with self.assertRaises(IntegrityError):
with transaction.atomic():
CloudAccount.objects.create(
provider=self.provider,
account_key="fake-demo",
name="重复账号",
)
def test_same_account_key_can_exist_for_another_provider(self):
other_provider = CloudProvider.objects.create(code="aliyun", name="阿里云")
account = CloudAccount.objects.create(
provider=other_provider,
account_key="fake-demo",
name="另一厂商同名键",
)
self.assertEqual(account.provider, other_provider)
def test_account_validates_credential_profile_and_region_allowlist(self):
self.account.credential_profile = "invalid-profile"
self.account.region_allowlist = "cn-hangzhou"
with self.assertRaises(ValidationError) as context:
self.account.full_clean()
self.assertIn("credential_profile", context.exception.message_dict)
self.assertIn("region_allowlist", context.exception.message_dict)
self.account.credential_profile = "ALIYUN_DEMO"
self.account.region_allowlist = ["cn-hangzhou", "cn-hangzhou"]
with self.assertRaises(ValidationError) as duplicate_context:
self.account.full_clean()
self.assertIn("region_allowlist", duplicate_context.exception.message_dict)
def test_account_form_applies_region_allowlist_validator(self):
form = CloudAccountForm(
data={
"provider": self.provider.pk,
"account_key": "form-demo",
"name": "表单账号",
"credential_profile": "",
"region_allowlist_text": "r%s" % ("x" * 100),
"is_active": "on",
"sync_enabled": "on",
}
)
self.assertFalse(form.is_valid())
self.assertIn("地域 ID 不能超过 100 个字符", form.errors.as_text())
def test_zone_and_instance_accounts_must_match_their_relations(self):
other_account = CloudAccount.objects.create(
provider=self.provider,
account_key="other",
name="其他账号",
)
region = CloudRegion.objects.create(
account=self.account,
provider_resource_id="cn-hangzhou",
name="杭州",
)
zone = CloudAvailabilityZone(
account=other_account,
region=region,
provider_resource_id="cn-hangzhou-i",
name="杭州可用区 I",
)
with self.assertRaisesMessage(ValidationError, "可用区账号必须与地域账号一致"):
zone.full_clean()
valid_zone = CloudAvailabilityZone.objects.create(
account=self.account,
region=region,
provider_resource_id="cn-hangzhou-i",
name="杭州可用区 I",
)
instance = ComputeInstance(
account=other_account,
region=region,
availability_zone=valid_zone,
provider_resource_id="i-invalid",
)
with self.assertRaises(ValidationError) as context:
instance.full_clean()
self.assertTrue(
any("实例账号必须与地域账号一致" in message for message in context.exception.messages)
)
other_region = CloudRegion.objects.create(
account=other_account,
provider_resource_id="cn-shanghai",
name="上海",
)
zone_mismatch_instance = ComputeInstance(
account=other_account,
region=other_region,
availability_zone=valid_zone,
provider_resource_id="i-zone-mismatch",
)
with self.assertRaisesMessage(
ValidationError,
"实例账号必须与可用区账号一致",
):
zone_mismatch_instance.full_clean()
def test_invalid_relation_ids_remain_validation_errors(self):
zone = CloudAvailabilityZone(
account=self.account,
region_id=999999,
provider_resource_id="invalid-zone",
name="无效可用区",
)
with self.assertRaises(ValidationError) as zone_context:
zone.full_clean()
self.assertIn("region", zone_context.exception.message_dict)
region = CloudRegion.objects.create(
account=self.account,
provider_resource_id="cn-hangzhou",
name="杭州",
)
instance = ComputeInstance(
account=self.account,
region=region,
availability_zone_id=999999,
provider_resource_id="i-invalid-zone",
)
with self.assertRaises(ValidationError) as instance_context:
instance.full_clean()
self.assertIn(
"availability_zone",
instance_context.exception.message_dict,
)
def test_tag_sources_are_independent(self):
from cmdb.providers.fake import FakeProvider
from cmdb.services.sync import sync_account
sync_account(self.account, adapter=FakeProvider())
instance = ComputeInstance.objects.get(provider_resource_id="i-fake-hz-001")
ComputeInstanceTag.objects.create(
instance=instance,
key="owner",
value="local-team",
source=ComputeInstanceTag.Source.MANUAL,
)
ComputeInstanceTag.objects.create(
instance=instance,
key="owner",
value="provider-team",
source=ComputeInstanceTag.Source.PROVIDER,
)
self.assertEqual(instance.tags.filter(key="owner").count(), 2)
2.2.16 devopsX/cmdb/tests/test_sync.py
清单第 53 项;ZIP 内路径:cmdb/tests/test_sync.py。
from dataclasses import replace
from datetime import timedelta
from io import StringIO
from unittest.mock import patch
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
from django.utils import timezone
from cmdb.models import (
CloudAccount,
CloudProvider,
ComputeInstance,
ComputeInstanceChange,
ComputeInstanceTag,
SyncRun,
)
from cmdb.providers.base import (
DiscoveredAvailabilityZone,
DiscoveredComputeInstance,
DiscoveredRegion,
DiscoveredTag,
DiscoveryScope,
DiscoverySnapshot,
ProviderError,
)
from cmdb.providers.fake import FakeProvider
from cmdb.services.sync import retire_instance, sync_account
class StaticAdapter:
def __init__(self, snapshot):
self.snapshot = snapshot
def discover(self, account):
return self.snapshot
class FailingAdapter:
def discover(self, account):
raise ProviderError("ACCESS_DENIED", "凭据无权读取资产。")
class UnexpectedFailingAdapter:
def discover(self, account):
raise RuntimeError("provider response containing private details")
class ChangingConfigurationAdapter:
def discover(self, account):
CloudAccount.objects.filter(pk=account.pk).update(
region_allowlist=["cn-hangzhou"]
)
return FakeProvider.scenarios["default"]
class SupersedingAdapter:
def __init__(self):
self.replacement_run = None
def discover(self, account):
SyncRun.objects.filter(
account=account,
status=SyncRun.Status.RUNNING,
).update(started_at=timezone.now() - timedelta(minutes=31))
self.replacement_run = sync_account(account, adapter=FakeProvider())
return DiscoverySnapshot()
class SyncAccountTests(TestCase):
def setUp(self):
provider = CloudProvider.objects.create(code="fake", name="Fake 教学云")
self.account = CloudAccount.objects.create(
provider=provider,
account_key="fake-demo",
name="Fake 演示账号",
)
def test_first_sync_creates_two_instances_and_changes(self):
sync_run = sync_account(self.account, adapter=FakeProvider())
self.assertEqual(sync_run.status, SyncRun.Status.SUCCEEDED)
self.assertEqual(sync_run.discovered_count, 2)
self.assertEqual(sync_run.created_count, 2)
self.assertEqual(ComputeInstance.objects.count(), 2)
self.assertEqual(
ComputeInstanceChange.objects.filter(
action=ComputeInstanceChange.Action.CREATED
).count(),
2,
)
def test_identical_snapshot_is_idempotent(self):
sync_account(self.account, adapter=FakeProvider())
initial_change_count = ComputeInstanceChange.objects.count()
sync_run = sync_account(self.account, adapter=FakeProvider())
self.assertEqual(sync_run.created_count, 0)
self.assertEqual(sync_run.updated_count, 0)
self.assertEqual(sync_run.unchanged_count, 2)
self.assertEqual(ComputeInstanceChange.objects.count(), initial_change_count)
def test_changed_provider_field_creates_one_update(self):
default_snapshot = FakeProvider.scenarios["default"]
sync_account(self.account, adapter=StaticAdapter(default_snapshot))
first_instance = replace(default_snapshot.instances[0], name="订单服务-已更新")
changed_snapshot = replace(
default_snapshot,
instances=(first_instance, default_snapshot.instances[1]),
)
sync_run = sync_account(self.account, adapter=StaticAdapter(changed_snapshot))
self.assertEqual(sync_run.updated_count, 1)
instance = ComputeInstance.objects.get(provider_resource_id="i-fake-hz-001")
self.assertEqual(instance.name, "订单服务-已更新")
change = instance.changes.first()
self.assertEqual(change.action, ComputeInstanceChange.Action.UPDATED)
self.assertIn("name", change.changed_fields)
def test_complete_empty_snapshot_marks_instances_missing(self):
sync_account(self.account, adapter=FakeProvider())
sync_run = sync_account(
self.account,
adapter=StaticAdapter(DiscoverySnapshot()),
)
self.assertEqual(sync_run.missing_count, 2)
self.assertEqual(
ComputeInstance.objects.filter(
lifecycle_state=ComputeInstance.LifecycleState.MISSING
).count(),
2,
)
def test_repeated_empty_snapshot_does_not_duplicate_missing_changes(self):
sync_account(self.account, adapter=FakeProvider())
sync_account(self.account, adapter=StaticAdapter(DiscoverySnapshot()))
first_missing_changes = ComputeInstanceChange.objects.filter(
action=ComputeInstanceChange.Action.MARKED_MISSING
).count()
sync_run = sync_account(
self.account,
adapter=StaticAdapter(DiscoverySnapshot()),
)
self.assertEqual(sync_run.missing_count, 0)
self.assertEqual(
ComputeInstanceChange.objects.filter(
action=ComputeInstanceChange.Action.MARKED_MISSING
).count(),
first_missing_changes,
)
def test_restored_change_contains_lifecycle_fields(self):
sync_account(self.account, adapter=FakeProvider())
sync_account(self.account, adapter=StaticAdapter(DiscoverySnapshot()))
sync_account(self.account, adapter=FakeProvider())
change = ComputeInstanceChange.objects.filter(
action=ComputeInstanceChange.Action.RESTORED
).latest("created_at")
self.assertIn("lifecycle_state", change.changed_fields)
self.assertIn("missing_since", change.changed_fields)
self.assertEqual(change.after_data["lifecycle_state"], "present")
self.assertIsNone(change.after_data["missing_since"])
def test_instance_zone_must_belong_to_instance_region(self):
snapshot = DiscoverySnapshot(
regions=(
DiscoveredRegion("cn-hangzhou", "杭州"),
DiscoveredRegion("cn-shanghai", "上海"),
),
availability_zones=(
DiscoveredAvailabilityZone(
"cn-hangzhou-i",
"cn-hangzhou",
"杭州可用区 I",
),
),
instances=(
replace(
FakeProvider.scenarios["default"].instances[0],
region_provider_resource_id="cn-shanghai",
),
),
)
with self.assertRaisesMessage(ProviderError, "地域与可用区所属地域不一致"):
sync_account(self.account, adapter=StaticAdapter(snapshot))
self.assertEqual(ComputeInstance.objects.count(), 0)
def test_missing_instances_are_restored(self):
sync_account(self.account, adapter=FakeProvider())
sync_account(self.account, adapter=StaticAdapter(DiscoverySnapshot()))
sync_run = sync_account(self.account, adapter=FakeProvider())
self.assertEqual(sync_run.restored_count, 2)
self.assertFalse(
ComputeInstance.objects.exclude(
lifecycle_state=ComputeInstance.LifecycleState.PRESENT
).exists()
)
def test_partial_snapshot_never_marks_existing_instances_missing(self):
sync_account(self.account, adapter=FakeProvider())
partial_snapshot = FakeProvider.scenarios["partial"]
sync_run = sync_account(self.account, adapter=StaticAdapter(partial_snapshot))
self.assertEqual(sync_run.status, SyncRun.Status.PARTIAL)
self.assertEqual(sync_run.missing_count, 0)
self.assertFalse(
ComputeInstance.objects.filter(
lifecycle_state=ComputeInstance.LifecycleState.MISSING
).exists()
)
def test_provider_failure_records_failed_run_without_changing_assets(self):
sync_account(self.account, adapter=FakeProvider())
with self.assertRaises(ProviderError):
sync_account(self.account, adapter=FailingAdapter())
failed_run = SyncRun.objects.first()
self.assertEqual(failed_run.status, SyncRun.Status.FAILED)
self.assertEqual(failed_run.error_code, "ACCESS_DENIED")
self.assertEqual(ComputeInstance.objects.count(), 2)
self.assertFalse(
ComputeInstance.objects.filter(
lifecycle_state=ComputeInstance.LifecycleState.MISSING
).exists()
)
def test_unexpected_provider_failure_is_logged_and_sanitized(self):
with patch("cmdb.services.sync.logger.error") as log_error:
with self.assertRaises(ProviderError) as context:
sync_account(self.account, adapter=UnexpectedFailingAdapter())
self.assertEqual(context.exception.code, "UNEXPECTED_ERROR")
self.assertNotIn("private details", context.exception.message)
sync_run = SyncRun.objects.get()
log_error.assert_called_once_with(
"Unexpected CMDB synchronization failure type=%s account_id=%s sync_run_id=%s.",
"RuntimeError",
self.account.pk,
sync_run.pk,
)
self.assertEqual(sync_run.status, SyncRun.Status.FAILED)
self.assertEqual(sync_run.error_code, "UNEXPECTED_ERROR")
def test_duplicate_region_is_rejected_before_writing_snapshot(self):
snapshot = DiscoverySnapshot(
regions=(
DiscoveredRegion("cn-demo", "演示地域一"),
DiscoveredRegion("cn-demo", "演示地域二"),
)
)
with self.assertRaisesMessage(ProviderError, "发现重复地域 ID"):
sync_account(self.account, adapter=StaticAdapter(snapshot))
self.assertEqual(self.account.regions.count(), 0)
self.assertEqual(SyncRun.objects.first().status, SyncRun.Status.FAILED)
def test_provider_sync_does_not_delete_manual_tags(self):
default_snapshot = FakeProvider.scenarios["default"]
sync_account(self.account, adapter=StaticAdapter(default_snapshot))
instance = ComputeInstance.objects.get(provider_resource_id="i-fake-hz-001")
ComputeInstanceTag.objects.create(
instance=instance,
source=ComputeInstanceTag.Source.MANUAL,
key="owner",
value="local-team",
)
without_tags = replace(default_snapshot.instances[0], tags=())
changed_snapshot = replace(
default_snapshot,
instances=(without_tags, default_snapshot.instances[1]),
)
sync_account(self.account, adapter=StaticAdapter(changed_snapshot))
self.assertTrue(
instance.tags.filter(
source=ComputeInstanceTag.Source.MANUAL,
key="owner",
value="local-team",
).exists()
)
self.assertFalse(
instance.tags.filter(
source=ComputeInstanceTag.Source.PROVIDER,
key="department",
).exists()
)
def test_provider_tag_only_change_is_audited_as_update(self):
default_snapshot = FakeProvider.scenarios["default"]
sync_account(self.account, adapter=StaticAdapter(default_snapshot))
first_instance = replace(
default_snapshot.instances[0],
tags=(DiscoveredTag("department", "security"),),
)
changed_snapshot = replace(
default_snapshot,
instances=(first_instance, default_snapshot.instances[1]),
)
sync_run = sync_account(self.account, adapter=StaticAdapter(changed_snapshot))
self.assertEqual(sync_run.updated_count, 1)
self.assertEqual(sync_run.unchanged_count, 1)
change = ComputeInstanceChange.objects.filter(
action=ComputeInstanceChange.Action.UPDATED,
instance__provider_resource_id="i-fake-hz-001",
).latest("created_at")
self.assertIn("provider_tags", change.changed_fields)
self.assertEqual(
change.after_data["provider_tags"],
{"department": "security"},
)
def test_allowlist_scope_does_not_mark_excluded_region_missing(self):
default_snapshot = FakeProvider.scenarios["default"]
sync_account(self.account, adapter=StaticAdapter(default_snapshot))
self.account.region_allowlist = ["cn-shanghai"]
self.account.save(update_fields=["region_allowlist", "updated_at"])
shanghai_snapshot = replace(
default_snapshot,
regions=(default_snapshot.regions[1],),
availability_zones=(default_snapshot.availability_zones[1],),
instances=(default_snapshot.instances[1],),
scope=DiscoveryScope(
mode="allowlist",
region_ids=("cn-shanghai",),
),
)
sync_run = sync_account(self.account, adapter=StaticAdapter(shanghai_snapshot))
hangzhou_instance = ComputeInstance.objects.get(
provider_resource_id="i-fake-hz-001"
)
self.assertEqual(sync_run.missing_count, 0)
self.assertEqual(
hangzhou_instance.lifecycle_state,
ComputeInstance.LifecycleState.PRESENT,
)
def test_unscoped_account_rejects_allowlist_snapshot(self):
sync_account(self.account, adapter=FakeProvider())
snapshot = replace(
FakeProvider.scenarios["default"],
regions=(FakeProvider.scenarios["default"].regions[0],),
availability_zones=(FakeProvider.scenarios["default"].availability_zones[0],),
instances=(FakeProvider.scenarios["default"].instances[0],),
scope=DiscoveryScope(
mode="allowlist",
region_ids=("cn-hangzhou",),
),
)
with self.assertRaisesMessage(ProviderError, "未配置地域白名单"):
sync_account(self.account, adapter=StaticAdapter(snapshot))
self.assertEqual(
SyncRun.objects.latest("created_at").status,
SyncRun.Status.FAILED,
)
self.assertFalse(
ComputeInstance.objects.filter(
lifecycle_state=ComputeInstance.LifecycleState.MISSING
).exists()
)
def test_stale_running_sync_is_failed_and_does_not_block_new_sync(self):
stale_run = SyncRun.objects.create(
account=self.account,
status=SyncRun.Status.RUNNING,
trigger=SyncRun.Trigger.MANUAL,
started_at=timezone.now() - timedelta(minutes=31),
)
sync_run = sync_account(self.account, adapter=FakeProvider())
stale_run.refresh_from_db()
self.assertEqual(stale_run.status, SyncRun.Status.FAILED)
self.assertEqual(stale_run.error_code, "STALE_SYNC_RUN")
self.assertEqual(sync_run.status, SyncRun.Status.SUCCEEDED)
def test_superseded_sync_cannot_apply_its_snapshot(self):
adapter = SupersedingAdapter()
with self.assertRaises(ProviderError) as context:
sync_account(self.account, adapter=adapter)
self.assertEqual(context.exception.code, "SYNC_RUN_SUPERSEDED")
self.assertEqual(adapter.replacement_run.status, SyncRun.Status.SUCCEEDED)
self.assertEqual(ComputeInstance.objects.count(), 2)
self.assertFalse(
ComputeInstance.objects.exclude(
lifecycle_state=ComputeInstance.LifecycleState.PRESENT
).exists()
)
def test_configuration_change_during_discovery_fences_snapshot(self):
with self.assertRaises(ProviderError) as context:
sync_account(self.account, adapter=ChangingConfigurationAdapter())
self.assertEqual(context.exception.code, "SYNC_CONFIGURATION_CHANGED")
sync_run = SyncRun.objects.get()
self.assertEqual(sync_run.status, SyncRun.Status.FAILED)
self.assertEqual(sync_run.error_code, "SYNC_CONFIGURATION_CHANGED")
self.assertEqual(ComputeInstance.objects.count(), 0)
def test_disabled_provider_prevents_sync(self):
self.account.provider.is_active = False
self.account.provider.save(update_fields=["is_active", "updated_at"])
with self.assertRaises(ProviderError) as context:
sync_account(self.account, adapter=FakeProvider())
self.assertEqual(context.exception.code, "PROVIDER_DISABLED")
self.assertEqual(SyncRun.objects.count(), 0)
def test_fake_provider_filters_configured_region_allowlist(self):
self.account.region_allowlist = ["cn-hangzhou"]
self.account.save(update_fields=["region_allowlist", "updated_at"])
sync_run = sync_account(self.account, adapter=FakeProvider())
self.assertEqual(sync_run.status, SyncRun.Status.SUCCEEDED)
self.assertEqual(sync_run.discovered_count, 1)
self.assertEqual(
list(
ComputeInstance.objects.values_list(
"provider_resource_id",
flat=True,
)
),
["i-fake-hz-001"],
)
self.assertEqual(
list(
self.account.regions.values_list(
"provider_resource_id",
flat=True,
)
),
["cn-hangzhou"],
)
def test_fake_provider_rejects_unknown_allowlist_region(self):
self.account.region_allowlist = ["cn-unknown"]
self.account.save(update_fields=["region_allowlist", "updated_at"])
with self.assertRaises(ProviderError) as context:
sync_account(self.account, adapter=FakeProvider())
self.assertEqual(context.exception.code, "UNKNOWN_REGION_ALLOWLIST")
self.assertEqual(SyncRun.objects.first().status, SyncRun.Status.FAILED)
def test_unknown_fake_scenario_is_rejected(self):
with self.assertRaises(ProviderError) as context:
sync_account(self.account, adapter=FakeProvider("unknown"))
self.assertEqual(context.exception.code, "UNKNOWN_FAKE_SCENARIO")
def test_fresh_running_sync_still_blocks_new_sync(self):
SyncRun.objects.create(
account=self.account,
status=SyncRun.Status.RUNNING,
trigger=SyncRun.Trigger.MANUAL,
started_at=timezone.now(),
)
with self.assertRaises(ProviderError) as context:
sync_account(self.account, adapter=FakeProvider())
self.assertEqual(context.exception.code, "SYNC_ALREADY_RUNNING")
def test_empty_resource_identity_and_invalid_status_are_rejected(self):
empty_region = DiscoverySnapshot(
regions=(DiscoveredRegion("", "空地域"),),
)
invalid_status = DiscoverySnapshot(
regions=(DiscoveredRegion("cn-demo", "演示地域"),),
instances=(
DiscoveredComputeInstance(
provider_resource_id="i-demo",
region_provider_resource_id="cn-demo",
normalized_status="invalid",
),
),
)
with self.assertRaisesMessage(ProviderError, "空地域 ID"):
sync_account(self.account, adapter=StaticAdapter(empty_region))
with self.assertRaisesMessage(ProviderError, "标准状态无效"):
sync_account(self.account, adapter=StaticAdapter(invalid_status))
def test_partial_allowlist_snapshot_rejects_out_of_scope_region(self):
self.account.region_allowlist = ["cn-hangzhou"]
self.account.save(update_fields=["region_allowlist", "updated_at"])
snapshot = DiscoverySnapshot(
regions=(DiscoveredRegion("cn-beijing", "北京"),),
complete=False,
scope=DiscoveryScope(
mode="allowlist",
region_ids=("cn-hangzhou",),
),
)
with self.assertRaises(ProviderError) as context:
sync_account(self.account, adapter=StaticAdapter(snapshot))
self.assertEqual(context.exception.code, "OUT_OF_SCOPE_DISCOVERY_REGION")
self.assertFalse(self.account.regions.exists())
def test_complete_allowlist_snapshot_must_cover_its_scope(self):
self.account.region_allowlist = ["cn-hangzhou", "cn-shanghai"]
self.account.save(update_fields=["region_allowlist", "updated_at"])
snapshot = DiscoverySnapshot(
regions=(DiscoveredRegion("cn-hangzhou", "杭州"),),
scope=DiscoveryScope(
mode="allowlist",
region_ids=("cn-hangzhou", "cn-shanghai"),
),
)
with self.assertRaisesMessage(ProviderError, "没有覆盖地域白名单"):
sync_account(self.account, adapter=StaticAdapter(snapshot))
def test_bootstrap_preserves_disabled_provider(self):
provider = self.account.provider
provider.is_active = False
provider.save(update_fields=["is_active", "updated_at"])
call_command("bootstrap_cmdb", stdout=StringIO())
provider.refresh_from_db()
self.assertFalse(provider.is_active)
self.assertEqual(provider.name, "Fake 教学云")
def test_sync_command_reports_partial_as_failure(self):
with self.assertRaisesMessage(CommandError, "同步部分完成"):
call_command(
"sync_cloud_account",
"fake-demo",
provider_code="fake",
scenario="partial",
)
sync_run = SyncRun.objects.get()
self.assertEqual(sync_run.status, SyncRun.Status.PARTIAL)
self.assertEqual(sync_run.error_code, "PAGINATION_INCOMPLETE")
def test_sync_command_requires_provider_when_account_key_is_ambiguous(self):
aliyun = CloudProvider.objects.create(code="aliyun", name="阿里云")
CloudAccount.objects.create(
provider=aliyun,
account_key="fake-demo",
name="同名阿里云账号",
)
with self.assertRaisesMessage(CommandError, "--provider"):
call_command("sync_cloud_account", "fake-demo")
output = StringIO()
call_command(
"sync_cloud_account",
"fake-demo",
provider_code="fake",
stdout=output,
)
self.assertIn("succeeded", output.getvalue())
def test_retirement_rolls_back_when_audit_creation_fails(self):
sync_account(self.account, adapter=FakeProvider())
instance = ComputeInstance.objects.get(provider_resource_id="i-fake-hz-001")
with patch(
"cmdb.services.sync.ComputeInstanceChange.objects.create",
side_effect=RuntimeError("audit write failed"),
):
with self.assertRaises(RuntimeError):
retire_instance(instance)
instance.refresh_from_db()
self.assertEqual(
instance.lifecycle_state,
ComputeInstance.LifecycleState.PRESENT,
)
self.assertIsNone(instance.retired_at)
self.assertFalse(
instance.changes.filter(
action=ComputeInstanceChange.Action.RETIRED
).exists()
)
def test_retiring_missing_instance_clears_missing_state_in_audit(self):
sync_account(self.account, adapter=FakeProvider())
sync_account(self.account, adapter=StaticAdapter(DiscoverySnapshot()))
instance = ComputeInstance.objects.get(provider_resource_id="i-fake-hz-001")
retire_instance(instance)
instance.refresh_from_db()
change = instance.changes.filter(
action=ComputeInstanceChange.Action.RETIRED
).latest("created_at")
self.assertEqual(
instance.lifecycle_state,
ComputeInstance.LifecycleState.RETIRED,
)
self.assertIsNone(instance.missing_since)
self.assertIn("missing_since", change.changed_fields)
self.assertIsNotNone(change.before_data["missing_since"])
self.assertIsNone(change.after_data["missing_since"])
def test_retired_instance_is_not_automatically_restored(self):
sync_account(self.account, adapter=FakeProvider())
instance = ComputeInstance.objects.get(provider_resource_id="i-fake-hz-001")
retire_instance(instance)
sync_run = sync_account(self.account, adapter=FakeProvider())
instance.refresh_from_db()
self.assertEqual(sync_run.restored_count, 0)
self.assertEqual(
instance.lifecycle_state,
ComputeInstance.LifecycleState.RETIRED,
)
self.assertIsNotNone(instance.retired_at)
2.2.17 devopsX/cmdb/tests/test_views.py
清单第 54 项;ZIP 内路径:cmdb/tests/test_views.py。
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
from django.test import TestCase
from django.urls import reverse
from cmdb.models import (
CloudAccount,
CloudAvailabilityZone,
CloudProvider,
CloudRegion,
ComputeInstance,
ComputeInstanceChange,
ComputeInstanceTag,
SyncRun,
)
from cmdb.providers.fake import FakeProvider
from cmdb.services.sync import sync_account
class CmdbViewTests(TestCase):
def setUp(self):
self.provider = CloudProvider.objects.create(code="fake", name="Fake 教学云")
self.account = CloudAccount.objects.create(
provider=self.provider,
account_key="fake-demo",
name="Fake 演示账号",
)
user_model = get_user_model()
self.superuser = user_model.objects.create_superuser(
username="admin",
password="test-pass-123",
email="admin@example.invalid",
)
def test_anonymous_user_is_redirected_to_login(self):
response = self.client.get(reverse("cmdb:home"))
expected_url = "%s?next=%s" % (
reverse("accounts:login"),
reverse("cmdb:home"),
)
self.assertRedirects(response, expected_url)
def test_authenticated_user_sees_home_counts(self):
user_model = get_user_model()
user = user_model.objects.create_user(
username="learner",
password="test-pass-123",
)
user.user_permissions.add(
*Permission.objects.filter(
content_type__app_label="cmdb",
codename__in=[
"view_cloudprovider",
"view_cloudaccount",
"view_computeinstance",
"view_syncrun",
],
)
)
self.client.force_login(user)
response = self.client.get(reverse("cmdb:home"))
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["provider_count"], 1)
self.assertEqual(response.context["account_count"], 1)
self.assertContains(response, "CMDB 资产管理")
def test_superuser_can_open_all_read_pages(self):
sync_run = sync_account(self.account, adapter=FakeProvider())
instance = ComputeInstance.objects.first()
self.client.force_login(self.superuser)
urls = [
reverse("cmdb:provider_list"),
reverse("cmdb:account_list"),
reverse("cmdb:account_detail", args=[self.account.pk]),
reverse("cmdb:instance_list"),
reverse("cmdb:instance_detail", args=[instance.pk]),
reverse("cmdb:sync_run_list"),
reverse("cmdb:sync_run_detail", args=[sync_run.public_id]),
reverse("cmdb:topology"),
reverse("cmdb:account_csv_import"),
]
for url in urls:
with self.subTest(url=url):
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
def test_synchronized_models_are_read_only_in_admin(self):
sync_run = sync_account(self.account, adapter=FakeProvider())
instance = ComputeInstance.objects.first()
provider_tag = instance.tags.filter(
source=ComputeInstanceTag.Source.PROVIDER
).first()
read_only_models = (
CloudRegion,
CloudAvailabilityZone,
ComputeInstance,
ComputeInstanceTag,
SyncRun,
ComputeInstanceChange,
)
for model in read_only_models:
with self.subTest(model=model.__name__):
model_admin = admin.site._registry[model]
self.assertFalse(model_admin.has_add_permission(None))
self.assertFalse(model_admin.has_change_permission(None))
self.assertFalse(model_admin.has_delete_permission(None))
self.client.force_login(self.superuser)
instance_response = self.client.post(
reverse("admin:cmdb_computeinstance_change", args=[instance.pk]),
{"name": "被绕过的名称"},
)
tag_response = self.client.post(
reverse("admin:cmdb_computeinstancetag_change", args=[provider_tag.pk]),
{"value": "被绕过的标签"},
)
sync_response = self.client.post(
reverse("admin:cmdb_syncrun_change", args=[sync_run.pk]),
{"status": SyncRun.Status.FAILED},
)
self.assertEqual(instance_response.status_code, 403)
self.assertEqual(tag_response.status_code, 403)
self.assertEqual(sync_response.status_code, 403)
instance.refresh_from_db()
provider_tag.refresh_from_db()
sync_run.refresh_from_db()
self.assertNotEqual(instance.name, "被绕过的名称")
self.assertNotEqual(provider_tag.value, "被绕过的标签")
self.assertEqual(sync_run.status, SyncRun.Status.SUCCEEDED)
def test_authenticated_user_without_permission_gets_403(self):
user_model = get_user_model()
user = user_model.objects.create_user(
username="no-permission",
password="test-pass-123",
)
self.client.force_login(user)
instance_response = self.client.get(reverse("cmdb:instance_list"))
home_response = self.client.get(reverse("cmdb:home"))
self.assertEqual(instance_response.status_code, 403)
self.assertEqual(home_response.status_code, 403)
def test_home_hides_account_create_without_account_view_permission(self):
user_model = get_user_model()
user = user_model.objects.create_user(
username="provider-maintainer",
password="test-pass-123",
)
user.user_permissions.add(
*Permission.objects.filter(
content_type__app_label="cmdb",
codename__in=["view_cloudprovider", "add_cloudaccount"],
)
)
self.client.force_login(user)
response = self.client.get(reverse("cmdb:home"))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, reverse("cmdb:account_create"))
self.assertNotContains(response, "添加云账号")
def test_provider_reader_does_not_see_account_counts(self):
user_model = get_user_model()
user = user_model.objects.create_user(
username="provider-reader",
password="test-pass-123",
)
user.user_permissions.add(
Permission.objects.get(
content_type__app_label="cmdb",
codename="view_cloudprovider",
)
)
self.client.force_login(user)
response = self.client.get(reverse("cmdb:provider_list"))
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "云账号数量")
def test_sync_run_reader_does_not_see_instance_changes(self):
sync_run = sync_account(self.account, adapter=FakeProvider())
user_model = get_user_model()
user = user_model.objects.create_user(
username="sync-reader",
password="test-pass-123",
)
user.user_permissions.add(
Permission.objects.get(
content_type__app_label="cmdb",
codename="view_syncrun",
)
)
self.client.force_login(user)
response = self.client.get(
reverse("cmdb:sync_run_detail", args=[sync_run.public_id])
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "本次变化")
self.assertNotContains(response, "i-fake-hz-001")
def test_account_detail_hides_instances_and_sync_runs_without_permissions(self):
sync_run = sync_account(self.account, adapter=FakeProvider())
user_model = get_user_model()
user = user_model.objects.create_user(
username="account-reader",
password="test-pass-123",
)
user.user_permissions.add(
Permission.objects.get(
content_type__app_label="cmdb",
codename="view_cloudaccount",
)
)
self.client.force_login(user)
response = self.client.get(
reverse("cmdb:account_detail", args=[self.account.pk])
)
self.assertEqual(response.status_code, 200)
self.assertNotContains(response, "i-fake-hz-001")
self.assertNotContains(response, str(sync_run.public_id))
self.assertNotContains(response, "实例 2 台")
def test_authorized_get_on_sync_endpoint_gets_405(self):
self.client.force_login(self.superuser)
response = self.client.get(reverse("cmdb:account_sync", args=[self.account.pk]))
self.assertEqual(response.status_code, 405)
def test_sync_permission_without_view_permission_is_rejected(self):
user_model = get_user_model()
user = user_model.objects.create_user(
username="sync-only",
password="test-pass-123",
)
user.user_permissions.add(
Permission.objects.get(
content_type__app_label="cmdb",
codename="sync_cloudaccount",
)
)
self.client.force_login(user)
response = self.client.post(
reverse("cmdb:account_sync", args=[self.account.pk]),
{"scenario": "default"},
)
self.assertEqual(response.status_code, 403)
self.assertEqual(SyncRun.objects.count(), 0)
def test_user_with_sync_permission_can_start_sync(self):
user_model = get_user_model()
user = user_model.objects.create_user(
username="operator",
password="test-pass-123",
)
user.user_permissions.add(
*Permission.objects.filter(
content_type__app_label="cmdb",
codename__in=["sync_cloudaccount", "view_cloudaccount"],
)
)
self.client.force_login(user)
response = self.client.post(
reverse("cmdb:account_sync", args=[self.account.pk]),
{"scenario": "default"},
)
self.assertRedirects(
response,
reverse("cmdb:account_detail", args=[self.account.pk]),
fetch_redirect_response=False,
)
self.assertEqual(ComputeInstance.objects.count(), 2)
self.assertEqual(SyncRun.objects.first().requested_by, user)
def test_instance_detail_does_not_link_account_without_account_view_permission(self):
sync_account(self.account, adapter=FakeProvider())
instance = ComputeInstance.objects.first()
user_model = get_user_model()
user = user_model.objects.create_user(
username="instance-reader",
password="test-pass-123",
)
user.user_permissions.add(
Permission.objects.get(
content_type__app_label="cmdb",
codename="view_computeinstance",
)
)
self.client.force_login(user)
response = self.client.get(
reverse("cmdb:instance_detail", args=[instance.pk])
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, self.account.name)
self.assertNotContains(
response,
'href="%s"' % reverse("cmdb:account_detail", args=[self.account.pk]),
)
def test_instance_filter_searches_name_and_ip(self):
sync_account(self.account, adapter=FakeProvider())
self.client.force_login(self.superuser)
response = self.client.get(reverse("cmdb:instance_list"), {"q": "203.0.113.10"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["page_obj"].paginator.count, 1)
self.assertContains(response, "订单服务-演示")
def test_instance_filter_rejects_malformed_relation_ids_without_500(self):
sync_account(self.account, adapter=FakeProvider())
self.client.force_login(self.superuser)
response = self.client.get(
reverse("cmdb:instance_list"),
{"account": "bad", "region": "not-a-number"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["page_obj"].paginator.count, 0)
def test_partial_sync_displays_warning(self):
self.client.force_login(self.superuser)
response = self.client.post(
reverse("cmdb:account_sync", args=[self.account.pk]),
{"scenario": "partial"},
follow=True,
)
self.assertEqual(response.status_code, 200)
self.assertContains(response, "同步部分完成")
self.assertEqual(SyncRun.objects.first().status, SyncRun.Status.PARTIAL)
def test_instance_change_history_is_paginated(self):
sync_account(self.account, adapter=FakeProvider())
instance = ComputeInstance.objects.get(provider_resource_id="i-fake-hz-001")
ComputeInstanceChange.objects.bulk_create(
[
ComputeInstanceChange(
instance=instance,
action=ComputeInstanceChange.Action.UPDATED,
changed_fields=["name"],
before_data={"name": "before-%s" % index},
after_data={"name": "after-%s" % index},
)
for index in range(21)
]
)
self.client.force_login(self.superuser)
response = self.client.get(
reverse("cmdb:instance_detail", args=[instance.pk]),
{"change_page": "2"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.context["change_page"].paginator.count, 22)
self.assertEqual(response.context["change_page"].number, 2)
self.assertEqual(len(response.context["change_page"]), 2)
def test_topology_shows_instances_without_availability_zone(self):
sync_account(self.account, adapter=FakeProvider())
instance = ComputeInstance.objects.get(provider_resource_id="i-fake-hz-001")
instance.availability_zone = None
instance.save(update_fields=["availability_zone", "updated_at"])
self.client.force_login(self.superuser)
response = self.client.get(reverse("cmdb:topology"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "未分配可用区")
self.assertContains(response, "订单服务-演示")
def test_manual_tag_and_retire_are_post_only(self):
sync_account(self.account, adapter=FakeProvider())
instance = ComputeInstance.objects.first()
self.client.force_login(self.superuser)
tag_get = self.client.get(reverse("cmdb:manual_tag_save", args=[instance.pk]))
retire_get = self.client.get(reverse("cmdb:instance_retire", args=[instance.pk]))
tag_post = self.client.post(
reverse("cmdb:manual_tag_save", args=[instance.pk]),
{"key": "owner", "value": "platform"},
)
retire_post = self.client.post(
reverse("cmdb:instance_retire", args=[instance.pk])
)
self.assertEqual(tag_get.status_code, 405)
self.assertEqual(retire_get.status_code, 405)
self.assertEqual(tag_post.status_code, 302)
self.assertEqual(retire_post.status_code, 302)
instance.refresh_from_db()
self.assertEqual(instance.lifecycle_state, ComputeInstance.LifecycleState.RETIRED)
self.assertTrue(instance.tags.filter(key="owner", source="manual").exists())
2.2.18 devopsX/cmdb/urls.py
清单第 55 项;ZIP 内路径: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")),
]
2.2.19 devopsX/cmdb/views.py
清单第 56 项;ZIP 内路径: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
2.2.20 devopsX/devopsX/__init__.py
清单第 57 项;ZIP 内路径:devopsX/__init__.py。
这是有意保留的空文件;空内容本身就是完整内容,用于声明 Python 包或迁移包。
2.2.21 devopsX/devopsX/asgi.py
清单第 58 项;ZIP 内路径: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()
2.2.22 devopsX/devopsX/settings.py
清单第 59 项;ZIP 内路径: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"
2.2.23 devopsX/devopsX/urls.py
清单第 60 项;ZIP 内路径: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")),
]
2.2.24 devopsX/devopsX/wsgi.py
清单第 61 项;ZIP 内路径: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()
2.2.25 devopsX/manage.py
清单第 62 项;ZIP 内路径: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()
2.2.26 devopsX/requirements-aliyun.txt
清单第 63 项;ZIP 内路径:requirements-aliyun.txt。
-r requirements.txt
alibabacloud-ecs20140526==7.11.5
2.2.27 devopsX/requirements-mysql.txt
清单第 64 项;ZIP 内路径:requirements-mysql.txt。
-r requirements.txt
mysqlclient==2.2.7
2.2.28 devopsX/requirements.txt
清单第 65 项;ZIP 内路径:requirements.txt。
Django==5.2.17
python-dotenv==1.2.3
2.2.29 devopsX/templates/403.html
清单第 66 项;ZIP 内路径: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 %}
2.2.30 devopsX/templates/accounts/home.html
清单第 67 项;ZIP 内路径: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 %}
2.2.31 devopsX/templates/accounts/login.html
清单第 68 项;ZIP 内路径: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 %}
2.2.32 devopsX/templates/base.html
清单第 69 项;ZIP 内路径: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>
2.3 源码与 ZIP 往返一致性
- 本文代码块直接读取最终 ZIP 条目字节生成。
- 69 个条目路径唯一,不含绝对路径、
..或危险解压路径。 SOURCE_SHA256SUMS.txt覆盖自身之外的 68 个条目。- 最终 ZIP SHA-256 为
2a2c9b44c4c63207ac65644947c4c40166ae8f871519ed035bb8001b62c8823c,大小为 78,816 字节。 - 新鲜解压副本以自身目录为工作目录后,105 项测试全部通过。
2.4 最终验证边界
已经完成 SQLite、迁移、生产风格设置、静态文件、浏览器、权限、Fake Provider、Aliyun mock/SDK 结构和冷包回归。没有真实阿里云只读凭据,也没有 MySQL 认证信息,因此这两项仍明确写为“未运行”,不会由 SDK 安装、端口可达或模拟测试替代。
2.5 归档复核值
| 文件名 | devopsX-CMDB资产管理-v1.0.0.zip |
|---|---|
| 源码文件 | 67 |
| ZIP 条目 | 69 |
| 精确字节数 | 78,816 |
| SHA-256 | 2a2c9b44c4c63207ac65644947c4c40166ae8f871519ed035bb8001b62c8823c |
源码 ZIP 已通过当前交付渠道提供;博客不填写虚构下载地址。

浙公网安备 33010602011771号