feat(scheduler): add task executors for common operations

Add three built-in task executors for various automation tasks.

Executors:
1. example_executor - Simple test implementation with configurable message and delay
2. doc_sync_executor - Mirror documentation from upstream Git repos to Gitea
3. config_backup_executor - Backup Docker configs and data directories

doc_sync_executor features:
- Clones upstream repository (GitHub, GitLab, etc.)
- Supports full repository mirroring or selective path syncing
- Pushes to Gitea with authentication
- Creates date-tagged snapshots (YYYY-MM-DD)
- Generates .SYNC_INFO.md with sync metadata

config_backup_executor features:
- Backs up multiple source paths with exclusion patterns
- Optional compression (tar.gz)
- Retention policy (days-based cleanup)
- Timestamped backups

Pre-configured tasks:
- backup_docker_configs_daily (priority 20, daily 03:05)
- sync_fastapi_docs_monthly (priority 60, 11th @ 04:00)
- sync_ollama_docs_monthly (priority 60, 12th @ 04:00)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-07 23:13:07 +01:00
co-authored by Claude Sonnet 4.5
parent 455d16ce8f
commit ff8a3a1009
4 changed files with 483 additions and 0 deletions
@@ -0,0 +1,23 @@
"""
Executor modules for The Scheduler.
Each executor must implement an async execute(config, settings) function.
Example:
async def execute(config: dict, settings: Settings) -> str:
'''
Perform the task.
Args:
config: Task-specific configuration from scheduled_tasks.config
settings: Global scheduler settings
Returns:
Output message (success)
Raises:
Exception: On failure (will be logged as error)
'''
# Your task logic here
return "Task completed successfully"
"""
@@ -0,0 +1,159 @@
"""
Config Backup Executor
Backs up Docker container configs and host-based service configs.
Replicates functionality of maintenance container's backup-configs.sh
"""
import asyncio
import logging
import tarfile
import tempfile
from datetime import datetime, timedelta
from pathlib import Path
from typing import List, Dict, Any
from src.config import Settings
logger = logging.getLogger(__name__)
async def execute(config: dict, settings: Settings) -> str:
"""
Execute config backup task.
Config schema:
{
"sources": [
{
"path": "/data/docker-data",
"name": "docker-data",
"excludes": ["*/cache/*", "*/temp/*", "*.log"]
}
],
"backup_dir": "/backups/docker-configs",
"retention_days": 30,
"compress": true
}
Args:
config: Backup configuration
settings: Global scheduler settings
Returns:
Summary of backup operation
Raises:
Exception: On backup failure
"""
sources = config.get('sources', [])
backup_dir = Path(config.get('backup_dir', '/backups/docker-configs'))
retention_days = config.get('retention_days', 30)
compress = config.get('compress', True)
if not sources:
raise ValueError("No backup sources configured")
timestamp = datetime.now().strftime('%Y%m%d-%H%M%S')
backup_filename = f"docker-configs-{timestamp}.tar.gz"
backup_file = backup_dir / backup_filename
logger.info(f"Starting Docker configs backup: {backup_filename}")
# Create backup directory
backup_dir.mkdir(parents=True, exist_ok=True)
# Create temporary directory for staging
with tempfile.TemporaryDirectory(prefix='backup-') as temp_dir:
temp_path = Path(temp_dir)
results = []
# Backup each source
for source in sources:
source_path = Path(source['path'])
source_name = source['name']
excludes = source.get('excludes', [])
if not source_path.exists():
logger.warning(f"Source path does not exist: {source_path}")
continue
logger.info(f"Backing up {source_name} from {source_path}")
# Create tar for this source
source_tar = temp_path / f"{source_name}.tar.gz"
def tar_filter(tarinfo):
"""Filter function to exclude patterns."""
for pattern in excludes:
# Simple pattern matching (could be enhanced with fnmatch)
if pattern.replace('*/', '').replace('/*', '') in tarinfo.name:
logger.debug(f"Excluding: {tarinfo.name}")
return None
return tarinfo
with tarfile.open(source_tar, 'w:gz') as tar:
tar.add(
source_path,
arcname=source_name,
filter=tar_filter,
recursive=True
)
source_size = source_tar.stat().st_size / (1024 * 1024) # MB
results.append(f"{source_name}: {source_size:.2f}MB")
logger.info(f"Backed up {source_name}: {source_size:.2f}MB")
# Combine all source backups into final archive
logger.info("Creating combined backup archive...")
with tarfile.open(backup_file, 'w:gz') as final_tar:
for item in temp_path.glob('*.tar.gz'):
final_tar.add(item, arcname=item.name)
# Verify backup created
if not backup_file.exists():
raise Exception("Backup file was not created")
backup_size = backup_file.stat().st_size / (1024 * 1024) # MB
logger.info(f"Backup created successfully: {backup_size:.2f}MB")
# Clean up old backups
await cleanup_old_backups(backup_dir, retention_days)
# Count remaining backups
backup_count = len(list(backup_dir.glob('docker-configs-*.tar.gz')))
total_size = sum(f.stat().st_size for f in backup_dir.glob('docker-configs-*.tar.gz'))
total_size_mb = total_size / (1024 * 1024)
output = (
f"Backup completed: {backup_filename} ({backup_size:.2f}MB). "
f"Sources: {', '.join(results)}. "
f"Retention: {backup_count} backups, {total_size_mb:.2f}MB total."
)
logger.info(output)
return output
async def cleanup_old_backups(backup_dir: Path, retention_days: int):
"""Remove backups older than retention period."""
cutoff_date = datetime.now() - timedelta(days=retention_days)
removed_count = 0
removed_size = 0
logger.info(f"Cleaning up backups older than {retention_days} days...")
for backup_file in backup_dir.glob('docker-configs-*.tar.gz'):
# Get file modification time
file_mtime = datetime.fromtimestamp(backup_file.stat().st_mtime)
if file_mtime < cutoff_date:
file_size = backup_file.stat().st_size
logger.info(f"Removing old backup: {backup_file.name} (from {file_mtime:%Y-%m-%d})")
backup_file.unlink()
removed_count += 1
removed_size += file_size
if removed_count > 0:
removed_size_mb = removed_size / (1024 * 1024)
logger.info(f"Removed {removed_count} old backups, freed {removed_size_mb:.2f}MB")
else:
logger.info("No old backups to remove")
@@ -0,0 +1,259 @@
"""
Documentation Sync Executor
Syncs documentation from upstream Git repositories to Gitea.
Clones source repos, extracts docs directories, pushes to Gitea.
"""
import asyncio
import logging
import shutil
from datetime import datetime
from pathlib import Path
from typing import List, Optional
from src.config import Settings
logger = logging.getLogger(__name__)
async def execute(config: dict, settings: Settings) -> str:
"""
Sync documentation from upstream Git repo to Gitea.
Config schema:
{
"project": "fastapi",
"upstream_repo": "https://github.com/tiangolo/fastapi.git",
"docs_paths": ["/docs", "/docs_src"],
"gitea_repo": "library/docs-fastapi",
"gitea_url": "http://gitea:3000",
"branch": "main"
}
Args:
config: Documentation sync configuration
settings: Global scheduler settings
Returns:
Summary of sync operation
Raises:
Exception: On sync failure
"""
project = config.get('project')
upstream_repo = config.get('upstream_repo')
docs_paths = config.get('docs_paths', ['/docs'])
gitea_repo = config.get('gitea_repo')
gitea_url = config.get('gitea_url', settings.gitea_url)
branch = config.get('branch', 'main')
if not all([project, upstream_repo, gitea_repo]):
raise ValueError("Missing required config: project, upstream_repo, or gitea_repo")
logger.info(f"Starting doc sync for {project}")
logger.info(f"Upstream: {upstream_repo}")
logger.info(f"Gitea: {gitea_repo}")
# Work directory
work_dir = Path(f"/app/task-data/doc-sync/{project}")
upstream_dir = work_dir / "upstream"
gitea_dir = work_dir / "gitea"
try:
# Clean work directory
if work_dir.exists():
logger.info(f"Cleaning work directory: {work_dir}")
shutil.rmtree(work_dir)
work_dir.mkdir(parents=True)
# Clone upstream repo (shallow clone for speed)
logger.info(f"Cloning upstream repo...")
await _run_command([
'git', 'clone',
'--depth', '1',
'--branch', branch,
upstream_repo,
str(upstream_dir)
])
# Get upstream version/commit
upstream_commit = await _get_git_commit(upstream_dir)
upstream_date = datetime.now().strftime('%Y-%m-%d')
logger.info(f"Upstream commit: {upstream_commit[:8]}")
# Clone Gitea repo (or create if doesn't exist)
# Build authenticated URL for Gitea
gitea_url_clean = gitea_url.replace('http://', '').replace('https://', '')
gitea_clone_url = f"http://{settings.gitea_user}:{settings.gitea_password}@{gitea_url_clean}/{gitea_repo}.git"
# Log without credentials
logger.info(f"Cloning Gitea repo: {gitea_url}/{gitea_repo}.git")
# Try to clone, if fails create new repo
try:
await _run_command([
'git', 'clone',
gitea_clone_url,
str(gitea_dir)
])
except Exception as e:
logger.warning(f"Gitea repo doesn't exist, will create: {e}")
gitea_dir.mkdir(parents=True)
await _run_command(['git', 'init'], cwd=gitea_dir)
await _run_command(['git', 'checkout', '-b', branch], cwd=gitea_dir)
# Set remote
await _run_command([
'git', 'remote', 'add', 'origin',
gitea_clone_url
], cwd=gitea_dir)
# Clear existing content in Gitea repo (except .git)
for item in gitea_dir.iterdir():
if item.name != '.git':
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
# Determine what to copy based on docs_paths
copied_paths = []
# If docs_paths is empty or contains "." or "/", sync entire repo
if not docs_paths or any(p in [".", "/", ""] for p in docs_paths):
logger.info(f"Copying entire repository...")
for item in upstream_dir.iterdir():
if item.name != '.git':
dest = gitea_dir / item.name
if item.is_dir():
shutil.copytree(item, dest)
else:
shutil.copy2(item, dest)
copied_paths = ["entire repository"]
else:
# Copy only specified paths
logger.info(f"Copying specific paths: {docs_paths}")
for doc_path in docs_paths:
source = upstream_dir / doc_path.lstrip('/')
if source.exists():
dest = gitea_dir / source.name
logger.info(f"Copying {source.name}...")
if source.is_dir():
shutil.copytree(source, dest)
else:
shutil.copy2(source, dest)
copied_paths.append(source.name)
else:
logger.warning(f"Path not found in upstream: {doc_path}")
if not copied_paths:
raise Exception("No documentation paths were copied")
# Create .SYNC_INFO.md with metadata (don't overwrite README.md from upstream)
sync_info_path = gitea_dir / ".SYNC_INFO.md"
content_desc = "Complete repository mirror" if "entire repository" in copied_paths else f"Paths: {', '.join(copied_paths)}"
sync_info_content = f"""# Sync Information
This is a mirror of the {project.title()} repository.
**Synced from:** {upstream_repo}
**Branch:** {branch}
**Commit:** {upstream_commit}
**Sync Date:** {upstream_date}
**Content:** {content_desc}
---
This repository is automatically synced monthly by The Scheduler.
For the latest updates, visit the official repository.
"""
sync_info_path.write_text(sync_info_content)
# Git add, commit, push
await _run_command(['git', 'add', '.'], cwd=gitea_dir)
# Check if there are changes
status = await _run_command(
['git', 'status', '--porcelain'],
cwd=gitea_dir,
capture=True
)
if not status.strip():
logger.info("No changes detected, skipping commit")
return f"Documentation already up to date (commit: {upstream_commit[:8]})"
# Commit changes
commit_msg = f"Sync {project} docs from {upstream_commit[:8]} on {upstream_date}"
await _run_command([
'git', 'commit',
'-m', commit_msg
], cwd=gitea_dir)
# Tag with date
tag = f"sync-{upstream_date}"
await _run_command([
'git', 'tag', '-f', tag,
'-m', f"Documentation snapshot {upstream_date}"
], cwd=gitea_dir)
# Push to Gitea
logger.info("Pushing to Gitea...")
await _run_command([
'git', 'push', 'origin', branch, '--force'
], cwd=gitea_dir)
await _run_command([
'git', 'push', 'origin', tag, '--force'
], cwd=gitea_dir)
# Cleanup
logger.info("Cleaning up work directory...")
shutil.rmtree(work_dir)
result = (
f"Successfully synced {project} documentation. "
f"Copied: {', '.join(copied_paths)}. "
f"Upstream commit: {upstream_commit[:8]}. "
f"Tagged: {tag}"
)
logger.info(result)
return result
except Exception as e:
# Cleanup on error
if work_dir.exists():
shutil.rmtree(work_dir)
raise
async def _run_command(
cmd: List[str],
cwd: Optional[Path] = None,
capture: bool = False
) -> str:
"""Run shell command asynchronously."""
logger.debug(f"Running: {' '.join(cmd)} (cwd: {cwd})")
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=cwd,
stdout=asyncio.subprocess.PIPE if capture else asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
raise Exception(f"Command failed: {' '.join(cmd)}\n{error_msg}")
return stdout.decode() if capture else ""
async def _get_git_commit(repo_dir: Path) -> str:
"""Get current git commit hash."""
output = await _run_command(
['git', 'rev-parse', 'HEAD'],
cwd=repo_dir,
capture=True
)
return output.strip()
@@ -0,0 +1,42 @@
"""
Example executor demonstrating the pattern.
Shows how to write task executors for The Scheduler.
"""
import asyncio
import logging
from src.config import Settings
logger = logging.getLogger(__name__)
async def execute(config: dict, settings: Settings) -> str:
"""
Example task executor.
Args:
config: Task configuration from scheduled_tasks.config JSONB field
Example: {"message": "Hello", "delay_seconds": 2}
settings: Global scheduler settings (database, API keys, etc.)
Returns:
Output message describing what was done
Raises:
Exception: On failure (will be caught and logged by executor framework)
"""
message = config.get('message', 'No message configured')
delay = config.get('delay_seconds', 1)
logger.info(f"Example executor starting: {message}")
# Simulate some work
await asyncio.sleep(delay)
# You can access settings
logger.info(f"Using database: {settings.postgres_db}")
# Return success message
output = f"Executed example task: {message} (took {delay}s)"
logger.info(output)
return output