defmodule Bonny.API.Version do
@moduledoc ~S"""
Describes an API version of a custom resource.
The `%Bonny.API.Version{}` struct contains the fields required to build the
manifest for this version.
This module is meant to be `use`d by a module representing the
API version of a custom resource. The using module has to define
the function `manifest/0`.
The macro `defaults/1` is imported to the using module. It can be used to
simplify getting started. The first argument is the version's name (e.g. "v1").
If no name is passed, The macro will use the using module's name as the
version name.
**Note: The `:storage` flag has to be `true` for exactly one version of a
CRD.**
defmodule MyOperator.API.V1.CronTab do
use Bonny.API.Version
def manifest() do
struct!(defaults(), storage: true)
end
Use the `manifest/0` callback to override the defaults, e.g. add a schema.
Pipe your struct into `add_observed_generation_status/1` - which is imported
into the using module - if you use the
`Bonny.Pluggable.SkipObservedGenerations` step in your controller
defmodule MyOperator.API.V1.CronTab do
use Bonny.API.Version
def manifest() do
struct!(
defaults(),
storage: true,
schema: %{
openAPIV3Schema: %{
type: :object,
properties: %{
spec: %{
}
}
}
)
end
"""
@doc """
Return a `%Bonny.API.Version{}` struct representing the manifest for this
version of the CRD API.
"""
@callback manifest() :: Bonny.API.Version.t()
@typedoc """
Defines an [additional printer column](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#additional-printer-columns).
"""
@type printer_column_t :: %{
required(:name) => String.t(),
required(:type) => String.t() | atom(),
optional(:description) => String.t(),
required(:jsonPath) => String.t()
}
@typedoc """
Defines an [OpenAPI V3 Schema](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#specifying-a-structural-schema).
The typespec might be incomplete. Please open a PR with your additions and links to the relevant documentation, thanks.
"""
@type schema_t :: %{
required(:schema) => %{
required(:openAPIV3Schema) => %{
required(:type) =>
:array | :boolean | :date | :integer | :number | :object | :string,
required(:description) => binary(),
optional(:format) =>
:int32 | :int64 | :float | :double | :byte | :date | :"date-time" | :password,
optional(:properties) => %{
required(atom() | binary()) => schema_t()
},
optional(:additionalProperties) => schema_t() | boolean(),
optional(:items) => schema_t(),
optional(:"x-kubernetes-preserve-unknown-fields") => boolean(),
optional(:"x-kubernetes-int-or-string") => boolean(),
optional(:"x-kubernetes-embedded-resource") => boolean(),
optional(:"x-kubernetes-validations") =>
list(%{
required(:rule) => binary(),
optional(:message) => binary()
}),
optional(:pattern) => binary(),
optional(:anyOf) => schema_t(),
optional(:allOf) => schema_t(),
optional(:oneOf) => schema_t(),
optional(:not) => schema_t(),
optional(:nullable) => boolean(),
optional(:default) => any()
}
}
}
@typedoc """
Defines a version of a custom resource. Refer to the
[CRD versioning documentation](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/)
"""
@type subresources_t :: %{
optional(:status) => %{},
optional(:scale) => %{
required(:specReplicasPath) => binary(),
required(:statusReplicasPath) => binary(),
required(:labelSelectorPath) => binary()
}
}
@type t :: %__MODULE__{
name: binary(),
served: boolean(),
storage: boolean(),
deprecated: boolean(),
deprecationWarning: nil | binary(),
schema: schema_t(),
additionalPrinterColumns: list(printer_column_t()),
subresources: subresources_t()
}
defstruct [
:name,
served: true,
storage: true,
deprecated: false,
deprecationWarning: nil,
schema: %{openAPIV3Schema: %{type: :object, "x-kubernetes-preserve-unknown-fields": true}},
additionalPrinterColumns: [],
subresources: %{}
]
defmacro __using__(opts) do
quote do
@behaviour Bonny.API.Version
import Bonny.API.Version,
only: [defaults: 0, add_observed_generation_status: 1, add_conditions: 1]
@hub Keyword.get(unquote(opts), :hub, false)
end
end
@doc """
Returns a `Bonny.API.Version` struct with default values. Use this and pipe
it into `struct!()` to override the defaults in your `manifest/0` callback.
"""
defmacro defaults() do
name = __extract_version__(__CALLER__.module)
quote do
struct!(Bonny.API.Version,
name: unquote(name),
storage: @hub
)
end
end
def __extract_version__(module) do
module
|> Module.split()
|> Enum.reverse()
|> Enum.at(1)
|> String.downcase()
end
@doc """
Adds the status subresource if it hasn't been added before
and adds a field .status.observedGeneration of type integer
to the OpenAPIV3Schema.
### Example
iex> %Bonny.API.Version{}
...> |> Bonny.API.Version.add_observed_generation_status()
...> |> Map.take([:subresources, :schema])
%{
subresources: %{status: %{}},
schema: %{
openAPIV3Schema: %{
type: :object,
properties: %{
status: %{
type: :object,
properties: %{
observedGeneration: %{type: :integer}
}
}
},
"x-kubernetes-preserve-unknown-fields": true,
}
}
}
"""
@spec add_observed_generation_status(t()) :: t()
def add_observed_generation_status(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, %{}),
:observedGeneration
],
%{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