Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

feat: server-side deployment policy adoption (Phase 2)

Server now uses policy rules for all deployment decisions:
- Add Policy.from_legacy/1 to convert old subscription fields to policy rules
- Server converts old-format subscriptions at registration time
- Replace within_window? with Policy.evaluate in DeploySubscription worker
- Replace allow_realtime filter with policy-based realtime trigger check
- Add policy evaluation gate in deploy_subscription/2 entry point
- Add user_retry and poll_on_connect to deployment_event_reason enum
- Change retry reason from :retry to :user_retry
- Display policy rules on subscription show page
- Update all affected tests

Old gardens without policy field continue to work via from_legacy conversion.

sow-162

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

+335 -29
.dexter.db-shm

This is a binary file and will not be displayed.

+25 -4
apps/sower/lib/sower/orchestration/deployment.ex
··· 248 248 249 249 case create_deployment(attrs) do 250 250 {:ok, retry_deployment} -> 251 - DeploymentEvent.record_event(retry_deployment, :created, :retry, user.sid) 251 + DeploymentEvent.record_event(retry_deployment, :created, :user_retry, user.sid) 252 252 253 253 retry_deployment = 254 254 Repo.preload(retry_deployment, [:garden, :subscriptions, seeds: [:tags]]) ··· 355 355 # Deployment request handling 356 356 357 357 def deploy_subscription(%Subscription{} = sub, opts \\ []) do 358 + alias SowerClient.Orchestration.Subscription.Policy 359 + 358 360 subscription = Repo.preload(sub, :garden) 359 361 360 362 case subscription.garden do ··· 362 364 {:error, :garden_not_found} 363 365 364 366 %Garden{} = garden -> 365 - request_id = SowerClient.Sid.generate("req") 366 - {:ok, request_id, pid} = process_deployment(request_id, [subscription], garden, opts) 367 - {:ok, request_id, pid} 367 + event_reason = Keyword.get(opts, :event_reason) 368 + trigger = if event_reason, do: Policy.trigger_for_reason(event_reason), else: :manual 369 + now = DateTime.utc_now() 370 + 371 + case Policy.evaluate( 372 + subscription.policy, 373 + trigger, 374 + now, 375 + subscription.seed_type, 376 + subscription.timezone 377 + ) do 378 + {:allow, _action} -> 379 + request_id = SowerClient.Sid.generate("req") 380 + {:ok, request_id, pid} = process_deployment(request_id, [subscription], garden, opts) 381 + {:ok, request_id, pid} 382 + 383 + {:confirm, _action} -> 384 + {:error, :confirmation_required} 385 + 386 + :deny -> 387 + {:error, :policy_denied} 388 + end 368 389 end 369 390 end 370 391
+2
apps/sower/lib/sower/orchestration/deployment_event.ex
··· 16 16 :schedule_triggered, 17 17 :realtime_triggered, 18 18 :retry, 19 + :user_retry, 20 + :poll_on_connect, 19 21 :superseded, 20 22 :stale 21 23 ]
+13 -2
apps/sower/lib/sower/orchestration/subscription.ex
··· 146 146 end 147 147 148 148 def find_realtime_subscriptions(%Sower.Orchestration.Seed{} = seed) do 149 + alias SowerClient.Orchestration.Subscription.Policy 150 + 149 151 seed 150 152 |> find_subscription() 151 - |> Enum.filter(fn sub -> sub.allow_realtime end) 153 + |> Enum.filter(fn sub -> Policy.has_realtime_trigger?(sub.policy) end) 152 154 end 153 155 154 156 def within_window?(%__MODULE__{window: nil}, _now), do: true ··· 204 206 end 205 207 206 208 def register_subscription(%SowerClient.Orchestration.Subscription{} = sub, garden_id) do 209 + alias SowerClient.Orchestration.Subscription.Policy 210 + 211 + policy = 212 + if sub.policy == nil or sub.policy == [] do 213 + Policy.from_legacy(sub) 214 + else 215 + sub.policy 216 + end 217 + 207 218 attrs = %{ 208 219 garden_id: garden_id, 209 220 name: sub.name, ··· 216 227 reboot_policy: sub.reboot_policy, 217 228 allow_realtime: sub.allow_realtime, 218 229 window: sub.window, 219 - policy: sub.policy 230 + policy: policy 220 231 } 221 232 222 233 case create_subscription(attrs) do
+5 -4
apps/sower/lib/sower/workers/deploy_subscription.ex
··· 2 2 use Oban.Worker, queue: :default, max_attempts: 3 3 3 4 4 alias Sower.Orchestration.{Deployment, Subscription} 5 + alias SowerClient.Orchestration.Subscription.Policy 5 6 6 7 require Logger 7 8 ··· 20 21 :ok 21 22 22 23 sub -> 23 - if Subscription.within_window?(sub, now) do 24 - deploy(sub, deploy_fun) 25 - else 26 - :ok 24 + case Policy.evaluate(sub.policy, :realtime, now, sub.seed_type, sub.timezone) do 25 + {:allow, _action} -> deploy(sub, deploy_fun) 26 + {:confirm, _action} -> :ok 27 + :deny -> :ok 27 28 end 28 29 end 29 30 end
+2
apps/sower/lib/sower_web/live/deployment_live/show.ex
··· 266 266 do: "Deployed by realtime trigger" 267 267 268 268 defp event_description(%{event: :created, reason: :retry}), do: "Retried" 269 + defp event_description(%{event: :created, reason: :user_retry}), do: "Retried by user" 270 + defp event_description(%{event: :created, reason: :poll_on_connect}), do: "Deployed on connect" 269 271 defp event_description(%{event: :canceled, reason: :superseded}), do: "Canceled — superseded" 270 272 defp event_description(%{event: :canceled, reason: :stale}), do: "Canceled — stale" 271 273
+36
apps/sower/lib/sower_web/live/subscription_live/show.html.heex
··· 50 50 {@subscription.window.time_start}–{@subscription.window.time_end} ({@subscription.window.tz}) 51 51 </.detail_field> 52 52 53 + <section :if={@subscription.policy != []} class="-mx-4 sm:mx-0"> 54 + <h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-200 mb-4 px-4 sm:px-0"> 55 + Policy 56 + </h2> 57 + <div class="space-y-3 px-4 sm:px-0"> 58 + <div 59 + :for={rule <- @subscription.policy} 60 + class="rounded-lg border border-zinc-200 dark:border-zinc-700 p-3 text-sm" 61 + > 62 + <div class="flex flex-wrap gap-1.5 mb-1.5"> 63 + <span 64 + :for={action <- rule.actions} 65 + class="inline-flex items-center rounded-md bg-blue-50 dark:bg-blue-900/30 px-2 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300" 66 + > 67 + {action} 68 + </span> 69 + </div> 70 + <div class="text-zinc-500 dark:text-zinc-400 text-xs space-y-0.5"> 71 + <p :if={rule.triggers}> 72 + Triggers: {Enum.join(rule.triggers, ", ")} 73 + </p> 74 + <p :if={!rule.triggers}> 75 + Triggers: any 76 + </p> 77 + <p :if={rule.window}> 78 + Window: {Enum.join(rule.window.days, ", ")} 79 + {rule.window.time_start}–{rule.window.time_end} 80 + </p> 81 + <p :if={rule.confirm} class="text-amber-600 dark:text-amber-400"> 82 + Requires confirmation 83 + </p> 84 + </div> 85 + </div> 86 + </div> 87 + </section> 88 + 53 89 <section class="-mx-4 sm:mx-0"> 54 90 <h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-200 mb-4 px-4 sm:px-0"> 55 91 Rules
+14
apps/sower/priv/repo/migrations/20260415230441_update_deployment_event_reasons.exs
··· 1 + defmodule Sower.Repo.Migrations.UpdateDeploymentEventReasons do 2 + use Ecto.Migration 3 + 4 + def up do 5 + execute("ALTER TYPE deployment_event_reason ADD VALUE IF NOT EXISTS 'user_retry'") 6 + execute("ALTER TYPE deployment_event_reason ADD VALUE IF NOT EXISTS 'poll_on_connect'") 7 + end 8 + 9 + def down do 10 + # PostgreSQL does not support removing enum values. 11 + # These values are safe to leave in place. 12 + :ok 13 + end 14 + end
+1 -1
apps/sower/test/sower/orchestration_test.exs
··· 1316 1316 retried = Sower.Repo.preload(retried, :events) 1317 1317 assert [event] = retried.events 1318 1318 assert event.event == :created 1319 - assert event.reason == :retry 1319 + assert event.reason == :user_retry 1320 1320 assert event.actor_sid == user.sid 1321 1321 1322 1322 retried = Sower.Repo.preload(retried, [:seeds, :subscriptions])
+36 -11
apps/sower/test/sower/workers/deploy_subscription_test.exs
··· 19 19 assert :ok = DeploySubscription.run("sub_nonexistent") 20 20 end 21 21 22 - test "returns :ok when subscription is outside window" do 22 + test "returns :ok when policy denies realtime trigger" do 23 23 garden = garden_fixture() 24 24 25 25 sub = ··· 27 27 garden_id: garden.id, 28 28 seed_name: "myhost", 29 29 seed_type: "nixos", 30 - allow_realtime: true, 31 - window: %{ 32 - days: ["mon"], 33 - time_start: "00:00", 34 - time_end: "00:01", 35 - tz: "Pacific/Kiritimati" 36 - } 30 + policy: [ 31 + %{actions: ["activate"], triggers: ["manual"]} 32 + ] 33 + }) 34 + 35 + assert :ok = DeploySubscription.run(sub.sid) 36 + end 37 + 38 + test "returns :ok when policy window is outside current time" do 39 + garden = garden_fixture() 40 + 41 + sub = 42 + subscription_fixture(%{ 43 + garden_id: garden.id, 44 + seed_name: "myhost", 45 + seed_type: "nixos", 46 + policy: [ 47 + %{ 48 + actions: ["activate"], 49 + triggers: ["realtime"], 50 + window: %{ 51 + days: ["mon"], 52 + time_start: "00:00", 53 + time_end: "00:01", 54 + tz: "Pacific/Kiritimati" 55 + } 56 + } 57 + ] 37 58 }) 38 59 39 60 assert :ok = DeploySubscription.run(sub.sid) 40 61 end 41 62 42 63 @tag :capture_log 43 - test "calls deploy function for subscription within window" do 64 + test "calls deploy function for subscription with realtime policy" do 44 65 garden = garden_fixture() 45 66 seed_fixture(%{name: "myhost", seed_type: "nixos"}) 46 67 ··· 49 70 garden_id: garden.id, 50 71 seed_name: "myhost", 51 72 seed_type: "nixos", 52 - allow_realtime: true 73 + policy: [ 74 + %{actions: ["activate"], triggers: ["realtime"]} 75 + ] 53 76 }) 54 77 55 78 test_pid = self() ··· 74 97 garden_id: garden.id, 75 98 seed_name: "myhost", 76 99 seed_type: "nixos", 77 - allow_realtime: true 100 + policy: [ 101 + %{actions: ["activate"], triggers: ["realtime"]} 102 + ] 78 103 }) 79 104 80 105 deploy_fun = fn _sub, _opts -> {:error, :connection_refused} end
+9 -5
apps/sower/test/sower/workers/realtime_deploy_test.exs
··· 16 16 end 17 17 18 18 describe "perform/1" do 19 - test "enqueues deploy jobs for realtime subscriptions", %{org: org} do 19 + test "enqueues deploy jobs for subscriptions with realtime policy", %{org: org} do 20 20 garden = garden_fixture() 21 21 22 22 seed = ··· 29 29 garden_id: garden.id, 30 30 seed_name: "myhost", 31 31 seed_type: "nixos", 32 - allow_realtime: true 32 + policy: [ 33 + %{actions: ["activate"], triggers: ["realtime"]} 34 + ] 33 35 }) 34 36 35 37 assert :ok = ··· 41 43 assert_enqueued(worker: DeploySubscription) 42 44 end 43 45 44 - test "does not enqueue jobs when no realtime subscriptions exist", %{org: org} do 46 + test "does not enqueue jobs when no subscriptions exist", %{org: org} do 45 47 seed = 46 48 seed_fixture(%{ 47 49 name: "myhost", ··· 57 59 refute_enqueued(worker: DeploySubscription) 58 60 end 59 61 60 - test "skips subscriptions with allow_realtime false", %{org: org} do 62 + test "skips subscriptions without realtime in policy triggers", %{org: org} do 61 63 garden = garden_fixture() 62 64 63 65 seed = ··· 70 72 garden_id: garden.id, 71 73 seed_name: "myhost", 72 74 seed_type: "nixos", 73 - allow_realtime: false 75 + policy: [ 76 + %{actions: ["activate"], triggers: ["manual", "scheduled"]} 77 + ] 74 78 }) 75 79 76 80 assert :ok =
+1 -1
apps/sower/test/sower_web/live/deployment_live_index_test.exs
··· 55 55 retried = Sower.Repo.preload(retried, :events) 56 56 assert [event] = retried.events 57 57 assert event.event == :created 58 - assert event.reason == :retry 58 + assert event.reason == :user_retry 59 59 assert event.actor_sid == user.sid 60 60 end 61 61
+1 -1
apps/sower/test/sower_web/live/deployment_live_show_test.exs
··· 224 224 retried = Sower.Repo.preload(retried, :events) 225 225 assert [event] = retried.events 226 226 assert event.event == :created 227 - assert event.reason == :retry 227 + assert event.reason == :user_retry 228 228 assert event.actor_sid == user.sid 229 229 230 230 assert_redirect(show_live, ~p"/deployments/#{retried.sid}")
+68
apps/sower_client/lib/sower_client/orchestration/subscription/policy.ex
··· 95 95 def trigger_for_reason(:realtime_triggered), do: :realtime 96 96 def trigger_for_reason(:poll_on_connect), do: :poll_on_connect 97 97 98 + @doc """ 99 + Convert legacy subscription fields to equivalent policy rules. 100 + 101 + Accepts a subscription struct or map with old-style fields: 102 + `reboot_policy`, `allow_realtime`, `poll_on_connect`, `window`, `activation_args`. 103 + """ 104 + def from_legacy(subscription) do 105 + reboot_policy = get_field(subscription, :reboot_policy, "never") 106 + allow_realtime = get_field(subscription, :allow_realtime, false) 107 + poll_on_connect = get_field(subscription, :poll_on_connect, false) 108 + window = get_field(subscription, :window, nil) 109 + 110 + actions = build_legacy_actions(reboot_policy) 111 + triggers = build_legacy_triggers(allow_realtime, poll_on_connect) 112 + 113 + rule = %{actions: actions, triggers: triggers} 114 + 115 + rule = 116 + if window do 117 + Map.put(rule, :window, normalize_window(window)) 118 + else 119 + rule 120 + end 121 + 122 + [rule] 123 + end 124 + 125 + defp build_legacy_actions(reboot_policy) when reboot_policy in ["always", "when-required"], 126 + do: ["stage", "activate", "restart"] 127 + 128 + defp build_legacy_actions(_), do: ["stage", "activate"] 129 + 130 + defp build_legacy_triggers(allow_realtime, poll_on_connect) do 131 + base = ["manual", "scheduled"] 132 + base = if poll_on_connect, do: base ++ ["poll_on_connect"], else: base 133 + if allow_realtime, do: base ++ ["realtime"], else: base 134 + end 135 + 136 + defp normalize_window(%{days: _} = w), 137 + do: %{days: w.days, time_start: w.time_start, time_end: w.time_end, tz: Map.get(w, :tz)} 138 + 139 + defp normalize_window(%{"days" => _} = w), 140 + do: %{ 141 + days: w["days"], 142 + time_start: w["time_start"], 143 + time_end: w["time_end"], 144 + tz: w["tz"] 145 + } 146 + 147 + defp get_field(%{__struct__: _} = s, key, default), do: Map.get(s, key, default) 148 + 149 + defp get_field(m, key, default) when is_map(m), 150 + do: Map.get(m, key, Map.get(m, to_string(key), default)) 151 + 152 + @doc """ 153 + Returns true if any policy rule includes the realtime trigger. 154 + """ 155 + def has_realtime_trigger?(rules) do 156 + rules = effective_rules(rules) 157 + 158 + Enum.any?(rules, fn rule -> 159 + case rule_triggers(rule) do 160 + nil -> true 161 + triggers -> "realtime" in triggers 162 + end 163 + end) 164 + end 165 + 98 166 defp effective_rules(nil), do: @default_policy 99 167 defp effective_rules([]), do: @default_policy 100 168 defp effective_rules(rules), do: rules
+122
apps/sower_client/test/sower_client/orchestration/subscription/policy_test.exs
··· 395 395 assert {:allow, :activate} = Policy.evaluate(rules, :manual, @now, "home-manager") 396 396 end 397 397 end 398 + 399 + describe "from_legacy/1" do 400 + test "basic subscription with defaults" do 401 + sub = %{reboot_policy: "never", allow_realtime: false, poll_on_connect: false, window: nil} 402 + [rule] = Policy.from_legacy(sub) 403 + 404 + assert rule.actions == ["stage", "activate"] 405 + assert rule.triggers == ["manual", "scheduled"] 406 + refute Map.has_key?(rule, :window) 407 + end 408 + 409 + test "reboot_policy always adds restart action" do 410 + sub = %{reboot_policy: "always", allow_realtime: false, poll_on_connect: false, window: nil} 411 + [rule] = Policy.from_legacy(sub) 412 + 413 + assert "restart" in rule.actions 414 + end 415 + 416 + test "reboot_policy when-required adds restart action" do 417 + sub = %{ 418 + reboot_policy: "when-required", 419 + allow_realtime: false, 420 + poll_on_connect: false, 421 + window: nil 422 + } 423 + 424 + [rule] = Policy.from_legacy(sub) 425 + 426 + assert "restart" in rule.actions 427 + end 428 + 429 + test "allow_realtime adds realtime trigger" do 430 + sub = %{reboot_policy: "never", allow_realtime: true, poll_on_connect: false, window: nil} 431 + [rule] = Policy.from_legacy(sub) 432 + 433 + assert "realtime" in rule.triggers 434 + end 435 + 436 + test "poll_on_connect adds poll_on_connect trigger" do 437 + sub = %{reboot_policy: "never", allow_realtime: false, poll_on_connect: true, window: nil} 438 + [rule] = Policy.from_legacy(sub) 439 + 440 + assert "poll_on_connect" in rule.triggers 441 + end 442 + 443 + test "window is attached to the rule" do 444 + window = %{ 445 + days: ["mon", "fri"], 446 + time_start: "09:00", 447 + time_end: "17:00", 448 + tz: "America/New_York" 449 + } 450 + 451 + sub = %{ 452 + reboot_policy: "never", 453 + allow_realtime: false, 454 + poll_on_connect: false, 455 + window: window 456 + } 457 + 458 + [rule] = Policy.from_legacy(sub) 459 + 460 + assert rule.window.days == ["mon", "fri"] 461 + assert rule.window.time_start == "09:00" 462 + assert rule.window.time_end == "17:00" 463 + assert rule.window.tz == "America/New_York" 464 + end 465 + 466 + test "full legacy subscription converts correctly" do 467 + sub = %{ 468 + reboot_policy: "always", 469 + allow_realtime: true, 470 + poll_on_connect: true, 471 + window: %{days: ["mon"], time_start: "02:00", time_end: "06:00", tz: "Etc/UTC"} 472 + } 473 + 474 + [rule] = Policy.from_legacy(sub) 475 + 476 + assert rule.actions == ["stage", "activate", "restart"] 477 + assert "manual" in rule.triggers 478 + assert "scheduled" in rule.triggers 479 + assert "poll_on_connect" in rule.triggers 480 + assert "realtime" in rule.triggers 481 + assert rule.window.days == ["mon"] 482 + end 483 + 484 + test "works with string-keyed maps" do 485 + sub = %{ 486 + "reboot_policy" => "always", 487 + "allow_realtime" => true, 488 + "poll_on_connect" => false, 489 + "window" => nil 490 + } 491 + 492 + [rule] = Policy.from_legacy(sub) 493 + 494 + assert "restart" in rule.actions 495 + assert "realtime" in rule.triggers 496 + end 497 + end 498 + 499 + describe "has_realtime_trigger?/1" do 500 + test "returns true when realtime is in triggers" do 501 + rules = [%{actions: ["activate"], triggers: ["realtime"]}] 502 + assert Policy.has_realtime_trigger?(rules) 503 + end 504 + 505 + test "returns true when triggers is nil (matches all)" do 506 + rules = [%{actions: ["activate"]}] 507 + assert Policy.has_realtime_trigger?(rules) 508 + end 509 + 510 + test "returns false when realtime not in any triggers" do 511 + rules = [%{actions: ["activate"], triggers: ["manual", "scheduled"]}] 512 + refute Policy.has_realtime_trigger?(rules) 513 + end 514 + 515 + test "returns true with default policy (no realtime)" do 516 + # Default policy has manual, scheduled, poll_on_connect — no realtime 517 + refute Policy.has_realtime_trigger?([]) 518 + end 519 + end 398 520 end