Skip to content

Commit

Permalink
Service annotation reconciler (#277)
Browse files Browse the repository at this point in the history
* various changes

Signed-off-by: Jasmin Gacic <jasmin.gacic@gmail.com>

* add upstream to x-kusk if not present

Signed-off-by: Jasmin Gacic <jasmin.gacic@gmail.com>

* removed some leftovers

Signed-off-by: Jasmin Gacic <jasmin.gacic@gmail.com>

* removed some leftovers

Signed-off-by: Jasmin Gacic <jasmin.gacic@gmail.com>

* addressing PR remarks

Signed-off-by: Jasmin Gacic <jasmin.gacic@gmail.com>

* parse url before making http request to fetch openapi spec. Remove else clauses in favour of guard clauses

* doc for API autopilot

Signed-off-by: Jasmin Gacic <jasmin.gacic@gmail.com>

* Update docs/api_autopilot.md

Co-authored-by: Kyle Hodgetts <kyle@kubeshop.io>

* Update docs/api_autopilot.md

Co-authored-by: Kyle Hodgetts <kyle@kubeshop.io>

* Update docs/api_autopilot.md

Co-authored-by: Kyle Hodgetts <kyle@kubeshop.io>

* Update docs/api_autopilot.md

Co-authored-by: Kyle Hodgetts <kyle@kubeshop.io>

Co-authored-by: Kyle Hodgetts <kyle@kubeshop.io>
  • Loading branch information
jasmingacic and Kyle Hodgetts authored Apr 1, 2022
1 parent 6d404a4 commit 9b55a67
Show file tree
Hide file tree
Showing 4 changed files with 221 additions and 1 deletion.
10 changes: 10 additions & 0 deletions cmd/manager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,16 @@ func main() {
controllerConfigManager.WatchSecrets(ctx.Done())
}()

if err = (&controllers.ServiceReconciler{
Client: mgr.GetClient(),
Scheme: mgr.GetScheme(),
}).SetupWithManager(mgr); err != nil {
setupLog.
WithValues("controller", "ServiceAnnotationReconciler").
Error(err, "Unable to create controller")
os.Exit(1)
}

// EnvoyFleet obj controller
if err = (&controllers.EnvoyFleetReconciler{
Client: mgr.GetClient(),
Expand Down
56 changes: 56 additions & 0 deletions docs/api_autopilot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# API autopilot

Kusk gateway supports API auto deployment.

Any REST API will typically consist of a Kubernetes pod(s) that is running the server and a Kubernetes service pointed to it.

## It just works
To expose a pre-existing REST API in the cluster, you would just have to execute this:

```sh
kubectl apply -f svc.yaml
```
Or edit an existing Kubernetes service to add the annotation `kusk-gateway/openapi-url` with a URL to the location of your OpenAPI

## Under the hood

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.

Assuming that the user has already set up a pod that is running REST API server code and the pod name is `todo-backend`:

```yaml
apiVersion: v1
kind: Service
metadata:
name: todo-backend
annotations:
kusk-gateway/openapi-url: https://gist.githubusercontent.com/jasmingacic/082849b29d0e06e5f018a66f4cd49ec3/raw/e91c94cc82e7591031399e0d8c563d28a62de460/openapi.yaml
#NOTE: we need a sleeker URL for this
spec:
type: ClusterIP
selector:
app: todo-backend # aforementioned pod name
ports:
- port: 3000
targetPort: http
```
Once the service is deployed to a cluster Kusk gateway will pick it up and create an API resource.
Annotation `kusk-gateway/openapi-url` contains URL to OpenAPI spec for the user. Provided API may or may not contain `x-kusk` [extension](extension.md) so the reconciler will check if `x-kusk` extension is present and:
* if not present controller will add it and point upstream to the newly created service
```yaml
x-kusk:
upstream:
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.


Upcoming features:
- Refresh interval
- Versioning
- ...

2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ require (
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
k8s.io/apiextensions-apiserver v0.23.0 // indirect
k8s.io/component-base v0.23.0 // indirect
k8s.io/klog/v2 v2.30.0 // indirect
Expand Down
154 changes: 154 additions & 0 deletions internal/controllers/service_controller.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/*
Copyright 2022.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package controllers

import (
"context"
"fmt"
"io"
"net/http"
"net/url"

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

"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"
)

// ServiceReconciler reconciles a Pod object
type ServiceReconciler struct {
client.Client
Scheme *runtime.Scheme
}

//+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
func (r *ServiceReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
l := log.FromContext(ctx).WithName("service-reconciler")

l.Info("Reconciling changed Service resource", "changed", req.NamespacedName)

svc := &corev1.Service{}
if err := r.Client.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: req.Name}, svc); err != nil {
if errors.IsNotFound(err) {
return ctrl.Result{}, nil
}
return ctrl.Result{}, err
}

val, ok := svc.Annotations["kusk-gateway/openapi-url"]
if !ok {
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
}

var yml map[string]interface{}
err = yaml.Unmarshal(openapi, &yml)
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,
}

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)
if err != nil {
return ctrl.Result{}, err
}

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

api := &gateway.API{}
if err := r.Client.Get(ctx, client.ObjectKey{Namespace: req.Namespace, Name: req.Name}, api); err != nil && errors.IsNotFound(err) {
//create
api.Name = req.Name
api.Namespace = req.Namespace
api.Spec = gatewaySpec
if err := r.Client.Create(ctx, api, &client.CreateOptions{}); err != nil {
l.Error(err, "error occured while creating API")
return ctrl.Result{}, err
}

return ctrl.Result{}, nil
}

api.Spec = gatewaySpec
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
}

// SetupWithManager sets up the controller with the Manager.
func (r *ServiceReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&corev1.Service{}).
Complete(r)
}

func getOpenAPIfromURL(u string) ([]byte, error) {
if _, err := url.Parse(u); err != nil {
return nil, fmt.Errorf("invalid url %s: %w", u, err)
}

resp, err := http.Get(u)
if err != nil {
return nil, err
}
defer resp.Body.Close()

b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}

return b, err
}

0 comments on commit 9b55a67

Please sign in to comment.