Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

feat: move activation_args and reboot_policy onto subscription, remove DeploymentProfile

activation_args and reboot_policy are now fields on the subscription
schema instead of a separate DeploymentProfile struct. The deployer
and seed activation code read these directly from the subscription
looked up by sid.

- Client schema: added activation_args (default []) and reboot_policy
(default "never") to Subscription
- Server schema: added activation_args and reboot_policy columns
- Garden.Seed: accepts Subscription instead of DeploymentProfile
- Deployer: uses find_subscription_fun instead of get_deployment_profile_fun
- Deleted DeploymentProfile schema and all references

SOW-121

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

+101 -93
+24 -19
apps/garden/lib/garden/deployer.ex
··· 3 3 4 4 alias Garden.Socket 5 5 6 + alias Garden.Storage 6 7 alias SowerClient.Activator 7 8 alias SowerClient.Orchestration.Deployment 8 - alias SowerClient.Orchestration.DeploymentProfile 9 9 alias SowerClient.Orchestration.SeedDeployment 10 10 alias SowerClient.Orchestration.SeedDeploymentResult 11 11 alias SowerClient.Orchestration.SeedDeploymentStatus 12 + alias SowerClient.Orchestration.Subscription 12 13 13 14 def run(%Deployment{} = deployment) do 14 15 run_with_opts(deployment, upgrade_opts: [], reboot_opts: []) ··· 66 67 async_stream_fun = Keyword.get(opts, :async_stream_fun, &async_stream/2) 67 68 realize_seed_fun = Keyword.get(opts, :realize_seed_fun, &realize_seed/1) 68 69 69 - get_deployment_profile_fun = 70 - Keyword.get(opts, :get_deployment_profile_fun, fn _sid -> %DeploymentProfile{} end) 70 + find_subscription_fun = 71 + Keyword.get(opts, :find_subscription_fun, &find_subscription/1) 71 72 72 73 activate_seed_fun = Keyword.get(opts, :activate_seed_fun, &Garden.Seed.activate/2) 73 74 ··· 92 93 end) 93 94 |> async_stream_fun.(fn 94 95 {:ok, {downloading_line, {:ok, %SeedDeployment{seed: seed} = seed_deploy, download_output}}} -> 95 - profile = get_deployment_profile_fun.(seed_deploy.subscription_sid) 96 - mode = Garden.Seed.activation_mode(profile) 96 + subscription = find_subscription_fun.(seed_deploy.subscription_sid) || %Subscription{} 97 + mode = Garden.Seed.activation_mode(subscription) 97 98 98 99 preamble = 99 100 [downloading_line | download_output] ++ ··· 109 110 seed_type: seed.seed_type, 110 111 artifact: seed.artifact, 111 112 deployment_sid: deployment.sid, 112 - activation_args: get_in(profile.activation_args) 113 + activation_args: get_in(subscription.activation_args) 113 114 ) 114 115 115 116 report_seed_status_fun.(deployment, seed, :activating) 116 - result = activate_seed_fun.(seed, profile) 117 + result = activate_seed_fun.(seed, subscription) 117 118 118 119 case result do 119 120 {:ok, output} -> ··· 225 226 ) 226 227 end 227 228 229 + defp find_subscription(sid) do 230 + (Storage.read().subscriptions || []) |> Enum.find(&(&1.sid == sid)) 231 + end 232 + 228 233 def maybe_reboot(%Deployment{} = deployment, result) do 229 234 maybe_reboot(deployment, result, []) 230 235 end ··· 320 325 321 326 def reboot_reason( 322 327 seed_deployments, 323 - get_profile \\ fn _sid -> %DeploymentProfile{} end, 328 + find_sub \\ &find_subscription/1, 324 329 read_link \\ &:file.read_link_all/1 325 330 ) do 326 - profiles = 331 + subscriptions = 327 332 seed_deployments 328 333 |> Enum.filter(&(get_in(&1.seed.seed_type) == "nixos")) 329 334 |> Enum.map(fn %SeedDeployment{subscription_sid: subscription_sid} -> 330 - get_profile.(subscription_sid) || %DeploymentProfile{} 335 + find_sub.(subscription_sid) || %Subscription{} 331 336 end) 332 337 333 338 cond do 334 - profiles == [] -> 339 + subscriptions == [] -> 335 340 nil 336 341 337 - Enum.any?(profiles, fn profile -> 338 - profile.reboot_policy == "always" 342 + Enum.any?(subscriptions, fn sub -> 343 + sub.reboot_policy == "always" 339 344 end) -> 340 345 "policy_always" 341 346 342 - Enum.any?(profiles, fn profile -> 343 - profile.reboot_policy == "when-required" and 344 - Garden.Seed.activation_mode(profile) == "boot" and 347 + Enum.any?(subscriptions, fn sub -> 348 + sub.reboot_policy == "when-required" and 349 + Garden.Seed.activation_mode(sub) == "boot" and 345 350 not is_nil(detect_boot_critical_change_reason(read_link)) 346 351 end) -> 347 352 "boot_mode" 348 353 349 - Enum.any?(profiles, fn profile -> 350 - profile.reboot_policy == "when-required" and 351 - Garden.Seed.activation_mode(profile) == "switch" 354 + Enum.any?(subscriptions, fn sub -> 355 + sub.reboot_policy == "when-required" and 356 + Garden.Seed.activation_mode(sub) == "switch" 352 357 end) -> 353 358 detect_boot_critical_change_reason(read_link) 354 359
+7 -8
apps/garden/lib/garden/seed.ex
··· 1 1 defmodule Garden.Seed do 2 2 alias SowerClient.{Activator, Seed} 3 - alias SowerClient.Orchestration.DeploymentProfile 3 + alias SowerClient.Orchestration.Subscription 4 4 5 5 require Logger 6 6 7 7 @default_socket_path "/run/sower-activator/activator.sock" 8 8 9 - def activate(seed, profile \\ %DeploymentProfile{}) 9 + def activate(seed, subscription \\ %Subscription{}) 10 10 11 - def activate(%Seed{seed_type: "home-manager"} = seed, _profile) do 11 + def activate(%Seed{seed_type: "home-manager"} = seed, _subscription) do 12 12 run_activation("home-manager", seed.artifact, tags: seed.tags) 13 13 end 14 14 15 - def activate(%Seed{seed_type: "nixos"} = seed, %DeploymentProfile{} = profile) do 16 - run_activation("nixos", seed.artifact, mode: activation_mode(profile)) 15 + def activate(%Seed{seed_type: "nixos"} = seed, %Subscription{} = subscription) do 16 + run_activation("nixos", seed.artifact, mode: activation_mode(subscription)) 17 17 end 18 18 19 - # TODO pass these args through to the activator once we validate the store paths it receives 20 - def activation_mode(%DeploymentProfile{} = profile) do 21 - case profile.activation_args do 19 + def activation_mode(%Subscription{} = subscription) do 20 + case subscription.activation_args do 22 21 [mode | _] when is_binary(mode) and mode != "" -> 23 22 mode 24 23
+17 -17
apps/garden/test/garden/deployer_test.exs
··· 7 7 alias Garden.Deployer 8 8 alias SowerClient.Orchestration.Deployment 9 9 alias SowerClient.Orchestration.SeedDeployment 10 - alias SowerClient.Orchestration.DeploymentProfile 10 + alias SowerClient.Orchestration.Subscription 11 11 alias SowerClient.Seed 12 12 13 13 describe "deployment_result/1" do ··· 94 94 test "returns nil when there are no nixos seed deployments" do 95 95 seed_deployments = [seed_deploy("sub1", "home-manager")] 96 96 97 - assert Deployer.reboot_reason(seed_deployments, fn _ -> %DeploymentProfile{} end) == nil 97 + assert Deployer.reboot_reason(seed_deployments, fn _ -> %Subscription{} end) == nil 98 98 end 99 99 100 100 test "returns policy_always when profile reboot policy is always" do 101 101 seed_deployments = [seed_deploy("sub_always")] 102 102 103 103 get_profile = fn "sub_always" -> 104 - %DeploymentProfile{activation_args: ["switch"], reboot_policy: "always"} 104 + %Subscription{activation_args: ["switch"], reboot_policy: "always"} 105 105 end 106 106 107 107 assert Deployer.reboot_reason(seed_deployments, get_profile) == "policy_always" ··· 111 111 seed_deployments = [seed_deploy("sub_boot")] 112 112 113 113 get_profile = fn "sub_boot" -> 114 - %DeploymentProfile{activation_args: ["boot"], reboot_policy: "when-required"} 114 + %Subscription{activation_args: ["boot"], reboot_policy: "when-required"} 115 115 end 116 116 117 117 read_link = fn ··· 127 127 seed_deployments = [seed_deploy("sub_boot")] 128 128 129 129 get_profile = fn "sub_boot" -> 130 - %DeploymentProfile{activation_args: ["boot"], reboot_policy: "when-required"} 130 + %Subscription{activation_args: ["boot"], reboot_policy: "when-required"} 131 131 end 132 132 133 133 read_link = fn ··· 144 144 seed_deployments = [seed_deploy("sub_switch")] 145 145 146 146 get_profile = fn "sub_switch" -> 147 - %DeploymentProfile{activation_args: ["switch"], reboot_policy: "when-required"} 147 + %Subscription{activation_args: ["switch"], reboot_policy: "when-required"} 148 148 end 149 149 150 150 read_link = fn ··· 160 160 seed_deployments = [seed_deploy("sub_switch")] 161 161 162 162 get_profile = fn "sub_switch" -> 163 - %DeploymentProfile{activation_args: ["switch"], reboot_policy: "when-required"} 163 + %Subscription{activation_args: ["switch"], reboot_policy: "when-required"} 164 164 end 165 165 166 166 logs = ··· 194 194 Enum.map(enumerable, fn item -> {:ok, func.(item)} end) 195 195 end, 196 196 realize_seed_fun: fn seed_deploy -> {:ok, seed_deploy, []} end, 197 - get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 197 + find_subscription_fun: fn _ -> %Subscription{} end, 198 198 activate_seed_fun: fn _seed, _profile -> {:error, :activator_unavailable} end, 199 199 report_seed_status_fun: fn _, _, _ -> :ok end, 200 200 report_seed_result_fun: fn _deployment, _seed, result, output_lines -> ··· 226 226 Enum.map(enumerable, fn item -> {:ok, func.(item)} end) 227 227 end, 228 228 realize_seed_fun: fn seed_deploy -> {:ok, seed_deploy, []} end, 229 - get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 229 + find_subscription_fun: fn _ -> %Subscription{} end, 230 230 activate_seed_fun: fn _seed, _profile -> {:ok, ["activation complete"]} end, 231 231 report_seed_status_fun: fn _, _, _ -> :ok end, 232 232 report_seed_result_fun: fn _deployment, _seed, result, _output_lines -> ··· 256 256 Enum.map(enumerable, fn item -> {:ok, func.(item)} end) 257 257 end, 258 258 realize_seed_fun: fn seed_deploy -> {:ok, seed_deploy, []} end, 259 - get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 259 + find_subscription_fun: fn _ -> %Subscription{} end, 260 260 activate_seed_fun: fn _seed, _profile -> {:error, :cmd_not_found} end, 261 261 report_seed_status_fun: fn _, _, _ -> :ok end, 262 262 report_seed_result_fun: fn _deployment, _seed, result, output_lines -> ··· 319 319 320 320 logged_lines = 321 321 capture_seed_result_lines(deployment, 322 - get_deployment_profile_fun: fn _ -> 323 - %DeploymentProfile{activation_args: ["boot"]} 322 + find_subscription_fun: fn _ -> 323 + %Subscription{activation_args: ["boot"]} 324 324 end 325 325 ) 326 326 ··· 345 345 Enum.map(enumerable, fn item -> {:ok, func.(item)} end) 346 346 end, 347 347 realize_seed_fun: fn sd -> {:ok, sd, []} end, 348 - get_deployment_profile_fun: fn _ -> 349 - %DeploymentProfile{reboot_policy: "always"} 348 + find_subscription_fun: fn _ -> 349 + %Subscription{reboot_policy: "always"} 350 350 end, 351 351 activate_seed_fun: fn _seed, _profile -> {:ok, ["ok"]} end, 352 352 report_seed_result_fun: fn _deployment, _seed, result, output_lines -> ··· 387 387 Enum.map(enumerable, fn item -> {:ok, func.(item)} end) 388 388 end, 389 389 realize_seed_fun: fn sd -> {:ok, sd, []} end, 390 - get_deployment_profile_fun: fn _ -> %DeploymentProfile{} end, 390 + find_subscription_fun: fn _ -> %Subscription{} end, 391 391 activate_seed_fun: fn _seed, _profile -> {:error, 1, ["failed"]} end, 392 392 report_seed_result_fun: fn _deployment, _seed, result, output_lines -> 393 393 send(test_pid, {:seed_result, result, output_lines}) ··· 499 499 Enum.map(enumerable, fn item -> {:ok, func.(item)} end) 500 500 end, 501 501 realize_seed_fun: Keyword.get(opts, :realize_seed_fun, fn sd -> {:ok, sd, []} end), 502 - get_deployment_profile_fun: 503 - Keyword.get(opts, :get_deployment_profile_fun, fn _ -> %DeploymentProfile{} end), 502 + find_subscription_fun: 503 + Keyword.get(opts, :find_subscription_fun, fn _ -> %Subscription{} end), 504 504 activate_seed_fun: 505 505 Keyword.get(opts, :activate_seed_fun, fn _seed, _profile -> 506 506 {:ok, ["activation output"]}
+4 -4
apps/garden/test/garden/seed_test.exs
··· 4 4 import ExUnit.CaptureLog 5 5 6 6 alias Garden.Seed 7 - alias SowerClient.Orchestration.DeploymentProfile 7 + alias SowerClient.Orchestration.Subscription 8 8 alias SowerClient.Seed, as: ClientSeed 9 9 10 10 describe "activate/1" do ··· 98 98 end 99 99 100 100 describe "activate/2" do 101 - test "uses deployment profile activation_args for nixos mode" do 101 + test "uses subscription activation_args for nixos mode" do 102 102 {socket_path, server_pid} = 103 103 start_mock_server(fn request_line, client_socket -> 104 104 request = Jason.decode!(request_line) ··· 119 119 120 120 seed = %ClientSeed{name: "test", seed_type: "nixos", artifact: "/nix/store/xyz"} 121 121 122 - profile = %DeploymentProfile{ 122 + subscription = %Subscription{ 123 123 activation_args: ["boot"], 124 124 reboot_policy: "never" 125 125 } 126 126 127 - assert {:ok, []} = Seed.activate(seed, profile) 127 + assert {:ok, []} = Seed.activate(seed, subscription) 128 128 end 129 129 end 130 130
+26 -3
apps/sower/lib/sower/orchestration/subscription.ex
··· 24 24 field :seed_type, :string 25 25 field :schedule, :string 26 26 field :timezone, :string 27 + field :activation_args, {:array, :string}, default: [] 28 + field :reboot_policy, :string, default: "never" 27 29 embeds_many :rules, __MODULE__.Rule 28 30 29 31 timestamps(type: :utc_datetime) ··· 32 34 @doc false 33 35 def changeset(subscription, attrs) do 34 36 subscription 35 - |> cast(attrs, [:garden_id, :name, :seed_name, :seed_type, :schedule, :timezone]) 37 + |> cast(attrs, [ 38 + :garden_id, 39 + :name, 40 + :seed_name, 41 + :seed_type, 42 + :schedule, 43 + :timezone, 44 + :activation_args, 45 + :reboot_policy 46 + ]) 36 47 |> cast_embed(:rules, with: &__MODULE__.Rule.changeset/2) 37 48 |> unique_constraint([:garden_id, :org_id, :name]) 38 49 end ··· 138 149 |> changeset(attrs) 139 150 |> Repo.insert( 140 151 on_conflict: 141 - {:replace, [:updated_at, :seed_name, :seed_type, :rules, :schedule, :timezone]}, 152 + {:replace, 153 + [ 154 + :updated_at, 155 + :seed_name, 156 + :seed_type, 157 + :rules, 158 + :schedule, 159 + :timezone, 160 + :activation_args, 161 + :reboot_policy 162 + ]}, 142 163 conflict_target: [:garden_id, :org_id, :name], 143 164 returning: true 144 165 ) do ··· 157 178 seed_type: sub.seed_type, 158 179 rules: sub.rules, 159 180 schedule: sub.schedule, 160 - timezone: sub.timezone 181 + timezone: sub.timezone, 182 + activation_args: sub.activation_args, 183 + reboot_policy: sub.reboot_policy 161 184 }) do 162 185 {:ok, subscription} -> 163 186 {:ok, SowerClient.Orchestration.Subscription.cast!(subscription)}
+10
apps/sower/priv/repo/migrations/20260329171759_add_activation_fields_to_subscriptions.exs
··· 1 + defmodule Sower.Repo.Migrations.AddActivationFieldsToSubscriptions do 2 + use Ecto.Migration 3 + 4 + def change do 5 + alter table(:subscriptions) do 6 + add :activation_args, {:array, :string}, default: [] 7 + add :reboot_policy, :string, default: "never" 8 + end 9 + end 10 + end
-2
apps/sower_client/lib/sower_client.ex
··· 5 5 @server_pushed_schema_titles [ 6 6 "Deployment", 7 7 "SeedDeployment", 8 - "DeploymentProfile", 9 8 "Seed", 10 9 "SeedTag", 11 10 "PresignedUploadReply" ··· 33 32 SowerClient.Orchestration.AgentSeedProfile, 34 33 SowerClient.Orchestration.AgentSeedsReport, 35 34 SowerClient.Orchestration.Deployment, 36 - SowerClient.Orchestration.DeploymentProfile, 37 35 SowerClient.Orchestration.DeploymentResult, 38 36 SowerClient.Orchestration.DeploymentRequest, 39 37 SowerClient.Orchestration.DeploymentStatus,
-27
apps/sower_client/lib/sower_client/orchestration/deployment_profile.ex
··· 1 - defmodule SowerClient.Orchestration.DeploymentProfile do 2 - use SowerClient.Schema 3 - 4 - @reboot_policies ["never", "when-required", "always"] 5 - 6 - OpenApiSpex.schema(%{ 7 - title: "DeploymentProfile", 8 - type: :object, 9 - properties: %{ 10 - activation_args: %Schema{ 11 - type: :array, 12 - items: %Schema{type: :string}, 13 - default: [], 14 - description: 15 - "Arguments to pass to activation script. For example [`boot`] for NixOS seeds to apply in boot mode." 16 - }, 17 - reboot_policy: %Schema{ 18 - type: :string, 19 - description: "Whether deployment can trigger automated reboots.", 20 - enum: @reboot_policies, 21 - default: "never", 22 - example: "when-required" 23 - } 24 - }, 25 - required: [] 26 - }) 27 - end
+13
apps/sower_client/lib/sower_client/orchestration/subscription.ex
··· 51 51 type: :boolean, 52 52 description: "Whether to request deployment immediately on connect (garden-only)", 53 53 default: false 54 + }, 55 + activation_args: %Schema{ 56 + type: :array, 57 + items: %Schema{type: :string}, 58 + default: [], 59 + description: 60 + "Arguments to pass to activation script (e.g. [\"boot\"] for NixOS boot mode)" 61 + }, 62 + reboot_policy: %Schema{ 63 + type: :string, 64 + description: "Whether deployment can trigger automated reboots", 65 + enum: ["never", "when-required", "always"], 66 + default: "never" 54 67 } 55 68 }, 56 69 required: [:seed_name, :seed_type],
-13
apps/sower_client/test/fixtures/contract_baseline.json
··· 22 22 }, 23 23 "required": [] 24 24 }, 25 - "DeploymentProfile": { 26 - "properties": { 27 - "activation_args": { 28 - "has_default": true, 29 - "type": "array" 30 - }, 31 - "reboot_policy": { 32 - "has_default": true, 33 - "type": "string" 34 - } 35 - }, 36 - "required": [] 37 - }, 38 25 "PresignedUploadReply": { 39 26 "properties": { 40 27 "headers": {