用 Claude Code 做“可检查”的维护脚本:从 dry-run 开始的安全自动化教程

1. 先给结论

如果你想用 Claude Code、Codex 或其他 AI coding 工具写运维维护脚本,不要从“帮我自动清理服务器”开始。

更安全的起点是:先让 AI 写一个只读检查脚本,默认 dry-run,输出它准备观察或触碰的对象;等你能看懂、能复现、能验证,再考虑真实执行。

这篇教程把原文的 home lab 经验改造成一个可复制的运维脚本工作流:

  1. 把维护任务拆成小脚本;
  2. 每个脚本只做一件事;
  3. 先实现 dry-run / 只读检查;
  4. 明确路径边界、排除目录、日志和退出码;
  5. 用真实样例验证输出,再让人决定是否升级为执行脚本。

2. 原文事实:作者真正强调的不是“AI 更快”

原文讲的是作者在 home lab 中用 Claude Code 写维护脚本,包括:

作者的核心观点是:Claude Code 的价值不是让脚本变成不可见的自动化黑盒,而是让脚本更容易被人检查、解释和修改。

原文也明确提醒:AI 生成脚本可能看起来很完整,但仍会误判路径、服务名、文件夹含义或边界条件。像 old-but-important 这种目录,AI 不知道它的重要性,除非你把规则写清楚。

3. 本文提炼:安全维护脚本的最小工作流

可以把 AI 辅助维护脚本拆成 5 个阶段:

  1. 观察阶段:只读检查,不修改文件、不重启服务、不删除目录。
  2. 解释阶段:脚本输出检查项、命中规则、风险和退出码。
  3. dry-run 阶段:如果将来要移动、删除、归档,也先只打印计划动作。
  4. 人工确认阶段:人审查输出,确认范围正确。
  5. 执行阶段:只有低风险、可回滚、路径明确的动作,才允许进入真实执行。

第一版不要接数据库、Kubernetes、生产服务,也不要给 AI 一个通用 shell 工具。先从本地样例和只读脚本开始。

4. 给 Claude Code 的提示词模板

把模糊请求改成有边界的请求。

不推荐

帮我写一个脚本清理旧文件和检查服务。

推荐

请帮我写一个 Python 维护检查脚本,要求:

目标:检查 home lab 目录状态,只读输出报告,默认不做任何修改。

边界:
- 只允许读取我传入的 --root 目录;
- 不允许删除、移动、重启服务或修改配置;
- 如果未来需要归档旧目录,也必须先实现 --dry-run,只打印计划动作;
- 排除目录 old-but-important;
- 所有函数必须有类型注解;
- 不使用 shell=True;
- 不引入第三方依赖。

检查项:
1. backups 目录是否存在;
2. logs/*.log 是否超过 1MB;
3. projects 下超过 30 天未修改的目录是否需要归档;
4. 输出清晰日志;
5. 有明确退出码:0=正常,1=参数或路径错误,2=发现告警。

请先输出代码,然后解释每个安全边界。

这类提示词的重点不是“写得快”,而是把执行权关在笼子里。

5. 可运行示例:只读维护检查脚本

下面示例只使用 Python 标准库。它会检查备份目录、大日志文件和陈旧项目目录。即使发现旧目录,也只打印 would archive,不会真的移动或删除。

创建文件:maintenance_check.py

#!/usr/bin/env python3
from __future__ import annotations

import argparse
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class CheckResult:
    name: str
    status: str
    detail: str


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Safe home-lab maintenance checker")
    parser.add_argument("--root", required=True, help="Allowed root directory")
    parser.add_argument("--dry-run", action="store_true", help="Show planned actions only")
    parser.add_argument("--max-log-mb", type=int, default=1, help="Warn when log files exceed this size")
    parser.add_argument("--stale-days", type=int, default=30, help="Warn when project folders are older than this")
    return parser.parse_args()


def resolve_root(raw_root: str) -> Path:
    root = Path(raw_root).expanduser().resolve()
    if not root.exists() or not root.is_dir():
        raise ValueError(f"root is not a directory: {root}")
    return root


def ensure_inside_root(root: Path, candidate: Path) -> Path:
    resolved = candidate.resolve()
    if root not in [resolved, *resolved.parents]:
        raise ValueError(f"path escapes allowed root: {resolved}")
    return resolved


def check_backup_dir(root: Path) -> CheckResult:
    backup_dir = ensure_inside_root(root, root / "backups")
    if backup_dir.exists():
        return CheckResult("backup_dir", "OK", f"found {backup_dir}")
    return CheckResult("backup_dir", "WARN", f"missing {backup_dir}")


def check_large_logs(root: Path, max_log_mb: int) -> list[CheckResult]:
    log_dir = ensure_inside_root(root, root / "logs")
    if not log_dir.exists():
        return [CheckResult("large_logs", "WARN", f"missing {log_dir}")]
    limit_bytes = max_log_mb * 1024 * 1024
    results: list[CheckResult] = []
    for path in sorted(log_dir.glob("*.log")):
        size = path.stat().st_size
        if size > limit_bytes:
            results.append(CheckResult("large_logs", "WARN", f"{path.name} is {size} bytes"))
    if not results:
        results.append(CheckResult("large_logs", "OK", "no oversized log files"))
    return results


def check_stale_projects(root: Path, stale_days: int) -> list[CheckResult]:
    projects_dir = ensure_inside_root(root, root / "projects")
    if not projects_dir.exists():
        return [CheckResult("stale_projects", "WARN", f"missing {projects_dir}")]

    now = Path.cwd().stat().st_mtime
    threshold_seconds = stale_days * 24 * 60 * 60
    results: list[CheckResult] = []

    for path in sorted(projects_dir.iterdir()):
        if not path.is_dir():
            continue
        if path.name in {"old-but-important"}:
            results.append(CheckResult("stale_projects", "SKIP", f"excluded {path.name}"))
            continue
        age_seconds = now - path.stat().st_mtime
        if age_seconds > threshold_seconds:
            planned = f"would archive {path} to {root / 'archive' / path.name}"
            results.append(CheckResult("stale_projects", "DRY-RUN", planned))

    if not results:
        results.append(CheckResult("stale_projects", "OK", "no stale project folders"))
    return results


def print_results(results: list[CheckResult]) -> None:
    for item in results:
        print(f"[{item.status}] {item.name}: {item.detail}")


def main() -> int:
    args = parse_args()
    try:
        root = resolve_root(args.root)
        results = [check_backup_dir(root)]
        results.extend(check_large_logs(root, args.max_log_mb))
        results.extend(check_stale_projects(root, args.stale_days))
        print_results(results)
        if any(item.status == "WARN" for item in results):
            return 2
        return 0
    except ValueError as exc:
        print(f"[ERROR] validation: {exc}")
        return 1


if __name__ == "__main__":
    raise SystemExit(main())

6. 准备样例目录

mkdir -p /tmp/maint-ai-demo/sample_home_lab/{backups,projects/old-but-important,projects/tmp-old,logs,compose}
printf 'important\n' > /tmp/maint-ai-demo/sample_home_lab/projects/old-but-important/README.txt
printf 'scratch\n' > /tmp/maint-ai-demo/sample_home_lab/projects/tmp-old/notes.txt
printf 'version: "3"\nservices:\n  demo:\n    image: nginx:alpine\n' > /tmp/maint-ai-demo/sample_home_lab/compose/docker-compose.yml
truncate -s 2M /tmp/maint-ai-demo/sample_home_lab/logs/app.log
touch -d '45 days ago' /tmp/maint-ai-demo/sample_home_lab/projects/tmp-old /tmp/maint-ai-demo/sample_home_lab/projects/tmp-old/notes.txt

7. 场景一:每日主机健康检查

运行:

python3 maintenance_check.py \
  --root /tmp/maint-ai-demo/sample_home_lab \
  --dry-run \
  --max-log-mb 1 \
  --stale-days 30

实际验证输出节选:

[OK] backup_dir: found /tmp/maint-ai-demo/sample_home_lab/backups
[WARN] large_logs: app.log is 2097152 bytes
[SKIP] stale_projects: excluded old-but-important
[DRY-RUN] stale_projects: would archive /tmp/maint-ai-demo/sample_home_lab/projects/tmp-old to /tmp/maint-ai-demo/sample_home_lab/archive/tmp-old
exit=2

验收标准:

8. 场景二:旧项目归档前的 dry-run 审查

这个脚本故意没有实现真实归档。原因很简单:归档、移动、删除都属于写操作,第一版只应该证明“识别逻辑正确”。

审查重点:

如果你后续要实现真实归档,应该另开一个函数,例如 archive_project(source: Path, target: Path, dry_run: bool) -> CheckResult,并继续保留 dry-run 默认路径。不要把检查逻辑和执行逻辑混在一起。

9. 场景三:错误输入必须显式失败

运行一个不存在的目录:

python3 maintenance_check.py --root /tmp/maint-ai-demo/missing --dry-run

实际验证输出:

[ERROR] validation: root is not a directory: /tmp/maint-ai-demo/missing
exit=1

验收标准:

10. 让 AI 解释脚本,而不是替你信任脚本

生成脚本后,继续让 Claude Code 做审查型任务,而不是马上让它扩展功能。

可复制提示词:

请审查这个维护脚本,只关注安全边界,不新增功能。

检查项:
1. 是否有路径逃逸风险;
2. 是否存在 shell=True 或隐式 shell 执行;
3. 是否有真实删除、移动、重启服务、写配置动作;
4. dry-run 是否只是展示计划,不产生副作用;
5. 是否能通过退出码区分正常、告警和参数错误;
6. 哪些地方必须人工确认后才能进入执行模式。

请按 blocking / important / optional 分类输出。

这一步的价值是让 AI 帮你暴露风险,而不是让 AI 替你承担风险。

11. 升级到真实环境前的安全清单

上线前至少检查:

12. 一周落地计划

13. 常见坑

14. 最小原则

维护脚本的第一目标不是“自动化一切”,而是:

让重复检查更容易发生,让危险动作更容易被看见。

这正是原文最值得复用的地方:Claude Code 可以帮你更快写脚本,但你应该用它来放大可读性、可验证性和边界感,而不是放大盲目信任。