|
| 1 | +package github |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "fmt" |
| 7 | + "os" |
| 8 | + "path/filepath" |
| 9 | + "regexp" |
| 10 | + "strings" |
| 11 | + "testing" |
| 12 | + "time" |
| 13 | + |
| 14 | + corev1 "k8s.io/api/core/v1" |
| 15 | + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" |
| 16 | +) |
| 17 | + |
| 18 | +// InstrumentationAPICall represents a single GitHub API call with its metadata. |
| 19 | +type InstrumentationAPICall struct { |
| 20 | + Operation string `json:"operation"` |
| 21 | + DurationMs int64 `json:"duration_ms"` |
| 22 | + URLPath string `json:"url_path"` |
| 23 | + RateLimitRemaining string `json:"rate_limit_remaining"` |
| 24 | + StatusCode int `json:"status_code"` |
| 25 | + Provider string `json:"provider"` |
| 26 | + Repo string `json:"repo"` |
| 27 | +} |
| 28 | + |
| 29 | +// TestResult represents the complete test result with GitHub API calls. |
| 30 | +type TestResult struct { |
| 31 | + TestName string `json:"test_name"` |
| 32 | + Timestamp string `json:"timestamp"` |
| 33 | + Controller string `json:"controller"` |
| 34 | + PRNumber int `json:"pr_number"` |
| 35 | + SHA string `json:"sha"` |
| 36 | + TargetNamespace string `json:"target_namespace"` |
| 37 | + GitHubAPICalls []InstrumentationAPICall `json:"github_api_calls"` |
| 38 | + TotalCalls int `json:"total_calls"` |
| 39 | + OAuth2MarkerLine int `json:"oauth2_marker_line"` |
| 40 | +} |
| 41 | + |
| 42 | +func (g *PRTest) collectGitHubAPICalls(ctx context.Context, _ *testing.T) { |
| 43 | + numLines := int64(100) |
| 44 | + controllerName := "controller" |
| 45 | + if g.SecondController { |
| 46 | + controllerName = "ghe-controller" |
| 47 | + } |
| 48 | + labelselector := fmt.Sprintf("app.kubernetes.io/name=%s", controllerName) |
| 49 | + containerName := "pac-controller" |
| 50 | + |
| 51 | + g.Logger.Infof("Attempting to collect GitHub API calls from controller: %s", controllerName) |
| 52 | + |
| 53 | + // Get controller logs using the existing pattern |
| 54 | + logs, err := g.Cnx.Clients.Kube.CoreV1().Pods("").List(ctx, metav1.ListOptions{ |
| 55 | + LabelSelector: labelselector, |
| 56 | + }) |
| 57 | + if err != nil { |
| 58 | + g.Logger.Warnf("Failed to get controller pods with label selector '%s': %v", labelselector, err) |
| 59 | + return |
| 60 | + } |
| 61 | + |
| 62 | + g.Logger.Infof("Found %d pods with label selector '%s'", len(logs.Items), labelselector) |
| 63 | + |
| 64 | + if len(logs.Items) == 0 { |
| 65 | + g.Logger.Warnf("No controller pods found with label selector: %s", labelselector) |
| 66 | + // Try to list all pods to see what's available |
| 67 | + allPods, err := g.Cnx.Clients.Kube.CoreV1().Pods("").List(ctx, metav1.ListOptions{}) |
| 68 | + if err != nil { |
| 69 | + g.Logger.Warnf("Failed to list all pods: %v", err) |
| 70 | + return |
| 71 | + } |
| 72 | + g.Logger.Infof("Available pods: %d total", len(allPods.Items)) |
| 73 | + for _, pod := range allPods.Items { |
| 74 | + if strings.Contains(pod.Name, "controller") { |
| 75 | + g.Logger.Infof(" - %s (namespace: %s, labels: %v)", pod.Name, pod.Namespace, pod.Labels) |
| 76 | + } |
| 77 | + } |
| 78 | + return |
| 79 | + } |
| 80 | + |
| 81 | + // Log details about the found pods |
| 82 | + for i, pod := range logs.Items { |
| 83 | + g.Logger.Infof("Pod %d: %s (namespace: %s, status: %s)", i+1, pod.Name, pod.Namespace, pod.Status.Phase) |
| 84 | + } |
| 85 | + |
| 86 | + // Get logs from the first controller pod |
| 87 | + selectedPod := logs.Items[0] |
| 88 | + g.Logger.Infof("Attempting to get logs from pod: %s in namespace: %s", selectedPod.Name, selectedPod.Namespace) |
| 89 | + |
| 90 | + // Try to get logs with more specific options |
| 91 | + podLogs, err := g.Cnx.Clients.Kube.CoreV1().Pods(selectedPod.Namespace).GetLogs(selectedPod.Name, &corev1.PodLogOptions{ |
| 92 | + Container: containerName, |
| 93 | + TailLines: &numLines, |
| 94 | + Previous: false, // Don't get previous container logs |
| 95 | + }).Do(ctx).Raw() |
| 96 | + if err != nil { |
| 97 | + g.Logger.Warnf("Failed to get controller logs from pod %s: %v", selectedPod.Name, err) |
| 98 | + |
| 99 | + // Try to get pod details to understand the issue |
| 100 | + podDetails, err := g.Cnx.Clients.Kube.CoreV1().Pods(selectedPod.Namespace).Get(ctx, selectedPod.Name, metav1.GetOptions{}) |
| 101 | + if err != nil { |
| 102 | + g.Logger.Warnf("Failed to get pod details for %s: %v", selectedPod.Name, err) |
| 103 | + } else { |
| 104 | + g.Logger.Infof("Pod %s status: %s, containers: %v", selectedPod.Name, podDetails.Status.Phase, podDetails.Status.ContainerStatuses) |
| 105 | + |
| 106 | + // Check if the container exists and is ready |
| 107 | + for _, container := range podDetails.Status.ContainerStatuses { |
| 108 | + if container.Name == containerName { |
| 109 | + g.Logger.Infof("Container %s ready: %v, restart count: %d", container.Name, container.Ready, container.RestartCount) |
| 110 | + } |
| 111 | + } |
| 112 | + } |
| 113 | + |
| 114 | + // Try alternative approach - get logs without specifying container |
| 115 | + g.Logger.Infof("Trying to get logs without specifying container name...") |
| 116 | + podLogs, err = g.Cnx.Clients.Kube.CoreV1().Pods(selectedPod.Namespace).GetLogs(selectedPod.Name, &corev1.PodLogOptions{ |
| 117 | + TailLines: &numLines, |
| 118 | + }).Do(ctx).Raw() |
| 119 | + if err != nil { |
| 120 | + g.Logger.Warnf("Failed to get logs without container specification: %v", err) |
| 121 | + return |
| 122 | + } |
| 123 | + g.Logger.Infof("Successfully got logs without container specification") |
| 124 | + } |
| 125 | + |
| 126 | + logContent := string(podLogs) |
| 127 | + logLines := strings.Split(logContent, "\n") |
| 128 | + g.Logger.Infof("Collected %d lines from controller logs", len(logLines)) |
| 129 | + |
| 130 | + // Find the last OAuth2 initialization marker |
| 131 | + oauth2Marker := "github-app: initialized OAuth2 client" |
| 132 | + lastOAuth2Index := -1 |
| 133 | + for i := len(logLines) - 1; i >= 0; i-- { |
| 134 | + if strings.Contains(logLines[i], oauth2Marker) { |
| 135 | + lastOAuth2Index = i |
| 136 | + break |
| 137 | + } |
| 138 | + } |
| 139 | + |
| 140 | + if lastOAuth2Index == -1 { |
| 141 | + g.Logger.Infof("OAuth2 initialization marker not found in the last %d lines", numLines) |
| 142 | + return |
| 143 | + } |
| 144 | + |
| 145 | + g.Logger.Infof("Found OAuth2 initialization marker at line %d (from end)", len(logLines)-lastOAuth2Index) |
| 146 | + |
| 147 | + // Extract GitHub API calls only from the last OAuth2 marker onwards |
| 148 | + apiCallRegex := regexp.MustCompile(`GitHub API call completed.*`) |
| 149 | + var apiCalls []string |
| 150 | + |
| 151 | + for i := lastOAuth2Index; i < len(logLines); i++ { |
| 152 | + line := logLines[i] |
| 153 | + if apiCallRegex.MatchString(line) { |
| 154 | + apiCalls = append(apiCalls, strings.TrimSpace(line)) |
| 155 | + } |
| 156 | + } |
| 157 | + |
| 158 | + g.Logger.Infof("Found %d GitHub API calls after the last OAuth2 initialization", len(apiCalls)) |
| 159 | + |
| 160 | + // Log each API call for debugging |
| 161 | + for i, match := range apiCalls { |
| 162 | + g.Logger.Infof("API Call %d: %s", i+1, match) |
| 163 | + } |
| 164 | + |
| 165 | + // Check if we should output to file |
| 166 | + outputDir := os.Getenv("PAC_API_INSTRUMENTATION_DIR") |
| 167 | + if outputDir != "" { |
| 168 | + g.outputTestResultToFile(outputDir, apiCalls, lastOAuth2Index, len(logLines)) |
| 169 | + } |
| 170 | +} |
| 171 | + |
| 172 | +// sanitizeFilename converts a test name to a safe filename. |
| 173 | +func sanitizeFilename(name string) string { |
| 174 | + // Replace spaces and special characters with underscores |
| 175 | + re := regexp.MustCompile(`[^a-zA-Z0-9._-]`) |
| 176 | + sanitized := re.ReplaceAllString(name, "_") |
| 177 | + // Convert to lowercase |
| 178 | + return strings.ToLower(sanitized) |
| 179 | +} |
| 180 | + |
| 181 | +// parseAPICallLog parses a GitHub API call log line and extracts structured data. |
| 182 | +func parseAPICallLog(logLine string) *InstrumentationAPICall { |
| 183 | + // The log line contains a JSON object, so we need to extract and parse it |
| 184 | + // Example: "API Call 1: {\"level\":\"debug\",\"ts\":\"2025-08-05T16:12:17.508Z\",...}" |
| 185 | + |
| 186 | + // Find the JSON part after "API Call X: " |
| 187 | + jsonStart := strings.Index(logLine, "{") |
| 188 | + if jsonStart == -1 { |
| 189 | + return nil |
| 190 | + } |
| 191 | + |
| 192 | + jsonStr := logLine[jsonStart:] |
| 193 | + |
| 194 | + // Parse the JSON object |
| 195 | + var logEntry map[string]interface{} |
| 196 | + if err := json.Unmarshal([]byte(jsonStr), &logEntry); err != nil { |
| 197 | + return nil |
| 198 | + } |
| 199 | + |
| 200 | + call := &InstrumentationAPICall{} |
| 201 | + |
| 202 | + // Extract fields from the JSON |
| 203 | + if operation, ok := logEntry["operation"].(string); ok { |
| 204 | + call.Operation = operation |
| 205 | + } |
| 206 | + |
| 207 | + if durationMs, ok := logEntry["duration_ms"].(float64); ok { |
| 208 | + call.DurationMs = int64(durationMs) |
| 209 | + } |
| 210 | + |
| 211 | + if urlPath, ok := logEntry["url_path"].(string); ok { |
| 212 | + call.URLPath = urlPath |
| 213 | + } |
| 214 | + |
| 215 | + if rateLimit, ok := logEntry["rate_limit_remaining"].(string); ok { |
| 216 | + call.RateLimitRemaining = rateLimit |
| 217 | + } |
| 218 | + |
| 219 | + if statusCode, ok := logEntry["status_code"].(float64); ok { |
| 220 | + call.StatusCode = int(statusCode) |
| 221 | + } |
| 222 | + |
| 223 | + if provider, ok := logEntry["provider"].(string); ok { |
| 224 | + call.Provider = provider |
| 225 | + } |
| 226 | + |
| 227 | + if repo, ok := logEntry["repo"].(string); ok { |
| 228 | + call.Repo = repo |
| 229 | + } |
| 230 | + |
| 231 | + return call |
| 232 | +} |
| 233 | + |
| 234 | +// outputTestResultToFile writes the test result to a JSON file. |
| 235 | +func (g *PRTest) outputTestResultToFile(outputDir string, apiCalls []string, lastOAuth2Index, totalLines int) { |
| 236 | + // Create output directory if it doesn't exist |
| 237 | + if err := os.MkdirAll(outputDir, 0o755); err != nil { |
| 238 | + g.Logger.Warnf("Failed to create output directory %s: %v", outputDir, err) |
| 239 | + return |
| 240 | + } |
| 241 | + |
| 242 | + // Parse API calls into structured data |
| 243 | + var structuredCalls []InstrumentationAPICall |
| 244 | + for _, call := range apiCalls { |
| 245 | + if parsed := parseAPICallLog(call); parsed != nil { |
| 246 | + structuredCalls = append(structuredCalls, *parsed) |
| 247 | + } |
| 248 | + } |
| 249 | + |
| 250 | + // Create test result |
| 251 | + controllerName := "controller" |
| 252 | + if g.SecondController { |
| 253 | + controllerName = "ghe-controller" |
| 254 | + } |
| 255 | + |
| 256 | + result := TestResult{ |
| 257 | + TestName: g.Label, |
| 258 | + Timestamp: time.Now().Format(time.RFC3339), |
| 259 | + Controller: controllerName, |
| 260 | + PRNumber: g.PRNumber, |
| 261 | + SHA: g.SHA, |
| 262 | + TargetNamespace: g.TargetNamespace, |
| 263 | + GitHubAPICalls: structuredCalls, |
| 264 | + TotalCalls: len(structuredCalls), |
| 265 | + OAuth2MarkerLine: totalLines - lastOAuth2Index, |
| 266 | + } |
| 267 | + |
| 268 | + // Generate filename |
| 269 | + timestamp := time.Now().Format("2006-01-02T15-04-05") |
| 270 | + sanitizedTestName := sanitizeFilename(g.Label) |
| 271 | + filename := fmt.Sprintf("%s_%s.json", timestamp, sanitizedTestName) |
| 272 | + filepath := filepath.Join(outputDir, filename) |
| 273 | + |
| 274 | + // Write JSON file |
| 275 | + jsonData, err := json.MarshalIndent(result, "", " ") |
| 276 | + if err != nil { |
| 277 | + g.Logger.Warnf("Failed to marshal test result to JSON: %v", err) |
| 278 | + return |
| 279 | + } |
| 280 | + |
| 281 | + if err := os.WriteFile(filepath, jsonData, 0o600); err != nil { |
| 282 | + g.Logger.Warnf("Failed to write test result to file %s: %v", filepath, err) |
| 283 | + return |
| 284 | + } |
| 285 | + |
| 286 | + g.Logger.Infof("Test result written to: %s", filepath) |
| 287 | +} |
0 commit comments