mirror of
https://github.com/anchore/syft.git
synced 2026-10-30 22:12:28 +01:00
migrate json schema generation (#4270)
Signed-off-by: Alex Goodman <wagoodman@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
package sourcemetadata
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type CompletionTester struct {
|
||||
saw []any
|
||||
valid []any
|
||||
ignore []any
|
||||
}
|
||||
|
||||
func NewCompletionTester(t testing.TB, ignore ...any) *CompletionTester {
|
||||
t.Helper()
|
||||
tester := &CompletionTester{
|
||||
valid: AllTypes(),
|
||||
ignore: ignore,
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
t.Helper()
|
||||
tester.validate(t)
|
||||
})
|
||||
return tester
|
||||
}
|
||||
|
||||
func (tr *CompletionTester) Ignore(is ...any) {
|
||||
tr.ignore = append(tr.ignore, keepNonNil(is...)...)
|
||||
}
|
||||
|
||||
func (tr *CompletionTester) Expect(is ...any) {
|
||||
tr.valid = append(tr.valid, keepNonNil(is...)...)
|
||||
}
|
||||
|
||||
func (tr *CompletionTester) Tested(t testing.TB, m any) {
|
||||
t.Helper()
|
||||
|
||||
if m == nil {
|
||||
return
|
||||
}
|
||||
if len(tr.valid) == 0 {
|
||||
t.Fatal("no valid metadata types to test against")
|
||||
}
|
||||
ty := reflect.TypeOf(m)
|
||||
|
||||
for _, v := range tr.ignore {
|
||||
if reflect.TypeOf(v) == ty {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range tr.valid {
|
||||
if reflect.TypeOf(v) == ty {
|
||||
tr.saw = append(tr.saw, m)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("tested metadata type is not valid: %s", ty.Name())
|
||||
}
|
||||
|
||||
func (tr *CompletionTester) validate(t testing.TB) {
|
||||
t.Helper()
|
||||
|
||||
count := make(map[reflect.Type]int)
|
||||
for _, m := range tr.saw {
|
||||
count[reflect.TypeOf(m)]++
|
||||
}
|
||||
|
||||
validations:
|
||||
for _, v := range tr.valid {
|
||||
ty := reflect.TypeOf(v)
|
||||
|
||||
for _, ignore := range tr.ignore {
|
||||
if ty == reflect.TypeOf(ignore) {
|
||||
// skip ignored types
|
||||
continue validations
|
||||
}
|
||||
}
|
||||
|
||||
if c, exists := count[ty]; c == 0 || !exists {
|
||||
t.Errorf("metadata type %s is not covered by a test", ty.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func keepNonNil(is ...any) []any {
|
||||
var result []any
|
||||
for _, i := range is {
|
||||
if i != nil {
|
||||
result = append(result, i)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package sourcemetadata
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/scylladb/go-set/strset"
|
||||
)
|
||||
|
||||
var metadataExceptions = strset.New()
|
||||
|
||||
func DiscoverTypeNames() ([]string, error) {
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(root, "syft/source/*.go"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return findMetadataDefinitionNames(files...)
|
||||
}
|
||||
|
||||
func repoRoot() (string, error) {
|
||||
root, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to find repo root dir: %+v", err)
|
||||
}
|
||||
absRepoRoot, err := filepath.Abs(strings.TrimSpace(string(root)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unable to get abs path to repo root: %w", err)
|
||||
}
|
||||
return absRepoRoot, nil
|
||||
}
|
||||
|
||||
func findMetadataDefinitionNames(paths ...string) ([]string, error) {
|
||||
names := strset.New()
|
||||
usedNames := strset.New()
|
||||
for _, path := range paths {
|
||||
metadataDefinitions, usedTypeNames, err := findMetadataDefinitionNamesInFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// useful for debugging...
|
||||
// fmt.Println(path)
|
||||
// fmt.Println("Defs:", metadataDefinitions)
|
||||
// fmt.Println("Used Types:", usedTypeNames)
|
||||
// fmt.Println()
|
||||
|
||||
names.Add(metadataDefinitions...)
|
||||
usedNames.Add(usedTypeNames...)
|
||||
}
|
||||
|
||||
// any definition that is used within another struct should not be considered a top-level metadata definition
|
||||
names.Remove(usedNames.List()...)
|
||||
|
||||
strNames := names.List()
|
||||
sort.Strings(strNames)
|
||||
|
||||
// note: 3 is a point-in-time gut check. This number could be updated if new metadata definitions are added, but is not required.
|
||||
// it is really intended to catch any major issues with the generation process that would generate, say, 0 definitions.
|
||||
if len(strNames) < 3 {
|
||||
msg := fmt.Sprintf("not enough metadata definitions found (discovered %d)", len(strNames))
|
||||
return nil, fmt.Errorf("%v", msg)
|
||||
}
|
||||
|
||||
return strNames, nil
|
||||
}
|
||||
|
||||
func findMetadataDefinitionNamesInFile(path string) ([]string, []string, error) {
|
||||
// set up the parser
|
||||
fs := token.NewFileSet()
|
||||
f, err := parser.ParseFile(fs, path, nil, parser.ParseComments)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var metadataDefinitions []string
|
||||
var usedTypeNames []string
|
||||
for _, decl := range f.Decls {
|
||||
// check if the declaration is a type declaration
|
||||
spec, ok := decl.(*ast.GenDecl)
|
||||
if !ok || spec.Tok != token.TYPE {
|
||||
continue
|
||||
}
|
||||
|
||||
// loop over all types declared in the type declaration
|
||||
for _, typ := range spec.Specs {
|
||||
// check if the type is a struct type
|
||||
spec, ok := typ.(*ast.TypeSpec)
|
||||
if !ok || spec.Type == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
structType, ok := spec.Type.(*ast.StructType)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// check if the struct type ends with "Metadata"
|
||||
name := spec.Name.String()
|
||||
|
||||
// only look for exported types that end with "Metadata"
|
||||
if isMetadataTypeCandidate(name) {
|
||||
// print the full declaration of the struct type
|
||||
metadataDefinitions = append(metadataDefinitions, name)
|
||||
usedTypeNames = append(usedTypeNames, typeNamesUsedInStruct(structType)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return metadataDefinitions, usedTypeNames, nil
|
||||
}
|
||||
|
||||
func typeNamesUsedInStruct(structType *ast.StructType) []string {
|
||||
// recursively find all type names used in the struct type
|
||||
var names []string
|
||||
for i := range structType.Fields.List {
|
||||
// capture names of all of the types (not field names)
|
||||
ast.Inspect(structType.Fields.List[i].Type, func(n ast.Node) bool {
|
||||
ident, ok := n.(*ast.Ident)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
// add the type name to the list
|
||||
names = append(names, ident.Name)
|
||||
|
||||
// continue inspecting
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
func isMetadataTypeCandidate(name string) bool {
|
||||
return len(name) > 0 &&
|
||||
strings.HasSuffix(name, "Metadata") &&
|
||||
unicode.IsUpper(rune(name[0])) && // must be exported
|
||||
!metadataExceptions.Has(name)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/anchore/syft/internal/sourcemetadata"
|
||||
"github.com/dave/jennifer/jen"
|
||||
)
|
||||
|
||||
// This program is invoked from syft/internal and generates sourcemetadata/generated.go
|
||||
|
||||
const (
|
||||
srcImport = "github.com/anchore/syft/syft/source"
|
||||
path = "sourcemetadata/generated.go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
typeNames, err := sourcemetadata.DiscoverTypeNames()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("unable to get all metadata type names: %w", err))
|
||||
}
|
||||
|
||||
fmt.Printf("updating source metadata type list with %+v types\n", len(typeNames))
|
||||
|
||||
f := jen.NewFile("sourcemetadata")
|
||||
f.HeaderComment("DO NOT EDIT: generated by internal/sourcemetadata/generate/main.go")
|
||||
f.ImportName(srcImport, "source")
|
||||
f.Comment("AllTypes returns a list of all source metadata types that syft supports (that are represented in the source.Description.Metadata field).")
|
||||
|
||||
f.Func().Id("AllTypes").Params().Index().Any().BlockFunc(func(g *jen.Group) {
|
||||
g.ReturnFunc(func(g *jen.Group) {
|
||||
g.Index().Any().ValuesFunc(func(g *jen.Group) {
|
||||
for _, typeName := range typeNames {
|
||||
g.Qual(srcImport, typeName).Values()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
rendered := fmt.Sprintf("%#v", f)
|
||||
|
||||
fh, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("unable to open file: %w", err))
|
||||
}
|
||||
_, err = fh.WriteString(rendered)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("unable to write file: %w", err))
|
||||
}
|
||||
if err := fh.Close(); err != nil {
|
||||
panic(fmt.Errorf("unable to close file: %w", err))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// DO NOT EDIT: generated by internal/sourcemetadata/generate/main.go
|
||||
|
||||
package sourcemetadata
|
||||
|
||||
import "github.com/anchore/syft/syft/source"
|
||||
|
||||
// AllTypes returns a list of all source metadata types that syft supports (that are represented in the source.Description.Metadata field).
|
||||
func AllTypes() []any {
|
||||
return []any{source.DirectoryMetadata{}, source.FileMetadata{}, source.ImageMetadata{}, source.SnapMetadata{}}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package sourcemetadata
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/anchore/syft/syft/source"
|
||||
)
|
||||
|
||||
var jsonNameFromType = map[reflect.Type][]string{
|
||||
reflect.TypeOf(source.DirectoryMetadata{}): {"directory", "dir"},
|
||||
reflect.TypeOf(source.FileMetadata{}): {"file"},
|
||||
reflect.TypeOf(source.ImageMetadata{}): {"image"},
|
||||
reflect.TypeOf(source.SnapMetadata{}): {"snap"},
|
||||
}
|
||||
|
||||
func AllTypeNames() []string {
|
||||
names := make([]string, 0)
|
||||
for _, t := range AllTypes() {
|
||||
names = append(names, reflect.TypeOf(t).Name())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func JSONName(metadata any) string {
|
||||
if vs, exists := jsonNameFromType[reflect.TypeOf(metadata)]; exists {
|
||||
return vs[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func ReflectTypeFromJSONName(name string) reflect.Type {
|
||||
name = strings.ToLower(name)
|
||||
for t, vs := range jsonNameFromType {
|
||||
for _, v := range vs {
|
||||
if strings.ToLower(v) == name {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package sourcemetadata
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAllNames(t *testing.T) {
|
||||
// note: this is a form of completion testing relative to the current code base.
|
||||
|
||||
expected, err := DiscoverTypeNames()
|
||||
require.NoError(t, err)
|
||||
|
||||
actual := AllTypeNames()
|
||||
|
||||
// ensure that the codebase (from ast analysis) reflects the latest code generated state
|
||||
if !assert.ElementsMatch(t, expected, actual) {
|
||||
t.Errorf("metadata types not fully represented: \n%s", cmp.Diff(expected, actual))
|
||||
t.Log("did you add a new source.*Metadata type without updating the JSON schema?")
|
||||
t.Log("if so, you need to update the schema version and regenerate the JSON schema (make generate-json-schema)")
|
||||
}
|
||||
|
||||
for _, ty := range AllTypes() {
|
||||
assert.NotEmpty(t, JSONName(ty), "metadata type %q does not have a JSON name", reflect.TypeOf(ty).Name())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user