Repository: coryodaniel/bonny
Pull request #188 (2023-02-17 20:47:41)

Add helpers to define conditions on custom resources
* `Bonny.API.Version.add_conditions/1` - Helper to define a schema for conditions on the CRD
* `Bonny.Axn.set_condition/4` - Helper to set a condition on a resource


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e8d3e4f..d8a6ece 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,9 +11,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 <!-- ### Added | Changed | Deprecated | Removed | Fixed | Security -->
 
+### Changed
+
+- Upgraded k8s dependency to 2.0.x. [#173](https://github.com/coryodaniel/bonny/pull/173)
+
 ### Added
 
-- A pluggable step for adding missing GVK information to resources - [#184](https://github.com/coryodaniel/bonny/pull/184), [#183](https://github.com/coryodaniel/bonny/issues/183)
+- `Bonny.Pluggable.AddMissingGVK` - A pluggable step for adding missing GVK information to resources - [#184](https://github.com/coryodaniel/bonny/pull/184), [#183](https://github.com/coryodaniel/bonny/issues/183)
+- Helpers to define conditions on custom resources. [#188](https://github.com/coryodaniel/bonny/pull/188)
+  - `Bonny.API.Version.add_conditions/1` - Helper to define a schema for conditions on the CRD
+  - `Bonny.Axn.set_condition/4` - Helper to set a condition on a resource
 
 <!-- No new entries below this line! -->
 
diff --git a/Makefile b/Makefile
index 804f16f..bb1357e 100644
--- a/Makefile
+++ b/Makefile
@@ -8,20 +8,20 @@ help:
 
 all: test lint docs analyze
 
-integration.yaml: ## Create a k3d cluster
+integration.yaml: ## Create a kind cluster
 	$(MAKE) cluster.delete cluster.create
 	kind export kubeconfig --kubeconfig ${KUBECONFIG_PATH} --name "${CLUSTER_NAME}" 
 
 .PHONY: test.integration
 test.integration: integration.yaml
-test.integration: ## Run integration tests using k3d `make cluster`
+test.integration: ## Run integration tests using kind `make cluster`
 	MIX_ENV=test mix compile
 	MIX_ENV=test mix bonny.gen.manifest -o - | kubectl apply -f -
 	kubectl config use-context kind-${CLUSTER_NAME}
 	TEST_KUBECONFIG=${KUBECONFIG_PATH} mix test --only integration
 
 .PHONY: cluster.delete
-cluster.delete: ## Delete k3d cluster
+cluster.delete: ## Delete kind cluster
 	- kind delete cluster --kubeconfig ${KUBECONFIG_PATH} --name "${CLUSTER_NAME}"
 	rm -f ${KUBECONFIG_PATH}
 
@@ -38,7 +38,7 @@ lint:
 test:
 	MIX_ENV=test mix compile
 	MIX_ENV=test mix bonny.gen.manifest -o - | kubectl apply -f -
-	kubectl config use-context k3d-${CLUSTER_NAME}
+	kubectl config use-context kind-${CLUSTER_NAME}
 	TEST_KUBECONFIG=${KUBECONFIG_PATH} mix test --include integration --cover
 
 .PHONY: test.watch
diff --git a/lib/bonny/api/version.ex b/lib/bonny/api/version.ex
index 63264f0..1c1602a 100644
--- a/lib/bonny/api/version.ex
+++ b/lib/bonny/api/version.ex
@@ -140,7 +140,7 @@ defmodule Bonny.API.Version do
     quote do
       @behaviour Bonny.API.Version
       import Bonny.API.Version,
-        only: [defaults: 0, add_observed_generation_status: 1]
+        only: [defaults: 0, add_observed_generation_status: 1, add_conditions: 1]
 
       @hub Keyword.get(unquote(opts), :hub, false)
     end
@@ -213,4 +213,72 @@ defmodule Bonny.API.Version do
       %{type: :integer}
     )
   end
+
+  @doc """
+  Adds the status subresource if it hasn't been added before
+  and adds the schema for the `.status.conditions` array.
+
+  ### Example
+
+      iex> %Bonny.API.Version{}
+      ...> |> Bonny.API.Version.add_conditions()
+      ...> |> Map.take([:subresources, :schema])
+      %{
+        subresources: %{status: %{}},
+        schema: %{
+          openAPIV3Schema: %{
+            type: :object,
+            properties: %{
+              status: %{
+                type: :object,
+                properties: %{
+                  conditions: %{
+                    type: :array,
+                    items: %{
+                      type: :object,
+                      properties: %{
+                        type: %{type: :string},
+                        status: %{type: :string, enum: ["True", "False"]},
+                        message: %{type: :string},
+                        lastTransitionTime: %{type: :string, format: :"date-time"},
+                        lastHeartbeatTime: %{type: :string, format: :"date-time"}
+                      }
+                    }
+                  }
+                }
+              }
+            },
+            "x-kubernetes-preserve-unknown-fields": true,
+          }
+        }
+      }
+  """
+  @spec add_conditions(t()) :: t()
+  def add_conditions(version) do
+    version
+    |> put_in([Access.key(:subresources, %{}), :status], %{})
+    |> put_in(
+      [
+        Access.key(:schema, %{}),
+        Access.key(:openAPIV3Schema, %{type: :object}),
+        Access.key(:properties, %{}),
+        Access.key(:status, %{type: :object, properties: %{}}),
+        Access.key(:properties, %{}),
+        :conditions
+      ],
+      %{
+        type: :array,
+        items: %{
+          type: :object,
+          properties: %{
+            type: %{type: :string},
+            status: %{type: :string, enum: ["True", "False"]},
+            message: %{type: :string},
+            lastTransitionTime: %{type: :string, format: :"date-time"},
+            lastHeartbeatTime: %{type: :string, format: :"date-time"}
+          }
+        }
+      }
+    )
+  end
 end
diff --git a/lib/bonny/axn.ex b/lib/bonny/axn.ex
index b7b3597..cae4baf 100644
--- a/lib/bonny/axn.ex
+++ b/lib/bonny/axn.ex
@@ -509,4 +509,67 @@ defmodule Bonny.Axn do
     {ns_name, api_version, others} = Bonny.Resource.gvkn(resource)
     {ns_name, api_version, "#{others}, Action=#{inspect(action)}"}
   end
+
+  @doc """
+  Sets the condition in the resource status.
+
+  The field `.status.conditions`, if configured in the CRD, nolds a list of
+  conditions, their `status` with a `message` and two timestamps. On the
+  resource this could look something like this (taken from a Pod):
+
+  ```
+  kind: Pod
+  status:
+    conditions:
+      - lastTransitionTime: "2019-10-22T16:29:24Z"
+        status: "True"
+        type: PodScheduled
+      - lastTransitionTime: "2019-10-22T16:29:24Z"
+        status: "True"
+        type: Initialized
+      - lastTransitionTime: "2019-10-22T16:29:31Z"
+        status: "True"
+        type: ContainersReady
+      - lastTransitionTime: "2019-10-22T16:29:31Z"
+        status: "True"
+        type: Ready
+  ```
+  """
+  @spec set_condition(
+          axn :: t(),
+          type :: binary(),
+          status :: boolean(),
+          message :: binary() | nil
+        ) :: t()
+  def set_condition(axn, type, status, message \\ nil) do
+    condition_status = if(status, do: "True", else: "False")
+    now = DateTime.utc_now()
+
+    condition =
+      %{
+        "type" => type,
+        "status" => condition_status,
+        "message" => message,
+        "lastHeartbeatTime" => now,
+        "lastTransitionTime" => now
+      }
+      |> Map.filter(&(!is_nil(&1)))
+
+    update_status(axn, fn status ->
+      next_conditions =
+        status
+        |> Map.get("conditions", [])
+        |> Map.new(&{&1["type"], &1})
+        |> Map.update(type, condition, fn
+          %{"status" => ^condition_status} = old_condition ->
+            Map.put(condition, "lastTransitionTime", old_condition["lastTransitionTime"])
+
+          _old_condition ->
+            condition
+        end)
+        |> Map.values()
+
+      Map.put(status, "conditions", next_conditions)
+    end)
+  end
 end


Test patch:
diff --git a/test/bonny/axn_integration_test.exs b/test/bonny/axn_integration_test.exs
index 1075628..fce800f 100644
--- a/test/bonny/axn_integration_test.exs
+++ b/test/bonny/axn_integration_test.exs
@@ -132,4 +132,42 @@ defmodule Bonny.AxnIntegrationTest do
       assert log =~ ~s|type: Invalid value: "": has invalid value: InvalidType|
     end
   end
+
+  describe "set_condition/4" do
+    @tag :integration
+    test "runs without errors", %{conn: conn, resource: resource} do
+      {:ok, added_resource} = K8s.Client.run(conn, K8s.Client.apply(resource))
+
+      axn(:add, conn: conn, resource: added_resource)
+      |> MUT.set_condition("Ready", false, "not ready yet")
+      |> MUT.apply_status()
+
+      {:ok, resource} =
+        resource
+        |> K8s.Client.get()
+        |> K8s.Client.put_conn(conn)
+        |> K8s.Client.run()
+
+      assert "False" == hd(resource["status"]["conditions"])["status"]
+    end
+
+    @tag :integration
+    @tag :wip
+    test "message can be nil", %{conn: conn, resource: resource} do
+      {:ok, added_resource} = K8s.Client.run(conn, K8s.Client.apply(resource))
+
+      axn(:add, conn: conn, resource: added_resource)
+      |> MUT.set_condition("Ready", false)
+      |> MUT.apply_status()
+
+      {:ok, resource} =
+        resource
+        |> K8s.Client.get()
+        |> K8s.Client.put_conn(conn)
+        |> K8s.Client.run()
+
+      assert "False" == hd(resource["status"]["conditions"])["status"]
+      assert nil == hd(resource["status"]["conditions"])["message"]
+    end
+  end
 end
diff --git a/test/bonny/axn_test.exs b/test/bonny/axn_test.exs
index d2c02fd..d60501b 100644
--- a/test/bonny/axn_test.exs
+++ b/test/bonny/axn_test.exs
@@ -516,4 +516,37 @@ defmodule Bonny.AxnTest do
       assert_receive ^ref
     end
   end
+
+  describe "set_condition/4" do
+    test "Sets conditions on a fresh resource", %{axn: axn} do
+      axn =
+        axn
+        |> MUT.set_condition("Ready", false, "working on it...")
+        |> MUT.set_condition("Initialized", true, "Initialization done")
+
+      assert 2 == length(axn.status["conditions"])
+    end
+
+    test "Updates lastHeartbeatTime", %{axn: axn} do
+      axn =
+        axn
+        |> MUT.set_condition("Ready", false, "working on it...")
+        |> MUT.set_condition("Ready", false, "working on it...")
+
+      assert 1 == length(axn.status["conditions"])
+      ready_condition = hd(axn.status["conditions"])
+      assert ready_condition["lastHeartbeatTime"] > ready_condition["lastTransitionTime"]
+    end
+
+    test "Updates lastTransitionTime", %{axn: axn} do
+      axn =
+        axn
+        |> MUT.set_condition("Ready", false, "working on it...")
+        |> MUT.set_condition("Ready", true, "working on it...")
+
+      assert 1 == length(axn.status["conditions"])
+      ready_condition = hd(axn.status["conditions"])
+      assert ready_condition["lastHeartbeatTime"] == ready_condition["lastTransitionTime"]
+    end
+  end
 end
diff --git a/test/support/api/v1/test_resource_v2.ex b/test/support/api/v1/test_resource_v2.ex
index 114254b..183043c 100644
--- a/test/support/api/v1/test_resource_v2.ex
+++ b/test/support/api/v1/test_resource_v2.ex
@@ -30,5 +30,6 @@ defmodule Bonny.Test.API.V1.TestResourceV2 do
       }
     )
     |> add_observed_generation_status()
+    |> add_conditions()
   end
 end