code cleanup
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-01-21 08:45:46 +11:00
parent d683d23bfc
commit c7c7fd3dc9
5 changed files with 296 additions and 175 deletions

View File

@@ -14,8 +14,6 @@ import (
"vctp/db" "vctp/db"
"vctp/internal/metrics" "vctp/internal/metrics"
"vctp/internal/report" "vctp/internal/report"
"github.com/jmoiron/sqlx"
) )
// RunVcenterDailyAggregate summarizes hourly snapshots into a daily summary table. // RunVcenterDailyAggregate summarizes hourly snapshots into a daily summary table.
@@ -89,10 +87,6 @@ func (c *CronTask) aggregateDailySummary(ctx context.Context, targetTime time.Ti
hourlyTables := make([]string, 0, len(hourlySnapshots)) hourlyTables := make([]string, 0, len(hourlySnapshots))
for _, snapshot := range hourlySnapshots { for _, snapshot := range hourlySnapshots {
hourlyTables = append(hourlyTables, snapshot.TableName) hourlyTables = append(hourlyTables, snapshot.TableName)
// Ensure indexes exist on historical hourly tables for faster aggregation.
if err := db.EnsureSnapshotIndexes(ctx, dbConn, snapshot.TableName); err != nil {
c.Logger.Warn("failed to ensure indexes on hourly table", "table", snapshot.TableName, "error", err)
}
} }
unionQuery, err := buildUnionQuery(hourlyTables, summaryUnionColumns, templateExclusionFilter()) unionQuery, err := buildUnionQuery(hourlyTables, summaryUnionColumns, templateExclusionFilter())
if err != nil { if err != nil {
@@ -207,15 +201,10 @@ func (c *CronTask) aggregateDailySummaryGo(ctx context.Context, dayStart, dayEnd
hourlyTables := make([]string, 0, len(hourlySnapshots)) hourlyTables := make([]string, 0, len(hourlySnapshots))
for _, snapshot := range hourlySnapshots { for _, snapshot := range hourlySnapshots {
hourlyTables = append(hourlyTables, snapshot.TableName) hourlyTables = append(hourlyTables, snapshot.TableName)
if err := db.EnsureSnapshotIndexes(ctx, dbConn, snapshot.TableName); err != nil {
c.Logger.Warn("failed to ensure indexes on hourly table", "table", snapshot.TableName, "error", err)
}
} }
unionQuery, err := buildUnionQuery(hourlyTables, summaryUnionColumns, templateExclusionFilter()) unionQuery, err := buildUnionQuery(hourlyTables, summaryUnionColumns, templateExclusionFilter())
if err != nil { if err != nil {
return err return err
} else {
c.Logger.Debug("Built union query", "string", unionQuery)
} }
// Clear existing summary if forcing. // Clear existing summary if forcing.
@@ -286,9 +275,7 @@ LIMIT 1
} }
nextPresence := make(map[string]struct{}) nextPresence := make(map[string]struct{})
if nextSnapshotTable != "" && db.TableExists(ctx, dbConn, nextSnapshotTable) { if nextSnapshotTable != "" && db.TableExists(ctx, dbConn, nextSnapshotTable) {
q := fmt.Sprintf(`SELECT "VmId","VmUuid","Name" FROM %s WHERE "Vcenter" = ?`, nextSnapshotTable) rows, err := querySnapshotRows(ctx, dbConn, nextSnapshotTable, []string{"VmId", "VmUuid", "Name"}, `"Vcenter" = ?`, c.Settings.Values.Settings.VcenterAddresses[0])
q = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), q)
rows, err := dbConn.QueryxContext(ctx, q, c.Settings.Values.Settings.VcenterAddresses[0])
if err == nil { if err == nil {
for rows.Next() { for rows.Next() {
var vmId, vmUuid, name sql.NullString var vmId, vmUuid, name sql.NullString

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"strconv"
"strings" "strings"
"time" "time"
@@ -42,51 +41,62 @@ func boolStringFromInterface(value interface{}) string {
} }
} }
// latestHourlySnapshotBefore finds the most recent hourly snapshot table prior to the given time. // latestHourlySnapshotBefore finds the most recent hourly snapshot table prior to the given time, skipping empty tables.
func latestHourlySnapshotBefore(ctx context.Context, dbConn *sqlx.DB, cutoff time.Time) (string, error) { func latestHourlySnapshotBefore(ctx context.Context, dbConn *sqlx.DB, cutoff time.Time) (string, error) {
driver := strings.ToLower(dbConn.DriverName()) rows, err := dbConn.QueryxContext(ctx, `
var rows *sqlx.Rows SELECT table_name, snapshot_time
var err error FROM snapshot_registry
switch driver { WHERE snapshot_type = 'hourly' AND snapshot_time < ?
case "sqlite": ORDER BY snapshot_time DESC
rows, err = dbConn.QueryxContext(ctx, ` `, cutoff.Unix())
SELECT name FROM sqlite_master
WHERE type = 'table' AND name LIKE 'inventory_hourly_%'
`)
case "pgx", "postgres":
rows, err = dbConn.QueryxContext(ctx, `
SELECT tablename FROM pg_catalog.pg_tables
WHERE schemaname = 'public' AND tablename LIKE 'inventory_hourly_%'
`)
default:
return "", fmt.Errorf("unsupported driver for snapshot lookup: %s", driver)
}
if err != nil { if err != nil {
return "", err return "", err
} }
defer rows.Close() defer rows.Close()
var latest string
var latestTime int64
for rows.Next() { for rows.Next() {
var name string var name string
if scanErr := rows.Scan(&name); scanErr != nil { var ts int64
if scanErr := rows.Scan(&name, &ts); scanErr != nil {
continue continue
} }
if !strings.HasPrefix(name, "inventory_hourly_") { if err := db.ValidateTableName(name); err != nil {
continue continue
} }
suffix := strings.TrimPrefix(name, "inventory_hourly_") hasRows, err := db.TableHasRows(ctx, dbConn, name)
epoch, parseErr := strconv.ParseInt(suffix, 10, 64) if err != nil {
if parseErr != nil {
continue continue
} }
if epoch < cutoff.Unix() && epoch > latestTime { if hasRows {
latestTime = epoch return name, nil
latest = name
} }
} }
return latest, nil return "", nil
}
// HasSnapshotGap reports whether the gap between prev and curr exceeds 2x the expected interval.
func HasSnapshotGap(prevUnix, currUnix int64, expectedSeconds int64) bool {
if prevUnix == 0 || currUnix == 0 || expectedSeconds <= 0 {
return false
}
return currUnix-prevUnix > expectedSeconds*2
}
// querySnapshotRows builds a SELECT with proper rebind for the given table/columns/where.
func querySnapshotRows(ctx context.Context, dbConn *sqlx.DB, table string, columns []string, where string, args ...interface{}) (*sqlx.Rows, error) {
if err := db.ValidateTableName(table); err != nil {
return nil, err
}
colExpr := "*"
if len(columns) > 0 {
colExpr = `"` + strings.Join(columns, `","`) + `"`
}
query := fmt.Sprintf(`SELECT %s FROM %s`, colExpr, table)
if strings.TrimSpace(where) != "" {
query = fmt.Sprintf(`%s WHERE %s`, query, where)
}
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
return dbConn.QueryxContext(ctx, query, args...)
} }
// markMissingFromPrevious marks VMs that were present in the previous snapshot but missing now. // markMissingFromPrevious marks VMs that were present in the previous snapshot but missing now.
@@ -98,9 +108,6 @@ func (c *CronTask) markMissingFromPrevious(ctx context.Context, dbConn *sqlx.DB,
return 0 return 0
} }
query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name","Cluster","Datacenter","DeletionTime" FROM %s WHERE "Vcenter" = ?`, prevTable)
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
type prevRow struct { type prevRow struct {
VmId sql.NullString `db:"VmId"` VmId sql.NullString `db:"VmId"`
VmUuid sql.NullString `db:"VmUuid"` VmUuid sql.NullString `db:"VmUuid"`
@@ -110,7 +117,7 @@ func (c *CronTask) markMissingFromPrevious(ctx context.Context, dbConn *sqlx.DB,
DeletionTime sql.NullInt64 `db:"DeletionTime"` DeletionTime sql.NullInt64 `db:"DeletionTime"`
} }
rows, err := dbConn.QueryxContext(ctx, query, vcenter) rows, err := querySnapshotRows(ctx, dbConn, prevTable, []string{"VmId", "VmUuid", "Name", "Cluster", "Datacenter", "DeletionTime"}, `"Vcenter" = ?`, vcenter)
if err != nil { if err != nil {
c.Logger.Warn("failed to read previous snapshot for deletion detection", "error", err, "table", prevTable, "vcenter", vcenter) c.Logger.Warn("failed to read previous snapshot for deletion detection", "error", err, "table", prevTable, "vcenter", vcenter)
return 0 return 0

View File

@@ -0,0 +1,252 @@
package tasks
import (
"context"
"database/sql"
"log/slog"
"time"
"vctp/db"
"github.com/jmoiron/sqlx"
)
// presenceKeys builds lookup keys for vm presence comparison.
func presenceKeys(vmID, vmUUID, name string) []string {
keys := make([]string, 0, 3)
if vmID != "" {
keys = append(keys, "id:"+vmID)
}
if vmUUID != "" {
keys = append(keys, "uuid:"+vmUUID)
}
if name != "" {
keys = append(keys, "name:"+name)
}
return keys
}
// backfillLifecycleDeletionsToday looks for VMs in the lifecycle cache that are not in the current inventory,
// have no DeletedAt, and determines their deletion time from today's hourly snapshots, optionally checking the next snapshot (next day) to confirm.
func backfillLifecycleDeletionsToday(ctx context.Context, logger *slog.Logger, dbConn *sqlx.DB, vcenter string, snapshotTime time.Time, present map[string]InventorySnapshotRow) error {
dayStart := truncateDate(snapshotTime)
dayEnd := dayStart.Add(24 * time.Hour)
candidates, err := loadLifecycleCandidates(ctx, dbConn, vcenter, present)
if err != nil || len(candidates) == 0 {
return err
}
tables, err := listHourlyTablesForDay(ctx, dbConn, dayStart, dayEnd)
if err != nil {
return err
}
if len(tables) == 0 {
return nil
}
nextPresence := make(map[string]struct{})
if nextTable, nextErr := nextSnapshotAfter(ctx, dbConn, dayEnd, vcenter); nextErr == nil && nextTable != "" {
nextPresence = loadPresenceKeys(ctx, dbConn, nextTable, vcenter)
}
for _, cand := range candidates {
deletion, firstMiss := findDeletionInTables(ctx, dbConn, tables, vcenter, cand)
if deletion == 0 && len(nextPresence) > 0 && firstMiss > 0 {
if !isPresent(nextPresence, cand) {
// Single miss at end of day, confirmed by next-day absence.
deletion = firstMiss
logger.Debug("cross-day deletion inferred from next snapshot", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "deletion", deletion)
}
}
if deletion > 0 {
if err := db.MarkVmDeletedWithDetails(ctx, dbConn, vcenter, cand.vmID, cand.vmUUID, cand.name, cand.cluster, deletion); err != nil {
logger.Warn("lifecycle backfill mark deleted failed", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "deletion", deletion, "error", err)
continue
}
logger.Debug("lifecycle backfill applied", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "deletion", deletion)
}
}
return nil
}
type lifecycleCandidate struct {
vmID string
vmUUID string
name string
cluster string
}
func loadLifecycleCandidates(ctx context.Context, dbConn *sqlx.DB, vcenter string, present map[string]InventorySnapshotRow) ([]lifecycleCandidate, error) {
rows, err := dbConn.QueryxContext(ctx, `
SELECT "VmId","VmUuid","Name","Cluster"
FROM vm_lifecycle_cache
WHERE "Vcenter" = ? AND ("DeletedAt" IS NULL OR "DeletedAt" = 0)
`, vcenter)
if err != nil {
return nil, err
}
defer rows.Close()
var cands []lifecycleCandidate
for rows.Next() {
var vmID, vmUUID, name, cluster sql.NullString
if scanErr := rows.Scan(&vmID, &vmUUID, &name, &cluster); scanErr != nil {
continue
}
if vmID.String == "" {
continue
}
if _, ok := present[vmID.String]; ok {
continue // still present, skip
}
cands = append(cands, lifecycleCandidate{
vmID: vmID.String,
vmUUID: vmUUID.String,
name: name.String,
cluster: cluster.String,
})
}
return cands, nil
}
type snapshotTable struct {
Table string `db:"table_name"`
Time int64 `db:"snapshot_time"`
}
func listHourlyTablesForDay(ctx context.Context, dbConn *sqlx.DB, dayStart, dayEnd time.Time) ([]snapshotTable, error) {
rows, err := dbConn.QueryxContext(ctx, `
SELECT table_name, snapshot_time
FROM snapshot_registry
WHERE snapshot_type = 'hourly' AND snapshot_time >= ? AND snapshot_time < ?
ORDER BY snapshot_time ASC
`, dayStart.Unix(), dayEnd.Unix())
if err != nil {
return nil, err
}
defer rows.Close()
var tables []snapshotTable
for rows.Next() {
var t snapshotTable
if err := rows.StructScan(&t); err != nil {
continue
}
if err := db.ValidateTableName(t.Table); err != nil {
continue
}
hasRows, err := db.TableHasRows(ctx, dbConn, t.Table)
if err != nil || !hasRows {
continue
}
tables = append(tables, t)
}
return tables, nil
}
func nextSnapshotAfter(ctx context.Context, dbConn *sqlx.DB, after time.Time, vcenter string) (string, error) {
rows, err := dbConn.QueryxContext(ctx, `
SELECT table_name
FROM snapshot_registry
WHERE snapshot_type = 'hourly' AND snapshot_time >= ?
ORDER BY snapshot_time ASC
LIMIT 1
`, after.Unix())
if err != nil {
return "", err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
continue
}
if err := db.ValidateTableName(name); err != nil {
continue
}
// ensure the snapshot table actually has entries for this vcenter
vrows, qerr := querySnapshotRows(ctx, dbConn, name, []string{"VmId"}, `"Vcenter" = ? LIMIT 1`, vcenter)
if qerr != nil {
continue
}
hasVcenter := vrows.Next()
vrows.Close()
if hasVcenter {
return name, nil
}
}
return "", nil
}
func loadPresenceKeys(ctx context.Context, dbConn *sqlx.DB, table, vcenter string) map[string]struct{} {
out := make(map[string]struct{})
rows, err := querySnapshotRows(ctx, dbConn, table, []string{"VmId", "VmUuid", "Name"}, `"Vcenter" = ?`, vcenter)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var vmId, vmUuid, name sql.NullString
if err := rows.Scan(&vmId, &vmUuid, &name); err == nil {
for _, k := range presenceKeys(vmId.String, vmUuid.String, name.String) {
out[k] = struct{}{}
}
}
}
return out
}
func isPresent(presence map[string]struct{}, cand lifecycleCandidate) bool {
for _, k := range presenceKeys(cand.vmID, cand.vmUUID, cand.name) {
if _, ok := presence[k]; ok {
return true
}
}
return false
}
func findDeletionInTables(ctx context.Context, dbConn *sqlx.DB, tables []snapshotTable, vcenter string, cand lifecycleCandidate) (int64, int64) {
var lastSeen int64
var firstMiss int64
for i, tbl := range tables {
rows, err := querySnapshotRows(ctx, dbConn, tbl.Table, []string{"VmId", "VmUuid", "Name", "Cluster"}, `"Vcenter" = ? AND "VmId" = ?`, vcenter, cand.vmID)
if err == nil {
if rows.Next() {
var vmId, vmUuid, name, cluster sql.NullString
if scanErr := rows.Scan(&vmId, &vmUuid, &name, &cluster); scanErr == nil {
lastSeen = tbl.Time
if cand.name == "" && name.Valid {
cand.name = name.String
}
if cand.cluster == "" && cluster.Valid {
cand.cluster = cluster.String
}
}
}
rows.Close()
}
if lastSeen > 0 && tbl.Time > lastSeen {
// first table after last seen -> first miss
if seen, _ := candSeenInTable(ctx, dbConn, tbl.Table, vcenter, cand.vmID); !seen {
firstMiss = tbl.Time
// need two consecutive misses
if i+1 < len(tables) {
if seen2, _ := candSeenInTable(ctx, dbConn, tables[i+1].Table, vcenter, cand.vmID); !seen2 {
return firstMiss, firstMiss
}
}
}
}
}
return 0, firstMiss
}
func candSeenInTable(ctx context.Context, dbConn *sqlx.DB, table, vcenter, vmID string) (bool, error) {
rows, err := querySnapshotRows(ctx, dbConn, table, []string{"VmId"}, `"Vcenter" = ? AND "VmId" = ? LIMIT 1`, vcenter, vmID)
if err != nil {
return false, err
}
defer rows.Close()
return rows.Next(), nil
}

View File

@@ -503,128 +503,6 @@ func normalizeResourcePool(value string) string {
return trimmed return trimmed
} }
// backfillLifecycleDeletionsToday looks for VMs in the lifecycle cache that are not in the current inventory,
// have no DeletedAt, and determines their deletion time from today's hourly snapshots.
func backfillLifecycleDeletionsToday(ctx context.Context, logger *slog.Logger, dbConn *sqlx.DB, vcenter string, snapshotTime time.Time, present map[string]InventorySnapshotRow) error {
dayStart := truncateDate(snapshotTime)
dayEnd := dayStart.Add(24 * time.Hour)
// Lifecycle entries missing DeletedAt.
queryLifecycle := `
SELECT "VmId","VmUuid","Name","Cluster"
FROM vm_lifecycle_cache
WHERE "Vcenter" = ? AND ("DeletedAt" IS NULL OR "DeletedAt" = 0)
`
rows, err := dbConn.QueryxContext(ctx, queryLifecycle, vcenter)
if err != nil {
return err
}
defer rows.Close()
type candidate struct {
vmID string
vmUUID string
name string
cluster string
}
var cands []candidate
for rows.Next() {
var vmID, vmUUID, name, cluster sql.NullString
if scanErr := rows.Scan(&vmID, &vmUUID, &name, &cluster); scanErr != nil {
continue
}
if vmID.String == "" {
continue
}
if _, ok := present[vmID.String]; ok {
continue // still present, skip
}
cands = append(cands, candidate{
vmID: vmID.String,
vmUUID: vmUUID.String,
name: name.String,
cluster: cluster.String,
})
}
if len(cands) == 0 {
return nil
}
// Get today's hourly tables.
query := `
SELECT table_name, snapshot_time
FROM snapshot_registry
WHERE snapshot_type = 'hourly' AND snapshot_time >= ? AND snapshot_time < ?
ORDER BY snapshot_time ASC
`
query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query)
var tables []struct {
Table string `db:"table_name"`
Time int64 `db:"snapshot_time"`
}
rowsTables, err := dbConn.QueryxContext(ctx, query, dayStart.Unix(), dayEnd.Unix())
if err != nil {
return err
}
defer rowsTables.Close()
for rowsTables.Next() {
var t struct {
Table string `db:"table_name"`
Time int64 `db:"snapshot_time"`
}
if err := rowsTables.StructScan(&t); err != nil {
continue
}
tables = append(tables, t)
}
if len(tables) == 0 {
return nil
}
for _, cand := range cands {
var lastSeen int64
var deletion int64
logger.Debug("lifecycle backfill candidate", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "tables", len(tables))
for i, tbl := range tables {
if err := db.ValidateTableName(tbl.Table); err != nil {
continue
}
q := fmt.Sprintf(`SELECT "Name","Cluster" FROM %s WHERE "Vcenter" = ? AND "VmId" = ? LIMIT 1`, tbl.Table)
q = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), q)
var name, cluster sql.NullString
err := dbConn.QueryRowxContext(ctx, q, vcenter, cand.vmID).Scan(&name, &cluster)
if err == nil {
lastSeen = tbl.Time
if cand.name == "" && name.Valid {
cand.name = name.String
}
if cand.cluster == "" && cluster.Valid {
cand.cluster = cluster.String
}
continue
}
// Not found in this table; if previously seen today, mark deletion at this snapshot time.
if lastSeen > 0 {
deletion = tbl.Time
break
}
// If never seen today and we're at the last table, mark deletion at current snapshot time.
if i == len(tables)-1 {
deletion = tbl.Time
}
}
if deletion > 0 {
if err := db.MarkVmDeletedWithDetails(ctx, dbConn, vcenter, cand.vmID, cand.vmUUID, cand.name, cand.cluster, deletion); err != nil {
logger.Warn("lifecycle backfill mark deleted failed", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "deletion", deletion, "error", err)
continue
}
logger.Debug("lifecycle backfill applied", "vcenter", vcenter, "vm_id", cand.vmID, "vm_uuid", cand.vmUUID, "name", cand.name, "cluster", cand.cluster, "deletion", deletion)
}
}
return nil
}
func (c *CronTask) reportsDir() string { func (c *CronTask) reportsDir() string {
if c.Settings != nil && c.Settings.Values != nil { if c.Settings != nil && c.Settings.Values != nil {
if dir := strings.TrimSpace(c.Settings.Values.Settings.ReportsDir); dir != "" { if dir := strings.TrimSpace(c.Settings.Values.Settings.ReportsDir); dir != "" {
@@ -1099,8 +977,8 @@ func (c *CronTask) captureHourlySnapshotForVcenter(ctx context.Context, startTim
moreMissing := c.markMissingFromPrevious(ctx, dbConn, prevTableName, url, startTime, presentSnapshots, presentByUuid, presentByName, inventoryByVmID, inventoryByUuid, inventoryByName) moreMissing := c.markMissingFromPrevious(ctx, dbConn, prevTableName, url, startTime, presentSnapshots, presentByUuid, presentByName, inventoryByVmID, inventoryByUuid, inventoryByName)
missingCount += moreMissing missingCount += moreMissing
// Guard against gaps: if previous snapshot is much older than expected, skip "new" detection to avoid false positives when an hourly run was missed. // Guard against gaps: if previous snapshot is much older than expected, skip "new" detection to avoid false positives when an hourly run was missed.
snapshotPeriod := durationFromSeconds(c.Settings.Values.Settings.VcenterInventorySnapshotSeconds, time.Hour).Seconds() expectedSeconds := int64(durationFromSeconds(c.Settings.Values.Settings.VcenterInventorySnapshotSeconds, time.Hour).Seconds())
if prevSnapshotTime > 0 && startTime.Unix()-prevSnapshotTime > int64(snapshotPeriod*2) { if HasSnapshotGap(prevSnapshotTime, startTime.Unix(), expectedSeconds) {
c.Logger.Info("skipping new-VM detection due to gap between snapshots", "prev_table", prevTableName, "prev_snapshot_unix", prevSnapshotTime, "current_snapshot_unix", startTime.Unix()) c.Logger.Info("skipping new-VM detection due to gap between snapshots", "prev_table", prevTableName, "prev_snapshot_unix", prevSnapshotTime, "current_snapshot_unix", startTime.Unix())
} else { } else {
newCount = countNewFromPrevious(ctx, dbConn, prevTableName, url, presentSnapshots) newCount = countNewFromPrevious(ctx, dbConn, prevTableName, url, presentSnapshots)

View File

@@ -1,3 +0,0 @@
package tasks
// Legacy placeholder: type definitions moved to types.go.