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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/commands/delivery.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ import (
"github.com/werf/werf/v2/pkg/storage/synchronization/server"
)

// DeliveryKitCommandName is the top-level command name for the built-in werf
// re-skin. A plugin contract uses this same name to depend on delivery-kit
// while it ships as a built-in command rather than a standalone plugin.
const DeliveryKitCommandName = "delivery-kit"

func NewDeliveryCommand() (*cobra.Command, context.Context) {
server.DefaultAddress = "https://delivery-sync.deckhouse.ru"

Expand All @@ -49,7 +54,7 @@ func NewDeliveryCommand() (*cobra.Command, context.Context) {
return nil, ctx
}

werfRootCmd.Use = "delivery-kit"
werfRootCmd.Use = DeliveryKitCommandName
werfRootCmd.Aliases = []string{werfAlias}
werfRootCmd = ReplaceCommandName("werf", fmt.Sprintf("d8 %s", werfAlias), werfRootCmd)
werfRootCmd.Short = "A set of tools for building, distributing, and deploying containerized applications"
Expand Down
2 changes: 1 addition & 1 deletion cmd/d8/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ func (r *RootCommand) registerCommands() {

r.cmd.AddCommand(packagecmd.NewCommand())

r.cmd.AddCommand(pluginscmd.NewCommand(r.logger.Named("plugins-command")))
r.cmd.AddCommand(pluginscmd.NewCommand(r.logger.Named("plugins-command"), []string{commands.DeliveryKitCommandName}))

r.cmd.AddCommand(selfupdatecmd.NewCommand(r.logger.Named("cli-command")))
}
Expand Down
14 changes: 9 additions & 5 deletions internal/plugins/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,15 @@ the plugin routes are `/v1/images/deckhouse-cli/plugins/<name>/...`.

## What a plugin image contains

- `plugin` - the executable;
- `contract.yaml` - the contract: name, version, description, requested env
vars, flags, and `requirements` (Kubernetes / Deckhouse / modules / plugins).

The RPP source reads the `contract.yaml` file from the image tar.
- `plugin` - the executable, in the image layers;
- the contract - name, version, description, requested env vars, flags, and
`requirements` (Kubernetes / Deckhouse / modules / plugins) - published as a
base64-JSON `contract` annotation on the image manifest.

The RPP source fetches the raw image manifest over the proxy `manifests/<ref>`
route (a single manifest fetch, no layer pull) and reads the contract from its
base64-JSON `contract` annotation itself. The binary is pulled separately (full
image, over the `images/<version>` route) only when a plugin is installed.

## On-disk layout

Expand Down
44 changes: 44 additions & 0 deletions internal/plugins/builtins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
Copyright 2026 Flant JSC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package plugins

// SetBuiltinCommands records the d8 built-in command names that satisfy a plugin
// dependency of the same name. It bridges capabilities that ship as a built-in
// command but are not yet published as standalone plugins (e.g. delivery-kit):
// such a dependency counts as satisfied by the command's mere presence, with no
// on-disk install and no registry lookup.
func (m *Manager) SetBuiltinCommands(names []string) {
if len(names) == 0 {
m.builtins = nil

return
}

m.builtins = make(map[string]struct{}, len(names))
for _, name := range names {
m.builtins[name] = struct{}{}
}
}

// isBuiltinCommand reports whether name is provided by a built-in command and so
// satisfies a plugin dependency on it. Version constraints are not enforced: a
// built-in cannot be upgraded, so its presence alone satisfies the dependency.
func (m *Manager) isBuiltinCommand(name string) bool {
_, ok := m.builtins[name]

return ok
}
151 changes: 151 additions & 0 deletions internal/plugins/builtins_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*
Copyright 2026 Flant JSC

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package plugins

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/deckhouse/deckhouse-cli/internal"
)

// A built-in command satisfies a mandatory plugin dependency by presence. The
// dependency name is marked unpublished, so if resolution ever reached the
// registry it would fail with reasonDepNotPublished; the built-in short-circuit
// must avoid that lookup entirely. This is the delivery-kit bridge: it ships as a
// built-in command, not a plugin.
func TestPlannerBuiltinSatisfiesMandatoryDep(t *testing.T) {
src := &multiPluginSource{
unpublished: map[string]bool{"delivery-kit": true},
contracts: map[string]map[string]*internal.Plugin{},
}
m := plannerManager(t, src)
m.SetBuiltinCommands([]string{"delivery-kit"})

top := requires("package", "v1.0.0", "delivery-kit", ">= 1.0.0")

plan, reason, err := m.planFor(context.Background(), top, false)
require.NoError(t, err)
require.Nil(t, reason, "a built-in command satisfies the dependency")
require.NotNil(t, plan)
assert.NotContains(t, planStepVersions(plan), "delivery-kit",
"a built-in is not installed, so it is never planned")
}

// presence-only: a version constraint on a built-in dependency is not enforced,
// since a built-in cannot be upgraded.
func TestPlannerBuiltinIgnoresVersionConstraint(t *testing.T) {
src := &multiPluginSource{
unpublished: map[string]bool{"delivery-kit": true},
contracts: map[string]map[string]*internal.Plugin{},
}
m := plannerManager(t, src)
m.SetBuiltinCommands([]string{"delivery-kit"})

// An unsatisfiable constraint still passes - presence is enough.
top := requires("package", "v1.0.0", "delivery-kit", ">= 999.0.0")

_, reason, err := m.planFor(context.Background(), top, false)
require.NoError(t, err)
assert.Nil(t, reason, "presence satisfies the dependency regardless of the constraint")
}

// Without the built-in registered, the same contract is unsatisfiable: the
// dependency is looked up in the registry and reported unpublished. This is the
// contrast that proves the built-in short-circuit is what changes the outcome.
func TestPlannerUnregisteredBuiltinStillUnresolved(t *testing.T) {
src := &multiPluginSource{
unpublished: map[string]bool{"delivery-kit": true},
contracts: map[string]map[string]*internal.Plugin{},
}
m := plannerManager(t, src) // no SetBuiltinCommands

top := requires("package", "v1.0.0", "delivery-kit", "")

plan, reason, err := m.planFor(context.Background(), top, false)
require.NoError(t, err)
require.Nil(t, plan)
require.NotNil(t, reason, "an unregistered, unpublished dependency is unsatisfiable")
assert.Equal(t, reasonDepNotPublished, reason.kind)
}

// The final pre-switch guard also treats a built-in as satisfying a mandatory
// requirement: no soft failure is recorded, so the install is not rejected after
// the binary swap.
func TestValidateMandatoryRequirementSatisfiedByBuiltin(t *testing.T) {
m := testManager()
m.pluginDirectory = t.TempDir()
m.SetBuiltinCommands([]string{"delivery-kit"})

plugin := &internal.Plugin{
Name: "package",
Version: "v1.0.0",
Requirements: internal.Requirements{
Plugins: internal.PluginRequirementsGroup{
Mandatory: []internal.PluginRequirement{{Name: "delivery-kit", Constraint: ">= 1.0.0"}},
},
},
}

failed, err := m.validatePluginRequirementMandatory(plugin)
require.NoError(t, err)
assert.Empty(t, failed, "a built-in command satisfies the mandatory requirement")
}

// Without the built-in registered, the same mandatory requirement is recorded as
// missing - the regression guard for the validator change.
func TestValidateMandatoryRequirementMissingWithoutBuiltin(t *testing.T) {
m := testManager()
m.pluginDirectory = t.TempDir() // empty install root: nothing installed

plugin := &internal.Plugin{
Name: "package",
Version: "v1.0.0",
Requirements: internal.Requirements{
Plugins: internal.PluginRequirementsGroup{
Mandatory: []internal.PluginRequirement{{Name: "delivery-kit", Constraint: ""}},
},
},
}

failed, err := m.validatePluginRequirementMandatory(plugin)
require.NoError(t, err)
assert.Contains(t, failed, "delivery-kit", "an unregistered, uninstalled dependency is missing")
}

// A conditional requirement on a built-in is satisfied (a no-op), never a hard
// error - even with a version constraint that is not enforced for a built-in.
func TestValidateConditionalRequirementSatisfiedByBuiltin(t *testing.T) {
m := testManager()
m.pluginDirectory = t.TempDir()
m.SetBuiltinCommands([]string{"delivery-kit"})

plugin := &internal.Plugin{
Name: "package",
Version: "v1.0.0",
Requirements: internal.Requirements{
Plugins: internal.PluginRequirementsGroup{
Conditional: []internal.PluginRequirement{{Name: "delivery-kit", Constraint: ">= 1.0.0"}},
},
},
}

require.NoError(t, m.validatePluginRequirementConditional(plugin))
}
6 changes: 5 additions & 1 deletion internal/plugins/cmd/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,12 @@ import (
)

// NewCommand returns the `d8 plugins` command tree for managing plugins.
func NewCommand(logger *dkplog.Logger) *cobra.Command {
// builtinCommands are built-in command names that satisfy a plugin dependency of
// the same name (e.g. delivery-kit) until such capabilities ship as standalone
// plugins.
func NewCommand(logger *dkplog.Logger, builtinCommands []string) *cobra.Command {
manager := plugins.NewManager(logger)
manager.SetBuiltinCommands(builtinCommands)

cmd := &cobra.Command{
Use: "plugins",
Expand Down
2 changes: 1 addition & 1 deletion internal/plugins/cmd/plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ func TestPluginsDirFlagIsHonored(t *testing.T) {

dir := t.TempDir() + "/custom-root"

cmd := NewCommand(dkplog.NewNop())
cmd := NewCommand(dkplog.NewNop(), nil)
cmd.SetContext(context.Background())
cmd.SetArgs([]string{"list", "--plugins-dir", dir})
cmd.SetOut(io.Discard)
Expand Down
6 changes: 3 additions & 3 deletions internal/plugins/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ limitations under the License.
// # What d8-cli can do with plugins
//
// - Download them.
// - Validate their dependencies (requirements declared in contract.yaml that plugins declare).
// - Validate their dependencies (requirements the plugin declares in its contract).
// - Run them as if they were native subcommands.
//
// # Where a plugin lives
//
// A plugin lives in the cluster's OCI registry, reached exclusively through the
// in-cluster registry-packages-proxy. The image carries the plugin binary and a
// contract.yaml file that describes the plugin:
// in-cluster registry-packages-proxy. The image carries the plugin binary in its
// layers and a contract, published as a manifest annotation, that describes the plugin:
//
// - name;
// - version;
Expand Down
15 changes: 8 additions & 7 deletions internal/plugins/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ func TestInstallPluginSmokeFailureRollsBack(t *testing.T) {
assert.True(t, os.IsNotExist(binErr), "the broken binary is removed on rollback (fresh install)")
}

func TestInstallPluginDoesNotDowngradeOnContractError(t *testing.T) {
func TestInstallPluginHardStopsOnContractError(t *testing.T) {
root := t.TempDir()
m := testManager()
m.pluginDirectory = root
Expand All @@ -315,8 +315,9 @@ func TestInstallPluginDoesNotDowngradeOnContractError(t *testing.T) {
src := &fakeInstallSource{
tags: []string{"v1.2.0", "v1.3.0"},
contractByTag: map[string]*internal.Plugin{
// v1.3.0 is deliberately absent: its contract fetch fails transiently,
// so selection falls back to v1.2.0.
// v1.3.0 is deliberately absent: its contract fetch fails (operational
// error). That must stop selection, not silently demote to v1.2.0 - a
// missing contract is signalled separately (without an error).
"v1.2.0": {Name: "p", Version: "v1.2.0"},
},
extract: func(dest string) error {
Expand All @@ -336,14 +337,14 @@ func TestInstallPluginDoesNotDowngradeOnContractError(t *testing.T) {
require.NoError(t, err)
require.NoError(t, os.Symlink(absV1, layout.CurrentLinkPath(root, "p")))

require.NoError(t, m.InstallPlugin(context.Background(), "p"),
"a transient contract error on the newest tag is not an install failure")
require.Error(t, m.InstallPlugin(context.Background(), "p"),
"an operational contract error must stop selection, not silently demote")

assert.False(t, extractCalled, "the older selection must not be installed over a newer installed version")
assert.False(t, extractCalled, "nothing is installed when contract resolution fails")

version, err := pluginBinaryVersion(context.Background(), v1)
require.NoError(t, err)
assert.Equal(t, "1.3.0", version.String(), "the installed newer version is kept, not silently downgraded")
assert.Equal(t, "1.3.0", version.String(), "the installed version is left untouched")
}

func TestValidateInstalledRequirementsFailsOnUnsatisfiedDep(t *testing.T) {
Expand Down
17 changes: 17 additions & 0 deletions internal/plugins/planner.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"maps"
"os"
"slices"
Expand Down Expand Up @@ -242,6 +243,12 @@ func (m *Manager) reverseConflictReason(plugin *internal.Plugin) (*unsatisfiable
// out-of-constraint conditional dependency makes the candidate unsatisfiable (a
// conditional dependency is not auto-upgraded).
func (m *Manager) conditionalReason(req internal.PluginRequirement, plan *resolutionPlan) (*unsatisfiableReason, error) {
if m.isBuiltinCommand(req.Name) {
// Provided by a built-in command: the conditional dependency is satisfied;
// its version constraint is not enforced for a built-in.
return nil, nil
}

version, present, err := m.effectiveVersion(req.Name, plan)
if err != nil {
return nil, err
Expand Down Expand Up @@ -278,6 +285,16 @@ func (m *Manager) resolveMandatoryDep(
depth int,
path []string,
) (*unsatisfiableReason, error) {
if m.isBuiltinCommand(req.Name) {
// A built-in command of this name provides the capability: the dependency
// is satisfied by presence. A built-in cannot be upgraded, so any version
// constraint is not enforced; no plan step, no registry lookup.
m.logger.Debug("mandatory dependency satisfied by built-in command",
slog.String("plugin_dependency", req.Name))

return nil, nil
}

if visited[req.Name] {
return &unsatisfiableReason{kind: reasonDepCycle, pluginName: req.Name, path: append(slices.Clone(path), req.Name), detail: "dependency cycle"}, nil
}
Expand Down
6 changes: 6 additions & 0 deletions internal/plugins/plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,12 @@ type Manager struct {
// listing to one registry call per plugin within a command run.
tagsCache map[string][]string

// builtins are d8 built-in command names that satisfy a plugin dependency of
// the same name by their mere presence - a bridge for capabilities not yet
// shipped as standalone plugins (e.g. delivery-kit). Set via
// SetBuiltinCommands; nil for Managers that never resolve dependencies.
builtins map[string]struct{}

logger *dkplog.Logger
}

Expand Down
Loading
Loading