Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

feat: switch subscription config to map format, remove deployment profiles

Subscriptions are now configured as a map (name -> config) instead of a
list. The map key becomes the subscription name. This is a breaking
change to the config file format.

Also removes deployment_profiles and default_deployment_profile from
the Config schema since the deployer already uses defaults.

refactor: remove deployment profile lookup chain from garden deployer

get_deployment_profile, find_deployment_profile, default_deployment_profile,
and find_subscription were all dead code after removing deployment_profiles
from config. The deployer now uses %DeploymentProfile{} defaults directly.

SOW-119

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

+29 -189
+3 -54
apps/garden/lib/garden/deployer.ex
··· 2 2 require Logger 3 3 4 4 alias Garden.Socket 5 - alias Garden.Config 6 - alias Garden.Storage 5 + 7 6 alias SowerClient.Activator 8 7 alias SowerClient.Orchestration.Deployment 9 8 alias SowerClient.Orchestration.DeploymentProfile ··· 68 67 realize_seed_fun = Keyword.get(opts, :realize_seed_fun, &realize_seed/1) 69 68 70 69 get_deployment_profile_fun = 71 - Keyword.get(opts, :get_deployment_profile_fun, &get_deployment_profile/1) 70 + Keyword.get(opts, :get_deployment_profile_fun, fn _sid -> %DeploymentProfile{} end) 72 71 73 72 activate_seed_fun = Keyword.get(opts, :activate_seed_fun, &Garden.Seed.activate/2) 74 73 ··· 226 225 ) 227 226 end 228 227 229 - def get_deployment_profile(nil), do: nil 230 - 231 - def get_deployment_profile( 232 - subscription_sid, 233 - find_sub \\ &find_subscription/1, 234 - find_profile \\ &find_deployment_profile/1 235 - ) do 236 - case find_sub.(subscription_sid) do 237 - nil -> 238 - Logger.info( 239 - msg: "Subscription not found, using defaults", 240 - deploy_subscription_sid: subscription_sid 241 - ) 242 - 243 - %DeploymentProfile{} 244 - 245 - sub -> 246 - profile_name = default_deployment_profile() 247 - 248 - Logger.info( 249 - msg: "Using default deployment profile", 250 - default_deployment_profile: profile_name, 251 - deploy_subscription_sid: subscription_sid, 252 - subscription_seed_name: get_in(sub.seed_name), 253 - subscription_seed_type: get_in(sub.seed_type) 254 - ) 255 - 256 - subscription_overrides = find_profile.(profile_name) || %DeploymentProfile{} 257 - 258 - %DeploymentProfile{} 259 - |> Map.merge(subscription_overrides) 260 - end 261 - end 262 - 263 - defp default_deployment_profile() do 264 - Config.get() 265 - |> Kernel.||(%{}) 266 - |> Map.get(:default_deployment_profile) 267 - |> Kernel.||("default") 268 - end 269 - 270 - def find_deployment_profile(name) do 271 - config = Config.get() 272 - get_in(config.deployment_profiles[name]) || %DeploymentProfile{} 273 - end 274 - 275 - defp find_subscription(sid) do 276 - Storage.read().subscriptions |> Enum.find(&(&1.sid == sid)) 277 - end 278 - 279 228 def maybe_reboot(%Deployment{} = deployment, result) do 280 229 maybe_reboot(deployment, result, []) 281 230 end ··· 371 320 372 321 def reboot_reason( 373 322 seed_deployments, 374 - get_profile \\ &get_deployment_profile/1, 323 + get_profile \\ fn _sid -> %DeploymentProfile{} end, 375 324 read_link \\ &:file.read_link_all/1 376 325 ) do 377 326 profiles =
-84
apps/garden/test/garden/deployer_test.exs
··· 8 8 alias SowerClient.Orchestration.Deployment 9 9 alias SowerClient.Orchestration.SeedDeployment 10 10 alias SowerClient.Orchestration.DeploymentProfile 11 - alias SowerClient.Orchestration.Subscription 12 11 alias SowerClient.Seed 13 - 14 - describe "get_deployment_profile/3" do 15 - test "returns nil for nil subscription sid" do 16 - assert Deployer.get_deployment_profile(nil) == nil 17 - end 18 - 19 - test "returns defaults and logs when subscription is missing" do 20 - assert Deployer.get_deployment_profile("sub_missing", fn _sid -> nil end, fn _name -> 21 - %{} 22 - end) == 23 - %DeploymentProfile{} 24 - end 25 - 26 - test "uses the default profile name when subscription deployment profile is not set" do 27 - sid = "sub_default" 28 - 29 - sub = %Subscription{ 30 - sid: sid, 31 - seed_name: "kale", 32 - seed_type: "nixos" 33 - } 34 - 35 - assert Deployer.get_deployment_profile( 36 - sid, 37 - fn _ -> sub end, 38 - fn 39 - "default" -> %{activation_args: ["boot"], reboot_policy: "always"} 40 - other -> flunk("expected \"default\" profile lookup, got: #{inspect(other)}") 41 - end 42 - ) == %DeploymentProfile{ 43 - activation_args: ["boot"], 44 - reboot_policy: "always" 45 - } 46 - end 47 - 48 - test "uses default deployment profile to resolve profile overrides" do 49 - sid = "sub_boot" 50 - 51 - sub = %Subscription{sid: sid} 52 - 53 - find_sub = fn 54 - ^sid -> sub 55 - _ -> nil 56 - end 57 - 58 - find_profile = fn 59 - "default" -> %{activation_args: ["boot"], reboot_policy: "always"} 60 - _ -> %{} 61 - end 62 - 63 - assert Deployer.get_deployment_profile(sid, find_sub, find_profile) == %DeploymentProfile{ 64 - activation_args: ["boot"], 65 - reboot_policy: "always" 66 - } 67 - end 68 - 69 - test "keeps defaults for fields not present in resolved profile overrides" do 70 - sid = "sub_partial" 71 - 72 - sub = %Subscription{sid: sid} 73 - 74 - assert Deployer.get_deployment_profile( 75 - sid, 76 - fn _ -> sub end, 77 - fn _ -> %{activation_args: ["boot"]} end 78 - ) == %DeploymentProfile{ 79 - activation_args: ["boot"], 80 - reboot_policy: "never" 81 - } 82 - end 83 - 84 - test "falls back to defaults when profile is not found" do 85 - sid = "sub_unknown_profile" 86 - 87 - sub = %Subscription{sid: sid} 88 - 89 - assert Deployer.get_deployment_profile( 90 - sid, 91 - fn _ -> sub end, 92 - fn _ -> nil end 93 - ) == %DeploymentProfile{} 94 - end 95 - end 96 12 97 13 describe "deployment_result/1" do 98 14 test "returns :success when all seed activations succeed" do
+2
apps/sower_cli/test/sower_cli/config_test.exs
··· 5 5 test "loads config and stores in application env" do 6 6 config = 7 7 SowerCli.Config.load( 8 + skip_config_file: true, 8 9 overrides: %{"endpoint" => "https://test.com", "access_token_file" => nil} 9 10 ) 10 11 ··· 20 21 test "returns cached config from application env" do 21 22 config = 22 23 SowerCli.Config.load( 24 + skip_config_file: true, 23 25 overrides: %{"caches" => ["attic://server:cache"], "access_token_file" => nil} 24 26 ) 25 27
+12 -18
apps/sower_client/lib/sower_client/config.ex
··· 50 50 type: :string, 51 51 description: "Directory where state files are written (garden-only)" 52 52 }, 53 - default_deployment_profile: %Schema{ 54 - type: :string, 55 - description: "Name of default deployment profile", 56 - nullable: true 57 - }, 58 - deployment_profiles: %Schema{ 59 - type: :object, 60 - additionalProperties: SowerClient.Orchestration.DeploymentProfile, 61 - nullable: true, 62 - description: "Deployment policies (garden-only)" 63 - }, 64 53 subscriptions: %Schema{ 65 54 type: :array, 66 55 items: SowerClient.Orchestration.Subscription, 67 56 default: [], 68 - description: "Subscriptions (garden-only)" 57 + description: 58 + "Subscriptions (garden-only). Configured as a map (name -> config), converted to list during loading." 69 59 } 70 60 }, 71 61 required: [] ··· 102 92 end) 103 93 end 104 94 end) 105 - |> preprocess_subscription_rules() 95 + |> preprocess_subscriptions() 106 96 |> Map.merge(config_overrides) 107 97 |> override_with_env() 108 98 |> parse_file_values() ··· 366 356 end 367 357 368 358 # parse subscriptions and rules 369 - defp preprocess_subscription_rules(%{"subscriptions" => subscriptions} = config) 370 - when is_list(subscriptions) do 359 + defp preprocess_subscriptions(%{"subscriptions" => subscriptions} = config) 360 + when is_map(subscriptions) do 371 361 normalized_subscriptions = 372 362 subscriptions 373 - |> Enum.map(&parse_subscription_rules/1) 374 - |> Enum.map(&fill_default_subscription_name/1) 363 + |> Enum.map(fn {name, sub} -> 364 + sub 365 + |> Map.put("name", name) 366 + |> parse_subscription_rules() 367 + |> fill_default_subscription_name() 368 + end) 375 369 376 370 Map.put(config, "subscriptions", normalized_subscriptions) 377 371 end 378 372 379 - defp preprocess_subscription_rules(config), do: config 373 + defp preprocess_subscriptions(config), do: config 380 374 381 375 defp parse_subscription_rules(%{"rules" => rules} = sub) when is_list(rules) do 382 376 normalized_rules =
-21
apps/sower_client/test/sower_client/config_test.exs
··· 2 2 use ExUnit.Case, async: true 3 3 4 4 alias SowerClient.Config 5 - alias SowerClient.Orchestration.DeploymentProfile 6 5 7 6 describe "xdg_config_path/2" do 8 7 test "respects XDG_CONFIG_HOME when set and file exists" do ··· 269 268 defaults: %{"state_directory" => "$MISSING_STATE_DIR/sower"} 270 269 ) 271 270 end 272 - end 273 - 274 - test "casts deployment_profiles additionalProperties into deployment profiles", %{ 275 - config_file: config_file 276 - } do 277 - config_data = %{ 278 - "endpoint" => "https://my.sower.dev", 279 - "deployment_profiles" => %{ 280 - "default" => %{"reboot_policy" => "when-required"} 281 - } 282 - } 283 - 284 - File.write!(config_file, Jason.encode!(config_data)) 285 - 286 - config = Config.load(config_path: config_file) 287 - 288 - assert %Config{} = config 289 - assert %DeploymentProfile{} = config.deployment_profiles["default"] 290 - assert config.deployment_profiles["default"].reboot_policy == "when-required" 291 - assert config.deployment_profiles["default"].activation_args == [] 292 271 end 293 272 294 273 test "prefers access_token override over access_token_file from config", %{
+4 -4
dev-client-example.json
··· 2 2 "access_token_file": "./.dev-api-token", 3 3 "endpoint": "http://localhost:7150", 4 4 "state_directory": "./_build/garden1", 5 - "subscriptions": [ 6 - { 5 + "subscriptions": { 6 + "deck-nixos": { 7 7 "seed_name": "deck", 8 8 "seed_type": "nixos", 9 9 "rules": ["source=dev"], 10 10 "schedule": "* * * * *", 11 11 "poll_on_connect": true 12 12 }, 13 - { 13 + "deck-hm": { 14 14 "seed_name": "deck", 15 15 "seed_type": "home-manager", 16 16 "poll_on_connect": false 17 17 } 18 - ] 18 + } 19 19 }
+8 -8
nix/tests/e2e.nix
··· 86 86 settings = { 87 87 access_token_file = "/run/sower/test_token"; 88 88 endpoint = "http://localhost:4000"; 89 - subscriptions = [ 90 - { 89 + subscriptions = { 90 + server = { 91 91 seed_name = "server"; 92 92 seed_type = "nixos"; 93 - } 94 - ]; 93 + }; 94 + }; 95 95 }; 96 96 }; 97 97 }; ··· 150 150 151 151 settings = { 152 152 endpoint = "http://localhost:4000"; 153 - subscriptions = [ 154 - { 153 + subscriptions = { 154 + testuser = { 155 155 seed_name = "testuser"; 156 156 seed_type = "home-manager"; 157 157 rules = [ ··· 161 161 op = "eq"; 162 162 } 163 163 ]; 164 - } 165 - ]; 164 + }; 165 + }; 166 166 }; 167 167 }; 168 168 };