Ctrl K
ComunidadGuías…

server/internal/handlers/datasource_migration.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
package handlers

import (
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"slices"
	"strings"
	"time"

	"blackbox/server/internal/models"
	"github.com/oklog/ulid/v2"
	"gorm.io/gorm"
	"gorm.io/gorm/clause"
)

const dataSourcesMigratedKey = "data_sources_migrated"

// MigrateDataSources seeds data_source_instances from legacy tables.
// Safe to call on every startup — skips rows that already exist.
func MigrateDataSources(db *gorm.DB, envWebhookSecret string) error {
	return db.Transaction(func(tx *gorm.DB) error {
		now := time.Now().UTC()
		var migrationSetting models.AppSetting
		if err := tx.First(&migrationSetting, "key = ?", dataSourcesMigratedKey).Error; err == nil {
			if strings.EqualFold(migrationSetting.Value, "true") {
				return nil
			}
		} else if !errors.Is(err, gorm.ErrRecordNotFound) {
			return fmt.Errorf("load migration marker: %w", err)
		}

		// 1. Systemd: one instance per node in systemd_unit_configs
		var systemdConfigs []models.SystemdUnitConfig
		if err := tx.Find(&systemdConfigs).Error; err != nil {
			return fmt.Errorf("load systemd configs: %w", err)
		}
		for _, cfg := range systemdConfigs {
			nodeName := cfg.NodeName
			var units []string
			if err := json.Unmarshal([]byte(cfg.Units), &units); err != nil {
				log.Printf("MigrateDataSources: invalid systemd units for node %s: %v (raw=%q)", nodeName, err, cfg.Units)
				units = []string{}
			}
			cfgJSON, err := json.Marshal(map[string]any{"units": units})
			if err != nil {
				log.Printf("MigrateDataSources: failed to marshal systemd config for node %s: %v", nodeName, err)
				return fmt.Errorf("marshal systemd config for %s: %w", nodeName, err)
			}

			var existing models.DataSourceInstance
			err = tx.Where("type = ? AND node_id = ?", "systemd", nodeName).First(&existing).Error
			if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
				return fmt.Errorf("check systemd instance for %s: %w", nodeName, err)
			}
			if errors.Is(err, gorm.ErrRecordNotFound) {
				inst := models.DataSourceInstance{
					ID: ulid.Make().String(), Type: "systemd", Scope: "agent",
					NodeID: &nodeName, Name: "Systemd",
					Config: string(cfgJSON), Enabled: true,
					CreatedAt: now, UpdatedAt: now,
				}
				if err := tx.Create(&inst).Error; err != nil {
					return fmt.Errorf("insert systemd instance for %s: %w", nodeName, err)
				}
			}
			if err := tx.Delete(&models.SystemdUnitConfig{}, "node_name = ?", nodeName).Error; err != nil {
				return fmt.Errorf("delete legacy systemd config for %s: %w", nodeName, err)
			}
		}

		// 2. File watcher: one instance per capable node
		redact := true
		var fwSetting models.AppSetting
		if err := tx.First(&fwSetting, "key = ?", fileWatcherRedactSecretsKey).Error; err == nil {
			redact = !strings.EqualFold(fwSetting.Value, "false")
		} else if !errors.Is(err, gorm.ErrRecordNotFound) {
			return fmt.Errorf("load file watcher setting: %w", err)
		}
		var nodes []models.Node
		if err := tx.Find(&nodes).Error; err != nil {
			return fmt.Errorf("load nodes: %w", err)
		}
		fwCfg, err := json.Marshal(map[string]any{"redact_secrets": redact})
		if err != nil {
			return fmt.Errorf("marshal filewatcher config: %w", err)
		}
		for _, node := range nodes {
			nodeName := node.Name
			if !nodeHasCapability(node.Capabilities, "filewatcher") {
				// Treat empty capability payloads as legacy nodes that still need filewatcher seeding.
				if strings.TrimSpace(node.Capabilities) == "" || node.Capabilities == "[]" {
					capsJSON, err := json.Marshal([]string{"filewatcher"})
					if err != nil {
						return fmt.Errorf("marshal capabilities for %s: %w", nodeName, err)
					}
					log.Printf("MigrateDataSources: seeding legacy capabilities for node %s from %q to %s", nodeName, node.Capabilities, string(capsJSON))
					if err := tx.Model(&models.Node{}).Where("name = ?", nodeName).Update("capabilities", string(capsJSON)).Error; err != nil {
						log.Printf("MigrateDataSources: failed to update capabilities for node %s: %v", nodeName, err)
						return fmt.Errorf("update capabilities for %s: %w", nodeName, err)
					}
				} else {
					continue
				}
			}
			var existing models.DataSourceInstance
			err := tx.Where("type = ? AND node_id = ?", "filewatcher", nodeName).First(&existing).Error
			if err == nil {
				continue
			}
			if !errors.Is(err, gorm.ErrRecordNotFound) {
				return fmt.Errorf("check filewatcher instance for %s: %w", nodeName, err)
			}
			inst := models.DataSourceInstance{
				ID: ulid.Make().String(), Type: "filewatcher", Scope: "agent",
				NodeID: &nodeName, Name: "File Watcher",
				Config: string(fwCfg), Enabled: true,
				CreatedAt: now, UpdatedAt: now,
			}
			if err := tx.Create(&inst).Error; err != nil {
				return fmt.Errorf("insert filewatcher instance for %s: %w", nodeName, err)
			}
		}

		// 3. Webhook instances — only seed if WEBHOOK_SECRET was set; otherwise the user
		// creates them explicitly via the catalog so they don't appear unconfigured.
		if envWebhookSecret != "" {
			for _, wType := range []string{"webhook_uptime_kuma", "webhook_watchtower"} {
				var existing models.DataSourceInstance
				err := tx.Where("type = ? AND scope = ?", wType, models.ScopeServer).First(&existing).Error
				if err == nil {
					continue
				}
				if !errors.Is(err, gorm.ErrRecordNotFound) {
					return fmt.Errorf("check %s instance: %w", wType, err)
				}
				wCfg, err := json.Marshal(map[string]any{"secret": envWebhookSecret})
				if err != nil {
					log.Printf("MigrateDataSources: failed to marshal webhook config for %s: %v", wType, err)
					return fmt.Errorf("marshal webhook config for %s: %w", wType, err)
				}
				name := "Uptime Kuma"
				if wType == "webhook_watchtower" {
					name = "Watchtower"
				}
				inst := models.DataSourceInstance{
					ID: ulid.Make().String(), Type: wType, Scope: "server",
					Name: name, Config: string(wCfg), Enabled: true,
					CreatedAt: now, UpdatedAt: now,
				}
				if err := tx.Create(&inst).Error; err != nil {
					return fmt.Errorf("insert %s instance: %w", wType, err)
				}
			}
		}

		return tx.Clauses(clause.OnConflict{
			Columns: []clause.Column{{Name: "key"}},
			DoUpdates: clause.Assignments(map[string]any{
				"value":      "true",
				"updated_at": now,
			}),
		}).Create(&models.AppSetting{
			Key:       dataSourcesMigratedKey,
			Value:     "true",
			UpdatedAt: now,
		}).Error
	})
}

func nodeHasCapability(rawCapabilities, capability string) bool {
	var capabilities []string
	if err := json.Unmarshal([]byte(rawCapabilities), &capabilities); err != nil {
		log.Printf("nodeHasCapability: malformed capabilities JSON %q: %v", rawCapabilities, err)
		return false
	}
	return slices.Contains(capabilities, capability)
}