feat(schema): add schema command to dump JSON Schema for zot config (#3905)

Fixes https://github.com/project-zot/zot/issues/3882

Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>
This commit is contained in:
Ramkumar Chinchani
2026-03-28 08:28:35 -07:00
committed by GitHub
parent 2fec21c839
commit 705939aed3
8 changed files with 632 additions and 12 deletions
+2
View File
@@ -234,6 +234,8 @@ func NewServerRootCmd() *cobra.Command {
rootCmd.AddCommand(newVerifyCmd(conf))
// "scrub"
rootCmd.AddCommand(newScrubCmd(conf))
// "schema"
rootCmd.AddCommand(newSchemaCmd())
// "verify-feature"
rootCmd.AddCommand(newVerifyFeatureCmd(conf))
// "version"
+133
View File
@@ -1,6 +1,7 @@
package server_test
import (
"bytes"
"encoding/json"
"fmt"
"os"
@@ -86,6 +87,138 @@ func TestServerUsage(t *testing.T) {
})
}
func TestSchema(t *testing.T) {
Convey("Test schema command", t, func(c C) {
cmd := cli.NewServerRootCmd()
buf := bytes.NewBuffer(nil)
cmd.SetArgs([]string{"schema"})
cmd.SetOut(buf)
err := cmd.Execute()
So(err, ShouldBeNil)
var schemaDoc map[string]any
err = json.Unmarshal(buf.Bytes(), &schemaDoc)
So(err, ShouldBeNil)
So(schemaDoc["$schema"], ShouldEqual, "http://json-schema.org/draft-07/schema#")
So(schemaDoc["title"], ShouldEqual, "zot config schema")
defs, ok := schemaDoc["definitions"].(map[string]any)
So(ok, ShouldBeTrue)
So(defs, ShouldContainKey, "zotregistry.dev~1zot~1v2~1pkg~1api~1config.Config")
So(defs, ShouldContainKey, "zotregistry.dev~1zot~1v2~1pkg~1extensions~1config.ExtensionConfig")
rootRef, ok := schemaDoc["$ref"].(string)
So(ok, ShouldBeTrue)
So(rootRef, ShouldEqual, "#/definitions/zotregistry.dev~01zot~01v2~01pkg~01api~01config.Config")
configSchema, ok := mustResolveSchemaRef(rootRef, defs)
So(ok, ShouldBeTrue)
configProperties, ok := configSchema["properties"].(map[string]any)
So(ok, ShouldBeTrue)
So(configProperties, ShouldContainKey, "distSpecVersion")
So(configProperties, ShouldContainKey, "storage")
So(configProperties, ShouldContainKey, "http")
So(configProperties, ShouldContainKey, "extensions")
So(configProperties, ShouldNotContainKey, "hTTP")
storageSchema, ok := mustResolvePropertySchema(configProperties, "storage", defs)
So(ok, ShouldBeTrue)
storageProperties, ok := storageSchema["properties"].(map[string]any)
So(ok, ShouldBeTrue)
So(storageProperties, ShouldContainKey, "rootDirectory")
So(storageProperties, ShouldContainKey, "gcDelay")
So(storageProperties, ShouldContainKey, "gcInterval")
So(storageProperties, ShouldContainKey, "subPaths")
So(storageProperties, ShouldNotContainKey, "gcMaxSchedulerDelay")
So(storageProperties, ShouldNotContainKey, "gCDelay")
So(storageProperties, ShouldNotContainKey, "gCInterval")
httpSchema, ok := mustResolvePropertySchema(configProperties, "http", defs)
So(ok, ShouldBeTrue)
httpProperties, ok := httpSchema["properties"].(map[string]any)
So(ok, ShouldBeTrue)
So(httpProperties, ShouldContainKey, "address")
So(httpProperties, ShouldContainKey, "port")
So(httpProperties, ShouldContainKey, "accessControl")
extensionsSchema, ok := mustResolvePropertySchema(configProperties, "extensions", defs)
So(ok, ShouldBeTrue)
extensionsProperties, ok := extensionsSchema["properties"].(map[string]any)
So(ok, ShouldBeTrue)
So(extensionsProperties, ShouldContainKey, "search")
So(extensionsProperties, ShouldContainKey, "sync")
So(extensionsProperties, ShouldContainKey, "metrics")
syncSchema, ok := mustResolvePropertySchema(extensionsProperties, "sync", defs)
So(ok, ShouldBeTrue)
syncProperties, ok := syncSchema["properties"].(map[string]any)
So(ok, ShouldBeTrue)
So(syncProperties, ShouldContainKey, "registries")
registriesSchema, ok := mustResolvePropertySchema(syncProperties, "registries", defs)
So(ok, ShouldBeTrue)
registriesItemSchema, ok := registriesSchema["items"].(map[string]any)
So(ok, ShouldBeTrue)
registrySchema, ok := mustResolveSchema(registriesItemSchema, defs)
So(ok, ShouldBeTrue)
registryProperties, ok := registrySchema["properties"].(map[string]any)
So(ok, ShouldBeTrue)
So(registryProperties, ShouldNotContainKey, "responseHeaderTimeout")
})
}
func mustResolvePropertySchema(properties map[string]any, name string, defs map[string]any) (map[string]any, bool) {
schema, ok := properties[name].(map[string]any)
if !ok {
return nil, false
}
return mustResolveSchema(schema, defs)
}
func mustResolveSchema(schema map[string]any, defs map[string]any) (map[string]any, bool) {
if schema == nil {
return nil, false
}
if ref, ok := schema["$ref"].(string); ok {
return mustResolveSchemaRef(ref, defs)
}
if anyOf, ok := schema["anyOf"].([]any); ok {
for _, option := range anyOf {
optionSchema, ok := option.(map[string]any)
if !ok {
continue
}
if optionType, ok := optionSchema["type"].(string); ok && optionType == "null" {
continue
}
return mustResolveSchema(optionSchema, defs)
}
}
return schema, true
}
func mustResolveSchemaRef(ref string, defs map[string]any) (map[string]any, bool) {
defKey := strings.TrimPrefix(ref, "#/definitions/")
// Decode one JSON Pointer token level to the stored definition key.
defKey = strings.NewReplacer("~1", "/", "~0", "~").Replace(defKey)
schema, ok := defs[defKey].(map[string]any)
if !ok {
return nil, false
}
return schema, true
}
func TestServe(t *testing.T) {
oldArgs := os.Args
+385
View File
@@ -0,0 +1,385 @@
package server
import (
"encoding/json"
"fmt"
"maps"
"reflect"
"slices"
"sort"
"strings"
"github.com/santhosh-tekuri/jsonschema/v6"
"github.com/spf13/cobra"
"zotregistry.dev/zot/v2/pkg/api/config"
)
func newSchemaCmd() *cobra.Command {
schemaCmd := &cobra.Command{
Use: "schema",
Short: "`schema` dumps JSON Schema for zot config",
Long: "`schema` dumps JSON Schema for zot config",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
schemaDoc := buildConfigSchemaDocument()
schemaJSON, err := json.MarshalIndent(schemaDoc, "", " ")
if err != nil {
return err
}
compiler := jsonschema.NewCompiler()
compiler.DefaultDraft(jsonschema.Draft7)
if err := compiler.AddResource("zot://config-schema.json", schemaDoc); err != nil {
return err
}
if _, err := compiler.Compile("zot://config-schema.json"); err != nil {
return fmt.Errorf("generated schema is invalid: %w", err)
}
if _, err := cmd.OutOrStdout().Write(schemaJSON); err != nil {
return err
}
_, err = cmd.OutOrStdout().Write([]byte("\n"))
return err
},
}
return schemaCmd
}
func buildConfigSchemaDocument() map[string]any {
gen := newSchemaGenerator()
configSchema := gen.schemaForType(reflect.TypeFor[config.Config]())
doc := map[string]any{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "zot config schema",
"$ref": configSchema["$ref"],
"definitions": gen.defs,
}
return doc
}
type schemaGenerator struct {
defs map[string]any
}
func newSchemaGenerator() *schemaGenerator {
return &schemaGenerator{defs: map[string]any{}}
}
func (g *schemaGenerator) schemaForType(reflectType reflect.Type) map[string]any {
if reflectType.Kind() == reflect.Pointer {
return nullableSchema(g.schemaForType(derefPointerType(reflectType)))
}
if reflectType.PkgPath() == "time" && reflectType.Name() == "Duration" {
return map[string]any{"type": "string"}
}
switch reflectType.Kind() {
case reflect.Bool:
return map[string]any{"type": "boolean"}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return map[string]any{"type": "integer"}
case reflect.Float32, reflect.Float64:
return map[string]any{"type": "number"}
case reflect.String:
return map[string]any{"type": "string"}
case reflect.Slice, reflect.Array:
return map[string]any{
"type": "array",
"items": g.schemaForType(reflectType.Elem()),
}
case reflect.Map:
return map[string]any{
"type": "object",
"additionalProperties": g.schemaForType(reflectType.Elem()),
}
case reflect.Interface:
return map[string]any{}
case reflect.Struct:
return g.schemaForStruct(reflectType)
default:
return map[string]any{}
}
}
func (g *schemaGenerator) schemaForStruct(reflectType reflect.Type) map[string]any {
defName := schemaDefName(reflectType)
if defName != "." {
if _, ok := g.defs[defName]; ok {
return map[string]any{"$ref": "#/definitions/" + schemaDefRefToken(defName)}
}
g.defs[defName] = map[string]any{}
}
properties := map[string]any{}
for i := range reflectType.NumField() {
field := reflectType.Field(i)
if !field.IsExported() {
continue
}
fieldName, squash, skip := schemaFieldName(field)
if skip {
continue
}
fieldSchema := g.schemaForType(field.Type)
if squash {
if ref, ok := fieldSchema["$ref"].(string); ok {
defKey := schemaDefFromRefToken(strings.TrimPrefix(ref, "#/definitions/"))
if def, ok := g.defs[defKey].(map[string]any); ok {
mergeProperties(properties, def)
}
} else {
mergeProperties(properties, fieldSchema)
}
continue
}
properties[fieldName] = fieldSchema
for _, alias := range schemaFieldAliases(fieldName) {
if _, exists := properties[alias]; !exists {
properties[alias] = fieldSchema
}
}
}
schema := map[string]any{
"type": "object",
"properties": properties,
"additionalProperties": false,
}
if defName == "." {
return schema
}
g.defs[defName] = schema
return map[string]any{"$ref": "#/definitions/" + schemaDefRefToken(defName)}
}
func schemaFieldName(field reflect.StructField) (string, bool, bool) {
jsonName, _, jsonSkip := parseStructTag(field.Tag.Get("json"))
if jsonSkip {
return "", false, true
}
mapstructureName, mapstructureFlags, mapstructureSkip := parseStructTag(field.Tag.Get("mapstructure"))
if mapstructureSkip {
return "", false, true
}
yamlName, yamlFlags, yamlSkip := parseStructTag(field.Tag.Get("yaml"))
if yamlSkip {
return "", false, true
}
if hasFlag(mapstructureFlags, "squash") || hasFlag(yamlFlags, "inline") {
return "", true, false
}
if jsonName != "" {
return jsonName, false, false
}
if mapstructureName != "" {
return mapstructureName, false, false
}
if yamlName != "" {
return yamlName, false, false
}
if field.Anonymous {
return "", true, false
}
return lowerCamelCase(field.Name), false, false
}
func parseStructTag(tag string) (string, []string, bool) {
if tag == "" {
return "", nil, false
}
parts := strings.Split(tag, ",")
flags := []string(nil)
if parts[0] == "-" {
return "", nil, true
}
if len(parts) > 1 {
flags = parts[1:]
}
return parts[0], flags, false
}
func hasFlag(flags []string, target string) bool {
return slices.Contains(flags, target)
}
func mergeProperties(dst map[string]any, schema map[string]any) {
props, ok := schema["properties"].(map[string]any)
if !ok {
return
}
keys := make([]string, 0, len(props))
for k := range props {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
dst[key] = props[key]
}
}
func lowerCamelCase(input string) string {
if input == "" {
return ""
}
upperPrefixLen := 0
for upperPrefixLen < len(input) && input[upperPrefixLen] >= 'A' && input[upperPrefixLen] <= 'Z' {
upperPrefixLen++
}
if upperPrefixLen == 0 {
return input
}
if upperPrefixLen == len(input) {
return strings.ToLower(input)
}
if upperPrefixLen == 1 {
return strings.ToLower(input[:1]) + input[1:]
}
return strings.ToLower(input[:upperPrefixLen-1]) + input[upperPrefixLen-1:]
}
func schemaFieldAliases(fieldName string) []string {
aliases := []string{}
lower := strings.ToLower(fieldName)
if lower != fieldName {
aliases = append(aliases, lower)
}
collapsed := collapseUppercaseRuns(fieldName)
if collapsed != fieldName && collapsed != lower {
aliases = append(aliases, collapsed)
}
return aliases
}
func collapseUppercaseRuns(input string) string {
if input == "" {
return ""
}
builder := strings.Builder{}
builder.Grow(len(input))
for i := 0; i < len(input); {
uppercaseEnd := i
for uppercaseEnd < len(input) && input[uppercaseEnd] >= 'A' && input[uppercaseEnd] <= 'Z' {
uppercaseEnd++
}
if uppercaseEnd-i >= 2 {
builder.WriteByte(input[i])
builder.WriteString(strings.ToLower(input[i+1 : uppercaseEnd]))
i = uppercaseEnd
continue
}
builder.WriteByte(input[i])
i++
}
return builder.String()
}
func schemaDefName(reflectType reflect.Type) string {
if reflectType.Name() == "" {
return "."
}
name := reflectType.PkgPath() + "." + reflectType.Name()
// Keep definition keys safe/collision-free using RFC 6901 escaping.
// "~" becomes "~0" and "/" becomes "~1".
replacer := strings.NewReplacer("~", "~0", "/", "~1")
return replacer.Replace(name)
}
func schemaDefRefToken(defName string) string {
// $ref uses JSON Pointer tokens, which decode "~1"->"/" and "~0"->"~".
// Since defName is already RFC 6901-escaped for storage, escape it once more for pointer usage.
replacer := strings.NewReplacer("~", "~0", "/", "~1")
return replacer.Replace(defName)
}
func schemaDefFromRefToken(token string) string {
// Decode one JSON Pointer token level back to the stored definition key.
replacer := strings.NewReplacer("~1", "/", "~0", "~")
return replacer.Replace(token)
}
func derefPointerType(reflectType reflect.Type) reflect.Type {
for reflectType.Kind() == reflect.Pointer {
reflectType = reflectType.Elem()
}
return reflectType
}
func nullableSchema(schema map[string]any) map[string]any {
if schemaType, ok := schema["type"].(string); ok {
nullable := mapsClone(schema)
nullable["type"] = []any{schemaType, "null"}
return nullable
}
return map[string]any{
"anyOf": []any{
schema,
map[string]any{"type": "null"},
},
}
}
func mapsClone(src map[string]any) map[string]any {
dst := make(map[string]any, len(src))
maps.Copy(dst, src)
return dst
}
+90
View File
@@ -0,0 +1,90 @@
package server_test
import (
"bytes"
"encoding/json"
"testing"
"github.com/santhosh-tekuri/jsonschema/v6"
. "github.com/smartystreets/goconvey/convey"
cli "zotregistry.dev/zot/v2/pkg/cli/server"
)
func TestSchemaAllowsNullForPointerFields(t *testing.T) {
Convey("generated schema allows explicit null for pointer-backed config fields", t, func() {
cmd := cli.NewServerRootCmd()
buf := bytes.NewBuffer(nil)
cmd.SetArgs([]string{"schema"})
cmd.SetOut(buf)
err := cmd.Execute()
So(err, ShouldBeNil)
var schemaDoc map[string]any
err = json.Unmarshal(buf.Bytes(), &schemaDoc)
So(err, ShouldBeNil)
compiler := jsonschema.NewCompiler()
compiler.DefaultDraft(jsonschema.Draft7)
err = compiler.AddResource("zot://config-schema.json", schemaDoc)
So(err, ShouldBeNil)
compiledSchema, err := compiler.Compile("zot://config-schema.json")
So(err, ShouldBeNil)
configWithNullPointers := map[string]any{
"http": map[string]any{
"auth": map[string]any{
"secureSession": nil,
"mtls": nil,
},
},
"storage": map[string]any{
"retention": map[string]any{
"policies": []any{
map[string]any{
"deleteUntagged": nil,
"keepTags": []any{
map[string]any{
"pulledWithin": nil,
"pushedWithin": nil,
},
},
},
},
},
},
"extensions": map[string]any{
"sync": map[string]any{
"enable": nil,
"registries": []any{
map[string]any{
"tlsVerify": nil,
"retryDelay": nil,
"onlySigned": nil,
"syncLegacyCosignTags": nil,
},
},
},
},
"cluster": nil,
}
err = compiledSchema.Validate(configWithNullPointers)
So(err, ShouldBeNil)
err = compiledSchema.Validate(map[string]any{
"storage": map[string]any{
"rootDirectory": "/tmp/zot",
},
"http": map[string]any{
"address": "127.0.0.1",
"port": "8080",
},
})
So(err, ShouldBeNil)
})
}