View Source Controllers

Mix.install([:kino, :bonny])

Application.put_env(:bonny, :operator_name, "livebook")

Creating your first Controller

When running mix bonny.init, the task asks you to define CRDs and controllers already. It is optional but I advise to do so as it initializes your operator then. To create additional controllers, run mix bonny.gen.controller.

mix bonny.gen.controller does not register the controller with your operator. (Note however, that mix bonny.initdoes!) In order for your controller to do something, you need to add an entry to the controllers/2 callback in your operator. See the operators guide for more information about operators.

Action Event Handlers

A controller is a Pluggable step and uses Pluggable.StepBuilder underneath. When an action event is dispatched, the controller is called and all its steps are executed.Your task is to add steps to the controller which process the action event.

If you used mix bonny.gen.controller to create the controller, a handle_event/2 step and its implementation are added to your controller by the script. Besides that, mix bonny.gen.controller also adds steps for skipping observed generations. See the section about skipping observed generations for more information on those.

defmodule AppleController do
  use Bonny.ControllerV2

  step :handle_event

  # apply the resource
  def handle_event(%Bonny.Axn{action: action} = axn, _opts)
      when action in [:add, :modify, :reconcile] do
    IO.inspect(axn.resource)
    success_event(axn)
  end

  # delete the resource
  def handle_event(%Bonny.Axn{action: :delete} = axn, _opts) do
    IO.inspect(axn.resource)
    axn
  end
end

The step s you implement are called with a %Bonny.Axn{} token. It contains the action which triggered this event, the resource the event regards and other fields. Use pattern matching or a adispatch mechanism to handle the four different action types:

  • add/1 - resource was created in the cluster.
  • delete/1 - resource was deleted from the cluster.
  • modify/1 - resource was modified in the cluster.
  • reconcile/1 - Called on a regular basis in case we missed an action or to fix diverged state.

Your event handlers should return the struct it received as first parameter. However, your controller can use helper functions from the Bonny.Axn module to modify it before returning it.

Descendant Resources

Your controller might create descendant resources for its custom resource. For example, a MyAppController would create deployments and services for a MyApp resource.

Use Bonny.Axn.register_descendant/3 to add such descendants. Descendants are not directly applied to the cluster. They are only registered within the %Bonny.Axn{} token. Note that you need to add step Bonny.Pluggable.ApplyDescendants to either your controller or operator in order to apply descendants to the cluster.

Owner Reference

If your controller creates descendant resources for your custom resource, it is good practice to reference the owner(s). In kubernetes, you do this by adding an entry to .metadata.ownerReferences. Bonny.Axn.register_descendant/3 does that for you unless you pass ommit_owner_ref: true as option.

defmodule MyAppController do
  use Bonny.ControllerV2

  step :handle_event

  # apply the resource
  def handle_event(%Bonny.Axn{action: action} = axn, _opts)
      when action in [:add, :modify, :reconcile] do
    depl = %{
      "apiVersion" => "apps/v1",
      "kind" => "Deployment",
      "metadata" => %{"namespace" => "default", "name" => axn.resource["metadata"]["name"]}
      # spec
    }

    svc = %{
      "apiVersion" => "v1",
      "kind" => "Service",
      "metadata" => %{"namespace" => "default", "name" => axn.resource["metadata"]["name"]}
      # spec
    }

    axn
    |> Bonny.Axn.register_descendant(depl)
    |> Bonny.Axn.register_descendant(svc)
    |> success_event()
  end

  # delete the resource
  def handle_event(%Bonny.Axn{action: :delete} = axn, _opts) do
    # nothing to do because of owner reference
    success_event(axn)
  end
end

Let's see the result of the above event handler:

Bonny.Axn.new!(
  action: :add,
  conn: nil,
  resource: %{
    "apiVersion" => "example.com/v1",
    "kind" => "MyApp",
    "metadata" => %{
      "name" => "foo",
      "namespace" => "default",
      "uid" => "e19b6f40-3293-11ed-a261-0242ac120002"
    }
  }
)
|> MyAppController.call([])
|> Map.get(:descendants)

The Status Subresource

Controllers should use a resource's status subresource to communicate back data to the client. This can be results from underlaying APIs or stats. In your handler, use Bonny.Axn.update_status/2 to update the status. Make sure the status subresource is represented in your CRD's schema.

defmodule StatusController do
  use Bonny.ControllerV2

  step :handle_event

  def handle_event(axn, _) do
    axn
    |> Bonny.Axn.update_status(fn status ->
      put_in(status, [Access.key(:some, %{}), :field], "foo")
    end)
    |> Bonny.Axn.success_event()
  end
end
resource = %{
  "apiVersion" => "example.com/v1",
  "kind" => "MyApp",
  "metadata" => %{
    "name" => "foo",
    "namespace" => "default",
    "uid" => "e19b6f40-3293-11ed-a261-0242ac120002"
  }
}

Bonny.Axn.new!(action: :add, conn: nil, resource: resource)
|> StatusController.call([])
|> Map.get(:status)

Skipping Observed Generations

One of the kubernetes operator best practices is observing generations. This blog post explains it really well. It is extremly useful especially when you work with status subresources to not get another modify event for updating the status.

Bonny skips observed generations if you add the Bonny.Pluggable.SkipObservedGenrations step to your controller.

Preparations

In order to use the Bonny.Pluggable.SkipObservedGenrations step on a custom resource, you have to enable the status subresource and define the correct openAPIV3 schema in your CRD version file:

defmodule MyOperator.API.V1.CronTab do
  use Bonny.API.Version

  @impl Bonny.API.Version
  def manifest() do
    defaults()
    |> add_observed_generation_status()
  end
end

MyOperator.API.V1.CronTab.manifest()

How it works

Bonny.Pluggable.SkipObservedGenrations compares the current resource's fields .metadata.generation with the field defined by the :observed_generation_key option (.status.observedGeneration by default). It halts the pipeline if the two values match, i.e. if the resource generation had already been observed.

You can define for which actions this rule applies by adding the option :actions when placing the step. By default this rule applies to [:add, :modify] actions.

Finally, before the resource status is applied, the module copies the value in .metadata.generation to the field defined by the :observed_generation_key option (.status.observedGeneration by default).

Example

This example shows how only :reconcile and :delete events are handled by the controller:

defmodule MyThirdResourceController do
  use Bonny.ControllerV2

  step Bonny.Pluggable.SkipObservedGenerations,
    #  default
    actions: [:add, :modify],
    #  default
    observed_generation_key: ["status", "observedGeneration"]


  step :handle_event

  def handle_event(axn, _) do
    IO.puts("handling #{axn.action} event.")
    axn
  end
end

action =
  Kino.Input.select("Test Events",
    add: "add",
    modify: "modify",
    reconcile: "reconcile",
    delete: "delete"
  )
resource = %{
  "apiVersion" => "example.com/v1",
  "kind" => "MyApp",
  "metadata" => %{
    "name" => "foo",
    "namespace" => "default",
    "uid" => "e19b6f40-3293-11ed-a261-0242ac120002",
    "generation" => 1
  },
  "status" => %{"observedGeneration" => 1}
}

axn = Bonny.Axn.new!(action: Kino.Input.read(action), conn: nil, resource: resource)
MyThirdResourceController.call(axn, [])
:ok

Conditions

Conditions provide a way to communicate the resource's status in a machine readable state. One use case for conditions are custom health checks in ArgoCD. If you define conditions on your resources, users of your operator can - when using ArgoCD - define a custom health check which changes the status of resources to. degraded if specified conditions fail.

Preparations

In order to use conditions on custom resources, they have to be initialized in your CRD manifest:

defmodule MyOperator.API.V1.CronTab2 do
  use Bonny.API.Version

  @impl Bonny.API.Version
  def manifest() do
    defaults()
    |> add_conditions()
  end
end

MyOperator.API.V1.CronTab2.manifest()

Usage

I find the most elegant usage of conditions is in combination with a with statement. In the following code example, let's assume the controller needs to lookup a secret and a configmap in order to finally create a descendant resource:

defmodule MyFourthResourceController do
  use Bonny.ControllerV2

  step :handle_event

  def handle_event(axn, _) when axn.action in [:add, :modify, :reconcile] do
    with {:secret, {:ok, secret}} <- {:secret, get_secret(axn.resource)},
         axn <- set_condition(axn, "Secret", true, "the secret was loaded successfully."),
         {:configmap, axn, {:ok, cm}} <- {:configmap, axn, get_configmap(axn.resource)},
         axn <- set_condition(axn, "Configmap", true, "the configmap was loaded successfully."),
         {:descendant, axn, :ok} <-
           {:descendant, axn, create_descendant(axn.resource, secret, cm)} do
      axn
      |> success_event()
      |> set_condition("Descendant", true, "Descendant was created successfully")
    else
      {:secret, {:error, error}} ->
        axn
        |> failure_event(message: error)
        |> set_condition("Secret", false, error)

      {:configmap, axn, {:error, error}} ->
        axn
        |> failure_event(message: error)
        |> set_condition("Configmap", false, error)

      {:descendant, axn, {:error, error}} ->
        axn
        |> failure_event(message: error)
        |> set_condition("Descendant", false, error)
    end
  end

  # dummy functions:
  def get_secret(_), do: {:ok, :secret}
  def get_configmap(_), do: {:ok, :configmap}
  def create_descendant(_, :secret, :configmap), do: {:error, "Something went wrong"}
end

Bonny.Axn.new!(action: :add, conn: nil, resource: %{})
|> MyFourthResourceController.call([])
|> Map.get(:status)

Kubernetes Events

Kubernetes events provide a way to report back to the client. A Kubernetes event always references the object to which the event relates. For a controller the regarding object would be the handled resource. The user can then use kubectl describe on the custom resource to see the events.

Use Bonny.Axn.success_event/2, Bonny.Axn.failure_event/2 or Bonny.Axn.register_event/6 do register events in the %Bonny.Axn{} struct. Events are going to be applied to the cluster at the end of the Operator pipeline. There is no need to register a step for that.

defmodule MyResourceController do
  use Bonny.ControllerV2

  step :handle_event

  def handle_event(axn, _) when axn.action == :add do
    Bonny.Axn.success_event(axn)
  end

  def handle_event(axn, _) when axn.action == :modify do
    Bonny.Axn.failure_event(axn)
  end
end

action = Kino.Input.select("Test Events", add: "Success", modify: "Failure")
resource = %{
  "apiVersion" => "example.com/v1",
  "kind" => "MyApp",
  "metadata" => %{
    "name" => "foo",
    "namespace" => "default",
    "uid" => "e19b6f40-3293-11ed-a261-0242ac120002"
  }
}

action = Kino.Input.read(action)
axn = Bonny.Axn.new!(action: action, conn: nil, resource: resource)
MyResourceController.call(axn, []) |> Map.get(:events)

RBAC Rules

Your controller might need special permissions on the kubernetes cluster. Maybe it needs to be able to read secrets. Or it has to be able to create pods. These permissions need to be reflected in the final manifest generated by mix bonny.gen.manifest through RBAC rules.

You can define such rules one by defining the rbac_rules/0 callback. This callback should return a list of rbac rules of the following spec:

@type rbac_rule :: %{
  apiGroups: list(binary()),
  resources: list(binary()),
  binary()s: list(binary())}

You can use the helper function to_rbac_rule/1 to convert a tuple to an rbac rule. Its spec is:

  @spec to_rbac_rule({
    binary() | list(binary()),
    binary() | list(binary()),
    binary() | list(binary())
  }) :: rbac_rule
defmodule MySecondResourceController do
  use Bonny.ControllerV2

  @impl Bonny.ControllerV2
  def rbac_rules() do
    [
      to_rbac_rule(
        {"apps/v1", "Deployment", ["get", "list", "create", "update", "patch", "delete"]}
      )
    ]
  end
end

MySecondResourceController.rbac_rules()