k8s.io/kubernetes@v1.29.3/test/conformance/walk.go (about)

     1  /*
     2  Copyright 2019 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  	"encoding/json"
    21  	"flag"
    22  	"fmt"
    23  	"go/ast"
    24  	"go/parser"
    25  	"go/token"
    26  	"io"
    27  	"log"
    28  	"os"
    29  	"regexp"
    30  	"sort"
    31  	"strings"
    32  	"text/template"
    33  
    34  	"gopkg.in/yaml.v2"
    35  
    36  	"github.com/onsi/ginkgo/v2/types"
    37  )
    38  
    39  // ConformanceData describes the structure of the conformance.yaml file
    40  type ConformanceData struct {
    41  	// A URL to the line of code in the kube src repo for the test. Omitted from the YAML to avoid exposing line number.
    42  	URL string `yaml:"-"`
    43  	// Extracted from the "Testname:" comment before the test
    44  	TestName string
    45  	// CodeName is taken from the actual ginkgo descriptions, e.g. `[sig-apps] Foo should bar [Conformance]`
    46  	CodeName string
    47  	// Extracted from the "Description:" comment before the test
    48  	Description string
    49  	// Version when this test is added or modified ex: v1.12, v1.13
    50  	Release string
    51  	// File is the filename where the test is defined. We intentionally don't save the line here to avoid meaningless changes.
    52  	File string
    53  }
    54  
    55  var (
    56  	baseURL = flag.String("url", "https://github.com/kubernetes/kubernetes/tree/master/", "location of the current source")
    57  	k8sPath = flag.String("source", "", "location of the current source on the current machine")
    58  	confDoc = flag.Bool("docs", false, "write a conformance document")
    59  	version = flag.String("version", "v1.9", "version of this conformance document")
    60  
    61  	// If a test name contains any of these tags, it is ineligible for promotion to conformance
    62  	regexIneligibleTags = regexp.MustCompile(`\[(Alpha|Feature:[^\]]+|Flaky)\]`)
    63  
    64  	// Conformance comments should be within this number of lines to the call itself.
    65  	// Allowing for more than one in case a spare comment or two is below it.
    66  	conformanceCommentsLineWindow = 5
    67  
    68  	seenLines map[string]struct{}
    69  )
    70  
    71  type frame struct {
    72  	// File and Line are the file name and line number of the
    73  	// location in this frame. For non-leaf frames, this will be
    74  	// the location of a call. These may be the empty string and
    75  	// zero, respectively, if not known.
    76  	File string
    77  	Line int
    78  }
    79  
    80  func main() {
    81  	flag.Parse()
    82  
    83  	if len(flag.Args()) < 1 {
    84  		log.Fatalln("Requires the name of the test details file as first and only argument.")
    85  	}
    86  	testDetailsFile := flag.Args()[0]
    87  	f, err := os.Open(testDetailsFile)
    88  	if err != nil {
    89  		log.Fatalf("Failed to open file %v: %v", testDetailsFile, err)
    90  	}
    91  	defer f.Close()
    92  
    93  	seenLines = map[string]struct{}{}
    94  	dec := json.NewDecoder(f)
    95  	testInfos := []*ConformanceData{}
    96  	for {
    97  		var spec *types.SpecReport
    98  		if err := dec.Decode(&spec); err == io.EOF {
    99  			break
   100  		} else if err != nil {
   101  			log.Fatal(err)
   102  		}
   103  
   104  		if isConformance(spec) {
   105  			testInfo := getTestInfo(spec)
   106  			if testInfo != nil {
   107  				testInfos = append(testInfos, testInfo)
   108  				if err := validateTestName(testInfo.CodeName); err != nil {
   109  					log.Fatal(err)
   110  				}
   111  			}
   112  		}
   113  	}
   114  
   115  	sort.Slice(testInfos, func(i, j int) bool { return testInfos[i].CodeName < testInfos[j].CodeName })
   116  	saveAllTestInfo(testInfos)
   117  }
   118  
   119  func isConformance(spec *types.SpecReport) bool {
   120  	return strings.Contains(getTestName(spec), "[Conformance]")
   121  }
   122  
   123  func getTestInfo(spec *types.SpecReport) *ConformanceData {
   124  	var c *ConformanceData
   125  	var err error
   126  	// The key to this working is that we don't need to parse every file or walk
   127  	// every types.CodeLocation. The LeafNodeLocation is going to be file:line which
   128  	// attached to the comment that we want.
   129  	leafNodeLocation := spec.LeafNodeLocation
   130  	frame := frame{
   131  		File: leafNodeLocation.FileName,
   132  		Line: leafNodeLocation.LineNumber,
   133  	}
   134  	c, err = getConformanceData(frame)
   135  	if err != nil {
   136  		log.Printf("Error looking for conformance data: %v", err)
   137  	}
   138  	if c == nil {
   139  		log.Printf("Did not find test info for spec: %#v\n", getTestName(spec))
   140  		return nil
   141  	}
   142  	c.CodeName = getTestName(spec)
   143  	return c
   144  }
   145  
   146  func getTestName(spec *types.SpecReport) string {
   147  	return strings.Join(spec.ContainerHierarchyTexts[0:], " ") + " " + spec.LeafNodeText
   148  }
   149  
   150  func saveAllTestInfo(dataSet []*ConformanceData) {
   151  	if *confDoc {
   152  		// Note: this assumes that you're running from the root of the kube src repo
   153  		templ, err := template.ParseFiles("./test/conformance/cf_header.md")
   154  		if err != nil {
   155  			fmt.Printf("Error reading the Header file information: %s\n\n", err)
   156  		}
   157  		data := struct {
   158  			Version string
   159  		}{
   160  			Version: *version,
   161  		}
   162  		templ.Execute(os.Stdout, data)
   163  
   164  		for _, data := range dataSet {
   165  			fmt.Printf("## [%s](%s)\n\n", data.TestName, data.URL)
   166  			fmt.Printf("- Added to conformance in release %s\n", data.Release)
   167  			fmt.Printf("- Defined in code as: %s\n\n", data.CodeName)
   168  			fmt.Printf("%s\n\n", data.Description)
   169  		}
   170  		return
   171  	}
   172  
   173  	// Serialize the list as a whole. Generally meant to end up as conformance.txt which tracks the set of tests.
   174  	b, err := yaml.Marshal(dataSet)
   175  	if err != nil {
   176  		log.Printf("Error marshalling data into YAML: %v", err)
   177  	}
   178  	fmt.Println(string(b))
   179  }
   180  
   181  func getConformanceData(targetFrame frame) (*ConformanceData, error) {
   182  	// filenames are in one of two special GOPATHs depending on if they were
   183  	// built dockerized or with the host go
   184  	// we want to trim this prefix to produce portable relative paths
   185  	k8sSRC := *k8sPath + "/_output/local/go/src/k8s.io/kubernetes/"
   186  	trimmedFile := strings.TrimPrefix(targetFrame.File, k8sSRC)
   187  	trimmedFile = strings.TrimPrefix(trimmedFile, "/go/src/k8s.io/kubernetes/_output/dockerized/go/src/k8s.io/kubernetes/")
   188  	targetFrame.File = trimmedFile
   189  
   190  	freader, err := os.Open(targetFrame.File)
   191  	if err != nil {
   192  		return nil, err
   193  	}
   194  	defer freader.Close()
   195  
   196  	cd, err := scanFileForFrame(targetFrame.File, freader, targetFrame)
   197  	if err != nil {
   198  		return nil, err
   199  	}
   200  	if cd != nil {
   201  		return cd, nil
   202  	}
   203  
   204  	return nil, nil
   205  }
   206  
   207  // scanFileForFrame will scan the target and look for a conformance comment attached to the function
   208  // described by the target frame. If the comment can't be found then nil, nil is returned.
   209  func scanFileForFrame(filename string, src interface{}, targetFrame frame) (*ConformanceData, error) {
   210  	fset := token.NewFileSet() // positions are relative to fset
   211  	f, err := parser.ParseFile(fset, filename, src, parser.ParseComments)
   212  	if err != nil {
   213  		return nil, err
   214  	}
   215  
   216  	cmap := ast.NewCommentMap(fset, f, f.Comments)
   217  	for _, cs := range cmap {
   218  		for _, c := range cs {
   219  			if cd := tryCommentGroupAndFrame(fset, c, targetFrame); cd != nil {
   220  				return cd, nil
   221  			}
   222  		}
   223  	}
   224  	return nil, nil
   225  }
   226  
   227  func validateTestName(s string) error {
   228  	matches := regexIneligibleTags.FindAllString(s, -1)
   229  	if matches != nil {
   230  		return fmt.Errorf("'%s' cannot have invalid tags %v", s, strings.Join(matches, ","))
   231  	}
   232  	return nil
   233  }
   234  
   235  func tryCommentGroupAndFrame(fset *token.FileSet, cg *ast.CommentGroup, f frame) *ConformanceData {
   236  	if !shouldProcessCommentGroup(fset, cg, f) {
   237  		return nil
   238  	}
   239  
   240  	// Each file/line will either be some helper function (not a conformance comment) or apply to just a single test. Don't revisit.
   241  	if seenLines != nil {
   242  		seenLines[fmt.Sprintf("%v:%v", f.File, f.Line)] = struct{}{}
   243  	}
   244  	cd := commentToConformanceData(cg.Text())
   245  	if cd == nil {
   246  		return nil
   247  	}
   248  
   249  	cd.URL = fmt.Sprintf("%s%s#L%d", *baseURL, f.File, f.Line)
   250  	cd.File = f.File
   251  	return cd
   252  }
   253  
   254  func shouldProcessCommentGroup(fset *token.FileSet, cg *ast.CommentGroup, f frame) bool {
   255  	lineDiff := f.Line - fset.Position(cg.End()).Line
   256  	return lineDiff > 0 && lineDiff <= conformanceCommentsLineWindow
   257  }
   258  
   259  func commentToConformanceData(comment string) *ConformanceData {
   260  	lines := strings.Split(comment, "\n")
   261  	descLines := []string{}
   262  	cd := &ConformanceData{}
   263  	var curLine string
   264  	for _, line := range lines {
   265  		line = strings.TrimSpace(line)
   266  		if len(line) == 0 {
   267  			continue
   268  		}
   269  		if sline := regexp.MustCompile("^Testname\\s*:\\s*").Split(line, -1); len(sline) == 2 {
   270  			curLine = "Testname"
   271  			cd.TestName = sline[1]
   272  			continue
   273  		}
   274  		if sline := regexp.MustCompile("^Release\\s*:\\s*").Split(line, -1); len(sline) == 2 {
   275  			curLine = "Release"
   276  			cd.Release = sline[1]
   277  			continue
   278  		}
   279  		if sline := regexp.MustCompile("^Description\\s*:\\s*").Split(line, -1); len(sline) == 2 {
   280  			curLine = "Description"
   281  			descLines = append(descLines, sline[1])
   282  			continue
   283  		}
   284  
   285  		// Line has no header
   286  		if curLine == "Description" {
   287  			descLines = append(descLines, line)
   288  		}
   289  	}
   290  	if cd.TestName == "" {
   291  		return nil
   292  	}
   293  
   294  	cd.Description = strings.Join(descLines, " ")
   295  	return cd
   296  }