Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

refactor: make policy a map in client schema for config ergonomics

Policy rules now support a name field for better config ergonomics,
especially in Nix where attrsets are easier to merge and override than
arrays. Config preprocessing converts map-format policy to array with
names injected, matching the existing subscription naming pattern.

Policy is now a map (keyed by rule name) in the client-side OpenAPI
schema, matching Nix attrset ergonomics. The server continues to store
policy as embeds_many (list) with list<->map conversion at the
registration boundary.

- Change Subscription.policy from array to object with additionalProperties
- Remove name from Policy schema (name is the map key)
- Add normalize_rules/1 to evaluator to handle both map and list formats
- from_legacy/1 now returns a map
- Remove preprocess_policy config preprocessor (schema handles it natively)
- Convert list->map when casting server subscription back to client struct

sow-166

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

+133 -21
+22 -5
apps/sower/lib/sower/orchestration/subscription.ex
··· 209 209 alias SowerClient.Orchestration.Subscription.Policy 210 210 211 211 policy = 212 - if sub.policy == nil or sub.policy == [] do 212 + if sub.policy == nil or sub.policy == %{} or sub.policy == [] do 213 213 Policy.from_legacy(sub) 214 214 else 215 215 sub.policy ··· 227 227 reboot_policy: sub.reboot_policy, 228 228 allow_realtime: sub.allow_realtime, 229 229 window: sub.window, 230 - policy: policy 230 + policy: policy_to_list(policy) 231 231 } 232 232 233 233 case create_subscription(attrs) do 234 234 {:ok, subscription} -> 235 - {:ok, SowerClient.Orchestration.Subscription.cast!(subscription)} 235 + castable = Map.update!(subscription, :policy, &policy_list_to_map/1) 236 + {:ok, SowerClient.Orchestration.Subscription.cast!(castable)} 236 237 237 238 {:error, _} = error -> 238 239 error 239 240 end 241 + end 242 + 243 + defp policy_list_to_map(rules) when is_list(rules) do 244 + Map.new(rules, fn rule -> 245 + name = Map.get(rule, :name) || Map.get(rule, "name") || "unnamed" 246 + {name, rule} 247 + end) 248 + end 249 + 250 + defp policy_to_list(policy) when is_list(policy), do: policy 251 + 252 + defp policy_to_list(policy) when is_map(policy) do 253 + Enum.map(policy, fn {name, rule} -> 254 + Map.put(rule, :name, name) 255 + end) 240 256 end 241 257 242 258 def sync_subscriptions(subscriptions, garden_id) do ··· 433 449 434 450 alias Sower.Orchestration.Subscription.Window 435 451 436 - @derive {Jason.Encoder, only: [:actions, :triggers, :window, :confirm]} 452 + @derive {Jason.Encoder, only: [:name, :actions, :triggers, :window, :confirm]} 437 453 438 454 embedded_schema do 455 + field :name, :string 439 456 field :actions, {:array, :string} 440 457 field :triggers, {:array, :string} 441 458 field :confirm, :boolean, default: false ··· 448 465 449 466 def changeset(rule, attrs) do 450 467 rule 451 - |> cast(attrs, [:actions, :triggers, :confirm]) 468 + |> cast(attrs, [:name, :actions, :triggers, :confirm]) 452 469 |> cast_embed(:window, with: &Window.changeset/2) 453 470 |> validate_required([:actions]) 454 471 end
+5 -4
apps/sower_client/lib/sower_client/orchestration/subscription.ex
··· 70 70 }, 71 71 window: __MODULE__.Window, 72 72 policy: %Schema{ 73 - type: :array, 74 - items: __MODULE__.Policy, 75 - default: [], 76 - description: "Policy rules controlling when and how deployment actions are permitted" 73 + type: :object, 74 + additionalProperties: __MODULE__.Policy, 75 + default: %{}, 76 + description: 77 + "Policy rules controlling when and how deployment actions are permitted, keyed by rule name" 77 78 } 78 79 }, 79 80 required: [:name, :seed_name, :seed_type],
+20 -4
apps/sower_client/lib/sower_client/orchestration/subscription/policy.ex
··· 59 59 Returns `{:allow, action}`, `{:confirm, action}`, or `:deny`. 60 60 """ 61 61 def evaluate(rules, trigger, now, seed_type, timezone \\ "Etc/UTC") do 62 - rules = effective_rules(rules) 62 + rules = rules |> normalize_rules() |> effective_rules() 63 63 supported_actions = Map.get(@actions_by_seed_type, seed_type, []) 64 64 65 65 @disruption_hierarchy ··· 119 119 rule 120 120 end 121 121 122 - [rule] 122 + %{"default" => rule} 123 123 end 124 124 125 125 defp build_legacy_actions(reboot_policy) when reboot_policy in ["always", "when-required"], ··· 153 153 Returns true if any policy rule includes the realtime trigger. 154 154 """ 155 155 def has_realtime_trigger?(rules) do 156 - rules = effective_rules(rules) 156 + rules = rules |> normalize_rules() |> effective_rules() 157 157 158 158 Enum.any?(rules, fn rule -> 159 159 case rule_triggers(rule) do ··· 163 163 end) 164 164 end 165 165 166 + defp normalize_rules(nil), do: nil 167 + defp normalize_rules(rules) when is_list(rules), do: rules 168 + defp normalize_rules(rules) when rules == %{}, do: [] 169 + 170 + defp normalize_rules(rules) when is_map(rules) do 171 + Enum.map(rules, fn {name, rule} -> 172 + Map.put(rule, :name, name) 173 + end) 174 + end 175 + 166 176 defp effective_rules(nil), do: @default_policy 167 177 defp effective_rules([]), do: @default_policy 168 - defp effective_rules(rules), do: rules 178 + defp effective_rules(rules) when rules == %{}, do: @default_policy 179 + 180 + defp effective_rules(rules) when is_map(rules) do 181 + Enum.map(rules, fn {_name, rule} -> rule end) 182 + end 183 + 184 + defp effective_rules(rules) when is_list(rules), do: rules 169 185 170 186 defp action_matches?(rule, action) do 171 187 actions = rule_actions(rule)
+39
apps/sower_client/test/sower_client/config_test.exs
··· 285 285 assert %Config{} = config 286 286 assert config.access_token == "token-from-env" 287 287 end 288 + 289 + test "casts map-format policy from config" do 290 + tmp_dir = System.tmp_dir!() 291 + config_file = Path.join(tmp_dir, "policy_map_config_#{:rand.uniform(1000)}.json") 292 + 293 + config_data = %{ 294 + "endpoint" => "https://my.sower.dev", 295 + "subscriptions" => %{ 296 + "myhost" => %{ 297 + "seed_name" => "myhost", 298 + "seed_type" => "nixos", 299 + "policy" => %{ 300 + "weekday_activate" => %{ 301 + "actions" => ["activate"], 302 + "triggers" => ["manual", "scheduled"] 303 + }, 304 + "weekend_reboot" => %{ 305 + "actions" => ["restart"], 306 + "triggers" => ["manual"] 307 + } 308 + } 309 + } 310 + } 311 + } 312 + 313 + File.write!(config_file, Jason.encode!(config_data)) 314 + on_exit(fn -> File.rm(config_file) end) 315 + 316 + config = Config.load(config_path: config_file) 317 + 318 + assert %Config{} = config 319 + [sub] = config.subscriptions 320 + assert sub.name == "myhost" 321 + assert is_map(sub.policy) 322 + assert map_size(sub.policy) == 2 323 + assert Map.has_key?(sub.policy, "weekday_activate") 324 + assert Map.has_key?(sub.policy, "weekend_reboot") 325 + assert sub.policy["weekday_activate"].actions == ["activate"] 326 + end 288 327 end 289 328 290 329 # Helper to temporarily set environment variables
+47 -8
apps/sower_client/test/sower_client/orchestration/subscription/policy_test.exs
··· 397 397 end 398 398 399 399 describe "from_legacy/1" do 400 + test "returns a map keyed by rule name" do 401 + sub = %{reboot_policy: "never", allow_realtime: false, poll_on_connect: false, window: nil} 402 + policy = Policy.from_legacy(sub) 403 + 404 + assert is_map(policy) 405 + assert Map.has_key?(policy, "default") 406 + end 407 + 400 408 test "basic subscription with defaults" do 401 409 sub = %{reboot_policy: "never", allow_realtime: false, poll_on_connect: false, window: nil} 402 - [rule] = Policy.from_legacy(sub) 410 + %{"default" => rule} = Policy.from_legacy(sub) 403 411 404 412 assert rule.actions == ["stage", "activate"] 405 413 assert rule.triggers == ["manual", "scheduled"] ··· 408 416 409 417 test "reboot_policy always adds restart action" do 410 418 sub = %{reboot_policy: "always", allow_realtime: false, poll_on_connect: false, window: nil} 411 - [rule] = Policy.from_legacy(sub) 419 + %{"default" => rule} = Policy.from_legacy(sub) 412 420 413 421 assert "restart" in rule.actions 414 422 end ··· 421 429 window: nil 422 430 } 423 431 424 - [rule] = Policy.from_legacy(sub) 432 + %{"default" => rule} = Policy.from_legacy(sub) 425 433 426 434 assert "restart" in rule.actions 427 435 end 428 436 429 437 test "allow_realtime adds realtime trigger" do 430 438 sub = %{reboot_policy: "never", allow_realtime: true, poll_on_connect: false, window: nil} 431 - [rule] = Policy.from_legacy(sub) 439 + %{"default" => rule} = Policy.from_legacy(sub) 432 440 433 441 assert "realtime" in rule.triggers 434 442 end 435 443 436 444 test "poll_on_connect adds poll_on_connect trigger" do 437 445 sub = %{reboot_policy: "never", allow_realtime: false, poll_on_connect: true, window: nil} 438 - [rule] = Policy.from_legacy(sub) 446 + %{"default" => rule} = Policy.from_legacy(sub) 439 447 440 448 assert "poll_on_connect" in rule.triggers 441 449 end ··· 455 463 window: window 456 464 } 457 465 458 - [rule] = Policy.from_legacy(sub) 466 + %{"default" => rule} = Policy.from_legacy(sub) 459 467 460 468 assert rule.window.days == ["mon", "fri"] 461 469 assert rule.window.time_start == "09:00" ··· 471 479 window: %{days: ["mon"], time_start: "02:00", time_end: "06:00", tz: "Etc/UTC"} 472 480 } 473 481 474 - [rule] = Policy.from_legacy(sub) 482 + %{"default" => rule} = Policy.from_legacy(sub) 475 483 476 484 assert rule.actions == ["stage", "activate", "restart"] 477 485 assert "manual" in rule.triggers ··· 489 497 "window" => nil 490 498 } 491 499 492 - [rule] = Policy.from_legacy(sub) 500 + %{"default" => rule} = Policy.from_legacy(sub) 493 501 494 502 assert "restart" in rule.actions 495 503 assert "realtime" in rule.triggers ··· 515 523 test "returns true with default policy (no realtime)" do 516 524 # Default policy has manual, scheduled, poll_on_connect — no realtime 517 525 refute Policy.has_realtime_trigger?([]) 526 + end 527 + 528 + test "works with map-format policy" do 529 + rules = %{"rt_rule" => %{actions: ["activate"], triggers: ["realtime"]}} 530 + assert Policy.has_realtime_trigger?(rules) 531 + end 532 + 533 + test "returns false with empty map" do 534 + refute Policy.has_realtime_trigger?(%{}) 535 + end 536 + end 537 + 538 + describe "evaluate/5 — map-format policy" do 539 + test "evaluates map-format policy" do 540 + policy = %{ 541 + "manual_activate" => %{actions: ["activate"], triggers: ["manual"]}, 542 + "reboot_window" => %{ 543 + actions: ["restart"], 544 + window: %{ 545 + days: ["mon", "tue", "wed", "thu", "fri", "sat", "sun"], 546 + time_start: "02:00", 547 + time_end: "04:00" 548 + } 549 + } 550 + } 551 + 552 + assert {:allow, :activate} = Policy.evaluate(policy, :manual, @now, "nixos") 553 + end 554 + 555 + test "empty map uses default policy" do 556 + assert {:allow, :activate} = Policy.evaluate(%{}, :manual, @now, "nixos") 518 557 end 519 558 end 520 559 end