Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/packagecmd/internal/packages/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,7 @@ func buildPlatformValues() any {
"mode": "CertManager",
},
"ingressClass": "nginx",
"publicDomainTemplate": "%s.%s.%s.domain.io",
"publicDomainTemplate": "%s.%s.domain.io",
},
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,12 @@ func mergedProperties(s *spec.Schema) map[string]spec.Schema {
// the example is layered on top — this lets schema authors write a partial
// `x-example` and have unset fields filled from per-property defaults.
//
// For non-object schemas, the example is returned as a deep clone.
// For non-object schemas, the example is returned as a deep clone, shaped as a
// list when the schema declares an array.
func overlayExample(s *spec.Schema, example any) (any, error) {
exMap, ok := example.(map[string]any)
if !ok || !isObject(s) {
return deepCopyJSON(example), nil
return arrayShaped(s, deepCopyJSON(example)), nil
}

base, err := synthesizeObject(s)
Expand All @@ -244,6 +245,21 @@ func overlayExample(s *spec.Schema, example any) (any, error) {
return base, nil
}

// arrayShaped keeps the shape the schema declares. An array property is often
// illustrated with a single element rather than a one-element list; a chart that
// renders such a value with toYaml or ranges over it needs the list.
func arrayShaped(s *spec.Schema, v any) any {
if !s.Type.Contains(typeArray) {
return v
}

if _, isList := v.([]any); isList {
return v
}

return []any{v}
}

// firstExample picks a representative value out of an x-examples block.
// Three forms are recognized:
//
Expand Down
73 changes: 72 additions & 1 deletion internal/packagecmd/internal/verify/lint/doc/openapi.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ var openapiDoc = Linter{
Summary: "The OpenAPI schemas a package ships under openapi/",
Description: []string{
"Checks the schemas that describe a package's settings, rather than the values rendered from them — a rendered manifest is the concern of the templates linter.",
"Only openapi/settings.yaml is inspected, because it is the schema the UI builds a settings form from.",
"Only the top level of openapi/ is read, because that is where the runtime looks a package's schemas up. openapi/settings.yaml is the one every rule has something to say about, being the schema the UI builds a settings form from.",
},
Rules: []Rule{
{
Expand Down Expand Up @@ -59,6 +59,77 @@ var openapiDoc = Linter{
"A package without openapi/settings.yaml exposes no settings and reports nothing.",
},
},
{
ID: rules.EnumRuleID,
Impact: lint.Warn.Ptr(),
Summary: "Requires enum values to be CamelCase",
Description: []string{
"A setting that lists the values it accepts is declaring API constants, and the Kubernetes API conventions spell those in CamelCase with an initial capital: ClusterFirst, Pending, ClientIP.",
"Every schema in openapi/ is checked, and every enum in it, however deeply nested. Translations and -tests.yaml fixtures are skipped: they carry descriptions and values rather than a schema.",
"An acronym keeps all its letters capital, as in ClientIP or TCPDelay. Digits are allowed anywhere, and a dot is allowed inside a number, so Version2 and TLS1.3 are accepted while a space, hyphen or underscore is not.",
},
Reports: []string{
"an enum value starts with a lower-case letter",
"an enum value contains anything other than letters, digits and dots inside numbers",
"a schema under openapi/ cannot be read or is not valid YAML",
},
Example: Example{
Reported: []string{
"properties:",
" logLevel:",
" type: string",
" enum:",
" - debug # starts lower-case",
" - error-level # hyphen",
},
Accepted: []string{
"properties:",
" logLevel:",
" type: string",
" enum:",
" - Debug",
" - Error",
},
},
Fix: "Rename the enum values to CamelCase and map them onto whatever the application expects in the template.",
Notes: []string{
"The reported value is the pointer of the enum holding the value, so it maps straight onto the lines of the schema.",
"Values that are not strings are skipped, because a boolean or numeric constant carries no casing.",
"This rule advises rather than blocks. An application whose settings pass values straight through to an upstream configuration file cannot follow the convention without translating every value back in the template, which is why a finding here is a warning.",
},
},
{
ID: rules.BilingualRuleID,
Impact: lint.Error.Ptr(),
Summary: "Requires a Russian translation for every settings schema",
Description: []string{
"Settings are published in both languages. The translation is a second schema next to the first, carrying the same property tree with Russian descriptions and nothing else, named after the schema it translates with a doc-ru- prefix.",
"openapi/values.yaml is exempt. It describes the values computed for the templates rather than the settings a user writes, so it holds no user-facing descriptions to translate. A translation of it is accepted but never required.",
"The check runs both ways, so a translation whose schema is missing is reported too — which is how a misspelled doc-ru- name surfaces instead of silently translating nothing.",
},
Reports: []string{
"a schema under openapi/ other than values.yaml has no doc-ru- counterpart next to it",
"a doc-ru- file under openapi/ has no schema of that name next to it",
},
Example: Example{
Reported: []string{
"my-package/openapi/",
" settings.yaml",
" values.yaml # exempt",
},
Accepted: []string{
"my-package/openapi/",
" settings.yaml",
" doc-ru-settings.yaml",
" values.yaml",
},
},
Fix: "Add openapi/doc-ru-<name>.yaml next to every schema, holding the property tree of the schema with Russian descriptions.",
Notes: []string{
"A translation carries descriptions only. Types, defaults and enums stay in the schema itself, where the runtime validates against them.",
"A package without openapi/ ships no schemas and reports nothing.",
},
},
},
Notes: []string{
"The openapi linter is a hard schema contract: it has no .pkglint.yaml settings, and its rules report at their built-in severity.",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Package openapi validates the OpenAPI schemas a package ships under openapi/. Its
// rules encode hard schema contracts rather than preferences, so the linter carries no
// .pkglint.yaml settings and its rules always report at their built-in severity.
// Package openapi validates the OpenAPI schemas a package ships under openapi/. Only the
// top level of the directory is read, because that is where the runtime looks a package's
// schemas up. Its rules encode schema contracts rather than preferences, so the linter
// carries no .pkglint.yaml settings and its rules always report at their built-in severity.
package openapi

import (
Expand Down Expand Up @@ -41,4 +42,6 @@ type Linter struct {
// Lint executes the openapi rules against the configured package path.
func (l *Linter) Lint(ctx context.Context) {
rules.NewAdvancedRule(l.config.Path, l.collector).Check(ctx)
rules.NewEnumRule(l.config.Path, l.collector).Check(ctx)
rules.NewBilingualRule(l.config.Path, l.collector).Check(ctx)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ import (
const AdvancedRuleID = "advanced"

const (
// openAPIDir is the package subdirectory holding the OpenAPI schemas.
openAPIDir = "openapi"
// settingsFile is the OpenAPI schema describing user-configurable settings.
settingsFile = "settings.yaml"
// advancedKey is the vendor extension that marks a setting as advanced in the UI.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package rules

import (
"context"
"path/filepath"
"strings"

"github.com/deckhouse/deckhouse-cli/internal/packagecmd/internal/verify/lint/diag"
)

// Rule purpose: require a Russian translation next to every schema that documents
// user-facing settings.

// BilingualRuleID is the stable identifier used to reference this rule in configuration.
const BilingualRuleID = "bilingual"

// BilingualRule checks that the schemas under openapi/ and their translations come in pairs.
type BilingualRule struct {
collector *diag.Collector
path string
}

// NewBilingualRule constructs a BilingualRule scoped to a package directory. Which schemas
// it pairs up is fixed, so the rule resolves them from packageDir itself.
func NewBilingualRule(packageDir string, collector *diag.Collector) *BilingualRule {
return &BilingualRule{
path: packageDir,
collector: collector.With(diag.RuleID(BilingualRuleID)),
}
}

// Check reports a schema whose translation is missing and a translation whose schema is
// missing, the latter being how a misspelled translation surfaces. values.yaml is exempt:
// it describes the values computed for the templates rather than the settings a user
// writes, so it carries no user-facing descriptions to translate. So are the test
// fixtures, which hold values rather than a schema.
func (r *BilingualRule) Check(_ context.Context) {
names, err := schemaNames(r.path)
if err != nil {
r.collector.With(
diag.Path(openAPIDir),
diag.Value(err.Error())).
Error("cannot read the openapi directory")

return
}

present := make(map[string]struct{}, len(names))
for _, name := range names {
present[name] = struct{}{}
}

for _, name := range names {
if isTestFixture(name) {
continue
}

collector := r.collector.With(diag.Path(filepath.Join(openAPIDir, name)))

if isTranslation(name) {
translated := strings.TrimPrefix(name, docRuPrefix)
if _, ok := present[translated]; !ok {
collector.Error("translation has nothing to translate: %s is missing next to it", translated)
}

continue
}

if name == valuesFile {
continue
}

if _, ok := present[docRuPrefix+name]; !ok {
collector.Error("translation is missing: need to create %s next to it", docRuPrefix+name)
}
}

r.collector.Commit()
}
Loading
Loading