CMDB 资产管理(6):v1.0.0 源码汇总(上篇)
1 CMDB 资产管理(6):v1.0.0 源码汇总(上篇)
1.1 导航与交付边界
源码汇总与最终 ZIP 来自同一份冻结内容。ZIP 由当前交付渠道直接提供,本文不上传第三方,也不猜测下载地址。包内不含 .env、数据库、缓存、字节码、虚拟环境、收集后的静态目录、工具配置或真实凭据。
1.2 最终环境与验证证据
| 项目 | 最终证据 | 边界 |
|---|---|---|
| 验证日期 | 2026-09-16 | CMDB v1.0.0 冻结交付。 |
| Python | CPython 3.10.8,64 位 | 本地实际验证版本。 |
| Django / python-dotenv | 5.2.17 / 1.2.3 | 与最终依赖文件一致。 |
| SQLite | 105 项测试通过;迁移无漂移;系统检查无问题。 | 确定性教学与回归路径。 |
| 随机顺序 | 四进程完整通过;最终 seed 2147873966。 | 降低测试顺序依赖风险。 |
| 生产设置 | 16 个 fail-closed 场景通过;安全配置下 check --deploy 无问题。 | 不等于真实基础设施整体验收。 |
| 静态文件 | 128 个文件实际收集成功。 | staticfiles/ 不进入源码包。 |
| 浏览器 | 主要路由、权限和 Admin 只读边界通过;无 console/server/failed network 错误。 | 真实云和真实 MySQL 不在浏览器证据内。 |
| MySQL | 8.0.46 客户端/握手、端口和 mysqlclient 导入可用。 | 无认证凭据,未执行 MySQL migration/tests。 |
| 阿里云 | SDK 已安装,18 项合同/响应结构测试和 SDK 模型字段检查通过。 | 无真实只读凭据,未执行真实账号 smoke test。 |
1.3 最终 ZIP 与清单
| 文件名 | devopsX-CMDB资产管理-v1.0.0.zip |
|---|---|
| 源码文件 | 67 |
| ZIP 条目 | 69 |
| 精确字节数 | 78,816 |
| SHA-256 | 2a2c9b44c4c63207ac65644947c4c40166ae8f871519ed035bb8001b62c8823c |
| 内部路径 | 项目根相对路径,无额外顶层前缀 |
SOURCE_FILE_LIST.txt 记录 69 个条目的序号、字节数和路径;SOURCE_SHA256SUMS.txt 记录除自身外 68 个条目的哈希。ZIP 条目字节已经与 staging 树逐项比较,归档哈希也已独立复核。
1.4 最终目录树
devopsX/
├── accounts
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ └── __init__.py
│ ├── __init__.py
│ ├── admin.py
│ ├── apps.py
│ ├── forms.py
│ ├── models.py
│ ├── tests.py
│ ├── urls.py
│ └── views.py
├── cmdb
│ ├── management
│ │ ├── commands
│ │ │ ├── __init__.py
│ │ │ ├── bootstrap_cmdb.py
│ │ │ └── sync_cloud_account.py
│ │ └── __init__.py
│ ├── migrations
│ │ ├── 0001_initial.py
│ │ ├── 0002_alter_cloudaccount_credential_profile_and_more.py
│ │ └── __init__.py
│ ├── providers
│ │ ├── __init__.py
│ │ ├── aliyun.py
│ │ ├── base.py
│ │ └── fake.py
│ ├── services
│ │ ├── __init__.py
│ │ ├── csv_io.py
│ │ └── sync.py
│ ├── static
│ │ └── cmdb
│ │ └── style.css
│ ├── templates
│ │ └── cmdb
│ │ ├── _pagination.html
│ │ ├── account_csv_import.html
│ │ ├── account_detail.html
│ │ ├── account_form.html
│ │ ├── account_list.html
│ │ ├── home.html
│ │ ├── instance_detail.html
│ │ ├── instance_list.html
│ │ ├── provider_list.html
│ │ ├── sync_run_detail.html
│ │ ├── sync_run_list.html
│ │ └── topology.html
│ ├── tests
│ │ ├── __init__.py
│ │ ├── test_aliyun_provider.py
│ │ ├── test_api.py
│ │ ├── test_csv.py
│ │ ├── test_models.py
│ │ ├── test_sync.py
│ │ └── test_views.py
│ ├── __init__.py
│ ├── admin.py
│ ├── api_urls.py
│ ├── api_views.py
│ ├── apps.py
│ ├── forms.py
│ ├── models.py
│ ├── urls.py
│ └── views.py
├── devopsX
│ ├── __init__.py
│ ├── asgi.py
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── templates
│ ├── accounts
│ │ ├── home.html
│ │ └── login.html
│ ├── 403.html
│ └── base.html
├── .env.example
├── manage.py
├── requirements-aliyun.txt
├── requirements-mysql.txt
├── requirements.txt
├── SOURCE_FILE_LIST.txt
└── SOURCE_SHA256SUMS.txt
1.5 安装、迁移与启动
把 ZIP 解压到空目录后,该目录本身就是项目根,里面直接存在 manage.py。基础 SQLite 教学路径依次执行:
python -m venv .venv
.\.venv\Scripts\Activate.ps1
python -m pip install -r requirements.txt
python manage.py migrate
python manage.py bootstrap_cmdb
python manage.py runserver 127.0.0.1:8000
阿里云可选依赖使用 requirements-aliyun.txt;MySQL 可选驱动使用 requirements-mysql.txt。不要把真实 AccessKey、Token 或数据库密码写入源码、清单、日志或博客。
1.6 复验命令
python manage.py check
python manage.py makemigrations --check --dry-run
python manage.py test
最终预期是 105 项通过、无迁移漂移和系统检查无问题。真实 MySQL 与真实阿里云仍必须在获得批准的最小权限凭据后单独验收,不能由端口可达或 mock 测试替代。
1.7 数据与安全边界
- 数据库只保存
credential_profile前缀,不保存 AccessKey。 - Provider 所有字段、CMDB 本地字段和对账字段分开管理。
- 只有完整权威快照才能推断 missing;partial/failed 快照绝不做缺失对账。
- 地域、可用区、实例、Provider 标签、同步运行和变化历史在 Admin 中只读。
- 人工标签必须经过受权限、POST 和 CSRF 保护的 CMDB 页面。
- Provider 异常和日志使用稳定分类,不记录原始异常详情或敏感请求。
1.8 v1 性能与运行限制
发现快照驻留内存;ORM 同步逐行执行;CSV 导出使用缓冲响应;拓扑和同步变化尚未分页;Web/API 同步同步占用 worker;SQLite 不是生产并发替代;没有分布式锁、Celery/Redis 或同步心跳;超过 stale 超时的合法长任务可能被替代并重复请求云端,但旧运行被 fencing 阻止写入。
1.9 完整源码(第 1 至 37 项)
1.9.1 devopsX/.env.example
清单第 1 项;ZIP 内路径:.env.example。
# 仅用于本地教学开发;生产环境必须设置 DEBUG=false 并使用独立安全配置。
DEVOPSX_SECRET_KEY=
DEVOPSX_DEBUG=true
DEVOPSX_ALLOWED_HOSTS=127.0.0.1,localhost
DEVOPSX_CSRF_TRUSTED_ORIGINS=
# 仅当受信任反向代理会覆盖 X-Forwarded-Proto 时设为 true。
DEVOPSX_TRUST_X_FORWARDED_PROTO=false
DEVOPSX_DB_ENGINE=sqlite
DEVOPSX_DB_NAME=
DEVOPSX_DB_USER=
DEVOPSX_DB_PASSWORD=
DEVOPSX_DB_HOST=127.0.0.1
DEVOPSX_DB_PORT=3306
ALIYUN_DEMO_ACCESS_KEY_ID=
ALIYUN_DEMO_ACCESS_KEY_SECRET=
ALIYUN_DEMO_SECURITY_TOKEN=
1.9.2 devopsX/SOURCE_FILE_LIST.txt
清单第 2 项;ZIP 内路径:SOURCE_FILE_LIST.txt。
538 .env.example
2085 SOURCE_FILE_LIST.txt
6253 SOURCE_SHA256SUMS.txt
0 accounts/__init__.py
146 accounts/admin.py
185 accounts/apps.py
90 accounts/forms.py
2885 accounts/migrations/0001_initial.py
0 accounts/migrations/__init__.py
167 accounts/models.py
3622 accounts/tests.py
239 accounts/urls.py
1768 accounts/views.py
0 cmdb/__init__.py
3724 cmdb/admin.py
397 cmdb/api_urls.py
8925 cmdb/api_views.py
179 cmdb/apps.py
3800 cmdb/forms.py
0 cmdb/management/__init__.py
0 cmdb/management/commands/__init__.py
632 cmdb/management/commands/bootstrap_cmdb.py
2487 cmdb/management/commands/sync_cloud_account.py
15679 cmdb/migrations/0001_initial.py
806 cmdb/migrations/0002_alter_cloudaccount_credential_profile_and_more.py
0 cmdb/migrations/__init__.py
15146 cmdb/models.py
0 cmdb/providers/__init__.py
15140 cmdb/providers/aliyun.py
1892 cmdb/providers/base.py
4891 cmdb/providers/fake.py
0 cmdb/services/__init__.py
8109 cmdb/services/csv_io.py
24613 cmdb/services/sync.py
6514 cmdb/static/cmdb/style.css
530 cmdb/templates/cmdb/_pagination.html
2500 cmdb/templates/cmdb/account_csv_import.html
3204 cmdb/templates/cmdb/account_detail.html
630 cmdb/templates/cmdb/account_form.html
1762 cmdb/templates/cmdb/account_list.html
3044 cmdb/templates/cmdb/home.html
3972 cmdb/templates/cmdb/instance_detail.html
4114 cmdb/templates/cmdb/instance_list.html
1098 cmdb/templates/cmdb/provider_list.html
2399 cmdb/templates/cmdb/sync_run_detail.html
1339 cmdb/templates/cmdb/sync_run_list.html
2240 cmdb/templates/cmdb/topology.html
0 cmdb/tests/__init__.py
14165 cmdb/tests/test_aliyun_provider.py
7762 cmdb/tests/test_api.py
11704 cmdb/tests/test_csv.py
6685 cmdb/tests/test_models.py
24867 cmdb/tests/test_sync.py
15811 cmdb/tests/test_views.py
1241 cmdb/urls.py
17746 cmdb/views.py
0 devopsX/__init__.py
407 devopsX/asgi.py
6270 devopsX/settings.py
209 devopsX/urls.py
407 devopsX/wsgi.py
685 manage.py
53 requirements-aliyun.txt
39 requirements-mysql.txt
36 requirements.txt
631 templates/403.html
818 templates/accounts/home.html
419 templates/accounts/login.html
1786 templates/base.html
1.9.3 devopsX/SOURCE_SHA256SUMS.txt
清单第 3 项;ZIP 内路径:SOURCE_SHA256SUMS.txt。
cc969de6c1804142407854f173686e5b67dfd3505d4cd789f22e9ed82f446e5c .env.example
5104d34f835941f70a58ca019ad0e0f0bb02479a7ddd6db26e5cedba2734753a SOURCE_FILE_LIST.txt
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 accounts/__init__.py
12a0192eb7bd5ad4ea95f513412f280e181a38f12e9c505053c88a4ab97734e0 accounts/admin.py
6328ccba24338a87289b066489472616f76b8e8779ecefa55a6ffb5945a5b5c6 accounts/apps.py
cabd4455c496ffb330d47f38d6bcf13bc729112b92c9eb0cdaa80d856e9af6b1 accounts/forms.py
b67241199d8b1a72f5e0e9a268beb8ddc6b5d3a71e9a15e6a966931fbeb04256 accounts/migrations/0001_initial.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 accounts/migrations/__init__.py
4a953abedbf2271d4919a5da546b52b1d00f666eb47d492f7a0d9eb5007ec01b accounts/models.py
c4dd5b1fa2d25b856a0b5b0564c8ddd1be9ef7ec3486f31c0350e703afcf5ba6 accounts/tests.py
6842f21bbb2c20c0fe102be8eaa16afbc0a3d5341b988ff453fdb7695f3f5150 accounts/urls.py
e02c4b166e81f17dc693794ca5123dd208e1d5a9215e01bc9c106b8f78f42591 accounts/views.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 cmdb/__init__.py
73f65027f1d322411d041c3cb010bd5a8e92f576200eebf1ce4053aaf00311cf cmdb/admin.py
80574603947c9c7bf63dda3b4890b9e7799f503f85ccb9a6ce457c8dd3863819 cmdb/api_urls.py
021d228f2ec73f6aec5e6b6fcd9703873724034e19b8a76f1dc635af8e76e34b cmdb/api_views.py
0ae1cf3903a04d1ee61d8cb70f543374d3bc65c5d07b4b7d4a731e197e0e036d cmdb/apps.py
32f281f6a766f7dcb5992b65a4b4abbe4a4ff230ba94316c3681e11baa780278 cmdb/forms.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 cmdb/management/__init__.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 cmdb/management/commands/__init__.py
ad76bd9e752146155ec6537491adfd184b9faf1e82f990204f6ae922685aa92b cmdb/management/commands/bootstrap_cmdb.py
534058c20bf37044d8f4f570ba53daf0e6a586a09e69d424f08abb3ca36c02b1 cmdb/management/commands/sync_cloud_account.py
907c42378d92b5c0637b4985f115d08e1f6b03c43dcec4216679adb41749f1fe cmdb/migrations/0001_initial.py
6b01dac5db50bf35624e191fdc549d7fe232e1ab454820a2ab28a0a989e2368c cmdb/migrations/0002_alter_cloudaccount_credential_profile_and_more.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 cmdb/migrations/__init__.py
599b20df903e5e494662633e56ed0f5e1fd75943a120dc8e5be98862b73aa9a8 cmdb/models.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 cmdb/providers/__init__.py
067be6da4e456e301098ab14b73fcc055b08997ea84557143ae944f8f8bcefa9 cmdb/providers/aliyun.py
6850377b3e188f2f584c31c66d9a35f0b63cf162433ecc50fb5b5e173850b5c6 cmdb/providers/base.py
8b60105690f3993ca93dc9f19ffcb143342df17ec984af59f61e61bbc91b3095 cmdb/providers/fake.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 cmdb/services/__init__.py
944c75714c2fedcfcd03cd7aa2c8d6f2bf0878e5417d91523193b0961bed0e43 cmdb/services/csv_io.py
8d6adcffbbca4dee429f791b59a0a9141564200d1c2f6ffca00869773d638210 cmdb/services/sync.py
2a6a18232dd7ac50dd81b4b62833fc6533a9d43a695f11926131d559df2e43fb cmdb/static/cmdb/style.css
cda2ade80e60aa45711551c80858e9a04733d7916539ba256288c8c8bb95a6d8 cmdb/templates/cmdb/_pagination.html
5e79adae25b74cccfaff81259802e4b709e1582137014e3b4a4857dfaf3ed55b cmdb/templates/cmdb/account_csv_import.html
508751a0c904c7a11618e4866db5f888026711b666720050c2d075079e49f7d2 cmdb/templates/cmdb/account_detail.html
08a27cbecf79180aa2669a25dacd6d09d7e879835bceca9b553c856cde15afd0 cmdb/templates/cmdb/account_form.html
810aad0121b9e99cbc934ddcd8f2dcfcaa7b233f9eff48cb3ddb31791979505b cmdb/templates/cmdb/account_list.html
1911478320b162c1fce51d94f1c1a7271372300985509bb43418fe8f1cd874a7 cmdb/templates/cmdb/home.html
bbe92700a0e705ef4ff6ad3a2a66444702e3f4510cf1206e1dca74e5c0b01085 cmdb/templates/cmdb/instance_detail.html
255b0658c5eecdb397b83bf60c8d7db04ffa2adeb0e826844c45725d2793cda6 cmdb/templates/cmdb/instance_list.html
46b6f55fa368d5aba7f1073186607515f3640a368e715dc7ef7cc067d2e040d3 cmdb/templates/cmdb/provider_list.html
063d3bd19a387b1dcaa938239f0637b3c82cddf34287c84d3de5f972520210fc cmdb/templates/cmdb/sync_run_detail.html
018027557b2845bc05cd07447971a908141f76507f7b5b26e0991303ad2316d9 cmdb/templates/cmdb/sync_run_list.html
bc123dc1d6f1eeb195de67827a46e194bb5157a2c33bb502f91d544df67b1651 cmdb/templates/cmdb/topology.html
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 cmdb/tests/__init__.py
37e81059ddca859ce06d2680ae237c36c487a7875f1178eb516a72de9314a80f cmdb/tests/test_aliyun_provider.py
f63372f3d5e241298c6e8106fadbdbd9098f5e883a8b83ed6fafb8b84f1445b1 cmdb/tests/test_api.py
0514961564b9ebe90cee7be778548d6e6329fb5d061dfad258009bc5a712fb68 cmdb/tests/test_csv.py
779e3ce414398302e067c65f20f5b169403b0cad4a418879bbbb7d6021ddbce3 cmdb/tests/test_models.py
04e63c878a4aec0522511e9e8f5eb35be3591c52ffaa21407ec3fefe158e5471 cmdb/tests/test_sync.py
08db8644d01dc8a66b6aad81f861a8cbceb15c683bc9941fb214b2e071b47bd5 cmdb/tests/test_views.py
4677d8256f5a02680e0edbc0390d03319fc0bbe0b3759c1395799a8b518d1b5b cmdb/urls.py
d7627dabd0f44593042d7d2ab1bd6bd474b083be690e63c4aeddd669859cd5f4 cmdb/views.py
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 devopsX/__init__.py
985ecf823aa9f07031e09b6caa4130615b81387c423a51cf83f0ee87f6d7dd6e devopsX/asgi.py
36e0e12e253e5c273f3f9e4203036300947792c564b25d7d561af828cdda5fcb devopsX/settings.py
dd9683792b95fad3c1526223d459580ca3f66f3777661bfe9ff1c12abc45f5c0 devopsX/urls.py
267097aeebe7e6f3629059f2becece55ffeebe4a3657973deabd1cd4fe8d1a27 devopsX/wsgi.py
a6d7830c16e1de91d0b3b5050d6dada013621d7a89c5c30da94be7c16ce34a47 manage.py
e456cc62e6a5f25832f9b0ced593fc17f416725ec638b704449db7c310af9612 requirements-aliyun.txt
ce5ecc29696208e973a721afeff98a897d68f3134e701e4b81e17500b4fc0ec7 requirements-mysql.txt
ee5a8d28f851bc721213b04a0c460c3fb00499f6b9859346a6f81d354cd34b9b requirements.txt
ea042ada9eb5c98a989f43d13c135dd47482d31fac5fec38dec2c6a4d8e5e3d8 templates/403.html
cb8fadc5d2069e95055802c94b015a743487d1ae3ca9dca77a3d5cd30ba6dded templates/accounts/home.html
6920aa6f0c1a053163b76b13af46492361470559bef6b026b4f189a68e345107 templates/accounts/login.html
2f3478745f2144a1978c08c859db6e80eed5648fd92885383a09777397031f27 templates/base.html
1.9.4 devopsX/accounts/__init__.py
清单第 4 项;ZIP 内路径:accounts/__init__.py。
这是有意保留的空文件;空内容本身就是完整内容,用于声明 Python 包或迁移包。
1.9.5 devopsX/accounts/admin.py
清单第 5 项;ZIP 内路径:accounts/admin.py。
from django.contrib import admin
from django.contrib.auth.admin import UserAdmin
from .models import User
admin.site.register(User, UserAdmin)
1.9.6 devopsX/accounts/apps.py
清单第 6 项;ZIP 内路径:accounts/apps.py。
from django.apps import AppConfig
class AccountsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "accounts"
verbose_name = "用户与权限"
1.9.7 devopsX/accounts/forms.py
清单第 7 项;ZIP 内路径:accounts/forms.py。
from django.contrib.auth.forms import AuthenticationForm
LoginForm = AuthenticationForm
1.9.8 devopsX/accounts/migrations/0001_initial.py
清单第 8 项;ZIP 内路径:accounts/migrations/0001_initial.py。
# Generated by Django 5.2.17 on 2026-09-16 10:06
import django.contrib.auth.models
import django.contrib.auth.validators
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('auth', '0012_alter_user_first_name_max_length'),
]
operations = [
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')),
('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')),
('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')),
('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
],
options={
'verbose_name': '用户',
'verbose_name_plural': '用户',
},
managers=[
('objects', django.contrib.auth.models.UserManager()),
],
),
]
1.9.9 devopsX/accounts/migrations/__init__.py
清单第 9 项;ZIP 内路径:accounts/migrations/__init__.py。
这是有意保留的空文件;空内容本身就是完整内容,用于声明 Python 包或迁移包。
1.9.10 devopsX/accounts/models.py
清单第 10 项;ZIP 内路径:accounts/models.py。
from django.contrib.auth.models import AbstractUser
class User(AbstractUser):
class Meta:
verbose_name = "用户"
verbose_name_plural = "用户"
1.9.11 devopsX/accounts/tests.py
清单第 11 项;ZIP 内路径:accounts/tests.py。
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
class AccountViewTests(TestCase):
def test_home_is_public(self):
response = self.client.get(reverse("accounts:home"))
self.assertEqual(response.status_code, 200)
self.assertContains(response, "devopsX")
def test_login_redirects_authorized_user_to_cmdb(self):
user_model = get_user_model()
user = user_model.objects.create_user(
username="learner",
password="test-pass-123",
)
user.user_permissions.add(
Permission.objects.get(
content_type__app_label="cmdb",
codename="view_cloudaccount",
)
)
response = self.client.post(
reverse("accounts:login"),
{"username": "learner", "password": "test-pass-123"},
)
self.assertRedirects(
response,
reverse("cmdb:home"),
fetch_redirect_response=False,
)
def test_login_without_cmdb_view_permission_redirects_to_public_home(self):
user_model = get_user_model()
user_model.objects.create_user(
username="no-cmdb-view",
password="test-pass-123",
)
response = self.client.post(
reverse("accounts:login"),
{"username": "no-cmdb-view", "password": "test-pass-123"},
)
self.assertRedirects(response, reverse("accounts:home"))
home_response = self.client.get(reverse("accounts:home"))
self.assertContains(home_response, "尚未获得 CMDB 查看权限")
self.assertNotContains(
home_response,
'href="%s"' % reverse("cmdb:home"),
)
def test_permission_denied_without_cmdb_view_links_to_public_home(self):
user_model = get_user_model()
user = user_model.objects.create_user(
username="action-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.get(reverse("cmdb:home"))
self.assertEqual(response.status_code, 403)
self.assertContains(
response,
"返回平台首页",
status_code=403,
)
self.assertNotContains(
response,
"返回 CMDB 首页",
status_code=403,
)
def test_login_honors_safe_next_and_rejects_external_next(self):
user_model = get_user_model()
user_model.objects.create_user(username="learner", password="test-pass-123")
target = reverse("cmdb:instance_list")
response = self.client.post(
reverse("accounts:login"),
{
"username": "learner",
"password": "test-pass-123",
"next": target,
},
)
self.assertRedirects(response, target, fetch_redirect_response=False)
self.client.logout()
external_response = self.client.post(
reverse("accounts:login"),
{
"username": "learner",
"password": "test-pass-123",
"next": "https://example.invalid/steal",
},
)
self.assertRedirects(
external_response,
reverse("accounts:home"),
)
1.9.12 devopsX/accounts/urls.py
清单第 12 项;ZIP 内路径:accounts/urls.py。
from django.urls import path
from . import views
app_name = "accounts"
urlpatterns = [
path("", views.home, name="home"),
path("login/", views.login_view, name="login"),
path("logout/", views.logout_view, name="logout"),
]
1.9.13 devopsX/accounts/views.py
清单第 13 项;ZIP 内路径:accounts/views.py。
from django.contrib import messages
from django.contrib.auth import login, logout
from django.http import HttpResponseNotAllowed
from django.shortcuts import redirect, render
from django.utils.http import url_has_allowed_host_and_scheme
from .forms import LoginForm
CMDB_VIEW_PERMISSIONS = (
"cmdb.view_cloudprovider",
"cmdb.view_cloudaccount",
"cmdb.view_computeinstance",
"cmdb.view_syncrun",
)
def _default_login_redirect(user):
if any(user.has_perm(permission) for permission in CMDB_VIEW_PERMISSIONS):
return "cmdb:home"
return "accounts:home"
def home(request):
return render(request, "accounts/home.html")
def login_view(request):
next_url = request.POST.get("next", "") or request.GET.get("next", "")
next_is_safe = next_url and url_has_allowed_host_and_scheme(
next_url,
allowed_hosts={request.get_host()},
require_https=request.is_secure(),
)
if request.user.is_authenticated:
return redirect(
next_url if next_is_safe else _default_login_redirect(request.user)
)
form = LoginForm(request, data=request.POST or None)
if request.method == "POST" and form.is_valid():
login(request, form.get_user())
messages.success(request, "登录成功。")
return redirect(
next_url if next_is_safe else _default_login_redirect(request.user)
)
return render(
request,
"accounts/login.html",
{"form": form, "next": next_url if next_is_safe else ""},
)
def logout_view(request):
if request.method != "POST":
return HttpResponseNotAllowed(["POST"])
logout(request)
messages.success(request, "已经退出登录。")
return redirect("accounts:home")
1.9.14 devopsX/cmdb/__init__.py
清单第 14 项;ZIP 内路径:cmdb/__init__.py。
这是有意保留的空文件;空内容本身就是完整内容,用于声明 Python 包或迁移包。
1.9.15 devopsX/cmdb/admin.py
清单第 15 项;ZIP 内路径:cmdb/admin.py。
from django.contrib import admin
from .models import (
CloudAccount,
CloudAvailabilityZone,
CloudProvider,
CloudRegion,
ComputeInstance,
ComputeInstanceChange,
ComputeInstanceTag,
SyncRun,
)
class ReadOnlyAdminMixin:
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
return False
def has_delete_permission(self, request, obj=None):
return False
def get_readonly_fields(self, request, obj=None):
return tuple(field.name for field in self.model._meta.concrete_fields)
@admin.register(CloudProvider)
class CloudProviderAdmin(admin.ModelAdmin):
list_display = ("code", "name", "is_active", "updated_at")
list_filter = ("is_active",)
search_fields = ("code", "name")
@admin.register(CloudAccount)
class CloudAccountAdmin(admin.ModelAdmin):
list_display = (
"account_key",
"name",
"provider",
"is_active",
"sync_enabled",
"last_successful_sync_at",
)
list_filter = ("provider", "is_active", "sync_enabled")
search_fields = ("account_key", "name", "credential_profile")
@admin.register(CloudRegion)
class CloudRegionAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = (
"provider_resource_id",
"name",
"account",
"is_active",
"last_seen_at",
)
list_filter = ("account__provider", "account", "is_active")
search_fields = ("provider_resource_id", "name")
@admin.register(CloudAvailabilityZone)
class CloudAvailabilityZoneAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = (
"provider_resource_id",
"name",
"region",
"account",
"is_active",
)
list_filter = ("account__provider", "account", "region", "is_active")
search_fields = ("provider_resource_id", "name")
@admin.register(ComputeInstance)
class ComputeInstanceAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = (
"provider_resource_id",
"name",
"account",
"region",
"normalized_status",
"lifecycle_state",
"last_seen_at",
)
list_filter = (
"account__provider",
"account",
"region",
"normalized_status",
"lifecycle_state",
)
search_fields = ("provider_resource_id", "name", "private_ips", "public_ips")
@admin.register(ComputeInstanceTag)
class ComputeInstanceTagAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ("instance", "key", "value", "source", "updated_at")
list_filter = ("source",)
search_fields = ("key", "value", "instance__provider_resource_id")
@admin.register(SyncRun)
class SyncRunAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = (
"public_id",
"account",
"status",
"trigger",
"discovered_count",
"created_count",
"updated_count",
"created_at",
)
list_filter = ("status", "trigger", "account__provider", "account")
search_fields = ("public_id", "account__account_key", "error_code")
readonly_fields = (
"public_id",
"started_at",
"finished_at",
"created_at",
)
@admin.register(ComputeInstanceChange)
class ComputeInstanceChangeAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
list_display = ("instance", "action", "sync_run", "created_at")
list_filter = ("action",)
search_fields = ("instance__provider_resource_id", "instance__name")
readonly_fields = (
"sync_run",
"instance",
"action",
"changed_fields",
"before_data",
"after_data",
"created_at",
)
1.9.16 devopsX/cmdb/api_urls.py
清单第 16 项;ZIP 内路径:cmdb/api_urls.py。
from django.urls import path
from . import api_views
app_name = "api"
urlpatterns = [
path("accounts/", api_views.account_list, name="account_list"),
path("accounts/<int:pk>/sync/", api_views.account_sync, name="account_sync"),
path("instances/", api_views.instance_list, name="instance_list"),
path("instances/<int:pk>/", api_views.instance_detail, name="instance_detail"),
]
1.9.17 devopsX/cmdb/api_views.py
清单第 17 项;ZIP 内路径:cmdb/api_views.py。
import json
from django.contrib.auth.decorators import login_required, permission_required
from django.core.paginator import Paginator
from django.db.models import Q
from django.http import JsonResponse
from django.shortcuts import get_object_or_404
from django.views.decorators.http import require_GET, require_POST
from .models import CloudAccount, ComputeInstance, SyncRun
from .providers.base import ProviderError
from .services.sync import sync_account
API_VERSION = "v1"
CONFLICT_ERROR_CODES = {
"ACCOUNT_DISABLED",
"PROVIDER_DISABLED",
"SYNC_ALREADY_RUNNING",
"SYNC_CONFIGURATION_CHANGED",
"SYNC_RUN_SUPERSEDED",
}
UPSTREAM_ERROR_CODES = {
"ALIYUN_ACCESS_DENIED",
"ALIYUN_INCOMPLETE_PAGINATION",
"ALIYUN_INCONSISTENT_PAGINATION",
"ALIYUN_INVALID_RESPONSE",
"ALIYUN_INVALID_TIME",
"ALIYUN_NETWORK_ERROR",
"ALIYUN_REQUEST_FAILED",
"ALIYUN_SDK_NOT_INSTALLED",
"ALIYUN_THROTTLED",
"DUPLICATE_INSTANCE",
"DUPLICATE_REGION",
"DUPLICATE_ZONE",
"EMPTY_INSTANCE_ID",
"EMPTY_REGION_ID",
"EMPTY_ZONE_ID",
"INCOMPLETE_DISCOVERY_SCOPE",
"INSTANCE_ZONE_REGION_MISMATCH",
"INVALID_DISCOVERY_SCOPE",
"INVALID_NORMALIZED_STATUS",
"OUT_OF_SCOPE_DISCOVERY_REGION",
"PROVIDER_UNAVAILABLE",
"UNEXPECTED_ERROR",
"UNKNOWN_INSTANCE_REGION",
"UNKNOWN_INSTANCE_ZONE",
"UNKNOWN_ZONE_REGION",
}
def _provider_error_status(code):
if code in CONFLICT_ERROR_CODES:
return 409
if code in UPSTREAM_ERROR_CODES:
return 502
return 422
def _json_response(data, status=200):
return JsonResponse(
data,
status=status,
json_dumps_params={"ensure_ascii": False},
)
def _instance_payload(instance):
return {
"id": instance.id,
"provider": instance.account.provider.code,
"account_key": instance.account.account_key,
"provider_resource_id": instance.provider_resource_id,
"name": instance.name,
"region": instance.region.provider_resource_id,
"availability_zone": (
instance.availability_zone.provider_resource_id
if instance.availability_zone
else None
),
"instance_type": instance.instance_type,
"vcpu": instance.vcpu,
"memory_mb": instance.memory_mb,
"os_name": instance.os_name,
"provider_status": instance.provider_status,
"normalized_status": instance.normalized_status,
"lifecycle_state": instance.lifecycle_state,
"private_ips": instance.private_ips,
"public_ips": instance.public_ips,
"cloud_created_at": (
instance.cloud_created_at.isoformat() if instance.cloud_created_at else None
),
"first_seen_at": instance.first_seen_at.isoformat(),
"last_seen_at": instance.last_seen_at.isoformat(),
"missing_since": (
instance.missing_since.isoformat() if instance.missing_since else None
),
"retired_at": instance.retired_at.isoformat() if instance.retired_at else None,
"tags": [
{"key": tag.key, "value": tag.value, "source": tag.source}
for tag in instance.tags.all()
],
}
@login_required
@permission_required("cmdb.view_cloudaccount", raise_exception=True)
@require_GET
def account_list(request):
accounts = CloudAccount.objects.select_related("provider").order_by(
"provider__code",
"account_key",
)
try:
page_size = min(max(int(request.GET.get("page_size", 50)), 1), 100)
except ValueError:
return _json_response(
{"api_version": API_VERSION, "error": "page_size 必须是整数。"},
status=400,
)
page_obj = Paginator(accounts, page_size).get_page(request.GET.get("page"))
data = [
{
"id": account.id,
"provider": account.provider.code,
"account_key": account.account_key,
"name": account.name,
"credential_profile": account.credential_profile,
"region_allowlist": account.region_allowlist,
"is_active": account.is_active,
"sync_enabled": account.sync_enabled,
"last_successful_sync_at": (
account.last_successful_sync_at.isoformat()
if account.last_successful_sync_at
else None
),
}
for account in page_obj
]
return _json_response(
{
"api_version": API_VERSION,
"count": page_obj.paginator.count,
"page": page_obj.number,
"pages": page_obj.paginator.num_pages,
"results": data,
}
)
@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
@require_GET
def instance_list(request):
instances = ComputeInstance.objects.select_related(
"account",
"account__provider",
"region",
"availability_zone",
).prefetch_related("tags")
keyword = request.GET.get("q", "").strip()
if keyword:
instances = instances.filter(
Q(provider_resource_id__icontains=keyword)
| Q(name__icontains=keyword)
| Q(os_name__icontains=keyword)
)
lifecycle = request.GET.get("lifecycle", "").strip()
if lifecycle:
instances = instances.filter(lifecycle_state=lifecycle)
instances = instances.order_by("id")
try:
page_size = min(max(int(request.GET.get("page_size", 50)), 1), 100)
except ValueError:
return _json_response(
{"api_version": API_VERSION, "error": "page_size 必须是整数。"},
status=400,
)
page_obj = Paginator(instances, page_size).get_page(request.GET.get("page"))
return _json_response(
{
"api_version": API_VERSION,
"count": page_obj.paginator.count,
"page": page_obj.number,
"pages": page_obj.paginator.num_pages,
"results": [_instance_payload(instance) for instance in page_obj],
}
)
@login_required
@permission_required("cmdb.view_computeinstance", raise_exception=True)
@require_GET
def instance_detail(request, pk):
instance = get_object_or_404(
ComputeInstance.objects.select_related(
"account",
"account__provider",
"region",
"availability_zone",
).prefetch_related("tags"),
pk=pk,
)
return _json_response(
{"api_version": API_VERSION, "result": _instance_payload(instance)}
)
@login_required
@permission_required("cmdb.sync_cloudaccount", raise_exception=True)
@require_POST
def account_sync(request, pk):
account = get_object_or_404(
CloudAccount.objects.select_related("provider"),
pk=pk,
)
try:
body = json.loads(request.body or b"{}")
except (UnicodeDecodeError, json.JSONDecodeError):
return _json_response(
{"api_version": API_VERSION, "error": "请求正文不是有效 JSON。"},
status=400,
)
if not isinstance(body, dict):
return _json_response(
{"api_version": API_VERSION, "error": "请求正文必须是 JSON 对象。"},
status=400,
)
scenario = body.get("scenario", "default")
if not isinstance(scenario, str) or scenario not in {
"default",
"empty",
"partial",
"failed",
}:
return _json_response(
{"api_version": API_VERSION, "error": "未知 Fake 场景。"},
status=400,
)
if account.provider.code != "fake":
scenario = "default"
try:
sync_run = sync_account(
account,
requested_by=request.user,
trigger=SyncRun.Trigger.API,
scenario=scenario,
)
except ProviderError as exc:
return _json_response(
{
"api_version": API_VERSION,
"error": exc.message,
"error_code": exc.code,
},
status=_provider_error_status(exc.code),
)
return _json_response(
{
"api_version": API_VERSION,
"result": {
"public_id": str(sync_run.public_id),
"status": sync_run.status,
"discovered_count": sync_run.discovered_count,
"created_count": sync_run.created_count,
"updated_count": sync_run.updated_count,
"unchanged_count": sync_run.unchanged_count,
"missing_count": sync_run.missing_count,
"restored_count": sync_run.restored_count,
"error_code": sync_run.error_code,
"error_message": sync_run.error_message,
},
},
status=207 if sync_run.status == SyncRun.Status.PARTIAL else 201,
)
1.9.18 devopsX/cmdb/apps.py
清单第 18 项;ZIP 内路径:cmdb/apps.py。
from django.apps import AppConfig
class CmdbConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "cmdb"
verbose_name = "CMDB 资产管理"
1.9.19 devopsX/cmdb/forms.py
清单第 19 项;ZIP 内路径:cmdb/forms.py。
import re
from django import forms
from django.core.exceptions import ValidationError
from .models import CloudAccount, ComputeInstanceTag, validate_region_allowlist
class CloudAccountForm(forms.ModelForm):
region_allowlist_text = forms.CharField(
label="地域白名单",
required=False,
help_text="多个地域用英文逗号分隔;留空表示发现全部地域。",
)
class Meta:
model = CloudAccount
fields = (
"provider",
"account_key",
"name",
"credential_profile",
"region_allowlist_text",
"is_active",
"sync_enabled",
)
help_texts = {
"account_key": "平台内稳定账号键,例如 aliyun-production。",
"credential_profile": (
"只填写环境变量前缀,例如 ALIYUN_PRODUCTION;"
"不要填写 AccessKey、Secret 或 Token。"
),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.fields["region_allowlist_text"].initial = ", ".join(
self.instance.region_allowlist
)
def clean_credential_profile(self):
value = self.cleaned_data["credential_profile"].strip()
if value and not re.fullmatch(r"[A-Z][A-Z0-9_]*", value):
raise forms.ValidationError("凭据前缀只能使用大写字母、数字和下划线。")
return value
def clean_region_allowlist_text(self):
value = self.cleaned_data["region_allowlist_text"]
regions = []
for item in value.split(","):
region = item.strip()
if region and region not in regions:
regions.append(region)
try:
validate_region_allowlist(regions)
except ValidationError as exc:
raise forms.ValidationError(exc.messages) from exc
return regions
def save(self, commit=True):
instance = super().save(commit=False)
instance.region_allowlist = self.cleaned_data["region_allowlist_text"]
if commit:
instance.save()
return instance
class ManualTagForm(forms.Form):
key = forms.CharField(label="标签键", max_length=100)
value = forms.CharField(label="标签值", max_length=255, required=False)
def clean_key(self):
return self.cleaned_data["key"].strip()
def save(self, instance):
return ComputeInstanceTag.objects.update_or_create(
instance=instance,
source=ComputeInstanceTag.Source.MANUAL,
key=self.cleaned_data["key"],
defaults={"value": self.cleaned_data["value"].strip()},
)
class CloudAccountCsvForm(forms.Form):
csv_file = forms.FileField(
label="云账号 CSV 文件",
help_text="只接受 UTF-8 CSV,最大 1 MiB。",
)
def clean_csv_file(self):
uploaded_file = self.cleaned_data["csv_file"]
if uploaded_file.size > 1024 * 1024:
raise forms.ValidationError("CSV 文件不能超过 1 MiB。")
if not uploaded_file.name.lower().endswith(".csv"):
raise forms.ValidationError("请选择 .csv 文件。")
return uploaded_file
class CloudAccountCsvConfirmForm(forms.Form):
signed_payload = forms.CharField(widget=forms.HiddenInput)
class SyncAccountForm(forms.Form):
scenario = forms.ChoiceField(
label="Fake 教学场景",
required=False,
choices=(
("default", "默认:发现两台实例"),
("empty", "完整空快照:演示缺失"),
("partial", "部分快照:不标记缺失"),
("failed", "Provider 失败"),
),
)
1.9.20 devopsX/cmdb/management/__init__.py
清单第 20 项;ZIP 内路径:cmdb/management/__init__.py。
这是有意保留的空文件;空内容本身就是完整内容,用于声明 Python 包或迁移包。
1.9.21 devopsX/cmdb/management/commands/__init__.py
清单第 21 项;ZIP 内路径:cmdb/management/commands/__init__.py。
这是有意保留的空文件;空内容本身就是完整内容,用于声明 Python 包或迁移包。
1.9.22 devopsX/cmdb/management/commands/bootstrap_cmdb.py
清单第 22 项;ZIP 内路径:cmdb/management/commands/bootstrap_cmdb.py。
from django.core.management.base import BaseCommand
from cmdb.models import CloudProvider
class Command(BaseCommand):
help = "创建 CMDB v1 内置云厂商"
def handle(self, *args, **options):
providers = (
("fake", "Fake 教学云"),
("aliyun", "阿里云"),
)
for code, name in providers:
provider, created = CloudProvider.objects.update_or_create(
code=code,
defaults={"name": name},
)
action = "创建" if created else "更新"
self.stdout.write("%s云厂商:%s" % (action, provider))
1.9.23 devopsX/cmdb/management/commands/sync_cloud_account.py
清单第 23 项;ZIP 内路径:cmdb/management/commands/sync_cloud_account.py。
from django.core.management.base import BaseCommand, CommandError
from cmdb.models import CloudAccount, SyncRun
from cmdb.providers.base import ProviderError
from cmdb.services.sync import sync_account
class Command(BaseCommand):
help = "同步一个云账号的资产"
def add_arguments(self, parser):
parser.add_argument("account_key")
parser.add_argument(
"--provider",
dest="provider_code",
help="云厂商代码;同名账号存在于多个厂商时必须填写。",
)
parser.add_argument(
"--scenario",
default="default",
choices=["default", "empty", "partial", "failed"],
help="Fake Provider 教学场景",
)
def handle(self, *args, **options):
accounts = CloudAccount.objects.select_related("provider").filter(
account_key=options["account_key"]
)
if options["provider_code"]:
accounts = accounts.filter(provider__code=options["provider_code"])
matches = list(accounts[:2])
if not matches:
raise CommandError("云账号不存在:%s" % options["account_key"])
if len(matches) > 1:
raise CommandError(
"多个云厂商存在账号键 %s,请使用 --provider 指定云厂商。"
% options["account_key"]
)
account = matches[0]
try:
sync_run = sync_account(
account,
trigger=SyncRun.Trigger.COMMAND,
scenario=options["scenario"],
)
except ProviderError as exc:
raise CommandError("%s:%s" % (exc.code, exc.message)) from exc
if sync_run.status == SyncRun.Status.PARTIAL:
raise CommandError(
"同步部分完成(%s):%s"
% (sync_run.error_code, sync_run.error_message)
)
self.stdout.write(
self.style.SUCCESS(
"同步完成:状态=%s,发现=%s,新建=%s,更新=%s,未变化=%s,缺失=%s,恢复=%s"
% (
sync_run.status,
sync_run.discovered_count,
sync_run.created_count,
sync_run.updated_count,
sync_run.unchanged_count,
sync_run.missing_count,
sync_run.restored_count,
)
)
)
1.9.24 devopsX/cmdb/migrations/0001_initial.py
清单第 24 项;ZIP 内路径:cmdb/migrations/0001_initial.py。
# Generated by Django 5.2.17 on 2026-09-16 10:10
import django.db.models.deletion
import django.utils.timezone
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='CloudProvider',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(max_length=32, unique=True, verbose_name='代码')),
('name', models.CharField(max_length=100, verbose_name='名称')),
('is_active', models.BooleanField(default=True, verbose_name='启用')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
],
options={
'verbose_name': '云厂商',
'verbose_name_plural': '云厂商',
'ordering': ['code'],
},
),
migrations.CreateModel(
name='CloudAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('account_key', models.SlugField(max_length=100, verbose_name='账号键')),
('name', models.CharField(max_length=100, verbose_name='显示名称')),
('credential_profile', models.CharField(blank=True, max_length=100, verbose_name='凭据环境变量前缀')),
('region_allowlist', models.JSONField(blank=True, default=list, verbose_name='地域白名单')),
('is_active', models.BooleanField(default=True, verbose_name='启用')),
('sync_enabled', models.BooleanField(default=True, verbose_name='允许同步')),
('last_successful_sync_at', models.DateTimeField(blank=True, null=True, verbose_name='最后成功同步时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('provider', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='accounts', to='cmdb.cloudprovider', verbose_name='云厂商')),
],
options={
'verbose_name': '云账号',
'verbose_name_plural': '云账号',
'ordering': ['provider__code', 'account_key'],
'permissions': [('sync_cloudaccount', '可以同步云账号'), ('import_cloudaccount', '可以导入云账号')],
},
),
migrations.CreateModel(
name='CloudRegion',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider_resource_id', models.CharField(max_length=100, verbose_name='厂商地域 ID')),
('name', models.CharField(max_length=100, verbose_name='名称')),
('endpoint', models.CharField(blank=True, max_length=255, verbose_name='服务端点')),
('is_active', models.BooleanField(default=True, verbose_name='当前存在')),
('first_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='首次发现时间')),
('last_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='最后发现时间')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='regions', to='cmdb.cloudaccount', verbose_name='云账号')),
],
options={
'verbose_name': '云地域',
'verbose_name_plural': '云地域',
'ordering': ['account', 'provider_resource_id'],
},
),
migrations.CreateModel(
name='CloudAvailabilityZone',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider_resource_id', models.CharField(max_length=100, verbose_name='厂商可用区 ID')),
('name', models.CharField(max_length=100, verbose_name='名称')),
('is_active', models.BooleanField(default=True, verbose_name='当前存在')),
('first_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='首次发现时间')),
('last_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='最后发现时间')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='availability_zones', to='cmdb.cloudaccount', verbose_name='云账号')),
('region', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='availability_zones', to='cmdb.cloudregion', verbose_name='云地域')),
],
options={
'verbose_name': '可用区',
'verbose_name_plural': '可用区',
'ordering': ['account', 'region', 'provider_resource_id'],
},
),
migrations.CreateModel(
name='ComputeInstance',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('provider_resource_id', models.CharField(max_length=100, verbose_name='厂商实例 ID')),
('name', models.CharField(blank=True, max_length=255, verbose_name='实例名称')),
('instance_type', models.CharField(blank=True, max_length=100, verbose_name='实例规格')),
('vcpu', models.PositiveIntegerField(default=0, verbose_name='vCPU')),
('memory_mb', models.PositiveIntegerField(default=0, verbose_name='内存 MiB')),
('os_name', models.CharField(blank=True, max_length=255, verbose_name='操作系统')),
('provider_status', models.CharField(blank=True, max_length=100, verbose_name='厂商原始状态')),
('normalized_status', models.CharField(choices=[('running', '运行中'), ('stopped', '已停止'), ('starting', '启动中'), ('stopping', '停止中'), ('unknown', '未知')], default='unknown', max_length=20, verbose_name='标准状态')),
('lifecycle_state', models.CharField(choices=[('present', '当前存在'), ('missing', '本次未发现'), ('retired', '已退役')], default='present', max_length=20, verbose_name='生命周期')),
('private_ips', models.JSONField(blank=True, default=list, verbose_name='私网 IP')),
('public_ips', models.JSONField(blank=True, default=list, verbose_name='公网 IP')),
('cloud_created_at', models.DateTimeField(blank=True, null=True, verbose_name='云上创建时间')),
('first_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='首次发现时间')),
('last_seen_at', models.DateTimeField(default=django.utils.timezone.now, verbose_name='最后发现时间')),
('missing_since', models.DateTimeField(blank=True, null=True, verbose_name='开始缺失时间')),
('retired_at', models.DateTimeField(blank=True, null=True, verbose_name='退役时间')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='compute_instances', to='cmdb.cloudaccount', verbose_name='云账号')),
('availability_zone', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='compute_instances', to='cmdb.cloudavailabilityzone', verbose_name='可用区')),
('region', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='compute_instances', to='cmdb.cloudregion', verbose_name='云地域')),
],
options={
'verbose_name': '计算实例',
'verbose_name_plural': '计算实例',
'ordering': ['account', 'provider_resource_id'],
'permissions': [('export_computeinstance', '可以导出计算实例'), ('retire_computeinstance', '可以退役计算实例')],
},
),
migrations.CreateModel(
name='ComputeInstanceTag',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(max_length=100, verbose_name='键')),
('value', models.CharField(blank=True, max_length=255, verbose_name='值')),
('source', models.CharField(choices=[('provider', '云厂商'), ('manual', '人工维护')], max_length=20, verbose_name='来源')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
('instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='tags', to='cmdb.computeinstance', verbose_name='计算实例')),
],
options={
'verbose_name': '计算实例标签',
'verbose_name_plural': '计算实例标签',
'ordering': ['source', 'key'],
},
),
migrations.CreateModel(
name='SyncRun',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='公开 ID')),
('status', models.CharField(choices=[('pending', '等待中'), ('running', '运行中'), ('succeeded', '成功'), ('partial', '部分成功'), ('failed', '失败')], default='pending', max_length=20, verbose_name='状态')),
('trigger', models.CharField(choices=[('manual', '页面手动触发'), ('command', '管理命令触发'), ('api', 'API 触发')], max_length=20, verbose_name='触发方式')),
('started_at', models.DateTimeField(blank=True, null=True, verbose_name='开始时间')),
('finished_at', models.DateTimeField(blank=True, null=True, verbose_name='结束时间')),
('discovered_count', models.PositiveIntegerField(default=0, verbose_name='发现数量')),
('created_count', models.PositiveIntegerField(default=0, verbose_name='新建数量')),
('updated_count', models.PositiveIntegerField(default=0, verbose_name='更新数量')),
('unchanged_count', models.PositiveIntegerField(default=0, verbose_name='未变化数量')),
('missing_count', models.PositiveIntegerField(default=0, verbose_name='标记缺失数量')),
('restored_count', models.PositiveIntegerField(default=0, verbose_name='恢复数量')),
('retired_count', models.PositiveIntegerField(default=0, verbose_name='退役数量')),
('error_code', models.CharField(blank=True, max_length=100, verbose_name='错误代码')),
('error_message', models.TextField(blank=True, verbose_name='错误信息')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='sync_runs', to='cmdb.cloudaccount', verbose_name='云账号')),
('requested_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cmdb_sync_runs', to=settings.AUTH_USER_MODEL, verbose_name='请求用户')),
],
options={
'verbose_name': '同步执行',
'verbose_name_plural': '同步执行',
'ordering': ['-created_at'],
},
),
migrations.CreateModel(
name='ComputeInstanceChange',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('created', '创建'), ('updated', '更新'), ('marked_missing', '标记缺失'), ('restored', '恢复'), ('retired', '退役')], max_length=30, verbose_name='动作')),
('changed_fields', models.JSONField(blank=True, default=list, verbose_name='变化字段')),
('before_data', models.JSONField(blank=True, default=dict, verbose_name='变化前')),
('after_data', models.JSONField(blank=True, default=dict, verbose_name='变化后')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
('instance', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='changes', to='cmdb.computeinstance', verbose_name='计算实例')),
('sync_run', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='changes', to='cmdb.syncrun', verbose_name='同步执行')),
],
options={
'verbose_name': '计算实例变化',
'verbose_name_plural': '计算实例变化',
'ordering': ['-created_at'],
},
),
migrations.AddConstraint(
model_name='cloudaccount',
constraint=models.UniqueConstraint(fields=('provider', 'account_key'), name='cmdb_unique_provider_account_key'),
),
migrations.AddConstraint(
model_name='cloudregion',
constraint=models.UniqueConstraint(fields=('account', 'provider_resource_id'), name='cmdb_unique_account_region_id'),
),
migrations.AddConstraint(
model_name='cloudavailabilityzone',
constraint=models.UniqueConstraint(fields=('account', 'provider_resource_id'), name='cmdb_unique_account_zone_id'),
),
migrations.AddIndex(
model_name='computeinstance',
index=models.Index(fields=['lifecycle_state', 'normalized_status'], name='cmdb_inst_life_status_idx'),
),
migrations.AddIndex(
model_name='computeinstance',
index=models.Index(fields=['name'], name='cmdb_inst_name_idx'),
),
migrations.AddConstraint(
model_name='computeinstance',
constraint=models.UniqueConstraint(fields=('account', 'provider_resource_id'), name='cmdb_unique_account_instance_id'),
),
migrations.AddConstraint(
model_name='computeinstancetag',
constraint=models.UniqueConstraint(fields=('instance', 'source', 'key'), name='cmdb_unique_instance_tag_source_key'),
),
]
1.9.25 devopsX/cmdb/migrations/0002_alter_cloudaccount_credential_profile_and_more.py
清单第 25 项;ZIP 内路径:cmdb/migrations/0002_alter_cloudaccount_credential_profile_and_more.py。
# Generated by Django 5.2.17 on 2026-09-16 11:35
import cmdb.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('cmdb', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='cloudaccount',
name='credential_profile',
field=models.CharField(blank=True, max_length=100, validators=[cmdb.models.validate_credential_profile], verbose_name='凭据环境变量前缀'),
),
migrations.AlterField(
model_name='cloudaccount',
name='region_allowlist',
field=models.JSONField(blank=True, default=list, validators=[cmdb.models.validate_region_allowlist], verbose_name='地域白名单'),
),
]
1.9.26 devopsX/cmdb/migrations/__init__.py
清单第 26 项;ZIP 内路径:cmdb/migrations/__init__.py。
这是有意保留的空文件;空内容本身就是完整内容,用于声明 Python 包或迁移包。
1.9.27 devopsX/cmdb/models.py
清单第 27 项;ZIP 内路径:cmdb/models.py。
import re
import uuid
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils import timezone
def validate_credential_profile(value):
if value and not re.fullmatch(r"[A-Z][A-Z0-9_]*", value):
raise ValidationError("凭据前缀只能使用大写字母、数字和下划线。")
def validate_region_allowlist(value):
if not isinstance(value, list):
raise ValidationError("地域白名单必须是地域 ID 列表。")
normalized = []
for item in value:
if not isinstance(item, str) or not item.strip():
raise ValidationError("地域白名单只能包含非空字符串。")
region_id = item.strip()
if len(region_id) > 100:
raise ValidationError("地域 ID 不能超过 100 个字符。")
if region_id in normalized:
raise ValidationError("地域白名单不能包含重复地域 ID。")
normalized.append(region_id)
if normalized != value:
raise ValidationError("地域 ID 首尾不能包含空格。")
class CloudProvider(models.Model):
code = models.SlugField("代码", max_length=32, unique=True)
name = models.CharField("名称", max_length=100)
is_active = models.BooleanField("启用", default=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
ordering = ["code"]
verbose_name = "云厂商"
verbose_name_plural = "云厂商"
def __str__(self):
return "%s - %s" % (self.code, self.name)
class CloudAccount(models.Model):
provider = models.ForeignKey(
CloudProvider,
on_delete=models.PROTECT,
related_name="accounts",
verbose_name="云厂商",
)
account_key = models.SlugField("账号键", max_length=100)
name = models.CharField("显示名称", max_length=100)
credential_profile = models.CharField(
"凭据环境变量前缀",
max_length=100,
blank=True,
validators=[validate_credential_profile],
)
region_allowlist = models.JSONField(
"地域白名单",
default=list,
blank=True,
validators=[validate_region_allowlist],
)
is_active = models.BooleanField("启用", default=True)
sync_enabled = models.BooleanField("允许同步", default=True)
last_successful_sync_at = models.DateTimeField(
"最后成功同步时间",
null=True,
blank=True,
)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
ordering = ["provider__code", "account_key"]
constraints = [
models.UniqueConstraint(
fields=["provider", "account_key"],
name="cmdb_unique_provider_account_key",
)
]
permissions = [
("sync_cloudaccount", "可以同步云账号"),
("import_cloudaccount", "可以导入云账号"),
]
verbose_name = "云账号"
verbose_name_plural = "云账号"
def __str__(self):
return "%s - %s" % (self.provider.code, self.name)
class CloudRegion(models.Model):
account = models.ForeignKey(
CloudAccount,
on_delete=models.PROTECT,
related_name="regions",
verbose_name="云账号",
)
provider_resource_id = models.CharField("厂商地域 ID", max_length=100)
name = models.CharField("名称", max_length=100)
endpoint = models.CharField("服务端点", max_length=255, blank=True)
is_active = models.BooleanField("当前存在", default=True)
first_seen_at = models.DateTimeField("首次发现时间", default=timezone.now)
last_seen_at = models.DateTimeField("最后发现时间", default=timezone.now)
class Meta:
ordering = ["account", "provider_resource_id"]
constraints = [
models.UniqueConstraint(
fields=["account", "provider_resource_id"],
name="cmdb_unique_account_region_id",
)
]
verbose_name = "云地域"
verbose_name_plural = "云地域"
def __str__(self):
return "%s - %s" % (self.account.account_key, self.name)
class CloudAvailabilityZone(models.Model):
account = models.ForeignKey(
CloudAccount,
on_delete=models.PROTECT,
related_name="availability_zones",
verbose_name="云账号",
)
region = models.ForeignKey(
CloudRegion,
on_delete=models.PROTECT,
related_name="availability_zones",
verbose_name="云地域",
)
provider_resource_id = models.CharField("厂商可用区 ID", max_length=100)
name = models.CharField("名称", max_length=100)
is_active = models.BooleanField("当前存在", default=True)
first_seen_at = models.DateTimeField("首次发现时间", default=timezone.now)
last_seen_at = models.DateTimeField("最后发现时间", default=timezone.now)
class Meta:
ordering = ["account", "region", "provider_resource_id"]
constraints = [
models.UniqueConstraint(
fields=["account", "provider_resource_id"],
name="cmdb_unique_account_zone_id",
)
]
verbose_name = "可用区"
verbose_name_plural = "可用区"
def clean(self):
super().clean()
if self.region_id and self.account_id:
try:
region = self.region
except CloudRegion.DoesNotExist:
region = None
if region is not None and region.account_id != self.account_id:
raise ValidationError("可用区账号必须与地域账号一致。")
def __str__(self):
return "%s - %s" % (self.region.provider_resource_id, self.name)
class ComputeInstance(models.Model):
class NormalizedStatus(models.TextChoices):
RUNNING = "running", "运行中"
STOPPED = "stopped", "已停止"
STARTING = "starting", "启动中"
STOPPING = "stopping", "停止中"
UNKNOWN = "unknown", "未知"
class LifecycleState(models.TextChoices):
PRESENT = "present", "当前存在"
MISSING = "missing", "本次未发现"
RETIRED = "retired", "已退役"
account = models.ForeignKey(
CloudAccount,
on_delete=models.PROTECT,
related_name="compute_instances",
verbose_name="云账号",
)
region = models.ForeignKey(
CloudRegion,
on_delete=models.PROTECT,
related_name="compute_instances",
verbose_name="云地域",
)
availability_zone = models.ForeignKey(
CloudAvailabilityZone,
on_delete=models.PROTECT,
related_name="compute_instances",
verbose_name="可用区",
null=True,
blank=True,
)
provider_resource_id = models.CharField("厂商实例 ID", max_length=100)
name = models.CharField("实例名称", max_length=255, blank=True)
instance_type = models.CharField("实例规格", max_length=100, blank=True)
vcpu = models.PositiveIntegerField("vCPU", default=0)
memory_mb = models.PositiveIntegerField("内存 MiB", default=0)
os_name = models.CharField("操作系统", max_length=255, blank=True)
provider_status = models.CharField("厂商原始状态", max_length=100, blank=True)
normalized_status = models.CharField(
"标准状态",
max_length=20,
choices=NormalizedStatus.choices,
default=NormalizedStatus.UNKNOWN,
)
lifecycle_state = models.CharField(
"生命周期",
max_length=20,
choices=LifecycleState.choices,
default=LifecycleState.PRESENT,
)
private_ips = models.JSONField("私网 IP", default=list, blank=True)
public_ips = models.JSONField("公网 IP", default=list, blank=True)
cloud_created_at = models.DateTimeField("云上创建时间", null=True, blank=True)
first_seen_at = models.DateTimeField("首次发现时间", default=timezone.now)
last_seen_at = models.DateTimeField("最后发现时间", default=timezone.now)
missing_since = models.DateTimeField("开始缺失时间", null=True, blank=True)
retired_at = models.DateTimeField("退役时间", null=True, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
ordering = ["account", "provider_resource_id"]
constraints = [
models.UniqueConstraint(
fields=["account", "provider_resource_id"],
name="cmdb_unique_account_instance_id",
)
]
indexes = [
models.Index(
fields=["lifecycle_state", "normalized_status"],
name="cmdb_inst_life_status_idx",
),
models.Index(fields=["name"], name="cmdb_inst_name_idx"),
]
permissions = [
("export_computeinstance", "可以导出计算实例"),
("retire_computeinstance", "可以退役计算实例"),
]
verbose_name = "计算实例"
verbose_name_plural = "计算实例"
def clean(self):
super().clean()
region = None
if self.region_id and self.account_id:
try:
region = self.region
except CloudRegion.DoesNotExist:
pass
if region is not None and region.account_id != self.account_id:
raise ValidationError("实例账号必须与地域账号一致。")
if self.availability_zone_id:
try:
availability_zone = self.availability_zone
except CloudAvailabilityZone.DoesNotExist:
availability_zone = None
if availability_zone is not None:
if availability_zone.account_id != self.account_id:
raise ValidationError("实例账号必须与可用区账号一致。")
if availability_zone.region_id != self.region_id:
raise ValidationError("实例地域必须与可用区所属地域一致。")
def __str__(self):
return "%s - %s" % (self.provider_resource_id, self.name or "未命名")
class ComputeInstanceTag(models.Model):
class Source(models.TextChoices):
PROVIDER = "provider", "云厂商"
MANUAL = "manual", "人工维护"
instance = models.ForeignKey(
ComputeInstance,
on_delete=models.CASCADE,
related_name="tags",
verbose_name="计算实例",
)
key = models.CharField("键", max_length=100)
value = models.CharField("值", max_length=255, blank=True)
source = models.CharField("来源", max_length=20, choices=Source.choices)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
updated_at = models.DateTimeField("更新时间", auto_now=True)
class Meta:
ordering = ["source", "key"]
constraints = [
models.UniqueConstraint(
fields=["instance", "source", "key"],
name="cmdb_unique_instance_tag_source_key",
)
]
verbose_name = "计算实例标签"
verbose_name_plural = "计算实例标签"
def __str__(self):
return "%s=%s" % (self.key, self.value)
class SyncRun(models.Model):
class Status(models.TextChoices):
PENDING = "pending", "等待中"
RUNNING = "running", "运行中"
SUCCEEDED = "succeeded", "成功"
PARTIAL = "partial", "部分成功"
FAILED = "failed", "失败"
class Trigger(models.TextChoices):
MANUAL = "manual", "页面手动触发"
COMMAND = "command", "管理命令触发"
API = "api", "API 触发"
public_id = models.UUIDField("公开 ID", default=uuid.uuid4, unique=True, editable=False)
account = models.ForeignKey(
CloudAccount,
on_delete=models.PROTECT,
related_name="sync_runs",
verbose_name="云账号",
)
status = models.CharField(
"状态",
max_length=20,
choices=Status.choices,
default=Status.PENDING,
)
trigger = models.CharField("触发方式", max_length=20, choices=Trigger.choices)
requested_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="cmdb_sync_runs",
verbose_name="请求用户",
null=True,
blank=True,
)
started_at = models.DateTimeField("开始时间", null=True, blank=True)
finished_at = models.DateTimeField("结束时间", null=True, blank=True)
discovered_count = models.PositiveIntegerField("发现数量", default=0)
created_count = models.PositiveIntegerField("新建数量", default=0)
updated_count = models.PositiveIntegerField("更新数量", default=0)
unchanged_count = models.PositiveIntegerField("未变化数量", default=0)
missing_count = models.PositiveIntegerField("标记缺失数量", default=0)
restored_count = models.PositiveIntegerField("恢复数量", default=0)
retired_count = models.PositiveIntegerField("退役数量", default=0)
error_code = models.CharField("错误代码", max_length=100, blank=True)
error_message = models.TextField("错误信息", blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
class Meta:
ordering = ["-created_at"]
verbose_name = "同步执行"
verbose_name_plural = "同步执行"
def __str__(self):
return "%s - %s" % (self.account.account_key, self.public_id)
class ComputeInstanceChange(models.Model):
class Action(models.TextChoices):
CREATED = "created", "创建"
UPDATED = "updated", "更新"
MARKED_MISSING = "marked_missing", "标记缺失"
RESTORED = "restored", "恢复"
RETIRED = "retired", "退役"
sync_run = models.ForeignKey(
SyncRun,
on_delete=models.PROTECT,
related_name="changes",
verbose_name="同步执行",
null=True,
blank=True,
)
instance = models.ForeignKey(
ComputeInstance,
on_delete=models.PROTECT,
related_name="changes",
verbose_name="计算实例",
)
action = models.CharField("动作", max_length=30, choices=Action.choices)
changed_fields = models.JSONField("变化字段", default=list, blank=True)
before_data = models.JSONField("变化前", default=dict, blank=True)
after_data = models.JSONField("变化后", default=dict, blank=True)
created_at = models.DateTimeField("创建时间", auto_now_add=True)
class Meta:
ordering = ["-created_at"]
verbose_name = "计算实例变化"
verbose_name_plural = "计算实例变化"
def __str__(self):
return "%s - %s" % (self.instance.provider_resource_id, self.action)
1.9.28 devopsX/cmdb/providers/__init__.py
清单第 28 项;ZIP 内路径:cmdb/providers/__init__.py。
这是有意保留的空文件;空内容本身就是完整内容,用于声明 Python 包或迁移包。
1.9.29 devopsX/cmdb/providers/aliyun.py
清单第 29 项;ZIP 内路径:cmdb/providers/aliyun.py。
import logging
import os
from datetime import datetime
from .base import (
CloudProviderAdapter,
DiscoveredAvailabilityZone,
DiscoveredComputeInstance,
DiscoveredRegion,
DiscoveredTag,
DiscoveryScope,
DiscoverySnapshot,
ProviderError,
)
logger = logging.getLogger(__name__)
_MISSING = object()
STATUS_MAP = {
"Pending": "starting",
"Running": "running",
"Stopped": "stopped",
"Starting": "starting",
"Stopping": "stopping",
}
def _value(obj, name, default=None):
if isinstance(obj, dict):
return obj.get(name, default)
return getattr(obj, name, default)
def _nested_list(obj, *names):
current = obj
for name in names:
current = _value(current, name)
if current is None:
return []
return list(current or [])
def _invalid_response():
return ProviderError(
"ALIYUN_INVALID_RESPONSE",
"阿里云接口返回了不完整的响应结构,当前快照不完整。",
)
def _required_nested_list(obj, *names):
current = obj
for name in names:
current = _value(current, name, _MISSING)
if current is _MISSING or current is None:
raise _invalid_response()
if not isinstance(current, (list, tuple)):
raise _invalid_response()
return list(current)
def _required_int(obj, name):
value = _value(obj, name, _MISSING)
if not isinstance(value, int) or isinstance(value, bool):
raise _invalid_response()
return value
def _parse_cloud_time(value):
if not value:
return None
if not isinstance(value, str):
raise ProviderError(
"ALIYUN_INVALID_TIME",
"阿里云实例返回了无效创建时间,当前快照不完整。",
)
normalized = value.replace("Z", "+00:00")
try:
return datetime.fromisoformat(normalized)
except ValueError as exc:
raise ProviderError(
"ALIYUN_INVALID_TIME",
"阿里云实例返回了无效创建时间,当前快照不完整。",
) from exc
def _normalize_status(value):
return STATUS_MAP.get(value, "unknown")
def map_region(raw_region):
return DiscoveredRegion(
provider_resource_id=_value(raw_region, "region_id", ""),
name=_value(raw_region, "local_name", "")
or _value(raw_region, "region_id", ""),
endpoint=_value(raw_region, "region_endpoint", ""),
)
def map_zone(raw_zone, region_id):
zone_id = _value(raw_zone, "zone_id", "")
return DiscoveredAvailabilityZone(
provider_resource_id=zone_id,
region_provider_resource_id=region_id,
name=_value(raw_zone, "local_name", "") or zone_id,
)
def map_instance(raw_instance, region_id):
provider_status = _value(raw_instance, "status", "")
inner_ips = _nested_list(raw_instance, "inner_ip_address", "ip_address")
vpc_ips = _nested_list(
raw_instance,
"vpc_attributes",
"private_ip_address",
"ip_address",
)
private_ips = list(dict.fromkeys(inner_ips + vpc_ips))
public_ips = _nested_list(raw_instance, "public_ip_address", "ip_address")
eip_address = _value(
_value(raw_instance, "eip_address", {}),
"ip_address",
"",
)
if isinstance(eip_address, str):
eip_ips = [eip_address] if eip_address else []
else:
eip_ips = list(eip_address or [])
public_ips = list(dict.fromkeys(public_ips + eip_ips))
tags = tuple(
DiscoveredTag(
key=_value(raw_tag, "tag_key", ""),
value=_value(raw_tag, "tag_value", ""),
)
for raw_tag in _nested_list(raw_instance, "tags", "tag")
if _value(raw_tag, "tag_key", "")
)
return DiscoveredComputeInstance(
provider_resource_id=_value(raw_instance, "instance_id", ""),
region_provider_resource_id=region_id,
availability_zone_provider_resource_id=(
_value(raw_instance, "zone_id", "") or None
),
name=_value(raw_instance, "instance_name", ""),
instance_type=_value(raw_instance, "instance_type", ""),
vcpu=int(_value(raw_instance, "cpu", 0) or 0),
memory_mb=int(_value(raw_instance, "memory", 0) or 0),
os_name=_value(raw_instance, "osname", ""),
provider_status=provider_status,
normalized_status=_normalize_status(provider_status),
private_ips=tuple(private_ips),
public_ips=tuple(public_ips),
cloud_created_at=_parse_cloud_time(_value(raw_instance, "creation_time", "")),
tags=tags,
)
def _safe_provider_error(exc):
code = str(
getattr(exc, "code", "")
or getattr(exc, "error_code", "")
or "ALIYUN_REQUEST_FAILED"
)
status_code = getattr(exc, "status_code", None)
normalized = code.lower()
if status_code == 403 or "forbidden" in normalized or "accessdenied" in normalized:
provider_error = ProviderError(
"ALIYUN_ACCESS_DENIED",
"阿里云拒绝了只读资产请求,请检查 RAM 权限。",
)
elif "throttl" in normalized or status_code == 429:
provider_error = ProviderError(
"ALIYUN_THROTTLED",
"阿里云接口触发限流,请稍后重试。",
)
elif isinstance(exc, (ConnectionError, TimeoutError)):
provider_error = ProviderError(
"ALIYUN_NETWORK_ERROR",
"连接阿里云接口失败,请检查网络。",
)
else:
provider_error = ProviderError(
"ALIYUN_REQUEST_FAILED",
"阿里云资产发现失败,请查看服务日志。",
)
logger.warning(
"Aliyun SDK request failed mapped_code=%s exception_type=%s status_code=%s.",
provider_error.code,
type(exc).__name__,
status_code,
)
return provider_error
class AliyunSdkClient:
def __init__(self, access_key_id, access_key_secret, security_token=""):
try:
from alibabacloud_ecs20140526.client import Client as EcsClient
from alibabacloud_ecs20140526 import models as ecs_models
from alibabacloud_tea_openapi import models as open_api_models
from alibabacloud_tea_util import models as util_models
except ImportError as exc:
raise ProviderError(
"ALIYUN_SDK_NOT_INSTALLED",
"未安装阿里云 ECS SDK,请先安装 requirements-aliyun.txt。",
) from exc
self.ecs_client_class = EcsClient
self.ecs_models = ecs_models
self.open_api_models = open_api_models
self.runtime = util_models.RuntimeOptions()
self.access_key_id = access_key_id
self.access_key_secret = access_key_secret
self.security_token = security_token
def _client(self, endpoint):
config = self.open_api_models.Config(
access_key_id=self.access_key_id,
access_key_secret=self.access_key_secret,
security_token=self.security_token or None,
)
config.endpoint = endpoint
return self.ecs_client_class(config)
def describe_regions(self):
client = self._client("ecs.cn-hangzhou.aliyuncs.com")
request = self.ecs_models.DescribeRegionsRequest(resource_type="instance")
response = client.describe_regions_with_options(request, self.runtime)
return _required_nested_list(
_value(response, "body"),
"regions",
"region",
)
def describe_zones(self, region_id):
client = self._client("ecs.%s.aliyuncs.com" % region_id)
request = self.ecs_models.DescribeZonesRequest(region_id=region_id)
response = client.describe_zones_with_options(request, self.runtime)
return _required_nested_list(
_value(response, "body"),
"zones",
"zone",
)
def describe_instances_page(self, region_id, page_number, page_size):
client = self._client("ecs.%s.aliyuncs.com" % region_id)
request = self.ecs_models.DescribeInstancesRequest(
region_id=region_id,
page_number=page_number,
page_size=page_size,
)
response = client.describe_instances_with_options(request, self.runtime)
body = _value(response, "body")
return (
_required_nested_list(body, "instances", "instance"),
_required_int(body, "total_count"),
)
class AliyunEcsProvider(CloudProviderAdapter):
page_size = 100
def __init__(self, client_factory=None):
self.client_factory = client_factory or AliyunSdkClient
def _client_for_account(self, account):
profile = account.credential_profile.strip()
if not profile:
raise ProviderError(
"MISSING_CREDENTIAL_PROFILE",
"阿里云账号未配置凭据环境变量前缀。",
)
access_key_id = os.environ.get("%s_ACCESS_KEY_ID" % profile, "")
access_key_secret = os.environ.get("%s_ACCESS_KEY_SECRET" % profile, "")
security_token = os.environ.get("%s_SECURITY_TOKEN" % profile, "")
if not access_key_id or not access_key_secret:
raise ProviderError(
"ALIYUN_CREDENTIALS_NOT_FOUND",
"未找到该凭据前缀对应的阿里云环境变量。",
)
return self.client_factory(access_key_id, access_key_secret, security_token)
def discover(self, account):
client = self._client_for_account(account)
regions = []
zones = []
instances = []
try:
raw_regions = client.describe_regions()
if not raw_regions:
raise _invalid_response()
except Exception as exc:
if isinstance(exc, ProviderError):
logger.warning("Aliyun discovery failed code=%s.", exc.code)
raise
raise _safe_provider_error(exc) from exc
allowlist = set(account.region_allowlist or [])
discovered_region_ids = {
_value(raw_region, "region_id", "") for raw_region in raw_regions
}
unknown_regions = allowlist - discovered_region_ids
if unknown_regions:
raise ProviderError(
"ALIYUN_UNKNOWN_REGION",
"地域白名单包含阿里云未返回的地域:%s"
% ", ".join(sorted(unknown_regions)),
)
selected_raw_regions = [
raw_region
for raw_region in raw_regions
if not allowlist or _value(raw_region, "region_id", "") in allowlist
]
selected_region_ids = [
_value(raw_region, "region_id", "") for raw_region in selected_raw_regions
]
for raw_region in selected_raw_regions:
region = map_region(raw_region)
regions.append(region)
try:
raw_zones = client.describe_zones(region.provider_resource_id)
if not raw_zones:
raise _invalid_response()
zones.extend(
map_zone(raw_zone, region.provider_resource_id)
for raw_zone in raw_zones
)
page_number = 1
region_instance_count = 0
expected_total = None
while True:
raw_instances, total_count = client.describe_instances_page(
region.provider_resource_id,
page_number,
self.page_size,
)
if total_count < 0:
raise ProviderError(
"ALIYUN_INCONSISTENT_PAGINATION",
"阿里云实例分页返回了无效总数,当前快照不完整。",
)
if expected_total is None:
expected_total = total_count
elif total_count != expected_total:
raise ProviderError(
"ALIYUN_INCONSISTENT_PAGINATION",
"阿里云实例分页总数在请求期间发生变化,当前快照不完整。",
)
if raw_instances and expected_total == 0:
raise ProviderError(
"ALIYUN_INCONSISTENT_PAGINATION",
"阿里云实例分页返回的数据与总数不一致,当前快照不完整。",
)
instances.extend(
map_instance(raw_instance, region.provider_resource_id)
for raw_instance in raw_instances
)
region_instance_count += len(raw_instances)
if region_instance_count > expected_total:
raise ProviderError(
"ALIYUN_INCONSISTENT_PAGINATION",
"阿里云实例分页数量超过总数,当前快照不完整。",
)
if region_instance_count == expected_total:
break
if not raw_instances:
raise ProviderError(
"ALIYUN_INCOMPLETE_PAGINATION",
"阿里云实例分页提前结束,当前快照不完整。",
)
page_number += 1
except Exception as exc:
provider_error = exc if isinstance(exc, ProviderError) else _safe_provider_error(exc)
logger.warning(
"Aliyun discovery incomplete code=%s.",
provider_error.code,
)
if regions or zones or instances:
return DiscoverySnapshot(
regions=tuple(regions),
availability_zones=tuple(zones),
instances=tuple(instances),
complete=False,
error_code=provider_error.code,
error_message=provider_error.message,
scope=DiscoveryScope(
mode="allowlist" if allowlist else "all",
region_ids=(
tuple(selected_region_ids) if allowlist else ()
),
),
)
if isinstance(exc, ProviderError):
raise
raise provider_error from exc
return DiscoverySnapshot(
regions=tuple(regions),
availability_zones=tuple(zones),
instances=tuple(instances),
complete=True,
scope=DiscoveryScope(
mode="allowlist" if allowlist else "all",
region_ids=tuple(selected_region_ids) if allowlist else (),
),
)
1.9.30 devopsX/cmdb/providers/base.py
清单第 30 项;ZIP 内路径:cmdb/providers/base.py。
from dataclasses import dataclass, field
from datetime import datetime
from typing import Protocol
@dataclass(frozen=True)
class DiscoveredRegion:
provider_resource_id: str
name: str
endpoint: str = ""
@dataclass(frozen=True)
class DiscoveredAvailabilityZone:
provider_resource_id: str
region_provider_resource_id: str
name: str
@dataclass(frozen=True)
class DiscoveredTag:
key: str
value: str
@dataclass(frozen=True)
class DiscoveredComputeInstance:
provider_resource_id: str
region_provider_resource_id: str
availability_zone_provider_resource_id: str | None = None
name: str = ""
instance_type: str = ""
vcpu: int = 0
memory_mb: int = 0
os_name: str = ""
provider_status: str = ""
normalized_status: str = "unknown"
private_ips: tuple[str, ...] = ()
public_ips: tuple[str, ...] = ()
cloud_created_at: datetime | None = None
tags: tuple[DiscoveredTag, ...] = ()
@dataclass(frozen=True)
class DiscoveryScope:
mode: str = "all"
region_ids: tuple[str, ...] = ()
@dataclass(frozen=True)
class DiscoverySnapshot:
regions: tuple[DiscoveredRegion, ...] = ()
availability_zones: tuple[DiscoveredAvailabilityZone, ...] = ()
instances: tuple[DiscoveredComputeInstance, ...] = ()
complete: bool = True
error_code: str = ""
error_message: str = ""
scope: DiscoveryScope = field(default_factory=DiscoveryScope)
class ProviderError(Exception):
def __init__(
self,
code: str,
message: str,
snapshot: DiscoverySnapshot | None = None,
):
self.code = code
self.message = message
self.snapshot = snapshot
super().__init__(message)
class CloudProviderAdapter(Protocol):
def discover(self, account) -> DiscoverySnapshot:
"""Return a normalized snapshot without returning ORM objects."""
1.9.31 devopsX/cmdb/providers/fake.py
清单第 31 项;ZIP 内路径:cmdb/providers/fake.py。
from dataclasses import replace
from datetime import datetime, timezone
from .base import (
CloudProviderAdapter,
DiscoveredAvailabilityZone,
DiscoveredComputeInstance,
DiscoveredRegion,
DiscoveredTag,
DiscoveryScope,
DiscoverySnapshot,
ProviderError,
)
class FakeProvider(CloudProviderAdapter):
scenarios = {}
def __init__(self, scenario="default"):
self.scenario = scenario
def discover(self, account):
if self.scenario not in self.scenarios:
raise ProviderError(
"UNKNOWN_FAKE_SCENARIO",
"未知 Fake Provider 场景:%s" % self.scenario,
)
scenario = self.scenarios[self.scenario]
if isinstance(scenario, Exception):
raise scenario
if callable(scenario):
scenario = scenario(account)
allowlist = tuple(account.region_allowlist)
if not allowlist:
return scenario
default_snapshot = self.scenarios["default"]
known_region_ids = {
region.provider_resource_id for region in default_snapshot.regions
}
unknown_region_ids = set(allowlist) - known_region_ids
if unknown_region_ids:
raise ProviderError(
"UNKNOWN_REGION_ALLOWLIST",
"Fake Provider 地域白名单包含未知地域:%s"
% ", ".join(sorted(unknown_region_ids)),
)
source_regions = scenario.regions
source_zones = scenario.availability_zones
if self.scenario == "empty":
source_regions = default_snapshot.regions
source_zones = default_snapshot.availability_zones
return replace(
scenario,
regions=tuple(
region
for region in source_regions
if region.provider_resource_id in allowlist
),
availability_zones=tuple(
zone
for zone in source_zones
if zone.region_provider_resource_id in allowlist
),
instances=tuple(
instance
for instance in scenario.instances
if instance.region_provider_resource_id in allowlist
),
scope=DiscoveryScope(mode="allowlist", region_ids=allowlist),
)
FakeProvider.scenarios = {
"default": DiscoverySnapshot(
regions=(
DiscoveredRegion("cn-hangzhou", "杭州", "ecs.cn-hangzhou.aliyuncs.com"),
DiscoveredRegion("cn-shanghai", "上海", "ecs.cn-shanghai.aliyuncs.com"),
),
availability_zones=(
DiscoveredAvailabilityZone("cn-hangzhou-i", "cn-hangzhou", "杭州可用区 I"),
DiscoveredAvailabilityZone("cn-shanghai-a", "cn-shanghai", "上海可用区 A"),
),
instances=(
DiscoveredComputeInstance(
provider_resource_id="i-fake-hz-001",
region_provider_resource_id="cn-hangzhou",
availability_zone_provider_resource_id="cn-hangzhou-i",
name="订单服务-演示",
instance_type="ecs.g7.large",
vcpu=2,
memory_mb=8192,
os_name="Alibaba Cloud Linux 3",
provider_status="Running",
normalized_status="running",
private_ips=("10.10.1.10",),
public_ips=("203.0.113.10",),
cloud_created_at=datetime(2026, 1, 10, tzinfo=timezone.utc),
tags=(
DiscoveredTag("department", "platform"),
DiscoveredTag("environment", "demo"),
),
),
DiscoveredComputeInstance(
provider_resource_id="i-fake-sh-001",
region_provider_resource_id="cn-shanghai",
availability_zone_provider_resource_id="cn-shanghai-a",
name="构建服务-演示",
instance_type="ecs.c7.large",
vcpu=2,
memory_mb=8192,
os_name="Ubuntu 22.04",
provider_status="Stopped",
normalized_status="stopped",
private_ips=("10.20.1.10",),
public_ips=(),
cloud_created_at=datetime(2026, 1, 11, tzinfo=timezone.utc),
tags=(DiscoveredTag("department", "devops"),),
),
),
),
"empty": DiscoverySnapshot(),
"partial": DiscoverySnapshot(
regions=(DiscoveredRegion("cn-hangzhou", "杭州"),),
instances=(),
complete=False,
error_code="PAGINATION_INCOMPLETE",
error_message="演示第二页发现失败,不能据此标记已有资产缺失。",
),
"failed": ProviderError("PROVIDER_UNAVAILABLE", "演示 Provider 暂时不可用。"),
}
1.9.32 devopsX/cmdb/services/__init__.py
清单第 32 项;ZIP 内路径:cmdb/services/__init__.py。
这是有意保留的空文件;空内容本身就是完整内容,用于声明 Python 包或迁移包。
1.9.33 devopsX/cmdb/services/csv_io.py
清单第 33 项;ZIP 内路径:cmdb/services/csv_io.py。
import csv
import io
from django.core import signing
from django.core.exceptions import ValidationError
from django.db import connection, transaction
from django.db.models import F
from cmdb.models import CloudAccount, CloudProvider
REQUIRED_HEADERS = {"provider_code", "account_key", "name"}
OPTIONAL_HEADERS = {
"credential_profile",
"region_allowlist",
"is_active",
"sync_enabled",
}
ALLOWED_HEADERS = REQUIRED_HEADERS | OPTIONAL_HEADERS
SENSITIVE_HEADERS = {
"access_key",
"access_key_id",
"access_key_secret",
"accesskey",
"accesskeyid",
"accesskeysecret",
"password",
"secret",
"secret_key",
"token",
}
PAYLOAD_SALT = "cmdb-cloud-account-csv-v1"
def _parse_boolean(value, field_name, row_number):
normalized = value.strip().lower()
if normalized in {"", "1", "true", "yes", "y"}:
return True
if normalized in {"0", "false", "no", "n"}:
return False
raise ValueError("第 %s 行的 %s 必须是 true 或 false。" % (row_number, field_name))
def _provided_optional_fields(row):
if "provided_fields" in row:
return set(row["provided_fields"])
return OPTIONAL_HEADERS & set(row)
def _validated_account(row, provider, instance=None):
account = instance or CloudAccount(
provider=provider,
account_key=row["account_key"],
)
account.provider = provider
account.account_key = row["account_key"]
account.name = row["name"]
for field_name in _provided_optional_fields(row):
setattr(account, field_name, row[field_name])
try:
account.full_clean(validate_unique=False, validate_constraints=False)
except ValidationError as exc:
raise ValueError(";".join(exc.messages)) from exc
return account
def parse_cloud_account_csv(uploaded_file):
try:
content = uploaded_file.read().decode("utf-8-sig")
except UnicodeDecodeError as exc:
raise ValueError("CSV 必须使用 UTF-8 编码。") from exc
reader = csv.DictReader(io.StringIO(content))
try:
fieldnames = reader.fieldnames
except csv.Error as exc:
raise ValueError("CSV 字段过长或格式无效。") from exc
if not fieldnames:
raise ValueError("CSV 缺少表头。")
normalized_headers = []
for header in fieldnames:
if header is None or not header.strip():
raise ValueError("CSV 表头不能包含空字段名。")
normalized_headers.append(header.strip().lower())
if len(set(normalized_headers)) != len(normalized_headers):
raise ValueError("CSV 表头不能包含重复字段。")
headers = set(normalized_headers)
sensitive = headers & SENSITIVE_HEADERS
if sensitive:
raise ValueError(
"CSV 禁止包含密钥字段:%s。请只填写 credential_profile。"
% ", ".join(sorted(sensitive))
)
unsupported = headers - ALLOWED_HEADERS
if unsupported:
raise ValueError(
"CSV 包含不支持的字段:%s。"
% ", ".join(sorted(unsupported))
)
missing = REQUIRED_HEADERS - headers
if missing:
raise ValueError("CSV 缺少字段:%s。" % ", ".join(sorted(missing)))
providers = {
provider.code: provider for provider in CloudProvider.objects.all()
}
normalized_rows = []
errors = []
seen_identities = set()
try:
raw_rows = list(reader)
except csv.Error as exc:
raise ValueError("CSV 字段过长或格式无效。") from exc
for row_number, raw_row in enumerate(raw_rows, start=2):
if None in raw_row:
errors.append("第 %s 行:字段数量超过 CSV 表头。" % row_number)
continue
row = {
key.strip().lower(): (value or "").strip()
for key, value in raw_row.items()
}
if not any(row.values()):
continue
try:
provider_code = row["provider_code"]
account_key = row["account_key"]
name = row["name"]
if not provider_code or not account_key or not name:
raise ValueError("provider_code、account_key 和 name 不能为空。")
provider = providers.get(provider_code)
if provider is None:
raise ValueError("云厂商不存在:%s。" % provider_code)
identity = (provider_code, account_key)
if identity in seen_identities:
raise ValueError(
"CSV 内重复云账号身份:%s / %s。"
% (provider_code, account_key)
)
region_allowlist = []
seen_region_ids = set()
for item in row.get("region_allowlist", "").split("|"):
region_id = item.strip()
if region_id and region_id not in seen_region_ids:
region_allowlist.append(region_id)
seen_region_ids.add(region_id)
normalized_row = {
"provider_code": provider_code,
"account_key": account_key,
"name": name,
"credential_profile": row.get("credential_profile", ""),
"region_allowlist": region_allowlist,
"is_active": _parse_boolean(
row.get("is_active", "true"),
"is_active",
row_number,
),
"sync_enabled": _parse_boolean(
row.get("sync_enabled", "true"),
"sync_enabled",
row_number,
),
"provided_fields": sorted(headers & OPTIONAL_HEADERS),
}
_validated_account(normalized_row, provider)
normalized_rows.append(normalized_row)
seen_identities.add(identity)
except ValueError as exc:
errors.append("第 %s 行:%s" % (row_number, exc))
if not normalized_rows and not errors:
errors.append("CSV 没有可导入的数据行。")
return normalized_rows, errors
def sign_rows(rows):
return signing.dumps(rows, salt=PAYLOAD_SALT, compress=True)
def load_signed_rows(payload):
try:
return signing.loads(payload, salt=PAYLOAD_SALT, max_age=1800)
except signing.BadSignature as exc:
raise ValueError("预览数据已经失效,请重新上传 CSV。") from exc
@transaction.atomic
def import_cloud_accounts(rows):
provider_codes = {row["provider_code"] for row in rows}
provider_queryset = CloudProvider.objects.filter(
code__in=provider_codes
).order_by("pk")
if connection.vendor == "sqlite":
provider_queryset.update(updated_at=F("updated_at"))
providers = {
provider.code: provider
for provider in provider_queryset.select_for_update()
}
missing_providers = provider_codes - set(providers)
if missing_providers:
raise ValueError(
"云厂商不存在:%s。" % ", ".join(sorted(missing_providers))
)
created_count = 0
updated_count = 0
for row_number, row in enumerate(rows, start=1):
provider = providers[row["provider_code"]]
existing = CloudAccount.objects.filter(
provider=provider,
account_key=row["account_key"],
).first()
try:
validated = _validated_account(row, provider, instance=existing)
except ValueError as exc:
raise ValueError("第 %s 行:%s" % (row_number, exc)) from exc
provided_fields = _provided_optional_fields(row)
defaults = {"name": validated.name}
for field_name in provided_fields:
defaults[field_name] = getattr(validated, field_name)
account, created = CloudAccount.objects.update_or_create(
provider=provider,
account_key=validated.account_key,
defaults=defaults,
)
if created:
created_count += 1
else:
updated_count += 1
return created_count, updated_count
1.9.34 devopsX/cmdb/services/sync.py
清单第 34 项;ZIP 内路径:cmdb/services/sync.py。
import logging
from datetime import timedelta
from django.db import connection, transaction
from django.db.models import F, Q
from django.utils import timezone
from cmdb.models import (
CloudAccount,
CloudAvailabilityZone,
CloudRegion,
ComputeInstance,
ComputeInstanceChange,
ComputeInstanceTag,
SyncRun,
)
from ..providers.base import DiscoverySnapshot, ProviderError
from ..providers.fake import FakeProvider
logger = logging.getLogger(__name__)
MANAGED_FIELDS = (
"region_id",
"availability_zone_id",
"name",
"instance_type",
"vcpu",
"memory_mb",
"os_name",
"provider_status",
"normalized_status",
"private_ips",
"public_ips",
"cloud_created_at",
"provider_tags",
)
SYNC_RUN_STALE_AFTER = timedelta(minutes=30)
def _lock_account(account_id):
if connection.vendor == "sqlite":
CloudAccount.objects.filter(pk=account_id).update(
updated_at=F("updated_at")
)
return (
CloudAccount.objects.select_for_update()
.select_related("provider")
.get(pk=account_id)
)
def _sync_configuration(account):
return (
account.provider_id,
account.provider.code,
account.provider.is_active,
account.is_active,
account.sync_enabled,
account.credential_profile,
tuple(sorted(account.region_allowlist)),
)
def get_provider_adapter(account, scenario="default"):
if account.provider.code == "fake":
return FakeProvider(scenario=scenario)
if account.provider.code == "aliyun":
from ..providers.aliyun import AliyunEcsProvider
return AliyunEcsProvider()
raise ProviderError("UNSUPPORTED_PROVIDER", "暂不支持云厂商:%s" % account.provider.code)
def _instance_state(instance):
return {
"region_id": instance.region_id,
"availability_zone_id": instance.availability_zone_id,
"name": instance.name,
"instance_type": instance.instance_type,
"vcpu": instance.vcpu,
"memory_mb": instance.memory_mb,
"os_name": instance.os_name,
"provider_status": instance.provider_status,
"normalized_status": instance.normalized_status,
"private_ips": instance.private_ips,
"public_ips": instance.public_ips,
"cloud_created_at": instance.cloud_created_at.isoformat()
if instance.cloud_created_at
else None,
"lifecycle_state": instance.lifecycle_state,
"missing_since": (
instance.missing_since.isoformat() if instance.missing_since else None
),
"retired_at": instance.retired_at.isoformat() if instance.retired_at else None,
"provider_tags": {
key: value
for key, value in instance.tags.filter(
source=ComputeInstanceTag.Source.PROVIDER
).order_by("key").values_list("key", "value")
},
}
def _validate_snapshot(account, snapshot):
if snapshot.scope.mode not in {"all", "allowlist"}:
raise ProviderError(
"INVALID_DISCOVERY_SCOPE",
"发现范围模式无效:%s" % snapshot.scope.mode,
)
if snapshot.scope.mode == "allowlist":
if not snapshot.scope.region_ids:
raise ProviderError(
"INVALID_DISCOVERY_SCOPE",
"地域白名单发现范围不能为空。",
)
if len(set(snapshot.scope.region_ids)) != len(snapshot.scope.region_ids):
raise ProviderError(
"INVALID_DISCOVERY_SCOPE",
"地域白名单发现范围包含重复地域 ID。",
)
if any(not region_id for region_id in snapshot.scope.region_ids):
raise ProviderError(
"INVALID_DISCOVERY_SCOPE",
"地域白名单发现范围包含空地域 ID。",
)
configured_allowlist = tuple(account.region_allowlist)
if configured_allowlist:
if snapshot.scope.mode != "allowlist":
raise ProviderError(
"INVALID_DISCOVERY_SCOPE",
"云账号配置了地域白名单,但快照未声明白名单范围。",
)
if set(snapshot.scope.region_ids) != set(configured_allowlist):
raise ProviderError(
"INVALID_DISCOVERY_SCOPE",
"发现快照范围与云账号地域白名单不一致。",
)
elif snapshot.scope.mode != "all":
raise ProviderError(
"INVALID_DISCOVERY_SCOPE",
"未配置地域白名单的云账号只能接受全部地域范围的快照。",
)
region_ids = set()
for region in snapshot.regions:
if not region.provider_resource_id:
raise ProviderError("EMPTY_REGION_ID", "发现了空地域 ID。")
if region.provider_resource_id in region_ids:
raise ProviderError("DUPLICATE_REGION", "发现重复地域 ID:%s" % region.provider_resource_id)
region_ids.add(region.provider_resource_id)
if snapshot.scope.mode == "allowlist":
declared_region_ids = set(snapshot.scope.region_ids)
if not region_ids.issubset(declared_region_ids):
raise ProviderError(
"OUT_OF_SCOPE_DISCOVERY_REGION",
"发现快照包含地域白名单范围外的地域。",
)
if snapshot.complete and declared_region_ids != region_ids:
raise ProviderError(
"INCOMPLETE_DISCOVERY_SCOPE",
"完整快照没有覆盖地域白名单中的全部地域。",
)
zone_ids = set()
zone_regions = {}
for zone in snapshot.availability_zones:
if not zone.provider_resource_id:
raise ProviderError("EMPTY_ZONE_ID", "发现了空可用区 ID。")
if zone.provider_resource_id in zone_ids:
raise ProviderError("DUPLICATE_ZONE", "发现重复可用区 ID:%s" % zone.provider_resource_id)
if zone.region_provider_resource_id not in region_ids:
raise ProviderError(
"UNKNOWN_ZONE_REGION",
"可用区 %s 引用了未知地域 %s"
% (zone.provider_resource_id, zone.region_provider_resource_id),
)
zone_ids.add(zone.provider_resource_id)
zone_regions[zone.provider_resource_id] = zone.region_provider_resource_id
valid_statuses = set(ComputeInstance.NormalizedStatus.values)
instance_ids = set()
for instance in snapshot.instances:
if not instance.provider_resource_id:
raise ProviderError("EMPTY_INSTANCE_ID", "发现了空实例 ID。")
if instance.provider_resource_id in instance_ids:
raise ProviderError(
"DUPLICATE_INSTANCE",
"发现重复实例 ID:%s" % instance.provider_resource_id,
)
if instance.region_provider_resource_id not in region_ids:
raise ProviderError(
"UNKNOWN_INSTANCE_REGION",
"实例 %s 引用了未知地域 %s"
% (instance.provider_resource_id, instance.region_provider_resource_id),
)
zone_id = instance.availability_zone_provider_resource_id
if zone_id and zone_id not in zone_ids:
raise ProviderError(
"UNKNOWN_INSTANCE_ZONE",
"实例 %s 引用了未知可用区 %s"
% (instance.provider_resource_id, zone_id),
)
if zone_id and zone_regions[zone_id] != instance.region_provider_resource_id:
raise ProviderError(
"INSTANCE_ZONE_REGION_MISMATCH",
"实例 %s 的地域与可用区所属地域不一致。"
% instance.provider_resource_id,
)
if instance.normalized_status not in valid_statuses:
raise ProviderError(
"INVALID_NORMALIZED_STATUS",
"实例 %s 的标准状态无效:%s"
% (instance.provider_resource_id, instance.normalized_status),
)
instance_ids.add(instance.provider_resource_id)
def _upsert_region(account, item, now):
region, created = CloudRegion.objects.get_or_create(
account=account,
provider_resource_id=item.provider_resource_id,
defaults={
"name": item.name,
"endpoint": item.endpoint,
"first_seen_at": now,
"last_seen_at": now,
"is_active": True,
},
)
if not created:
region.name = item.name
region.endpoint = item.endpoint
region.is_active = True
region.last_seen_at = now
region.save(update_fields=["name", "endpoint", "is_active", "last_seen_at"])
return region
def _upsert_zone(account, region_map, item, now):
region = region_map[item.region_provider_resource_id]
zone, created = CloudAvailabilityZone.objects.get_or_create(
account=account,
provider_resource_id=item.provider_resource_id,
defaults={
"region": region,
"name": item.name,
"first_seen_at": now,
"last_seen_at": now,
"is_active": True,
},
)
if not created:
zone.region = region
zone.name = item.name
zone.is_active = True
zone.last_seen_at = now
zone.save(update_fields=["region", "name", "is_active", "last_seen_at"])
return zone
def _provider_tag_state(tags):
return {tag.key: tag.value for tag in sorted(tags, key=lambda tag: tag.key)}
def _upsert_provider_tags(instance, tags):
incoming = {tag.key: tag.value for tag in tags}
existing = set(
ComputeInstanceTag.objects.filter(
instance=instance,
source=ComputeInstanceTag.Source.PROVIDER,
).values_list("key", flat=True)
)
for key in existing - set(incoming):
ComputeInstanceTag.objects.filter(
instance=instance,
source=ComputeInstanceTag.Source.PROVIDER,
key=key,
).delete()
for key, value in incoming.items():
ComputeInstanceTag.objects.update_or_create(
instance=instance,
source=ComputeInstanceTag.Source.PROVIDER,
key=key,
defaults={"value": value},
)
def _apply_snapshot(account, sync_run, snapshot):
now = timezone.now()
region_map = {}
for item in snapshot.regions:
region_map[item.provider_resource_id] = _upsert_region(account, item, now)
zone_map = {}
for item in snapshot.availability_zones:
zone_map[item.provider_resource_id] = _upsert_zone(account, region_map, item, now)
discovered_ids = set()
created_count = 0
updated_count = 0
unchanged_count = 0
restored_count = 0
for item in snapshot.instances:
region = region_map[item.region_provider_resource_id]
zone = zone_map.get(item.availability_zone_provider_resource_id)
defaults = {
"region": region,
"availability_zone": zone,
"name": item.name,
"instance_type": item.instance_type,
"vcpu": item.vcpu,
"memory_mb": item.memory_mb,
"os_name": item.os_name,
"provider_status": item.provider_status,
"normalized_status": item.normalized_status,
"lifecycle_state": ComputeInstance.LifecycleState.PRESENT,
"private_ips": list(item.private_ips),
"public_ips": list(item.public_ips),
"cloud_created_at": item.cloud_created_at,
"first_seen_at": now,
"last_seen_at": now,
"missing_since": None,
"retired_at": None,
}
instance = ComputeInstance.objects.filter(
account=account,
provider_resource_id=item.provider_resource_id,
).first()
if instance is None:
instance = ComputeInstance.objects.create(
account=account,
provider_resource_id=item.provider_resource_id,
**defaults,
)
_upsert_provider_tags(instance, item.tags)
ComputeInstanceChange.objects.create(
sync_run=sync_run,
instance=instance,
action=ComputeInstanceChange.Action.CREATED,
changed_fields=list(MANAGED_FIELDS),
before_data={},
after_data=_instance_state(instance),
)
created_count += 1
else:
before = _instance_state(instance)
was_missing = instance.lifecycle_state == ComputeInstance.LifecycleState.MISSING
was_retired = instance.lifecycle_state == ComputeInstance.LifecycleState.RETIRED
after = dict(before)
after.update(
{
"region_id": region.id,
"availability_zone_id": zone.id if zone else None,
"name": item.name,
"instance_type": item.instance_type,
"vcpu": item.vcpu,
"memory_mb": item.memory_mb,
"os_name": item.os_name,
"provider_status": item.provider_status,
"normalized_status": item.normalized_status,
"private_ips": list(item.private_ips),
"public_ips": list(item.public_ips),
"cloud_created_at": item.cloud_created_at.isoformat()
if item.cloud_created_at
else None,
"provider_tags": _provider_tag_state(item.tags),
}
)
changed_fields = [field for field in MANAGED_FIELDS if before[field] != after[field]]
instance.region = region
instance.availability_zone = zone
instance.name = item.name
instance.instance_type = item.instance_type
instance.vcpu = item.vcpu
instance.memory_mb = item.memory_mb
instance.os_name = item.os_name
instance.provider_status = item.provider_status
instance.normalized_status = item.normalized_status
if not was_retired:
instance.lifecycle_state = ComputeInstance.LifecycleState.PRESENT
instance.private_ips = list(item.private_ips)
instance.public_ips = list(item.public_ips)
instance.cloud_created_at = item.cloud_created_at
instance.last_seen_at = now
if not was_retired:
instance.missing_since = None
instance.retired_at = None
instance.save()
_upsert_provider_tags(instance, item.tags)
current_state = _instance_state(instance)
if was_missing:
ComputeInstanceChange.objects.create(
sync_run=sync_run,
instance=instance,
action=ComputeInstanceChange.Action.RESTORED,
changed_fields=changed_fields
+ ["lifecycle_state", "missing_since"],
before_data=before,
after_data=current_state,
)
restored_count += 1
elif changed_fields:
ComputeInstanceChange.objects.create(
sync_run=sync_run,
instance=instance,
action=ComputeInstanceChange.Action.UPDATED,
changed_fields=changed_fields,
before_data=before,
after_data=current_state,
)
updated_count += 1
else:
unchanged_count += 1
discovered_ids.add(item.provider_resource_id)
missing_count = 0
if snapshot.complete:
seen_region_ids = {item.provider_resource_id for item in snapshot.regions}
seen_zone_ids = {
item.provider_resource_id for item in snapshot.availability_zones
}
scope_mode = snapshot.scope.mode
scoped_region_ids = snapshot.scope.region_ids
region_scope = CloudRegion.objects.filter(account=account)
zone_scope = CloudAvailabilityZone.objects.filter(account=account)
active_instances = ComputeInstance.objects.filter(account=account).exclude(
lifecycle_state=ComputeInstance.LifecycleState.RETIRED
)
if scope_mode == "allowlist":
region_scope = region_scope.filter(
provider_resource_id__in=scoped_region_ids
)
zone_scope = zone_scope.filter(
region__provider_resource_id__in=scoped_region_ids
)
active_instances = active_instances.filter(
region__provider_resource_id__in=scoped_region_ids
)
region_scope.exclude(provider_resource_id__in=seen_region_ids).update(
is_active=False
)
zone_scope.exclude(provider_resource_id__in=seen_zone_ids).update(
is_active=False
)
for instance in active_instances:
if instance.provider_resource_id in discovered_ids:
continue
if instance.lifecycle_state == ComputeInstance.LifecycleState.MISSING:
continue
before = _instance_state(instance)
instance.lifecycle_state = ComputeInstance.LifecycleState.MISSING
instance.missing_since = instance.missing_since or now
instance.save(update_fields=["lifecycle_state", "missing_since", "updated_at"])
ComputeInstanceChange.objects.create(
sync_run=sync_run,
instance=instance,
action=ComputeInstanceChange.Action.MARKED_MISSING,
changed_fields=["lifecycle_state", "missing_since"],
before_data=before,
after_data=_instance_state(instance),
)
missing_count += 1
return {
"discovered_count": len(snapshot.instances),
"created_count": created_count,
"updated_count": updated_count,
"unchanged_count": unchanged_count,
"missing_count": missing_count,
"restored_count": restored_count,
"retired_count": 0,
}
def sync_account(
account,
requested_by=None,
trigger=SyncRun.Trigger.MANUAL,
scenario="default",
adapter=None,
):
with transaction.atomic():
locked_account = _lock_account(account.pk)
if not locked_account.provider.is_active:
raise ProviderError("PROVIDER_DISABLED", "云厂商已停用。")
if not locked_account.is_active or not locked_account.sync_enabled:
raise ProviderError("ACCOUNT_DISABLED", "云账号未启用同步。")
now = timezone.now()
stale_before = now - SYNC_RUN_STALE_AFTER
SyncRun.objects.filter(
Q(started_at__lt=stale_before)
| Q(started_at__isnull=True, created_at__lt=stale_before),
account=locked_account,
status__in=[SyncRun.Status.PENDING, SyncRun.Status.RUNNING],
).update(
status=SyncRun.Status.FAILED,
error_code="STALE_SYNC_RUN",
error_message="上一次同步超过 30 分钟未结束,已自动解除阻塞。",
finished_at=now,
)
if SyncRun.objects.filter(
account=locked_account,
status__in=[SyncRun.Status.PENDING, SyncRun.Status.RUNNING],
).exists():
raise ProviderError("SYNC_ALREADY_RUNNING", "该云账号已有同步正在执行。")
discovery_configuration = _sync_configuration(locked_account)
sync_run = SyncRun.objects.create(
account=locked_account,
status=SyncRun.Status.RUNNING,
trigger=trigger,
requested_by=requested_by,
started_at=now,
)
try:
adapter = adapter or get_provider_adapter(locked_account, scenario=scenario)
snapshot = adapter.discover(locked_account)
_validate_snapshot(locked_account, snapshot)
with transaction.atomic():
locked_account = _lock_account(locked_account.pk)
current_run = SyncRun.objects.select_for_update().get(pk=sync_run.pk)
if current_run.status != SyncRun.Status.RUNNING:
raise ProviderError(
"SYNC_RUN_SUPERSEDED",
"本次同步已被更新的同步执行取代,快照未写入。",
)
if _sync_configuration(locked_account) != discovery_configuration:
raise ProviderError(
"SYNC_CONFIGURATION_CHANGED",
"同步期间云账号或云厂商配置发生变化,快照未写入。",
)
_validate_snapshot(locked_account, snapshot)
stats = _apply_snapshot(locked_account, current_run, snapshot)
current_run.status = (
SyncRun.Status.SUCCEEDED
if snapshot.complete
else SyncRun.Status.PARTIAL
)
current_run.error_code = snapshot.error_code
current_run.error_message = snapshot.error_message
for key, value in stats.items():
setattr(current_run, key, value)
current_run.finished_at = timezone.now()
current_run.save()
if snapshot.complete:
locked_account.last_successful_sync_at = current_run.finished_at
locked_account.save(update_fields=["last_successful_sync_at", "updated_at"])
sync_run = current_run
return sync_run
except ProviderError as exc:
if exc.code.startswith("ALIYUN_"):
logger.warning(
"CMDB synchronization failed code=%s account_id=%s sync_run_id=%s.",
exc.code,
locked_account.pk,
sync_run.pk,
)
with transaction.atomic():
SyncRun.objects.filter(
pk=sync_run.pk,
status=SyncRun.Status.RUNNING,
).update(
status=SyncRun.Status.FAILED,
error_code=exc.code,
error_message=exc.message,
finished_at=timezone.now(),
)
sync_run.refresh_from_db()
raise
except Exception as exc:
logger.error(
"Unexpected CMDB synchronization failure type=%s account_id=%s sync_run_id=%s.",
type(exc).__name__,
locked_account.pk,
sync_run.pk,
)
with transaction.atomic():
SyncRun.objects.filter(
pk=sync_run.pk,
status=SyncRun.Status.RUNNING,
).update(
status=SyncRun.Status.FAILED,
error_code="UNEXPECTED_ERROR",
error_message="同步失败,请查看服务日志。",
finished_at=timezone.now(),
)
sync_run.refresh_from_db()
raise ProviderError("UNEXPECTED_ERROR", "同步失败,请查看服务日志。") from exc
def retire_instance(instance, sync_run=None):
with transaction.atomic():
locked_account = _lock_account(instance.account_id)
locked_instance = ComputeInstance.objects.select_for_update().get(
pk=instance.pk,
account=locked_account,
)
if locked_instance.lifecycle_state == ComputeInstance.LifecycleState.RETIRED:
return False
before = _instance_state(locked_instance)
changed_fields = ["lifecycle_state", "retired_at"]
if locked_instance.missing_since is not None:
changed_fields.append("missing_since")
locked_instance.lifecycle_state = ComputeInstance.LifecycleState.RETIRED
locked_instance.missing_since = None
locked_instance.retired_at = timezone.now()
locked_instance.save(
update_fields=[
"lifecycle_state",
"missing_since",
"retired_at",
"updated_at",
]
)
ComputeInstanceChange.objects.create(
sync_run=sync_run,
instance=locked_instance,
action=ComputeInstanceChange.Action.RETIRED,
changed_fields=changed_fields,
before_data=before,
after_data=_instance_state(locked_instance),
)
return True
1.9.35 devopsX/cmdb/static/cmdb/style.css
清单第 35 项;ZIP 内路径:cmdb/static/cmdb/style.css。
:root {
color-scheme: light;
font-family: "Microsoft YaHei", "PingFang SC", sans-serif;
color: #172033;
background: #f4f7fb;
}
* { box-sizing: border-box; }
body { margin: 0; min-height: 100vh; background: #f4f7fb; }
a { color: #2457d6; text-decoration: none; }
code, pre { font-family: Consolas, "SFMono-Regular", monospace; }
.site-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
padding: 18px max(24px, calc((100vw - 1120px) / 2));
background: #ffffff;
border-bottom: 1px solid #dfe6f0;
}
.brand { color: #172033; font-size: 22px; font-weight: 800; }
.site-header nav { display: flex; align-items: center; flex-wrap: wrap; gap: 16px; }
.inline-form { display: inline; }
.link-button { padding: 0; border: 0; color: #2457d6; background: transparent; font: inherit; cursor: pointer; }
.page-shell { width: min(1180px, calc(100% - 32px)); margin: 42px auto; }
.hero, .panel, .page-heading, .empty-state, .stat-card {
background: #ffffff;
border: 1px solid #dfe6f0;
border-radius: 16px;
box-shadow: 0 12px 32px rgba(23, 32, 51, 0.06);
}
.hero, .page-heading, .empty-state, .panel { padding: 28px; }
.page-heading { margin-bottom: 22px; }
.page-heading h1, .hero h1 { margin: 8px 0 12px; font-size: clamp(30px, 5vw, 48px); }
.page-heading p, .hero p { color: #58708f; line-height: 1.7; }
.split-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
.eyebrow { margin: 0; color: #58708f; font-size: 13px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; }
.actions { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
.button { display: inline-block; padding: 10px 16px; border: 0; border-radius: 9px; color: #fff; background: #2457d6; font: inherit; cursor: pointer; }
.button-secondary { color: #2457d6; background: #e8eefc; }
.button-danger { background: #c63b4a; }
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin: 20px 0; }
.four-columns { grid-template-columns: repeat(4, 1fr); }
.stat-card { padding: 22px; }
.stat-card strong, .stat-card span { display: block; }
.stat-card strong { font-size: 34px; }
.stat-card span { margin-top: 6px; color: #58708f; }
.empty-state { text-align: center; margin: 22px 0; }
.empty-state h1, .empty-state h2 { margin-top: 0; }
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.section-heading h2, .panel h2 { margin-top: 0; }
.card-grid, .detail-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 18px; }
.detail-grid { margin: 18px 0; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; min-width: 760px; }
th, td { padding: 12px 10px; border-bottom: 1px solid #e6ebf2; text-align: left; vertical-align: top; }
th { color: #58708f; font-size: 13px; white-space: nowrap; }
tr:last-child td { border-bottom: 0; }
.muted { color: #71839c; }
.callout { padding: 14px 16px; border-left: 4px solid #2457d6; background: #eef3ff; line-height: 1.7; }
.message { padding: 12px 16px; border-radius: 8px; background: #e8f5ec; color: #25613b; }
.message-error { background: #fff0f0; color: #9b2737; }
.message-warning { background: #fff8e6; color: #805b00; }
.badge, .tag { display: inline-block; padding: 3px 8px; border-radius: 999px; background: #edf1f7; color: #40536d; font-size: 12px; white-space: nowrap; }
.badge-succeeded, .badge-present, .badge-running { background: #dff4e5; color: #25613b; }
.badge-partial, .badge-missing, .badge-starting, .badge-stopping { background: #fff2cf; color: #805b00; }
.badge-failed, .badge-retired, .badge-stopped { background: #ffe3e3; color: #9b2737; }
.tag { margin: 0 6px 8px 0; }
.tag-manual { background: #e8eefc; color: #2457d6; }
.tag small { margin-left: 4px; opacity: .75; }
.detail-list { display: grid; grid-template-columns: minmax(120px, .7fr) 1.3fr; gap: 11px 18px; margin: 0; }
.detail-list dt { color: #58708f; }
.detail-list dd { margin: 0; overflow-wrap: anywhere; }
.form-panel { max-width: 720px; margin: 0 auto; }
form p { margin: 16px 0; }
label { display: block; color: #40536d; font-weight: 600; }
input, select, textarea { display: block; width: 100%; margin-top: 6px; padding: 10px 12px; border: 1px solid #b9c7da; border-radius: 8px; background: #fff; color: #172033; font: inherit; }
input[type="checkbox"] { display: inline-block; width: auto; margin-right: 6px; }
.helptext { display: block; margin-top: 5px; color: #71839c; font-size: 13px; font-weight: 400; }
.errorlist { padding: 0; color: #9b2737; list-style: none; font-size: 13px; }
.filter-grid { display: grid; grid-template-columns: repeat(4, minmax(140px, 1fr)); align-items: end; gap: 14px; }
.filter-actions { display: flex; gap: 8px; align-items: end; }
.sync-form { min-width: 260px; }
.sync-form p { margin: 0 0 10px; }
.sync-form label { font-size: 13px; }
.inline-fields { display: flex; align-items: end; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.inline-fields p { margin: 0; }
.inline-fields input { min-width: 160px; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 22px; }
.pagination a { padding: 7px 12px; border-radius: 7px; background: #e8eefc; }
.change-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
pre { overflow-x: auto; padding: 12px; border-radius: 8px; background: #f3f6fa; font-size: 12px; white-space: pre-wrap; }
.topology-tree details { margin: 8px 0 8px 16px; padding: 8px 0 0 14px; border-left: 2px solid #dfe6f0; }
.topology-tree > details { margin-left: 0; padding-left: 0; border-left: 0; }
.topology-tree summary { cursor: pointer; line-height: 1.8; }
.topology-tree ul { margin: 8px 0; padding-left: 24px; }
@media (max-width: 900px) {
.four-columns { grid-template-columns: repeat(2, 1fr); }
.filter-grid { grid-template-columns: repeat(2, minmax(140px, 1fr)); }
}
@media (max-width: 700px) {
.site-header { align-items: flex-start; padding: 16px; }
.page-shell { width: min(100% - 20px, 1180px); margin: 24px auto; }
.split-heading, .detail-grid, .card-grid { display: block; }
.split-heading .actions, .split-heading .sync-form { margin-top: 18px; }
.stats-grid, .four-columns { grid-template-columns: 1fr; }
.filter-grid { grid-template-columns: 1fr; }
.hero, .page-heading, .empty-state, .panel { padding: 20px; }
.change-grid { display: block; }
}
1.9.36 devopsX/cmdb/templates/cmdb/_pagination.html
清单第 36 项;ZIP 内路径:cmdb/templates/cmdb/_pagination.html。
{% if page_obj.paginator.num_pages > 1 %}
<nav class="pagination" aria-label="分页">
{% if page_obj.has_previous %}<a href="?{% if query_without_page %}{{ query_without_page }}&{% endif %}page={{ page_obj.previous_page_number }}">上一页</a>{% endif %}
<span>第 {{ page_obj.number }} / {{ page_obj.paginator.num_pages }} 页</span>
{% if page_obj.has_next %}<a href="?{% if query_without_page %}{{ query_without_page }}&{% endif %}page={{ page_obj.next_page_number }}">下一页</a>{% endif %}
</nav>
{% endif %}
1.9.37 devopsX/cmdb/templates/cmdb/account_csv_import.html
清单第 37 项;ZIP 内路径:cmdb/templates/cmdb/account_csv_import.html。
{% extends "base.html" %}
{% block title %}CSV 导入云账号 - CMDB{% endblock %}
{% block content %}
<section class="page-heading"><p class="eyebrow">CSV Import</p><h1>CSV 导入云账号</h1><p>先预览、再确认导入;CSV 禁止包含密钥、密码和 Token。</p></section>
<section class="panel"><h2>1. 上传并预览</h2><p class="callout"><code>provider_code,account_key,name,credential_profile,region_allowlist,is_active,sync_enabled</code><br>地域使用竖线分隔,例如 <code>cn-hangzhou|cn-shanghai</code>。更新已有账号时,未提供的可选列保持原值;已提供的空单元格按该列的空值或默认值处理。</p><form method="post" enctype="multipart/form-data">{% csrf_token %}<input type="hidden" name="action" value="preview">{{ upload_form.as_p }}<button class="button" type="submit">预览 CSV</button></form></section>
{% if preview_rows %}<section class="panel"><h2>2. 核对并导入</h2><div class="table-wrap"><table><thead><tr><th>厂商</th><th>账号键</th><th>名称</th><th>凭据前缀</th><th>地域</th><th>启用</th><th>同步</th></tr></thead><tbody>{% for row in preview_rows %}<tr><td>{{ row.provider_code }}</td><td><code>{{ row.account_key }}</code></td><td>{{ row.name }}</td><td>{% if "credential_profile" in row.provided_fields %}<code>{{ row.credential_profile|default:"—" }}</code>{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td><td>{% if "region_allowlist" in row.provided_fields %}{{ row.region_allowlist|join:", "|default:"全部" }}{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td><td>{% if "is_active" in row.provided_fields %}{{ row.is_active|yesno:"是,否" }}{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td><td>{% if "sync_enabled" in row.provided_fields %}{{ row.sync_enabled|yesno:"是,否" }}{% else %}<span class="muted">未提供(更新时保留)</span>{% endif %}</td></tr>{% endfor %}</tbody></table></div><p class="muted">新建账号未提供可选列时使用模型默认值;更新已有账号时保留数据库中的当前值。</p><form method="post">{% csrf_token %}<input type="hidden" name="action" value="import">{{ confirm_form }}<button class="button" type="submit">确认导入 {{ preview_rows|length }} 行</button></form></section>{% endif %}
{% if confirm_form.errors %}<section class="panel"><h2>导入失败</h2>{{ confirm_form.errors }}</section>{% endif %}
{% endblock %}

浙公网安备 33010602011771号