restic 봇 생성
This commit is contained in:
parent
ee761d3568
commit
97a3158c10
6 changed files with 389 additions and 0 deletions
13
restic/.env
Normal file
13
restic/.env
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# restic 백업 알림 봇 설정
|
||||||
|
|
||||||
|
# 디스코드 봇 토큰 (Bot 페이지에서 발급)
|
||||||
|
DISCORD_BOT_TOKEN=MTQ1MDQyMDEwMDc3NDYyNTQyMg.GLoqvA.QO9E6E2s5uLCQfl4a_OZR6A_PVKjwcQ7KwsMAM
|
||||||
|
|
||||||
|
# 성공 알림 채널 ID
|
||||||
|
DISCORD_SUCCESS_CHANNEL_ID=1450289742175408279
|
||||||
|
|
||||||
|
# 실패 알림 채널 ID
|
||||||
|
DISCORD_FAILURE_CHANNEL_ID=1450289802090909716
|
||||||
|
|
||||||
|
# 웹훅 서버 포트
|
||||||
|
WEBHOOK_PORT=5001
|
||||||
12
restic/Dockerfile
Normal file
12
restic/Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM python:3.14.2-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 의존성 설치
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 소스 복사
|
||||||
|
COPY main.py .
|
||||||
|
|
||||||
|
CMD ["python", "main.py"]
|
||||||
86
restic/README.md
Normal file
86
restic/README.md
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
# 📦 Restic Backup Discord Bot
|
||||||
|
|
||||||
|
restic 백업 결과를 디스코드로 알려주는 봇입니다.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 주요 기능
|
||||||
|
|
||||||
|
- ✅ **성공 알림** - 백업 성공 시 지정 채널로 알림
|
||||||
|
- ❌ **실패 알림** - 백업 실패 시 별도 채널로 알림
|
||||||
|
- 📊 **상세 정보** - 파일 수, 크기, 소요 시간, 스냅샷 ID 표시
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 기술 스택
|
||||||
|
|
||||||
|
| 기술 | 설명 |
|
||||||
|
| -------------- | ---------------- |
|
||||||
|
| **Python** | 런타임 |
|
||||||
|
| **discord.py** | 디스코드 API |
|
||||||
|
| **aiohttp** | 비동기 HTTP 서버 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 웹훅 엔드포인트
|
||||||
|
|
||||||
|
| 엔드포인트 | 설명 |
|
||||||
|
| --------------- | -------------- |
|
||||||
|
| `POST /success` | 백업 성공 알림 |
|
||||||
|
| `POST /failure` | 백업 실패 알림 |
|
||||||
|
|
||||||
|
### 요청 형식
|
||||||
|
|
||||||
|
**성공:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"files": "1382",
|
||||||
|
"size": "316.110 MiB",
|
||||||
|
"duration": "0:01",
|
||||||
|
"snapshot_id": "8042b460"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**실패:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": "에러 메시지"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 실행 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 환경 변수
|
||||||
|
|
||||||
|
| 변수 | 설명 |
|
||||||
|
| ---------------------------- | ----------------- |
|
||||||
|
| `DISCORD_BOT_TOKEN` | 디스코드 봇 토큰 |
|
||||||
|
| `DISCORD_SUCCESS_CHANNEL_ID` | 성공 알림 채널 ID |
|
||||||
|
| `DISCORD_FAILURE_CHANNEL_ID` | 실패 알림 채널 ID |
|
||||||
|
| `WEBHOOK_PORT` | 웹훅 포트 (5001) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
restic/
|
||||||
|
├── main.py # 봇 메인 로직
|
||||||
|
├── docker-compose.yml # Docker Compose 설정
|
||||||
|
├── Dockerfile # 컨테이너 빌드
|
||||||
|
├── requirements.txt # Python 의존성
|
||||||
|
└── .env # 환경 변수
|
||||||
|
```
|
||||||
20
restic/docker-compose.yml
Normal file
20
restic/docker-compose.yml
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
services:
|
||||||
|
discord-bot-restic:
|
||||||
|
build: .
|
||||||
|
container_name: discord-bot-restic
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
ports:
|
||||||
|
# 호스트 restic 스크립트 접근용 (localhost만)
|
||||||
|
- "127.0.0.1:5001:5001"
|
||||||
|
networks:
|
||||||
|
- bot
|
||||||
|
labels:
|
||||||
|
- "com.centurylinklabs.watchtower.enable=false"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
bot:
|
||||||
|
external: true
|
||||||
256
restic/main.py
Normal file
256
restic/main.py
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
"""
|
||||||
|
restic 백업 디스코드 알림 봇
|
||||||
|
- 백업 성공/실패 시 각각 다른 채널로 알림
|
||||||
|
- HTTP 웹훅으로 알림 수신
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import discord
|
||||||
|
from discord.ext import commands
|
||||||
|
from aiohttp import web
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# 환경변수
|
||||||
|
BOT_TOKEN = os.getenv("DISCORD_BOT_TOKEN")
|
||||||
|
SUCCESS_CHANNEL_ID = int(os.getenv("DISCORD_SUCCESS_CHANNEL_ID", "0"))
|
||||||
|
FAILURE_CHANNEL_ID = int(os.getenv("DISCORD_FAILURE_CHANNEL_ID", "0"))
|
||||||
|
WEBHOOK_PORT = int(os.getenv("WEBHOOK_PORT", "5001"))
|
||||||
|
|
||||||
|
# 봇 설정
|
||||||
|
intents = discord.Intents.default()
|
||||||
|
bot = commands.Bot(command_prefix="!", intents=intents)
|
||||||
|
|
||||||
|
|
||||||
|
def format_size(size_bytes: int) -> str:
|
||||||
|
"""바이트를 읽기 쉬운 형식으로 변환"""
|
||||||
|
for unit in ["B", "KB", "MB", "GB", "TB"]:
|
||||||
|
if size_bytes < 1024:
|
||||||
|
return f"{size_bytes:.2f} {unit}"
|
||||||
|
size_bytes /= 1024
|
||||||
|
return f"{size_bytes:.2f} PB"
|
||||||
|
|
||||||
|
|
||||||
|
@bot.event
|
||||||
|
async def on_ready():
|
||||||
|
"""봇 시작 시 실행"""
|
||||||
|
print(f"✅ 봇 로그인: {bot.user}")
|
||||||
|
try:
|
||||||
|
synced = await bot.tree.sync()
|
||||||
|
print(f"✅ {len(synced)}개 명령어 동기화 완료")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 명령어 동기화 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_success_notification(data: dict):
|
||||||
|
"""백업 성공 알림 전송"""
|
||||||
|
if not SUCCESS_CHANNEL_ID:
|
||||||
|
print("❌ SUCCESS_CHANNEL_ID가 설정되지 않음")
|
||||||
|
return
|
||||||
|
|
||||||
|
channel = bot.get_channel(SUCCESS_CHANNEL_ID)
|
||||||
|
if not channel:
|
||||||
|
print(f"❌ 채널을 찾을 수 없음: {SUCCESS_CHANNEL_ID}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 스냅샷 ID 축약 (앞 8자리만)
|
||||||
|
snapshot_id = data.get('snapshot_id', 'N/A')
|
||||||
|
if len(snapshot_id) > 8:
|
||||||
|
snapshot_id = snapshot_id[:8]
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="✅ 백업 완료",
|
||||||
|
color=0x2ecc71, # 초록색
|
||||||
|
timestamp=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 통계 정보
|
||||||
|
stats = f"📁 **{data.get('files', '0')}** 파일 • 💾 **{data.get('size', 'N/A')}** • ⏱️ **{data.get('duration', 'N/A')}**"
|
||||||
|
embed.description = stats
|
||||||
|
|
||||||
|
# 백업 폴더 목록 (1024자 제한)
|
||||||
|
folders = data.get('folders', [])
|
||||||
|
if folders:
|
||||||
|
folder_list = " ".join([f"`{f}`" for f in folders])
|
||||||
|
# 디스코드 필드 길이 제한 적용
|
||||||
|
if len(folder_list) > 1000:
|
||||||
|
folder_list = folder_list[:1000] + "..."
|
||||||
|
embed.add_field(name="📂 백업 대상", value=folder_list, inline=False)
|
||||||
|
|
||||||
|
# 하단 정보
|
||||||
|
embed.set_footer(text=f"🆔 {snapshot_id} • 🖥️ {os.uname().nodename}")
|
||||||
|
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
print(f"✅ 성공 알림 전송")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 알림 전송 실패: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_failure_notification(data: dict):
|
||||||
|
"""백업 실패 알림 전송"""
|
||||||
|
if not FAILURE_CHANNEL_ID:
|
||||||
|
print("❌ FAILURE_CHANNEL_ID가 설정되지 않음")
|
||||||
|
return
|
||||||
|
|
||||||
|
channel = bot.get_channel(FAILURE_CHANNEL_ID)
|
||||||
|
if not channel:
|
||||||
|
print(f"❌ 채널을 찾을 수 없음: {FAILURE_CHANNEL_ID}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
error_msg = data.get('error', 'Unknown error')
|
||||||
|
# 에러 메시지 길이 제한
|
||||||
|
if len(error_msg) > 500:
|
||||||
|
error_msg = error_msg[:500] + "..."
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="🛑 백업 실패",
|
||||||
|
color=0xe74c3c, # 빨간색
|
||||||
|
timestamp=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
# 에러 내용
|
||||||
|
embed.description = f"```\n{error_msg}\n```"
|
||||||
|
|
||||||
|
# 하단 정보
|
||||||
|
embed.set_footer(text=f"🖥️ {os.uname().nodename}")
|
||||||
|
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
print(f"✅ 실패 알림 전송")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 실패 알림 전송 오류: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# HTTP 웹훅 핸들러
|
||||||
|
async def success_handler(request):
|
||||||
|
"""백업 성공 웹훅"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
await send_success_notification(data)
|
||||||
|
return web.Response(text="OK")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 성공 웹훅 오류: {e}")
|
||||||
|
return web.Response(text="Error", status=500)
|
||||||
|
|
||||||
|
|
||||||
|
async def failure_handler(request):
|
||||||
|
"""백업 실패 웹훅"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
await send_failure_notification(data)
|
||||||
|
return web.Response(text="OK")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 실패 웹훅 오류: {e}")
|
||||||
|
return web.Response(text="Error", status=500)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_sync_success_notification(data: dict):
|
||||||
|
"""동기화 성공 알림 전송"""
|
||||||
|
if not SUCCESS_CHANNEL_ID:
|
||||||
|
return
|
||||||
|
|
||||||
|
channel = bot.get_channel(SUCCESS_CHANNEL_ID)
|
||||||
|
if not channel:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="✅ 동기화 완료",
|
||||||
|
color=0x3498db, # 파란색
|
||||||
|
timestamp=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
stats = f"💾 **{data.get('size', 'N/A')}** • ⏱️ **{data.get('duration', 'N/A')}**"
|
||||||
|
embed.description = stats
|
||||||
|
embed.add_field(name="📍 대상", value=f"`{data.get('target', 'N/A')}`", inline=False)
|
||||||
|
embed.set_footer(text=f"🖥️ {os.uname().nodename}")
|
||||||
|
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
print(f"✅ 동기화 성공 알림 전송")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 동기화 성공 알림 전송 오류: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def send_sync_failure_notification(data: dict):
|
||||||
|
"""동기화 실패 알림 전송"""
|
||||||
|
if not FAILURE_CHANNEL_ID:
|
||||||
|
return
|
||||||
|
|
||||||
|
channel = bot.get_channel(FAILURE_CHANNEL_ID)
|
||||||
|
if not channel:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
error_msg = data.get('error', 'Unknown error')
|
||||||
|
if len(error_msg) > 500:
|
||||||
|
error_msg = error_msg[:500] + "..."
|
||||||
|
|
||||||
|
embed = discord.Embed(
|
||||||
|
title="🛑 동기화 실패",
|
||||||
|
color=0xe74c3c,
|
||||||
|
timestamp=datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
embed.description = f"```\n{error_msg}\n```"
|
||||||
|
embed.add_field(name="📍 대상", value=f"`{data.get('target', 'N/A')}`", inline=False)
|
||||||
|
embed.set_footer(text=f"🖥️ {os.uname().nodename}")
|
||||||
|
|
||||||
|
await channel.send(embed=embed)
|
||||||
|
print(f"✅ 동기화 실패 알림 전송")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 동기화 실패 알림 전송 오류: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_success_handler(request):
|
||||||
|
"""동기화 성공 웹훅"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
await send_sync_success_notification(data)
|
||||||
|
return web.Response(text="OK")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 동기화 성공 웹훅 오류: {e}")
|
||||||
|
return web.Response(text="Error", status=500)
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_failure_handler(request):
|
||||||
|
"""동기화 실패 웹훅"""
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
await send_sync_failure_notification(data)
|
||||||
|
return web.Response(text="OK")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ 동기화 실패 웹훅 오류: {e}")
|
||||||
|
return web.Response(text="Error", status=500)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_webhook_server():
|
||||||
|
"""웹훅 서버 시작"""
|
||||||
|
app = web.Application()
|
||||||
|
app.router.add_post("/success", success_handler)
|
||||||
|
app.router.add_post("/failure", failure_handler)
|
||||||
|
app.router.add_post("/sync-success", sync_success_handler)
|
||||||
|
app.router.add_post("/sync-failure", sync_failure_handler)
|
||||||
|
|
||||||
|
runner = web.AppRunner(app)
|
||||||
|
await runner.setup()
|
||||||
|
site = web.TCPSite(runner, "0.0.0.0", WEBHOOK_PORT)
|
||||||
|
await site.start()
|
||||||
|
print(f"✅ 웹훅 서버 시작: http://0.0.0.0:{WEBHOOK_PORT}")
|
||||||
|
print(f" - 백업 성공: POST /success")
|
||||||
|
print(f" - 백업 실패: POST /failure")
|
||||||
|
print(f" - 동기화 성공: POST /sync-success")
|
||||||
|
print(f" - 동기화 실패: POST /sync-failure")
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""메인 함수"""
|
||||||
|
# 웹훅 서버 시작
|
||||||
|
await start_webhook_server()
|
||||||
|
|
||||||
|
# 봇 시작
|
||||||
|
await bot.start(BOT_TOKEN)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
2
restic/requirements.txt
Normal file
2
restic/requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
discord.py==2.6.4
|
||||||
|
aiohttp==3.13.2
|
||||||
Loading…
Add table
Reference in a new issue