Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

agent: only submit seeds relevant to subscriptions

+455 -26
+14 -4
apps/sower/lib/sower/orchestration.ex
··· 1144 1144 %Agent{} = agent 1145 1145 ) do 1146 1146 Repo.transaction(fn -> 1147 - for profile <- report.profiles do 1148 - nix_profile = NixProfile.find_or_create!(profile.profile_path) 1149 - rows = resolve_profile_generation_rows(agent, profile) 1150 - sync_profile_generation_rows(agent, nix_profile, rows) 1147 + if Enum.empty?(report.profiles) do 1148 + # Empty report means agent has no subscriptions - delete all generations 1149 + delete_all_agent_seed_generations(agent.id) 1150 + else 1151 + for profile <- report.profiles do 1152 + nix_profile = NixProfile.find_or_create!(profile.profile_path) 1153 + rows = resolve_profile_generation_rows(agent, profile) 1154 + sync_profile_generation_rows(agent, nix_profile, rows) 1155 + end 1151 1156 end 1152 1157 1153 1158 :ok ··· 1284 1289 end 1285 1290 1286 1291 Repo.delete_all(query) 1292 + end 1293 + 1294 + defp delete_all_agent_seed_generations(agent_id) do 1295 + from(asg in AgentSeedGeneration, where: asg.agent_id == ^agent_id) 1296 + |> Repo.delete_all() 1287 1297 end 1288 1298 end
+21 -8
apps/sower_agent/lib/sower_agent/client.ex
··· 24 24 25 25 @impl Slipstream 26 26 def handle_cast(:report_seeds, socket) do 27 - report = SowerAgent.Profile.collect_all_profiles() 27 + storage = Storage.read() 28 + subscriptions = Map.get(storage, :subscriptions, []) 29 + 30 + report = SowerAgent.Profile.collect_profiles_for_subscriptions(subscriptions) 31 + 32 + if not Enum.empty?(subscriptions) and Enum.empty?(report.profiles) do 33 + Logger.debug( 34 + msg: "No profiles found for any targets", 35 + subscription_count: length(subscriptions) 36 + ) 28 37 29 - Logger.debug( 30 - msg: "Reporting seed profiles", 31 - profile_count: length(report.profiles) 32 - ) 38 + {:noreply, socket} 39 + else 40 + Logger.debug( 41 + msg: "Reporting seed profiles", 42 + profile_count: length(report.profiles), 43 + subscription_count: length(subscriptions) 44 + ) 33 45 34 - topic = private_channel(socket) 35 - {:ok, _ref} = push(socket, topic, "agent:seeds:report", report) 46 + topic = private_channel(socket) 47 + {:ok, _ref} = push(socket, topic, "agent:seeds:report", report) 36 48 37 - {:noreply, socket} 49 + {:noreply, socket} 50 + end 38 51 end 39 52 40 53 @impl Slipstream
+187 -14
apps/sower_agent/lib/sower_agent/profile.ex
··· 8 8 alias SowerClient.Orchestration.{AgentSeedGeneration, AgentSeedProfile, AgentSeedsReport} 9 9 10 10 @doc """ 11 - Collects all available Nix profiles with their generations. 11 + Collects profiles based on the provided targets. 12 + 13 + Targets should be a list of maps with :type and :path keys: 14 + - `%{type: "nixos", path: "/nix/var/nix/profiles/system"}` 15 + - `%{type: "home-manager", path: "/home/user/.local/state/nix/profiles/home-manager"}` 12 16 13 - Returns an AgentSeedsReport struct containing NixOS and HomeManager profiles. 17 + Returns an AgentSeedsReport struct containing only the requested profiles. 14 18 Profiles that fail to load are logged as warnings and excluded from the report. 19 + 20 + ## Examples 21 + 22 + iex> targets = [%{type: "nixos", path: "/nix/var/nix/profiles/system"}] 23 + iex> SowerAgent.Profile.collect_profiles(targets) 24 + %SowerClient.Orchestration.AgentSeedsReport{profiles: [...]} 15 25 """ 16 - def collect_all_profiles() do 26 + def collect_profiles(targets) when is_list(targets) do 17 27 profiles = 18 - [ 19 - collect_nixos(), 20 - collect_home_manager() 21 - ] 28 + targets 29 + |> Enum.map(&collect_target/1) 22 30 |> Enum.reject(fn {result, _} -> result == :error end) 23 31 |> Enum.map(fn {_, profile} -> profile end) 24 32 25 33 AgentSeedsReport.cast!(%{profiles: profiles}) 26 34 end 27 35 28 - def collect_nixos() do 29 - collect_profile(Nix.NixOS, "/nix/var/nix/profiles/system") 36 + @doc """ 37 + Builds profile targets from subscriptions. 38 + 39 + For nixos subscriptions: generates target with system profile path. 40 + For home-manager subscriptions: extracts username from rules and generates 41 + profile path for each unique user. 42 + 43 + ## Examples 44 + 45 + iex> subs = [%{seed_type: "nixos", seed_name: "host", rules: []}] 46 + iex> SowerAgent.Profile.build_profile_targets(subs) 47 + [%{type: "nixos", path: "/nix/var/nix/profiles/system"}] 48 + 49 + iex> subs = [%{seed_type: "home-manager", seed_name: "alice@host", rules: [%{key: "username", value: "alice"}]}] 50 + iex> SowerAgent.Profile.build_profile_targets(subs) 51 + [%{type: "home-manager", path: "/home/alice/.local/state/nix/profiles/home-manager"}] 52 + """ 53 + def build_profile_targets(subscriptions) when is_list(subscriptions) do 54 + subscriptions 55 + |> Enum.flat_map(&subscription_to_targets/1) 56 + |> Enum.uniq() 57 + end 58 + 59 + defp subscription_to_targets(%{seed_type: "nixos"}) do 60 + [%{type: "nixos", path: "/nix/var/nix/profiles/system"}] 61 + end 62 + 63 + defp subscription_to_targets(%{seed_type: "home-manager", rules: rules}) do 64 + username = extract_username_from_rules(rules) 65 + 66 + case home_manager_profile_path(username) do 67 + {:ok, path} -> 68 + [%{type: "home-manager", path: path}] 69 + 70 + {:error, reason} -> 71 + Logger.warning( 72 + msg: "Could not determine home-manager profile path", 73 + username: username, 74 + reason: reason 75 + ) 76 + 77 + [] 78 + end 79 + end 80 + 81 + defp subscription_to_targets(_), do: [] 82 + 83 + defp extract_username_from_rules(rules) when is_list(rules) do 84 + case Enum.find(rules, &(&1.key == "username")) do 85 + %{value: username} when is_binary(username) and username != "" -> 86 + username 87 + 88 + _ -> 89 + nil 90 + end 91 + end 92 + 93 + defp extract_username_from_rules(_), do: nil 94 + 95 + @doc """ 96 + Collects profiles for the given subscriptions. 97 + 98 + Builds profile targets from subscriptions and collects profiles for each target. 99 + Returns an empty report if no subscriptions exist (allowing server cleanup). 100 + 101 + ## Examples 102 + 103 + iex> subs = [%{seed_type: "nixos", seed_name: "host", rules: []}] 104 + iex> SowerAgent.Profile.collect_profiles_for_subscriptions(subs) 105 + %SowerClient.Orchestration.AgentSeedsReport{profiles: [...]} 106 + 107 + iex> SowerAgent.Profile.collect_profiles_for_subscriptions([]) 108 + %SowerClient.Orchestration.AgentSeedsReport{profiles: []} 109 + """ 110 + def collect_profiles_for_subscriptions(subscriptions) when is_list(subscriptions) do 111 + targets = build_profile_targets(subscriptions) 112 + collect_profiles(targets) 113 + end 114 + 115 + @doc """ 116 + Returns the home-manager profile path for the current user. 117 + """ 118 + def home_manager_profile_path(user \\ nil) 119 + 120 + def home_manager_profile_path(nil) do 121 + case System.get_env("XDG_STATE_HOME") do 122 + nil -> 123 + # Get current user and look up their home directory 124 + case get_user_home(System.fetch_env!("USER")) do 125 + {:ok, home} -> 126 + {:ok, "#{home}/.local/state/nix/profiles/home-manager"} 127 + 128 + {:error, reason} -> 129 + {:error, reason} 130 + end 131 + 132 + xdg_state -> 133 + {:ok, "#{xdg_state}/nix/profiles/home-manager"} 134 + end 30 135 end 31 136 32 - def collect_home_manager() do 33 - collect_profile(Nix.HomeManager, home_manager_profile_path()) 137 + def home_manager_profile_path(username) when is_binary(username) do 138 + case get_user_home(username) do 139 + {:ok, home} -> 140 + {:ok, "#{home}/.local/state/nix/profiles/home-manager"} 141 + 142 + {:error, reason} -> 143 + {:error, reason} 144 + end 34 145 end 35 146 36 - def home_manager_profile_path() do 37 - xdg_state = System.get_env("XDG_STATE_HOME", "#{System.get_env("HOME")}/.local/state") 38 - "#{xdg_state}/nix/profiles/home-manager" 147 + @doc """ 148 + Gets the home directory for a user using getent, falling back to /etc/passwd, 149 + then assuming /home/<username> as a last resort. 150 + 151 + Returns `{:ok, home_path}` or `{:error, :user_not_found}`. 152 + """ 153 + def get_user_home(username) when is_binary(username) do 154 + case System.cmd("getent", ["passwd", username], stderr_to_stdout: true) do 155 + {output, 0} -> 156 + parse_passwd_home(output) 157 + 158 + {_output, _exit_code} -> 159 + fallback_get_user_home(username) 160 + end 161 + end 162 + 163 + defp parse_passwd_home(output) do 164 + # Format: username:password:uid:gid:gecos:home:shell 165 + case String.split(String.trim(output), ":") do 166 + parts when length(parts) >= 6 -> 167 + {:ok, Enum.at(parts, 5)} 168 + 169 + _ -> 170 + {:error, :invalid_passwd_format} 171 + end 172 + end 173 + 174 + defp fallback_get_user_home(username) do 175 + case File.read("/etc/passwd") do 176 + {:ok, contents} -> 177 + contents 178 + |> String.split("\n") 179 + |> Enum.find(&String.starts_with?(&1, "#{username}:")) 180 + |> case do 181 + nil -> 182 + # Last resort: assume /home/<username> 183 + {:ok, "/home/#{username}"} 184 + 185 + line -> 186 + parse_passwd_home(line) 187 + end 188 + 189 + {:error, reason} -> 190 + # Can't read /etc/passwd, assume /home/<username> 191 + Logger.debug( 192 + msg: "Could not read /etc/passwd, assuming /home/<username>", 193 + username: username, 194 + reason: reason 195 + ) 196 + 197 + {:ok, "/home/#{username}"} 198 + end 199 + end 200 + 201 + defp collect_target(%{type: "nixos", path: path}) do 202 + collect_profile(Nix.NixOS, path) 203 + end 204 + 205 + defp collect_target(%{type: "home-manager", path: path}) do 206 + collect_profile(Nix.HomeManager, path) 207 + end 208 + 209 + defp collect_target(target) do 210 + Logger.warning(msg: "Unknown profile target type", target: target) 211 + {:error, :unknown_target_type} 39 212 end 40 213 41 214 def collect_profile(module, profile_path) do
+233
apps/sower_agent/test/sower_agent/profile_test.exs
··· 135 135 end 136 136 end 137 137 138 + describe "build_profile_targets/1" do 139 + test "generates nixos target for nixos subscription" do 140 + subscriptions = [ 141 + %{seed_type: "nixos", seed_name: "myhost", rules: []} 142 + ] 143 + 144 + targets = Profile.build_profile_targets(subscriptions) 145 + 146 + assert targets == [%{type: "nixos", path: "/nix/var/nix/profiles/system"}] 147 + end 148 + 149 + test "generates home-manager target with current user when no username rule" do 150 + subscriptions = [ 151 + %{seed_type: "home-manager", seed_name: "user@host", rules: []} 152 + ] 153 + 154 + targets = Profile.build_profile_targets(subscriptions) 155 + [target] = targets 156 + 157 + assert target.type == "home-manager" 158 + # Path should use XDG_STATE_HOME or default 159 + assert target.path =~ "home-manager" 160 + end 161 + 162 + test "generates home-manager target with username from rules" do 163 + subscriptions = [ 164 + %{ 165 + seed_type: "home-manager", 166 + seed_name: "alice@host", 167 + rules: [%{key: "username", op: "eq", value: "alice"}] 168 + } 169 + ] 170 + 171 + targets = Profile.build_profile_targets(subscriptions) 172 + 173 + assert targets == [ 174 + %{type: "home-manager", path: "/home/alice/.local/state/nix/profiles/home-manager"} 175 + ] 176 + end 177 + 178 + test "generates unique targets for multiple nixos subscriptions" do 179 + subscriptions = [ 180 + %{seed_type: "nixos", seed_name: "host1", rules: []}, 181 + %{seed_type: "nixos", seed_name: "host2", rules: []} 182 + ] 183 + 184 + targets = Profile.build_profile_targets(subscriptions) 185 + 186 + # Should deduplicate - only one system profile 187 + assert length(targets) == 1 188 + assert hd(targets) == %{type: "nixos", path: "/nix/var/nix/profiles/system"} 189 + end 190 + 191 + test "generates targets for multiple home-manager users" do 192 + subscriptions = [ 193 + %{ 194 + seed_type: "home-manager", 195 + seed_name: "alice@host", 196 + rules: [%{key: "username", value: "alice"}] 197 + }, 198 + %{ 199 + seed_type: "home-manager", 200 + seed_name: "bob@host", 201 + rules: [%{key: "username", value: "bob"}] 202 + } 203 + ] 204 + 205 + targets = Profile.build_profile_targets(subscriptions) 206 + 207 + assert length(targets) == 2 208 + 209 + assert %{type: "home-manager", path: "/home/alice/.local/state/nix/profiles/home-manager"} in targets 210 + 211 + assert %{type: "home-manager", path: "/home/bob/.local/state/nix/profiles/home-manager"} in targets 212 + end 213 + 214 + test "generates targets for mixed nixos and home-manager subscriptions" do 215 + subscriptions = [ 216 + %{seed_type: "nixos", seed_name: "myhost", rules: []}, 217 + %{ 218 + seed_type: "home-manager", 219 + seed_name: "alice@host", 220 + rules: [%{key: "username", value: "alice"}] 221 + } 222 + ] 223 + 224 + targets = Profile.build_profile_targets(subscriptions) 225 + 226 + assert length(targets) == 2 227 + assert %{type: "nixos", path: "/nix/var/nix/profiles/system"} in targets 228 + 229 + assert %{type: "home-manager", path: "/home/alice/.local/state/nix/profiles/home-manager"} in targets 230 + end 231 + 232 + test "returns empty list for empty subscriptions" do 233 + assert Profile.build_profile_targets([]) == [] 234 + end 235 + 236 + test "returns empty list for subscriptions with unsupported seed types" do 237 + subscriptions = [ 238 + %{seed_type: "unknown-type", seed_name: "test", rules: []} 239 + ] 240 + 241 + assert Profile.build_profile_targets(subscriptions) == [] 242 + end 243 + 244 + test "handles subscriptions with nil rules" do 245 + subscriptions = [ 246 + %{seed_type: "nixos", seed_name: "myhost", rules: nil} 247 + ] 248 + 249 + targets = Profile.build_profile_targets(subscriptions) 250 + assert targets == [%{type: "nixos", path: "/nix/var/nix/profiles/system"}] 251 + end 252 + end 253 + 254 + describe "home_manager_profile_path/1" do 255 + test "uses getent to get home directory" do 256 + # This test verifies the function calls get_user_home 257 + # Actual getent behavior is tested in get_user_home/1 tests 258 + result = Profile.home_manager_profile_path("alice") 259 + 260 + # Should return {:ok, path} or {:error, reason} 261 + assert match?({:ok, _path}, result) or match?({:error, _reason}, result) 262 + end 263 + end 264 + 265 + describe "get_user_home/1" do 266 + test "parses getent output successfully" do 267 + # Mock getent by temporarily redefining System.cmd 268 + # Note: In actual test environment, getent may not be available 269 + # so we test the parsing logic 270 + _output = "alice:x:1000:1000:Alice User:/home/alice:/bin/bash" 271 + 272 + # Test the parsing directly via get_user_home 273 + # If getent succeeds, it should return the home directory 274 + case Profile.get_user_home("root") do 275 + {:ok, home} -> 276 + # getent worked - verify we got a path 277 + assert is_binary(home) 278 + assert home != "" 279 + 280 + {:error, _} -> 281 + # getent not available - fallback should work 282 + # This is acceptable in test environments 283 + :ok 284 + end 285 + end 286 + 287 + test "returns error for non-existent user" do 288 + result = Profile.get_user_home("nonexistentuser12345") 289 + 290 + # Should return error since this user doesn't exist 291 + assert match?({:error, _}, result) or match?({:ok, "/home/nonexistentuser12345"}, result) 292 + # The last resort fallback will return /home/<username> 293 + end 294 + end 295 + 296 + describe "home_manager_profile_path/0" do 297 + test "uses XDG_STATE_HOME when available" do 298 + # Store original value 299 + original_xdg = System.get_env("XDG_STATE_HOME") 300 + original_user = System.get_env("USER") 301 + 302 + try do 303 + System.put_env("XDG_STATE_HOME", "/custom/state") 304 + System.put_env("USER", "testuser") 305 + 306 + assert {:ok, "/custom/state/nix/profiles/home-manager"} = 307 + Profile.home_manager_profile_path() 308 + after 309 + # Restore original values 310 + if original_xdg, 311 + do: System.put_env("XDG_STATE_HOME", original_xdg), 312 + else: System.delete_env("XDG_STATE_HOME") 313 + 314 + if original_user, 315 + do: System.put_env("USER", original_user), 316 + else: System.delete_env("USER") 317 + end 318 + end 319 + 320 + test "falls back to USER when XDG_STATE_HOME not set" do 321 + original_xdg = System.get_env("XDG_STATE_HOME") 322 + original_user = System.get_env("USER") 323 + 324 + try do 325 + System.delete_env("XDG_STATE_HOME") 326 + System.put_env("USER", "root") 327 + 328 + result = Profile.home_manager_profile_path() 329 + 330 + # Should return {:ok, path} or {:error, reason} tuple 331 + case result do 332 + {:ok, path} when is_binary(path) -> 333 + # Success - should contain the profile path 334 + assert path =~ "home-manager" 335 + 336 + {:error, _reason} -> 337 + # Error is acceptable in test environment 338 + :ok 339 + end 340 + after 341 + if original_xdg, 342 + do: System.put_env("XDG_STATE_HOME", original_xdg), 343 + else: System.delete_env("XDG_STATE_HOME") 344 + 345 + if original_user, 346 + do: System.put_env("USER", original_user), 347 + else: System.delete_env("USER") 348 + end 349 + end 350 + end 351 + 138 352 describe "extract_generation_number/1" do 139 353 test "extracts generation number from standard link path" do 140 354 assert Profile.extract_generation_number("/nix/var/nix/profiles/system-42-link") == 42 ··· 156 370 test "returns nil for non-matching patterns" do 157 371 assert Profile.extract_generation_number("/nix/store/abc123-package") == nil 158 372 assert Profile.extract_generation_number("system-42") == nil 373 + end 374 + end 375 + 376 + describe "collect_profiles_for_subscriptions/1" do 377 + test "returns empty report for empty subscriptions" do 378 + report = Profile.collect_profiles_for_subscriptions([]) 379 + assert report.profiles == [] 380 + end 381 + 382 + test "collects profiles when subscriptions exist and paths exist" do 383 + # This test runs on a system that may have /nix/var/nix/profiles/system 384 + # so we just verify it returns a report (empty or not) 385 + subscriptions = [ 386 + %{seed_type: "nixos", seed_name: "test", rules: []} 387 + ] 388 + 389 + report = Profile.collect_profiles_for_subscriptions(subscriptions) 390 + # Report structure should be valid regardless of whether profiles exist 391 + assert is_list(report.profiles) 159 392 end 160 393 end 161 394