Rename service man
This commit is contained in:
380
coolify/proxy/caddy/service-man/main.go
Normal file
380
coolify/proxy/caddy/service-man/main.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/template"
|
||||
|
||||
"git.site.quack-lab.dev/dave/cylogger"
|
||||
"github.com/BurntSushi/toml"
|
||||
"github.com/sergi/go-diff/diffmatchpatch"
|
||||
)
|
||||
|
||||
type ServiceConfig struct {
|
||||
Domains []string `toml:"domains"`
|
||||
Backend string `toml:"backend"`
|
||||
IPRange string `toml:"ip_range"`
|
||||
}
|
||||
|
||||
type FileConfig struct {
|
||||
Services []ServiceConfig `toml:"services"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
IPRanges map[string]string `toml:"ip_ranges"`
|
||||
Files map[string]FileConfig `toml:"files"`
|
||||
}
|
||||
|
||||
// Template for generating Caddy configuration
|
||||
const caddyTemplate = `{{- range $service := .Services -}}
|
||||
{{ join $service.Domains " " }} {
|
||||
@lan {
|
||||
remote_ip {{ $service.IPRange }}
|
||||
}
|
||||
handle @lan {
|
||||
reverse_proxy {{ $service.Backend }}
|
||||
}
|
||||
handle {
|
||||
respond "Njet Molotoff" 403
|
||||
}
|
||||
}
|
||||
{{ end }}`
|
||||
|
||||
// Global variables
|
||||
var (
|
||||
dryRun = flag.Bool("dry-run", false, "Show what would be done without making changes")
|
||||
force = flag.Bool("force", false, "Force apply changes")
|
||||
delete = flag.Bool("d", false, "Delete files not in TOML config")
|
||||
tomlFile = flag.String("config", "services.toml", "Path to TOML configuration file")
|
||||
rootDir = flag.String("root", "../dynamic", "Root directory to run in (.caddy files written here)")
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
cylogger.InitFlag() // Automatically reads -loglevel flag
|
||||
|
||||
logger := cylogger.Default.WithPrefix(fmt.Sprintf("config=%s", *tomlFile))
|
||||
logger.Info("Starting Caddy configuration sync")
|
||||
logger.Debug("Flags: dry-run=%v, force=%v, delete=%v", *dryRun, *force, *delete)
|
||||
|
||||
if *delete {
|
||||
logger.Warning("Delete mode enabled - will remove files not in TOML")
|
||||
}
|
||||
|
||||
if *dryRun {
|
||||
logger.Info("Dry-run mode enabled - no changes will be made")
|
||||
}
|
||||
|
||||
// Parse TOML configuration
|
||||
logger.Debug("Loading configuration from TOML")
|
||||
config, err := loadConfig(*tomlFile)
|
||||
if err != nil {
|
||||
logger.Error("Failed to load configuration: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Debug("Successfully loaded configuration")
|
||||
|
||||
// Validate configuration
|
||||
logger.Debug("Validating configuration")
|
||||
if err := validateConfig(config); err != nil {
|
||||
logger.Error("Configuration validation failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
logger.Debug("Configuration validation passed")
|
||||
|
||||
// Perform synchronization
|
||||
logger.Debug("Starting synchronization")
|
||||
if err := syncConfigs(config); err != nil {
|
||||
logger.Error("Synchronization failed: %v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("Caddy configuration sync completed successfully")
|
||||
}
|
||||
|
||||
func loadConfig(filename string) (*Config, error) {
|
||||
logger := cylogger.Default.WithPrefix(fmt.Sprintf("file=%s", filename))
|
||||
logger.Debug("Loading TOML configuration")
|
||||
|
||||
var config Config
|
||||
|
||||
_, err := toml.DecodeFile(filename, &config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode TOML: %w", err)
|
||||
}
|
||||
|
||||
logger.Trace("Loaded %d IP ranges and %d files", len(config.IPRanges), len(config.Files))
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
func validateConfig(config *Config) error {
|
||||
// Validate IP ranges are defined
|
||||
for filename, fileConfig := range config.Files {
|
||||
for i, service := range fileConfig.Services {
|
||||
if service.IPRange == "" {
|
||||
return fmt.Errorf("service %d in file %s has no IP range specified", i+1, filename)
|
||||
}
|
||||
|
||||
if _, exists := config.IPRanges[service.IPRange]; !exists {
|
||||
return fmt.Errorf("IP range '%s' not defined for service %d in file %s", service.IPRange, i+1, filename)
|
||||
}
|
||||
|
||||
if len(service.Domains) == 0 {
|
||||
return fmt.Errorf("service %d in file %s has no domains specified", i+1, filename)
|
||||
}
|
||||
|
||||
if service.Backend == "" {
|
||||
return fmt.Errorf("service %d in file %s has no backend specified", i+1, filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func syncConfigs(config *Config) error {
|
||||
// Determine directory for .caddy files
|
||||
currentDir := *rootDir
|
||||
if currentDir == "" || currentDir == "." {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get current directory: %w", err)
|
||||
}
|
||||
currentDir = wd
|
||||
}
|
||||
logger := cylogger.Default
|
||||
|
||||
// Get existing .caddy files
|
||||
logger.Debug("Scanning for existing .caddy files")
|
||||
existingFiles, err := getExistingCaddyFiles(currentDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get existing .caddy files: %w", err)
|
||||
}
|
||||
logger.Debug("Found %d existing .caddy files", len(existingFiles))
|
||||
|
||||
// Track which files we've processed
|
||||
processedFiles := make(map[string]bool)
|
||||
var created, updated, unchanged int
|
||||
|
||||
// Process each file in configuration
|
||||
logger.Debug("Processing %d configured files", len(config.Files))
|
||||
for filename, fileConfig := range config.Files {
|
||||
caddyFilename := filename + ".caddy"
|
||||
processedFiles[caddyFilename] = true
|
||||
fileLogger := logger.WithPrefix(fmt.Sprintf("file=%s", caddyFilename))
|
||||
fileLogger.Trace("Processing")
|
||||
|
||||
// Generate expected content
|
||||
expectedContent, err := generateCaddyContent(fileConfig, config.IPRanges)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate content for %s: %w", caddyFilename, err)
|
||||
}
|
||||
|
||||
// Check if file exists and compare content
|
||||
existingContent, exists := existingFiles[caddyFilename]
|
||||
|
||||
if !exists {
|
||||
// File doesn't exist, create it
|
||||
fileLogger.Info("Creating new file")
|
||||
created++
|
||||
|
||||
// Print the diff as if the old content was empty and the new content is expectedContent
|
||||
generateAndLogDiff(fileLogger, caddyFilename, "", expectedContent)
|
||||
|
||||
if !*dryRun {
|
||||
if err := writeFile(filepath.Join(currentDir, caddyFilename), expectedContent); err != nil {
|
||||
return fmt.Errorf("failed to create file %s: %w", caddyFilename, err)
|
||||
}
|
||||
}
|
||||
} else if !contentMatches(existingContent, expectedContent) {
|
||||
// File exists but content differs
|
||||
fileLogger.Info("File content differs, updating")
|
||||
updated++
|
||||
|
||||
// Show detailed diff of what's changing
|
||||
generateAndLogDiff(fileLogger, caddyFilename, existingContent, expectedContent)
|
||||
|
||||
if !*dryRun {
|
||||
if err := writeFile(filepath.Join(currentDir, caddyFilename), expectedContent); err != nil {
|
||||
return fmt.Errorf("failed to update file %s: %w", caddyFilename, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// File exists and content matches
|
||||
fileLogger.Debug("File content matches, no action needed")
|
||||
unchanged++
|
||||
}
|
||||
}
|
||||
|
||||
// Handle orphaned files (files that exist but are not in config)
|
||||
var orphanedCount int
|
||||
for filename := range existingFiles {
|
||||
if !processedFiles[filename] {
|
||||
orphanedCount++
|
||||
orphanLogger := logger.WithPrefix(fmt.Sprintf("orphan=%s", filename))
|
||||
if *delete {
|
||||
orphanLogger.Info("Deleting orphaned file")
|
||||
if !*dryRun {
|
||||
if err := os.Remove(filepath.Join(currentDir, filename)); err != nil {
|
||||
return fmt.Errorf("failed to delete file %s: %w", filename, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
orphanLogger.Warning("Found orphaned file not in configuration (use -d to delete)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("Sync summary: created=%d, updated=%d, unchanged=%d, orphaned=%d",
|
||||
created, updated, unchanged, orphanedCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateCaddyContent(fileConfig FileConfig, ipRanges map[string]string) (string, error) {
|
||||
logger := cylogger.Default.WithPrefix(fmt.Sprintf("template services=%d", len(fileConfig.Services)))
|
||||
logger.Debug("Generating Caddy content")
|
||||
|
||||
// Create template data with resolved IP ranges
|
||||
templateData := struct {
|
||||
Services []ServiceConfig
|
||||
}{
|
||||
Services: make([]ServiceConfig, len(fileConfig.Services)),
|
||||
}
|
||||
|
||||
// Resolve IP ranges for each service
|
||||
for i, service := range fileConfig.Services {
|
||||
logger.Trace("Resolving service %d: domains=%v, backend=%s, ip_range=%s",
|
||||
i, service.Domains, service.Backend, service.IPRange)
|
||||
|
||||
templateData.Services[i] = ServiceConfig{
|
||||
Domains: service.Domains,
|
||||
Backend: service.Backend,
|
||||
IPRange: ipRanges[service.IPRange],
|
||||
}
|
||||
}
|
||||
|
||||
// Create template with join function
|
||||
funcMap := template.FuncMap{
|
||||
"join": strings.Join,
|
||||
}
|
||||
|
||||
tmpl, err := template.New("caddy").Funcs(funcMap).Parse(caddyTemplate)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse template: %w", err)
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
if err := tmpl.Execute(&result, templateData); err != nil {
|
||||
return "", fmt.Errorf("failed to execute template: %w", err)
|
||||
}
|
||||
|
||||
content := result.String()
|
||||
logger.Trace("Generated content:\n%s", content)
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func getExistingCaddyFiles(dir string) (map[string]string, error) {
|
||||
logger := cylogger.Default.WithPrefix(fmt.Sprintf("scanner dir=%s", dir))
|
||||
logger.Debug("Scanning directory for .caddy files")
|
||||
|
||||
files := make(map[string]string)
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".caddy") {
|
||||
// Skip services.toml and other non-caddy files
|
||||
if entry.Name() == "services.toml" {
|
||||
continue
|
||||
}
|
||||
|
||||
logger.Trace("Found .caddy file: %s", entry.Name())
|
||||
content, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file %s: %w", entry.Name(), err)
|
||||
}
|
||||
files[entry.Name()] = string(content)
|
||||
}
|
||||
}
|
||||
|
||||
logger.Debug("Scanned %d files, found %d .caddy files", len(entries), len(files))
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func writeFile(filename, content string) error {
|
||||
return os.WriteFile(filename, []byte(content), 0644)
|
||||
}
|
||||
|
||||
func generateAndLogDiff(logger *cylogger.Logger, filename, existingContent, expectedContent string) {
|
||||
logger.Info("Changes needed for %s:", filename)
|
||||
|
||||
// ANSI color codes
|
||||
const (
|
||||
ColorRed = "\033[31m"
|
||||
ColorGreen = "\033[32m"
|
||||
ColorReset = "\033[0m"
|
||||
)
|
||||
|
||||
dmp := diffmatchpatch.New()
|
||||
diffs := dmp.DiffMain(existingContent, expectedContent, false)
|
||||
|
||||
// Use the library's built-in pretty diff formatting
|
||||
prettyDiff := dmp.DiffPrettyText(diffs)
|
||||
|
||||
// Print the pretty diff with colors
|
||||
lines := strings.Split(prettyDiff, "\n")
|
||||
for _, line := range lines {
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(line, "+") {
|
||||
fmt.Printf("%s%s%s\n", ColorGreen, line, ColorReset)
|
||||
} else if strings.HasPrefix(line, "-") {
|
||||
fmt.Printf("%s%s%s\n", ColorRed, line, ColorReset)
|
||||
} else {
|
||||
fmt.Printf("%s\n", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func contentMatches(existing, expected string) bool {
|
||||
dmp := diffmatchpatch.New()
|
||||
diffs := dmp.DiffMain(normalize(existing), normalize(expected), false)
|
||||
|
||||
// If there are any insertions or deletions, content differs
|
||||
for _, diff := range diffs {
|
||||
if diff.Type == diffmatchpatch.DiffInsert || diff.Type == diffmatchpatch.DiffDelete {
|
||||
cylogger.Trace("Content differs at %q", diff.Text)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalize(text string) string {
|
||||
// Standardize line endings
|
||||
text = strings.ReplaceAll(text, "\r\n", "\n")
|
||||
text = strings.ReplaceAll(text, "\r", "\n")
|
||||
|
||||
// Split into lines and normalize each
|
||||
lines := strings.Split(text, "\n")
|
||||
var normalizedLines []string
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed != "" { // Skip empty lines
|
||||
normalizedLines = append(normalizedLines, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
// Join with single newlines and trim ends
|
||||
result := strings.Join(normalizedLines, "\n")
|
||||
return strings.TrimSpace(result)
|
||||
}
|
||||
Reference in New Issue
Block a user