Browse Source

feat: log 및 배포 스크립트

main
지대한 2 weeks ago
parent
commit
f94c0c088c
  1. 8
      .env.example
  2. 4
      CLAUDE.md
  3. 108
      Jenkinsfile
  4. 121
      deploy/README.md
  5. 28
      internal/config/config.go
  6. 19
      internal/logging/logging.go
  7. 100
      internal/logging/rotate.go
  8. 63
      internal/logging/rotate_test.go
  9. 4
      internal/server/webhooks.go

8
.env.example

@ -16,6 +16,10 @@ MONITOR_SLACK_CHANNEL=
# Gitea webhook 설정 시 입력한 Secret. HMAC-SHA256 서명 검증에 사용. # Gitea webhook 설정 시 입력한 Secret. HMAC-SHA256 서명 검증에 사용.
GITEA_WEBHOOK_SECRET= GITEA_WEBHOOK_SECRET=
# 모든 Gitea 이벤트를 DEFAULT_SLACK_CHANNEL로 브로드캐스트할지 on/off (기본 off).
# off면 담당자 개인 DM만 발송. on이면 DM + 채널 브로드캐스트 둘 다.
GITEA_BROADCAST=off
# --- Notion --- # --- Notion ---
# Notion webhook subscription 검증 토큰 (서명 검증용) # Notion webhook subscription 검증 토큰 (서명 검증용)
NOTION_VERIFICATION_TOKEN= NOTION_VERIFICATION_TOKEN=
@ -39,8 +43,10 @@ ADDR=:8000
# 아래 셋은 비우면 환경에 따라 자동 결정됨. 명시하면 오버라이드. # 아래 셋은 비우면 환경에 따라 자동 결정됨. 명시하면 오버라이드.
# LOG_LEVEL=debug # debug | info | warn | error # LOG_LEVEL=debug # debug | info | warn | error
# LOG_FORMAT=text # text | json # LOG_FORMAT=text # text | json
# LOG_FILE=logs/app.log # 경로 지정 시 stdout + 파일 동시 기록. dev에서 비우면 콘솔만, # LOG_FILE=logs/app.log # 경로 지정 시 stdout + 날짜별 파일 동시 기록. dev에서 비우면 콘솔만,
# # prod에서 비우면 자동으로 logs/app.log 사용 # # prod에서 비우면 자동으로 logs/app.log 사용
# # 실제 파일은 날짜별로 회전됨: logs/app-YYYY-MM-DD.log
# LOG_RETENTION_DAYS=7 # 날짜별 로그 파일 보존 일수(오늘 포함). 기본 7일, 초과분 자동 삭제
# (선택) 저사양 환경에서 메모리 상한 가이드 — Go 런타임이 이 아래로 유지하려 함 # (선택) 저사양 환경에서 메모리 상한 가이드 — Go 런타임이 이 아래로 유지하려 함
# GOMEMLIMIT=200MiB # GOMEMLIMIT=200MiB

4
CLAUDE.md

@ -54,7 +54,9 @@ deploy/ # 비-Docker 배포: slack-notifier.service(system
- `prod` → JSON 포맷 + info 레벨 + Gin release 모드, **`logs/app.log` 파일로 추출**(+ stdout) - `prod` → JSON 포맷 + info 레벨 + Gin release 모드, **`logs/app.log` 파일로 추출**(+ stdout)
- `LOG_LEVEL`/`LOG_FORMAT`/`LOG_FILE`로 개별 오버라이드. 코드에선 `slog.Info/Warn/Error` 사용(표준 `log` 금지). - `LOG_LEVEL`/`LOG_FORMAT`/`LOG_FILE`로 개별 오버라이드. 코드에선 `slog.Info/Warn/Error` 사용(표준 `log` 금지).
- `LOG_FILE` 지정 시 부모 디렉터리는 자동 생성, stdout과 파일에 동시 기록. dev 기본값은 빈 값(콘솔만). - `LOG_FILE` 지정 시 부모 디렉터리는 자동 생성, stdout과 파일에 동시 기록. dev 기본값은 빈 값(콘솔만).
- 파일 회전은 앱이 하지 않음 — Docker/journald/logrotate에 위임(저사양·의존성 최소화). - 파일은 **날짜별로 회전**됨: `LOG_FILE=logs/app.log` → 실제 기록은 `logs/app-YYYY-MM-DD.log`.
날짜가 바뀌면 새 파일 생성, `LOG_RETENTION_DAYS`(기본 7)일치만 보존하고 초과분 자동 삭제.
구현은 `internal/logging/rotate.go`(표준 라이브러리만, 외부 회전 의존성 없음).
## 작업 시 주의 ## 작업 시 주의

108
Jenkinsfile vendored

@ -0,0 +1,108 @@
// slack-notifier CI/CD
// golang 컨테이너에서 빌드·테스트 → EC2(systemd)로 단일 정적 바이너리 배포.
//
// 필요한 Jenkins 구성 (자세한 건 deploy/README.md):
// - 플러그인: Go Plugin, SSH Agent
// - Global Tool Configuration → Go 에 1.26 등록(이름 = GO_TOOL 값과 일치)
// - 자격증명: "SSH Username with private key" 를 만들고 ID 를 SSH_CRED_ID 와 일치시킴 (EC2 접속 키)
// - 대상 EC2 1회 셋업: /opt/slack-notifier, .env.prod, systemd 서비스, Caddy (deploy/README.md)
//
// 호스트/유저/아키텍처는 파라미터로 조정. main 브랜치 + DEPLOY_HOST 지정 시에만 배포.
pipeline {
agent none
options {
timestamps()
disableConcurrentBuilds()
timeout(time: 20, unit: 'MINUTES')
}
parameters {
// parameters 를 바꿀 때마다 이 기본값과 environment.PARAMS_VERSION 을 함께 +1 한다.
// (파라미터는 1빌드 늦게 등록되므로, 아래 Guard 가 stale 빌드를 막는다)
string(name: 'PARAMS_VERSION', defaultValue: '1', description: '파라미터 동기화 가드용 — 직접 바꾸지 말 것')
string(name: 'DEPLOY_HOST', defaultValue: '', description: 'EC2 호스트(DNS 또는 IP). 비우면 배포 스킵(빌드/테스트만).')
string(name: 'DEPLOY_USER', defaultValue: 'ubuntu', description: 'SSH 사용자 (Ubuntu=ubuntu, Amazon Linux=ec2-user)')
string(name: 'APP_DIR', defaultValue: '/opt/slack-notifier', description: '원격 설치 경로')
choice(name: 'ARCH', choices: ['arm64', 'amd64'], description: 'EC2 아키텍처 (Graviton/t4g=arm64, x86=amd64)')
}
environment {
SSH_CRED_ID = 'slack-notifier-ec2' // Jenkins SSH 자격증명 ID (실제 ID로 교체 가능)
BIN = 'slack-notifier'
GOFLAGS = '-buildvcs=false' // .git 소유권 경고 회피
PARAMS_VERSION = '1' // ← parameters 변경 시 위 기본값과 함께 +1
}
stages {
// 파라미터 정의가 갱신되기 전(=stale)에 빌드/배포되는 것을 막는다.
// Jenkinsfile 의 step/environment 는 즉시 반영되지만 parameters 는 1빌드 늦게 등록되므로,
// "현재 코드의 PARAMS_VERSION(즉시 반영)" 과 "등록된 params.PARAMS_VERSION(지난 등록)" 이 다르면 중단.
stage('Guard: params sync') {
agent any
steps {
script {
if (params.PARAMS_VERSION != env.PARAMS_VERSION) {
error("파라미터가 아직 갱신되지 않음(stale): 등록=${params.PARAMS_VERSION}, 현재=${env.PARAMS_VERSION}. " +
"이 빌드가 파라미터를 재등록했으니, 한 번 더 빌드하면 정상 진행됩니다.")
}
}
}
}
stage('Build & Test') {
agent any
// 'go-1.26' = Global Tool Configuration 의 Go 도구 이름(다르면 여기와 일치시킬 것). 컨테이너 없음 — 경량.
tools { go 'go-1.26' }
steps {
sh 'go version'
sh 'go vet ./...'
sh 'go test ./...'
sh "CGO_ENABLED=0 GOOS=linux GOARCH=${params.ARCH} go build -ldflags='-s -w' -o ${BIN} ."
sh "ls -lh ${BIN}"
stash name: 'binary', includes: "${BIN}"
}
}
stage('Deploy') {
when {
allOf {
branch 'main'
expression { return params.DEPLOY_HOST?.trim() }
}
}
agent any
steps {
unstash 'binary'
sshagent(credentials: [env.SSH_CRED_ID]) {
sh '''
set -eu
H="${DEPLOY_USER}@${DEPLOY_HOST}"
SSH="ssh -o StrictHostKeyChecking=accept-new"
# 1) 새 바이너리를 임시 위치로 전송
scp -o StrictHostKeyChecking=accept-new "${BIN}" "$H:/tmp/${BIN}.new"
# 2) 원자적 교체 후 재시작.
# 실행 중 바이너리 덮어쓰기는 ETXTBSY 가 나므로, 같은 파일시스템에서 rename(mv) 으로 교체한다.
# (running 프로세스는 옛 inode 를 유지 → 안전)
$SSH "$H" "set -e; \
sudo mkdir -p '${APP_DIR}' '${APP_DIR}/logs' '${APP_DIR}/data'; \
sudo mv '/tmp/${BIN}.new' '${APP_DIR}/${BIN}.staged'; \
sudo chmod 0755 '${APP_DIR}/${BIN}.staged'; \
sudo mv -f '${APP_DIR}/${BIN}.staged' '${APP_DIR}/${BIN}'; \
sudo systemctl restart slack-notifier; \
sleep 1; \
sudo systemctl is-active slack-notifier"
'''
}
}
}
}
post {
success { echo "✅ ${env.BRANCH_NAME} 빌드 성공" }
failure { echo "❌ 빌드 실패 — 콘솔 로그 확인" }
}
}

121
deploy/README.md

@ -0,0 +1,121 @@
# 배포 가이드 (EC2 + systemd + Jenkins)
slack-notifier 를 **EC2(리눅스)** 에 단일 정적 바이너리로 올리고, **systemd** 로 운영하며,
**Jenkins** 로 빌드·배포를 자동화한다. 도커 레지스트리 불필요(바이너리 직접 배포).
```
[git push main] → Jenkins (golang:1.26 컨테이너) → go test/build → scp 바이너리 → EC2
→ systemctl restart slack-notifier
[Gitea/Notion] ──webhook(HTTPS)──→ Caddy(:443) ──→ slack-notifier(:8000) ──→ Slack
```
---
## 1. EC2 최초 1회 셋업
### 1-1. 설치 경로 / 환경파일
```bash
sudo mkdir -p /opt/slack-notifier/{logs,data}
sudo chown -R $USER:$USER /opt/slack-notifier # Jenkins 배포 유저가 쓸 수 있게(또는 sudo 권한)
# 비밀값/설정 — 서버에만 두고 절대 커밋하지 않음
sudo vi /opt/slack-notifier/.env.prod
# SLACK_BOT_TOKEN=xoxb-...
# DEFAULT_SLACK_CHANNEL=C... (브로드캐스트 채널, GITEA_BROADCAST=on 일 때)
# GITEA_BROADCAST=off
# MONITOR_SLACK_CHANNEL=C... (에러 알림)
# GITEA_WEBHOOK_SECRET=...
# NOTION_VERIFICATION_TOKEN=...
# NOTION_API_TOKEN=...
# LOG_RETENTION_DAYS=7
```
> systemd 서비스는 `-env prod` 로 실행되어 `.env.prod` 를 읽고, 로그는 `/opt/slack-notifier/logs/app-YYYY-MM-DD.log` 로 날짜별 회전된다.
### 1-2. systemd 서비스 등록
```bash
sudo cp deploy/slack-notifier.service /etc/systemd/system/slack-notifier.service
sudo systemctl daemon-reload
sudo systemctl enable --now slack-notifier
sudo systemctl status slack-notifier
```
서비스는 `/opt/slack-notifier/slack-notifier` 바이너리를 실행한다(첫 배포 전이면 아직 없음 → Jenkins 첫 배포 후 정상 기동).
### 1-3. HTTPS 리버스 프록시(Caddy)
웹훅은 공개 HTTPS 가 필수. Caddy 가 Let's Encrypt 인증서를 자동 발급한다.
```bash
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
sudo vi /etc/caddy/Caddyfile # your-domain.com 을 실제 도메인으로 교체
sudo systemctl restart caddy
```
- 도메인 A 레코드 → EC2 퍼블릭 IP
- 보안그룹 인바운드 **80, 443** 개방(ACME 검증 + 웹훅 수신)
- 웹훅 등록 주소: `https://your-domain.com/webhooks/{gitea,notion}`
---
## 2. Jenkins 셋업
### 2-1. 플러그인 / Go 도구
- 플러그인: **Go Plugin**, **SSH Agent**
- **Manage Jenkins → Tools → Go installations** 에서 Go 추가:
- Name: **`go-1.26`** (Jenkinsfile 의 `GO_TOOL` 과 일치, 다르면 Jenkinsfile 수정)
- "Install automatically" 로 1.26.x 선택(또는 에이전트에 설치된 Go 경로 지정)
- 컨테이너 없이 에이전트의 네이티브 Go 로 빌드 → 이미지 풀/디스크 오버헤드 없음
### 2-2. 자격증명(SSH 키)
- Jenkins → Credentials → **SSH Username with private key** 추가
- Username: EC2 SSH 유저(`ubuntu` 등)
- Private key: EC2 접속 키(.pem)
- **ID: `slack-notifier-ec2`** (Jenkinsfile 의 `SSH_CRED_ID` 와 일치, 다르면 Jenkinsfile 수정)
- 배포 유저는 `/opt/slack-notifier` 쓰기 + `systemctl restart slack-notifier` 가능해야 함
(sudoers 에 무암호 허용 권장):
```
ubuntu ALL=(root) NOPASSWD: /bin/systemctl restart slack-notifier, /bin/systemctl is-active slack-notifier, /bin/mv, /bin/mkdir, /bin/chmod
```
### 2-3. 파이프라인 잡
- **Pipeline script from SCM** → 이 저장소 → `Jenkinsfile`
- 파라미터(첫 실행 시 생성됨):
- `DEPLOY_HOST` = EC2 DNS/IP (비우면 빌드·테스트만, 배포 스킵)
- `DEPLOY_USER` = `ubuntu`(또는 `ec2-user`)
- `APP_DIR` = `/opt/slack-notifier`
- `ARCH` = `arm64`(Graviton/t4g) 또는 `amd64`(x86) ← **인스턴스 아키텍처와 반드시 일치**
배포는 **main 브랜치 + DEPLOY_HOST 지정** 시에만 수행된다.
### 2-4. 파라미터 변경 시 (stale 가드)
Jenkins 의 `parameters` 는 변경해도 **1빌드 늦게** 등록된다(step/environment 는 즉시 반영).
그 사이 옛 파라미터로 빌드·배포되는 사고를 막기 위해 `Guard: params sync` 스테이지가 있다.
`parameters {}` 를 바꿀 때 규칙:
1. 파라미터를 수정하고
2. **`parameters``PARAMS_VERSION` 기본값**과 **`environment.PARAMS_VERSION`** 을 **둘 다 +1**
3. 커밋/푸시 → 첫 빌드는 `Guard` 에서 **의도적으로 실패**(파라미터 재등록만 됨) → **한 번 더 빌드**하면 정상 진행
즉 "파라미터가 실제로 적용된 빌드"에서만 배포가 일어나도록 강제된다.
---
## 3. 배포 동작 / 롤백
- 무중단 교체: 새 바이너리를 임시 전송 후 **같은 파일시스템에서 rename** 으로 교체(실행 중 `ETXTBSY` 회피) → `systemctl restart`.
- 로그 확인: `journalctl -u slack-notifier -f` 또는 `tail -f /opt/slack-notifier/logs/app-$(date +%F).log`
- 롤백: 이전 빌드의 바이너리로 다시 배포(Jenkins "Rebuild" 또는 이전 커밋으로 빌드). 필요하면 배포 전 `cp slack-notifier slack-notifier.bak` 단계를 추가해도 됨.
---
## 4. 수동 배포(참고, Jenkins 없이)
```bash
make build-linux ARCH=arm64 # 정적 바이너리 생성
scp slack-notifier ubuntu@EC2:/tmp/sn.new
ssh ubuntu@EC2 'sudo mv /tmp/sn.new /opt/slack-notifier/slack-notifier && sudo systemctl restart slack-notifier'
```

28
internal/config/config.go

@ -2,6 +2,7 @@ package config
import ( import (
"os" "os"
"strconv"
"strings" "strings"
"github.com/joho/godotenv" "github.com/joho/godotenv"
@ -16,6 +17,7 @@ type Config struct {
// Gitea // Gitea
GiteaWebhookSecret string GiteaWebhookSecret string
GiteaBroadcast bool // true면 모든 Gitea 이벤트를 DefaultSlackChannel로 브로드캐스트(기본 off, DM만)
// Notion // Notion
NotionVerificationToken string NotionVerificationToken string
@ -30,7 +32,8 @@ type Config struct {
Env string // development | production Env string // development | production
LogLevel string // debug | info | warn | error LogLevel string // debug | info | warn | error
LogFormat string // text | json LogFormat string // text | json
LogFile string // optional file path; empty = stdout only LogFile string // optional file path; empty = stdout only (날짜별 회전의 기준 경로)
LogRetentionDays int // 날짜별 로그 파일 보존 일수 (기본 7)
} }
// Load reads configuration for the given environment and loads its .env files. // Load reads configuration for the given environment and loads its .env files.
@ -61,6 +64,7 @@ func Load(envOverride string) Config {
DefaultSlackChannel: os.Getenv("DEFAULT_SLACK_CHANNEL"), DefaultSlackChannel: os.Getenv("DEFAULT_SLACK_CHANNEL"),
MonitorChannel: os.Getenv("MONITOR_SLACK_CHANNEL"), MonitorChannel: os.Getenv("MONITOR_SLACK_CHANNEL"),
GiteaWebhookSecret: os.Getenv("GITEA_WEBHOOK_SECRET"), GiteaWebhookSecret: os.Getenv("GITEA_WEBHOOK_SECRET"),
GiteaBroadcast: getenvBool("GITEA_BROADCAST", false),
NotionVerificationToken: os.Getenv("NOTION_VERIFICATION_TOKEN"), NotionVerificationToken: os.Getenv("NOTION_VERIFICATION_TOKEN"),
NotionAPIToken: os.Getenv("NOTION_API_TOKEN"), NotionAPIToken: os.Getenv("NOTION_API_TOKEN"),
NotionAssigneeProperty: getenv("NOTION_ASSIGNEE_PROPERTY", "담당자"), NotionAssigneeProperty: getenv("NOTION_ASSIGNEE_PROPERTY", "담당자"),
@ -71,6 +75,7 @@ func Load(envOverride string) Config {
LogLevel: getenv("LOG_LEVEL", defaultLogLevel(env)), LogLevel: getenv("LOG_LEVEL", defaultLogLevel(env)),
LogFormat: getenv("LOG_FORMAT", defaultLogFormat(env)), LogFormat: getenv("LOG_FORMAT", defaultLogFormat(env)),
LogFile: getenv("LOG_FILE", defaultLogFile(env)), LogFile: getenv("LOG_FILE", defaultLogFile(env)),
LogRetentionDays: getenvInt("LOG_RETENTION_DAYS", 7),
} }
} }
@ -118,3 +123,24 @@ func getenv(key, fallback string) string {
} }
return fallback return fallback
} }
func getenvInt(key string, fallback int) int {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
if n, err := strconv.Atoi(v); err == nil {
return n
}
}
return fallback
}
// getenvBool은 on/off, true/false, 1/0, yes/no를 모두 받는다. 미설정/인식불가 시 fallback.
func getenvBool(key string, fallback bool) bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) {
case "1", "true", "t", "yes", "y", "on":
return true
case "0", "false", "f", "no", "n", "off":
return false
default:
return fallback
}
}

19
internal/logging/logging.go

@ -6,7 +6,6 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@ -41,26 +40,20 @@ func Setup(cfg config.Config, poster AlertPoster) *slog.Logger {
return logger return logger
} }
// resolveWriter returns stdout (development), or stdout+file when LOG_FILE is set // resolveWriter returns stdout (development), or stdout+날짜별 파일 when LOG_FILE is set
// (production defaults to logs/app.log). Keeping stdout means Docker/journald still // (production defaults to logs/app.log → logs/app-YYYY-MM-DD.log). 날짜가 바뀌면 새 파일로
// capture logs while the file gives a directly tail-able copy. // 회전하고 LOG_RETENTION_DAYS일치만 보존한다. stdout도 유지해 Docker/journald가 함께 캡처.
func resolveWriter(cfg config.Config) io.Writer { func resolveWriter(cfg config.Config) io.Writer {
if cfg.LogFile == "" { if cfg.LogFile == "" {
return os.Stdout return os.Stdout
} }
if dir := filepath.Dir(cfg.LogFile); dir != "" && dir != "." { dw, err := newDailyWriter(cfg.LogFile, cfg.LogRetentionDays)
if err := os.MkdirAll(dir, 0o755); err != nil {
slog.Warn("cannot create log dir, using stdout only", "dir", dir, "err", err)
return os.Stdout
}
}
f, err := os.OpenFile(cfg.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil { if err != nil {
// Can't open the file yet — log later via default; fall back to stdout. // 파일 준비 실패 — stdout으로 폴백(기본 로거로 경고).
slog.Warn("cannot open log file, using stdout only", "file", cfg.LogFile, "err", err) slog.Warn("cannot open log file, using stdout only", "file", cfg.LogFile, "err", err)
return os.Stdout return os.Stdout
} }
return io.MultiWriter(os.Stdout, f) return io.MultiWriter(os.Stdout, dw)
} }
func parseLevel(s string) slog.Level { func parseLevel(s string) slog.Level {

100
internal/logging/rotate.go

@ -0,0 +1,100 @@
package logging
import (
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// dailyWriter는 날짜별 로그 파일(<dir>/<prefix>-YYYY-MM-DD<ext>)에 기록하고,
// 날짜가 바뀌면 새 파일로 전환하며 retention일보다 오래된 파일을 삭제한다.
// 외부 의존성 없이 표준 라이브러리만 사용(저사양 타깃 정책 — 회전을 위해 lumberjack 등을 쓰지 않음).
type dailyWriter struct {
mu sync.Mutex
dir string
prefix string // 예: "app"
ext string // 예: ".log"
retention int // 보존 일수(오늘 포함)
curDate string
file *os.File
}
// newDailyWriter는 logFile(예: logs/app.log)을 기준으로 날짜별 writer를 만든다.
// → logs/app-2026-06-11.log 형태로 기록.
func newDailyWriter(logFile string, retention int) (*dailyWriter, error) {
if retention <= 0 {
retention = 7
}
base := filepath.Base(logFile)
ext := filepath.Ext(base) // ".log" (없으면 "")
w := &dailyWriter{
dir: filepath.Dir(logFile),
prefix: strings.TrimSuffix(base, ext),
ext: ext,
retention: retention,
}
if err := w.rotate(time.Now()); err != nil {
return nil, err
}
return w, nil
}
func (w *dailyWriter) filename(date string) string {
return filepath.Join(w.dir, w.prefix+"-"+date+w.ext)
}
func (w *dailyWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if err := w.rotate(time.Now()); err != nil {
return 0, err
}
return w.file.Write(p)
}
// rotate은 now 날짜의 파일을 연다. 이미 같은 날짜 파일이 열려 있으면 아무것도 안 한다.
// (newDailyWriter에서 최초 1회, 이후 Write에서 lock 보유 상태로 호출)
func (w *dailyWriter) rotate(now time.Time) error {
date := now.Format("2006-01-02")
if w.file != nil && date == w.curDate {
return nil
}
if w.dir != "" && w.dir != "." {
if err := os.MkdirAll(w.dir, 0o755); err != nil {
return err
}
}
f, err := os.OpenFile(w.filename(date), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
if w.file != nil {
_ = w.file.Close()
}
w.file = f
w.curDate = date
w.cleanup(now)
return nil
}
// cleanup은 retention일치(오늘 포함)만 남기고 오래된 날짜 파일을 삭제한다.
func (w *dailyWriter) cleanup(now time.Time) {
cutoff := now.AddDate(0, 0, -(w.retention - 1)).Format("2006-01-02")
matches, err := filepath.Glob(filepath.Join(w.dir, w.prefix+"-*"+w.ext))
if err != nil {
return
}
for _, path := range matches {
name := filepath.Base(path)
ds := strings.TrimSuffix(strings.TrimPrefix(name, w.prefix+"-"), w.ext)
if _, err := time.Parse("2006-01-02", ds); err != nil {
continue // 날짜 형식이 아니면 우리 파일이 아님 — 건드리지 않음
}
if ds < cutoff { // YYYY-MM-DD는 사전식 비교 = 날짜순 비교
_ = os.Remove(path)
}
}
}

63
internal/logging/rotate_test.go

@ -0,0 +1,63 @@
package logging
import (
"os"
"path/filepath"
"testing"
"time"
)
// 오래된 날짜 파일은 지우고 최근 retention일치만 남기는지 확인.
func TestDailyWriterCleanup(t *testing.T) {
dir := t.TempDir()
now := time.Date(2026, 6, 11, 10, 0, 0, 0, time.UTC)
// 14일치 더미 파일 생성 (app-YYYY-MM-DD.log)
for i := 0; i < 14; i++ {
d := now.AddDate(0, 0, -i).Format("2006-01-02")
if err := os.WriteFile(filepath.Join(dir, "app-"+d+".log"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
}
// 우리 형식이 아닌 파일은 보존돼야 함
keep := filepath.Join(dir, "other.txt")
_ = os.WriteFile(keep, []byte("x"), 0o644)
w := &dailyWriter{dir: dir, prefix: "app", ext: ".log", retention: 7}
w.cleanup(now)
// 오늘 포함 7일치(today..today-6)만 남아야 함
for i := 0; i < 14; i++ {
d := now.AddDate(0, 0, -i).Format("2006-01-02")
_, err := os.Stat(filepath.Join(dir, "app-"+d+".log"))
if i < 7 && err != nil {
t.Errorf("day-%d (%s) 가 삭제됨 — 보존돼야 함", i, d)
}
if i >= 7 && err == nil {
t.Errorf("day-%d (%s) 가 남아있음 — 삭제돼야 함", i, d)
}
}
if _, err := os.Stat(keep); err != nil {
t.Errorf("형식이 다른 파일 other.txt 가 삭제됨 — 보존돼야 함")
}
}
// Write가 날짜 파일을 만들고 거기에 기록하는지 확인.
func TestDailyWriterWrite(t *testing.T) {
dir := t.TempDir()
w, err := newDailyWriter(filepath.Join(dir, "app.log"), 7)
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte("hello\n")); err != nil {
t.Fatal(err)
}
today := time.Now().Format("2006-01-02")
data, err := os.ReadFile(filepath.Join(dir, "app-"+today+".log"))
if err != nil {
t.Fatalf("오늘 날짜 파일이 없음: %v", err)
}
if string(data) != "hello\n" {
t.Errorf("기록 내용 불일치: %q", string(data))
}
}

4
internal/server/webhooks.go

@ -37,8 +37,8 @@ func (s *Server) handleGitea(c *gin.Context) {
event := c.GetHeader("X-Gitea-Event") event := c.GetHeader("X-Gitea-Event")
// 모든 이벤트(push 포함)를 기본 Slack 채널로 브로드캐스트. // 모든 이벤트(push 포함)를 기본 Slack 채널로 브로드캐스트 (GITEA_BROADCAST=on 일 때만).
if s.cfg.DefaultSlackChannel != "" { if s.cfg.GiteaBroadcast && s.cfg.DefaultSlackChannel != "" {
if msg, ok := gitea.BuildChannelMessage(event, raw); ok { if msg, ok := gitea.BuildChannelMessage(event, raw); ok {
s.slack.PostMessage(c.Request.Context(), s.cfg.DefaultSlackChannel, msg.Text, msg.Blocks) s.slack.PostMessage(c.Request.Context(), s.cfg.DefaultSlackChannel, msg.Text, msg.Blocks)
} }

Loading…
Cancel
Save