From 0e97b8bd342f64273cd08e29222d22ea8018fb29 Mon Sep 17 00:00:00 2001 From: Artem Kuleshov Date: Tue, 14 Jul 2026 14:46:15 +0300 Subject: [PATCH] [deckhouse-cli] Add noneOf and validate plugin contract module requirements Signed-off-by: Artem Kuleshov --- internal/plugin.go | 17 +- internal/plugins/requirements/checks.go | 65 ++++++- internal/plugins/requirements/checks_test.go | 67 +++++++- internal/plugins/rpp_source.go | 5 +- internal/plugins/validators.go | 7 +- pkg/registry/service/contract.go | 172 ++++++++++++++++--- pkg/registry/service/dto.go | 14 +- pkg/registry/service/dto_test.go | 110 +++++++++++- 8 files changed, 411 insertions(+), 46 deletions(-) diff --git a/internal/plugin.go b/internal/plugin.go index 16291174..b1269a8e 100644 --- a/internal/plugin.go +++ b/internal/plugin.go @@ -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 } @@ -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 } diff --git a/internal/plugins/requirements/checks.go b/internal/plugins/requirements/checks.go index a1ff44ab..ad6e58d0 100644 --- a/internal/plugins/requirements/checks.go +++ b/internal/plugins/requirements/checks.go @@ -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. @@ -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) @@ -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 } @@ -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 } @@ -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 } diff --git a/internal/plugins/requirements/checks_test.go b/internal/plugins/requirements/checks_test.go index 2e8973b7..284b21dd 100644 --- a/internal/plugins/requirements/checks_test.go +++ b/internal/plugins/requirements/checks_test.go @@ -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) { @@ -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"}, @@ -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 @@ -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" @@ -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 { diff --git a/internal/plugins/rpp_source.go b/internal/plugins/rpp_source.go index a5c336ab..a5cf8016 100644 --- a/internal/plugins/rpp_source.go +++ b/internal/plugins/rpp_source.go @@ -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 diff --git a/internal/plugins/validators.go b/internal/plugins/validators.go index 5cc91d00..247cff7a 100644 --- a/internal/plugins/validators.go +++ b/internal/plugins/validators.go @@ -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 diff --git a/pkg/registry/service/contract.go b/pkg/registry/service/contract.go index 169337f1..3956471f 100644 --- a/pkg/registry/service/contract.go +++ b/pkg/registry/service/contract.go @@ -21,6 +21,8 @@ import ( "errors" "fmt" + "github.com/Masterminds/semver/v3" + "github.com/deckhouse/deckhouse-cli/internal" ) @@ -55,8 +57,11 @@ func UnmarshalContract(raw []byte, dst *PluginContract) error { return fmt.Errorf("invalid contract: %w", err) } -// ContractToDomain converts PluginContract DTO to Plugin domain entity. -func ContractToDomain(contract *PluginContract) *internal.Plugin { +// ContractToDomain converts a PluginContract DTO to a Plugin domain entity and +// validates its module requirement groups (see validateModuleRequirements). An +// ill-formed contract is rejected rather than silently yielding a plugin whose +// requirements cannot be enforced. +func ContractToDomain(contract *PluginContract) (*internal.Plugin, error) { plugin := &internal.Plugin{ Name: contract.Name, Version: contract.Version, @@ -80,7 +85,11 @@ func ContractToDomain(contract *PluginContract) *internal.Plugin { Plugins: pluginGroupToDomain(contract.Requirements.Plugins), } - return plugin + if err := validateModuleRequirements(plugin.Requirements.Modules); err != nil { + return nil, err + } + + return plugin, nil } // DomainToContract converts Plugin domain entity to PluginContract DTO. @@ -143,35 +152,47 @@ func pluginReqsToDTO(reqs []internal.PluginRequirement) []PluginRequirementDTO { } func moduleGroupToDomain(g ModuleRequirementsGroupDTO) internal.ModuleRequirementsGroup { - anyOf := make([]internal.AnyOfGroup, 0, len(g.AnyOf)) - for _, grp := range g.AnyOf { - anyOf = append(anyOf, internal.AnyOfGroup{ - Description: grp.Description, - Modules: moduleReqsToDomain(grp.Modules), - }) - } - return internal.ModuleRequirementsGroup{ Mandatory: moduleReqsToDomain(g.Mandatory), Conditional: moduleReqsToDomain(g.Conditional), - AnyOf: anyOf, + AnyOf: moduleGroupsToDomain(g.AnyOf), + NoneOf: moduleGroupsToDomain(g.NoneOf), } } func moduleGroupToDTO(g internal.ModuleRequirementsGroup) ModuleRequirementsGroupDTO { - anyOf := make([]AnyOfGroupDTO, 0, len(g.AnyOf)) - for _, grp := range g.AnyOf { - anyOf = append(anyOf, AnyOfGroupDTO{ + return ModuleRequirementsGroupDTO{ + Mandatory: moduleReqsToDTO(g.Mandatory), + Conditional: moduleReqsToDTO(g.Conditional), + AnyOf: moduleGroupsToDTO(g.AnyOf), + NoneOf: moduleGroupsToDTO(g.NoneOf), + } +} + +func moduleGroupsToDomain(groups []ModuleGroupDTO) []internal.ModuleGroup { + out := make([]internal.ModuleGroup, 0, len(groups)) + for _, grp := range groups { + out = append(out, internal.ModuleGroup{ + Name: grp.Name, Description: grp.Description, - Modules: moduleReqsToDTO(grp.Modules), + Modules: moduleReqsToDomain(grp.Modules), }) } - return ModuleRequirementsGroupDTO{ - Mandatory: moduleReqsToDTO(g.Mandatory), - Conditional: moduleReqsToDTO(g.Conditional), - AnyOf: anyOf, + return out +} + +func moduleGroupsToDTO(groups []internal.ModuleGroup) []ModuleGroupDTO { + out := make([]ModuleGroupDTO, 0, len(groups)) + for _, grp := range groups { + out = append(out, ModuleGroupDTO{ + Name: grp.Name, + Description: grp.Description, + Modules: moduleReqsToDTO(grp.Modules), + }) } + + return out } func moduleReqsToDomain(reqs []ModuleRequirementDTO) []internal.ModuleRequirement { @@ -191,3 +212,114 @@ func moduleReqsToDTO(reqs []internal.ModuleRequirement) []ModuleRequirementDTO { return out } + +// validateModuleRequirements checks contract well-formedness for the module +// requirement groups, mirroring what the Deckhouse controller applies to a +// package.yaml (buildModuleGroups + validateBucketCollisions): each anyOf/noneOf +// group needs a unique, non-empty name and at least one member; members are +// unique within a group and carry valid semver constraints; and no module may +// appear in contradictory buckets. +func validateModuleRequirements(m internal.ModuleRequirementsGroup) error { + mandatory := moduleNameSet(m.Mandatory) + conditional := moduleNameSet(m.Conditional) + + for name := range conditional { + if _, dup := mandatory[name]; dup { + return fmt.Errorf("module %q appears in both mandatory and conditional", name) + } + } + + anyOfMembers, err := validateModuleGroups(m.AnyOf, "anyOf") + if err != nil { + return err + } + + noneOfMembers, err := validateModuleGroups(m.NoneOf, "noneOf") + if err != nil { + return err + } + + for member, group := range anyOfMembers { + if _, dup := mandatory[member]; dup { + return fmt.Errorf("module %q appears in both mandatory and anyOf group %q", member, group) + } + + if _, dup := conditional[member]; dup { + return fmt.Errorf("module %q appears in both conditional and anyOf group %q", member, group) + } + } + + for member, group := range noneOfMembers { + if _, dup := mandatory[member]; dup { + return fmt.Errorf("module %q appears in both mandatory and noneOf group %q", member, group) + } + + if _, dup := conditional[member]; dup { + return fmt.Errorf("module %q appears in both conditional and noneOf group %q", member, group) + } + + if anyGroup, dup := anyOfMembers[member]; dup { + return fmt.Errorf("module %q appears in both anyOf group %q and noneOf group %q", member, anyGroup, group) + } + } + + return nil +} + +// validateModuleGroups validates one bucket of anyOf/noneOf groups and returns a +// map of member module name to the group that declares it, for cross-bucket +// collision checks. bucket ("anyOf"/"noneOf") is woven into error messages. The +// same module across two distinct groups of one bucket is allowed. +func validateModuleGroups(groups []internal.ModuleGroup, bucket string) (map[string]string, error) { + seenGroups := make(map[string]struct{}, len(groups)) + members := make(map[string]string) + + for i, group := range groups { + if group.Name == "" { + return nil, fmt.Errorf("%s group [%d]: name is required", bucket, i) + } + + if _, dup := seenGroups[group.Name]; dup { + return nil, fmt.Errorf("%s group %q: duplicate group name", bucket, group.Name) + } + + seenGroups[group.Name] = struct{}{} + + if len(group.Modules) == 0 { + return nil, fmt.Errorf("%s group %q: at least one member is required", bucket, group.Name) + } + + seenMembers := make(map[string]struct{}, len(group.Modules)) + for _, member := range group.Modules { + if member.Name == "" { + return nil, fmt.Errorf("%s group %q: member name is required", bucket, group.Name) + } + + if _, dup := seenMembers[member.Name]; dup { + return nil, fmt.Errorf("%s group %q: duplicate member %q", bucket, group.Name, member.Name) + } + + seenMembers[member.Name] = struct{}{} + + if member.Constraint != "" { + if _, err := semver.NewConstraint(member.Constraint); err != nil { + return nil, fmt.Errorf("%s group %q member %q: invalid constraint %q: %w", + bucket, group.Name, member.Name, member.Constraint, err) + } + } + + members[member.Name] = group.Name + } + } + + return members, nil +} + +func moduleNameSet(reqs []internal.ModuleRequirement) map[string]struct{} { + out := make(map[string]struct{}, len(reqs)) + for _, r := range reqs { + out[r.Name] = struct{}{} + } + + return out +} diff --git a/pkg/registry/service/dto.go b/pkg/registry/service/dto.go index e8fb2644..a9d5bc20 100644 --- a/pkg/registry/service/dto.go +++ b/pkg/registry/service/dto.go @@ -66,10 +66,11 @@ type PluginRequirementDTO struct { Constraint string `json:"constraint"` } -// AnyOfGroupDTO represents an "at least one of" group of module requirements. -// The Description is surfaced in user-facing error messages when no module in -// the group satisfies the constraint. -type AnyOfGroupDTO struct { +// ModuleGroupDTO is a named group of module requirements (JSON DTO), shared by +// the anyOf and noneOf buckets. Name is a required, stable identifier used in +// diagnostics; Description is optional human-facing text. +type ModuleGroupDTO struct { + Name string `json:"name"` Description string `json:"description,omitempty"` Modules []ModuleRequirementDTO `json:"modules,omitempty"` } @@ -82,9 +83,10 @@ type PluginRequirementsGroupDTO struct { } // ModuleRequirementsGroupDTO splits module requirements into Mandatory, -// Conditional, and AnyOf sections. +// Conditional, AnyOf, and NoneOf sections. type ModuleRequirementsGroupDTO struct { Mandatory []ModuleRequirementDTO `json:"mandatory,omitempty"` Conditional []ModuleRequirementDTO `json:"conditional,omitempty"` - AnyOf []AnyOfGroupDTO `json:"anyOf,omitempty"` + AnyOf []ModuleGroupDTO `json:"anyOf,omitempty"` + NoneOf []ModuleGroupDTO `json:"noneOf,omitempty"` } diff --git a/pkg/registry/service/dto_test.go b/pkg/registry/service/dto_test.go index 3180839e..0caae483 100644 --- a/pkg/registry/service/dto_test.go +++ b/pkg/registry/service/dto_test.go @@ -24,7 +24,7 @@ import ( // TestPluginContract_V2FieldsParsed: a full v2 contract must populate every // new top-level field (deckhouse, plugins.{mandatory,conditional}, -// modules.{mandatory,conditional,anyOf}). Catches structural breakage of +// modules.{mandatory,conditional,anyOf,noneOf}). Catches structural breakage of // any new DTO field with one assertion per section. func TestPluginContract_V2FieldsParsed(t *testing.T) { in := []byte(`{ @@ -35,7 +35,8 @@ func TestPluginContract_V2FieldsParsed(t *testing.T) { "conditional":[{"name":"iam","constraint":">=1.0.0"}]}, "modules": {"mandatory":[{"name":"stronghold","constraint":">=1.0.0"}], "conditional":[{"name":"observability","constraint":">=1.0.0"}], - "anyOf":[{"description":"cni","modules":[{"name":"cni-flannel","constraint":">=1.5.0"}]}]} + "anyOf":[{"name":"cni","description":"cni","modules":[{"name":"cni-flannel","constraint":">=1.5.0"}]}], + "noneOf":[{"name":"legacy","description":"legacy","modules":[{"name":"cni-simple-bridge","constraint":"<1.0.0"}]}]} } }`) var c PluginContract @@ -54,6 +55,15 @@ func TestPluginContract_V2FieldsParsed(t *testing.T) { if len(c.Requirements.Modules.AnyOf) != 1 || len(c.Requirements.Modules.AnyOf[0].Modules) != 1 { t.Errorf("modules.anyOf not parsed: %+v", c.Requirements.Modules.AnyOf) } + if c.Requirements.Modules.AnyOf[0].Name != "cni" { + t.Errorf("modules.anyOf[0].name not parsed: %+v", c.Requirements.Modules.AnyOf) + } + if len(c.Requirements.Modules.NoneOf) != 1 || len(c.Requirements.Modules.NoneOf[0].Modules) != 1 { + t.Errorf("modules.noneOf not parsed: %+v", c.Requirements.Modules.NoneOf) + } + if c.Requirements.Modules.NoneOf[0].Name != "legacy" { + t.Errorf("modules.noneOf[0].name not parsed: %+v", c.Requirements.Modules.NoneOf) + } } // TestPluginContract_FlatArrayRejected: flat-array form for @@ -104,3 +114,99 @@ func TestUnmarshalContract_FriendlyArrayMessage(t *testing.T) { t.Errorf("error message still leaks internal Go type name:\n%s", msg) } } + +// TestContractToDomain_CarriesModuleGroups: a valid contract's anyOf and noneOf +// groups must reach the domain plugin the checker consumes, with name, member +// name, and constraint intact. Guards the parse -> domain link that the +// enforcement tests (which build the domain directly) do not exercise. +func TestContractToDomain_CarriesModuleGroups(t *testing.T) { + plugin, err := ContractToDomain(&PluginContract{ + Name: "p", + Version: "v1.0.0", + Requirements: RequirementsDTO{Modules: ModuleRequirementsGroupDTO{ + AnyOf: []ModuleGroupDTO{{ + Name: "cni", + Modules: []ModuleRequirementDTO{{Name: "cni-cilium", Constraint: ">= 1.0"}}, + }}, + NoneOf: []ModuleGroupDTO{{ + Name: "legacy", + Modules: []ModuleRequirementDTO{{Name: "cni-flannel", Constraint: "< 1.0"}}, + }}, + }}, + }) + if err != nil { + t.Fatalf("ContractToDomain: %v", err) + } + + anyOf := plugin.Requirements.Modules.AnyOf + if len(anyOf) != 1 || anyOf[0].Name != "cni" || + len(anyOf[0].Modules) != 1 || anyOf[0].Modules[0].Name != "cni-cilium" || + anyOf[0].Modules[0].Constraint != ">= 1.0" { + t.Errorf("anyOf not carried into domain: %+v", anyOf) + } + + noneOf := plugin.Requirements.Modules.NoneOf + if len(noneOf) != 1 || noneOf[0].Name != "legacy" || + len(noneOf[0].Modules) != 1 || noneOf[0].Modules[0].Name != "cni-flannel" || + noneOf[0].Modules[0].Constraint != "< 1.0" { + t.Errorf("noneOf not carried into domain: %+v", noneOf) + } +} + +// TestContractToDomain_RejectsInvalidGroups: ContractToDomain must reject an +// ill-formed contract, and the error must name the violated rule. Asserting the +// message pins each case to its rule instead of just "some error occurred". +func TestContractToDomain_RejectsInvalidGroups(t *testing.T) { + tests := []struct { + name string + giveModules ModuleRequirementsGroupDTO + wantMsg string + }{ + { + name: "group without name", + giveModules: ModuleRequirementsGroupDTO{NoneOf: []ModuleGroupDTO{{Modules: []ModuleRequirementDTO{{Name: "m"}}}}}, + wantMsg: "name is required", + }, + { + name: "duplicate group name", + giveModules: ModuleRequirementsGroupDTO{AnyOf: []ModuleGroupDTO{ + {Name: "g", Modules: []ModuleRequirementDTO{{Name: "a"}}}, + {Name: "g", Modules: []ModuleRequirementDTO{{Name: "b"}}}, + }}, + wantMsg: "duplicate group name", + }, + { + name: "module in both anyOf and noneOf", + giveModules: ModuleRequirementsGroupDTO{ + AnyOf: []ModuleGroupDTO{{Name: "a", Modules: []ModuleRequirementDTO{{Name: "shared"}}}}, + NoneOf: []ModuleGroupDTO{{Name: "n", Modules: []ModuleRequirementDTO{{Name: "shared"}}}}, + }, + wantMsg: "both anyOf group", + }, + { + name: "noneOf member also mandatory", + giveModules: ModuleRequirementsGroupDTO{ + Mandatory: []ModuleRequirementDTO{{Name: "shared"}}, + NoneOf: []ModuleGroupDTO{{Name: "n", Modules: []ModuleRequirementDTO{{Name: "shared"}}}}, + }, + wantMsg: "both mandatory and noneOf group", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ContractToDomain(&PluginContract{ + Name: "p", + Version: "v1.0.0", + Requirements: RequirementsDTO{Modules: tt.giveModules}, + }) + if err == nil { + t.Fatalf("expected error, got nil") + } + + if !strings.Contains(err.Error(), tt.wantMsg) { + t.Errorf("error %q does not mention %q", err.Error(), tt.wantMsg) + } + }) + } +}