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..55a05116 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. @@ -534,6 +599,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) + } + }) + } +} 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) {