Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

cleanup: drop deprecated agent:* channel names, broadcasts, and permissions

Remove all backwards-compatible "agent:*" channel handlers, socket path,
broadcast duplicates, permission role, and old SowerClient type aliases
introduced during the Agent → Garden rename.

Also removes the deprecated DeploymentLogUploadRequest handler that was
already returning {:error, :deprecated}.

Includes a migration to update any existing agent:register access tokens
to garden:register.

sow-84

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

+32 -383
+1 -1
apps/peer_node/lib/peer_node.ex
··· 117 117 put_env( 118 118 node, 119 119 Garden.Socket, 120 - uri: "ws://#{:inet.ntoa(central_node_ip())}:7150/agent/websocket", 120 + uri: "ws://#{:inet.ntoa(central_node_ip())}:7150/garden/websocket", 121 121 reconnect_after_msec: [200, 500, 1000, 2000] 122 122 ) 123 123 end
+1 -2
apps/sower/lib/sower/accounts/access_token.ex
··· 28 28 :"seed:read", 29 29 :"seed:write", 30 30 :"nix-cache:read", 31 - :"garden:register", 32 - :"agent:register" 31 + :"garden:register" 33 32 ] 34 33 end 35 34
-9
apps/sower/lib/sower/authorization/permissions.ex
··· 73 73 permit 74 74 |> all(Sower.Orchestration.Garden, org_id: org_id) 75 75 end 76 - 77 - defp check_role_perm( 78 - %Permit.Permissions{} = permit, 79 - %Sower.Accounts.AccessToken.Permission{role: :"agent:register"}, 80 - org_id 81 - ) do 82 - permit 83 - |> all(Sower.Orchestration.Garden, org_id: org_id) 84 - end 85 76 end
-22
apps/sower/lib/sower/orchestration/deployment.ex
··· 264 264 deployment_event_payload(retry_deployment, request_id) 265 265 ) 266 266 267 - # Backward compatibility: 0.7.0 gardens join "agent:*" 268 - SowerWeb.Endpoint.broadcast( 269 - "agent:#{retry_deployment.garden.sid}", 270 - "deployment", 271 - deployment_event_payload(retry_deployment, request_id) 272 - ) 273 - 274 267 retry_deployment 275 268 276 269 {:error, changeset} -> ··· 309 302 request_id = SowerClient.Sid.generate("req") 310 303 payload = deployment_event_payload(deployment, request_id) 311 304 SowerWeb.Endpoint.broadcast("garden:#{garden.sid}", "deployment", payload) 312 - SowerWeb.Endpoint.broadcast("agent:#{garden.sid}", "deployment", payload) 313 305 end) 314 306 315 307 # 4. Deploy fresh for all overdue subscriptions ··· 423 415 Map.from_struct(deployment) 424 416 ) 425 417 426 - # Backward compatibility: 0.7.0 gardens join "agent:*" 427 - SowerWeb.Endpoint.broadcast( 428 - "agent:#{garden.sid}", 429 - "deployment", 430 - Map.from_struct(deployment) 431 - ) 432 - 433 418 {:error, reason} -> 434 419 Logger.error( 435 420 msg: "Deployment processing failed", ··· 439 424 440 425 SowerWeb.Endpoint.broadcast( 441 426 "garden:#{garden.sid}", 442 - "deployment:error", 443 - %{request_id: request_id, reason: to_string(reason)} 444 - ) 445 - 446 - # Backward compatibility: 0.7.0 gardens join "agent:*" 447 - SowerWeb.Endpoint.broadcast( 448 - "agent:#{garden.sid}", 449 427 "deployment:error", 450 428 %{request_id: request_id, reason: to_string(reason)} 451 429 )
-6
apps/sower/lib/sower/orchestration/deployment_pubsub.ex
··· 26 26 "deployments:garden:#{deployment.garden.sid}", 27 27 {:deployment, event, deployment} 28 28 ) 29 - 30 - # Deprecated: kept for 0.7.0 LiveView backward compatibility 31 - broadcast( 32 - "deployments:agent:#{deployment.garden.sid}", 33 - {:deployment, event, deployment} 34 - ) 35 29 end 36 30 37 31 Enum.each(deployment.subscriptions, fn subscription ->
-5
apps/sower/lib/sower_web/endpoint.ex
··· 22 22 websocket: [connect_info: [:x_headers]], 23 23 longpoll: false 24 24 25 - # Deprecated: kept for 0.7.0 garden backward compatibility 26 - socket "/agent", SowerWeb.GardenSocket, 27 - websocket: [connect_info: [:x_headers]], 28 - longpoll: false 29 - 30 25 # Serve at "/" the static files from "priv/static" directory. 31 26 # 32 27 # You should set gzip to true if you are running phx.digest
-36
apps/sower/lib/sower_web/garden_channel.ex
··· 27 27 28 28 @impl Phoenix.Channel 29 29 def join("garden:lobby", message, socket), do: do_join_lobby(message, socket) 30 - def join("agent:lobby", message, socket), do: do_join_lobby(message, socket) 31 30 32 31 def join("garden:" <> topic_sid = topic, params, socket), 33 - do: do_join_private(topic_sid, topic, params, socket) 34 - 35 - def join("agent:" <> topic_sid = topic, params, socket), 36 32 do: do_join_private(topic_sid, topic, params, socket) 37 33 38 34 def join(topic, params, socket) do ··· 105 101 {:reply, :ok, socket} 106 102 end 107 103 108 - # Accept both "garden:hello" and "agent:hello" 109 104 def handle_in("garden:hello", payload, socket), do: do_handle_hello(payload, socket) 110 - def handle_in("agent:hello", payload, socket), do: do_handle_hello(payload, socket) 111 105 112 106 defp do_handle_hello(payload, socket) do 113 107 case payload 114 - |> normalize_hello_payload() 115 108 |> SowerClient.GardenHello.cast!() 116 109 |> Sower.Orchestration.get_garden(socket) do 117 110 {:ok, garden, oauth_credentials} -> ··· 134 127 end 135 128 end 136 129 137 - # Accept both "agent_sid" (legacy) and "garden_sid" from hello payloads 138 - defp normalize_hello_payload(%{"agent_sid" => sid} = payload) when is_binary(sid) do 139 - payload 140 - |> Map.delete("agent_sid") 141 - |> Map.put_new("garden_sid", sid) 142 - end 143 - 144 - defp normalize_hello_payload(payload), do: payload 145 - 146 130 handle_schema(SowerClient.Seed, &Sower.Orchestration.Seed.get_by_request/1) 147 131 148 132 handle_schema(SowerClient.Orchestration.Subscription, fn req, socket -> ··· 191 175 Sower.Orchestration.SeedDeployment.record_seed_result(req, socket.assigns.garden) 192 176 end) 193 177 194 - # Accept both new and old seeds report events 195 178 handle_schema(SowerClient.Orchestration.GardenSeedsReport, fn report, socket -> 196 179 Orchestration.update_garden_seed_generations(report, socket.assigns.garden) 197 180 end) 198 181 199 - handle_schema(SowerClient.Orchestration.AgentSeedsReport, fn report, socket -> 200 - # Convert legacy AgentSeedsReport to GardenSeedsReport for internal handling 201 - report = struct(SowerClient.Orchestration.GardenSeedsReport, Map.from_struct(report)) 202 - Orchestration.update_garden_seed_generations(report, socket.assigns.garden) 203 - end) 204 - 205 - # Kept for backward compatibility with old gardens that still upload logs to S3. 206 - # New gardens send SeedDeploymentResult instead. 207 - # remove 0.7.0 208 - handle_schema(SowerClient.Storage.DeploymentLogUploadRequest, fn _req, _socket -> 209 - {:error, :deprecated} 210 - end) 211 - 212 182 @impl Phoenix.Channel 213 183 def handle_info(:track_presence, %Phoenix.Socket{assigns: %{garden: garden}} = socket) do 214 184 Logger.debug(msg: "Tracking garden presence", garden_sid: garden.sid) 215 185 216 186 {:ok, _} = 217 187 Presence.track(self(), "garden:presence", socket.assigns.garden.sid, %{ 218 - online_at: DateTime.utc_now() 219 - }) 220 - 221 - # Also track on legacy topic for 0.7.0 LiveView compatibility 222 - {:ok, _} = 223 - Presence.track(self(), "agent:presence", socket.assigns.garden.sid, %{ 224 188 online_at: DateTime.utc_now() 225 189 }) 226 190
-1
apps/sower/lib/sower_web/garden_socket.ex
··· 4 4 use Phoenix.Socket 5 5 6 6 channel("garden:*", SowerWeb.GardenChannel) 7 - channel("agent:*", SowerWeb.GardenChannel) 8 7 9 8 @impl Phoenix.Socket 10 9 def connect(params, socket, connect_info) do
+24
apps/sower/priv/repo/migrations/20260411145412_migrate_agent_register_to_garden_register.exs
··· 1 + defmodule Sower.Repo.Migrations.MigrateAgentRegisterToGardenRegister do 2 + use Ecto.Migration 3 + 4 + def up do 5 + execute(""" 6 + UPDATE access_tokens 7 + SET permissions = ( 8 + SELECT jsonb_agg( 9 + CASE 10 + WHEN elem->>'role' = 'agent:register' 11 + THEN jsonb_set(elem, '{role}', '"garden:register"') 12 + ELSE elem 13 + END 14 + ) 15 + FROM jsonb_array_elements(permissions) AS elem 16 + ) 17 + WHERE permissions::text LIKE '%agent:register%' 18 + """) 19 + end 20 + 21 + def down do 22 + :ok 23 + end 24 + end
+4 -4
apps/sower/test/sower/orchestration_test.exs
··· 1034 1034 }) 1035 1035 1036 1036 replayed_at = DateTime.utc_now() |> DateTime.truncate(:second) 1037 - Phoenix.PubSub.subscribe(Sower.PubSub, "agent:#{garden.sid}") 1037 + Phoenix.PubSub.subscribe(Sower.PubSub, "garden:#{garden.sid}") 1038 1038 1039 1039 assert {:ok, %{replayed: replayed, cancelled: [], overdue: []}} = 1040 1040 Orchestration.Deployment.reconcile_deployments_on_connect(garden, now: replayed_at) ··· 1047 1047 payload: payload 1048 1048 } 1049 1049 1050 - assert topic == "agent:#{garden.sid}" 1050 + assert topic == "garden:#{garden.sid}" 1051 1051 assert payload.sid == unresolved.sid 1052 1052 assert payload.skipped == false 1053 1053 assert is_binary(payload.request_id) ··· 1408 1408 deployed_at: DateTime.utc_now() 1409 1409 }) 1410 1410 1411 - Phoenix.PubSub.subscribe(Sower.PubSub, "agent:#{garden.sid}") 1411 + Phoenix.PubSub.subscribe(Sower.PubSub, "garden:#{garden.sid}") 1412 1412 1413 1413 assert {:ok, retried} = Orchestration.retry_deployment(deployment, user.id) 1414 1414 ··· 1418 1418 payload: payload 1419 1419 } 1420 1420 1421 - assert topic == "agent:#{garden.sid}" 1421 + assert topic == "garden:#{garden.sid}" 1422 1422 assert payload.sid == retried.sid 1423 1423 assert payload.skipped == false 1424 1424 assert is_binary(payload.request_id)
-28
apps/sower/test/sower_web/channels/garden_channel_handle_in_test.exs
··· 54 54 refute Map.has_key?(reply.oauth_credentials, :refresh_token) 55 55 refute Map.has_key?(reply.oauth_credentials, :access_token) 56 56 end 57 - 58 - test "accepts legacy agent:hello event" do 59 - %{socket: socket, garden: garden} = connect_and_join_garden() 60 - 61 - ref = 62 - push(socket, "agent:hello", %{ 63 - "garden_sid" => garden.sid, 64 - "local_sid" => garden.local_sid, 65 - "name" => garden.name 66 - }) 67 - 68 - assert_reply ref, :ok, reply, 1_000 69 - assert reply.sid == garden.sid 70 - end 71 - 72 - test "normalizes legacy agent_sid field to garden_sid" do 73 - %{socket: socket, garden: garden} = connect_and_join_garden() 74 - 75 - ref = 76 - push(socket, "garden:hello", %{ 77 - "agent_sid" => garden.sid, 78 - "local_sid" => garden.local_sid, 79 - "name" => garden.name 80 - }) 81 - 82 - assert_reply ref, :ok, reply, 1_000 83 - assert reply.sid == garden.sid 84 - end 85 57 end 86 58 87 59 describe "deployment:request" do
-33
apps/sower/test/sower_web/channels/garden_channel_test.exs
··· 64 64 assert socket.assigns.garden.id == garden.id 65 65 end 66 66 67 - test "accepts legacy agent: topic prefix" do 68 - user = user_fixture() 69 - Sower.Repo.put_org_id(user.org_id) 70 - 71 - {:ok, access_token} = 72 - Sower.Accounts.AccessToken.create(%{ 73 - "description" => "test", 74 - "user_id" => user.id, 75 - "org_id" => user.org_id, 76 - "permissions" => [%{"role" => "garden:register"}] 77 - }) 78 - 79 - garden = 80 - garden_fixture(%{ 81 - sid: SowerClient.Sid.generate("grdn"), 82 - local_sid: SowerClient.Sid.generate("lc_grdn") 83 - }) 84 - 85 - encoded_token = Base.encode64(access_token.token) 86 - 87 - {:ok, socket} = connect(SowerWeb.GardenSocket, %{"token" => encoded_token}) 88 - 89 - {:ok, _reply, socket} = 90 - subscribe_and_join( 91 - socket, 92 - SowerWeb.GardenChannel, 93 - "agent:#{garden.sid}", 94 - %{"local_sid" => garden.local_sid} 95 - ) 96 - 97 - assert socket.assigns.garden.id == garden.id 98 - end 99 - 100 67 test "rejects join when garden does not exist" do 101 68 user = user_fixture() 102 69 Sower.Repo.put_org_id(user.org_id)
+2 -2
apps/sower/test/sower_web/live/settings/access_token_live_test.exs
··· 122 122 123 123 {:ok, updated} = 124 124 Sower.Accounts.AccessToken.update(token, %{ 125 - "permissions" => ["nix-cache:read", "agent:register"] 125 + "permissions" => ["nix-cache:read", "garden:register"] 126 126 }) 127 127 128 128 roles = Enum.map(updated.permissions, & &1.role) 129 129 assert :"nix-cache:read" in roles 130 - assert :"agent:register" in roles 130 + assert :"garden:register" in roles 131 131 refute :"seed:read" in roles 132 132 end 133 133 end
-5
apps/sower_client/lib/sower_client.ex
··· 25 25 |> OpenApiSpex.add_schemas([ 26 26 SowerClient.GardenHello, 27 27 SowerClient.GardenRegistration, 28 - SowerClient.AgentHello, 29 28 SowerClient.Auth.OAuthCredentials, 30 29 SowerClient.Auth.TokenInfo, 31 30 SowerClient.Orchestration.GardenSeedGeneration, 32 31 SowerClient.Orchestration.GardenSeedProfile, 33 32 SowerClient.Orchestration.GardenSeedsReport, 34 - SowerClient.Orchestration.AgentSeedGeneration, 35 - SowerClient.Orchestration.AgentSeedProfile, 36 - SowerClient.Orchestration.AgentSeedsReport, 37 33 SowerClient.Orchestration.Deployment, 38 34 SowerClient.Orchestration.DeploymentResult, 39 35 SowerClient.Orchestration.DeploymentRequest, ··· 45 41 SowerClient.Orchestration.Subscription.Window, 46 42 SowerClient.Orchestration.SubscriptionSync, 47 43 SowerClient.Storage.PresignedUploadReply, 48 - SowerClient.Storage.DeploymentLogUploadRequest, 49 44 SowerClient.Seed, 50 45 SowerClient.SeedMeta, 51 46 SowerClient.SeedTag
-28
apps/sower_client/lib/sower_client/agent_hello.ex
··· 1 - # Deprecated: use SowerClient.GardenHello 2 - # Kept as alias for 0.7.0 backward compatibility 3 - defmodule SowerClient.AgentHello do 4 - use SowerClient.Schema 5 - use SowerClient.ChannelMessage, event: "agent:hello", topic_type: :lobby 6 - 7 - OpenApiSpex.schema(%{ 8 - title: "AgentHello", 9 - type: :object, 10 - properties: %{ 11 - agent_sid: %Schema{ 12 - type: :string, 13 - description: "sid allocated by Sower", 14 - readOnly: true, 15 - nullable: true 16 - }, 17 - local_sid: %Schema{ 18 - type: :string, 19 - description: "sid allocated locally" 20 - }, 21 - name: %Schema{ 22 - type: :string, 23 - description: "Name of agent" 24 - } 25 - }, 26 - required: [:local_sid, :name] 27 - }) 28 - end
-37
apps/sower_client/lib/sower_client/orchestration/agent_seed_generation.ex
··· 1 - defmodule SowerClient.Orchestration.AgentSeedGeneration do 2 - @moduledoc """ 3 - Represents a single Nix profile generation reported by an agent. 4 - """ 5 - use SowerClient.Schema 6 - 7 - OpenApiSpex.schema(%{ 8 - title: "AgentSeedGeneration", 9 - type: :object, 10 - properties: %{ 11 - path: %Schema{ 12 - type: :string, 13 - description: "The Nix store path (e.g., /nix/store/abc-nixos-system)" 14 - }, 15 - link: %Schema{ 16 - type: :string, 17 - description: "The symlink path (e.g., /nix/var/nix/profiles/system-42-link)" 18 - }, 19 - created: %Schema{ 20 - type: :string, 21 - format: :"date-time", 22 - description: "When the Nix generation was created" 23 - }, 24 - generation_number: %Schema{ 25 - type: :integer, 26 - description: "Nix generation number (extracted from link)", 27 - nullable: true 28 - }, 29 - is_current: %Schema{ 30 - type: :boolean, 31 - description: "Is this the active generation?", 32 - default: false 33 - } 34 - }, 35 - required: [:path, :link, :created, :is_current] 36 - }) 37 - end
-29
apps/sower_client/lib/sower_client/orchestration/agent_seed_profile.ex
··· 1 - defmodule SowerClient.Orchestration.AgentSeedProfile do 2 - @moduledoc """ 3 - Represents all generations for a single Nix profile on an agent. 4 - """ 5 - use SowerClient.Schema 6 - 7 - OpenApiSpex.schema(%{ 8 - title: "AgentSeedProfile", 9 - type: :object, 10 - properties: %{ 11 - profile_path: %Schema{ 12 - type: :string, 13 - description: "The Nix profile path (e.g., /nix/var/nix/profiles/system)" 14 - }, 15 - tags: %Schema{ 16 - type: :object, 17 - additionalProperties: %Schema{type: :string}, 18 - description: "Profile tags (e.g., %{user: alice} for HomeManager)", 19 - default: %{} 20 - }, 21 - generations: %Schema{ 22 - type: :array, 23 - items: SowerClient.Orchestration.AgentSeedGeneration, 24 - description: "All available generations for this profile" 25 - } 26 - }, 27 - required: [:profile_path, :generations] 28 - }) 29 - end
-23
apps/sower_client/lib/sower_client/orchestration/agent_seeds_report.ex
··· 1 - # Deprecated: use SowerClient.Orchestration.GardenSeedsReport 2 - # Kept as alias for 0.7.0 backward compatibility 3 - defmodule SowerClient.Orchestration.AgentSeedsReport do 4 - @moduledoc """ 5 - Deprecated: use SowerClient.Orchestration.GardenSeedsReport. 6 - Kept for backward compatibility with 0.7.0 gardens. 7 - """ 8 - use SowerClient.Schema 9 - use SowerClient.ChannelMessage, event: "agent:seeds:report" 10 - 11 - OpenApiSpex.schema(%{ 12 - title: "AgentSeedsReport", 13 - type: :object, 14 - properties: %{ 15 - profiles: %Schema{ 16 - type: :array, 17 - items: SowerClient.Orchestration.GardenSeedProfile, 18 - description: "All Nix profiles with their generations" 19 - } 20 - }, 21 - required: [:profiles] 22 - }) 23 - end
-25
apps/sower_client/lib/sower_client/storage/deployment_log_upload_request.ex
··· 1 - defmodule SowerClient.Storage.DeploymentLogUploadRequest do 2 - use SowerClient.Schema 3 - use SowerClient.ChannelMessage, event: "storage:deployment_log_upload" 4 - 5 - OpenApiSpex.schema(%{ 6 - title: "DeploymentLogUploadRequest", 7 - type: :object, 8 - properties: %{ 9 - deployment_sid: %Schema{ 10 - type: :string, 11 - description: "SID of the deployment the log belongs to" 12 - }, 13 - seed_sid: %Schema{ 14 - type: :string, 15 - description: "SID of the seed within the deployment" 16 - }, 17 - checksum_sha256: %Schema{ 18 - type: :string, 19 - description: "Base64 SHA-256 checksum to sign for upload integrity", 20 - nullable: true 21 - } 22 - }, 23 - required: [:deployment_sid, :seed_sid] 24 - }) 25 - end
-87
apps/sower_client/test/sower_client/storage/deployment_log_upload_test.exs
··· 1 - defmodule SowerClient.Storage.DeploymentLogUploadTest do 2 - use ExUnit.Case, async: true 3 - 4 - alias SowerClient.Storage.DeploymentLogUploadRequest 5 - alias SowerClient.Storage.PresignedUploadReply 6 - 7 - describe "DeploymentLogUploadRequest" do 8 - test "cast/1 with valid data succeeds" do 9 - assert {:ok, request} = 10 - DeploymentLogUploadRequest.cast(%{ 11 - "deployment_sid" => "deploy_123", 12 - "seed_sid" => "seed_456", 13 - "checksum_sha256" => "abc123" 14 - }) 15 - 16 - assert request.deployment_sid == "deploy_123" 17 - assert request.seed_sid == "seed_456" 18 - assert request.checksum_sha256 == "abc123" 19 - end 20 - 21 - test "cast/1 without checksum succeeds" do 22 - assert {:ok, request} = 23 - DeploymentLogUploadRequest.cast(%{ 24 - "deployment_sid" => "deploy_123", 25 - "seed_sid" => "seed_456" 26 - }) 27 - 28 - assert request.deployment_sid == "deploy_123" 29 - assert request.seed_sid == "seed_456" 30 - assert request.checksum_sha256 == nil 31 - end 32 - 33 - test "cast/1 with missing deployment_sid fails" do 34 - assert {:error, _} = 35 - DeploymentLogUploadRequest.cast(%{ 36 - "seed_sid" => "seed_456" 37 - }) 38 - end 39 - 40 - test "cast/1 with missing seed_sid fails" do 41 - assert {:error, _} = 42 - DeploymentLogUploadRequest.cast(%{ 43 - "deployment_sid" => "deploy_123" 44 - }) 45 - end 46 - 47 - test "event/0 returns correct channel event" do 48 - assert DeploymentLogUploadRequest.event() == "storage:deployment_log_upload" 49 - end 50 - end 51 - 52 - describe "PresignedUploadReply" do 53 - test "cast/1 with valid data succeeds" do 54 - assert {:ok, reply} = 55 - PresignedUploadReply.cast(%{ 56 - "url" => "https://s3.example.com/upload", 57 - "method" => "PUT", 58 - "headers" => %{"x-amz-checksum-sha256" => "abc123"} 59 - }) 60 - 61 - assert reply.url == "https://s3.example.com/upload" 62 - assert reply.method == "PUT" 63 - assert reply.headers == %{"x-amz-checksum-sha256" => "abc123"} 64 - end 65 - 66 - test "cast!/1 with valid data returns struct" do 67 - reply = 68 - PresignedUploadReply.cast!(%{ 69 - "url" => "https://s3.example.com/upload", 70 - "method" => "PUT", 71 - "headers" => %{} 72 - }) 73 - 74 - assert reply.url == "https://s3.example.com/upload" 75 - assert reply.method == "PUT" 76 - assert reply.headers == %{} 77 - end 78 - 79 - test "cast/1 with missing required gardens fails" do 80 - assert {:error, _} = 81 - PresignedUploadReply.cast(%{ 82 - "method" => "PUT", 83 - "headers" => %{} 84 - }) 85 - end 86 - end 87 - end