package tasks import ( "context" "database/sql" "fmt" "strconv" "strings" "time" "vctp/db" "vctp/db/queries" "github.com/jmoiron/sqlx" ) func boolStringFromInterface(value interface{}) string { switch v := value.(type) { case nil: return "" case string: return v case []byte: return string(v) case bool: if v { return "TRUE" } return "FALSE" case int: if v != 0 { return "TRUE" } return "FALSE" case int64: if v != 0 { return "TRUE" } return "FALSE" default: return fmt.Sprint(v) } } // latestHourlySnapshotBefore finds the most recent hourly snapshot table prior to the given time. func latestHourlySnapshotBefore(ctx context.Context, dbConn *sqlx.DB, cutoff time.Time) (string, error) { driver := strings.ToLower(dbConn.DriverName()) var rows *sqlx.Rows var err error switch driver { case "sqlite": rows, err = dbConn.QueryxContext(ctx, ` 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 { return "", err } defer rows.Close() var latest string var latestTime int64 for rows.Next() { var name string if scanErr := rows.Scan(&name); scanErr != nil { continue } if !strings.HasPrefix(name, "inventory_hourly_") { continue } suffix := strings.TrimPrefix(name, "inventory_hourly_") epoch, parseErr := strconv.ParseInt(suffix, 10, 64) if parseErr != nil { continue } if epoch < cutoff.Unix() && epoch > latestTime { latestTime = epoch latest = name } } return latest, nil } // markMissingFromPrevious marks VMs that were present in the previous snapshot but missing now. func (c *CronTask) markMissingFromPrevious(ctx context.Context, dbConn *sqlx.DB, prevTable string, vcenter string, snapshotTime time.Time, currentByID map[string]InventorySnapshotRow, currentByUuid map[string]struct{}, currentByName map[string]struct{}, invByID map[string]queries.Inventory, invByUuid map[string]queries.Inventory, invByName map[string]queries.Inventory) int { if err := db.ValidateTableName(prevTable); err != nil { 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 { VmId sql.NullString `db:"VmId"` VmUuid sql.NullString `db:"VmUuid"` Name string `db:"Name"` Cluster sql.NullString `db:"Cluster"` Datacenter sql.NullString `db:"Datacenter"` DeletionTime sql.NullInt64 `db:"DeletionTime"` } rows, err := dbConn.QueryxContext(ctx, query, vcenter) if err != nil { c.Logger.Warn("failed to read previous snapshot for deletion detection", "error", err, "table", prevTable, "vcenter", vcenter) return 0 } defer rows.Close() missing := 0 for rows.Next() { var r prevRow if err := rows.StructScan(&r); err != nil { continue } vmID := r.VmId.String uuid := r.VmUuid.String name := r.Name cluster := r.Cluster.String found := false if vmID != "" { if _, ok := currentByID[vmID]; ok { found = true } } if !found && uuid != "" { if _, ok := currentByUuid[uuid]; ok { found = true } } if !found && name != "" { if _, ok := currentByName[name]; ok { found = true } } // If the name is missing but UUID+Cluster still exists in inventory/current, treat it as present (rename, not delete). if !found && uuid != "" && cluster != "" { if inv, ok := invByUuid[uuid]; ok && strings.EqualFold(inv.Cluster.String, cluster) { found = true } } if found { continue } var inv queries.Inventory var ok bool if vmID != "" { inv, ok = invByID[vmID] } if !ok && uuid != "" { inv, ok = invByUuid[uuid] } if !ok && name != "" { inv, ok = invByName[name] } if !ok { continue } if inv.DeletionTime.Valid { continue } delTime := sql.NullInt64{Int64: snapshotTime.Unix(), Valid: true} if err := c.Database.Queries().InventoryMarkDeleted(ctx, queries.InventoryMarkDeletedParams{ DeletionTime: delTime, VmId: inv.VmId, DatacenterName: inv.Datacenter, }); err != nil { c.Logger.Warn("failed to mark inventory record deleted from previous snapshot", "error", err, "vm_id", inv.VmId.String) continue } // Also update lifecycle cache so deletion time is available for rollups. vmUUID := "" if inv.VmUuid.Valid { vmUUID = inv.VmUuid.String } if err := db.MarkVmDeletedWithDetails(ctx, dbConn, vcenter, inv.VmId.String, vmUUID, inv.Name, inv.Cluster.String, delTime.Int64); err != nil { c.Logger.Warn("failed to mark lifecycle cache deleted from previous snapshot", "error", err, "vm_id", inv.VmId.String, "vm_uuid", vmUUID, "vcenter", vcenter) } c.Logger.Debug("Detected VM missing compared to previous snapshot", "name", inv.Name, "vm_id", inv.VmId.String, "vm_uuid", inv.VmUuid.String, "vcenter", vcenter, "snapshot_time", snapshotTime, "prev_table", prevTable) missing++ } return missing } // countNewFromPrevious returns how many VMs are present in the current snapshot but not in the previous snapshot. func countNewFromPrevious(ctx context.Context, dbConn *sqlx.DB, prevTable string, vcenter string, current map[string]InventorySnapshotRow) int { if err := db.ValidateTableName(prevTable); err != nil { return len(current) } query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name" FROM %s WHERE "Vcenter" = ?`, prevTable) query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query) rows, err := dbConn.QueryxContext(ctx, query, vcenter) if err != nil { return len(current) } defer rows.Close() prevIDs := make(map[string]struct{}) prevUUIDs := make(map[string]struct{}) prevNames := make(map[string]struct{}) for rows.Next() { var vmID, vmUUID, name string if scanErr := rows.Scan(&vmID, &vmUUID, &name); scanErr != nil { continue } if vmID != "" { prevIDs[vmID] = struct{}{} } if vmUUID != "" { prevUUIDs[vmUUID] = struct{}{} } if name != "" { prevNames[name] = struct{}{} } } newCount := 0 for _, cur := range current { id := cur.VmId.String uuid := cur.VmUuid.String name := cur.Name if id != "" { if _, ok := prevIDs[id]; ok { continue } } if uuid != "" { if _, ok := prevUUIDs[uuid]; ok { continue } } if name != "" { if _, ok := prevNames[name]; ok { continue } } newCount++ } return newCount } // listNewFromPrevious returns the rows present now but not in the previous snapshot. func listNewFromPrevious(ctx context.Context, dbConn *sqlx.DB, prevTable string, vcenter string, current map[string]InventorySnapshotRow) []InventorySnapshotRow { if err := db.ValidateTableName(prevTable); err != nil { all := make([]InventorySnapshotRow, 0, len(current)) for _, cur := range current { all = append(all, cur) } return all } query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name" FROM %s WHERE "Vcenter" = ?`, prevTable) query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query) rows, err := dbConn.QueryxContext(ctx, query, vcenter) if err != nil { all := make([]InventorySnapshotRow, 0, len(current)) for _, cur := range current { all = append(all, cur) } return all } defer rows.Close() prevIDs := make(map[string]struct{}) prevUUIDs := make(map[string]struct{}) prevNames := make(map[string]struct{}) for rows.Next() { var vmID, vmUUID, name string if scanErr := rows.Scan(&vmID, &vmUUID, &name); scanErr != nil { continue } if vmID != "" { prevIDs[vmID] = struct{}{} } if vmUUID != "" { prevUUIDs[vmUUID] = struct{}{} } if name != "" { prevNames[name] = struct{}{} } } newRows := make([]InventorySnapshotRow, 0) for _, cur := range current { id := cur.VmId.String uuid := cur.VmUuid.String name := cur.Name if id != "" { if _, ok := prevIDs[id]; ok { continue } } if uuid != "" { if _, ok := prevUUIDs[uuid]; ok { continue } } if name != "" { if _, ok := prevNames[name]; ok { continue } } newRows = append(newRows, cur) } return newRows } // findVMInHourlySnapshots searches recent hourly snapshot tables for a VM by ID for the given vCenter. // extraTables are searched first (e.g., known previous snapshot tables). func findVMInHourlySnapshots(ctx context.Context, dbConn *sqlx.DB, vcenter string, vmID string, extraTables ...string) (InventorySnapshotRow, bool) { if vmID == "" { return InventorySnapshotRow{}, false } // Use a short timeout to avoid hanging if the DB is busy. ctx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() // First search any explicit tables provided. for _, table := range extraTables { if table == "" { continue } if err := db.ValidateTableName(table); err != nil { continue } query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name","Datacenter","Cluster" FROM %s WHERE "Vcenter" = ? AND "VmId" = ? LIMIT 1`, table) query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query) var row InventorySnapshotRow if err := dbConn.QueryRowxContext(ctx, query, vcenter, vmID).Scan(&row.VmId, &row.VmUuid, &row.Name, &row.Datacenter, &row.Cluster); err == nil { return row, true } } // Try a handful of most recent hourly tables from the registry. rows, err := dbConn.QueryxContext(ctx, ` SELECT table_name FROM snapshot_registry WHERE snapshot_type = 'hourly' ORDER BY snapshot_time DESC LIMIT 20 `) if err != nil { return InventorySnapshotRow{}, false } defer rows.Close() checked := 0 for rows.Next() { var table string if scanErr := rows.Scan(&table); scanErr != nil { continue } if err := db.ValidateTableName(table); err != nil { continue } query := fmt.Sprintf(`SELECT "VmId","VmUuid","Name","Datacenter","Cluster" FROM %s WHERE "Vcenter" = ? AND "VmId" = ? LIMIT 1`, table) query = sqlx.Rebind(sqlx.BindType(dbConn.DriverName()), query) var row InventorySnapshotRow if err := dbConn.QueryRowxContext(ctx, query, vcenter, vmID).Scan(&row.VmId, &row.VmUuid, &row.Name, &row.Datacenter, &row.Cluster); err == nil { return row, true } checked++ if checked >= 10 { // limit work break } } return InventorySnapshotRow{}, false }