Current section

Files

Jump to
kuberlnetes src kuberlnetes.erl
Raw

src/kuberlnetes.erl

%%%=============================================================================
%%% @doc kuberlnetes
%%% A low level kubernetes api helper - primarlily for applications that interact
%%% with the kubernetes api from inside the cluster.
%%% @end
%%%=============================================================================
-module(kuberlnetes).
-export([
in_cluster/0,
get/1,
get/2,
post/2,
patch/2,
delete/2,
microtime_now/0,
from_config/0,
from_config/1,
from_raw/1,
watch/1,
watch_close/1,
watches/0,
headers/1,
headers/2
]).
-author("bnjm").
-include("kuberlnetes.hrl").
-include_lib("kernel/include/logger.hrl").
-type options() :: #{
server => server() | undefined
}.
-type kube_cfg_options() :: #{
%% path to the kube config file
%% defaults to user.home/.kube/config
path => string(),
%% the context to be selected
%% defaults to "current-context"
context => string(),
%% cache the server config in ets (default: true)
cache => boolean()
}.
-type mutation_request() :: #{
path => string(),
body => map() | binary()
}.
-type http_status_err() :: 300..600.
-type resp_with_body() :: {ok, map()} | {error, http_status_err()}.
-type server() :: #server{}.
-export_type([options/0, kube_cfg_options/0, mutation_request/0, server/0]).
%% @doc
%% Configures a server using the service account
%% @end
-spec in_cluster() -> server().
in_cluster() ->
in_cluster(#{}).
-spec in_cluster(Opts) -> server() when
Opts :: kube_cfg_options().
in_cluster(Opts) ->
Host = os:getenv("KUBERNETES_SERVICE_HOST"),
Port = os:getenv("KUBERNETES_SERVICE_PORT"),
{ok, Token} = file:read_file("/var/run/secrets/kubernetes.io/serviceaccount/token"),
ServerConfig = #server{
url = "https://" ++ Host ++ ":" ++ Port,
host = Host,
port = erlang:list_to_integer(Port),
ca_cert = cert_from_file("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"),
auth = #auth_token{token = Token}
},
maybe_cache_srv_config(ServerConfig, Opts),
ServerConfig.
%% @doc
%% Loads the current-context from ~/.kube/config
%% @end
-spec from_config(Options) -> server() when
Options :: kube_cfg_options().
from_config(Opts) ->
KubeConfigPath = maps:get(path, Opts, default_kube_cfg_path()),
[KubeConfig | _] = yamerl_constr:file(KubeConfigPath),
SelectedContext = maps:get(context, Opts, get_current_context(KubeConfig)),
ClusterName = get_cluster_name_from_context(SelectedContext, KubeConfig),
User = get_relevant_user(SelectedContext, KubeConfig),
Auth = get_auth(User, KubeConfig),
Cert = get_certificate(User, KubeConfig),
Url = get_url(SelectedContext, KubeConfig),
#{host := Host, port := Port} = uri_string:parse(Url),
ServerConfig = #server{
url = Url,
host = Host,
port = Port,
ca_cert = Cert,
auth = Auth,
username = User,
clustername = ClusterName
},
maybe_cache_srv_config(ServerConfig, Opts),
ServerConfig.
from_config() -> from_config(#{}).
-spec from_raw(string()) -> server().
from_raw(Url) ->
#{host := Host, port := Port} = uri_string:parse(Url),
#server{url = Url, host = Host, port = Port}.
%% @doc
%% Initiates a GET request against the configured kubernetes api server
%% or raises an error if the server is not configured correctly.
%% @end
-spec get(Path, Opts) -> {ok, Body} when
Path :: string(),
Opts :: options(),
Body :: map().
get(Path, Opts) ->
Server = get_server(Opts),
Response = httpc:request(
get,
{Server#server.url ++ Path, headers(Server)},
ssl_options(Server),
[]
),
map_http_response(200, Response).
-spec get(Path) -> {ok, Body} when
Path :: string(),
Body :: map().
get(Path) ->
get(Path, #{}).
%% @doc
%% Initiates a POST request against the configured kubernetes api server or
%% raises an error if the server is not configured correctly.
%% see: server()
%% @end
-spec post(Request, Opts) -> {ok, Body} when
Request :: mutation_request(),
Opts :: options(),
Body :: map().
post(#{path := Path, body := Body}, Opts) when is_map(Body) ->
Server = get_server(Opts),
do_post(Path, jsone:encode(Body), Server, "application/json");
post(#{path := Path, body := Body}, Opts) when is_binary(Body) ->
Server = get_server(Opts),
ContentType = maps:get('content-type', Opts),
do_post(Path, Body, Server, ContentType).
do_post(Path, Body, Server, ContentType) when is_binary(Body) and is_list(ContentType) ->
Response = httpc:request(
post,
{
Server#server.url ++ Path,
headers(Server),
ContentType,
Body
},
ssl_options(Server),
[]
),
map_http_response([200, 201, 202], Response).
%% @doc
%% Initiates a PATCH request against the configured kubernetes api server or
%% raises an error if the server is not configured correctly.
%% see: server()
%% @end
-spec patch(Request, Opts) -> {ok, Body} when
Request :: mutation_request(),
Opts :: options(),
Body :: map().
patch(#{path := Path, body := Body}, Opts) when is_map(Body) ->
Server = get_server(Opts),
do_patch(Path, jsone:encode(Body), Server);
patch(#{path := Path, body := Body}, Opts) when is_binary(Body) ->
Server = get_server(Opts),
do_patch(Path, Body, Server).
do_patch(Path, Body, Server) when is_binary(Body) ->
Response = httpc:request(
patch,
{
Server#server.url ++ Path,
headers(Server),
"application/strategic-merge-patch+json",
Body
},
ssl_options(Server),
[]
),
map_http_response([200, 201], Response).
%% @doc
%% Deletes the given resource
%% @end
-spec delete(Path, Opts) -> {ok, Body} when
Path :: string(),
Opts :: options(),
Body :: map().
delete(Path, Opts) ->
Server = get_server(Opts),
Response = httpc:request(
delete,
{Server#server.url ++ Path, headers(Server)},
ssl_options(Server),
[]
),
map_http_response([200, 202], Response).
-spec watch(Opts) -> Result when
Opts :: kuberlnetes_watch:watch_opts(),
Result :: {ok, pid()}.
watch(Opts) ->
Server = get_server(Opts),
kuberlnetes_watch:watch(Server, Opts).
%% @doc
%% Closes an active watch process when not running in a supervision
%% tree.
%% @end
-spec watch_close(Pid) -> Result when
Pid :: pid(),
Result :: ok.
watch_close(Pid) ->
kuberlnetes_watch:stop(Pid).
%% @doc
%% Returns all currently active watch processes
%% @end
-spec watches() -> Result when
Result :: list(pid()).
watches() ->
ChildSpecs = supervisor:which_children(kuberlnetes_watch_sup),
lists:map(fun({_, Pid, _, _}) -> Pid end, ChildSpecs).
%% @doc
%% Returns the current datetime in the kubernetes MicroTime format
%% see: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.22/#microtime-v1-meta
%% @end
-spec microtime_now() -> binary().
microtime_now() ->
erlang:list_to_binary(
calendar:system_time_to_rfc3339(erlang:system_time(microsecond), [
{unit, microsecond}, {offset, "Z"}
])
).
%% private parts
headers(Server) -> headers(Server, []).
headers(Server, binary) ->
lists:map(
fun({K, V}) -> {erlang:list_to_binary(K), erlang:list_to_binary(V)} end, headers(Server)
);
headers(#server{auth = #auth_token{token = Token}}, AdditionalHeaders) ->
[
{"Accept", "application/json"},
{"Authorization", "Bearer " ++ Token}
] ++ AdditionalHeaders;
headers(_, AdditionalHeaders) ->
AdditionalHeaders.
ssl_options(#server{ca_cert = Cert}) when is_binary(Cert) ->
[{ssl, [{cacerts, [Cert]}]}];
ssl_options(_) ->
[].
cert_from_file(Path) ->
true = filelib:is_file(Path),
{ok, BinaryContent} = file:read_file(Path),
[{'Certificate', Data, _}] = public_key:pem_decode(BinaryContent),
Data.
cert_from_b64(CertData) when is_list(CertData) ->
DecodedCert = base64:decode(CertData),
[{'Certificate', Data, _}] = public_key:pem_decode(DecodedCert),
Data;
cert_from_b64(undefined) ->
undefined.
get_server(Opts) ->
OptsServer = maps:get(server, Opts, undefined),
case OptsServer of
undefined -> server_cfg_from_cache_or_error();
Else when is_record(Else, server) -> Else;
_ -> error(invalid_server)
end.
server_cfg_from_cache_or_error() ->
case ets:lookup(?SERVER_CFG_TAB, cached) of
[] ->
error(no_server_config);
[{cached, ServerConfig}] ->
ServerConfig
end.
decode(Body) when is_binary(Body) -> jsone:decode(Body);
decode(Body) when is_list(Body) -> jsone:decode(erlang:list_to_binary(Body)).
get_relevant_user(CurrentContext, KubeConfig) ->
RelevantContext = get_context_for_name(CurrentContext, KubeConfig),
RelevantUser = proplists:get_value("user", RelevantContext),
RelevantUser.
%% todo: remove duplication
get_context_for_name(Name, KubeConfig) when is_list(Name) and is_list(KubeConfig) ->
AllContexts = proplists:get_value("contexts", KubeConfig),
[H | _] = lists:filter(
fun(Context) -> proplists:get_value("name", Context) =:= Name end, AllContexts
),
proplists:get_value("context", H).
find_user(User, KubeConfig) ->
Users = proplists:get_value("users", KubeConfig),
[H | _] = lists:filter(fun(X) -> proplists:get_value("name", X) =:= User end, Users),
proplists:get_value("user", H).
get_cluster_for_name(Name, KubeConfig) when is_list(Name) and is_list(KubeConfig) ->
AllClusters = proplists:get_value("clusters", KubeConfig),
[H | _] = lists:filter(
fun(Cluster) -> proplists:get_value("name", Cluster) =:= Name end, AllClusters
),
proplists:get_value("cluster", H).
get_cluster_name_from_context(ContextName, KubeConfig) ->
RelevantContext = get_context_for_name(ContextName, KubeConfig),
proplists:get_value("cluster", RelevantContext).
get_certificate(User, KubeConfig) when is_list(User) ->
U = find_user(User, KubeConfig),
CertData = proplists:get_value("client-certificate-data", U),
cert_from_b64(CertData).
%% end todo
%%
get_auth(User, KubeConfig) when is_list(User) and is_list(KubeConfig) ->
U = find_user(User, KubeConfig),
do_get_auth(proplists:to_map(U)).
do_get_auth(#{"token" := T}) when is_list(T) -> #auth_token{token = list_to_binary(T)};
do_get_auth(#{"client-certificate-data" := T, "client-key-data" := KeyData}) when is_list(T) ->
#auth_cert{cert = list_to_binary(T), key = list_to_binary(KeyData)};
do_get_auth(#{"client-certificate" := ClientCertPath, "client-key" := ClientKeyPath}) ->
Cert = cert_from_file(ClientCertPath),
BinaryKey = file:read_file(ClientKeyPath),
Key = public_key:pem_decode(BinaryKey),
#auth_cert{cert = Cert, key = Key};
do_get_auth(#{"client-authority" := Path}) ->
Cert = cert_from_file(Path),
#auth_cert{cert = Cert};
do_get_auth(#{"exec" := ExecProps}) ->
Command = proplists:get_value("command", ExecProps),
Args = proplists:get_value("args", ExecProps),
FullCmd = string:join([Command | Args], " "),
ExecResult = os:cmd(FullCmd),
#{
<<"kind">> := <<"ExecCredential">>,
<<"status">> := #{<<"token">> := Token}
} = jsone:decode(erlang:list_to_binary(ExecResult)),
#auth_token{token = Token}.
get_url(CurrentContext, KubeConfig) ->
ClusterName = get_cluster_name_from_context(CurrentContext, KubeConfig),
RelevantCluster = get_cluster_for_name(ClusterName, KubeConfig),
proplists:get_value("server", RelevantCluster).
default_kube_cfg_path() ->
HomeDir = os:getenv("HOME"),
HomeDir ++ "/.kube/config".
get_current_context(KubeConfig) ->
proplists:get_value("current-context", KubeConfig).
-spec map_http_response(ExpectedStatus, HttpResponse) -> Result when
ExpectedStatus :: 200 | 201,
HttpResponse :: any(),
Result :: resp_with_body().
map_http_response(ExpectedStatus, {ok, {{_Proto, Status, _}, _Headers, Body}}) when
ExpectedStatus =:= Status
->
{ok, decode(Body)};
map_http_response(ExpectedStatuses, {ok, {{_Proto, Status, _}, _Headers, Body}} = R) when
is_list(ExpectedStatuses)
->
case lists:any(fun(S) -> S =:= Status end, ExpectedStatuses) of
true ->
{ok, decode(Body)};
false ->
[X | _] = ExpectedStatuses,
map_http_response(X, R)
end;
map_http_response(_, {ok, {{_Proto, 409, _}, _Headers, _Body}}) ->
{error, already_exists};
map_http_response(_, {ok, {{_Proto, 404, _}, _Headers, _Body}}) ->
{error, resource_not_found};
map_http_response(ExpectedStatus, {ok, {{_, Status, _}, _, _Body}} = Response) when
ExpectedStatus =/= Status
->
?LOG_ERROR(#{
event => "unexpected_response",
info => #{
"expected_status" => ExpectedStatus,
"actual_status" => Status,
"response" => Response
}
}),
{error, Status};
map_http_response(_, Response) ->
?LOG_ERROR(#{event => "unexpected_response", info => #{"response" => Response}}),
{error, unknown_response}.
maybe_cache_srv_config(ServerConfig, Opts) ->
DoCache = maps:get(cache, Opts, true),
case DoCache of
true ->
ets:insert(?SERVER_CFG_TAB, {cached, ServerConfig}),
?LOG_DEBUG(#{event => "kuberlnetes_cached_srv_cfg"}),
true;
false ->
false
end.