Skip to content

Commit 3fc0d82

Browse files
committed
test: Add a test for the endpointslice example with endpoint pooling
1 parent 011a53d commit 3fc0d82

3 files changed

Lines changed: 323 additions & 70 deletions

File tree

examples/endpointslice-controller/README.md

Lines changed: 40 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,25 @@ The EndpointSlice operator is a "hybrid" Kubernetes controller that demonstrates
44

55
## Description
66

7-
Certain use cases cannot be fully implemented in a purely declarative style, for instance because the Kubernetes operator needs to manipulate an imperative API. Such is the case if the task is to implement an **endpoint-discovery service** to collect the endpoints for a Kubernetes service and program an underlying system, say, a service mesh proxy, with the discovered endpoints. Δ-controller can come in handy in such cases as well, by letting the difficult part of the operator, the endpoint-discovery pipeline, to be implemented in a declarative form, leaving only the reconciliation logic, which updates the imperative API based on the endpoints discovered by the declarative controller, to be written in imperative Go.
7+
Certain use cases cannot be fully implemented in a purely declarative style, for instance because a Kubernetes operator needs to manipulate an imperative API. Such is the case if the task is to implement an **endpoint-discovery service** in order to program an underlying system (say, a service mesh proxy) with the endpoints for a Kubernetes service (e.g., for load-balancing). Δ-controller can come in handy in such cases as well, by letting the difficult part of the operator, the endpoint-discovery pipeline, to be implemented in a declarative form, leaving only the reconciliation logic, which updates the imperative API based on the endpoints discovered by the declarative controller, to be written in imperative Go.
88

99
This example demonstrates the use of Δ-controller in such a use case. The example code comprises two parts: an imperative **endpoint-discovery operator** that is written in Go using the Δ-controller API, and a declarative **controller pipeline** that automates the difficult part: generating the up-to-date list of endpoints for a Kubernetes Service based on the Kubernetes resources obtained from the API server.
1010

1111
### The controller pipeline
1212

1313
The declarative controller pipeline spec is read from a YAML manifest. There are two versions:
14-
- `endpointslice-controller-spec.yaml`: this is the default spec, that will generate a separate view object per each (service, service-port, endpoint-address) combination. This is the one we discuss below.
15-
- `endpointslice-controller-gather-spec.yaml`: the alternative spec gathers all separate endpoint addresses into view object that will hold a list of all the endpoints addresses for a (service, service-port) combination. This is mostly the same as the default spec, but it contains a final "gather" aggregation stage that will collapse the endpoint addresses into a list. See the YAML spec for the details.
14+
- `endpointslice-controller-spec.yaml`: this is the default spec, which will generate a separate view object per each (service, service-port, endpoint-address) combination. This is the one we discuss below.
15+
- `endpointslice-controller-gather-spec.yaml`: the alternative spec gathers all endpoint addresses into a single view object per (service, service-port) combination. This pipeline is mostly the same as the default spec but it contains a final "gather" aggregation stage that will collapse the endpoint addresses into a list. See the YAML for the details.
1616

17-
The default declarative pipeline to controllers:
18-
- the `service-controller` will watch the Kubernetes core.v1 Service API, generate a separate out object per each service-port, and load the resultant objects into a view called ServiceView.
19-
- the `endpointslice-controller` watches the objects from the ServiceView and the EndpointSlice objects from the Kubernetes discovery.v1 API, pair service view objects with the corresponding EndpointSlices to expand it with the endpoint-addresses, filter addresses with `ready` status, demultiplex the endpoint addresses into a separate object and converts the shape of the resultant objects into a simpler form, and then load the results into a view called EndpointView.
17+
The default declarative pipeline defines to controllers:
18+
- the `service-controller` will watch the Kubernetes core.v1 Service API, generate a separate object per each service-port, and load the resultant objects into an internal view called ServiceView.
19+
- the `endpointslice-controller` watches the objects from the ServiceView and the EndpointSlice objects from the Kubernetes discovery.v1 API, pairs service view objects with the corresponding EndpointSlices to match it with the endpoint-addresses, filters addresses with `ready` status, demultiplexes the endpoint addresses into separate objects, converts the resultant objects into a simpler form, and then load the results into a view called EndpointView.
2020

21-
The idea is that the imperative controller will watch this EndpointView, which already contains the objects demultplexed and converted into the shape we want, instead of us having to write the imperative code to watch the Kubernetes API and perform the conversions ourselves. This helps cutting down development costs.
21+
The idea is that the imperative controller will watch this EndpointView to learn all the (service, service-port, endpoint-address) combinations in the form of a view object that is converted into a convenient shape that can be used just like any regular Kubernetes object. This is much simpler than writing the entire logic to watch the Kubernetes API and perform all the joins and conversions in go.
2222

2323
#### The Service controller
2424

25-
The task of this service is to generate a single object per each service-port in the watched Services. To simplify the task we will process only the Services annotated with `dcontroller.io/endpointslice-controller-enabled`.
25+
The first controller will generate an object per each service-port per the watched Services to fill the ServiceView. To simplify the task we will process only the Services annotated with `dcontroller.io/endpointslice-controller-enabled`.
2626

2727
The pipeline is as follows.
2828

@@ -36,11 +36,9 @@ The pipeline is as follows.
3636
kind: Service
3737
```
3838
39-
3. Create the **aggregation** pipeline.
40-
41-
This contains a single aggregation with 3 stages:
42-
- filter the Services annotated with `dcontroller.io/endpointslice-controller-enabled`. Note that we use the long-form JSONpath expression `$["metadata"][..."]` because the annotation contains a `/` that is incompatible with the simpler short-form,
43-
- remove some useless fields and converts the shape of the resultant objects,
39+
3. Create the **aggregation** pipeline. The aggregation consists of 3 stages:
40+
- filter the Services annotated with `dcontroller.io/endpointslice-controller-enabled`. Note that we use the long-form JSONpath expression format `$["metadata"][..."]` because the annotation contains a `/` that is incompatible with the simpler short-form,
41+
- remove some useless fields and convert the shape of the resultant objects,
4442
- demultiplex the result into multiple objects by blowing up the `$.spec.ports` list.
4543

4644
The pipeline is as follows:
@@ -98,14 +96,12 @@ The pipeline is as follows.
9896
- $.EndpointSlice.metadata.namespace
9997
```
10098

101-
4. Create the **aggregation** pipeline to convert the shape of the resultant, somewhat convoluted objects.
102-
103-
The aggregation pipeline again has multiple stages:
104-
- set up the metadata, copy the ServiceView spec and the endpoints from the EndpointSlice, and create an `id` that will be used later to generate a unique stable name for the resultant objects,
99+
4. Create the **aggregation** pipeline to convert the shape of the resultant, somewhat convoluted objects. The aggregation again has multiple stages:
100+
- set up the metadata, copy the ServiceView spec and the endpoints from the EndpointSlice,
105101
- demultiplex on the `$.endpoint` list,
106102
- filter ready addresses,
107103
- demultiplex again, now on the `$.endpoint.addresses` field of the original EndpointSlice object, and
108-
- finally again convert the object shape: set a unique name by concatenating Service name with the hash of the object id (this name will be different per each (service, service-port, endpoint-address)) and copy the relevant fields of the spec.
104+
- finally convert the object into a simple shape and set a unique stable object name by concatenating Service name with the hash of the object spec.
109105

110106
```yaml
111107
"@aggregate":
@@ -115,32 +111,28 @@ The pipeline is as follows.
115111
namespace: $.ServiceView.metadata.namespace
116112
spec: $.ServiceView.spec
117113
endpoints: $.EndpointSlice.endpoints
118-
id:
119-
name: $.ServiceView.spec.serviceName
120-
namespace: $.ServiceView.metadata.namespace
121-
type: $.ServiceView.spec.type
122-
protocol: $.ServiceView.spec.ports.protocol
123-
port: $.ServiceView.spec.ports.port
124-
targetPort: $.ServiceView.spec.ports.targetPort
125114
- "@unwind": $.endpoints
126115
- "@select":
127116
"@eq": ["$.endpoints.conditions.ready", true]
128117
- "@unwind": $.endpoints.addresses
129118
- "@project":
130119
metadata:
131-
name:
132-
"@concat":
133-
- $.metadata.name
134-
- "-"
135-
- { "@hash": $.id }
136120
namespace: $.metadata.namespace
137121
spec:
138122
serviceName: $.spec.serviceName
139123
type: $.spec.type
140-
port: $.id.port
141-
targetPort: $.id.targetPort
142-
protocol: $.id.protocol
124+
port: $.spec.ports.port
125+
targetPort: $.spec.ports.targetPort
126+
protocol: $.spec.ports.protocol
143127
address: $.endpoints.addresses
128+
- "@project":
129+
"@merge":
130+
metadata:
131+
name:
132+
"@concat":
133+
- $.spec.serviceName
134+
- "-"
135+
- { "@hash": $.spec }
144136
```
145137

146138
5. Set up the **target** to update the EndpointView view with the results.
@@ -154,11 +146,11 @@ The pipeline is as follows.
154146

155147
### The endoint-discovery operator
156148

157-
The operator will be written in Go. This will watch the EndpointView view as generated by the declarative pipeline for updates and implement the reconciliation logic. Again, the idea is that we don't have to write the tedious join+aggregation pipeline in imperative Go, instead we can implement this logic in a purely declarative style.
149+
The endoint-discovery operator will process the events on the EndpointView view. Since the reconciliation logic often needs to interact with an imperative API (say, to program a service-mesh proxy), this part will be written in Go. Recall, the idea is that we don't want to write the tedious join+aggregation pipeline in imperative Go; rather we implement just a minimal part in Go while the complex data manipulation logic will be handled in a purely declarative style (see above).
158150

159-
The Go code itself will differ too much from a standard [Kubernetes operator](https://book.kubebuilder.io/), just with the common packages taken from Δ-controller instead of the usual [Kubernetes controller runtime](https://pkg.go.dev/sigs.k8s.io/controller-runtime).
151+
The Go code itself will not differ too much from a standard [Kubernetes operator](https://book.kubebuilder.io/), just with the common packages taken from Δ-controller instead of the usual [Kubernetes controller runtime](https://pkg.go.dev/sigs.k8s.io/controller-runtime).
160152

161-
1. Define the usual Kubernetes operator boilerplate by importing packages, defining constants, parsing the command line arguments, and setting up a logger.
153+
1. Define the usual boilerplate: import packages, define constants, parse command line arguments, and set up a logger.
162154

163155
2. Create a Δ-controller manager:
164156

@@ -169,15 +161,15 @@ The Go code itself will differ too much from a standard [Kubernetes operator](ht
169161
if err != nil { ... }
170162
```
171163

172-
3. Load the declarative operator pipeline from a file held in `specFile` (see later):
164+
3. Load the declarative controllers we have implemented above:
173165

174166
```go
175-
if _, err := doperator.NewFromFile("endpointslice-operator", mgr, specFile, opts); err != nil {
167+
if _, err := doperator.NewFromFile("endpointslice-operator", mgr, "endpointslice-controller-spec.yaml", opts); err != nil {
176168
...
177169
}
178170
```
179171

180-
4. Define the controller that will reconcile the events generated by the operator:
172+
4. Define the controller that will reconcile the events generated by the operator (this will be written below):
181173

182174
```go
183175
if _, err := NewEndpointSliceController(mgr, logger); err != nil { ... }
@@ -213,7 +205,7 @@ The constructor will be called `NewEndpointSliceController`:
213205
if err != nil { ... }
214206
```
215207

216-
3. Create a source for the `EndpointView` (this will be generated by the declarative part):
208+
3. Create a source for the `EndpointView` (recall, this is the view that we load from the declarative part):
217209

218210
```go
219211
src, err := dreconciler.NewSource(mgr, opv1a1.Source{
@@ -224,18 +216,13 @@ The constructor will be called `NewEndpointSliceController`:
224216
if err != nil { ... }
225217
```
226218

227-
4. And finally set up a watch that will bind our controller to the above source so that every time
228-
there is an update on the `EndpointView` our `Reconcile(...)` function will be called with
229-
the event to process it.
219+
4. And finally set up a watch that will bind our controller to the above source so that every time there is an update on the `EndpointView` our `Reconcile(...)` function will be called with the update event.
230220

231221
```go
232222
if err := c.Watch(src); err != nil { ... }
233223
```
234224

235-
And finally the most important part, the `Reconcile(...)` function. Normally, this would be the
236-
function that implements the business logic of our operator, say, by controlling a proxy with the
237-
endpoints discovered by our operator. Here for simplicity we will just log the events and return a
238-
successful reconcile result.
225+
And finally the most important part, the `Reconcile(...)` function. Normally, this would be the function that implements the business logic of our operator, say, by programming a proxy with the endpoints discovered by our operator. Here for simplicity we will just log the events and return a successful reconcile result.
239226

240227
```go
241228
func (r *endpointSliceController) Reconcile(ctx context.Context, req dreconciler.Request) (reconcile.Result, error) {
@@ -261,6 +248,8 @@ func (r *endpointSliceController) Reconcile(ctx context.Context, req dreconciler
261248
}
262249
```
263250

251+
And that's all: the operative part is only some 200 lines of code, at least an order of magnitude less than if we had to implement the entire operator logic in an imperative style.
252+
264253
## Testing
265254

266255
Take off from an empty cluster and start the EndpointSlice controller:
@@ -270,7 +259,7 @@ cd <project-root>
270259
go run examples/endpointslice-controller/main.go -zap-log-level info -disable-endpoint-pooling
271260
```
272261

273-
Deploy a sample deployment with to endpoints:
262+
Deploy a sample deployment with two endpoints:
274263

275264
``` console
276265
kubectl create deployment testdep --image=registry.k8s.io/pause:3.9 --replicas=2
@@ -299,11 +288,11 @@ spec:
299288
EOF
300289
```
301290

302-
The controller will emit 4 `Add` events for each object generated by the EndpointSlice controller, one per each (service, service-port, endpoint-address) combination:
291+
The controller now will emit 4 `Add` events for each object generated by the EndpointSlice controller, one per each (service, service-port, endpoint-address) combination:
303292

304293
``` console
305294
INFO endpointslice-ctrl Add/update EndpointView object {"name": "testsvc-b9p5tj", "namespace": "default", "spec": "map[string]interface {}{\"address\":\"10.244.1.69\", \"port\":80, \"protocol\":\"TCP\", \"serviceName\":\"testsvc\", \"targetPort\":80, \"type\":\"ClusterIP\"}"}
306-
INFO endpointslice-ctrl Add/update EndpointView object {"name": "testsvc-dg9n0j", "namespace": "default", "spec": "map[string]interface {}{\"address\":\"10.244.1.229\", \"port\":80, \"protocol\":\"TCP\", \"serviceName\":\"testsvc\", \"targetPort\":80, \"type\":\"ClusterIP\"}"}
295+
INFO endpointslice-ctrl Add/update EndpointView object {"name": "testsvc-8x1zl2", "namespace": "default", "spec": "map[string]interface {}{\"address\":\"10.244.1.69\", \"port\":8843, \"protocol\":\"TCP\", \"serviceName\":\"testsvc\", \"targetPort\":8843, \"type\":\"ClusterIP\"}"}
307296
INFO endpointslice-ctrl Add/update EndpointView object {"name": "testsvc-6kq57l", "namespace": "default", "spec": "map[string]interface {}{\"address\":\"10.244.1.90\", \"port\":80, \"protocol\":\"TCP\", \"serviceName\":\"testsvc\", \"targetPort\":80, \"type\":\"ClusterIP\"}"}
308297
INFO endpointslice-ctrl Add/update EndpointView object {"name": "testsvc-43s657", "namespace": "default", "spec": "map[string]interface {}{\"address\":\"10.244.1.90\", \"port\":8843, \"protocol\":\"TCP\", \"serviceName\":\"testsvc\", \"targetPort\":8843, \"type\":\"ClusterIP\"}"}
309298
```
@@ -313,7 +302,7 @@ Scale the deployment to 3 pods: this will generate another two further `Add` eve
313302
``` console
314303
kubectl scale deployment testdep --replicas=3
315304
...
316-
INFO endpointslice-ctrl Add/update EndpointView object {"name": "testsvc-8x1zl2", "namespace": "default", "spec": "map[string]interface {}{\"address\":\"10.244.1.69\", \"port\":8843, \"protocol\":\"TCP\", \"serviceName\":\"testsvc\", \"targetPort\":8843, \"type\":\"ClusterIP\"}"}
305+
INFO endpointslice-ctrl Add/update EndpointView object {"name": "testsvc-dg9n0j", "namespace": "default", "spec": "map[string]interface {}{\"address\":\"10.244.1.229\", \"port\":80, \"protocol\":\"TCP\", \"serviceName\":\"testsvc\", \"targetPort\":80, \"type\":\"ClusterIP\"}"}
317306
INFO endpointslice-ctrl Add/update EndpointView object {"name": "testsvc-8zbi3v", "namespace": "default", "spec": "map[string]interface {}{\"address\":\"10.244.1.229\", \"port\":8843, \"protocol\":\"TCP\", \"serviceName\":\"testsvc\", \"targetPort\":8843, \"type\":\"ClusterIP\"}"}
318307
```
319308

examples/endpointslice-controller/endpointslice-controller-gather-spec.yaml

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,8 @@ controllers:
4747
"@aggregate":
4848
- "@project":
4949
metadata:
50-
name: $.EndpointSlice.metadata.name
51-
namespace: $.EndpointSlice.metadata.namespace
50+
name: $.ServiceView.metadata.name
51+
namespace: $.ServiceView.metadata.namespace
5252
spec: $.ServiceView.spec
5353
endpoints: $.EndpointSlice.endpoints
5454
id:
@@ -65,17 +65,21 @@ controllers:
6565
- $.id
6666
- $.endpoints.addresses
6767
- "@project":
68-
# use @merge so that expressions are applied in order
69-
"@merge":
70-
- {metadata: $.metadata}
71-
- {spec: $.spec}
72-
- {"$.spec.addresses": $.endpoints.addresses}
73-
- "$.metadata.name":
74-
"@concat":
75-
- $.id.name
76-
- "-"
77-
- $.id.protocol
78-
- "-"
79-
- $.id.port
68+
metadata:
69+
namespace: $.metadata.namespace
70+
name:
71+
"@concat":
72+
- $.id.name
73+
- "-"
74+
- $.id.protocol
75+
- "-"
76+
- $.id.port
77+
spec:
78+
serviceName: $.spec.serviceName
79+
type: $.spec.type
80+
port: $.spec.ports.port
81+
targetPort: $.spec.ports.targetPort
82+
protocol: $.spec.ports.protocol
83+
addresses: $.endpoints.addresses
8084
target:
8185
kind: EndpointView

0 commit comments

Comments
 (0)