Files
vctp2/main.go
Nathan Coad c566456ebd
All checks were successful
continuous-integration/drone/push Build is passing
add configuration for monthly aggregation job timing
2026-01-28 09:04:16 +11:00

352 lines
11 KiB
Go

package main
import (
"context"
"flag"
"fmt"
"os"
"runtime"
"strings"
"time"
"vctp/db"
"vctp/internal/secrets"
"vctp/internal/settings"
"vctp/internal/tasks"
utils "vctp/internal/utils"
"vctp/internal/vcenter"
"vctp/log"
"vctp/server"
"vctp/server/router"
"crypto/sha256"
"log/slog"
"github.com/go-co-op/gocron/v2"
)
var (
bindDisableTls bool
sha1ver string // sha1 revision used to build the program
buildTime string // when the executable was built
cronFrequency time.Duration
cronInvFrequency time.Duration
cronSnapshotFrequency time.Duration
cronAggregateFrequency time.Duration
)
const fallbackEncryptionKey = "5L1l3B5KvwOCzUHMAlCgsgUTRAYMfSpa"
func main() {
settingsPath := flag.String("settings", "/etc/dtms/vctp.yml", "Path to settings YAML")
runInventory := flag.Bool("run-inventory", false, "Run a single inventory snapshot across all configured vCenters and exit")
flag.Parse()
bootstrapLogger := log.New(log.LevelInfo, log.OutputText)
ctx, cancel := context.WithCancel(context.Background())
// Load settings from yaml
s := settings.New(bootstrapLogger, *settingsPath)
err := s.ReadYMLSettings()
if err != nil {
bootstrapLogger.Error("failed to open yaml settings file", "error", err, "filename", *settingsPath)
os.Exit(1)
}
logger := log.New(
log.ToLevel(strings.ToLower(strings.TrimSpace(s.Values.Settings.LogLevel))),
log.ToOutput(strings.ToLower(strings.TrimSpace(s.Values.Settings.LogOutput))),
)
s.Logger = logger
logger.Info("vCTP starting", "build_time", buildTime, "sha1_version", sha1ver, "go_version", runtime.Version(), "settings_file", *settingsPath)
// Configure database
dbDriver := strings.TrimSpace(s.Values.Settings.DatabaseDriver)
if dbDriver == "" {
dbDriver = "sqlite"
}
normalizedDriver := strings.ToLower(strings.TrimSpace(dbDriver))
if normalizedDriver == "" || normalizedDriver == "sqlite3" {
normalizedDriver = "sqlite"
}
dbURL := strings.TrimSpace(s.Values.Settings.DatabaseURL)
if dbURL == "" && normalizedDriver == "sqlite" {
dbURL = utils.GetFilePath("db.sqlite3")
}
database, err := db.New(logger, db.Config{Driver: normalizedDriver, DSN: dbURL})
if err != nil {
logger.Error("Failed to create database", "error", err)
os.Exit(1)
}
defer database.Close()
//defer database.DB().Close()
if err = db.Migrate(database, normalizedDriver); err != nil {
logger.Error("failed to migrate database", "error", err)
os.Exit(1)
}
// Determine bind IP
bindIP := strings.TrimSpace(s.Values.Settings.BindIP)
if bindIP == "" {
bindIP = utils.GetOutboundIP().String()
}
// Determine bind port
bindPort := s.Values.Settings.BindPort
if bindPort == 0 {
bindPort = 9443
}
bindAddress := fmt.Sprint(bindIP, ":", bindPort)
//logger.Info("Will listen on address", "ip", bindIP, "port", bindPort)
// Determine bind disable TLS
bindDisableTls = s.Values.Settings.BindDisableTLS
// Get file names for TLS cert/key
tlsCertFilename := strings.TrimSpace(s.Values.Settings.TLSCertFilename)
if tlsCertFilename != "" {
tlsCertFilename = utils.GetFilePath(tlsCertFilename)
} else {
tlsCertFilename = "./cert.pem"
}
tlsKeyFilename := strings.TrimSpace(s.Values.Settings.TLSKeyFilename)
if tlsKeyFilename != "" {
tlsKeyFilename = utils.GetFilePath(tlsKeyFilename)
} else {
tlsKeyFilename = "./privkey.pem"
}
// Generate certificate if required
if !(utils.FileExists(tlsCertFilename) && utils.FileExists(tlsKeyFilename)) {
logger.Warn("Specified TLS certificate or private key do not exist", "certificate", tlsCertFilename, "tls-key", tlsKeyFilename)
utils.GenerateCerts(tlsCertFilename, tlsKeyFilename)
}
// Load vcenter credentials from serttings, decrypt if required
encKey := deriveEncryptionKey(logger)
a := secrets.New(logger, encKey)
vcEp := strings.TrimSpace(s.Values.Settings.VcenterPassword)
if len(vcEp) == 0 {
logger.Error("No vcenter password configured")
os.Exit(1)
}
vcPass, err := a.Decrypt(vcEp)
if err != nil {
logger.Error("failed to decrypt vcenter credentials. Assuming un-encrypted", "error", err)
vcPass = []byte(vcEp)
if cipherText, encErr := a.Encrypt([]byte(vcEp)); encErr != nil {
logger.Warn("failed to encrypt vcenter credentials", "error", encErr)
} else {
s.Values.Settings.VcenterPassword = cipherText
if err := s.WriteYMLSettings(); err != nil {
logger.Warn("failed to update settings with encrypted vcenter password", "error", err)
} else {
logger.Info("encrypted vcenter password stored in settings file")
}
}
}
creds := vcenter.VcenterLogin{
Username: strings.TrimSpace(s.Values.Settings.VcenterUsername),
Password: string(vcPass),
Insecure: s.Values.Settings.VcenterInsecure,
}
if creds.Username == "" {
logger.Error("No vcenter username configured")
os.Exit(1)
}
// Set a recognizable User-Agent for vCenter sessions.
ua := "vCTP"
if sha1ver != "" {
ua = fmt.Sprintf("vCTP/%s", sha1ver)
}
vcenter.SetUserAgent(ua)
// Prepare the task scheduler
c, err := gocron.NewScheduler()
if err != nil {
logger.Error("failed to create scheduler", "error", err)
os.Exit(1)
}
// Pass useful information to the cron jobs
ct := &tasks.CronTask{
Logger: logger,
Database: database,
Settings: s,
VcCreds: &creds,
FirstHourlySnapshotCheck: true,
}
// One-shot mode: run a single inventory snapshot across all configured vCenters and exit.
if *runInventory {
logger.Info("Running one-shot inventory snapshot across all vCenters")
ct.RunVcenterSnapshotHourly(ctx, logger, true)
logger.Info("One-shot inventory snapshot complete; exiting")
return
}
cronSnapshotFrequency = durationFromSeconds(s.Values.Settings.VcenterInventorySnapshotSeconds, 3600)
logger.Debug("Setting VM inventory snapshot cronjob frequency to", "frequency", cronSnapshotFrequency)
cronAggregateFrequency = durationFromSeconds(s.Values.Settings.VcenterInventoryAggregateSeconds, 86400)
logger.Debug("Setting VM inventory daily aggregation cronjob frequency to", "frequency", cronAggregateFrequency)
startsAt3 := alignStart(time.Now(), cronSnapshotFrequency)
job3, err := c.NewJob(
gocron.DurationJob(cronSnapshotFrequency),
gocron.NewTask(func() {
ct.RunVcenterSnapshotHourly(ctx, logger, false)
}), gocron.WithSingletonMode(gocron.LimitModeReschedule),
gocron.WithStartAt(gocron.WithStartDateTime(startsAt3)),
)
if err != nil {
logger.Error("failed to start vcenter inventory snapshot cron job", "error", err)
os.Exit(1)
}
logger.Debug("Created vcenter inventory snapshot cron job", "job", job3.ID(), "starting_at", startsAt3)
startsAt4 := time.Now().Add(cronAggregateFrequency)
if cronAggregateFrequency == time.Hour*24 {
now := time.Now()
startsAt4 = time.Date(now.Year(), now.Month(), now.Day()+1, 0, 10, 0, 0, now.Location())
}
job4, err := c.NewJob(
gocron.DurationJob(cronAggregateFrequency),
gocron.NewTask(func() {
ct.RunVcenterDailyAggregate(ctx, logger)
}), gocron.WithSingletonMode(gocron.LimitModeReschedule),
gocron.WithStartAt(gocron.WithStartDateTime(startsAt4)),
)
if err != nil {
logger.Error("failed to start vcenter inventory aggregation cron job", "error", err)
os.Exit(1)
}
logger.Debug("Created vcenter inventory aggregation cron job", "job", job4.ID(), "starting_at", startsAt4)
monthlyCron := strings.TrimSpace(s.Values.Settings.MonthlyAggregationCron)
if monthlyCron == "" {
monthlyCron = "10 3 1 * *"
}
logger.Debug("Setting monthly aggregation cron schedule", "cron", monthlyCron)
job5, err := c.NewJob(
gocron.CronJob(monthlyCron, false),
gocron.NewTask(func() {
ct.RunVcenterMonthlyAggregate(ctx, logger)
}), gocron.WithSingletonMode(gocron.LimitModeReschedule),
)
if err != nil {
logger.Error("failed to start vcenter monthly aggregation cron job", "error", err)
os.Exit(1)
}
logger.Debug("Created vcenter monthly aggregation cron job", "job", job5.ID())
snapshotCleanupCron := strings.TrimSpace(s.Values.Settings.SnapshotCleanupCron)
if snapshotCleanupCron == "" {
snapshotCleanupCron = "30 2 * * *"
}
job6, err := c.NewJob(
gocron.CronJob(snapshotCleanupCron, false),
gocron.NewTask(func() {
ct.RunSnapshotCleanup(ctx, logger)
if strings.EqualFold(s.Values.Settings.DatabaseDriver, "sqlite") {
logger.Info("Performing sqlite VACUUM after snapshot cleanup")
if _, err := ct.Database.DB().ExecContext(ctx, "VACUUM"); err != nil {
logger.Warn("VACUUM failed after snapshot cleanup", "error", err)
} else {
logger.Debug("VACUUM completed after snapshot cleanup")
}
}
}), gocron.WithSingletonMode(gocron.LimitModeReschedule),
)
if err != nil {
logger.Error("failed to start snapshot cleanup cron job", "error", err)
os.Exit(1)
}
logger.Debug("Created snapshot cleanup cron job", "job", job6.ID())
// Retry failed hourly snapshots
retrySeconds := s.Values.Settings.HourlySnapshotRetrySeconds
if retrySeconds <= 0 {
retrySeconds = 300
}
job7, err := c.NewJob(
gocron.DurationJob(time.Duration(retrySeconds)*time.Second),
gocron.NewTask(func() {
ct.RunHourlySnapshotRetry(ctx, logger)
}), gocron.WithSingletonMode(gocron.LimitModeReschedule),
)
if err != nil {
logger.Error("failed to start hourly snapshot retry cron job", "error", err)
os.Exit(1)
}
logger.Debug("Created hourly snapshot retry cron job", "job", job7.ID(), "interval_seconds", retrySeconds)
// start cron scheduler
c.Start()
// Start server
r := router.New(logger, database, buildTime, sha1ver, runtime.Version(), &creds, a, s)
svr := server.New(
logger,
c,
cancel,
bindAddress,
server.WithRouter(r),
server.SetTls(bindDisableTls),
server.SetCertificate(tlsCertFilename),
server.SetPrivateKey(tlsKeyFilename),
)
//logger.Debug("Server configured", "object", svr)
svr.StartAndWait()
os.Exit(0)
}
// alignStart snaps the first run to a sensible boundary (hour or 15-minute block) when possible.
func alignStart(now time.Time, freq time.Duration) time.Time {
if freq == time.Hour {
return now.Truncate(time.Hour).Add(time.Hour)
}
quarter := 15 * time.Minute
if freq%quarter == 0 {
return now.Truncate(quarter).Add(quarter)
}
return now.Add(freq)
}
func durationFromSeconds(value int, fallback int) time.Duration {
if value <= 0 {
return time.Second * time.Duration(fallback)
}
return time.Second * time.Duration(value)
}
func deriveEncryptionKey(logger *slog.Logger) []byte {
if runtime.GOOS == "linux" {
if data, err := os.ReadFile("/sys/class/dmi/id/product_uuid"); err == nil {
src := strings.TrimSpace(string(data))
if src != "" {
sum := sha256.Sum256([]byte(src))
logger.Debug("derived encryption key from BIOS UUID")
return sum[:]
}
}
if data, err := os.ReadFile("/etc/machine-id"); err == nil {
src := strings.TrimSpace(string(data))
if src != "" {
sum := sha256.Sum256([]byte(src))
logger.Debug("derived encryption key from machine-id")
return sum[:]
}
}
}
logger.Warn("using fallback encryption key; hardware UUID not available")
return []byte(fallbackEncryptionKey)
}