Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

deployments: add state tracking

+125 -19
+25 -10
apps/sower/lib/sower/orchestration/deployment.ex
··· 31 31 32 32 field :deployed_at, :utc_datetime 33 33 field :result, Ecto.Enum, values: [:success, :failure, :partial] 34 + 35 + field :state, Ecto.Enum, 36 + values: [:created, :dispatched, :completed, :stale], 37 + default: :created 38 + 34 39 field :last_dispatched_at, :utc_datetime_usec 35 40 field :content_hash, :string 36 41 field :retry_ordinal, :integer ··· 45 50 |> cast(attrs, [ 46 51 :deployed_at, 47 52 :result, 53 + :state, 48 54 :last_dispatched_at, 49 55 :agent_id, 50 56 :content_hash, ··· 90 96 91 97 query = 92 98 from(d in __MODULE__, 93 - where: d.agent_id == ^agent.id and is_nil(d.result), 99 + where: d.agent_id == ^agent.id and d.state in [:created, :dispatched], 94 100 order_by: [ 95 101 asc: fragment("COALESCE(?, ?)", d.last_dispatched_at, d.inserted_at), 96 102 asc: d.inserted_at ··· 185 191 true -> 186 192 retry_in_progress? = 187 193 from(d in __MODULE__, 188 - where: d.parent_deployment_id == ^deployment.id and is_nil(d.result), 194 + where: 195 + d.parent_deployment_id == ^deployment.id and d.state in [:created, :dispatched], 189 196 limit: 1, 190 197 select: d.id 191 198 ) ··· 210 217 retried_by_user_id: user_id, 211 218 retry_ordinal: max_retry_ordinal + 1, 212 219 retried_at: DateTime.utc_now(), 213 - last_dispatched_at: DateTime.utc_now() 220 + last_dispatched_at: DateTime.utc_now(), 221 + state: :dispatched 214 222 } 215 223 216 224 case create_deployment(attrs) do ··· 370 378 {:error, :deployment_not_found} 371 379 372 380 deploy -> 373 - update_deployment(deploy, %{deployed_at: result.deployed_at, result: result.result}) 381 + update_deployment(deploy, %{ 382 + deployed_at: result.deployed_at, 383 + result: result.result, 384 + state: :completed 385 + }) 374 386 end 375 387 end 376 388 ··· 388 400 389 401 stale_deployments = 390 402 from(d in __MODULE__, 391 - where: is_nil(d.result), 403 + where: d.state in [:created, :dispatched], 392 404 where: fragment("COALESCE(?, ?) <= ?", d.last_dispatched_at, d.inserted_at, ^cutoff), 393 405 order_by: [ 394 406 asc: fragment("COALESCE(?, ?)", d.last_dispatched_at, d.inserted_at), ··· 524 536 agent_id: agent_id, 525 537 content_hash: content_hash, 526 538 last_dispatched_at: DateTime.utc_now(), 539 + state: :dispatched, 527 540 seeds: seeds, 528 541 subscriptions: subscriptions 529 542 }) do ··· 572 585 where: 573 586 d.agent_id == ^agent_id and 574 587 d.content_hash == ^content_hash and 575 - (d.result == :success or is_nil(d.result)), 588 + (d.result == :success or d.state in [:created, :dispatched]), 576 589 order_by: [desc: d.inserted_at], 577 590 limit: 1 578 591 ) ··· 618 631 now = DateTime.utc_now() 619 632 620 633 from(d in __MODULE__, 621 - where: d.id in ^ids and is_nil(d.result) 634 + where: d.id in ^ids and d.state in [:created, :dispatched] 635 + ) 636 + |> Repo.update_all( 637 + set: [last_dispatched_at: dispatched_at, state: :dispatched, updated_at: now] 622 638 ) 623 - |> Repo.update_all(set: [last_dispatched_at: dispatched_at, updated_at: now]) 624 639 625 640 :ok 626 641 end ··· 634 649 nil -> 635 650 :ignore 636 651 637 - %__MODULE__{result: nil} = unresolved -> 638 - update_deployment(unresolved, %{deployed_at: now, result: :failure}) 652 + %__MODULE__{state: state} = unresolved when state in [:created, :dispatched] -> 653 + update_deployment(unresolved, %{deployed_at: now, result: :failure, state: :stale}) 639 654 640 655 %__MODULE__{} -> 641 656 :ignore
+35
apps/sower/lib/sower_web/components/sower_components.ex
··· 140 140 """ 141 141 end 142 142 143 + attr :state, :atom, required: true 144 + attr :result, :atom, default: nil 145 + 146 + def deployment_status(assigns) do 147 + ~H""" 148 + <%= case @state do %> 149 + <% :created -> %> 150 + <span class="inline-flex items-center gap-1.5 text-sm text-zinc-500 dark:text-zinc-400"> 151 + <span class="relative flex h-2.5 w-2.5"> 152 + <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-zinc-400 opacity-75" /> 153 + <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-zinc-400" /> 154 + </span> 155 + Created 156 + </span> 157 + <% :dispatched -> %> 158 + <span class="inline-flex items-center gap-1.5 text-sm text-blue-600 dark:text-blue-400"> 159 + <span class="relative flex h-2.5 w-2.5"> 160 + <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-blue-500 opacity-75" /> 161 + <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-blue-500" /> 162 + </span> 163 + Dispatched 164 + </span> 165 + <% :completed -> %> 166 + <.result result={@result} /> 167 + <% :stale -> %> 168 + <span class="inline-flex items-center gap-1.5 text-sm text-amber-600 dark:text-amber-400"> 169 + <span class="relative flex h-2.5 w-2.5"> 170 + <span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-amber-500" /> 171 + </span> 172 + Stale 173 + </span> 174 + <% end %> 175 + """ 176 + end 177 + 143 178 attr :result, :string, required: true 144 179 145 180 def result(assigns) do
+3 -3
apps/sower/lib/sower_web/live/agent_live/show.html.heex
··· 108 108 rows={@deployments} 109 109 row_click={fn deployment -> JS.navigate(~p"/deployments/#{deployment.sid}") end} 110 110 > 111 - <:col :let={deployment} label="Result"> 112 - <.result result={deployment.result} /> 111 + <:col :let={deployment} label="Status"> 112 + <.deployment_status state={deployment.state} result={deployment.result} /> 113 113 </:col> 114 114 <:col :let={deployment} label="SID">{deployment.sid}</:col> 115 115 <:col :let={deployment} label="Created"> ··· 117 117 </:col> 118 118 <:col :let={deployment} label=""> 119 119 <.button 120 - :if={deployment.result in [:success, :failure]} 120 + :if={deployment.state in [:completed, :stale]} 121 121 type="button" 122 122 phx-click="retry_deployment" 123 123 phx-value-deployment_sid={deployment.sid}
+3 -3
apps/sower/lib/sower_web/live/deployment_live/index.ex
··· 19 19 rows={@streams.deployments} 20 20 row_click={fn {_id, deployment} -> JS.navigate(~p"/deployments/#{deployment}") end} 21 21 > 22 - <:col :let={{_id, deployment}} label="Result"> 23 - <.result result={deployment.result} /> 22 + <:col :let={{_id, deployment}} label="Status"> 23 + <.deployment_status state={deployment.state} result={deployment.result} /> 24 24 </:col> 25 25 <:col :let={{_id, deployment}} label="sid">{deployment.sid}</:col> 26 26 <:col :let={{_id, deployment}} label="agent">{get_in(deployment.agent.name) || "-"}</:col> ··· 119 119 end 120 120 121 121 defp retryable?(deployment) do 122 - deployment.result in [:success, :failure] 122 + deployment.state in [:completed, :stale] 123 123 end 124 124 end
+2 -2
apps/sower/lib/sower_web/live/deployment_live/show.ex
··· 11 11 <Layouts.app flash={@flash} current_user={@current_user}> 12 12 <.header> 13 13 <div class="flex items-center space-x-2"> 14 - <.result result={@deployment.result} /> 14 + <.deployment_status state={@deployment.state} result={@deployment.result} /> 15 15 <span>{@deployment.sid}</span> 16 16 </div> 17 17 <:actions> ··· 237 237 end 238 238 239 239 defp retryable?(deployment) do 240 - deployment.result in [:success, :failure] 240 + deployment.state in [:completed, :stale] 241 241 end 242 242 243 243 defp initialize_socket(socket) do
+30
apps/sower/priv/repo/migrations/20260305120000_add_state_to_deployments.exs
··· 1 + defmodule Sower.Repo.Migrations.AddStateToDeployments do 2 + use Ecto.Migration 3 + 4 + def up do 5 + alter table(:deployments) do 6 + add :state, :string 7 + end 8 + 9 + # Backfill existing rows 10 + execute """ 11 + UPDATE deployments 12 + SET state = CASE 13 + WHEN result IS NOT NULL THEN 'completed' 14 + WHEN last_dispatched_at IS NOT NULL THEN 'dispatched' 15 + ELSE 'created' 16 + END 17 + """ 18 + 19 + alter table(:deployments) do 20 + modify :state, :string, null: false 21 + end 22 + 23 + end 24 + 25 + def down do 26 + alter table(:deployments) do 27 + remove :state 28 + end 29 + end 30 + end
+9
apps/sower/test/sower/orchestration_test.exs
··· 1021 1021 seeds: [seed], 1022 1022 subscriptions: [subscription], 1023 1023 result: :success, 1024 + state: :completed, 1024 1025 deployed_at: DateTime.utc_now() 1025 1026 }) 1026 1027 ··· 1069 1070 agent_id: agent.id, 1070 1071 result: nil, 1071 1072 deployed_at: nil, 1073 + state: :dispatched, 1072 1074 last_dispatched_at: old_dispatch 1073 1075 }) 1074 1076 ··· 1077 1079 agent_id: agent.id, 1078 1080 result: nil, 1079 1081 deployed_at: nil, 1082 + state: :dispatched, 1080 1083 last_dispatched_at: fresh_dispatch 1081 1084 }) 1082 1085 ··· 1089 1092 1090 1093 stale = Orchestration.get_deployment_sid!(stale.sid) 1091 1094 assert stale.result == :failure 1095 + assert stale.state == :stale 1092 1096 assert stale.deployed_at == now 1093 1097 1094 1098 fresh = Orchestration.get_deployment_sid!(fresh.sid) 1095 1099 assert is_nil(fresh.result) 1100 + assert fresh.state == :dispatched 1096 1101 assert is_nil(fresh.deployed_at) 1097 1102 end 1098 1103 ··· 1108 1113 deployment_fixture(%{ 1109 1114 agent_id: agent.id, 1110 1115 result: :success, 1116 + state: :completed, 1111 1117 deployed_at: DateTime.utc_now() 1112 1118 }) 1113 1119 ··· 1119 1125 retried_by_user_id: user.id, 1120 1126 retried_at: DateTime.utc_now(), 1121 1127 result: nil, 1128 + state: :dispatched, 1122 1129 deployed_at: nil, 1123 1130 last_dispatched_at: old_dispatch 1124 1131 }) ··· 1147 1154 deployment_fixture(%{ 1148 1155 agent_id: agent.id, 1149 1156 result: nil, 1157 + state: :dispatched, 1150 1158 deployed_at: nil, 1151 1159 last_dispatched_at: old_dispatch 1152 1160 }) ··· 1170 1178 1171 1179 refreshed = Orchestration.get_deployment_sid!(deployment.sid) 1172 1180 assert refreshed.result == :success 1181 + assert refreshed.state == :completed 1173 1182 assert refreshed.deployed_at == later 1174 1183 end 1175 1184 end
+1
apps/sower/test/sower_web/channels/agent_channel_test.exs
··· 67 67 seeds: [seed], 68 68 subscriptions: [subscription], 69 69 result: :success, 70 + state: :completed, 70 71 deployed_at: DateTime.utc_now() |> DateTime.truncate(:second) 71 72 }) 72 73
+4
apps/sower/test/sower_web/live/deployment_live_index_test.exs
··· 14 14 deployment_fixture(%{ 15 15 agent_id: agent.id, 16 16 result: :success, 17 + state: :completed, 17 18 deployed_at: DateTime.utc_now() 18 19 }) 19 20 ··· 21 22 deployment_fixture(%{ 22 23 agent_id: agent.id, 23 24 result: nil, 25 + state: :dispatched, 24 26 deployed_at: nil 25 27 }) 26 28 ··· 38 40 deployment_fixture(%{ 39 41 agent_id: agent.id, 40 42 result: :failure, 43 + state: :completed, 41 44 deployed_at: DateTime.utc_now() 42 45 }) 43 46 ··· 60 63 deployment_fixture(%{ 61 64 agent_id: agent.id, 62 65 result: :success, 66 + state: :completed, 63 67 deployed_at: DateTime.utc_now() 64 68 }) 65 69
+13 -1
apps/sower/test/sower_web/live/deployment_live_show_test.exs
··· 73 73 deployment_fixture(%{ 74 74 agent_id: agent.id, 75 75 result: nil, 76 + state: :dispatched, 76 77 deployed_at: nil 77 78 }) 78 79 ··· 80 81 deployment_fixture(%{ 81 82 agent_id: agent.id, 82 83 result: nil, 84 + state: :dispatched, 83 85 deployed_at: nil 84 86 }) 85 87 ··· 104 106 deployment_fixture(%{ 105 107 agent_id: agent.id, 106 108 result: nil, 109 + state: :dispatched, 107 110 deployed_at: nil 108 111 }) 109 112 ··· 111 114 refute has_element?(show_live, "button", "Retry") 112 115 113 116 deployment 114 - |> change(%{result: :success, deployed_at: DateTime.utc_now() |> DateTime.truncate(:second)}) 117 + |> change(%{ 118 + result: :success, 119 + state: :completed, 120 + deployed_at: DateTime.utc_now() |> DateTime.truncate(:second) 121 + }) 115 122 |> Sower.Repo.update!() 116 123 117 124 assert {:ok, _deployment} = DeploymentPubSub.broadcast_deployment_change(deployment, :updated) ··· 127 134 deployment_fixture(%{ 128 135 agent_id: agent.id, 129 136 result: nil, 137 + state: :dispatched, 130 138 deployed_at: nil 131 139 }) 132 140 ··· 169 177 deployment_fixture(%{ 170 178 agent_id: agent.id, 171 179 result: :success, 180 + state: :completed, 172 181 deployed_at: DateTime.utc_now() 173 182 }) 174 183 ··· 176 185 deployment_fixture(%{ 177 186 agent_id: agent.id, 178 187 result: nil, 188 + state: :dispatched, 179 189 deployed_at: nil 180 190 }) 181 191 ··· 196 206 deployment_fixture(%{ 197 207 agent_id: agent.id, 198 208 result: :success, 209 + state: :completed, 199 210 deployed_at: DateTime.utc_now() 200 211 }) 201 212 ··· 219 230 deployment_fixture(%{ 220 231 agent_id: agent.id, 221 232 result: :success, 233 + state: :completed, 222 234 deployed_at: DateTime.utc_now() 223 235 }) 224 236