跳到主要内容

生产项目模板与配置

单文件脚本适合一次性任务;当脚本需要定时执行、被 CI 调用或承担变更动作时,应从第一天起具备可安装、可测试、可配置和可审计的项目骨架。本章使用 uv 与标准库,示例可直接作为新项目起点。

1. 目录与职责

ops-tool/
├── pyproject.toml # Python 版本、依赖与工具配置
├── uv.lock # 由 uv 生成并提交,保证部署环境可复现
├── src/ops_tool/
│ ├── __init__.py
│ ├── cli.py # 参数解析与进程退出码
│ ├── config.py # 配置加载与启动时校验
│ ├── service.py # 可测试的业务逻辑
│ └── logging.py # 日志脱敏与统一格式
├── tests/ # 单元、集成和契约测试
├── deploy/ # systemd、容器或 K8s 部署清单
└── README.md # 用途、权限、回滚和运行手册

HTTP、SSH、Kubernetes 等外部调用放在 adapter/client 层;业务规则不应直接读取环境变量、打印日志或调用 sys.exit()。这样测试可以替换外部依赖,CLI 也可以稳定地转换错误为退出码。

2. 锁定运行环境

# pyproject.toml
[project]
name = "ops-tool"
version = "0.1.0"
requires-python = ">=3.12,<3.13"
dependencies = ["httpx>=0.27,<1"]

[project.scripts]
# 安装后可直接执行 ops-tool,而不依赖当前工作目录。
ops-tool = "ops_tool.cli:main"

[dependency-groups]
dev = ["pytest>=8,<9", "ruff>=0.6,<1", "mypy>=1.11,<2"]

[tool.ruff]
line-length = 100
target-version = "py312"

[tool.pytest.ini_options]
testpaths = ["tests"]
# 初始化项目后只在受控变更中升级依赖,并提交 uv.lock。
uv init --package ops-tool
uv add httpx
uv add --dev pytest ruff mypy
uv lock
uv sync --locked --all-groups

# 验证安装的命令入口与解释器版本。
uv run ops-tool --help
uv run python --version

生产部署必须使用 uv sync --locked 或导出的固定 requirements,不能在部署时无约束地解析最新依赖。解释器版本也应固定在支持矩阵内,避免新版本默认行为改变任务结果。

3. 显式配置模型

环境变量是传递配置的通道,不是配置设计本身。集中加载、校验并记录非敏感配置,能把运行时错误提前到启动阶段。

# src/ops_tool/config.py
from __future__ import annotations

import os
from dataclasses import dataclass
from urllib.parse import urlparse


@dataclass(frozen=True)
class Settings:
api_base: str
api_token: str
timeout_seconds: float
dry_run: bool


def required(name: str) -> str:
"""缺少密钥或必填配置时失败,绝不回退到公开开发默认值。"""
value = os.environ.get(name, "").strip()
if not value:
raise RuntimeError(f"缺少必填环境变量: {name}")
return value


def load_settings() -> Settings:
api_base = required("OPS_API_BASE").rstrip("/")
parsed = urlparse(api_base)
if parsed.scheme != "https" or not parsed.netloc:
raise RuntimeError("OPS_API_BASE 必须是完整 HTTPS 地址")

timeout = float(os.environ.get("OPS_TIMEOUT_SECONDS", "10"))
if not 0 < timeout <= 300:
raise RuntimeError("OPS_TIMEOUT_SECONDS 必须在 0 到 300 秒之间")

return Settings(
api_base=api_base,
api_token=required("OPS_API_TOKEN"),
timeout_seconds=timeout,
# 默认 dry-run;只有明确设置 true 才允许真实变更。
dry_run=os.environ.get("OPS_DRY_RUN", "true").lower() == "true",
)

配置优先级应固定并写入 README,例如“命令行参数 > 环境变量 > 只读配置文件 > 安全默认值”。机密值仅来自 Secret 管理系统、受限挂载文件或 CI Secret;不要将 .env 当作生产密钥仓库。

4. CLI、退出码与计划模式

# src/ops_tool/cli.py
from __future__ import annotations

import argparse
import json
import sys

from ops_tool.config import load_settings
from ops_tool.service import restart_service


def main() -> int:
parser = argparse.ArgumentParser(description="受控重启服务")
parser.add_argument("--host", required=True, help="CMDB 中登记的主机名")
parser.add_argument("--service", required=True, help="允许列表中的 systemd 单元")
parser.add_argument("--apply", action="store_true", help="真正执行;默认只输出计划")
args = parser.parse_args()

try:
settings = load_settings()
result = restart_service(
settings=settings,
host=args.host,
service=args.service,
apply=args.apply and not settings.dry_run,
)
except (RuntimeError, ValueError) as exc:
# 2 表示用法或配置错误,便于 cron、CI 和调用方区分。
print(json.dumps({"ok": False, "error": str(exc)}), file=sys.stderr)
return 2

print(json.dumps(result, ensure_ascii=False))
return 0 if result["ok"] else 1


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

--apply 不是唯一保护措施。实际写操作还应校验环境、目标白名单、调用身份和变更窗口,并为每个任务分配可追踪的 change_id

5. 结构化日志与脱敏

# src/ops_tool/logging.py
import json
import logging

SENSITIVE_KEYS = {"authorization", "token", "password", "secret", "api_key"}


def redact(fields: dict) -> dict:
"""仅记录可审计的上下文,绝不把凭据写进日志。"""
return {
key: "***" if key.lower() in SENSITIVE_KEYS else value
for key, value in fields.items()
}


def log_event(logger: logging.Logger, event: str, **fields: object) -> None:
logger.info(json.dumps({"event": event, **redact(fields)}, ensure_ascii=False))

日志至少包含事件名、任务 ID、调用者、目标、动作、结果和耗时。不要在日志里存放完整请求体、环境变量、Bearer Token 或 SSH 私钥路径以外的内容。

6. 交付前检查

  1. pyproject.toml 限定 Python 与关键依赖范围,uv.lock 已提交;
  2. 配置在启动时完成校验,密钥没有默认值;
  3. 业务逻辑可被测试,CLI 只负责输入、输出与退出码;
  4. 变更动作默认计划模式,并具备明确的 --apply
  5. 每一次真实变更都能在日志中按任务 ID 检索到。