Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

feat: garden-side deployment policy adoption (Phase 3)

Garden now uses policy rules for all deployment decisions:
- Add Policy.highest_permitted_action/4 for trigger-agnostic action resolution
- Replace poll_on_connect field filter with Policy.evaluate in Lifecycle
- Add policy evaluation in Scheduler before deploying (deny logs warning)
- Replace Garden.Seed.activation_mode with policy-derived action in Deployer
- Replace reboot_reason logic: restart permitted by policy + boot profile changed
- Add from_legacy conversion with deprecation warning in config preprocessing
- Stage-only policy skips activation (download only)

sow-163

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

+353 -133
+97 -79
apps/garden/lib/garden/deployer.ex
··· 10 10 alias SowerClient.Orchestration.SeedDeploymentResult 11 11 alias SowerClient.Orchestration.SeedDeploymentStatus 12 12 alias SowerClient.Orchestration.Subscription 13 + alias SowerClient.Orchestration.Subscription.Policy 13 14 14 15 @rebootable_seed_types ["nixos"] 15 16 ··· 96 97 |> async_stream_fun.(fn 97 98 {:ok, {downloading_line, {:ok, %SeedDeployment{seed: seed} = seed_deploy, download_output}}} -> 98 99 subscription = find_subscription_fun.(seed_deploy.subscription_sid) || %Subscription{} 99 - mode = Garden.Seed.activation_mode(subscription) 100 + 101 + action = 102 + Policy.highest_permitted_action( 103 + subscription.policy, 104 + DateTime.utc_now(), 105 + subscription.seed_type, 106 + subscription.timezone 107 + ) 108 + 109 + mode = action_to_mode(action) 100 110 101 111 preamble = 102 112 [downloading_line | download_output] ++ 103 - [ 104 - decision_line("realized #{seed.name} (#{seed.seed_type})"), 105 - decision_line("activating #{seed.name} (#{seed.seed_type}) with mode: #{mode}") 106 - ] 113 + [decision_line("realized #{seed.name} (#{seed.seed_type})")] 107 114 108 - Logger.info( 109 - msg: "Activating seed", 110 - name: seed.name, 111 - seed_sid: seed.sid, 112 - seed_type: seed.seed_type, 113 - artifact: seed.artifact, 114 - deployment_sid: deployment.sid, 115 - activation_args: get_in(subscription.activation_args) 116 - ) 115 + if mode == nil do 116 + Logger.info( 117 + msg: "Stage only — skipping activation", 118 + name: seed.name, 119 + seed_sid: seed.sid, 120 + deployment_sid: deployment.sid 121 + ) 117 122 118 - report_seed_status_fun.(deployment, seed, :activating) 119 - result = activate_seed_fun.(seed, subscription) 123 + preamble = 124 + preamble ++ 125 + [decision_line("staged #{seed.name} (#{seed.seed_type}), activation not permitted")] 120 126 121 - case result do 122 - {:ok, output} -> 123 - Logger.info( 124 - msg: "Completed activation", 125 - deployment_sid: deployment.sid, 126 - seed_sid: seed.sid 127 - ) 127 + report_seed_status_fun.(deployment, seed, :completed) 128 + report_seed_result_fun.(deployment, seed, :success, preamble) 129 + {:ok, ["staged"]} 130 + else 131 + preamble = 132 + preamble ++ 133 + [decision_line("activating #{seed.name} (#{seed.seed_type}) with mode: #{mode}")] 128 134 129 - report_seed_status_fun.(deployment, seed, :completed) 130 - report_seed_result_fun.(deployment, seed, :success, preamble ++ output) 135 + Logger.info( 136 + msg: "Activating seed", 137 + name: seed.name, 138 + seed_sid: seed.sid, 139 + seed_type: seed.seed_type, 140 + artifact: seed.artifact, 141 + deployment_sid: deployment.sid, 142 + mode: mode 143 + ) 131 144 132 - {:error, _code, output} -> 133 - Logger.error( 134 - msg: "Error during activation", 135 - deployment_sid: deployment.sid, 136 - seed_sid: seed.sid 137 - ) 145 + # Pass the policy-derived mode to the subscription so activate uses it 146 + subscription = %{subscription | activation_args: [mode]} 147 + report_seed_status_fun.(deployment, seed, :activating) 148 + result = activate_seed_fun.(seed, subscription) 138 149 139 - report_seed_result_fun.(deployment, seed, :failure, preamble ++ output) 150 + case result do 151 + {:ok, output} -> 152 + Logger.info( 153 + msg: "Completed activation", 154 + deployment_sid: deployment.sid, 155 + seed_sid: seed.sid 156 + ) 140 157 141 - {:error, reason} when reason in [:activator_unavailable, :cmd_not_found] -> 142 - Logger.error( 143 - msg: "Missing activator during deployment activation", 144 - deployment_sid: deployment.sid, 145 - seed_sid: seed.sid, 146 - reason: inspect(reason) 147 - ) 158 + report_seed_status_fun.(deployment, seed, :completed) 159 + report_seed_result_fun.(deployment, seed, :success, preamble ++ output) 148 160 149 - report_seed_result_fun.( 150 - deployment, 151 - seed, 152 - :failure, 153 - preamble ++ 154 - [ 155 - "FATAL: missing activator executable sower-activator; deployment cannot continue" 156 - ] 157 - ) 161 + {:error, _code, output} -> 162 + Logger.error( 163 + msg: "Error during activation", 164 + deployment_sid: deployment.sid, 165 + seed_sid: seed.sid 166 + ) 158 167 159 - {:error, _reason} -> 160 - :ok 161 - end 168 + report_seed_result_fun.(deployment, seed, :failure, preamble ++ output) 162 169 163 - result 170 + {:error, reason} when reason in [:activator_unavailable, :cmd_not_found] -> 171 + Logger.error( 172 + msg: "Missing activator during deployment activation", 173 + deployment_sid: deployment.sid, 174 + seed_sid: seed.sid, 175 + reason: inspect(reason) 176 + ) 177 + 178 + report_seed_result_fun.( 179 + deployment, 180 + seed, 181 + :failure, 182 + preamble ++ 183 + [ 184 + "FATAL: missing activator executable sower-activator; deployment cannot continue" 185 + ] 186 + ) 187 + 188 + {:error, _reason} -> 189 + :ok 190 + end 191 + 192 + result 193 + end 164 194 165 195 {:ok, 166 196 {downloading_line, ··· 345 375 find_sub \\ &find_subscription/1, 346 376 read_link \\ &:file.read_link_all/1 347 377 ) do 348 - subscriptions = 378 + now = DateTime.utc_now() 379 + 380 + restart_permitted = 349 381 seed_deployments 350 382 |> Enum.filter(&(get_in(&1.seed.seed_type) in @rebootable_seed_types)) 351 - |> Enum.map(fn %SeedDeployment{subscription_sid: subscription_sid} -> 352 - find_sub.(subscription_sid) || %Subscription{} 383 + |> Enum.any?(fn %SeedDeployment{subscription_sid: subscription_sid} -> 384 + sub = find_sub.(subscription_sid) || %Subscription{} 385 + Policy.highest_permitted_action(sub.policy, now, sub.seed_type, sub.timezone) == :restart 353 386 end) 354 387 355 - cond do 356 - subscriptions == [] -> 357 - nil 358 - 359 - Enum.any?(subscriptions, fn sub -> 360 - sub.reboot_policy == "always" 361 - end) -> 362 - "policy_always" 363 - 364 - Enum.any?(subscriptions, fn sub -> 365 - sub.reboot_policy == "when-required" and 366 - Garden.Seed.activation_mode(sub) == "boot" and 367 - not is_nil(detect_boot_critical_change_reason(read_link)) 368 - end) -> 369 - "boot_mode" 370 - 371 - Enum.any?(subscriptions, fn sub -> 372 - sub.reboot_policy == "when-required" and 373 - Garden.Seed.activation_mode(sub) == "switch" 374 - end) -> 375 - detect_boot_critical_change_reason(read_link) 376 - 377 - true -> 378 - nil 388 + if restart_permitted do 389 + detect_boot_critical_change_reason(read_link) 390 + else 391 + nil 379 392 end 380 393 end 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" 381 399 382 400 defp report_seed_status(%Deployment{} = deployment, seed, status) do 383 401 seed_status =
+29 -6
apps/garden/lib/garden/scheduler.ex
··· 137 137 ) 138 138 139 139 subscription -> 140 - Logger.info( 141 - msg: "Running scheduled deployment", 142 - subscription_sid: sid, 143 - schedule: schedule 144 - ) 140 + alias SowerClient.Orchestration.Subscription.Policy 141 + 142 + case Policy.evaluate( 143 + subscription.policy, 144 + :scheduled, 145 + DateTime.utc_now(), 146 + subscription.seed_type, 147 + subscription.timezone 148 + ) do 149 + {:allow, _} -> 150 + Logger.info( 151 + msg: "Running scheduled deployment", 152 + subscription_sid: sid, 153 + schedule: schedule 154 + ) 155 + 156 + deploy_fun.(subscription) 157 + 158 + {:confirm, _} -> 159 + Logger.info( 160 + msg: "Scheduled deploy requires confirmation, skipping", 161 + subscription_sid: sid 162 + ) 145 163 146 - deploy_fun.(subscription) 164 + :deny -> 165 + Logger.warning( 166 + msg: "Scheduled deploy denied by policy", 167 + subscription_sid: sid 168 + ) 169 + end 147 170 end 148 171 end 149 172 end
+9 -1
apps/garden/lib/garden/socket/lifecycle.ex
··· 11 11 alias SowerClient.Orchestration.DeploymentRequest 12 12 alias SowerClient.Orchestration.DeploymentResult 13 13 alias SowerClient.Orchestration.Subscription 14 + alias SowerClient.Orchestration.Subscription.Policy 14 15 15 16 def build_seed_report( 16 17 subscriptions, ··· 55 56 end 56 57 57 58 def poll_on_connect_subscriptions(subscriptions) do 58 - Enum.filter(subscriptions, & &1.poll_on_connect) 59 + now = DateTime.utc_now() 60 + 61 + Enum.filter(subscriptions, fn sub -> 62 + case Policy.evaluate(sub.policy, :poll_on_connect, now, sub.seed_type, sub.timezone) do 63 + {:allow, _} -> true 64 + _ -> false 65 + end 66 + end) 59 67 end 60 68 61 69 def complete_deployment(sid, result, active_deployments) do
+39 -41
apps/garden/test/garden/deployer_test.exs
··· 97 97 assert Deployer.reboot_reason(seed_deployments, fn _ -> %Subscription{} end) == nil 98 98 end 99 99 100 - test "returns policy_always when profile reboot policy is always" do 101 - seed_deployments = [seed_deploy("sub_always")] 100 + test "returns reason when policy permits restart and boot profile changed" do 101 + seed_deployments = [seed_deploy("sub_restart")] 102 102 103 - get_profile = fn "sub_always" -> 104 - %Subscription{activation_args: ["switch"], reboot_policy: "always"} 105 - end 106 - 107 - assert Deployer.reboot_reason(seed_deployments, get_profile) == "policy_always" 108 - end 109 - 110 - test "returns boot_mode when profile is when-required and activation mode is boot" do 111 - seed_deployments = [seed_deploy("sub_boot")] 112 - 113 - get_profile = fn "sub_boot" -> 114 - %Subscription{activation_args: ["boot"], reboot_policy: "when-required"} 103 + get_sub = fn "sub_restart" -> 104 + %Subscription{ 105 + seed_type: "nixos", 106 + policy: [%{actions: ["restart"]}] 107 + } 115 108 end 116 109 117 110 read_link = fn ··· 120 113 "/run/booted-system" -> {:ok, "/nix/store/sys-a"} 121 114 end 122 115 123 - assert Deployer.reboot_reason(seed_deployments, get_profile, read_link) == "boot_mode" 116 + assert Deployer.reboot_reason(seed_deployments, get_sub, read_link) == "system_changed" 124 117 end 125 118 126 - test "returns nil when boot profile already matches running and booted system" do 127 - seed_deployments = [seed_deploy("sub_boot")] 119 + test "returns nil when policy does not permit restart" do 120 + seed_deployments = [seed_deploy("sub_no_restart")] 128 121 129 - get_profile = fn "sub_boot" -> 130 - %Subscription{activation_args: ["boot"], reboot_policy: "when-required"} 122 + get_sub = fn "sub_no_restart" -> 123 + %Subscription{ 124 + seed_type: "nixos", 125 + policy: [%{actions: ["activate"]}] 126 + } 131 127 end 132 128 133 - read_link = fn 134 - "/nix/var/nix/profiles/system" -> {:ok, "/nix/var/nix/profiles/system-123-link"} 135 - "/nix/var/nix/profiles/system-123-link" -> {:ok, "/nix/store/sys-a"} 136 - "/run/current-system" -> {:ok, "/nix/store/sys-a"} 137 - "/run/booted-system" -> {:ok, "/nix/store/sys-a"} 138 - end 139 - 140 - assert Deployer.reboot_reason(seed_deployments, get_profile, read_link) == nil 129 + assert Deployer.reboot_reason(seed_deployments, get_sub) == nil 141 130 end 142 131 143 - test "returns initrd_changed when when-required switch profile has boot-critical changes" do 144 - seed_deployments = [seed_deploy("sub_switch")] 132 + test "returns nil when boot profile already matches" do 133 + seed_deployments = [seed_deploy("sub_restart")] 145 134 146 - get_profile = fn "sub_switch" -> 147 - %Subscription{activation_args: ["switch"], reboot_policy: "when-required"} 135 + get_sub = fn "sub_restart" -> 136 + %Subscription{ 137 + seed_type: "nixos", 138 + policy: [%{actions: ["restart"]}] 139 + } 148 140 end 149 141 150 142 read_link = fn 151 - "/nix/var/nix/profiles/system" -> {:ok, "/nix/store/sys-a"} 143 + "/nix/var/nix/profiles/system" -> {:ok, "/nix/var/nix/profiles/system-123-link"} 144 + "/nix/var/nix/profiles/system-123-link" -> {:ok, "/nix/store/sys-a"} 152 145 "/run/current-system" -> {:ok, "/nix/store/sys-a"} 153 - "/run/booted-system" -> {:ok, "/nix/store/sys-b"} 146 + "/run/booted-system" -> {:ok, "/nix/store/sys-a"} 154 147 end 155 148 156 - assert Deployer.reboot_reason(seed_deployments, get_profile, read_link) == "initrd_changed" 149 + assert Deployer.reboot_reason(seed_deployments, get_sub, read_link) == nil 157 150 end 158 151 159 152 test "returns nil and logs warning when boot-critical detection cannot read links" do 160 - seed_deployments = [seed_deploy("sub_switch")] 153 + seed_deployments = [seed_deploy("sub_restart")] 161 154 162 - get_profile = fn "sub_switch" -> 163 - %Subscription{activation_args: ["switch"], reboot_policy: "when-required"} 155 + get_sub = fn "sub_restart" -> 156 + %Subscription{ 157 + seed_type: "nixos", 158 + policy: [%{actions: ["restart"]}] 159 + } 164 160 end 165 161 166 162 logs = 167 163 capture_log(fn -> 168 - assert Deployer.reboot_reason(seed_deployments, get_profile, fn _ -> 164 + assert Deployer.reboot_reason(seed_deployments, get_sub, fn _ -> 169 165 {:error, :enoent} 170 - end) == 171 - nil 166 + end) == nil 172 167 end) 173 168 174 169 assert logs =~ "Could not evaluate reboot requirement from system profile links" ··· 320 315 logged_lines = 321 316 capture_seed_result_lines(deployment, 322 317 find_subscription_fun: fn _ -> 323 - %Subscription{activation_args: ["boot"]} 318 + %Subscription{ 319 + seed_type: "nixos", 320 + policy: [%{actions: ["restart"]}] 321 + } 324 322 end 325 323 ) 326 324
+25
apps/garden/test/garden/scheduler_test.exs
··· 105 105 assert log =~ "Subscription not found for scheduled deployment" 106 106 end 107 107 108 + test "skips when policy denies scheduled trigger", %{check_cooldown: check_cooldown} do 109 + sid = "sub_denied_#{System.unique_integer([:positive])}" 110 + 111 + sub = %Subscription{ 112 + sid: sid, 113 + seed_name: "test", 114 + seed_type: "nixos", 115 + policy: [%{actions: ["activate"], triggers: ["manual"]}] 116 + } 117 + 118 + test_pid = self() 119 + 120 + log = 121 + capture_log([level: :info], fn -> 122 + Scheduler.deploy_if_not_cooled_down(sid, "0 3", 123 + deploy_fun: fn _sub -> send(test_pid, :deployed) end, 124 + check_cooldown: check_cooldown, 125 + read_subscriptions: fn -> [sub] end 126 + ) 127 + end) 128 + 129 + refute_received :deployed 130 + assert log =~ "Scheduled deploy denied by policy" 131 + end 132 + 108 133 test "rapid-fire calls only deploy once", %{check_cooldown: check_cooldown} do 109 134 sid = "sub_rapid_#{System.unique_integer([:positive])}" 110 135 sub = %Subscription{sid: sid, seed_name: "test", seed_type: "nixos"}
+27 -6
apps/garden/test/garden/socket/lifecycle_test.exs
··· 112 112 end 113 113 114 114 describe "poll_on_connect_subscriptions/1" do 115 - test "filters to subscriptions with poll_on_connect true" do 115 + test "includes subscriptions with poll_on_connect in policy" do 116 116 subs = [ 117 117 Subscription.cast!(%{ 118 118 name: "host", 119 119 seed_name: "host", 120 120 seed_type: "nixos", 121 - poll_on_connect: true, 122 - sid: "sub_1" 121 + sid: "sub_1", 122 + policy: %{ 123 + "default" => %{actions: ["activate"], triggers: ["poll_on_connect"]} 124 + } 123 125 }), 124 126 Subscription.cast!(%{ 125 127 name: "user", 126 128 seed_name: "user", 127 129 seed_type: "home-manager", 128 - sid: "sub_2" 130 + sid: "sub_2", 131 + policy: %{ 132 + "default" => %{actions: ["activate"], triggers: ["manual"]} 133 + } 129 134 }) 130 135 ] 131 136 ··· 135 140 assert hd(result).seed_name == "host" 136 141 end 137 142 138 - test "returns empty list when none have poll_on_connect" do 143 + test "returns empty list when no policy permits poll_on_connect" do 144 + subs = [ 145 + Subscription.cast!(%{ 146 + name: "host", 147 + seed_name: "host", 148 + seed_type: "nixos", 149 + sid: "sub_1", 150 + policy: %{ 151 + "default" => %{actions: ["activate"], triggers: ["manual", "scheduled"]} 152 + } 153 + }) 154 + ] 155 + 156 + assert Lifecycle.poll_on_connect_subscriptions(subs) == [] 157 + end 158 + 159 + test "default policy includes poll_on_connect" do 139 160 subs = [ 140 161 Subscription.cast!(%{name: "host", seed_name: "host", seed_type: "nixos", sid: "sub_1"}) 141 162 ] 142 163 143 - assert Lifecycle.poll_on_connect_subscriptions(subs) == [] 164 + assert length(Lifecycle.poll_on_connect_subscriptions(subs)) == 1 144 165 end 145 166 end 146 167
+27
apps/sower_client/lib/sower_client/config.ex
··· 365 365 |> Map.put("name", name) 366 366 |> parse_subscription_rules() 367 367 |> fill_default_subscription_name() 368 + |> maybe_convert_legacy_policy() 368 369 end) 369 370 370 371 Map.put(config, "subscriptions", normalized_subscriptions) ··· 394 395 end 395 396 396 397 defp fill_default_subscription_name(sub), do: sub 398 + 399 + @legacy_policy_fields ["reboot_policy", "allow_realtime", "activation_args", "window"] 400 + 401 + defp maybe_convert_legacy_policy(%{"policy" => policy} = sub) 402 + when is_map(policy) and map_size(policy) > 0 do 403 + sub 404 + end 405 + 406 + defp maybe_convert_legacy_policy(sub) do 407 + has_legacy = Enum.any?(@legacy_policy_fields, &Map.has_key?(sub, &1)) 408 + 409 + if has_legacy do 410 + name = Map.get(sub, "name", "unknown") 411 + 412 + Logger.warning( 413 + msg: "Subscription uses deprecated policy fields, convert to policy map", 414 + subscription: name, 415 + deprecated_fields: Enum.filter(@legacy_policy_fields, &Map.has_key?(sub, &1)) 416 + ) 417 + 418 + policy = SowerClient.Orchestration.Subscription.Policy.from_legacy(sub) 419 + Map.put(sub, "policy", policy) 420 + else 421 + sub 422 + end 423 + end 397 424 398 425 defp process_side_effects(%SowerClient.Config{} = config) do 399 426 # Configure websocket client (only if endpoint is set)
+23
apps/sower_client/lib/sower_client/orchestration/subscription/policy.ex
··· 87 87 end 88 88 89 89 @doc """ 90 + Returns the highest-disruption action permitted by any rule at the given time, 91 + ignoring triggers. Used by the garden deployer to determine activation mode — 92 + the server already approved the deployment, the garden just needs to know how 93 + far it can go right now. 94 + 95 + Returns `:restart`, `:activate`, `:stage`, or `nil`. 96 + """ 97 + def highest_permitted_action(rules, now, seed_type, timezone \\ "Etc/UTC") do 98 + rules = rules |> normalize_rules() |> effective_rules() 99 + supported_actions = Map.get(@actions_by_seed_type, seed_type, []) 100 + 101 + @disruption_hierarchy 102 + |> Enum.filter(&(&1 in supported_actions)) 103 + |> Enum.find_value(nil, fn action -> 104 + if Enum.any?(rules, fn rule -> 105 + action_matches?(rule, action) and window_matches?(rule, now, timezone) 106 + end) do 107 + String.to_existing_atom(action) 108 + end 109 + end) 110 + end 111 + 112 + @doc """ 90 113 Map an audit reason to a policy trigger. 91 114 """ 92 115 def trigger_for_reason(:user_triggered), do: :manual
+77
apps/sower_client/test/sower_client/orchestration/subscription/policy_test.exs
··· 556 556 assert {:allow, :activate} = Policy.evaluate(%{}, :manual, @now, "nixos") 557 557 end 558 558 end 559 + 560 + describe "highest_permitted_action/4" do 561 + test "returns highest action permitted by any rule" do 562 + rules = [ 563 + %{actions: ["activate"], triggers: ["manual"]}, 564 + %{actions: ["restart"], triggers: ["scheduled"]} 565 + ] 566 + 567 + assert :restart = Policy.highest_permitted_action(rules, @now, "nixos") 568 + end 569 + 570 + test "respects window constraints" do 571 + rules = [ 572 + %{actions: ["activate"]}, 573 + %{ 574 + actions: ["restart"], 575 + window: %{days: ["sat"], time_start: "02:00", time_end: "04:00"} 576 + } 577 + ] 578 + 579 + # Wednesday — restart window closed, activate is highest 580 + assert :activate = Policy.highest_permitted_action(rules, @now, "nixos") 581 + end 582 + 583 + test "returns restart when window is open" do 584 + # Friday at 23:00 585 + friday_23 = DateTime.from_naive!(~N[2026-04-17 23:00:00], "Etc/UTC") 586 + 587 + rules = [ 588 + %{actions: ["activate"]}, 589 + %{ 590 + actions: ["restart"], 591 + window: %{days: ["fri"], time_start: "22:00", time_end: "06:00"} 592 + } 593 + ] 594 + 595 + assert :restart = Policy.highest_permitted_action(rules, friday_23, "nixos") 596 + end 597 + 598 + test "skips unsupported actions for seed type" do 599 + rules = [%{actions: ["restart", "activate", "stage"]}] 600 + 601 + assert :activate = Policy.highest_permitted_action(rules, @now, "home-manager") 602 + end 603 + 604 + test "returns nil when no actions permitted" do 605 + rules = [ 606 + %{ 607 + actions: ["activate"], 608 + window: %{days: ["sat"], time_start: "02:00", time_end: "04:00"} 609 + } 610 + ] 611 + 612 + # Wednesday — window closed, nothing permitted 613 + assert nil == Policy.highest_permitted_action(rules, @now, "nixos") 614 + end 615 + 616 + test "ignores triggers" do 617 + rules = [%{actions: ["restart"], triggers: ["manual"]}] 618 + 619 + # Triggers are ignored — restart is permitted 620 + assert :restart = Policy.highest_permitted_action(rules, @now, "nixos") 621 + end 622 + 623 + test "works with map-format policy" do 624 + policy = %{ 625 + "activate_rule" => %{actions: ["activate"]}, 626 + "restart_rule" => %{actions: ["restart"]} 627 + } 628 + 629 + assert :restart = Policy.highest_permitted_action(policy, @now, "nixos") 630 + end 631 + 632 + test "default policy returns activate" do 633 + assert :activate = Policy.highest_permitted_action([], @now, "nixos") 634 + end 635 + end 559 636 end