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
26 changes: 18 additions & 8 deletions api/v1alpha1/claw_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ const (
ConditionTypeWebSearchConfigured = "WebSearchConfigured"
ConditionTypeIdle = "Idle"
ConditionTypeRestrictionsEnforced = "RestrictionsEnforced"
ConditionTypePluginCompatibility = "PluginCompatibility"
ConditionTypeVersionDowngrade = "VersionDowngrade"
)

// Annotation keys used on pod templates to trigger rollouts on config changes.
Expand All @@ -97,14 +99,17 @@ const (

// Condition reasons for Claw status.
const (
ConditionReasonReady = "Ready"
ConditionReasonProvisioning = "Provisioning"
ConditionReasonResolved = "Resolved"
ConditionReasonValidationFailed = "ValidationFailed"
ConditionReasonConfigured = "Configured"
ConditionReasonConfigFailed = "ConfigFailed"
ConditionReasonIdle = "Idle"
ConditionReasonIdledByRequest = "IdledByRequest"
ConditionReasonReady = "Ready"
ConditionReasonProvisioning = "Provisioning"
ConditionReasonResolved = "Resolved"
ConditionReasonValidationFailed = "ValidationFailed"
ConditionReasonConfigured = "Configured"
ConditionReasonConfigFailed = "ConfigFailed"
ConditionReasonIdle = "Idle"
ConditionReasonIdledByRequest = "IdledByRequest"
ConditionReasonIncompatible = "Incompatible"
ConditionReasonVersionDowngrade = "VersionDowngrade"
ConditionReasonInitContainerFailure = "InitContainerFailure"
)

// SecretRefEntry references a specific key in a Secret.
Expand Down Expand Up @@ -743,6 +748,11 @@ type ClawStatus struct {
// GatewayURL is the HTTPS URL for accessing the Claw gateway, including the auth token fragment when applicable
// +optional
GatewayURL string `json:"gatewayURL,omitempty"`

// LastDeployedVersion records the spec.version that was last successfully deployed.
// Used to detect version downgrades that may cause PVC data incompatibility.
// +optional
LastDeployedVersion string `json:"lastDeployedVersion,omitempty"`
}

// +kubebuilder:object:root=true
Expand Down
5 changes: 5 additions & 0 deletions config/crd/bases/claw.sandbox.redhat.com_claws.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,11 @@ spec:
description: GatewayURL is the HTTPS URL for accessing the Claw gateway,
including the auth token fragment when applicable
type: string
lastDeployedVersion:
description: |-
LastDeployedVersion records the spec.version that was last successfully deployed.
Used to detect version downgrades that may cause PVC data incompatibility.
type: string
url:
description: |-
Deprecated: Use GatewayURL instead. Will be removed in a future version.
Expand Down
1 change: 1 addition & 0 deletions config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ rules:
resources:
- pods
verbs:
- get
- list
- apiGroups:
- ""
Expand Down
70 changes: 70 additions & 0 deletions internal/controller/claw_plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package controller

import (
"fmt"
"strconv"
"strings"

"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
Expand Down Expand Up @@ -256,3 +257,72 @@ func pluginsNoProxy(instance *clawv1alpha1.Claw) string {
}
return base
}

// compareCalver compares two calver version strings (e.g. "2026.6.5").
// Returns -1 if a < b, 0 if a == b, 1 if a > b.
// The bool is false when either string is malformed (non-numeric segments).
func compareCalver(a, b string) (int, bool) {
aParts := strings.Split(a, ".")
bParts := strings.Split(b, ".")

maxLen := len(aParts)
if len(bParts) > maxLen {
maxLen = len(bParts)
}

for i := range maxLen {
var aVal, bVal int
var err error
if i < len(aParts) {
aVal, err = strconv.Atoi(aParts[i])
if err != nil {
return 0, false
}
}
if i < len(bParts) {
bVal, err = strconv.Atoi(bParts[i])
if err != nil {
return 0, false
}
}
if aVal < bVal {
return -1, true
}
if aVal > bVal {
return 1, true
}
}
return 0, true
}

// checkPluginCompatibility checks whether any implicitly required plugin
// has a minimum version that exceeds spec.version. Returns a warning
// message or "" if all plugins are compatible.
func checkPluginCompatibility(instance *clawv1alpha1.Claw) string {
if instance.Spec.Version == "" {
return ""
}
for _, cred := range instance.Spec.Credentials {
if !usesVertexSDK(cred) {
continue
}
defaults, ok := knownProviders[cred.Provider]
if !ok || defaults.VertexPlugin == "" || defaults.PluginMinVersion == "" {
continue
}
cmp, ok := compareCalver(instance.Spec.Version, defaults.PluginMinVersion)
if !ok {
return fmt.Sprintf(
"cannot check plugin compatibility: spec.version %q is not a valid CalVer string",
instance.Spec.Version,
)
}
if cmp < 0 {
return fmt.Sprintf(
"plugin %s requires OpenClaw >= %s, but spec.version is %s",
defaults.VertexPlugin, defaults.PluginMinVersion, instance.Spec.Version,
)
}
}
return ""
}
98 changes: 93 additions & 5 deletions internal/controller/claw_plugins_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,17 +260,15 @@ func TestConfigurePluginsInitContainer(t *testing.T) {
)
pluginInit := initContainers[3].(map[string]any)
volumeMounts := pluginInit["volumeMounts"].([]any)
require.Len(t, volumeMounts, 5)
require.Len(t, volumeMounts, 3)

mountPaths := make(map[string]string)
for _, vm := range volumeMounts {
m := vm.(map[string]any)
mountPaths[m["mountPath"].(string)] = m["name"].(string)
}

assert.Equal(t, "claw-home", mountPaths["/home/node/.openclaw"])
assert.Equal(t, "claw-home", mountPaths["/home/node/.local"])
assert.Equal(t, "claw-home", mountPaths["/home/node/.cache"])
assert.Equal(t, "claw-home", mountPaths["/home/node"])
assert.Equal(t, "proxy-ca", mountPaths["/etc/proxy-ca"])
assert.Equal(t, "tmp-volume", mountPaths["/tmp"])
})
Expand Down Expand Up @@ -639,7 +637,7 @@ func TestPluginsIntegration(t *testing.T) {
for _, vm := range ic.VolumeMounts {
mountPaths[vm.MountPath] = vm.Name
}
assert.Equal(t, "claw-home", mountPaths["/home/node/.openclaw"])
assert.Equal(t, "claw-home", mountPaths["/home/node"])
assert.Equal(t, "proxy-ca", mountPaths["/etc/proxy-ca"])
assert.Equal(t, "tmp-volume", mountPaths["/tmp"])
break
Expand Down Expand Up @@ -794,3 +792,93 @@ func TestPluginsIntegration(t *testing.T) {
assert.NotEqual(t, hash1, hash2, "config hash should change when plugins change")
})
}

const (
testVersionOld = "2026.6.5"
testVersionMinPlugin = "2026.6.8"
)

func TestCompareCalver(t *testing.T) {
tests := []struct {
name string
a, b string
want int
wantOK bool
}{
{"a less than b", testVersionOld, testVersionMinPlugin, -1, true},
{"a greater than b", testVersionMinPlugin, testVersionOld, 1, true},
{"equal", testVersionMinPlugin, testVersionMinPlugin, 0, true},
{"numeric not lexicographic", "2026.10.1", "2026.9.30", 1, true},
{"year difference", "2027.1.1", "2026.12.31", 1, true},
{"different segment count", "2026.6", "2026.6.0", 0, true},
{"malformed a", "invalid", testVersionMinPlugin, 0, false},
{"malformed b", testVersionOld, "bad", 0, false},
{"both empty", "", "", 0, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := compareCalver(tt.a, tt.b)
assert.Equal(t, tt.want, got)
assert.Equal(t, tt.wantOK, ok)
})
}
}

func TestCheckPluginCompatibility(t *testing.T) {
t.Run("no version set", func(t *testing.T) {
instance := &clawv1alpha1.Claw{}
instance.Spec.Credentials = []clawv1alpha1.CredentialSpec{
{Name: "vertex", Type: clawv1alpha1.CredentialTypeGCP, Provider: "anthropic",
GCP: &clawv1alpha1.GCPConfig{Project: "proj"}},
}
assert.Empty(t, checkPluginCompatibility(instance))
})

t.Run("compatible version", func(t *testing.T) {
instance := &clawv1alpha1.Claw{}
instance.Spec.Version = testVersionMinPlugin
instance.Spec.Credentials = []clawv1alpha1.CredentialSpec{
{Name: "vertex", Type: clawv1alpha1.CredentialTypeGCP, Provider: "anthropic",
GCP: &clawv1alpha1.GCPConfig{Project: "proj"}},
}
assert.Empty(t, checkPluginCompatibility(instance))
})

t.Run("incompatible version", func(t *testing.T) {
instance := &clawv1alpha1.Claw{}
instance.Spec.Version = testVersionOld
instance.Spec.Credentials = []clawv1alpha1.CredentialSpec{
{Name: "vertex", Type: clawv1alpha1.CredentialTypeGCP, Provider: "anthropic",
GCP: &clawv1alpha1.GCPConfig{Project: "proj"}},
}
result := checkPluginCompatibility(instance)
assert.Contains(t, result, testVersionMinPlugin)
assert.Contains(t, result, testVersionOld)
assert.Contains(t, result, "@openclaw/anthropic-vertex-provider")
})

t.Run("non-vertex credential", func(t *testing.T) {
instance := &clawv1alpha1.Claw{}
instance.Spec.Version = testVersionOld
instance.Spec.Credentials = []clawv1alpha1.CredentialSpec{
{Name: "api", Type: clawv1alpha1.CredentialTypeAPIKey, Provider: "anthropic"},
}
assert.Empty(t, checkPluginCompatibility(instance))
})

t.Run("google gcp has no vertex plugin", func(t *testing.T) {
instance := &clawv1alpha1.Claw{}
instance.Spec.Version = testVersionOld
instance.Spec.Credentials = []clawv1alpha1.CredentialSpec{
{Name: "vertex", Type: clawv1alpha1.CredentialTypeGCP, Provider: "google",
GCP: &clawv1alpha1.GCPConfig{Project: "proj"}},
}
assert.Empty(t, checkPluginCompatibility(instance))
})

t.Run("no credentials", func(t *testing.T) {
instance := &clawv1alpha1.Claw{}
instance.Spec.Version = testVersionOld
assert.Empty(t, checkPluginCompatibility(instance))
})
}
19 changes: 12 additions & 7 deletions internal/controller/claw_providers.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ type providerDefaults struct {
// manifest. Empty means no config entry is needed.
VertexPluginID string

// PluginMinVersion is the minimum OpenClaw calver version (e.g. "2026.6.8")
// required for VertexPlugin to work. Empty means no minimum.
PluginMinVersion string

// Models is the hardcoded model catalog for this provider.
// Order matters: the first model becomes the default primary when this
// provider is the first configured credential in the Claw CR; remaining
Expand Down Expand Up @@ -102,13 +106,14 @@ var knownProviders = map[string]providerDefaults{
},
},
"anthropic": {
CredType: clawv1alpha1.CredentialTypeAPIKey,
Domain: "api.anthropic.com",
Header: "x-api-key",
API: "anthropic-messages",
VertexAPI: "anthropic-messages",
VertexPlugin: "@openclaw/anthropic-vertex-provider",
VertexPluginID: "anthropic-vertex",
CredType: clawv1alpha1.CredentialTypeAPIKey,
Domain: "api.anthropic.com",
Header: "x-api-key",
API: "anthropic-messages",
VertexAPI: "anthropic-messages",
VertexPlugin: "@openclaw/anthropic-vertex-provider",
VertexPluginID: "anthropic-vertex",
PluginMinVersion: "2026.6.8",
Models: []modelEntry{
{Name: "claude-sonnet-4-6", Alias: "Claude Sonnet 4.6"},
{Name: "claude-opus-4-8", Alias: "Claude Opus 4.8"},
Expand Down
8 changes: 8 additions & 0 deletions internal/controller/claw_resource_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ type ClawResourceReconciler struct {
// +kubebuilder:rbac:groups=claw.sandbox.redhat.com,resources=claws/finalizers,verbs=update
// +kubebuilder:rbac:groups="",resources=configmaps,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups="",resources=secrets,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list
// +kubebuilder:rbac:groups="",resources=persistentvolumeclaims,verbs=get;list;watch;create;update;patch
// +kubebuilder:rbac:groups=apps,resources=deployments,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=networking.k8s.io,resources=networkpolicies,verbs=get;list;watch;create;update;patch;delete
Expand Down Expand Up @@ -924,6 +925,13 @@ func (r *ClawResourceReconciler) configureDeployments(
return fmt.Errorf("failed to configure metrics sidecar: %w", err)
}
}
if warning := checkPluginCompatibility(instance); warning != "" {
setCondition(instance, clawv1alpha1.ConditionTypePluginCompatibility,
metav1.ConditionFalse, clawv1alpha1.ConditionReasonIncompatible, warning)
} else {
meta.RemoveStatusCondition(&instance.Status.Conditions,
clawv1alpha1.ConditionTypePluginCompatibility)
}
if !pluginInstallationDisabled(instance) {
plugins := effectivePlugins(instance)
if len(plugins) > 0 {
Expand Down
Loading
Loading