From abda4709b8eb73cc4622ec51825972065cc4001f Mon Sep 17 00:00:00 2001 From: diyliv Date: Thu, 14 May 2026 12:41:15 +0300 Subject: [PATCH 1/7] requirements Signed-off-by: diyliv --- internal/module/module.go | 1 + pkg/config.go | 1 + pkg/config/global/global.go | 1 + pkg/linters/module/README.md | 89 ++- pkg/linters/module/module.go | 1 + pkg/linters/module/rules/package_yaml.go | 266 ++++++++ pkg/linters/module/rules/package_yaml_test.go | 610 ++++++++++++++++++ 7 files changed, 968 insertions(+), 1 deletion(-) create mode 100644 pkg/linters/module/rules/package_yaml.go create mode 100644 pkg/linters/module/rules/package_yaml_test.go diff --git a/internal/module/module.go b/internal/module/module.go index e34d3b9a..458eb534 100644 --- a/internal/module/module.go +++ b/internal/module/module.go @@ -317,6 +317,7 @@ func mapModuleRules(linterSettings *pkg.LintersSettings, configSettings *config. rules.HelmignoreRule.SetLevel(globalRules.HelmignoreRule.Impact, fallbackImpact) rules.LicenseRule.SetLevel(globalRules.LicenseRule.Impact, fallbackImpact) rules.RequarementsRule.SetLevel(globalRules.RequarementsRule.Impact, fallbackImpact) + rules.PackageYAMLRule.SetLevel(globalRules.PackageYAMLRule.Impact, fallbackImpact) rules.LegacyReleaseFileRule.SetLevel(globalRules.LegacyReleaseFileRule.Impact, fallbackImpact) } diff --git a/pkg/config.go b/pkg/config.go index b71376fa..b0a80588 100644 --- a/pkg/config.go +++ b/pkg/config.go @@ -220,6 +220,7 @@ type ModuleLinterRules struct { HelmignoreRule RuleConfig LicenseRule RuleConfig RequarementsRule RuleConfig + PackageYAMLRule RuleConfig LegacyReleaseFileRule RuleConfig } type OSSRuleSettings struct { diff --git a/pkg/config/global/global.go b/pkg/config/global/global.go index d4c76082..2f2583c4 100644 --- a/pkg/config/global/global.go +++ b/pkg/config/global/global.go @@ -108,6 +108,7 @@ type ModuleLinterRules struct { HelmignoreRule RuleConfig `mapstructure:"helmignore"` LicenseRule RuleConfig `mapstructure:"license"` RequarementsRule RuleConfig `mapstructure:"requarements"` + PackageYAMLRule RuleConfig `mapstructure:"package-yaml"` LegacyReleaseFileRule RuleConfig `mapstructure:"legacy-release-file"` } diff --git a/pkg/linters/module/README.md b/pkg/linters/module/README.md index 9a4f9131..f98021a1 100644 --- a/pkg/linters/module/README.md +++ b/pkg/linters/module/README.md @@ -8,7 +8,7 @@ The Module linter performs automated checks on Deckhouse modules to validate con ## Rules -The Module linter includes **7 validation rules**: +The Module linter includes **8 validation rules**: | Rule | Description | Configurable | |------|-------------|--------------| @@ -18,6 +18,7 @@ The Module linter includes **7 validation rules**: | [**helmignore**](#helmignore) | Validates `.helmignore` file presence and content | ✅ Yes | | [**license**](#license) | Validates license headers in source files | ✅ Yes | | [**requirements**](#requirements) | Validates version requirements for features | ❌ No | +| [**package-yaml**](#package-yaml) | Validates `package.yaml` metadata and new requirements schema | ✅ Yes | | [**legacy-release-file**](#legacy-release-file) | Checks for deprecated `release.yaml` file | ❌ No | --- @@ -417,6 +418,70 @@ requirements: --- +### Package YAML + +Validates the optional `package.yaml` file in the module root. + +**Purpose:** Ensures modules that use the new package requirements schema declare a compatible Deckhouse version and keep dependency constraints parseable as plain semantic version constraints. This prevents modules from publishing v2 package metadata that older Deckhouse versions cannot read. + +**Checks:** +- ✅ If `package.yaml` exists, it must be valid YAML +- ✅ `apiVersion` is required +- ✅ `name` is required +- ✅ 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 + +**New Requirements Schema Detection:** +The rule treats `package.yaml` as using the new requirements schema when any of these fields are present: +- `requirements.kubernetes.constraint` +- `requirements.modules.mandatory` +- `requirements.modules.conditional` +- `requirements.modules.anyOf` + +**Example:** +```yaml +# package.yaml +apiVersion: v2 +name: stronghold + +requirements: + kubernetes: + constraint: ">= 1.26" + deckhouse: + constraint: ">= 1.77.0" + modules: + mandatory: + - name: cloud-provider-yandex + constraint: ">= 1.5.0" + conditional: + - name: observability + constraint: ">= 1.0.0" + anyOf: + - description: "One of the following cloud providers must be installed" + modules: + - name: cloud-provider-gcp + constraint: ">= 1.5.0" + - name: cloud-provider-aws + constraint: ">= 2.0.0" + +subscribe: + apis: + - autoscaling.k8s.io/v1/VerticalPodAutoscaler + values: + - module: stronghold + value: .someValues.strField +``` + +**Error Examples:** +``` +❌ 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 +``` + +--- + ### Legacy release file Checks for the deprecated `release.yaml` file. @@ -459,6 +524,10 @@ linters-settings: helmignore: disable: false # Enable/disable .helmignore validation + + rules: + package-yaml: + impact: error # Override package.yaml validation level # License exclusions exclude-rules: @@ -534,6 +603,24 @@ requirements: deckhouse: ">= 1.68.0" ``` +### ❌ package.yaml Uses New Requirements Without Deckhouse 1.77 + +**Error:** `package.yaml requirements.deckhouse.constraint version range should start no lower than 1.77.0` + +**Solution:** Raise the package-level Deckhouse requirement: +```yaml +# package.yaml +apiVersion: v2 +name: my-module +requirements: + deckhouse: + constraint: ">= 1.77.0" + modules: + mandatory: + - name: dependency-module + constraint: ">= 1.0.0" +``` + ### ❌ Update Versions Not Sorted **Error:** `Update versions must be sorted` diff --git a/pkg/linters/module/module.go b/pkg/linters/module/module.go index 17ac7df9..b5cf0b09 100644 --- a/pkg/linters/module/module.go +++ b/pkg/linters/module/module.go @@ -55,6 +55,7 @@ func (l *Module) Run(m *module.Module) { rules.NewLicenseRule(l.cfg.ExcludeRules.License.Files.Get(), l.cfg.ExcludeRules.License.Directories.Get()). CheckFiles(m, errorList.WithMaxLevel(l.cfg.Rules.LicenseRule.GetLevel())) rules.NewRequirementsRule().CheckRequirements(m.GetPath(), errorList.WithMaxLevel(l.cfg.Rules.RequarementsRule.GetLevel())) + rules.NewPackageYAMLRule().CheckPackageYAML(m.GetPath(), errorList.WithMaxLevel(l.cfg.Rules.PackageYAMLRule.GetLevel())) rules.NewLegacyReleaseFileRule().CheckLegacyReleaseFile(m.GetPath(), errorList.WithMaxLevel(l.cfg.Rules.LegacyReleaseFileRule.GetLevel())) } diff --git a/pkg/linters/module/rules/package_yaml.go b/pkg/linters/module/rules/package_yaml.go new file mode 100644 index 00000000..1c29e632 --- /dev/null +++ b/pkg/linters/module/rules/package_yaml.go @@ -0,0 +1,266 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + stderrors "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Masterminds/semver/v3" + "sigs.k8s.io/yaml" + + "github.com/deckhouse/dmt/pkg" + "github.com/deckhouse/dmt/pkg/errors" +) + +const ( + PackageYAMLRuleName = "package-yaml" + PackageConfigFilename = "package.yaml" + MinimalDeckhouseVersionForPackageRequirements = "1.77.0" +) + +// NewPackageYAMLRule creates a rule for validating package.yaml. +func NewPackageYAMLRule() *PackageYAMLRule { + return &PackageYAMLRule{ + RuleMeta: pkg.RuleMeta{ + Name: PackageYAMLRuleName, + }, + } +} + +// PackageYAMLRule validates the module package.yaml file. +type PackageYAMLRule struct { + pkg.RuleMeta +} + +// ModulePackage describes package.yaml fields used by module lint rules. +type ModulePackage struct { + APIVersion string `json:"apiVersion,omitempty"` + Name string `json:"name,omitempty"` + Requirements *PackageRequirements `json:"requirements,omitempty"` + Subscribe *PackageSubscribe `json:"subscribe,omitempty"` +} + +// PackageRequirements describes package.yaml requirements. +type PackageRequirements struct { + Kubernetes PackageVersionRequirement `json:"kubernetes,omitempty"` + Deckhouse PackageVersionRequirement `json:"deckhouse,omitempty"` + Modules PackageModulesRequirements `json:"modules,omitempty"` +} + +// PackageVersionRequirement describes a version constraint requirement. +type PackageVersionRequirement struct { + Constraint string `json:"constraint,omitempty"` +} + +// PackageModulesRequirements describes package.yaml module dependency groups. +type PackageModulesRequirements struct { + Mandatory []PackageModuleRequirement `json:"mandatory,omitempty"` + Conditional []PackageModuleRequirement `json:"conditional,omitempty"` + AnyOf []PackageAnyOfRequirement `json:"anyOf,omitempty"` +} + +// PackageModuleRequirement describes a package.yaml module dependency. +type PackageModuleRequirement struct { + Name string `json:"name,omitempty"` + Constraint string `json:"constraint,omitempty"` +} + +// PackageAnyOfRequirement describes an anyOf module dependency group. +type PackageAnyOfRequirement struct { + Description string `json:"description,omitempty"` + Modules []PackageModuleRequirement `json:"modules,omitempty"` +} + +// PackageSubscribe describes package.yaml subscribe settings. +type PackageSubscribe struct { + APIs []string `json:"apis,omitempty"` + Values []PackageSubscribeValue `json:"values,omitempty"` +} + +// PackageSubscribeValue describes a subscribed module value. +type PackageSubscribeValue struct { + Module string `json:"module,omitempty"` + Value string `json:"value,omitempty"` +} + +// getModulePackage parses package.yaml and returns the subset of fields used by module rules. +func getModulePackage(modulePath string, errorList *errors.LintRuleErrorsList) (*ModulePackage, error) { + errorList = errorList.WithFilePath(PackageConfigFilename) + packageFilePath := filepath.Join(modulePath, PackageConfigFilename) + + _, err := os.Stat(packageFilePath) + + if stderrors.Is(err, os.ErrNotExist) { + return nil, nil + } + + if err != nil { + errorList.Errorf("Cannot stat file %q: %s", PackageConfigFilename, err) + + return nil, err + } + + yamlFile, err := os.ReadFile(packageFilePath) + if err != nil { + errorList.Errorf("Cannot read file %q: %s", PackageConfigFilename, err) + + return nil, err + } + + var yml ModulePackage + + err = yaml.Unmarshal(yamlFile, &yml) + if err != nil { + errorList.Errorf("Cannot parse file %q: %s", PackageConfigFilename, err) + + return nil, err + } + + return &yml, nil +} + +// CheckPackageYAML validates package.yaml in the module root. +func (r *PackageYAMLRule) CheckPackageYAML(modulePath string, errorList *errors.LintRuleErrorsList) { + errorList = errorList.WithRule(r.GetName()) + + modulePackage, err := getModulePackage(modulePath, errorList) + if err != nil { + return + } + + checkModulePackageRequirements(modulePackage, errorList) +} + +// checkModulePackageRequirements runs all package.yaml checks. +func checkModulePackageRequirements(modulePackage *ModulePackage, errorList *errors.LintRuleErrorsList) { + if modulePackage == nil { + return + } + + validatePackageMetadata(modulePackage, errorList) + validatePackageConstraints(modulePackage, errorList) + validatePackageDeckhouseRequirement(modulePackage, errorList) +} + +// validatePackageMetadata validates required package.yaml metadata fields. +func validatePackageMetadata(modulePackage *ModulePackage, errorList *errors.LintRuleErrorsList) { + if modulePackage == nil { + return + } + + errorList = errorList.WithFilePath(PackageConfigFilename) + if modulePackage.APIVersion == "" { + errorList.Error("package.yaml apiVersion is required") + } + + if modulePackage.Name == "" { + errorList.Error("package.yaml name is required") + } +} + +// validatePackageConstraints validates all package.yaml constraints as-is. +func validatePackageConstraints(modulePackage *ModulePackage, errorList *errors.LintRuleErrorsList) { + if modulePackage == nil || modulePackage.Requirements == nil { + return + } + + errorList = errorList.WithFilePath(PackageConfigFilename) + requirements := modulePackage.Requirements + + validatePackageConstraint("requirements.kubernetes.constraint", requirements.Kubernetes.Constraint, errorList) + validatePackageConstraint("requirements.deckhouse.constraint", requirements.Deckhouse.Constraint, errorList) + + for idx, module := range requirements.Modules.Mandatory { + validatePackageConstraint(fmt.Sprintf("requirements.modules.mandatory[%d].constraint", idx), module.Constraint, errorList) + } + + for idx, module := range requirements.Modules.Conditional { + validatePackageConstraint(fmt.Sprintf("requirements.modules.conditional[%d].constraint", idx), module.Constraint, errorList) + } + + for anyOfIdx, anyOf := range requirements.Modules.AnyOf { + for moduleIdx, module := range anyOf.Modules { + validatePackageConstraint(fmt.Sprintf("requirements.modules.anyOf[%d].modules[%d].constraint", anyOfIdx, moduleIdx), module.Constraint, errorList) + } + } +} + +// validatePackageConstraint validates a single package.yaml version constraint. +func validatePackageConstraint(fieldPath, constraint string, errorList *errors.LintRuleErrorsList) { + if constraint == "" { + return + } + + if _, err := semver.NewConstraint(constraint); err != nil { + errorList.Errorf("Invalid package.yaml %s version constraint %q: %s", fieldPath, constraint, err) + } +} + +// hasNewPackageRequirementsSchema checks if package.yaml uses the new requirements schema. +func hasNewPackageRequirementsSchema(modulePackage *ModulePackage) bool { + if modulePackage == nil || modulePackage.Requirements == nil { + return false + } + + requirements := modulePackage.Requirements + + return requirements.Kubernetes.Constraint != "" || + len(requirements.Modules.Mandatory) > 0 || + len(requirements.Modules.Conditional) > 0 || + len(requirements.Modules.AnyOf) > 0 +} + +// validatePackageDeckhouseRequirement validates the Deckhouse requirement for the new requirements schema. +func validatePackageDeckhouseRequirement(modulePackage *ModulePackage, errorList *errors.LintRuleErrorsList) { + if !hasNewPackageRequirementsSchema(modulePackage) { + return + } + + errorList = errorList.WithFilePath(PackageConfigFilename) + + deckhouseConstraint := modulePackage.Requirements.Deckhouse.Constraint + + if deckhouseConstraint == "" { + errorList.Errorf("package.yaml requirements.deckhouse.constraint is required when new requirements schema is used and must start no lower than %s", MinimalDeckhouseVersionForPackageRequirements) + return + } + + constraint, err := semver.NewConstraint(deckhouseConstraint) + if err != nil { + return + } + + minAllowed := findMinimalAllowedVersion(constraint) + + minimalVersion, err := semver.NewVersion(MinimalDeckhouseVersionForPackageRequirements) + if err != nil { + errorList.Errorf("invalid package.yaml minimum Deckhouse version format %s: %s", MinimalDeckhouseVersionForPackageRequirements, err) + return + } + + if minAllowed == nil || minAllowed.LessThan(minimalVersion) { + if minAllowed == nil { + errorList.Errorf("package.yaml requirements.deckhouse.constraint version range should start no lower than %s", MinimalDeckhouseVersionForPackageRequirements) + return + } + + errorList.Errorf("package.yaml requirements.deckhouse.constraint version range should start no lower than %s (currently: %s)", MinimalDeckhouseVersionForPackageRequirements, minAllowed.String()) + } +} diff --git a/pkg/linters/module/rules/package_yaml_test.go b/pkg/linters/module/rules/package_yaml_test.go new file mode 100644 index 00000000..37253110 --- /dev/null +++ b/pkg/linters/module/rules/package_yaml_test.go @@ -0,0 +1,610 @@ +/* +Copyright 2026 Flant JSC + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package rules + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/deckhouse/dmt/pkg/errors" +) + +func TestGetModulePackageMissingFile(t *testing.T) { + modulePath := t.TempDir() + errorList := errors.NewLintRuleErrorsList() + + result, err := getModulePackage(modulePath, errorList) + + require.NoError(t, err) + assert.Nil(t, result) + assert.False(t, errorList.ContainsErrors()) + assert.Empty(t, errorList.GetErrors()) +} + +func TestGetModulePackageValidFile(t *testing.T) { + modulePath := t.TempDir() + + content := `apiVersion: v2 +name: stronghold +requirements: + kubernetes: + constraint: ">= 1.26" + deckhouse: + constraint: ">= 1.77" + modules: + mandatory: + - name: stronghold + constraint: ">= 1.0.0" + conditional: + - name: observability + constraint: ">= 1.0.0" + anyOf: + - description: "One of the following cloud providers must be installed" + modules: + - name: cloud-provider-gcp + constraint: ">= 1.5.0" + - name: cloud-provider-aws + constraint: ">= 2.0.0" +subscribe: + apis: + - autoscaling.k8s.io/v1/VerticalPodAutoscaler + - deckhouse.io/v1alpha1/ModuleRelease + values: + - module: stronghold + value: .someValues.strField + - module: cloud-provider-yandex + value: .values.sliceField +` + + require.NoError(t, os.WriteFile(filepath.Join(modulePath, PackageConfigFilename), []byte(content), DefaultFilePerm)) + + errorList := errors.NewLintRuleErrorsList() + result, err := getModulePackage(modulePath, errorList) + + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, errorList.ContainsErrors()) + + assert.Equal(t, "v2", result.APIVersion) + assert.Equal(t, "stronghold", result.Name) + require.NotNil(t, result.Requirements) + assert.Equal(t, ">= 1.26", result.Requirements.Kubernetes.Constraint) + assert.Equal(t, ">= 1.77", result.Requirements.Deckhouse.Constraint) + + require.Len(t, result.Requirements.Modules.Mandatory, 1) + assert.Equal(t, "stronghold", result.Requirements.Modules.Mandatory[0].Name) + assert.Equal(t, ">= 1.0.0", result.Requirements.Modules.Mandatory[0].Constraint) + + require.Len(t, result.Requirements.Modules.Conditional, 1) + assert.Equal(t, "observability", result.Requirements.Modules.Conditional[0].Name) + assert.Equal(t, ">= 1.0.0", result.Requirements.Modules.Conditional[0].Constraint) + + require.Len(t, result.Requirements.Modules.AnyOf, 1) + assert.Equal(t, "One of the following cloud providers must be installed", result.Requirements.Modules.AnyOf[0].Description) + require.Len(t, result.Requirements.Modules.AnyOf[0].Modules, 2) + assert.Equal(t, "cloud-provider-gcp", result.Requirements.Modules.AnyOf[0].Modules[0].Name) + assert.Equal(t, ">= 1.5.0", result.Requirements.Modules.AnyOf[0].Modules[0].Constraint) + assert.Equal(t, "cloud-provider-aws", result.Requirements.Modules.AnyOf[0].Modules[1].Name) + assert.Equal(t, ">= 2.0.0", result.Requirements.Modules.AnyOf[0].Modules[1].Constraint) + + require.NotNil(t, result.Subscribe) + assert.Equal(t, []string{ + "autoscaling.k8s.io/v1/VerticalPodAutoscaler", + "deckhouse.io/v1alpha1/ModuleRelease", + }, result.Subscribe.APIs) + require.Len(t, result.Subscribe.Values, 2) + assert.Equal(t, "stronghold", result.Subscribe.Values[0].Module) + assert.Equal(t, ".someValues.strField", result.Subscribe.Values[0].Value) + assert.Equal(t, "cloud-provider-yandex", result.Subscribe.Values[1].Module) + assert.Equal(t, ".values.sliceField", result.Subscribe.Values[1].Value) +} + +func TestGetModulePackageInvalidYAML(t *testing.T) { + modulePath := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(modulePath, PackageConfigFilename), []byte(`invalid: yaml: content: [`), DefaultFilePerm)) + + errorList := errors.NewLintRuleErrorsList() + result, err := getModulePackage(modulePath, errorList) + + require.Error(t, err) + assert.Nil(t, result) + + errs := errorList.GetErrors() + require.Len(t, errs, 1) + assert.Contains(t, errs[0].Text, `Cannot parse file "package.yaml"`) + assert.Equal(t, PackageConfigFilename, errs[0].FilePath) +} + +func TestValidatePackageConstraintsValid(t *testing.T) { + modulePackage := &ModulePackage{ + Requirements: &PackageRequirements{ + Kubernetes: PackageVersionRequirement{Constraint: ">= 1.26"}, + Deckhouse: PackageVersionRequirement{Constraint: ">= 1.77"}, + Modules: PackageModulesRequirements{ + Mandatory: []PackageModuleRequirement{ + {Name: "stronghold", Constraint: ">= 1.0.0"}, + }, + Conditional: []PackageModuleRequirement{ + {Name: "observability", Constraint: "~1.2.0"}, + }, + AnyOf: []PackageAnyOfRequirement{ + { + Description: "cloud provider", + Modules: []PackageModuleRequirement{ + {Name: "cloud-provider-gcp", Constraint: ">= 1.5.0"}, + {Name: "cloud-provider-aws", Constraint: "< 2.0.0"}, + }, + }, + }, + }, + }, + } + + errorList := errors.NewLintRuleErrorsList() + validatePackageConstraints(modulePackage, errorList) + + assert.False(t, errorList.ContainsErrors()) + assert.Empty(t, errorList.GetErrors()) +} + +func TestValidatePackageMetadata(t *testing.T) { + tests := []struct { + name string + modulePackage *ModulePackage + expectedErrors []string + }{ + { + name: "nil package", + modulePackage: nil, + expectedErrors: []string{}, + }, + { + name: "valid metadata", + modulePackage: &ModulePackage{ + APIVersion: "v2", + Name: "stronghold", + }, + expectedErrors: []string{}, + }, + { + name: "missing apiVersion", + modulePackage: &ModulePackage{ + Name: "stronghold", + }, + expectedErrors: []string{"package.yaml apiVersion is required"}, + }, + { + name: "missing name", + modulePackage: &ModulePackage{ + APIVersion: "v2", + }, + expectedErrors: []string{"package.yaml name is required"}, + }, + { + name: "missing apiVersion and name", + modulePackage: &ModulePackage{}, + expectedErrors: []string{"package.yaml apiVersion is required", "package.yaml name is required"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errorList := errors.NewLintRuleErrorsList() + validatePackageMetadata(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 TestValidatePackageConstraintsInvalidAsIs(t *testing.T) { + modulePackage := &ModulePackage{ + Requirements: &PackageRequirements{ + Kubernetes: PackageVersionRequirement{Constraint: "invalid-version"}, + Deckhouse: PackageVersionRequirement{Constraint: ">= 1.77 !optional"}, + Modules: PackageModulesRequirements{ + Mandatory: []PackageModuleRequirement{ + {Name: "stronghold", Constraint: ">= 1.0.0 !optional"}, + }, + Conditional: []PackageModuleRequirement{ + {Name: "observability", Constraint: "wrong"}, + }, + AnyOf: []PackageAnyOfRequirement{ + { + Description: "cloud provider", + Modules: []PackageModuleRequirement{ + {Name: "cloud-provider-gcp", Constraint: ">= 1.5.0 !optional"}, + }, + }, + }, + }, + }, + } + + errorList := errors.NewLintRuleErrorsList() + validatePackageConstraints(modulePackage, errorList) + + errs := errorList.GetErrors() + require.Len(t, errs, 5) + + assert.Contains(t, errs[0].Text, "Invalid package.yaml requirements.kubernetes.constraint version constraint") + assert.Contains(t, errs[1].Text, "Invalid package.yaml requirements.deckhouse.constraint version constraint") + assert.Contains(t, errs[1].Text, `">= 1.77 !optional"`) + assert.Contains(t, errs[2].Text, "Invalid package.yaml requirements.modules.mandatory[0].constraint version constraint") + assert.Contains(t, errs[2].Text, `">= 1.0.0 !optional"`) + assert.Contains(t, errs[3].Text, "Invalid package.yaml requirements.modules.conditional[0].constraint version constraint") + assert.Contains(t, errs[4].Text, "Invalid package.yaml requirements.modules.anyOf[0].modules[0].constraint version constraint") + assert.Contains(t, errs[4].Text, `">= 1.5.0 !optional"`) + + for _, err := range errs { + assert.Equal(t, PackageConfigFilename, err.FilePath) + } +} + +func TestValidatePackageConstraintsSkipsEmptyAndMissingSections(t *testing.T) { + tests := []struct { + name string + modulePackage *ModulePackage + }{ + { + name: "nil package", + modulePackage: nil, + }, + { + name: "nil requirements", + modulePackage: &ModulePackage{}, + }, + { + name: "empty constraints", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Modules: PackageModulesRequirements{ + Mandatory: []PackageModuleRequirement{{Name: "stronghold"}}, + }, + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errorList := errors.NewLintRuleErrorsList() + validatePackageConstraints(tt.modulePackage, errorList) + + assert.False(t, errorList.ContainsErrors()) + assert.Empty(t, errorList.GetErrors()) + }) + } +} + +func TestHasNewPackageRequirementsSchema(t *testing.T) { + tests := []struct { + name string + modulePackage *ModulePackage + expected bool + }{ + { + name: "nil package", + modulePackage: nil, + expected: false, + }, + { + name: "nil requirements", + modulePackage: &ModulePackage{}, + expected: false, + }, + { + name: "empty requirements", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{}, + }, + expected: false, + }, + { + name: "only deckhouse constraint does not trigger new schema", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Deckhouse: PackageVersionRequirement{Constraint: ">= 1.77"}, + }, + }, + expected: false, + }, + { + name: "kubernetes constraint triggers new schema", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Kubernetes: PackageVersionRequirement{Constraint: ">= 1.26"}, + }, + }, + expected: true, + }, + { + name: "mandatory modules trigger new schema", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Modules: PackageModulesRequirements{ + Mandatory: []PackageModuleRequirement{{Name: "stronghold"}}, + }, + }, + }, + expected: true, + }, + { + name: "conditional modules trigger new schema", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Modules: PackageModulesRequirements{ + Conditional: []PackageModuleRequirement{{Name: "observability"}}, + }, + }, + }, + expected: true, + }, + { + name: "anyOf modules trigger new schema", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Modules: PackageModulesRequirements{ + AnyOf: []PackageAnyOfRequirement{{Description: "cloud provider"}}, + }, + }, + }, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, hasNewPackageRequirementsSchema(tt.modulePackage)) + }) + } +} + +func TestValidatePackageDeckhouseRequirement(t *testing.T) { + tests := []struct { + name string + modulePackage *ModulePackage + expectedErrors []string + }{ + { + name: "nil package does not trigger check", + modulePackage: nil, + expectedErrors: []string{}, + }, + { + name: "only deckhouse constraint does not trigger check", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Deckhouse: PackageVersionRequirement{Constraint: ">= 1.76"}, + }, + }, + expectedErrors: []string{}, + }, + { + name: "new schema with deckhouse 1.77 passes", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Kubernetes: PackageVersionRequirement{Constraint: ">= 1.26"}, + Deckhouse: PackageVersionRequirement{Constraint: ">= 1.77"}, + }, + }, + expectedErrors: []string{}, + }, + { + name: "new schema with deckhouse 1.77.0 passes", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Modules: PackageModulesRequirements{ + Mandatory: []PackageModuleRequirement{{Name: "stronghold", Constraint: ">= 1.0.0"}}, + }, + Deckhouse: PackageVersionRequirement{Constraint: ">= 1.77.0"}, + }, + }, + expectedErrors: []string{}, + }, + { + name: "new schema without deckhouse constraint fails", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Kubernetes: PackageVersionRequirement{Constraint: ">= 1.26"}, + }, + }, + expectedErrors: []string{"package.yaml requirements.deckhouse.constraint is required when new requirements schema is used and must start no lower than 1.77.0"}, + }, + { + name: "new schema with deckhouse below 1.77 fails", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Kubernetes: PackageVersionRequirement{Constraint: ">= 1.26"}, + Deckhouse: PackageVersionRequirement{Constraint: ">= 1.76"}, + }, + }, + expectedErrors: []string{"package.yaml requirements.deckhouse.constraint version range should start no lower than 1.77.0 (currently: 1.76.0)"}, + }, + { + name: "new schema with deckhouse upper bound only fails", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Kubernetes: PackageVersionRequirement{Constraint: ">= 1.26"}, + Deckhouse: PackageVersionRequirement{Constraint: "< 1.80"}, + }, + }, + expectedErrors: []string{"package.yaml requirements.deckhouse.constraint version range should start no lower than 1.77.0"}, + }, + { + name: "new schema with invalid deckhouse constraint does not duplicate semver error", + modulePackage: &ModulePackage{ + Requirements: &PackageRequirements{ + Kubernetes: PackageVersionRequirement{Constraint: ">= 1.26"}, + Deckhouse: PackageVersionRequirement{Constraint: ">= 1.77 !optional"}, + }, + }, + expectedErrors: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errorList := errors.NewLintRuleErrorsList() + validatePackageDeckhouseRequirement(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 + packageContent string + expectedErrors []string + }{ + { + name: "package.yaml is missing", + packageContent: "", + expectedErrors: []string{}, + }, + { + name: "valid new requirements schema", + packageContent: `apiVersion: v2 +name: stronghold +requirements: + kubernetes: + constraint: ">= 1.26" + deckhouse: + constraint: ">= 1.77" + modules: + mandatory: + - name: stronghold + constraint: ">= 1.0.0" + conditional: + - name: observability + constraint: ">= 1.0.0" + anyOf: + - description: "cloud provider" + modules: + - name: cloud-provider-gcp + constraint: ">= 1.5.0" +`, + expectedErrors: []string{}, + }, + { + name: "package.yaml requires apiVersion", + packageContent: `name: stronghold +requirements: + deckhouse: + constraint: ">= 1.77" +`, + expectedErrors: []string{"package.yaml apiVersion is required"}, + }, + { + name: "package.yaml requires name", + packageContent: `apiVersion: v2 +requirements: + deckhouse: + constraint: ">= 1.77" +`, + expectedErrors: []string{"package.yaml name is required"}, + }, + { + name: "new schema requires deckhouse constraint", + packageContent: `apiVersion: v2 +name: stronghold +requirements: + kubernetes: + constraint: ">= 1.26" +`, + expectedErrors: []string{"package.yaml requirements.deckhouse.constraint is required when new requirements schema is used and must start no lower than 1.77.0"}, + }, + { + name: "new schema requires deckhouse 1.77", + packageContent: `apiVersion: v2 +name: stronghold +requirements: + kubernetes: + constraint: ">= 1.26" + deckhouse: + constraint: ">= 1.76" +`, + expectedErrors: []string{"package.yaml requirements.deckhouse.constraint version range should start no lower than 1.77.0 (currently: 1.76.0)"}, + }, + { + name: "constraints are parsed as is", + packageContent: `apiVersion: v2 +name: stronghold +requirements: + kubernetes: + constraint: ">= 1.26" + deckhouse: + constraint: ">= 1.77" + modules: + conditional: + - name: observability + constraint: ">= 1.0.0 !optional" +`, + expectedErrors: []string{"Invalid package.yaml requirements.modules.conditional[0].constraint version constraint \">= 1.0.0 !optional\""}, + }, + { + name: "invalid deckhouse constraint does not duplicate deckhouse minimum error", + packageContent: `apiVersion: v2 +name: stronghold +requirements: + kubernetes: + constraint: ">= 1.26" + deckhouse: + constraint: ">= 1.77 !optional" +`, + expectedErrors: []string{"Invalid package.yaml requirements.deckhouse.constraint version constraint \">= 1.77 !optional\""}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + modulePath := t.TempDir() + if tt.packageContent != "" { + require.NoError(t, os.WriteFile(filepath.Join(modulePath, PackageConfigFilename), []byte(tt.packageContent), DefaultFilePerm)) + } + + errorList := errors.NewLintRuleErrorsList() + NewPackageYAMLRule().CheckPackageYAML(modulePath, 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) + assert.Equal(t, PackageYAMLRuleName, errs[idx].RuleID) + } + }) + } +} From 0b895cd715dd768fdb420a4e1b7577112d59521e Mon Sep 17 00:00:00 2001 From: diyliv Date: Thu, 21 May 2026 16:15:28 +0300 Subject: [PATCH 2/7] fix review: exclude != from findMinimalAllowedVersion, remove invalid rules section from README Signed-off-by: diyliv --- pkg/linters/module/README.md | 4 ---- pkg/linters/module/rules/requirements.go | 7 ++++--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/pkg/linters/module/README.md b/pkg/linters/module/README.md index f98021a1..55a05116 100644 --- a/pkg/linters/module/README.md +++ b/pkg/linters/module/README.md @@ -524,10 +524,6 @@ linters-settings: helmignore: disable: false # Enable/disable .helmignore validation - - rules: - package-yaml: - impact: error # Override package.yaml validation level # License exclusions exclude-rules: diff --git a/pkg/linters/module/rules/requirements.go b/pkg/linters/module/rules/requirements.go index 6c070498..85263730 100644 --- a/pkg/linters/module/rules/requirements.go +++ b/pkg/linters/module/rules/requirements.go @@ -448,8 +448,9 @@ func (r *RequirementsRule) CheckRequirements(modulePath string, errorList *error registry.RunAllChecks(modulePath, moduleDescriptions, errorList) } -// findMinimalAllowedVersion finds the minimum allowed version among all >=, >, =, != in constraint -// Uses regex to extract versions and operators, returns the minimal version, or nil if only < or <= are present +// findMinimalAllowedVersion finds the minimum allowed version among >=, >, = operators in constraint. +// != is deliberately excluded — it means "not equal" and does not set a lower bound. +// Returns the minimal version, or nil if only <, <=, or != are present. func findMinimalAllowedVersion(constraint *semver.Constraints) *semver.Version { if constraint == nil { return nil @@ -469,7 +470,7 @@ func findMinimalAllowedVersion(constraint *semver.Constraints) *semver.Version { op := m[1] verStr := m[2] - if op == ">=" || op == ">" || op == "=" || op == "!=" { + if op == ">=" || op == ">" || op == "=" { v, err := semver.NewVersion(verStr) if err == nil { if minVersion == nil || v.LessThan(minVersion) { From 3a16547a2d159cdf126f9e062338430fda68406c Mon Sep 17 00:00:00 2001 From: diyliv Date: Thu, 21 May 2026 18:27:22 +0300 Subject: [PATCH 3/7] validate subscribe Signed-off-by: diyliv --- pkg/linters/module/rules/package_yaml.go | 48 ++++ pkg/linters/module/rules/package_yaml_test.go | 214 ++++++++++++++++++ 2 files changed, 262 insertions(+) diff --git a/pkg/linters/module/rules/package_yaml.go b/pkg/linters/module/rules/package_yaml.go index 1c29e632..2ae668d6 100644 --- a/pkg/linters/module/rules/package_yaml.go +++ b/pkg/linters/module/rules/package_yaml.go @@ -21,8 +21,11 @@ import ( "fmt" "os" "path/filepath" + "regexp" + "strings" "github.com/Masterminds/semver/v3" + "k8s.io/apimachinery/pkg/runtime/schema" "sigs.k8s.io/yaml" "github.com/deckhouse/dmt/pkg" @@ -35,6 +38,8 @@ const ( MinimalDeckhouseVersionForPackageRequirements = "1.77.0" ) +var subscribeAPIKindRegex = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) + // NewPackageYAMLRule creates a rule for validating package.yaml. func NewPackageYAMLRule() *PackageYAMLRule { return &PackageYAMLRule{ @@ -157,6 +162,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. @@ -264,3 +270,45 @@ 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 a non-empty API group", fieldPath, "//") + return + } + + group, version, kind := parts[0], parts[1], parts[2] + if group == "" || version == "" || kind == "" { + errorList.Errorf("package.yaml %s must use %q format with a non-empty API group", fieldPath, "//") + return + } + + groupVersion := fmt.Sprintf("%s/%s", group, version) + if _, err := schema.ParseGroupVersion(groupVersion); err != nil { + errorList.Errorf("package.yaml %s has invalid Kubernetes group/version %q: %s", fieldPath, groupVersion, err) + return + } + + if !isValidSubscribeAPIKind(kind) { + errorList.Errorf("package.yaml %s kind must be UpperCamelCase and start with an uppercase letter", fieldPath) + } +} + +func isValidSubscribeAPIKind(kind string) bool { + return subscribeAPIKindRegex.MatchString(kind) +} diff --git a/pkg/linters/module/rules/package_yaml_test.go b/pkg/linters/module/rules/package_yaml_test.go index 37253110..0ba7592f 100644 --- a/pkg/linters/module/rules/package_yaml_test.go +++ b/pkg/linters/module/rules/package_yaml_test.go @@ -483,6 +483,178 @@ 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", + }, + }, + }, + expectedErrors: []string{}, + }, + { + name: "missing group", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{"v1/Pod"}, + }, + }, + expectedErrors: []string{ + `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + }, + }, + { + name: "lowercase kind", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{"apps/v1/pod"}, + }, + }, + expectedErrors: []string{ + "package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter", + }, + }, + { + name: "missing kind", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{"apps/v1/"}, + }, + }, + expectedErrors: []string{ + `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + }, + }, + { + name: "missing version", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{"apps//Pod"}, + }, + }, + expectedErrors: []string{ + `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + }, + }, + { + name: "missing group with three parts", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{"/v1/Pod"}, + }, + }, + expectedErrors: []string{ + `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + }, + }, + { + name: "malformed part count short", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{"apps/v1"}, + }, + }, + expectedErrors: []string{ + `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + }, + }, + { + name: "malformed part count long", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{"apps/v1/Pod/Extra"}, + }, + }, + expectedErrors: []string{ + `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + }, + }, + { + name: "empty string", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{""}, + }, + }, + expectedErrors: []string{ + `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + }, + }, + { + name: "invalid kind characters lowercase dash", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{"apps/v1/pod-name"}, + }, + }, + expectedErrors: []string{ + "package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter", + }, + }, + { + name: "invalid kind characters uppercase dash", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{"apps/v1/Pod-Name"}, + }, + }, + expectedErrors: []string{ + "package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter", + }, + }, + } + + 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 @@ -515,6 +687,9 @@ requirements: modules: - name: cloud-provider-gcp constraint: ">= 1.5.0" +subscribe: + apis: + - autoscaling.k8s.io/v1/VerticalPodAutoscaler `, expectedErrors: []string{}, }, @@ -586,6 +761,45 @@ 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 "//" format with a non-empty API group`, + }, + }, + { + name: "subscribe api lowercase kind", + packageContent: `apiVersion: v2 +name: stronghold +subscribe: + apis: + - apps/v1/pod +`, + expectedErrors: []string{ + "package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter", + }, + }, + { + name: "mixed subscribe apis keep order", + packageContent: `apiVersion: v2 +name: stronghold +subscribe: + apis: + - autoscaling.k8s.io/v1/VerticalPodAutoscaler + - v1/Pod + - apps/v1/pod +`, + expectedErrors: []string{ + `package.yaml subscribe.apis[1] must use "//" format with a non-empty API group`, + "package.yaml subscribe.apis[2] kind must be UpperCamelCase and start with an uppercase letter", + }, + }, } for _, tt := range tests { From 163183901d1347662e6375568f983a98f94e6f07 Mon Sep 17 00:00:00 2001 From: diyliv Date: Thu, 21 May 2026 18:27:33 +0300 Subject: [PATCH 4/7] update readme Signed-off-by: diyliv --- pkg/linters/module/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/linters/module/README.md b/pkg/linters/module/README.md index 55a05116..937e198b 100644 --- a/pkg/linters/module/README.md +++ b/pkg/linters/module/README.md @@ -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 `//` with an explicit API group and UpperCamelCase `Kind` **New Requirements Schema Detection:** The rule treats `package.yaml` as using the new requirements schema when any of these fields are present: @@ -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 "//" format with a non-empty API group +❌ package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter ``` --- From cc61b6ad716aa86749ed835a2d6589756d1e7e6e Mon Sep 17 00:00:00 2001 From: diyliv Date: Fri, 22 May 2026 00:13:35 +0300 Subject: [PATCH 5/7] validate subscribe apis Signed-off-by: diyliv --- pkg/linters/module/rules/package_yaml.go | 21 +++++++++--- pkg/linters/module/rules/package_yaml_test.go | 33 +++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/pkg/linters/module/rules/package_yaml.go b/pkg/linters/module/rules/package_yaml.go index 2ae668d6..3f22fc9e 100644 --- a/pkg/linters/module/rules/package_yaml.go +++ b/pkg/linters/module/rules/package_yaml.go @@ -25,7 +25,7 @@ import ( "strings" "github.com/Masterminds/semver/v3" - "k8s.io/apimachinery/pkg/runtime/schema" + utilvalidation "k8s.io/apimachinery/pkg/util/validation" "sigs.k8s.io/yaml" "github.com/deckhouse/dmt/pkg" @@ -39,6 +39,7 @@ const ( ) 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 { @@ -298,9 +299,13 @@ func validatePackageSubscribeAPI(fieldPath, value string, errorList *errors.Lint return } - groupVersion := fmt.Sprintf("%s/%s", group, version) - if _, err := schema.ParseGroupVersion(groupVersion); err != nil { - errorList.Errorf("package.yaml %s has invalid Kubernetes group/version %q: %s", fieldPath, groupVersion, err) + 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 } @@ -309,6 +314,14 @@ func validatePackageSubscribeAPI(fieldPath, value string, errorList *errors.Lint } } +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) } diff --git a/pkg/linters/module/rules/package_yaml_test.go b/pkg/linters/module/rules/package_yaml_test.go index 0ba7592f..3b57d020 100644 --- a/pkg/linters/module/rules/package_yaml_test.go +++ b/pkg/linters/module/rules/package_yaml_test.go @@ -549,6 +549,39 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { "package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter", }, }, + { + 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{ From 814dacf2430b3d6c3507c9c46b90e64c660cb4bc Mon Sep 17 00:00:00 2001 From: diyliv Date: Fri, 22 May 2026 12:38:14 +0300 Subject: [PATCH 6/7] fix subscribe apis review Signed-off-by: diyliv --- pkg/linters/module/rules/package_yaml.go | 8 +-- pkg/linters/module/rules/package_yaml_test.go | 57 ++++++++++++++----- 2 files changed, 47 insertions(+), 18 deletions(-) diff --git a/pkg/linters/module/rules/package_yaml.go b/pkg/linters/module/rules/package_yaml.go index 3f22fc9e..c50e5166 100644 --- a/pkg/linters/module/rules/package_yaml.go +++ b/pkg/linters/module/rules/package_yaml.go @@ -38,7 +38,7 @@ const ( MinimalDeckhouseVersionForPackageRequirements = "1.77.0" ) -var subscribeAPIKindRegex = regexp.MustCompile(`^[A-Z][A-Za-z0-9]*$`) +var subscribeAPIKindRegex = regexp.MustCompile(`^[A-Z]+[a-z0-9]+(?:[A-Z]+[a-z0-9]+)*[A-Z]*$`) var subscribeAPIVersionRegex = regexp.MustCompile(`^v[0-9]+(?:(alpha|beta)[0-9]+)?$`) // NewPackageYAMLRule creates a rule for validating package.yaml. @@ -289,13 +289,13 @@ func validatePackageSubscribeAPIs(modulePackage *ModulePackage, errorList *error 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 a non-empty API group", fieldPath, "//") + errorList.Errorf("package.yaml %s must use %q format with non-empty group, version, and Kind (got %q)", fieldPath, "//", 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 a non-empty API group", fieldPath, "//") + errorList.Errorf("package.yaml %s must use %q format with non-empty group, version, and Kind (got %q)", fieldPath, "//", value) return } @@ -310,7 +310,7 @@ func validatePackageSubscribeAPI(fieldPath, value string, errorList *errors.Lint } if !isValidSubscribeAPIKind(kind) { - errorList.Errorf("package.yaml %s kind must be UpperCamelCase and start with an uppercase letter", fieldPath) + errorList.Errorf("package.yaml %s kind %q must be UpperCamelCase", fieldPath, kind) } } diff --git a/pkg/linters/module/rules/package_yaml_test.go b/pkg/linters/module/rules/package_yaml_test.go index 3b57d020..ad3a467e 100644 --- a/pkg/linters/module/rules/package_yaml_test.go +++ b/pkg/linters/module/rules/package_yaml_test.go @@ -522,6 +522,9 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { APIs: []string{ "autoscaling.k8s.io/v1/VerticalPodAutoscaler", "deckhouse.io/v1alpha1/ModuleRelease", + "apiregistration.k8s.io/v1/APIService", + "example.io/v1/MyAPI", + "example.io/v1/ServiceDNS", }, }, }, @@ -535,7 +538,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + `package.yaml subscribe.apis[0] must use "//" format with non-empty group, version, and Kind`, }, }, { @@ -546,7 +549,18 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - "package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter", + `package.yaml subscribe.apis[0] kind "pod" must be UpperCamelCase`, + }, + }, + { + name: "uppercase acronym-only kind is invalid", + modulePackage: &ModulePackage{ + Subscribe: &PackageSubscribe{ + APIs: []string{"apps/v1/POD"}, + }, + }, + expectedErrors: []string{ + `package.yaml subscribe.apis[0] kind "POD" must be UpperCamelCase`, }, }, { @@ -590,7 +604,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + `package.yaml subscribe.apis[0] must use "//" format with non-empty group, version, and Kind`, }, }, { @@ -601,7 +615,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + `package.yaml subscribe.apis[0] must use "//" format with non-empty group, version, and Kind`, }, }, { @@ -612,7 +626,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + `package.yaml subscribe.apis[0] must use "//" format with non-empty group, version, and Kind`, }, }, { @@ -623,7 +637,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + `package.yaml subscribe.apis[0] must use "//" format with non-empty group, version, and Kind`, }, }, { @@ -634,7 +648,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + `package.yaml subscribe.apis[0] must use "//" format with non-empty group, version, and Kind`, }, }, { @@ -645,7 +659,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + `package.yaml subscribe.apis[0] must use "//" format with non-empty group, version, and Kind`, }, }, { @@ -656,7 +670,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - "package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter", + `package.yaml subscribe.apis[0] kind "pod-name" must be UpperCamelCase`, }, }, { @@ -667,7 +681,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - "package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter", + `package.yaml subscribe.apis[0] kind "Pod-Name" must be UpperCamelCase`, }, }, } @@ -723,6 +737,8 @@ requirements: subscribe: apis: - autoscaling.k8s.io/v1/VerticalPodAutoscaler + - example.io/v1/MyAPI + - example.io/v1/ServiceDNS `, expectedErrors: []string{}, }, @@ -803,7 +819,7 @@ subscribe: - v1/Pod `, expectedErrors: []string{ - `package.yaml subscribe.apis[0] must use "//" format with a non-empty API group`, + `package.yaml subscribe.apis[0] must use "//" format with non-empty group, version, and Kind`, }, }, { @@ -815,7 +831,19 @@ subscribe: - apps/v1/pod `, expectedErrors: []string{ - "package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter", + `package.yaml subscribe.apis[0] kind "pod" must be UpperCamelCase`, + }, + }, + { + name: "subscribe api uppercase acronym-only kind", + packageContent: `apiVersion: v2 +name: stronghold +subscribe: + apis: + - apps/v1/POD +`, + expectedErrors: []string{ + `package.yaml subscribe.apis[0] kind "POD" must be UpperCamelCase`, }, }, { @@ -825,12 +853,13 @@ 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[1] must use "//" format with a non-empty API group`, - "package.yaml subscribe.apis[2] kind must be UpperCamelCase and start with an uppercase letter", + `package.yaml subscribe.apis[2] must use "//" format with non-empty group, version, and Kind`, + `package.yaml subscribe.apis[3] kind "pod" must be UpperCamelCase`, }, }, } From a13b1f7c3098890de0a4b42ad7cfb8cb025c494d Mon Sep 17 00:00:00 2001 From: diyliv Date: Mon, 25 May 2026 13:11:46 +0300 Subject: [PATCH 7/7] fix regex Signed-off-by: diyliv --- pkg/linters/module/README.md | 4 ++-- pkg/linters/module/rules/package_yaml.go | 4 ++-- pkg/linters/module/rules/package_yaml_test.go | 22 ++++++++----------- 3 files changed, 13 insertions(+), 17 deletions(-) diff --git a/pkg/linters/module/README.md b/pkg/linters/module/README.md index 937e198b..ee9e5fe7 100644 --- a/pkg/linters/module/README.md +++ b/pkg/linters/module/README.md @@ -431,7 +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 `//` with an explicit API group and UpperCamelCase `Kind` +- ✅ `subscribe.apis` entries must use `//` 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: @@ -480,7 +480,7 @@ subscribe: ❌ 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 "//" format with a non-empty API group -❌ package.yaml subscribe.apis[0] kind must be UpperCamelCase and start with an uppercase letter +❌ package.yaml subscribe.apis[0] kind "pod" must start with an uppercase letter and contain only letters and digits ``` --- diff --git a/pkg/linters/module/rules/package_yaml.go b/pkg/linters/module/rules/package_yaml.go index c50e5166..3d5c6cb9 100644 --- a/pkg/linters/module/rules/package_yaml.go +++ b/pkg/linters/module/rules/package_yaml.go @@ -38,7 +38,7 @@ const ( MinimalDeckhouseVersionForPackageRequirements = "1.77.0" ) -var subscribeAPIKindRegex = regexp.MustCompile(`^[A-Z]+[a-z0-9]+(?:[A-Z]+[a-z0-9]+)*[A-Z]*$`) +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. @@ -310,7 +310,7 @@ func validatePackageSubscribeAPI(fieldPath, value string, errorList *errors.Lint } if !isValidSubscribeAPIKind(kind) { - errorList.Errorf("package.yaml %s kind %q must be UpperCamelCase", fieldPath, kind) + errorList.Errorf("package.yaml %s kind %q must start with an uppercase letter and contain only letters and digits", fieldPath, kind) } } diff --git a/pkg/linters/module/rules/package_yaml_test.go b/pkg/linters/module/rules/package_yaml_test.go index ad3a467e..a389254f 100644 --- a/pkg/linters/module/rules/package_yaml_test.go +++ b/pkg/linters/module/rules/package_yaml_test.go @@ -549,19 +549,17 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - `package.yaml subscribe.apis[0] kind "pod" must be UpperCamelCase`, + `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 invalid", + name: "uppercase acronym-only kind is valid", modulePackage: &ModulePackage{ Subscribe: &PackageSubscribe{ APIs: []string{"apps/v1/POD"}, }, }, - expectedErrors: []string{ - `package.yaml subscribe.apis[0] kind "POD" must be UpperCamelCase`, - }, + expectedErrors: []string{}, }, { name: "uppercase group is invalid", @@ -670,7 +668,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - `package.yaml subscribe.apis[0] kind "pod-name" must be UpperCamelCase`, + `package.yaml subscribe.apis[0] kind "pod-name" must start with an uppercase letter and contain only letters and digits`, }, }, { @@ -681,7 +679,7 @@ func TestValidatePackageSubscribeAPIs(t *testing.T) { }, }, expectedErrors: []string{ - `package.yaml subscribe.apis[0] kind "Pod-Name" must be UpperCamelCase`, + `package.yaml subscribe.apis[0] kind "Pod-Name" must start with an uppercase letter and contain only letters and digits`, }, }, } @@ -831,20 +829,18 @@ subscribe: - apps/v1/pod `, expectedErrors: []string{ - `package.yaml subscribe.apis[0] kind "pod" must be UpperCamelCase`, + `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", + name: "subscribe api uppercase acronym-only kind is valid", packageContent: `apiVersion: v2 name: stronghold subscribe: apis: - apps/v1/POD `, - expectedErrors: []string{ - `package.yaml subscribe.apis[0] kind "POD" must be UpperCamelCase`, - }, + expectedErrors: []string{}, }, { name: "mixed subscribe apis keep order", @@ -859,7 +855,7 @@ subscribe: `, expectedErrors: []string{ `package.yaml subscribe.apis[2] must use "//" format with non-empty group, version, and Kind`, - `package.yaml subscribe.apis[3] kind "pod" must be UpperCamelCase`, + `package.yaml subscribe.apis[3] kind "pod" must start with an uppercase letter and contain only letters and digits`, }, }, }