CMDB 资产管理(6):v1.0.0 源码汇总(上篇)

CMDB 资产管理(6):v1.0.0 源码汇总(上篇)

1 CMDB 资产管理(6):v1.0.0 源码汇总(上篇)

1.1 导航与交付边界

源码汇总与最终 ZIP 来自同一份冻结内容。ZIP 由当前交付渠道直接提供,本文不上传第三方,也不猜测下载地址。包内不含 .env、数据库、缓存、字节码、虚拟环境、收集后的静态目录、工具配置或真实凭据。

1.2 最终环境与验证证据

项目最终证据边界
验证日期2026-09-16CMDB v1.0.0 冻结交付。
PythonCPython 3.10.8,64 位本地实际验证版本。
Django / python-dotenv5.2.17 / 1.2.3与最终依赖文件一致。
SQLite105 项测试通过;迁移无漂移;系统检查无问题。确定性教学与回归路径。
随机顺序四进程完整通过;最终 seed 2147873966。降低测试顺序依赖风险。
生产设置16 个 fail-closed 场景通过;安全配置下 check --deploy 无问题。不等于真实基础设施整体验收。
静态文件128 个文件实际收集成功。staticfiles/ 不进入源码包。
浏览器主要路由、权限和 Admin 只读边界通过;无 console/server/failed network 错误。真实云和真实 MySQL 不在浏览器证据内。
MySQL8.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-2562a2c9b44c4c63207ac65644947c4c40166ae8f871519ed035bb8001b62c8823c
内部路径项目根相对路径,无额外顶层前缀

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 %}
posted @ 2026-09-16 23:38  小家电维修  阅读(9)  评论(0)    收藏  举报