feat: init sproutclaw-cron 定时任务管理框架
- 每任务一目录结构,统一开关与日志 - cronctl CLI:enable/disable/toggle/status/run/sync-cron - 公共库 lib/shumengya_cron:runner/manager/notify/ssh - _template 示例任务(hello world + 复制模板) - webui 管理面板:FastAPI 后端 + 文档式响应式前端
This commit is contained in:
5
lib/shumengya_cron/__init__.py
Normal file
5
lib/shumengya_cron/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""shumengya-cron:共用库(日志/锁、飞书通知、SSH 辅助)。各任务逻辑在任务目录 run.py。"""
|
||||
|
||||
__all__ = ["__version__"]
|
||||
|
||||
__version__ = "0.1.0"
|
||||
204
lib/shumengya_cron/manager.py
Normal file
204
lib/shumengya_cron/manager.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""sproutclaw-cron 任务管理 API,供 cronctl 与 WebUI 共用。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import importlib.util
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from shumengya_cron.runner import (
|
||||
DISABLED_DIR_NAME,
|
||||
LogMode,
|
||||
TaskContext,
|
||||
set_task_enabled,
|
||||
task_is_enabled,
|
||||
)
|
||||
|
||||
CRON_D = Path("/etc/cron.d")
|
||||
_CRON_LINE = re.compile(
|
||||
r"^(\S+\s+\S+\s+\S+\s+\S+\s+\S+)\s+\S+\s+.+$",
|
||||
)
|
||||
|
||||
|
||||
def cron_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def migrate_legacy_disabled_layout(root: Path | None = None) -> None:
|
||||
"""将旧的 <task-id>.disabled/ 目录迁入 .disabled/<task-id>/。"""
|
||||
base = root or cron_root()
|
||||
disabled_root = base / DISABLED_DIR_NAME
|
||||
disabled_root.mkdir(exist_ok=True)
|
||||
for path in sorted(base.iterdir()):
|
||||
if not path.is_dir() or not path.name.endswith(".disabled"):
|
||||
continue
|
||||
task_id = path.name.removesuffix(".disabled")
|
||||
target = disabled_root / task_id
|
||||
if target.exists():
|
||||
continue
|
||||
path.rename(target)
|
||||
|
||||
|
||||
def iter_task_ids(root: Path | None = None) -> list[str]:
|
||||
"""遍历根目录与 .disabled/ 下的任务,返回 task_id 列表。"""
|
||||
base = root or cron_root()
|
||||
skip = {"lib", "__pycache__", DISABLED_DIR_NAME, ".claude", "webui"}
|
||||
task_ids: set[str] = set()
|
||||
|
||||
for path in sorted(base.iterdir()):
|
||||
if not path.is_dir() or path.name in skip or path.name.startswith("."):
|
||||
continue
|
||||
if path.name.endswith(".disabled"):
|
||||
task_ids.add(path.name.removesuffix(".disabled"))
|
||||
continue
|
||||
if (path / "run.py").is_file():
|
||||
task_ids.add(path.name)
|
||||
|
||||
disabled_root = base / DISABLED_DIR_NAME
|
||||
if disabled_root.is_dir():
|
||||
for path in sorted(disabled_root.iterdir()):
|
||||
if path.is_dir() and (path / "run.py").is_file():
|
||||
task_ids.add(path.name)
|
||||
|
||||
return sorted(task_ids)
|
||||
|
||||
|
||||
def get_context(task_id: str, root: Path | None = None) -> TaskContext:
|
||||
ctx = TaskContext.from_task_id(task_id, cron_root=root)
|
||||
if not ctx.task_dir.is_dir():
|
||||
raise FileNotFoundError(f"任务不存在: {task_id}")
|
||||
if not (ctx.task_dir / "run.py").is_file():
|
||||
raise FileNotFoundError(f"任务目录缺少 run.py: {task_id}")
|
||||
return ctx
|
||||
|
||||
|
||||
def _parse_schedule(task_dir: Path) -> tuple[str | None, str | None]:
|
||||
schedule = task_dir / "schedule.cron"
|
||||
if not schedule.is_file():
|
||||
return None, None
|
||||
description: str | None = None
|
||||
expression: str | None = None
|
||||
for raw in schedule.read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith("#"):
|
||||
if description is None:
|
||||
text = line.lstrip("#").strip()
|
||||
if text and not text.startswith("开关") and "cronctl" not in text:
|
||||
description = text
|
||||
continue
|
||||
match = _CRON_LINE.match(line)
|
||||
if match:
|
||||
expression = match.group(1)
|
||||
break
|
||||
return expression, description
|
||||
|
||||
|
||||
def task_is_running(ctx: TaskContext) -> bool:
|
||||
lock_file = ctx.lock_file
|
||||
if not lock_file.is_file():
|
||||
return False
|
||||
fd = lock_file.open("r")
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return False
|
||||
except BlockingIOError:
|
||||
return True
|
||||
finally:
|
||||
fd.close()
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskInfo:
|
||||
task_id: str
|
||||
enabled: bool
|
||||
running: bool
|
||||
schedule: str | None
|
||||
description: str | None
|
||||
log_size: int
|
||||
log_updated: str | None
|
||||
task_dir: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def build_task_info(task_id: str, root: Path | None = None) -> TaskInfo:
|
||||
ctx = get_context(task_id, root)
|
||||
schedule, description = _parse_schedule(ctx.task_dir)
|
||||
log_size = 0
|
||||
log_updated: str | None = None
|
||||
if ctx.log_file.is_file():
|
||||
stat = ctx.log_file.stat()
|
||||
log_size = stat.st_size
|
||||
log_updated = datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
return TaskInfo(
|
||||
task_id=task_id,
|
||||
enabled=task_is_enabled(ctx),
|
||||
running=task_is_running(ctx),
|
||||
schedule=schedule,
|
||||
description=description,
|
||||
log_size=log_size,
|
||||
log_updated=log_updated,
|
||||
task_dir=str(ctx.task_dir),
|
||||
)
|
||||
|
||||
|
||||
def list_tasks(root: Path | None = None) -> list[TaskInfo]:
|
||||
migrate_legacy_disabled_layout(root)
|
||||
return [build_task_info(task_id, root) for task_id in iter_task_ids(root)]
|
||||
|
||||
|
||||
def sync_cron_d(ctx: TaskContext, enabled: bool) -> str | None:
|
||||
"""将 schedule.cron 安装到 /etc/cron.d/。返回提示信息或 None。"""
|
||||
cron_d_file = CRON_D / ctx.task_id
|
||||
schedule = ctx.task_dir / "schedule.cron"
|
||||
if not schedule.is_file():
|
||||
return "未找到 schedule.cron,跳过安装"
|
||||
shutil.copy2(schedule, cron_d_file)
|
||||
state = "开启" if enabled else "关闭(cron 仍触发,run.py 自动跳过)"
|
||||
return f"已同步到 {cron_d_file}({state})"
|
||||
|
||||
|
||||
def set_task_state(task_id: str, enabled: bool, root: Path | None = None) -> tuple[TaskInfo, str | None]:
|
||||
ctx = get_context(task_id, root)
|
||||
set_task_enabled(ctx, enabled)
|
||||
ctx = TaskContext.from_task_id(task_id, cron_root=root)
|
||||
message = sync_cron_d(ctx, enabled)
|
||||
return build_task_info(task_id, root), message
|
||||
|
||||
|
||||
def toggle_task(task_id: str, root: Path | None = None) -> tuple[TaskInfo, str | None]:
|
||||
ctx = get_context(task_id, root)
|
||||
return set_task_state(task_id, not task_is_enabled(ctx), root)
|
||||
|
||||
|
||||
def run_task(task_id: str, root: Path | None = None) -> int:
|
||||
"""动态加载任务 run.py,调用其 run(ctx) 并返回退出码。"""
|
||||
ctx = get_context(task_id, root)
|
||||
run_py = ctx.task_dir / "run.py"
|
||||
|
||||
spec = importlib.util.spec_from_file_location(f"cron_run_{task_id}", run_py)
|
||||
mod = importlib.util.module_from_spec(spec) # type: ignore[arg-type]
|
||||
spec.loader.exec_module(mod) # type: ignore[union-attr]
|
||||
|
||||
log_mode = getattr(mod, "LOG_MODE", LogMode.REDIRECT_STD)
|
||||
ctx = TaskContext.from_task_id(task_id, log_mode=log_mode, cron_root=root)
|
||||
return mod.run(ctx)
|
||||
|
||||
|
||||
def read_log_tail(task_id: str, lines: int = 200, root: Path | None = None) -> str:
|
||||
ctx = get_context(task_id, root)
|
||||
if not ctx.log_file.is_file():
|
||||
return ""
|
||||
content = ctx.log_file.read_text(encoding="utf-8", errors="replace")
|
||||
parts = content.splitlines()
|
||||
if lines <= 0 or len(parts) <= lines:
|
||||
return content
|
||||
return "\n".join(parts[-lines:]) + "\n"
|
||||
208
lib/shumengya_cron/notify.py
Normal file
208
lib/shumengya_cron/notify.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""
|
||||
飞书 / Lark Markdown 通知 + 任务结果汇总。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Callable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from shumengya_cron.runner import TaskContext
|
||||
|
||||
CRON_FEISHU_WEBHOOK_DEFAULT = (
|
||||
"https://open.feishu.cn/open-apis/bot/v2/hook/1b13f977-f848-4a42-8e43-1b16ad592a34"
|
||||
)
|
||||
CRON_LARK_NOTICE_API_SRC_DEFAULT = "/shumengya/project/python/lark-notice-api/src"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskResult:
|
||||
"""任务执行结果,汇总 ok / skip / fail 三类条目。"""
|
||||
|
||||
ok: list[str] = field(default_factory=list)
|
||||
skip: list[str] = field(default_factory=list)
|
||||
fail: list[str] = field(default_factory=list)
|
||||
|
||||
def add_ok(self, item: str) -> None:
|
||||
self.ok.append(item)
|
||||
|
||||
def add_skip(self, item: str) -> None:
|
||||
self.skip.append(item)
|
||||
|
||||
def add_fail(self, item: str) -> None:
|
||||
self.fail.append(item)
|
||||
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
return not self.fail
|
||||
|
||||
@property
|
||||
def status_cn(self) -> str:
|
||||
return "成功" if self.success else "失败"
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return len(self.ok) + len(self.skip) + len(self.fail)
|
||||
|
||||
|
||||
def markdown_list(items: list[str]) -> str:
|
||||
"""将多行条目格式化为 Markdown 列表。"""
|
||||
if not items:
|
||||
return "- 无"
|
||||
return "\n".join(f"- {x}" for x in items if x)
|
||||
|
||||
|
||||
def markdown_fields(items: list[tuple[str, object]]) -> str:
|
||||
"""将键值对格式化为更简洁的 Markdown 列表。"""
|
||||
if not items:
|
||||
return "- 无"
|
||||
lines: list[str] = []
|
||||
for key, value in items:
|
||||
text = "-" if value is None or value == "" else str(value)
|
||||
lines.append(f"- **{key}**:{text}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def send_task_summary(
|
||||
ctx: "TaskContext",
|
||||
result: TaskResult,
|
||||
start_time: str,
|
||||
*,
|
||||
log: Callable[[str], None],
|
||||
extra_fields: list[tuple[str, object]] | None = None,
|
||||
) -> None:
|
||||
"""发送标准任务汇总通知(飞书 + 邮件降级)。
|
||||
|
||||
extra_fields 会插入在 结束时间 和 总数 之间,适合放主机名、目标路径等任务特有字段。
|
||||
"""
|
||||
from shumengya_cron.runner import task_output_prefix
|
||||
|
||||
end_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
subject = f"{task_output_prefix(ctx.task_id)} {result.status_cn} {end_time}"
|
||||
|
||||
fields: list[tuple[str, object]] = [
|
||||
("任务", ctx.task_id),
|
||||
("开始", start_time),
|
||||
("结束", end_time),
|
||||
]
|
||||
if extra_fields:
|
||||
fields.extend(extra_fields)
|
||||
fields.extend([
|
||||
("总数", result.total),
|
||||
("成功", len(result.ok)),
|
||||
("跳过", len(result.skip)),
|
||||
("失败", len(result.fail)),
|
||||
])
|
||||
|
||||
md = "\n".join([
|
||||
markdown_fields(fields),
|
||||
"", "**成功列表**", markdown_list(result.ok),
|
||||
"", "**跳过列表**", markdown_list(result.skip),
|
||||
"", "**失败列表**", markdown_list(result.fail),
|
||||
])
|
||||
send_feishu_markdown(subject, md, log=log)
|
||||
|
||||
|
||||
def send_feishu_markdown(
|
||||
title: str,
|
||||
markdown: str,
|
||||
*,
|
||||
log: Callable[[str], None],
|
||||
) -> None:
|
||||
"""发送飞书 Markdown(失败时自动降级为邮件通知,邮件也失败则结束)。"""
|
||||
if not title or not markdown:
|
||||
log("飞书通知内容为空,跳过。")
|
||||
return
|
||||
|
||||
webhook = os.environ.get("LARK_NOTICE_WEBHOOK", CRON_FEISHU_WEBHOOK_DEFAULT)
|
||||
api_src = os.environ.get("LARK_NOTICE_API_SRC", CRON_LARK_NOTICE_API_SRC_DEFAULT)
|
||||
if not webhook:
|
||||
log("未配置飞书 webhook,跳过飞书通知。")
|
||||
return
|
||||
|
||||
env = os.environ.copy()
|
||||
pp = api_src
|
||||
if env.get("PYTHONPATH"):
|
||||
pp = f"{api_src}:{env['PYTHONPATH']}"
|
||||
env["PYTHONPATH"] = pp
|
||||
|
||||
feishu_ok = False
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"lark_notice_api",
|
||||
"--webhook",
|
||||
webhook,
|
||||
"send-markdown",
|
||||
"--title",
|
||||
title,
|
||||
"--markdown",
|
||||
markdown,
|
||||
],
|
||||
cwd=api_src if os.path.isdir(api_src) else None,
|
||||
env=env,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
feishu_ok = True
|
||||
else:
|
||||
detail = (r.stderr or r.stdout or "").strip()
|
||||
if detail:
|
||||
log(f"发送飞书通知失败(退出码 {r.returncode}):{detail}")
|
||||
else:
|
||||
log(f"发送飞书通知失败(退出码 {r.returncode})。")
|
||||
except OSError:
|
||||
log("发送飞书通知失败:lark-notice-api 调用异常。")
|
||||
|
||||
if feishu_ok:
|
||||
return
|
||||
|
||||
log("飞书通知失败,尝试通过邮件发送…")
|
||||
_send_mail_fallback(title, markdown, log)
|
||||
|
||||
|
||||
# ── 邮件降级 ──────────────────────────────────────────────
|
||||
|
||||
_MAIL_SCRIPT_DEFAULT = "/shumengya/project/skills/mengya-mail-skills/scripts/mengya-mail-api.py"
|
||||
_MAIL_ENV_FILE_DEFAULT = "/shumengya/project/python/mengya-mail-api/.env"
|
||||
|
||||
|
||||
def _send_mail_fallback(title: str, markdown: str, log: Callable[[str], None]) -> None:
|
||||
"""通过 mengya-mail-api 发送报告邮件(失败仅记日志,不再继续降级)。"""
|
||||
enabled = os.environ.get("CRON_MAIL_ENABLED", "0")
|
||||
if enabled != "1":
|
||||
log("邮件通知未开启(export CRON_MAIL_ENABLED=1 可启用),跳过。")
|
||||
return
|
||||
|
||||
script = os.environ.get("MAIL_API_SCRIPT", _MAIL_SCRIPT_DEFAULT)
|
||||
env_file = os.environ.get("MAIL_API_ENV_FILE", _MAIL_ENV_FILE_DEFAULT)
|
||||
mail_to = os.environ.get("MAIL_TO", "mail@smyhub.com")
|
||||
from_name = os.environ.get("MAIL_FROM_NAME", "cron")
|
||||
|
||||
if not os.path.isfile(script):
|
||||
log(f"邮件脚本不存在:{script},跳过邮件通知。")
|
||||
return
|
||||
|
||||
try:
|
||||
r = subprocess.run(
|
||||
[sys.executable, script, "--env-file", env_file, "--format", "json",
|
||||
"send-email", "--to", mail_to, "--subject", title,
|
||||
"--from-name", from_name, "--html-body", markdown],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if r.returncode == 0:
|
||||
log("邮件通知发送成功。")
|
||||
else:
|
||||
detail = (r.stderr or r.stdout or "").strip()
|
||||
log(f"邮件通知发送失败(退出码 {r.returncode}):{detail or '无详细信息'}")
|
||||
except OSError as exc:
|
||||
log(f"邮件通知发送异常:{exc}")
|
||||
282
lib/shumengya_cron/runner.py
Normal file
282
lib/shumengya_cron/runner.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""
|
||||
任务运行骨架:路径、日志轮转、互斥锁、stdout 重定向。
|
||||
|
||||
按大小轮转日志、flock 互斥、
|
||||
锁占用时退出码 0(避免 cron 误报)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import os
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Callable, Iterator, TextIO
|
||||
|
||||
|
||||
class LogMode(str, Enum):
|
||||
"""日志模式:整段重定向到文件(多数任务)或直接写文件(如 AI CLI 更新)。"""
|
||||
|
||||
REDIRECT_STD = "redirect_std" # 等价于 bash: exec >>LOG 2>&1
|
||||
DIRECT_FILE = "direct_file" # 等价于 cron_log_file
|
||||
|
||||
|
||||
def _cron_root() -> Path:
|
||||
# .../lib/shumengya_cron/runner.py -> cron 根目录
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
DISABLED_DIR_NAME = ".disabled"
|
||||
|
||||
|
||||
def disabled_tasks_root(cron_root: Path | None = None) -> Path:
|
||||
root = cron_root or _cron_root()
|
||||
return root / DISABLED_DIR_NAME
|
||||
|
||||
|
||||
def active_task_dir(cron_root: Path, task_id: str) -> Path:
|
||||
return cron_root / task_id
|
||||
|
||||
|
||||
def disabled_task_dir(cron_root: Path, task_id: str) -> Path:
|
||||
return disabled_tasks_root(cron_root) / task_id
|
||||
|
||||
|
||||
def _legacy_disabled_task_dir(cron_root: Path, task_id: str) -> Path:
|
||||
return cron_root / f"{task_id}.disabled"
|
||||
|
||||
|
||||
# 固定缩写,遇到这些词时全大写而非首字母大写
|
||||
_ACRONYMS = frozenset({"ai", "cli", "ssh", "api", "db", "url"})
|
||||
|
||||
|
||||
def task_output_prefix(task_id: str) -> str:
|
||||
"""将 task_id 格式化为统一输出前缀:[smallmengya][AI-CLI-Update]。"""
|
||||
family, suffix = (task_id.split("-", 1) + [task_id])[:2]
|
||||
display = "-".join(
|
||||
p.upper() if p.lower() in _ACRONYMS else p.capitalize()
|
||||
for p in suffix.split("-")
|
||||
)
|
||||
return f"[{family}][{display}]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskContext:
|
||||
"""单次任务运行的上下文(每个任务目录对应一个 task_id)。"""
|
||||
|
||||
task_id: str
|
||||
cron_root: Path
|
||||
task_dir: Path
|
||||
log_dir: Path
|
||||
log_file: Path
|
||||
lock_file: Path
|
||||
log_mode: LogMode
|
||||
|
||||
@classmethod
|
||||
def from_task_id(
|
||||
cls,
|
||||
task_id: str,
|
||||
*,
|
||||
log_mode: LogMode = LogMode.REDIRECT_STD,
|
||||
cron_root: Path | None = None,
|
||||
) -> TaskContext:
|
||||
"""根据 task_id 创建上下文,自动识别 .disabled/<task-id>/ 目录。"""
|
||||
root = cron_root or _cron_root()
|
||||
disabled_dir = disabled_task_dir(root, task_id).resolve()
|
||||
active_dir = active_task_dir(root, task_id).resolve()
|
||||
legacy_disabled_dir = _legacy_disabled_task_dir(root, task_id).resolve()
|
||||
|
||||
if disabled_dir.is_dir():
|
||||
task_dir = disabled_dir
|
||||
elif legacy_disabled_dir.is_dir():
|
||||
task_dir = legacy_disabled_dir
|
||||
elif active_dir.is_dir():
|
||||
task_dir = active_dir
|
||||
else:
|
||||
task_dir = active_dir
|
||||
log_dir = task_dir / "logs"
|
||||
log_file = log_dir / f"{task_id}.log"
|
||||
lock_file = task_dir / f"{task_id}.lock"
|
||||
return cls(
|
||||
task_id=task_id,
|
||||
cron_root=root,
|
||||
task_dir=task_dir,
|
||||
log_dir=log_dir,
|
||||
log_file=log_file,
|
||||
lock_file=lock_file,
|
||||
log_mode=log_mode,
|
||||
)
|
||||
|
||||
|
||||
def task_is_enabled(ctx: TaskContext) -> bool:
|
||||
"""任务位于 cron 根目录下为开启,位于 .disabled/ 或 *.disabled 下为关闭。"""
|
||||
if ctx.task_dir.name.endswith(".disabled"):
|
||||
return False
|
||||
disabled_root = disabled_tasks_root(ctx.cron_root).resolve()
|
||||
try:
|
||||
return not ctx.task_dir.resolve().is_relative_to(disabled_root)
|
||||
except AttributeError:
|
||||
return ctx.task_dir.resolve().parent != disabled_root
|
||||
|
||||
|
||||
def task_is_disabled(ctx: TaskContext) -> bool:
|
||||
return not task_is_enabled(ctx)
|
||||
|
||||
|
||||
def set_task_enabled(ctx: TaskContext, enabled: bool) -> None:
|
||||
"""通过移动目录切换任务状态:启用时在根目录,禁用时在 .disabled/ 下。"""
|
||||
root = ctx.cron_root
|
||||
active_dir = active_task_dir(root, ctx.task_id)
|
||||
disabled_dir = disabled_task_dir(root, ctx.task_id)
|
||||
legacy_disabled_dir = _legacy_disabled_task_dir(root, ctx.task_id)
|
||||
|
||||
if legacy_disabled_dir.is_dir() and not disabled_dir.is_dir():
|
||||
disabled_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
legacy_disabled_dir.rename(disabled_dir)
|
||||
|
||||
if enabled:
|
||||
if disabled_dir.is_dir() and not active_dir.is_dir():
|
||||
active_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
disabled_dir.rename(active_dir)
|
||||
ctx.task_dir = active_dir
|
||||
elif active_dir.is_dir():
|
||||
ctx.task_dir = active_dir
|
||||
return
|
||||
|
||||
disabled_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
if active_dir.is_dir() and not disabled_dir.is_dir():
|
||||
active_dir.rename(disabled_dir)
|
||||
ctx.task_dir = disabled_dir
|
||||
elif disabled_dir.is_dir():
|
||||
ctx.task_dir = disabled_dir
|
||||
|
||||
|
||||
def task_enabled_text(ctx: TaskContext) -> str:
|
||||
return "开启" if task_is_enabled(ctx) else "关闭"
|
||||
|
||||
|
||||
def cron_log_rotate(log_file: Path, max_bytes: int | None = None) -> None:
|
||||
"""单日志超过阈值则轮转(默认 10MB),与 legacy cron_log_rotate 一致。"""
|
||||
mb = max_bytes if max_bytes is not None else int(
|
||||
os.environ.get("CRON_LOG_MAX_BYTES", str(10 * 1024 * 1024))
|
||||
)
|
||||
try:
|
||||
if not log_file.is_file():
|
||||
return
|
||||
if log_file.stat().st_size <= mb:
|
||||
return
|
||||
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
||||
log_file.rename(log_file.with_name(f"{log_file.name}.{stamp}"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@contextmanager
|
||||
def acquire_cron_lock(
|
||||
lock_file: Path,
|
||||
*,
|
||||
busy_message: str | None = None,
|
||||
log: Callable[[str], None],
|
||||
) -> Iterator[bool]:
|
||||
"""
|
||||
获取互斥锁;若已被占用则记录日志并 yield False(调用方应 sys.exit(0))。
|
||||
使用 flock(与 bash 版一致)。
|
||||
"""
|
||||
msg = busy_message or os.environ.get(
|
||||
"CRON_LOCK_BUSY_MSG", "已有同名任务在运行,跳过本次。"
|
||||
)
|
||||
lock_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd = os.open(str(lock_file), os.O_RDWR | os.O_CREAT, 0o644)
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
log(msg)
|
||||
yield False
|
||||
return
|
||||
yield True
|
||||
finally:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
except OSError:
|
||||
pass
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _tee_streams(log_fp: TextIO) -> tuple[TextIO, TextIO]:
|
||||
"""将 stdout/stderr 同时写到日志与原始 fd(便于调试时仍可在终端看到)。"""
|
||||
|
||||
class Tee(TextIO):
|
||||
def __init__(self, *streams: TextIO) -> None:
|
||||
self._streams = streams
|
||||
|
||||
def write(self, s: str) -> int:
|
||||
n = 0
|
||||
for st in self._streams:
|
||||
n = st.write(s)
|
||||
st.flush()
|
||||
return n
|
||||
|
||||
def flush(self) -> None:
|
||||
for st in self._streams:
|
||||
st.flush()
|
||||
|
||||
# 保留原 stdout/stderr 供 Tee
|
||||
orig_out = sys.__stdout__
|
||||
orig_err = sys.__stderr__
|
||||
out = Tee(log_fp, orig_out)
|
||||
err = Tee(log_fp, orig_err)
|
||||
return out, err
|
||||
|
||||
|
||||
@contextmanager
|
||||
def task_logging(ctx: TaskContext) -> Iterator[Callable[[str], None]]:
|
||||
"""
|
||||
按 log_mode 配置日志。
|
||||
|
||||
- REDIRECT_STD:轮转后重定向 stdout/stderr 到日志文件(并 tee 到原终端)。
|
||||
- DIRECT_FILE:不重定向,返回 log_line() 仅写文件。
|
||||
"""
|
||||
ctx.log_dir.mkdir(parents=True, exist_ok=True)
|
||||
cron_log_rotate(ctx.log_file)
|
||||
prefix = task_output_prefix(ctx.task_id)
|
||||
|
||||
if ctx.log_mode == LogMode.DIRECT_FILE:
|
||||
|
||||
def log_line(message: str) -> None:
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
with ctx.log_file.open("a", encoding="utf-8") as fp:
|
||||
fp.write(f"[{ts}] {prefix} {message}\n")
|
||||
|
||||
yield log_line
|
||||
return
|
||||
|
||||
log_fp = ctx.log_file.open("a", encoding="utf-8")
|
||||
try:
|
||||
out, err = _tee_streams(log_fp)
|
||||
sys.stdout, sys.stderr = out, err
|
||||
|
||||
def log_line(message: str) -> None:
|
||||
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{ts}] {prefix} {message}", flush=True)
|
||||
|
||||
yield log_line
|
||||
finally:
|
||||
if ctx.log_mode == LogMode.REDIRECT_STD:
|
||||
sys.stdout = sys.__stdout__
|
||||
sys.stderr = sys.__stderr__
|
||||
log_fp.close()
|
||||
|
||||
|
||||
def append_raw_to_log(log_file: Path, text: str) -> None:
|
||||
"""将多行原文追加到日志(命令输出等)。"""
|
||||
if not text:
|
||||
return
|
||||
with log_file.open("a", encoding="utf-8") as fp:
|
||||
fp.write(text)
|
||||
if not text.endswith("\n"):
|
||||
fp.write("\n")
|
||||
25
lib/shumengya_cron/ssh.py
Normal file
25
lib/shumengya_cron/ssh.py
Normal file
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
远端 SSH:用 bash -lc 执行命令,使 PATH 与登录 shell 一致(非交互 ssh 常缺 /usr/local/bin 等,导致找不到 docker)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def ssh_bash_lc(host: str, remote_cmd: str) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(
|
||||
[
|
||||
"ssh",
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
host,
|
||||
"bash",
|
||||
"-lc",
|
||||
remote_cmd,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
Reference in New Issue
Block a user