Mix.install([:kino, :bonny])
Application.put_env(:bonny, :operator_name, "livebook")
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.init
does!) 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.
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.
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.
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)
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)
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.
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()
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).
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 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.
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()
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 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)
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()