From f94c0c088cbd83af0a1bbc476626f8fd6a6ade62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?dhji=28=EC=A7=80=EB=8C=80=ED=95=9C=29?= Date: Thu, 11 Jun 2026 17:06:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20log=20=EB=B0=8F=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 8 ++- CLAUDE.md | 4 +- Jenkinsfile | 108 ++++++++++++++++++++++++++++ deploy/README.md | 121 ++++++++++++++++++++++++++++++++ internal/config/config.go | 42 ++++++++--- internal/logging/logging.go | 19 ++--- internal/logging/rotate.go | 100 ++++++++++++++++++++++++++ internal/logging/rotate_test.go | 63 +++++++++++++++++ internal/server/webhooks.go | 4 +- 9 files changed, 444 insertions(+), 25 deletions(-) create mode 100644 Jenkinsfile create mode 100644 deploy/README.md create mode 100644 internal/logging/rotate.go create mode 100644 internal/logging/rotate_test.go diff --git a/.env.example b/.env.example index a7fe2e8..e1c353a 100644 --- a/.env.example +++ b/.env.example @@ -16,6 +16,10 @@ MONITOR_SLACK_CHANNEL= # Gitea webhook 설정 시 입력한 Secret. HMAC-SHA256 서명 검증에 사용. GITEA_WEBHOOK_SECRET= +# 모든 Gitea 이벤트를 DEFAULT_SLACK_CHANNEL로 브로드캐스트할지 on/off (기본 off). +# off면 담당자 개인 DM만 발송. on이면 DM + 채널 브로드캐스트 둘 다. +GITEA_BROADCAST=off + # --- Notion --- # Notion webhook subscription 검증 토큰 (서명 검증용) NOTION_VERIFICATION_TOKEN= @@ -39,8 +43,10 @@ ADDR=:8000 # 아래 셋은 비우면 환경에 따라 자동 결정됨. 명시하면 오버라이드. # LOG_LEVEL=debug # debug | info | warn | error # LOG_FORMAT=text # text | json -# LOG_FILE=logs/app.log # 경로 지정 시 stdout + 파일 동시 기록. dev에서 비우면 콘솔만, +# LOG_FILE=logs/app.log # 경로 지정 시 stdout + 날짜별 파일 동시 기록. dev에서 비우면 콘솔만, # # prod에서 비우면 자동으로 logs/app.log 사용 +# # 실제 파일은 날짜별로 회전됨: logs/app-YYYY-MM-DD.log +# LOG_RETENTION_DAYS=7 # 날짜별 로그 파일 보존 일수(오늘 포함). 기본 7일, 초과분 자동 삭제 # (선택) 저사양 환경에서 메모리 상한 가이드 — Go 런타임이 이 아래로 유지하려 함 # GOMEMLIMIT=200MiB diff --git a/CLAUDE.md b/CLAUDE.md index 30f7936..5cbd0f0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,9 @@ deploy/ # 비-Docker 배포: slack-notifier.service(system - `prod` → JSON 포맷 + info 레벨 + Gin release 모드, **`logs/app.log` 파일로 추출**(+ stdout) - `LOG_LEVEL`/`LOG_FORMAT`/`LOG_FILE`로 개별 오버라이드. 코드에선 `slog.Info/Warn/Error` 사용(표준 `log` 금지). - `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`(표준 라이브러리만, 외부 회전 의존성 없음). ## 작업 시 주의 diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..b7319d8 --- /dev/null +++ b/Jenkinsfile @@ -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 "❌ 빌드 실패 — 콘솔 로그 확인" } + } +} diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..27dc047 --- /dev/null +++ b/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' +``` diff --git a/internal/config/config.go b/internal/config/config.go index ecb3a0f..729f899 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "strconv" "strings" "github.com/joho/godotenv" @@ -16,6 +17,7 @@ type Config struct { // Gitea GiteaWebhookSecret string + GiteaBroadcast bool // true면 모든 Gitea 이벤트를 DefaultSlackChannel로 브로드캐스트(기본 off, DM만) // Notion NotionVerificationToken string @@ -27,10 +29,11 @@ type Config struct { Addr string // Logging - Env string // development | production - LogLevel string // debug | info | warn | error - LogFormat string // text | json - LogFile string // optional file path; empty = stdout only + Env string // development | production + LogLevel string // debug | info | warn | error + LogFormat string // text | json + LogFile string // optional file path; empty = stdout only (날짜별 회전의 기준 경로) + LogRetentionDays int // 날짜별 로그 파일 보존 일수 (기본 7) } // Load reads configuration for the given environment and loads its .env files. @@ -61,16 +64,18 @@ func Load(envOverride string) Config { DefaultSlackChannel: os.Getenv("DEFAULT_SLACK_CHANNEL"), MonitorChannel: os.Getenv("MONITOR_SLACK_CHANNEL"), GiteaWebhookSecret: os.Getenv("GITEA_WEBHOOK_SECRET"), + GiteaBroadcast: getenvBool("GITEA_BROADCAST", false), NotionVerificationToken: os.Getenv("NOTION_VERIFICATION_TOKEN"), NotionAPIToken: os.Getenv("NOTION_API_TOKEN"), NotionAssigneeProperty: getenv("NOTION_ASSIGNEE_PROPERTY", "담당자"), MappingFile: getenv("MAPPING_FILE", "data/mappings.json"), Addr: getenv("ADDR", ":8000"), - Env: env, - LogLevel: getenv("LOG_LEVEL", defaultLogLevel(env)), - LogFormat: getenv("LOG_FORMAT", defaultLogFormat(env)), - LogFile: getenv("LOG_FILE", defaultLogFile(env)), + Env: env, + LogLevel: getenv("LOG_LEVEL", defaultLogLevel(env)), + LogFormat: getenv("LOG_FORMAT", defaultLogFormat(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 } + +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 + } +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go index ba700e4..480a033 100644 --- a/internal/logging/logging.go +++ b/internal/logging/logging.go @@ -6,7 +6,6 @@ import ( "io" "log/slog" "os" - "path/filepath" "strings" "time" @@ -41,26 +40,20 @@ func Setup(cfg config.Config, poster AlertPoster) *slog.Logger { return logger } -// resolveWriter returns stdout (development), or stdout+file when LOG_FILE is set -// (production defaults to logs/app.log). Keeping stdout means Docker/journald still -// capture logs while the file gives a directly tail-able copy. +// resolveWriter returns stdout (development), or stdout+날짜별 파일 when LOG_FILE is set +// (production defaults to logs/app.log → logs/app-YYYY-MM-DD.log). 날짜가 바뀌면 새 파일로 +// 회전하고 LOG_RETENTION_DAYS일치만 보존한다. stdout도 유지해 Docker/journald가 함께 캡처. func resolveWriter(cfg config.Config) io.Writer { if cfg.LogFile == "" { return os.Stdout } - if dir := filepath.Dir(cfg.LogFile); dir != "" && dir != "." { - 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) + dw, err := newDailyWriter(cfg.LogFile, cfg.LogRetentionDays) 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) return os.Stdout } - return io.MultiWriter(os.Stdout, f) + return io.MultiWriter(os.Stdout, dw) } func parseLevel(s string) slog.Level { diff --git a/internal/logging/rotate.go b/internal/logging/rotate.go new file mode 100644 index 0000000..02efa6f --- /dev/null +++ b/internal/logging/rotate.go @@ -0,0 +1,100 @@ +package logging + +import ( + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +// dailyWriter는 날짜별 로그 파일(/-YYYY-MM-DD)에 기록하고, +// 날짜가 바뀌면 새 파일로 전환하며 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) + } + } +} diff --git a/internal/logging/rotate_test.go b/internal/logging/rotate_test.go new file mode 100644 index 0000000..80d99eb --- /dev/null +++ b/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)) + } +} diff --git a/internal/server/webhooks.go b/internal/server/webhooks.go index 73c3bb0..042b75d 100644 --- a/internal/server/webhooks.go +++ b/internal/server/webhooks.go @@ -37,8 +37,8 @@ func (s *Server) handleGitea(c *gin.Context) { event := c.GetHeader("X-Gitea-Event") - // 모든 이벤트(push 포함)를 기본 Slack 채널로 브로드캐스트. - if s.cfg.DefaultSlackChannel != "" { + // 모든 이벤트(push 포함)를 기본 Slack 채널로 브로드캐스트 (GITEA_BROADCAST=on 일 때만). + if s.cfg.GiteaBroadcast && s.cfg.DefaultSlackChannel != "" { if msg, ok := gitea.BuildChannelMessage(event, raw); ok { s.slack.PostMessage(c.Request.Context(), s.cfg.DefaultSlackChannel, msg.Text, msg.Blocks) }