Skip to content

🌱 Add envtest #12

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Mar 17, 2025
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
1 change: 0 additions & 1 deletion .golangci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ linters:
- gocritic
- gocyclo
- gofmt
- goimports
- goprintffuncname
- gosimple
- govet
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -106,5 +106,5 @@ verify:
./hack/verify-licenses.sh

.PHONY: test
test:
test: $(KCP)
./hack/run-tests.sh
84 changes: 84 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
Copyright 2025 The KCP Authors.

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 client

import (
"fmt"
"sync"

"github.com/hashicorp/golang-lru/v2"

"k8s.io/client-go/rest"
"sigs.k8s.io/controller-runtime/pkg/client"

"github.com/kcp-dev/logicalcluster/v3"
)

// ClusterClient is a cluster-aware client.
type ClusterClient interface {
// Cluster returns the client for the given cluster.
Cluster(cluster logicalcluster.Path) client.Client
}

// clusterClient is a multi-cluster-aware client.
type clusterClient struct {
baseConfig *rest.Config
opts client.Options

lock sync.RWMutex
cache *lru.Cache[logicalcluster.Path, client.Client]
}

// New creates a new cluster-aware client.
func New(cfg *rest.Config, options client.Options) (ClusterClient, error) {
ca, err := lru.New[logicalcluster.Path, client.Client](100)
if err != nil {
return nil, err
}
return &clusterClient{
opts: options,
baseConfig: cfg,
cache: ca,
}, nil
}

func (c *clusterClient) Cluster(cluster logicalcluster.Path) client.Client {
// quick path
c.lock.RLock()
cli, ok := c.cache.Get(cluster)
c.lock.RUnlock()
if ok {
return cli
}

// slow path
c.lock.Lock()
defer c.lock.Unlock()
if cli, ok := c.cache.Get(cluster); ok {
return cli
}

// cache miss
cfg := rest.CopyConfig(c.baseConfig)
cfg.Host += cluster.RequestPath()
cli, err := client.New(cfg, c.opts)
if err != nil {
panic(fmt.Errorf("failed to create client for cluster %s: %w", cluster, err))
}
c.cache.Add(cluster, cli)
return cli
}
19 changes: 19 additions & 0 deletions envtest/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
Copyright 2025 The KCP Authors.

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 envtest provides a test environment for testing code against a
// kcp control plane.
package envtest
141 changes: 141 additions & 0 deletions envtest/eventually.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
Copyright 2025 The KCP Authors.

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 envtest

import (
"fmt"
"time"

"github.com/stretchr/testify/require"

corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/utils/ptr"

conditionsv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/third_party/conditions/apis/conditions/v1alpha1"
"github.com/kcp-dev/kcp/sdk/apis/third_party/conditions/util/conditions"
)

// Eventually asserts that given condition will be met in waitFor time, periodically checking target function
// each tick. In addition to require.Eventually, this function t.Logs the reason string value returned by the condition
// function (eventually after 20% of the wait time) to aid in debugging.
func Eventually(t TestingT, condition func() (success bool, reason string), waitFor time.Duration, tick time.Duration, msgAndArgs ...interface{}) {
t.Helper()

var last string
start := time.Now()
require.Eventually(t, func() bool {
t.Helper()

ok, msg := condition()
if time.Since(start) > waitFor/5 {
if !ok && msg != "" && msg != last {
last = msg
t.Logf("Waiting for condition, but got: %s", msg)
} else if ok && msg != "" && last != "" {
t.Logf("Condition became true: %s", msg)
}
}
return ok
}, waitFor, tick, msgAndArgs...)
}

// EventuallyReady asserts that the object returned by getter() eventually has a ready condition.
func EventuallyReady(t TestingT, getter func() (conditions.Getter, error), msgAndArgs ...interface{}) {
t.Helper()
EventuallyCondition(t, getter, Is(conditionsv1alpha1.ReadyCondition, corev1.ConditionTrue), msgAndArgs...)
}

// ConditionEvaluator is a helper for evaluating conditions.
type ConditionEvaluator struct {
conditionType conditionsv1alpha1.ConditionType
isStatus *corev1.ConditionStatus
isNotStatus *corev1.ConditionStatus
reason *string
}

func (c *ConditionEvaluator) matches(object conditions.Getter) (*conditionsv1alpha1.Condition, string, bool) {
condition := conditions.Get(object, c.conditionType)
if condition == nil {
return nil, c.descriptor(), false
}
if c.isStatus != nil && condition.Status != *c.isStatus {
return condition, c.descriptor(), false
}
if c.isNotStatus != nil && condition.Status == *c.isNotStatus {
return condition, c.descriptor(), false
}
if c.reason != nil && condition.Reason != *c.reason {
return condition, c.descriptor(), false
}
return condition, c.descriptor(), true
}

func (c *ConditionEvaluator) descriptor() string {
var descriptor string
if c.isStatus != nil {
descriptor = fmt.Sprintf("%s to be %s", c.conditionType, *c.isStatus)
}
if c.isNotStatus != nil {
descriptor = fmt.Sprintf("%s not to be %s", c.conditionType, *c.isNotStatus)
}
if c.reason != nil {
descriptor += fmt.Sprintf(" (with reason %s)", *c.reason)
}
return descriptor
}

// Is matches if the given condition type is of the given value.
func Is(conditionType conditionsv1alpha1.ConditionType, s corev1.ConditionStatus) *ConditionEvaluator {
return &ConditionEvaluator{
conditionType: conditionType,
isStatus: ptr.To(s),
}
}

// IsNot matches if the given condition type is not of the given value.
func IsNot(conditionType conditionsv1alpha1.ConditionType, s corev1.ConditionStatus) *ConditionEvaluator {
return &ConditionEvaluator{
conditionType: conditionType,
isNotStatus: ptr.To(s),
}
}

// WithReason matches if the given condition type has the given reason.
func (c *ConditionEvaluator) WithReason(reason string) *ConditionEvaluator {
c.reason = &reason
return c
}

// EventuallyCondition asserts that the object returned by getter() eventually has a condition that matches the evaluator.
func EventuallyCondition(t TestingT, getter func() (conditions.Getter, error), evaluator *ConditionEvaluator, msgAndArgs ...interface{}) {
t.Helper()
Eventually(t, func() (bool, string) {
obj, err := getter()
require.NoError(t, err, "Error fetching object")
condition, descriptor, done := evaluator.matches(obj)
var reason string
if !done {
if condition != nil {
reason = fmt.Sprintf("Not done waiting for object %s: %s: %s", descriptor, condition.Reason, condition.Message)
} else {
reason = fmt.Sprintf("Not done waiting for object %s: no condition present", descriptor)
}
}
return done, reason
}, wait.ForeverTestTimeout, 100*time.Millisecond, msgAndArgs...)
}
30 changes: 30 additions & 0 deletions envtest/internal/addr/addr_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/*
Copyright 2021 The Kubernetes Authors.

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 addr_test

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestAddr(t *testing.T) {
t.Parallel()
RegisterFailHandler(Fail)
RunSpecs(t, "Addr Suite")
}
Loading