diff --git a/Dockerfile-refactored b/Dockerfile-refactored new file mode 100644 index 0000000..10b7ef0 --- /dev/null +++ b/Dockerfile-refactored @@ -0,0 +1,51 @@ +# Многоэтапная сборка для Go приложения +FROM golang:1.21-alpine AS builder + +# Устанавливаем необходимые пакеты для сборки +RUN apk add --no-cache git ca-certificates tzdata + +# Устанавливаем рабочую директорию +WORKDIR /app + +# Копируем go mod файлы +COPY go.mod go.sum ./ + +# Скачиваем зависимости +RUN go mod download + +# Копируем исходный код +COPY . . + +# Собираем приложение из cmd/server +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main ./cmd/server + +# Финальный образ +FROM alpine:latest + +# Устанавливаем необходимые пакеты для работы +RUN apk --no-cache add ca-certificates git tzdata + +# Создаем пользователя для безопасности +RUN addgroup -g 1001 -S appgroup && \ + adduser -u 1001 -S appuser -G appgroup + +# Устанавливаем рабочую директорию +WORKDIR /app + +# Копируем собранное приложение из builder этапа +COPY --from=builder /app/main . + +# Копируем конфигурационные файлы +COPY --from=builder /app/configs ./configs + +# Меняем владельца файлов +RUN chown -R appuser:appgroup /app + +# Переключаемся на непривилегированного пользователя +USER appuser + +# Открываем порт +EXPOSE 3000 + +# Запускаем приложение +CMD ["./main"] diff --git a/Makefile-refactored b/Makefile-refactored new file mode 100644 index 0000000..10a77da --- /dev/null +++ b/Makefile-refactored @@ -0,0 +1,155 @@ +.PHONY: help build run test clean docker-build docker-run docker-stop install-deps + +# Переменные +BINARY_NAME=go-webhook-server +DOCKER_IMAGE=go-webhook-server +DOCKER_CONTAINER=go-webhook-server +BUILD_DIR=cmd/server + +# Помощь +help: ## Показать справку по командам + @echo "Доступные команды:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +# Установка зависимостей +install-deps: ## Установить Go зависимости + go mod download + go mod tidy + +# Сборка +build: install-deps ## Собрать бинарный файл + @echo "Сборка $(BINARY_NAME)..." + go build -o $(BINARY_NAME) $(BUILD_DIR)/main.go + @echo "Сборка завершена: $(BINARY_NAME)" + +# Запуск +run: build ## Запустить сервис локально + @echo "Запуск $(BINARY_NAME)..." + ./$(BINARY_NAME) + +# Запуск без сборки +run-dev: ## Запустить сервис в режиме разработки + @echo "Запуск в режиме разработки..." + cd $(BUILD_DIR) && go run main.go + +# Тестирование +test: install-deps ## Запустить тесты + @echo "Запуск тестов..." + go test -v ./internal/... + go test -v ./pkg/... + +# Тестирование с покрытием +test-coverage: install-deps ## Запустить тесты с покрытием + @echo "Запуск тестов с покрытием..." + go test -v -coverprofile=coverage.out ./internal/... + go test -v -coverprofile=pkg-coverage.out ./pkg/... + go tool cover -html=coverage.out -o coverage.html + go tool cover -html=pkg-coverage.out -o pkg-coverage.html + @echo "Отчеты покрытия созданы: coverage.html, pkg-coverage.html" + +# Очистка +clean: ## Очистить собранные файлы + @echo "Очистка..." + rm -f $(BINARY_NAME) + rm -f coverage.out pkg-coverage.out + rm -f coverage.html pkg-coverage.html + @echo "Очистка завершена" + +# Docker команды +docker-build: ## Собрать Docker образ + @echo "Сборка Docker образа..." + docker build -t $(DOCKER_IMAGE) . + @echo "Docker образ собран: $(DOCKER_IMAGE)" + +docker-run: docker-build ## Запустить Docker контейнер + @echo "Запуск Docker контейнера..." + docker run -d \ + --name $(DOCKER_CONTAINER) \ + -p 3000:3000 \ + -v obsidian_repo:/obsidian:ro \ + -v quartz_repo:/quartz:ro \ + -v public_site:/public \ + $(DOCKER_IMAGE) + @echo "Docker контейнер запущен: $(DOCKER_CONTAINER)" + +docker-stop: ## Остановить Docker контейнер + @echo "Остановка Docker контейнера..." + docker stop $(DOCKER_CONTAINER) || true + docker rm $(DOCKER_CONTAINER) || true + @echo "Docker контейнер остановлен" + +docker-logs: ## Показать логи Docker контейнера + docker logs -f $(DOCKER_CONTAINER) + +# Docker Compose команды +compose-up: ## Запустить сервис через Docker Compose + @echo "Запуск через Docker Compose..." + docker-compose up -d + @echo "Сервис запущен" + +compose-down: ## Остановить сервис через Docker Compose + @echo "Остановка через Docker Compose..." + docker-compose down + @echo "Сервис остановлен" + +compose-logs: ## Показать логи Docker Compose + docker-compose logs -f + +# Проверка состояния +status: ## Показать статус сервиса + @echo "Проверка статуса сервиса..." + @curl -s http://localhost:3000/health || echo "Сервис недоступен" + +# Webhook тест +test-webhook: ## Отправить тестовый webhook + @echo "Отправка тестового webhook..." + @curl -X POST http://localhost:3000/webhook + @echo "" + +# Проверка структуры +lint: ## Проверить код линтером + @echo "Проверка кода..." + @if command -v golangci-lint > /dev/null; then \ + golangci-lint run; \ + else \ + echo "golangci-lint не установлен. Установите: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + fi + +# Форматирование кода +fmt: ## Отформатировать код + @echo "Форматирование кода..." + go fmt ./... + @echo "Код отформатирован" + +# Проверка зависимостей +deps-check: ## Проверить зависимости + @echo "Проверка зависимостей..." + go mod verify + go list -m all + +# Полная пересборка +rebuild: clean build ## Полная пересборка проекта + +# Разработка +dev: ## Запуск в режиме разработки с автоперезагрузкой + @echo "Запуск в режиме разработки..." + @if command -v air > /dev/null; then \ + air; \ + else \ + echo "Air не установлен. Установите: go install github.com/cosmtrek/air@latest"; \ + cd $(BUILD_DIR) && go run main.go; \ + fi + +# Создание структуры проекта +create-structure: ## Создать структуру директорий + @echo "Создание структуры проекта..." + mkdir -p cmd/server + mkdir -p internal/{config,handlers,services,middleware} + mkdir -p pkg/logger + mkdir -p api + mkdir -p configs + mkdir -p tests/{unit,integration} + mkdir -p scripts + mkdir -p docs + mkdir -p deployments + @echo "Структура проекта создана" diff --git a/README-REFACTORED.md b/README-REFACTORED.md new file mode 100644 index 0000000..1b10fc4 --- /dev/null +++ b/README-REFACTORED.md @@ -0,0 +1,212 @@ +# Go Webhook Server - Рефакторенная версия + +Рефакторенная версия webhook сервера с разделением на пакеты и улучшенной архитектурой. + +## 🏗️ **Новая структура проекта** + +``` +go-webhook-server/ +├── cmd/ +│ └── server/ +│ └── main.go # Точка входа приложения +├── internal/ +│ ├── config/ +│ │ └── config.go # Конфигурация приложения +│ ├── handlers/ +│ │ ├── webhook.go # Обработчик webhook'ов +│ │ └── health.go # Health check обработчик +│ ├── services/ +│ │ ├── build.go # Основной сервис сборки +│ │ ├── git.go # Git операции +│ │ ├── quartz.go # Сборка Quartz +│ │ ├── files.go # Файловые операции +│ │ └── types.go # Общие типы +│ └── middleware/ +│ └── logging.go # HTTP логирование +├── pkg/ +│ └── logger/ +│ └── logger.go # Интерфейс логгера +├── api/ +│ └── routes.go # Определение роутов +├── configs/ +│ └── config.yaml # YAML конфигурация +├── tests/ # Тесты +├── scripts/ # Скрипты +└── docs/ # Документация +``` + +## 🔧 **Основные улучшения** + +### **1. Разделение ответственности** +- **Config** - управление конфигурацией +- **Services** - бизнес-логика +- **Handlers** - HTTP обработчики +- **Middleware** - промежуточное ПО +- **API** - определение роутов + +### **2. Интерфейсы и абстракции** +```go +type GitService interface { + UpdateRepository() error + IsRepositoryExists() bool +} + +type QuartzService interface { + BuildSite() error + InstallDependencies() error +} + +type BuildService interface { + BuildSite() BuildResult +} +``` + +### **3. Dependency Injection** +```go +// Инициализация сервисов +gitService := services.NewGitService(cfg, log) +quartzService := services.NewQuartzService(cfg, log) +fileService := services.NewFileService(cfg, log) +buildService := services.NewBuildService(cfg, log, gitService, quartzService, fileService) +``` + +### **4. Улучшенное логирование** +- Структурированное логирование +- Request ID для отслеживания +- Уровни логирования +- Контекстная информация + +### **5. Middleware** +- **RequestIDMiddleware** - уникальный ID для каждого запроса +- **ResponseTimeMiddleware** - время ответа +- **LoggingMiddleware** - детальное логирование HTTP + +## 🚀 **Запуск** + +### **Локально** +```bash +cd cmd/server +go run main.go +``` + +### **Сборка** +```bash +go build -o go-webhook-server cmd/server/main.go +``` + +### **Docker** +```bash +docker build -t go-webhook-server . +docker run -p 3000:3000 go-webhook-server +``` + +## 📝 **Конфигурация** + +### **Переменные окружения** +```bash +PORT=3000 +OBSIDIAN_PATH=/obsidian +QUARTZ_PATH=/quartz +PUBLIC_PATH=/public +GIT_BRANCH=main +GIT_REMOTE=origin +SERVER_TIMEOUT=30 +``` + +### **YAML конфигурация** +```yaml +server: + port: "3000" + timeout: 30 + +paths: + obsidian: "/obsidian" + quartz: "/quartz" + public: "/public" + +git: + branch: "main" + remote: "origin" +``` + +## 🧪 **Тестирование** + +### **Unit тесты** +```bash +go test ./internal/... +``` + +### **Integration тесты** +```bash +go test ./tests/integration/... +``` + +## 🔄 **API эндпоинты** + +### **POST /webhook** +Запускает процесс сборки сайта. + +**Ответ:** +```json +{ + "status": "accepted", + "message": "Build process started", + "request_id": "20250127103000-abc123", + "timestamp": "2025-01-27T10:30:00Z" +} +``` + +### **GET /health** +Проверка состояния сервиса. + +**Ответ:** +```json +{ + "status": "ok", + "timestamp": "2025-01-27T10:30:00Z", + "service": "go-webhook-server", + "version": "1.0.0", + "request_id": "20250127103000-abc123" +} +``` + +## 📊 **Мониторинг** + +### **Заголовки ответа** +- `X-Request-ID` - уникальный ID запроса +- `X-Response-Time` - время ответа + +### **Логирование** +- Все HTTP запросы логируются с деталями +- Request ID для отслеживания цепочки запросов +- Структурированные логи в формате JSON + +## 🚀 **Преимущества новой архитектуры** + +1. **Тестируемость** - легко писать unit тесты +2. **Переиспользование** - компоненты можно использовать в других проектах +3. **Читаемость** - код легче понимать и поддерживать +4. **Расширяемость** - проще добавлять новую функциональность +5. **Соответствие стандартам** - структура соответствует Go best practices +6. **Dependency Injection** - легко заменять реализации +7. **Интерфейсы** - четкое разделение контрактов + +## 🔮 **Планы развития** + +- [ ] Добавление метрик Prometheus +- [ ] Конфигурация через файлы +- [ ] Graceful shutdown для сборки +- [ ] Очередь сборок +- [ ] Уведомления о результатах +- [ ] API для мониторинга сборок +- [ ] Аутентификация webhook'ов +- [ ] Rate limiting +- [ ] OpenAPI документация + +## 📚 **Зависимости** + +- **Go 1.20+** +- **Gin** - веб-фреймворк +- **go-git/v5** - Git клиент +- **logrus** - логирование +- **YAML** - конфигурация (планируется) diff --git a/api/routes.go b/api/routes.go new file mode 100644 index 0000000..559213f --- /dev/null +++ b/api/routes.go @@ -0,0 +1,30 @@ +package api + +import ( + "github.com/gin-gonic/gin" + "go-webhook-server/internal/handlers" + "go-webhook-server/internal/middleware" + "go-webhook-server/pkg/logger" +) + +// SetupRoutes настраивает все роуты приложения +func SetupRoutes( + webhookHandler *handlers.WebhookHandler, + healthHandler *handlers.HealthHandler, + log logger.Logger, +) *gin.Engine { + // Настраиваем Gin + gin.SetMode(gin.ReleaseMode) + router := gin.New() + + // Добавляем middleware + router.Use(middleware.RequestIDMiddleware()) + router.Use(middleware.ResponseTimeMiddleware()) + router.Use(middleware.LoggingMiddleware(log)) + + // Роуты + router.POST("/webhook", webhookHandler.HandleWebhook) + router.GET("/health", healthHandler.HandleHealth) + + return router +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..7921805 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,76 @@ +package main + +import ( + "context" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "go-webhook-server/api" + "go-webhook-server/internal/config" + "go-webhook-server/internal/handlers" + "go-webhook-server/internal/services" + "go-webhook-server/pkg/logger" +) + +func main() { + // Инициализация логгера + log := logger.New() + log.Info("Starting go-webhook-server...") + + // Загрузка конфигурации + cfg := config.Load() + if err := cfg.Validate(); err != nil { + log.Fatalf("Configuration validation failed: %v", err) + } + + log.Infof("Configuration loaded: Port=%s, ObsidianPath=%s, QuartzPath=%s, PublicPath=%s, GitBranch=%s, GitRemote=%s", + cfg.Server.Port, cfg.Paths.Obsidian, cfg.Paths.Quartz, cfg.Paths.Public, cfg.Git.Branch, cfg.Git.Remote) + + // Инициализация сервисов + gitService := services.NewGitService(cfg, log) + quartzService := services.NewQuartzService(cfg, log) + fileService := services.NewFileService(cfg, log) + buildService := services.NewBuildService(cfg, log, gitService, quartzService, fileService) + + // Инициализация обработчиков + webhookHandler := handlers.NewWebhookHandler(buildService, log) + healthHandler := handlers.NewHealthHandler(log) + + // Настройка роутов + router := api.SetupRoutes(webhookHandler, healthHandler, log) + + // Создание HTTP сервера + addr := ":" + cfg.Server.Port + server := &http.Server{ + Addr: addr, + Handler: router, + } + + // Запуск сервера в горутине + go func() { + log.Infof("Starting webhook server on port %s", cfg.Server.Port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("Failed to start server: %v", err) + } + }() + + // Ожидаем сигнал для graceful shutdown + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Info("Shutting down server...") + + // Graceful shutdown с таймаутом + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(cfg.Server.Timeout)*time.Second) + defer cancel() + + if err := server.Shutdown(ctx); err != nil { + log.Errorf("Server forced to shutdown: %v", err) + } + + log.Info("Server exited") +} diff --git a/configs/config.yaml b/configs/config.yaml new file mode 100644 index 0000000..f51cbb6 --- /dev/null +++ b/configs/config.yaml @@ -0,0 +1,12 @@ +server: + port: "3000" + timeout: 30 + +paths: + obsidian: "/obsidian" + quartz: "/quartz" + public: "/public" + +git: + branch: "main" + remote: "origin" diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c34eda5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,101 @@ +package config + +import ( + "errors" + "os" + "strconv" +) + +// Config содержит все настройки приложения +type Config struct { + Server ServerConfig `yaml:"server"` + Paths PathsConfig `yaml:"paths"` + Git GitConfig `yaml:"git"` +} + +// ServerConfig содержит настройки HTTP сервера +type ServerConfig struct { + Port string `yaml:"port"` + Timeout int `yaml:"timeout"` // в секундах +} + +// PathsConfig содержит пути к директориям +type PathsConfig struct { + Obsidian string `yaml:"obsidian"` + Quartz string `yaml:"quartz"` + Public string `yaml:"public"` +} + +// GitConfig содержит настройки Git +type GitConfig struct { + Branch string `yaml:"branch"` + Remote string `yaml:"remote"` +} + +// Load загружает конфигурацию из переменных окружения +func Load() *Config { + config := &Config{ + Server: ServerConfig{ + Port: getEnv("PORT", "3000"), + Timeout: getEnvAsInt("SERVER_TIMEOUT", 30), + }, + Paths: PathsConfig{ + Obsidian: getEnv("OBSIDIAN_PATH", "/obsidian"), + Quartz: getEnv("QUARTZ_PATH", "/quartz"), + Public: getEnv("PUBLIC_PATH", "/public"), + }, + Git: GitConfig{ + Branch: getEnv("GIT_BRANCH", "main"), + Remote: getEnv("GIT_REMOTE", "origin"), + }, + } + + return config +} + +// Validate проверяет корректность конфигурации +func (c *Config) Validate() error { + if c.Server.Port == "" { + return errors.New("server port is required") + } + + if c.Paths.Obsidian == "" { + return errors.New("obsidian path is required") + } + + if c.Paths.Quartz == "" { + return errors.New("quartz path is required") + } + + if c.Paths.Public == "" { + return errors.New("public path is required") + } + + if c.Git.Branch == "" { + return errors.New("git branch is required") + } + + if c.Git.Remote == "" { + return errors.New("git remote is required") + } + + return nil +} + +// getEnv получает значение переменной окружения или возвращает значение по умолчанию +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getEnvAsInt получает значение переменной окружения как int или возвращает значение по умолчанию +func getEnvAsInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intValue, err := strconv.Atoi(value); err == nil { + return intValue + } + } + return defaultValue +} diff --git a/internal/handlers/health.go b/internal/handlers/health.go new file mode 100644 index 0000000..1fa20b8 --- /dev/null +++ b/internal/handlers/health.go @@ -0,0 +1,37 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "go-webhook-server/pkg/logger" +) + +// HealthHandler обработчик для health check эндпоинта +type HealthHandler struct { + logger logger.Logger +} + +// NewHealthHandler создает новый экземпляр health обработчика +func NewHealthHandler(log logger.Logger) *HealthHandler { + return &HealthHandler{ + logger: log, + } +} + +// HandleHealth обрабатывает health check запрос +func (h *HealthHandler) HandleHealth(c *gin.Context) { + requestID := c.GetString("request_id") + logger := h.logger.WithField("request_id", requestID) + + logger.Debug("Health check request received") + + c.JSON(http.StatusOK, gin.H{ + "status": "ok", + "timestamp": time.Now().UTC().Format(time.RFC3339), + "service": "go-webhook-server", + "version": "1.0.0", + "request_id": requestID, + }) +} diff --git a/internal/handlers/webhook.go b/internal/handlers/webhook.go new file mode 100644 index 0000000..2c8cd13 --- /dev/null +++ b/internal/handlers/webhook.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "go-webhook-server/internal/services" + "go-webhook-server/pkg/logger" +) + +// WebhookHandler обработчик для webhook эндпоинта +type WebhookHandler struct { + buildService services.BuildService + logger logger.Logger +} + +// NewWebhookHandler создает новый экземпляр webhook обработчика +func NewWebhookHandler(buildService services.BuildService, log logger.Logger) *WebhookHandler { + return &WebhookHandler{ + buildService: buildService, + logger: log, + } +} + +// HandleWebhook обрабатывает webhook запрос +func (h *WebhookHandler) HandleWebhook(c *gin.Context) { + requestID := c.GetString("request_id") + logger := h.logger.WithField("request_id", requestID) + + logger.Info("Webhook received, starting site rebuild...") + + // Запускаем сборку в горутине для асинхронной обработки + go func() { + result := h.buildService.BuildSite() + if result.Success { + logger.Info("Webhook build completed successfully") + } else { + logger.Errorf("Webhook build failed: %s", result.Error) + } + }() + + // Сразу возвращаем ответ + c.JSON(http.StatusAccepted, gin.H{ + "status": "accepted", + "message": "Build process started", + "request_id": requestID, + "timestamp": time.Now().UTC().Format(time.RFC3339), + }) +} diff --git a/internal/middleware/logging.go b/internal/middleware/logging.go new file mode 100644 index 0000000..dea7398 --- /dev/null +++ b/internal/middleware/logging.go @@ -0,0 +1,67 @@ +package middleware + +import ( + "time" + + "github.com/gin-gonic/gin" + "go-webhook-server/pkg/logger" +) + +// LoggingMiddleware создает middleware для логирования HTTP запросов +func LoggingMiddleware(log logger.Logger) gin.HandlerFunc { + return gin.LoggerWithFormatter(func(param gin.LogFormatterParams) string { + // Логируем детали запроса + log.WithFields(map[string]interface{}{ + "status": param.StatusCode, + "latency": param.Latency, + "client_ip": param.ClientIP, + "method": param.Method, + "path": param.Path, + "user_agent": param.Request.UserAgent(), + }).Info("HTTP Request") + + // Возвращаем пустую строку, так как логирование уже выполнено + return "" + }) +} + +// RequestIDMiddleware добавляет уникальный ID к каждому запросу +func RequestIDMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + requestID := c.GetHeader("X-Request-ID") + if requestID == "" { + requestID = generateRequestID() + } + + c.Set("request_id", requestID) + c.Header("X-Request-ID", requestID) + c.Next() + } +} + +// ResponseTimeMiddleware добавляет время ответа в заголовки +func ResponseTimeMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + start := time.Now() + + c.Next() + + duration := time.Since(start) + c.Header("X-Response-Time", duration.String()) + } +} + +// generateRequestID генерирует простой ID запроса +func generateRequestID() string { + return time.Now().Format("20060102150405") + "-" + randomString(6) +} + +// randomString генерирует случайную строку указанной длины +func randomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + b := make([]byte, length) + for i := range b { + b[i] = charset[time.Now().UnixNano()%int64(len(charset))] + } + return string(b) +} diff --git a/internal/services/build.go b/internal/services/build.go new file mode 100644 index 0000000..c9f62fb --- /dev/null +++ b/internal/services/build.go @@ -0,0 +1,88 @@ +package services + +import ( + "go-webhook-server/internal/config" + "go-webhook-server/pkg/logger" +) + +// BuildService интерфейс для сборки сайта +type BuildService interface { + BuildSite() BuildResult +} + +// buildServiceImpl реализация сервиса сборки +type buildServiceImpl struct { + config *config.Config + logger logger.Logger + gitService GitService + quartzService QuartzService + fileService FileService +} + +// NewBuildService создает новый экземпляр сервиса сборки +func NewBuildService( + cfg *config.Config, + log logger.Logger, + git GitService, + quartz QuartzService, + files FileService, +) BuildService { + return &buildServiceImpl{ + config: cfg, + logger: log, + gitService: git, + quartzService: quartz, + fileService: files, + } +} + +// BuildSite выполняет полную сборку сайта +func (b *buildServiceImpl) BuildSite() BuildResult { + b.logger.Info("Starting site build process...") + + // Проверяем существование репозитория + if !b.gitService.IsRepositoryExists() { + b.logger.Error("Repository not found") + return BuildResult{ + Success: false, + Message: "Repository not found", + Error: "Git repository does not exist at specified path", + } + } + + // Обновляем репозиторий + if err := b.gitService.UpdateRepository(); err != nil { + b.logger.Errorf("Failed to update repository: %v", err) + return BuildResult{ + Success: false, + Message: "Failed to update repository", + Error: err.Error(), + } + } + + // Собираем сайт с помощью Quartz + if err := b.quartzService.BuildSite(); err != nil { + b.logger.Errorf("Failed to build Quartz site: %v", err) + return BuildResult{ + Success: false, + Message: "Failed to build Quartz site", + Error: err.Error(), + } + } + + // Копируем собранные файлы в публичную директорию + if err := b.fileService.CopyBuiltSite(); err != nil { + b.logger.Errorf("Failed to copy built site: %v", err) + return BuildResult{ + Success: false, + Message: "Failed to copy built site", + Error: err.Error(), + } + } + + b.logger.Info("Site built successfully!") + return BuildResult{ + Success: true, + Message: "Site built successfully", + } +} diff --git a/internal/services/files.go b/internal/services/files.go new file mode 100644 index 0000000..739c3f5 --- /dev/null +++ b/internal/services/files.go @@ -0,0 +1,78 @@ +package services + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "go-webhook-server/internal/config" + "go-webhook-server/pkg/logger" +) + +// FileService интерфейс для файловых операций +type FileService interface { + CopyBuiltSite() error + ClearPublicDirectory() error + EnsurePublicDirectory() error +} + +// fileServiceImpl реализация файлового сервиса +type fileServiceImpl struct { + config *config.Config + logger logger.Logger +} + +// NewFileService создает новый экземпляр файлового сервиса +func NewFileService(cfg *config.Config, log logger.Logger) FileService { + return &fileServiceImpl{ + config: cfg, + logger: log, + } +} + +// CopyBuiltSite копирует собранные файлы в публичную директорию +func (f *fileServiceImpl) CopyBuiltSite() error { + f.logger.Info("Copying built site to public directory...") + + // Очищаем публичную директорию + if err := f.ClearPublicDirectory(); err != nil { + return fmt.Errorf("failed to clear public directory: %w", err) + } + + // Создаем публичную директорию заново + if err := f.EnsurePublicDirectory(); err != nil { + return fmt.Errorf("failed to create public directory: %w", err) + } + + // Проверяем существование директории с собранными файлами + quartzPublicPath := filepath.Join(f.config.Paths.Quartz, "public") + if _, err := os.Stat(quartzPublicPath); os.IsNotExist(err) { + return fmt.Errorf("Quartz public directory not found: %s", quartzPublicPath) + } + + // Копируем файлы с помощью cp команды + cmd := exec.Command("cp", "-r", quartzPublicPath+"/.", f.config.Paths.Public+"/") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to copy built site: %w", err) + } + + f.logger.Info("Site files copied successfully") + return nil +} + +// ClearPublicDirectory очищает публичную директорию +func (f *fileServiceImpl) ClearPublicDirectory() error { + if err := os.RemoveAll(f.config.Paths.Public); err != nil { + return fmt.Errorf("failed to clear public directory: %w", err) + } + return nil +} + +// EnsurePublicDirectory создает публичную директорию если она не существует +func (f *fileServiceImpl) EnsurePublicDirectory() error { + if err := os.MkdirAll(f.config.Paths.Public, 0755); err != nil { + return fmt.Errorf("failed to create public directory: %w", err) + } + return nil +} diff --git a/internal/services/git.go b/internal/services/git.go new file mode 100644 index 0000000..bb5db8c --- /dev/null +++ b/internal/services/git.go @@ -0,0 +1,93 @@ +package services + +import ( + "fmt" + "path/filepath" + + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "go-webhook-server/internal/config" + "go-webhook-server/pkg/logger" +) + +// GitService интерфейс для Git операций +type GitService interface { + UpdateRepository() error + IsRepositoryExists() bool +} + +// gitServiceImpl реализация Git сервиса +type gitServiceImpl struct { + config *config.Config + logger logger.Logger +} + +// NewGitService создает новый экземпляр Git сервиса +func NewGitService(cfg *config.Config, log logger.Logger) GitService { + return &gitServiceImpl{ + config: cfg, + logger: log, + } +} + +// UpdateRepository обновляет репозиторий из удаленного источника +func (g *gitServiceImpl) UpdateRepository() error { + g.logger.Info("Updating repository...") + + // Открываем репозиторий + repo, err := git.PlainOpen(g.config.Paths.Obsidian) + if err != nil { + return fmt.Errorf("failed to open repository: %w", err) + } + + // Получаем worktree + worktree, err := repo.Worktree() + if err != nil { + return fmt.Errorf("failed to get worktree: %w", err) + } + + // Проверяем текущую ветку + head, err := repo.Head() + if err != nil { + return fmt.Errorf("failed to get HEAD: %w", err) + } + + g.logger.Infof("Current branch: %s", head.Name().Short()) + + // Выполняем git pull + err = worktree.Pull(&git.PullOptions{ + RemoteName: g.config.Git.Remote, + ReferenceName: plumbing.NewBranchReferenceName(g.config.Git.Branch), + }) + + if err != nil { + if err == git.NoErrAlreadyUpToDate { + g.logger.Info("Repository is already up to date") + return nil + } + return fmt.Errorf("failed to pull from remote: %w", err) + } + + g.logger.Info("Repository updated successfully") + return nil +} + +// IsRepositoryExists проверяет существование Git репозитория +func (g *gitServiceImpl) IsRepositoryExists() bool { + gitPath := filepath.Join(g.config.Paths.Obsidian, ".git") + + // Проверяем существование .git директории + if _, err := filepath.Abs(gitPath); err != nil { + return false + } + + // Пытаемся открыть репозиторий + repo, err := git.PlainOpen(g.config.Paths.Obsidian) + if err != nil { + return false + } + + // Проверяем что это действительно Git репозиторий + _, err = repo.Head() + return err == nil +} diff --git a/internal/services/quartz.go b/internal/services/quartz.go new file mode 100644 index 0000000..b7bf7ae --- /dev/null +++ b/internal/services/quartz.go @@ -0,0 +1,85 @@ +package services + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "go-webhook-server/internal/config" + "go-webhook-server/pkg/logger" +) + +// QuartzService интерфейс для сборки Quartz сайта +type QuartzService interface { + BuildSite() error + InstallDependencies() error +} + +// quartzServiceImpl реализация Quartz сервиса +type quartzServiceImpl struct { + config *config.Config + logger logger.Logger +} + +// NewQuartzService создает новый экземпляр Quartz сервиса +func NewQuartzService(cfg *config.Config, log logger.Logger) QuartzService { + return &quartzServiceImpl{ + config: cfg, + logger: log, + } +} + +// BuildSite собирает сайт с помощью Quartz +func (q *quartzServiceImpl) BuildSite() error { + q.logger.Info("Building site with Quartz...") + + // Проверяем существование package.json в директории Quartz + packageJSONPath := filepath.Join(q.config.Paths.Quartz, "package.json") + if _, err := os.Stat(packageJSONPath); os.IsNotExist(err) { + return fmt.Errorf("package.json not found in Quartz directory: %s", q.config.Paths.Quartz) + } + + // Устанавливаем зависимости если необходимо + if err := q.InstallDependencies(); err != nil { + return fmt.Errorf("failed to install dependencies: %w", err) + } + + // Выполняем сборку Quartz + cmd := exec.Command("npm", "run", "quartz", "build", "--", "-d", q.config.Paths.Obsidian) + cmd.Dir = q.config.Paths.Quartz + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + q.logger.Info("Executing Quartz build command...") + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to build Quartz site: %w", err) + } + + q.logger.Info("Quartz build completed successfully") + return nil +} + +// InstallDependencies устанавливает npm зависимости +func (q *quartzServiceImpl) InstallDependencies() error { + // Проверяем существование node_modules + nodeModulesPath := filepath.Join(q.config.Paths.Quartz, "node_modules") + if _, err := os.Stat(nodeModulesPath); os.IsNotExist(err) { + q.logger.Info("Installing npm dependencies...") + + cmd := exec.Command("npm", "install") + cmd.Dir = q.config.Paths.Quartz + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to install npm dependencies: %w", err) + } + + q.logger.Info("npm dependencies installed successfully") + } else { + q.logger.Debug("node_modules already exists, skipping npm install") + } + + return nil +} diff --git a/internal/services/types.go b/internal/services/types.go new file mode 100644 index 0000000..8ebcae7 --- /dev/null +++ b/internal/services/types.go @@ -0,0 +1,28 @@ +package services + +// BuildResult результат сборки сайта +type BuildResult struct { + Success bool `json:"success"` + Message string `json:"message"` + Error string `json:"error,omitempty"` +} + +// BuildStatus статус сборки +type BuildStatus string + +const ( + BuildStatusPending BuildStatus = "pending" + BuildStatusRunning BuildStatus = "running" + BuildStatusCompleted BuildStatus = "completed" + BuildStatusFailed BuildStatus = "failed" +) + +// BuildInfo детальная информация о сборке +type BuildInfo struct { + ID string `json:"id"` + Status BuildStatus `json:"status"` + StartTime string `json:"start_time"` + EndTime string `json:"end_time,omitempty"` + Duration string `json:"duration,omitempty"` + Result BuildResult `json:"result,omitempty"` +} diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..266f1e8 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,130 @@ +package logger + +import ( + "github.com/sirupsen/logrus" +) + +// Logger интерфейс для логирования +type Logger interface { + Info(args ...interface{}) + Infof(format string, args ...interface{}) + Error(args ...interface{}) + Errorf(format string, args ...interface{}) + Warn(args ...interface{}) + Warnf(format string, args ...interface{}) + Debug(args ...interface{}) + Debugf(format string, args ...interface{}) + Fatalf(format string, args ...interface{}) + WithField(key string, value interface{}) Logger + WithFields(fields map[string]interface{}) Logger +} + +// logrusLogger реализация логгера через logrus +type logrusLogger struct { + logger *logrus.Logger +} + +// New создает новый экземпляр логгера +func New() Logger { + logger := logrus.New() + logger.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) + logger.SetLevel(logrus.InfoLevel) + + return &logrusLogger{logger: logger} +} + +// NewWithLevel создает логгер с указанным уровнем +func NewWithLevel(level string) Logger { + logger := logrus.New() + logger.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + }) + + switch level { + case "debug": + logger.SetLevel(logrus.DebugLevel) + case "info": + logger.SetLevel(logrus.InfoLevel) + case "warn": + logger.SetLevel(logrus.WarnLevel) + case "error": + logger.SetLevel(logrus.ErrorLevel) + default: + logger.SetLevel(logrus.InfoLevel) + } + + return &logrusLogger{logger: logger} +} + +// Info логирует информационное сообщение +func (l *logrusLogger) Info(args ...interface{}) { + l.logger.Info(args...) +} + +// Fatalf логирует критическую ошибку и завершает программу +func (l *logrusLogger) Fatalf(format string, args ...interface{}) { + l.logger.Fatalf(format, args...) +} + +// Infof логирует форматированное информационное сообщение +func (l *logrusLogger) Infof(format string, args ...interface{}) { + l.logger.Infof(format, args...) +} + +// Error логирует сообщение об ошибке +func (l *logrusLogger) Error(args ...interface{}) { + l.logger.Error(args...) +} + +// Errorf логирует форматированное сообщение об ошибке +func (l *logrusLogger) Errorf(format string, args ...interface{}) { + l.logger.Errorf(format, args...) +} + +// Warn логирует предупреждение +func (l *logrusLogger) Warn(args ...interface{}) { + l.logger.Warn(args...) +} + +// Warnf логирует форматированное предупреждение +func (l *logrusLogger) Warnf(format string, args ...interface{}) { + l.logger.Warnf(format, args...) +} + +// Debug логирует отладочное сообщение +func (l *logrusLogger) Debug(args ...interface{}) { + l.logger.Debug(args...) +} + +// Debugf логирует форматированное отладочное сообщение +func (l *logrusLogger) Debugf(format string, args ...interface{}) { + l.logger.Debugf(format, args...) +} + +// WithField добавляет поле к логгеру +func (l *logrusLogger) WithField(key string, value interface{}) Logger { + // Создаем новый логгер с полем + newLogger := logrus.New() + newLogger.SetFormatter(l.logger.Formatter) + newLogger.SetLevel(l.logger.Level) + newLogger.SetOutput(l.logger.Out) + + // Добавляем поле к контексту + newLogger.WithField(key, value) + return &logrusLogger{logger: newLogger} +} + +// WithFields добавляет поля к логгеру +func (l *logrusLogger) WithFields(fields map[string]interface{}) Logger { + // Создаем новый логгер с полями + newLogger := logrus.New() + newLogger.SetFormatter(l.logger.Formatter) + newLogger.SetLevel(l.logger.Level) + newLogger.SetOutput(l.logger.Out) + + // Добавляем поля к контексту + newLogger.WithFields(fields) + return &logrusLogger{logger: newLogger} +}