|
| 1 | +package diagnostics |
| 2 | + |
| 3 | +import ( |
| 4 | + "bufio" |
| 5 | + "fmt" |
| 6 | + "regexp" |
| 7 | + "strings" |
| 8 | + "time" |
| 9 | + |
| 10 | + "github.com/noobaa/noobaa-operator/v5/pkg/options" |
| 11 | + "github.com/noobaa/noobaa-operator/v5/pkg/util" |
| 12 | + "github.com/lithammer/fuzzysearch/fuzzy" |
| 13 | + |
| 14 | + corev1 "k8s.io/api/core/v1" |
| 15 | + "k8s.io/apimachinery/pkg/labels" |
| 16 | + "sigs.k8s.io/controller-runtime/pkg/client" |
| 17 | + |
| 18 | + "github.com/spf13/cobra" |
| 19 | +) |
| 20 | + |
| 21 | +// LogAnalysisOptions holds parameters for log analysis |
| 22 | +type LogAnalysisOptions struct { |
| 23 | + tailLines int64 |
| 24 | + fuzzyMatch bool |
| 25 | + searchString string |
| 26 | + noobaaTimestamp bool |
| 27 | + matchWholeString bool |
| 28 | + caseInsensitivity bool |
| 29 | + showLineIfNoTimestamp bool |
| 30 | +} |
| 31 | + |
| 32 | +const ( |
| 33 | + noobaaOutputTimestampFormat = "02/01/2006 15:04:05" |
| 34 | + // To be used in case of additional NooBaa operators in the future |
| 35 | + noobaaOperatorPodNamePrefix = "noobaa-operator" |
| 36 | + operatorTimestampPrefix = `time="` |
| 37 | + operatorWTimestampPrefix = "W" |
| 38 | +) |
| 39 | + |
| 40 | +var ( |
| 41 | + ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`) |
| 42 | +) |
| 43 | + |
| 44 | +// RunLogAnalysis runs a CLI command |
| 45 | +func RunLogAnalysis(cmd *cobra.Command, args []string) { |
| 46 | + tailLines, _ := cmd.Flags().GetInt64("tail") |
| 47 | + fuzzyMatch, _ := cmd.Flags().GetBool("fuzzy") |
| 48 | + noobaaTimestamp, _ := cmd.Flags().GetBool("noobaa-time") |
| 49 | + showLineIfNoTimestamp, _ := cmd.Flags().GetBool("prefer-line") |
| 50 | + matchWholeString, _ := cmd.Flags().GetBool("whole-string") |
| 51 | + caseInsensitivity, _ := cmd.Flags().GetBool("case-insensitive") |
| 52 | + searchString := "" |
| 53 | + |
| 54 | + analysisOptions := LogAnalysisOptions{ |
| 55 | + tailLines: tailLines, |
| 56 | + fuzzyMatch: fuzzyMatch, |
| 57 | + searchString: searchString, |
| 58 | + noobaaTimestamp: noobaaTimestamp, |
| 59 | + matchWholeString: matchWholeString, |
| 60 | + caseInsensitivity: caseInsensitivity, |
| 61 | + showLineIfNoTimestamp: showLineIfNoTimestamp, |
| 62 | + } |
| 63 | + |
| 64 | + validateLogAnalysisParameters(&analysisOptions, args) |
| 65 | + podSelector, _ := labels.Parse("app=noobaa") |
| 66 | + listOptions := client.ListOptions{Namespace: options.Namespace, LabelSelector: podSelector} |
| 67 | + CollectAndAnalyzeLogs(listOptions, &analysisOptions) |
| 68 | +} |
| 69 | + |
| 70 | +// validateLogAnalysisParameters validates the parameters for log analysis |
| 71 | +func validateLogAnalysisParameters(analysisOptions *LogAnalysisOptions, args []string) { |
| 72 | + log := util.Logger() |
| 73 | + if len(args) != 1 || args[0] == "" { |
| 74 | + analysisOptions.searchString = util.ShowStringPrompt("Provide a search string") |
| 75 | + } else { |
| 76 | + analysisOptions.searchString = args[0] |
| 77 | + } |
| 78 | + |
| 79 | + if analysisOptions.tailLines < 1 { |
| 80 | + log.Fatalf("❌ Tail must be a whole positive number") |
| 81 | + } |
| 82 | + |
| 83 | + if analysisOptions.fuzzyMatch && analysisOptions.matchWholeString { |
| 84 | + log.Fatalf("❌ Cannot use both fuzzy matching and whole-string matching") |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +// CollectAndAnalyzeLogs collects and analyzes logs of all existing noobaa pods |
| 89 | +func CollectAndAnalyzeLogs(listOptions client.ListOptions, analysisOptions *LogAnalysisOptions) { |
| 90 | + log := util.Logger() |
| 91 | + chosenTimestamp := "" |
| 92 | + if analysisOptions.noobaaTimestamp { |
| 93 | + chosenTimestamp = "NooBaa" |
| 94 | + } else { |
| 95 | + chosenTimestamp = "Kubernetes" |
| 96 | + } |
| 97 | + log.Println() |
| 98 | + log.Println("✨────────────────────────────────────────────✨") |
| 99 | + log.Printf(" Collecting and analyzing pod logs -") |
| 100 | + log.Printf(" Search string: %s", analysisOptions.searchString) |
| 101 | + log.Printf(" Case insensitivity: %t", analysisOptions.caseInsensitivity) |
| 102 | + log.Printf(" Match whole string: %t", analysisOptions.matchWholeString) |
| 103 | + log.Printf(" From the last %d lines", analysisOptions.tailLines) |
| 104 | + log.Printf(" Using %s timestamps", chosenTimestamp) |
| 105 | + log.Println(" Found occurrences will be printed below") |
| 106 | + log.Println(" in the format <pod name>:<container name>") |
| 107 | + log.Println("✨────────────────────────────────────────────✨") |
| 108 | + podList := &corev1.PodList{} |
| 109 | + if !util.KubeList(podList, &listOptions) { |
| 110 | + log.Printf(`❌ failed to get NooBaa pod list within namespace %s\n`, options.Namespace) |
| 111 | + return |
| 112 | + } |
| 113 | + for i := range podList.Items { |
| 114 | + pod := &podList.Items[i] |
| 115 | + analyzePodLogs(pod, analysisOptions) |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +// analyzePodLogs will count the number of occurrences of the search string in a pod log |
| 120 | +// as well as find and print the timestamps of the first and last occurrence of |
| 121 | +// the search string in the logs |
| 122 | +func analyzePodLogs(pod *corev1.Pod, analysisOptions *LogAnalysisOptions) { |
| 123 | + log := util.Logger() |
| 124 | + podLogs, err := util.GetPodLogs(*pod, &analysisOptions.tailLines, true) |
| 125 | + if err != nil { |
| 126 | + log.Printf("❌ Failed to get logs for pod %s: %v", pod.Name, err) |
| 127 | + return |
| 128 | + } |
| 129 | + if analysisOptions.caseInsensitivity { |
| 130 | + analysisOptions.searchString = strings.ToLower(analysisOptions.searchString) |
| 131 | + } |
| 132 | + stringBoundaryRegex := compileStringBoundaryRegex(analysisOptions) // Compiled here for better efficiency |
| 133 | + for containerName, containerLog := range podLogs { |
| 134 | + firstAppearanceFound := false |
| 135 | + firstAppearanceTimestamp := "" |
| 136 | + lastAppearanceTimestamp := "" |
| 137 | + lastOccurrenceLine := "" |
| 138 | + log.Printf("Analyzing %s:%s", pod.Name, containerName) |
| 139 | + defer containerLog.Close() |
| 140 | + occurrenceCounter := 0 |
| 141 | + scanner := bufio.NewScanner(containerLog) |
| 142 | + for scanner.Scan() { |
| 143 | + line := scanner.Text() |
| 144 | + // Clean line from ANSI escape codes |
| 145 | + if !strings.Contains(pod.Name, noobaaOperatorPodNamePrefix) { |
| 146 | + line = sanitizeANSI(line) |
| 147 | + } |
| 148 | + lineContainsMatch := stringMatchCheck(line, stringBoundaryRegex, analysisOptions) |
| 149 | + if lineContainsMatch { |
| 150 | + if !firstAppearanceFound { |
| 151 | + firstAppearanceFound = true |
| 152 | + firstAppearanceTimestamp = extractTimeString(pod, line, *analysisOptions) |
| 153 | + } |
| 154 | + occurrenceCounter++ |
| 155 | + lastOccurrenceLine = line |
| 156 | + } |
| 157 | + } |
| 158 | + lastAppearanceTimestamp = extractTimeString(pod, lastOccurrenceLine, *analysisOptions) |
| 159 | + if occurrenceCounter == 0 { |
| 160 | + log.Println("No occurrences found") |
| 161 | + } else { |
| 162 | + log.Printf("Hits: %d", occurrenceCounter) |
| 163 | + log.Printf("Earliest appearance: %s", firstAppearanceTimestamp) |
| 164 | + if occurrenceCounter > 1 { |
| 165 | + log.Printf("Latest appearance: %s", lastAppearanceTimestamp) |
| 166 | + } |
| 167 | + } |
| 168 | + log.Println("──────────────────────────────────────────────────────────────────────────────────") |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +// sanitizeANSI removes ANSI escape codes from a string |
| 173 | +func sanitizeANSI(line string) string { |
| 174 | + // Remove ANSI escape codes from the line |
| 175 | + // This is done to avoid the terminal from interpreting them as color codes |
| 176 | + // and printing them as garbage characters |
| 177 | + // The regex pattern is taken from |
| 178 | + return ansiRegex.ReplaceAllString(line, "") |
| 179 | +} |
| 180 | + |
| 181 | +// compileStringBoundaryRegex compiles a word boundary regex pattern for the search string |
| 182 | +func compileStringBoundaryRegex(analysisOptions *LogAnalysisOptions) *regexp.Regexp { |
| 183 | + var stringBoundarySearchPattern *regexp.Regexp |
| 184 | + stringBoundaryPattern := fmt.Sprintf(`\b%s\b`, regexp.QuoteMeta(analysisOptions.searchString)) |
| 185 | + if analysisOptions.caseInsensitivity { |
| 186 | + stringBoundarySearchPattern = regexp.MustCompile("(?i)" + stringBoundaryPattern) |
| 187 | + } else { |
| 188 | + stringBoundarySearchPattern = regexp.MustCompile(stringBoundaryPattern) |
| 189 | + } |
| 190 | + return stringBoundarySearchPattern |
| 191 | +} |
| 192 | + |
| 193 | +// stringMatchCheck checks if a line contains a match to the search string |
| 194 | +func stringMatchCheck (line string, stringBoundaryRegex *regexp.Regexp, analysisOptions *LogAnalysisOptions) bool { |
| 195 | + if analysisOptions.matchWholeString { |
| 196 | + return wholestringMatchCheck(line, stringBoundaryRegex) |
| 197 | + } else { |
| 198 | + return partialMatchCheck(line, analysisOptions) |
| 199 | + } |
| 200 | +} |
| 201 | + |
| 202 | +// wholestringMatchCheck checks if a line contains a whole string match to the search string |
| 203 | +// Mostly used for readability and organization purposes |
| 204 | +func wholestringMatchCheck (line string, stringBoundaryRegex *regexp.Regexp) bool { |
| 205 | + return stringBoundaryRegex.MatchString(line) |
| 206 | +} |
| 207 | + |
| 208 | +// partialMatchCheck checks if a line contains a partial/fuzzy match to the search string |
| 209 | +func partialMatchCheck (line string, analysisOptions *LogAnalysisOptions) bool { |
| 210 | + if analysisOptions.fuzzyMatch { |
| 211 | + fuzzyCaseInsensitiveMatch := analysisOptions.caseInsensitivity && fuzzy.MatchNormalized(analysisOptions.searchString, line) |
| 212 | + fuzzyCaseSensitiveMatch := fuzzy.Match(analysisOptions.searchString, line) |
| 213 | + return fuzzyCaseInsensitiveMatch || fuzzyCaseSensitiveMatch |
| 214 | + } else { |
| 215 | + // Check for a match by temporarily casting the line string to lowercase |
| 216 | + // (the search string is cast in the beginning of analyzePodLogs) |
| 217 | + caseInsensitiveMatch := analysisOptions.caseInsensitivity && strings.Contains(strings.ToLower(line), analysisOptions.searchString) |
| 218 | + caseSensitiveMatch := strings.Contains(line, analysisOptions.searchString) |
| 219 | + return caseInsensitiveMatch || caseSensitiveMatch |
| 220 | + } |
| 221 | +} |
| 222 | + |
| 223 | +// extractTimeString extracts the timestamp from a log line by checking which pod |
| 224 | +// it originated from and redirecting it to the appropriate extraction function |
| 225 | +func extractTimeString(pod *corev1.Pod, line string, analysisOptions LogAnalysisOptions) string { |
| 226 | + if analysisOptions.noobaaTimestamp { |
| 227 | + if strings.Contains(pod.Name, noobaaOperatorPodNamePrefix) { |
| 228 | + return extractOperatorTimestampString(line, analysisOptions.showLineIfNoTimestamp) |
| 229 | + } else { |
| 230 | + return extractCoreTimestampString(line, analysisOptions.showLineIfNoTimestamp) |
| 231 | + } |
| 232 | + } else { |
| 233 | + return extractKubernetesTimestampString(line) |
| 234 | + } |
| 235 | +} |
| 236 | + |
| 237 | +// extractKubernetesTimestampString extracts the timestamp from a Kubernetes log line |
| 238 | +func extractKubernetesTimestampString(line string) string { |
| 239 | + // Example log line: |
| 240 | + // 2024-12-10T07:27:16.856641898Z Dec-10 7:27:16.847 [BGWorkers/36]... |
| 241 | + splitLine := strings.SplitN(line, " ", 2) |
| 242 | + return splitLine[0] |
| 243 | +} |
| 244 | + |
| 245 | +// extractCoreTimestampString extracts, parses and formats a timestamp string |
| 246 | +// from pods running NooBaa Core code (core, endpoint, PV pod) |
| 247 | +func extractCoreTimestampString(line string, showLineIfNoTimestamp bool) string { |
| 248 | + // Example log line: |
| 249 | + // Dec-9 15:16:31.621 [BGWorkers/36] [L0] ... |
| 250 | + const minimumRequiredIndices = 2 |
| 251 | + // Example split result: |
| 252 | + // ["Dec-9", "15:16:31.621", "[BGWorkers..."] |
| 253 | + splitLine := strings.SplitN(line, " ", 3) |
| 254 | + if len(splitLine) < minimumRequiredIndices { |
| 255 | + return timeParsingError(showLineIfNoTimestamp, line) |
| 256 | + } |
| 257 | + lineDate := splitLine[0] |
| 258 | + lineTime := splitLine[1] |
| 259 | + // The year is assumed to be the current one since it's not provided |
| 260 | + year := time.Now().Year() |
| 261 | + layout := "Jan-2-2006 15:04:05.000" |
| 262 | + timestampString := fmt.Sprintf("%s-%d %s", lineDate, year, lineTime) |
| 263 | + parsedTime, err := time.Parse(layout, timestampString) |
| 264 | + if err != nil { |
| 265 | + return timeParsingError(showLineIfNoTimestamp, line) |
| 266 | + } |
| 267 | + return parsedTime.Format(noobaaOutputTimestampFormat) |
| 268 | +} |
| 269 | + |
| 270 | +// extractOperatorTimestampString extracts, parses, formats and returns a timestamp |
| 271 | +// string from the NooBaa Operator pod logs |
| 272 | +func extractOperatorTimestampString(line string, showLineIfNoTimestamp bool) string { |
| 273 | + if strings.HasPrefix(line, operatorTimestampPrefix) { |
| 274 | + return extractStandardOperatorTimestampString(line, showLineIfNoTimestamp) |
| 275 | + } else if strings.HasPrefix(line, operatorWTimestampPrefix) { |
| 276 | + return extractOperatorWTimestampString(line, showLineIfNoTimestamp) |
| 277 | + } |
| 278 | + return timeParsingError(showLineIfNoTimestamp, line) |
| 279 | +} |
| 280 | + |
| 281 | +// extractStandardOperatorTimestampString extracts the timestamp in case of a standard operator log line |
| 282 | +// Example: |
| 283 | +// time="2024-12-10T07:27:36Z" level=info msg="... |
| 284 | +func extractStandardOperatorTimestampString(line string, showLineIfNoTimestamp bool) string { |
| 285 | + secondQuotesIndex := strings.Index(line[len(operatorTimestampPrefix):], `"`) |
| 286 | + if secondQuotesIndex == -1 { |
| 287 | + return timeParsingError(showLineIfNoTimestamp, line) |
| 288 | + } |
| 289 | + timestampString := line[len(operatorTimestampPrefix) : len(operatorTimestampPrefix) + secondQuotesIndex] |
| 290 | + // Parse the date using RFC3339 layout |
| 291 | + const operatorTimestampFormat = time.RFC3339 |
| 292 | + parsedTimestamp, err := time.Parse(operatorTimestampFormat, timestampString) |
| 293 | + if err != nil { |
| 294 | + return timeParsingError(showLineIfNoTimestamp, line) |
| 295 | + } |
| 296 | + return parsedTimestamp.Format(noobaaOutputTimestampFormat) |
| 297 | +} |
| 298 | + |
| 299 | +// extractOperatorWTimestampString extracts the timestamp in case of a non-standard operator log line |
| 300 | +// Example: |
| 301 | +// W1209 13:41:05.890285 1 reflector.go:484... |
| 302 | +func extractOperatorWTimestampString(line string, showLineIfNoTimestamp bool) string { |
| 303 | + const minimumWTimeParseLength = 22 |
| 304 | + if len(line) < minimumWTimeParseLength { |
| 305 | + return timeParsingError(showLineIfNoTimestamp, line) |
| 306 | + } |
| 307 | + wStringDayMonthStartIndex := 1 |
| 308 | + wStringDayMonthEndIndex := 5 |
| 309 | + wStringTimeStartIndex := 6 |
| 310 | + wStringTimeEndIndex := 21 |
| 311 | + datePart := line[wStringDayMonthStartIndex : wStringDayMonthEndIndex] |
| 312 | + timePart := line[wStringTimeStartIndex : wStringTimeEndIndex] |
| 313 | + day := datePart[2:] |
| 314 | + month := datePart[:2] |
| 315 | + // The year is assumed to be the current one since it's not provided |
| 316 | + year := time.Now().Year() |
| 317 | + fullTimeStr := fmt.Sprintf("%s/%s/%d %s", day, month, year, timePart) |
| 318 | + return fullTimeStr |
| 319 | +} |
| 320 | + |
| 321 | +// timeParsingError returns an error message if the timestamp |
| 322 | +// could not be parsed based on the user's prefer-line setting |
| 323 | +func timeParsingError(showLineIfNoTimestamp bool, line string) string { |
| 324 | + absentTimestampErrorWithLine := fmt.Sprintf("Could not parse timestamp in line %s", line) |
| 325 | + const absentTimestampError = "No timestamp found" |
| 326 | + if showLineIfNoTimestamp { |
| 327 | + return absentTimestampErrorWithLine |
| 328 | + } |
| 329 | + return absentTimestampError |
| 330 | +} |
0 commit comments