Skip to content

Commit

Permalink
Refactor service reconciler to handle the annotations (#294)
Browse files Browse the repository at this point in the history
* Refactor service reconciler to handle the following annotations
- openapi-url which points to the open api spec

- path-prefix which specifies at which path prefix the api should be reachable in the envoyfleet. If not specified defaults to /

- path-prefix-substitution - which specifies how to substitute the path prefix with another value before passing on the request to the service. defaults to "" if path prefix is not / otherwise doesn't perform substitution

- envoy-fleet - which specifies which envoy fleet to use. Defaults to the default fleet

* document new annotations
  • Loading branch information
Kyle Hodgetts authored Apr 12, 2022
1 parent 93e50fe commit 2fc69ad
Show file tree
Hide file tree
Showing 2 changed files with 191 additions and 40 deletions.
35 changes: 28 additions & 7 deletions docs/api_autopilot.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,41 @@ Or edit an existing Kubernetes service to add the annotation `kusk-gateway/opena

Let's explain what is going on.

We added a convenience method that will allow users to easily expose their REST API through Kusk gateway by using the `kusk-gateway/openapi-url` annotation, and here is how.
There are several convenience annotation that will allow users to easily expose their REST API through Kusk gateway.

Assuming that the user has already set up a pod that is running REST API server code and the pod name is `todo-backend`:
Assuming that the user has already set up a deployment that is running their REST API server code and with the selector `my-api`:

```yaml
apiVersion: v1
kind: Service
metadata:
name: todo-backend
name: my-api
annotations:
kusk-gateway/openapi-url: https://gist.githubusercontent.com/jasmingacic/082849b29d0e06e5f018a66f4cd49ec3/raw/e91c94cc82e7591031399e0d8c563d28a62de460/openapi.yaml
#NOTE: we need a sleeker URL for this
# NOTE: we need a sleeker URL for this
kusk-gateway/openapi-url: https://gist.githubusercontent.com/jasmingacic/082849b29d0e06e5f018a66f4cd49ec3/raw/e91c94cc82e7591031399e0d8c563d28a62de460/openapi.yaml

# OPTIONAL annotations
# sets the request path prefix that your API will be reachable at via envoy
# will default to / if not specified
kusk-gateway/path-prefix: /my-api

# sets the value that will replace the prefix defined above if defined
# If you set value to "" then it will remove the prefix before sending
# the request onto your service which is normally the desired behaviour
# e.g. path-prefix = /my-api, path-prefix-substitution = ""
# and thers is a request to /my-api/foo then your service will receieve a
# request to /foo as the prefix /my-api is removed and replaced by ""
kusk-gateway/path-prefix-substitution: ""

# sets the envoyfleet to use. Defaults to default envoyfleet
# you may wish to set this to a custom envoyfleet if you have multiple
# envoyfleets in your cluster, one of which, for example, is private to
# the cluster, should you wish not to expose the API to the internet
kusk-gateway/envoy-fleet: my-private-fleet
spec:
type: ClusterIP
selector:
app: todo-backend # aforementioned pod name
app: my-api # aforementioned selector
ports:
- port: 3000
targetPort: http
Expand All @@ -46,7 +65,9 @@ x-kusk:
name: todo-backend
namespace: default
```
* or if the extension is present it will check if it contains `upstream` property configured. If not it will add it to the extension otherwise it will take OpenAPI definition as is and create API resource.
* or if the extension is present it will check if it contains `upstream` property configured. If not it will add it to the extension otherwise it will take OpenAPI definition as is and create API resource.

* this holds true for the other annotations too. If the corresponding x-kusk settings are present in the OpenAPI spec then they will be used and not overwritten.


Upcoming features:
Expand Down
196 changes: 163 additions & 33 deletions internal/controllers/service_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,26 @@ import (
"io"
"net/http"
"net/url"
"strings"

gateway "github.com/kubeshop/kusk-gateway/api/v1alpha1"

"github.com/go-logr/logr"
"gopkg.in/yaml.v3"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime"
ctrl "sigs.k8s.io/controller-runtime"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"

gateway "github.com/kubeshop/kusk-gateway/api/v1alpha1"
)

const (
xKuskAnnotation = "x-kusk"
annotationOpenapiUrl = "openapi-url"
annotationApiPathPrefix = "path-prefix"
annotationApiPathSubstitution = "path-prefix-substitution"
annotationEnvoyFleet = "envoy-fleet"
)

// ServiceReconciler reconciles a Pod object
Expand All @@ -40,6 +50,10 @@ type ServiceReconciler struct {
Scheme *runtime.Scheme
}

func annotation(a string) string {
return fmt.Sprintf("kusk-gateway/%s", a)
}

//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=core,resources=pods/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=core,resources=pods/finalizers,verbs=update
Expand All @@ -56,51 +70,40 @@ func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
return ctrl.Result{}, err
}

val, ok := svc.Annotations["kusk-gateway/openapi-url"]
l = l.WithValues(
"serviceName", svc.Name,
"serviceNamespace", svc.Namespace,
)

openAPIUrlAnnotation := annotation(annotationOpenapiUrl)
openApiUrl, ok := svc.Annotations[openAPIUrlAnnotation]
if !ok {
// if the service doesn't have the kusk-gateway/openapi-url annotation then we dont do anything
// as this is the minimum requirement for the service reconciler to have an effect
return ctrl.Result{}, nil
}

l.Info(`Detected "kusk-gateway/openapi-url" annotation`, "found", val)

openapi, err := getOpenAPIfromURL(svc.Annotations["kusk-gateway/openapi-url"])
if err != nil {
return ctrl.Result{}, err
}
l.Info(`Detected annotation`, "annotation", openAPIUrlAnnotation, "value", openApiUrl)

var yml map[string]interface{}
err = yaml.Unmarshal(openapi, &yml)
// fetch initial open api spec from url which we will build on
openApiSpec, err := processOpenAPIURLAnnotation(req, openApiUrl, svc.Spec.Ports[0].Port)
if err != nil {
return ctrl.Result{}, err
}

service := map[string]interface{}{"service": map[string]interface{}{
"name": req.Name,
"namespace": req.Namespace,
"port": svc.Spec.Ports[0].Port,
}}
upstream := map[string]interface{}{
"upstream": service,
}
processPathPrefixAnnotation(l, openApiSpec, svc.Annotations)
processSubstitutionAnnotation(l, openApiSpec, svc.Annotations)

if _, ok := yml["x-kusk"]; !ok {
yml["x-kusk"] = upstream
}

kusk := yml["x-kusk"]
if xkusk, ok := kusk.(map[string]interface{}); ok {
if _, contains := xkusk["upstream"]; !contains {
xkusk["upstream"] = service
}
}

yamlPayload, err := yaml.Marshal(yml)
yamlPayload, err := yaml.Marshal(openApiSpec)
if err != nil {
return ctrl.Result{}, err
}

envoyFleet := getEnvoyFleetFromAnnotations(l, svc.Annotations)

gatewaySpec := gateway.APISpec{
Spec: string(yamlPayload),
Fleet: envoyFleet,
Spec: string(yamlPayload),
}

api := &gateway.API{}
Expand All @@ -121,7 +124,6 @@ func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
if err := r.Client.Update(ctx, api, &client.UpdateOptions{}); err != nil {
l.Error(err, "error occured while updating API")
return ctrl.Result{}, err

}

return ctrl.Result{}, nil
Expand All @@ -134,6 +136,43 @@ func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
Complete(r)
}

func processOpenAPIURLAnnotation(req ctrl.Request, url string, svcPort int32) (map[string]interface{}, error) {
bOpenAPISpec, err := getOpenAPIfromURL(url)
if err != nil {
return nil, err
}

var openApiSpec map[string]interface{}
if err := yaml.Unmarshal(bOpenAPISpec, &openApiSpec); err != nil {
return nil, err
}

service := map[string]interface{}{
"service": map[string]interface{}{
"name": req.Name,
"namespace": req.Namespace,
"port": svcPort,
},
}
upstream := map[string]interface{}{
"upstream": service,
}

if _, ok := openApiSpec[xKuskAnnotation]; !ok {
openApiSpec[xKuskAnnotation] = upstream
}

kusk := openApiSpec[xKuskAnnotation]
if xkusk, ok := kusk.(map[string]interface{}); ok {
if _, contains := xkusk["upstream"]; !contains {
xkusk["upstream"] = service
openApiSpec[xKuskAnnotation] = xkusk
}
}

return openApiSpec, nil
}

func getOpenAPIfromURL(u string) ([]byte, error) {
if _, err := url.Parse(u); err != nil {
return nil, fmt.Errorf("invalid url %s: %w", u, err)
Expand All @@ -152,3 +191,94 @@ func getOpenAPIfromURL(u string) ([]byte, error) {

return b, err
}

func processPathPrefixAnnotation(l logr.Logger, openApiSpec map[string]interface{}, svcAnnotations map[string]string) {
pathPrefixAnnotation := annotation(annotationApiPathPrefix)
pathPrefix, ok := svcAnnotations[pathPrefixAnnotation]
if !ok {
// a path is required to properly configure an API. We are making the assumption that
// they want the api to be hosted at `/` if the user omits this annotation
pathPrefix = "/"
l.Info("no path prefix annotation set, defaulting to /")
} else {
l.Info(`Detected annotation`, "annotation", pathPrefixAnnotation, "value", pathPrefix)
}

xKusk, ok := openApiSpec[xKuskAnnotation].(map[string]interface{})
if !ok {
xKusk = map[string]interface{}{}
}

if _, ok := xKusk["path"]; !ok {
xKusk["path"] = map[string]string{
"prefix": pathPrefix,
}
openApiSpec[xKuskAnnotation] = xKusk
}
}

func processSubstitutionAnnotation(l logr.Logger, openApiSpec map[string]interface{}, svcAnnotations map[string]string) {
substitutionAnnotation := annotation(annotationApiPathSubstitution)
pathSubstitution, ok := svcAnnotations[substitutionAnnotation]
if !ok {
// we only substitute if an annotation is explicitly set
return
}

pathPrefixAnnotation := annotation(annotationApiPathPrefix)
pathPrefix, ok := svcAnnotations[pathPrefixAnnotation]
if !ok {
pathPrefix = "/"
}

xKusk, ok := openApiSpec[xKuskAnnotation].(map[string]interface{})
if !ok {
xKusk = map[string]interface{}{}
}

xKuskUpstream, ok := xKusk["upstream"].(map[string]interface{})
if !ok {
xKuskUpstream = map[string]interface{}{}
}

if _, ok := xKuskUpstream["rewrite"]; !ok && pathPrefix != "/" {
l.Info(fmt.Sprintf("path prefix is not /. setting path substitution to \"%s\"", pathSubstitution))
xKuskUpstream["rewrite"] = map[string]interface{}{
"pattern": fmt.Sprintf("^%s", pathPrefix),
"substitution": pathSubstitution,
}

xKusk["upstream"] = xKuskUpstream
openApiSpec[xKuskAnnotation] = xKusk
}
}

func getEnvoyFleetFromAnnotations(l logr.Logger, svcAnnotations map[string]string) *gateway.EnvoyFleetID {
defaultEnvoyFleet := &gateway.EnvoyFleetID{
Name: "default",
Namespace: "default",
}

envoyFleetAnnotation := annotation(annotationEnvoyFleet)
if envoyFleet, ok := svcAnnotations[envoyFleetAnnotation]; ok {
// valid envoy fleet annotation value should be of the form `envofleetname.namespace`
splitEnvoyFleetString := strings.Split(envoyFleet, ".")
if len(splitEnvoyFleetString) < 2 {
// if string is not in the valid form, return the default fleet
// we should revisit this because this could be seen as a "silent failure"
l.Info("invalid envoy fleet annotation value, using default envoy fleet", "invalidValue", envoyFleet)
return defaultEnvoyFleet
}

l.Info("using envoyfleet", "envoyfleet", envoyFleet)

return &gateway.EnvoyFleetID{
Name: splitEnvoyFleetString[0],
Namespace: splitEnvoyFleetString[1],
}
}

l.Info("no envoy fleet annotation found, using default envoy fleet")

return defaultEnvoyFleet
}

0 comments on commit 2fc69ad

Please sign in to comment.