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) } }