Add the provider Checker contract and the kubernetes and GCP checks
Checker is an optional interface next to Provider: Check returns one verdict per target and never fails as a whole. The kubernetes provider lists at most one managed Pod cluster-wide (the verb and scope GC relies on); the GCP provider fetches a single page of at most one managed instance through a new Probe seam method, since the SDK treats MaxResults as a page size and the existing AggregatedList drains every page. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
|
||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||
)
|
||||
|
||||
var _ provider.Checker = (*Provider)(nil)
|
||||
|
||||
// Check lists at most one managed Pod across the cluster: the same client,
|
||||
// verb and scope ListByTag and orphan GC rely on, so a pass means the API
|
||||
// server answers and the operator's RBAC still covers them. It does not
|
||||
// prove create or delete rights.
|
||||
func (p *Provider) Check(ctx context.Context) []provider.TargetStatus {
|
||||
start := time.Now()
|
||||
var pods corev1.PodList
|
||||
err := p.client.List(ctx, &pods, client.MatchingLabels{
|
||||
provider.LabelManaged: provider.LabelManagedYes,
|
||||
}, client.Limit(1))
|
||||
ts := provider.TargetStatus{Name: "apiserver", Address: p.apiHost, Latency: time.Since(start)}
|
||||
if err != nil {
|
||||
ts.Reason = checkReason(ctx, err)
|
||||
ts.Message = err.Error()
|
||||
return []provider.TargetStatus{ts}
|
||||
}
|
||||
ts.Ready = true
|
||||
ts.Reason = provider.ReasonReachable
|
||||
return []provider.TargetStatus{ts}
|
||||
}
|
||||
|
||||
func checkReason(ctx context.Context, err error) string {
|
||||
var status apierrors.APIStatus
|
||||
switch {
|
||||
case ctx.Err() != nil, errors.Is(err, context.DeadlineExceeded),
|
||||
apierrors.IsTimeout(err), apierrors.IsServerTimeout(err):
|
||||
return provider.ReasonTimeout
|
||||
case apierrors.IsUnauthorized(err):
|
||||
return provider.ReasonUnauthorized
|
||||
case apierrors.IsForbidden(err):
|
||||
return provider.ReasonForbidden
|
||||
case errors.As(err, &status):
|
||||
return provider.ReasonAPIError
|
||||
default:
|
||||
return provider.ReasonUnreachable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package kubernetes
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
corev1 "k8s.io/api/core/v1"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
"k8s.io/apimachinery/pkg/runtime"
|
||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
||||
|
||||
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||
)
|
||||
|
||||
type listFunc func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error
|
||||
|
||||
func newCheckProvider(list listFunc) *Provider {
|
||||
scheme := runtime.NewScheme()
|
||||
if err := corev1.AddToScheme(scheme); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
c := fake.NewClientBuilder().WithScheme(scheme).WithInterceptorFuncs(interceptor.Funcs{List: list}).Build()
|
||||
p := newWithClient(c, provider.ProviderConfig{Name: "kubernetes"})
|
||||
p.apiHost = "https://10.96.0.1:443"
|
||||
return p
|
||||
}
|
||||
|
||||
func TestCheck_reachable(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newTestProvider(testPod("ns", "a", "uid-a", corev1.PodRunning, "10.0.0.1"))
|
||||
p.apiHost = "https://10.96.0.1:443"
|
||||
|
||||
got := p.Check(context.Background())
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Check() returned %d targets, want 1", len(got))
|
||||
}
|
||||
ts := got[0]
|
||||
if !ts.Ready || ts.Reason != provider.ReasonReachable || ts.Message != "" {
|
||||
t.Errorf("Check() = %+v, want ready and Reachable", ts)
|
||||
}
|
||||
if ts.Name != "apiserver" || ts.Address != "https://10.96.0.1:443" {
|
||||
t.Errorf("target = %q at %q, want apiserver at https://10.96.0.1:443", ts.Name, ts.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_listOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
var got client.ListOptions
|
||||
p := newCheckProvider(func(ctx context.Context, c client.WithWatch, list client.ObjectList, opts ...client.ListOption) error {
|
||||
got.ApplyOptions(opts)
|
||||
return c.List(ctx, list, opts...)
|
||||
})
|
||||
|
||||
p.Check(context.Background())
|
||||
|
||||
if got.Limit != 1 {
|
||||
t.Errorf("Limit = %d, want 1", got.Limit)
|
||||
}
|
||||
if got.Namespace != "" {
|
||||
t.Errorf("Namespace = %q, want cluster-wide", got.Namespace)
|
||||
}
|
||||
want := provider.LabelManaged + "=" + provider.LabelManagedYes
|
||||
if got.LabelSelector == nil || got.LabelSelector.String() != want {
|
||||
t.Errorf("LabelSelector = %v, want %s", got.LabelSelector, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheck_failureReasons(t *testing.T) {
|
||||
t.Parallel()
|
||||
pods := schema.GroupResource{Resource: "pods"}
|
||||
ended, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{"forbidden", context.Background(), apierrors.NewForbidden(pods, "", errors.New("rbac denied")), provider.ReasonForbidden},
|
||||
{"unauthorized", context.Background(), apierrors.NewUnauthorized("token expired"), provider.ReasonUnauthorized},
|
||||
{"api timeout", context.Background(), apierrors.NewTimeoutError("slow etcd", 1), provider.ReasonTimeout},
|
||||
{"server timeout", context.Background(), apierrors.NewServerTimeout(pods, "list", 1), provider.ReasonTimeout},
|
||||
{"context ended", ended, context.Canceled, provider.ReasonTimeout},
|
||||
{"internal error", context.Background(), apierrors.NewInternalError(errors.New("etcd down")), provider.ReasonAPIError},
|
||||
{"connection refused", context.Background(), &url.Error{
|
||||
Op: "Get", URL: "https://10.96.0.1:443/api/v1/pods", Err: syscall.ECONNREFUSED,
|
||||
}, provider.ReasonUnreachable},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newCheckProvider(func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error {
|
||||
return tc.err
|
||||
})
|
||||
|
||||
got := p.Check(tc.ctx)
|
||||
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("Check() returned %d targets, want 1", len(got))
|
||||
}
|
||||
if got[0].Ready || got[0].Reason != tc.want || got[0].Message != tc.err.Error() {
|
||||
t.Errorf("Check() = %+v, want not ready, reason %s, message %q", got[0], tc.want, tc.err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,8 @@ const defaultImage = "ubuntu/squid:6.6-24.04_edge"
|
||||
type Provider struct {
|
||||
client client.Client
|
||||
image string
|
||||
// apiHost is the API server URL, shown by Check; empty in tests.
|
||||
apiHost string
|
||||
}
|
||||
|
||||
// New builds a kubernetes Provider from its config block. Satisfies
|
||||
@@ -64,7 +66,9 @@ func NewWithTransportWrapper(_ context.Context, cfg provider.ProviderConfig, wra
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("kubernetes provider %q: building client: %w", cfg.Name, err)
|
||||
}
|
||||
return newWithClient(c, cfg), nil
|
||||
p := newWithClient(c, cfg)
|
||||
p.apiHost = restCfg.Host
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// newWithClient builds a Provider around an already-constructed client,
|
||||
|
||||
Reference in New Issue
Block a user