Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

agent/server: record individual seed deployment status and put log in db

+252 -690
+1
apps/sower/lib/sower/orchestration/deployment.ex
··· 27 27 28 28 many_to_many :subscriptions, Subscription, join_through: Orchestration.SubscriptionDeployment 29 29 30 + has_many :seed_deployments, Orchestration.SeedDeployment 30 31 many_to_many :seeds, Seed, join_through: Orchestration.SeedDeployment 31 32 32 33 field :deployed_at, :utc_datetime
+70 -4
apps/sower/lib/sower/orchestration/seed_deployment.ex
··· 1 1 defmodule Sower.Orchestration.SeedDeployment do 2 2 use Sower.Schema 3 3 import Ecto.Changeset 4 + import Ecto.Query, warn: false 5 + 6 + alias Sower.Repo 7 + alias Sower.Orchestration.{Deployment, Seed} 4 8 5 9 schema "seed_deployment" do 6 - field :seed_id, :id 7 - field :deployment_id, :id 10 + belongs_to :seed, Seed 11 + belongs_to :deployment, Deployment 12 + field :log, :string 13 + field :result, Ecto.Enum, values: [:success, :failure] 8 14 9 15 timestamps() 10 16 end ··· 12 18 @doc false 13 19 def changeset(seed_deployment, attrs) do 14 20 seed_deployment 15 - |> cast(attrs, []) 16 - |> validate_required([]) 21 + |> cast(attrs, [:log, :result]) 22 + end 23 + 24 + def record_seed_result( 25 + %SowerClient.Orchestration.SeedDeploymentResult{} = result, 26 + %Sower.Orchestration.Agent{} = agent 27 + ) do 28 + with {:ok, deployment} <- fetch_deployment(result.deployment_sid), 29 + :ok <- verify_ownership(deployment, agent), 30 + {:ok, seed_deployment} <- fetch_seed_deployment(deployment.id, result.seed_sid) do 31 + attrs = build_update_attrs(seed_deployment, result) 32 + 33 + with {:ok, _seed_deployment} <- 34 + seed_deployment 35 + |> changeset(attrs) 36 + |> Repo.update(skip_org_id: true) do 37 + {:ok, %{}} 38 + end 39 + end 40 + end 41 + 42 + defp build_update_attrs(seed_deployment, result) do 43 + log = append_log(seed_deployment.log, result.log) 44 + 45 + case result.result do 46 + nil -> %{log: log} 47 + result -> %{log: log, result: result} 48 + end 49 + end 50 + 51 + defp append_log(nil, ""), do: nil 52 + defp append_log(nil, new), do: new 53 + defp append_log(_existing, nil), do: nil 54 + defp append_log(existing, ""), do: existing 55 + defp append_log(existing, new), do: existing <> "\n" <> new 56 + 57 + defp fetch_deployment(deployment_sid) do 58 + case Repo.get_by(Deployment, sid: deployment_sid) do 59 + nil -> {:error, :deployment_not_found} 60 + deployment -> {:ok, deployment} 61 + end 62 + end 63 + 64 + defp verify_ownership(deployment, agent) do 65 + if deployment.agent_id == agent.id do 66 + :ok 67 + else 68 + {:error, :unauthorized} 69 + end 70 + end 71 + 72 + defp fetch_seed_deployment(deployment_id, seed_sid) do 73 + query = 74 + from sd in __MODULE__, 75 + join: s in Seed, 76 + on: s.id == sd.seed_id, 77 + where: sd.deployment_id == ^deployment_id and s.sid == ^seed_sid 78 + 79 + case Repo.one(query, skip_org_id: true) do 80 + nil -> {:error, :seed_not_in_deployment} 81 + seed_deployment -> {:ok, seed_deployment} 82 + end 17 83 end 18 84 end
-95
apps/sower/lib/sower/storage.ex
··· 1 1 defmodule Sower.Storage do 2 2 require Logger 3 3 4 - alias Sower.Orchestration 5 - alias Sower.Orchestration.Agent 6 - alias SowerClient.Storage.PresignedUploadReply 7 - alias SowerClient.Storage.DeploymentLogUploadRequest 8 - 9 - @doc """ 10 - Generates a presigned URL for deployment log upload with authorization checks. 11 - 12 - Validates that: 13 - 1. The deployment exists 14 - 2. The deployment belongs to the requesting agent 15 - 3. The seed is associated with the deployment 16 - 17 - Returns {:ok, PresignedUploadReply} on success, {:error, reason} on failure. 18 - """ 19 - def presign_deployment_log_upload( 20 - %Agent{} = agent, 21 - %DeploymentLogUploadRequest{} = request, 22 - presign_upload_fun \\ &presign_upload/2 23 - ) do 24 - with {:ok, deployment} <- fetch_deployment(request.deployment_sid), 25 - :ok <- verify_deployment_ownership(deployment, agent), 26 - :ok <- verify_seed_in_deployment(deployment, request.seed_sid), 27 - object_path = 28 - SowerClient.Orchestration.SeedDeployment.log_path( 29 - request.deployment_sid, 30 - request.seed_sid 31 - ), 32 - {:ok, url} <- presign_upload_fun.(object_path, presign_upload_opts(request)) do 33 - {:ok, 34 - PresignedUploadReply.cast!(%{ 35 - url: url, 36 - method: "PUT", 37 - headers: presign_upload_headers(request) 38 - })} 39 - else 40 - {:error, :deployment_not_found} -> 41 - {:error, :unauthorized} 42 - 43 - {:error, :unauthorized} -> 44 - {:error, :unauthorized} 45 - 46 - {:error, :seed_not_in_deployment} -> 47 - {:error, :seed_not_in_deployment} 48 - 49 - {:error, reason} -> 50 - Logger.error( 51 - msg: "Failed to presign deployment log upload", 52 - deployment_sid: request.deployment_sid, 53 - seed_sid: request.seed_sid, 54 - agent_sid: agent.sid, 55 - reason: inspect(reason) 56 - ) 57 - 58 - {:error, :failed_to_presign_upload} 59 - end 60 - end 61 - 62 - defp fetch_deployment(deployment_sid) do 63 - case Orchestration.get_deployment_sid(deployment_sid) do 64 - nil -> {:error, :deployment_not_found} 65 - deployment -> {:ok, Sower.Repo.preload(deployment, :seeds)} 66 - end 67 - end 68 - 69 - defp verify_deployment_ownership(deployment, agent) do 70 - if deployment.agent_id == agent.id do 71 - :ok 72 - else 73 - {:error, :unauthorized} 74 - end 75 - end 76 - 77 - defp verify_seed_in_deployment(deployment, seed_sid) do 78 - if Enum.any?(deployment.seeds, &(&1.sid == seed_sid)) do 79 - :ok 80 - else 81 - {:error, :seed_not_in_deployment} 82 - end 83 - end 84 - 85 4 def presign_upload(file, opts \\ []) do 86 5 bucket = get_in(config(), [:s3, :bucket]) 87 6 expires_in = Keyword.get(opts, :expires_in, 60 * 60) ··· 126 45 127 46 :error -> 128 47 [] 129 - end 130 - end 131 - 132 - defp presign_upload_opts(%DeploymentLogUploadRequest{} = request) do 133 - case request.checksum_sha256 do 134 - nil -> [] 135 - checksum -> [checksum_sha256: checksum] 136 - end 137 - end 138 - 139 - defp presign_upload_headers(%DeploymentLogUploadRequest{} = request) do 140 - case request.checksum_sha256 do 141 - nil -> %{} 142 - checksum -> %{"x-amz-checksum-sha256" => checksum} 143 48 end 144 49 end 145 50
+9 -2
apps/sower/lib/sower_web/agent_channel.ex
··· 135 135 &Sower.Orchestration.record_deployment/1 136 136 ) 137 137 138 + handle_schema(SowerClient.Orchestration.SeedDeploymentResult, fn req, socket -> 139 + Sower.Orchestration.SeedDeployment.record_seed_result(req, socket.assigns.agent) 140 + end) 141 + 138 142 handle_schema(SowerClient.Orchestration.AgentSeedsReport, fn report, socket -> 139 143 Orchestration.update_agent_seed_generations(report, socket.assigns.agent) 140 144 end) 141 145 142 - handle_schema(SowerClient.Storage.DeploymentLogUploadRequest, fn req, socket -> 143 - Sower.Storage.presign_deployment_log_upload(socket.assigns.agent, req) 146 + # Kept for backward compatibility with old agents that still upload logs to S3. 147 + # New agents send SeedDeploymentResult instead. 148 + # remove 0.7.0 149 + handle_schema(SowerClient.Storage.DeploymentLogUploadRequest, fn _req, _socket -> 150 + {:error, :deprecated} 144 151 end) 145 152 146 153 @impl Phoenix.Channel
-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
+28 -51
apps/sower/lib/sower_web/live/deployment_live/show.ex
··· 66 66 67 67 <section> 68 68 <h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-200 mb-4">Seeds</h2> 69 - <div :if={@deployment.seeds != []} class="space-y-4"> 69 + <div :if={@deployment.seed_deployments != []} class="space-y-4"> 70 70 <article 71 - :for={seed <- @deployment.seeds} 72 - id={"seed-log-#{seed.sid}"} 71 + :for={sd <- @deployment.seed_deployments} 72 + id={"seed-log-#{sd.seed.sid}"} 73 73 class="rounded-lg border border-zinc-200/50 dark:border-zinc-700/50 p-4" 74 74 > 75 75 <div class="flex items-center justify-between gap-4"> 76 76 <div class="text-sm font-semibold text-zinc-900 dark:text-zinc-200 flex flex-wrap items-center gap-2"> 77 77 <.link 78 - navigate={~p"/seeds/#{seed.sid}"} 78 + navigate={~p"/seeds/#{sd.seed.sid}"} 79 79 class="hover:text-orange-500 dark:hover:text-orange-400" 80 80 > 81 - {seed.seed_type}/{seed.name} 81 + {sd.seed.seed_type}/{sd.seed.name} 82 82 </.link> 83 83 <span class="text-xs font-normal text-zinc-500 dark:text-zinc-400"> 84 - {seed.artifact} 84 + {sd.seed.artifact} 85 85 </span> 86 86 </div> 87 87 <div class="flex items-center gap-2"> 88 - <.link 89 - :if={expanded_seed_log?(@expanded_seed_logs, seed.sid)} 90 - href={seed_log_url(@deployment.sid, seed.sid)} 91 - target="_blank" 92 - rel="noopener noreferrer" 93 - class="text-sm text-orange-600 hover:text-orange-500 dark:text-orange-400 dark:hover:text-orange-300" 94 - > 95 - Open in new tab 96 - </.link> 88 + <.deployment_status 89 + :if={sd.result} 90 + state={:completed} 91 + result={sd.result} 92 + /> 97 93 <button 94 + :if={sd.log} 98 95 type="button" 99 96 phx-click="toggle_seed_log" 100 - phx-value-seed_sid={seed.sid} 97 + phx-value-seed_sid={sd.seed.sid} 101 98 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" 102 99 > 103 - {if expanded_seed_log?(@expanded_seed_logs, seed.sid), 100 + {if expanded_seed_log?(@expanded_seed_logs, sd.seed.sid), 104 101 do: "Hide log", 105 102 else: "View log"} 106 103 </button> 107 104 </div> 108 105 </div> 109 - <div 110 - :if={loaded_seed_log?(@loaded_seed_logs, seed.sid)} 111 - id={"seed-log-frame-#{seed.sid}"} 112 - class={["mt-3", !expanded_seed_log?(@expanded_seed_logs, seed.sid) && "hidden"]} 113 - > 114 - <iframe 115 - src={seed_log_url(@deployment.sid, seed.sid)} 116 - class="w-full h-80 rounded-md border border-zinc-200 dark:border-zinc-700 bg-white dark:bg-zinc-300 dark:invert" 117 - title={"Seed deployment log #{seed.sid}"} 118 - /> 119 - </div> 106 + <pre 107 + :if={sd.log && expanded_seed_log?(@expanded_seed_logs, sd.seed.sid)} 108 + id={"seed-log-content-#{sd.seed.sid}"} 109 + class="mt-3 p-3 rounded-md border border-zinc-200 dark:border-zinc-700 bg-zinc-50 dark:bg-zinc-800 text-xs text-zinc-800 dark:text-zinc-200 overflow-x-auto whitespace-pre-wrap" 110 + >{sd.log}</pre> 120 111 </article> 121 112 </div> 122 113 <p 123 - :if={@deployment.seeds == []} 114 + :if={@deployment.seed_deployments == []} 124 115 class="text-sm text-zinc-500 dark:text-zinc-400 italic" 125 116 > 126 117 No seeds. ··· 154 145 |> redirect(to: ~p"/deployments")} 155 146 156 147 deployment -> 157 - deployment = Sower.Repo.preload(deployment, [:seeds, :subscriptions, :agent]) 148 + deployment = Sower.Repo.preload(deployment, [seed_deployments: :seed, subscriptions: [], agent: []]) 158 149 159 150 {:noreply, 160 151 socket 161 152 |> assign(:deployment, deployment) 162 - |> assign(:expanded_seed_logs, MapSet.new()) 163 - |> assign(:loaded_seed_logs, MapSet.new())} 153 + |> assign(:expanded_seed_logs, MapSet.new())} 164 154 end 165 155 end 166 156 ··· 171 161 {:noreply, socket} 172 162 173 163 deployment -> 174 - deployment = Sower.Repo.preload(deployment, [:seeds, :subscriptions, :agent]) 164 + deployment = Sower.Repo.preload(deployment, [seed_deployments: :seed, subscriptions: [], agent: []]) 175 165 {:noreply, assign(socket, :deployment, deployment)} 176 166 end 177 167 end ··· 179 169 @impl true 180 170 def handle_event("toggle_seed_log", %{"seed_sid" => seed_sid}, socket) do 181 171 expanded_seed_logs = socket.assigns.expanded_seed_logs 182 - loaded_seed_logs = socket.assigns.loaded_seed_logs 183 172 184 - {expanded_seed_logs, loaded_seed_logs} = 185 - if expanded_seed_log?(socket.assigns.expanded_seed_logs, seed_sid) do 186 - {MapSet.delete(expanded_seed_logs, seed_sid), loaded_seed_logs} 173 + expanded_seed_logs = 174 + if MapSet.member?(expanded_seed_logs, seed_sid) do 175 + MapSet.delete(expanded_seed_logs, seed_sid) 187 176 else 188 - {MapSet.put(expanded_seed_logs, seed_sid), MapSet.put(loaded_seed_logs, seed_sid)} 177 + MapSet.put(expanded_seed_logs, seed_sid) 189 178 end 190 179 191 - {:noreply, 192 - socket 193 - |> assign(:expanded_seed_logs, expanded_seed_logs) 194 - |> assign(:loaded_seed_logs, loaded_seed_logs)} 180 + {:noreply, assign(socket, :expanded_seed_logs, expanded_seed_logs)} 195 181 end 196 182 197 183 def handle_event("retry", _params, socket) do ··· 228 214 MapSet.member?(expanded_seed_logs, seed_sid) 229 215 end 230 216 231 - defp loaded_seed_log?(loaded_seed_logs, seed_sid) do 232 - MapSet.member?(loaded_seed_logs, seed_sid) 233 - end 234 - 235 - defp seed_log_url(deployment_sid, seed_sid) do 236 - ~p"/deployments/#{deployment_sid}/seeds/#{seed_sid}/log" 237 - end 238 - 239 217 defp retryable?(deployment) do 240 218 deployment.state in [:completed, :stale] 241 219 end ··· 244 222 socket 245 223 |> assign(:page_title, "Show Deployment") 246 224 |> assign(:expanded_seed_logs, MapSet.new()) 247 - |> assign(:loaded_seed_logs, MapSet.new()) 248 225 |> assign(:retrying, false) 249 226 end 250 227 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 - 45 43 live_session :authenticated, on_mount: [{SowerWeb.UserAuth, :ensure_authenticated}] do 46 44 live "/agents", AgentLive.Index, :index 47 45 live "/agents/new", AgentLive.Index, :new
+10
apps/sower/priv/repo/migrations/20260307205843_add_log_and_result_to_seed_deployment.exs
··· 1 + defmodule Sower.Repo.Migrations.AddLogAndResultToSeedDeployment do 2 + use Ecto.Migration 3 + 4 + def change do 5 + alter table(:seed_deployment) do 6 + add :log, :text 7 + add :result, :string 8 + end 9 + end 10 + end
-107
apps/sower/test/sower/storage_test.exs
··· 1 - defmodule Sower.StorageTest do 2 - use Sower.DataCase 3 - 4 - alias Sower.Storage 5 - alias SowerClient.Storage.DeploymentLogUploadRequest 6 - alias SowerClient.Storage.PresignedUploadReply 7 - 8 - import Sower.AccountsFixtures 9 - import Sower.OrchestrationFixtures 10 - import Sower.SeedFixtures 11 - 12 - setup do 13 - org = organization_fixture() 14 - Sower.Repo.put_org_id(org.org_id) 15 - 16 - %{organization: org} 17 - end 18 - 19 - describe "presign_deployment_log_upload/2" do 20 - setup %{organization: org} do 21 - agent = agent_fixture(%{org_id: org.org_id}) 22 - other_agent = agent_fixture(%{org_id: org.org_id, name: "other agent"}) 23 - 24 - seed = seed_fixture(%{org_id: org.org_id}) 25 - 26 - deployment = 27 - deployment_fixture(%{ 28 - org_id: org.org_id, 29 - agent_id: agent.id, 30 - seeds: [seed], 31 - subscriptions: [] 32 - }) 33 - 34 - deployment = Sower.Repo.preload(deployment, :seeds) 35 - 36 - %{agent: agent, other_agent: other_agent, deployment: deployment, seed: seed} 37 - end 38 - 39 - test "returns presigned upload URL for valid request", %{ 40 - agent: agent, 41 - deployment: deployment, 42 - seed: seed 43 - } do 44 - request = %DeploymentLogUploadRequest{ 45 - deployment_sid: deployment.sid, 46 - seed_sid: seed.sid, 47 - checksum_sha256: nil 48 - } 49 - 50 - # Mock the presign_upload function to avoid S3 dependency 51 - mock_presign = fn path, _opts -> 52 - {:ok, "https://mock-s3.example.com/#{path}?signed=true"} 53 - end 54 - 55 - assert {:ok, %PresignedUploadReply{} = reply} = 56 - Storage.presign_deployment_log_upload(agent, request, mock_presign) 57 - 58 - assert reply.url == 59 - "https://mock-s3.example.com/logs/deployments/#{deployment.sid}/seeds/#{seed.sid}.log?signed=true" 60 - 61 - assert reply.method == "PUT" 62 - assert reply.headers == %{} 63 - end 64 - 65 - test "returns error for non-existent deployment", %{agent: agent} do 66 - request = %DeploymentLogUploadRequest{ 67 - deployment_sid: "nonexistent_deploy", 68 - seed_sid: "some_seed", 69 - checksum_sha256: nil 70 - } 71 - 72 - assert {:error, :unauthorized} = 73 - Storage.presign_deployment_log_upload(agent, request) 74 - end 75 - 76 - test "returns unauthorized error for deployment owned by different agent", %{ 77 - other_agent: other_agent, 78 - deployment: deployment, 79 - seed: seed 80 - } do 81 - request = %DeploymentLogUploadRequest{ 82 - deployment_sid: deployment.sid, 83 - seed_sid: seed.sid, 84 - checksum_sha256: nil 85 - } 86 - 87 - assert {:error, :unauthorized} = 88 - Storage.presign_deployment_log_upload(other_agent, request) 89 - end 90 - 91 - test "returns error when seed is not associated with deployment", %{ 92 - agent: agent, 93 - deployment: deployment 94 - } do 95 - other_seed = seed_fixture(%{org_id: agent.org_id, name: "other_seed"}) 96 - 97 - request = %DeploymentLogUploadRequest{ 98 - deployment_sid: deployment.sid, 99 - seed_sid: other_seed.sid, 100 - checksum_sha256: nil 101 - } 102 - 103 - assert {:error, :seed_not_in_deployment} = 104 - Storage.presign_deployment_log_upload(agent, request) 105 - end 106 - end 107 - end
-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
+15 -12
apps/sower/test/sower_web/live/deployment_live_show_test.exs
··· 23 23 subscriptions: [] 24 24 }) 25 25 26 + # Write a log to the seed_deployment 27 + seed_deployment = 28 + Sower.Repo.get_by!(Sower.Orchestration.SeedDeployment, 29 + [deployment_id: deployment.id, seed_id: seed.id], 30 + skip_org_id: true 31 + ) 32 + 33 + seed_deployment 34 + |> Ecto.Changeset.change(%{log: "test log output", result: :success}) 35 + |> Sower.Repo.update!(skip_org_id: true) 36 + 26 37 {:ok, show_live, html} = live(conn, ~p"/deployments/#{deployment.sid}") 27 38 28 39 assert html =~ "Seeds" ··· 32 43 33 44 assert show_live |> element("#seed-log-#{seed.sid} button", "View log") |> render_click() 34 45 35 - log_url = ~p"/deployments/#{deployment.sid}/seeds/#{seed.sid}/log" 36 - assert has_element?(show_live, "#seed-log-#{seed.sid} iframe[src=\"#{log_url}\"]") 37 - refute has_element?(show_live, "#seed-log-frame-#{seed.sid}.hidden") 38 - 39 - assert has_element?( 40 - show_live, 41 - "#seed-log-#{seed.sid} a[href=\"#{log_url}\"]", 42 - "Open in new tab" 43 - ) 46 + assert has_element?(show_live, "#seed-log-content-#{seed.sid}") 47 + assert render(show_live) =~ "test log output" 44 48 45 49 assert show_live |> element("#seed-log-#{seed.sid} button", "Hide log") |> render_click() 46 - assert has_element?(show_live, "#seed-log-#{seed.sid} iframe[src=\"#{log_url}\"]") 47 - assert has_element?(show_live, "#seed-log-frame-#{seed.sid}.hidden") 50 + refute has_element?(show_live, "#seed-log-content-#{seed.sid}") 48 51 end 49 52 50 - test "shows empty logs state when deployment has no seeds", %{conn: conn, user: user} do 53 + test "shows empty state when deployment has no seeds", %{conn: conn, user: user} do 51 54 Sower.Repo.put_org_id(user.org_id) 52 55 53 56 agent = agent_fixture()
+29 -100
apps/sower_agent/lib/sower_agent/deployer.ex
··· 8 8 alias SowerClient.Orchestration.Deployment 9 9 alias SowerClient.Orchestration.DeploymentProfile 10 10 alias SowerClient.Orchestration.SeedDeployment 11 - alias SowerClient.Storage.PresignedUploadReply 12 - alias SowerClient.Storage.DeploymentLogUploadRequest 11 + alias SowerClient.Orchestration.SeedDeploymentResult 13 12 14 13 def run(%Deployment{} = deployment) do 15 14 run_with_opts(deployment, upgrade_opts: [], reboot_opts: []) ··· 18 17 def run_with_opts(%Deployment{} = deployment, opts) do 19 18 upgrade_opts = Keyword.get(opts, :upgrade_opts, []) 20 19 reboot_opts = Keyword.get(opts, :reboot_opts, []) 21 - write_log_fun = Keyword.get(upgrade_opts, :write_log_fun, &maybe_write_log/3) 20 + 21 + report_seed_result_fun = 22 + Keyword.get(upgrade_opts, :report_seed_result_fun, &report_seed_result/4) 22 23 23 24 result = 24 25 deployment 25 26 |> upgrade(upgrade_opts) 26 27 |> deployment_result() 27 28 28 - maybe_reboot(deployment, result, [{:write_log_fun, write_log_fun} | reboot_opts]) 29 + maybe_reboot(deployment, result, [{:report_seed_result_fun, report_seed_result_fun} | reboot_opts]) 29 30 result 30 31 end 31 32 ··· 66 67 Keyword.get(opts, :get_deployment_profile_fun, &get_deployment_profile/1) 67 68 68 69 activate_seed_fun = Keyword.get(opts, :activate_seed_fun, &SowerAgent.Seed.activate/2) 69 - write_log_fun = Keyword.get(opts, :write_log_fun, &maybe_write_log/3) 70 + 71 + report_seed_result_fun = 72 + Keyword.get(opts, :report_seed_result_fun, &report_seed_result/4) 70 73 71 74 async_stream_fun.(deployment.seed_deployments, fn %{seed: seed} = seed_deploy -> 72 75 Logger.debug( ··· 109 112 seed_sid: seed.sid 110 113 ) 111 114 112 - write_log_fun.(deployment, seed, preamble ++ output) 115 + report_seed_result_fun.(deployment, seed, :success, preamble ++ output) 113 116 114 117 {:error, _code, output} -> 115 118 Logger.error( ··· 118 121 seed_sid: seed.sid 119 122 ) 120 123 121 - write_log_fun.(deployment, seed, preamble ++ output) 124 + report_seed_result_fun.(deployment, seed, :failure, preamble ++ output) 122 125 123 126 {:error, reason} when reason in [:activator_unavailable, :cmd_not_found] -> 124 127 Logger.error( ··· 128 131 reason: inspect(reason) 129 132 ) 130 133 131 - write_log_fun.( 134 + report_seed_result_fun.( 132 135 deployment, 133 136 seed, 137 + :failure, 134 138 preamble ++ 135 139 [ 136 140 "FATAL: missing activator executable sower-activator; deployment cannot continue" ··· 144 148 result 145 149 146 150 {:ok, {:error, :failed_to_realize, %SeedDeployment{seed: seed} = _seed_deploy}} -> 147 - write_log_fun.(deployment, seed, [ 151 + report_seed_result_fun.(deployment, seed, :failure, [ 148 152 decision_line("realization failed for #{seed.name} (#{seed.seed_type})") 149 153 ]) 150 154 ··· 345 349 end 346 350 347 351 defp write_reboot_decision(%Deployment{} = deployment, opts, message) do 348 - write_log_fun = Keyword.get(opts, :write_log_fun, &maybe_write_log/3) 352 + report_seed_result_fun = 353 + Keyword.get(opts, :report_seed_result_fun, &report_seed_result/4) 349 354 350 355 last_seed = 351 356 deployment.seed_deployments ··· 353 358 |> List.last() 354 359 355 360 if last_seed do 356 - write_log_fun.(deployment, last_seed, [decision_line(message)]) 361 + report_seed_result_fun.(deployment, last_seed, nil, [decision_line(message)]) 357 362 end 358 363 end 359 364 ··· 396 401 end 397 402 end 398 403 399 - defp maybe_write_log(_deployment, _seed, []), do: :ok 400 - 401 - defp maybe_write_log(%Deployment{} = deployment, seed, output_lines) do 402 - content = 404 + defp report_seed_result(%Deployment{} = deployment, seed, result, output_lines) do 405 + log = 403 406 output_lines 404 407 |> Enum.reject(&is_nil/1) 405 408 |> Enum.map(&strip_ansi/1) 406 409 |> Enum.join("\n") 407 410 408 - checksum_sha256 = :crypto.hash(:sha256, content) |> Base.encode64() 409 - object_path = SeedDeployment.log_path(deployment.sid, seed.sid) 410 - 411 - request = 412 - DeploymentLogUploadRequest.cast!(%{ 411 + seed_result = 412 + SeedDeploymentResult.cast!(%{ 413 413 deployment_sid: deployment.sid, 414 414 seed_sid: seed.sid, 415 - checksum_sha256: checksum_sha256 415 + result: result, 416 + log: log 416 417 }) 417 418 418 - case Client.call(DeploymentLogUploadRequest.event(), request, 15_000) do 419 - {:ok, reply_payload} -> 420 - case PresignedUploadReply.cast(reply_payload) do 421 - {:ok, reply} -> 422 - upload_deployment_log(reply, content, deployment.sid, seed.sid, object_path) 419 + case Client.call(SeedDeploymentResult.event(), seed_result, 15_000) do 420 + :ok -> 421 + :ok 423 422 424 - {:error, error} -> 425 - Logger.error( 426 - msg: "Failed to parse deployment log upload reply", 427 - deployment_sid: deployment.sid, 428 - seed_sid: seed.sid, 429 - object_path: object_path, 430 - error: inspect(error) 431 - ) 432 - end 423 + {:ok, _reply} -> 424 + :ok 433 425 434 426 {:error, error} -> 435 427 Logger.error( 436 - msg: "Failed to request deployment log upload URL", 428 + msg: "Failed to report seed deployment result", 437 429 deployment_sid: deployment.sid, 438 430 seed_sid: seed.sid, 439 - object_path: object_path, 440 - error: inspect(error) 441 - ) 442 - end 443 - end 444 - 445 - defp upload_deployment_log( 446 - %PresignedUploadReply{} = reply, 447 - content, 448 - deployment_sid, 449 - seed_sid, 450 - object_path 451 - ) do 452 - case String.upcase(reply.method || "") do 453 - "PUT" -> 454 - do_upload_deployment_log(reply, content, deployment_sid, seed_sid, object_path) 455 - 456 - method -> 457 - Logger.error( 458 - msg: "Unsupported presign upload method", 459 - deployment_sid: deployment_sid, 460 - seed_sid: seed_sid, 461 - object_path: object_path, 462 - method: method 463 - ) 464 - end 465 - end 466 - 467 - defp do_upload_deployment_log( 468 - %PresignedUploadReply{} = reply, 469 - content, 470 - deployment_sid, 471 - seed_sid, 472 - object_path 473 - ) do 474 - headers = 475 - (reply.headers || %{}) 476 - |> Enum.to_list() 477 - 478 - case Req.put(url: reply.url, headers: headers, body: content, retry: false) do 479 - {:ok, %Req.Response{status: status}} when status in 200..299 -> 480 - Logger.info( 481 - msg: "Uploaded deployment log", 482 - deployment_sid: deployment_sid, 483 - seed_sid: seed_sid, 484 - object_path: object_path 485 - ) 486 - 487 - {:ok, %Req.Response{status: status, body: body}} -> 488 - Logger.error( 489 - msg: "Failed to upload deployment log", 490 - deployment_sid: deployment_sid, 491 - seed_sid: seed_sid, 492 - object_path: object_path, 493 - status: status, 494 - body: inspect(body) 495 - ) 496 - 497 - {:error, error} -> 498 - Logger.error( 499 - msg: "Failed to upload deployment log", 500 - deployment_sid: deployment_sid, 501 - seed_sid: seed_sid, 502 - object_path: object_path, 431 + result: result, 503 432 error: inspect(error) 504 433 ) 505 434 end
+48 -136
apps/sower_agent/test/sower_agent/deployer_test.exs
··· 138 138 {:ok, []} 139 139 end, 140 140 activation_enabled_fun: fn -> true end, 141 - write_log_fun: fn _, _, _ -> :ok end 141 + report_seed_result_fun: fn _, _, _, _ -> :ok end 142 142 ) == :ok 143 143 144 144 refute_received :reboot_reason_called ··· 155 155 end, 156 156 reboot_fun: fn _ -> flunk("reboot should not be requested") end, 157 157 activation_enabled_fun: fn -> true end, 158 - write_log_fun: fn _, _, _ -> :ok end 158 + report_seed_result_fun: fn _, _, _, _ -> :ok end 159 159 ) == :ok 160 160 161 161 assert_received :reboot_reason_called ··· 174 174 {:ok, ["ok"]} 175 175 end, 176 176 activation_enabled_fun: fn -> true end, 177 - write_log_fun: fn _, _, _ -> :ok end 177 + report_seed_result_fun: fn _, _, _, _ -> :ok end 178 178 ) == :ok 179 179 180 180 assert_received {:reboot_called, [reason: "policy_always"]} ··· 267 267 end 268 268 269 269 describe "upgrade/2" do 270 - test "writes a fatal deployment log line when activator is unavailable" do 270 + test "reports failure with fatal log line when activator is unavailable" do 271 271 deployment = %Deployment{ 272 272 sid: "dep_1", 273 273 seed_deployments: [seed_deploy_with_identity("seed_1")] 274 274 } 275 + 276 + test_pid = self() 275 277 276 278 logs = 277 279 capture_log(fn -> ··· 285 287 realize_seed_fun: fn seed_deploy -> {:ok, seed_deploy} end, 286 288 get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 287 289 activate_seed_fun: fn _seed, _profile -> {:error, :activator_unavailable} end, 288 - write_log_fun: fn _deployment, _seed, output_lines -> 289 - Logger.error( 290 - msg: "Deployment log payload", 291 - output: Enum.join(output_lines, "\n") 292 - ) 290 + report_seed_result_fun: fn _deployment, _seed, result, output_lines -> 291 + send(test_pid, {:seed_result, result, output_lines}) 293 292 end 294 293 ) 295 294 end) 296 295 297 296 assert logs =~ "Missing activator during deployment activation" 298 297 299 - assert logs =~ 300 - "FATAL: missing activator executable sower-activator; deployment cannot continue" 298 + assert_received {:seed_result, :failure, lines} 299 + assert Enum.any?(lines, &(&1 =~ "FATAL: missing activator executable sower-activator")) 301 300 end 302 301 303 - test "keeps successful deployment logging behavior unchanged" do 302 + test "reports success for successful activation" do 304 303 deployment = %Deployment{ 305 304 sid: "dep_2", 306 305 seed_deployments: [seed_deploy_with_identity("seed_2")] 307 306 } 308 307 308 + test_pid = self() 309 + 309 310 capture_log(fn -> 310 311 assert [ 311 312 {:ok, {:ok, ["activation complete"]}} ··· 317 318 realize_seed_fun: fn seed_deploy -> {:ok, seed_deploy} end, 318 319 get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 319 320 activate_seed_fun: fn _seed, _profile -> {:ok, ["activation complete"]} end, 320 - write_log_fun: fn _deployment, _seed, output_lines -> 321 - Logger.info( 322 - msg: "Deployment log payload", 323 - output: Enum.join(output_lines, "\n") 324 - ) 321 + report_seed_result_fun: fn _deployment, _seed, result, _output_lines -> 322 + send(test_pid, {:seed_result, result}) 325 323 end 326 324 ) 327 325 end) 326 + 327 + assert_received {:seed_result, :success} 328 328 end 329 329 330 - test "writes a fatal deployment log line when activator executable is missing" do 330 + test "reports failure with fatal log line when activator executable is missing" do 331 331 deployment = %Deployment{ 332 332 sid: "dep_3", 333 333 seed_deployments: [seed_deploy_with_identity("seed_3")] 334 334 } 335 + 336 + test_pid = self() 335 337 336 338 logs = 337 339 capture_log(fn -> ··· 345 347 realize_seed_fun: fn seed_deploy -> {:ok, seed_deploy} end, 346 348 get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 347 349 activate_seed_fun: fn _seed, _profile -> {:error, :cmd_not_found} end, 348 - write_log_fun: fn _deployment, _seed, output_lines -> 349 - Logger.error( 350 - msg: "Deployment log payload", 351 - output: Enum.join(output_lines, "\n") 352 - ) 350 + report_seed_result_fun: fn _deployment, _seed, result, output_lines -> 351 + send(test_pid, {:seed_result, result, output_lines}) 353 352 end 354 353 ) 355 354 end) ··· 357 356 assert logs =~ "Missing activator during deployment activation" 358 357 assert logs =~ "cmd_not_found" 359 358 360 - assert logs =~ 361 - "FATAL: missing activator executable sower-activator; deployment cannot continue" 359 + assert_received {:seed_result, :failure, lines} 360 + assert Enum.any?(lines, &(&1 =~ "FATAL: missing activator executable sower-activator")) 362 361 end 363 362 end 364 363 ··· 375 374 seed_deployments: [seed_deploy_with_identity("seed_r1")] 376 375 } 377 376 378 - logged_lines = capture_log_lines(deployment) 377 + logged_lines = capture_seed_result_lines(deployment) 379 378 380 379 assert Enum.any?( 381 380 logged_lines, ··· 390 389 } 391 390 392 391 logged_lines = 393 - capture_log_lines(deployment, 392 + capture_seed_result_lines(deployment, 394 393 realize_seed_fun: fn seed_deploy -> {:error, :failed_to_realize, seed_deploy} end 395 394 ) 396 395 ··· 404 403 } 405 404 406 405 logged_lines = 407 - capture_log_lines(deployment, 406 + capture_seed_result_lines(deployment, 408 407 get_deployment_profile_fun: fn _ -> 409 408 %DeploymentProfile{activation_args: ["boot"]} 410 409 end ··· 413 412 assert Enum.any?(logged_lines, &(&1 =~ "[sower]" and &1 =~ "boot" and &1 =~ "seed-seed_m1")) 414 413 end 415 414 416 - test "includes reboot triggered decision line" do 415 + test "includes reboot decision in last seed log" do 417 416 deployment = %Deployment{ 418 417 sid: "dep_reboot_log", 419 418 seed_deployments: [seed_deploy_with_identity("seed_rb1")] ··· 432 431 %DeploymentProfile{reboot_policy: "always"} 433 432 end, 434 433 activate_seed_fun: fn _seed, _profile -> {:ok, ["ok"]} end, 435 - write_log_fun: fn _deployment, _seed, output_lines -> 436 - send(test_pid, {:log_lines, output_lines}) 434 + report_seed_result_fun: fn _deployment, _seed, result, output_lines -> 435 + send(test_pid, {:seed_result, result, output_lines}) 437 436 end 438 437 ], 439 438 reboot_opts: [ ··· 444 443 ) 445 444 end) 446 445 447 - # Collect all messages 448 - lines = collect_log_lines() 449 - all_lines = List.flatten(lines) 450 - 451 - assert Enum.any?(all_lines, &(&1 =~ "[sower]" and &1 =~ "reboot" and &1 =~ "policy_always")) 446 + # First call: activation result 447 + assert_received {:seed_result, :success, _activation_lines} 448 + # Second call: reboot decision appended to last seed 449 + assert_received {:seed_result, nil, reboot_lines} 450 + assert Enum.any?(reboot_lines, &(&1 =~ "[sower]" and &1 =~ "reboot initiated: policy_always")) 452 451 end 453 452 454 - test "includes reboot skipped decision line for failed deployment" do 453 + test "includes reboot skipped in last seed log for failed deployment" do 455 454 deployment = %Deployment{ 456 455 sid: "dep_reboot_skip", 457 456 seed_deployments: [seed_deploy_with_identity("seed_rs1")] ··· 468 467 realize_seed_fun: fn sd -> {:ok, sd} end, 469 468 get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 470 469 activate_seed_fun: fn _seed, _profile -> {:error, 1, ["failed"]} end, 471 - write_log_fun: fn _deployment, _seed, output_lines -> 472 - send(test_pid, {:log_lines, output_lines}) 473 - end 474 - ], 475 - reboot_opts: [] 476 - ) 477 - end) 478 - 479 - lines = collect_log_lines() 480 - all_lines = List.flatten(lines) 481 - 482 - assert Enum.any?(all_lines, &(&1 =~ "[sower]" and &1 =~ "reboot" and &1 =~ "skipped")) 483 - end 484 - 485 - test "includes reboot skipped decision line for non-nixos deployment" do 486 - deployment = %Deployment{ 487 - sid: "dep_reboot_nonnix", 488 - seed_deployments: [ 489 - %SeedDeployment{ 490 - subscription_sid: "sub_nonnix", 491 - seed: %Seed{ 492 - sid: "seed_nonnix", 493 - name: "my-service", 494 - seed_type: "service", 495 - artifact: "/nix/store/nonnix" 496 - } 497 - } 498 - ] 499 - } 500 - 501 - test_pid = self() 502 - 503 - capture_log(fn -> 504 - Deployer.run_with_opts(deployment, 505 - upgrade_opts: [ 506 - async_stream_fun: fn enumerable, func -> 507 - Enum.map(enumerable, fn item -> {:ok, func.(item)} end) 508 - end, 509 - realize_seed_fun: fn sd -> {:ok, sd} end, 510 - get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 511 - activate_seed_fun: fn _seed, _profile -> {:ok, ["ok"]} end, 512 - write_log_fun: fn _deployment, _seed, output_lines -> 513 - send(test_pid, {:log_lines, output_lines}) 470 + report_seed_result_fun: fn _deployment, _seed, result, output_lines -> 471 + send(test_pid, {:seed_result, result, output_lines}) 514 472 end 515 473 ], 516 474 reboot_opts: [] 517 475 ) 518 476 end) 519 477 520 - lines = collect_log_lines() 521 - all_lines = List.flatten(lines) 522 - 523 - assert Enum.any?(all_lines, &(&1 =~ "[sower]" and &1 =~ "no NixOS seeds")) 524 - end 525 - 526 - test "includes no reboot needed decision line" do 527 - deployment = %Deployment{ 528 - sid: "dep_reboot_none", 529 - seed_deployments: [seed_deploy_with_identity("seed_rn1")] 530 - } 531 - 532 - test_pid = self() 533 - 534 - capture_log(fn -> 535 - Deployer.run_with_opts(deployment, 536 - upgrade_opts: [ 537 - async_stream_fun: fn enumerable, func -> 538 - Enum.map(enumerable, fn item -> {:ok, func.(item)} end) 539 - end, 540 - realize_seed_fun: fn sd -> {:ok, sd} end, 541 - get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 542 - activate_seed_fun: fn _seed, _profile -> {:ok, ["ok"]} end, 543 - write_log_fun: fn _deployment, _seed, output_lines -> 544 - send(test_pid, {:log_lines, output_lines}) 545 - end 546 - ], 547 - reboot_opts: [ 548 - reboot_reason_fun: fn _ -> nil end, 549 - reboot_fun: fn _opts -> flunk("should not reboot") end, 550 - activation_enabled_fun: fn -> true end 551 - ] 552 - ) 553 - end) 554 - 555 - lines = collect_log_lines() 556 - all_lines = List.flatten(lines) 557 - 558 - assert Enum.any?(all_lines, &(&1 =~ "[sower]" and &1 =~ "no reboot required")) 478 + # First call: activation result 479 + assert_received {:seed_result, :failure, _activation_lines} 480 + # Second call: reboot decision 481 + assert_received {:seed_result, nil, reboot_lines} 482 + assert Enum.any?(reboot_lines, &(&1 =~ "[sower]" and &1 =~ "reboot skipped")) 559 483 end 560 484 561 485 test "includes default activation mode when none configured" do ··· 564 488 seed_deployments: [seed_deploy_with_identity("seed_md1")] 565 489 } 566 490 567 - logged_lines = capture_log_lines(deployment) 491 + logged_lines = capture_seed_result_lines(deployment) 568 492 569 493 assert Enum.any?( 570 494 logged_lines, ··· 573 497 end 574 498 end 575 499 576 - defp capture_log_lines(%Deployment{} = deployment, opts \\ []) do 500 + defp capture_seed_result_lines(%Deployment{} = deployment, opts \\ []) do 577 501 test_pid = self() 578 502 579 503 capture_log(fn -> ··· 588 512 Keyword.get(opts, :activate_seed_fun, fn _seed, _profile -> 589 513 {:ok, ["activation output"]} 590 514 end), 591 - write_log_fun: fn _deployment, _seed, output_lines -> 592 - send(test_pid, {:log_lines, output_lines}) 515 + report_seed_result_fun: fn _deployment, _seed, _result, output_lines -> 516 + send(test_pid, {:seed_result_lines, output_lines}) 593 517 end 594 518 ) 595 519 end) 596 520 597 521 receive do 598 - {:log_lines, lines} -> lines 522 + {:seed_result_lines, lines} -> lines 599 523 after 600 524 1000 -> [] 601 - end 602 - end 603 - 604 - defp collect_log_lines do 605 - collect_log_lines([]) 606 - end 607 - 608 - defp collect_log_lines(acc) do 609 - receive do 610 - {:log_lines, lines} -> collect_log_lines([lines | acc]) 611 - after 612 - 100 -> Enum.reverse(acc) 613 525 end 614 526 end 615 527
+1
apps/sower_client/lib/sower_client.ex
··· 19 19 SowerClient.Orchestration.DeploymentResult, 20 20 SowerClient.Orchestration.DeploymentRequest, 21 21 SowerClient.Orchestration.SeedDeployment, 22 + SowerClient.Orchestration.SeedDeploymentResult, 22 23 SowerClient.Orchestration.Subscription, 23 24 SowerClient.Orchestration.SubscriptionSync, 24 25 SowerClient.Storage.PresignedUploadReply,
-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 - 8 4 OpenApiSpex.schema(%{ 9 5 title: "SeedDeployment", 10 6 type: :object,
+31
apps/sower_client/lib/sower_client/orchestration/seed_deployment_result.ex
··· 1 + defmodule SowerClient.Orchestration.SeedDeploymentResult do 2 + use SowerClient.Schema 3 + use SowerClient.ChannelMessage, event: "deployment:seed_result" 4 + 5 + OpenApiSpex.schema(%{ 6 + title: "SeedDeploymentResult", 7 + type: :object, 8 + properties: %{ 9 + deployment_sid: %Schema{ 10 + type: :string, 11 + description: "deployment sid which is being reported on" 12 + }, 13 + seed_sid: %Schema{ 14 + type: :string, 15 + description: "seed sid which is being reported on" 16 + }, 17 + result: %Schema{ 18 + type: :string, 19 + description: "result of the seed deployment", 20 + enum: [:success, :failure], 21 + nullable: true 22 + }, 23 + log: %Schema{ 24 + type: :string, 25 + description: "deployment log output for this seed", 26 + default: "" 27 + } 28 + }, 29 + required: [:deployment_sid, :seed_sid] 30 + }) 31 + end
+10 -3
apps/sower_client/test/sower_client/orchestration/seed_deployment_test.exs
··· 3 3 4 4 alias SowerClient.Orchestration.SeedDeployment 5 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" 6 + test "casts valid seed deployment" do 7 + assert {:ok, %SeedDeployment{}} = 8 + SeedDeployment.cast(%{ 9 + seed: %{ 10 + sid: "seed_123", 11 + name: "my-seed", 12 + seed_type: "nixos", 13 + artifact: "/nix/store/abc" 14 + } 15 + }) 9 16 end 10 17 end