diff --git a/internal/plugin.go b/internal/plugin.go
index 0854dc42..597861c4 100644
--- a/internal/plugin.go
+++ b/internal/plugin.go
@@ -36,11 +36,15 @@ type Flag struct {
Name string
}
-// Requirements represents plugin dependencies
+// Requirements represents plugin dependencies (v2 schema).
+//
+// v1 schema: flat array of plugin and module requirements
+// v2 schema: structured groups (with matchers anyOf, mandatory, conditional) + deckhouse
type Requirements struct {
Kubernetes KubernetesRequirement
- Modules []ModuleRequirement
- Plugins []PluginRequirement
+ Deckhouse DeckhouseRequirement
+ Modules ModuleRequirementsGroup
+ Plugins PluginRequirementsGroup
}
// KubernetesRequirement represents Kubernetes version constraint
@@ -48,6 +52,11 @@ type KubernetesRequirement struct {
Constraint string
}
+// DeckhouseRequirement represents a constraint on the Deckhouse cluster version.
+type DeckhouseRequirement struct {
+ Constraint string
+}
+
// ModuleRequirement represents a required Deckhouse module
type ModuleRequirement struct {
Name string
@@ -59,3 +68,28 @@ type PluginRequirement struct {
Name string
Constraint string
}
+
+// AnyOfGroup represents an "at least one of" group of module requirements.
+// Description is used in user-facing error messages.
+type AnyOfGroup struct {
+ Description string
+ Modules []ModuleRequirement
+}
+
+// PluginRequirementsGroup splits plugin requirements into Mandatory and Conditional.
+// - Mandatory: the dependent plugin must be installed AND satisfy the constraint.
+// - Conditional: only enforced if the dependent plugin is installed; otherwise skipped.
+type PluginRequirementsGroup struct {
+ Mandatory []PluginRequirement
+ Conditional []PluginRequirement
+}
+
+// ModuleRequirementsGroup splits module requirements into Mandatory, Conditional, and AnyOf.
+// - Mandatory: the module must be in the cluster AND satisfy the constraint.
+// - Conditional: only enforced if the module is in the cluster.
+// - AnyOf: at least one module per group must be in the cluster and satisfy its constraint.
+type ModuleRequirementsGroup struct {
+ Mandatory []ModuleRequirement
+ Conditional []ModuleRequirement
+ AnyOf []AnyOfGroup
+}
diff --git a/internal/plugins/cmd/validators.go b/internal/plugins/cmd/validators.go
index 7694e7ee..498c88e3 100644
--- a/internal/plugins/cmd/validators.go
+++ b/internal/plugins/cmd/validators.go
@@ -136,24 +136,32 @@ func (pc *PluginsCommand) fetchLatestVersion(ctx context.Context, pluginName str
type FailedConstraints map[string]*semver.Constraints
func (pc *PluginsCommand) validateRequirements(plugin *internal.Plugin) (FailedConstraints, error) {
- // validate plugin requirements
pc.logger.Debug("validating plugin requirements", slog.String("plugin", plugin.Name))
- err := pc.validatePluginConflicts(plugin)
- if err != nil {
+ if err := pc.validatePluginConflicts(plugin); err != nil {
return nil, fmt.Errorf("plugin conflicts: %w", err)
}
- failedConstraints, err := pc.validatePluginRequirement(plugin)
+ failedConstraints, err := pc.validatePluginRequirementMandatory(plugin)
if err != nil {
- return nil, fmt.Errorf("plugin requirements: %w", err)
+ return nil, fmt.Errorf("plugin requirements (mandatory): %w", err)
}
- // validate module requirements
- pc.logger.Debug("validating module requirements", slog.String("plugin", plugin.Name))
+ if err := pc.validatePluginRequirementConditional(plugin); err != nil {
+ return nil, fmt.Errorf("plugin requirements (conditional): %w", err)
+ }
- err = pc.validateModuleRequirement(plugin)
- if err != nil {
+ // Cluster-side requirements - currently log-only, no enforcement.
+ // Real validation lands when cluster connectivity is added.
+ if err := pc.validateKubernetesRequirement(plugin); err != nil {
+ return nil, fmt.Errorf("kubernetes requirement: %w", err)
+ }
+ if err := pc.validateDeckhouseRequirement(plugin); err != nil {
+ return nil, fmt.Errorf("deckhouse requirement: %w", err)
+ }
+
+ pc.logger.Debug("validating module requirements", slog.String("plugin", plugin.Name))
+ if err := pc.validateModuleRequirement(plugin); err != nil {
return nil, fmt.Errorf("module requirements: %w", err)
}
@@ -189,75 +197,219 @@ func (pc *PluginsCommand) validatePluginConflicts(plugin *internal.Plugin) error
return nil
}
+// validatePluginConflict checks whether installing `plugin` violates any
+// constraint that the already-installed `installedPlugin` places on it.
+//
+// Both Mandatory and Conditional sections of installedPlugin's requirements
+// are inspected - if an existing plugin requires us, we must satisfy its
+// constraint regardless of whether the requirement is mandatory or conditional.
func validatePluginConflict(plugin *internal.Plugin, installedPlugin *internal.Plugin) error {
- for _, requirement := range installedPlugin.Requirements.Plugins {
- // installed plugin requirement is the same as the plugin we are validating
- if requirement.Name == plugin.Name {
- constraint, err := semver.NewConstraint(requirement.Constraint)
- if err != nil {
- return fmt.Errorf("failed to parse constraint: %w", err)
- }
-
- version, err := semver.NewVersion(installedPlugin.Version)
- if err != nil {
- return fmt.Errorf("failed to parse version: %w", err)
- }
-
- if !constraint.Check(version) {
- return fmt.Errorf("installing plugin %s %s will make conflict with existing plugin %s %s",
- plugin.Name,
- plugin.Version,
- installedPlugin.Name,
- constraint.String())
- }
+ candidates := make([]internal.PluginRequirement, 0,
+ len(installedPlugin.Requirements.Plugins.Mandatory)+len(installedPlugin.Requirements.Plugins.Conditional))
+ candidates = append(candidates, installedPlugin.Requirements.Plugins.Mandatory...)
+ candidates = append(candidates, installedPlugin.Requirements.Plugins.Conditional...)
+
+ for _, requirement := range candidates {
+ if requirement.Name != plugin.Name {
+ continue
+ }
+ constraint, err := semver.NewConstraint(requirement.Constraint)
+ if err != nil {
+ return fmt.Errorf("failed to parse constraint: %w", err)
+ }
+ // Check the NEW plugin's version against the constraint -
+ // not installedPlugin.Version (that was a long-standing bug).
+ version, err := semver.NewVersion(plugin.Version)
+ if err != nil {
+ return fmt.Errorf("failed to parse version: %w", err)
+ }
+ if !constraint.Check(version) {
+ return fmt.Errorf("installing plugin %s %s conflicts with existing plugin %s which requires %s %s",
+ plugin.Name,
+ plugin.Version,
+ installedPlugin.Name,
+ plugin.Name,
+ constraint.String())
}
}
return nil
}
-func (pc *PluginsCommand) validatePluginRequirement(plugin *internal.Plugin) (FailedConstraints, error) {
+// validatePluginRequirementMandatory enforces mandatory plugin requirements:
+//
+// For mandatory requirements:
+
+// - if the dependency is not installed, record a soft failure in FailedConstraints
+// - if the dependency is installed but fails the constraint, record a soft failure in FailedConstraints
+// - return a non-nil error only for operational failures such as install checks,
+// version lookup failures, or invalid version constraints
+func (pc *PluginsCommand) validatePluginRequirementMandatory(plugin *internal.Plugin) (FailedConstraints, error) {
result := make(FailedConstraints)
- for _, pluginRequirement := range plugin.Requirements.Plugins {
- // check if plugin is installed
+ for _, pluginRequirement := range plugin.Requirements.Plugins.Mandatory {
installed, err := pc.checkInstalled(pluginRequirement.Name)
if err != nil {
return nil, fmt.Errorf("failed to check if plugin is installed: %w", err)
}
if !installed {
+ pc.logger.Warn("plugin requirement not installed",
+ slog.String("plugin", plugin.Name),
+ slog.String("requirement", pluginRequirement.Name))
result[pluginRequirement.Name] = nil
continue
}
-
- // check constraint
- if pluginRequirement.Constraint != "" {
- installedVersion, err := pc.getInstalledPluginVersion(pluginRequirement.Name)
- if err != nil {
- return nil, fmt.Errorf("failed to get installed version: %w", err)
- }
-
- constraint, err := semver.NewConstraint(pluginRequirement.Constraint)
- if err != nil {
- return nil, fmt.Errorf("failed to parse constraint: %w", err)
- }
-
- if !constraint.Check(installedVersion) {
- pc.logger.Warn("plugin requirement not satisfied",
- slog.String("plugin", plugin.Name),
- slog.String("requirement", pluginRequirement.Name),
- slog.String("constraint", pluginRequirement.Constraint),
- slog.String("installedVersion", installedVersion.Original()))
-
- result[pluginRequirement.Name] = constraint
- }
+ if pluginRequirement.Constraint == "" {
+ continue
+ }
+ installedVersion, err := pc.getInstalledPluginVersion(pluginRequirement.Name)
+ if err != nil {
+ return nil, fmt.Errorf("failed to get installed version: %w", err)
+ }
+ constraint, err := semver.NewConstraint(pluginRequirement.Constraint)
+ if err != nil {
+ return nil, fmt.Errorf("failed to parse constraint: %w", err)
+ }
+ if !constraint.Check(installedVersion) {
+ pc.logger.Warn("plugin requirement not satisfied",
+ slog.String("plugin", plugin.Name),
+ slog.String("requirement", pluginRequirement.Name),
+ slog.String("constraint", pluginRequirement.Constraint),
+ slog.String("installedVersion", installedVersion.Original()))
+ result[pluginRequirement.Name] = constraint
}
}
return result, nil
}
-func (pc *PluginsCommand) validateModuleRequirement(_ *internal.Plugin) error {
- // TODO: Implement module requirement validation
+// validatePluginRequirementConditional enforces conditional plugin requirements:
+//
+// For conditional requirements:
+// - if the dependency is not installed, skip silently;
+// - if the dependency is installed but fails the constraint, return a hard error
+func (pc *PluginsCommand) validatePluginRequirementConditional(plugin *internal.Plugin) error {
+ for _, pluginRequirement := range plugin.Requirements.Plugins.Conditional {
+ installed, err := pc.checkInstalled(pluginRequirement.Name)
+ if err != nil {
+ return fmt.Errorf("failed to check if plugin is installed: %w", err)
+ }
+ if !installed {
+ continue
+ }
+ if pluginRequirement.Constraint == "" {
+ continue
+ }
+ installedVersion, err := pc.getInstalledPluginVersion(pluginRequirement.Name)
+ if err != nil {
+ return fmt.Errorf("failed to get installed version: %w", err)
+ }
+ constraint, err := semver.NewConstraint(pluginRequirement.Constraint)
+ if err != nil {
+ return fmt.Errorf("failed to parse constraint: %w", err)
+ }
+ if !constraint.Check(installedVersion) {
+ return fmt.Errorf("conditional plugin requirement not satisfied: plugin %s %s installed but %s requires %s",
+ pluginRequirement.Name,
+ installedVersion.Original(),
+ plugin.Name,
+ pluginRequirement.Constraint)
+ }
+ }
+ return nil
+}
+
+// debugPrintPendingRequirement prints a human-readable warning for a declared
+// requirement that d8 currently surfaces but does not enforce.
+//
+// Until the requirement enforcement is implemented, we print a warning to the user.
+//
+// Example:
+//
+// !
+// key1: value1
+// key2: value2
+func debugPrintPendingRequirement(title string, kv ...[2]string) {
+ fmt.Fprintf(os.Stderr, "! %s\n", title)
+ for _, p := range kv {
+ fmt.Fprintf(os.Stderr, " %s: %s\n", p[0], p[1])
+ }
+}
+
+// validateKubernetesRequirement is a log-only stub (yet).
+//
+// For Kubernetes requirement:
+// - if the constraint is empty, skip silently
+// - if the constraint is not empty, print a warning
+//
+//nolint:unparam // stub — will return real errors once enforcement is implemented
+func (pc *PluginsCommand) validateKubernetesRequirement(plugin *internal.Plugin) error {
+ if plugin.Requirements.Kubernetes.Constraint == "" {
+ return nil
+ }
+ debugPrintPendingRequirement(
+ "plugin declares a Kubernetes version requirement but enforcement is not implemented yet",
+ [2]string{"plugin", plugin.Name},
+ [2]string{"constraint", plugin.Requirements.Kubernetes.Constraint},
+ )
+ return nil
+}
+
+// validateDeckhouseRequirement is a log-only stub. See validateKubernetesRequirement
+// for rationale; enforcement will land once Deckhouse version discovery is in place.
+//
+//nolint:unparam // stub — will return real errors once enforcement is implemented
+func (pc *PluginsCommand) validateDeckhouseRequirement(plugin *internal.Plugin) error {
+ if plugin.Requirements.Deckhouse.Constraint == "" {
+ return nil
+ }
+ debugPrintPendingRequirement(
+ "plugin declares a Deckhouse version requirement but enforcement is not implemented yet",
+ [2]string{"plugin", plugin.Name},
+ [2]string{"constraint", plugin.Requirements.Deckhouse.Constraint},
+ )
+ return nil
+}
+
+// validateModuleRequirement is a log-only stub. Mandatory, Conditional and
+// AnyOf sections are all surfaced so authors and operators see the declared
+// expectations even though d8 does not yet inspect the cluster to verify them.
+//
+//nolint:unparam // stub — will return real errors once enforcement is implemented
+func (pc *PluginsCommand) validateModuleRequirement(plugin *internal.Plugin) error {
+ mods := plugin.Requirements.Modules
+ if len(mods.Mandatory) == 0 && len(mods.Conditional) == 0 && len(mods.AnyOf) == 0 {
+ return nil
+ }
+
+ for _, m := range mods.Mandatory {
+ debugPrintPendingRequirement(
+ "plugin declares a mandatory module requirement but enforcement is not implemented yet",
+ [2]string{"plugin", plugin.Name},
+ [2]string{"module", m.Name},
+ [2]string{"constraint", m.Constraint},
+ )
+ }
+ for _, m := range mods.Conditional {
+ debugPrintPendingRequirement(
+ "plugin declares a conditional module requirement but enforcement is not implemented yet",
+ [2]string{"plugin", plugin.Name},
+ [2]string{"module", m.Name},
+ [2]string{"constraint", m.Constraint},
+ )
+ }
+ for i, grp := range mods.AnyOf {
+ names := make([]string, 0, len(grp.Modules))
+ for _, m := range grp.Modules {
+ names = append(names, m.Name)
+ }
+ debugPrintPendingRequirement(
+ "plugin declares an anyOf module group but enforcement is not implemented yet",
+ [2]string{"plugin", plugin.Name},
+ [2]string{"group_index", fmt.Sprintf("%d", i)},
+ [2]string{"group_description", grp.Description},
+ [2]string{"modules", strings.Join(names, ", ")},
+ )
+ }
return nil
}
diff --git a/internal/plugins/cmd/validators_test.go b/internal/plugins/cmd/validators_test.go
new file mode 100644
index 00000000..323d2295
--- /dev/null
+++ b/internal/plugins/cmd/validators_test.go
@@ -0,0 +1,97 @@
+/*
+Copyright 2025 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 plugins
+
+import (
+ "testing"
+
+ "github.com/deckhouse/deckhouse-cli/internal"
+)
+
+// TestValidatePluginConflict_BugRegression covers the historical bug where the
+// reverse conflict check compared the *installed* plugin's version against its
+// own constraint on the new plugin (a tautology). With the fix the constraint
+// is checked against the NEW plugin's version, which is the only thing that
+// matters when deciding whether the install is compatible.
+//
+// Pre-fix scenario the bug silently passed:
+// - plugin-A v2.5.0 installed; requires "foo ^2.0.0"
+// - installing foo v5.0.0
+// - bug: check A's 2.5.0 vs "^2.0.0" → satisfied → no error (WRONG)
+// - fix: check foo's 5.0.0 vs "^2.0.0" → not satisfied → error (CORRECT)
+func TestValidatePluginConflict_BugRegression(t *testing.T) {
+ installedA := &internal.Plugin{
+ Name: "plugin-a",
+ Version: "v2.5.0",
+ Requirements: internal.Requirements{
+ Plugins: internal.PluginRequirementsGroup{
+ Mandatory: []internal.PluginRequirement{
+ {Name: "foo", Constraint: "^2.0.0"},
+ },
+ },
+ },
+ }
+ newFoo := &internal.Plugin{Name: "foo", Version: "v5.0.0"}
+
+ if err := validatePluginConflict(newFoo, installedA); err == nil {
+ t.Fatal("expected conflict error (bug regression): installing foo v5.0.0 violates plugin-a's '^2.0.0' constraint, but pre-fix code missed it")
+ }
+}
+
+// TestValidatePluginConflict_NoConflictWhenSatisfies covers the happy path:
+// when the new plugin's version satisfies the existing requirement, no error.
+func TestValidatePluginConflict_NoConflictWhenSatisfies(t *testing.T) {
+ installedA := &internal.Plugin{
+ Name: "plugin-a",
+ Version: "v1.0.0",
+ Requirements: internal.Requirements{
+ Plugins: internal.PluginRequirementsGroup{
+ Mandatory: []internal.PluginRequirement{
+ {Name: "foo", Constraint: "^2.0.0"},
+ },
+ },
+ },
+ }
+ newFoo := &internal.Plugin{Name: "foo", Version: "v2.5.0"}
+
+ if err := validatePluginConflict(newFoo, installedA); err != nil {
+ t.Errorf("expected no conflict (v2.5.0 satisfies ^2.0.0), got: %v", err)
+ }
+}
+
+// TestValidatePluginConflict_DetectsConditional ensures that constraints
+// declared under .Conditional also trigger the conflict check - the section
+// describes intent (mandatory vs conditional from the installer's perspective)
+// but for backwards compatibility on the conflict side both must be honoured.
+func TestValidatePluginConflict_DetectsConditional(t *testing.T) {
+ installedA := &internal.Plugin{
+ Name: "plugin-a",
+ Version: "v1.0.0",
+ Requirements: internal.Requirements{
+ Plugins: internal.PluginRequirementsGroup{
+ Conditional: []internal.PluginRequirement{
+ {Name: "foo", Constraint: "^2.0.0"},
+ },
+ },
+ },
+ }
+ newFoo := &internal.Plugin{Name: "foo", Version: "v5.0.0"}
+
+ if err := validatePluginConflict(newFoo, installedA); err == nil {
+ t.Fatal("expected conflict error for .Conditional section, got nil")
+ }
+}
diff --git a/pkg/registry/service/dto.go b/pkg/registry/service/dto.go
index ef0109e8..e8fb2644 100644
--- a/pkg/registry/service/dto.go
+++ b/pkg/registry/service/dto.go
@@ -36,11 +36,12 @@ type FlagDTO struct {
Name string `json:"name"`
}
-// RequirementsDTO represents requirements in JSON
+// RequirementsDTO represents requirements in JSON.
type RequirementsDTO struct {
- Kubernetes KubernetesRequirementDTO `json:"kubernetes,omitempty"`
- Modules []ModuleRequirementDTO `json:"modules,omitempty"`
- Plugins []PluginRequirementDTO `json:"plugins,omitempty"`
+ Kubernetes KubernetesRequirementDTO `json:"kubernetes,omitempty"`
+ Deckhouse DeckhouseRequirementDTO `json:"deckhouse,omitempty"`
+ Modules ModuleRequirementsGroupDTO `json:"modules,omitempty"`
+ Plugins PluginRequirementsGroupDTO `json:"plugins,omitempty"`
}
// KubernetesRequirementDTO represents Kubernetes requirement in JSON
@@ -48,6 +49,11 @@ type KubernetesRequirementDTO struct {
Constraint string `json:"constraint"`
}
+// DeckhouseRequirementDTO represents a constraint on the running Deckhouse version.
+type DeckhouseRequirementDTO struct {
+ Constraint string `json:"constraint"`
+}
+
// ModuleRequirementDTO represents module requirement in JSON
type ModuleRequirementDTO struct {
Name string `json:"name"`
@@ -59,3 +65,26 @@ type PluginRequirementDTO struct {
Name string `json:"name"`
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 {
+ Description string `json:"description,omitempty"`
+ Modules []ModuleRequirementDTO `json:"modules,omitempty"`
+}
+
+// PluginRequirementsGroupDTO splits plugin requirements into Mandatory and
+// Conditional sections.
+type PluginRequirementsGroupDTO struct {
+ Mandatory []PluginRequirementDTO `json:"mandatory,omitempty"`
+ Conditional []PluginRequirementDTO `json:"conditional,omitempty"`
+}
+
+// ModuleRequirementsGroupDTO splits module requirements into Mandatory,
+// Conditional, and AnyOf sections.
+type ModuleRequirementsGroupDTO struct {
+ Mandatory []ModuleRequirementDTO `json:"mandatory,omitempty"`
+ Conditional []ModuleRequirementDTO `json:"conditional,omitempty"`
+ AnyOf []AnyOfGroupDTO `json:"anyOf,omitempty"`
+}
diff --git a/pkg/registry/service/dto_test.go b/pkg/registry/service/dto_test.go
new file mode 100644
index 00000000..cac8acef
--- /dev/null
+++ b/pkg/registry/service/dto_test.go
@@ -0,0 +1,106 @@
+/*
+Copyright 2025 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 service
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+// 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
+// any new DTO field with one assertion per section.
+func TestPluginContract_V2FieldsParsed(t *testing.T) {
+ in := []byte(`{
+ "name":"stronghold","version":"v1.2.3","description":"x",
+ "requirements":{
+ "deckhouse":{"constraint":">=1.76"},
+ "plugins": {"mandatory":[{"name":"delivery","constraint":">=1.0.0"}],
+ "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"}]}]}
+ }
+ }`)
+ var c PluginContract
+ if err := json.Unmarshal(in, &c); err != nil {
+ t.Fatalf("unmarshal v2: %v", err)
+ }
+ if c.Requirements.Deckhouse.Constraint != ">=1.76" {
+ t.Errorf("deckhouse not parsed: %+v", c.Requirements.Deckhouse)
+ }
+ if len(c.Requirements.Plugins.Mandatory) != 1 || len(c.Requirements.Plugins.Conditional) != 1 {
+ t.Errorf("plugins not split: %+v", c.Requirements.Plugins)
+ }
+ if len(c.Requirements.Modules.Mandatory) != 1 || len(c.Requirements.Modules.Conditional) != 1 {
+ t.Errorf("modules.mandatory/conditional not parsed: %+v", c.Requirements.Modules)
+ }
+ 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)
+ }
+}
+
+// TestPluginContract_FlatArrayRejected: flat-array form for
+// requirements.modules / requirements.plugins isn't part of the schema
+// and must surface as an unmarshal error rather than silently coercing
+// into one of the mandatory/conditional sections.
+func TestPluginContract_FlatArrayRejected(t *testing.T) {
+ in := []byte(`{
+ "name":"x","version":"v1.0.0","description":"x",
+ "requirements":{
+ "modules":[{"name":"m","constraint":">=1.0.0"}],
+ "plugins":[{"name":"p","constraint":">=1.0.0"}]
+ }
+ }`)
+ var c PluginContract
+ if err := json.Unmarshal(in, &c); err == nil {
+ t.Fatal("expected unmarshal error for flat-array contract, got nil")
+ }
+}
+
+// TestUnmarshalContract_FriendlyArrayMessage: when a flat-array contract
+// hits the production unmarshal path (unmarshalContract, used by both the
+// OCI-annotation and file-load code paths), the error must be a
+// user-actionable "invalid contract" message naming the offending field
+// and the expected shape, NOT the raw encoding/json reflect-soup.
+func TestUnmarshalContract_FriendlyArrayMessage(t *testing.T) {
+ in := []byte(`{
+ "name":"x","version":"v1.0.0",
+ "requirements":{"modules":[{"name":"m","constraint":">=1.0.0"}]}
+ }`)
+ var c PluginContract
+ err := unmarshalContract(in, &c)
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ msg := err.Error()
+ for _, want := range []string{
+ "invalid contract",
+ `"requirements.modules"`,
+ "mandatory/conditional sections",
+ "got a JSON array",
+ } {
+ if !strings.Contains(msg, want) {
+ t.Errorf("error message missing %q\ngot: %s", want, msg)
+ }
+ }
+ if strings.Contains(msg, "ModuleRequirementsGroupDTO") {
+ t.Errorf("error message still leaks internal Go type name:\n%s", msg)
+ }
+}
diff --git a/pkg/registry/service/plugin_service.go b/pkg/registry/service/plugin_service.go
index 086e6441..feeaaa89 100644
--- a/pkg/registry/service/plugin_service.go
+++ b/pkg/registry/service/plugin_service.go
@@ -21,6 +21,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
+ "errors"
"fmt"
"io"
"log/slog"
@@ -127,9 +128,8 @@ func (s *PluginService) GetPluginContract(ctx context.Context, pluginName, tag s
s.log.Debug("Contract raw retrieved successfully", slog.String("contractraw", string(contractRaw)))
contract := new(PluginContract)
- err = json.Unmarshal(contractRaw, contract)
- if err != nil {
- return nil, fmt.Errorf("failed to unmarshal contract: %w", err)
+ if err := unmarshalContract(contractRaw, contract); err != nil {
+ return nil, err
}
s.log.Debug("Plugin contract parsed successfully", slog.String("plugin", pluginName), slog.String("tag", tag), slog.String("name", contract.Name), slog.String("version", contract.Version))
@@ -138,6 +138,36 @@ func (s *PluginService) GetPluginContract(ctx context.Context, pluginName, tag s
return ContractToDomain(contract), nil
}
+// unmarshalContract decodes raw JSON into a PluginContract and rewrites
+// encoding/json's verbose default errors as user-actionable messages.
+// Used by every caller that turns a contract blob into a domain object so
+// the wording stays identical whether the source is an OCI annotation or
+// a local file.
+func unmarshalContract(raw []byte, dst *PluginContract) error {
+ err := json.Unmarshal(raw, dst)
+ if err == nil {
+ return nil
+ }
+
+ var typeErr *json.UnmarshalTypeError
+ if errors.As(err, &typeErr) {
+ if (typeErr.Field == "requirements.modules" || typeErr.Field == "requirements.plugins") && typeErr.Value == "array" {
+ return fmt.Errorf("invalid contract: field %q must be an object with mandatory/conditional sections, got a JSON array", typeErr.Field)
+ }
+ if typeErr.Field != "" {
+ return fmt.Errorf("invalid contract: field %q has wrong JSON type (got %s)", typeErr.Field, typeErr.Value)
+ }
+ return fmt.Errorf("invalid contract: wrong JSON type (got %s)", typeErr.Value)
+ }
+
+ var syntaxErr *json.SyntaxError
+ if errors.As(err, &syntaxErr) {
+ return fmt.Errorf("invalid contract: malformed JSON at byte offset %d", syntaxErr.Offset)
+ }
+
+ return fmt.Errorf("invalid contract: %w", err)
+}
+
// GetPluginContractFromFile reads the plugin contract from a file
func GetPluginContractFromFile(contractFilePath string) (*internal.Plugin, error) {
contractBytes, err := os.ReadFile(contractFilePath)
@@ -146,9 +176,8 @@ func GetPluginContractFromFile(contractFilePath string) (*internal.Plugin, error
}
contract := new(PluginContract)
- err = json.Unmarshal(contractBytes, contract)
- if err != nil {
- return nil, fmt.Errorf("failed to unmarshal contract: %w", err)
+ if err := unmarshalContract(contractBytes, contract); err != nil {
+ return nil, err
}
return ContractToDomain(contract), nil
@@ -226,41 +255,18 @@ func ContractToDomain(contract *PluginContract) *internal.Plugin {
Flags: make([]internal.Flag, 0, len(contract.Flags)),
}
- // Convert env vars
for _, envDTO := range contract.Env {
- plugin.Env = append(plugin.Env, internal.EnvVar{
- Name: envDTO.Name,
- })
+ plugin.Env = append(plugin.Env, internal.EnvVar{Name: envDTO.Name})
}
-
- // Convert flags
for _, flagDTO := range contract.Flags {
- plugin.Flags = append(plugin.Flags, internal.Flag{
- Name: flagDTO.Name,
- })
+ plugin.Flags = append(plugin.Flags, internal.Flag{Name: flagDTO.Name})
}
- // Convert requirements
plugin.Requirements = internal.Requirements{
- Kubernetes: internal.KubernetesRequirement{
- Constraint: contract.Requirements.Kubernetes.Constraint,
- },
- Modules: make([]internal.ModuleRequirement, 0, len(contract.Requirements.Modules)),
- Plugins: make([]internal.PluginRequirement, 0, len(contract.Requirements.Plugins)),
- }
-
- for _, modDTO := range contract.Requirements.Modules {
- plugin.Requirements.Modules = append(plugin.Requirements.Modules, internal.ModuleRequirement{
- Name: modDTO.Name,
- Constraint: modDTO.Constraint,
- })
- }
-
- for _, pluginDTO := range contract.Requirements.Plugins {
- plugin.Requirements.Plugins = append(plugin.Requirements.Plugins, internal.PluginRequirement{
- Name: pluginDTO.Name,
- Constraint: pluginDTO.Constraint,
- })
+ Kubernetes: internal.KubernetesRequirement{Constraint: contract.Requirements.Kubernetes.Constraint},
+ Deckhouse: internal.DeckhouseRequirement{Constraint: contract.Requirements.Deckhouse.Constraint},
+ Modules: moduleGroupToDomain(contract.Requirements.Modules),
+ Plugins: pluginGroupToDomain(contract.Requirements.Plugins),
}
return plugin
@@ -275,41 +281,97 @@ func DomainToContract(plugin *internal.Plugin) *PluginContract {
Env: make([]EnvVarDTO, 0, len(plugin.Env)),
Flags: make([]FlagDTO, 0, len(plugin.Flags)),
Requirements: RequirementsDTO{
- Kubernetes: KubernetesRequirementDTO{
- Constraint: plugin.Requirements.Kubernetes.Constraint,
- },
- Modules: make([]ModuleRequirementDTO, 0, len(plugin.Requirements.Modules)),
- Plugins: make([]PluginRequirementDTO, 0, len(plugin.Requirements.Plugins)),
+ Kubernetes: KubernetesRequirementDTO{Constraint: plugin.Requirements.Kubernetes.Constraint},
+ Deckhouse: DeckhouseRequirementDTO{Constraint: plugin.Requirements.Deckhouse.Constraint},
+ Modules: moduleGroupToDTO(plugin.Requirements.Modules),
+ Plugins: pluginGroupToDTO(plugin.Requirements.Plugins),
},
}
for _, env := range plugin.Env {
- contract.Env = append(contract.Env, EnvVarDTO{
- Name: env.Name,
- })
+ contract.Env = append(contract.Env, EnvVarDTO{Name: env.Name})
}
-
for _, flag := range plugin.Flags {
- contract.Flags = append(contract.Flags, FlagDTO{
- Name: flag.Name,
- })
+ contract.Flags = append(contract.Flags, FlagDTO{Name: flag.Name})
}
- for _, mod := range plugin.Requirements.Modules {
- contract.Requirements.Modules = append(contract.Requirements.Modules, ModuleRequirementDTO{
- Name: mod.Name,
- Constraint: mod.Constraint,
+ return contract
+}
+
+func pluginGroupToDomain(g PluginRequirementsGroupDTO) internal.PluginRequirementsGroup {
+ return internal.PluginRequirementsGroup{
+ Mandatory: pluginReqsToDomain(g.Mandatory),
+ Conditional: pluginReqsToDomain(g.Conditional),
+ }
+}
+
+func pluginGroupToDTO(g internal.PluginRequirementsGroup) PluginRequirementsGroupDTO {
+ return PluginRequirementsGroupDTO{
+ Mandatory: pluginReqsToDTO(g.Mandatory),
+ Conditional: pluginReqsToDTO(g.Conditional),
+ }
+}
+
+func pluginReqsToDomain(reqs []PluginRequirementDTO) []internal.PluginRequirement {
+ out := make([]internal.PluginRequirement, 0, len(reqs))
+ for _, r := range reqs {
+ out = append(out, internal.PluginRequirement{Name: r.Name, Constraint: r.Constraint})
+ }
+ return out
+}
+
+func pluginReqsToDTO(reqs []internal.PluginRequirement) []PluginRequirementDTO {
+ out := make([]PluginRequirementDTO, 0, len(reqs))
+ for _, r := range reqs {
+ out = append(out, PluginRequirementDTO{Name: r.Name, Constraint: r.Constraint})
+ }
+ return out
+}
+
+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,
+ }
+}
- for _, plugin := range plugin.Requirements.Plugins {
- contract.Requirements.Plugins = append(contract.Requirements.Plugins, PluginRequirementDTO{
- Name: plugin.Name,
- Constraint: plugin.Constraint,
+func moduleGroupToDTO(g internal.ModuleRequirementsGroup) ModuleRequirementsGroupDTO {
+ anyOf := make([]AnyOfGroupDTO, 0, len(g.AnyOf))
+ for _, grp := range g.AnyOf {
+ anyOf = append(anyOf, AnyOfGroupDTO{
+ Description: grp.Description,
+ Modules: moduleReqsToDTO(grp.Modules),
})
}
+ return ModuleRequirementsGroupDTO{
+ Mandatory: moduleReqsToDTO(g.Mandatory),
+ Conditional: moduleReqsToDTO(g.Conditional),
+ AnyOf: anyOf,
+ }
+}
- return contract
+func moduleReqsToDomain(reqs []ModuleRequirementDTO) []internal.ModuleRequirement {
+ out := make([]internal.ModuleRequirement, 0, len(reqs))
+ for _, r := range reqs {
+ out = append(out, internal.ModuleRequirement{Name: r.Name, Constraint: r.Constraint})
+ }
+ return out
+}
+
+func moduleReqsToDTO(reqs []internal.ModuleRequirement) []ModuleRequirementDTO {
+ out := make([]ModuleRequirementDTO, 0, len(reqs))
+ for _, r := range reqs {
+ out = append(out, ModuleRequirementDTO{Name: r.Name, Constraint: r.Constraint})
+ }
+ return out
}
// ListPlugins lists all available plugin names from the registry
diff --git a/pkg/registry/service/plugin_service_test.go b/pkg/registry/service/plugin_service_test.go
index 4dd2d909..ad3fbc00 100644
--- a/pkg/registry/service/plugin_service_test.go
+++ b/pkg/registry/service/plugin_service_test.go
@@ -168,7 +168,7 @@ func TestGetPluginContract_Success(t *testing.T) {
// Arrange
mc := minimock.NewController(t)
- contractJSON := `{"name": "test-plugin", "version": "v1.0.0", "description": "A test plugin", "env": [{"name": "TEST_ENV"}], "flags": [{"name": "--test-flag"}], "requirements": {"kubernetes": {"constraint": ">= 1.26"}, "modules": [{"name": "test-module", "constraint": ">= 1.0.0"}]}}`
+ contractJSON := `{"name": "test-plugin", "version": "v1.0.0", "description": "A test plugin", "env": [{"name": "TEST_ENV"}], "flags": [{"name": "--test-flag"}], "requirements": {"kubernetes": {"constraint": ">= 1.26"}, "modules": {"mandatory": [{"name": "test-module", "constraint": ">= 1.0.0"}]}}}`
contractB64 := base64.StdEncoding.EncodeToString([]byte(contractJSON))
manifestJSON := `{"annotations": {"` + service.PluginContractAnnotation + `": "` + contractB64 + `"}}`
@@ -221,16 +221,16 @@ func TestGetPluginContract_Success(t *testing.T) {
t.Errorf("Expected kubernetes constraint '>= 1.26', got '%s'", plugin.Requirements.Kubernetes.Constraint)
}
- if len(plugin.Requirements.Modules) != 1 {
- t.Fatalf("Expected 1 module requirement, got %d", len(plugin.Requirements.Modules))
+ if len(plugin.Requirements.Modules.Mandatory) != 1 {
+ t.Fatalf("Expected 1 mandatory module requirement, got %d", len(plugin.Requirements.Modules.Mandatory))
}
- if plugin.Requirements.Modules[0].Name != "test-module" {
- t.Errorf("Expected module name 'test-module', got '%s'", plugin.Requirements.Modules[0].Name)
+ if plugin.Requirements.Modules.Mandatory[0].Name != "test-module" {
+ t.Errorf("Expected module name 'test-module', got '%s'", plugin.Requirements.Modules.Mandatory[0].Name)
}
- if plugin.Requirements.Modules[0].Constraint != ">= 1.0.0" {
- t.Errorf("Expected module constraint '>= 1.0.0', got '%s'", plugin.Requirements.Modules[0].Constraint)
+ if plugin.Requirements.Modules.Mandatory[0].Constraint != ">= 1.0.0" {
+ t.Errorf("Expected module constraint '>= 1.0.0', got '%s'", plugin.Requirements.Modules.Mandatory[0].Constraint)
}
}