Skip to content

Commit

Permalink
consolidate Kubeconf
Browse files Browse the repository at this point in the history
  • Loading branch information
mruoss committed Mar 28, 2024
1 parent 8e6c530 commit a47c913
Show file tree
Hide file tree
Showing 29 changed files with 1,071 additions and 63 deletions.
36 changes: 33 additions & 3 deletions .github/workflows/code_quality.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,20 @@ on:

env:
MIX_ENV: test
KUBECONFIG: test/support/kubeconfig-integration.yaml

jobs:
code-quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
- uses: engineerd/[email protected]
id: kind
with:
version: v0.20.0
image: kindest/node:${{ matrix.k8s_version }}
name: kubereq

- name: Setup elixir
id: beam
Expand Down Expand Up @@ -47,8 +54,31 @@ jobs:
- name: Compile
run: MIX_ENV=test mix compile --warnings-as-errors

- name: Unit Tests
run: MIX_ENV=test mix test

- name: Run Credo
run: MIX_ENV=test mix credo --strict

- name: Run Coverage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MIX_ENV: test
run: TEST_WAIT_TIMEOUT=10000 mix coveralls.github --include integration --timeout 20000

# Create PLTs if no cache was found
- name: Create PLTs
if: steps.plt-cache.outputs.cache-hit != 'true'
run: |
mkdir -p priv/plts
MIX_ENV=test mix dialyzer --plt
- name: Save PLT cache
id: plt_cache_save
uses: actions/cache/save@v4
if: steps.plt-cache.outputs.cache-hit != 'true'
with:
key: |
${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.ito-version }}-plt-${{ hashFiles('**/mix.lock') }}
path: |
priv/plts
- name: Run dialyzer
run: MIX_ENV=test mix dialyzer --format github
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,7 @@ kubereq-*.tar
/tmp/

# Ignore dialyzer files
/priv/plts/
/priv/plts/

# integration cluster config
/test/support/kubeconfig-integration.yaml
88 changes: 77 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,85 @@ resource kind you need. Check out [`kubegen`](https://github.com/mruoss/kubegen)

## Build your own clients

### Loading the Kubernetes Configuration
### Define how to load the Kubernetes Config

Before you can connect to the Kubernetes API Server you need to load the
cluster's Kubernetes configuration (Kubeconfig). This library expects you to use
[`kubeconf`](https://github.com/mruoss/kubeconf) to load the configuration.
In order to get started quickly, you can just use the default pipeline
(`Kubereq.Kubeconfig.Default`) which tries to load the Kubernetes configuration
one-by-one from well-known sources.

Once you have loaded the configuration and filled the `%Kubeconf{}` struct, you
can get a `Req` request using `Kubereq.new/2`.
If you need more sophisticated rules, you can build your own Kubeconfig loader
pipeline by creating a module `use`-ing [`Pluggable.StepBuilder`](https://hexdocs.pm/pluggable/Pluggable.StepBuilder.html)
and adding `Pluggable` steps defined by this module. The mechanism is exactly
the same as you know from the `Plug` library.

In fact, the default pipeline mentioned above is implemented defining a set of
steps.

```ex
defmodule Kubereq.Kubeconfig.Default do
use Pluggable.StepBuilder

step Kubereq.Kubeconfig.ENV
step Kubereq.Kubeconfig.File, path: ".kube/config", relative_to_home?: true
step Kubereq.Kubeconfig.ServiceAccount
end
```

### Load the Kubernetes Config

With the pipeline defined or implemented, you can now call
`Kubereq.Kubeconfig.load/1` to load the config:

```ex
Kubereq.Kubeconfig.load(Kubereq.Kubeconfig.Default)
```

If your pipelines requires options, you can pass a tuple to
`Kubereq.Kubeconfig.load/1`:

```ex
Kubereq.Kubeconfig.load({Kubereq.Kubeconfig.File, path: ".kube/config", relative_to_home?: true})
```

Instead of creating a new module, you can also pass a list of steps to
`Kubereq.Kubeconfig.load/1`:

```ex
Kubereq.Kubeconfig.load([
Kubereq.Kubeconfig.ENV,
{Kubereq.Kubeconfig.File, path: ".kube/config", relative_to_home?: true},
Kubereq.Kubeconfig.ServiceAccount
])
```

### Building the `Req.Request` struct

`Kubereq.new/2` creates a `%Req.Request{}` struct which allows you to make
requests to the Kubernetes API Server for **a specific resource kind**. It expects
the `kubeconf` as first argument and the `path` to the resource as second
argument. The path should contain placeholders for `:namespace` and `:name`
which are filled once you make a request to a specific resource.
Once you have loaded the, you can pass it to `Kubereq.new/2` to get a
`%Req.Request{}` struct which is prepared to make requests to the Kubernetes
API Server for **a specific resource kind**. It expects the `kubeconf` as first
argument and the `path` to the resource as second argument. The path should
contain placeholders for `:namespace` and `:name` which are filled once you make
a request to a specific resource.

The following example builds a `%Req.Request{}` which acts as client for running
operations on `ConfigMaps`:

```ex
kubeconfig = Kubereq.Kubeconfig.load(Kubereq.Kubeconfig.Default)
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/configmaps/:name")
```

### Running Operations

With the `req` built above, you can now use the other functions defined by
`Kubereq` to run operations - in this example on `ConfigMaps`.

```ex
kubeconfig = Kubereq.Kubeconfig.load(Kubereq.Kubeconfig.Default)
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/configmaps/:name")

{:ok, resp} = Kubereq.get(req, "my-namespace", "my-config-map")
```

`resp` is a `Req.Response.t()` and you can check for `req.status` and get
`req.body` etc.
18 changes: 9 additions & 9 deletions lib/kubereq.ex
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
defmodule Kubereq do
@moduledoc ~S"""
Kubereq defines a set of Request Steps for `Req`. All steps combined turn
a Kubernetes configuration in the form of a `%Kubeconf{}` struct into a
a Kubernetes configuration in the form of a `%Kubereq.Kubeconfig{}` struct into a
`%Req.Request{}` struct containing all headers and options required to
connect to the cluster and perform the given operations.
In order to build `%Kubeconf{}` struct you can either use the steps defined
In order to build `%Kubereq.Kubeconfig{}` struct you can either use the steps defined
in the `Kubeconf` library or create your own Kubernetes configuration loader
module combining those steps.
Expand All @@ -23,7 +23,7 @@ defmodule Kubereq do
@resource_path "api/v1/namespaces/:namespace/configmaps/:name"
defp req() do
kubeconfig = Kubeconf.load(Kubeconf.Default)
kubeconfig = Kubereq.Kubeconfig.load(Kubereq.Kubeconfig.Default)
Kubereq.new(kubeconfig, @resource_path)
end
Expand All @@ -48,16 +48,16 @@ defmodule Kubereq do
@doc """
Prepares a `Req.Request` struct for making HTTP requests to a Kubernetes
cluster. The `kubeconfig` is the Kubernetes configuration in the form of a
`%Kubeconf{}` struct and should contain all informations required to connect
`%Kubereq.Kubeconfig{}` struct and should contain all informations required to connect
to the Kubernetes cluster.
### Examples
iex> kubeconfig = Kubeconf.load(Kubeconf.Default)
iex> kubeconfig = Kubereq.Kubeconfig.load(Kubereq.Kubeconfig.Default)
...> Kubereq.new(kubeconfig)
%Request.Req{...}
"""
@spec new(kubeconfig :: Kubeconf.t()) ::
@spec new(kubeconfig :: Kubereq.Kubeconfig.t()) ::
Req.Request.t()
def new(kubeconfig) do
Req.new()
Expand All @@ -75,7 +75,7 @@ defmodule Kubereq do
@doc """
Prepares a `Req.Request` struct for a specific resource on a specific
Kubernetes cluster. The `kubeconfig` is the Kubernetes configuration in the
form of a `%Kubeconf{}` struct and should contain all informations required to
form of a `%Kubereq.Kubeconfig{}` struct and should contain all informations required to
connect to the Kubernetes cluster.
The parameter `resource_path` should be the path on which the Kubernetes API
Expand All @@ -89,11 +89,11 @@ defmodule Kubereq do
Prepare a `Req.Request` for ConfigMaps:
iex> kubeconfig = Kubeconf.load(Kubeconf.Default)
iex> kubeconfig = Kubereq.Kubeconfig.load(Kubereq.Kubeconfig.Default)
...> Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/configmaps/:name")
%Request.Req{...}
"""
@spec new(kubeconfig :: Kubeconf.t(), resource_path :: binary()) ::
@spec new(kubeconfig :: Kubereq.Kubeconfig.t(), resource_path :: binary()) ::
Req.Request.t()
def new(kubeconfig, resource_path) do
new(kubeconfig)
Expand Down
154 changes: 154 additions & 0 deletions lib/kubereq/access.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
defmodule Kubereq.Access do
@moduledoc """
Helper module to access maps in lists.
"""

@doc ~S"""
Returns a function that accesses the first element for which `fun` returns a truthy value.
The returned function is typically passed as an accessor to `Kernel.get_in/2`,
`Kernel.get_and_update_in/3`, and friends.
## Examples
iex> list = [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]
iex> get_in(list, [Kubereq.Access.find(&(&1.name == "john")), :salary])
10
iex> get_and_update_in(list, [Kubereq.Access.find(&(&1.name == "john")), :salary], fn prev ->
...> {prev, 15}
...> end)
{10, [%{name: "john", salary: 15}, %{name: "francine", salary: 30}]}
`find/1` can also be used to pop elements out of a list or
a key inside of a list:
iex> list = [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]
iex> pop_in(list, [Kubereq.Access.find(&(&1.name == "francine"))])
{%{name: "francine", salary: 30}, [%{name: "john", salary: 10}]}
iex> pop_in(list, [Kubereq.Access.find(&(&1.name == "francine")), :name])
{"francine", [%{salary: 30}, %{name: "john", salary: 10}]}
When no match is found, the given default is used. This can be used to
specify defaults and safely traverse missing items.
iex> list = [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]
iex> get_in(list, [Kubereq.Access.find(&(&1.name == "adam"), %{name: "adam", salary: 50}), :salary])
50
iex> get_and_update_in(list, [Kubereq.Access.find(&(&1.name == "adam"), %{name: "adam"}), :salary], fn prev ->
...> {prev, 50}
...> end)
{nil, [%{name: "adam", salary: 50}, %{name: "john", salary: 10}, %{name: "francine", salary: 30}]}
When multiple items exist for which `fun` return a truthy value, the first one is accessed.
iex> list = [%{name: "john", salary: 10}, %{name: "john", salary: 30}]
iex> get_in(list, [Kubereq.Access.find(&(&1.name == "john")), :salary])
10
An error is raised if the accessed structure is not a list:
iex> get_in(%{}, [Kubereq.Access.find(&(&1.name == "john"))])
** (RuntimeError) Kubereq.Access.find/1 expected a list, got: %{}
"""
@spec find((term -> boolean), term()) :: Access.access_fun(data :: list, current_value :: list)
def find(func, default \\ nil) when is_function(func) do
fn op, data, next -> find(op, data, func, default, next) end
end

defp find(:get, data, func, default, next) when is_list(data) do
data |> Enum.find(default, func) |> next.()
end

defp find(:get_and_update, data, func, default, next) when is_list(data) do
get_and_update_find(data, func, next, default, false)
end

defp find(_op, data, _default, _func, _next) do
raise "Kubereq.Access.find/1 expected a list, got: #{inspect(data)}"
end

@doc ~S"""
Returns a function that accesses the first element for which `fun` returns a truthy value.
The returned function is typically passed as an accessor to `Kernel.get_in/2`,
`Kernel.get_and_update_in/3`, and friends.
Similar to find/2, but the returned function raises if the no item is found for which `fun` returns a truthy value.
## Examples
iex> list = [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]
iex> get_in(list, [Kubereq.Access.find!(&(&1.name == "john")), :salary])
10
iex> get_and_update_in(list, [Kubereq.Access.find!(&(&1.name == "john")), :salary], fn prev ->
...> {prev, 15}
...> end)
{10, [%{name: "john", salary: 15}, %{name: "francine", salary: 30}]}
`find/1` can also be used to pop elements out of a list or
a key inside of a list:
iex> list = [%{name: "john", salary: 10}, %{name: "francine", salary: 30}]
iex> pop_in(list, [Kubereq.Access.find!(&(&1.name == "francine"))])
{%{name: "francine", salary: 30}, [%{name: "john", salary: 10}]}
iex> pop_in(list, [Kubereq.Access.find!(&(&1.name == "francine")), :name])
{"francine", [%{salary: 30}, %{name: "john", salary: 10}]}
iex> get_in(list, [Kubereq.Access.find!(&(&1.name == "adam")), :salary])
** (ArgumentError) There is no item in the list for which the given function returns a truthy value.
When multiple items exist for which `fun` return a truthy value, the first one is accessed.
iex> list = [%{name: "john", salary: 10}, %{name: "john", salary: 30}]
iex> get_in(list, [Kubereq.Access.find!(&(&1.name == "john")), :salary])
10
An error is raised if the accessed structure is not a list:
iex> get_in(%{}, [Kubereq.Access.find!(&(&1.name == "john"))])
** (RuntimeError) Kubereq.Access.find!/1 expected a list, got: %{}
"""
@spec find!((term -> boolean)) :: Access.access_fun(data :: list, current_value :: list)
def find!(func) when is_function(func) do
fn op, data, next -> find!(op, data, func, next) end
end

defp find!(:get, data, func, next) when is_list(data) do
case Enum.find(data, func) do
nil ->
raise ArgumentError,
"There is no item in the list for which the given function returns a truthy value."

item ->
next.(item)
end
end

defp find!(:get_and_update, data, func, next) when is_list(data) do
get_and_update_find(data, func, next)
end

defp find!(_op, data, _func, _next) do
raise "Kubereq.Access.find!/1 expected a list, got: #{inspect(data)}"
end

defp get_and_update_find(data, func, next, default \\ nil, raise? \\ true) do
{value, rest} =
case Enum.find_index(data, func) do
nil when raise? ->
raise ArgumentError,
"There is no item in the list for which the given function returns a truthy value."

nil ->
{default, data}

index ->
List.pop_at(data, index)
end

case next.(value) do
{get, update} -> {get, [update | rest]}
:pop -> {value, rest}
end
end
end
Loading

0 comments on commit a47c913

Please sign in to comment.