homelab/services/ha-sync/internal/config/config.go
Dan V deb6c38d7b chore: commit homelab setup — deployment, services, orchestration, skill
- Add .gitignore: exclude compiled binaries, build artifacts, and Helm
  values files containing real secrets (authentik, prometheus)
- Add all Kubernetes deployment manifests (deployment/)
- Add services source code: ha-sync, device-inventory, games-console,
  paperclip, parts-inventory
- Add Ansible orchestration: playbooks, roles, inventory, cloud-init
- Add hardware specs, execution plans, scripts, HOMELAB.md
- Add skills/homelab/SKILL.md + skills/install.sh to preserve Copilot skill
- Remove previously-tracked inventory-cli binary from git index

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 08:10:32 +02:00

140 lines
5.6 KiB
Go

package config
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
)
// Config holds all runtime configuration for ha-sync.
type Config struct {
Src string
Dest string
Pair string
Direction string
DBDSN string
LockTTL int
LogDir string
LogRetainDays int
MtimeThreshold time.Duration
DeleteMissing bool
Workers int
DryRun bool
Verbose bool
KubeNamespace string
Excludes []string // glob patterns passed via --exclude flags
SrcHost string
DestHost string
}
// NewRootCmd returns a configured cobra root command with all flags bound to a
// Config. Call FromFlags(cmd) after Execute() to obtain a validated *Config.
func NewRootCmd() *cobra.Command {
cfg := &Config{}
cmd := &cobra.Command{
Use: "ha-sync",
Short: "Bidirectional HA file-sync with leader-election and audit logging",
Long: `ha-sync copies files from a source directory to a destination directory,
ensuring only one instance runs per sync pair at a time via a Kubernetes Lease.
Each run is recorded in MySQL with per-file operation details. Use --dry-run to
preview what would change without writing any files.
Examples:
# Sync /data/media from Dell to HP (production)
ha-sync --src=/data/media --dest=/mnt/hp/media --pair=media --direction=dell-to-hp
# Dry-run first to preview changes
ha-sync --src=/data/media --dest=/mnt/hp/media --pair=media --direction=dell-to-hp --dry-run
# Exclude temporary and lock files
ha-sync --src=/data/infra --dest=/mnt/hp/infra --pair=infra --direction=dell-to-hp \
--exclude='*.sock' --exclude='*.lock' --exclude='*.pid'`,
}
f := cmd.Flags()
f.StringVar(&cfg.Src, "src", "", "Source directory path (required)")
f.StringVar(&cfg.Dest, "dest", "", "Destination directory path (required)")
f.StringVar(&cfg.Pair, "pair", "", "Unique name for this sync pair; used as the Kubernetes Lease name ha-sync-<pair> (required)")
f.StringVar(&cfg.Direction, "direction", "fwd", "Label stored in the audit log identifying sync direction (e.g. dell-to-hp, hp-to-dell)")
f.StringVar(&cfg.DBDSN, "db-dsn", "", "MySQL DSN for audit logging. Defaults to env HA_SYNC_DB_DSN (format: user:pass@tcp(host:port)/db?parseTime=true)")
f.IntVar(&cfg.LockTTL, "lock-ttl", 3600, "Kubernetes Lease TTL in seconds; a crashed pod releases the lock after this duration")
f.StringVar(&cfg.LogDir, "log-dir", "/var/log/ha-sync", "Directory for file-based operation logs")
f.IntVar(&cfg.LogRetainDays, "log-retain-days", 10, "Number of days to retain log files before rotation")
f.DurationVar(&cfg.MtimeThreshold, "mtime-threshold", 2*time.Second, "Minimum mtime difference required to treat a same-size file as changed (avoids NFS clock skew false positives)")
f.BoolVar(&cfg.DeleteMissing, "delete-missing", false, "Delete files in dest that no longer exist in src (use with caution)")
f.IntVar(&cfg.Workers, "workers", 4, "Number of concurrent file-copy workers")
f.BoolVar(&cfg.DryRun, "dry-run", false, "Preview what would be synced without writing any files; results are recorded in the DB with dry_run=true")
f.BoolVar(&cfg.Verbose, "verbose", false, "Log every file operation (create/update/skip); default logs only errors and summary")
f.StringVar(&cfg.KubeNamespace, "kube-namespace", "infrastructure", "Kubernetes namespace where the Lease object is managed")
f.StringArrayVar(&cfg.Excludes, "exclude", nil, "Glob pattern to skip (may be repeated). Matched against the file's base name and its path relative to src. Supports * and ? wildcards. Example: --exclude='*.sock' --exclude='tmp/*'")
f.StringVar(&cfg.SrcHost, "src-host", "dell", "Label for the source host (stored in audit log, e.g. dell, hp)")
f.StringVar(&cfg.DestHost, "dest-host", "hp", "Label for the destination host (stored in audit log, e.g. dell, hp)")
return cmd
}
// FromFlags reads flag values from cmd and returns a validated Config.
// It returns an error if any required field is missing.
func FromFlags(cmd *cobra.Command) (*Config, error) {
f := cmd.Flags()
src, _ := f.GetString("src")
dest, _ := f.GetString("dest")
pair, _ := f.GetString("pair")
direction, _ := f.GetString("direction")
dbDSN, _ := f.GetString("db-dsn")
lockTTL, _ := f.GetInt("lock-ttl")
logDir, _ := f.GetString("log-dir")
logRetainDays, _ := f.GetInt("log-retain-days")
mtimeThreshold, _ := f.GetDuration("mtime-threshold")
deleteMissing, _ := f.GetBool("delete-missing")
workers, _ := f.GetInt("workers")
dryRun, _ := f.GetBool("dry-run")
verbose, _ := f.GetBool("verbose")
kubeNamespace, _ := f.GetString("kube-namespace")
excludes, _ := f.GetStringArray("exclude")
srcHost, _ := f.GetString("src-host")
destHost, _ := f.GetString("dest-host")
if src == "" {
return nil, fmt.Errorf("--src is required")
}
if dest == "" {
return nil, fmt.Errorf("--dest is required")
}
if pair == "" {
return nil, fmt.Errorf("--pair is required")
}
// Fall back to env if the flag was left at its default empty value.
if dbDSN == "" {
dbDSN = os.Getenv("HA_SYNC_DB_DSN")
}
if dbDSN == "" {
return nil, fmt.Errorf("--db-dsn or env HA_SYNC_DB_DSN is required")
}
return &Config{
Src: src,
Dest: dest,
Pair: pair,
Direction: direction,
DBDSN: dbDSN,
LockTTL: lockTTL,
LogDir: logDir,
LogRetainDays: logRetainDays,
MtimeThreshold: mtimeThreshold,
DeleteMissing: deleteMissing,
Workers: workers,
DryRun: dryRun,
Verbose: verbose,
KubeNamespace: kubeNamespace,
Excludes: excludes,
SrcHost: srcHost,
DestHost: destHost,
}, nil
}