Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

client: direct activation if username matches running user

+156 -7
+1
.gitignore
··· 45 45 dev-server.json 46 46 .erlang-history/ 47 47 /sower-activator 48 + .direnv
+41 -6
apps/sower_client/lib/sower_client/activator.ex
··· 4 4 5 5 Supports two activation methods: 6 6 - Socket mode: Communicates with sower-activator daemon via Unix socket 7 - - CLI mode: Invokes sower-activator binary directly (with sudo) 7 + - CLI mode: Invokes sower-activator binary directly (with sudo for NixOS) 8 8 9 - The `activate/3` function automatically tries socket first, then falls back to CLI. 9 + ## Activation Path Selection 10 + 11 + The `activate/3` function selects the activation method based on type and context: 12 + 13 + 1. For **home-manager** with username tag matching the current user: 14 + Uses CLI activation directly (no sudo, no socket) - enables self-managed 15 + home-manager configurations without requiring a privileged daemon. 16 + 17 + 2. For **NixOS** or other types: 18 + Uses socket activation when available, falls back to CLI with sudo. 19 + 20 + 3. For **home-manager** with mismatched username: 21 + Uses socket activation when available, otherwise returns `{:error, :username_mismatch}`. 10 22 """ 11 23 12 24 use TypedStruct ··· 54 66 """ 55 67 def activate(type, path, opts \\ []) do 56 68 socket_path = Keyword.get(opts, :socket_path, @default_socket_path) 69 + tags = Keyword.get(opts, :tags, []) 57 70 58 - if socket_available?(socket_path) do 59 - activate_via_socket(type, path, opts) 60 - else 61 - Logger.debug("Socket not available, falling back to CLI activation") 71 + if type == "home-manager" and username_matches_current_user?(tags) do 72 + Logger.debug("Home-manager username matches current user, using direct CLI activation") 62 73 activate_via_cli(type, path, opts) 74 + else 75 + if socket_available?(socket_path) do 76 + activate_via_socket(type, path, opts) 77 + else 78 + Logger.debug("Socket not available, falling back to CLI activation") 79 + activate_via_cli(type, path, opts) 80 + end 63 81 end 64 82 end 65 83 ··· 204 222 def socket_available?(socket_path \\ @default_socket_path) do 205 223 File.exists?(socket_path) 206 224 end 225 + 226 + @doc """ 227 + Check if the username tag matches the current user. 228 + 229 + Returns `true` if a username tag exists and matches `System.get_env("USER")`. 230 + Returns `false` if no username tag found or it doesn't match. 231 + """ 232 + def username_matches_current_user?(tags) when is_list(tags) do 233 + current_user = System.get_env("USER") 234 + 235 + case Enum.find(tags, &(&1.key == "username")) do 236 + %{value: ^current_user} -> true 237 + _ -> false 238 + end 239 + end 240 + 241 + def username_matches_current_user?(_tags), do: false 207 242 208 243 # Private functions - Socket communication 209 244
+114 -1
apps/sower_client/test/sower_client/activator_test.exs
··· 159 159 end 160 160 161 161 test "falls back to CLI when socket not available" do 162 - # Ensure the executables don't exist in PATH 163 162 original_path = System.get_env("PATH") 164 163 System.put_env("PATH", "/nonexistent") 165 164 ··· 172 171 assert {:error, :cmd_not_found} = result 173 172 end) 174 173 end 174 + 175 + test "home-manager with matching username bypasses socket" do 176 + {socket_path, server_pid} = 177 + start_mock_server(fn _request_line, _client_socket -> 178 + # This should not be called - we're testing CLI bypass 179 + flunk("Socket should not be contacted for same-user home-manager") 180 + end) 181 + 182 + original_user = System.get_env("USER") 183 + System.put_env("USER", "alice") 184 + 185 + original_path = System.get_env("PATH") 186 + System.put_env("PATH", "/nonexistent") 187 + 188 + on_exit(fn -> 189 + System.put_env("USER", original_user || "") 190 + System.put_env("PATH", original_path) 191 + stop_mock_server(server_pid) 192 + File.rm_rf!(Path.dirname(socket_path)) 193 + end) 194 + 195 + tags = [%{key: "username", value: "alice"}] 196 + 197 + capture_log(fn -> 198 + result = 199 + Activator.activate("home-manager", "/nix/store/xyz", 200 + socket_path: socket_path, 201 + tags: tags 202 + ) 203 + 204 + assert {:error, :cmd_not_found} = result 205 + end) 206 + end 207 + 208 + test "home-manager with mismatched username fails without socket" do 209 + original_user = System.get_env("USER") 210 + System.put_env("USER", "alice") 211 + 212 + on_exit(fn -> System.put_env("USER", original_user || "") end) 213 + 214 + tags = [%{key: "username", value: "bob"}] 215 + 216 + capture_log(fn -> 217 + result = 218 + Activator.activate("home-manager", "/nix/store/xyz", 219 + socket_path: "/nonexistent/socket", 220 + tags: tags 221 + ) 222 + 223 + assert {:error, :username_mismatch} = result 224 + end) 225 + end 226 + 227 + test "NixOS activation still uses socket when available" do 228 + {socket_path, server_pid} = 229 + start_mock_server(fn request_line, client_socket -> 230 + request = Jason.decode!(request_line) 231 + assert request["type"] == "nixos" 232 + 233 + send_response(client_socket, %{id: request["id"], type: "complete", exit_code: 0}) 234 + end) 235 + 236 + on_exit(fn -> 237 + stop_mock_server(server_pid) 238 + File.rm_rf!(Path.dirname(socket_path)) 239 + end) 240 + 241 + assert {:ok, []} = 242 + Activator.activate("nixos", "/nix/store/xyz", socket_path: socket_path) 243 + end 244 + 245 + test "home-manager without username tag returns appropriate error" do 246 + original_user = System.get_env("USER") 247 + System.put_env("USER", "alice") 248 + 249 + on_exit(fn -> System.put_env("USER", original_user || "") end) 250 + 251 + capture_log(fn -> 252 + result = 253 + Activator.activate("home-manager", "/nix/store/xyz", 254 + socket_path: "/nonexistent/socket", 255 + tags: [] 256 + ) 257 + 258 + assert {:error, :missing_username_tag} = result 259 + end) 260 + end 175 261 end 176 262 177 263 describe "reboot/1" do ··· 190 276 191 277 assert {:error, :cmd_not_found} = result 192 278 end) 279 + end 280 + end 281 + 282 + describe "username_matches_current_user?/1" do 283 + test "returns true when username tag matches current user" do 284 + original_user = System.get_env("USER") 285 + System.put_env("USER", "alice") 286 + 287 + on_exit(fn -> System.put_env("USER", original_user || "") end) 288 + 289 + tags = [%{key: "username", value: "alice"}] 290 + assert Activator.username_matches_current_user?(tags) 291 + end 292 + 293 + test "returns false when username tag doesn't match current user" do 294 + original_user = System.get_env("USER") 295 + System.put_env("USER", "alice") 296 + 297 + on_exit(fn -> System.put_env("USER", original_user || "") end) 298 + 299 + tags = [%{key: "username", value: "bob"}] 300 + refute Activator.username_matches_current_user?(tags) 301 + end 302 + 303 + test "returns false when no username tag present" do 304 + refute Activator.username_matches_current_user?([]) 305 + refute Activator.username_matches_current_user?([%{key: "other", value: "test"}]) 193 306 end 194 307 end 195 308