package main import ( "crypto/md5" "fmt" "log" "os" "os/exec" "path/filepath" "strings" "sync" "time" ) type ContainerInfo struct { ID string Name string Interval time.Duration Ignores []string } var notifiedChanges = struct { sync.RWMutex changes map[string]string }{changes: make(map[string]string)} var watchedContainers = struct { sync.RWMutex containers map[string]ContainerInfo }{containers: make(map[string]ContainerInfo)} func main() { // Check if GLITCHTIP_DSN environment variable is set glitchtipDSN := os.Getenv("GLITCHTIP_DSN") if glitchtipDSN == "" { log.Fatal("GLITCHTIP_DSN environment variable is not set") } log.Println("Starting Oculus...") go monitorContainerList() for { containers, err := getContainers() if err != nil { log.Printf("Error listing containers: %v", err) time.Sleep(1 * time.Minute) continue } for _, container := range containers { watchedContainers.RLock() _, watched := watchedContainers.containers[container.ID] watchedContainers.RUnlock() if !watched { log.Printf("Monitoring container: %s (%s) every %s", container.Name, container.ID, container.Interval) watchedContainers.Lock() watchedContainers.containers[container.ID] = container watchedContainers.Unlock() go monitorContainer(container, true) } } // Sleep for 1 minute before checking for new containers again time.Sleep(1 * time.Minute) } } func getContainers() ([]ContainerInfo, error) { log.Println("Fetching container list...") output, err := exec.Command("docker", "ps", "--format", "{{.ID}} {{.Label \"oculus.enable\"}} {{.Label \"oculus.interval\"}} {{.Label \"oculus.cname\"}} {{.Label \"oculus.ignores\"}}").Output() if err != nil { return nil, err } lines := strings.Split(string(output), "\n") var containers []ContainerInfo for _, line := range lines { if line == "" { continue } parts := strings.Fields(line) if len(parts) < 2 || parts[1] == "" { continue } id := parts[0] intervalStr := "300s" if len(parts) > 2 && parts[2] != "" { intervalStr = parts[2] } interval, err := time.ParseDuration(intervalStr) if err != nil { log.Printf("Invalid interval format for container %s: %v", id, err) continue } cname := id if len(parts) > 3 && parts[3] != "" { cname = parts[3] } ignores := []string{} if len(parts) > 4 && parts[4] != "" { ignores = strings.Split(parts[4], ",") } log.Printf("Container ID: %s, Name: %s, Interval: %s, Ignores: %v", id, cname, interval, ignores) containers = append(containers, ContainerInfo{ ID: id, Name: cname, Interval: interval, Ignores: ignores, }) } log.Printf("Found %d containers to monitor.", len(containers)) return containers, nil } func monitorContainer(container ContainerInfo, initial bool) { ticker := time.NewTicker(container.Interval) defer ticker.Stop() for { select { case <-ticker.C: log.Printf("Checking diffs for container: %s (%s)", container.Name, container.ID) diffOutput, err := getDiffOutput(container.ID) if err != nil { log.Printf("Error getting diffs for container %s (%s): %v", container.Name, container.ID, err) return } filteredOutput, err := filterDiffOutput(diffOutput, container.Ignores) if err != nil { log.Printf("Error filtering diffs for container %s (%s): %v", container.Name, container.ID, err) return } log.Printf("Filtered diff output for container %s: %s", container.Name, filteredOutput) err = handleDiffOutput(container, filteredOutput, initial) if err != nil { log.Printf("Error handling diff output for container %s (%s): %v", container.Name, container.ID, err) return } initial = false } } } func getDiffOutput(containerID string) (string, error) { cmd := exec.Command("docker", "diff", containerID) output, err := cmd.Output() if err != nil { return "", fmt.Errorf("docker diff failed: %w", err) } return string(output), nil } func handleDiffOutput(container ContainerInfo, filteredOutput string, initial bool) error { if filteredOutput != "" { // Calculate a hash of the filtered diff output diffHash := fmt.Sprintf("%x", md5.Sum([]byte(filteredOutput))) log.Printf("Diff hash for container %s: %s", container.Name, diffHash) notifiedChanges.RLock() lastNotifiedHash, notified := notifiedChanges.changes[container.ID] notifiedChanges.RUnlock() if initial { // For the initial check, just store the hash and don't send a notification notifiedChanges.Lock() notifiedChanges.changes[container.ID] = diffHash notifiedChanges.Unlock() log.Printf("Initial check, storing hash for container: %s (%s)", container.Name, container.ID) } else if !notified || lastNotifiedHash != diffHash { log.Printf("Writing diff output for container: %s (%s)", container.Name, container.ID) // Write diff output to a file filename := fmt.Sprintf("%s.diff", container.Name) err := writeToFile(filename, filteredOutput) if err != nil { return fmt.Errorf("error writing diff to file: %w", err) } // Send notification using go-glitch log.Printf("Sending notification for container: %s (%s)", container.Name, container.ID) err = sendNotification(filteredOutput) if err != nil { log.Printf("Error sending notification for container %s: %v", container.ID, err) } else { notifiedChanges.Lock() notifiedChanges.changes[container.ID] = diffHash notifiedChanges.Unlock() log.Printf("Notification sent and hash updated for container: %s (%s)", container.Name, container.ID) } } else { log.Printf("No new changes detected for container: %s (%s)", container.Name, container.ID) } } else { log.Printf("No significant changes detected for container: %s (%s)", container.Name, container.ID) } return nil } func filterDiffOutput(diffOutput string, ignores []string) (string, error) { tempFile, err := os.CreateTemp("", "diff_output") if err != nil { return "", err } defer os.Remove(tempFile.Name()) _, err = tempFile.WriteString(diffOutput) if err != nil { return "", err } tempFile.Close() for _, ignore := range ignores { cmd := exec.Command("sed", "-i", fmt.Sprintf("/%s/d", ignore), tempFile.Name()) err := cmd.Run() if err != nil { return "", fmt.Errorf("error running sed command: %w", err) } } filteredOutput, err := os.ReadFile(tempFile.Name()) if err != nil { return "", err } return string(filteredOutput), nil } func matchPath(path, pattern string) bool { match, err := filepath.Match(pattern, filepath.Base(path)) if err != nil { log.Printf("Error matching pattern: %s with path: %s, error: %v", pattern, path, err) return false } log.Printf("Matching path %s with pattern %s: %v", path, pattern, match) return match } func writeToFile(filename, content string) error { file, err := os.Create(filename) if err != nil { return err } defer file.Close() _, err = file.WriteString(content) return err } func sendNotification(content string) error { cmd := exec.Command("go-glitch") cmd.Stdin = strings.NewReader(content) output, err := cmd.CombinedOutput() if err != nil { log.Printf("go-glitch output: %s", output) return err } return nil } func monitorContainerList() { for { watchedContainers.Lock() for id, container := watchedContainers.containers { if !isContainerRunning(id) { log.Printf("Removing stopped container from watch list: %s (%s)", container.Name, id) delete(watchedContainers.containers, id) } } watchedContainers.Unlock() time.Sleep(1 * time.Minute) } } func isContainerRunning(containerID string) bool { cmd := exec.Command("docker", "inspect", "--format", "{{.State.Running}}", containerID) output, err := cmd.Output() if err != nil { log.Printf("Error inspecting container %s: %v", containerID, err) return false } return strings.TrimSpace(string(output)) == "true" }