package discovery import ( "encoding/json" "fmt" "log" "net" "os" "path/filepath" "sync" "time" "github.com/metazla/meta-core/internal/config" ) // ServiceInfo matches the TypeScript ServiceInfo interface type ServiceInfo struct { Name string `json:"name"` Version string `json:"version"` API string `json:"api"` Status string `json:"status"` PID int `json:"pid"` Hostname string `json:"hostname"` StartedAt string `json:"startedAt"` LastHeartbeat string `json:"lastHeartbeat"` Capabilities []string `json:"capabilities"` Endpoints map[string]string `json:"endpoints"` } // Service handles service registration and discovery type Service struct { config *config.Config servicesDir string serviceFile string info *ServiceInfo mu sync.RWMutex stopChan chan struct{} wg sync.WaitGroup } // NewService creates a new service discovery instance func NewService(cfg *config.Config) *Service { return &Service{ config: cfg, servicesDir: cfg.ServicesDir(), serviceFile: filepath.Join(cfg.ServicesDir(), cfg.ServiceName+".json"), stopChan: make(chan struct{}), } } // Start begins service registration and heartbeat func (s *Service) Start() error { log.Printf("[Discovery] Starting service discovery for %s", s.config.ServiceName) // Ensure services directory exists if err := os.MkdirAll(s.servicesDir, 0755); err != nil { return fmt.Errorf("failed to create services directory: %w", err) } // Build and register service info s.info = s.buildServiceInfo() if err := s.register(); err != nil { return fmt.Errorf("failed to register service: %w", err) } // Start heartbeat loop s.wg.Add(1) go s.heartbeatLoop() return nil } // Stop stops service discovery and unregisters func (s *Service) Stop() error { log.Println("[Discovery] Stopping service discovery...") close(s.stopChan) s.wg.Wait() // Unregister by removing service file if err := os.Remove(s.serviceFile); err != nil && !os.IsNotExist(err) { log.Printf("[Discovery] Failed to remove service file: %v", err) } return nil } // buildServiceInfo creates the service info for this instance func (s *Service) buildServiceInfo() *ServiceInfo { hostname, _ := os.Hostname() ip := getLocalIP() return &ServiceInfo{ Name: s.config.ServiceName, Version: s.config.ServiceVersion, API: fmt.Sprintf("http://%s:%d", ip, s.config.APIPort), Status: "running", PID: os.Getpid(), Hostname: hostname, StartedAt: time.Now().UTC().Format(time.RFC3339), LastHeartbeat: time.Now().UTC().Format(time.RFC3339), Capabilities: []string{"meta-core"}, Endpoints: map[string]string{ "health": fmt.Sprintf("http://%s:%d/health", ip, s.config.HTTPPort), "meta": fmt.Sprintf("http://%s:%d/meta", ip, s.config.HTTPPort), }, } } // register writes service info to file func (s *Service) register() error { s.mu.Lock() defer s.mu.Unlock() return s.writeServiceInfo(s.info) } // writeServiceInfo atomically writes service info to file func (s *Service) writeServiceInfo(info *ServiceInfo) error { tempPath := s.serviceFile + ".tmp" data, err := json.MarshalIndent(info, "", " ") if err != nil { return err } if err := os.WriteFile(tempPath, data, 0644); err != nil { return err } return os.Rename(tempPath, s.serviceFile) } // heartbeatLoop periodically updates the last heartbeat func (s *Service) heartbeatLoop() { defer s.wg.Done() ticker := time.NewTicker(time.Duration(s.config.HeartbeatIntervalMS) * time.Millisecond) defer ticker.Stop() for { select { case <-s.stopChan: return case <-ticker.C: s.heartbeat() } } } // heartbeat updates the last heartbeat timestamp func (s *Service) heartbeat() { s.mu.Lock() defer s.mu.Unlock() if s.info == nil { return } s.info.LastHeartbeat = time.Now().UTC().Format(time.RFC3339) if err := s.writeServiceInfo(s.info); err != nil { log.Printf("[Discovery] Failed to update heartbeat: %v", err) } } // UpdateStatus updates the service status func (s *Service) UpdateStatus(status string) error { s.mu.Lock() defer s.mu.Unlock() if s.info == nil { return fmt.Errorf("service not registered") } s.info.Status = status s.info.LastHeartbeat = time.Now().UTC().Format(time.RFC3339) return s.writeServiceInfo(s.info) } // Discover finds a service by name func (s *Service) Discover(name string) (*ServiceInfo, error) { filePath := filepath.Join(s.servicesDir, name+".json") data, err := os.ReadFile(filePath) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } var info ServiceInfo if err := json.Unmarshal(data, &info); err != nil { return nil, err } // Check if service is stale lastHeartbeat, err := time.Parse(time.RFC3339, info.LastHeartbeat) if err == nil { staleThreshold := time.Duration(s.config.StaleThresholdMS) * time.Millisecond if time.Since(lastHeartbeat) > staleThreshold { info.Status = "stale" } } return &info, nil } // DiscoverAll finds all registered services func (s *Service) DiscoverAll() ([]*ServiceInfo, error) { entries, err := os.ReadDir(s.servicesDir) if err != nil { if os.IsNotExist(err) { return []*ServiceInfo{}, nil } return nil, err } var services []*ServiceInfo for _, entry := range entries { if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { continue } name := entry.Name()[:len(entry.Name())-5] // Remove .json extension info, err := s.Discover(name) if err != nil { log.Printf("[Discovery] Failed to read service %s: %v", name, err) continue } if info != nil { services = append(services, info) } } return services, nil } // getLocalIP returns the local IP address func getLocalIP() string { addrs, err := net.InterfaceAddrs() if err != nil { hostname, _ := os.Hostname() return hostname } for _, addr := range addrs { if ipnet, ok := addr.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { if ipnet.IP.To4() != nil { return ipnet.IP.String() } } } hostname, _ := os.Hostname() return hostname }