diff --git a/.drone.yml b/.drone.yml index 54f0be4..07322f9 100644 --- a/.drone.yml +++ b/.drone.yml @@ -37,7 +37,7 @@ steps: - go install github.com/a-h/templ/cmd/templ@latest - go install github.com/sqlc-dev/sqlc/cmd/sqlc@latest - go install github.com/swaggo/swag/cmd/swag@latest - - go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest +# - go install github.com/goreleaser/nfpm/v2/cmd/nfpm@latest - sqlc generate - templ generate -path ./components - swag init --exclude "pkg.mod,pkg.build,pkg.tools" -o server/router/docs diff --git a/internal/tasks/inventorySnapshots.go b/internal/tasks/inventorySnapshots.go index d8dd816..0201c44 100644 --- a/internal/tasks/inventorySnapshots.go +++ b/internal/tasks/inventorySnapshots.go @@ -86,6 +86,7 @@ func (c *CronTask) RunVcenterSnapshotHourly(ctx context.Context, logger *slog.Lo } presentSnapshots := make(map[string]inventorySnapshotRow, len(vcVms)) + totals := snapshotTotals{} for _, vm := range vcVms { if strings.HasPrefix(vm.Name(), "vCLS-") { continue @@ -113,6 +114,11 @@ func (c *CronTask) RunVcenterSnapshotHourly(ctx context.Context, logger *slog.Lo } row.IsPresent = "TRUE" presentSnapshots[vm.Reference().Value] = row + + totals.VmCount++ + totals.VcpuTotal += nullInt64ToInt(row.InitialVcpus) + totals.RamTotal += nullInt64ToInt(row.InitialRam) + totals.DiskTotal += nullFloat64ToFloat(row.ProvisionedDisk) } for _, row := range presentSnapshots { @@ -137,6 +143,14 @@ func (c *CronTask) RunVcenterSnapshotHourly(ctx context.Context, logger *slog.Lo } vc.Logout() + + c.Logger.Info("Hourly snapshot summary", + "vcenter", url, + "vm_count", totals.VmCount, + "vcpu_total", totals.VcpuTotal, + "ram_total_mb", totals.RamTotal, + "disk_total_gb", totals.DiskTotal, + ) } c.Logger.Debug("Finished hourly vcenter snapshot") @@ -160,17 +174,61 @@ func (c *CronTask) RunVcenterDailyAggregate(ctx context.Context, logger *slog.Lo return err } + currentTotals, err := snapshotTotalsForTable(ctx, dbConn, sourceTable) + if err != nil { + c.Logger.Warn("unable to calculate daily totals", "error", err, "table", sourceTable) + } else { + c.Logger.Info("Daily snapshot totals", + "table", sourceTable, + "vm_count", currentTotals.VmCount, + "vcpu_total", currentTotals.VcpuTotal, + "ram_total_mb", currentTotals.RamTotal, + "disk_total_gb", currentTotals.DiskTotal, + ) + } + + prevTable, _ := dailyInventoryTableName(targetTime.AddDate(0, 0, -1)) + if prevTable != "" && tableExists(ctx, dbConn, prevTable) { + prevTotals, err := snapshotTotalsForTable(ctx, dbConn, prevTable) + if err != nil { + c.Logger.Warn("unable to calculate previous day totals", "error", err, "table", prevTable) + } else { + c.Logger.Info("Daily snapshot comparison", + "current_table", sourceTable, + "previous_table", prevTable, + "vm_delta", currentTotals.VmCount-prevTotals.VmCount, + "vcpu_delta", currentTotals.VcpuTotal-prevTotals.VcpuTotal, + "ram_delta_mb", currentTotals.RamTotal-prevTotals.RamTotal, + "disk_delta_gb", currentTotals.DiskTotal-prevTotals.DiskTotal, + ) + } + } + insertQuery := fmt.Sprintf(` INSERT INTO %s ( "InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime", "ResourcePool", "VmType", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "InitialVcpus", - "InitialRam", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", "SamplesPresent" + "InitialRam", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", + "SamplesPresent", "AvgVcpus", "AvgRam", "AvgDisk", "AvgIsPresent", + "PoolTinPct", "PoolBronzePct", "PoolSilverPct", "PoolGoldPct" ) SELECT "InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime", "ResourcePool", "VmType", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "InitialVcpus", "InitialRam", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", - SUM(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END) AS "SamplesPresent" + SUM(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END) AS "SamplesPresent", + AVG(CASE WHEN "IsPresent" = 'TRUE' AND "InitialVcpus" IS NOT NULL THEN "InitialVcpus" END) AS "AvgVcpus", + AVG(CASE WHEN "IsPresent" = 'TRUE' AND "InitialRam" IS NOT NULL THEN "InitialRam" END) AS "AvgRam", + AVG(CASE WHEN "IsPresent" = 'TRUE' AND "ProvisionedDisk" IS NOT NULL THEN "ProvisionedDisk" END) AS "AvgDisk", + AVG(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END) AS "AvgIsPresent", + 100.0 * SUM(CASE WHEN "IsPresent" = 'TRUE' AND LOWER("ResourcePool") = 'tin' THEN 1 ELSE 0 END) + / NULLIF(SUM(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END), 0) AS "PoolTinPct", + 100.0 * SUM(CASE WHEN "IsPresent" = 'TRUE' AND LOWER("ResourcePool") = 'bronze' THEN 1 ELSE 0 END) + / NULLIF(SUM(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END), 0) AS "PoolBronzePct", + 100.0 * SUM(CASE WHEN "IsPresent" = 'TRUE' AND LOWER("ResourcePool") = 'silver' THEN 1 ELSE 0 END) + / NULLIF(SUM(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END), 0) AS "PoolSilverPct", + 100.0 * SUM(CASE WHEN "IsPresent" = 'TRUE' AND LOWER("ResourcePool") = 'gold' THEN 1 ELSE 0 END) + / NULLIF(SUM(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END), 0) AS "PoolGoldPct" FROM %s GROUP BY "InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime", @@ -222,12 +280,26 @@ func (c *CronTask) RunVcenterMonthlyAggregate(ctx context.Context, logger *slog. return fmt.Errorf("no valid daily snapshot tables found for %s", targetMonth.Format("2006-01")) } + monthlyTotals, err := snapshotTotalsForUnion(ctx, dbConn, unionQuery) + if err != nil { + c.Logger.Warn("unable to calculate monthly totals", "error", err, "month", targetMonth.Format("2006-01")) + } else { + c.Logger.Info("Monthly snapshot totals", + "month", targetMonth.Format("2006-01"), + "vm_count", monthlyTotals.VmCount, + "vcpu_total", monthlyTotals.VcpuTotal, + "ram_total_mb", monthlyTotals.RamTotal, + "disk_total_gb", monthlyTotals.DiskTotal, + ) + } + insertQuery := fmt.Sprintf(` INSERT INTO %s ( "InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime", "ResourcePool", "VmType", "Datacenter", "Cluster", "Folder", "ProvisionedDisk", "InitialVcpus", "InitialRam", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", - "AvgVcpus", "AvgRam", "AvgIsPresent" + "AvgVcpus", "AvgRam", "AvgDisk", "AvgIsPresent", + "PoolTinPct", "PoolBronzePct", "PoolSilverPct", "PoolGoldPct" ) SELECT "InventoryId", "Name", "Vcenter", "VmId", "EventKey", "CloudId", "CreationTime", "DeletionTime", @@ -235,7 +307,16 @@ SELECT "InitialRam", "IsTemplate", "PoweredOn", "SrmPlaceholder", "VmUuid", AVG(CASE WHEN "InitialVcpus" IS NOT NULL THEN "InitialVcpus" END) AS "AvgVcpus", AVG(CASE WHEN "InitialRam" IS NOT NULL THEN "InitialRam" END) AS "AvgRam", - AVG(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END) AS "AvgIsPresent" + AVG(CASE WHEN "ProvisionedDisk" IS NOT NULL THEN "ProvisionedDisk" END) AS "AvgDisk", + AVG(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END) AS "AvgIsPresent", + 100.0 * SUM(CASE WHEN "IsPresent" = 'TRUE' AND LOWER("ResourcePool") = 'tin' THEN 1 ELSE 0 END) + / NULLIF(SUM(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END), 0) AS "PoolTinPct", + 100.0 * SUM(CASE WHEN "IsPresent" = 'TRUE' AND LOWER("ResourcePool") = 'bronze' THEN 1 ELSE 0 END) + / NULLIF(SUM(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END), 0) AS "PoolBronzePct", + 100.0 * SUM(CASE WHEN "IsPresent" = 'TRUE' AND LOWER("ResourcePool") = 'silver' THEN 1 ELSE 0 END) + / NULLIF(SUM(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END), 0) AS "PoolSilverPct", + 100.0 * SUM(CASE WHEN "IsPresent" = 'TRUE' AND LOWER("ResourcePool") = 'gold' THEN 1 ELSE 0 END) + / NULLIF(SUM(CASE WHEN "IsPresent" = 'TRUE' THEN 1 ELSE 0 END), 0) AS "PoolGoldPct" FROM ( %s ) snapshots @@ -270,6 +351,7 @@ func (c *CronTask) RunSnapshotCleanup(ctx context.Context, logger *slog.Logger) return err } + removedHourly := 0 for _, table := range hourlyTables { if strings.HasPrefix(table, "inventory_daily_summary_") { continue @@ -281,6 +363,8 @@ func (c *CronTask) RunSnapshotCleanup(ctx context.Context, logger *slog.Logger) if tableDate.Before(truncateDate(hourlyCutoff)) { if err := dropSnapshotTable(ctx, dbConn, table); err != nil { c.Logger.Error("failed to drop hourly snapshot table", "error", err, "table", table) + } else { + removedHourly++ } } } @@ -289,6 +373,7 @@ func (c *CronTask) RunSnapshotCleanup(ctx context.Context, logger *slog.Logger) if err != nil { return err } + removedDaily := 0 for _, table := range dailyTables { tableDate, ok := parseSnapshotDate(table, "inventory_daily_summary_", "20060102") if !ok { @@ -297,11 +382,18 @@ func (c *CronTask) RunSnapshotCleanup(ctx context.Context, logger *slog.Logger) if tableDate.Before(truncateDate(dailyCutoff)) { if err := dropSnapshotTable(ctx, dbConn, table); err != nil { c.Logger.Error("failed to drop daily snapshot table", "error", err, "table", table) + } else { + removedDaily++ } } } - c.Logger.Debug("Finished snapshot cleanup") + c.Logger.Info("Finished snapshot cleanup", + "removed_hourly_tables", removedHourly, + "removed_daily_tables", removedDaily, + "hourly_max_age_days", hourlyMaxDays, + "daily_max_age_months", dailyMaxMonths, + ) return nil } @@ -379,11 +471,31 @@ func ensureDailySummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName str "PoweredOn" TEXT, "SrmPlaceholder" TEXT, "VmUuid" TEXT, - "SamplesPresent" BIGINT NOT NULL + "SamplesPresent" BIGINT NOT NULL, + "AvgVcpus" REAL, + "AvgRam" REAL, + "AvgDisk" REAL, + "AvgIsPresent" REAL, + "PoolTinPct" REAL, + "PoolBronzePct" REAL, + "PoolSilverPct" REAL, + "PoolGoldPct" REAL );`, tableName) - _, err := dbConn.ExecContext(ctx, ddl) - return err + if _, err := dbConn.ExecContext(ctx, ddl); err != nil { + return err + } + + return ensureSnapshotColumns(ctx, dbConn, tableName, []columnDef{ + {Name: "AvgVcpus", Type: "REAL"}, + {Name: "AvgRam", Type: "REAL"}, + {Name: "AvgDisk", Type: "REAL"}, + {Name: "AvgIsPresent", Type: "REAL"}, + {Name: "PoolTinPct", Type: "REAL"}, + {Name: "PoolBronzePct", Type: "REAL"}, + {Name: "PoolSilverPct", Type: "REAL"}, + {Name: "PoolGoldPct", Type: "REAL"}, + }) } func ensureMonthlySummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName string) error { @@ -410,11 +522,25 @@ func ensureMonthlySummaryTable(ctx context.Context, dbConn *sqlx.DB, tableName s "VmUuid" TEXT, "AvgVcpus" REAL, "AvgRam" REAL, - "AvgIsPresent" REAL + "AvgDisk" REAL, + "AvgIsPresent" REAL, + "PoolTinPct" REAL, + "PoolBronzePct" REAL, + "PoolSilverPct" REAL, + "PoolGoldPct" REAL );`, tableName) - _, err := dbConn.ExecContext(ctx, ddl) - return err + if _, err := dbConn.ExecContext(ctx, ddl); err != nil { + return err + } + + return ensureSnapshotColumns(ctx, dbConn, tableName, []columnDef{ + {Name: "AvgDisk", Type: "REAL"}, + {Name: "PoolTinPct", Type: "REAL"}, + {Name: "PoolBronzePct", Type: "REAL"}, + {Name: "PoolSilverPct", Type: "REAL"}, + {Name: "PoolGoldPct", Type: "REAL"}, + }) } func buildUnionQuery(tables []string, columns []string) string { @@ -453,6 +579,121 @@ func dropSnapshotTable(ctx context.Context, dbConn *sqlx.DB, table string) error return err } +type snapshotTotals struct { + VmCount int64 + VcpuTotal int64 + RamTotal int64 + DiskTotal float64 +} + +type columnDef struct { + Name string + Type string +} + +func ensureSnapshotColumns(ctx context.Context, dbConn *sqlx.DB, tableName string, columns []columnDef) error { + if _, err := safeTableName(tableName); err != nil { + return err + } + for _, column := range columns { + if err := addColumnIfMissing(ctx, dbConn, tableName, column); err != nil { + return err + } + } + return nil +} + +func addColumnIfMissing(ctx context.Context, dbConn *sqlx.DB, tableName string, column columnDef) error { + query := fmt.Sprintf(`ALTER TABLE %s ADD COLUMN "%s" %s`, tableName, column.Name, column.Type) + if _, err := dbConn.ExecContext(ctx, query); err != nil { + errText := strings.ToLower(err.Error()) + if strings.Contains(errText, "duplicate column") || strings.Contains(errText, "already exists") { + return nil + } + return err + } + return nil +} + +func snapshotTotalsForTable(ctx context.Context, dbConn *sqlx.DB, table string) (snapshotTotals, error) { + if _, err := safeTableName(table); err != nil { + return snapshotTotals{}, err + } + query := fmt.Sprintf(` +SELECT + COUNT(DISTINCT "VmId") AS vm_count, + COALESCE(SUM(CASE WHEN "InitialVcpus" IS NOT NULL THEN "InitialVcpus" ELSE 0 END), 0) AS vcpu_total, + COALESCE(SUM(CASE WHEN "InitialRam" IS NOT NULL THEN "InitialRam" ELSE 0 END), 0) AS ram_total, + COALESCE(SUM(CASE WHEN "ProvisionedDisk" IS NOT NULL THEN "ProvisionedDisk" ELSE 0 END), 0) AS disk_total +FROM %s +WHERE "IsPresent" = 'TRUE' +`, table) + + var totals snapshotTotals + if err := dbConn.GetContext(ctx, &totals, query); err != nil { + return snapshotTotals{}, err + } + return totals, nil +} + +func snapshotTotalsForUnion(ctx context.Context, dbConn *sqlx.DB, unionQuery string) (snapshotTotals, error) { + query := fmt.Sprintf(` +SELECT + COUNT(DISTINCT "VmId") AS vm_count, + COALESCE(SUM(CASE WHEN "InitialVcpus" IS NOT NULL THEN "InitialVcpus" ELSE 0 END), 0) AS vcpu_total, + COALESCE(SUM(CASE WHEN "InitialRam" IS NOT NULL THEN "InitialRam" ELSE 0 END), 0) AS ram_total, + COALESCE(SUM(CASE WHEN "ProvisionedDisk" IS NOT NULL THEN "ProvisionedDisk" ELSE 0 END), 0) AS disk_total +FROM ( +%s +) snapshots +WHERE "IsPresent" = 'TRUE' +`, unionQuery) + + var totals snapshotTotals + if err := dbConn.GetContext(ctx, &totals, query); err != nil { + return snapshotTotals{}, err + } + return totals, nil +} + +func tableExists(ctx context.Context, dbConn *sqlx.DB, table string) bool { + driver := strings.ToLower(dbConn.DriverName()) + switch driver { + case "sqlite": + var count int + err := dbConn.GetContext(ctx, &count, ` +SELECT COUNT(1) +FROM sqlite_master +WHERE type = 'table' AND name = ? +`, table) + return err == nil && count > 0 + case "pgx", "postgres": + var count int + err := dbConn.GetContext(ctx, &count, ` +SELECT COUNT(1) +FROM pg_catalog.pg_tables +WHERE schemaname = 'public' AND tablename = $1 +`, table) + return err == nil && count > 0 + default: + return false + } +} + +func nullInt64ToInt(value sql.NullInt64) int64 { + if value.Valid { + return value.Int64 + } + return 0 +} + +func nullFloat64ToFloat(value sql.NullFloat64) float64 { + if value.Valid { + return value.Float64 + } + return 0 +} + func getEnvInt(key string, fallback int) int { raw := strings.TrimSpace(os.Getenv(key)) if raw == "" {