This commit is contained in:
252
internal/tasks/inventoryLifecycle.go
Normal file
252
internal/tasks/inventoryLifecycle.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user