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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions internal/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,12 @@ type PluginRequirement struct {
Constraint string
}

// AnyOfGroup represents an "at least one of" group of module requirements.
// Description is used in user-facing error messages.
type AnyOfGroup struct {
// ModuleGroup is a named group of module requirements, shared by the AnyOf and
// NoneOf buckets of ModuleRequirementsGroup (bucket semantics are documented
// there). Name is a required, stable identifier used in diagnostics; Description
// is optional human-facing text.
type ModuleGroup struct {
Name string
Description string
Modules []ModuleRequirement
}
Expand All @@ -84,12 +87,16 @@ type PluginRequirementsGroup struct {
Conditional []PluginRequirement
}

// ModuleRequirementsGroup splits module requirements into Mandatory, Conditional, and AnyOf.
// ModuleRequirementsGroup splits module requirements into Mandatory, Conditional,
// AnyOf, and NoneOf.
// - Mandatory: the module must be enabled AND satisfy the constraint.
// - Conditional: only enforced if the module is enabled.
// - AnyOf: at least one module per group must be enabled and satisfy its constraint.
// - NoneOf: no module in any group may be enabled (a member constraint, if set,
// narrows the forbidden version range).
type ModuleRequirementsGroup struct {
Mandatory []ModuleRequirement
Conditional []ModuleRequirement
AnyOf []AnyOfGroup
AnyOf []ModuleGroup
NoneOf []ModuleGroup
}
65 changes: 57 additions & 8 deletions internal/plugins/requirements/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ func HasClusterRequirements(plugin *internal.Plugin) bool {
requirements.Deckhouse.Constraint != "" ||
len(requirements.Modules.Mandatory) > 0 ||
len(requirements.Modules.Conditional) > 0 ||
len(requirements.Modules.AnyOf) > 0
len(requirements.Modules.AnyOf) > 0 ||
len(requirements.Modules.NoneOf) > 0
}

// normalizedForConstraint prepares a version for constraint matching.
Expand Down Expand Up @@ -177,7 +178,8 @@ func (c *Checker) validateDeckhouseRequirement(plugin *internal.Plugin, state *C
// validateModuleRequirement enforces module requirements against the cluster:
// - Mandatory: the module must be enabled and satisfy its version constraint;
// - Conditional: checked only when the module is enabled;
// - AnyOf: at least one module in each group must be enabled and satisfy its constraint.
// - AnyOf: at least one module in each group must be enabled and satisfy its constraint;
// - NoneOf: no module in any group may be enabled within its forbidden version range.
func (c *Checker) validateModuleRequirement(plugin *internal.Plugin, state *ClusterState) error {
for _, requirement := range plugin.Requirements.Modules.Mandatory {
module, enabled := enabledModule(state, requirement.Name)
Expand Down Expand Up @@ -207,6 +209,12 @@ func (c *Checker) validateModuleRequirement(plugin *internal.Plugin, state *Clus
}
}

for index, group := range plugin.Requirements.Modules.NoneOf {
if err := c.checkNoneOfModules(plugin.Name, index, group, state); err != nil {
return err
}
}

return nil
}

Expand Down Expand Up @@ -270,7 +278,7 @@ func (c *Checker) checkModuleConstraint(pluginName string, requirement internal.
// An enabled-but-unversioned module does NOT satisfy a versioned alternative here
// (unlike the mandatory/conditional paths): another candidate may be verifiable.
// A malformed constraint is operational and propagates, not swallowed as "none satisfied".
func (c *Checker) checkAnyOfModules(pluginName string, index int, group internal.AnyOfGroup, state *ClusterState) error {
func (c *Checker) checkAnyOfModules(pluginName string, index int, group internal.ModuleGroup, state *ClusterState) error {
if len(group.Modules) == 0 {
return nil
}
Expand All @@ -295,11 +303,52 @@ func (c *Checker) checkAnyOfModules(pluginName string, index int, group internal
}
}

description := group.Description
if description == "" {
description = fmt.Sprintf("group %d", index)
return unmetf("plugin %s requires at least one of [%s] (%s), but none is satisfied",
pluginName, strings.Join(names, ", "), groupID(group.Name, index))
}

// groupID returns a stable identifier for a requirement group in diagnostics:
// the group's (required) name, with a positional fallback for a group built
// without one (e.g. in tests, which bypass contract validation).
func groupID(name string, index int) string {
if name == "" {
return fmt.Sprintf("group %d", index)
}

return unmetf("plugin %s requires at least one of [%s] (%s), but none is satisfied",
pluginName, strings.Join(names, ", "), description)
return name
}

// checkNoneOfModules fails if any module in the group is enabled and its version
// falls in the forbidden range. An empty member constraint forbids the module at
// any version. An enabled module that reports no version is skipped with a warning
// when a version constraint is set (it cannot be judged). A malformed constraint is
// operational and propagates, not swallowed as "not forbidden".
func (c *Checker) checkNoneOfModules(pluginName string, index int, group internal.ModuleGroup, state *ClusterState) error {
for _, requirement := range group.Modules {
module, enabled := enabledModule(state, requirement.Name)
if !enabled {
continue
}

forbidden, versionKnown, err := evaluateModuleVersion(requirement, module)
if err != nil {
return err
}

if !versionKnown {
c.logger.Warn("skipping noneOf version check: module reports no version",
slog.String("plugin", pluginName),
slog.String("module", requirement.Name),
slog.String("constraint", requirement.Constraint))

continue
}

if forbidden {
return unmetf("plugin %s forbids module %q (%s), but it is enabled in the cluster",
pluginName, requirement.Name, groupID(group.Name, index))
}
}

return nil
}
67 changes: 64 additions & 3 deletions internal/plugins/requirements/checks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ func TestHasClusterRequirements(t *testing.T) {
Mandatory: []internal.ModuleRequirement{{Name: "x"}},
}},
}))
assert.True(t, HasClusterRequirements(&internal.Plugin{
Requirements: internal.Requirements{Modules: internal.ModuleRequirementsGroup{
AnyOf: []internal.ModuleGroup{{Name: "g", Modules: []internal.ModuleRequirement{{Name: "x"}}}},
}},
}))
assert.True(t, HasClusterRequirements(&internal.Plugin{
Requirements: internal.Requirements{Modules: internal.ModuleRequirementsGroup{
NoneOf: []internal.ModuleGroup{{Name: "g", Modules: []internal.ModuleRequirement{{Name: "x"}}}},
}},
}))
}

func TestIsUnmet(t *testing.T) {
Expand Down Expand Up @@ -148,7 +158,7 @@ func TestValidateModuleRequirementConditional(t *testing.T) {
func TestValidateModuleRequirementAnyOf(t *testing.T) {
c := testChecker()
reqs := reqModules(internal.ModuleRequirementsGroup{
AnyOf: []internal.AnyOfGroup{{
AnyOf: []internal.ModuleGroup{{
Description: "ingress",
Modules: []internal.ModuleRequirement{
{Name: "ingress-nginx", Constraint: ">= 1.0"},
Expand All @@ -174,7 +184,7 @@ func TestValidateModuleRequirementAnyOf(t *testing.T) {
func TestValidateModuleRequirementAnyOfUnversionedNotSatisfied(t *testing.T) {
c := testChecker()
reqs := reqModules(internal.ModuleRequirementsGroup{
AnyOf: []internal.AnyOfGroup{{Modules: []internal.ModuleRequirement{{Name: "m", Constraint: ">= 1.0"}}}},
AnyOf: []internal.ModuleGroup{{Modules: []internal.ModuleRequirement{{Name: "m", Constraint: ">= 1.0"}}}},
})

// enabled but no version → does NOT satisfy a versioned anyOf alternative
Expand All @@ -186,7 +196,7 @@ func TestValidateModuleRequirementAnyOfUnversionedNotSatisfied(t *testing.T) {
func TestValidateModuleRequirementMalformedConstraintPropagates(t *testing.T) {
c := testChecker()
reqs := reqModules(internal.ModuleRequirementsGroup{
AnyOf: []internal.AnyOfGroup{{Modules: []internal.ModuleRequirement{{Name: "m", Constraint: "abc"}}}},
AnyOf: []internal.ModuleGroup{{Modules: []internal.ModuleRequirement{{Name: "m", Constraint: "abc"}}}},
})

// a malformed constraint is operational - it propagates, not swallowed as "none satisfied"
Expand All @@ -197,6 +207,57 @@ func TestValidateModuleRequirementMalformedConstraintPropagates(t *testing.T) {
assert.False(t, IsUnmet(err), "operational errors are not reported as unmet requirements")
}

func TestValidateModuleRequirementNoneOf(t *testing.T) {
c := testChecker()
reqs := reqModules(internal.ModuleRequirementsGroup{
NoneOf: []internal.ModuleGroup{{
Description: "legacy",
Modules: []internal.ModuleRequirement{{Name: "legacy-cni", Constraint: "< 1.0"}},
}},
})

// forbidden module not enabled → ok
require.NoError(t, c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{}}))

// enabled, version outside the forbidden range → ok
require.NoError(t, c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{
"legacy-cni": enabled("v1.5.0"),
}}))

// enabled, version inside the forbidden range → unmet
err := c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{
"legacy-cni": enabled("v0.9.0"),
}})
require.Error(t, err)
assert.True(t, IsUnmet(err))
}

func TestValidateModuleRequirementNoneOfEmptyConstraintForbidsAnyVersion(t *testing.T) {
c := testChecker()
reqs := reqModules(internal.ModuleRequirementsGroup{
NoneOf: []internal.ModuleGroup{{Modules: []internal.ModuleRequirement{{Name: "banned"}}}},
})

// an empty constraint forbids the module at any version
require.Error(t, c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{
"banned": enabled("v3.0.0"),
}}))
}

func TestValidateModuleRequirementNoneOfMalformedConstraintPropagates(t *testing.T) {
c := testChecker()
reqs := reqModules(internal.ModuleRequirementsGroup{
NoneOf: []internal.ModuleGroup{{Modules: []internal.ModuleRequirement{{Name: "m", Constraint: "abc"}}}},
})

// a malformed constraint is operational - it propagates, not swallowed as "not forbidden"
err := c.validateModuleRequirement(reqs, &ClusterState{Modules: map[string]ModuleState{
"m": enabled("v1.0.0"),
}})
require.Error(t, err)
assert.False(t, IsUnmet(err), "operational errors are not reported as unmet requirements")
}

// --- helpers to build plugins with specific requirements ---

func reqK8s(constraint string) *internal.Plugin {
Expand Down
5 changes: 4 additions & 1 deletion internal/plugins/rpp_source.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,10 @@ func contractFromBytes(raw []byte, pluginName, tag string) (*internal.Plugin, er
return nil, fmt.Errorf("decode contract for plugin %q: %w", pluginName, err)
}

plugin := service.ContractToDomain(&dto)
plugin, err := service.ContractToDomain(&dto)
if err != nil {
return nil, fmt.Errorf("decode contract for plugin %q: %w", pluginName, err)
}

if plugin.Name == "" {
plugin.Name = pluginName
Expand Down
7 changes: 6 additions & 1 deletion internal/plugins/validators.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,12 @@ func (m *Manager) InstalledPluginContract(pluginName string) (*internal.Plugin,
return nil, fmt.Errorf("failed to unmarshal contract: %w", err)
}

return service.ContractToDomain(contract), nil
plugin, err := service.ContractToDomain(contract)
if err != nil {
return nil, fmt.Errorf("invalid contract for plugin %q: %w", pluginName, err)
}

return plugin, nil
}

// getInstalledPluginVersion runs the installed plugin's current binary and parses
Expand Down
Loading
Loading