mirror of
https://github.com/cozystack/cozystack.git
synced 2026-10-30 08:37:31 +09:00
The storage class check used || where it needed &&, so a PVC with no
storageClassName (one that takes the cluster default) was dereferenced
as a nil pointer. controller-runtime recovers the panic, but the
reconcile of that WorkloadMonitor fails on every retry and never
reaches its status update. A PVC with an empty storageClassName
produced a resource key with an empty class name instead of falling
back to "default".
Assisted-by: LLM
Signed-off-by: Aleksei Sviridkin <f@lex.la>
(cherry picked from commit 8b8d4115fc)
1303 lines
39 KiB
Go
1303 lines
39 KiB
Go
package controller
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
cozyv1alpha1 "github.com/cozystack/cozystack/api/v1alpha1"
|
|
corev1 "k8s.io/api/core/v1"
|
|
"k8s.io/apimachinery/pkg/api/resource"
|
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
|
"k8s.io/apimachinery/pkg/runtime"
|
|
"k8s.io/apimachinery/pkg/types"
|
|
"k8s.io/utils/ptr"
|
|
cosiv1alpha1 "sigs.k8s.io/container-object-storage-interface-api/apis/objectstorage/v1alpha1"
|
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
|
"sigs.k8s.io/controller-runtime/pkg/reconcile"
|
|
)
|
|
|
|
func TestReconcile_OperationalStatusPersisted(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
_ = cozyv1alpha1.AddToScheme(scheme)
|
|
_ = corev1.AddToScheme(scheme)
|
|
_ = cosiv1alpha1.AddToScheme(scheme)
|
|
|
|
minReplicas := int32(2)
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-monitor",
|
|
Namespace: "default",
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Selector: map[string]string{"app": "test"},
|
|
MinReplicas: &minReplicas,
|
|
},
|
|
}
|
|
|
|
// Create one pod that is ready — availableReplicas=1 < minReplicas=2, so Operational should be false
|
|
pod := &corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pod-1",
|
|
Namespace: "default",
|
|
Labels: map[string]string{"app": "test"},
|
|
},
|
|
Status: corev1.PodStatus{
|
|
Conditions: []corev1.PodCondition{
|
|
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(monitor, pod).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: scheme}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-monitor", Namespace: "default"}}
|
|
|
|
_, err := reconciler.Reconcile(context.TODO(), req)
|
|
if err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
// Fetch the monitor back from fake client and check Operational is persisted
|
|
updated := &cozyv1alpha1.WorkloadMonitor{}
|
|
if err := fakeClient.Get(context.TODO(), req.NamespacedName, updated); err != nil {
|
|
t.Fatalf("Failed to get updated WorkloadMonitor: %v", err)
|
|
}
|
|
|
|
if updated.Status.Operational == nil {
|
|
t.Fatal("Expected Operational to be set, got nil")
|
|
}
|
|
if *updated.Status.Operational {
|
|
t.Error("Expected Operational=false (1 available < 2 minReplicas), got true")
|
|
}
|
|
if updated.Status.ObservedReplicas != 1 {
|
|
t.Errorf("Expected ObservedReplicas=1, got %d", updated.Status.ObservedReplicas)
|
|
}
|
|
if updated.Status.AvailableReplicas != 1 {
|
|
t.Errorf("Expected AvailableReplicas=1, got %d", updated.Status.AvailableReplicas)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_OperationalTrue_WhenEnoughReplicas(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
_ = cozyv1alpha1.AddToScheme(scheme)
|
|
_ = corev1.AddToScheme(scheme)
|
|
_ = cosiv1alpha1.AddToScheme(scheme)
|
|
|
|
minReplicas := int32(1)
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-monitor",
|
|
Namespace: "default",
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Selector: map[string]string{"app": "test"},
|
|
MinReplicas: &minReplicas,
|
|
},
|
|
}
|
|
|
|
pod := &corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pod-1",
|
|
Namespace: "default",
|
|
Labels: map[string]string{"app": "test"},
|
|
},
|
|
Status: corev1.PodStatus{
|
|
Conditions: []corev1.PodCondition{
|
|
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(monitor, pod).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: scheme}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-monitor", Namespace: "default"}}
|
|
|
|
_, err := reconciler.Reconcile(context.TODO(), req)
|
|
if err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
updated := &cozyv1alpha1.WorkloadMonitor{}
|
|
if err := fakeClient.Get(context.TODO(), req.NamespacedName, updated); err != nil {
|
|
t.Fatalf("Failed to get updated WorkloadMonitor: %v", err)
|
|
}
|
|
|
|
if updated.Status.Operational == nil {
|
|
t.Fatal("Expected Operational to be set, got nil")
|
|
}
|
|
if !*updated.Status.Operational {
|
|
t.Error("Expected Operational=true (1 available >= 1 minReplicas), got false")
|
|
}
|
|
}
|
|
|
|
func TestGetMonitorLabels(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
labels map[string]string
|
|
expected map[string]string
|
|
}{
|
|
{
|
|
name: "nil labels",
|
|
labels: nil,
|
|
expected: map[string]string{},
|
|
},
|
|
{
|
|
name: "only workloads.cozystack.io/* labels are propagated",
|
|
labels: map[string]string{
|
|
"workloads.cozystack.io/resource-preset": "medium",
|
|
"app.kubernetes.io/name": "postgres",
|
|
"custom.example.com/team": "platform",
|
|
},
|
|
expected: map[string]string{
|
|
"workloads.cozystack.io/resource-preset": "medium",
|
|
},
|
|
},
|
|
{
|
|
name: "monitor label is reserved and excluded",
|
|
labels: map[string]string{
|
|
"workloads.cozystack.io/resource-preset": "small",
|
|
"workloads.cozystack.io/monitor": "should-be-dropped",
|
|
},
|
|
expected: map[string]string{
|
|
"workloads.cozystack.io/resource-preset": "small",
|
|
},
|
|
},
|
|
{
|
|
name: "multiple workloads.cozystack.io labels propagate",
|
|
labels: map[string]string{
|
|
"workloads.cozystack.io/resource-preset": "large",
|
|
"workloads.cozystack.io/tier": "db",
|
|
},
|
|
expected: map[string]string{
|
|
"workloads.cozystack.io/resource-preset": "large",
|
|
"workloads.cozystack.io/tier": "db",
|
|
},
|
|
},
|
|
{
|
|
name: "no matching labels returns empty map",
|
|
labels: map[string]string{
|
|
"app.kubernetes.io/name": "postgres",
|
|
},
|
|
expected: map[string]string{},
|
|
},
|
|
}
|
|
|
|
r := &WorkloadMonitorReconciler{}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{Labels: tc.labels},
|
|
}
|
|
got := r.getMonitorLabels(monitor)
|
|
if len(got) != len(tc.expected) {
|
|
t.Fatalf("expected %d labels, got %d (%v)", len(tc.expected), len(got), got)
|
|
}
|
|
for k, v := range tc.expected {
|
|
if gv, ok := got[k]; !ok || gv != v {
|
|
t.Errorf("expected label %q=%q, got %q", k, v, gv)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReconcile_MonitorLabelsPropagatedToPodWorkload(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
_ = cozyv1alpha1.AddToScheme(scheme)
|
|
_ = corev1.AddToScheme(scheme)
|
|
_ = cosiv1alpha1.AddToScheme(scheme)
|
|
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-monitor",
|
|
Namespace: "default",
|
|
Labels: map[string]string{
|
|
"workloads.cozystack.io/resource-preset": "medium",
|
|
"app.kubernetes.io/name": "ignored-not-propagated",
|
|
},
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Selector: map[string]string{"app": "test"},
|
|
Kind: "postgres",
|
|
Type: "postgres",
|
|
},
|
|
}
|
|
|
|
pod := &corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pod-1",
|
|
Namespace: "default",
|
|
Labels: map[string]string{
|
|
"app": "test",
|
|
"app.kubernetes.io/name": "pod-wins-on-conflict",
|
|
},
|
|
},
|
|
Status: corev1.PodStatus{
|
|
Conditions: []corev1.PodCondition{
|
|
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(monitor, pod).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: scheme}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-monitor", Namespace: "default"}}
|
|
if _, err := reconciler.Reconcile(context.TODO(), req); err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
workload := &cozyv1alpha1.Workload{}
|
|
if err := fakeClient.Get(context.TODO(), types.NamespacedName{Name: "pod-test-pod-1", Namespace: "default"}, workload); err != nil {
|
|
t.Fatalf("Failed to get Workload: %v", err)
|
|
}
|
|
|
|
if got := workload.Labels["workloads.cozystack.io/resource-preset"]; got != "medium" {
|
|
t.Errorf("expected monitor label propagated, got %q", got)
|
|
}
|
|
// Non-workloads.cozystack.io monitor labels must not be copied
|
|
if _, ok := workload.Labels["app.kubernetes.io/name"]; !ok {
|
|
t.Error("expected pod label to be present on Workload")
|
|
}
|
|
// Source-object label takes precedence on conflict
|
|
if got := workload.Labels["app.kubernetes.io/name"]; got != "pod-wins-on-conflict" {
|
|
t.Errorf("expected pod label to win on conflict, got %q", got)
|
|
}
|
|
// Reserved monitor label is always set from the monitor name
|
|
if got := workload.Labels["workloads.cozystack.io/monitor"]; got != "test-monitor" {
|
|
t.Errorf("expected monitor-name label, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestReconcile_BackwardCompat_NoMonitorLabels(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
_ = cozyv1alpha1.AddToScheme(scheme)
|
|
_ = corev1.AddToScheme(scheme)
|
|
_ = cosiv1alpha1.AddToScheme(scheme)
|
|
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-monitor",
|
|
Namespace: "default",
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Selector: map[string]string{"app": "test"},
|
|
},
|
|
}
|
|
|
|
pod := &corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-pod-1",
|
|
Namespace: "default",
|
|
Labels: map[string]string{"app": "test"},
|
|
},
|
|
Status: corev1.PodStatus{
|
|
Conditions: []corev1.PodCondition{
|
|
{Type: corev1.PodReady, Status: corev1.ConditionTrue},
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(monitor, pod).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: scheme}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-monitor", Namespace: "default"}}
|
|
if _, err := reconciler.Reconcile(context.TODO(), req); err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
workload := &cozyv1alpha1.Workload{}
|
|
if err := fakeClient.Get(context.TODO(), types.NamespacedName{Name: "pod-test-pod-1", Namespace: "default"}, workload); err != nil {
|
|
t.Fatalf("Failed to get Workload: %v", err)
|
|
}
|
|
for k := range workload.Labels {
|
|
if strings.HasPrefix(k, "workloads.cozystack.io/") && k != "workloads.cozystack.io/monitor" {
|
|
t.Errorf("unexpected workload label present: %q", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestReconcile_OperationalTrue_WhenNoMinReplicas(t *testing.T) {
|
|
scheme := runtime.NewScheme()
|
|
_ = cozyv1alpha1.AddToScheme(scheme)
|
|
_ = corev1.AddToScheme(scheme)
|
|
_ = cosiv1alpha1.AddToScheme(scheme)
|
|
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "test-monitor",
|
|
Namespace: "default",
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Selector: map[string]string{"app": "test"},
|
|
// No MinReplicas — should default to operational=true
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(monitor).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: scheme}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-monitor", Namespace: "default"}}
|
|
|
|
_, err := reconciler.Reconcile(context.TODO(), req)
|
|
if err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
updated := &cozyv1alpha1.WorkloadMonitor{}
|
|
if err := fakeClient.Get(context.TODO(), req.NamespacedName, updated); err != nil {
|
|
t.Fatalf("Failed to get updated WorkloadMonitor: %v", err)
|
|
}
|
|
|
|
if updated.Status.Operational == nil {
|
|
t.Fatal("Expected Operational to be set, got nil")
|
|
}
|
|
if !*updated.Status.Operational {
|
|
t.Error("Expected Operational=true (no MinReplicas constraint), got false")
|
|
}
|
|
}
|
|
|
|
func newTestScheme() *runtime.Scheme {
|
|
s := runtime.NewScheme()
|
|
_ = cozyv1alpha1.AddToScheme(s)
|
|
_ = corev1.AddToScheme(s)
|
|
_ = cosiv1alpha1.AddToScheme(s)
|
|
return s
|
|
}
|
|
|
|
func TestReconcileBucketClaimCreatesWorkload(t *testing.T) {
|
|
s := newTestScheme()
|
|
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Kind: "bucket",
|
|
Type: "s3",
|
|
Selector: map[string]string{
|
|
"app.kubernetes.io/instance": "my-bucket",
|
|
},
|
|
},
|
|
}
|
|
|
|
bc := &cosiv1alpha1.BucketClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
Labels: map[string]string{
|
|
"app.kubernetes.io/instance": "my-bucket",
|
|
},
|
|
},
|
|
Spec: cosiv1alpha1.BucketClaimSpec{
|
|
BucketClassName: "seaweedfs",
|
|
Protocols: []cosiv1alpha1.Protocol{cosiv1alpha1.ProtocolS3},
|
|
},
|
|
Status: cosiv1alpha1.BucketClaimStatus{
|
|
BucketReady: true,
|
|
BucketName: "cosi-abc123",
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(monitor, bc).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: s}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}}
|
|
|
|
_, err := reconciler.Reconcile(context.TODO(), req)
|
|
if err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
workload := &cozyv1alpha1.Workload{}
|
|
err = fakeClient.Get(context.TODO(), types.NamespacedName{
|
|
Name: "bucket-my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}, workload)
|
|
if err != nil {
|
|
t.Fatalf("expected Workload to be created, got error: %v", err)
|
|
}
|
|
|
|
if workload.Status.Kind != "bucket" {
|
|
t.Errorf("expected Kind=bucket, got %q", workload.Status.Kind)
|
|
}
|
|
if workload.Status.Type != "s3" {
|
|
t.Errorf("expected Type=s3, got %q", workload.Status.Type)
|
|
}
|
|
if !workload.Status.Operational {
|
|
t.Error("expected Operational=true for ready BucketClaim")
|
|
}
|
|
}
|
|
|
|
func TestReconcileBucketClaimNotReady(t *testing.T) {
|
|
s := newTestScheme()
|
|
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Kind: "bucket",
|
|
Type: "s3",
|
|
Selector: map[string]string{
|
|
"app.kubernetes.io/instance": "my-bucket",
|
|
},
|
|
},
|
|
}
|
|
|
|
bc := &cosiv1alpha1.BucketClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
Labels: map[string]string{
|
|
"app.kubernetes.io/instance": "my-bucket",
|
|
},
|
|
},
|
|
Spec: cosiv1alpha1.BucketClaimSpec{
|
|
BucketClassName: "seaweedfs",
|
|
Protocols: []cosiv1alpha1.Protocol{cosiv1alpha1.ProtocolS3},
|
|
},
|
|
Status: cosiv1alpha1.BucketClaimStatus{
|
|
BucketReady: false,
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(monitor, bc).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: s}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}}
|
|
|
|
_, err := reconciler.Reconcile(context.TODO(), req)
|
|
if err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
workload := &cozyv1alpha1.Workload{}
|
|
err = fakeClient.Get(context.TODO(), types.NamespacedName{
|
|
Name: "bucket-my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}, workload)
|
|
if err != nil {
|
|
t.Fatalf("expected Workload to be created, got error: %v", err)
|
|
}
|
|
|
|
if workload.Status.Operational {
|
|
t.Error("expected Operational=false for not-ready BucketClaim")
|
|
}
|
|
}
|
|
|
|
func TestReconcile_MonitorLabelsPropagatedToBucketClaimWorkload(t *testing.T) {
|
|
s := newTestScheme()
|
|
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
Labels: map[string]string{
|
|
"workloads.cozystack.io/resource-preset": "medium",
|
|
"app.kubernetes.io/name": "ignored-not-propagated",
|
|
},
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Kind: "bucket",
|
|
Type: "s3",
|
|
Selector: map[string]string{
|
|
"app.kubernetes.io/instance": "my-bucket",
|
|
},
|
|
},
|
|
}
|
|
|
|
bc := &cosiv1alpha1.BucketClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
Labels: map[string]string{
|
|
"app.kubernetes.io/instance": "my-bucket",
|
|
"app.kubernetes.io/name": "bucket-wins-on-conflict",
|
|
},
|
|
},
|
|
Spec: cosiv1alpha1.BucketClaimSpec{
|
|
BucketClassName: "seaweedfs",
|
|
Protocols: []cosiv1alpha1.Protocol{cosiv1alpha1.ProtocolS3},
|
|
},
|
|
Status: cosiv1alpha1.BucketClaimStatus{
|
|
BucketReady: true,
|
|
BucketName: "cosi-abc123",
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(monitor, bc).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: s}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}}
|
|
if _, err := reconciler.Reconcile(context.TODO(), req); err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
workload := &cozyv1alpha1.Workload{}
|
|
if err := fakeClient.Get(context.TODO(), types.NamespacedName{
|
|
Name: "bucket-my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}, workload); err != nil {
|
|
t.Fatalf("Failed to get Workload: %v", err)
|
|
}
|
|
|
|
if got := workload.Labels["workloads.cozystack.io/resource-preset"]; got != "medium" {
|
|
t.Errorf("expected monitor label propagated, got %q", got)
|
|
}
|
|
// Source-object label takes precedence on conflict
|
|
if got := workload.Labels["app.kubernetes.io/name"]; got != "bucket-wins-on-conflict" {
|
|
t.Errorf("expected bucket claim label to win on conflict, got %q", got)
|
|
}
|
|
// Reserved monitor label is always set from the monitor name
|
|
if got := workload.Labels["workloads.cozystack.io/monitor"]; got != "my-bucket" {
|
|
t.Errorf("expected monitor-name label, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestReconcileNoBucketClaimSkips(t *testing.T) {
|
|
s := newTestScheme()
|
|
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-postgres",
|
|
Namespace: "tenant-demo",
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Kind: "postgres",
|
|
Type: "postgres",
|
|
Selector: map[string]string{
|
|
"app.kubernetes.io/instance": "my-postgres",
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(monitor).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: s}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{
|
|
Name: "my-postgres",
|
|
Namespace: "tenant-demo",
|
|
}}
|
|
|
|
_, err := reconciler.Reconcile(context.TODO(), req)
|
|
if err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
workloadList := &cozyv1alpha1.WorkloadList{}
|
|
err = fakeClient.List(context.TODO(), workloadList)
|
|
if err != nil {
|
|
t.Fatalf("List returned error: %v", err)
|
|
}
|
|
|
|
for _, w := range workloadList.Items {
|
|
if w.Status.Kind == "bucket" {
|
|
t.Error("expected no bucket workloads to be created for postgres monitor")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestQueryAllBucketMetrics(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"status":"success","data":{"resultType":"vector","result":[
|
|
{"metric":{"__name__":"SeaweedFS_s3_bucket_size_bytes","bucket":"bucket-aaa"},"value":[1713000000,"10485864"]},
|
|
{"metric":{"__name__":"SeaweedFS_s3_bucket_physical_size_bytes","bucket":"bucket-aaa"},"value":[1713000000,"20971728"]},
|
|
{"metric":{"__name__":"SeaweedFS_s3_bucket_size_bytes","bucket":"bucket-bbb"},"value":[1713000000,"0"]}
|
|
]}}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{}
|
|
metrics, err := reconciler.queryAllBucketMetrics(context.TODO(), srv.URL, []string{"bucket-aaa", "bucket-bbb"})
|
|
if err != nil {
|
|
t.Fatalf("queryAllBucketMetrics returned error: %v", err)
|
|
}
|
|
|
|
bm, ok := metrics["bucket-aaa"]
|
|
if !ok {
|
|
t.Fatal("expected bucket-aaa in metrics")
|
|
}
|
|
if !bm.HasLogical || bm.LogicalSize != 10485864 {
|
|
t.Errorf("expected logical=10485864, got %d", bm.LogicalSize)
|
|
}
|
|
if !bm.HasPhysical || bm.PhysicalSize != 20971728 {
|
|
t.Errorf("expected physical=20971728, got %d", bm.PhysicalSize)
|
|
}
|
|
|
|
bm2, ok := metrics["bucket-bbb"]
|
|
if !ok {
|
|
t.Fatal("expected bucket-bbb in metrics")
|
|
}
|
|
if !bm2.HasLogical || bm2.LogicalSize != 0 {
|
|
t.Errorf("expected logical=0 for empty bucket, got %d", bm2.LogicalSize)
|
|
}
|
|
if bm2.HasPhysical {
|
|
t.Error("expected no physical size for bucket-bbb")
|
|
}
|
|
}
|
|
|
|
func TestQueryAllBucketMetricsEmpty(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"status":"success","data":{"resultType":"vector","result":[]}}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{}
|
|
metrics, err := reconciler.queryAllBucketMetrics(context.TODO(), srv.URL, []string{"bucket-aaa", "bucket-bbb"})
|
|
if err != nil {
|
|
t.Fatalf("queryAllBucketMetrics returned error: %v", err)
|
|
}
|
|
if len(metrics) != 0 {
|
|
t.Errorf("expected empty metrics, got %d", len(metrics))
|
|
}
|
|
}
|
|
|
|
func TestQueryAllBucketMetricsServerError(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{}
|
|
_, err := reconciler.queryAllBucketMetrics(context.TODO(), srv.URL, []string{"bucket-aaa", "bucket-bbb"})
|
|
if err == nil {
|
|
t.Error("expected error on server failure, got nil")
|
|
}
|
|
}
|
|
|
|
func TestQueryAllBucketMetricsNoURL(t *testing.T) {
|
|
reconciler := &WorkloadMonitorReconciler{}
|
|
metrics, err := reconciler.queryAllBucketMetrics(context.TODO(), "", nil)
|
|
if err != nil {
|
|
t.Fatalf("queryAllBucketMetrics returned error: %v", err)
|
|
}
|
|
if len(metrics) != 0 {
|
|
t.Errorf("expected empty metrics when URL is empty, got %d", len(metrics))
|
|
}
|
|
}
|
|
|
|
func TestQueryAllBucketMetricsPathPrefixAndUserinfo(t *testing.T) {
|
|
var gotPath, gotUser, gotPass string
|
|
var gotOK bool
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
gotPath = r.URL.Path
|
|
gotUser, gotPass, gotOK = r.BasicAuth()
|
|
fmt.Fprint(w, `{"status":"success","data":{"resultType":"vector","result":[]}}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{}
|
|
// Trailing slash on the base URL must not produce a double slash before
|
|
// /api/v1/query, and userinfo must arrive as basic auth.
|
|
baseURL := strings.Replace(srv.URL, "http://", "http://billing:hunter2@", 1) + "/path/to/prometheus/"
|
|
if _, err := reconciler.queryAllBucketMetrics(context.TODO(), baseURL, []string{"bucket-aaa"}); err != nil {
|
|
t.Fatalf("queryAllBucketMetrics returned error: %v", err)
|
|
}
|
|
|
|
if gotPath != "/path/to/prometheus/api/v1/query" {
|
|
t.Errorf("expected path prefix preserved, got %q", gotPath)
|
|
}
|
|
if !gotOK || gotUser != "billing" || gotPass != "hunter2" {
|
|
t.Errorf("expected basic auth billing/hunter2 from userinfo, got %q/%q ok=%v", gotUser, gotPass, gotOK)
|
|
}
|
|
}
|
|
|
|
func TestParseMetricsEndpointURL(t *testing.T) {
|
|
valid := map[string]string{
|
|
"https://vm.example.com": "https://vm.example.com",
|
|
"https://vm.example.com/": "https://vm.example.com",
|
|
"https://vm.example.com:8427/vm/prometheus": "https://vm.example.com:8427/vm/prometheus",
|
|
"https://user:pass@vm.example.com/prometheus": "https://user:pass@vm.example.com/prometheus",
|
|
"http://vm.example.com/select/0/prometheus///": "http://vm.example.com/select/0/prometheus",
|
|
}
|
|
for raw, want := range valid {
|
|
got, err := ParseMetricsEndpointURL(raw)
|
|
if err != nil {
|
|
t.Errorf("ParseMetricsEndpointURL(%q) returned error: %v", raw, err)
|
|
continue
|
|
}
|
|
if got != want {
|
|
t.Errorf("ParseMetricsEndpointURL(%q) = %q, want %q", raw, got, want)
|
|
}
|
|
}
|
|
|
|
invalid := []string{
|
|
"",
|
|
"vm.example.com/prometheus",
|
|
"ftp://vm.example.com",
|
|
"https://",
|
|
"https://vm.example.com/prometheus?query=up",
|
|
"https://vm.example.com/prometheus#frag",
|
|
}
|
|
for _, raw := range invalid {
|
|
if got, err := ParseMetricsEndpointURL(raw); err == nil {
|
|
t.Errorf("ParseMetricsEndpointURL(%q) = %q, want error", raw, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolvePrometheusURLFromLabel(t *testing.T) {
|
|
s := newTestScheme()
|
|
|
|
ns := &corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "tenant-demo",
|
|
Labels: map[string]string{
|
|
"namespace.cozystack.io/monitoring": "tenant-root",
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(ns).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: s}
|
|
url, err := reconciler.resolvePrometheusURL(context.TODO(), "tenant-demo")
|
|
if err != nil {
|
|
t.Fatalf("resolvePrometheusURL returned error: %v", err)
|
|
}
|
|
|
|
expected := "http://vmselect-shortterm.tenant-root.svc:8481/select/0/prometheus"
|
|
if url != expected {
|
|
t.Errorf("expected %q, got %q", expected, url)
|
|
}
|
|
}
|
|
|
|
func TestResolvePrometheusURLNoLabel(t *testing.T) {
|
|
s := newTestScheme()
|
|
|
|
ns := &corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "tenant-demo",
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(ns).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: s}
|
|
url, err := reconciler.resolvePrometheusURL(context.TODO(), "tenant-demo")
|
|
if err != nil {
|
|
t.Fatalf("resolvePrometheusURL returned error: %v", err)
|
|
}
|
|
|
|
if url != "" {
|
|
t.Errorf("expected empty URL when no monitoring label, got %q", url)
|
|
}
|
|
}
|
|
|
|
func TestResolvePrometheusURLEndpointOverrideWinsOverLabel(t *testing.T) {
|
|
s := newTestScheme()
|
|
|
|
ns := &corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "tenant-demo",
|
|
Labels: map[string]string{
|
|
"namespace.cozystack.io/monitoring": "tenant-root",
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(ns).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{
|
|
Client: fakeClient,
|
|
Scheme: s,
|
|
SeaweedfsMetricsEndpoint: "https://vm.example.com/path/to/prometheus",
|
|
}
|
|
url, err := reconciler.resolvePrometheusURL(context.TODO(), "tenant-demo")
|
|
if err != nil {
|
|
t.Fatalf("resolvePrometheusURL returned error: %v", err)
|
|
}
|
|
|
|
if url != "https://vm.example.com/path/to/prometheus" {
|
|
t.Errorf("expected the endpoint override to win over label discovery, got %q", url)
|
|
}
|
|
}
|
|
|
|
func TestReconcileBucketClaimRequeuesWhenBucketsExist(t *testing.T) {
|
|
s := newTestScheme()
|
|
|
|
ns := &corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "tenant-demo",
|
|
},
|
|
}
|
|
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Kind: "bucket",
|
|
Type: "s3",
|
|
Selector: map[string]string{
|
|
"app.kubernetes.io/instance": "my-bucket",
|
|
},
|
|
},
|
|
}
|
|
|
|
bc := &cosiv1alpha1.BucketClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
Labels: map[string]string{
|
|
"app.kubernetes.io/instance": "my-bucket",
|
|
},
|
|
},
|
|
Spec: cosiv1alpha1.BucketClaimSpec{
|
|
BucketClassName: "seaweedfs",
|
|
Protocols: []cosiv1alpha1.Protocol{cosiv1alpha1.ProtocolS3},
|
|
},
|
|
Status: cosiv1alpha1.BucketClaimStatus{
|
|
BucketReady: true,
|
|
BucketName: "cosi-abc123",
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(ns, monitor, bc).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: s}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}}
|
|
|
|
result, err := reconciler.Reconcile(context.TODO(), req)
|
|
if err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
if result.RequeueAfter == 0 {
|
|
t.Error("expected RequeueAfter > 0 when buckets exist")
|
|
}
|
|
|
|
workload := &cozyv1alpha1.Workload{}
|
|
err = fakeClient.Get(context.TODO(), types.NamespacedName{
|
|
Name: "bucket-my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}, workload)
|
|
if err != nil {
|
|
t.Fatalf("expected Workload to be created, got error: %v", err)
|
|
}
|
|
|
|
// Without monitoring label on namespace, no size metrics should be set
|
|
if _, ok := workload.Status.Resources["s3-storage-bytes"]; ok {
|
|
t.Error("expected no s3-storage-bytes when monitoring is not configured")
|
|
}
|
|
if len(workload.Status.Resources) != 0 {
|
|
t.Errorf("expected empty resources without monitoring, got %v", workload.Status.Resources)
|
|
}
|
|
}
|
|
|
|
// newBucketFixture returns a plain namespace, a bucket WorkloadMonitor, and a
|
|
// ready BucketClaim named my-bucket whose SeaweedFS bucket is cosi-abc123.
|
|
func newBucketFixture() (*corev1.Namespace, *cozyv1alpha1.WorkloadMonitor, *cosiv1alpha1.BucketClaim) {
|
|
ns := &corev1.Namespace{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "tenant-demo",
|
|
},
|
|
}
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Kind: "bucket",
|
|
Type: "s3",
|
|
Selector: map[string]string{
|
|
"app.kubernetes.io/instance": "my-bucket",
|
|
},
|
|
},
|
|
}
|
|
bc := &cosiv1alpha1.BucketClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
Labels: map[string]string{
|
|
"app.kubernetes.io/instance": "my-bucket",
|
|
},
|
|
},
|
|
Spec: cosiv1alpha1.BucketClaimSpec{
|
|
BucketClassName: "seaweedfs",
|
|
Protocols: []cosiv1alpha1.Protocol{cosiv1alpha1.ProtocolS3},
|
|
},
|
|
Status: cosiv1alpha1.BucketClaimStatus{
|
|
BucketReady: true,
|
|
BucketName: "cosi-abc123",
|
|
},
|
|
}
|
|
return ns, monitor, bc
|
|
}
|
|
|
|
func TestReconcileBucketMetricsFromEndpointOverride(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/vm/select/0/prometheus/api/v1/query" {
|
|
t.Errorf("unexpected query path %q", r.URL.Path)
|
|
}
|
|
fmt.Fprint(w, `{"status":"success","data":{"resultType":"vector","result":[
|
|
{"metric":{"__name__":"SeaweedFS_s3_bucket_size_bytes","bucket":"cosi-abc123"},"value":[1713000000,"4096"]}
|
|
]}}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := newTestScheme()
|
|
ns, monitor, bc := newBucketFixture()
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(ns, monitor, bc).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{
|
|
Client: fakeClient,
|
|
Scheme: s,
|
|
SeaweedfsMetricsEndpoint: srv.URL + "/vm/select/0/prometheus",
|
|
}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}}
|
|
if _, err := reconciler.Reconcile(context.TODO(), req); err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
workload := &cozyv1alpha1.Workload{}
|
|
if err := fakeClient.Get(context.TODO(), types.NamespacedName{
|
|
Name: "bucket-my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}, workload); err != nil {
|
|
t.Fatalf("Failed to get Workload: %v", err)
|
|
}
|
|
|
|
q, ok := workload.Status.Resources["s3-storage-bytes"]
|
|
if !ok {
|
|
t.Fatal("expected s3-storage-bytes from the overridden endpoint")
|
|
}
|
|
if q.Value() != 4096 {
|
|
t.Errorf("expected s3-storage-bytes=4096, got %d", q.Value())
|
|
}
|
|
}
|
|
|
|
func TestReconcileRetainsLastKnownSizesOnQueryFailure(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := newTestScheme()
|
|
ns, monitor, bc := newBucketFixture()
|
|
|
|
// A Workload from a previous successful reconcile already carries sizes.
|
|
workload := &cozyv1alpha1.Workload{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "bucket-my-bucket",
|
|
Namespace: "tenant-demo",
|
|
},
|
|
Status: cozyv1alpha1.WorkloadStatus{
|
|
Kind: "bucket",
|
|
Type: "s3",
|
|
Resources: map[string]resource.Quantity{
|
|
"s3-storage-bytes": *resource.NewQuantity(4096, resource.BinarySI),
|
|
"s3-physical-storage-bytes": *resource.NewQuantity(8192, resource.BinarySI),
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(ns, monitor, bc, workload).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{
|
|
Client: fakeClient,
|
|
Scheme: s,
|
|
SeaweedfsMetricsEndpoint: srv.URL,
|
|
}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}}
|
|
|
|
// The failure must be loud: the reconcile returns an error instead of
|
|
// pretending the buckets are empty.
|
|
if _, err := reconciler.Reconcile(context.TODO(), req); err == nil {
|
|
t.Error("expected Reconcile to return an error when the metrics endpoint fails")
|
|
}
|
|
|
|
updated := &cozyv1alpha1.Workload{}
|
|
if err := fakeClient.Get(context.TODO(), types.NamespacedName{
|
|
Name: "bucket-my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}, updated); err != nil {
|
|
t.Fatalf("Failed to get Workload: %v", err)
|
|
}
|
|
|
|
q, ok := updated.Status.Resources["s3-storage-bytes"]
|
|
if !ok || q.Value() != 4096 {
|
|
t.Errorf("expected last known s3-storage-bytes=4096 retained, got %v (present=%v)", q.Value(), ok)
|
|
}
|
|
qp, ok := updated.Status.Resources["s3-physical-storage-bytes"]
|
|
if !ok || qp.Value() != 8192 {
|
|
t.Errorf("expected last known s3-physical-storage-bytes=8192 retained, got %v (present=%v)", qp.Value(), ok)
|
|
}
|
|
}
|
|
|
|
func TestReconcileRetainsLastKnownSizesWhenBucketMissingFromResult(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, `{"status":"success","data":{"resultType":"vector","result":[]}}`)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := newTestScheme()
|
|
ns, monitor, bc := newBucketFixture()
|
|
|
|
workload := &cozyv1alpha1.Workload{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "bucket-my-bucket",
|
|
Namespace: "tenant-demo",
|
|
},
|
|
Status: cozyv1alpha1.WorkloadStatus{
|
|
Kind: "bucket",
|
|
Type: "s3",
|
|
Resources: map[string]resource.Quantity{
|
|
"s3-storage-bytes": *resource.NewQuantity(4096, resource.BinarySI),
|
|
},
|
|
},
|
|
}
|
|
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(s).
|
|
WithObjects(ns, monitor, bc, workload).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{
|
|
Client: fakeClient,
|
|
Scheme: s,
|
|
SeaweedfsMetricsEndpoint: srv.URL,
|
|
}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{
|
|
Name: "my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}}
|
|
if _, err := reconciler.Reconcile(context.TODO(), req); err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
updated := &cozyv1alpha1.Workload{}
|
|
if err := fakeClient.Get(context.TODO(), types.NamespacedName{
|
|
Name: "bucket-my-bucket",
|
|
Namespace: "tenant-demo",
|
|
}, updated); err != nil {
|
|
t.Fatalf("Failed to get Workload: %v", err)
|
|
}
|
|
|
|
q, ok := updated.Status.Resources["s3-storage-bytes"]
|
|
if !ok || q.Value() != 4096 {
|
|
t.Errorf("expected last known s3-storage-bytes=4096 retained when series is absent, got %v (present=%v)", q.Value(), ok)
|
|
}
|
|
}
|
|
|
|
func TestGetWorkloadMetadata(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
annotations map[string]string
|
|
expected map[string]string
|
|
}{
|
|
{
|
|
name: "nil annotations returns empty map",
|
|
annotations: nil,
|
|
expected: map[string]string{},
|
|
},
|
|
{
|
|
// KubeVirt stamps the VM preference (which carries the guest OS
|
|
// profile, e.g. Windows) under kubevirt.io/cluster-preference-name.
|
|
// This value is taken verbatim from a real Windows VMInstance
|
|
// virt-launcher pod on a live cluster.
|
|
name: "instance profile from cluster-preference-name annotation",
|
|
annotations: map[string]string{
|
|
"kubevirt.io/cluster-preference-name": "windows.2k22.virtio",
|
|
},
|
|
expected: map[string]string{
|
|
"workloads.cozystack.io/kubevirt-vmi-instance-profile": "windows.2k22.virtio",
|
|
},
|
|
},
|
|
{
|
|
name: "instance type from cluster-instancetype-name annotation",
|
|
annotations: map[string]string{
|
|
"kubevirt.io/cluster-instancetype-name": "cx1.large",
|
|
},
|
|
expected: map[string]string{
|
|
"workloads.cozystack.io/kubevirt-vmi-instance-type": "cx1.large",
|
|
},
|
|
},
|
|
{
|
|
name: "both instance type and profile propagate",
|
|
annotations: map[string]string{
|
|
"kubevirt.io/cluster-instancetype-name": "cx1.large",
|
|
"kubevirt.io/cluster-preference-name": "windows.2k22.virtio",
|
|
},
|
|
expected: map[string]string{
|
|
"workloads.cozystack.io/kubevirt-vmi-instance-type": "cx1.large",
|
|
"workloads.cozystack.io/kubevirt-vmi-instance-profile": "windows.2k22.virtio",
|
|
},
|
|
},
|
|
{
|
|
// KubeVirt never stamps kubevirt.io/cluster-instanceprofile-name, so
|
|
// reading it must not yield a profile label.
|
|
name: "nonexistent cluster-instanceprofile-name annotation is ignored",
|
|
annotations: map[string]string{
|
|
"kubevirt.io/cluster-instanceprofile-name": "windows.2k22.virtio",
|
|
},
|
|
expected: map[string]string{},
|
|
},
|
|
}
|
|
|
|
r := &WorkloadMonitorReconciler{}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
pod := &corev1.Pod{
|
|
ObjectMeta: metav1.ObjectMeta{Annotations: tc.annotations},
|
|
}
|
|
got := r.getWorkloadMetadata(pod)
|
|
if len(got) != len(tc.expected) {
|
|
t.Fatalf("expected %d labels, got %d (%v)", len(tc.expected), len(got), got)
|
|
}
|
|
for k, v := range tc.expected {
|
|
if gv, ok := got[k]; !ok || gv != v {
|
|
t.Errorf("expected label %q=%q, got %q", k, v, gv)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReconcile_PVCStorageClassResourceKey(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
storageClass *string
|
|
wantKey string
|
|
}{
|
|
{"unset falls back to default", nil, "default.storageclass.storage.k8s.io/requests.storage"},
|
|
{"empty falls back to default", ptr.To(""), "default.storageclass.storage.k8s.io/requests.storage"},
|
|
{"explicit class is kept", ptr.To("replicated"), "replicated.storageclass.storage.k8s.io/requests.storage"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
scheme := newTestScheme()
|
|
monitor := &cozyv1alpha1.WorkloadMonitor{
|
|
ObjectMeta: metav1.ObjectMeta{Name: "test-monitor", Namespace: "default"},
|
|
Spec: cozyv1alpha1.WorkloadMonitorSpec{
|
|
Selector: map[string]string{"app": "test"},
|
|
Kind: "postgres",
|
|
Type: "postgres",
|
|
},
|
|
}
|
|
pvc := &corev1.PersistentVolumeClaim{
|
|
ObjectMeta: metav1.ObjectMeta{
|
|
Name: "data",
|
|
Namespace: "default",
|
|
Labels: map[string]string{"app": "test"},
|
|
},
|
|
Spec: corev1.PersistentVolumeClaimSpec{StorageClassName: tc.storageClass},
|
|
Status: corev1.PersistentVolumeClaimStatus{
|
|
Phase: corev1.ClaimBound,
|
|
Capacity: corev1.ResourceList{corev1.ResourceStorage: resource.MustParse("10Gi")},
|
|
},
|
|
}
|
|
fakeClient := fake.NewClientBuilder().
|
|
WithScheme(scheme).
|
|
WithObjects(monitor, pvc).
|
|
WithStatusSubresource(monitor).
|
|
Build()
|
|
|
|
reconciler := &WorkloadMonitorReconciler{Client: fakeClient, Scheme: scheme}
|
|
req := reconcile.Request{NamespacedName: types.NamespacedName{Name: "test-monitor", Namespace: "default"}}
|
|
if _, err := reconciler.Reconcile(context.TODO(), req); err != nil {
|
|
t.Fatalf("Reconcile returned error: %v", err)
|
|
}
|
|
|
|
workload := &cozyv1alpha1.Workload{}
|
|
if err := fakeClient.Get(context.TODO(), types.NamespacedName{Name: "pvc-data", Namespace: "default"}, workload); err != nil {
|
|
t.Fatalf("Failed to get Workload: %v", err)
|
|
}
|
|
if len(workload.Status.Resources) != 1 {
|
|
t.Fatalf("expected one resource entry, got %v", workload.Status.Resources)
|
|
}
|
|
if _, ok := workload.Status.Resources[tc.wantKey]; !ok {
|
|
t.Errorf("expected resource key %q, got %v", tc.wantKey, workload.Status.Resources)
|
|
}
|
|
})
|
|
}
|
|
}
|