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는 날짜별 로그 파일(