Skip to content

Commit 17e9743

Browse files
committed
feat(triggers): schema-aware match conditions WIP
Pushes CRD OpenAPI schemas from agent to Control Plane and adds the inventory + match-condition validation pieces on the agent side. - clusterdiscovery: extract openAPIV3Schema from CRDs and ship it on each ClusterResource (CRDs only; built-in K8s types are still nil). - internal/inventory/grpc + controller: gRPC client wrapping the AgentInventoryService.PutClusterResources RPC, plus a controller that pushes on startup, on a periodic interval (default 1h), and on CRD informer events (debounced 5s to coalesce helm-install bursts). - api/testtriggers/v1: TestTriggerSpec.Validate now checks each match[].path against MatchPathPattern (canonical regex) and MatchPathBracketPattern (bracket suffixes are rejected because the expression engine in pkg/expressions can't tokenize [*] or [N]; array-path support is a follow-up). - pkg/triggers tests: 32 CRD-shape matcher cases + 19 spec.Validate cases + a TestFieldOperatorParity fixture that locks the DTO and CRD operator enums in lockstep. - internal/inventory/controller tests: 10 cases covering watchable filtering, retry on push failure, ctx-cancel cleanup, debounced notifier coalescing, and the no-notifier path. Companion: testkube-cloud-api PR receives + caches + serves the snapshot, validates match[] against the schema, and surfaces the UI.
1 parent b91e1a0 commit 17e9743

10 files changed

Lines changed: 1096 additions & 7 deletions

File tree

api/testtriggers/v1/validation.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,23 @@ package v1
22

33
import (
44
"fmt"
5+
"regexp"
56

67
workflowtriggersv1 "github.com/kubeshop/testkube/api/workflowtriggers/v1"
78
)
89

10+
// MatchPathPattern is the canonical regex for match-condition paths. Exported
11+
// so cp-api and the UI can pin to the same shape. Accepts dot-separated
12+
// identifiers, with optional `[*]` or `[N]` suffixes per segment for forward
13+
// compatibility with array-aware backends.
14+
var MatchPathPattern = regexp.MustCompile(`^\.[A-Za-z0-9_-]+(\[\*\]|\[\d+\])?(\.[A-Za-z0-9_-]+(\[\*\]|\[\d+\])?)*$`)
15+
16+
// MatchPathBracketPattern detects array-segment suffixes (`[*]` or `[N]`).
17+
// The matcher's expression engine can't tokenize either today, so paths with
18+
// bracket suffixes silently no-op at fire time. We reject them at save time
19+
// to surface the gap loudly. Lift this once pkg/expressions gains support.
20+
var MatchPathBracketPattern = regexp.MustCompile(`\[\*\]|\[\d+\]`)
21+
922
// Validate checks the TestTriggerSpec for logical errors that can't be caught
1023
// by CRD schema validation alone. Called from REST create/update handlers.
1124
// Currently scoped to the match[] field, mirroring WorkflowTriggerSpec.Validate.
@@ -17,6 +30,12 @@ func (s *TestTriggerSpec) Validate() []error {
1730
errs = append(errs, fmt.Errorf("match[%d].path is required", i))
1831
continue
1932
}
33+
if !MatchPathPattern.MatchString(cond.Path) {
34+
errs = append(errs, fmt.Errorf("match[%d].path %q is not a valid dot-path (e.g. .status.phase)", i, cond.Path))
35+
}
36+
if MatchPathBracketPattern.MatchString(cond.Path) {
37+
errs = append(errs, fmt.Errorf("match[%d].path %q contains an array index/wildcard ([*] or [N]) — array-path matching is not supported yet; please match on a scalar field", i, cond.Path))
38+
}
2039

2140
switch cond.Operator {
2241
case workflowtriggersv1.FieldOperatorEquals,

cmd/api-server/main.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import (
1919
"google.golang.org/grpc/metadata"
2020
"google.golang.org/grpc/status"
2121
corev1 "k8s.io/api/core/v1"
22+
apiextclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset"
2223
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2324
"k8s.io/apimachinery/pkg/runtime"
2425
"k8s.io/client-go/dynamic"
@@ -42,6 +43,8 @@ import (
4243
intconfig "github.com/kubeshop/testkube/internal/config"
4344
"github.com/kubeshop/testkube/internal/cronjob/robfig"
4445
cronjobtestworkflow "github.com/kubeshop/testkube/internal/cronjob/testworkflow"
46+
inventorycontroller "github.com/kubeshop/testkube/internal/inventory/controller"
47+
inventorygrpc "github.com/kubeshop/testkube/internal/inventory/grpc"
4548
synccontroller "github.com/kubeshop/testkube/internal/sync/controller"
4649
syncgrpc "github.com/kubeshop/testkube/internal/sync/grpc"
4750
"github.com/kubeshop/testkube/pkg/agent"
@@ -51,11 +54,11 @@ import (
5154
cloudartifacts "github.com/kubeshop/testkube/pkg/cloud/data/artifact"
5255
cloudtestworkflow "github.com/kubeshop/testkube/pkg/cloud/data/testworkflow"
5356
cloudwebhook "github.com/kubeshop/testkube/pkg/cloud/data/webhook"
57+
"github.com/kubeshop/testkube/pkg/clusterdiscovery"
5458
"github.com/kubeshop/testkube/pkg/configmap"
5559
"github.com/kubeshop/testkube/pkg/controller"
5660
"github.com/kubeshop/testkube/pkg/controlplane"
5761
"github.com/kubeshop/testkube/pkg/controlplane/scheduling"
58-
"github.com/kubeshop/testkube/pkg/clusterdiscovery"
5962
"github.com/kubeshop/testkube/pkg/controlplaneclient"
6063
"github.com/kubeshop/testkube/pkg/coordination/leader"
6164
"github.com/kubeshop/testkube/pkg/cronjob"
@@ -155,6 +158,8 @@ func main() {
155158
commons.ExitOnError("creating k8s clientset", err)
156159
dynamicClient, err := dynamic.NewForConfig(kubeConfig)
157160
commons.ExitOnError("creating k8s dynamic client", err)
161+
apiextClient, err := apiextclient.NewForConfig(kubeConfig)
162+
commons.ExitOnError("creating k8s apiextensions client", err)
158163

159164
log.DefaultLogger.Infow("connected to Kubernetes cluster successfully", "namespace", cfg.TestkubeNamespace)
160165

@@ -321,6 +326,7 @@ func main() {
321326
// This setup can be moved back down to just before the controller initialisation
322327
// when the SuperAgent migration has been removed.
323328
syncStore := syncgrpc.NewClient(grpcConn, log.DefaultLogger, proContext.APIKey, proContext.OrgID, grpcTLSEnabled)
329+
inventoryClient := inventorygrpc.NewClient(grpcConn, log.DefaultLogger, proContext.APIKey, proContext.OrgID, grpcTLSEnabled)
324330
// SUPER AGENT DEPRECATION MIGRATION
325331
// Run the migration function blocking further processing. We want the migration to run and succeed or to fail and
326332
// kill the program before any additional processing occurs to avoid any conflicts with the migration process and
@@ -713,9 +719,25 @@ func main() {
713719
testWorkflowExecutor,
714720
cfg.ExportArchiveMaxSize,
715721
)
716-
api.ClusterDiscoverer = clusterdiscovery.New(clientset)
722+
api.ClusterDiscoverer = clusterdiscovery.New(clientset).WithSchemas(apiextClient)
717723
api.Init(httpServer)
718724

725+
// Push watchable cluster-resources snapshot to CP on startup + every hour.
726+
// Control Plane needs this to render the TestTrigger resourceRef picker —
727+
// new architecture: agent pushes, CP caches (see AgentInventoryService).
728+
if proContext.APIKey != "" {
729+
// Start a CRD informer so adds/updates/deletes trigger an immediate
730+
// push to CP. The hourly tick is the safety net for missed events.
731+
crdNotifier := inventorycontroller.StartCRDChangeNotifier(ctx, apiextClient, log.DefaultLogger)
732+
clusterResourcesController := &inventorycontroller.ClusterResourcesController{
733+
Discoverer: api.ClusterDiscoverer,
734+
Pusher: inventoryClient,
735+
Notifier: crdNotifier,
736+
Log: log.DefaultLogger,
737+
}
738+
g.Go(func() error { return clusterResourcesController.Run(ctx) })
739+
}
740+
719741
log.DefaultLogger.Info("starting agent service")
720742

721743
shouldRunAgent := shouldRunDefaultAgent(cfg, proContext)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Package controller runs the Agent-side loop that pushes cluster-environment
2+
// inventory to the Control Plane. See architecture/new-architecture.md — the
3+
// Agent is authoritative about what it sees in its cluster; the Control Plane
4+
// caches a snapshot.
5+
//
6+
// Triggers:
7+
// - on startup (push immediately after the loop begins)
8+
// - periodic ticker (default 1h; covers RBAC drift and missed CRD events)
9+
//
10+
// A future extension is a controller-runtime reconciler on CustomResourceDefinition
11+
// to push immediately when a CRD is installed/removed.
12+
package controller
13+
14+
import (
15+
"context"
16+
"time"
17+
18+
"go.uber.org/zap"
19+
20+
"github.com/kubeshop/testkube/pkg/api/v1/testkube"
21+
"github.com/kubeshop/testkube/pkg/clusterdiscovery"
22+
)
23+
24+
// ClusterResourcesPusher is the subset of the inventory gRPC client we need.
25+
type ClusterResourcesPusher interface {
26+
PutClusterResources(ctx context.Context, resources []testkube.ClusterResource) error
27+
}
28+
29+
// ClusterResourcesDiscoverer is the subset of pkg/clusterdiscovery.Discoverer we need.
30+
type ClusterResourcesDiscoverer interface {
31+
List(ctx context.Context) ([]testkube.ClusterResource, error)
32+
}
33+
34+
// ClusterResourcesController pushes watchable cluster GVKs to the Control Plane
35+
// on startup, on a periodic interval, and (optionally) when an external
36+
// Notifier signals a change. Safe to cancel via context.
37+
type ClusterResourcesController struct {
38+
Discoverer ClusterResourcesDiscoverer
39+
Pusher ClusterResourcesPusher
40+
Interval time.Duration
41+
// Notifier is fired by external sources (e.g. a CRD informer) when
42+
// inventory may have changed. Multiple signals in quick succession are
43+
// coalesced into one push via Debounce.
44+
Notifier <-chan struct{}
45+
// Debounce is the quiet period after the last Notifier event before the
46+
// push fires. Defaults to 5s — long enough to coalesce a burst (e.g. helm
47+
// install of an operator that registers ten CRDs at once) without delaying
48+
// a single-CRD update too noticeably in the UI.
49+
Debounce time.Duration
50+
Log *zap.SugaredLogger
51+
}
52+
53+
// Run blocks until ctx is canceled. Errors are logged but not returned — a
54+
// transient push failure should not kill the agent; the next tick will retry.
55+
func (c *ClusterResourcesController) Run(ctx context.Context) error {
56+
interval := c.Interval
57+
if interval <= 0 {
58+
interval = time.Hour
59+
}
60+
debounce := c.Debounce
61+
if debounce <= 0 {
62+
debounce = 5 * time.Second
63+
}
64+
65+
c.pushOnce(ctx)
66+
67+
ticker := time.NewTicker(interval)
68+
defer ticker.Stop()
69+
70+
// debounceC starts nil so the select arm is inactive until the first
71+
// notifier event arms the timer. Subsequent events extend the timer.
72+
var debounceTimer *time.Timer
73+
var debounceC <-chan time.Time
74+
75+
for {
76+
select {
77+
case <-ctx.Done():
78+
if debounceTimer != nil {
79+
debounceTimer.Stop()
80+
}
81+
return nil
82+
case <-ticker.C:
83+
c.pushOnce(ctx)
84+
case <-c.Notifier:
85+
if debounceTimer == nil {
86+
debounceTimer = time.NewTimer(debounce)
87+
debounceC = debounceTimer.C
88+
} else if !debounceTimer.Stop() {
89+
// timer was about to fire; drain channel before reset.
90+
select {
91+
case <-debounceC:
92+
default:
93+
}
94+
}
95+
debounceTimer.Reset(debounce)
96+
case <-debounceC:
97+
debounceTimer = nil
98+
debounceC = nil
99+
c.pushOnce(ctx)
100+
}
101+
}
102+
}
103+
104+
func (c *ClusterResourcesController) pushOnce(ctx context.Context) {
105+
resources, err := c.Discoverer.List(ctx)
106+
if err != nil {
107+
c.Log.Warnw("inventory: cluster discovery failed; skipping push", "error", err)
108+
return
109+
}
110+
watchable := resources[:0]
111+
for _, r := range resources {
112+
if r.CanWatch {
113+
watchable = append(watchable, r)
114+
}
115+
}
116+
if err := c.Pusher.PutClusterResources(ctx, watchable); err != nil {
117+
c.Log.Warnw("inventory: push cluster resources to CP failed", "error", err, "count", len(watchable))
118+
return
119+
}
120+
c.Log.Infow("inventory: pushed cluster resources snapshot to CP", "count", len(watchable))
121+
}
122+
123+
// compile-time check: pkg/clusterdiscovery.Discoverer satisfies our interface.
124+
var _ ClusterResourcesDiscoverer = (*clusterdiscovery.Discoverer)(nil)

0 commit comments

Comments
 (0)