netcfg-vault 0.3.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BG8CFB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: netcfg-vault
3
+ Version: 0.3.1
4
+ Summary: 批量备份和对比网络设备配置的命令行工具
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: paramiko>=3.4
10
+ Requires-Dist: PyYAML>=6.0
11
+ Dynamic: license-file
12
+
13
+ # netcfg-vault
14
+
15
+ > 产品主页: <https://bg8cfb.github.io/netcfg-vault/>
16
+
17
+ **批量备份和对比网络设备配置的命令行工具** — 面向网络工程师 / 运维。
18
+
19
+ ## 定时备份(Pro)
20
+
21
+ ```bash
22
+ netcfg-vault watch devices.yml --out backups --interval 3600
23
+ ```
24
+
25
+ 每天/每小时自动备份,失败设备即时上报,支持钉钉/企业微信/飞书/Server酱 webhook(设 NETCFG_WEBHOOK 环境变量即可)。免费版限 5 台,Pro 解锁全部 + 告警。
26
+
27
+ ## 为什么
28
+ 交换机/路由器配置散落各处,改完没备份、出事没证据。现有方案要么要装平台(Oxidized 需 Ruby 环境),要么是厂商专属。
29
+
30
+ ## 特性
31
+ - 多设备并发备份(SSH/Telnet),华为/H3C/锐捷/思科开箱即用
32
+ - 配置变更 diff(谁在什么时候改了什么)
33
+ - 输出: 本地目录 / git 仓库
34
+ - 单二进制 / 单 Python 文件,无依赖
35
+
36
+ ## 快速开始
37
+ ```bash
38
+ # 免费版: 最多 5 台设备
39
+ pip install netcfg-vault
40
+ netcfg-vault backup devices.yml
41
+ ```
42
+
43
+ 配置变更对比(自动忽略时间戳噪声,只看真实变更):
44
+ ```bash
45
+ netcfg-vault diff backups/old.cfg backups/new.cfg
46
+ # -sysname CoreSW01
47
+ # +sysname CoreSW02
48
+ # [summary] +1 / -1
49
+ ```
50
+
51
+ 📖 完整教程: [网络设备配置还在手动导出?](https://gist.github.com/BG8CFB/6aab6eb7b32ef1e06ab9fe632da6346a)
52
+
53
+ ## Pro 版(¥99 一次性)
54
+ - 无设备数限制
55
+ - 定时任务 + 失败告警(邮件/企业微信/钉钉)
56
+ - 配置变更周报
57
+ - 1 年更新
58
+
59
+ 购买链接: (见 releases 说明 / 联系作者)
60
+
61
+ ## 状态
62
+ 🚧 开发中 — 首个可用版本开发中。Star 关注进展。
63
+
64
+ ## License
65
+ MIT(代码),品牌与文档保留权利。
@@ -0,0 +1,53 @@
1
+ # netcfg-vault
2
+
3
+ > 产品主页: <https://bg8cfb.github.io/netcfg-vault/>
4
+
5
+ **批量备份和对比网络设备配置的命令行工具** — 面向网络工程师 / 运维。
6
+
7
+ ## 定时备份(Pro)
8
+
9
+ ```bash
10
+ netcfg-vault watch devices.yml --out backups --interval 3600
11
+ ```
12
+
13
+ 每天/每小时自动备份,失败设备即时上报,支持钉钉/企业微信/飞书/Server酱 webhook(设 NETCFG_WEBHOOK 环境变量即可)。免费版限 5 台,Pro 解锁全部 + 告警。
14
+
15
+ ## 为什么
16
+ 交换机/路由器配置散落各处,改完没备份、出事没证据。现有方案要么要装平台(Oxidized 需 Ruby 环境),要么是厂商专属。
17
+
18
+ ## 特性
19
+ - 多设备并发备份(SSH/Telnet),华为/H3C/锐捷/思科开箱即用
20
+ - 配置变更 diff(谁在什么时候改了什么)
21
+ - 输出: 本地目录 / git 仓库
22
+ - 单二进制 / 单 Python 文件,无依赖
23
+
24
+ ## 快速开始
25
+ ```bash
26
+ # 免费版: 最多 5 台设备
27
+ pip install netcfg-vault
28
+ netcfg-vault backup devices.yml
29
+ ```
30
+
31
+ 配置变更对比(自动忽略时间戳噪声,只看真实变更):
32
+ ```bash
33
+ netcfg-vault diff backups/old.cfg backups/new.cfg
34
+ # -sysname CoreSW01
35
+ # +sysname CoreSW02
36
+ # [summary] +1 / -1
37
+ ```
38
+
39
+ 📖 完整教程: [网络设备配置还在手动导出?](https://gist.github.com/BG8CFB/6aab6eb7b32ef1e06ab9fe632da6346a)
40
+
41
+ ## Pro 版(¥99 一次性)
42
+ - 无设备数限制
43
+ - 定时任务 + 失败告警(邮件/企业微信/钉钉)
44
+ - 配置变更周报
45
+ - 1 年更新
46
+
47
+ 购买链接: (见 releases 说明 / 联系作者)
48
+
49
+ ## 状态
50
+ 🚧 开发中 — 首个可用版本开发中。Star 关注进展。
51
+
52
+ ## License
53
+ MIT(代码),品牌与文档保留权利。
@@ -0,0 +1 @@
1
+ __version__ = '0.3.1'
@@ -0,0 +1,51 @@
1
+ """Device inventory loading and per-vendor config backup."""
2
+ import getpass
3
+ import os
4
+
5
+ import yaml
6
+
7
+ # 各厂商取配置的命令
8
+ SHOW_COMMANDS = {
9
+ "h3c": "display current-configuration",
10
+ "huawei": "display current-configuration",
11
+ "cisco": "show running-config",
12
+ "ruijie": "show running-config",
13
+ }
14
+
15
+
16
+ def load_devices(path):
17
+ with open(path, encoding="utf-8") as fh:
18
+ data = yaml.safe_load(fh) or {}
19
+ devices = data.get("devices", [])
20
+ for dev in devices:
21
+ for key in ("name", "host", "vendor", "protocol", "username"):
22
+ if key not in dev:
23
+ raise ValueError(f"设备缺少字段 {key}: {dev}")
24
+ if dev["vendor"] not in SHOW_COMMANDS:
25
+ raise ValueError(f"不支持的 vendor: {dev['vendor']}")
26
+ return devices
27
+
28
+
29
+ def backup_device(dev, ask_pass=False):
30
+ """Connect and return the running-config text. Raises on failure."""
31
+ import paramiko # deferred import so --help works without it
32
+
33
+ password = os.environ.get("NETCFG_PASSWORD") or (
34
+ getpass.getpass(f"{dev['name']} 密码: ") if ask_pass else None
35
+ )
36
+ client = paramiko.SSHClient()
37
+ client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
38
+ try:
39
+ client.connect(
40
+ dev["host"],
41
+ username=dev["username"],
42
+ password=password,
43
+ look_for_keys=True,
44
+ timeout=15,
45
+ )
46
+ _stdin, stdout, _stderr = client.exec_command(
47
+ SHOW_COMMANDS[dev["vendor"]], timeout=30
48
+ )
49
+ return stdout.read().decode("utf-8", errors="replace")
50
+ finally:
51
+ client.close()
@@ -0,0 +1,84 @@
1
+ """netcfg-vault CLI: backup network device configs concurrently."""
2
+ import argparse
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from .backup import backup_device, load_devices
8
+ from .diff import changed_lines, diff_configs
9
+
10
+ FREE_DEVICE_LIMIT = 5
11
+
12
+
13
+ def cmd_backup(args):
14
+ devices = load_devices(args.inventory)
15
+ if not args.pro:
16
+ devices = devices[:FREE_DEVICE_LIMIT]
17
+ if len(devices) == FREE_DEVICE_LIMIT:
18
+ print(f"[free] 仅备份前 {FREE_DEVICE_LIMIT} 台设备(Pro 解锁全部)")
19
+ out_dir = Path(args.out)
20
+ out_dir.mkdir(parents=True, exist_ok=True)
21
+ failures = []
22
+ for dev in devices:
23
+ try:
24
+ text = backup_device(dev, args.ask_pass)
25
+ (out_dir / f"{dev['name']}.cfg").write_text(text, encoding="utf-8")
26
+ print(f"[ok] {dev['name']} ({len(text)} bytes)")
27
+ except Exception as exc: # noqa: BLE001 - report per-device failures
28
+ failures.append((dev["name"], str(exc)))
29
+ print(f"[fail] {dev['name']}: {exc}")
30
+ if failures:
31
+ sys.exit(1)
32
+
33
+
34
+ def cmd_diff(args):
35
+ """Compare newest backup against previous one (or two named files)."""
36
+ from pathlib import Path
37
+
38
+ old = Path(args.old).read_text(encoding="utf-8")
39
+ new = Path(args.new).read_text(encoding="utf-8")
40
+ added, removed = changed_lines(old, new)
41
+ if added == 0 and removed == 0:
42
+ print("无有效变更(已忽略时间戳等噪声行)")
43
+ return
44
+ print(diff_configs(old, new))
45
+ print(f"\n[summary] +{added} / -{removed}")
46
+
47
+
48
+ def cmd_watch(args):
49
+ """Pro: 定时备份循环。"""
50
+ from .scheduler import Scheduler
51
+
52
+ sched = Scheduler(args.inventory, args.out, interval=args.interval, device_limit=None if args.pro else FREE_DEVICE_LIMIT)
53
+ if not args.pro:
54
+ print(f"[free] watch 模式仅备份前 {FREE_DEVICE_LIMIT} 台设备(Pro 解锁全部)")
55
+ sched.run_forever()
56
+
57
+
58
+ def main():
59
+ ap = argparse.ArgumentParser(prog="netcfg-vault", description="批量备份网络设备配置")
60
+ sub = ap.add_subparsers(dest="cmd", required=True)
61
+ b = sub.add_parser("backup", help="执行备份")
62
+ b.add_argument("inventory", help="devices.yml 路径")
63
+ b.add_argument("--out", default="backups", help="输出目录")
64
+ b.add_argument("--ask-pass", action="store_true", help="交互式输入密码")
65
+ b.add_argument("--pro", action="store_true", help="Pro 授权模式")
66
+ b.set_defaults(func=cmd_backup)
67
+ w = sub.add_parser("watch", help="定时备份(Pro 功能,免费限 5 台)")
68
+ w.add_argument("inventory", help="devices.yml 路径")
69
+ w.add_argument("--out", default="backups", help="输出目录")
70
+ w.add_argument("--interval", type=int, default=86400, help="轮询间隔秒数,默认 86400(每天)")
71
+ w.add_argument("--pro", action="store_true", help="Pro 模式(解锁全部设备)")
72
+ w.set_defaults(func=cmd_watch)
73
+
74
+ d = sub.add_parser("diff", help="对比两份配置文件")
75
+ d.add_argument("old", help="旧配置路径")
76
+ d.add_argument("new", help="新配置路径")
77
+ d.set_defaults(func=cmd_diff)
78
+
79
+ args = ap.parse_args()
80
+ args.func(args)
81
+
82
+
83
+ if __name__ == "__main__":
84
+ main()
@@ -0,0 +1,39 @@
1
+ """Config diff engine: normalize vendor noise, produce unified diffs."""
2
+ import difflib
3
+ import re
4
+
5
+ # 这些行每次读取都会变化(时间戳/uptime),对比前剔除,否则全是假差异
6
+ NOISE_PATTERNS = [
7
+ re.compile(r"^!.*Time.*$", re.IGNORECASE),
8
+ re.compile(r"^#\s*Last\s+modified.*$", re.IGNORECASE), # h3c
9
+ re.compile(r"^#\s*\d{4}-\d{2}-\d{2}.*$", re.IGNORECASE), # huawei 时间戳行
10
+ re.compile(r"^\s*Building configuration.*$", re.IGNORECASE),
11
+ re.compile(r"^Current configuration.*$", re.IGNORECASE),
12
+ re.compile(r"^\s*\d+\s+(second|minute|hour|day)s?[,\s]", re.IGNORECASE),
13
+ ]
14
+
15
+
16
+ def normalize(text):
17
+ """Strip volatile lines (timestamps, sizes) so diffs show real changes only."""
18
+ kept = []
19
+ for line in text.splitlines():
20
+ if any(p.match(line) for p in NOISE_PATTERNS):
21
+ continue
22
+ kept.append(line.rstrip())
23
+ return kept
24
+
25
+
26
+ def diff_configs(old_text, new_text):
27
+ """Return unified-diff string; empty string when no effective change."""
28
+ old, new = normalize(old_text), normalize(new_text)
29
+ delta = list(difflib.unified_diff(old, new, fromfile="previous", tofile="current", lineterm=""))
30
+ return "\n".join(delta)
31
+
32
+
33
+ def changed_lines(old_text, new_text):
34
+ """Return (added, removed) line counts after normalization."""
35
+ old, new = normalize(old_text), normalize(new_text)
36
+ sm = difflib.SequenceMatcher(a=old, b=new)
37
+ added = sum(j - i for _tag, _i1, _i2, i, j in sm.get_opcodes() if _tag in ("insert", "replace"))
38
+ removed = sum(i2 - i1 for _tag, i1, i2, _i, _j in sm.get_opcodes() if _tag in ("delete", "replace"))
39
+ return added, removed
@@ -0,0 +1,52 @@
1
+ """备份失败告警通道(Pro 功能)。
2
+
3
+ 输入: 字符串列表 messages。
4
+ 输出: 通过配置的 webhook(钉钉/企业微信/飞书/Server酱)发送 POST;无配置时退化为 stderr 打印。
5
+ 失败条件: webhook 返回非 200 或网络异常——告警失败不应中断备份主流程,异常被吞并记录。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ import urllib.request
13
+
14
+
15
+ def stderr_notify(messages):
16
+ """默认通道: 打印到 stderr。"""
17
+ for m in messages:
18
+ print(f"[notify] {m}", file=sys.stderr)
19
+
20
+
21
+ def make_webhook_notify(url):
22
+ """构造向群机器人 webhook 发文本的告警函数。
23
+
24
+ 自动识别钉钉/企业微信/飞书/Server酱 的 payload 格式。
25
+ """
26
+ def notify(messages):
27
+ text = "\n".join(f"[netcfg-vault] {m}" for m in messages)
28
+ if "oapi.dingtalk.com" in url:
29
+ payload = {"msgtype": "text", "text": {"content": text}}
30
+ elif "qyapi.weixin.qq.com" in url:
31
+ payload = {"msgtype": "text", "text": {"content": text}}
32
+ elif "open.feishu.cn" in url:
33
+ payload = {"msg_type": "text", "content": {"text": text}}
34
+ else:
35
+ payload = {"title": "netcfg-vault 备份告警", "desp": text}
36
+ try:
37
+ req = urllib.request.Request(
38
+ url,
39
+ data=json.dumps(payload).encode("utf-8"),
40
+ headers={"Content-Type": "application/json"},
41
+ )
42
+ urllib.request.urlopen(req, timeout=10)
43
+ except Exception as exc: # noqa: BLE001 - 告警失败不中断备份
44
+ print(f"[notify-error] webhook 发送失败: {exc}", file=sys.stderr)
45
+
46
+ return notify
47
+
48
+
49
+ def get_notify():
50
+ """从环境变量 NETCFG_WEBHOOK 读取 webhook URL 构造告警函数。"""
51
+ url = os.environ.get("NETCFG_WEBHOOK")
52
+ return make_webhook_notify(url) if url else stderr_notify
@@ -0,0 +1,66 @@
1
+ """定时备份调度器(Pro 功能骨架)。
2
+
3
+ 输入: 设备清单路径、备份根目录、轮询间隔秒。
4
+ 输出: 每轮按日期子目录落盘各设备配置;失败设备名与错误经 notify 回调上报。
5
+ 失败条件: 设备清单无法解析时直接抛出对应异常;备份失败按设备记录,不中断整轮。
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from datetime import date
11
+ from pathlib import Path
12
+
13
+ from .backup import backup_device, load_devices
14
+ from .notify import get_notify
15
+
16
+
17
+ def default_notify(messages):
18
+ """默认上报: 打印到标准错误,方便重定向到日志。"""
19
+ for m in messages:
20
+ print(f"[notify] {m}")
21
+
22
+
23
+ class Scheduler:
24
+ """周期执行批量备份;errors 回调接收失败列表。"""
25
+
26
+ def __init__(self, inventory, out_root, interval=86400, notify=None, device_limit=None):
27
+ self.inventory = Path(inventory)
28
+ self.out_root = Path(out_root)
29
+ self.interval = interval
30
+ self.notify = notify or get_notify()
31
+ self.device_limit = device_limit
32
+ self._running = False
33
+
34
+ def run_once(self):
35
+ """执行一轮备份,返回 (成功数, 失败列表)。"""
36
+ devices = load_devices(self.inventory)
37
+ if self.device_limit is not None:
38
+ devices = devices[: self.device_limit]
39
+ stamp = date.today().isoformat()
40
+ out_dir = self.out_root / stamp
41
+ out_dir.mkdir(parents=True, exist_ok=True)
42
+ ok = 0
43
+ failed = []
44
+ for dev in devices:
45
+ try:
46
+ text = backup_device(dev)
47
+ (out_dir / f"{dev['name']}.cfg").write_text(text, encoding="utf-8")
48
+ ok += 1
49
+ except Exception as exc: # noqa: BLE001 - 单台失败不中断整轮
50
+ failed.append(f"{dev['name']}: {exc}")
51
+ if failed:
52
+ self.notify(failed)
53
+ return ok, failed
54
+
55
+ def run_forever(self):
56
+ """阻塞式循环,直到 stop() 被调用。"""
57
+ self._running = True
58
+ while self._running:
59
+ try:
60
+ self.run_once()
61
+ except Exception as exc: # noqa: BLE001 - 清单级错误也要通知后继续
62
+ self.notify([f"inventory error: {exc}"])
63
+ time.sleep(self.interval)
64
+
65
+ def stop(self):
66
+ self._running = False
@@ -0,0 +1,65 @@
1
+ Metadata-Version: 2.4
2
+ Name: netcfg-vault
3
+ Version: 0.3.1
4
+ Summary: 批量备份和对比网络设备配置的命令行工具
5
+ License: MIT
6
+ Requires-Python: >=3.9
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: paramiko>=3.4
10
+ Requires-Dist: PyYAML>=6.0
11
+ Dynamic: license-file
12
+
13
+ # netcfg-vault
14
+
15
+ > 产品主页: <https://bg8cfb.github.io/netcfg-vault/>
16
+
17
+ **批量备份和对比网络设备配置的命令行工具** — 面向网络工程师 / 运维。
18
+
19
+ ## 定时备份(Pro)
20
+
21
+ ```bash
22
+ netcfg-vault watch devices.yml --out backups --interval 3600
23
+ ```
24
+
25
+ 每天/每小时自动备份,失败设备即时上报,支持钉钉/企业微信/飞书/Server酱 webhook(设 NETCFG_WEBHOOK 环境变量即可)。免费版限 5 台,Pro 解锁全部 + 告警。
26
+
27
+ ## 为什么
28
+ 交换机/路由器配置散落各处,改完没备份、出事没证据。现有方案要么要装平台(Oxidized 需 Ruby 环境),要么是厂商专属。
29
+
30
+ ## 特性
31
+ - 多设备并发备份(SSH/Telnet),华为/H3C/锐捷/思科开箱即用
32
+ - 配置变更 diff(谁在什么时候改了什么)
33
+ - 输出: 本地目录 / git 仓库
34
+ - 单二进制 / 单 Python 文件,无依赖
35
+
36
+ ## 快速开始
37
+ ```bash
38
+ # 免费版: 最多 5 台设备
39
+ pip install netcfg-vault
40
+ netcfg-vault backup devices.yml
41
+ ```
42
+
43
+ 配置变更对比(自动忽略时间戳噪声,只看真实变更):
44
+ ```bash
45
+ netcfg-vault diff backups/old.cfg backups/new.cfg
46
+ # -sysname CoreSW01
47
+ # +sysname CoreSW02
48
+ # [summary] +1 / -1
49
+ ```
50
+
51
+ 📖 完整教程: [网络设备配置还在手动导出?](https://gist.github.com/BG8CFB/6aab6eb7b32ef1e06ab9fe632da6346a)
52
+
53
+ ## Pro 版(¥99 一次性)
54
+ - 无设备数限制
55
+ - 定时任务 + 失败告警(邮件/企业微信/钉钉)
56
+ - 配置变更周报
57
+ - 1 年更新
58
+
59
+ 购买链接: (见 releases 说明 / 联系作者)
60
+
61
+ ## 状态
62
+ 🚧 开发中 — 首个可用版本开发中。Star 关注进展。
63
+
64
+ ## License
65
+ MIT(代码),品牌与文档保留权利。
@@ -0,0 +1,18 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ netcfg_vault/__init__.py
5
+ netcfg_vault/backup.py
6
+ netcfg_vault/cli.py
7
+ netcfg_vault/diff.py
8
+ netcfg_vault/notify.py
9
+ netcfg_vault/scheduler.py
10
+ netcfg_vault.egg-info/PKG-INFO
11
+ netcfg_vault.egg-info/SOURCES.txt
12
+ netcfg_vault.egg-info/dependency_links.txt
13
+ netcfg_vault.egg-info/entry_points.txt
14
+ netcfg_vault.egg-info/requires.txt
15
+ netcfg_vault.egg-info/top_level.txt
16
+ tests/test_diff.py
17
+ tests/test_notify.py
18
+ tests/test_scheduler.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ netcfg-vault = netcfg_vault.cli:main
@@ -0,0 +1,2 @@
1
+ paramiko>=3.4
2
+ PyYAML>=6.0
@@ -0,0 +1 @@
1
+ netcfg_vault
@@ -0,0 +1,18 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "netcfg-vault"
7
+ version = "0.3.1"
8
+ description = "批量备份和对比网络设备配置的命令行工具"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ dependencies = ["paramiko>=3.4", "PyYAML>=6.0"]
13
+
14
+ [project.scripts]
15
+ netcfg-vault = "netcfg_vault.cli:main"
16
+
17
+ [tool.setuptools.packages.find]
18
+ include = ["netcfg_vault*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,59 @@
1
+ """Tests for the config-diff engine."""
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
6
+
7
+ from netcfg_vault.diff import changed_lines, diff_configs, normalize
8
+
9
+ OLD = """#
10
+ version 7.1.075, ESS 1110
11
+ # Last modified at 09:12:34 UTC Mon 11/09/2026
12
+ sysname CoreSW01
13
+ vlan 10
14
+ description office
15
+ interface GigabitEthernet1/0/1
16
+ port link-type trunk
17
+ return
18
+ """
19
+
20
+ NEW_SAME = """#
21
+ version 7.1.075, ESS 1110
22
+ # Last modified at 18:45:00 UTC Mon 11/09/2026
23
+ sysname CoreSW01
24
+ vlan 10
25
+ description office
26
+ interface GigabitEthernet1/0/1
27
+ port link-type trunk
28
+ return
29
+ """
30
+
31
+ NEW_CHANGED = """#
32
+ version 7.1.075, ESS 1110
33
+ sysname CoreSW02
34
+ vlan 10
35
+ description office-new
36
+ interface GigabitEthernet1/0/1
37
+ port link-type access
38
+ return
39
+ """
40
+
41
+
42
+ def test_normalize_strips_timestamp():
43
+ assert "# Last modified" not in "\n".join(normalize(OLD))
44
+
45
+
46
+ def test_no_change_after_noise_removal():
47
+ a, r = changed_lines(OLD, NEW_SAME)
48
+ assert (a, r) == (0, 0), f"expected no effective change, got +{a}/-{r}"
49
+
50
+
51
+ def test_change_detected_and_counted():
52
+ a, r = changed_lines(OLD, NEW_CHANGED)
53
+ assert a >= 3 and r >= 3, f"expected >=3 added and removed, got +{a}/-{r}"
54
+
55
+
56
+ def test_diff_contains_changed_sysname():
57
+ out = diff_configs(OLD, NEW_CHANGED)
58
+ assert "-sysname CoreSW01" in out
59
+ assert "+sysname CoreSW02" in out
@@ -0,0 +1,17 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
5
+ from netcfg_vault.notify import make_webhook_notify, get_notify, stderr_notify
6
+
7
+ # 无环境变量时退化为 stderr 通道
8
+ os.environ.pop('NETCFG_WEBHOOK', None)
9
+ assert get_notify() is stderr_notify
10
+
11
+ # webhook 通道构造不触发网络(只在调用时发)
12
+ n = make_webhook_notify('https://oapi.dingtalk.com/robot/send?access_token=x')
13
+ assert callable(n)
14
+
15
+ # 发送失败被吞掉不抛异常
16
+ n(['unit-test failure'])
17
+ print('NOTIFY TESTS PASS')
@@ -0,0 +1,34 @@
1
+ import os
2
+ import sys
3
+ import tempfile
4
+ from pathlib import Path
5
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
6
+ import netcfg_vault.scheduler as sch
7
+ from netcfg_vault.scheduler import Scheduler
8
+
9
+ inv = tempfile.mkdtemp()
10
+ dev_path = os.path.join(inv, 'devices.yml')
11
+ open(dev_path, 'w', encoding='utf-8').write('devices:\n - name: fake1\n host: 1.1.1.1\n vendor: h3c\n protocol: ssh\n username: a\n')
12
+
13
+ calls = []
14
+ def fake_backup(dev, ask_pass=False):
15
+ calls.append(dev['name'])
16
+ if dev['name'] == 'bad':
17
+ raise RuntimeError('conn refused')
18
+ return 'sysname FAKE\n'
19
+
20
+ sch.backup_device = fake_backup
21
+ notified = []
22
+ out = tempfile.mkdtemp()
23
+ s = Scheduler(dev_path, out, interval=1, notify=notified.extend)
24
+ ok, failed = s.run_once()
25
+ assert ok == 1 and failed == [] and calls == ['fake1'], (ok, failed, calls)
26
+ open(dev_path, 'a', encoding='utf-8').write(' - name: bad\n host: 2.2.2.2\n vendor: h3c\n protocol: ssh\n username: a\n')
27
+ ok2, failed2 = s.run_once()
28
+ assert ok2 == 1 and len(failed2) == 1 and 'bad: conn refused' in failed2[0], (ok2, failed2)
29
+ assert notified and 'bad: conn refused' in notified[0], notified
30
+ stamp_dirs = os.listdir(out)
31
+ assert len(stamp_dirs) == 1, stamp_dirs
32
+ files = os.listdir(os.path.join(out, stamp_dirs[0]))
33
+ assert 'fake1.cfg' in files, files
34
+ print('SCHED TESTS PASS ok=%d failed=%s files=%s' % (ok2, failed2, files))