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
53 changes: 53 additions & 0 deletions internal/data/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import (
"time"

"golang.org/x/term"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

const (
Expand Down Expand Up @@ -112,6 +114,57 @@ func KindToGroup(kind string) (string, error) {
}
}

// Condition types and reasons shared by DataExport and DataImport.
const (
// ConditionTypeReady is the readiness condition both producers set.
ConditionTypeReady = "Ready"

// ConditionTypeExpired is the standalone expiry condition storage-volume-data-manager sets.
// storage-foundation dropped it in favour of ReasonExpired on Ready.
ConditionTypeExpired = "Expired"

// ReasonExpired is the Ready-condition reason both producers use for idle expiry.
ReasonExpired = "Expired"
)

// IsExpired reports whether the conditions say the DataExport or DataImport has terminally
// idle-expired, so the caller must recreate it rather than keep polling. After expiry the
// producer's garbage collector only removes the object once its retention TTL runs out, so
// waiting it out would stall for as long as that retention lasts.
//
// Both spellings of expiry are accepted, because the producers do not agree on one and a client
// that reads only its own producer's spelling silently waits forever against the other:
//
// - storage-volume-data-manager raises a standalone Expired condition (and also reports it as a
// Ready reason);
// - storage-foundation has no Expired condition type at all and reports it only as
// Ready=False with reason Expired.
//
// The two cannot be confused for one another: neither producer uses either spelling to mean
// anything but expiry.
func IsExpired(conditions []metav1.Condition) bool {
if expired := meta.FindStatusCondition(conditions, ConditionTypeExpired); expired != nil &&
expired.Status == metav1.ConditionTrue {
return true
}

ready := meta.FindStatusCondition(conditions, ConditionTypeReady)

return ready != nil && ready.Status == metav1.ConditionFalse && ready.Reason == ReasonExpired
}

// NotReady returns the Ready condition when it is present and not True, and nil otherwise —
// including when the object carries no Ready condition at all, which callers treat as "nothing
// said yet" rather than as a failure.
func NotReady(conditions []metav1.Condition) *metav1.Condition {
ready := meta.FindStatusCondition(conditions, ConditionTypeReady)
if ready == nil || ready.Status == metav1.ConditionTrue {
return nil
}

return ready
}

func ParseArgs(args []string) ( /*deName*/ string /*srcPath*/, string, error) {
var deName, srcPath string

Expand Down
134 changes: 134 additions & 0 deletions internal/data/conditions_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/*
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 dataio

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func cond(condType string, status metav1.ConditionStatus, reason string) metav1.Condition {
return metav1.Condition{
Type: condType,
Status: status,
Reason: reason,
Message: reason,
}
}

// TestIsExpired covers both producers' spellings of expiry plus the states that must not be read as
// expiry. Reading only one spelling is not a cosmetic bug: the caller keeps polling an object the
// producer will not revive, for as long as that producer's retention TTL lasts.
func TestIsExpired(t *testing.T) {
t.Parallel()

tests := []struct {
name string
conditions []metav1.Condition
want bool
}{
{
name: "storage-foundation spelling: Ready=False with reason Expired",
conditions: []metav1.Condition{cond(ConditionTypeReady, metav1.ConditionFalse, ReasonExpired)},
want: true,
},
{
// The importer pod raises this before the controller mirrors it onto Ready, so a real
// object passes through exactly this pairing.
name: "older producer spelling: standalone Expired=True while Ready is still True",
conditions: []metav1.Condition{
cond(ConditionTypeExpired, metav1.ConditionTrue, ReasonExpired),
cond(ConditionTypeReady, metav1.ConditionTrue, "PodReady"),
},
want: true,
},
{
name: "older producer, after the controller mirrored it",
conditions: []metav1.Condition{
cond(ConditionTypeExpired, metav1.ConditionTrue, ReasonExpired),
cond(ConditionTypeReady, metav1.ConditionFalse, ReasonExpired),
},
want: true,
},
{
name: "an Expired condition that is False says the object has not expired",
conditions: []metav1.Condition{cond(ConditionTypeExpired, metav1.ConditionFalse, "Pending")},
want: false,
},
{
// Guards against too broad a predicate: a not-Ready object is not an expired one, and
// recreating it would destroy an import that was merely still working.
name: "Ready=False for another reason is not expiry",
conditions: []metav1.Condition{cond(ConditionTypeReady, metav1.ConditionFalse, "Completed")},
want: false,
},
{
name: "healthy object",
conditions: []metav1.Condition{cond(ConditionTypeReady, metav1.ConditionTrue, "PodReady")},
want: false,
},
{
name: "no conditions at all",
conditions: nil,
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
assert.Equal(t, tt.want, IsExpired(tt.conditions))
})
}
}

// TestNotReady pins the distinction between "reported as not ready" and "has not reported yet". An
// object carrying no Ready condition has not been reconciled, and treating that as a failure turns
// the first poll of a freshly created object into an error.
func TestNotReady(t *testing.T) {
t.Parallel()

t.Run("absent Ready condition is not a failure", func(t *testing.T) {
t.Parallel()
assert.Nil(t, NotReady(nil))
assert.Nil(t, NotReady([]metav1.Condition{cond(ConditionTypeExpired, metav1.ConditionFalse, "Pending")}))
})

t.Run("Ready=True is not a failure", func(t *testing.T) {
t.Parallel()
assert.Nil(t, NotReady([]metav1.Condition{cond(ConditionTypeReady, metav1.ConditionTrue, "PodReady")}))
})

t.Run("Ready=False is returned with its reason", func(t *testing.T) {
t.Parallel()

got := NotReady([]metav1.Condition{cond(ConditionTypeReady, metav1.ConditionFalse, "TargetNotFound")})
require.NotNil(t, got)
assert.Equal(t, "TargetNotFound", got.Reason)
})

t.Run("Ready=Unknown is returned too", func(t *testing.T) {
t.Parallel()

got := NotReady([]metav1.Condition{cond(ConditionTypeReady, metav1.ConditionUnknown, "Pending")})
require.NotNil(t, got)
assert.Equal(t, "Pending", got.Reason)
})
}
87 changes: 87 additions & 0 deletions internal/data/dataapi/groups.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
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 dataapi resolves which of the two API groups a cluster serves DataExport and
// DataImport under, and which of them the calling user is actually authorized to use.
//
// Two different modules produce the same pair of CRDs:
//
// - storage-foundation serves them under FoundationGroup. It supersedes the older module
// and is what a cluster with storage-foundation enabled exposes.
// - storage-volume-data-manager serves them under LegacyGroup. Editions that ship that
// module alone expose this group and nothing else.
//
// A single d8 binary has to work against both, so the group is a runtime decision rather than a
// compile-time constant. The decision cannot be made from the module list: `d8 data` is run by
// ordinary users, who are not authorized to read ModuleConfig, and whose RBAC may cover only one
// of the two groups even when the cluster serves both.
package dataapi

import "k8s.io/apimachinery/pkg/runtime/schema"

const (
// FoundationGroup is the API group under which storage-foundation serves DataExport and
// DataImport. Preferred whenever the cluster serves it and the user is authorized for it.
FoundationGroup = "storage-foundation.deckhouse.io"

// LegacyGroup is the API group under which storage-volume-data-manager serves DataExport
// and DataImport. Used when the cluster does not serve FoundationGroup, or serves it but
// denies the user access to it.
LegacyGroup = "storage.deckhouse.io"

// Version is the version both groups serve these CRDs under.
Version = "v1alpha1"
)

// Resource plurals this package can resolve a group for. Resolution is per resource rather
// than per group because a cluster is free to serve one CRD of the pair and not the other.
const (
ResourceDataExports = "dataexports"
ResourceDataImports = "dataimports"
)

// Module names, used only in operator-facing messages that name what to enable.
const (
foundationModule = "storage-foundation"
legacyModule = "storage-volume-data-manager"
)

var (
// FoundationGroupVersion is the storage-foundation GroupVersion of DataExport/DataImport.
FoundationGroupVersion = schema.GroupVersion{Group: FoundationGroup, Version: Version}

// LegacyGroupVersion is the storage-volume-data-manager GroupVersion of the same pair.
LegacyGroupVersion = schema.GroupVersion{Group: LegacyGroup, Version: Version}
)

// Backend is a resolved answer: the GroupVersion to address the CRD through, plus the module
// that serves it for messages.
type Backend struct {
GroupVersion schema.GroupVersion
Module string
}

// Legacy reports whether the resolved backend is storage-volume-data-manager's group. Callers
// that build a request body differing between the two producers branch on this; callers that
// only address the object by GroupVersion do not need it.
func (b Backend) Legacy() bool {
return b.GroupVersion.Group == LegacyGroup
}

// String renders the backend as its GroupVersion, e.g. "storage-foundation.deckhouse.io/v1alpha1".
func (b Backend) String() string {
return b.GroupVersion.String()
}
Loading
Loading