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  }