k8s.io/test-infra@v0.0.0-20240520184403-27c6b4c223d8/experiment/coverage/apicoverage.go (about) 1 /* 2 Copyright 2017 The Kubernetes Authors. 3 4 Licensed under the Apache License, Version 2.0 (the "License"); 5 you may not use this file except in compliance with the License. 6 You may obtain a copy of the License at 7 8 http://www.apache.org/licenses/LICENSE-2.0 9 10 Unless required by applicable law or agreed to in writing, software 11 distributed under the License is distributed on an "AS IS" BASIS, 12 WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 See the License for the specific language governing permissions and 14 limitations under the License. 15 */ 16 17 package main 18 19 import ( 20 "bufio" 21 "encoding/csv" 22 "flag" 23 "fmt" 24 "io" 25 "log" 26 "net/http" 27 "net/url" 28 "os" 29 "regexp" 30 "strconv" 31 "strings" 32 33 "github.com/go-openapi/spec" 34 "github.com/golang/glog" 35 ) 36 37 var ( 38 openAPIFile = flag.String("openapi", "https://raw.githubusercontent.com/kubernetes/kubernetes/master/api/openapi-spec/swagger.json", "URL to openapi-spec of Kubernetes") 39 outputCoveredAPIs = flag.Bool("output-covered-apis", false, "Output the list of covered APIs") 40 minCoverage = flag.Int("minimum-coverage", 0, "This command fails if the number of covered APIs is less than this option ratio(percent)") 41 restLog = flag.String("restlog", "", "File path to REST API operation log of Kubernetes") 42 logType = flag.String("logtype", "e2e", "Type of REST API operation log of Kubernetes(e2e, apiserver)") 43 ) 44 45 type apiData struct { 46 Method string 47 URL string 48 } 49 50 type apiArray []apiData 51 52 func parseOpenAPI(rawdata []byte) apiArray { 53 var swaggerSpec spec.Swagger 54 var apisOpenapi apiArray 55 56 err := swaggerSpec.UnmarshalJSON(rawdata) 57 if err != nil { 58 log.Fatal(err) 59 } 60 61 for path, pathItem := range swaggerSpec.Paths.Paths { 62 // Some paths contain "/" at the end of swagger spec, here removes "/" for comparing them easily later. 63 path = strings.TrimRight(path, "/") 64 65 // Standard HTTP methods: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/2.0.md#path-item-object 66 methods := []string{"get", "put", "post", "delete", "options", "head", "patch"} 67 for _, method := range methods { 68 methodSpec, err := pathItem.JSONLookup(method) 69 if err != nil { 70 log.Fatal(err) 71 } 72 t, ok := methodSpec.(*spec.Operation) 73 if !ok { 74 log.Fatal("Failed to convert methodSpec.") 75 } 76 if t == nil { 77 continue 78 } 79 method := strings.ToUpper(method) 80 api := apiData{ 81 Method: method, 82 URL: path, 83 } 84 apisOpenapi = append(apisOpenapi, api) 85 } 86 } 87 return apisOpenapi 88 } 89 90 func getOpenAPISpec(url string) apiArray { 91 resp, err := http.Get(url) 92 if err != nil { 93 log.Fatal(err) 94 } 95 bytes, err := io.ReadAll(resp.Body) 96 if err != nil { 97 log.Fatal(err) 98 } 99 return parseOpenAPI(bytes) 100 } 101 102 // I0919 15:34:14.943642 6611 round_trippers.go:414] GET https://172.27.138.63:6443/api/v1/namespaces/kube-system/replicationcontrollers 103 var reE2eAPILog = regexp.MustCompile(`round_trippers.go:\d+\] (GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH) (\S+)`) 104 105 func parseE2eAPILog(fp io.Reader) apiArray { 106 var apisLog apiArray 107 var err error 108 109 reader := bufio.NewReaderSize(fp, 4096) 110 for line := ""; err == nil; line, err = reader.ReadString('\n') { 111 result := reE2eAPILog.FindSubmatch([]byte(line)) 112 if len(result) == 0 { 113 continue 114 } 115 method := strings.ToUpper(string(result[1])) 116 rawurl := string(result[2]) 117 parsedURL, err := url.Parse(rawurl) 118 if err != nil { 119 log.Fatal(err) 120 } 121 api := apiData{ 122 Method: method, 123 URL: parsedURL.Path, 124 } 125 apisLog = append(apisLog, api) 126 } 127 return apisLog 128 } 129 130 // I0413 12:10:56.612005 1 wrap.go:42] PUT /apis/apiregistration.k8s.io/v1/apiservices/v1.apps/status: (1.671974ms) 200 [[kube-apiserver/v1.11.0 (linux/amd64) kubernetes/7297c1c] 127.0.0.1:44356] 131 var reAPIServerLog = regexp.MustCompile(`wrap.go:\d+\] (GET|PUT|POST|DELETE|OPTIONS|HEAD|PATCH) (\S+)`) 132 133 func parseAPIServerLog(fp io.Reader) apiArray { 134 var apisLog apiArray 135 var err error 136 137 reader := bufio.NewReaderSize(fp, 4096) 138 for line := ""; err == nil; line, err = reader.ReadString('\n') { 139 result := reAPIServerLog.FindSubmatch([]byte(line)) 140 if len(result) == 0 { 141 continue 142 } 143 method := strings.ToUpper(string(result[1])) 144 rawurl := strings.Replace(string(result[2]), ":", "", 1) 145 parsedURL, err := url.Parse(rawurl) 146 if err != nil { 147 log.Fatal(err) 148 } 149 api := apiData{ 150 Method: method, 151 URL: parsedURL.Path, 152 } 153 apisLog = append(apisLog, api) 154 } 155 return apisLog 156 } 157 158 func getAPILog(restlog string) apiArray { 159 var fp *os.File 160 var err error 161 162 fp, err = os.Open(restlog) 163 if err != nil { 164 log.Fatal(err) 165 } 166 defer fp.Close() 167 168 if *logType == "e2e" { 169 return parseE2eAPILog(fp) 170 } 171 return parseAPIServerLog(fp) 172 } 173 174 var reOpenapi = regexp.MustCompile(`({\S+?})`) 175 176 func getTestedAPIs(apisOpenapi, apisLogs apiArray) apiArray { 177 var found bool 178 var apisTested apiArray 179 180 for _, openapi := range apisOpenapi { 181 regURL := reOpenapi.ReplaceAllLiteralString(openapi.URL, `[^/\s]+`) + `$` 182 reg := regexp.MustCompile(regURL) 183 found = false 184 for _, log := range apisLogs { 185 if openapi.Method != log.Method { 186 continue 187 } 188 if !reg.MatchString(log.URL) { 189 continue 190 } 191 found = true 192 apisTested = append(apisTested, openapi) 193 break 194 } 195 if found { 196 continue 197 } 198 } 199 return apisTested 200 } 201 202 func getTestedAPIsByLevel(negative bool, reg *regexp.Regexp, apisOpenapi, apisTested apiArray) (apiArray, apiArray) { 203 var apisTestedByLevel apiArray 204 var apisAllByLevel apiArray 205 206 for _, openapi := range apisTested { 207 if (!negative && reg.MatchString(openapi.URL)) || 208 (negative && !reg.MatchString(openapi.URL)) { 209 apisTestedByLevel = append(apisTestedByLevel, openapi) 210 } 211 } 212 for _, openapi := range apisOpenapi { 213 if (!negative && reg.MatchString(openapi.URL)) || 214 (negative && !reg.MatchString(openapi.URL)) { 215 apisAllByLevel = append(apisAllByLevel, openapi) 216 } 217 } 218 return apisTestedByLevel, apisAllByLevel 219 } 220 221 type coverageData struct { 222 Total string 223 Tested string 224 Untested string 225 Coverage string 226 } 227 228 func getCoverageByLevel(apisTested, apisAll apiArray) coverageData { 229 var coverage coverageData 230 231 coverage.Total = fmt.Sprint(len(apisAll)) 232 coverage.Tested = fmt.Sprint(len(apisTested)) 233 coverage.Untested = fmt.Sprint(len(apisAll) - len(apisTested)) 234 coverage.Coverage = fmt.Sprint(100 * len(apisTested) / len(apisAll)) 235 236 return coverage 237 } 238 239 // NOTE: This is messy, but the regex doesn't support negative lookahead(?!) on golang. 240 // This is just a workaround. 241 var reNotStableAPI = regexp.MustCompile(`\S+(alpha|beta)\S+`) 242 var reAlphaAPI = regexp.MustCompile(`\S+alpha\S+`) 243 var reBetaAPI = regexp.MustCompile(`\S+beta\S+`) 244 245 func outputCoverage(apisOpenapi, apisTested apiArray) { 246 apisTestedByStable, apisAllByStable := getTestedAPIsByLevel(true, reNotStableAPI, apisOpenapi, apisTested) 247 apisTestedByAlpha, apisAllByAlpha := getTestedAPIsByLevel(false, reAlphaAPI, apisOpenapi, apisTested) 248 apisTestedByBeta, apisAllByBeta := getTestedAPIsByLevel(false, reBetaAPI, apisOpenapi, apisTested) 249 250 coverageAll := getCoverageByLevel(apisTested, apisOpenapi) 251 coverageStable := getCoverageByLevel(apisTestedByStable, apisAllByStable) 252 coverageAlpha := getCoverageByLevel(apisTestedByAlpha, apisAllByAlpha) 253 coverageBeta := getCoverageByLevel(apisTestedByBeta, apisAllByBeta) 254 255 records := [][]string{ 256 {"API", "TOTAL", "TESTED", "UNTESTED", "COVERAGE(%)"}, 257 {"ALL", coverageAll.Total, coverageAll.Tested, coverageAll.Untested, coverageAll.Coverage}, 258 {"STABLE", coverageStable.Total, coverageStable.Tested, coverageStable.Untested, coverageStable.Coverage}, 259 {"Alpha", coverageAlpha.Total, coverageAlpha.Tested, coverageAlpha.Untested, coverageAlpha.Coverage}, 260 {"Beta", coverageBeta.Total, coverageBeta.Tested, coverageBeta.Untested, coverageBeta.Coverage}, 261 } 262 w := csv.NewWriter(os.Stdout) 263 w.WriteAll(records) 264 265 actualCoverage, _ := strconv.Atoi(coverageAll.Coverage) 266 if *minCoverage > int(actualCoverage) { 267 log.Fatalf("The API coverage(%d) is lower than the specified one(%d).", actualCoverage, *minCoverage) 268 } 269 } 270 271 func main() { 272 flag.Parse() 273 if len(*restLog) == 0 { 274 glog.Fatal("need to set '--restlog'") 275 } 276 if *logType != "e2e" && *logType != "apiserver" { 277 glog.Fatal("need to specify e2e or apiserver with '--logtype'") 278 } 279 280 apisOpenapi := getOpenAPISpec(*openAPIFile) 281 apisLogs := getAPILog(*restLog) 282 apisTested := getTestedAPIs(apisOpenapi, apisLogs) 283 outputCoverage(apisOpenapi, apisTested) 284 if *outputCoveredAPIs { 285 for _, openapi := range apisTested { 286 fmt.Printf("%s %s\n", openapi.Method, openapi.URL) 287 } 288 } 289 }