Deployment and lifecycle management for Nix
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

web: add deploy buttons to subscriptions

+361 -1
+29
apps/sower/lib/sower_web/components/sower_components.ex
··· 1 1 defmodule SowerWeb.SowerComponents do 2 2 use Phoenix.Component 3 + import SowerWeb.CoreComponents, only: [button: 1] 3 4 4 5 attr :label, :string, required: true 5 6 slot :inner_block, required: true ··· 200 201 201 202 ~H""" 202 203 <span>{@local_dt}</span> 204 + """ 205 + end 206 + 207 + attr :subscription_sid, :string, required: true 208 + attr :deployable, :boolean, default: false 209 + attr :deploying, :boolean, default: false 210 + attr :deploy_error, :string, default: nil 211 + 212 + def deploy_button(assigns) do 213 + ~H""" 214 + <div class="inline-flex items-center gap-2"> 215 + <.button 216 + :if={@deployable} 217 + type="button" 218 + phx-click="deploy_subscription" 219 + phx-value-subscription_sid={@subscription_sid} 220 + phx-disable-with="Deploying..." 221 + disabled={@deploying} 222 + > 223 + Deploy 224 + </.button> 225 + <span 226 + :if={@deploy_error} 227 + class="text-sm text-red-600 dark:text-red-400" 228 + > 229 + {@deploy_error} 230 + </span> 231 + </div> 203 232 """ 204 233 end 205 234
+87
apps/sower/lib/sower_web/live/agent_live/show.ex
··· 31 31 generations_filter = Map.get(params, "generations_filter", "current") 32 32 generations = load_generations(agent, generations_filter) 33 33 34 + deployable_subs = resolve_deployable_subscriptions(agent.subscriptions) 35 + 34 36 socket = 35 37 socket 36 38 |> assign(:page_title, page_title(socket.assigns.live_action)) ··· 40 42 |> assign(:current_generation, %{}) 41 43 |> assign(:generations_filter, generations_filter) 42 44 |> assign(:generations, generations) 45 + |> assign(:deployable_subs, deployable_subs) 46 + |> assign(:deploying_sub, nil) 47 + |> assign(:deploy_error, nil) 48 + |> assign(:retrying_deployment, nil) 43 49 44 50 if connected?(socket) do 45 51 Phoenix.PubSub.subscribe(Sower.PubSub, "agent:view:#{sid}") ··· 55 61 {:noreply, add_online_status(socket)} 56 62 end 57 63 64 + def handle_info({:deployment, :created, deployment}, socket) do 65 + if socket.assigns.deploying_sub do 66 + {:noreply, 67 + socket 68 + |> assign(:deploying_sub, nil) 69 + |> redirect(to: ~p"/deployments/#{deployment.sid}")} 70 + else 71 + deployments = Orchestration.list_deployments(socket.assigns.agent, limit: 10) 72 + {:noreply, assign(socket, :deployments, deployments)} 73 + end 74 + end 75 + 58 76 def handle_info({:deployment, _event, _deployment}, socket) do 59 77 deployments = Orchestration.list_deployments(socket.assigns.agent, limit: 10) 60 78 {:noreply, assign(socket, :deployments, deployments)} ··· 66 84 end 67 85 68 86 @impl true 87 + def handle_event("deploy_subscription", %{"subscription_sid" => sub_sid}, socket) do 88 + subscription = Enum.find(socket.assigns.agent.subscriptions, &(&1.sid == sub_sid)) 89 + 90 + case subscription do 91 + nil -> 92 + {:noreply, assign(socket, :deploy_error, "Subscription not found")} 93 + 94 + sub -> 95 + socket = assign(socket, deploying_sub: sub_sid, deploy_error: nil) 96 + 97 + case Orchestration.deploy_subscription(sub) do 98 + {:ok, _request_id} -> 99 + {:noreply, socket} 100 + 101 + {:error, reason} -> 102 + {:noreply, 103 + assign(socket, deploying_sub: nil, deploy_error: deploy_error_message(reason))} 104 + end 105 + end 106 + end 107 + 69 108 def handle_event("set_generations_filter", %{"filter" => filter}, socket) do 70 109 generations = load_generations(socket.assigns.agent, filter) 71 110 ··· 78 117 {:noreply, socket} 79 118 end 80 119 120 + def handle_event("retry_deployment", %{"deployment_sid" => deployment_sid}, socket) do 121 + socket = assign(socket, :retrying_deployment, deployment_sid) 122 + 123 + case Enum.find(socket.assigns.deployments, &(&1.sid == deployment_sid)) do 124 + nil -> 125 + {:noreply, 126 + socket 127 + |> assign(:retrying_deployment, nil) 128 + |> put_flash(:error, "Deployment not found")} 129 + 130 + deployment -> 131 + case Orchestration.retry_deployment(deployment, socket.assigns.current_user.id) do 132 + {:ok, _retry_deployment} -> 133 + {:noreply, 134 + socket 135 + |> assign(:retrying_deployment, nil) 136 + |> put_flash(:info, "Retry deployment created")} 137 + 138 + {:error, :deployment_not_retryable} -> 139 + {:noreply, 140 + socket 141 + |> assign(:retrying_deployment, nil) 142 + |> put_flash(:error, "Only successful or failed deployments can be retried")} 143 + 144 + {:error, :retry_in_progress} -> 145 + {:noreply, 146 + socket 147 + |> assign(:retrying_deployment, nil) 148 + |> put_flash(:error, "A retry is already in progress for this deployment")} 149 + 150 + {:error, _reason} -> 151 + {:noreply, 152 + socket 153 + |> assign(:retrying_deployment, nil) 154 + |> put_flash(:error, "Failed to retry deployment")} 155 + end 156 + end 157 + end 158 + 81 159 defp add_online_status(%{assigns: %{agent: agent}} = socket) do 82 160 online_agents = Presence.list("agent:presence") |> Map.keys() 83 161 assign(socket, :online, agent.sid in online_agents) ··· 99 177 end 100 178 101 179 defp load_generations(agent_id, _), do: load_generations(agent_id, "current") 180 + 181 + defp resolve_deployable_subscriptions(subscriptions) do 182 + subscriptions 183 + |> Enum.filter(fn sub -> Orchestration.match_seed(sub) != nil end) 184 + |> MapSet.new(& &1.sid) 185 + end 186 + 187 + defp deploy_error_message(:agent_not_found), do: "Agent not found" 188 + defp deploy_error_message(_), do: "Deployment failed" 102 189 end
+20
apps/sower/lib/sower_web/live/agent_live/show.html.heex
··· 84 84 <:col :let={subscription} label="Updated"> 85 85 <.local_datetime datetime={subscription.updated_at} user_timezone={@user_timezone} /> 86 86 </:col> 87 + <:col :let={subscription} label=""> 88 + <.deploy_button 89 + subscription_sid={subscription.sid} 90 + deployable={MapSet.member?(@deployable_subs, subscription.sid)} 91 + deploying={@deploying_sub == subscription.sid} 92 + deploy_error={if @deploying_sub == subscription.sid, do: @deploy_error} 93 + /> 94 + </:col> 87 95 </.responsive_table> 88 96 <p 89 97 :if={@agent.subscriptions == []} ··· 106 114 <:col :let={deployment} label="SID">{deployment.sid}</:col> 107 115 <:col :let={deployment} label="Created"> 108 116 <.local_datetime datetime={deployment.inserted_at} user_timezone={@user_timezone} /> 117 + </:col> 118 + <:col :let={deployment} label=""> 119 + <.button 120 + :if={deployment.result in [:success, :failure]} 121 + type="button" 122 + phx-click="retry_deployment" 123 + phx-value-deployment_sid={deployment.sid} 124 + phx-disable-with="Retrying..." 125 + disabled={@retrying_deployment == deployment.sid} 126 + > 127 + Retry 128 + </.button> 109 129 </:col> 110 130 </.responsive_table> 111 131 <p
+40 -1
apps/sower/lib/sower_web/live/subscription_live/show.ex
··· 35 35 |> assign(:agent, agent) 36 36 |> assign(:page_title, page_title(socket.assigns.live_action)) 37 37 |> assign(:subscription, subscription) 38 - |> assign(:matching_seeds, matching_seeds)} 38 + |> assign(:matching_seeds, matching_seeds) 39 + |> assign(:deployable, matching_seeds != []) 40 + |> assign(:deploying, false) 41 + |> assign(:deploy_error, nil)} 42 + end 43 + end 44 + 45 + @impl true 46 + def handle_event("deploy_subscription", %{"subscription_sid" => _sub_sid}, socket) do 47 + socket = assign(socket, deploying: true, deploy_error: nil) 48 + 49 + case Orchestration.deploy_subscription(socket.assigns.subscription) do 50 + {:ok, _request_id} -> 51 + {:noreply, socket} 52 + 53 + {:error, reason} -> 54 + {:noreply, assign(socket, deploying: false, deploy_error: deploy_error_message(reason))} 55 + end 56 + end 57 + 58 + @impl Phoenix.LiveView 59 + def handle_info({:deployment, :created, deployment}, socket) do 60 + if socket.assigns.deploying do 61 + {:noreply, 62 + socket 63 + |> assign(:deploying, false) 64 + |> redirect(to: ~p"/deployments/#{deployment.sid}")} 65 + else 66 + subscription = 67 + Orchestration.get_subscription_sid_with_deployments!(socket.assigns.subscription.sid) 68 + 69 + matching_seeds = Orchestration.list_matching_seeds(subscription, 5) 70 + 71 + {:noreply, 72 + socket 73 + |> assign(:subscription, subscription) 74 + |> assign(:matching_seeds, matching_seeds)} 39 75 end 40 76 end 41 77 ··· 54 90 55 91 defp page_title(:show), do: "Show Subscription" 56 92 defp page_title(:edit), do: "Edit Subscription" 93 + 94 + defp deploy_error_message(:agent_not_found), do: "Agent not found" 95 + defp deploy_error_message(_), do: "Deployment failed" 57 96 end
+6
apps/sower/lib/sower_web/live/subscription_live/show.html.heex
··· 3 3 Subscription {@subscription.id} 4 4 <:subtitle>This is a subscription record from your database.</:subtitle> 5 5 <:actions> 6 + <.deploy_button 7 + subscription_sid={@subscription.sid} 8 + deployable={@deployable} 9 + deploying={@deploying} 10 + deploy_error={@deploy_error} 11 + /> 6 12 <.link 7 13 patch={~p"/agents/#{@agent}/subscriptions/#{@subscription}/show/edit"} 8 14 phx-click={JS.push_focus()}
+90
apps/sower/test/sower_web/live/agent_live_show_test.exs
··· 1 + defmodule SowerWeb.AgentLive.ShowTest do 2 + use SowerWeb.ConnCase, async: true 3 + 4 + import Phoenix.LiveViewTest 5 + import Sower.OrchestrationFixtures 6 + import Sower.SeedFixtures 7 + 8 + setup [:register_and_log_in_user] 9 + 10 + defp create_agent_with_subscription(user, seed_attrs \\ %{}) do 11 + Sower.Repo.put_org_id(user.org_id) 12 + agent = agent_fixture() 13 + 14 + seed = seed_fixture(seed_attrs) 15 + 16 + subscription = 17 + subscription_fixture(%{ 18 + agent_id: agent.id, 19 + seed_name: seed.name, 20 + seed_type: seed.seed_type 21 + }) 22 + 23 + %{agent: agent, subscription: subscription, seed: seed} 24 + end 25 + 26 + test "shows deploy button when subscription has matching seed", %{conn: conn, user: user} do 27 + %{agent: agent} = create_agent_with_subscription(user) 28 + 29 + {:ok, show_live, _html} = live(conn, ~p"/agents/#{agent}") 30 + 31 + assert has_element?(show_live, "button", "Deploy") 32 + end 33 + 34 + test "does not show deploy button when subscription has no matching seed", %{ 35 + conn: conn, 36 + user: user 37 + } do 38 + Sower.Repo.put_org_id(user.org_id) 39 + agent = agent_fixture() 40 + 41 + subscription_fixture(%{ 42 + agent_id: agent.id, 43 + seed_name: "nonexistent-seed", 44 + seed_type: "nixos" 45 + }) 46 + 47 + {:ok, show_live, _html} = live(conn, ~p"/agents/#{agent}") 48 + 49 + refute has_element?(show_live, "button", "Deploy") 50 + end 51 + 52 + test "clicking deploy triggers deployment and redirects", %{conn: conn, user: user} do 53 + %{agent: agent, subscription: subscription} = create_agent_with_subscription(user) 54 + 55 + {:ok, show_live, _html} = live(conn, ~p"/agents/#{agent}") 56 + 57 + show_live 58 + |> element("button[phx-value-subscription_sid=\"#{subscription.sid}\"]", "Deploy") 59 + |> render_click() 60 + 61 + # The deployment is async - wait for PubSub broadcast to trigger redirect 62 + deployment = 63 + eventually(fn -> 64 + [d | _] = Sower.Orchestration.list_deployments(agent, limit: 1) 65 + d 66 + end) 67 + 68 + assert_redirect(show_live, ~p"/deployments/#{deployment.sid}") 69 + end 70 + 71 + defp eventually(fun, retries \\ 20) do 72 + fun.() 73 + rescue 74 + _ -> 75 + if retries > 0 do 76 + Process.sleep(50) 77 + eventually(fun, retries - 1) 78 + else 79 + raise "eventually timed out" 80 + end 81 + catch 82 + _ -> 83 + if retries > 0 do 84 + Process.sleep(50) 85 + eventually(fun, retries - 1) 86 + else 87 + raise "eventually timed out" 88 + end 89 + end 90 + end
+89
apps/sower/test/sower_web/live/subscription_live_show_test.exs
··· 1 + defmodule SowerWeb.SubscriptionLive.ShowTest do 2 + use SowerWeb.ConnCase, async: true 3 + 4 + import Phoenix.LiveViewTest 5 + import Sower.OrchestrationFixtures 6 + import Sower.SeedFixtures 7 + 8 + setup [:register_and_log_in_user] 9 + 10 + defp create_subscription_with_seed(user) do 11 + Sower.Repo.put_org_id(user.org_id) 12 + agent = agent_fixture() 13 + seed = seed_fixture() 14 + 15 + subscription = 16 + subscription_fixture(%{ 17 + agent_id: agent.id, 18 + seed_name: seed.name, 19 + seed_type: seed.seed_type 20 + }) 21 + 22 + %{agent: agent, subscription: subscription, seed: seed} 23 + end 24 + 25 + test "shows deploy button when subscription matches latest seed", %{conn: conn, user: user} do 26 + %{agent: agent, subscription: subscription} = create_subscription_with_seed(user) 27 + 28 + {:ok, show_live, _html} = 29 + live(conn, ~p"/agents/#{agent}/subscriptions/#{subscription}") 30 + 31 + assert has_element?(show_live, "button", "Deploy") 32 + end 33 + 34 + test "does not show deploy button when no matching seed", %{conn: conn, user: user} do 35 + Sower.Repo.put_org_id(user.org_id) 36 + agent = agent_fixture() 37 + 38 + subscription = 39 + subscription_fixture(%{ 40 + agent_id: agent.id, 41 + seed_name: "nonexistent-seed", 42 + seed_type: "nixos" 43 + }) 44 + 45 + {:ok, show_live, _html} = 46 + live(conn, ~p"/agents/#{agent}/subscriptions/#{subscription}") 47 + 48 + refute has_element?(show_live, "button", "Deploy") 49 + end 50 + 51 + test "clicking deploy triggers deployment and redirects", %{conn: conn, user: user} do 52 + %{agent: agent, subscription: subscription} = create_subscription_with_seed(user) 53 + 54 + {:ok, show_live, _html} = 55 + live(conn, ~p"/agents/#{agent}/subscriptions/#{subscription}") 56 + 57 + show_live 58 + |> element("button", "Deploy") 59 + |> render_click() 60 + 61 + deployment = 62 + eventually(fn -> 63 + [d | _] = Sower.Orchestration.list_deployments(agent, limit: 1) 64 + d 65 + end) 66 + 67 + assert_redirect(show_live, ~p"/deployments/#{deployment.sid}") 68 + end 69 + 70 + defp eventually(fun, retries \\ 20) do 71 + fun.() 72 + rescue 73 + _ -> 74 + if retries > 0 do 75 + Process.sleep(50) 76 + eventually(fun, retries - 1) 77 + else 78 + raise "eventually timed out" 79 + end 80 + catch 81 + _ -> 82 + if retries > 0 do 83 + Process.sleep(50) 84 + eventually(fun, retries - 1) 85 + else 86 + raise "eventually timed out" 87 + end 88 + end 89 + end