Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

feat(garden): live-reload show page on garden reports

Garden agents push GardenReport (version) and GardenSeedsReport
(profile generations) over the channel. Persist-only behavior meant
the show page only refreshed on full reload.

Add GardenPubSub broadcasting on garden:view:<sid> after each report
ingestion, and handle the events in GardenLive.Show to refresh the
garden record and seed generations live.

Ticket: sow-180

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+179 -13
+9 -1
apps/sower/lib/sower/orchestration/garden.ex
··· 5 5 6 6 alias Sower.Repo 7 7 alias Sower.Orchestration.Deployment 8 + alias Sower.Orchestration.GardenPubSub 8 9 9 10 require Logger 10 11 ··· 129 130 %__MODULE__{} = garden, 130 131 %SowerClient.Orchestration.GardenReport{} = report 131 132 ) do 132 - update_garden(garden, %{version: report.version}) 133 + case update_garden(garden, %{version: report.version}) do 134 + {:ok, updated} = result -> 135 + GardenPubSub.broadcast_garden_change(updated, :updated) 136 + result 137 + 138 + other -> 139 + other 140 + end 133 141 end 134 142 135 143 defp delete_existing_client(%__MODULE__{oauth_client_id: nil}), do: :ok
+40
apps/sower/lib/sower/orchestration/garden_pubsub.ex
··· 1 + defmodule Sower.Orchestration.GardenPubSub do 2 + @moduledoc """ 3 + PubSub broadcasting for garden reporting events. 4 + """ 5 + 6 + alias Sower.Orchestration.Garden 7 + require Logger 8 + 9 + @doc """ 10 + Broadcasts when a garden's reported metadata (e.g., version) changes. 11 + """ 12 + def broadcast_garden_change(%Garden{} = garden, event \\ :updated) do 13 + broadcast("garden:view:#{garden.sid}", {:garden, event, garden}) 14 + {:ok, garden} 15 + end 16 + 17 + @doc """ 18 + Broadcasts when a garden's seed generations report has been ingested. 19 + """ 20 + def broadcast_seed_generations_change(%Garden{} = garden, event \\ :updated) do 21 + broadcast("garden:view:#{garden.sid}", {:garden_seed_generations, event, garden}) 22 + {:ok, garden} 23 + end 24 + 25 + defp broadcast(topic, message) do 26 + case Phoenix.PubSub.broadcast(Sower.PubSub, topic, message) do 27 + :ok -> 28 + :ok 29 + 30 + {:error, reason} -> 31 + Logger.warning( 32 + msg: "Failed to broadcast garden change", 33 + topic: topic, 34 + error: inspect(reason) 35 + ) 36 + 37 + :ok 38 + end 39 + end 40 + end
+22 -12
apps/sower/lib/sower/orchestration/garden_seed_generation.ex
··· 4 4 import Ecto.Query, warn: false 5 5 6 6 alias Sower.Repo 7 - alias Sower.Orchestration.{Garden, NixProfile, Seed} 7 + alias Sower.Orchestration.{Garden, GardenPubSub, NixProfile, Seed} 8 8 9 9 require Logger 10 10 ··· 108 108 %SowerClient.Orchestration.GardenSeedsReport{} = report, 109 109 %Garden{} = garden 110 110 ) do 111 - Repo.transaction(fn -> 112 - if Enum.empty?(report.profiles) do 113 - delete_all_garden_seed_generations(garden.id) 114 - else 115 - for profile <- report.profiles do 116 - nix_profile = NixProfile.find_or_create!(profile.profile_path) 117 - rows = resolve_profile_generation_rows(garden, profile) 118 - sync_profile_generation_rows(garden, nix_profile, rows) 111 + result = 112 + Repo.transaction(fn -> 113 + if Enum.empty?(report.profiles) do 114 + delete_all_garden_seed_generations(garden.id) 115 + else 116 + for profile <- report.profiles do 117 + nix_profile = NixProfile.find_or_create!(profile.profile_path) 118 + rows = resolve_profile_generation_rows(garden, profile) 119 + sync_profile_generation_rows(garden, nix_profile, rows) 120 + end 119 121 end 120 - end 121 122 122 - :ok 123 - end) 123 + :ok 124 + end) 125 + 126 + case result do 127 + {:ok, :ok} -> 128 + GardenPubSub.broadcast_seed_generations_change(garden, :updated) 129 + result 130 + 131 + _ -> 132 + result 133 + end 124 134 end 125 135 126 136 defp resolve_profile_generation_rows(%Garden{} = garden, profile) do
+19
apps/sower/lib/sower_web/live/garden_live/show.ex
··· 77 77 {:noreply, assign(socket, :deployments, deployments)} 78 78 end 79 79 80 + def handle_info({:garden, _event, %Sower.Orchestration.Garden{}}, socket) do 81 + {:noreply, refresh_garden(socket)} 82 + end 83 + 84 + def handle_info({:garden_seed_generations, _event, %Sower.Orchestration.Garden{}}, socket) do 85 + generations = load_generations(socket.assigns.garden, socket.assigns.generations_filter) 86 + {:noreply, assign(socket, :generations, generations)} 87 + end 88 + 80 89 def handle_info({SowerWeb.GardenLive.FormComponent, {:saved, garden}}, socket) do 81 90 garden = Sower.Repo.preload(garden, :subscriptions) 82 91 {:noreply, assign(socket, :garden, garden)} ··· 165 174 |> assign(:retrying_deployment, nil) 166 175 |> put_flash(:error, "Failed to retry deployment")} 167 176 end 177 + end 178 + end 179 + 180 + defp refresh_garden(socket) do 181 + case Orchestration.get_garden_sid(socket.assigns.garden.sid) do 182 + nil -> 183 + socket 184 + 185 + garden -> 186 + assign(socket, :garden, Sower.Repo.preload(garden, :subscriptions)) 168 187 end 169 188 end 170 189
+39
apps/sower/test/sower/orchestration/garden_pubsub_test.exs
··· 1 + defmodule Sower.Orchestration.GardenPubSubTest do 2 + use Sower.DataCase, async: true 3 + 4 + alias Sower.Orchestration.GardenPubSub 5 + 6 + import Sower.AccountsFixtures 7 + import Sower.OrchestrationFixtures 8 + 9 + setup do 10 + org = organization_fixture() 11 + Sower.Repo.put_org_id(org.org_id) 12 + 13 + %{organization: org} 14 + end 15 + 16 + test "broadcast_garden_change/2 publishes per-garden view topic", %{organization: org} do 17 + garden = garden_fixture(%{org_id: org.org_id}) 18 + 19 + Phoenix.PubSub.subscribe(Sower.PubSub, "garden:view:#{garden.sid}") 20 + 21 + assert {:ok, _garden} = GardenPubSub.broadcast_garden_change(garden, :updated) 22 + 23 + garden_sid = garden.sid 24 + assert_receive {:garden, :updated, %{sid: ^garden_sid}} 25 + end 26 + 27 + test "broadcast_seed_generations_change/2 publishes per-garden view topic", %{ 28 + organization: org 29 + } do 30 + garden = garden_fixture(%{org_id: org.org_id}) 31 + 32 + Phoenix.PubSub.subscribe(Sower.PubSub, "garden:view:#{garden.sid}") 33 + 34 + assert {:ok, _garden} = GardenPubSub.broadcast_seed_generations_change(garden, :updated) 35 + 36 + garden_sid = garden.sid 37 + assert_receive {:garden_seed_generations, :updated, %{sid: ^garden_sid}} 38 + end 39 + end
+50
apps/sower/test/sower_web/live/garden_live_show_test.exs
··· 49 49 refute has_element?(show_live, "button", "Deploy") 50 50 end 51 51 52 + test "live-updates version when garden report broadcast arrives", %{conn: conn, user: user} do 53 + Sower.Repo.put_org_id(user.org_id) 54 + garden = garden_fixture() 55 + 56 + {:ok, show_live, html} = live(conn, ~p"/gardens/#{garden}") 57 + assert html =~ "unknown" 58 + 59 + {:ok, _} = 60 + Sower.Orchestration.update_garden_report( 61 + garden, 62 + %SowerClient.Orchestration.GardenReport{version: "9.9.9"} 63 + ) 64 + 65 + assert render(show_live) =~ "9.9.9" 66 + end 67 + 68 + test "live-updates seed generations when seeds report broadcast arrives", %{ 69 + conn: conn, 70 + user: user 71 + } do 72 + Sower.Repo.put_org_id(user.org_id) 73 + garden = garden_fixture() 74 + seed = seed_fixture(%{name: "live-update-seed", seed_type: "nixos"}) 75 + 76 + {:ok, show_live, html} = live(conn, ~p"/gardens/#{garden}") 77 + refute html =~ "live-update-seed" 78 + 79 + report = %SowerClient.Orchestration.GardenSeedsReport{ 80 + profiles: [ 81 + %SowerClient.Orchestration.GardenSeedProfile{ 82 + profile_path: "/nix/var/nix/profiles/system", 83 + tags: %{}, 84 + generations: [ 85 + %SowerClient.Orchestration.GardenSeedGeneration{ 86 + path: seed.artifact, 87 + link: "/nix/var/nix/profiles/system-1-link", 88 + created: DateTime.to_iso8601(DateTime.utc_now()), 89 + generation_number: 1, 90 + is_current: true 91 + } 92 + ] 93 + } 94 + ] 95 + } 96 + 97 + {:ok, :ok} = Sower.Orchestration.update_garden_seed_generations(report, garden) 98 + 99 + assert render(show_live) =~ "live-update-seed" 100 + end 101 + 52 102 test "clicking deploy triggers deployment and redirects", %{conn: conn, user: user} do 53 103 %{garden: garden, subscription: subscription} = create_garden_with_subscription(user) 54 104