mirror of
https://github.com/livebook-dev/livebook.git
synced 2025-11-12 07:01:40 +08:00
Encapsulate Kubernetes API calls (#2790)
This commit is contained in:
parent
fa7cf99990
commit
579eecfed1
9 changed files with 512 additions and 335 deletions
|
|
@ -1,65 +0,0 @@
|
||||||
defmodule Livebook.K8s.Auth do
|
|
||||||
# Implementation of Access Review checks for the authenticated user
|
|
||||||
# using the `SelfSubjectAccessReview` [1] resource.
|
|
||||||
#
|
|
||||||
# [1]: https://kubernetes.io/docs/reference/kubernetes-api/authorization-resources/self-subject-access-review-v1/#SelfSubjectAccessReviewSpec
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Concurrently reviews access according to a list of `resource_attributes`.
|
|
||||||
|
|
||||||
Expects `req` to be prepared for `SelfSubjectAccessReview`.
|
|
||||||
"""
|
|
||||||
@spec batch_check(Req.Request.t(), [keyword()]) ::
|
|
||||||
[:ok | {:error, %Req.Response{}} | {:error, Exception.t()}]
|
|
||||||
def batch_check(req, resource_attribute_list) do
|
|
||||||
resource_attribute_list
|
|
||||||
|> Enum.map(&Task.async(fn -> can_i?(req, &1) end))
|
|
||||||
|> Task.await_many(:infinity)
|
|
||||||
end
|
|
||||||
|
|
||||||
@doc """
|
|
||||||
Reviews access according to `resource_attributes`.
|
|
||||||
|
|
||||||
Expects `req` to be prepared for `SelfSubjectAccessReview`.
|
|
||||||
"""
|
|
||||||
@spec can_i?(Req.Request.t(), keyword()) ::
|
|
||||||
:ok | {:error, %Req.Response{}} | {:error, Exception.t()}
|
|
||||||
def can_i?(req, resource_attributes) do
|
|
||||||
resource_attributes =
|
|
||||||
resource_attributes
|
|
||||||
|> Keyword.validate!([
|
|
||||||
:name,
|
|
||||||
:namespace,
|
|
||||||
:path,
|
|
||||||
:resource,
|
|
||||||
:subresource,
|
|
||||||
:verb,
|
|
||||||
:version,
|
|
||||||
group: ""
|
|
||||||
])
|
|
||||||
|> Enum.into(%{})
|
|
||||||
|
|
||||||
access_review = %{
|
|
||||||
"apiVersion" => "authorization.k8s.io/v1",
|
|
||||||
"kind" => "SelfSubjectAccessReview",
|
|
||||||
"spec" => %{
|
|
||||||
"resourceAttributes" => resource_attributes
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
create_self_subject_access_review(req, access_review)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp create_self_subject_access_review(req, access_review) do
|
|
||||||
case Kubereq.create(req, access_review) do
|
|
||||||
{:ok, %Req.Response{status: 201, body: %{"status" => %{"allowed" => true}}}} ->
|
|
||||||
:ok
|
|
||||||
|
|
||||||
{:ok, %Req.Response{} = response} ->
|
|
||||||
{:error, response}
|
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:error, error}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -177,6 +177,38 @@ defmodule Livebook.K8s.Pod do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp access_main_container() do
|
defp access_main_container() do
|
||||||
Kubereq.Access.find(&(&1["name"] == @main_container_name))
|
access_find(&(&1["name"] == @main_container_name))
|
||||||
|
end
|
||||||
|
|
||||||
|
# TODO: use Access.find/1 once we require Elixir v1.17
|
||||||
|
defp access_find(predicate) when is_function(predicate, 1) do
|
||||||
|
fn op, data, next -> find(op, data, predicate, next) end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find(:get, data, predicate, next) when is_list(data) do
|
||||||
|
data |> Enum.find(predicate) |> next.()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find(:get_and_update, data, predicate, next) when is_list(data) do
|
||||||
|
get_and_update_find(data, [], predicate, next)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find(_op, data, _predicate, _next) do
|
||||||
|
raise "Access.find/1 expected a list, got: #{inspect(data)}"
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_and_update_find([], updates, _predicate, _next) do
|
||||||
|
{nil, :lists.reverse(updates)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_and_update_find([head | rest], updates, predicate, next) do
|
||||||
|
if predicate.(head) do
|
||||||
|
case next.(head) do
|
||||||
|
{get, update} -> {get, :lists.reverse([update | updates], rest)}
|
||||||
|
:pop -> {head, :lists.reverse(updates, rest)}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
get_and_update_find(rest, [head | updates], predicate, next)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
292
lib/livebook/k8s_api.ex
Normal file
292
lib/livebook/k8s_api.ex
Normal file
|
|
@ -0,0 +1,292 @@
|
||||||
|
defmodule Livebook.K8sAPI do
|
||||||
|
# Calls to the Kubernetes API.
|
||||||
|
|
||||||
|
@type kubeconfig :: Kubereq.Kubeconfig.t()
|
||||||
|
@type error :: %{message: String.t(), status: pos_integer() | nil}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a new Pod.
|
||||||
|
"""
|
||||||
|
@spec create_pod(kubeconfig(), map()) :: {:ok, data} | error()
|
||||||
|
when data: %{name: String.t()}
|
||||||
|
def create_pod(kubeconfig, manifest) do
|
||||||
|
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/pods/:name")
|
||||||
|
|
||||||
|
case Kubereq.create(req, manifest) do
|
||||||
|
{:ok, %{status: 201, body: %{"metadata" => %{"name" => name}}}} ->
|
||||||
|
{:ok, %{name: name}}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
result_to_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Fetches Pod with the given name.
|
||||||
|
"""
|
||||||
|
@spec get_pod(kubeconfig(), String.t(), String.t()) :: {:ok, data} | error()
|
||||||
|
when data: %{ip: String.t()}
|
||||||
|
def get_pod(kubeconfig, namespace, name) do
|
||||||
|
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/pods/:name")
|
||||||
|
|
||||||
|
case Kubereq.get(req, namespace, name) do
|
||||||
|
{:ok, %{status: 200, body: pod}} ->
|
||||||
|
{:ok, %{ip: pod["status"]["podIP"]}}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
result_to_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes Pod with the given name.
|
||||||
|
"""
|
||||||
|
@spec delete_pod(kubeconfig(), String.t(), String.t()) :: :ok | error()
|
||||||
|
def delete_pod(kubeconfig, namespace, name) do
|
||||||
|
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/pods/:name")
|
||||||
|
|
||||||
|
case Kubereq.delete(req, namespace, name) do
|
||||||
|
{:ok, %{status: 200}} -> :ok
|
||||||
|
other -> result_to_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Awaits Pod to reach the ready status.
|
||||||
|
"""
|
||||||
|
@spec await_pod_ready(kubeconfig(), String.t(), String.t()) :: :ok | error()
|
||||||
|
def await_pod_ready(kubeconfig, namespace, name) do
|
||||||
|
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/pods/:name")
|
||||||
|
|
||||||
|
callback = fn
|
||||||
|
:deleted ->
|
||||||
|
{:error, %{message: "the Pod has been deleted", status: nil}}
|
||||||
|
|
||||||
|
pod ->
|
||||||
|
get_in(pod, [
|
||||||
|
"status",
|
||||||
|
"conditions",
|
||||||
|
Access.filter(&(&1["type"] == "Ready")),
|
||||||
|
"status"
|
||||||
|
]) == ["True"]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wait up to 30 minutes
|
||||||
|
case Kubereq.wait_until(req, namespace, name, callback, 1_800_000) do
|
||||||
|
{:error, :watch_timeout} ->
|
||||||
|
{:error, %{message: "timed out waiting for Pod to become ready", status: nil}}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Watches and streams Pod events to the caller.
|
||||||
|
|
||||||
|
The emitted events have the following shape:
|
||||||
|
|
||||||
|
%{message: String.t()}
|
||||||
|
|
||||||
|
"""
|
||||||
|
@spec watch_pod_events(kubeconfig(), String.t(), String.t()) :: {:ok, Enumerable.t()} | error()
|
||||||
|
def watch_pod_events(kubeconfig, namespace, name) do
|
||||||
|
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/events/:name")
|
||||||
|
|
||||||
|
Kubereq.watch(req, namespace,
|
||||||
|
field_selectors: [
|
||||||
|
{"involvedObject.kind", "Pod"},
|
||||||
|
{"involvedObject.name", name}
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|> case do
|
||||||
|
{:ok, stream} ->
|
||||||
|
stream =
|
||||||
|
Stream.map(stream, fn event ->
|
||||||
|
%{message: event["object"]["message"]}
|
||||||
|
end)
|
||||||
|
|
||||||
|
{:ok, stream}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
result_to_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists all namespaces in the cluster.
|
||||||
|
"""
|
||||||
|
@spec list_namespaces(kubeconfig()) :: {:ok, data} | error()
|
||||||
|
when data: list(%{name: String.t()})
|
||||||
|
def list_namespaces(kubeconfig) do
|
||||||
|
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:name")
|
||||||
|
|
||||||
|
case Kubereq.list(req, nil) do
|
||||||
|
{:ok, %{status: 200, body: %{"items" => items}}} ->
|
||||||
|
namespaces =
|
||||||
|
for item <- items do
|
||||||
|
%{name: item["metadata"]["name"]}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, namespaces}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
result_to_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Creates a Persistent Volume Claim.
|
||||||
|
"""
|
||||||
|
@spec create_pvc(kubeconfig(), map()) :: {:ok, data} | error()
|
||||||
|
when data: %{name: String.t()}
|
||||||
|
def create_pvc(kubeconfig, manifest) do
|
||||||
|
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/persistentvolumeclaims/:name")
|
||||||
|
|
||||||
|
case Kubereq.create(req, manifest) do
|
||||||
|
{:ok, %{status: 201, body: %{"metadata" => %{"name" => name}}}} ->
|
||||||
|
{:ok, %{name: name}}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
result_to_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists all Persistent Volume Claims.
|
||||||
|
"""
|
||||||
|
@spec list_pvcs(kubeconfig(), String.t()) :: {:ok, data} | error()
|
||||||
|
when data: list(%{name: String.t(), deleted_at: String.t() | nil})
|
||||||
|
def list_pvcs(kubeconfig, namespace) do
|
||||||
|
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/persistentvolumeclaims/:name")
|
||||||
|
|
||||||
|
case Kubereq.list(req, namespace) do
|
||||||
|
{:ok, %{status: 200, body: %{"items" => items}}} ->
|
||||||
|
storage_classes =
|
||||||
|
for item <- items do
|
||||||
|
%{
|
||||||
|
name: item["metadata"]["name"],
|
||||||
|
deleted_at: item["metadata"]["deletionTimestamp"]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, storage_classes}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
result_to_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Deletes Persistent Volume Claim with the given name.
|
||||||
|
"""
|
||||||
|
@spec delete_pvc(kubeconfig(), String.t(), String.t()) :: :ok | error()
|
||||||
|
def delete_pvc(kubeconfig, namespace, name) do
|
||||||
|
req = Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/persistentvolumeclaims/:name")
|
||||||
|
|
||||||
|
case Kubereq.delete(req, namespace, name) do
|
||||||
|
{:ok, %{status: 200}} -> :ok
|
||||||
|
other -> result_to_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Lists storage classes available in the cluster.
|
||||||
|
"""
|
||||||
|
@spec list_storage_classes(kubeconfig()) :: {:ok, data} | error()
|
||||||
|
when data: list(%{name: String.t()})
|
||||||
|
def list_storage_classes(kubeconfig) do
|
||||||
|
req = Kubereq.new(kubeconfig, "apis/storage.k8s.io/v1/storageclasses/:name")
|
||||||
|
|
||||||
|
case Kubereq.list(req, nil) do
|
||||||
|
{:ok, %{status: 200, body: %{"items" => items}}} ->
|
||||||
|
storage_classes =
|
||||||
|
for item <- items do
|
||||||
|
%{name: item["metadata"]["name"]}
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok, storage_classes}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
result_to_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Reviews access according to `resource_attributes`.
|
||||||
|
|
||||||
|
Implements Access Review checks for the authenticated user using
|
||||||
|
the `SelfSubjectAccessReview` [1] resource.
|
||||||
|
|
||||||
|
[1]: https://kubernetes.io/docs/reference/kubernetes-api/authorization-resources/self-subject-access-review-v1/#SelfSubjectAccessReviewSpec
|
||||||
|
"""
|
||||||
|
@spec create_access_review(kubeconfig(), keyword()) :: {:ok, data} | error()
|
||||||
|
when data: %{
|
||||||
|
allowed: boolean(),
|
||||||
|
resource: String.t(),
|
||||||
|
verb: String.t(),
|
||||||
|
namespace: String.t() | nil,
|
||||||
|
group: String.t(),
|
||||||
|
version: String.t()
|
||||||
|
}
|
||||||
|
def create_access_review(kubeconfig, resource_attributes) do
|
||||||
|
resource_attributes =
|
||||||
|
resource_attributes
|
||||||
|
|> Keyword.validate!([
|
||||||
|
:name,
|
||||||
|
:namespace,
|
||||||
|
:path,
|
||||||
|
:resource,
|
||||||
|
:subresource,
|
||||||
|
:verb,
|
||||||
|
:version,
|
||||||
|
group: ""
|
||||||
|
])
|
||||||
|
|> Enum.into(%{})
|
||||||
|
|
||||||
|
access_review = %{
|
||||||
|
"apiVersion" => "authorization.k8s.io/v1",
|
||||||
|
"kind" => "SelfSubjectAccessReview",
|
||||||
|
"spec" => %{
|
||||||
|
"resourceAttributes" => resource_attributes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req = Kubereq.new(kubeconfig, "apis/authorization.k8s.io/v1/selfsubjectaccessreviews")
|
||||||
|
|
||||||
|
case Kubereq.create(req, access_review) do
|
||||||
|
{:ok, %Req.Response{status: 201, body: body}} ->
|
||||||
|
resource_attributes = body["spec"]["resourceAttributes"]
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
%{
|
||||||
|
allowed: body["status"]["allowed"],
|
||||||
|
resource: resource_attributes["resource"],
|
||||||
|
verb: resource_attributes["verb"],
|
||||||
|
namespace: resource_attributes["namespace"],
|
||||||
|
group: resource_attributes["group"],
|
||||||
|
version: resource_attributes["version"]
|
||||||
|
}}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
result_to_error(other)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp result_to_error({:ok, %{status: status, body: body}}) do
|
||||||
|
message =
|
||||||
|
case body do
|
||||||
|
%{"message" => message} when is_binary(message) ->
|
||||||
|
message
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
"HTTP status #{status}"
|
||||||
|
end
|
||||||
|
|
||||||
|
{:error, %{message: message, status: status}}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp result_to_error({:error, exception}) do
|
||||||
|
{:error, %{message: "reason: #{Exception.message(exception)}", status: nil}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -6,7 +6,7 @@ defmodule Livebook.Runtime.K8s do
|
||||||
# proxy a local port to the distribution port of the remote node.
|
# proxy a local port to the distribution port of the remote node.
|
||||||
# See `Livebook.Runtime.Fly` for more design details.
|
# See `Livebook.Runtime.Fly` for more design details.
|
||||||
|
|
||||||
defstruct [:config, :node, :req, :server_pid, :lv_pid, :pod_name]
|
defstruct [:config, :node, :server_pid, :lv_pid, :pod_name]
|
||||||
|
|
||||||
use GenServer, restart: :temporary
|
use GenServer, restart: :temporary
|
||||||
|
|
||||||
|
|
@ -17,7 +17,6 @@ defmodule Livebook.Runtime.K8s do
|
||||||
|
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
node: node() | nil,
|
node: node() | nil,
|
||||||
req: Req.Request.t(),
|
|
||||||
server_pid: pid() | nil,
|
server_pid: pid() | nil,
|
||||||
lv_pid: pid(),
|
lv_pid: pid(),
|
||||||
pod_name: String.t() | nil
|
pod_name: String.t() | nil
|
||||||
|
|
@ -74,11 +73,10 @@ defmodule Livebook.Runtime.K8s do
|
||||||
{"remote_runtime_#{local_port}", local_port}
|
{"remote_runtime_#{local_port}", local_port}
|
||||||
end
|
end
|
||||||
|
|
||||||
req =
|
kubeconfig =
|
||||||
Kubereq.Kubeconfig.Default
|
Kubereq.Kubeconfig.Default
|
||||||
|> Kubereq.Kubeconfig.load()
|
|> Kubereq.Kubeconfig.load()
|
||||||
|> Kubereq.Kubeconfig.set_current_context(context)
|
|> Kubereq.Kubeconfig.set_current_context(context)
|
||||||
|> Kubereq.new("api/v1/namespaces/:namespace/pods/:name")
|
|
||||||
|
|
||||||
runtime_data = RemoteUtils.encode_runtime_data(node_base)
|
runtime_data = RemoteUtils.encode_runtime_data(node_base)
|
||||||
|
|
||||||
|
|
@ -87,17 +85,17 @@ defmodule Livebook.Runtime.K8s do
|
||||||
{:ok, watcher_pid} =
|
{:ok, watcher_pid} =
|
||||||
DynamicSupervisor.start_child(
|
DynamicSupervisor.start_child(
|
||||||
Livebook.RuntimeSupervisor,
|
Livebook.RuntimeSupervisor,
|
||||||
{Task, fn -> watcher(parent, req, config) end}
|
{Task, fn -> watcher(parent, kubeconfig, config) end}
|
||||||
)
|
)
|
||||||
|
|
||||||
with {:ok, pod_name} <-
|
with {:ok, pod_name} <-
|
||||||
with_log(caller, "create pod", fn ->
|
with_log(caller, "create pod", fn ->
|
||||||
create_pod(req, config, runtime_data)
|
create_pod(kubeconfig, config, runtime_data)
|
||||||
end),
|
end),
|
||||||
_ <- send(watcher_pid, {:pod_created, pod_name}),
|
_ <- send(watcher_pid, {:pod_created, pod_name}),
|
||||||
{:ok, pod_ip} <-
|
{:ok, pod_ip} <-
|
||||||
with_pod_events(caller, "waiting for pod", req, namespace, pod_name, fn ->
|
with_pod_events(caller, "waiting for pod", kubeconfig, namespace, pod_name, fn ->
|
||||||
await_pod_ready(req, namespace, pod_name)
|
await_pod_ready(kubeconfig, namespace, pod_name)
|
||||||
end),
|
end),
|
||||||
child_node <- :"#{node_base}@#{pod_ip}",
|
child_node <- :"#{node_base}@#{pod_ip}",
|
||||||
:ok <-
|
:ok <-
|
||||||
|
|
@ -145,35 +143,18 @@ defmodule Livebook.Runtime.K8s do
|
||||||
{:noreply, state}
|
{:noreply, state}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp with_pod_events(caller, name, req, namespace, pod_name, fun) do
|
defp with_pod_events(caller, name, kubeconfig, namespace, pod_name, fun) do
|
||||||
with_log(caller, name, fn ->
|
with_log(caller, name, fn ->
|
||||||
runtime_pid = self()
|
runtime_pid = self()
|
||||||
|
|
||||||
event_watcher_pid =
|
event_watcher_pid =
|
||||||
spawn_link(fn ->
|
spawn_link(fn ->
|
||||||
watch_result =
|
with {:ok, stream} <- Livebook.K8sAPI.watch_pod_events(kubeconfig, namespace, pod_name) do
|
||||||
req
|
for event <- stream do
|
||||||
|> Req.merge(
|
message = Livebook.Utils.downcase_first(event.message)
|
||||||
resource_path: "api/v1/namespaces/:namespace/events/:name",
|
|
||||||
resource_list_path: "api/v1/namespaces/:namespace/events"
|
|
||||||
)
|
|
||||||
|> Kubereq.watch(namespace,
|
|
||||||
field_selectors: [
|
|
||||||
{"involvedObject.kind", "Pod"},
|
|
||||||
{"involvedObject.name", pod_name}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
case watch_result do
|
|
||||||
{:ok, stream} ->
|
|
||||||
Enum.each(stream, fn event ->
|
|
||||||
message = Livebook.Utils.downcase_first(event["object"]["message"])
|
|
||||||
send(caller, {:runtime_connect_info, runtime_pid, message})
|
send(caller, {:runtime_connect_info, runtime_pid, message})
|
||||||
Logger.debug(~s/[k8s runtime] Pod event: "#{message}"/)
|
Logger.debug(~s/[k8s runtime] Pod event: "#{message}"/)
|
||||||
end)
|
end
|
||||||
|
|
||||||
_error ->
|
|
||||||
:ok
|
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -183,9 +164,9 @@ defmodule Livebook.Runtime.K8s do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp watcher(parent, req, config) do
|
defp watcher(parent, kubeconfig, config) do
|
||||||
ref = Process.monitor(parent)
|
ref = Process.monitor(parent)
|
||||||
watcher_loop(%{ref: ref, config: config, req: req, pod_name: nil})
|
watcher_loop(%{ref: ref, config: config, kubeconfig: kubeconfig, pod_name: nil})
|
||||||
end
|
end
|
||||||
|
|
||||||
defp watcher_loop(state) do
|
defp watcher_loop(state) do
|
||||||
|
|
@ -194,8 +175,7 @@ defmodule Livebook.Runtime.K8s do
|
||||||
# If the parent process is killed, we try to eagerly free the
|
# If the parent process is killed, we try to eagerly free the
|
||||||
# created resources
|
# created resources
|
||||||
if pod_name = state.pod_name do
|
if pod_name = state.pod_name do
|
||||||
namespace = state.config.namespace
|
_ = Livebook.K8sAPI.delete_pod(state.kubeconfig, state.config.namespace, pod_name)
|
||||||
_ = Kubereq.delete(state.req, namespace, pod_name)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
{:pod_created, pod_name} ->
|
{:pod_created, pod_name} ->
|
||||||
|
|
@ -206,7 +186,7 @@ defmodule Livebook.Runtime.K8s do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_pod(req, config, runtime_data) do
|
defp create_pod(kubeconfig, config, runtime_data) do
|
||||||
%{
|
%{
|
||||||
pod_template: pod_template,
|
pod_template: pod_template,
|
||||||
docker_tag: docker_tag,
|
docker_tag: docker_tag,
|
||||||
|
|
@ -245,15 +225,12 @@ defmodule Livebook.Runtime.K8s do
|
||||||
manifest
|
manifest
|
||||||
end
|
end
|
||||||
|
|
||||||
case Kubereq.create(req, manifest) do
|
case Livebook.K8sAPI.create_pod(kubeconfig, manifest) do
|
||||||
{:ok, %{status: 201, body: %{"metadata" => %{"name" => pod_name}}}} ->
|
{:ok, %{name: name}} ->
|
||||||
{:ok, pod_name}
|
{:ok, name}
|
||||||
|
|
||||||
{:ok, %{body: body}} ->
|
{:error, %{message: message}} ->
|
||||||
{:error, "could not create Pod, reason: #{body["message"]}"}
|
{:error, "could not create Pod, reason: #{message}"}
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:error, "could not create Pod, reason: #{Exception.message(error)}"}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -324,38 +301,13 @@ defmodule Livebook.Runtime.K8s do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp await_pod_ready(req, namespace, pod_name) do
|
defp await_pod_ready(kubeconfig, namespace, pod_name) do
|
||||||
with :ok <-
|
with :ok <- Livebook.K8sAPI.await_pod_ready(kubeconfig, namespace, pod_name),
|
||||||
Kubereq.wait_until(
|
{:ok, %{ip: pod_ip}} <- Livebook.K8sAPI.get_pod(kubeconfig, namespace, pod_name) do
|
||||||
req,
|
{:ok, pod_ip}
|
||||||
namespace,
|
|
||||||
pod_name,
|
|
||||||
fn
|
|
||||||
:deleted ->
|
|
||||||
{:error, "the Pod was deleted before it started running"}
|
|
||||||
|
|
||||||
pod ->
|
|
||||||
get_in(pod, [
|
|
||||||
"status",
|
|
||||||
"conditions",
|
|
||||||
Access.filter(&(&1["type"] == "Ready")),
|
|
||||||
"status"
|
|
||||||
]) == ["True"]
|
|
||||||
end,
|
|
||||||
# 30 minutes
|
|
||||||
1_800_000
|
|
||||||
),
|
|
||||||
{:ok, %{status: 200, body: pod}} <- Kubereq.get(req, namespace, pod_name) do
|
|
||||||
{:ok, pod["status"]["podIP"]}
|
|
||||||
else
|
else
|
||||||
{:error, :watch_timeout} ->
|
{:error, %{message: message}} ->
|
||||||
{:error, "timed out waiting for Pod to start up"}
|
{:error, "failed while waiting for the Pod to start, reason: #{message}"}
|
||||||
|
|
||||||
{:error, error} ->
|
|
||||||
{:error, error}
|
|
||||||
|
|
||||||
_other ->
|
|
||||||
{:error, "tailed getting the Pod's IP address"}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -356,7 +356,9 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
<.icon_button
|
<.icon_button
|
||||||
phx-click="delete_volume"
|
phx-click="delete_volume"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
disabled={@volume_id == nil or (@volume_action != nil and @volume_action.inflight)}
|
disabled={
|
||||||
|
@volume_id == nil or (@volume_action != nil and @volume_action.status == :inflight)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<.remix_icon icon="delete-bin-6-line" />
|
<.remix_icon icon="delete-bin-6-line" />
|
||||||
</.icon_button>
|
</.icon_button>
|
||||||
|
|
@ -380,7 +382,7 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
class="text-red-600 font-medium text-sm whitespace-nowrap"
|
class="text-red-600 font-medium text-sm whitespace-nowrap"
|
||||||
phx-click="confirm_delete_volume"
|
phx-click="confirm_delete_volume"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
disabled={@volume_action.inflight}
|
disabled={@volume_action.status == :inflight}
|
||||||
>
|
>
|
||||||
<.remix_icon icon="delete-bin-6-line" class="align-middle mr-1" /> Delete
|
<.remix_icon icon="delete-bin-6-line" class="align-middle mr-1" /> Delete
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -388,7 +390,7 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
class="text-gray-600 font-medium text-sm"
|
class="text-gray-600 font-medium text-sm"
|
||||||
phx-click="cancel_delete_volume"
|
phx-click="cancel_delete_volume"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
disabled={@volume_action.inflight}
|
disabled={@volume_action.status == :inflight}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -415,9 +417,9 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
</div>
|
</div>
|
||||||
<.button
|
<.button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={not @volume_action.changeset.valid? or @volume_action.inflight}
|
disabled={not @volume_action.changeset.valid? or @volume_action.status == :inflight}
|
||||||
>
|
>
|
||||||
<%= if(@volume_action.inflight, do: "Creating...", else: "Create") %>
|
<%= if(@volume_action.status == :inflight, do: "Creating...", else: "Create") %>
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -425,12 +427,13 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
outlined
|
outlined
|
||||||
phx-click="cancel_new_volume"
|
phx-click="cancel_new_volume"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
|
disabled={@volume_action.status == :inflight}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
<div :if={error = @volume_action[:error]}>
|
<div :if={@volume_action[:status] == :error}>
|
||||||
<.message_box kind={:error} message={"Error: " <> error.message} />
|
<.message_box kind={:error} message={"Error: " <> @volume_action.error.message} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -459,7 +462,7 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("delete_volume", %{}, socket) do
|
def handle_event("delete_volume", %{}, socket) do
|
||||||
volume_action = %{type: :delete, inflight: false, error: nil}
|
volume_action = %{type: :delete, status: :initial, error: nil}
|
||||||
{:noreply, assign(socket, volume_action: volume_action)}
|
{:noreply, assign(socket, volume_action: volume_action)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -472,7 +475,7 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("new_volume", %{}, socket) do
|
def handle_event("new_volume", %{}, socket) do
|
||||||
volume_action = %{type: :new, changeset: volume_changeset(), inflight: false, error: false}
|
volume_action = %{type: :new, changeset: volume_changeset(), status: :initial, error: nil}
|
||||||
{:noreply, assign(socket, volume_action: volume_action)}
|
{:noreply, assign(socket, volume_action: volume_action)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -592,7 +595,7 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
assign(socket, volumes: volumes, volume_id: volume.id, volume_action: nil)
|
assign(socket, volumes: volumes, volume_id: volume.id, volume_action: nil)
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
assign_nested(socket, :volume_action, error: error, inflight: false)
|
assign_nested(socket, :volume_action, error: error, status: :error)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -608,7 +611,7 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
assign(socket, volumes: volumes, volume_id: nil, volume_action: nil)
|
assign(socket, volumes: volumes, volume_id: nil, volume_action: nil)
|
||||||
|
|
||||||
{:error, error} ->
|
{:error, error} ->
|
||||||
assign_nested(socket, :volume_action, error: error, inflight: false)
|
assign_nested(socket, :volume_action, error: error, status: :error)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -767,7 +770,7 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
|> start_async(:delete_volume, fn ->
|
|> start_async(:delete_volume, fn ->
|
||||||
Livebook.FlyAPI.delete_volume(token, app_name, volume_id)
|
Livebook.FlyAPI.delete_volume(token, app_name, volume_id)
|
||||||
end)
|
end)
|
||||||
|> assign_nested(:volume_action, inflight: true)
|
|> assign_nested(:volume_action, status: :inflight)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp create_volume(socket, name, size_gb) do
|
defp create_volume(socket, name, size_gb) do
|
||||||
|
|
@ -787,7 +790,7 @@ defmodule LivebookWeb.SessionLive.FlyRuntimeComponent do
|
||||||
|> start_async(:create_volume, fn ->
|
|> start_async(:create_volume, fn ->
|
||||||
Livebook.FlyAPI.create_volume(token, app_name, name, region, size_gb, compute)
|
Livebook.FlyAPI.create_volume(token, app_name, name, region, size_gb, compute)
|
||||||
end)
|
end)
|
||||||
|> assign_nested(:volume_action, inflight: true)
|
|> assign_nested(:volume_action, status: :inflight)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_config(socket) do
|
defp build_config(socket) do
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
import Ecto.Changeset
|
import Ecto.Changeset
|
||||||
|
|
||||||
alias Livebook.{Session, Runtime}
|
alias Livebook.{Session, Runtime}
|
||||||
alias Livebook.K8s.{Auth, Pod, PVC}
|
alias Livebook.K8s.{Pod, PVC}
|
||||||
|
|
||||||
@kubeconfig_pipeline Application.compile_env(:livebook, :k8s_kubeconfig_pipeline)
|
@kubeconfig_pipeline Application.compile_env(:livebook, :k8s_kubeconfig_pipeline)
|
||||||
|
|
||||||
|
|
@ -23,7 +23,6 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
kubeconfig: kubeconfig,
|
kubeconfig: kubeconfig,
|
||||||
context_options: context_options,
|
context_options: context_options,
|
||||||
context: nil,
|
context: nil,
|
||||||
reqs: nil,
|
|
||||||
cluster_check: %{status: :initial, error: nil},
|
cluster_check: %{status: :initial, error: nil},
|
||||||
namespace: nil,
|
namespace: nil,
|
||||||
namespace_options: nil,
|
namespace_options: nil,
|
||||||
|
|
@ -118,14 +117,12 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
phx-change="set_context"
|
phx-change="set_context"
|
||||||
phx-nosubmit
|
phx-nosubmit
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
class="mt-1"
|
class="mt-1 flex flex-col gap-4"
|
||||||
>
|
>
|
||||||
<.select_field name="context" value={@context} label="Context" options={@context_options} />
|
<.select_field name="context" value={@context} label="Context" options={@context_options} />
|
||||||
</form>
|
|
||||||
|
|
||||||
<.loader :if={@cluster_check.status == :inflight} />
|
<.loader :if={@cluster_check.status == :inflight} />
|
||||||
|
|
||||||
<.cluster_check_error :if={@cluster_check.status == :error} error={@cluster_check.error} />
|
<.cluster_check_error :if={@cluster_check.status == :error} error={@cluster_check.error} />
|
||||||
|
</form>
|
||||||
|
|
||||||
<form
|
<form
|
||||||
:if={@cluster_check.status == :ok}
|
:if={@cluster_check.status == :ok}
|
||||||
|
|
@ -149,7 +146,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<.message_box :if={@rbac.status === :errors} kind={:error}>
|
<.message_box :if={@rbac.status == :errors} kind={:error}>
|
||||||
<%= for error <- @rbac.errors do %>
|
<%= for error <- @rbac.errors do %>
|
||||||
<.rbac_error error={error} />
|
<.rbac_error error={error} />
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
@ -260,7 +257,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
|
|
||||||
<div class="mt-4 flex flex-col">
|
<div class="mt-4 flex flex-col">
|
||||||
<div class="flex items-start gap-1">
|
<div class="flex items-start gap-1">
|
||||||
<form phx-change="set_pvc_name" phx-target={@myself} class="grow">
|
<form phx-change="set_pvc_name" phx-nosubmit phx-target={@myself} class="grow">
|
||||||
<.select_field
|
<.select_field
|
||||||
:if={@rbac.permissions.list_pvc}
|
:if={@rbac.permissions.list_pvc}
|
||||||
value={@pvc_name}
|
value={@pvc_name}
|
||||||
|
|
@ -302,7 +299,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:if={@pvc_action[:type] in [:delete, :delete_inflight]}
|
:if={@pvc_action[:type] == :delete}
|
||||||
class="px-4 py-3 mt-4 flex space-x-4 items-center border border-gray-200 rounded-lg"
|
class="px-4 py-3 mt-4 flex space-x-4 items-center border border-gray-200 rounded-lg"
|
||||||
>
|
>
|
||||||
<p class="grow text-gray-700 text-sm">
|
<p class="grow text-gray-700 text-sm">
|
||||||
|
|
@ -313,7 +310,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
class="text-red-600 font-medium text-sm whitespace-nowrap"
|
class="text-red-600 font-medium text-sm whitespace-nowrap"
|
||||||
phx-click="confirm_delete_pvc"
|
phx-click="confirm_delete_pvc"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
disabled={@pvc_action[:type] == :delete_inflight}
|
disabled={@pvc_action.status == :inflight}
|
||||||
>
|
>
|
||||||
<.remix_icon icon="delete-bin-6-line" class="align-middle mr-1" />
|
<.remix_icon icon="delete-bin-6-line" class="align-middle mr-1" />
|
||||||
<%= if @pvc_action[:type] == :delete, do: "Delete", else: "Deleting..." %>
|
<%= if @pvc_action[:type] == :delete, do: "Delete", else: "Deleting..." %>
|
||||||
|
|
@ -322,7 +319,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
class="text-gray-600 font-medium text-sm"
|
class="text-gray-600 font-medium text-sm"
|
||||||
phx-click="cancel_delete_pvc"
|
phx-click="cancel_delete_pvc"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
disabled={@pvc_action[:type] == :delete_inflight}
|
disabled={@pvc_action.status == :inflight}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -331,7 +328,7 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
|
|
||||||
<.form
|
<.form
|
||||||
:let={pvcf}
|
:let={pvcf}
|
||||||
:if={@pvc_action[:type] in [:new, :new_inflight]}
|
:if={@pvc_action[:type] == :new}
|
||||||
for={@pvc_action.changeset}
|
for={@pvc_action.changeset}
|
||||||
as={:pvc}
|
as={:pvc}
|
||||||
phx-submit="create_pvc"
|
phx-submit="create_pvc"
|
||||||
|
|
@ -354,25 +351,27 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
<.select_field field={pvcf[:storage_class]} options={@pvc_action.storage_classes} />
|
<.select_field field={pvcf[:storage_class]} options={@pvc_action.storage_classes} />
|
||||||
</div>
|
</div>
|
||||||
<.button
|
<.button
|
||||||
:if={@pvc_action[:type] == :new}
|
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={not @pvc_action.changeset.valid? or @pvc_action[:type] == :new_inflight}
|
disabled={not @pvc_action.changeset.valid? or @pvc_action.status == :inflight}
|
||||||
>
|
>
|
||||||
<%= if @pvc_action[:type] == :new, do: "Create", else: "Creating..." %>
|
<%= if(@pvc_action.status == :inflight, do: "Creating...", else: "Create") %>
|
||||||
</.button>
|
</.button>
|
||||||
<.button
|
<.button
|
||||||
:if={@pvc_action[:type] == :new}
|
|
||||||
type="button"
|
type="button"
|
||||||
color="gray"
|
color="gray"
|
||||||
outlined
|
outlined
|
||||||
phx-click="cancel_new_pvc"
|
phx-click="cancel_new_pvc"
|
||||||
phx-target={@myself}
|
phx-target={@myself}
|
||||||
disabled={@pvc_action[:type] == :new_inflight}
|
disabled={@pvc_action.status == :inflight}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
<.error :if={@pvc_action[:error]}><%= @pvc_action[:error] %></.error>
|
<.message_box
|
||||||
|
:if={@pvc_action[:status] == :error}
|
||||||
|
kind={:error}
|
||||||
|
message={"Error: " <> @pvc_action.error.message}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
|
|
@ -389,58 +388,47 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
|
|
||||||
defp cluster_check_error(%{error: %{status: 401}} = assigns) do
|
defp cluster_check_error(%{error: %{status: 401}} = assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.message_box kind={:error}>
|
<.message_box kind={:error} message="Authentication with cluster failed." />
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>Authentication with cluster failed.</div>
|
|
||||||
</div>
|
|
||||||
</.message_box>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp cluster_check_error(%{error: %{reason: :timeout}} = assigns) do
|
|
||||||
~H"""
|
|
||||||
<.message_box kind={:error}>
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>Connection to cluster timed out.</div>
|
|
||||||
</div>
|
|
||||||
</.message_box>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp cluster_check_error(assigns) do
|
defp cluster_check_error(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<.message_box kind={:error}>
|
<.message_box kind={:error} message={"Connection to cluster failed, reason: " <> @error.message} />
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>Connection to cluster failed.</div>
|
|
||||||
</div>
|
|
||||||
</.message_box>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
defp rbac_error(%{error: %Req.Response{status: 201} = resp} = assigns) do
|
defp rbac_error(%{error: {:ok, access_review}} = assigns) do
|
||||||
resourceAttributes = resp.body["spec"]["resourceAttributes"]
|
namespace = access_review.namespace
|
||||||
verb = resourceAttributes["verb"]
|
verb = access_review.verb
|
||||||
namespace = resourceAttributes["namespace"]
|
|
||||||
|
|
||||||
gkv =
|
path =
|
||||||
String.trim(
|
String.trim(
|
||||||
"#{resourceAttributes["group"]}/#{resourceAttributes["version"]}/#{resourceAttributes["resource"]}",
|
"#{access_review.group}/#{access_review.version}/#{access_review.resource}",
|
||||||
"/"
|
"/"
|
||||||
)
|
)
|
||||||
|
|
||||||
assigns = assign(assigns, verb: verb, gkv: gkv, namespace: namespace)
|
assigns = assign(assigns, verb: verb, path: path, namespace: namespace)
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
Authenticated user has no permission to <span class="font-semibold"><%= @verb %></span>
|
Authenticated user has no permission to <span class="font-semibold"><%= @verb %></span>
|
||||||
<code><%= @gkv %></code>
|
<code><%= @path %></code>
|
||||||
<span :if={@namespace}> in namespace <code><%= @namespace %></code> (or the namespace doesn't exist)</span>.
|
<span :if={@namespace}> in namespace <code><%= @namespace %></code> (or the namespace doesn't exist)</span>.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp rbac_error(%{error: {:error, %{message: message}}} = assigns) do
|
||||||
|
assigns = assign(assigns, :message, message)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div><%= @message %></div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("set_context", %{"context" => context}, socket) do
|
def handle_event("set_context", %{"context" => context}, socket) do
|
||||||
{:noreply, socket |> set_context(context) |> set_namespace(nil)}
|
{:noreply, socket |> set_context(context) |> set_namespace(nil)}
|
||||||
|
|
@ -467,8 +455,8 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
type: :new,
|
type: :new,
|
||||||
changeset: PVC.changeset(),
|
changeset: PVC.changeset(),
|
||||||
storage_classes: storage_classes(socket.assigns),
|
storage_classes: storage_classes(socket.assigns),
|
||||||
inflight: false,
|
status: :initial,
|
||||||
error: false
|
error: nil
|
||||||
}
|
}
|
||||||
|
|
||||||
{:noreply, assign(socket, pvc_action: pvc_action)}
|
{:noreply, assign(socket, pvc_action: pvc_action)}
|
||||||
|
|
@ -501,18 +489,20 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("delete_pvc", %{}, socket) do
|
def handle_event("delete_pvc", %{}, socket) do
|
||||||
pvc_action = %{type: :delete, error: nil}
|
pvc_action = %{type: :delete, status: :initial, error: nil}
|
||||||
{:noreply, assign(socket, pvc_action: pvc_action)}
|
{:noreply, assign(socket, pvc_action: pvc_action)}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("confirm_delete_pvc", %{}, socket) do
|
def handle_event("confirm_delete_pvc", %{}, socket) do
|
||||||
%{namespace: namespace, pvc_name: name} = socket.assigns
|
%{namespace: namespace, pvc_name: name} = socket.assigns
|
||||||
req = socket.assigns.reqs.pvc
|
kubeconfig = socket.assigns.kubeconfig
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> start_async(:delete_pvc, fn -> Kubereq.delete(req, namespace, name) end)
|
|> start_async(:delete_pvc, fn ->
|
||||||
|> assign_nested(:pvc_action, type: :delete_inflight)
|
Livebook.K8sAPI.delete_pvc(kubeconfig, namespace, name)
|
||||||
|
end)
|
||||||
|
|> assign_nested(:pvc_action, status: :inflight)
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
@ -536,26 +526,46 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_async(:rbac_check, {:ok, %{errors: errors, permissions: permissions}}, socket) do
|
def handle_async(:rbac_check, {:ok, %{errors: errors, permissions: permissions}}, socket) do
|
||||||
status = if errors === [], do: :ok, else: :errors
|
status = if errors == [], do: :ok, else: :errors
|
||||||
{:noreply, assign(socket, :rbac, %{status: status, errors: errors, permissions: permissions})}
|
{:noreply, assign(socket, :rbac, %{status: status, errors: errors, permissions: permissions})}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_async(:load_namespace_options, {:ok, [:ok, {:ok, resp}]}, socket) do
|
def handle_async(:cluster_check, {:ok, results}, socket) do
|
||||||
socket =
|
[access_review_result, namespaces_result] = results
|
||||||
case resp do
|
|
||||||
%Req.Response{status: 200, body: %{"items" => resources}} ->
|
|
||||||
namespace_options = Enum.map(resources, & &1["metadata"]["name"])
|
|
||||||
|
|
||||||
|
access_review_result =
|
||||||
|
case access_review_result do
|
||||||
|
{:ok, %{allowed: true}} -> :ok
|
||||||
|
{:ok, %{allowed: false}} -> {:error, %{message: "no access", status: nil}}
|
||||||
|
error -> error
|
||||||
|
end
|
||||||
|
|
||||||
|
namespaces_result =
|
||||||
|
case namespaces_result do
|
||||||
|
{:ok, namespaces} ->
|
||||||
|
namespace_options = Enum.map(namespaces, & &1.name)
|
||||||
|
{:ok, namespace_options, List.first(namespace_options)}
|
||||||
|
|
||||||
|
{:error, %{status: 403}} ->
|
||||||
|
# No access to list namespaces, we will show an input instead
|
||||||
|
{:ok, nil, nil}
|
||||||
|
|
||||||
|
other ->
|
||||||
|
other
|
||||||
|
end
|
||||||
|
|
||||||
|
socket =
|
||||||
|
with :ok <- access_review_result,
|
||||||
|
{:ok, namespace_options, namespace} <- namespaces_result do
|
||||||
socket
|
socket
|
||||||
|> assign(:namespace_options, namespace_options)
|
|> assign(:namespace_options, namespace_options)
|
||||||
|> set_namespace(List.first(namespace_options))
|
|> set_namespace(namespace)
|
||||||
|> assign(:cluster_check, %{status: :ok, error: nil})
|
|> assign(:cluster_check, %{status: :ok, error: nil})
|
||||||
|
else
|
||||||
%Req.Response{status: _other} ->
|
{:error, error} ->
|
||||||
# cannot list namespaces
|
|
||||||
socket
|
socket
|
||||||
|> assign(:namespace_options, nil)
|
|> assign(:namespace_options, nil)
|
||||||
|> assign(:cluster_check, %{status: :ok, error: nil})
|
|> assign(:cluster_check, %{status: :error, error: error})
|
||||||
end
|
end
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -564,13 +574,13 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
def handle_async(:delete_pvc, {:ok, result}, socket) do
|
def handle_async(:delete_pvc, {:ok, result}, socket) do
|
||||||
socket =
|
socket =
|
||||||
case result do
|
case result do
|
||||||
{:ok, %{status: 200}} ->
|
:ok ->
|
||||||
socket
|
socket
|
||||||
|> assign(pvc_name: nil, pvc_action: nil)
|
|> assign(pvc_name: nil, pvc_action: nil)
|
||||||
|> pvc_options()
|
|> pvc_options()
|
||||||
|
|
||||||
{:ok, %{body: %{"message" => message}}} ->
|
{:error, error} ->
|
||||||
assign_nested(socket, :pvc_action, error: message, type: :delete)
|
assign_nested(socket, :pvc_action, status: :error, error: error)
|
||||||
end
|
end
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -579,40 +589,18 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
def handle_async(:create_pvc, {:ok, result}, socket) do
|
def handle_async(:create_pvc, {:ok, result}, socket) do
|
||||||
socket =
|
socket =
|
||||||
case result do
|
case result do
|
||||||
{:ok, %{status: 201, body: created_pvc}} ->
|
{:ok, %{name: name}} ->
|
||||||
socket
|
socket
|
||||||
|> assign(pvc_name: created_pvc["metadata"]["name"], pvc_action: nil)
|
|> assign(pvc_name: name, pvc_action: nil)
|
||||||
|> pvc_options()
|
|> pvc_options()
|
||||||
|
|
||||||
{:ok, %{body: body}} ->
|
{:error, error} ->
|
||||||
socket
|
assign_nested(socket, :pvc_action, status: :error, error: error)
|
||||||
|> assign_nested(:pvc_action,
|
|
||||||
error: "Creating the PVC failed: #{body["message"]}",
|
|
||||||
type: :new
|
|
||||||
)
|
|
||||||
|
|
||||||
{:error, error} when is_exception(error) ->
|
|
||||||
socket
|
|
||||||
|> assign_nested(:pvc_action,
|
|
||||||
error: "Creating the PVC failed: #{Exception.message(error)}",
|
|
||||||
type: :new
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_async(:load_namespace_options, {:ok, results}, socket) do
|
|
||||||
{:error, error} = List.first(results, &match?({:error, _}, &1))
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:namespace_options, nil)
|
|
||||||
|> assign(:cluster_check, %{status: :error, error: error})
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp label(namespace, runtime, runtime_status) do
|
defp label(namespace, runtime, runtime_status) do
|
||||||
reconnecting? = reconnecting?(namespace, runtime)
|
reconnecting? = reconnecting?(namespace, runtime)
|
||||||
|
|
||||||
|
|
@ -630,11 +618,11 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
defp create_pvc(socket, pvc) do
|
defp create_pvc(socket, pvc) do
|
||||||
namespace = socket.assigns.namespace
|
namespace = socket.assigns.namespace
|
||||||
manifest = PVC.manifest(pvc, namespace)
|
manifest = PVC.manifest(pvc, namespace)
|
||||||
req = socket.assigns.reqs.pvc
|
kubeconfig = socket.assigns.kubeconfig
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> start_async(:create_pvc, fn -> Kubereq.create(req, manifest) end)
|
|> start_async(:create_pvc, fn -> Livebook.K8sAPI.create_pvc(kubeconfig, manifest) end)
|
||||||
|> assign_nested(:pvc_action, type: :new_inflight)
|
|> assign_nested(:pvc_action, status: :inflight)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp set_context(socket, nil), do: assign(socket, :context, nil)
|
defp set_context(socket, nil), do: assign(socket, :context, nil)
|
||||||
|
|
@ -642,26 +630,18 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
defp set_context(socket, context) do
|
defp set_context(socket, context) do
|
||||||
kubeconfig = Kubereq.Kubeconfig.set_current_context(socket.assigns.kubeconfig, context)
|
kubeconfig = Kubereq.Kubeconfig.set_current_context(socket.assigns.kubeconfig, context)
|
||||||
|
|
||||||
reqs = %{
|
|
||||||
access_reviews:
|
|
||||||
Kubereq.new(kubeconfig, "apis/authorization.k8s.io/v1/selfsubjectaccessreviews"),
|
|
||||||
namespaces: Kubereq.new(kubeconfig, "api/v1/namespaces/:name"),
|
|
||||||
pvc: Kubereq.new(kubeconfig, "api/v1/namespaces/:namespace/persistentvolumeclaims/:name"),
|
|
||||||
sc: Kubereq.new(kubeconfig, "apis/storage.k8s.io/v1/storageclasses/:name")
|
|
||||||
}
|
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> start_async(:load_namespace_options, fn ->
|
|> start_async(:cluster_check, fn ->
|
||||||
[
|
[
|
||||||
Task.async(fn ->
|
Task.async(fn ->
|
||||||
Livebook.K8s.Auth.can_i?(reqs.access_reviews,
|
Livebook.K8sAPI.create_access_review(kubeconfig,
|
||||||
verb: "create",
|
verb: "create",
|
||||||
group: "authorization.k8s.io",
|
group: "authorization.k8s.io",
|
||||||
version: "v1",
|
version: "v1",
|
||||||
resource: "selfsubjectaccessreviews"
|
resource: "selfsubjectaccessreviews"
|
||||||
)
|
)
|
||||||
end),
|
end),
|
||||||
Task.async(fn -> Kubereq.list(reqs.namespaces, nil) end)
|
Task.async(fn -> Livebook.K8sAPI.list_namespaces(kubeconfig) end)
|
||||||
]
|
]
|
||||||
|> Task.await_many(:infinity)
|
|> Task.await_many(:infinity)
|
||||||
end)
|
end)
|
||||||
|
|
@ -670,8 +650,6 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
context: context,
|
context: context,
|
||||||
namespace: nil,
|
namespace: nil,
|
||||||
namespace_options: nil,
|
namespace_options: nil,
|
||||||
rbac_error: nil,
|
|
||||||
reqs: reqs,
|
|
||||||
cluster_check: %{status: :inflight, error: nil}
|
cluster_check: %{status: :inflight, error: nil}
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
@ -681,36 +659,38 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp set_namespace(socket, ns) do
|
defp set_namespace(socket, ns) do
|
||||||
reqs = socket.assigns.reqs
|
kubeconfig = socket.assigns.kubeconfig
|
||||||
|
|
||||||
socket
|
socket
|
||||||
|> start_async(:rbac_check, fn ->
|
|> start_async(:rbac_check, fn ->
|
||||||
{required_permissions, optional_permissions} =
|
resource_attribute_list = [
|
||||||
Auth.batch_check(reqs.access_reviews, [
|
# Required permissions
|
||||||
# required permissions:
|
|
||||||
[verb: "get", version: "v1", resource: "pods", namespace: ns],
|
[verb: "get", version: "v1", resource: "pods", namespace: ns],
|
||||||
[verb: "list", version: "v1", resource: "pods", namespace: ns],
|
[verb: "list", version: "v1", resource: "pods", namespace: ns],
|
||||||
[verb: "watch", version: "v1", resource: "pods", namespace: ns],
|
[verb: "watch", version: "v1", resource: "pods", namespace: ns],
|
||||||
[verb: "create", version: "v1", resource: "pods", namespace: ns],
|
[verb: "create", version: "v1", resource: "pods", namespace: ns],
|
||||||
[verb: "delete", version: "v1", resource: "pods", namespace: ns],
|
[verb: "delete", version: "v1", resource: "pods", namespace: ns],
|
||||||
[verb: "create", version: "v1", resource: "pods/portforward", namespace: ns],
|
[verb: "create", version: "v1", resource: "pods/portforward", namespace: ns],
|
||||||
# optional permissions:
|
# Optional permissions
|
||||||
[verb: "list", version: "v1", resource: "persistentvolumeclaims", namespace: ns],
|
[verb: "list", version: "v1", resource: "persistentvolumeclaims", namespace: ns],
|
||||||
[verb: "create", version: "v1", resource: "persistentvolumeclaims", namespace: ns],
|
[verb: "create", version: "v1", resource: "persistentvolumeclaims", namespace: ns],
|
||||||
[verb: "delete", version: "v1", resource: "persistentvolumeclaims", namespace: ns],
|
[verb: "delete", version: "v1", resource: "persistentvolumeclaims", namespace: ns],
|
||||||
[verb: "list", version: "v1", resource: "storageclasses", namespace: ns]
|
[verb: "list", version: "v1", resource: "storageclasses", namespace: ns]
|
||||||
])
|
]
|
||||||
|
|
||||||
|
{required_permissions, optional_permissions} =
|
||||||
|
resource_attribute_list
|
||||||
|
|> Enum.map(&Task.async(fn -> Livebook.K8sAPI.create_access_review(kubeconfig, &1) end))
|
||||||
|
|> Task.await_many(:infinity)
|
||||||
|> Enum.split(6)
|
|> Enum.split(6)
|
||||||
|
|
||||||
errors =
|
errors =
|
||||||
required_permissions
|
Enum.reject(required_permissions, &match?({:ok, %{allowed: true}}, &1))
|
||||||
|> Enum.reject(&(&1 === :ok))
|
|
||||||
|> Enum.map(fn {:error, error} -> error end)
|
|
||||||
|
|
||||||
permissions =
|
permissions =
|
||||||
optional_permissions
|
optional_permissions
|
||||||
|> Enum.map(&(&1 === :ok))
|
|> Enum.map(&match?({:ok, %{allowed: true}}, &1))
|
||||||
|> then(&Enum.zip([:list_pvc, :create_pvc, :delete_pvc, :list_sc], &1))
|
|> then(&Enum.zip([:list_pvc, :create_pvc, :delete_pvc, :list_storage_classes], &1))
|
||||||
|> Map.new()
|
|> Map.new()
|
||||||
|
|
||||||
%{errors: errors, permissions: permissions}
|
%{errors: errors, permissions: permissions}
|
||||||
|
|
@ -751,33 +731,26 @@ defmodule LivebookWeb.SessionLive.K8sRuntimeComponent do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp pvc_options(socket) do
|
defp pvc_options(socket) do
|
||||||
%{reqs: %{pvc: req}, namespace: ns} = socket.assigns
|
%{kubeconfig: kubeconfig, namespace: namespace} = socket.assigns
|
||||||
|
|
||||||
case Kubereq.list(req, ns) do
|
case Livebook.K8sAPI.list_pvcs(kubeconfig, namespace) do
|
||||||
{:ok, %Req.Response{status: 200} = resp} ->
|
{:ok, pvcs} ->
|
||||||
pvcs =
|
pvcs = for pvc <- pvcs, pvc.deleted_at == nil, do: pvc.name
|
||||||
resp.body["items"]
|
assign(socket, :pvcs, pvcs)
|
||||||
|> Enum.reject(& &1["metadata"]["deletionTimestamp"])
|
|
||||||
|> Enum.map(& &1["metadata"]["name"])
|
|
||||||
|
|
||||||
socket
|
_other ->
|
||||||
|> assign(:pvcs, pvcs)
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
assign(socket, :pvcs, [])
|
assign(socket, :pvcs, [])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp storage_classes(%{rbac: %{permissions: %{list_sc: false}}}), do: []
|
defp storage_classes(%{rbac: %{permissions: %{list_storage_classes: false}}}), do: []
|
||||||
|
|
||||||
defp storage_classes(assigns) do
|
defp storage_classes(%{kubeconfig: kubeconfig}) do
|
||||||
%{reqs: %{sc: req}} = assigns
|
case Livebook.K8sAPI.list_storage_classes(kubeconfig) do
|
||||||
|
{:ok, storage_classes} ->
|
||||||
|
Enum.map(storage_classes, & &1.name)
|
||||||
|
|
||||||
case Kubereq.list(req, nil) do
|
_other ->
|
||||||
{:ok, %Req.Response{status: 200} = resp} ->
|
|
||||||
Enum.map(resp.body["items"], & &1["metadata"]["name"])
|
|
||||||
|
|
||||||
_ ->
|
|
||||||
[]
|
[]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,8 @@ defmodule Livebook.Runtime.K8sTest do
|
||||||
|
|
||||||
test "connecting flow" do
|
test "connecting flow" do
|
||||||
config = config()
|
config = config()
|
||||||
req = req()
|
|
||||||
|
|
||||||
assert [] = list_pods(req)
|
assert [] = list_pods()
|
||||||
|
|
||||||
pid = Runtime.K8s.new(config) |> Runtime.connect()
|
pid = Runtime.K8s.new(config) |> Runtime.connect()
|
||||||
|
|
||||||
|
|
@ -70,7 +69,7 @@ defmodule Livebook.Runtime.K8sTest do
|
||||||
|
|
||||||
Runtime.take_ownership(runtime)
|
Runtime.take_ownership(runtime)
|
||||||
|
|
||||||
assert [_] = list_pods(req)
|
assert [_] = list_pods()
|
||||||
|
|
||||||
# Verify that we can actually evaluate code on the Kubernetes Pod
|
# Verify that we can actually evaluate code on the Kubernetes Pod
|
||||||
Runtime.evaluate_code(runtime, :elixir, ~s/System.fetch_env!("POD_NAME")/, {:c1, :e1}, [])
|
Runtime.evaluate_code(runtime, :elixir, ~s/System.fetch_env!("POD_NAME")/, {:c1, :e1}, [])
|
||||||
|
|
@ -80,23 +79,10 @@ defmodule Livebook.Runtime.K8sTest do
|
||||||
Runtime.disconnect(runtime)
|
Runtime.disconnect(runtime)
|
||||||
|
|
||||||
# Wait for Pod to terminate
|
# Wait for Pod to terminate
|
||||||
assert :ok ==
|
cmd!(~w(kubectl wait --for=jsonpath={.status.phase}=Succeeded pod/#{runtime.pod_name}))
|
||||||
Kubereq.wait_until(
|
|
||||||
req,
|
|
||||||
"default",
|
|
||||||
runtime.pod_name,
|
|
||||||
&(&1["status"]["phase"] == "Succeeded")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Finally, delete the Pod object
|
# Finally, delete the Pod object
|
||||||
Kubereq.delete(req, "default", runtime.pod_name)
|
cmd!(~w(kubectl delete pod #{runtime.pod_name}))
|
||||||
end
|
|
||||||
|
|
||||||
defp req() do
|
|
||||||
Kubereq.Kubeconfig.Default
|
|
||||||
|> Kubereq.Kubeconfig.load()
|
|
||||||
|> Kubereq.Kubeconfig.set_current_context("kind-#{@cluster_name}")
|
|
||||||
|> Kubereq.new("api/v1/namespaces/:namespace/pods/:name")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp config(attrs \\ %{}) do
|
defp config(attrs \\ %{}) do
|
||||||
|
|
@ -123,21 +109,19 @@ defmodule Livebook.Runtime.K8sTest do
|
||||||
Map.merge(defaults, attrs)
|
Map.merge(defaults, attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp list_pods(req) do
|
defp list_pods() do
|
||||||
{:ok, resp} =
|
cmd!(
|
||||||
Kubereq.list(req, "default",
|
~w(kubectl get pod --selector=livebook.dev/runtime=integration-test --field-selector=status.phase==Running --output json)
|
||||||
label_selectors: [{"livebook.dev/runtime", "integration-test"}],
|
|
||||||
field_selectors: [{"status.phase", "Running"}]
|
|
||||||
)
|
)
|
||||||
|
|> Jason.decode!()
|
||||||
resp.body["items"]
|
|> Map.fetch!("items")
|
||||||
end
|
end
|
||||||
|
|
||||||
defp cmd!([command | args]) do
|
defp cmd!([command | args]) do
|
||||||
{output, status} = System.cmd(command, args, stderr_to_stdout: true)
|
{output, status} = System.cmd(command, args, stderr_to_stdout: true)
|
||||||
|
|
||||||
if status != 0 do
|
if status != 0 do
|
||||||
raise "command #{inspect(command)} #{inspect(args)} failed"
|
raise "command #{inspect(command)} #{inspect(args)} failed with output:\n#{output}"
|
||||||
end
|
end
|
||||||
|
|
||||||
output
|
output
|
||||||
|
|
|
||||||
|
|
@ -1205,6 +1205,8 @@ defmodule LivebookWeb.SessionLiveTest do
|
||||||
|> element(~s{form[phx-change="set_namespace"]})
|
|> element(~s{form[phx-change="set_namespace"]})
|
||||||
|> render_change(%{namespace: "default"})
|
|> render_change(%{namespace: "default"})
|
||||||
|
|
||||||
|
render_async(view)
|
||||||
|
|
||||||
assert view
|
assert view
|
||||||
|> element(~s{select[name="pvc_name"] option[value="foo-pvc"]})
|
|> element(~s{select[name="pvc_name"] option[value="foo-pvc"]})
|
||||||
|> has_element?()
|
|> has_element?()
|
||||||
|
|
|
||||||
|
|
@ -12,12 +12,16 @@ defmodule Livebook.K8sClusterStub do
|
||||||
plug :dispatch
|
plug :dispatch
|
||||||
|
|
||||||
post "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews" do
|
post "/apis/authorization.k8s.io/v1/selfsubjectaccessreviews" do
|
||||||
resource = conn.body_params["spec"]["resourceAttributes"]["resource"]
|
resource_attributes = conn.body_params["spec"]["resourceAttributes"]
|
||||||
|
resource = resource_attributes["resource"]
|
||||||
allowed = conn.host == "default" or resource == "selfsubjectaccessreviews"
|
allowed = conn.host == "default" or resource == "selfsubjectaccessreviews"
|
||||||
|
|
||||||
conn
|
conn
|
||||||
|> put_status(201)
|
|> put_status(201)
|
||||||
|> Req.Test.json(%{"status" => %{"allowed" => allowed}})
|
|> Req.Test.json(%{
|
||||||
|
"status" => %{"allowed" => allowed},
|
||||||
|
"spec" => %{"resourceAttributes" => resource_attributes}
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
get "/api/v1/namespaces", host: "default" do
|
get "/api/v1/namespaces", host: "default" do
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue