Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

fix: spec compliance fixes for deployment policy

- Make action_to_mode seed-type-aware: service activate maps to
"restart" mode per spec, not "switch"
- Add specific UI error messages for policy_denied and
confirmation_required instead of generic "Deployment failed"
- Add tests for deploy_subscription/2 policy denial and confirmation
paths
- Add from_legacy + poll_on_connect round-trip tests through evaluate
- Add service seed type activation mode test

sow-163

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

+152 -27
+6 -5
apps/garden/lib/garden/deployer.ex
··· 106 106 subscription.timezone 107 107 ) 108 108 109 - mode = action_to_mode(action) 109 + mode = action_to_mode(action, seed.seed_type) 110 110 111 111 preamble = 112 112 [downloading_line | download_output] ++ ··· 392 392 end 393 393 end 394 394 395 - defp action_to_mode(:restart), do: "boot" 396 - defp action_to_mode(:activate), do: "switch" 397 - defp action_to_mode(:stage), do: nil 398 - defp action_to_mode(nil), do: "switch" 395 + defp action_to_mode(:restart, _seed_type), do: "boot" 396 + defp action_to_mode(:activate, "service"), do: "restart" 397 + defp action_to_mode(:activate, _seed_type), do: "switch" 398 + defp action_to_mode(:stage, _seed_type), do: nil 399 + defp action_to_mode(nil, _seed_type), do: "switch" 399 400 400 401 defp report_seed_status(%Deployment{} = deployment, seed, status) do 401 402 seed_status =
+24 -2
apps/garden/test/garden/deployer_test.exs
··· 328 328 ) 329 329 end 330 330 331 + test "uses restart mode for service seed type with activate action" do 332 + deployment = %Deployment{ 333 + sid: "dep_svc", 334 + seed_deployments: [seed_deploy_with_identity("seed_svc1", "service")] 335 + } 336 + 337 + logged_lines = 338 + capture_seed_result_lines(deployment, 339 + find_subscription_fun: fn _ -> 340 + %Subscription{ 341 + seed_type: "service", 342 + policy: [%{actions: ["activate"]}] 343 + } 344 + end 345 + ) 346 + 347 + assert Enum.any?( 348 + logged_lines, 349 + &(&1 =~ "[garden]" and &1 =~ "restart" and &1 =~ "seed-seed_svc1") 350 + ) 351 + end 352 + 331 353 test "includes reboot decision in last seed log" do 332 354 deployment = %Deployment{ 333 355 sid: "dep_reboot_log", ··· 524 546 } 525 547 end 526 548 527 - defp seed_deploy_with_identity(seed_sid) do 549 + defp seed_deploy_with_identity(seed_sid, seed_type \\ "nixos") do 528 550 %SeedDeployment{ 529 551 subscription_sid: "sub_#{seed_sid}", 530 552 seed: %Seed{ 531 553 sid: seed_sid, 532 554 name: "seed-#{seed_sid}", 533 - seed_type: "nixos", 555 + seed_type: seed_type, 534 556 artifact: "/nix/store/#{seed_sid}" 535 557 } 536 558 }
-20
apps/sower/lib/sower/orchestration/subscription.ex
··· 153 153 |> Enum.filter(fn sub -> Policy.has_realtime_trigger?(sub.policy) end) 154 154 end 155 155 156 - def within_window?(%__MODULE__{window: nil}, _now), do: true 157 - 158 - def within_window?(%__MODULE__{window: window}, now) do 159 - local = DateTime.shift_zone!(now, window.tz) 160 - day = local |> DateTime.to_date() |> Date.day_of_week() |> day_name() 161 - time = DateTime.to_time(local) 162 - 163 - day in window.days and 164 - Time.compare(time, Time.from_iso8601!("#{window.time_start}:00")) != :lt and 165 - Time.compare(time, Time.from_iso8601!("#{window.time_end}:00")) != :gt 166 - end 167 - 168 - defp day_name(1), do: "mon" 169 - defp day_name(2), do: "tue" 170 - defp day_name(3), do: "wed" 171 - defp day_name(4), do: "thu" 172 - defp day_name(5), do: "fri" 173 - defp day_name(6), do: "sat" 174 - defp day_name(7), do: "sun" 175 - 176 156 def create_subscription(attrs \\ %{}) do 177 157 attrs = Map.put_new_lazy(attrs, :name, fn -> attrs[:seed_name] end) 178 158
+2
apps/sower/lib/sower_web/live/garden_live/show.ex
··· 197 197 end 198 198 199 199 defp deploy_error_message(:garden_not_found), do: "Garden not found" 200 + defp deploy_error_message(:policy_denied), do: "Denied by policy" 201 + defp deploy_error_message(:confirmation_required), do: "Confirmation required" 200 202 defp deploy_error_message(_), do: "Deployment failed" 201 203 end
+2
apps/sower/lib/sower_web/live/subscription_live/show.ex
··· 104 104 defp page_title(:edit), do: "Edit Subscription" 105 105 106 106 defp deploy_error_message(:garden_not_found), do: "Garden not found" 107 + defp deploy_error_message(:policy_denied), do: "Denied by policy" 108 + defp deploy_error_message(:confirmation_required), do: "Confirmation required" 107 109 defp deploy_error_message(_), do: "Deployment failed" 108 110 end
+68
apps/sower/test/sower/orchestration_test.exs
··· 1442 1442 1443 1443 last_value 1444 1444 end 1445 + 1446 + describe "deploy_subscription/2 policy evaluation" do 1447 + import Sower.OrchestrationFixtures 1448 + 1449 + test "returns policy_denied when policy blocks the trigger" do 1450 + garden = garden_fixture() 1451 + seed_fixture(%{name: "myhost", seed_type: "nixos"}) 1452 + 1453 + sub = 1454 + subscription_fixture(%{ 1455 + garden_id: garden.id, 1456 + seed_name: "myhost", 1457 + seed_type: "nixos", 1458 + policy: [ 1459 + %{actions: ["activate"], triggers: ["scheduled"]} 1460 + ] 1461 + }) 1462 + 1463 + assert {:error, :policy_denied} = 1464 + Orchestration.deploy_subscription(sub, 1465 + actor_sid: "user_test", 1466 + event_reason: :user_triggered 1467 + ) 1468 + end 1469 + 1470 + test "returns confirmation_required when matching rule has confirm" do 1471 + garden = garden_fixture() 1472 + seed_fixture(%{name: "myhost", seed_type: "nixos"}) 1473 + 1474 + sub = 1475 + subscription_fixture(%{ 1476 + garden_id: garden.id, 1477 + seed_name: "myhost", 1478 + seed_type: "nixos", 1479 + policy: [ 1480 + %{actions: ["activate"], triggers: ["manual"], confirm: true} 1481 + ] 1482 + }) 1483 + 1484 + assert {:error, :confirmation_required} = 1485 + Orchestration.deploy_subscription(sub, 1486 + actor_sid: "user_test", 1487 + event_reason: :user_triggered 1488 + ) 1489 + end 1490 + 1491 + @tag :capture_log 1492 + test "allows deployment when policy permits the trigger" do 1493 + garden = garden_fixture() 1494 + seed_fixture(%{name: "myhost", seed_type: "nixos"}) 1495 + 1496 + sub = 1497 + subscription_fixture(%{ 1498 + garden_id: garden.id, 1499 + seed_name: "myhost", 1500 + seed_type: "nixos", 1501 + policy: [ 1502 + %{actions: ["activate"], triggers: ["manual"]} 1503 + ] 1504 + }) 1505 + 1506 + assert {:ok, _request_id, _pid} = 1507 + Orchestration.deploy_subscription(sub, 1508 + actor_sid: "user_test", 1509 + event_reason: :user_triggered 1510 + ) 1511 + end 1512 + end 1445 1513 end
+19
apps/sower_client/lib/sower_client/orchestration/subscription/policy.ex
··· 1 1 defmodule SowerClient.Orchestration.Subscription.Policy do 2 2 use SowerClient.Schema 3 3 4 + require Logger 5 + 4 6 alias SowerClient.Orchestration.Subscription.Window 5 7 6 8 @actions ["stage", "activate", "restart"] ··· 61 63 def evaluate(rules, trigger, now, seed_type, timezone \\ "Etc/UTC") do 62 64 rules = rules |> normalize_rules() |> effective_rules() 63 65 supported_actions = Map.get(@actions_by_seed_type, seed_type, []) 66 + warn_unsupported_actions(rules, supported_actions, seed_type) 64 67 65 68 @disruption_hierarchy 66 69 |> Enum.filter(&(&1 in supported_actions)) ··· 97 100 def highest_permitted_action(rules, now, seed_type, timezone \\ "Etc/UTC") do 98 101 rules = rules |> normalize_rules() |> effective_rules() 99 102 supported_actions = Map.get(@actions_by_seed_type, seed_type, []) 103 + warn_unsupported_actions(rules, supported_actions, seed_type) 100 104 101 105 @disruption_hierarchy 102 106 |> Enum.filter(&(&1 in supported_actions)) ··· 263 267 in_closing_day = yesterday in days and Time.compare(time, end_time) != :gt 264 268 265 269 in_opening_day or in_closing_day 270 + end 271 + 272 + defp warn_unsupported_actions(rules, supported_actions, seed_type) do 273 + Enum.each(rules, fn rule -> 274 + rule_actions(rule) 275 + |> Enum.reject(&(&1 in supported_actions)) 276 + |> Enum.each(fn action -> 277 + Logger.warning( 278 + msg: "Policy rule references unsupported action for seed type", 279 + action: action, 280 + seed_type: seed_type, 281 + supported_actions: supported_actions 282 + ) 283 + end) 284 + end) 266 285 end 267 286 268 287 # Field accessors that work with both maps (string/atom keys) and structs
+31
apps/sower_client/test/sower_client/orchestration/subscription/policy_test.exs
··· 1 1 defmodule SowerClient.Orchestration.Subscription.PolicyTest do 2 2 use ExUnit.Case, async: true 3 3 4 + import ExUnit.CaptureLog 5 + 4 6 alias SowerClient.Orchestration.Subscription.Policy 5 7 6 8 # Wednesday 2026-04-15 at 14:00 UTC ··· 323 325 rules = [%{actions: ["stage", "activate", "restart"]}] 324 326 assert :deny = Policy.evaluate(rules, :manual, @now, "unknown") 325 327 end 328 + 329 + test "logs warning for unsupported action on seed type" do 330 + rules = [%{actions: ["restart", "activate"]}] 331 + 332 + log = 333 + capture_log(fn -> 334 + Policy.evaluate(rules, :manual, @now, "home-manager") 335 + end) 336 + 337 + assert log =~ "unsupported action" 338 + assert log =~ "restart" 339 + assert log =~ "home-manager" 340 + end 326 341 end 327 342 328 343 describe "evaluate/5 — string key maps" do ··· 501 516 502 517 assert "restart" in rule.actions 503 518 assert "realtime" in rule.triggers 519 + end 520 + 521 + test "from_legacy poll_on_connect round-trips through evaluate" do 522 + sub = %{reboot_policy: "never", allow_realtime: false, poll_on_connect: true, window: nil} 523 + policy = Policy.from_legacy(sub) 524 + 525 + now = DateTime.from_naive!(~N[2026-04-15 14:00:00], "Etc/UTC") 526 + assert {:allow, :activate} = Policy.evaluate(policy, :poll_on_connect, now, "nixos") 527 + end 528 + 529 + test "from_legacy without poll_on_connect denies poll_on_connect trigger" do 530 + sub = %{reboot_policy: "never", allow_realtime: false, poll_on_connect: false, window: nil} 531 + policy = Policy.from_legacy(sub) 532 + 533 + now = DateTime.from_naive!(~N[2026-04-15 14:00:00], "Etc/UTC") 534 + assert :deny = Policy.evaluate(policy, :poll_on_connect, now, "nixos") 504 535 end 505 536 end 506 537