Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

web: display deploy logs from s3

+370 -13
+22
apps/sower/lib/sower/storage.ex
··· 49 49 ) 50 50 end 51 51 52 + def presign_download(file, opts \\ []) do 53 + bucket = get_in(config(), [:s3, :bucket]) 54 + expires_in = Keyword.get(opts, :expires_in, 60 * 60) 55 + 56 + Logger.debug(msg: "Generating presigned download url", file: file, expires_in: expires_in) 57 + 58 + :s3 59 + |> ExAws.Config.new() 60 + |> ExAws.S3.presigned_url(:get, bucket, file, expires_in: expires_in) 61 + end 62 + 63 + def presign_head(file, opts \\ []) do 64 + bucket = get_in(config(), [:s3, :bucket]) 65 + expires_in = Keyword.get(opts, :expires_in, 60 * 60) 66 + 67 + Logger.debug(msg: "Generating presigned head url", file: file, expires_in: expires_in) 68 + 69 + :s3 70 + |> ExAws.Config.new() 71 + |> ExAws.S3.presigned_url(:head, bucket, file, expires_in: expires_in) 72 + end 73 + 52 74 defp checksum_headers(opts) do 53 75 case Keyword.fetch(opts, :checksum_sha256) do 54 76 {:ok, checksum} ->
+84
apps/sower/lib/sower_web/controllers/deployment_log_controller.ex
··· 1 + defmodule SowerWeb.DeploymentLogController do 2 + use SowerWeb, :controller 3 + 4 + require Logger 5 + 6 + alias Sower.Orchestration 7 + alias Sower.Storage 8 + alias SowerClient.Orchestration.SeedDeployment 9 + 10 + @default_expiry_seconds 5 * 60 11 + 12 + def show(conn, params), do: show(conn, params, []) 13 + 14 + def show(conn, %{"sid" => deployment_sid, "seed_sid" => seed_sid}, opts) do 15 + with {:ok, deployment} <- fetch_deployment_with_seed(deployment_sid, seed_sid), 16 + object_path <- SeedDeployment.log_path(deployment.sid, seed_sid), 17 + {:ok, download_url} <- presign_download_url(object_path, opts) do 18 + redirect(conn, external: download_url) 19 + else 20 + {:error, :no_log} -> 21 + conn 22 + |> put_status(:not_found) 23 + |> text("no log") 24 + |> halt() 25 + 26 + {:error, :not_found} -> 27 + conn 28 + |> put_status(:not_found) 29 + |> text("Not found") 30 + |> halt() 31 + 32 + {:error, reason} -> 33 + Logger.error( 34 + msg: "Failed to fetch deployment log", 35 + deployment_sid: deployment_sid, 36 + seed_sid: seed_sid, 37 + reason: inspect(reason) 38 + ) 39 + 40 + conn 41 + |> put_status(:internal_server_error) 42 + |> text("failed to load log") 43 + |> halt() 44 + end 45 + end 46 + 47 + defp fetch_deployment_with_seed(deployment_sid, seed_sid) do 48 + case Orchestration.get_deployment_sid(deployment_sid) do 49 + nil -> 50 + {:error, :not_found} 51 + 52 + deployment -> 53 + deployment = Sower.Repo.preload(deployment, :seeds) 54 + 55 + case Enum.any?(deployment.seeds, &(&1.sid == seed_sid)) do 56 + true -> {:ok, deployment} 57 + false -> {:error, :not_found} 58 + end 59 + end 60 + end 61 + 62 + defp presign_download_url(object_path, opts) do 63 + expiry_seconds = Keyword.get(opts, :expires_in, @default_expiry_seconds) 64 + presign_head_fun = Keyword.get(opts, :presign_head_fun, &Storage.presign_head/2) 65 + presign_download_fun = Keyword.get(opts, :presign_download_fun, &Storage.presign_download/2) 66 + req_head_fun = Keyword.get(opts, :req_head_fun, &Req.head/1) 67 + 68 + with {:ok, head_url} <- presign_head_fun.(object_path, expires_in: expiry_seconds), 69 + {:ok, %Req.Response{status: status}} when status >= 200 and status < 300 <- 70 + req_head_fun.(url: head_url, retry: false), 71 + {:ok, download_url} <- presign_download_fun.(object_path, expires_in: expiry_seconds) do 72 + {:ok, download_url} 73 + else 74 + {:ok, %Req.Response{status: status}} when status in [403, 404] -> 75 + {:error, :no_log} 76 + 77 + {:ok, %Req.Response{status: status}} -> 78 + {:error, {:unexpected_status, status}} 79 + 80 + {:error, reason} -> 81 + {:error, reason} 82 + end 83 + end 84 + end
+93 -12
apps/sower/lib/sower_web/live/deployment_live/show.ex
··· 62 62 63 63 <section> 64 64 <h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-200 mb-4">Seeds</h2> 65 - <.responsive_table 66 - id="seeds" 67 - rows={@deployment.seeds} 68 - row_click={fn seed -> JS.navigate(~p"/seeds/#{seed.sid}") end} 69 - > 70 - <:col :let={seed} label="Seed"> 71 - {seed.seed_type}/{seed.name} 72 - </:col> 73 - <:col :let={seed} label="Artifact">{seed.artifact}</:col> 74 - </.responsive_table> 65 + <div :if={@deployment.seeds != []} class="space-y-4"> 66 + <article 67 + :for={seed <- @deployment.seeds} 68 + id={"seed-log-#{seed.sid}"} 69 + class="rounded-lg border border-zinc-200/50 dark:border-zinc-700/50 p-4" 70 + > 71 + <div class="flex items-center justify-between gap-4"> 72 + <div class="text-sm font-semibold text-zinc-900 dark:text-zinc-200 flex flex-wrap items-center gap-2"> 73 + <.link 74 + navigate={~p"/seeds/#{seed.sid}"} 75 + class="hover:text-orange-500 dark:hover:text-orange-400" 76 + > 77 + {seed.seed_type}/{seed.name} 78 + </.link> 79 + <span class="text-xs font-normal text-zinc-500 dark:text-zinc-400"> 80 + {seed.artifact} 81 + </span> 82 + </div> 83 + <div class="flex items-center gap-2"> 84 + <.link 85 + :if={expanded_seed_log?(@expanded_seed_logs, seed.sid)} 86 + href={seed_log_url(@deployment.sid, seed.sid)} 87 + target="_blank" 88 + rel="noopener noreferrer" 89 + class="text-sm text-orange-600 hover:text-orange-500 dark:text-orange-400 dark:hover:text-orange-300" 90 + > 91 + Open in new tab 92 + </.link> 93 + <button 94 + type="button" 95 + phx-click="toggle_seed_log" 96 + phx-value-seed_sid={seed.sid} 97 + class="rounded-md border border-zinc-300 dark:border-zinc-600 px-3 py-1.5 text-sm text-zinc-700 dark:text-zinc-200 hover:bg-zinc-50 dark:hover:bg-zinc-800" 98 + > 99 + {if expanded_seed_log?(@expanded_seed_logs, seed.sid), 100 + do: "Hide log", 101 + else: "View log"} 102 + </button> 103 + </div> 104 + </div> 105 + <div 106 + :if={loaded_seed_log?(@loaded_seed_logs, seed.sid)} 107 + id={"seed-log-frame-#{seed.sid}"} 108 + class={["mt-3", !expanded_seed_log?(@expanded_seed_logs, seed.sid) && "hidden"]} 109 + > 110 + <iframe 111 + src={seed_log_url(@deployment.sid, seed.sid)} 112 + class="w-full h-80 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-300 dark:invert" 113 + title={"Seed deployment log #{seed.sid}"} 114 + /> 115 + </div> 116 + </article> 117 + </div> 75 118 <p 76 119 :if={@deployment.seeds == []} 77 120 class="text-sm text-zinc-500 dark:text-zinc-400 italic" ··· 86 129 87 130 @impl true 88 131 def mount(_params, _session, socket) do 89 - {:ok, assign(socket, :page_title, "Show Deployment")} 132 + {:ok, 133 + socket 134 + |> assign(:page_title, "Show Deployment") 135 + |> assign(:expanded_seed_logs, MapSet.new()) 136 + |> assign(:loaded_seed_logs, MapSet.new())} 90 137 end 91 138 92 139 @impl true ··· 101 148 deployment -> 102 149 deployment = Sower.Repo.preload(deployment, [:seeds, :subscriptions, :agent]) 103 150 104 - {:noreply, assign(socket, :deployment, deployment)} 151 + {:noreply, 152 + socket 153 + |> assign(:deployment, deployment) 154 + |> assign(:expanded_seed_logs, MapSet.new()) 155 + |> assign(:loaded_seed_logs, MapSet.new())} 105 156 end 157 + end 158 + 159 + @impl true 160 + def handle_event("toggle_seed_log", %{"seed_sid" => seed_sid}, socket) do 161 + expanded_seed_logs = socket.assigns.expanded_seed_logs 162 + loaded_seed_logs = socket.assigns.loaded_seed_logs 163 + 164 + {expanded_seed_logs, loaded_seed_logs} = 165 + if expanded_seed_log?(socket.assigns.expanded_seed_logs, seed_sid) do 166 + {MapSet.delete(expanded_seed_logs, seed_sid), loaded_seed_logs} 167 + else 168 + {MapSet.put(expanded_seed_logs, seed_sid), MapSet.put(loaded_seed_logs, seed_sid)} 169 + end 170 + 171 + {:noreply, 172 + socket 173 + |> assign(:expanded_seed_logs, expanded_seed_logs) 174 + |> assign(:loaded_seed_logs, loaded_seed_logs)} 175 + end 176 + 177 + defp expanded_seed_log?(expanded_seed_logs, seed_sid) do 178 + MapSet.member?(expanded_seed_logs, seed_sid) 179 + end 180 + 181 + defp loaded_seed_log?(loaded_seed_logs, seed_sid) do 182 + MapSet.member?(loaded_seed_logs, seed_sid) 183 + end 184 + 185 + defp seed_log_url(deployment_sid, seed_sid) do 186 + ~p"/deployments/#{deployment_sid}/seeds/#{seed_sid}/log" 106 187 end 107 188 end
+2
apps/sower/lib/sower_web/router.ex
··· 40 40 scope "/", SowerWeb do 41 41 pipe_through [:browser, :require_authenticated_user] 42 42 43 + get "/deployments/:sid/seeds/:seed_sid/log", DeploymentLogController, :show 44 + 43 45 live_session :authenticated, on_mount: [{SowerWeb.UserAuth, :ensure_authenticated}] do 44 46 live "/agents", AgentLive.Index, :index 45 47 live "/agents/new", AgentLive.Index, :new
+90
apps/sower/test/sower_web/controllers/deployment_log_controller_test.exs
··· 1 + defmodule SowerWeb.DeploymentLogControllerTest do 2 + use SowerWeb.ConnCase, async: true 3 + 4 + import Sower.OrchestrationFixtures 5 + import Sower.SeedFixtures 6 + 7 + alias SowerWeb.DeploymentLogController 8 + alias SowerClient.Orchestration.SeedDeployment 9 + 10 + setup [:register_and_log_in_user] 11 + 12 + setup %{user: user} do 13 + Sower.Repo.put_org_id(user.org_id) 14 + 15 + agent = agent_fixture() 16 + seed = seed_fixture() 17 + other_seed = seed_fixture() 18 + 19 + deployment = 20 + deployment_fixture(%{ 21 + agent_id: agent.id, 22 + seeds: [seed], 23 + subscriptions: [] 24 + }) 25 + 26 + %{deployment: deployment, seed: seed, other_seed: other_seed} 27 + end 28 + 29 + test "show/3 redirects to a presigned download url when log exists", %{ 30 + conn: conn, 31 + deployment: deployment, 32 + seed: seed 33 + } do 34 + expected_path = SeedDeployment.log_path(deployment.sid, seed.sid) 35 + 36 + conn = 37 + DeploymentLogController.show( 38 + conn, 39 + %{"sid" => deployment.sid, "seed_sid" => seed.sid}, 40 + presign_head_fun: fn path, opts -> 41 + assert path == expected_path 42 + assert opts[:expires_in] == 5 * 60 43 + {:ok, "https://example.com/head"} 44 + end, 45 + req_head_fun: fn opts -> 46 + assert opts[:url] == "https://example.com/head" 47 + assert opts[:retry] == false 48 + {:ok, %Req.Response{status: 200}} 49 + end, 50 + presign_download_fun: fn path, opts -> 51 + assert path == expected_path 52 + assert opts[:expires_in] == 5 * 60 53 + {:ok, "https://example.com/download"} 54 + end 55 + ) 56 + 57 + assert redirected_to(conn) == "https://example.com/download" 58 + end 59 + 60 + test "show/3 returns no log when storage object is missing", %{ 61 + conn: conn, 62 + deployment: deployment, 63 + seed: seed 64 + } do 65 + conn = 66 + DeploymentLogController.show( 67 + conn, 68 + %{"sid" => deployment.sid, "seed_sid" => seed.sid}, 69 + presign_head_fun: fn _, _ -> {:ok, "https://example.com/head"} end, 70 + req_head_fun: fn _ -> {:ok, %Req.Response{status: 404}} end 71 + ) 72 + 73 + assert response(conn, 404) == "no log" 74 + end 75 + 76 + test "show/3 returns not found when seed is not part of deployment", %{ 77 + conn: conn, 78 + deployment: deployment, 79 + other_seed: other_seed 80 + } do 81 + conn = 82 + DeploymentLogController.show( 83 + conn, 84 + %{"sid" => deployment.sid, "seed_sid" => other_seed.sid}, 85 + [] 86 + ) 87 + 88 + assert response(conn, 404) == "Not found" 89 + end 90 + end
+64
apps/sower/test/sower_web/live/deployment_live_show_test.exs
··· 1 + defmodule SowerWeb.DeploymentLive.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 + test "renders seed deployment logs and toggles inline log panel", %{conn: conn, user: user} do 11 + Sower.Repo.put_org_id(user.org_id) 12 + 13 + agent = agent_fixture() 14 + seed = seed_fixture() 15 + 16 + deployment = 17 + deployment_fixture(%{ 18 + agent_id: agent.id, 19 + seeds: [seed], 20 + subscriptions: [] 21 + }) 22 + 23 + {:ok, show_live, html} = live(conn, ~p"/deployments/#{deployment.sid}") 24 + 25 + assert html =~ "Seeds" 26 + assert html =~ "#{seed.seed_type}/#{seed.name}" 27 + assert html =~ seed.artifact 28 + assert has_element?(show_live, "#seed-log-#{seed.sid} button", "View log") 29 + 30 + assert show_live |> element("#seed-log-#{seed.sid} button", "View log") |> render_click() 31 + 32 + log_url = ~p"/deployments/#{deployment.sid}/seeds/#{seed.sid}/log" 33 + assert has_element?(show_live, "#seed-log-#{seed.sid} iframe[src=\"#{log_url}\"]") 34 + refute has_element?(show_live, "#seed-log-frame-#{seed.sid}.hidden") 35 + 36 + assert has_element?( 37 + show_live, 38 + "#seed-log-#{seed.sid} a[href=\"#{log_url}\"]", 39 + "Open in new tab" 40 + ) 41 + 42 + assert show_live |> element("#seed-log-#{seed.sid} button", "Hide log") |> render_click() 43 + assert has_element?(show_live, "#seed-log-#{seed.sid} iframe[src=\"#{log_url}\"]") 44 + assert has_element?(show_live, "#seed-log-frame-#{seed.sid}.hidden") 45 + end 46 + 47 + test "shows empty logs state when deployment has no seeds", %{conn: conn, user: user} do 48 + Sower.Repo.put_org_id(user.org_id) 49 + 50 + agent = agent_fixture() 51 + 52 + deployment = 53 + deployment_fixture(%{ 54 + agent_id: agent.id, 55 + seeds: [], 56 + subscriptions: [] 57 + }) 58 + 59 + {:ok, _show_live, html} = live(conn, ~p"/deployments/#{deployment.sid}") 60 + 61 + assert html =~ "Seeds" 62 + assert html =~ "No seeds." 63 + end 64 + end
+1 -1
apps/sower_agent/lib/sower_agent/deployer.ex
··· 316 316 |> Enum.join("\n") 317 317 318 318 checksum_sha256 = :crypto.hash(:sha256, content) |> Base.encode64() 319 - object_path = "logs/deployments/#{deployment.sid}/seeds/#{seed.sid}.log" 319 + object_path = SeedDeployment.log_path(deployment.sid, seed.sid) 320 320 321 321 request = 322 322 PresignUploadRequest.cast!(%{
+4
apps/sower_client/lib/sower_client/orchestration/seed_deployment.ex
··· 1 1 defmodule SowerClient.Orchestration.SeedDeployment do 2 2 use SowerClient.Schema 3 3 4 + def log_path(deployment_sid, seed_sid) do 5 + "logs/deployments/#{deployment_sid}/seeds/#{seed_sid}.log" 6 + end 7 + 4 8 OpenApiSpex.schema(%{ 5 9 title: "SeedDeployment", 6 10 type: :object,
+10
apps/sower_client/test/sower_client/orchestration/seed_deployment_test.exs
··· 1 + defmodule SowerClient.Orchestration.SeedDeploymentTest do 2 + use ExUnit.Case, async: true 3 + 4 + alias SowerClient.Orchestration.SeedDeployment 5 + 6 + test "log_path/2 builds deterministic seed deployment log path" do 7 + assert SeedDeployment.log_path("deploy_123", "seed_456") == 8 + "logs/deployments/deploy_123/seeds/seed_456.log" 9 + end 10 + end