Provider health checks: ProviderHealth CRD, per-provider checks and gauges #6
@@ -22,6 +22,22 @@ rules:
|
|||||||
- get
|
- get
|
||||||
- list
|
- list
|
||||||
- watch
|
- watch
|
||||||
|
- apiGroups:
|
||||||
|
- crawl.example.com
|
||||||
|
resources:
|
||||||
|
- providerhealths
|
||||||
|
verbs:
|
||||||
|
- create
|
||||||
|
- delete
|
||||||
|
- get
|
||||||
|
- list
|
||||||
|
- apiGroups:
|
||||||
|
- crawl.example.com
|
||||||
|
resources:
|
||||||
|
- providerhealths/status
|
||||||
|
verbs:
|
||||||
|
- get
|
||||||
|
- patch
|
||||||
- apiGroups:
|
- apiGroups:
|
||||||
- crawl.example.com
|
- crawl.example.com
|
||||||
resources:
|
resources:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ Plan: [docs/plans/2026-09-11-1526-provider-health-checks.md](../plans/2026-09-11
|
|||||||
- [x] Step 1 — ProviderHealth API and CRD
|
- [x] Step 1 — ProviderHealth API and CRD
|
||||||
- [x] Step 2 — Contract plus the kubernetes and GCP checks
|
- [x] Step 2 — Contract plus the kubernetes and GCP checks
|
||||||
- [x] Step 3 — KVM check
|
- [x] Step 3 — KVM check
|
||||||
- [ ] Step 4 — Monitor
|
- [x] Step 4 — Monitor
|
||||||
- [ ] Step 5 — Metrics and wiring
|
- [ ] Step 5 — Metrics and wiring
|
||||||
- [ ] Step 6 — Docs
|
- [ ] Step 6 — Docs
|
||||||
- [ ] Step 7 — Validate, record, push, open the PR
|
- [ ] Step 7 — Validate, record, push, open the PR
|
||||||
@@ -138,3 +138,53 @@ Worth noting:
|
|||||||
- `TestDialError_keepsClassification`.
|
- `TestDialError_keepsClassification`.
|
||||||
- **Not run yet:** `TestKVMCheck_realHost` in the integration file only compiles here.
|
- **Not run yet:** `TestKVMCheck_realHost` in the integration file only compiles here.
|
||||||
It needs beelink, so it goes on the Step 7 checklist for the user to run.
|
It needs beelink, so it goes on the Step 7 checklist for the user to run.
|
||||||
|
|
||||||
|
## Step 4 — Monitor
|
||||||
|
|
||||||
|
`internal/providerhealth` has the `Monitor` runnable plus the pure `buildStatus`.
|
||||||
|
|
||||||
|
- **Scheduling:** the Monitor is leader-only. It cleans up once at start, checks
|
||||||
|
immediately, then checks on a ticker.
|
||||||
|
- **Each round:** providers are checked concurrently under one timeout, then logged and
|
||||||
|
published one after another. A failed write is logged, and the next round writes
|
||||||
|
again.
|
||||||
|
- **Reads:** objects are read through the reader it is given. `main.go` passes the
|
||||||
|
manager's API reader (Step 5).
|
||||||
|
|
||||||
|
The first `make manifests` left `config/rbac/role.yaml` unchanged, with no error.
|
||||||
|
I had put the `+kubebuilder:rbac` markers inside `Monitor`'s doc comment, and
|
||||||
|
controller-gen only reads RBAC markers from package-level comment groups. So they were
|
||||||
|
silently attached to the type and ignored. An empty `git diff config/rbac/role.yaml` was
|
||||||
|
the only sign. Moving them into their own comment block fixed it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make manifests && git diff config/rbac/role.yaml
|
||||||
|
# + providerhealths: create, delete, get, list
|
||||||
|
# + providerhealths/status: get, patch
|
||||||
|
```
|
||||||
|
|
||||||
|
Vet, race tests and the envtest round-trip (using the `bin/k8s/1.36.2-linux-amd64`
|
||||||
|
binaries `make setup-envtest` had already fetched):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
go vet ./internal/providerhealth/ && go test -race -count=1 -v ./internal/providerhealth/
|
||||||
|
# --- PASS: TestMonitor_envtestRoundTrip (6.45s) ... ok
|
||||||
|
```
|
||||||
|
|
||||||
|
Worth noting:
|
||||||
|
- **Fake client scope:** the fake client needs a RESTMapper that registers
|
||||||
|
ProviderHealth as `RESTScopeRoot`. Its default mapper treats every kind as namespaced.
|
||||||
|
- **Why the envtest calls `publish` directly:** `checkOnce` only logs write errors, so a
|
||||||
|
schema rejection would look like a missing object. Calling `publish` makes the API
|
||||||
|
server's answer the test failure.
|
||||||
|
- Its first round writes a 2000-character message; the stored message is truncated to
|
||||||
|
1024.
|
||||||
|
- Its second round proves that only the target that flipped gets a new
|
||||||
|
`lastTransitionTime`.
|
||||||
|
- **No guard against a hung checker:** the Monitor trusts `Checker`'s contract to return
|
||||||
|
once the context ends, and doesn't abandon a checker that ignores it. All three
|
||||||
|
checkers stop when the context ends: KVM through `awaitOrCancel`, and kubernetes and
|
||||||
|
GCP through their clients.
|
||||||
|
- **Test renamed:** the planned `TestMonitor_timeoutBoundsBlockingChecker` is
|
||||||
|
`TestMonitor_checkOnce_timeoutBoundsEachRound`. It also asserts that a fast provider
|
||||||
|
still publishes next to a hung one.
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package providerhealth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/envtest"
|
||||||
|
|
||||||
|
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestMonitor_envtestRoundTrip writes through a real API server, which is
|
||||||
|
// the only thing that validates the generated schema: cluster scope, the
|
||||||
|
// status subresource, required fields, list-map keys and the message limit.
|
||||||
|
func TestMonitor_envtestRoundTrip(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("envtest skipped under -short")
|
||||||
|
}
|
||||||
|
env := &envtest.Environment{
|
||||||
|
CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")},
|
||||||
|
ErrorIfCRDPathMissing: true,
|
||||||
|
BinaryAssetsDirectory: envtestBinaryDir(),
|
||||||
|
}
|
||||||
|
cfg, err := env.Start()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("starting envtest (run `make setup-envtest` first): %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = env.Stop() })
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
if err := crawlv1alpha1.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c, err := client.New(cfg, client.Options{Scheme: scheme})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
m := &Monitor{Client: c, Reader: c}
|
||||||
|
|
||||||
|
first := Result{Provider: "kvm-home", Type: "kvm", Supported: true, CheckedAt: t0, Targets: []provider.TargetStatus{
|
||||||
|
{Name: "nas1", Address: "nas1:22", Ready: true, Reason: provider.ReasonReachable,
|
||||||
|
Latency: 42 * time.Millisecond, Details: map[string]string{"libvirtVersion": "10.2.0"}},
|
||||||
|
{Name: "nas2", Address: "nas2:22", Reason: provider.ReasonUnreachable, Message: strings.Repeat("x", 2000)},
|
||||||
|
}}
|
||||||
|
if err := m.publish(ctx, first); err != nil {
|
||||||
|
t.Fatalf("first publish: %v", err)
|
||||||
|
}
|
||||||
|
got := getHealth(t, c, "kvm-home")
|
||||||
|
if got.Status.TargetsReady != "1/2" || got.Status.Targets[0].Details["libvirtVersion"] != "10.2.0" ||
|
||||||
|
got.Status.Targets[0].LatencyMillis != 42 || utf8.RuneCountInString(got.Status.Targets[1].Message) != maxMessageLen {
|
||||||
|
t.Errorf("status after the first round = %+v", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := Result{Provider: "kvm-home", Type: "kvm", Supported: true, CheckedAt: t1,
|
||||||
|
Targets: []provider.TargetStatus{up("nas1"), up("nas2")}}
|
||||||
|
if err := m.publish(ctx, second); err != nil {
|
||||||
|
t.Fatalf("second publish: %v", err)
|
||||||
|
}
|
||||||
|
got = getHealth(t, c, "kvm-home")
|
||||||
|
if cond := readyCondition(got); cond == nil || cond.Reason != reasonAllTargetsReady {
|
||||||
|
t.Errorf("Ready after recovery = %+v, want %s", cond, reasonAllTargetsReady)
|
||||||
|
}
|
||||||
|
if lt := got.Status.Targets[0].LastTransitionTime.Time; !lt.Equal(t0) {
|
||||||
|
t.Errorf("nas1 lastTransitionTime = %s, want %s", lt, t0)
|
||||||
|
}
|
||||||
|
if lt := got.Status.Targets[1].LastTransitionTime.Time; !lt.Equal(t1) {
|
||||||
|
t.Errorf("nas2 lastTransitionTime = %s, want %s", lt, t1)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.cleanup(ctx, nil)
|
||||||
|
var gone crawlv1alpha1.ProviderHealth
|
||||||
|
if err := c.Get(ctx, client.ObjectKey{Name: "kvm-home"}, &gone); !apierrors.IsNotFound(err) {
|
||||||
|
t.Errorf("Get after cleanup = %v, want NotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// envtestBinaryDir finds the binaries `make setup-envtest` downloads, so
|
||||||
|
// the test also runs outside make (which sets KUBEBUILDER_ASSETS instead).
|
||||||
|
func envtestBinaryDir() string {
|
||||||
|
base := filepath.Join("..", "..", "bin", "k8s")
|
||||||
|
entries, err := os.ReadDir(base)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.IsDir() {
|
||||||
|
return filepath.Join(base, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
// Package providerhealth checks every configured provider on an interval
|
||||||
|
// and publishes the results: one ProviderHealth object per provider, a log
|
||||||
|
// line whenever a target goes down or comes back, and a snapshot the
|
||||||
|
// metrics collector reads at scrape time.
|
||||||
|
package providerhealth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-logr/logr"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"k8s.io/apimachinery/pkg/api/equality"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/util/validation"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
logf "sigs.k8s.io/controller-runtime/pkg/log"
|
||||||
|
|
||||||
|
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/tracing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// labelManagedBy marks the ProviderHealth objects this operator owns;
|
||||||
|
// cleanup never deletes an object without it.
|
||||||
|
labelManagedBy = "app.kubernetes.io/managed-by"
|
||||||
|
managedByValue = "egress-proxies-operator"
|
||||||
|
|
||||||
|
minInterval = 10 * time.Second
|
||||||
|
defaultTimeout = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Target is one configured provider. Checker is nil for a provider that
|
||||||
|
// cannot check itself.
|
||||||
|
type Target struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Checker provider.Checker
|
||||||
|
}
|
||||||
|
|
||||||
|
// Result is the outcome of one provider's latest check.
|
||||||
|
type Result struct {
|
||||||
|
Provider string
|
||||||
|
Type string
|
||||||
|
// Supported is false when the provider has no Checker.
|
||||||
|
Supported bool
|
||||||
|
Targets []provider.TargetStatus
|
||||||
|
CheckedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// controller-gen reads RBAC markers only at package level, so they stay
|
||||||
|
// out of Monitor's doc comment.
|
||||||
|
// +kubebuilder:rbac:groups=crawl.example.com,resources=providerhealths,verbs=get;list;create;delete
|
||||||
|
// +kubebuilder:rbac:groups=crawl.example.com,resources=providerhealths/status,verbs=get;patch
|
||||||
|
|
||||||
|
// Monitor is the manager Runnable that checks the providers.
|
||||||
|
type Monitor struct {
|
||||||
|
// Client writes the ProviderHealth objects.
|
||||||
|
Client client.Client
|
||||||
|
// Reader reads them; pass the manager's API reader. The objects are
|
||||||
|
// cluster-scoped, and reading them through the cache would start a
|
||||||
|
// cluster-wide informer needing watch rights, whatever --proxy-namespace
|
||||||
|
// says, for a handful of reads a minute.
|
||||||
|
Reader client.Reader
|
||||||
|
// Targets are the configured providers, in config order.
|
||||||
|
Targets []Target
|
||||||
|
// Interval between rounds of checks; 0 turns checking off. The first
|
||||||
|
// round runs at start: nothing races it, and early visibility is the
|
||||||
|
// point.
|
||||||
|
Interval time.Duration
|
||||||
|
// Timeout bounds each round (default 15s).
|
||||||
|
Timeout time.Duration
|
||||||
|
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
latest []Result
|
||||||
|
|
||||||
|
// prev holds each target's previous verdict for transition logging.
|
||||||
|
// Only the loop goroutine touches it.
|
||||||
|
prev map[string]provider.TargetStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
// NeedLeaderElection is true: only the leader dials the hypervisors and
|
||||||
|
// writes the objects, and only its gauges describe the checks.
|
||||||
|
func (m *Monitor) NeedLeaderElection() bool { return true }
|
||||||
|
|
||||||
|
// ValidateSchedule checks --provider-check-interval and
|
||||||
|
// --provider-check-timeout. The interval is 0 (off) or at least 10s, which
|
||||||
|
// is what keeps one status write per provider per round cheap; the timeout
|
||||||
|
// is positive and no longer than the interval.
|
||||||
|
func ValidateSchedule(interval, timeout time.Duration) error {
|
||||||
|
switch {
|
||||||
|
case interval == 0:
|
||||||
|
return nil
|
||||||
|
case interval < minInterval:
|
||||||
|
return fmt.Errorf("--provider-check-interval must be 0 (off) or at least %s, got %s", minInterval, interval)
|
||||||
|
case timeout <= 0 || timeout > interval:
|
||||||
|
return fmt.Errorf("--provider-check-timeout must be positive and at most the interval (%s), got %s", interval, timeout)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start deletes the objects of providers that are no longer configured,
|
||||||
|
// then checks every Interval until ctx ends. The providers config is read
|
||||||
|
// once per process, so cleaning up once is enough. With checking off,
|
||||||
|
// every object goes, so none is left claiming a stale Ready.
|
||||||
|
func (m *Monitor) Start(ctx context.Context) error {
|
||||||
|
if m.Timeout == 0 {
|
||||||
|
m.Timeout = defaultTimeout
|
||||||
|
}
|
||||||
|
if m.now == nil {
|
||||||
|
m.now = time.Now
|
||||||
|
}
|
||||||
|
log := logf.FromContext(ctx).WithName("provider-health")
|
||||||
|
ctx = logf.IntoContext(ctx, log)
|
||||||
|
|
||||||
|
keep := map[string]bool{}
|
||||||
|
if m.Interval > 0 {
|
||||||
|
for _, t := range m.Targets {
|
||||||
|
keep[t.Name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.cleanup(ctx, keep)
|
||||||
|
if m.Interval == 0 {
|
||||||
|
log.Info("Provider checks are off (--provider-check-interval=0)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, t := range m.Targets {
|
||||||
|
if errs := validation.IsDNS1123Subdomain(t.Name); len(errs) > 0 {
|
||||||
|
log.Info("WARNING: provider name is not a valid object name; its ProviderHealth object is not written, its metrics are",
|
||||||
|
"provider", t.Name, "problems", errs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.checkOnce(ctx)
|
||||||
|
ticker := time.NewTicker(m.Interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case <-ticker.C:
|
||||||
|
m.checkOnce(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns the latest results in config order.
|
||||||
|
func (m *Monitor) Snapshot() []Result {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
out := make([]Result, len(m.latest))
|
||||||
|
for i, r := range m.latest {
|
||||||
|
r.Targets = slices.Clone(r.Targets)
|
||||||
|
out[i] = r
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkOnce runs every provider's check concurrently under one timeout,
|
||||||
|
// then records, logs and publishes the results provider by provider. A
|
||||||
|
// failed write is logged and retried by the next round.
|
||||||
|
func (m *Monitor) checkOnce(ctx context.Context) {
|
||||||
|
ctx, span := tracing.Start(ctx, "providerhealth.check")
|
||||||
|
defer span.End()
|
||||||
|
log := logf.FromContext(ctx)
|
||||||
|
|
||||||
|
checkCtx, cancel := context.WithTimeout(ctx, m.Timeout)
|
||||||
|
defer cancel()
|
||||||
|
results := make([]Result, len(m.Targets))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, t := range m.Targets {
|
||||||
|
wg.Go(func() { results[i] = m.run(checkCtx, t) })
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
m.latest = results
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
notReady := 0
|
||||||
|
for _, r := range results {
|
||||||
|
m.logTransitions(log, r)
|
||||||
|
for _, ts := range r.Targets {
|
||||||
|
if !ts.Ready {
|
||||||
|
notReady++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := m.publish(ctx, r); err != nil {
|
||||||
|
log.Error(err, "Writing provider health", "provider", r.Provider)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
span.SetAttributes(
|
||||||
|
attribute.Int("providerhealth.providers", len(results)),
|
||||||
|
attribute.Int("providerhealth.targets_not_ready", notReady),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Monitor) run(ctx context.Context, t Target) Result {
|
||||||
|
r := Result{Provider: t.Name, Type: t.Type, Supported: t.Checker != nil}
|
||||||
|
if t.Checker != nil {
|
||||||
|
r.Targets = t.Checker.Check(ctx)
|
||||||
|
}
|
||||||
|
r.CheckedAt = m.now()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// logTransitions logs a target going down, staying down for a different
|
||||||
|
// reason, or coming back. Steady states stay quiet; a target that is ready
|
||||||
|
// the first time it is seen only logs at V(1).
|
||||||
|
func (m *Monitor) logTransitions(log logr.Logger, r Result) {
|
||||||
|
if m.prev == nil {
|
||||||
|
m.prev = map[string]provider.TargetStatus{}
|
||||||
|
}
|
||||||
|
for _, ts := range r.Targets {
|
||||||
|
key := r.Provider + "/" + ts.Name
|
||||||
|
prev, seen := m.prev[key]
|
||||||
|
m.prev[key] = ts
|
||||||
|
kv := []any{"provider", r.Provider, "target", ts.Name, "address", ts.Address, "reason", ts.Reason}
|
||||||
|
switch {
|
||||||
|
case !ts.Ready && (!seen || prev.Ready || prev.Reason != ts.Reason):
|
||||||
|
log.Info("WARNING: provider target not ready", append(kv, "message", ts.Message)...)
|
||||||
|
case ts.Ready && seen && !prev.Ready:
|
||||||
|
log.Info("Provider target ready", append(kv, "latency", ts.Latency.Round(time.Millisecond))...)
|
||||||
|
case ts.Ready && !seen:
|
||||||
|
log.V(1).Info("Provider target ready", append(kv, "latency", ts.Latency.Round(time.Millisecond))...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// publish creates the provider's ProviderHealth object when missing and
|
||||||
|
// patches its status. The status changes on every round, since
|
||||||
|
// lastCheckTime moves; the comparison only skips the patch when a clock
|
||||||
|
// did not advance.
|
||||||
|
func (m *Monitor) publish(ctx context.Context, r Result) error {
|
||||||
|
if len(validation.IsDNS1123Subdomain(r.Provider)) > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ph crawlv1alpha1.ProviderHealth
|
||||||
|
err := m.Reader.Get(ctx, client.ObjectKey{Name: r.Provider}, &ph)
|
||||||
|
switch {
|
||||||
|
case apierrors.IsNotFound(err):
|
||||||
|
ph = crawlv1alpha1.ProviderHealth{ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: r.Provider,
|
||||||
|
Labels: map[string]string{labelManagedBy: managedByValue},
|
||||||
|
}}
|
||||||
|
if err := m.Client.Create(ctx, &ph); err != nil {
|
||||||
|
return fmt.Errorf("creating ProviderHealth %s: %w", r.Provider, err)
|
||||||
|
}
|
||||||
|
case err != nil:
|
||||||
|
return fmt.Errorf("reading ProviderHealth %s: %w", r.Provider, err)
|
||||||
|
}
|
||||||
|
base := ph.DeepCopy()
|
||||||
|
ph.Status = buildStatus(ph.Status, r)
|
||||||
|
if equality.Semantic.DeepEqual(base.Status, ph.Status) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := m.Client.Status().Patch(ctx, &ph, client.MergeFrom(base)); err != nil {
|
||||||
|
return fmt.Errorf("patching ProviderHealth %s status: %w", r.Provider, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup deletes this operator's ProviderHealth objects whose name is not
|
||||||
|
// in keep.
|
||||||
|
func (m *Monitor) cleanup(ctx context.Context, keep map[string]bool) {
|
||||||
|
log := logf.FromContext(ctx)
|
||||||
|
var list crawlv1alpha1.ProviderHealthList
|
||||||
|
if err := m.Reader.List(ctx, &list, client.MatchingLabels{labelManagedBy: managedByValue}); err != nil {
|
||||||
|
log.Error(err, "Listing provider health objects; stale ones stay until the next start")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range list.Items {
|
||||||
|
ph := &list.Items[i]
|
||||||
|
if keep[ph.Name] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := m.Client.Delete(ctx, ph); client.IgnoreNotFound(err) != nil {
|
||||||
|
log.Error(err, "Deleting provider health of an unconfigured provider", "name", ph.Name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Info("Deleted provider health of an unconfigured provider", "name", ph.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,354 @@
|
|||||||
|
package providerhealth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-logr/logr"
|
||||||
|
"github.com/go-logr/logr/funcr"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
apimeta "k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"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"
|
||||||
|
|
||||||
|
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
t0 = time.Date(2026, 9, 11, 15, 0, 0, 0, time.UTC)
|
||||||
|
t1 = t0.Add(time.Minute)
|
||||||
|
)
|
||||||
|
|
||||||
|
type checkFunc func(ctx context.Context) []provider.TargetStatus
|
||||||
|
|
||||||
|
func (f checkFunc) Check(ctx context.Context) []provider.TargetStatus { return f(ctx) }
|
||||||
|
|
||||||
|
func verdicts(ts ...provider.TargetStatus) checkFunc {
|
||||||
|
return func(context.Context) []provider.TargetStatus { return ts }
|
||||||
|
}
|
||||||
|
|
||||||
|
func up(name string) provider.TargetStatus {
|
||||||
|
return provider.TargetStatus{Name: name, Address: name + ":22", Ready: true, Reason: provider.ReasonReachable}
|
||||||
|
}
|
||||||
|
|
||||||
|
func down(name, reason string) provider.TargetStatus {
|
||||||
|
return provider.TargetStatus{Name: name, Address: name + ":22", Reason: reason, Message: name + " is down"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func managed(name string) *crawlv1alpha1.ProviderHealth {
|
||||||
|
return &crawlv1alpha1.ProviderHealth{ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: name,
|
||||||
|
Labels: map[string]string{labelManagedBy: managedByValue},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFakeClient registers ProviderHealth as cluster-scoped; the fake's
|
||||||
|
// default mapper would treat it as namespaced.
|
||||||
|
func newFakeClient(t *testing.T, objs ...client.Object) client.WithWatch {
|
||||||
|
t.Helper()
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
if err := crawlv1alpha1.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
mapper := apimeta.NewDefaultRESTMapper([]schema.GroupVersion{crawlv1alpha1.GroupVersion})
|
||||||
|
mapper.Add(crawlv1alpha1.GroupVersion.WithKind("ProviderHealth"), apimeta.RESTScopeRoot)
|
||||||
|
return fake.NewClientBuilder().
|
||||||
|
WithScheme(scheme).
|
||||||
|
WithRESTMapper(mapper).
|
||||||
|
WithStatusSubresource(&crawlv1alpha1.ProviderHealth{}).
|
||||||
|
WithObjects(objs...).
|
||||||
|
Build()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestMonitor(c client.Client, targets ...Target) *Monitor {
|
||||||
|
return &Monitor{
|
||||||
|
Client: c,
|
||||||
|
Reader: c,
|
||||||
|
Targets: targets,
|
||||||
|
Interval: time.Minute,
|
||||||
|
Timeout: time.Second,
|
||||||
|
now: func() time.Time { return t1 },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getHealth(t *testing.T, c client.Reader, name string) *crawlv1alpha1.ProviderHealth {
|
||||||
|
t.Helper()
|
||||||
|
var ph crawlv1alpha1.ProviderHealth
|
||||||
|
if err := c.Get(context.Background(), client.ObjectKey{Name: name}, &ph); err != nil {
|
||||||
|
t.Fatalf("Get ProviderHealth %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return &ph
|
||||||
|
}
|
||||||
|
|
||||||
|
func readyCondition(ph *crawlv1alpha1.ProviderHealth) *metav1.Condition {
|
||||||
|
return apimeta.FindStatusCondition(ph.Status.Conditions, crawlv1alpha1.ConditionReady)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitor_checkOnce_createsLabelledObjects(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newFakeClient(t)
|
||||||
|
m := newTestMonitor(c,
|
||||||
|
Target{Name: "kvm-home", Type: "kvm", Checker: verdicts(up("nas1"), down("nas2", provider.ReasonUnreachable))},
|
||||||
|
Target{Name: "local", Type: "kubernetes", Checker: verdicts(up("apiserver"))},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.checkOnce(context.Background())
|
||||||
|
|
||||||
|
kvm := getHealth(t, c, "kvm-home")
|
||||||
|
if kvm.Labels[labelManagedBy] != managedByValue {
|
||||||
|
t.Errorf("labels = %v, want %s=%s", kvm.Labels, labelManagedBy, managedByValue)
|
||||||
|
}
|
||||||
|
if cond := readyCondition(kvm); cond == nil || cond.Status != metav1.ConditionFalse || cond.Reason != reasonTargetsNotReady {
|
||||||
|
t.Errorf("kvm-home Ready = %+v, want False/%s", cond, reasonTargetsNotReady)
|
||||||
|
}
|
||||||
|
if kvm.Status.Type != "kvm" || kvm.Status.TargetsReady != "1/2" || kvm.Status.LastCheckTime == nil || !kvm.Status.LastCheckTime.Time.Equal(t1) {
|
||||||
|
t.Errorf("kvm-home status = %+v, want type kvm, 1/2 ready, checked at %s", kvm.Status, t1)
|
||||||
|
}
|
||||||
|
local := getHealth(t, c, "local")
|
||||||
|
if cond := readyCondition(local); cond == nil || cond.Status != metav1.ConditionTrue {
|
||||||
|
t.Errorf("local Ready = %+v, want True", cond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitor_checkOnce_patchesExisting(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
existing := managed("local")
|
||||||
|
existing.Status = crawlv1alpha1.ProviderHealthStatus{
|
||||||
|
Type: "kubernetes",
|
||||||
|
Targets: []crawlv1alpha1.ProviderTarget{readyAt("apiserver", t0)},
|
||||||
|
}
|
||||||
|
c := newFakeClient(t, existing)
|
||||||
|
m := newTestMonitor(c, Target{Name: "local", Type: "kubernetes", Checker: verdicts(up("apiserver"))})
|
||||||
|
|
||||||
|
m.checkOnce(context.Background())
|
||||||
|
|
||||||
|
got := getHealth(t, c, "local")
|
||||||
|
if got.Status.LastCheckTime == nil || !got.Status.LastCheckTime.Time.Equal(t1) {
|
||||||
|
t.Errorf("lastCheckTime = %v, want %s", got.Status.LastCheckTime, t1)
|
||||||
|
}
|
||||||
|
if lt := got.Status.Targets[0].LastTransitionTime.Time; !lt.Equal(t0) {
|
||||||
|
t.Errorf("apiserver lastTransitionTime = %s, want %s kept: it was ready before too", lt, t0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitor_checkOnce_timeoutBoundsEachRound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newFakeClient(t)
|
||||||
|
var hadDeadline atomic.Bool
|
||||||
|
hanging := checkFunc(func(ctx context.Context) []provider.TargetStatus {
|
||||||
|
_, ok := ctx.Deadline()
|
||||||
|
hadDeadline.Store(ok)
|
||||||
|
<-ctx.Done()
|
||||||
|
return []provider.TargetStatus{down("nas1", provider.ReasonTimeout)}
|
||||||
|
})
|
||||||
|
m := newTestMonitor(c,
|
||||||
|
Target{Name: "kvm-home", Type: "kvm", Checker: hanging},
|
||||||
|
Target{Name: "local", Type: "kubernetes", Checker: verdicts(up("apiserver"))},
|
||||||
|
)
|
||||||
|
m.Timeout = 50 * time.Millisecond
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
m.checkOnce(context.Background())
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("checkOnce still running 5s after a 50ms timeout")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hadDeadline.Load() {
|
||||||
|
t.Error("the checker got a context without a deadline")
|
||||||
|
}
|
||||||
|
if cond := readyCondition(getHealth(t, c, "kvm-home")); cond == nil || cond.Status != metav1.ConditionFalse {
|
||||||
|
t.Errorf("kvm-home Ready = %+v, want False", cond)
|
||||||
|
}
|
||||||
|
if cond := readyCondition(getHealth(t, c, "local")); cond == nil || cond.Status != metav1.ConditionTrue {
|
||||||
|
t.Errorf("local Ready = %+v, want True despite the slow neighbour", cond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitor_checkOnce_noCheckerIsUnknown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newFakeClient(t)
|
||||||
|
m := newTestMonitor(c, Target{Name: "gcp-eu", Type: "gcp"})
|
||||||
|
|
||||||
|
m.checkOnce(context.Background())
|
||||||
|
|
||||||
|
got := getHealth(t, c, "gcp-eu")
|
||||||
|
if cond := readyCondition(got); cond == nil || cond.Status != metav1.ConditionUnknown || cond.Reason != reasonChecksNotImplemented {
|
||||||
|
t.Errorf("Ready = %+v, want Unknown/%s", cond, reasonChecksNotImplemented)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitor_checkOnce_invalidNameSkipsTheObject(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newFakeClient(t)
|
||||||
|
m := newTestMonitor(c, Target{Name: "Home_KVM", Type: "kvm", Checker: verdicts(up("nas1"))})
|
||||||
|
|
||||||
|
m.checkOnce(context.Background())
|
||||||
|
|
||||||
|
var list crawlv1alpha1.ProviderHealthList
|
||||||
|
if err := c.List(context.Background(), &list); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(list.Items) != 0 {
|
||||||
|
t.Errorf("wrote %d objects, want none for an invalid name", len(list.Items))
|
||||||
|
}
|
||||||
|
snap := m.Snapshot()
|
||||||
|
if len(snap) != 1 || snap[0].Provider != "Home_KVM" || !snap[0].Targets[0].Ready {
|
||||||
|
t.Errorf("Snapshot() = %+v, want the result kept for metrics", snap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitor_logsOnlyTransitions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
var mu sync.Mutex
|
||||||
|
var lines []string
|
||||||
|
log := funcr.New(func(_, args string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
lines = append(lines, args)
|
||||||
|
}, funcr.Options{})
|
||||||
|
ctx := logr.NewContext(context.Background(), log)
|
||||||
|
rounds := [][]provider.TargetStatus{
|
||||||
|
{up("nas1")},
|
||||||
|
{up("nas1")},
|
||||||
|
{down("nas1", provider.ReasonUnreachable)},
|
||||||
|
{down("nas1", provider.ReasonUnreachable)},
|
||||||
|
{down("nas1", provider.ReasonTimeout)},
|
||||||
|
{up("nas1")},
|
||||||
|
}
|
||||||
|
round := 0
|
||||||
|
m := newTestMonitor(newFakeClient(t), Target{Name: "kvm-home", Type: "kvm", Checker: checkFunc(
|
||||||
|
func(context.Context) []provider.TargetStatus { return rounds[round] })})
|
||||||
|
|
||||||
|
for round = range rounds {
|
||||||
|
m.checkOnce(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
want := [][]string{
|
||||||
|
{`"msg"="WARNING: provider target not ready"`, `"reason"="Unreachable"`},
|
||||||
|
{`"msg"="WARNING: provider target not ready"`, `"reason"="Timeout"`},
|
||||||
|
{`"msg"="Provider target ready"`},
|
||||||
|
}
|
||||||
|
if len(lines) != len(want) {
|
||||||
|
t.Fatalf("logged %d lines, want %d:\n%s", len(lines), len(want), strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
for i, subs := range want {
|
||||||
|
for _, sub := range subs {
|
||||||
|
if !strings.Contains(lines[i], sub) {
|
||||||
|
t.Errorf("line %d missing %s:\n%s", i, sub, lines[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitor_cleanup_deletesOnlyLabelledUnconfigured(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
foreign := &crawlv1alpha1.ProviderHealth{ObjectMeta: metav1.ObjectMeta{Name: "foreign"}}
|
||||||
|
c := newFakeClient(t, managed("kvm-home"), managed("retired"), foreign)
|
||||||
|
m := newTestMonitor(c)
|
||||||
|
|
||||||
|
m.cleanup(context.Background(), map[string]bool{"kvm-home": true})
|
||||||
|
|
||||||
|
getHealth(t, c, "kvm-home")
|
||||||
|
getHealth(t, c, "foreign")
|
||||||
|
var gone crawlv1alpha1.ProviderHealth
|
||||||
|
if err := c.Get(context.Background(), client.ObjectKey{Name: "retired"}, &gone); !apierrors.IsNotFound(err) {
|
||||||
|
t.Errorf("Get retired = %v, want NotFound", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitor_Start_offCleansUpAndReturns(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newFakeClient(t, managed("kvm-home"))
|
||||||
|
var checked atomic.Bool
|
||||||
|
m := newTestMonitor(c, Target{Name: "kvm-home", Type: "kvm", Checker: checkFunc(
|
||||||
|
func(context.Context) []provider.TargetStatus { checked.Store(true); return nil })})
|
||||||
|
m.Interval = 0
|
||||||
|
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() { errc <- m.Start(context.Background()) }()
|
||||||
|
select {
|
||||||
|
case err := <-errc:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Start() = %v, want nil", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Start did not return with checking off")
|
||||||
|
}
|
||||||
|
|
||||||
|
if checked.Load() {
|
||||||
|
t.Error("a check ran with checking off")
|
||||||
|
}
|
||||||
|
var gone crawlv1alpha1.ProviderHealth
|
||||||
|
if err := c.Get(context.Background(), client.ObjectKey{Name: "kvm-home"}, &gone); !apierrors.IsNotFound(err) {
|
||||||
|
t.Errorf("Get kvm-home = %v, want NotFound so no stale Ready is left behind", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMonitor_Start_checksAtOnceAndStops(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
c := newFakeClient(t)
|
||||||
|
m := newTestMonitor(c, Target{Name: "local", Type: "kubernetes", Checker: verdicts(up("apiserver"))})
|
||||||
|
m.Interval = time.Hour
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
errc := make(chan error, 1)
|
||||||
|
go func() { errc <- m.Start(ctx) }()
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for len(m.Snapshot()) == 0 {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("no check within 5s of Start, want one right away")
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-errc:
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Start() = %v, want nil", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("Start did not return after its context ended")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateSchedule(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
interval, timeout time.Duration
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"off", 0, 0, false},
|
||||||
|
{"defaults", time.Minute, 15 * time.Second, false},
|
||||||
|
{"shortest interval", 10 * time.Second, 10 * time.Second, false},
|
||||||
|
{"too frequent", 5 * time.Second, time.Second, true},
|
||||||
|
{"negative interval", -time.Minute, time.Second, true},
|
||||||
|
{"no timeout", time.Minute, 0, true},
|
||||||
|
{"timeout longer than interval", time.Minute, 2 * time.Minute, true},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if err := ValidateSchedule(tc.interval, tc.timeout); (err != nil) != tc.wantErr {
|
||||||
|
t.Errorf("ValidateSchedule(%s, %s) = %v, want error %v", tc.interval, tc.timeout, err, tc.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package providerhealth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
apimeta "k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
|
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reasons on the Ready condition.
|
||||||
|
const (
|
||||||
|
reasonAllTargetsReady = "AllTargetsReady"
|
||||||
|
reasonTargetsNotReady = "TargetsNotReady"
|
||||||
|
reasonChecksNotImplemented = "ChecksNotImplemented"
|
||||||
|
reasonNoTargets = "NoTargets"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxMessageLen matches the CRD's limit on a target's message.
|
||||||
|
const maxMessageLen = 1024
|
||||||
|
|
||||||
|
// buildStatus derives a ProviderHealth status from the previous one and a
|
||||||
|
// fresh result. Every timestamp comes from r.CheckedAt, and prev is left
|
||||||
|
// untouched. A target keeps its lastTransitionTime while ready does not
|
||||||
|
// flip, which is how several hosts each show when they went down.
|
||||||
|
func buildStatus(prev crawlv1alpha1.ProviderHealthStatus, r Result) crawlv1alpha1.ProviderHealthStatus {
|
||||||
|
now := metav1.NewTime(r.CheckedAt)
|
||||||
|
out := crawlv1alpha1.ProviderHealthStatus{
|
||||||
|
Type: r.Type,
|
||||||
|
Conditions: slices.Clone(prev.Conditions),
|
||||||
|
LastCheckTime: &now,
|
||||||
|
}
|
||||||
|
before := make(map[string]crawlv1alpha1.ProviderTarget, len(prev.Targets))
|
||||||
|
for _, t := range prev.Targets {
|
||||||
|
before[t.Name] = t
|
||||||
|
}
|
||||||
|
ready := 0
|
||||||
|
var failing []string
|
||||||
|
for _, ts := range r.Targets {
|
||||||
|
t := crawlv1alpha1.ProviderTarget{
|
||||||
|
Name: ts.Name,
|
||||||
|
Address: ts.Address,
|
||||||
|
Ready: ts.Ready,
|
||||||
|
Reason: ts.Reason,
|
||||||
|
Message: truncate(ts.Message, maxMessageLen),
|
||||||
|
LatencyMillis: ts.Latency.Milliseconds(),
|
||||||
|
LastTransitionTime: now,
|
||||||
|
Details: ts.Details,
|
||||||
|
}
|
||||||
|
if b, ok := before[ts.Name]; ok && b.Ready == ts.Ready {
|
||||||
|
t.LastTransitionTime = b.LastTransitionTime
|
||||||
|
}
|
||||||
|
if ts.Ready {
|
||||||
|
ready++
|
||||||
|
} else {
|
||||||
|
failing = append(failing, ts.Name+": "+ts.Reason)
|
||||||
|
}
|
||||||
|
out.Targets = append(out.Targets, t)
|
||||||
|
}
|
||||||
|
if r.Supported {
|
||||||
|
out.TargetsReady = fmt.Sprintf("%d/%d", ready, len(r.Targets))
|
||||||
|
}
|
||||||
|
|
||||||
|
cond := metav1.Condition{Type: crawlv1alpha1.ConditionReady, LastTransitionTime: now}
|
||||||
|
switch {
|
||||||
|
case !r.Supported:
|
||||||
|
cond.Status = metav1.ConditionUnknown
|
||||||
|
cond.Reason = reasonChecksNotImplemented
|
||||||
|
cond.Message = fmt.Sprintf("%s providers have no health check", r.Type)
|
||||||
|
case len(r.Targets) == 0:
|
||||||
|
cond.Status = metav1.ConditionUnknown
|
||||||
|
cond.Reason = reasonNoTargets
|
||||||
|
cond.Message = "the provider has nothing to check"
|
||||||
|
case len(failing) == 0:
|
||||||
|
cond.Status = metav1.ConditionTrue
|
||||||
|
cond.Reason = reasonAllTargetsReady
|
||||||
|
cond.Message = fmt.Sprintf("%d of %d targets ready", ready, len(r.Targets))
|
||||||
|
default:
|
||||||
|
cond.Status = metav1.ConditionFalse
|
||||||
|
cond.Reason = reasonTargetsNotReady
|
||||||
|
cond.Message = truncate(strings.Join(failing, "; "), maxMessageLen)
|
||||||
|
}
|
||||||
|
apimeta.SetStatusCondition(&out.Conditions, cond)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// truncate cuts s to at most n characters, which is how the API server
|
||||||
|
// counts maxLength.
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if utf8.RuneCountInString(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return string([]rune(s)[:n-1]) + "…"
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package providerhealth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
apimeta "k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
|
||||||
|
crawlv1alpha1 "gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/api/v1alpha1"
|
||||||
|
"gitea.home.hrajfrisbee.cz/kacerr/egress-proxies-operator/internal/provider"
|
||||||
|
)
|
||||||
|
|
||||||
|
func readyAt(name string, at time.Time) crawlv1alpha1.ProviderTarget {
|
||||||
|
return crawlv1alpha1.ProviderTarget{Name: name, Ready: true, Reason: provider.ReasonReachable, LastTransitionTime: metav1.NewTime(at)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
prev crawlv1alpha1.ProviderHealthStatus
|
||||||
|
result Result
|
||||||
|
wantStatus metav1.ConditionStatus
|
||||||
|
wantReason string
|
||||||
|
wantMessage string
|
||||||
|
wantTargetsReady string
|
||||||
|
// wantTransitions is each target's lastTransitionTime, in order.
|
||||||
|
wantTransitions []time.Time
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "all ready",
|
||||||
|
result: Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1"), up("nas2")}, CheckedAt: t1},
|
||||||
|
wantStatus: metav1.ConditionTrue,
|
||||||
|
wantReason: reasonAllTargetsReady,
|
||||||
|
wantMessage: "2 of 2 targets ready",
|
||||||
|
wantTargetsReady: "2/2",
|
||||||
|
wantTransitions: []time.Time{t1, t1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one target down",
|
||||||
|
result: Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1"), down("nas2", provider.ReasonUnreachable)}, CheckedAt: t1},
|
||||||
|
wantStatus: metav1.ConditionFalse,
|
||||||
|
wantReason: reasonTargetsNotReady,
|
||||||
|
wantMessage: "nas2: Unreachable",
|
||||||
|
wantTargetsReady: "1/2",
|
||||||
|
wantTransitions: []time.Time{t1, t1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only a flip moves a target's transition time",
|
||||||
|
prev: crawlv1alpha1.ProviderHealthStatus{Targets: []crawlv1alpha1.ProviderTarget{readyAt("nas1", t0), readyAt("nas2", t0)}},
|
||||||
|
result: Result{Type: "kvm", Supported: true, CheckedAt: t1,
|
||||||
|
Targets: []provider.TargetStatus{up("nas1"), down("nas2", provider.ReasonTimeout)}},
|
||||||
|
wantStatus: metav1.ConditionFalse,
|
||||||
|
wantReason: reasonTargetsNotReady,
|
||||||
|
wantMessage: "nas2: Timeout",
|
||||||
|
wantTargetsReady: "1/2",
|
||||||
|
wantTransitions: []time.Time{t0, t1},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no checker",
|
||||||
|
result: Result{Type: "gcp", CheckedAt: t1},
|
||||||
|
wantStatus: metav1.ConditionUnknown,
|
||||||
|
wantReason: reasonChecksNotImplemented,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nothing to check",
|
||||||
|
result: Result{Type: "kvm", Supported: true, CheckedAt: t1},
|
||||||
|
wantStatus: metav1.ConditionUnknown,
|
||||||
|
wantReason: reasonNoTargets,
|
||||||
|
wantTargetsReady: "0/0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
got := buildStatus(tc.prev, tc.result)
|
||||||
|
|
||||||
|
cond := apimeta.FindStatusCondition(got.Conditions, crawlv1alpha1.ConditionReady)
|
||||||
|
if cond == nil || cond.Status != tc.wantStatus || cond.Reason != tc.wantReason {
|
||||||
|
t.Fatalf("Ready = %+v, want %s/%s", cond, tc.wantStatus, tc.wantReason)
|
||||||
|
}
|
||||||
|
if tc.wantMessage != "" && cond.Message != tc.wantMessage {
|
||||||
|
t.Errorf("Ready message = %q, want %q", cond.Message, tc.wantMessage)
|
||||||
|
}
|
||||||
|
if got.TargetsReady != tc.wantTargetsReady {
|
||||||
|
t.Errorf("targetsReady = %q, want %q", got.TargetsReady, tc.wantTargetsReady)
|
||||||
|
}
|
||||||
|
if got.Type != tc.result.Type || got.LastCheckTime == nil || !got.LastCheckTime.Time.Equal(t1) {
|
||||||
|
t.Errorf("type/lastCheckTime = %q/%v, want %q/%s", got.Type, got.LastCheckTime, tc.result.Type, t1)
|
||||||
|
}
|
||||||
|
if len(got.Targets) != len(tc.wantTransitions) {
|
||||||
|
t.Fatalf("got %d targets, want %d", len(got.Targets), len(tc.wantTransitions))
|
||||||
|
}
|
||||||
|
for i, want := range tc.wantTransitions {
|
||||||
|
if lt := got.Targets[i].LastTransitionTime.Time; !lt.Equal(want) {
|
||||||
|
t.Errorf("target %s lastTransitionTime = %s, want %s", got.Targets[i].Name, lt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildStatus_conditionKeepsTransitionWhileUnchanged(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
first := buildStatus(crawlv1alpha1.ProviderHealthStatus{},
|
||||||
|
Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1")}, CheckedAt: t0})
|
||||||
|
firstCond := apimeta.FindStatusCondition(first.Conditions, crawlv1alpha1.ConditionReady)
|
||||||
|
|
||||||
|
second := buildStatus(first, Result{Type: "kvm", Supported: true, Targets: []provider.TargetStatus{up("nas1")}, CheckedAt: t1})
|
||||||
|
|
||||||
|
cond := apimeta.FindStatusCondition(second.Conditions, crawlv1alpha1.ConditionReady)
|
||||||
|
if !cond.LastTransitionTime.Time.Equal(t0) {
|
||||||
|
t.Errorf("Ready lastTransitionTime = %s, want %s from the first round", cond.LastTransitionTime, t0)
|
||||||
|
}
|
||||||
|
if !firstCond.LastTransitionTime.Time.Equal(t0) || len(first.Conditions) != 1 {
|
||||||
|
t.Errorf("buildStatus modified the previous status: %+v", first.Conditions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildStatus_truncatesLongMessages(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
long := strings.Repeat("é", 3000)
|
||||||
|
got := buildStatus(crawlv1alpha1.ProviderHealthStatus{}, Result{Type: "kvm", Supported: true, CheckedAt: t1,
|
||||||
|
Targets: []provider.TargetStatus{{Name: "nas1", Reason: provider.ReasonUnreachable, Message: long}}})
|
||||||
|
|
||||||
|
if n := utf8.RuneCountInString(got.Targets[0].Message); n != maxMessageLen {
|
||||||
|
t.Errorf("message has %d characters, want %d", n, maxMessageLen)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got.Targets[0].Message, "…") {
|
||||||
|
t.Error("truncated message does not end with an ellipsis")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user