Skip to content
Open
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
3 changes: 3 additions & 0 deletions pkg/linters/module/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -431,6 +431,7 @@ Validates the optional `package.yaml` file in the module root.
- ✅ All non-empty version constraints must be parsed as-is by the semver library
- ✅ The new requirements schema requires `requirements.deckhouse.constraint >= 1.77.0`
- ✅ Old markers such as `!optional` are rejected when placed inside a new `constraint` field
- ✅ `subscribe.apis` entries must use `<group>/<version>/<Kind>` with an explicit API group and `Kind` starting with an uppercase letter followed by letters and digits

**New Requirements Schema Detection:**
The rule treats `package.yaml` as using the new requirements schema when any of these fields are present:
Expand Down Expand Up @@ -478,6 +479,8 @@ subscribe:
❌ package.yaml apiVersion is required
❌ Invalid package.yaml requirements.modules.conditional[0].constraint version constraint ">= 1.0.0 !optional"
❌ package.yaml requirements.deckhouse.constraint version range should start no lower than 1.77.0
❌ package.yaml subscribe.apis[0] must use "<group>/<version>/<Kind>" format with a non-empty API group
❌ package.yaml subscribe.apis[0] kind "pod" must start with an uppercase letter and contain only letters and digits
```

---
Expand Down
61 changes: 61 additions & 0 deletions pkg/linters/module/rules/package_yaml.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"

"github.com/Masterminds/semver/v3"
utilvalidation "k8s.io/apimachinery/pkg/util/validation"
"sigs.k8s.io/yaml"

"github.com/deckhouse/dmt/pkg"
Expand All @@ -35,6 +38,9 @@ const (
MinimalDeckhouseVersionForPackageRequirements = "1.77.0"
)

var subscribeAPIKindRegex = regexp.MustCompile(`^[A-Z][a-zA-Z0-9]*$`)
var subscribeAPIVersionRegex = regexp.MustCompile(`^v[0-9]+(?:(alpha|beta)[0-9]+)?$`)

// NewPackageYAMLRule creates a rule for validating package.yaml.
func NewPackageYAMLRule() *PackageYAMLRule {
return &PackageYAMLRule{
Expand Down Expand Up @@ -157,6 +163,7 @@ func checkModulePackageRequirements(modulePackage *ModulePackage, errorList *err
validatePackageMetadata(modulePackage, errorList)
validatePackageConstraints(modulePackage, errorList)
validatePackageDeckhouseRequirement(modulePackage, errorList)
validatePackageSubscribeAPIs(modulePackage, errorList)
}

// validatePackageMetadata validates required package.yaml metadata fields.
Expand Down Expand Up @@ -264,3 +271,57 @@ func validatePackageDeckhouseRequirement(modulePackage *ModulePackage, errorList
errorList.Errorf("package.yaml requirements.deckhouse.constraint version range should start no lower than %s (currently: %s)", MinimalDeckhouseVersionForPackageRequirements, minAllowed.String())
}
}

// validatePackageSubscribeAPIs validates subscribed API references in package.yaml.
func validatePackageSubscribeAPIs(modulePackage *ModulePackage, errorList *errors.LintRuleErrorsList) {
if modulePackage == nil || modulePackage.Subscribe == nil {
return
}

errorList = errorList.WithFilePath(PackageConfigFilename)

for idx, api := range modulePackage.Subscribe.APIs {
validatePackageSubscribeAPI(fmt.Sprintf("subscribe.apis[%d]", idx), api, errorList)
}
}

// validatePackageSubscribeAPI validates a single subscribe.apis entry.
func validatePackageSubscribeAPI(fieldPath, value string, errorList *errors.LintRuleErrorsList) {
parts := strings.Split(value, "/")
if len(parts) != 3 {
errorList.Errorf("package.yaml %s must use %q format with non-empty group, version, and Kind (got %q)", fieldPath, "<group>/<version>/<Kind>", value)
return
}

group, version, kind := parts[0], parts[1], parts[2]
if group == "" || version == "" || kind == "" {
errorList.Errorf("package.yaml %s must use %q format with non-empty group, version, and Kind (got %q)", fieldPath, "<group>/<version>/<Kind>", value)
return
}
Comment on lines +289 to +300
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


if !isValidSubscribeAPIGroup(group) {
errorList.Errorf("package.yaml %s has invalid Kubernetes API group %q", fieldPath, group)
return
}

if !isValidSubscribeAPIVersion(version) {
errorList.Errorf("package.yaml %s has invalid Kubernetes API version %q", fieldPath, version)
return
}

if !isValidSubscribeAPIKind(kind) {
errorList.Errorf("package.yaml %s kind %q must start with an uppercase letter and contain only letters and digits", fieldPath, kind)
}
Comment on lines +312 to +314
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

}

func isValidSubscribeAPIGroup(group string) bool {
return len(utilvalidation.IsDNS1123Subdomain(group)) == 0
}

func isValidSubscribeAPIVersion(version string) bool {
return subscribeAPIVersionRegex.MatchString(version)
}

func isValidSubscribeAPIKind(kind string) bool {
return subscribeAPIKindRegex.MatchString(kind)
}
272 changes: 272 additions & 0 deletions pkg/linters/module/rules/package_yaml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,223 @@ func TestValidatePackageDeckhouseRequirement(t *testing.T) {
}
}

func TestValidatePackageSubscribeAPIs(t *testing.T) {
tests := []struct {
name string
modulePackage *ModulePackage
expectedErrors []string
}{
{
name: "nil package",
modulePackage: nil,
expectedErrors: []string{},
},
{
name: "nil subscribe",
modulePackage: &ModulePackage{},
expectedErrors: []string{},
},
{
name: "empty apis",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{},
},
expectedErrors: []string{},
},
{
name: "valid single api",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"autoscaling.k8s.io/v1/VerticalPodAutoscaler"},
},
},
expectedErrors: []string{},
},
{
name: "valid multiple apis",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{
"autoscaling.k8s.io/v1/VerticalPodAutoscaler",
"deckhouse.io/v1alpha1/ModuleRelease",
"apiregistration.k8s.io/v1/APIService",
"example.io/v1/MyAPI",
"example.io/v1/ServiceDNS",
},
},
},
expectedErrors: []string{},
},
{
name: "missing group",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"v1/Pod"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] must use "<group>/<version>/<Kind>" format with non-empty group, version, and Kind`,
},
},
{
name: "lowercase kind",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"apps/v1/pod"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] kind "pod" must start with an uppercase letter and contain only letters and digits`,
},
},
{
name: "uppercase acronym-only kind is valid",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"apps/v1/POD"},
},
},
expectedErrors: []string{},
},
{
name: "uppercase group is invalid",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"Apps/v1/Pod"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] has invalid Kubernetes API group "Apps"`,
},
},
{
name: "space in group is invalid",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"bad group/v1/Pod"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] has invalid Kubernetes API group "bad group"`,
},
},
{
name: "uppercase version is invalid",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"apps/V1/Pod"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] has invalid Kubernetes API version "V1"`,
},
},
{
name: "missing kind",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"apps/v1/"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] must use "<group>/<version>/<Kind>" format with non-empty group, version, and Kind`,
},
},
{
name: "missing version",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"apps//Pod"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] must use "<group>/<version>/<Kind>" format with non-empty group, version, and Kind`,
},
},
{
name: "missing group with three parts",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"/v1/Pod"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] must use "<group>/<version>/<Kind>" format with non-empty group, version, and Kind`,
},
},
{
name: "malformed part count short",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"apps/v1"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] must use "<group>/<version>/<Kind>" format with non-empty group, version, and Kind`,
},
},
{
name: "malformed part count long",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"apps/v1/Pod/Extra"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] must use "<group>/<version>/<Kind>" format with non-empty group, version, and Kind`,
},
},
{
name: "empty string",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{""},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] must use "<group>/<version>/<Kind>" format with non-empty group, version, and Kind`,
},
},
{
name: "invalid kind characters lowercase dash",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"apps/v1/pod-name"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] kind "pod-name" must start with an uppercase letter and contain only letters and digits`,
},
},
{
name: "invalid kind characters uppercase dash",
modulePackage: &ModulePackage{
Subscribe: &PackageSubscribe{
APIs: []string{"apps/v1/Pod-Name"},
},
},
expectedErrors: []string{
`package.yaml subscribe.apis[0] kind "Pod-Name" must start with an uppercase letter and contain only letters and digits`,
},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
errorList := errors.NewLintRuleErrorsList()
validatePackageSubscribeAPIs(tt.modulePackage, errorList)

errs := errorList.GetErrors()
require.Len(t, errs, len(tt.expectedErrors))

for idx, expectedError := range tt.expectedErrors {
assert.Contains(t, errs[idx].Text, expectedError)
assert.Equal(t, PackageConfigFilename, errs[idx].FilePath)
}
})
}
}

func TestPackageYAMLRule(t *testing.T) {
tests := []struct {
name string
Expand Down Expand Up @@ -515,6 +732,11 @@ requirements:
modules:
- name: cloud-provider-gcp
constraint: ">= 1.5.0"
subscribe:
apis:
- autoscaling.k8s.io/v1/VerticalPodAutoscaler
- example.io/v1/MyAPI
- example.io/v1/ServiceDNS
`,
expectedErrors: []string{},
},
Expand Down Expand Up @@ -586,6 +808,56 @@ requirements:
`,
expectedErrors: []string{"Invalid package.yaml requirements.deckhouse.constraint version constraint \">= 1.77 !optional\""},
},
{
name: "subscribe api missing group",
packageContent: `apiVersion: v2
name: stronghold
subscribe:
apis:
- v1/Pod
`,
expectedErrors: []string{
`package.yaml subscribe.apis[0] must use "<group>/<version>/<Kind>" format with non-empty group, version, and Kind`,
},
},
{
name: "subscribe api lowercase kind",
packageContent: `apiVersion: v2
name: stronghold
subscribe:
apis:
- apps/v1/pod
`,
expectedErrors: []string{
`package.yaml subscribe.apis[0] kind "pod" must start with an uppercase letter and contain only letters and digits`,
},
},
{
name: "subscribe api uppercase acronym-only kind is valid",
packageContent: `apiVersion: v2
name: stronghold
subscribe:
apis:
- apps/v1/POD
`,
expectedErrors: []string{},
},
{
name: "mixed subscribe apis keep order",
packageContent: `apiVersion: v2
name: stronghold
subscribe:
apis:
- autoscaling.k8s.io/v1/VerticalPodAutoscaler
- apiregistration.k8s.io/v1/APIService
- v1/Pod
- apps/v1/pod
`,
expectedErrors: []string{
`package.yaml subscribe.apis[2] must use "<group>/<version>/<Kind>" format with non-empty group, version, and Kind`,
`package.yaml subscribe.apis[3] kind "pod" must start with an uppercase letter and contain only letters and digits`,
},
},
}

for _, tt := range tests {
Expand Down
Loading