Skip to content
Draft
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
162 changes: 162 additions & 0 deletions internal/plugin/cli_version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// Copyright 2026 DataRobot, Inc. and its affiliates.
//
// 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 plugin

import (
"fmt"

"github.com/Masterminds/semver/v3"
"github.com/datarobot/cli/internal/version"
)

// currentCLIVersion is a package-level seam for the running CLI version.
// It defaults to version.Version, but tests MUST override it (with a
// t.Cleanup/defer restore) rather than relying on the real value: in `go
// test`, version.Version is the literal "dev", which is an unparseable-CLI
// bypass under compatibleCLIVersion and would silently make every bound
// assertion pass regardless of the production logic.
var currentCLIVersion = version.Version

// coreVersion strips any prerelease/build metadata from v, returning a
// version containing only major.minor.patch. This keeps comparisons
// symmetric: without it, a prerelease running CLI (e.g. 1.2.0-rc.1) would
// compare as less than its own release (1.2.0) under semver ordering, so it
// would fail a maxCLIVersion bound equal to its own release.
func coreVersion(v *semver.Version) *semver.Version {
return semver.New(v.Major(), v.Minor(), v.Patch(), "", "")
}

// compatibleCLIVersion reports whether cliVersion satisfies the inclusive
// [minBound, maxBound] range. Either bound may be empty, meaning
// unconstrained on that side. Comparison uses core versions (major.minor.
// patch) only, so a prerelease CLI version is judged by its release version.
//
// A malformed minBound or maxBound ALWAYS causes a skip (returns false with
// a non-nil error) — this is checked before the CLI version is even parsed,
// so it takes precedence over the dev/unparseable-CLI-version bypass below.
//
// An unparseable cliVersion (including the default "dev" build) is treated
// as a bypass: once the bounds themselves are confirmed well-formed, the
// plugin loads unconditionally, since there is no reliable CLI version to
// compare against.
func compatibleCLIVersion(cliVersion, minBound, maxBound string) (bool, error) {
if minBound == "" && maxBound == "" {
return true, nil
}

minVer, err := parseCLIVersionBound("minCLIVersion", minBound)
if err != nil {
return false, err
}

maxVer, err := parseCLIVersionBound("maxCLIVersion", maxBound)
if err != nil {
return false, err
}

cli, err := semver.NewVersion(cliVersion)
if err != nil {
// Unparseable/dev running CLI version: bypass now that the declared
// bounds are confirmed well-formed.
return true, nil
}

return versionWithinBounds(cli, minVer, maxVer), nil
}

// parseCLIVersionBound parses a declared minCLIVersion/maxCLIVersion value.
// An empty value is unconstrained (nil, nil). field names the manifest
// field in the returned error, for callers to surface an actionable message.
func parseCLIVersionBound(field, value string) (*semver.Version, error) {
if value == "" {
return nil, nil
}

v, err := semver.NewVersion(value)
if err != nil {
return nil, fmt.Errorf("invalid %s %q: %w", field, value, err)
}

return v, nil
}

// versionWithinBounds reports whether cli's core version falls within the
// inclusive [minVer, maxVer] range. Either bound may be nil, meaning
// unconstrained on that side.
func versionWithinBounds(cli, minVer, maxVer *semver.Version) bool {
core := coreVersion(cli)

if minVer != nil && core.LessThan(coreVersion(minVer)) {
return false
}

if maxVer != nil && core.GreaterThan(coreVersion(maxVer)) {
return false
}

return true
}

// cliVersionSkip evaluates manifest's declared CLI version bounds against
// currentCLIVersion and returns a PluginConflict describing the skip when
// the plugin is not compatible, or nil when it may load. path identifies the
// executable (or managed plugin dir) being evaluated, for reporting.
func cliVersionSkip(manifest *PluginManifest, path string) *PluginConflict {
ok, err := compatibleCLIVersion(currentCLIVersion, manifest.MinCLIVersion, manifest.MaxCLIVersion)
if err == nil && ok {
return nil
}

var detail string

switch {
case err != nil:
detail = err.Error()
case manifest.MinCLIVersion != "" && manifest.MaxCLIVersion != "":
// Both bounds declared and the combined check failed: report the
// bound that actually breached, preferring the minimum since a
// version cannot violate both an inclusive min and an inclusive max
// unless the manifest's own range is inverted.
minOK, _ := compatibleCLIVersion(currentCLIVersion, manifest.MinCLIVersion, "")
if !minOK {
detail = fmt.Sprintf(
"requires dr >= %s (running %s); run 'dr self update'",
manifest.MinCLIVersion, currentCLIVersion,
)
} else {
detail = fmt.Sprintf(
"supports dr <= %s (running %s); update the plugin",
manifest.MaxCLIVersion, currentCLIVersion,
)
}
case manifest.MinCLIVersion != "":
detail = fmt.Sprintf(
"requires dr >= %s (running %s); run 'dr self update'",
manifest.MinCLIVersion, currentCLIVersion,
)
default:
detail = fmt.Sprintf(
"supports dr <= %s (running %s); update the plugin",
manifest.MaxCLIVersion, currentCLIVersion,
)
}

return &PluginConflict{
Name: manifest.Name,
Path: path,
Reason: SkipReasonVersionIncompatible,
Detail: detail,
}
}
236 changes: 236 additions & 0 deletions internal/plugin/cli_version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
// Copyright 2026 DataRobot, Inc. and its affiliates.
//
// 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 plugin

import (
"testing"

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

func TestCompatibleCLIVersion(t *testing.T) {
tests := []struct {
name string
cliVersion string
minBound string
maxBound string
wantOK bool
wantErr bool
}{
{
name: "no bounds declared",
cliVersion: "1.0.0",
minBound: "",
maxBound: "",
wantOK: true,
},
{
name: "min-only bound satisfied above minimum",
cliVersion: "1.5.0",
minBound: "1.0.0",
maxBound: "",
wantOK: true,
},
{
name: "min-only bound satisfied at inclusive boundary",
cliVersion: "1.0.0",
minBound: "1.0.0",
maxBound: "",
wantOK: true,
},
{
name: "min-only bound violated below minimum",
cliVersion: "1.9.0",
minBound: "2.0.0",
maxBound: "",
wantOK: false,
},
{
name: "max-only bound satisfied at inclusive boundary",
cliVersion: "2.3.0",
minBound: "",
maxBound: "2.3.0",
wantOK: true,
},
{
name: "max-only bound violated above maximum",
cliVersion: "2.3.1",
minBound: "",
maxBound: "2.3.0",
wantOK: false,
},
{
name: "both bounds satisfied",
cliVersion: "1.5.0",
minBound: "1.0.0",
maxBound: "2.0.0",
wantOK: true,
},
{
name: "prerelease CLI version compared by core version",
cliVersion: "1.2.0-rc.1",
minBound: "",
maxBound: "1.2.0",
wantOK: true,
},
{
name: "dev CLI version bypasses well-formed bounds",
cliVersion: "dev",
minBound: "2.0.0",
maxBound: "",
wantOK: true,
},
{
name: "unparseable CLI version bypasses well-formed bounds",
cliVersion: "not-a-semver",
minBound: "",
maxBound: "1.0.0",
wantOK: true,
},
{
name: "malformed min bound is always a skip",
cliVersion: "1.5.0",
minBound: "1.x",
maxBound: "",
wantOK: false,
wantErr: true,
},
{
name: "malformed max bound is always a skip",
cliVersion: "1.5.0",
minBound: "",
maxBound: "2.x",
wantOK: false,
wantErr: true,
},
{
name: "malformed bound takes precedence over dev CLI bypass",
cliVersion: "dev",
minBound: "1.x",
maxBound: "",
wantOK: false,
wantErr: true,
},
{
name: "malformed bound takes precedence over unparseable CLI bypass",
cliVersion: "garbage",
minBound: "",
maxBound: "2.x",
wantOK: false,
wantErr: true,
},
{
name: "v-prefixed bounds are accepted",
cliVersion: "1.5.0",
minBound: "v1.0.0",
maxBound: "v2.0.0",
wantOK: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ok, err := compatibleCLIVersion(tt.cliVersion, tt.minBound, tt.maxBound)

if tt.wantErr {
require.Error(t, err)
} else {
require.NoError(t, err)
}

assert.Equal(t, tt.wantOK, ok)
})
}
}

func TestCliVersionSkip(t *testing.T) {
t.Run("no bounds returns nil", func(t *testing.T) {
restore := setCurrentCLIVersionForTest(t, "1.5.0")
defer restore()

manifest := &PluginManifest{BasicPluginManifest: BasicPluginManifest{Name: "widget"}}

assert.Nil(t, cliVersionSkip(manifest, "/usr/local/bin/dr-widget"))
})

t.Run("below minimum returns a version-incompatible conflict naming the upgrade path", func(t *testing.T) {
restore := setCurrentCLIVersionForTest(t, "1.9.0")
defer restore()

manifest := &PluginManifest{
BasicPluginManifest: BasicPluginManifest{Name: "widget"},
MinCLIVersion: "2.0.0",
}

conflict := cliVersionSkip(manifest, "/usr/local/bin/dr-widget")

require.NotNil(t, conflict)
assert.Equal(t, "widget", conflict.Name)
assert.Equal(t, "/usr/local/bin/dr-widget", conflict.Path)
assert.Equal(t, SkipReasonVersionIncompatible, conflict.Reason)
assert.Contains(t, conflict.Detail, "2.0.0")
assert.Contains(t, conflict.Detail, "1.9.0")
assert.Contains(t, conflict.Detail, "dr self update")
})

t.Run("above maximum returns a version-incompatible conflict naming the plugin update", func(t *testing.T) {
restore := setCurrentCLIVersionForTest(t, "1.6.0")
defer restore()

manifest := &PluginManifest{
BasicPluginManifest: BasicPluginManifest{Name: "widget"},
MaxCLIVersion: "1.5.0",
}

conflict := cliVersionSkip(manifest, "/usr/local/bin/dr-widget")

require.NotNil(t, conflict)
assert.Equal(t, SkipReasonVersionIncompatible, conflict.Reason)
assert.Contains(t, conflict.Detail, "1.5.0")
assert.Contains(t, conflict.Detail, "1.6.0")
assert.Contains(t, conflict.Detail, "update the plugin")
})

t.Run("malformed bound returns a version-incompatible conflict naming the field", func(t *testing.T) {
restore := setCurrentCLIVersionForTest(t, "1.6.0")
defer restore()

manifest := &PluginManifest{
BasicPluginManifest: BasicPluginManifest{Name: "widget"},
MaxCLIVersion: "1.x",
}

conflict := cliVersionSkip(manifest, "/usr/local/bin/dr-widget")

require.NotNil(t, conflict)
assert.Equal(t, SkipReasonVersionIncompatible, conflict.Reason)
assert.Contains(t, conflict.Detail, "maxCLIVersion")
assert.Contains(t, conflict.Detail, "1.x")
})
}

// setCurrentCLIVersionForTest overrides the currentCLIVersion seam for the
// duration of a test and returns a func that restores the prior value.
// version.Version is "dev" under `go test`, which would otherwise bypass
// every bound check and make these assertions vacuous.
func setCurrentCLIVersionForTest(t *testing.T, v string) func() {
t.Helper()

prev := currentCLIVersion
currentCLIVersion = v

return func() { currentCLIVersion = prev }
}
Loading
Loading