Reorganize repo: backend/, deploy/, docs/ layout + AGENTS.md
Restructure the monorepo into clear top-level directories: - backend/: Go module root (cmd, internal, pkg, configs, migrations, docs/swagger, scripts, tests, go.mod, Makefile, .air.toml) - deploy/: Docker (Dockerfile, docker-compose*), quickstart, fluentd - docs/: project documentation + reports/ (moved from repo root) - AGENTS.md: new AI coding-agent guide at repo root Update all references to the new layout: - Dockerfile: COPY backend/go.mod, COPY backend/ (context = repo root) - docker-compose files: context ../.., dockerfile deploy/docker/Dockerfile, env_file ../../.env, volume mounts ../../backend:/app - deploy/quickstart/compose.yaml: dockerfile deploy/docker/Dockerfile - CI: working-directory: backend for go commands, file deploy/docker/Dockerfile, coverage path backend/coverage.out, health_check backend/scripts/ - backend/Makefile: docker target uses -f ../deploy/docker/Dockerfile ../ - README: architecture tree, quickstart, config paths updated Move root stray scripts (rename_models.*, run_m11_tests.sh, verify_build.sh, gorm_bool_main.go) to backend/scripts/legacy/. All moves via git mv to preserve history. Build, vet, SQLite tests, and docker compose config verified.
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Validate checks the configuration for required fields and valid values.
|
||||
// Similar to Chatwoot's config validation in config/initializers.
|
||||
func Validate(cfg *Config) error {
|
||||
// Server validation
|
||||
if cfg.Server.Port < 1 || cfg.Server.Port > 65535 {
|
||||
return fmt.Errorf("invalid server port: %d", cfg.Server.Port)
|
||||
}
|
||||
if cfg.Server.Mode != "debug" && cfg.Server.Mode != "release" && cfg.Server.Mode != "test" {
|
||||
return fmt.Errorf("invalid server mode: %s (must be debug, release, or test)", cfg.Server.Mode)
|
||||
}
|
||||
|
||||
// Database validation (ref: Chatwoot requires POSTGRES_* env vars)
|
||||
if cfg.Database.Host == "" {
|
||||
return fmt.Errorf("database host is required")
|
||||
}
|
||||
if cfg.Database.Name == "" {
|
||||
return fmt.Errorf("database name is required")
|
||||
}
|
||||
if cfg.Database.User == "" {
|
||||
return fmt.Errorf("database user is required")
|
||||
}
|
||||
|
||||
// Redis validation (ref: Chatwoot config/cable.yml requires REDIS_URL)
|
||||
if cfg.Redis.URL == "" {
|
||||
return fmt.Errorf("redis url is required")
|
||||
}
|
||||
redisURL, err := url.Parse(cfg.Redis.URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid redis URL: %w", err)
|
||||
}
|
||||
if redisURL.Scheme != "redis" && redisURL.Scheme != "rediss" {
|
||||
return fmt.Errorf("invalid redis URL: scheme must be redis or rediss, got %s", redisURL.Scheme)
|
||||
}
|
||||
if redisURL.Host == "" {
|
||||
return fmt.Errorf("invalid redis URL: host is required")
|
||||
}
|
||||
|
||||
// JWT validation
|
||||
if cfg.JWT.Secret == "" || cfg.JWT.Secret == "change-me-in-production" {
|
||||
if cfg.Server.Mode == "release" {
|
||||
return fmt.Errorf("JWT secret must be changed in production mode")
|
||||
}
|
||||
}
|
||||
|
||||
// Log validation
|
||||
validLogLevels := map[string]bool{"debug": true, "info": true, "warn": true, "error": true}
|
||||
if !validLogLevels[cfg.Log.Level] {
|
||||
return fmt.Errorf("invalid log level: %s", cfg.Log.Level)
|
||||
}
|
||||
|
||||
// Worker validation (ref: Chatwoot sidekiq.yml concurrency check)
|
||||
if cfg.Worker.Concurrency < 1 {
|
||||
return fmt.Errorf("worker concurrency must be >= 1")
|
||||
}
|
||||
|
||||
// Rate limit validation
|
||||
if cfg.RateLimit.RequestsPerMinute < 1 {
|
||||
return fmt.Errorf("rate_limit.requests_per_minute must be >= 1")
|
||||
}
|
||||
if cfg.RateLimit.WindowSeconds < 1 {
|
||||
return fmt.Errorf("rate_limit.window_seconds must be >= 1")
|
||||
}
|
||||
|
||||
// Search validation. Meilisearch is the production parity engine; db remains
|
||||
// available only as an explicit development fallback.
|
||||
engine := strings.ToLower(cfg.Search.Engine)
|
||||
if engine == "" {
|
||||
engine = "meilisearch"
|
||||
}
|
||||
if engine != "meilisearch" && engine != "db" {
|
||||
return fmt.Errorf("invalid search engine: %s (must be meilisearch or db)", cfg.Search.Engine)
|
||||
}
|
||||
if engine == "db" && cfg.Server.Mode == "release" {
|
||||
return fmt.Errorf("search.engine=db is only allowed for local development; release mode requires meilisearch")
|
||||
}
|
||||
if engine == "meilisearch" {
|
||||
if cfg.Search.Host == "" {
|
||||
return fmt.Errorf("search.host is required when search.engine=meilisearch")
|
||||
}
|
||||
searchURL, err := url.Parse(cfg.Search.Host)
|
||||
if err != nil || searchURL.Scheme == "" || searchURL.Host == "" {
|
||||
return fmt.Errorf("invalid search.host: %s", cfg.Search.Host)
|
||||
}
|
||||
}
|
||||
if cfg.Search.TimeoutSeconds < 0 {
|
||||
return fmt.Errorf("search.timeout_seconds must be >= 0")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseStatementTimeout converts the string timeout to time.Duration.
|
||||
func ParseStatementTimeout(timeout string) (time.Duration, error) {
|
||||
if timeout == "" {
|
||||
return 14 * time.Second, nil
|
||||
}
|
||||
|
||||
// Handle plain seconds (e.g., "14s")
|
||||
if secs, err := strconv.Atoi(timeout); err == nil {
|
||||
return time.Duration(secs) * time.Second, nil
|
||||
}
|
||||
|
||||
// Handle Go duration format (e.g., "14s", "500ms")
|
||||
d, err := time.ParseDuration(timeout)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid statement_timeout format: %s", timeout)
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
Reference in New Issue
Block a user