Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

feat: add deployment_events audit table for tracking who/why

Replace retried_by_user_id/retried_at on deployments with a
deployment_events table that records created/canceled events with
reason and actor_sid at each deployment lifecycle point.

sow-151

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

+156 -30
+35 -13
apps/sower/lib/sower/orchestration/deployment.ex
··· 6 6 alias Sower.Repo 7 7 alias Sower.Accounts.User 8 8 alias Sower.Orchestration 9 - alias Sower.Orchestration.{Garden, Seed, Subscription, DeploymentPubSub} 9 + alias Sower.Orchestration.{DeploymentEvent, DeploymentPubSub, Garden, Seed, Subscription} 10 10 11 11 require Logger 12 12 ··· 39 39 belongs_to :garden, Garden 40 40 belongs_to :parent_deployment, __MODULE__ 41 41 has_many :retries, __MODULE__, foreign_key: :parent_deployment_id 42 - belongs_to :retried_by_user, User 42 + has_many :events, Orchestration.DeploymentEvent 43 43 44 44 many_to_many :subscriptions, Subscription, join_through: Orchestration.SubscriptionDeployment 45 45 ··· 56 56 field :last_dispatched_at, :utc_datetime_usec 57 57 field :content_hash, :string 58 58 field :retry_ordinal, :integer 59 - field :retried_at, :utc_datetime_usec 60 59 61 60 timestamps() 62 61 end ··· 72 71 :garden_id, 73 72 :content_hash, 74 73 :parent_deployment_id, 75 - :retried_by_user_id, 76 - :retry_ordinal, 77 - :retried_at 74 + :retry_ordinal 78 75 ]) 79 76 |> put_assoc(:seeds, Map.get(attrs, :seeds, deployment.seeds)) 80 77 |> put_assoc(:subscriptions, Map.get(attrs, :subscriptions, deployment.subscriptions)) ··· 244 241 seeds: deployment.seeds, 245 242 subscriptions: deployment.subscriptions, 246 243 parent_deployment_id: deployment.id, 247 - retried_by_user_id: user_id, 248 244 retry_ordinal: max_retry_ordinal + 1, 249 - retried_at: DateTime.utc_now(), 250 245 last_dispatched_at: DateTime.utc_now(), 251 246 state: :dispatched 252 247 } 253 248 254 249 case create_deployment(attrs) do 255 250 {:ok, retry_deployment} -> 251 + DeploymentEvent.record_event(retry_deployment, :created, :retry, user.sid) 252 + 256 253 retry_deployment = 257 254 Repo.preload(retry_deployment, [:garden, :subscriptions, seeds: [:tags]]) 258 255 ··· 293 290 294 291 Enum.each(to_cancel, fn deployment -> 295 292 update_deployment(deployment, %{state: :canceled, result: :failure}) 293 + DeploymentEvent.record_event(deployment, :canceled, :superseded, garden.sid) 296 294 end) 297 295 298 296 # 3. Replay valid unresolved deployments ··· 307 305 # 4. Deploy fresh for all overdue subscriptions 308 306 overdue_tasks = 309 307 Enum.map(overdue, fn sub -> 310 - {:ok, _request_id, pid} = deploy_subscription(sub) 308 + {:ok, _request_id, pid} = 309 + deploy_subscription(sub, actor_sid: garden.sid, event_reason: :schedule_triggered) 310 + 311 311 pid 312 312 end) 313 313 ··· 384 384 ) do 385 385 with {:ok, subscriptions} <- validate_deployment_request(request, garden.id), 386 386 {:ok, request_id, pid} <- 387 - process_deployment(request.request_id, subscriptions, garden, force: request.force) do 387 + process_deployment(request.request_id, subscriptions, garden, 388 + force: request.force, 389 + actor_sid: garden.sid, 390 + event_reason: :realtime_triggered 391 + ) do 388 392 {:ok, request_id, pid} 389 393 end 390 394 end ··· 517 521 518 522 Enum.reduce(stale_deployments, 0, fn deployment, acc -> 519 523 case update_deployment(deployment, %{state: :canceled}) do 520 - {:ok, _} -> acc + 1 521 - _ -> acc 524 + {:ok, _} -> 525 + DeploymentEvent.record_event(deployment, :canceled, :stale, "system") 526 + acc + 1 527 + 528 + _ -> 529 + acc 522 530 end 523 531 end) 524 532 end ··· 558 566 559 567 defp do_deployment(request_id, subscriptions, opts) do 560 568 force? = Keyword.get(opts, :force, false) 569 + actor_sid = Keyword.get(opts, :actor_sid) 570 + event_reason = Keyword.get(opts, :event_reason) 561 571 garden_id = hd(subscriptions).garden_id 562 572 563 573 seed_deploys = ··· 633 643 subscriptions: subscriptions 634 644 }) do 635 645 {:ok, deploy} -> 646 + if actor_sid && event_reason do 647 + DeploymentEvent.record_event(deploy, :created, event_reason, actor_sid) 648 + end 649 + 636 650 subscription_ids = Enum.map(subscriptions, & &1.id) 637 651 cancel_stale_for_subscriptions(subscription_ids) 638 652 ··· 746 760 747 761 %__MODULE__{state: state} = unresolved 748 762 when state in [:created, :dispatched, :acknowledged] -> 749 - update_deployment(unresolved, %{deployed_at: now, result: :failure, state: :stale}) 763 + result = 764 + update_deployment(unresolved, %{deployed_at: now, result: :failure, state: :stale}) 765 + 766 + case result do 767 + {:ok, _} -> DeploymentEvent.record_event(unresolved, :canceled, :stale, "system") 768 + _ -> :ok 769 + end 770 + 771 + result 750 772 751 773 %__MODULE__{} -> 752 774 :ignore
+47
apps/sower/lib/sower/orchestration/deployment_event.ex
··· 1 + defmodule Sower.Orchestration.DeploymentEvent do 2 + use Sower.Schema 3 + import Ecto.Changeset 4 + 5 + alias Sower.Repo 6 + alias Sower.Orchestration.Deployment 7 + 8 + schema "deployment_events" do 9 + belongs_to :deployment, Deployment 10 + field :org_id, Ecto.UUID 11 + field :event, Ecto.Enum, values: [:created, :canceled] 12 + 13 + field :reason, Ecto.Enum, 14 + values: [ 15 + :user_triggered, 16 + :schedule_triggered, 17 + :realtime_triggered, 18 + :retry, 19 + :superseded, 20 + :stale 21 + ] 22 + 23 + field :actor_sid, :string 24 + 25 + timestamps(updated_at: false) 26 + end 27 + 28 + def changeset(deployment_event, attrs) do 29 + deployment_event 30 + |> cast(attrs, [:deployment_id, :org_id, :event, :reason, :actor_sid]) 31 + |> validate_required([:deployment_id, :org_id, :event, :reason, :actor_sid]) 32 + |> foreign_key_constraint(:deployment_id) 33 + end 34 + 35 + def record_event(%Deployment{} = deployment, event, reason, actor_sid) do 36 + %__MODULE__{ 37 + org_id: Repo.get_org_id() 38 + } 39 + |> changeset(%{ 40 + deployment_id: deployment.id, 41 + event: event, 42 + reason: reason, 43 + actor_sid: actor_sid 44 + }) 45 + |> Repo.insert() 46 + end 47 + end
+4 -2
apps/sower/lib/sower/workers/deploy_subscription.ex
··· 11 11 run(sid) 12 12 end 13 13 14 - def run(sid, deploy_fun \\ &Deployment.deploy_subscription/1) do 14 + def run(sid, deploy_fun \\ &Deployment.deploy_subscription/2) do 15 15 now = DateTime.utc_now() 16 16 17 17 case Subscription.get_subscription_sid(sid) do ··· 29 29 end 30 30 31 31 defp deploy(%Subscription{} = sub, deploy_fun) do 32 - case deploy_fun.(sub) do 32 + sub = Sower.Repo.preload(sub, :garden) 33 + 34 + case deploy_fun.(sub, actor_sid: sub.garden.sid, event_reason: :realtime_triggered) do 33 35 {:ok, request_id, _pid} -> 34 36 Logger.info( 35 37 msg: "Realtime deploy triggered",
+7 -1
apps/sower/lib/sower_web/live/garden_live/show.ex
··· 100 100 sub -> 101 101 socket = assign(socket, deploying_sub: sub_sid, deploy_error: nil) 102 102 103 - case Orchestration.deploy_subscription(sub, force: true) do 103 + user = socket.assigns.current_user 104 + 105 + case Orchestration.deploy_subscription(sub, 106 + force: true, 107 + actor_sid: user.sid, 108 + event_reason: :user_triggered 109 + ) do 104 110 {:ok, _request_id, _pid} -> 105 111 {:noreply, socket} 106 112
+7 -1
apps/sower/lib/sower_web/live/subscription_live/show.ex
··· 47 47 def handle_event("deploy_subscription", %{"subscription_sid" => _sub_sid}, socket) do 48 48 socket = assign(socket, deploying: true, deploy_error: nil) 49 49 50 - case Orchestration.deploy_subscription(socket.assigns.subscription, force: true) do 50 + user = socket.assigns.current_user 51 + 52 + case Orchestration.deploy_subscription(socket.assigns.subscription, 53 + force: true, 54 + actor_sid: user.sid, 55 + event_reason: :user_triggered 56 + ) do 51 57 {:ok, _request_id, _pid} -> 52 58 {:noreply, socket} 53 59
+36
apps/sower/priv/repo/migrations/20260411171740_create_deployment_events.exs
··· 1 + defmodule Sower.Repo.Migrations.CreateDeploymentEvents do 2 + use Ecto.Migration 3 + 4 + def change do 5 + create_query = 6 + "CREATE TYPE deployment_event_type AS ENUM ('created', 'canceled')" 7 + 8 + drop_query = "DROP TYPE deployment_event_type" 9 + execute(create_query, drop_query) 10 + 11 + create_query = 12 + "CREATE TYPE deployment_event_reason AS ENUM ('user_triggered', 'schedule_triggered', 'realtime_triggered', 'retry', 'superseded', 'stale')" 13 + 14 + drop_query = "DROP TYPE deployment_event_reason" 15 + execute(create_query, drop_query) 16 + 17 + create table(:deployment_events) do 18 + add :deployment_id, references(:deployments, on_delete: :restrict), null: false 19 + add :org_id, :uuid, null: false 20 + add :event, :deployment_event_type, null: false 21 + add :reason, :deployment_event_reason, null: false 22 + add :actor_sid, :string, null: false 23 + 24 + timestamps(updated_at: false) 25 + end 26 + 27 + create index(:deployment_events, [:deployment_id]) 28 + 29 + drop index(:deployments, [:retried_by_user_id]) 30 + 31 + alter table(:deployments) do 32 + remove :retried_by_user_id 33 + remove :retried_at 34 + end 35 + end 36 + end
+6 -3
apps/sower/test/sower/orchestration_test.exs
··· 1160 1160 garden_id: garden.id, 1161 1161 parent_deployment_id: parent.id, 1162 1162 retry_ordinal: 1, 1163 - retried_by_user_id: user.id, 1164 - retried_at: DateTime.utc_now(), 1165 1163 result: nil, 1166 1164 state: :dispatched, 1167 1165 deployed_at: nil, ··· 1313 1311 1314 1312 assert {:ok, retried} = Orchestration.retry_deployment(deployment, user.id) 1315 1313 assert retried.parent_deployment_id == deployment.id 1316 - assert retried.retried_by_user_id == user.id 1317 1314 assert retried.retry_ordinal == 1 1315 + 1316 + retried = Sower.Repo.preload(retried, :events) 1317 + assert [event] = retried.events 1318 + assert event.event == :created 1319 + assert event.reason == :retry 1320 + assert event.actor_sid == user.sid 1318 1321 1319 1322 retried = Sower.Repo.preload(retried, [:seeds, :subscriptions]) 1320 1323 assert Enum.map(retried.seeds, & &1.id) == [seed.id]
+2 -2
apps/sower/test/sower/workers/deploy_subscription_test.exs
··· 54 54 55 55 test_pid = self() 56 56 57 - deploy_fun = fn subscription -> 57 + deploy_fun = fn subscription, _opts -> 58 58 send(test_pid, {:deployed, subscription.sid}) 59 59 {:ok, "req_test123", self()} 60 60 end ··· 77 77 allow_realtime: true 78 78 }) 79 79 80 - deploy_fun = fn _sub -> {:error, :connection_refused} end 80 + deploy_fun = fn _sub, _opts -> {:error, :connection_refused} end 81 81 82 82 assert {:error, :connection_refused} = DeploySubscription.run(sub.sid, deploy_fun) 83 83 end
+6 -4
apps/sower/test/sower_web/live/deployment_live_index_test.exs
··· 52 52 retried = 53 53 Sower.Repo.get_by!(Sower.Orchestration.Deployment, parent_deployment_id: deployment.id) 54 54 55 - assert retried.retried_by_user_id == user.id 55 + retried = Sower.Repo.preload(retried, :events) 56 + assert [event] = retried.events 57 + assert event.event == :created 58 + assert event.reason == :retry 59 + assert event.actor_sid == user.sid 56 60 end 57 61 58 62 test "shows error when retry submission fails", %{conn: conn, user: user} do ··· 70 74 deployment_fixture(%{ 71 75 garden_id: garden.id, 72 76 parent_deployment_id: deployment.id, 73 - retry_ordinal: 1, 74 - retried_by_user_id: user.id, 75 - retried_at: DateTime.utc_now() 77 + retry_ordinal: 1 76 78 }) 77 79 78 80 {:ok, index_live, _html} = live(conn, ~p"/deployments")
+6 -4
apps/sower/test/sower_web/live/deployment_live_show_test.exs
··· 221 221 retried = 222 222 Sower.Repo.get_by!(Sower.Orchestration.Deployment, parent_deployment_id: deployment.id) 223 223 224 - assert retried.retried_by_user_id == user.id 224 + retried = Sower.Repo.preload(retried, :events) 225 + assert [event] = retried.events 226 + assert event.event == :created 227 + assert event.reason == :retry 228 + assert event.actor_sid == user.sid 225 229 226 230 assert_redirect(show_live, ~p"/deployments/#{retried.sid}") 227 231 end ··· 313 317 deployment_fixture(%{ 314 318 garden_id: garden.id, 315 319 parent_deployment_id: parent.id, 316 - retry_ordinal: 1, 317 - retried_by_user_id: user.id, 318 - retried_at: DateTime.utc_now() 320 + retry_ordinal: 1 319 321 }) 320 322 321 323 {:ok, show_live, _html} = live(conn, ~p"/deployments/#{parent.sid}")