Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

refactor: rename SowerAgent to Garden

Had Claude use a script, roughly, and then do some manual cleanup.

refactor: rename Agent → Field in database & internal domain model

refactor: rename Agent → Field in channel contract with dual-accept

refactor: rename Agent → Field in web UI and routes

refactor: rename Agent → Field: internal cleanup (non-compat)

refactor: rename Field → Garden across codebase

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

+2232 -2390
-10
.iex-agent.exs
··· 1 - Application.ensure_all_started([:erlexec, :exsync, :sower_agent]) 2 - 3 - IEx.configure( 4 - inspect: [ 5 - pretty: true, 6 - limit: 1000, 7 - width: 80 8 - ], 9 - width: 80 10 - )
+10
.iex-garden.exs
··· 1 + Application.ensure_all_started([:erlexec, :exsync, :garden]) 2 + 3 + IEx.configure( 4 + inspect: [ 5 + pretty: true, 6 + limit: 1000, 7 + width: 80 8 + ], 9 + width: 80 10 + )
+1
.services/postgres/.gitignore
··· 1 + data*/
+2 -2
AGENTS.md
··· 6 6 7 7 If more than one of the rules conflict, ask before implementing. 8 8 9 - - Never break deployments or strand agents such that they cannot apply an upgrade. 10 - - Ensure backwards compatibility or migration paths for changes that affect contracts between components (e.g. agent/server), but otherwise assume breaking changes are ok. 9 + - Never break deployments or strand gardens such that they cannot apply an upgrade. 10 + - Ensure backwards compatibility or migration paths for changes that affect contracts between components (e.g. garden/server), but otherwise assume breaking changes are ok. 11 11 - Evolve the code without planning for every possible future or edge case. 12 12 - Delete abandoned paths by default when changing direction, except when required by compatibility/migration. Ask before keeping more than one implementation. 13 13
+36 -1
CLAUDE.md
··· 1 - AGENTS.md 1 + ## Agent Workflow 2 + - **IMPORTANT**: before you do anything else, invoke the vein `orient` MCP prompt and heed its output with `/mcp__vein__orient`. 3 + - **Always** use `elixir-conventions` for elixir code. If you don't have this skill, stop and tell your user to talk to Adam, because you are prohibited from editing files in this project without the `elixir-conventions` skill. 4 + 5 + ## Rules 6 + 7 + If more than one of the rules conflict, ask before implementing. 8 + 9 + - Never break deployments or strand gardens such that they cannot apply an upgrade. 10 + - Ensure backwards compatibility or migration paths for changes that affect contracts between components (e.g. garden/server), but otherwise assume breaking changes are ok. 11 + - Evolve the code without planning for every possible future or edge case. 12 + - Delete abandoned paths by default when changing direction, except when required by compatibility/migration. Ask before keeping more than one implementation. 13 + 14 + ## Definition of done 15 + - formatting done, `just format` 16 + - tests pass, `just check-elixir`, `just check-go`, or `just check-e2e` 17 + - code committed with all ticket changes included 18 + - Ticket ID in the body 19 + - Co-Authored-By line always included 20 + - *important* After committing, stop and get user approval for completion. 21 + - ticket marked complete once approved 22 + 23 + ## Code conventions 24 + 25 + - Prefer red/green TDD. If unsure what style of testing, stop and ask. 26 + - Always read code for project elixir dependencies from `deps`. Never query hexdocs or hex. 27 + - SowerClient schemas must *always* be added to `sower_client.ex` 28 + 29 + ## Testing 30 + 31 + - Test nix end to end with `just check-e2e` 32 + - You can access the dev server live over tidewave project_eval, allowing for introspection of a live environment. 33 + 34 + ## Workspace setup 35 + 36 + After creating a workspace, run: `mix deps.get && mix compile`
+13 -13
README.md
··· 6 6 7 7 - A seed is an extra bundle of metadata for an artifact path, e.g. a Nix store path. 8 8 - Seed metadata includes a set of tags, with git and user-provided tags. 9 - - An agent defines seeds they want to subscribe to. 9 + - A garden defines seeds they want to subscribe to. 10 10 - Seeds are submitted to a server to be used for deployments. 11 11 12 12 **WARNING** 13 13 This project is experimental and is not recommended for production installation. 14 14 One of the goals is never break deployments, but it is **not guaranteed yet**. 15 - I'm only using this in a homelab with approximately a dozen agents. 15 + I'm only using this in a homelab with approximately a dozen gardens. 16 16 This means the risk to me of breaking deployments is moderately low. 17 17 18 18 I'd love for others to get value out of what I'm building here. ··· 20 20 21 21 ## Installation 22 22 23 - Read the NixOS modules for the server and the agent. There is an example in nix/tests/e2e.nix 23 + Read the NixOS modules for the server and the garden. There is an example in nix/tests/e2e.nix 24 24 25 25 1. An example server config exists in nix/tests/e2e.nix 26 - 2. An example agent config is below. 26 + 2. An example garden config is below. 27 27 28 28 Good luck, everyone's counting on you. 29 29 30 30 ## Components 31 31 32 32 - Server including Phoenix LiveView web interface. 33 - - Always-on Agent with bi-directional communication with the Server over WebSockets. 34 - - Activator used by the Agent for running specific actions as root, over a systemd initiated socket. 33 + - Always-on end-system daemon (Garden) with bi-directional communication to the Server over real-time WebSocket connection. 34 + - Activator used by the Garden for running limited, specific actions as root, over a systemd initiated socket. 35 35 - CLI for submitting seeds including a full code to submitted builder. 36 36 37 - ### Agents 37 + ### Gardens 38 38 39 - Agents have full control over what the seeds from the server can or will do. 40 - This is managed through their configuration. 39 + Gardens are an always on client, which have full control over what the seeds from the server can or will do. 40 + Through the garden's configuration, you can determine what the garden should subscribe to and what should happen when working with seeds for deployment. 41 41 42 42 #### Subscriptions 43 43 ··· 54 54 - Arguments to pass to activation 55 55 - Rules about rebooting (NixOS seeds only) 56 56 57 - #### Example agent config 57 + #### Example garden config 58 58 59 59 ```nix 60 60 { 61 61 age.secrets.sower-next-api-token = { 62 62 file = cfg.access_token_secret; 63 - owner = "sower-agent"; 63 + owner = "sower-garden"; 64 64 }; 65 65 66 66 services.sower = { ··· 69 69 allowedGroups = [ "users" ]; 70 70 }; 71 71 72 - agent = { 72 + garden = { 73 73 enable = true; 74 74 accessTokenFile = config.age.secrets.sower-next-api-token.path; 75 - package = inputs.sower-next.packages.${pkgs.stdenv.hostPlatform.system}.agent; 75 + package = inputs.sower-next.packages.${pkgs.stdenv.hostPlatform.system}.garden; 76 76 77 77 settings = { 78 78 access_token_file = config.age.secrets.sower-next-api-token.path;
+3
apps/garden/README.md
··· 1 + # Garden 2 + 3 + **TODO: Add description**
+3
apps/garden/test/garden_test.exs
··· 1 + defmodule GardenTest do 2 + use ExUnit.Case 3 + end
+7 -7
apps/peer_node/lib/peer_node.ex
··· 74 74 start(node.instance) 75 75 end 76 76 77 - def start_agent(%__MODULE__{} = node) do 77 + def start_garden(%__MODULE__{} = node) do 78 78 set_config(node) 79 79 80 - call(node, Application, :ensure_all_started, [:sower_agent]) 80 + call(node, Application, :ensure_all_started, [:garden]) 81 81 end 82 82 83 83 def get_env(%__MODULE__{} = node, name) do 84 84 call(node, Application, :get_env, [ 85 - :sower_agent, 85 + :garden, 86 86 name 87 87 ]) 88 88 end 89 89 90 90 def put_env(%__MODULE__{} = node, name, value) do 91 91 call(node, Application, :put_env, [ 92 - :sower_agent, 92 + :garden, 93 93 name, 94 94 value 95 95 ]) ··· 101 101 put_env( 102 102 node, 103 103 :config, 104 - Application.get_env(:sower_agent, :config) 104 + Application.get_env(:garden, :config) 105 105 ) 106 106 107 107 storage_dir = "/tmp/#{node.name}" ··· 110 110 111 111 put_env( 112 112 node, 113 - SowerAgent.Storage, 113 + Garden.Storage, 114 114 file: "#{storage_dir}/storage.etf" 115 115 ) 116 116 117 117 put_env( 118 118 node, 119 - SowerAgent.Client, 119 + Garden.Socket, 120 120 uri: "ws://#{:inet.ntoa(central_node_ip())}:7150/agent/websocket", 121 121 reconnect_after_msec: [200, 500, 1000, 2000] 122 122 )
+9 -3
apps/sower/lib/sower/accounts/access_token.ex
··· 24 24 25 25 embeds_many :permissions, Permission, on_replace: :delete do 26 26 field :role, Ecto.Enum, 27 - values: [:"seed:read", :"seed:write", :"nix-cache:read", :"agent:register"] 27 + values: [ 28 + :"seed:read", 29 + :"seed:write", 30 + :"nix-cache:read", 31 + :"garden:register", 32 + :"agent:register" 33 + ] 28 34 end 29 35 30 36 timestamps() ··· 51 57 end 52 58 53 59 def validate_expires_at(changeset) do 54 - validate_change(changeset, :expires_at, fn field, value -> 60 + validate_change(changeset, :expires_at, fn garden, value -> 55 61 {:ok, expire} = 56 62 value 57 63 |> DateTime.new(Time.new!(0, 0, 0), "Etc/UTC") 58 64 59 65 if DateTime.before?(expire, DateTime.utc_now()) do 60 - [{field, "must be at least 24 hours"}] 66 + [{garden, "must be at least 24 hours"}] 61 67 else 62 68 [] 63 69 end
+10 -1
apps/sower/lib/sower/authorization/permissions.ex
··· 50 50 51 51 defp check_role_perm( 52 52 %Permit.Permissions{} = permit, 53 + %Sower.Accounts.AccessToken.Permission{role: :"garden:register"}, 54 + org_id 55 + ) do 56 + permit 57 + |> all(Sower.Orchestration.Garden, org_id: org_id) 58 + end 59 + 60 + defp check_role_perm( 61 + %Permit.Permissions{} = permit, 53 62 %Sower.Accounts.AccessToken.Permission{role: :"agent:register"}, 54 63 org_id 55 64 ) do 56 65 permit 57 - |> all(Sower.Orchestration.Agent, org_id: org_id) 66 + |> all(Sower.Orchestration.Garden, org_id: org_id) 58 67 end 59 68 end
+8 -8
apps/sower/lib/sower/forge.ex
··· 106 106 107 107 ## Examples 108 108 109 - iex> create_connection(%{field: value}) 109 + iex> create_connection(%{garden: value}) 110 110 {:ok, %Connection{}} 111 111 112 - iex> create_connection(%{field: bad_value}) 112 + iex> create_connection(%{garden: bad_value}) 113 113 {:error, %Ecto.Changeset{}} 114 114 115 115 """ ··· 126 126 127 127 ## Examples 128 128 129 - iex> update_connection(connection, %{field: new_value}) 129 + iex> update_connection(connection, %{garden: new_value}) 130 130 {:ok, %Connection{}} 131 131 132 - iex> update_connection(connection, %{field: bad_value}) 132 + iex> update_connection(connection, %{garden: bad_value}) 133 133 {:error, %Ecto.Changeset{}} 134 134 135 135 """ ··· 254 254 255 255 ## Examples 256 256 257 - iex> create_repository(%{field: value}) 257 + iex> create_repository(%{garden: value}) 258 258 {:ok, %Repository{}} 259 259 260 - iex> create_repository(%{field: bad_value}) 260 + iex> create_repository(%{garden: bad_value}) 261 261 {:error, %Ecto.Changeset{}} 262 262 263 263 """ ··· 275 275 276 276 ## Examples 277 277 278 - iex> update_repository(repository, %{field: new_value}) 278 + iex> update_repository(repository, %{garden: new_value}) 279 279 {:ok, %Repository{}} 280 280 281 - iex> update_repository(repository, %{field: bad_value}) 281 + iex> update_repository(repository, %{garden: bad_value}) 282 282 {:error, %Ecto.Changeset{}} 283 283 284 284 """
+4 -4
apps/sower/lib/sower/nix.ex
··· 74 74 75 75 ## Examples 76 76 77 - iex> create_cache(%{field: value}) 77 + iex> create_cache(%{garden: value}) 78 78 {:ok, %Cache{}} 79 79 80 - iex> create_cache(%{field: bad_value}) 80 + iex> create_cache(%{garden: bad_value}) 81 81 {:error, %Ecto.Changeset{}} 82 82 83 83 """ ··· 94 94 95 95 ## Examples 96 96 97 - iex> update_cache(cache, %{field: new_value}) 97 + iex> update_cache(cache, %{garden: new_value}) 98 98 {:ok, %Cache{}} 99 99 100 - iex> update_cache(cache, %{field: bad_value}) 100 + iex> update_cache(cache, %{garden: bad_value}) 101 101 {:error, %Ecto.Changeset{}} 102 102 103 103 """
+30 -30
apps/sower/lib/sower/orchestration.ex
··· 3 3 The Orchestration context. 4 4 """ 5 5 6 - alias Sower.Orchestration.Agent 7 - alias Sower.Orchestration.AgentSeedGeneration 6 + alias Sower.Orchestration.Garden 7 + alias Sower.Orchestration.GardenSeedGeneration 8 8 alias Sower.Orchestration.Deployment 9 9 alias Sower.Orchestration.Subscription 10 10 11 - # Agent delegates 12 - defdelegate change_agent(agent, attrs \\ %{}), to: Agent 13 - defdelegate create_agent(attrs \\ %{}), to: Agent 14 - defdelegate delete_agent(agent), to: Agent 15 - defdelegate get_agent!(id), to: Agent 16 - defdelegate get_agent(hello, socket), to: Agent 17 - defdelegate get_agent_local_sid!(local_sid), to: Agent 18 - defdelegate get_agent_local_sid(local_sid), to: Agent 19 - defdelegate get_agent_sid!(sid), to: Agent 20 - defdelegate get_agent_sid(sid), to: Agent 21 - defdelegate list_agents(), to: Agent 22 - defdelegate list_agents_with_latest_deployment(), to: Agent 23 - defdelegate update_agent(agent, attrs), to: Agent 11 + # Garden delegates 12 + defdelegate change_garden(garden, attrs \\ %{}), to: Garden 13 + defdelegate create_garden(attrs \\ %{}), to: Garden 14 + defdelegate delete_garden(garden), to: Garden 15 + defdelegate get_garden!(id), to: Garden 16 + defdelegate get_garden(hello, socket), to: Garden 17 + defdelegate get_garden_local_sid!(local_sid), to: Garden 18 + defdelegate get_garden_local_sid(local_sid), to: Garden 19 + defdelegate get_garden_sid!(sid), to: Garden 20 + defdelegate get_garden_sid(sid), to: Garden 21 + defdelegate list_gardens(), to: Garden 22 + defdelegate list_gardens_with_latest_deployment(), to: Garden 23 + defdelegate update_garden(garden, attrs), to: Garden 24 24 25 25 # Subscription delegates 26 26 defdelegate change_subscription(subscription, attrs \\ %{}), to: Subscription ··· 34 34 defdelegate get_subscription_sid_with_deployments(sid), to: Subscription 35 35 defdelegate get_subscription_sids(sids), to: Subscription 36 36 defdelegate list_subscriptions(), to: Subscription 37 - defdelegate list_subscriptions_for_agent(agent), to: Subscription 38 - defdelegate register_subscription(req, agent_id), to: Subscription 39 - defdelegate sync_subscriptions(subscriptions, agent_id), to: Subscription 37 + defdelegate list_subscriptions_for_garden(garden), to: Subscription 38 + defdelegate register_subscription(req, garden_id), to: Subscription 39 + defdelegate sync_subscriptions(subscriptions, garden_id), to: Subscription 40 40 defdelegate update_subscription(subscription, attrs), to: Subscription 41 41 42 42 # Deployment delegates ··· 48 48 defdelegate get_deployment!(id), to: Deployment 49 49 defdelegate get_deployment_sid!(sid), to: Deployment 50 50 defdelegate get_deployment_sid(sid), to: Deployment 51 - defdelegate handle_deployment_request(payload, agent), to: Deployment 51 + defdelegate handle_deployment_request(payload, garden), to: Deployment 52 52 defdelegate list_deployments(), to: Deployment 53 - defdelegate list_deployments(agent, opts \\ []), to: Deployment 53 + defdelegate list_deployments(garden, opts \\ []), to: Deployment 54 54 defdelegate list_matching_seeds(subscription, limit \\ 10), to: Deployment 55 - defdelegate list_unresolved_deployments_for_agent(agent, opts \\ []), to: Deployment 55 + defdelegate list_unresolved_deployments_for_garden(garden, opts \\ []), to: Deployment 56 56 defdelegate match_seed(subscription), to: Deployment 57 - defdelegate process_deployment(request_id, subscriptions, agent, opts \\ []), to: Deployment 57 + defdelegate process_deployment(request_id, subscriptions, garden, opts \\ []), to: Deployment 58 58 defdelegate record_deployment(result), to: Deployment 59 - defdelegate replay_unresolved_deployments(agent, opts \\ []), to: Deployment 59 + defdelegate replay_unresolved_deployments(garden, opts \\ []), to: Deployment 60 60 defdelegate request_deployment(request), to: Deployment 61 61 defdelegate retry_deployment(deployment, user_id), to: Deployment 62 62 defdelegate update_deployment(deployment, attrs), to: Deployment 63 63 64 - # AgentSeedGeneration delegates 65 - defdelegate list_agent_seed_generation(agent), to: AgentSeedGeneration 66 - defdelegate list_agent_seed_generation_profile(agent_id, profile_id), to: AgentSeedGeneration 67 - defdelegate list_current_seed_generation(agent), to: AgentSeedGeneration 68 - defdelegate update_agent_seed_generations(report, agent), to: AgentSeedGeneration 64 + # GardenSeedGeneration delegates 65 + defdelegate list_garden_seed_generation(garden), to: GardenSeedGeneration 66 + defdelegate list_garden_seed_generation_profile(garden_id, profile_id), to: GardenSeedGeneration 67 + defdelegate list_current_seed_generation(garden), to: GardenSeedGeneration 68 + defdelegate update_garden_seed_generations(report, garden), to: GardenSeedGeneration 69 69 70 - defdelegate upsert_agent_generation(agent_id, profile_id, seed_id, attrs), 71 - to: AgentSeedGeneration 70 + defdelegate upsert_garden_generation(garden_id, profile_id, seed_id, attrs), 71 + to: GardenSeedGeneration 72 72 end
-195
apps/sower/lib/sower/orchestration/agent.ex
··· 1 - defmodule Sower.Orchestration.Agent do 2 - use Ecto.Schema 3 - import Ecto.Changeset 4 - import Ecto.Query, warn: false 5 - import Sower.Authorization 6 - 7 - alias Sower.Repo 8 - alias Sower.Orchestration.Deployment 9 - 10 - require Logger 11 - 12 - @derive {Jason.Encoder, only: [:sid, :local_sid]} 13 - @derive {Phoenix.Param, key: :sid} 14 - 15 - schema "agents" do 16 - field :sid, SowerClient.Sid, autogenerate: true 17 - field :name, :string 18 - field :local_sid, :string 19 - field :org_id, Ecto.UUID 20 - 21 - has_many :subscriptions, Sower.Orchestration.Subscription 22 - has_many :deployments, Sower.Orchestration.Deployment 23 - has_many :agent_seed_generations, Sower.Orchestration.AgentSeedGeneration 24 - 25 - field :latest_deployment, :any, virtual: true 26 - 27 - timestamps() 28 - end 29 - 30 - @doc false 31 - def changeset(agent, attrs) do 32 - agent 33 - |> cast(attrs, [:name, :org_id, :local_sid]) 34 - |> validate_required([:name]) 35 - end 36 - 37 - def list_agents do 38 - Repo.all(__MODULE__) 39 - end 40 - 41 - def list_agents_with_latest_deployment do 42 - latest_deployment_query = 43 - from(d in Deployment, 44 - where: d.agent_id == parent_as(:agent).id, 45 - order_by: [desc: d.inserted_at], 46 - limit: 1 47 - ) 48 - 49 - from(a in __MODULE__, 50 - as: :agent, 51 - left_lateral_join: d in subquery(latest_deployment_query), 52 - on: true, 53 - select: %{a | latest_deployment: d} 54 - ) 55 - |> Repo.all() 56 - end 57 - 58 - def get_agent( 59 - %SowerClient.AgentHello{agent_sid: nil, name: name, local_sid: local_sid}, 60 - socket 61 - ) do 62 - case get_agent_local_sid(local_sid) do 63 - nil -> 64 - Logger.debug( 65 - msg: "Registering new agent", 66 - name: name, 67 - local_sid: local_sid 68 - ) 69 - 70 - if socket.assigns.access_token |> can() |> create?(__MODULE__) do 71 - create_agent(%{name: name, local_sid: local_sid}) 72 - else 73 - {:error, :unauthorized} 74 - end 75 - 76 - %__MODULE__{} = agent -> 77 - Logger.error( 78 - msg: "Local agent attempted to re-register existing agent", 79 - name: agent.name, 80 - local_sid: local_sid, 81 - existing_agent_sid: agent.sid 82 - ) 83 - 84 - {:error, :unauthorized_agent_hello} 85 - end 86 - end 87 - 88 - def get_agent( 89 - %SowerClient.AgentHello{agent_sid: agent_sid, name: name, local_sid: local_sid}, 90 - socket 91 - ) do 92 - case get_agent_sid(agent_sid) do 93 - nil -> 94 - Logger.debug( 95 - msg: "Local agent requested a missing agent", 96 - name: name, 97 - local_sid: local_sid, 98 - requested_agent_sid: agent_sid 99 - ) 100 - 101 - if socket.assigns.access_token |> can() |> create?(__MODULE__) do 102 - create_agent(%{name: name, local_sid: local_sid}) 103 - else 104 - {:error, :unauthorized} 105 - end 106 - 107 - %__MODULE__{local_sid: nil} = agent when agent.name == name -> 108 - Logger.debug( 109 - msg: "Registering local sid to existing agent", 110 - name: agent.name, 111 - local_sid: local_sid, 112 - agent_sid: agent.sid 113 - ) 114 - 115 - if socket.assigns.access_token |> can() |> create?(__MODULE__) do 116 - agent = update_agent(agent, %{local_sid: local_sid}) 117 - 118 - {:ok, agent} 119 - else 120 - {:error, :unauthorized_agent_hello} 121 - end 122 - 123 - %__MODULE__{} = agent 124 - when agent.sid == agent_sid and 125 - agent.name == name and 126 - agent.local_sid == local_sid -> 127 - Logger.debug( 128 - msg: "Found matching agent", 129 - name: agent.name, 130 - local_sid: local_sid, 131 - agent_sid: agent.sid 132 - ) 133 - 134 - {:ok, agent} 135 - 136 - %__MODULE__{} = agent 137 - when agent.sid == agent_sid and 138 - agent.name != name and 139 - agent.local_sid == local_sid -> 140 - Logger.info( 141 - msg: "Found matching agent with different name, renaming", 142 - name: name, 143 - previous_name: agent.name, 144 - local_sid: local_sid, 145 - agent_sid: agent.sid 146 - ) 147 - 148 - {:ok, agent} = update_agent(agent, %{name: name}) 149 - 150 - {:ok, agent} 151 - 152 - %__MODULE__{} = agent -> 153 - Logger.error( 154 - msg: "Invalid agent request", 155 - local_sid: local_sid, 156 - agent_sid: agent.sid 157 - ) 158 - 159 - {:error, :unauthorized_agent_hello} 160 - end 161 - end 162 - 163 - def get_agent!(id), do: Repo.get!(__MODULE__, id) 164 - 165 - def get_agent_sid!(sid), do: Repo.get_by!(__MODULE__, sid: sid) 166 - 167 - def get_agent_sid(sid), do: Repo.get_by(__MODULE__, sid: sid) 168 - 169 - def get_agent_local_sid(local_sid), do: Repo.get_by(__MODULE__, local_sid: local_sid) 170 - 171 - def get_agent_local_sid!(local_sid), do: Repo.get_by!(__MODULE__, local_sid: local_sid) 172 - 173 - def create_agent(attrs \\ %{}) do 174 - %__MODULE__{ 175 - org_id: Sower.Repo.get_org_id(), 176 - sid: SowerClient.Sid.generate("agent") 177 - } 178 - |> changeset(attrs) 179 - |> Repo.insert() 180 - end 181 - 182 - def update_agent(%__MODULE__{} = agent, attrs) do 183 - agent 184 - |> changeset(attrs) 185 - |> Repo.update() 186 - end 187 - 188 - def delete_agent(%__MODULE__{} = agent) do 189 - Repo.delete(agent) 190 - end 191 - 192 - def change_agent(%__MODULE__{} = agent, attrs \\ %{}) do 193 - changeset(agent, attrs) 194 - end 195 - end
+37 -39
apps/sower/lib/sower/orchestration/agent_seed_generation.ex apps/sower/lib/sower/orchestration/garden_seed_generation.ex
··· 1 - defmodule Sower.Orchestration.AgentSeedGeneration do 1 + defmodule Sower.Orchestration.GardenSeedGeneration do 2 2 use Sower.Schema 3 3 import Ecto.Changeset 4 4 import Ecto.Query, warn: false 5 5 6 6 alias Sower.Repo 7 - alias Sower.Orchestration.{Agent, NixProfile, Seed} 7 + alias Sower.Orchestration.{Garden, NixProfile, Seed} 8 8 9 9 require Logger 10 10 11 - schema "agent_seed_generations" do 11 + schema "garden_seed_generations" do 12 12 field :org_id, Ecto.UUID 13 13 14 - belongs_to :agent, Agent 14 + belongs_to :garden, Garden 15 15 belongs_to :seed, Seed 16 16 belongs_to :profile, NixProfile 17 17 ··· 23 23 end 24 24 25 25 @doc false 26 - def changeset(%__MODULE__{} = agent_seed_generation, attrs) do 27 - agent_seed_generation 26 + def changeset(%__MODULE__{} = garden_seed_generation, attrs) do 27 + garden_seed_generation 28 28 |> cast(attrs, [ 29 29 :org_id, 30 - :agent_id, 30 + :garden_id, 31 31 :seed_id, 32 32 :profile_id, 33 33 :generation_number, 34 34 :is_current, 35 35 :created_at_generation 36 36 ]) 37 - |> validate_required([:org_id, :agent_id, :seed_id, :profile_id, :created_at_generation]) 37 + |> validate_required([:org_id, :garden_id, :seed_id, :profile_id, :created_at_generation]) 38 38 |> foreign_key_constraint(:org_id) 39 - |> foreign_key_constraint(:agent_id) 39 + |> foreign_key_constraint(:garden_id) 40 40 |> foreign_key_constraint(:seed_id) 41 41 |> foreign_key_constraint(:profile_id) 42 - |> unique_constraint([:agent_id, :seed_id]) 42 + |> unique_constraint([:garden_id, :seed_id]) 43 43 end 44 44 45 - def list_agent_seed_generation(%Agent{id: agent_id}) do 45 + def list_garden_seed_generation(%Garden{id: garden_id}) do 46 46 from(asg in __MODULE__, 47 - where: asg.agent_id == ^agent_id, 47 + where: asg.garden_id == ^garden_id, 48 48 order_by: [desc: asg.generation_number], 49 49 preload: [:seed, :profile] 50 50 ) 51 51 |> Repo.all() 52 52 end 53 53 54 - def list_current_seed_generation(%Agent{id: agent_id}) do 54 + def list_current_seed_generation(%Garden{id: garden_id}) do 55 55 from(asg in __MODULE__, 56 - where: asg.agent_id == ^agent_id and asg.is_current == true, 56 + where: asg.garden_id == ^garden_id and asg.is_current == true, 57 57 preload: [:seed, :profile] 58 58 ) 59 59 |> Repo.all() 60 60 end 61 61 62 - def list_agent_seed_generation_profile(agent_id, profile_id) do 62 + def list_garden_seed_generation_profile(garden_id, profile_id) do 63 63 from(asg in __MODULE__, 64 - where: asg.agent_id == ^agent_id and asg.profile_id == ^profile_id, 64 + where: asg.garden_id == ^garden_id and asg.profile_id == ^profile_id, 65 65 order_by: [desc: asg.generation_number], 66 66 preload: [:seed, :profile] 67 67 ) 68 68 |> Repo.all() 69 69 end 70 70 71 - def upsert_agent_generation(agent_id, profile_id, seed_id, attrs) do 72 - now = DateTime.utc_now() 73 - 71 + def upsert_garden_generation(garden_id, profile_id, seed_id, attrs) do 74 72 changeset_attrs = %{ 75 73 org_id: Repo.get_org_id(), 76 - agent_id: agent_id, 74 + garden_id: garden_id, 77 75 seed_id: seed_id, 78 76 profile_id: profile_id, 79 77 generation_number: attrs.generation_number, ··· 81 79 created_at_generation: attrs.created_at_generation 82 80 } 83 81 84 - case Repo.get_by(__MODULE__, agent_id: agent_id, seed_id: seed_id) do 82 + case Repo.get_by(__MODULE__, garden_id: garden_id, seed_id: seed_id) do 85 83 nil -> 86 84 %__MODULE__{} 87 85 |> changeset(changeset_attrs) ··· 93 91 generation_number: attrs.generation_number, 94 92 is_current: attrs.is_current, 95 93 created_at_generation: attrs.created_at_generation, 96 - updated_at: now 94 + updated_at: DateTime.utc_now() 97 95 } 98 96 99 97 if generation_row_changed?(existing, update_attrs) do ··· 106 104 end 107 105 end 108 106 109 - def update_agent_seed_generations( 110 - %SowerClient.Orchestration.AgentSeedsReport{} = report, 111 - %Agent{} = agent 107 + def update_garden_seed_generations( 108 + %SowerClient.Orchestration.GardenSeedsReport{} = report, 109 + %Garden{} = garden 112 110 ) do 113 111 Repo.transaction(fn -> 114 112 if Enum.empty?(report.profiles) do 115 - delete_all_agent_seed_generations(agent.id) 113 + delete_all_garden_seed_generations(garden.id) 116 114 else 117 115 for profile <- report.profiles do 118 116 nix_profile = NixProfile.find_or_create!(profile.profile_path) 119 - rows = resolve_profile_generation_rows(agent, profile) 120 - sync_profile_generation_rows(agent, nix_profile, rows) 117 + rows = resolve_profile_generation_rows(garden, profile) 118 + sync_profile_generation_rows(garden, nix_profile, rows) 121 119 end 122 120 end 123 121 ··· 125 123 end) 126 124 end 127 125 128 - defp resolve_profile_generation_rows(%Agent{} = agent, profile) do 126 + defp resolve_profile_generation_rows(%Garden{} = garden, profile) do 129 127 artifacts = 130 128 profile.generations 131 129 |> Enum.map(& &1.path) ··· 141 139 {seed, seeds} = 142 140 case Map.get(seeds, gen.path) do 143 141 nil -> 144 - case Seed.find_or_register(agent, gen, profile) do 142 + case Seed.find_or_register(garden, gen, profile) do 145 143 {:ok, seed} -> 146 144 {seed, Map.put(seeds, gen.path, seed)} 147 145 148 146 {:error, error} -> 149 147 Logger.warning( 150 - msg: "Failed to auto-register seed from agent", 148 + msg: "Failed to auto-register seed from garden", 151 149 artifact: gen.path, 152 150 error: error 153 151 ) ··· 216 214 end 217 215 end 218 216 219 - defp sync_profile_generation_rows(%Agent{} = agent, nix_profile, rows) do 217 + defp sync_profile_generation_rows(%Garden{} = garden, nix_profile, rows) do 220 218 if Enum.any?(rows, & &1.is_current) do 221 219 from(asg in __MODULE__, 222 - where: asg.agent_id == ^agent.id and asg.profile_id == ^nix_profile.id 220 + where: asg.garden_id == ^garden.id and asg.profile_id == ^nix_profile.id 223 221 ) 224 222 |> Repo.update_all(set: [is_current: false]) 225 223 end 226 224 227 225 keep_seed_ids = 228 226 Enum.reduce(rows, [], fn row, acc -> 229 - upsert_agent_generation(agent.id, nix_profile.id, row.seed_id, row) 227 + upsert_garden_generation(garden.id, nix_profile.id, row.seed_id, row) 230 228 [row.seed_id | acc] 231 229 end) 232 230 |> Enum.uniq() 233 231 234 - delete_stale_agent_seed_generations(agent.id, nix_profile.id, keep_seed_ids) 232 + delete_stale_garden_seed_generations(garden.id, nix_profile.id, keep_seed_ids) 235 233 end 236 234 237 235 defp generation_row_changed?(existing, attrs) do ··· 241 239 existing.created_at_generation != attrs.created_at_generation 242 240 end 243 241 244 - defp delete_stale_agent_seed_generations(agent_id, profile_id, keep_seed_ids) do 242 + defp delete_stale_garden_seed_generations(garden_id, profile_id, keep_seed_ids) do 245 243 query = 246 244 from(asg in __MODULE__, 247 - where: asg.agent_id == ^agent_id and asg.profile_id == ^profile_id 245 + where: asg.garden_id == ^garden_id and asg.profile_id == ^profile_id 248 246 ) 249 247 250 248 query = ··· 257 255 Repo.delete_all(query) 258 256 end 259 257 260 - defp delete_all_agent_seed_generations(agent_id) do 261 - from(asg in __MODULE__, where: asg.agent_id == ^agent_id) 258 + defp delete_all_garden_seed_generations(garden_id) do 259 + from(asg in __MODULE__, where: asg.garden_id == ^garden_id) 262 260 |> Repo.delete_all() 263 261 end 264 262 end
+61 -38
apps/sower/lib/sower/orchestration/deployment.ex
··· 6 6 alias Sower.Repo 7 7 alias Sower.Accounts.User 8 8 alias Sower.Orchestration 9 - alias Sower.Orchestration.{Agent, Seed, Subscription, DeploymentPubSub} 9 + alias Sower.Orchestration.{Garden, Seed, Subscription, DeploymentPubSub} 10 10 11 11 require Logger 12 12 ··· 20 20 field :sid, SowerClient.Sid, autogenerate: true 21 21 field :org_id, Ecto.UUID 22 22 23 - belongs_to :agent, Agent 23 + belongs_to :garden, Garden 24 24 belongs_to :parent_deployment, __MODULE__ 25 25 has_many :retries, __MODULE__, foreign_key: :parent_deployment_id 26 26 belongs_to :retried_by_user, User ··· 53 53 :result, 54 54 :state, 55 55 :last_dispatched_at, 56 - :agent_id, 56 + :garden_id, 57 57 :content_hash, 58 58 :parent_deployment_id, 59 59 :retried_by_user_id, ··· 81 81 Repo.all(query) 82 82 end 83 83 84 - def list_deployments(%Agent{} = agent, opts \\ []) do 84 + def list_deployments(%Garden{} = garden, opts \\ []) do 85 85 limit = Keyword.get(opts, :limit, 10) 86 86 87 87 from(d in __MODULE__, 88 - where: d.agent_id == ^agent.id, 88 + where: d.garden_id == ^garden.id, 89 89 order_by: [desc: d.inserted_at], 90 90 limit: ^limit 91 91 ) 92 92 |> Repo.all() 93 93 end 94 94 95 - def list_unresolved_deployments_for_agent(%Agent{} = agent, opts \\ []) do 95 + def list_unresolved_deployments_for_garden(%Garden{} = garden, opts \\ []) do 96 96 limit = Keyword.get(opts, :limit) 97 97 98 98 query = 99 99 from(d in __MODULE__, 100 - where: d.agent_id == ^agent.id and d.state in [:created, :dispatched, :acknowledged], 100 + where: d.garden_id == ^garden.id and d.state in [:created, :dispatched, :acknowledged], 101 101 order_by: [ 102 102 asc: fragment("COALESCE(?, ?)", d.last_dispatched_at, d.inserted_at), 103 103 asc: d.inserted_at ··· 211 211 |> Repo.one() || 0 212 212 213 213 attrs = %{ 214 - agent_id: deployment.agent_id, 214 + garden_id: deployment.garden_id, 215 215 content_hash: deployment.content_hash, 216 216 seeds: deployment.seeds, 217 217 subscriptions: deployment.subscriptions, ··· 226 226 case create_deployment(attrs) do 227 227 {:ok, retry_deployment} -> 228 228 retry_deployment = 229 - Repo.preload(retry_deployment, [:agent, :subscriptions, seeds: [:tags]]) 229 + Repo.preload(retry_deployment, [:garden, :subscriptions, seeds: [:tags]]) 230 230 231 231 request_id = SowerClient.Sid.generate("request") 232 232 233 233 SowerWeb.Endpoint.broadcast( 234 - "agent:#{retry_deployment.agent.sid}", 234 + "garden:#{retry_deployment.garden.sid}", 235 + "deployment", 236 + deployment_event_payload(retry_deployment, request_id) 237 + ) 238 + 239 + # Backward compatibility: 0.7.0 gardens join "agent:*" 240 + SowerWeb.Endpoint.broadcast( 241 + "agent:#{retry_deployment.garden.sid}", 235 242 "deployment", 236 243 deployment_event_payload(retry_deployment, request_id) 237 244 ) ··· 248 255 249 256 # Replay 250 257 251 - def replay_unresolved_deployments(%Agent{} = agent, opts \\ []) do 258 + def replay_unresolved_deployments(%Garden{} = garden, opts \\ []) do 252 259 broadcast_fun = Keyword.get(opts, :broadcast_fun, &SowerWeb.Endpoint.broadcast/3) 253 260 254 261 request_id_fun = ··· 256 263 257 264 now = Keyword.get(opts, :now, DateTime.utc_now()) 258 265 259 - deployments = list_unresolved_deployments_for_agent(agent) 266 + deployments = list_unresolved_deployments_for_garden(garden) 260 267 mark_deployments_dispatched(deployments, now) 261 268 262 269 Enum.each(deployments, fn deployment -> 263 270 payload = deployment_event_payload(deployment, request_id_fun.()) 264 - broadcast_fun.("agent:#{agent.sid}", "deployment", payload) 271 + broadcast_fun.("garden:#{garden.sid}", "deployment", payload) 272 + # Backward compatibility: 0.7.0 gardens join "agent:*" 273 + broadcast_fun.("agent:#{garden.sid}", "deployment", payload) 265 274 end) 266 275 267 276 if deployments != [] do 268 277 Logger.info( 269 278 msg: "Replayed unresolved deployments", 270 - agent_sid: agent.sid, 279 + garden_sid: garden.sid, 271 280 deployment_count: length(deployments), 272 281 deployment_sids: Enum.map(deployments, & &1.sid) 273 282 ) ··· 299 308 # Deployment request handling 300 309 301 310 def deploy_subscription(%Subscription{} = sub, opts \\ []) do 302 - subscription = Repo.preload(sub, :agent) 311 + subscription = Repo.preload(sub, :garden) 303 312 304 - case subscription.agent do 313 + case subscription.garden do 305 314 nil -> 306 - {:error, :agent_not_found} 315 + {:error, :garden_not_found} 307 316 308 - %Agent{} = agent -> 317 + %Garden{} = garden -> 309 318 request_id = SowerClient.Sid.generate("request") 310 - {:ok, request_id} = process_deployment(request_id, [subscription], agent, opts) 319 + {:ok, request_id} = process_deployment(request_id, [subscription], garden, opts) 311 320 {:ok, request_id} 312 321 end 313 322 end ··· 322 331 end 323 332 end 324 333 325 - def handle_deployment_request(payload, agent) do 334 + def handle_deployment_request(payload, garden) do 326 335 with {:ok, request} <- SowerClient.Orchestration.DeploymentRequest.cast(payload), 327 - {:ok, subscriptions} <- validate_deployment_request(request, agent.id), 336 + {:ok, subscriptions} <- validate_deployment_request(request, garden.id), 328 337 {:ok, request_id} <- 329 - process_deployment(request.request_id, subscriptions, agent, force: request.force) do 338 + process_deployment(request.request_id, subscriptions, garden, force: request.force) do 330 339 {:ok, request_id} 331 340 end 332 341 end 333 342 334 - def process_deployment(request_id, subscriptions, %Agent{} = agent, opts \\ []) do 343 + def process_deployment(request_id, subscriptions, %Garden{} = garden, opts \\ []) do 335 344 Task.Supervisor.start_child(Sower.TaskSupervisor, fn -> 336 - Repo.put_org_id(agent.org_id) 345 + Repo.put_org_id(garden.org_id) 337 346 338 347 Logger.info( 339 348 msg: "Deployment processing started", 340 349 request_id: request_id, 341 - agent_id: agent.id 350 + garden_id: garden.id 342 351 ) 343 352 344 353 case do_deployment(request_id, subscriptions, opts) do ··· 351 360 ) 352 361 353 362 SowerWeb.Endpoint.broadcast( 354 - "agent:#{agent.sid}", 363 + "garden:#{garden.sid}", 364 + "deployment", 365 + Map.from_struct(deployment) 366 + ) 367 + 368 + # Backward compatibility: 0.7.0 gardens join "agent:*" 369 + SowerWeb.Endpoint.broadcast( 370 + "agent:#{garden.sid}", 355 371 "deployment", 356 372 Map.from_struct(deployment) 357 373 ) ··· 364 380 ) 365 381 366 382 SowerWeb.Endpoint.broadcast( 367 - "agent:#{agent.sid}", 383 + "garden:#{garden.sid}", 384 + "deployment:error", 385 + %{request_id: request_id, reason: to_string(reason)} 386 + ) 387 + 388 + # Backward compatibility: 0.7.0 gardens join "agent:*" 389 + SowerWeb.Endpoint.broadcast( 390 + "agent:#{garden.sid}", 368 391 "deployment:error", 369 392 %{request_id: request_id, reason: to_string(reason)} 370 393 ) ··· 447 470 448 471 defp validate_request_subscriptions(sids) when is_list(sids) and length(sids) > 0 do 449 472 subs = Subscription.get_subscription_sids(sids) 450 - subs = Repo.preload(subs, :agent) 473 + subs = Repo.preload(subs, :garden) 451 474 452 475 if subs == [] do 453 476 {:error, :subscription_not_found} ··· 460 483 461 484 defp validate_deployment_request( 462 485 %SowerClient.Orchestration.DeploymentRequest{} = request, 463 - agent_id 486 + garden_id 464 487 ) do 465 488 subs = Subscription.get_subscription_sids(request.subscription_sids) 466 489 ··· 468 491 subs == [] -> 469 492 {:error, :subscription_not_found} 470 493 471 - not Enum.all?(subs, &(&1.agent_id == agent_id)) -> 494 + not Enum.all?(subs, &(&1.garden_id == garden_id)) -> 472 495 {:error, :unauthorized} 473 496 474 497 true -> 475 - {:ok, Repo.preload(subs, :agent)} 498 + {:ok, Repo.preload(subs, :garden)} 476 499 end 477 500 end 478 501 479 502 defp do_deployment(request_id, subscriptions, opts) do 480 503 force? = Keyword.get(opts, :force, false) 481 - agent_id = hd(subscriptions).agent_id 504 + garden_id = hd(subscriptions).garden_id 482 505 483 506 seed_deploys = 484 507 subscriptions ··· 518 541 content_hash: content_hash 519 542 ) 520 543 521 - case find_duplicate_deployment(agent_id, content_hash, force?) do 544 + case find_duplicate_deployment(garden_id, content_hash, force?) do 522 545 {:skip, existing} -> 523 546 existing = Repo.preload(existing, [:seeds]) 524 547 ··· 541 564 Logger.debug( 542 565 msg: "Creating new deployment record", 543 566 request_id: request_id, 544 - agent_id: agent_id 567 + garden_id: garden_id 545 568 ) 546 569 547 570 case create_deployment(%{ 548 - agent_id: agent_id, 571 + garden_id: garden_id, 549 572 content_hash: content_hash, 550 573 last_dispatched_at: DateTime.utc_now(), 551 574 state: :dispatched, ··· 589 612 |> Base.encode16(case: :lower) 590 613 end 591 614 592 - defp find_duplicate_deployment(_agent_id, _content_hash, true), do: :proceed 615 + defp find_duplicate_deployment(_garden_id, _content_hash, true), do: :proceed 593 616 594 - defp find_duplicate_deployment(agent_id, content_hash, false) do 617 + defp find_duplicate_deployment(garden_id, content_hash, false) do 595 618 query = 596 619 from(d in __MODULE__, 597 620 where: 598 - d.agent_id == ^agent_id and 621 + d.garden_id == ^garden_id and 599 622 d.content_hash == ^content_hash and 600 623 (d.result == :success or d.state in [:created, :dispatched, :acknowledged]), 601 624 order_by: [desc: d.inserted_at],
+10 -4
apps/sower/lib/sower/orchestration/deployment_pubsub.ex
··· 12 12 Broadcasts to multiple topics: 13 13 - "deployments" - Global topic for all deployments 14 14 - "deployment:<deployment_sid>" - Per-deployment topic 15 - - "deployments:agent:<agent_sid>" - Per-agent topic 15 + - "deployments:garden:<garden_sid>" - Per-garden topic 16 16 - "deployments:subscription:<subscription_sid>" - Per-subscription topics 17 17 """ 18 18 def broadcast_deployment_change(%Deployment{} = deployment, event \\ :updated) do 19 - deployment = Sower.Repo.preload(deployment, [:agent, :subscriptions]) 19 + deployment = Sower.Repo.preload(deployment, [:garden, :subscriptions]) 20 20 21 21 broadcast("deployments", {:deployment, event, deployment}) 22 22 broadcast("deployment:#{deployment.sid}", {:deployment, event, deployment}) 23 23 24 - if deployment.agent do 24 + if deployment.garden do 25 25 broadcast( 26 - "deployments:agent:#{deployment.agent.sid}", 26 + "deployments:garden:#{deployment.garden.sid}", 27 + {:deployment, event, deployment} 28 + ) 29 + 30 + # Deprecated: kept for 0.7.0 LiveView backward compatibility 31 + broadcast( 32 + "deployments:agent:#{deployment.garden.sid}", 27 33 {:deployment, event, deployment} 28 34 ) 29 35 end
+195
apps/sower/lib/sower/orchestration/garden.ex
··· 1 + defmodule Sower.Orchestration.Garden do 2 + use Ecto.Schema 3 + import Ecto.Changeset 4 + import Ecto.Query, warn: false 5 + import Sower.Authorization 6 + 7 + alias Sower.Repo 8 + alias Sower.Orchestration.Deployment 9 + 10 + require Logger 11 + 12 + @derive {Jason.Encoder, only: [:sid, :local_sid]} 13 + @derive {Phoenix.Param, key: :sid} 14 + 15 + schema "gardens" do 16 + field :sid, SowerClient.Sid, autogenerate: true 17 + field :name, :string 18 + field :local_sid, :string 19 + field :org_id, Ecto.UUID 20 + 21 + has_many :subscriptions, Sower.Orchestration.Subscription 22 + has_many :deployments, Sower.Orchestration.Deployment 23 + has_many :garden_seed_generations, Sower.Orchestration.GardenSeedGeneration 24 + 25 + field :latest_deployment, :any, virtual: true 26 + 27 + timestamps() 28 + end 29 + 30 + @doc false 31 + def changeset(garden, attrs) do 32 + garden 33 + |> cast(attrs, [:name, :org_id, :local_sid]) 34 + |> validate_required([:name]) 35 + end 36 + 37 + def list_gardens do 38 + Repo.all(__MODULE__) 39 + end 40 + 41 + def list_gardens_with_latest_deployment do 42 + latest_deployment_query = 43 + from(d in Deployment, 44 + where: d.garden_id == parent_as(:garden).id, 45 + order_by: [desc: d.inserted_at], 46 + limit: 1 47 + ) 48 + 49 + from(a in __MODULE__, 50 + as: :garden, 51 + left_lateral_join: d in subquery(latest_deployment_query), 52 + on: true, 53 + select: %{a | latest_deployment: d} 54 + ) 55 + |> Repo.all() 56 + end 57 + 58 + def get_garden( 59 + %SowerClient.GardenHello{garden_sid: nil, name: name, local_sid: local_sid}, 60 + socket 61 + ) do 62 + case get_garden_local_sid(local_sid) do 63 + nil -> 64 + Logger.debug( 65 + msg: "Registering new garden", 66 + name: name, 67 + local_sid: local_sid 68 + ) 69 + 70 + if socket.assigns.access_token |> can() |> create?(__MODULE__) do 71 + create_garden(%{name: name, local_sid: local_sid}) 72 + else 73 + {:error, :unauthorized} 74 + end 75 + 76 + %__MODULE__{} = garden -> 77 + Logger.error( 78 + msg: "Local garden attempted to re-register existing garden", 79 + name: garden.name, 80 + local_sid: local_sid, 81 + existing_garden_sid: garden.sid 82 + ) 83 + 84 + {:error, :unauthorized_garden_hello} 85 + end 86 + end 87 + 88 + def get_garden( 89 + %SowerClient.GardenHello{garden_sid: garden_sid, name: name, local_sid: local_sid}, 90 + socket 91 + ) do 92 + case get_garden_sid(garden_sid) do 93 + nil -> 94 + Logger.debug( 95 + msg: "Local garden requested a missing garden", 96 + name: name, 97 + local_sid: local_sid, 98 + requested_garden_sid: garden_sid 99 + ) 100 + 101 + if socket.assigns.access_token |> can() |> create?(__MODULE__) do 102 + create_garden(%{name: name, local_sid: local_sid}) 103 + else 104 + {:error, :unauthorized} 105 + end 106 + 107 + %__MODULE__{local_sid: nil} = garden when garden.name == name -> 108 + Logger.debug( 109 + msg: "Registering local sid to existing garden", 110 + name: garden.name, 111 + local_sid: local_sid, 112 + garden_sid: garden.sid 113 + ) 114 + 115 + if socket.assigns.access_token |> can() |> create?(__MODULE__) do 116 + garden = update_garden(garden, %{local_sid: local_sid}) 117 + 118 + {:ok, garden} 119 + else 120 + {:error, :unauthorized_garden_hello} 121 + end 122 + 123 + %__MODULE__{} = garden 124 + when garden.sid == garden_sid and 125 + garden.name == name and 126 + garden.local_sid == local_sid -> 127 + Logger.debug( 128 + msg: "Found matching garden", 129 + name: garden.name, 130 + local_sid: local_sid, 131 + garden_sid: garden.sid 132 + ) 133 + 134 + {:ok, garden} 135 + 136 + %__MODULE__{} = garden 137 + when garden.sid == garden_sid and 138 + garden.name != name and 139 + garden.local_sid == local_sid -> 140 + Logger.info( 141 + msg: "Found matching garden with different name, renaming", 142 + name: name, 143 + previous_name: garden.name, 144 + local_sid: local_sid, 145 + garden_sid: garden.sid 146 + ) 147 + 148 + {:ok, garden} = update_garden(garden, %{name: name}) 149 + 150 + {:ok, garden} 151 + 152 + %__MODULE__{} = garden -> 153 + Logger.error( 154 + msg: "Invalid garden request", 155 + local_sid: local_sid, 156 + garden_sid: garden.sid 157 + ) 158 + 159 + {:error, :unauthorized_garden_hello} 160 + end 161 + end 162 + 163 + def get_garden!(id), do: Repo.get!(__MODULE__, id) 164 + 165 + def get_garden_sid!(sid), do: Repo.get_by!(__MODULE__, sid: sid) 166 + 167 + def get_garden_sid(sid), do: Repo.get_by(__MODULE__, sid: sid) 168 + 169 + def get_garden_local_sid(local_sid), do: Repo.get_by(__MODULE__, local_sid: local_sid) 170 + 171 + def get_garden_local_sid!(local_sid), do: Repo.get_by!(__MODULE__, local_sid: local_sid) 172 + 173 + def create_garden(attrs \\ %{}) do 174 + %__MODULE__{ 175 + org_id: Sower.Repo.get_org_id(), 176 + sid: SowerClient.Sid.generate("grdn") 177 + } 178 + |> changeset(attrs) 179 + |> Repo.insert() 180 + end 181 + 182 + def update_garden(%__MODULE__{} = garden, attrs) do 183 + garden 184 + |> changeset(attrs) 185 + |> Repo.update() 186 + end 187 + 188 + def delete_garden(%__MODULE__{} = garden) do 189 + Repo.delete(garden) 190 + end 191 + 192 + def change_garden(%__MODULE__{} = garden, attrs \\ %{}) do 193 + changeset(garden, attrs) 194 + end 195 + end
+1 -1
apps/sower/lib/sower/orchestration/nix_profile.ex
··· 7 7 schema "nix_profiles" do 8 8 field :profile_path, :string 9 9 10 - has_many :agent_seed_generations, Sower.Orchestration.AgentSeedGeneration, 10 + has_many :garden_seed_generations, Sower.Orchestration.GardenSeedGeneration, 11 11 foreign_key: :profile_id 12 12 13 13 timestamps()
+14 -14
apps/sower/lib/sower/orchestration/seed.ex
··· 234 234 end 235 235 236 236 @doc """ 237 - Finds an existing seed by artifact path, or registers a new one from agent-reported data. 237 + Finds an existing seed by artifact path, or registers a new one from garden-reported data. 238 238 239 - When an agent reports a generation that doesn't match any known seed, this function 240 - auto-registers it with the `agent_source` tag set to the agent's SID. 239 + When a garden reports a generation that doesn't match any known seed, this function 240 + auto-registers it with the `garden_source` tag set to the garden's SID. 241 241 242 242 ## Parameters 243 - - `agent` - The Agent struct reporting the generation 244 - - `generation` - The AgentSeedGeneration with path, link, etc. 245 - - `profile` - The AgentSeedProfile containing profile_path and tags 243 + - `garden` - The Garden struct reporting the generation 244 + - `generation` - The GardenSeedGeneration with path, link, etc. 245 + - `profile` - The GardenSeedProfile containing profile_path and tags 246 246 247 247 ## Returns 248 248 - `{:ok, seed}` on success (existing or newly created) 249 249 - `{:error, changeset}` on validation failure 250 250 """ 251 251 def find_or_register( 252 - %Sower.Orchestration.Agent{} = agent, 253 - %SowerClient.Orchestration.AgentSeedGeneration{} = generation, 254 - %SowerClient.Orchestration.AgentSeedProfile{} = profile 252 + %Sower.Orchestration.Garden{} = garden, 253 + %SowerClient.Orchestration.GardenSeedGeneration{} = generation, 254 + %SowerClient.Orchestration.GardenSeedProfile{} = profile 255 255 ) do 256 256 case get_by_artifact(generation.path) do 257 257 nil -> 258 - register(agent, generation, profile) 258 + register(garden, generation, profile) 259 259 260 260 seed -> 261 261 {:ok, seed} 262 262 end 263 263 end 264 264 265 - defp register(agent, generation, profile) do 265 + defp register(garden, generation, profile) do 266 266 {name, path_tags} = extract_info_from_store_path(generation.path) 267 267 seed_type = seed_type_from_profile_path(profile.profile_path) 268 268 269 - # Build tags: agent_source + any profile tags 269 + # Build tags: garden_source + any profile tags 270 270 tags = 271 271 path_tags ++ 272 - [%{key: "agent_source", value: agent.sid}] ++ 272 + [%{key: "garden_source", value: garden.sid}] ++ 273 273 Enum.map(profile.tags || [], fn {k, v} -> %{key: to_string(k), value: to_string(v)} end) 274 274 275 275 name = ··· 279 279 _ -> nil 280 280 end) do 281 281 nil -> name 282 - user_name -> "#{user_name}@#{agent.name}" 282 + user_name -> "#{user_name}@#{garden.name}" 283 283 end 284 284 else 285 285 name
+6 -6
apps/sower/lib/sower/orchestration/seed_deployment.ex
··· 27 27 28 28 def record_seed_status( 29 29 %SowerClient.Orchestration.SeedDeploymentStatus{} = status, 30 - %Sower.Orchestration.Agent{} = agent 30 + %Sower.Orchestration.Garden{} = garden 31 31 ) do 32 32 with {:ok, deployment} <- fetch_deployment(status.deployment_sid), 33 - :ok <- verify_ownership(deployment, agent), 33 + :ok <- verify_ownership(deployment, garden), 34 34 {:ok, seed_deployment} <- fetch_seed_deployment(deployment.id, status.seed_sid) do 35 35 seed_deployment 36 36 |> changeset(%{state: status.status}) ··· 44 44 45 45 def record_seed_result( 46 46 %SowerClient.Orchestration.SeedDeploymentResult{} = result, 47 - %Sower.Orchestration.Agent{} = agent 47 + %Sower.Orchestration.Garden{} = garden 48 48 ) do 49 49 with {:ok, deployment} <- fetch_deployment(result.deployment_sid), 50 - :ok <- verify_ownership(deployment, agent), 50 + :ok <- verify_ownership(deployment, garden), 51 51 {:ok, seed_deployment} <- fetch_seed_deployment(deployment.id, result.seed_sid) do 52 52 attrs = build_update_attrs(seed_deployment, result) 53 53 ··· 82 82 end 83 83 end 84 84 85 - defp verify_ownership(deployment, agent) do 86 - if deployment.agent_id == agent.id do 85 + defp verify_ownership(deployment, garden) do 86 + if deployment.garden_id == garden.id do 87 87 :ok 88 88 else 89 89 {:error, :unauthorized}
+17 -17
apps/sower/lib/sower/orchestration/subscription.ex
··· 4 4 import Ecto.Query, warn: false 5 5 6 6 alias Sower.Repo 7 - alias Sower.Orchestration.{Agent, Deployment, SubscriptionDeployment} 7 + alias Sower.Orchestration.{Garden, Deployment, SubscriptionDeployment} 8 8 9 9 @derive {Jason.Encoder, only: [:sid]} 10 10 @derive {Phoenix.Param, key: :sid} ··· 13 13 field :sid, SowerClient.Sid, autogenerate: true 14 14 field :org_id, Ecto.UUID 15 15 16 - belongs_to :agent, Agent 16 + belongs_to :garden, Garden 17 17 18 18 many_to_many :deployments, Deployment, join_through: SubscriptionDeployment 19 19 ··· 27 27 @doc false 28 28 def changeset(subscription, attrs) do 29 29 subscription 30 - |> cast(attrs, [:agent_id, :seed_name, :seed_type]) 30 + |> cast(attrs, [:garden_id, :seed_name, :seed_type]) 31 31 |> cast_embed(:rules, with: &__MODULE__.Rule.changeset/2) 32 - |> unique_constraint([:agent_id, :org_id, :seed_name, :seed_type]) 32 + |> unique_constraint([:garden_id, :org_id, :seed_name, :seed_type]) 33 33 end 34 34 35 35 def list_subscriptions do 36 36 Repo.all(__MODULE__) 37 - |> Repo.preload([:agent]) 37 + |> Repo.preload([:garden]) 38 38 end 39 39 40 - def list_subscriptions_for_agent(%Agent{} = agent) do 40 + def list_subscriptions_for_garden(%Garden{} = garden) do 41 41 __MODULE__ 42 - |> where([s], s.agent_id == ^agent.id) 42 + |> where([s], s.garden_id == ^garden.id) 43 43 |> Repo.all() 44 44 end 45 45 46 46 def get_subscription!(id) do 47 47 Repo.get!(__MODULE__, id) 48 - |> Repo.preload(:agent) 48 + |> Repo.preload(:garden) 49 49 end 50 50 51 51 def get_subscription_sid!(sid), do: Repo.get_by!(__MODULE__, sid: sid) ··· 59 59 subscription = get_subscription_sid!(sid) 60 60 61 61 Repo.preload(subscription, [ 62 - :agent, 62 + :garden, 63 63 deployments: 64 64 from(d in Deployment, 65 65 order_by: [ ··· 74 74 def get_subscription_sid_with_deployments(sid) do 75 75 get_subscription_sid(sid) 76 76 |> Repo.preload([ 77 - :agent, 77 + :garden, 78 78 deployments: 79 79 from(d in Deployment, 80 80 order_by: [ ··· 131 131 |> changeset(attrs) 132 132 |> Repo.insert( 133 133 on_conflict: {:replace, [:updated_at, :rules]}, 134 - conflict_target: [:agent_id, :org_id, :seed_name, :seed_type], 134 + conflict_target: [:garden_id, :org_id, :seed_name, :seed_type], 135 135 returning: true 136 136 ) do 137 137 {:ok, sub} -> {:ok, Repo.reload(sub)} ··· 145 145 seed_type: seed_type, 146 146 rules: rules 147 147 }, 148 - agent_id 148 + garden_id 149 149 ) do 150 150 case create_subscription(%{ 151 - agent_id: agent_id, 151 + garden_id: garden_id, 152 152 seed_name: seed_name, 153 153 seed_type: seed_type, 154 154 rules: rules ··· 161 161 end 162 162 end 163 163 164 - def sync_subscriptions(subscriptions, agent_id) do 164 + def sync_subscriptions(subscriptions, garden_id) do 165 165 Repo.transaction(fn -> 166 166 registered = 167 167 Enum.map(subscriptions, fn sub -> 168 - case register_subscription(sub, agent_id) do 168 + case register_subscription(sub, garden_id) do 169 169 {:ok, s} -> s 170 170 {:error, reason} -> Repo.rollback(reason) 171 171 end ··· 174 174 registered_sids = Enum.map(registered, & &1.sid) 175 175 176 176 from(s in __MODULE__, 177 - where: s.agent_id == ^agent_id, 177 + where: s.garden_id == ^garden_id, 178 178 where: s.sid not in ^registered_sids 179 179 ) 180 180 |> Repo.delete_all() ··· 195 195 196 196 def change_subscription(%__MODULE__{} = subscription, attrs \\ %{}) do 197 197 subscription 198 - |> Repo.preload(:agent) 198 + |> Repo.preload(:garden) 199 199 |> changeset(attrs) 200 200 end 201 201
+1 -1
apps/sower/lib/sower/repo/seeds/org.ex
··· 47 47 "role" => "seed:write" 48 48 }, 49 49 %{ 50 - "role" => "agent:register" 50 + "role" => "garden:register" 51 51 } 52 52 ], 53 53 "user_id" => user.id,
-225
apps/sower/lib/sower_web/agent_channel.ex
··· 1 - defmodule SowerWeb.AgentChannel.Impl do 2 - defmacro handle_schema(module, func) do 3 - {_, _, module} = module 4 - module = Module.concat(module) 5 - event = apply(module, :event, []) 6 - 7 - quote do 8 - def handle_in(unquote(event), payload, socket) do 9 - handle_message(payload, unquote(module), socket, unquote(func)) 10 - end 11 - end 12 - end 13 - end 14 - 15 - defmodule SowerWeb.AgentChannel do 16 - use Phoenix.Channel 17 - 18 - alias Sower.Orchestration 19 - alias SowerWeb.Presence 20 - require Logger 21 - 22 - import SowerWeb.AgentChannel.Impl, only: [handle_schema: 2] 23 - 24 - def get_assigns(agent_sid) do 25 - Phoenix.PubSub.broadcast(Sower.PubSub, "agent:#{agent_sid}", :ping) 26 - end 27 - 28 - @impl Phoenix.Channel 29 - def join("agent:lobby", _message, %{assigns: %{conn_sid: conn_sid}} = socket) do 30 - Sower.Repo.put_org_id(socket.assigns.access_token.org_id) 31 - 32 - Logger.debug(msg: "Channel topic joined", topic: "agent:lobby", conn_sid: conn_sid) 33 - 34 - {:ok, %{conn_sid: conn_sid}, socket} 35 - end 36 - 37 - def join( 38 - "agent:" <> topic_sid = topic, 39 - %{"local_sid" => local_sid}, 40 - %{assigns: %{conn_sid: conn_sid}} = socket 41 - ) do 42 - Sower.Repo.put_org_id(socket.assigns.access_token.org_id) 43 - 44 - Logger.debug( 45 - msg: "Channel topic joined", 46 - topic: topic, 47 - local_sid: local_sid, 48 - conn_sid: conn_sid 49 - ) 50 - 51 - case Orchestration.get_agent_sid(topic_sid) do 52 - nil -> 53 - {:error, %{reason: "unauthorized"}} 54 - 55 - agent when is_nil(agent.local_sid) -> 56 - {:error, %{reason: "unauthorized"}} 57 - 58 - agent when agent.local_sid == local_sid -> 59 - send(self(), :track_presence) 60 - send(self(), :replay_unresolved_deployments) 61 - {:ok, %{conn_sid: conn_sid}, assign(socket, :agent, agent)} 62 - 63 - _ -> 64 - {:error, %{reason: "unauthorized"}} 65 - end 66 - end 67 - 68 - def join(topic, params, socket) do 69 - Logger.warning( 70 - msg: "Unauthorized join", 71 - topic: topic, 72 - params: params, 73 - socket: socket 74 - ) 75 - 76 - {:error, %{reason: "unauthorized"}} 77 - end 78 - 79 - @impl Phoenix.Channel 80 - def handle_in("ping", _, socket) do 81 - Logger.debug(msg: "Received ping, ponging") 82 - {:reply, {:ok, :pong}, socket} 83 - end 84 - 85 - def handle_in("pong", %{"ref" => _ref}, socket) do 86 - {:reply, :ok, socket} 87 - end 88 - 89 - def handle_in("agent:hello", payload, socket) do 90 - case payload 91 - |> SowerClient.AgentHello.cast!() 92 - |> Sower.Orchestration.get_agent(socket) do 93 - {:ok, agent} -> 94 - Logger.debug(msg: "Replying to hello", agent: agent) 95 - {:reply, {:ok, agent}, assign(socket, :agent_sid, agent.sid)} 96 - 97 - {:error, error} -> 98 - Logger.error(msg: "Error returning hello", error: error) 99 - {:reply, {:error, error}, socket} 100 - end 101 - end 102 - 103 - handle_schema(SowerClient.Seed, &Sower.Orchestration.Seed.get_by_request/1) 104 - 105 - handle_schema(SowerClient.Orchestration.Subscription, fn req, socket -> 106 - Sower.Orchestration.register_subscription(req, socket.assigns.agent.id) 107 - end) 108 - 109 - handle_schema(SowerClient.Orchestration.SubscriptionSync, fn req, socket -> 110 - case Sower.Orchestration.sync_subscriptions(req.subscriptions, socket.assigns.agent.id) do 111 - {:ok, subscriptions} -> 112 - {:ok, %{subscriptions: subscriptions}} 113 - 114 - {:error, error} -> 115 - {:error, error} 116 - end 117 - end) 118 - 119 - handle_schema(SowerClient.Orchestration.DeploymentRequest, fn req, socket -> 120 - case Orchestration.handle_deployment_request( 121 - req, 122 - socket.assigns.agent 123 - ) do 124 - {:ok, request_id} -> 125 - {:ok, %{request_id: request_id}} 126 - 127 - {:error, error} -> 128 - Logger.error(msg: "Invalid deployment request", error: error) 129 - {:error, error} 130 - end 131 - end) 132 - 133 - handle_schema( 134 - SowerClient.Orchestration.DeploymentStatus, 135 - &Sower.Orchestration.Deployment.record_deployment_status/1 136 - ) 137 - 138 - handle_schema( 139 - SowerClient.Orchestration.DeploymentResult, 140 - &Sower.Orchestration.record_deployment/1 141 - ) 142 - 143 - handle_schema(SowerClient.Orchestration.SeedDeploymentStatus, fn req, socket -> 144 - Sower.Orchestration.SeedDeployment.record_seed_status(req, socket.assigns.agent) 145 - end) 146 - 147 - handle_schema(SowerClient.Orchestration.SeedDeploymentResult, fn req, socket -> 148 - Sower.Orchestration.SeedDeployment.record_seed_result(req, socket.assigns.agent) 149 - end) 150 - 151 - handle_schema(SowerClient.Orchestration.AgentSeedsReport, fn report, socket -> 152 - Orchestration.update_agent_seed_generations(report, socket.assigns.agent) 153 - end) 154 - 155 - # Kept for backward compatibility with old agents that still upload logs to S3. 156 - # New agents send SeedDeploymentResult instead. 157 - # remove 0.7.0 158 - handle_schema(SowerClient.Storage.DeploymentLogUploadRequest, fn _req, _socket -> 159 - {:error, :deprecated} 160 - end) 161 - 162 - @impl Phoenix.Channel 163 - def handle_info(:track_presence, %Phoenix.Socket{assigns: %{agent: agent}} = socket) do 164 - Logger.debug(msg: "Tracking agent presence", agent_sid: agent.sid) 165 - 166 - {:ok, _} = 167 - Presence.track(self(), "agent:presence", socket.assigns.agent.sid, %{ 168 - online_at: DateTime.utc_now() 169 - }) 170 - 171 - {:noreply, socket} 172 - end 173 - 174 - def handle_info( 175 - :replay_unresolved_deployments, 176 - %Phoenix.Socket{assigns: %{agent: agent}} = socket 177 - ) do 178 - {:ok, deployments} = Orchestration.replay_unresolved_deployments(agent) 179 - 180 - if deployments != [] do 181 - Logger.debug( 182 - msg: "Replayed unresolved deployments after agent join", 183 - agent_sid: agent.sid, 184 - deployment_count: length(deployments) 185 - ) 186 - end 187 - 188 - {:noreply, socket} 189 - end 190 - 191 - def handle_info(:ping, socket) do 192 - ref = SowerClient.Sid.generate() 193 - Logger.debug(msg: "Sending ping", ref: ref, component: :server, topic: socket.topic) 194 - push(socket, "ping", %{ref: ref}) 195 - {:noreply, socket} 196 - end 197 - 198 - # provide a standard way of casting the schemas and handling the errors 199 - defp handle_message(payload, schema, socket, fun) do 200 - with {:ok, params} <- schema.cast(payload), 201 - {:ok, result} <- apply_handler(fun, params, socket) do 202 - {:reply, {:ok, result}, socket} 203 - else 204 - nil -> 205 - {:reply, {:error, :not_found}, socket} 206 - 207 - {:error, _} = error -> 208 - Logger.error(msg: "Channel handler error", error: error, topic: socket.topic) 209 - {:reply, error, socket} 210 - end 211 - end 212 - 213 - defp apply_handler(fun, params, socket) do 214 - case :erlang.fun_info(fun, :arity) do 215 - {:arity, 1} -> 216 - fun.(params) 217 - 218 - {:arity, 2} -> 219 - fun.(params, socket) 220 - 221 - {:arity, arity} -> 222 - raise ArgumentError, "Channel handler arity #{arity} is not supported" 223 - end 224 - end 225 - end
+7 -6
apps/sower/lib/sower_web/agent_socket.ex apps/sower/lib/sower_web/garden_socket.ex
··· 1 - defmodule SowerWeb.AgentSocket do 1 + defmodule SowerWeb.GardenSocket do 2 2 import Sower.Authorization 3 3 require Logger 4 4 use Phoenix.Socket 5 5 6 - channel("agent:*", SowerWeb.AgentChannel) 6 + channel("garden:*", SowerWeb.GardenChannel) 7 + channel("agent:*", SowerWeb.GardenChannel) 7 8 8 - @impl true 9 + @impl Phoenix.Socket 9 10 def connect(%{"token" => token}, socket, _connect_info) do 10 11 case token |> Base.decode64!() |> Sower.Accounts.AccessToken.authenticate() do 11 12 {:ok, access_token} -> 12 - if access_token |> can() |> read?(Sower.Orchestration.Agent) do 13 + if access_token |> can() |> read?(Sower.Orchestration.Garden) do 13 14 socket = 14 15 socket 15 16 |> assign(:access_token, access_token) ··· 18 19 {:ok, socket} 19 20 else 20 21 Logger.error( 21 - msg: "Access token is not authorized to be an agent", 22 + msg: "Access token is not authorized to be a garden", 22 23 access_token_sid: access_token.sid 23 24 ) 24 25 ··· 36 37 {:error, :unauthorized} 37 38 end 38 39 39 - @impl true 40 + @impl Phoenix.Socket 40 41 def id(_socket), do: nil 41 42 end
+7 -4
apps/sower/lib/sower_web/components/layouts.ex
··· 51 51 <ul class="mobile-nav-dropdown-panel absolute left-0 top-full z-50 mt-2 w-56 rounded-lg border border-zinc-300 bg-zinc-100 p-2 shadow-lg dark:border-zinc-600 dark:bg-zinc-700"> 52 52 <li> 53 53 <.link 54 - navigate={~p"/agents"} 54 + navigate={~p"/gardens"} 55 55 class="block rounded-md px-3 py-2 hover:text-orange-700 hover:bg-zinc-200 dark:hover:text-orange-300 dark:hover:bg-zinc-600" 56 56 > 57 - Agents 57 + Gardens 58 58 </.link> 59 59 </li> 60 60 <li> ··· 78 78 79 79 <ul class="hidden font-medium md:flex md:items-center md:space-x-8 rtl:space-x-reverse"> 80 80 <li> 81 - <.link navigate={~p"/agents"} class="hover:text-orange-700 dark:hover:text-orange-300"> 82 - Agents 81 + <.link 82 + navigate={~p"/gardens"} 83 + class="hover:text-orange-700 dark:hover:text-orange-300" 84 + > 85 + Gardens 83 86 </.link> 84 87 </li> 85 88 <li>
+3 -1
apps/sower/lib/sower_web/endpoint.ex
··· 18 18 19 19 socket "/live", Phoenix.LiveView.Socket, websocket: [connect_info: [session: @session_options]] 20 20 21 - socket "/agent", SowerWeb.AgentSocket, websocket: true, longpoll: true 21 + socket "/garden", SowerWeb.GardenSocket, websocket: true, longpoll: true 22 + # Deprecated: kept for 0.7.0 garden backward compatibility 23 + socket "/agent", SowerWeb.GardenSocket, websocket: true, longpoll: true 22 24 23 25 # Serve at "/" the static files from "priv/static" directory. 24 26 #
+262
apps/sower/lib/sower_web/garden_channel.ex
··· 1 + defmodule SowerWeb.GardenChannel.Impl do 2 + defmacro handle_schema(module, func) do 3 + {_, _, module} = module 4 + module = Module.concat(module) 5 + event = apply(module, :event, []) 6 + 7 + quote do 8 + def handle_in(unquote(event), payload, socket) do 9 + handle_message(payload, unquote(module), socket, unquote(func)) 10 + end 11 + end 12 + end 13 + end 14 + 15 + defmodule SowerWeb.GardenChannel do 16 + use Phoenix.Channel 17 + 18 + alias Sower.Orchestration 19 + alias SowerWeb.Presence 20 + require Logger 21 + 22 + import SowerWeb.GardenChannel.Impl, only: [handle_schema: 2] 23 + 24 + def get_assigns(garden_sid) do 25 + Phoenix.PubSub.broadcast(Sower.PubSub, "garden:#{garden_sid}", :ping) 26 + end 27 + 28 + @impl Phoenix.Channel 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 + 32 + 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 + do: do_join_private(topic_sid, topic, params, socket) 37 + 38 + def join(topic, params, socket) do 39 + Logger.warning( 40 + msg: "Unauthorized join", 41 + topic: topic, 42 + params: params, 43 + socket: socket 44 + ) 45 + 46 + {:error, %{reason: "unauthorized"}} 47 + end 48 + 49 + defp do_join_lobby(_message, %{assigns: %{conn_sid: conn_sid}} = socket) do 50 + Sower.Repo.put_org_id(socket.assigns.access_token.org_id) 51 + 52 + Logger.debug(msg: "Channel topic joined", topic: socket.topic, conn_sid: conn_sid) 53 + 54 + {:ok, %{conn_sid: conn_sid}, socket} 55 + end 56 + 57 + defp do_join_private( 58 + topic_sid, 59 + topic, 60 + %{"local_sid" => local_sid}, 61 + %{assigns: %{conn_sid: conn_sid}} = socket 62 + ) do 63 + Sower.Repo.put_org_id(socket.assigns.access_token.org_id) 64 + 65 + Logger.debug( 66 + msg: "Channel topic joined", 67 + topic: topic, 68 + local_sid: local_sid, 69 + conn_sid: conn_sid 70 + ) 71 + 72 + case Orchestration.get_garden_sid(topic_sid) do 73 + nil -> 74 + {:error, %{reason: "unauthorized"}} 75 + 76 + garden when is_nil(garden.local_sid) -> 77 + {:error, %{reason: "unauthorized"}} 78 + 79 + garden when garden.local_sid == local_sid -> 80 + send(self(), :track_presence) 81 + send(self(), :replay_unresolved_deployments) 82 + {:ok, %{conn_sid: conn_sid}, assign(socket, :garden, garden)} 83 + 84 + _ -> 85 + {:error, %{reason: "unauthorized"}} 86 + end 87 + end 88 + 89 + @impl Phoenix.Channel 90 + def handle_in("ping", _, socket) do 91 + Logger.debug(msg: "Received ping, ponging") 92 + {:reply, {:ok, :pong}, socket} 93 + end 94 + 95 + def handle_in("pong", %{"ref" => _ref}, socket) do 96 + {:reply, :ok, socket} 97 + end 98 + 99 + # Accept both "garden:hello" and "agent:hello" 100 + def handle_in("garden:hello", payload, socket), do: do_handle_hello(payload, socket) 101 + def handle_in("agent:hello", payload, socket), do: do_handle_hello(payload, socket) 102 + 103 + defp do_handle_hello(payload, socket) do 104 + case payload 105 + |> normalize_hello_payload() 106 + |> SowerClient.GardenHello.cast!() 107 + |> Sower.Orchestration.get_garden(socket) do 108 + {:ok, garden} -> 109 + Logger.debug(msg: "Replying to hello", garden: garden) 110 + {:reply, {:ok, garden}, assign(socket, :garden_sid, garden.sid)} 111 + 112 + {:error, error} -> 113 + Logger.error(msg: "Error returning hello", error: error) 114 + {:reply, {:error, error}, socket} 115 + end 116 + end 117 + 118 + # Accept both "agent_sid" (legacy) and "garden_sid" from hello payloads 119 + defp normalize_hello_payload(%{"agent_sid" => sid} = payload) when is_binary(sid) do 120 + payload 121 + |> Map.delete("agent_sid") 122 + |> Map.put_new("garden_sid", sid) 123 + end 124 + 125 + defp normalize_hello_payload(payload), do: payload 126 + 127 + handle_schema(SowerClient.Seed, &Sower.Orchestration.Seed.get_by_request/1) 128 + 129 + handle_schema(SowerClient.Orchestration.Subscription, fn req, socket -> 130 + Sower.Orchestration.register_subscription(req, socket.assigns.garden.id) 131 + end) 132 + 133 + handle_schema(SowerClient.Orchestration.SubscriptionSync, fn req, socket -> 134 + case Sower.Orchestration.sync_subscriptions(req.subscriptions, socket.assigns.garden.id) do 135 + {:ok, subscriptions} -> 136 + {:ok, %{subscriptions: subscriptions}} 137 + 138 + {:error, error} -> 139 + {:error, error} 140 + end 141 + end) 142 + 143 + handle_schema(SowerClient.Orchestration.DeploymentRequest, fn req, socket -> 144 + case Orchestration.handle_deployment_request( 145 + req, 146 + socket.assigns.garden 147 + ) do 148 + {:ok, request_id} -> 149 + {:ok, %{request_id: request_id}} 150 + 151 + {:error, error} -> 152 + Logger.error(msg: "Invalid deployment request", error: error) 153 + {:error, error} 154 + end 155 + end) 156 + 157 + handle_schema( 158 + SowerClient.Orchestration.DeploymentStatus, 159 + &Sower.Orchestration.Deployment.record_deployment_status/1 160 + ) 161 + 162 + handle_schema( 163 + SowerClient.Orchestration.DeploymentResult, 164 + &Sower.Orchestration.record_deployment/1 165 + ) 166 + 167 + handle_schema(SowerClient.Orchestration.SeedDeploymentStatus, fn req, socket -> 168 + Sower.Orchestration.SeedDeployment.record_seed_status(req, socket.assigns.garden) 169 + end) 170 + 171 + handle_schema(SowerClient.Orchestration.SeedDeploymentResult, fn req, socket -> 172 + Sower.Orchestration.SeedDeployment.record_seed_result(req, socket.assigns.garden) 173 + end) 174 + 175 + # Accept both new and old seeds report events 176 + handle_schema(SowerClient.Orchestration.GardenSeedsReport, fn report, socket -> 177 + Orchestration.update_garden_seed_generations(report, socket.assigns.garden) 178 + end) 179 + 180 + handle_schema(SowerClient.Orchestration.AgentSeedsReport, fn report, socket -> 181 + # Convert legacy AgentSeedsReport to GardenSeedsReport for internal handling 182 + report = struct(SowerClient.Orchestration.GardenSeedsReport, Map.from_struct(report)) 183 + Orchestration.update_garden_seed_generations(report, socket.assigns.garden) 184 + end) 185 + 186 + # Kept for backward compatibility with old gardens that still upload logs to S3. 187 + # New gardens send SeedDeploymentResult instead. 188 + # remove 0.7.0 189 + handle_schema(SowerClient.Storage.DeploymentLogUploadRequest, fn _req, _socket -> 190 + {:error, :deprecated} 191 + end) 192 + 193 + @impl Phoenix.Channel 194 + def handle_info(:track_presence, %Phoenix.Socket{assigns: %{garden: garden}} = socket) do 195 + Logger.debug(msg: "Tracking garden presence", garden_sid: garden.sid) 196 + 197 + {:ok, _} = 198 + Presence.track(self(), "garden:presence", socket.assigns.garden.sid, %{ 199 + online_at: DateTime.utc_now() 200 + }) 201 + 202 + # Also track on legacy topic for 0.7.0 LiveView compatibility 203 + {:ok, _} = 204 + Presence.track(self(), "agent:presence", socket.assigns.garden.sid, %{ 205 + online_at: DateTime.utc_now() 206 + }) 207 + 208 + {:noreply, socket} 209 + end 210 + 211 + def handle_info( 212 + :replay_unresolved_deployments, 213 + %Phoenix.Socket{assigns: %{garden: garden}} = socket 214 + ) do 215 + {:ok, deployments} = Orchestration.replay_unresolved_deployments(garden) 216 + 217 + if deployments != [] do 218 + Logger.debug( 219 + msg: "Replayed unresolved deployments after garden join", 220 + garden_sid: garden.sid, 221 + deployment_count: length(deployments) 222 + ) 223 + end 224 + 225 + {:noreply, socket} 226 + end 227 + 228 + def handle_info(:ping, socket) do 229 + ref = SowerClient.Sid.generate() 230 + Logger.debug(msg: "Sending ping", ref: ref, component: :server, topic: socket.topic) 231 + push(socket, "ping", %{ref: ref}) 232 + {:noreply, socket} 233 + end 234 + 235 + # provide a standard way of casting the schemas and handling the errors 236 + defp handle_message(payload, schema, socket, fun) do 237 + with {:ok, params} <- schema.cast(payload), 238 + {:ok, result} <- apply_handler(fun, params, socket) do 239 + {:reply, {:ok, result}, socket} 240 + else 241 + nil -> 242 + {:reply, {:error, :not_found}, socket} 243 + 244 + {:error, _} = error -> 245 + Logger.error(msg: "Channel handler error", error: error, topic: socket.topic) 246 + {:reply, error, socket} 247 + end 248 + end 249 + 250 + defp apply_handler(fun, params, socket) do 251 + case :erlang.fun_info(fun, :arity) do 252 + {:arity, 1} -> 253 + fun.(params) 254 + 255 + {:arity, 2} -> 256 + fun.(params, socket) 257 + 258 + {:arity, arity} -> 259 + raise ArgumentError, "Channel handler arity #{arity} is not supported" 260 + end 261 + end 262 + end
-82
apps/sower/lib/sower_web/live/agent_live/form_component.ex
··· 1 - defmodule SowerWeb.AgentLive.FormComponent do 2 - use SowerWeb, :live_component 3 - 4 - alias Sower.Orchestration 5 - 6 - @impl true 7 - def render(assigns) do 8 - ~H""" 9 - <div> 10 - <.header> 11 - {@title} 12 - <:subtitle>Use this form to manage agent records in your database.</:subtitle> 13 - </.header> 14 - 15 - <.simple_form 16 - for={@form} 17 - id="agent-form" 18 - phx-target={@myself} 19 - phx-change="validate" 20 - phx-submit="save" 21 - > 22 - <.input field={@form[:name]} type="text" label="Name" /> 23 - <:actions> 24 - <.button phx-disable-with="Saving...">Save Agent</.button> 25 - </:actions> 26 - </.simple_form> 27 - </div> 28 - """ 29 - end 30 - 31 - @impl true 32 - def update(%{agent: agent} = assigns, socket) do 33 - {:ok, 34 - socket 35 - |> assign(assigns) 36 - |> assign_new(:form, fn -> 37 - to_form(Orchestration.change_agent(agent)) 38 - end)} 39 - end 40 - 41 - @impl true 42 - def handle_event("validate", %{"agent" => agent_params}, socket) do 43 - changeset = Orchestration.change_agent(socket.assigns.agent, agent_params) 44 - {:noreply, assign(socket, form: to_form(changeset, action: :validate))} 45 - end 46 - 47 - def handle_event("save", %{"agent" => agent_params}, socket) do 48 - save_agent(socket, socket.assigns.action, agent_params) 49 - end 50 - 51 - defp save_agent(socket, :edit, agent_params) do 52 - case Orchestration.update_agent(socket.assigns.agent, agent_params) do 53 - {:ok, agent} -> 54 - notify_parent({:saved, agent}) 55 - 56 - {:noreply, 57 - socket 58 - |> put_flash(:info, "Agent updated successfully") 59 - |> push_patch(to: socket.assigns.patch)} 60 - 61 - {:error, %Ecto.Changeset{} = changeset} -> 62 - {:noreply, assign(socket, form: to_form(changeset))} 63 - end 64 - end 65 - 66 - defp save_agent(socket, :new, agent_params) do 67 - case Orchestration.create_agent(agent_params) do 68 - {:ok, agent} -> 69 - notify_parent({:saved, agent}) 70 - 71 - {:noreply, 72 - socket 73 - |> put_flash(:info, "Agent created successfully") 74 - |> push_patch(to: socket.assigns.patch)} 75 - 76 - {:error, %Ecto.Changeset{} = changeset} -> 77 - {:noreply, assign(socket, form: to_form(changeset))} 78 - end 79 - end 80 - 81 - defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) 82 - end
-60
apps/sower/lib/sower_web/live/agent_live/index.ex
··· 1 - defmodule SowerWeb.AgentLive.Index do 2 - use SowerWeb, :live_view 3 - 4 - alias Phoenix.Socket.Broadcast 5 - alias Sower.Orchestration 6 - alias Sower.Orchestration.Agent 7 - alias SowerWeb.Presence 8 - 9 - @impl true 10 - def mount(_params, _session, socket) do 11 - if connected?(socket) do 12 - Phoenix.PubSub.subscribe(Sower.PubSub, "agent:presence") 13 - end 14 - 15 - {:ok, 16 - stream(socket, :agents, Orchestration.list_agents_with_latest_deployment()) 17 - |> assign(:agent_presence, Presence.list("agent:presence"))} 18 - end 19 - 20 - @impl true 21 - def handle_params(params, _url, socket) do 22 - {:noreply, apply_action(socket, socket.assigns.live_action, params)} 23 - end 24 - 25 - defp apply_action(socket, :new, _params) do 26 - socket 27 - |> assign(:page_title, "New Agent") 28 - |> assign(:agent, %Agent{}) 29 - end 30 - 31 - defp apply_action(socket, :index, _params) do 32 - socket 33 - |> assign(:page_title, "Listing Agents") 34 - |> assign(:agent, nil) 35 - end 36 - 37 - @impl true 38 - def handle_info({SowerWeb.AgentLive.FormComponent, {:saved, agent}}, socket) do 39 - {:noreply, stream_insert(socket, :agents, agent)} 40 - end 41 - 42 - @impl true 43 - def handle_info(%Broadcast{topic: "agent:presence", event: "presence_diff"}, socket) do 44 - # update the presence list, then touch the stream to force a table refresh 45 - socket = 46 - socket 47 - |> assign(:agent_presence, Presence.list("agent:presence")) 48 - |> stream(:agents, Orchestration.list_agents_with_latest_deployment()) 49 - 50 - {:noreply, socket} 51 - end 52 - 53 - @impl true 54 - def handle_event("delete", %{"id" => id}, socket) do 55 - agent = Orchestration.get_agent!(id) 56 - {:ok, _} = Orchestration.delete_agent(agent) 57 - 58 - {:noreply, stream_delete(socket, :agents, agent)} 59 - end 60 - end
-49
apps/sower/lib/sower_web/live/agent_live/index.html.heex
··· 1 - <Layouts.app flash={@flash} current_user={@current_user}> 2 - <.header> 3 - Listing Agents 4 - </.header> 5 - 6 - <.table 7 - id="agents" 8 - rows={@streams.agents} 9 - row_click={fn {_id, agent} -> JS.navigate(~p"/agents/#{agent}") end} 10 - action_hide_on={:sm} 11 - > 12 - <:col :let={{_id, agent}} label="Name">{agent.name}</:col> 13 - <:col :let={{_id, agent}} label="Online"> 14 - <.online state={agent.sid in Map.keys(@agent_presence)} /> 15 - </:col> 16 - <:col :let={{_id, agent}} label="Deploy"> 17 - <.result result={agent.latest_deployment && agent.latest_deployment.result} /> 18 - </:col> 19 - <:action :let={{_id, agent}}> 20 - <div class="sr-only"> 21 - <.link navigate={~p"/agents/#{agent}"}>Show</.link> 22 - </div> 23 - </:action> 24 - <:action :let={{id, agent}}> 25 - <.link 26 - phx-click={JS.push("delete", value: %{id: agent.id}) |> hide("##{id}")} 27 - data-confirm="Are you sure?" 28 - > 29 - Delete 30 - </.link> 31 - </:action> 32 - </.table> 33 - 34 - <.modal 35 - :if={@live_action in [:new, :edit]} 36 - id="agent-modal" 37 - show 38 - on_cancel={JS.patch(~p"/agents")} 39 - > 40 - <.live_component 41 - module={SowerWeb.AgentLive.FormComponent} 42 - id={@agent.id || :new} 43 - title={@page_title} 44 - action={@live_action} 45 - agent={@agent} 46 - patch={~p"/agents"} 47 - /> 48 - </.modal> 49 - </Layouts.app>
+33 -33
apps/sower/lib/sower_web/live/agent_live/show.ex apps/sower/lib/sower_web/live/garden_live/show.ex
··· 1 - defmodule SowerWeb.AgentLive.Show do 1 + defmodule SowerWeb.GardenLive.Show do 2 2 use SowerWeb, :live_view 3 3 4 4 alias Phoenix.Socket.Broadcast ··· 8 8 @impl true 9 9 def mount(_params, _session, socket) do 10 10 if connected?(socket) do 11 - Phoenix.PubSub.subscribe(Sower.PubSub, "agent:presence") 11 + Phoenix.PubSub.subscribe(Sower.PubSub, "garden:presence") 12 12 end 13 13 14 14 {:ok, add_online_status(socket)} ··· 16 16 17 17 @impl true 18 18 def handle_params(%{"sid" => sid} = params, _, socket) do 19 - case Orchestration.get_agent_sid(sid) do 19 + case Orchestration.get_garden_sid(sid) do 20 20 nil -> 21 21 {:noreply, 22 22 socket 23 - |> put_flash(:error, "Agent not found") 24 - |> redirect(to: ~p"/agents")} 23 + |> put_flash(:error, "Garden not found") 24 + |> redirect(to: ~p"/gardens")} 25 25 26 - agent -> 27 - agent = Sower.Repo.preload(agent, :subscriptions) 28 - deployments = Orchestration.list_deployments(agent, limit: 10) 26 + garden -> 27 + garden = Sower.Repo.preload(garden, :subscriptions) 28 + deployments = Orchestration.list_deployments(garden, limit: 10) 29 29 30 30 generations_filter = Map.get(params, "generations_filter", "current") 31 - generations = load_generations(agent, generations_filter) 31 + generations = load_generations(garden, generations_filter) 32 32 33 - deployable_subs = resolve_deployable_subscriptions(agent.subscriptions) 33 + deployable_subs = resolve_deployable_subscriptions(garden.subscriptions) 34 34 35 35 socket = 36 36 socket 37 37 |> assign(:page_title, page_title(socket.assigns.live_action)) 38 - |> assign(:agent, agent) 38 + |> assign(:garden, garden) 39 39 |> assign(:deployments, deployments) 40 40 |> add_online_status() 41 41 |> assign(:current_generation, %{}) ··· 47 47 |> assign(:retrying_deployment, nil) 48 48 49 49 if connected?(socket) do 50 - Phoenix.PubSub.subscribe(Sower.PubSub, "agent:view:#{sid}") 51 - Phoenix.PubSub.subscribe(Sower.PubSub, "deployments:agent:#{sid}") 50 + Phoenix.PubSub.subscribe(Sower.PubSub, "garden:view:#{sid}") 51 + Phoenix.PubSub.subscribe(Sower.PubSub, "deployments:garden:#{sid}") 52 52 end 53 53 54 54 {:noreply, socket} ··· 56 56 end 57 57 58 58 @impl Phoenix.LiveView 59 - def handle_info(%Broadcast{topic: "agent:presence", event: "presence_diff"}, socket) do 59 + def handle_info(%Broadcast{topic: "garden:presence", event: "presence_diff"}, socket) do 60 60 {:noreply, add_online_status(socket)} 61 61 end 62 62 ··· 67 67 |> assign(:deploying_sub, nil) 68 68 |> redirect(to: ~p"/deployments/#{deployment.sid}")} 69 69 else 70 - deployments = Orchestration.list_deployments(socket.assigns.agent, limit: 10) 70 + deployments = Orchestration.list_deployments(socket.assigns.garden, limit: 10) 71 71 {:noreply, assign(socket, :deployments, deployments)} 72 72 end 73 73 end 74 74 75 75 def handle_info({:deployment, _event, _deployment}, socket) do 76 - deployments = Orchestration.list_deployments(socket.assigns.agent, limit: 10) 76 + deployments = Orchestration.list_deployments(socket.assigns.garden, limit: 10) 77 77 {:noreply, assign(socket, :deployments, deployments)} 78 78 end 79 79 80 - def handle_info({SowerWeb.AgentLive.FormComponent, {:saved, agent}}, socket) do 81 - agent = Sower.Repo.preload(agent, :subscriptions) 82 - {:noreply, assign(socket, :agent, agent)} 80 + def handle_info({SowerWeb.GardenLive.FormComponent, {:saved, garden}}, socket) do 81 + garden = Sower.Repo.preload(garden, :subscriptions) 82 + {:noreply, assign(socket, :garden, garden)} 83 83 end 84 84 85 85 @impl true 86 86 def handle_event("deploy_subscription", %{"subscription_sid" => sub_sid}, socket) do 87 - subscription = Enum.find(socket.assigns.agent.subscriptions, &(&1.sid == sub_sid)) 87 + subscription = Enum.find(socket.assigns.garden.subscriptions, &(&1.sid == sub_sid)) 88 88 89 89 case subscription do 90 90 nil -> ··· 105 105 end 106 106 107 107 def handle_event("set_generations_filter", %{"filter" => filter}, socket) do 108 - generations = load_generations(socket.assigns.agent, filter) 108 + generations = load_generations(socket.assigns.garden, filter) 109 109 110 110 socket = 111 111 socket 112 112 |> assign(:generations_filter, filter) 113 113 |> assign(:generations, generations) 114 - |> push_patch(to: ~p"/agents/#{socket.assigns.agent}?generations_filter=#{filter}") 114 + |> push_patch(to: ~p"/gardens/#{socket.assigns.garden}?generations_filter=#{filter}") 115 115 116 116 {:noreply, socket} 117 117 end ··· 155 155 end 156 156 end 157 157 158 - defp add_online_status(%{assigns: %{agent: agent}} = socket) do 159 - online_agents = Presence.list("agent:presence") |> Map.keys() 160 - assign(socket, :online, agent.sid in online_agents) 158 + defp add_online_status(%{assigns: %{garden: garden}} = socket) do 159 + online_gardens = Presence.list("garden:presence") |> Map.keys() 160 + assign(socket, :online, garden.sid in online_gardens) 161 161 end 162 162 163 163 defp add_online_status(socket) do 164 164 assign(socket, :online, false) 165 165 end 166 166 167 - defp page_title(:show), do: "Show Agent" 168 - defp page_title(:edit), do: "Edit Agent" 167 + defp page_title(:show), do: "Show Garden" 168 + defp page_title(:edit), do: "Edit Garden" 169 169 170 - defp load_generations(agent, "all") do 171 - Sower.Orchestration.list_agent_seed_generation(agent) 170 + defp load_generations(garden, "all") do 171 + Sower.Orchestration.list_garden_seed_generation(garden) 172 172 end 173 173 174 - defp load_generations(agent, "current") do 175 - Sower.Orchestration.list_current_seed_generation(agent) 174 + defp load_generations(garden, "current") do 175 + Sower.Orchestration.list_current_seed_generation(garden) 176 176 end 177 177 178 - defp load_generations(agent_id, _), do: load_generations(agent_id, "current") 178 + defp load_generations(garden, _), do: load_generations(garden, "current") 179 179 180 180 defp resolve_deployable_subscriptions(subscriptions) do 181 181 subscriptions ··· 183 183 |> MapSet.new(& &1.sid) 184 184 end 185 185 186 - defp deploy_error_message(:agent_not_found), do: "Agent not found" 186 + defp deploy_error_message(:garden_not_found), do: "Garden not found" 187 187 defp deploy_error_message(_), do: "Deployment failed" 188 188 end
+13 -13
apps/sower/lib/sower_web/live/agent_live/show.html.heex apps/sower/lib/sower_web/live/garden_live/show.html.heex
··· 2 2 <.header> 3 3 <div class="flex items-center space-x-2"> 4 4 <.online state={@online} /> 5 - <span>Agent: {@agent.name}</span> 5 + <span>Garden: {@garden.name}</span> 6 6 </div> 7 7 </.header> 8 8 9 9 <div class="mt-8 space-y-10"> 10 - <.detail_field label="Local Sid">{@agent.local_sid}</.detail_field> 10 + <.detail_field label="Local Sid">{@garden.local_sid}</.detail_field> 11 11 12 12 <section> 13 13 <div class="flex items-center justify-between mb-4"> 14 14 <h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-200">Seed Generations</h2> 15 15 <div class="flex items-center space-x-2"> 16 16 <.link 17 - patch={~p"/agents/#{@agent}?generations_filter=current"} 17 + patch={~p"/gardens/#{@garden}?generations_filter=current"} 18 18 class={[ 19 19 "px-3 py-1 rounded text-sm border", 20 20 @generations_filter == "current" && ··· 26 26 Current 27 27 </.link> 28 28 <.link 29 - patch={~p"/agents/#{@agent}?generations_filter=all"} 29 + patch={~p"/gardens/#{@garden}?generations_filter=all"} 30 30 class={[ 31 31 "px-3 py-1 rounded text-sm border", 32 32 @generations_filter == "all" && ··· 66 66 <h2 class="text-sm font-semibold text-zinc-900 dark:text-zinc-200 mb-4">Subscriptions</h2> 67 67 <.table 68 68 id="subscriptions" 69 - rows={@agent.subscriptions} 69 + rows={@garden.subscriptions} 70 70 row_click={ 71 71 fn subscription -> 72 - JS.navigate(~p"/agents/#{@agent}/subscriptions/#{subscription.sid}") 72 + JS.navigate(~p"/gardens/#{@garden}/subscriptions/#{subscription.sid}") 73 73 end 74 74 } 75 75 > ··· 88 88 </:col> 89 89 </.table> 90 90 <p 91 - :if={@agent.subscriptions == []} 91 + :if={@garden.subscriptions == []} 92 92 class="text-sm text-zinc-500 dark:text-zinc-400 italic" 93 93 > 94 94 No subscriptions. ··· 143 143 144 144 <.modal 145 145 :if={@live_action == :edit} 146 - id="agent-modal" 146 + id="garden-modal" 147 147 show 148 - on_cancel={JS.patch(~p"/agents/#{@agent}")} 148 + on_cancel={JS.patch(~p"/gardens/#{@garden}")} 149 149 > 150 150 <.live_component 151 - module={SowerWeb.AgentLive.FormComponent} 152 - id={@agent.id} 151 + module={SowerWeb.GardenLive.FormComponent} 152 + id={@garden.id} 153 153 title={@page_title} 154 154 action={@live_action} 155 - agent={@agent} 156 - patch={~p"/agents/#{@agent}"} 155 + garden={@garden} 156 + patch={~p"/gardens/#{@garden}"} 157 157 /> 158 158 </.modal> 159 159 </Layouts.app>
+5 -5
apps/sower/lib/sower_web/live/deployment_live/index.ex
··· 29 29 </span> 30 30 <span class="hidden sm:inline">{deployment.sid}</span> 31 31 </:col> 32 - <:col :let={{_id, deployment}} label="agent"> 33 - {get_in(deployment.agent.name) || "-"} 32 + <:col :let={{_id, deployment}} label="garden"> 33 + {get_in(deployment.garden.name) || "-"} 34 34 </:col> 35 35 <:col :let={{_id, deployment}} label="completed" hide_on={:sm}> 36 36 <.local_datetime datetime={deployment.deployed_at} user_timezone={@user_timezone} /> ··· 65 65 socket 66 66 |> assign(:page_title, "Listing Deployments") 67 67 |> assign(:retrying_deployment_sid, nil) 68 - |> stream(:deployments, Orchestration.list_deployments() |> Sower.Repo.preload([:agent]))} 68 + |> stream(:deployments, Orchestration.list_deployments() |> Sower.Repo.preload([:garden]))} 69 69 end 70 70 71 71 @impl Phoenix.LiveView 72 72 def handle_info({:deployment, :created, deployment}, socket) do 73 - deployment = Sower.Repo.preload(deployment, [:agent]) 73 + deployment = Sower.Repo.preload(deployment, [:garden]) 74 74 75 75 # Insert new deployment at the top of the stream 76 76 {:noreply, stream_insert(socket, :deployments, deployment, at: 0)} 77 77 end 78 78 79 79 def handle_info({:deployment, :updated, deployment}, socket) do 80 - deployment = Sower.Repo.preload(deployment, [:agent]) 80 + deployment = Sower.Repo.preload(deployment, [:garden]) 81 81 82 82 # Update existing deployment in the stream 83 83 {:noreply, stream_insert(socket, :deployments, deployment)}
+6 -6
apps/sower/lib/sower_web/live/deployment_live/show.ex
··· 31 31 <.local_datetime datetime={@deployment.deployed_at} user_timezone={@user_timezone} /> 32 32 </.detail_field> 33 33 34 - <.detail_field label="Agent"> 34 + <.detail_field label="Garden"> 35 35 <.link 36 - navigate={~p"/agents/#{@deployment.agent}"} 36 + navigate={~p"/gardens/#{@deployment.garden}"} 37 37 class="hover:text-orange-500 dark:hover:text-orange-400" 38 38 > 39 - {@deployment.agent.name} 39 + {@deployment.garden.name} 40 40 </.link> 41 41 </.detail_field> 42 42 ··· 47 47 rows={@deployment.subscriptions} 48 48 row_click={ 49 49 fn subscription -> 50 - JS.navigate(~p"/agents/#{@deployment.agent}/subscriptions/#{subscription.sid}") 50 + JS.navigate(~p"/gardens/#{@deployment.garden}/subscriptions/#{subscription.sid}") 51 51 end 52 52 } 53 53 header_border={false} ··· 146 146 147 147 deployment -> 148 148 deployment = 149 - Sower.Repo.preload(deployment, seed_deployments: :seed, subscriptions: [], agent: []) 149 + Sower.Repo.preload(deployment, seed_deployments: :seed, subscriptions: [], garden: []) 150 150 151 151 {:noreply, 152 152 socket ··· 163 163 164 164 deployment -> 165 165 deployment = 166 - Sower.Repo.preload(deployment, seed_deployments: :seed, subscriptions: [], agent: []) 166 + Sower.Repo.preload(deployment, seed_deployments: :seed, subscriptions: [], garden: []) 167 167 168 168 {:noreply, assign(socket, :deployment, deployment)} 169 169 end
+82
apps/sower/lib/sower_web/live/garden_live/form_component.ex
··· 1 + defmodule SowerWeb.GardenLive.FormComponent do 2 + use SowerWeb, :live_component 3 + 4 + alias Sower.Orchestration 5 + 6 + @impl true 7 + def render(assigns) do 8 + ~H""" 9 + <div> 10 + <.header> 11 + {@title} 12 + <:subtitle>Use this form to manage garden records in your database.</:subtitle> 13 + </.header> 14 + 15 + <.simple_form 16 + for={@form} 17 + id="garden-form" 18 + phx-target={@myself} 19 + phx-change="validate" 20 + phx-submit="save" 21 + > 22 + <.input field={@form[:name]} type="text" label="Name" /> 23 + <:actions> 24 + <.button phx-disable-with="Saving...">Save Garden</.button> 25 + </:actions> 26 + </.simple_form> 27 + </div> 28 + """ 29 + end 30 + 31 + @impl true 32 + def update(%{garden: garden} = assigns, socket) do 33 + {:ok, 34 + socket 35 + |> assign(assigns) 36 + |> assign_new(:form, fn -> 37 + to_form(Orchestration.change_garden(garden)) 38 + end)} 39 + end 40 + 41 + @impl true 42 + def handle_event("validate", %{"garden" => garden_params}, socket) do 43 + changeset = Orchestration.change_garden(socket.assigns.garden, garden_params) 44 + {:noreply, assign(socket, form: to_form(changeset, action: :validate))} 45 + end 46 + 47 + def handle_event("save", %{"garden" => garden_params}, socket) do 48 + save_garden(socket, socket.assigns.action, garden_params) 49 + end 50 + 51 + defp save_garden(socket, :edit, garden_params) do 52 + case Orchestration.update_garden(socket.assigns.garden, garden_params) do 53 + {:ok, garden} -> 54 + notify_parent({:saved, garden}) 55 + 56 + {:noreply, 57 + socket 58 + |> put_flash(:info, "Garden updated successfully") 59 + |> push_patch(to: socket.assigns.patch)} 60 + 61 + {:error, %Ecto.Changeset{} = changeset} -> 62 + {:noreply, assign(socket, form: to_form(changeset))} 63 + end 64 + end 65 + 66 + defp save_garden(socket, :new, garden_params) do 67 + case Orchestration.create_garden(garden_params) do 68 + {:ok, garden} -> 69 + notify_parent({:saved, garden}) 70 + 71 + {:noreply, 72 + socket 73 + |> put_flash(:info, "Garden created successfully") 74 + |> push_patch(to: socket.assigns.patch)} 75 + 76 + {:error, %Ecto.Changeset{} = changeset} -> 77 + {:noreply, assign(socket, form: to_form(changeset))} 78 + end 79 + end 80 + 81 + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) 82 + end
+60
apps/sower/lib/sower_web/live/garden_live/index.ex
··· 1 + defmodule SowerWeb.GardenLive.Index do 2 + use SowerWeb, :live_view 3 + 4 + alias Phoenix.Socket.Broadcast 5 + alias Sower.Orchestration 6 + alias Sower.Orchestration.Garden 7 + alias SowerWeb.Presence 8 + 9 + @impl true 10 + def mount(_params, _session, socket) do 11 + if connected?(socket) do 12 + Phoenix.PubSub.subscribe(Sower.PubSub, "garden:presence") 13 + end 14 + 15 + {:ok, 16 + stream(socket, :gardens, Orchestration.list_gardens_with_latest_deployment()) 17 + |> assign(:garden_presence, Presence.list("garden:presence"))} 18 + end 19 + 20 + @impl true 21 + def handle_params(params, _url, socket) do 22 + {:noreply, apply_action(socket, socket.assigns.live_action, params)} 23 + end 24 + 25 + defp apply_action(socket, :new, _params) do 26 + socket 27 + |> assign(:page_title, "New Garden") 28 + |> assign(:garden, %Garden{}) 29 + end 30 + 31 + defp apply_action(socket, :index, _params) do 32 + socket 33 + |> assign(:page_title, "Listing Gardens") 34 + |> assign(:garden, nil) 35 + end 36 + 37 + @impl true 38 + def handle_info({SowerWeb.GardenLive.FormComponent, {:saved, garden}}, socket) do 39 + {:noreply, stream_insert(socket, :gardens, garden)} 40 + end 41 + 42 + @impl true 43 + def handle_info(%Broadcast{topic: "garden:presence", event: "presence_diff"}, socket) do 44 + # update the presence list, then touch the stream to force a table refresh 45 + socket = 46 + socket 47 + |> assign(:garden_presence, Presence.list("garden:presence")) 48 + |> stream(:gardens, Orchestration.list_gardens_with_latest_deployment()) 49 + 50 + {:noreply, socket} 51 + end 52 + 53 + @impl true 54 + def handle_event("delete", %{"id" => id}, socket) do 55 + garden = Orchestration.get_garden!(id) 56 + {:ok, _} = Orchestration.delete_garden(garden) 57 + 58 + {:noreply, stream_delete(socket, :gardens, garden)} 59 + end 60 + end
+49
apps/sower/lib/sower_web/live/garden_live/index.html.heex
··· 1 + <Layouts.app flash={@flash} current_user={@current_user}> 2 + <.header> 3 + Listing Gardens 4 + </.header> 5 + 6 + <.table 7 + id="gardens" 8 + rows={@streams.gardens} 9 + row_click={fn {_id, garden} -> JS.navigate(~p"/gardens/#{garden}") end} 10 + action_hide_on={:sm} 11 + > 12 + <:col :let={{_id, garden}} label="Name">{garden.name}</:col> 13 + <:col :let={{_id, garden}} label="Online"> 14 + <.online state={garden.sid in Map.keys(@garden_presence)} /> 15 + </:col> 16 + <:col :let={{_id, garden}} label="Deploy"> 17 + <.result result={garden.latest_deployment && garden.latest_deployment.result} /> 18 + </:col> 19 + <:action :let={{_id, garden}}> 20 + <div class="sr-only"> 21 + <.link navigate={~p"/gardens/#{garden}"}>Show</.link> 22 + </div> 23 + </:action> 24 + <:action :let={{id, garden}}> 25 + <.link 26 + phx-click={JS.push("delete", value: %{id: garden.id}) |> hide("##{id}")} 27 + data-confirm="Are you sure?" 28 + > 29 + Delete 30 + </.link> 31 + </:action> 32 + </.table> 33 + 34 + <.modal 35 + :if={@live_action in [:new, :edit]} 36 + id="garden-modal" 37 + show 38 + on_cancel={JS.patch(~p"/gardens")} 39 + > 40 + <.live_component 41 + module={SowerWeb.GardenLive.FormComponent} 42 + id={@garden.id || :new} 43 + title={@page_title} 44 + action={@live_action} 45 + garden={@garden} 46 + patch={~p"/gardens"} 47 + /> 48 + </.modal> 49 + </Layouts.app>
+1 -1
apps/sower/lib/sower_web/live/subscription_live/form_component.ex
··· 33 33 socket 34 34 |> assign(assigns) 35 35 |> assign_new(:form, fn -> 36 - to_form(Orchestration.change_subscription(subscription, %{agent: nil})) 36 + to_form(Orchestration.change_subscription(subscription, %{garden: nil})) 37 37 end)} 38 38 end 39 39
+5 -5
apps/sower/lib/sower_web/live/subscription_live/index.ex
··· 10 10 end 11 11 12 12 @impl true 13 - def handle_params(%{"agent_sid" => agent_sid} = params, _url, socket) do 14 - agent = Orchestration.get_agent_sid!(agent_sid) 13 + def handle_params(%{"garden_sid" => garden_sid} = params, _url, socket) do 14 + garden = Orchestration.get_garden_sid!(garden_sid) 15 15 16 16 socket = 17 17 socket 18 - |> assign(:agent, agent) 19 - |> stream(:subscriptions, Orchestration.list_subscriptions_for_agent(agent)) 18 + |> assign(:garden, garden) 19 + |> stream(:subscriptions, Orchestration.list_subscriptions_for_garden(garden)) 20 20 21 21 {:noreply, apply_action(socket, socket.assigns.live_action, params)} 22 22 end ··· 30 30 defp apply_action(socket, :new, _params) do 31 31 socket 32 32 |> assign(:page_title, "New Subscription") 33 - |> assign(:subscription, %Subscription{agent_id: socket.assigns.agent.id}) 33 + |> assign(:subscription, %Subscription{garden_id: socket.assigns.garden.id}) 34 34 end 35 35 36 36 defp apply_action(socket, :index, _params) do
+6 -6
apps/sower/lib/sower_web/live/subscription_live/index.html.heex
··· 2 2 <.header> 3 3 Listing Subscriptions 4 4 <:actions> 5 - <.link patch={~p"/agents/#{@agent}/subscriptions/new"}> 5 + <.link patch={~p"/gardens/#{@garden}/subscriptions/new"}> 6 6 <.button>New Subscription</.button> 7 7 </.link> 8 8 </:actions> ··· 13 13 rows={@streams.subscriptions} 14 14 row_click={ 15 15 fn {_id, subscription} -> 16 - JS.navigate(~p"/agents/#{@agent}/subscriptions/#{subscription}") 16 + JS.navigate(~p"/gardens/#{@garden}/subscriptions/#{subscription}") 17 17 end 18 18 } 19 19 > 20 20 <:col :let={{_id, subscription}} label="Sid">{subscription.sid}</:col> 21 21 <:action :let={{_id, subscription}}> 22 22 <div class="sr-only"> 23 - <.link navigate={~p"/agents/#{@agent}/subscriptions/#{subscription}"}>Show</.link> 23 + <.link navigate={~p"/gardens/#{@garden}/subscriptions/#{subscription}"}>Show</.link> 24 24 </div> 25 - <.link patch={~p"/agents/#{@agent}/subscriptions/#{subscription}/edit"}>Edit</.link> 25 + <.link patch={~p"/gardens/#{@garden}/subscriptions/#{subscription}/edit"}>Edit</.link> 26 26 </:action> 27 27 <:action :let={{id, subscription}}> 28 28 <.link ··· 38 38 :if={@live_action in [:new, :edit]} 39 39 id="subscription-modal" 40 40 show 41 - on_cancel={JS.patch(~p"/agents/#{@agent}/subscriptions")} 41 + on_cancel={JS.patch(~p"/gardens/#{@garden}/subscriptions")} 42 42 > 43 43 <.live_component 44 44 module={SowerWeb.SubscriptionLive.FormComponent} ··· 46 46 title={@page_title} 47 47 action={@live_action} 48 48 subscription={@subscription} 49 - patch={~p"/agents/#{@agent}/subscriptions"} 49 + patch={~p"/gardens/#{@garden}/subscriptions"} 50 50 /> 51 51 </.modal> 52 52 </Layouts.app>
+5 -5
apps/sower/lib/sower_web/live/subscription_live/show.ex
··· 9 9 end 10 10 11 11 @impl true 12 - def handle_params(%{"agent_sid" => agent_sid, "sid" => sid}, _, socket) do 13 - agent = Orchestration.get_agent_sid!(agent_sid) 12 + def handle_params(%{"garden_sid" => garden_sid, "sid" => sid}, _, socket) do 13 + garden = Orchestration.get_garden_sid!(garden_sid) 14 14 15 15 case Orchestration.get_subscription_sid_with_deployments(sid) do 16 16 nil -> 17 17 {:noreply, 18 18 socket 19 19 |> put_flash(:error, "Subscription not found") 20 - |> redirect(to: ~p"/agents/#{agent}/subscriptions")} 20 + |> redirect(to: ~p"/gardens/#{garden}/subscriptions")} 21 21 22 22 subscription -> 23 23 matching_seeds = Orchestration.list_matching_seeds(subscription, 5) ··· 31 31 32 32 {:noreply, 33 33 socket 34 - |> assign(:agent, agent) 34 + |> assign(:garden, garden) 35 35 |> assign(:page_title, page_title(socket.assigns.live_action)) 36 36 |> assign(:subscription, subscription) 37 37 |> assign(:matching_seeds, matching_seeds) ··· 90 90 defp page_title(:show), do: "Show Subscription" 91 91 defp page_title(:edit), do: "Edit Subscription" 92 92 93 - defp deploy_error_message(:agent_not_found), do: "Agent not found" 93 + defp deploy_error_message(:garden_not_found), do: "Garden not found" 94 94 defp deploy_error_message(_), do: "Deployment failed" 95 95 end
+5 -5
apps/sower/lib/sower_web/live/subscription_live/show.html.heex
··· 12 12 </.header> 13 13 14 14 <div class="mt-8 space-y-10"> 15 - <.detail_field label="Agent"> 15 + <.detail_field label="Garden"> 16 16 <.link 17 - navigate={~p"/agents/#{@subscription.agent}"} 17 + navigate={~p"/gardens/#{@subscription.garden}"} 18 18 class="hover:text-orange-500 dark:hover:text-orange-400" 19 19 > 20 - {@subscription.agent.name} 20 + {@subscription.garden.name} 21 21 </.link> 22 22 </.detail_field> 23 23 ··· 103 103 :if={@live_action == :edit} 104 104 id="subscription-modal" 105 105 show 106 - on_cancel={JS.patch(~p"/agents/#{@agent}/subscriptions/#{@subscription}")} 106 + on_cancel={JS.patch(~p"/gardens/#{@garden}/subscriptions/#{@subscription}")} 107 107 > 108 108 <.live_component 109 109 module={SowerWeb.SubscriptionLive.FormComponent} ··· 111 111 title={@page_title} 112 112 action={@live_action} 113 113 subscription={@subscription} 114 - patch={~p"/agents/#{@agent}/subscriptions/#{@subscription}"} 114 + patch={~p"/gardens/#{@garden}/subscriptions/#{@subscription}"} 115 115 /> 116 116 </.modal> 117 117 </Layouts.app>
+10 -10
apps/sower/lib/sower_web/router.ex
··· 41 41 pipe_through [:browser, :require_authenticated_user] 42 42 43 43 live_session :authenticated, on_mount: [{SowerWeb.UserAuth, :ensure_authenticated}] do 44 - live "/agents", AgentLive.Index, :index 45 - live "/agents/new", AgentLive.Index, :new 46 - live "/agents/:sid/edit", AgentLive.Index, :edit 47 - live "/agents/:sid", AgentLive.Show, :show 48 - live "/agents/:sid/show/edit", AgentLive.Show, :edit 44 + live "/gardens", GardenLive.Index, :index 45 + live "/gardens/new", GardenLive.Index, :new 46 + live "/gardens/:sid/edit", GardenLive.Index, :edit 47 + live "/gardens/:sid", GardenLive.Show, :show 48 + live "/gardens/:sid/show/edit", GardenLive.Show, :edit 49 49 50 - live "/agents/:agent_sid/subscriptions", SubscriptionLive.Index, :index 51 - live "/agents/:agent_sid/subscriptions/new", SubscriptionLive.Index, :new 52 - live "/agents/:agent_sid/subscriptions/:sid/edit", SubscriptionLive.Index, :edit 53 - live "/agents/:agent_sid/subscriptions/:sid", SubscriptionLive.Show, :show 54 - live "/agents/:agent_sid/subscriptions/:sid/show/edit", SubscriptionLive.Show, :edit 50 + live "/gardens/:garden_sid/subscriptions", SubscriptionLive.Index, :index 51 + live "/gardens/:garden_sid/subscriptions/new", SubscriptionLive.Index, :new 52 + live "/gardens/:garden_sid/subscriptions/:sid/edit", SubscriptionLive.Index, :edit 53 + live "/gardens/:garden_sid/subscriptions/:sid", SubscriptionLive.Show, :show 54 + live "/gardens/:garden_sid/subscriptions/:sid/show/edit", SubscriptionLive.Show, :edit 55 55 56 56 live "/deployments", DeploymentLive.Index, :index 57 57 live "/deployments/:sid", DeploymentLive.Show, :show
+79
apps/sower/priv/repo/migrations/20260321120000_rename_agents_to_gardens.exs
··· 1 + defmodule Sower.Repo.Migrations.RenameAgentsToGardens do 2 + use Ecto.Migration 3 + 4 + def change do 5 + # Rename tables 6 + rename table(:agents), to: table(:gardens) 7 + rename table(:agent_seed_generations), to: table(:garden_seed_generations) 8 + 9 + # Rename columns 10 + rename table(:deployments), :agent_id, to: :garden_id 11 + rename table(:subscriptions), :agent_id, to: :garden_id 12 + rename table(:garden_seed_generations), :agent_id, to: :garden_id 13 + 14 + # Rename indexes on gardens (formerly agents) 15 + execute "ALTER INDEX agents_pkey RENAME TO gardens_pkey", 16 + "ALTER INDEX gardens_pkey RENAME TO agents_pkey" 17 + 18 + execute "ALTER INDEX agents_org_id_index RENAME TO gardens_org_id_index", 19 + "ALTER INDEX gardens_org_id_index RENAME TO agents_org_id_index" 20 + 21 + execute "ALTER INDEX agents_sid_index RENAME TO gardens_sid_index", 22 + "ALTER INDEX gardens_sid_index RENAME TO agents_sid_index" 23 + 24 + # Rename indexes on deployments 25 + execute "ALTER INDEX deployments_agent_id_index RENAME TO deployments_garden_id_index", 26 + "ALTER INDEX deployments_garden_id_index RENAME TO deployments_agent_id_index" 27 + 28 + # Rename indexes on subscriptions 29 + execute "ALTER INDEX subscriptions_agent_id_org_id_seed_name_seed_type_index RENAME TO subscriptions_garden_id_org_id_seed_name_seed_type_index", 30 + "ALTER INDEX subscriptions_garden_id_org_id_seed_name_seed_type_index RENAME TO subscriptions_agent_id_org_id_seed_name_seed_type_index" 31 + 32 + # Rename indexes on garden_seed_generations (formerly agent_seed_generations) 33 + execute "ALTER INDEX agent_seed_generations_pkey RENAME TO garden_seed_generations_pkey", 34 + "ALTER INDEX garden_seed_generations_pkey RENAME TO agent_seed_generations_pkey" 35 + 36 + execute "ALTER INDEX agent_seed_generations_org_id_index RENAME TO garden_seed_generations_org_id_index", 37 + "ALTER INDEX garden_seed_generations_org_id_index RENAME TO agent_seed_generations_org_id_index" 38 + 39 + execute "ALTER INDEX agent_seed_generations_agent_id_index RENAME TO garden_seed_generations_garden_id_index", 40 + "ALTER INDEX garden_seed_generations_garden_id_index RENAME TO agent_seed_generations_agent_id_index" 41 + 42 + execute "ALTER INDEX agent_seed_generations_seed_id_index RENAME TO garden_seed_generations_seed_id_index", 43 + "ALTER INDEX garden_seed_generations_seed_id_index RENAME TO agent_seed_generations_seed_id_index" 44 + 45 + execute "ALTER INDEX agent_seed_generations_profile_id_index RENAME TO garden_seed_generations_profile_id_index", 46 + "ALTER INDEX garden_seed_generations_profile_id_index RENAME TO agent_seed_generations_profile_id_index" 47 + 48 + execute "ALTER INDEX agent_seed_generations_agent_id_is_current_index RENAME TO garden_seed_generations_garden_id_is_current_index", 49 + "ALTER INDEX garden_seed_generations_garden_id_is_current_index RENAME TO agent_seed_generations_agent_id_is_current_index" 50 + 51 + execute "ALTER INDEX agent_seed_generations_agent_id_profile_id_index RENAME TO garden_seed_generations_garden_id_profile_id_index", 52 + "ALTER INDEX garden_seed_generations_garden_id_profile_id_index RENAME TO agent_seed_generations_agent_id_profile_id_index" 53 + 54 + execute "ALTER INDEX agent_seed_generations_agent_id_seed_id_index RENAME TO garden_seed_generations_garden_id_seed_id_index", 55 + "ALTER INDEX garden_seed_generations_garden_id_seed_id_index RENAME TO agent_seed_generations_agent_id_seed_id_index" 56 + 57 + # Rename foreign key constraints 58 + execute "ALTER TABLE gardens RENAME CONSTRAINT agents_org_id_fkey TO gardens_org_id_fkey", 59 + "ALTER TABLE gardens RENAME CONSTRAINT gardens_org_id_fkey TO agents_org_id_fkey" 60 + 61 + execute "ALTER TABLE deployments RENAME CONSTRAINT deployments_agent_id_fkey TO deployments_garden_id_fkey", 62 + "ALTER TABLE deployments RENAME CONSTRAINT deployments_garden_id_fkey TO deployments_agent_id_fkey" 63 + 64 + execute "ALTER TABLE subscriptions RENAME CONSTRAINT subscriptions_agent_id_fkey TO subscriptions_garden_id_fkey", 65 + "ALTER TABLE subscriptions RENAME CONSTRAINT subscriptions_garden_id_fkey TO subscriptions_agent_id_fkey" 66 + 67 + execute "ALTER TABLE garden_seed_generations RENAME CONSTRAINT agent_seed_generations_agent_id_fkey TO garden_seed_generations_garden_id_fkey", 68 + "ALTER TABLE garden_seed_generations RENAME CONSTRAINT garden_seed_generations_garden_id_fkey TO agent_seed_generations_agent_id_fkey" 69 + 70 + execute "ALTER TABLE garden_seed_generations RENAME CONSTRAINT agent_seed_generations_seed_id_fkey TO garden_seed_generations_seed_id_fkey", 71 + "ALTER TABLE garden_seed_generations RENAME CONSTRAINT garden_seed_generations_seed_id_fkey TO agent_seed_generations_seed_id_fkey" 72 + 73 + execute "ALTER TABLE garden_seed_generations RENAME CONSTRAINT agent_seed_generations_profile_id_fkey TO garden_seed_generations_profile_id_fkey", 74 + "ALTER TABLE garden_seed_generations RENAME CONSTRAINT garden_seed_generations_profile_id_fkey TO agent_seed_generations_profile_id_fkey" 75 + 76 + execute "ALTER TABLE garden_seed_generations RENAME CONSTRAINT agent_seed_generations_org_id_fkey TO garden_seed_generations_org_id_fkey", 77 + "ALTER TABLE garden_seed_generations RENAME CONSTRAINT gardens_org_id_fkey TO agent_seed_generations_org_id_fkey" 78 + end 79 + end
+4 -4
apps/sower/test/sower/orchestration/deployment_pubsub_test.exs
··· 15 15 end 16 16 17 17 test "broadcast_deployment_change/2 publishes per-deployment topic", %{organization: org} do 18 - agent = agent_fixture(%{org_id: org.org_id}) 18 + garden = garden_fixture(%{org_id: org.org_id}) 19 19 seed = seed_fixture(%{org_id: org.org_id, name: "seed", seed_type: "nixos"}) 20 20 21 21 subscription = 22 22 subscription_fixture(%{ 23 - agent_id: agent.id, 23 + garden_id: garden.id, 24 24 seed_name: seed.name, 25 25 seed_type: seed.seed_type 26 26 }) ··· 28 28 deployment = 29 29 deployment_fixture(%{ 30 30 org_id: org.org_id, 31 - agent_id: agent.id, 31 + garden_id: garden.id, 32 32 seeds: [seed], 33 33 subscriptions: [subscription] 34 34 }) 35 35 36 36 Phoenix.PubSub.subscribe(Sower.PubSub, "deployments") 37 37 Phoenix.PubSub.subscribe(Sower.PubSub, "deployment:#{deployment.sid}") 38 - Phoenix.PubSub.subscribe(Sower.PubSub, "deployments:agent:#{agent.sid}") 38 + Phoenix.PubSub.subscribe(Sower.PubSub, "deployments:garden:#{garden.sid}") 39 39 Phoenix.PubSub.subscribe(Sower.PubSub, "deployments:subscription:#{subscription.sid}") 40 40 41 41 assert {:ok, _deployment} = DeploymentPubSub.broadcast_deployment_change(deployment, :updated)
+214 -214
apps/sower/test/sower/orchestration_test.exs
··· 12 12 %{organization: org} 13 13 end 14 14 15 - describe "agents" do 16 - alias Sower.Orchestration.Agent 15 + describe "gardens" do 16 + alias Sower.Orchestration.Garden 17 17 18 18 import Sower.OrchestrationFixtures 19 19 20 20 @invalid_attrs %{name: nil} 21 21 22 - test "list_agents/0 returns all agents" do 23 - agent = agent_fixture() 24 - assert Orchestration.list_agents() == [agent] 22 + test "list_gardens/0 returns all gardens" do 23 + garden = garden_fixture() 24 + assert Orchestration.list_gardens() == [garden] 25 25 end 26 26 27 - test "get_agent!/1 returns the agent with given id" do 28 - agent = agent_fixture() 29 - assert Orchestration.get_agent!(agent.id) == agent 27 + test "get_garden!/1 returns the garden with given id" do 28 + garden = garden_fixture() 29 + assert Orchestration.get_garden!(garden.id) == garden 30 30 end 31 31 32 - test "create_agent/1 with valid data creates a agent" do 33 - valid_attrs = %{name: "some agent", local_sid: "some local_sid"} 32 + test "create_garden/1 with valid data creates a garden" do 33 + valid_attrs = %{name: "some garden", local_sid: "some local_sid"} 34 34 35 - assert {:ok, %Agent{} = agent} = Orchestration.create_agent(valid_attrs) 36 - assert agent.name == "some agent" 37 - assert agent.local_sid == "some local_sid" 35 + assert {:ok, %Garden{} = garden} = Orchestration.create_garden(valid_attrs) 36 + assert garden.name == "some garden" 37 + assert garden.local_sid == "some local_sid" 38 38 end 39 39 40 - test "create_agent/1 with invalid data returns error changeset" do 41 - assert {:error, %Ecto.Changeset{}} = Orchestration.create_agent(@invalid_attrs) 40 + test "create_garden/1 with invalid data returns error changeset" do 41 + assert {:error, %Ecto.Changeset{}} = Orchestration.create_garden(@invalid_attrs) 42 42 end 43 43 44 - test "update_agent/2 with valid data updates the agent" do 45 - agent = agent_fixture() 44 + test "update_garden/2 with valid data updates the garden" do 45 + garden = garden_fixture() 46 46 update_attrs = %{local_sid: "some updated local_sid"} 47 47 48 - assert {:ok, %Agent{} = agent} = Orchestration.update_agent(agent, update_attrs) 49 - assert agent.local_sid == "some updated local_sid" 48 + assert {:ok, %Garden{} = garden} = Orchestration.update_garden(garden, update_attrs) 49 + assert garden.local_sid == "some updated local_sid" 50 50 end 51 51 52 - test "update_agent/2 with invalid data returns error changeset" do 53 - agent = agent_fixture() 54 - assert {:error, %Ecto.Changeset{}} = Orchestration.update_agent(agent, @invalid_attrs) 55 - assert agent == Orchestration.get_agent!(agent.id) 52 + test "update_garden/2 with invalid data returns error changeset" do 53 + garden = garden_fixture() 54 + assert {:error, %Ecto.Changeset{}} = Orchestration.update_garden(garden, @invalid_attrs) 55 + assert garden == Orchestration.get_garden!(garden.id) 56 56 end 57 57 58 - test "delete_agent/1 deletes the agent" do 59 - agent = agent_fixture() 60 - assert {:ok, %Agent{}} = Orchestration.delete_agent(agent) 61 - assert_raise Ecto.NoResultsError, fn -> Orchestration.get_agent!(agent.id) end 58 + test "delete_garden/1 deletes the garden" do 59 + garden = garden_fixture() 60 + assert {:ok, %Garden{}} = Orchestration.delete_garden(garden) 61 + assert_raise Ecto.NoResultsError, fn -> Orchestration.get_garden!(garden.id) end 62 62 end 63 63 64 - test "change_agent/1 returns a agent changeset" do 65 - agent = agent_fixture() 66 - assert %Ecto.Changeset{} = Orchestration.change_agent(agent) 64 + test "change_garden/1 returns a garden changeset" do 65 + garden = garden_fixture() 66 + assert %Ecto.Changeset{} = Orchestration.change_garden(garden) 67 67 end 68 68 end 69 69 ··· 71 71 import Sower.OrchestrationFixtures 72 72 73 73 test "create_subscription/1 updates rules on conflict" do 74 - agent = agent_fixture() 74 + garden = garden_fixture() 75 75 76 76 # Create initial subscription with rules 77 77 {:ok, sub1} = 78 78 Orchestration.create_subscription(%{ 79 - agent_id: agent.id, 79 + garden_id: garden.id, 80 80 seed_name: "myhost", 81 81 seed_type: "nixos", 82 82 rules: [%{key: "branch", op: "eq", value: "main"}] ··· 85 85 assert length(sub1.rules) == 1 86 86 assert hd(sub1.rules).value == "main" 87 87 88 - # Re-create with different rules (same agent, seed_name, seed_type) 88 + # Re-create with different rules (same garden, seed_name, seed_type) 89 89 {:ok, sub2} = 90 90 Orchestration.create_subscription(%{ 91 - agent_id: agent.id, 91 + garden_id: garden.id, 92 92 seed_name: "myhost", 93 93 seed_type: "nixos", 94 94 rules: [%{key: "branch", op: "eq", value: "develop"}] ··· 111 111 import Sower.OrchestrationFixtures 112 112 113 113 test "returns nil when no seed matches name and type" do 114 - agent = agent_fixture() 114 + garden = garden_fixture() 115 115 116 116 subscription = 117 117 subscription_fixture(%{ 118 - agent_id: agent.id, 118 + garden_id: garden.id, 119 119 seed_name: "nonexistent", 120 120 seed_type: "nixos" 121 121 }) ··· 124 124 end 125 125 126 126 test "returns seed when name and type match with no rules" do 127 - agent = agent_fixture() 127 + garden = garden_fixture() 128 128 seed = seed_fixture(%{name: "myhost", seed_type: "nixos"}) 129 129 130 130 subscription = 131 131 subscription_fixture(%{ 132 - agent_id: agent.id, 132 + garden_id: garden.id, 133 133 seed_name: "myhost", 134 134 seed_type: "nixos" 135 135 }) ··· 139 139 end 140 140 141 141 test "returns seed when single rule matches" do 142 - agent = agent_fixture() 142 + garden = garden_fixture() 143 143 144 144 seed = 145 145 seed_fixture(%{ ··· 150 150 151 151 subscription = 152 152 subscription_fixture(%{ 153 - agent_id: agent.id, 153 + garden_id: garden.id, 154 154 seed_name: "myhost", 155 155 seed_type: "nixos", 156 156 rules: [%{key: "branch", op: :eq, value: "main"}] ··· 161 161 end 162 162 163 163 test "returns seed when all rules match" do 164 - agent = agent_fixture() 164 + garden = garden_fixture() 165 165 166 166 seed = 167 167 seed_fixture(%{ ··· 175 175 176 176 subscription = 177 177 subscription_fixture(%{ 178 - agent_id: agent.id, 178 + garden_id: garden.id, 179 179 seed_name: "myhost", 180 180 seed_type: "nixos", 181 181 rules: [ ··· 190 190 end 191 191 192 192 test "returns seed when all rules match even if seed has more tags" do 193 - agent = agent_fixture() 193 + garden = garden_fixture() 194 194 195 195 seed = 196 196 seed_fixture(%{ ··· 205 205 206 206 subscription = 207 207 subscription_fixture(%{ 208 - agent_id: agent.id, 208 + garden_id: garden.id, 209 209 seed_name: "myhost", 210 210 seed_type: "nixos", 211 211 rules: [ ··· 219 219 end 220 220 221 221 test "returns nil when rule does not match" do 222 - agent = agent_fixture() 222 + garden = garden_fixture() 223 223 224 224 seed_fixture(%{ 225 225 name: "myhost", ··· 229 229 230 230 subscription = 231 231 subscription_fixture(%{ 232 - agent_id: agent.id, 232 + garden_id: garden.id, 233 233 seed_name: "myhost", 234 234 seed_type: "nixos", 235 235 rules: [%{key: "branch", op: :eq, value: "main"}] ··· 239 239 end 240 240 241 241 test "returns nil when only some rules match" do 242 - agent = agent_fixture() 242 + garden = garden_fixture() 243 243 244 244 seed_fixture(%{ 245 245 name: "myhost", ··· 251 251 252 252 subscription = 253 253 subscription_fixture(%{ 254 - agent_id: agent.id, 254 + garden_id: garden.id, 255 255 seed_name: "myhost", 256 256 seed_type: "nixos", 257 257 rules: [ ··· 264 264 end 265 265 266 266 test "returns latest seed when multiple seeds match" do 267 - agent = agent_fixture() 267 + garden = garden_fixture() 268 268 269 269 artifact1 = random_nix_artifact() 270 270 artifact2 = random_nix_artifact() ··· 290 290 291 291 subscription = 292 292 subscription_fixture(%{ 293 - agent_id: agent.id, 293 + garden_id: garden.id, 294 294 seed_name: "myhost", 295 295 seed_type: "nixos", 296 296 rules: [%{key: "branch", op: :eq, value: "main"}] ··· 306 306 describe "nix_profiles" do 307 307 alias Sower.Orchestration.NixProfile 308 308 309 - test "changeset/2 validates required fields" do 309 + test "changeset/2 validates required gardens" do 310 310 changeset = NixProfile.changeset(%NixProfile{}, %{}) 311 311 refute changeset.valid? 312 312 assert "can't be blank" in errors_on(changeset).profile_path ··· 349 349 end 350 350 end 351 351 352 - describe "agent_seed_generations" do 353 - alias Sower.Orchestration.{AgentSeedGeneration, NixProfile} 352 + describe "garden_seed_generations" do 353 + alias Sower.Orchestration.{GardenSeedGeneration, NixProfile} 354 354 355 355 import Sower.OrchestrationFixtures 356 356 357 - test "changeset/2 validates required fields" do 358 - changeset = AgentSeedGeneration.changeset(%AgentSeedGeneration{}, %{}) 357 + test "changeset/2 validates required gardens" do 358 + changeset = GardenSeedGeneration.changeset(%GardenSeedGeneration{}, %{}) 359 359 refute changeset.valid? 360 360 361 361 errors = errors_on(changeset) 362 362 assert "can't be blank" in errors.org_id 363 - assert "can't be blank" in errors.agent_id 363 + assert "can't be blank" in errors.garden_id 364 364 assert "can't be blank" in errors.seed_id 365 365 assert "can't be blank" in errors.profile_id 366 366 assert "can't be blank" in errors.created_at_generation 367 367 end 368 368 369 369 test "changeset/2 accepts valid attributes" do 370 - agent = agent_fixture() 370 + garden = garden_fixture() 371 371 seed = seed_fixture() 372 372 profile = nix_profile_fixture() 373 373 374 374 changeset = 375 - AgentSeedGeneration.changeset(%AgentSeedGeneration{}, %{ 375 + GardenSeedGeneration.changeset(%GardenSeedGeneration{}, %{ 376 376 org_id: Sower.Repo.get_org_id(), 377 - agent_id: agent.id, 377 + garden_id: garden.id, 378 378 seed_id: seed.id, 379 379 profile_id: profile.id, 380 380 generation_number: 42, ··· 385 385 assert changeset.valid? 386 386 end 387 387 388 - test "list_for_agent/1 returns all profiles for agent ordered by generation_number desc" do 389 - agent = agent_fixture() 388 + test "list_for_garden/1 returns all profiles for garden ordered by generation_number desc" do 389 + garden = garden_fixture() 390 390 seed1 = seed_fixture() 391 391 seed2 = seed_fixture() 392 392 profile = nix_profile_fixture() 393 393 now = DateTime.utc_now() 394 394 395 395 asp1 = 396 - agent_seed_generation_fixture(%{ 397 - agent_id: agent.id, 396 + garden_seed_generation_fixture(%{ 397 + garden_id: garden.id, 398 398 seed_id: seed1.id, 399 399 profile_id: profile.id, 400 400 generation_number: 1, ··· 403 403 }) 404 404 405 405 asp2 = 406 - agent_seed_generation_fixture(%{ 407 - agent_id: agent.id, 406 + garden_seed_generation_fixture(%{ 407 + garden_id: garden.id, 408 408 seed_id: seed2.id, 409 409 profile_id: profile.id, 410 410 generation_number: 2, ··· 412 412 created_at_generation: now 413 413 }) 414 414 415 - result = Orchestration.list_agent_seed_generation(agent) 415 + result = Orchestration.list_garden_seed_generation(garden) 416 416 417 417 assert length(result) == 2 418 418 assert Enum.at(result, 0).id == asp2.id 419 419 assert Enum.at(result, 1).id == asp1.id 420 420 end 421 421 422 - test "list_agent_seed_generation/1 returns only current profiles" do 423 - agent = agent_fixture() 422 + test "list_current_seed_generation/1 returns only current profiles" do 423 + garden = garden_fixture() 424 424 seed1 = seed_fixture() 425 425 seed2 = seed_fixture() 426 426 profile = nix_profile_fixture() 427 427 now = DateTime.utc_now() 428 428 429 429 _asp1 = 430 - agent_seed_generation_fixture(%{ 431 - agent_id: agent.id, 430 + garden_seed_generation_fixture(%{ 431 + garden_id: garden.id, 432 432 seed_id: seed1.id, 433 433 profile_id: profile.id, 434 434 generation_number: 1, ··· 437 437 }) 438 438 439 439 asp2 = 440 - agent_seed_generation_fixture(%{ 441 - agent_id: agent.id, 440 + garden_seed_generation_fixture(%{ 441 + garden_id: garden.id, 442 442 seed_id: seed2.id, 443 443 profile_id: profile.id, 444 444 generation_number: 2, ··· 446 446 created_at_generation: now 447 447 }) 448 448 449 - result = Orchestration.list_current_seed_generation(agent) 449 + result = Orchestration.list_current_seed_generation(garden) 450 450 451 451 assert length(result) == 1 452 452 assert hd(result).id == asp2.id 453 453 end 454 454 455 - test "list_for_agent_profile/2 returns profiles for specific agent and profile" do 456 - agent = agent_fixture() 455 + test "list_for_garden_profile/2 returns profiles for specific garden and profile" do 456 + garden = garden_fixture() 457 457 seed1 = seed_fixture() 458 458 seed2 = seed_fixture() 459 459 profile1 = nix_profile_fixture(%{profile_path: "/nix/var/nix/profiles/system"}) ··· 461 461 now = DateTime.utc_now() 462 462 463 463 asp1 = 464 - agent_seed_generation_fixture(%{ 465 - agent_id: agent.id, 464 + garden_seed_generation_fixture(%{ 465 + garden_id: garden.id, 466 466 seed_id: seed1.id, 467 467 profile_id: profile1.id, 468 468 generation_number: 1, ··· 471 471 }) 472 472 473 473 _asp2 = 474 - agent_seed_generation_fixture(%{ 475 - agent_id: agent.id, 474 + garden_seed_generation_fixture(%{ 475 + garden_id: garden.id, 476 476 seed_id: seed2.id, 477 477 profile_id: profile2.id, 478 478 generation_number: 1, ··· 480 480 created_at_generation: now 481 481 }) 482 482 483 - result = Orchestration.list_agent_seed_generation_profile(agent.id, profile1.id) 483 + result = Orchestration.list_garden_seed_generation_profile(garden.id, profile1.id) 484 484 485 485 assert length(result) == 1 486 486 assert hd(result).id == asp1.id 487 487 end 488 488 489 489 test "upsert_from_report/4 inserts new profile" do 490 - agent = agent_fixture() 490 + garden = garden_fixture() 491 491 seed = seed_fixture() 492 492 profile = nix_profile_fixture() 493 493 now = DateTime.utc_now() ··· 499 499 } 500 500 501 501 assert {:ok, asp} = 502 - Orchestration.upsert_agent_generation(agent.id, profile.id, seed.id, attrs) 502 + Orchestration.upsert_garden_generation(garden.id, profile.id, seed.id, attrs) 503 503 504 504 assert asp.generation_number == 42 505 505 assert asp.is_current == true 506 506 end 507 507 508 508 test "upsert_from_report/4 updates existing profile on conflict" do 509 - agent = agent_fixture() 509 + garden = garden_fixture() 510 510 seed = seed_fixture() 511 511 profile = nix_profile_fixture() 512 512 now = DateTime.utc_now() ··· 517 517 created_at_generation: now 518 518 } 519 519 520 - {:ok, asp1} = Orchestration.upsert_agent_generation(agent.id, profile.id, seed.id, attrs1) 520 + {:ok, asp1} = Orchestration.upsert_garden_generation(garden.id, profile.id, seed.id, attrs1) 521 521 assert asp1.generation_number == 41 522 522 523 523 attrs2 = %{ ··· 526 526 created_at_generation: now 527 527 } 528 528 529 - {:ok, asp2} = Orchestration.upsert_agent_generation(agent.id, profile.id, seed.id, attrs2) 529 + {:ok, asp2} = Orchestration.upsert_garden_generation(garden.id, profile.id, seed.id, attrs2) 530 530 assert asp2.id == asp1.id 531 531 assert asp2.generation_number == 42 532 532 assert asp2.is_current == true 533 533 end 534 534 535 - test "unique constraint on agent_id and seed_id" do 536 - agent = agent_fixture() 535 + test "unique constraint on garden_id and seed_id" do 536 + garden = garden_fixture() 537 537 seed = seed_fixture() 538 538 profile = nix_profile_fixture() 539 539 now = DateTime.utc_now() 540 540 541 541 _asp1 = 542 - agent_seed_generation_fixture(%{ 543 - agent_id: agent.id, 542 + garden_seed_generation_fixture(%{ 543 + garden_id: garden.id, 544 544 seed_id: seed.id, 545 545 profile_id: profile.id, 546 546 generation_number: 1, ··· 550 550 551 551 # Attempting to insert a duplicate should fail 552 552 result = 553 - %AgentSeedGeneration{} 554 - |> AgentSeedGeneration.changeset(%{ 553 + %GardenSeedGeneration{} 554 + |> GardenSeedGeneration.changeset(%{ 555 555 org_id: Sower.Repo.get_org_id(), 556 - agent_id: agent.id, 556 + garden_id: garden.id, 557 557 seed_id: seed.id, 558 558 profile_id: profile.id, 559 559 generation_number: 2, ··· 563 563 |> Sower.Repo.insert() 564 564 565 565 assert {:error, changeset} = result 566 - assert "has already been taken" in errors_on(changeset).agent_id 566 + assert "has already been taken" in errors_on(changeset).garden_id 567 567 end 568 568 569 - test "deleting agent cascades to agent_seed_generations" do 570 - agent = agent_fixture() 569 + test "deleting garden cascades to garden_seed_generations" do 570 + garden = garden_fixture() 571 571 seed = seed_fixture() 572 572 profile = nix_profile_fixture() 573 573 now = DateTime.utc_now() 574 574 575 575 asp = 576 - agent_seed_generation_fixture(%{ 577 - agent_id: agent.id, 576 + garden_seed_generation_fixture(%{ 577 + garden_id: garden.id, 578 578 seed_id: seed.id, 579 579 profile_id: profile.id, 580 580 generation_number: 1, ··· 582 582 created_at_generation: now 583 583 }) 584 584 585 - {:ok, _} = Orchestration.delete_agent(agent) 585 + {:ok, _} = Orchestration.delete_garden(garden) 586 586 587 - assert Orchestration.list_agent_seed_generation(agent) == [] 588 - assert Sower.Repo.get(AgentSeedGeneration, asp.id) == nil 587 + assert Orchestration.list_garden_seed_generation(garden) == [] 588 + assert Sower.Repo.get(GardenSeedGeneration, asp.id) == nil 589 589 end 590 590 end 591 591 592 - describe "update_agent_seed_generations/2 with auto-registration" do 593 - alias Sower.Orchestration.{AgentSeedGeneration, NixProfile} 592 + describe "update_garden_seed_generations/2 with auto-registration" do 593 + alias Sower.Orchestration.{GardenSeedGeneration, NixProfile} 594 594 alias Sower.Orchestration.Seed 595 595 596 596 import Sower.OrchestrationFixtures 597 597 598 598 test "auto-registers unknown artifacts as seeds" do 599 - agent = agent_fixture() 599 + garden = garden_fixture() 600 600 artifact = "/nix/store/#{unique_hash()}-nixos-system-testhost-25.11" 601 601 602 - report = %SowerClient.Orchestration.AgentSeedsReport{ 602 + report = %SowerClient.Orchestration.GardenSeedsReport{ 603 603 profiles: [ 604 - %SowerClient.Orchestration.AgentSeedProfile{ 604 + %SowerClient.Orchestration.GardenSeedProfile{ 605 605 profile_path: "/nix/var/nix/profiles/system", 606 606 tags: %{}, 607 607 generations: [ 608 - %SowerClient.Orchestration.AgentSeedGeneration{ 608 + %SowerClient.Orchestration.GardenSeedGeneration{ 609 609 path: artifact, 610 610 link: "/nix/var/nix/profiles/system-42-link", 611 611 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 617 617 ] 618 618 } 619 619 620 - assert {:ok, :ok} = Orchestration.update_agent_seed_generations(report, agent) 620 + assert {:ok, :ok} = Orchestration.update_garden_seed_generations(report, garden) 621 621 622 622 # Verify seed was auto-registered 623 623 seed = Seed.get_by_artifact(artifact) ··· 626 626 assert seed.seed_type == "nixos" 627 627 628 628 assert Enum.any?(seed.tags, fn tag -> 629 - tag.key == "agent_source" && tag.value == agent.sid 629 + tag.key == "garden_source" && tag.value == garden.sid 630 630 end) 631 631 632 632 assert Enum.any?(seed.tags, fn tag -> 633 633 tag.key == "nixos_version" && tag.value == "25.11" 634 634 end) 635 635 636 - # Verify agent_seed_generation was created 637 - profiles = Orchestration.list_agent_seed_generation(agent) 636 + # Verify garden_seed_generation was created 637 + profiles = Orchestration.list_garden_seed_generation(garden) 638 638 assert length(profiles) == 1 639 639 assert hd(profiles).seed_id == seed.id 640 640 assert hd(profiles).is_current == true 641 641 end 642 642 643 643 test "auto-registers multiple generations and sets is_current correctly" do 644 - agent = agent_fixture() 644 + garden = garden_fixture() 645 645 artifact_current = "/nix/store/#{unique_hash()}-nixos-system-testhost-25.11" 646 646 artifact_previous = "/nix/store/#{unique_hash()}-nixos-system-testhost-24.04" 647 647 648 - report = %SowerClient.Orchestration.AgentSeedsReport{ 648 + report = %SowerClient.Orchestration.GardenSeedsReport{ 649 649 profiles: [ 650 - %SowerClient.Orchestration.AgentSeedProfile{ 650 + %SowerClient.Orchestration.GardenSeedProfile{ 651 651 profile_path: "/nix/var/nix/profiles/system", 652 652 tags: %{}, 653 653 generations: [ 654 - %SowerClient.Orchestration.AgentSeedGeneration{ 654 + %SowerClient.Orchestration.GardenSeedGeneration{ 655 655 path: artifact_current, 656 656 link: "/nix/var/nix/profiles/system-42-link", 657 657 created: DateTime.to_iso8601(DateTime.utc_now()), 658 658 generation_number: 42, 659 659 is_current: true 660 660 }, 661 - %SowerClient.Orchestration.AgentSeedGeneration{ 661 + %SowerClient.Orchestration.GardenSeedGeneration{ 662 662 path: artifact_previous, 663 663 link: "/nix/var/nix/profiles/system-41-link", 664 664 created: DateTime.to_iso8601(DateTime.add(DateTime.utc_now(), -86400, :second)), ··· 670 670 ] 671 671 } 672 672 673 - assert {:ok, :ok} = Orchestration.update_agent_seed_generations(report, agent) 673 + assert {:ok, :ok} = Orchestration.update_garden_seed_generations(report, garden) 674 674 675 675 # Both seeds should be auto-registered 676 676 assert Seed.get_by_artifact(artifact_current) != nil 677 677 assert Seed.get_by_artifact(artifact_previous) != nil 678 678 679 - # Both should have agent_seed_generations 680 - profiles = Orchestration.list_agent_seed_generation(agent) 679 + # Both should have garden_seed_generations 680 + profiles = Orchestration.list_garden_seed_generation(garden) 681 681 assert length(profiles) == 2 682 682 683 683 # Only one should be current ··· 687 687 end 688 688 689 689 test "includes profile tags in auto-registered seeds" do 690 - agent = agent_fixture() 690 + garden = garden_fixture() 691 691 artifact = "/nix/store/#{unique_hash()}-home-manager-generation" 692 692 693 - report = %SowerClient.Orchestration.AgentSeedsReport{ 693 + report = %SowerClient.Orchestration.GardenSeedsReport{ 694 694 profiles: [ 695 - %SowerClient.Orchestration.AgentSeedProfile{ 695 + %SowerClient.Orchestration.GardenSeedProfile{ 696 696 profile_path: "/home/alice/.local/state/nix/profiles/home-manager", 697 697 tags: %{"user" => "alice"}, 698 698 generations: [ 699 - %SowerClient.Orchestration.AgentSeedGeneration{ 699 + %SowerClient.Orchestration.GardenSeedGeneration{ 700 700 path: artifact, 701 701 link: "/home/alice/.local/state/nix/profiles/home-manager-5-link", 702 702 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 708 708 ] 709 709 } 710 710 711 - assert {:ok, :ok} = Orchestration.update_agent_seed_generations(report, agent) 711 + assert {:ok, :ok} = Orchestration.update_garden_seed_generations(report, garden) 712 712 713 713 seed = Seed.get_by_artifact(artifact) 714 714 assert seed.seed_type == "home-manager" 715 715 assert Enum.any?(seed.tags, fn tag -> tag.key == "user" && tag.value == "alice" end) 716 - assert Enum.any?(seed.tags, fn tag -> tag.key == "agent_source" end) 716 + assert Enum.any?(seed.tags, fn tag -> tag.key == "garden_source" end) 717 717 end 718 718 719 719 test "uses existing seed when artifact is already known" do 720 - agent = agent_fixture() 720 + garden = garden_fixture() 721 721 existing = seed_fixture() 722 722 723 - report = %SowerClient.Orchestration.AgentSeedsReport{ 723 + report = %SowerClient.Orchestration.GardenSeedsReport{ 724 724 profiles: [ 725 - %SowerClient.Orchestration.AgentSeedProfile{ 725 + %SowerClient.Orchestration.GardenSeedProfile{ 726 726 profile_path: "/nix/var/nix/profiles/system", 727 727 tags: %{}, 728 728 generations: [ 729 - %SowerClient.Orchestration.AgentSeedGeneration{ 729 + %SowerClient.Orchestration.GardenSeedGeneration{ 730 730 path: existing.artifact, 731 731 link: "/nix/var/nix/profiles/system-1-link", 732 732 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 738 738 ] 739 739 } 740 740 741 - assert {:ok, :ok} = Orchestration.update_agent_seed_generations(report, agent) 741 + assert {:ok, :ok} = Orchestration.update_garden_seed_generations(report, garden) 742 742 743 743 # Should use existing seed, not create a new one 744 - profiles = Orchestration.list_agent_seed_generation(agent) 744 + profiles = Orchestration.list_garden_seed_generation(garden) 745 745 assert length(profiles) == 1 746 746 assert hd(profiles).seed_id == existing.id 747 747 end 748 748 749 - test "deletes stale agent_seed_generations for removed generations" do 750 - agent = agent_fixture() 749 + test "deletes stale garden_seed_generations for removed generations" do 750 + garden = garden_fixture() 751 751 artifact1 = "/nix/store/#{unique_hash()}-nixos-system-testhost-1" 752 752 artifact2 = "/nix/store/#{unique_hash()}-nixos-system-testhost-2" 753 753 754 754 # First report with two generations 755 - report1 = %SowerClient.Orchestration.AgentSeedsReport{ 755 + report1 = %SowerClient.Orchestration.GardenSeedsReport{ 756 756 profiles: [ 757 - %SowerClient.Orchestration.AgentSeedProfile{ 757 + %SowerClient.Orchestration.GardenSeedProfile{ 758 758 profile_path: "/nix/var/nix/profiles/system", 759 759 tags: %{}, 760 760 generations: [ 761 - %SowerClient.Orchestration.AgentSeedGeneration{ 761 + %SowerClient.Orchestration.GardenSeedGeneration{ 762 762 path: artifact1, 763 763 link: "/nix/var/nix/profiles/system-1-link", 764 764 created: DateTime.to_iso8601(DateTime.utc_now()), 765 765 generation_number: 1, 766 766 is_current: false 767 767 }, 768 - %SowerClient.Orchestration.AgentSeedGeneration{ 768 + %SowerClient.Orchestration.GardenSeedGeneration{ 769 769 path: artifact2, 770 770 link: "/nix/var/nix/profiles/system-2-link", 771 771 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 777 777 ] 778 778 } 779 779 780 - assert {:ok, :ok} = Orchestration.update_agent_seed_generations(report1, agent) 781 - assert length(Orchestration.list_agent_seed_generation(agent)) == 2 780 + assert {:ok, :ok} = Orchestration.update_garden_seed_generations(report1, garden) 781 + assert length(Orchestration.list_garden_seed_generation(garden)) == 2 782 782 783 783 # Second report with only one generation (simulating garbage collection) 784 - report2 = %SowerClient.Orchestration.AgentSeedsReport{ 784 + report2 = %SowerClient.Orchestration.GardenSeedsReport{ 785 785 profiles: [ 786 - %SowerClient.Orchestration.AgentSeedProfile{ 786 + %SowerClient.Orchestration.GardenSeedProfile{ 787 787 profile_path: "/nix/var/nix/profiles/system", 788 788 tags: %{}, 789 789 generations: [ 790 - %SowerClient.Orchestration.AgentSeedGeneration{ 790 + %SowerClient.Orchestration.GardenSeedGeneration{ 791 791 path: artifact2, 792 792 link: "/nix/var/nix/profiles/system-2-link", 793 793 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 799 799 ] 800 800 } 801 801 802 - assert {:ok, :ok} = Orchestration.update_agent_seed_generations(report2, agent) 802 + assert {:ok, :ok} = Orchestration.update_garden_seed_generations(report2, garden) 803 803 804 - # Should only have one agent_seed_generation now 805 - profiles = Orchestration.list_agent_seed_generation(agent) 804 + # Should only have one garden_seed_generation now 805 + profiles = Orchestration.list_garden_seed_generation(garden) 806 806 assert length(profiles) == 1 807 807 assert hd(profiles).generation_number == 2 808 808 end 809 809 810 810 test "repeated identical reports do not advance generation id sequence" do 811 - agent = agent_fixture() 811 + garden = garden_fixture() 812 812 artifact = "/nix/store/#{unique_hash()}-nixos-system-testhost-25.11" 813 813 created_at = DateTime.to_iso8601(DateTime.utc_now()) 814 814 815 - report = %SowerClient.Orchestration.AgentSeedsReport{ 815 + report = %SowerClient.Orchestration.GardenSeedsReport{ 816 816 profiles: [ 817 - %SowerClient.Orchestration.AgentSeedProfile{ 817 + %SowerClient.Orchestration.GardenSeedProfile{ 818 818 profile_path: "/nix/var/nix/profiles/system", 819 819 tags: %{}, 820 820 generations: [ 821 - %SowerClient.Orchestration.AgentSeedGeneration{ 821 + %SowerClient.Orchestration.GardenSeedGeneration{ 822 822 path: artifact, 823 823 link: "/nix/var/nix/profiles/system-42-link", 824 824 created: created_at, ··· 830 830 ] 831 831 } 832 832 833 - assert {:ok, :ok} = Orchestration.update_agent_seed_generations(report, agent) 834 - first_sequence_value = agent_seed_generation_sequence_last_value() 833 + assert {:ok, :ok} = Orchestration.update_garden_seed_generations(report, garden) 834 + first_sequence_value = garden_seed_generation_sequence_last_value() 835 835 836 - [first_row] = Orchestration.list_agent_seed_generation(agent) 836 + [first_row] = Orchestration.list_garden_seed_generation(garden) 837 837 838 - assert {:ok, :ok} = Orchestration.update_agent_seed_generations(report, agent) 839 - second_sequence_value = agent_seed_generation_sequence_last_value() 838 + assert {:ok, :ok} = Orchestration.update_garden_seed_generations(report, garden) 839 + second_sequence_value = garden_seed_generation_sequence_last_value() 840 840 841 - [second_row] = Orchestration.list_agent_seed_generation(agent) 841 + [second_row] = Orchestration.list_garden_seed_generation(garden) 842 842 843 843 assert second_row.id == first_row.id 844 844 assert second_sequence_value == first_sequence_value 845 845 end 846 846 847 847 test "handles multiple profiles (NixOS + home-manager)" do 848 - agent = agent_fixture() 848 + garden = garden_fixture() 849 849 nixos_artifact = "/nix/store/#{unique_hash()}-nixos-system-testhost" 850 850 hm_artifact = "/nix/store/#{unique_hash()}-home-manager-generation" 851 851 852 - report = %SowerClient.Orchestration.AgentSeedsReport{ 852 + report = %SowerClient.Orchestration.GardenSeedsReport{ 853 853 profiles: [ 854 - %SowerClient.Orchestration.AgentSeedProfile{ 854 + %SowerClient.Orchestration.GardenSeedProfile{ 855 855 profile_path: "/nix/var/nix/profiles/system", 856 856 tags: %{}, 857 857 generations: [ 858 - %SowerClient.Orchestration.AgentSeedGeneration{ 858 + %SowerClient.Orchestration.GardenSeedGeneration{ 859 859 path: nixos_artifact, 860 860 link: "/nix/var/nix/profiles/system-42-link", 861 861 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 864 864 } 865 865 ] 866 866 }, 867 - %SowerClient.Orchestration.AgentSeedProfile{ 867 + %SowerClient.Orchestration.GardenSeedProfile{ 868 868 profile_path: "/home/testuser/.local/state/nix/profiles/home-manager", 869 869 tags: %{"user" => "testuser"}, 870 870 generations: [ 871 - %SowerClient.Orchestration.AgentSeedGeneration{ 871 + %SowerClient.Orchestration.GardenSeedGeneration{ 872 872 path: hm_artifact, 873 873 link: "/home/testuser/.local/state/nix/profiles/home-manager-10-link", 874 874 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 880 880 ] 881 881 } 882 882 883 - assert {:ok, :ok} = Orchestration.update_agent_seed_generations(report, agent) 883 + assert {:ok, :ok} = Orchestration.update_garden_seed_generations(report, garden) 884 884 885 - profiles = Orchestration.list_agent_seed_generation(agent) 885 + profiles = Orchestration.list_garden_seed_generation(garden) 886 886 assert length(profiles) == 2 887 887 888 888 nixos_seed = Seed.get_by_artifact(nixos_artifact) ··· 906 906 907 907 @tag :capture_log 908 908 test "returns immediate request_id for valid deployment request", %{organization: _org} do 909 - agent = agent_fixture() 909 + garden = garden_fixture() 910 910 _seed = seed_fixture(%{name: "testhost", seed_type: "nixos"}) 911 911 912 912 subscription = 913 913 subscription_fixture(%{ 914 - agent_id: agent.id, 914 + garden_id: garden.id, 915 915 seed_name: "testhost", 916 916 seed_type: "nixos" 917 917 }) ··· 923 923 "force" => false 924 924 } 925 925 926 - assert {:ok, request_id} = Orchestration.handle_deployment_request(payload, agent) 926 + assert {:ok, request_id} = Orchestration.handle_deployment_request(payload, garden) 927 927 assert is_binary(request_id) 928 928 end 929 929 930 930 test "returns error for deployment request with unauthorized subscription", %{ 931 931 organization: _org 932 932 } do 933 - agent1 = agent_fixture() 934 - agent2 = agent_fixture() 933 + garden1 = garden_fixture() 934 + garden2 = garden_fixture() 935 935 936 - # Create subscription for agent1 936 + # Create subscription for garden1 937 937 subscription = 938 938 subscription_fixture(%{ 939 - agent_id: agent1.id, 939 + garden_id: garden1.id, 940 940 seed_name: "testhost", 941 941 seed_type: "nixos" 942 942 }) 943 943 944 - # Try to use agent2's subscription with agent1's context (should fail) 944 + # Try to use garden2's subscription with garden1's context (should fail) 945 945 payload = %{ 946 946 "subscription_sids" => [subscription.sid], 947 947 "force" => false 948 948 } 949 949 950 - # This should be rejected because agent2 doesn't own the subscription 951 - result = Orchestration.handle_deployment_request(payload, agent2) 950 + # This should be rejected because garden2 doesn't own the subscription 951 + result = Orchestration.handle_deployment_request(payload, garden2) 952 952 assert result == {:error, :unauthorized} 953 953 end 954 954 955 955 @tag :capture_log 956 956 test "process_deployment returns request_id and starts async task", %{organization: _org} do 957 - agent = agent_fixture() 957 + garden = garden_fixture() 958 958 _seed = seed_fixture(%{name: "testhost", seed_type: "nixos"}) 959 959 960 960 subscription = 961 961 subscription_fixture(%{ 962 - agent_id: agent.id, 962 + garden_id: garden.id, 963 963 seed_name: "testhost", 964 964 seed_type: "nixos" 965 965 }) ··· 967 967 request_id = "dr_test_#{System.unique_integer([:positive])}" 968 968 969 969 assert {:ok, ^request_id} = 970 - Orchestration.process_deployment(request_id, [subscription], agent) 970 + Orchestration.process_deployment(request_id, [subscription], garden) 971 971 end 972 972 973 973 @tag :capture_log 974 974 test "process_deployment handles error case with no matching seeds", %{organization: _org} do 975 - agent = agent_fixture() 975 + garden = garden_fixture() 976 976 977 977 # Create subscription with no matching seed 978 978 subscription = 979 979 subscription_fixture(%{ 980 - agent_id: agent.id, 980 + garden_id: garden.id, 981 981 seed_name: "nonexistent", 982 982 seed_type: "nixos" 983 983 }) ··· 985 985 request_id = "dr_test_error_#{System.unique_integer([:positive])}" 986 986 987 987 assert {:ok, ^request_id} = 988 - Orchestration.process_deployment(request_id, [subscription], agent) 988 + Orchestration.process_deployment(request_id, [subscription], garden) 989 989 end 990 990 end 991 991 ··· 993 993 import Sower.OrchestrationFixtures 994 994 995 995 test "replays unresolved deployments and updates dispatch timestamp", %{organization: _org} do 996 - agent = agent_fixture() 996 + garden = garden_fixture() 997 997 seed = seed_fixture(%{name: "replay-host", seed_type: "nixos"}) 998 998 999 999 subscription = 1000 1000 subscription_fixture(%{ 1001 - agent_id: agent.id, 1001 + garden_id: garden.id, 1002 1002 seed_name: seed.name, 1003 1003 seed_type: seed.seed_type 1004 1004 }) 1005 1005 1006 1006 unresolved = 1007 1007 deployment_fixture(%{ 1008 - agent_id: agent.id, 1008 + garden_id: garden.id, 1009 1009 seeds: [seed], 1010 1010 subscriptions: [subscription], 1011 1011 result: nil, ··· 1014 1014 1015 1015 _terminal = 1016 1016 deployment_fixture(%{ 1017 - agent_id: agent.id, 1017 + garden_id: garden.id, 1018 1018 seeds: [seed], 1019 1019 subscriptions: [subscription], 1020 1020 result: :success, ··· 1023 1023 }) 1024 1024 1025 1025 replayed_at = DateTime.utc_now() |> DateTime.truncate(:second) 1026 - Phoenix.PubSub.subscribe(Sower.PubSub, "agent:#{agent.sid}") 1026 + Phoenix.PubSub.subscribe(Sower.PubSub, "agent:#{garden.sid}") 1027 1027 1028 1028 assert {:ok, deployments} = 1029 - Orchestration.replay_unresolved_deployments(agent, 1029 + Orchestration.replay_unresolved_deployments(garden, 1030 1030 now: replayed_at, 1031 1031 request_id_fun: fn -> "request_replay_1" end 1032 1032 ) ··· 1039 1039 payload: payload 1040 1040 } 1041 1041 1042 - assert topic == "agent:#{agent.sid}" 1042 + assert topic == "agent:#{garden.sid}" 1043 1043 assert payload.sid == unresolved.sid 1044 1044 assert payload.skipped == false 1045 1045 assert payload.request_id == "request_replay_1" ··· 1060 1060 now = DateTime.utc_now() |> DateTime.truncate(:second) 1061 1061 old_dispatch = DateTime.add(now, -8_000, :second) 1062 1062 fresh_dispatch = DateTime.add(now, -100, :second) 1063 - agent = agent_fixture() 1063 + garden = garden_fixture() 1064 1064 1065 1065 stale = 1066 1066 deployment_fixture(%{ 1067 - agent_id: agent.id, 1067 + garden_id: garden.id, 1068 1068 result: nil, 1069 1069 deployed_at: nil, 1070 1070 state: :dispatched, ··· 1073 1073 1074 1074 fresh = 1075 1075 deployment_fixture(%{ 1076 - agent_id: agent.id, 1076 + garden_id: garden.id, 1077 1077 result: nil, 1078 1078 deployed_at: nil, 1079 1079 state: :dispatched, ··· 1104 1104 user = user_fixture(%{org_id: org.org_id}) 1105 1105 now = DateTime.utc_now() |> DateTime.truncate(:second) 1106 1106 old_dispatch = DateTime.add(now, -10_000, :second) 1107 - agent = agent_fixture() 1107 + garden = garden_fixture() 1108 1108 1109 1109 parent = 1110 1110 deployment_fixture(%{ 1111 - agent_id: agent.id, 1111 + garden_id: garden.id, 1112 1112 result: :success, 1113 1113 state: :completed, 1114 1114 deployed_at: DateTime.utc_now() ··· 1116 1116 1117 1117 _child = 1118 1118 deployment_fixture(%{ 1119 - agent_id: agent.id, 1119 + garden_id: garden.id, 1120 1120 parent_deployment_id: parent.id, 1121 1121 retry_ordinal: 1, 1122 1122 retried_by_user_id: user.id, ··· 1145 1145 now = DateTime.utc_now() |> DateTime.truncate(:second) 1146 1146 old_dispatch = DateTime.add(now, -8_000, :second) 1147 1147 later = DateTime.add(now, 60, :second) 1148 - agent = agent_fixture() 1148 + garden = garden_fixture() 1149 1149 1150 1150 deployment = 1151 1151 deployment_fixture(%{ 1152 - agent_id: agent.id, 1152 + garden_id: garden.id, 1153 1153 result: nil, 1154 1154 state: :dispatched, 1155 1155 deployed_at: nil, ··· 1184 1184 import Sower.OrchestrationFixtures 1185 1185 1186 1186 test "non-force request skips duplicate successful deployment", %{organization: _org} do 1187 - agent = agent_fixture() 1187 + garden = garden_fixture() 1188 1188 _seed = seed_fixture(%{name: "retry-host", seed_type: "nixos"}) 1189 1189 1190 1190 subscription = 1191 1191 subscription_fixture(%{ 1192 - agent_id: agent.id, 1192 + garden_id: garden.id, 1193 1193 seed_name: "retry-host", 1194 1194 seed_type: "nixos" 1195 1195 }) ··· 1216 1216 %{ 1217 1217 organization: _org 1218 1218 } do 1219 - agent = agent_fixture() 1219 + garden = garden_fixture() 1220 1220 _seed = seed_fixture(%{name: "retry-host", seed_type: "nixos"}) 1221 1221 1222 1222 subscription = 1223 1223 subscription_fixture(%{ 1224 - agent_id: agent.id, 1224 + garden_id: garden.id, 1225 1225 seed_name: "retry-host", 1226 1226 seed_type: "nixos" 1227 1227 }) ··· 1251 1251 1252 1252 test "creates retry deployment for successful deployment", %{organization: org} do 1253 1253 user = user_fixture(%{org_id: org.org_id}) 1254 - agent = agent_fixture() 1254 + garden = garden_fixture() 1255 1255 seed = seed_fixture() 1256 1256 1257 1257 subscription = 1258 1258 subscription_fixture(%{ 1259 - agent_id: agent.id, 1259 + garden_id: garden.id, 1260 1260 seed_name: seed.name, 1261 1261 seed_type: seed.seed_type 1262 1262 }) 1263 1263 1264 1264 deployment = 1265 1265 deployment_fixture(%{ 1266 - agent_id: agent.id, 1266 + garden_id: garden.id, 1267 1267 seeds: [seed], 1268 1268 subscriptions: [subscription], 1269 1269 result: :success, ··· 1282 1282 1283 1283 test "creates retry deployment for failed deployment", %{organization: org} do 1284 1284 user = user_fixture(%{org_id: org.org_id}) 1285 - agent = agent_fixture() 1285 + garden = garden_fixture() 1286 1286 1287 1287 deployment = 1288 1288 deployment_fixture(%{ 1289 - agent_id: agent.id, 1289 + garden_id: garden.id, 1290 1290 result: :failure, 1291 1291 deployed_at: DateTime.utc_now() 1292 1292 }) ··· 1297 1297 1298 1298 test "rejects retries for non-terminal deployment", %{organization: org} do 1299 1299 user = user_fixture(%{org_id: org.org_id}) 1300 - agent = agent_fixture() 1300 + garden = garden_fixture() 1301 1301 1302 1302 deployment = 1303 1303 deployment_fixture(%{ 1304 - agent_id: agent.id, 1304 + garden_id: garden.id, 1305 1305 result: nil, 1306 1306 deployed_at: nil 1307 1307 }) ··· 1315 1315 owner_org_id = owner_user.org_id 1316 1316 Sower.Repo.put_org_id(owner_org_id) 1317 1317 1318 - agent = agent_fixture() 1318 + garden = garden_fixture() 1319 1319 1320 1320 deployment = 1321 1321 deployment_fixture(%{ 1322 - agent_id: agent.id, 1322 + garden_id: garden.id, 1323 1323 result: :success, 1324 1324 deployed_at: DateTime.utc_now() 1325 1325 }) ··· 1332 1332 1333 1333 test "blocks concurrent duplicate retries while retry is in progress", %{organization: org} do 1334 1334 user = user_fixture(%{org_id: org.org_id}) 1335 - agent = agent_fixture() 1335 + garden = garden_fixture() 1336 1336 1337 1337 deployment = 1338 1338 deployment_fixture(%{ 1339 - agent_id: agent.id, 1339 + garden_id: garden.id, 1340 1340 result: :success, 1341 1341 deployed_at: DateTime.utc_now() 1342 1342 }) ··· 1347 1347 1348 1348 test "broadcasts deployment event to agent topic when retry is created", %{organization: org} do 1349 1349 user = user_fixture(%{org_id: org.org_id}) 1350 - agent = agent_fixture() 1350 + garden = garden_fixture() 1351 1351 seed = seed_fixture(%{name: "kale", seed_type: "home-manager"}) 1352 1352 1353 1353 subscription = 1354 1354 subscription_fixture(%{ 1355 - agent_id: agent.id, 1355 + garden_id: garden.id, 1356 1356 seed_name: seed.name, 1357 1357 seed_type: seed.seed_type 1358 1358 }) 1359 1359 1360 1360 deployment = 1361 1361 deployment_fixture(%{ 1362 - agent_id: agent.id, 1362 + garden_id: garden.id, 1363 1363 seeds: [seed], 1364 1364 subscriptions: [subscription], 1365 1365 result: :success, 1366 1366 deployed_at: DateTime.utc_now() 1367 1367 }) 1368 1368 1369 - Phoenix.PubSub.subscribe(Sower.PubSub, "agent:#{agent.sid}") 1369 + Phoenix.PubSub.subscribe(Sower.PubSub, "agent:#{garden.sid}") 1370 1370 1371 1371 assert {:ok, retried} = Orchestration.retry_deployment(deployment, user.id) 1372 1372 ··· 1376 1376 payload: payload 1377 1377 } 1378 1378 1379 - assert topic == "agent:#{agent.sid}" 1379 + assert topic == "agent:#{garden.sid}" 1380 1380 assert payload.sid == retried.sid 1381 1381 assert payload.skipped == false 1382 1382 assert is_binary(payload.request_id) ··· 1388 1388 :crypto.strong_rand_bytes(16) |> Base.encode32(case: :lower) |> String.slice(0, 32) 1389 1389 end 1390 1390 1391 - defp agent_seed_generation_sequence_last_value do 1391 + defp garden_seed_generation_sequence_last_value do 1392 1392 %Postgrex.Result{rows: [[last_value]]} = 1393 1393 Ecto.Adapters.SQL.query!( 1394 1394 Sower.Repo,
+23 -23
apps/sower/test/sower/seed_test.exs
··· 176 176 describe "find_or_register/3" do 177 177 test "returns existing seed when artifact already exists" do 178 178 existing = seed_fixture() 179 - agent = agent_fixture() 179 + garden = garden_fixture() 180 180 181 - generation = %SowerClient.Orchestration.AgentSeedGeneration{ 181 + generation = %SowerClient.Orchestration.GardenSeedGeneration{ 182 182 path: existing.artifact, 183 183 link: "/nix/var/nix/profiles/system-1-link", 184 184 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 186 186 is_current: true 187 187 } 188 188 189 - profile = %SowerClient.Orchestration.AgentSeedProfile{ 189 + profile = %SowerClient.Orchestration.GardenSeedProfile{ 190 190 profile_path: "/nix/var/nix/profiles/system", 191 191 tags: [], 192 192 generations: [generation] 193 193 } 194 194 195 - assert {:ok, seed} = Seed.find_or_register(agent, generation, profile) 195 + assert {:ok, seed} = Seed.find_or_register(garden, generation, profile) 196 196 assert seed.id == existing.id 197 197 end 198 198 199 199 test "creates new seed when artifact is unknown" do 200 - agent = agent_fixture() 200 + garden = garden_fixture() 201 201 artifact = "/nix/store/#{unique_hash()}-nixos-system-testhost-25.11" 202 202 203 - generation = %SowerClient.Orchestration.AgentSeedGeneration{ 203 + generation = %SowerClient.Orchestration.GardenSeedGeneration{ 204 204 path: artifact, 205 205 link: "/nix/var/nix/profiles/system-42-link", 206 206 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 208 208 is_current: true 209 209 } 210 210 211 - profile = %SowerClient.Orchestration.AgentSeedProfile{ 211 + profile = %SowerClient.Orchestration.GardenSeedProfile{ 212 212 profile_path: "/nix/var/nix/profiles/system", 213 213 tags: [], 214 214 generations: [generation] 215 215 } 216 216 217 - assert {:ok, seed} = Seed.find_or_register(agent, generation, profile) 217 + assert {:ok, seed} = Seed.find_or_register(garden, generation, profile) 218 218 assert seed.artifact == artifact 219 219 assert seed.name == "testhost" 220 220 assert seed.seed_type == "nixos" 221 221 end 222 222 223 - test "adds agent_source tag when auto-registering" do 224 - agent = agent_fixture() 223 + test "adds garden_source tag when auto-registering" do 224 + garden = garden_fixture() 225 225 artifact = "/nix/store/#{unique_hash()}-nixos-system-testhost-25.11" 226 226 227 - generation = %SowerClient.Orchestration.AgentSeedGeneration{ 227 + generation = %SowerClient.Orchestration.GardenSeedGeneration{ 228 228 path: artifact, 229 229 link: "/nix/var/nix/profiles/system-42-link", 230 230 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 232 232 is_current: true 233 233 } 234 234 235 - profile = %SowerClient.Orchestration.AgentSeedProfile{ 235 + profile = %SowerClient.Orchestration.GardenSeedProfile{ 236 236 profile_path: "/nix/var/nix/profiles/system", 237 237 tags: [], 238 238 generations: [generation] 239 239 } 240 240 241 - assert {:ok, seed} = Seed.find_or_register(agent, generation, profile) 241 + assert {:ok, seed} = Seed.find_or_register(garden, generation, profile) 242 242 243 243 assert Enum.any?(seed.tags, fn tag -> 244 - tag.key == "agent_source" && tag.value == agent.sid 244 + tag.key == "garden_source" && tag.value == garden.sid 245 245 end) 246 246 end 247 247 248 248 test "includes profile tags when auto-registering" do 249 - agent = agent_fixture() 249 + garden = garden_fixture() 250 250 artifact = "/nix/store/#{unique_hash()}-home-manager-generation" 251 251 252 - generation = %SowerClient.Orchestration.AgentSeedGeneration{ 252 + generation = %SowerClient.Orchestration.GardenSeedGeneration{ 253 253 path: artifact, 254 254 link: "/home/alice/.local/state/nix/profiles/home-manager-5-link", 255 255 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 257 257 is_current: true 258 258 } 259 259 260 - profile = %SowerClient.Orchestration.AgentSeedProfile{ 260 + profile = %SowerClient.Orchestration.GardenSeedProfile{ 261 261 profile_path: "/home/alice/.local/state/nix/profiles/home-manager", 262 262 tags: [{"user", "alice"}], 263 263 generations: [generation] 264 264 } 265 265 266 - assert {:ok, seed} = Seed.find_or_register(agent, generation, profile) 266 + assert {:ok, seed} = Seed.find_or_register(garden, generation, profile) 267 267 assert seed.seed_type == "home-manager" 268 268 assert Enum.any?(seed.tags, fn tag -> tag.key == "user" && tag.value == "alice" end) 269 - assert Enum.any?(seed.tags, fn tag -> tag.key == "agent_source" end) 269 + assert Enum.any?(seed.tags, fn tag -> tag.key == "garden_source" end) 270 270 end 271 271 272 272 test "determines seed_type from home-manager profile path" do 273 - agent = agent_fixture() 273 + garden = garden_fixture() 274 274 artifact = "/nix/store/#{unique_hash()}-home-manager-generation" 275 275 276 - generation = %SowerClient.Orchestration.AgentSeedGeneration{ 276 + generation = %SowerClient.Orchestration.GardenSeedGeneration{ 277 277 path: artifact, 278 278 link: "/home/alice/.local/state/nix/profiles/home-manager-5-link", 279 279 created: DateTime.to_iso8601(DateTime.utc_now()), ··· 281 281 is_current: true 282 282 } 283 283 284 - profile = %SowerClient.Orchestration.AgentSeedProfile{ 284 + profile = %SowerClient.Orchestration.GardenSeedProfile{ 285 285 profile_path: "/home/alice/.local/state/nix/profiles/home-manager", 286 286 tags: [], 287 287 generations: [generation] 288 288 } 289 289 290 - assert {:ok, seed} = Seed.find_or_register(agent, generation, profile) 290 + assert {:ok, seed} = Seed.find_or_register(garden, generation, profile) 291 291 assert seed.seed_type == "home-manager" 292 292 end 293 293 end
-98
apps/sower/test/sower_web/channels/agent_channel_test.exs
··· 1 - defmodule SowerWeb.AgentChannelTest do 2 - use Sower.DataCase, async: true 3 - 4 - import Sower.AccountsFixtures 5 - import Sower.OrchestrationFixtures 6 - import Sower.SeedFixtures 7 - 8 - alias Phoenix.Socket.Broadcast 9 - alias Sower.Accounts.AccessToken 10 - alias Sower.Orchestration 11 - alias SowerWeb.AgentChannel 12 - 13 - describe "join/3" do 14 - test "schedules replay when agent joins with matching local sid" do 15 - user = user_fixture() 16 - Sower.Repo.put_org_id(user.org_id) 17 - 18 - agent = agent_fixture(%{sid: "agent_join_replay_1", local_sid: "agent_local_1"}) 19 - 20 - socket = %Phoenix.Socket{ 21 - assigns: %{ 22 - conn_sid: "conn_1", 23 - access_token: %AccessToken{org_id: user.org_id} 24 - } 25 - } 26 - 27 - assert {:ok, %{conn_sid: "conn_1"}, joined_socket} = 28 - AgentChannel.join( 29 - "agent:#{agent.sid}", 30 - %{"local_sid" => "agent_local_1"}, 31 - socket 32 - ) 33 - 34 - assert joined_socket.assigns.agent.id == agent.id 35 - assert_received :track_presence 36 - assert_received :replay_unresolved_deployments 37 - end 38 - end 39 - 40 - describe "handle_info/2 replay_unresolved_deployments" do 41 - test "replays unresolved deployments and skips terminal ones" do 42 - user = user_fixture() 43 - Sower.Repo.put_org_id(user.org_id) 44 - 45 - agent = agent_fixture(%{sid: "agent_replay_1"}) 46 - seed = seed_fixture(%{name: "replay-seed-1", seed_type: "nixos"}) 47 - 48 - subscription = 49 - subscription_fixture(%{ 50 - agent_id: agent.id, 51 - seed_name: seed.name, 52 - seed_type: seed.seed_type 53 - }) 54 - 55 - unresolved = 56 - deployment_fixture(%{ 57 - agent_id: agent.id, 58 - seeds: [seed], 59 - subscriptions: [subscription], 60 - result: nil, 61 - deployed_at: nil 62 - }) 63 - 64 - _terminal = 65 - deployment_fixture(%{ 66 - agent_id: agent.id, 67 - seeds: [seed], 68 - subscriptions: [subscription], 69 - result: :success, 70 - state: :completed, 71 - deployed_at: DateTime.utc_now() |> DateTime.truncate(:second) 72 - }) 73 - 74 - Phoenix.PubSub.subscribe(Sower.PubSub, "agent:#{agent.sid}") 75 - 76 - socket = %Phoenix.Socket{assigns: %{agent: agent}} 77 - 78 - assert {:noreply, ^socket} = 79 - AgentChannel.handle_info(:replay_unresolved_deployments, socket) 80 - 81 - assert_receive %Broadcast{ 82 - topic: topic, 83 - event: "deployment", 84 - payload: payload 85 - } 86 - 87 - assert topic == "agent:#{agent.sid}" 88 - assert payload.sid == unresolved.sid 89 - assert payload.skipped == false 90 - assert is_binary(payload.request_id) 91 - assert is_list(payload.seed_deployments) 92 - 93 - assert Enum.map(Orchestration.list_unresolved_deployments_for_agent(agent), & &1.sid) == [ 94 - unresolved.sid 95 - ] 96 - end 97 - end 98 - end
+121
apps/sower/test/sower_web/channels/garden_channel_test.exs
··· 1 + defmodule SowerWeb.GardenChannelTest do 2 + use Sower.DataCase, async: true 3 + 4 + import Sower.AccountsFixtures 5 + import Sower.OrchestrationFixtures 6 + import Sower.SeedFixtures 7 + 8 + alias Phoenix.Socket.Broadcast 9 + alias Sower.Accounts.AccessToken 10 + alias Sower.Orchestration 11 + alias SowerWeb.GardenChannel 12 + 13 + describe "join/3" do 14 + test "schedules replay when garden joins with matching local sid" do 15 + user = user_fixture() 16 + Sower.Repo.put_org_id(user.org_id) 17 + 18 + garden = garden_fixture(%{sid: "garden_join_replay_1", local_sid: "garden_local_1"}) 19 + 20 + socket = %Phoenix.Socket{ 21 + assigns: %{ 22 + conn_sid: "conn_1", 23 + access_token: %AccessToken{org_id: user.org_id} 24 + } 25 + } 26 + 27 + assert {:ok, %{conn_sid: "conn_1"}, joined_socket} = 28 + GardenChannel.join( 29 + "garden:#{garden.sid}", 30 + %{"local_sid" => "garden_local_1"}, 31 + socket 32 + ) 33 + 34 + assert joined_socket.assigns.garden.id == garden.id 35 + assert_received :track_presence 36 + assert_received :replay_unresolved_deployments 37 + end 38 + 39 + test "accepts legacy agent: topic prefix for backward compatibility" do 40 + user = user_fixture() 41 + Sower.Repo.put_org_id(user.org_id) 42 + 43 + garden = garden_fixture(%{sid: "garden_join_compat_1", local_sid: "garden_compat_local_1"}) 44 + 45 + socket = %Phoenix.Socket{ 46 + assigns: %{ 47 + conn_sid: "conn_2", 48 + access_token: %AccessToken{org_id: user.org_id} 49 + } 50 + } 51 + 52 + assert {:ok, %{conn_sid: "conn_2"}, joined_socket} = 53 + GardenChannel.join( 54 + "agent:#{garden.sid}", 55 + %{"local_sid" => "garden_compat_local_1"}, 56 + socket 57 + ) 58 + 59 + assert joined_socket.assigns.garden.id == garden.id 60 + end 61 + end 62 + 63 + describe "handle_info/2 replay_unresolved_deployments" do 64 + test "replays unresolved deployments and skips terminal ones" do 65 + user = user_fixture() 66 + Sower.Repo.put_org_id(user.org_id) 67 + 68 + garden = garden_fixture(%{sid: "garden_replay_1"}) 69 + seed = seed_fixture(%{name: "replay-seed-1", seed_type: "nixos"}) 70 + 71 + subscription = 72 + subscription_fixture(%{ 73 + garden_id: garden.id, 74 + seed_name: seed.name, 75 + seed_type: seed.seed_type 76 + }) 77 + 78 + unresolved = 79 + deployment_fixture(%{ 80 + garden_id: garden.id, 81 + seeds: [seed], 82 + subscriptions: [subscription], 83 + result: nil, 84 + deployed_at: nil 85 + }) 86 + 87 + _terminal = 88 + deployment_fixture(%{ 89 + garden_id: garden.id, 90 + seeds: [seed], 91 + subscriptions: [subscription], 92 + result: :success, 93 + state: :completed, 94 + deployed_at: DateTime.utc_now() |> DateTime.truncate(:second) 95 + }) 96 + 97 + Phoenix.PubSub.subscribe(Sower.PubSub, "garden:#{garden.sid}") 98 + 99 + socket = %Phoenix.Socket{assigns: %{garden: garden}} 100 + 101 + assert {:noreply, ^socket} = 102 + GardenChannel.handle_info(:replay_unresolved_deployments, socket) 103 + 104 + assert_receive %Broadcast{ 105 + topic: topic, 106 + event: "deployment", 107 + payload: payload 108 + } 109 + 110 + assert topic == "garden:#{garden.sid}" 111 + assert payload.sid == unresolved.sid 112 + assert payload.skipped == false 113 + assert is_binary(payload.request_id) 114 + assert is_list(payload.seed_deployments) 115 + 116 + assert Enum.map(Orchestration.list_unresolved_deployments_for_garden(garden), & &1.sid) == [ 117 + unresolved.sid 118 + ] 119 + end 120 + end 121 + end
+1 -1
apps/sower/test/sower_web/controllers/page_controller_test.exs
··· 9 9 assert html =~ "<summary" 10 10 assert html =~ "Menu" 11 11 assert html =~ "mobile-nav-dropdown-panel" 12 - assert html =~ ~s(href="/agents") 12 + assert html =~ ~s(href="/gardens") 13 13 assert html =~ ~s(href="/seeds") 14 14 assert html =~ ~s(href="/deployments") 15 15 end
+13 -13
apps/sower/test/sower_web/live/agent_live_show_test.exs apps/sower/test/sower_web/live/garden_live_show_test.exs
··· 1 - defmodule SowerWeb.AgentLive.ShowTest do 1 + defmodule SowerWeb.GardenLive.ShowTest do 2 2 use SowerWeb.ConnCase, async: true 3 3 4 4 import Phoenix.LiveViewTest ··· 7 7 8 8 setup [:register_and_log_in_user] 9 9 10 - defp create_agent_with_subscription(user, seed_attrs \\ %{}) do 10 + defp create_garden_with_subscription(user, seed_attrs \\ %{}) do 11 11 Sower.Repo.put_org_id(user.org_id) 12 - agent = agent_fixture() 12 + garden = garden_fixture() 13 13 14 14 seed = seed_fixture(seed_attrs) 15 15 16 16 subscription = 17 17 subscription_fixture(%{ 18 - agent_id: agent.id, 18 + garden_id: garden.id, 19 19 seed_name: seed.name, 20 20 seed_type: seed.seed_type 21 21 }) 22 22 23 - %{agent: agent, subscription: subscription, seed: seed} 23 + %{garden: garden, subscription: subscription, seed: seed} 24 24 end 25 25 26 26 test "shows deploy button when subscription has matching seed", %{conn: conn, user: user} do 27 - %{agent: agent} = create_agent_with_subscription(user) 27 + %{garden: garden} = create_garden_with_subscription(user) 28 28 29 - {:ok, show_live, _html} = live(conn, ~p"/agents/#{agent}") 29 + {:ok, show_live, _html} = live(conn, ~p"/gardens/#{garden}") 30 30 31 31 assert has_element?(show_live, "button", "Deploy") 32 32 end ··· 36 36 user: user 37 37 } do 38 38 Sower.Repo.put_org_id(user.org_id) 39 - agent = agent_fixture() 39 + garden = garden_fixture() 40 40 41 41 subscription_fixture(%{ 42 - agent_id: agent.id, 42 + garden_id: garden.id, 43 43 seed_name: "nonexistent-seed", 44 44 seed_type: "nixos" 45 45 }) 46 46 47 - {:ok, show_live, _html} = live(conn, ~p"/agents/#{agent}") 47 + {:ok, show_live, _html} = live(conn, ~p"/gardens/#{garden}") 48 48 49 49 refute has_element?(show_live, "button", "Deploy") 50 50 end 51 51 52 52 test "clicking deploy triggers deployment and redirects", %{conn: conn, user: user} do 53 - %{agent: agent, subscription: subscription} = create_agent_with_subscription(user) 53 + %{garden: garden, subscription: subscription} = create_garden_with_subscription(user) 54 54 55 - {:ok, show_live, _html} = live(conn, ~p"/agents/#{agent}") 55 + {:ok, show_live, _html} = live(conn, ~p"/gardens/#{garden}") 56 56 57 57 show_live 58 58 |> element("button[phx-value-subscription_sid=\"#{subscription.sid}\"]", "Deploy") ··· 61 61 # The deployment is async - wait for PubSub broadcast to trigger redirect 62 62 deployment = 63 63 eventually(fn -> 64 - [d | _] = Sower.Orchestration.list_deployments(agent, limit: 1) 64 + [d | _] = Sower.Orchestration.list_deployments(garden, limit: 1) 65 65 d 66 66 end) 67 67
+8 -8
apps/sower/test/sower_web/live/deployment_live_index_test.exs
··· 8 8 9 9 test "shows retry button only for terminal deployments", %{conn: conn, user: user} do 10 10 Sower.Repo.put_org_id(user.org_id) 11 - agent = agent_fixture() 11 + garden = garden_fixture() 12 12 13 13 retryable = 14 14 deployment_fixture(%{ 15 - agent_id: agent.id, 15 + garden_id: garden.id, 16 16 result: :success, 17 17 state: :completed, 18 18 deployed_at: DateTime.utc_now() ··· 20 20 21 21 not_retryable = 22 22 deployment_fixture(%{ 23 - agent_id: agent.id, 23 + garden_id: garden.id, 24 24 result: nil, 25 25 state: :dispatched, 26 26 deployed_at: nil ··· 34 34 35 35 test "creates retry deployment from index retry button", %{conn: conn, user: user} do 36 36 Sower.Repo.put_org_id(user.org_id) 37 - agent = agent_fixture() 37 + garden = garden_fixture() 38 38 39 39 deployment = 40 40 deployment_fixture(%{ 41 - agent_id: agent.id, 41 + garden_id: garden.id, 42 42 result: :failure, 43 43 state: :completed, 44 44 deployed_at: DateTime.utc_now() ··· 57 57 58 58 test "shows error when retry submission fails", %{conn: conn, user: user} do 59 59 Sower.Repo.put_org_id(user.org_id) 60 - agent = agent_fixture() 60 + garden = garden_fixture() 61 61 62 62 deployment = 63 63 deployment_fixture(%{ 64 - agent_id: agent.id, 64 + garden_id: garden.id, 65 65 result: :success, 66 66 state: :completed, 67 67 deployed_at: DateTime.utc_now() 68 68 }) 69 69 70 70 deployment_fixture(%{ 71 - agent_id: agent.id, 71 + garden_id: garden.id, 72 72 parent_deployment_id: deployment.id, 73 73 retry_ordinal: 1, 74 74 retried_by_user_id: user.id,
+19 -19
apps/sower/test/sower_web/live/deployment_live_show_test.exs
··· 13 13 test "renders seed deployment logs and toggles inline log panel", %{conn: conn, user: user} do 14 14 Sower.Repo.put_org_id(user.org_id) 15 15 16 - agent = agent_fixture() 16 + garden = garden_fixture() 17 17 seed = seed_fixture() 18 18 19 19 deployment = 20 20 deployment_fixture(%{ 21 - agent_id: agent.id, 21 + garden_id: garden.id, 22 22 seeds: [seed], 23 23 subscriptions: [] 24 24 }) ··· 54 54 test "shows empty state when deployment has no seeds", %{conn: conn, user: user} do 55 55 Sower.Repo.put_org_id(user.org_id) 56 56 57 - agent = agent_fixture() 57 + garden = garden_fixture() 58 58 59 59 deployment = 60 60 deployment_fixture(%{ 61 - agent_id: agent.id, 61 + garden_id: garden.id, 62 62 seeds: [], 63 63 subscriptions: [] 64 64 }) ··· 71 71 72 72 test "subscribes to per-deployment topic on mount", %{conn: conn, user: user} do 73 73 Sower.Repo.put_org_id(user.org_id) 74 - agent = agent_fixture() 74 + garden = garden_fixture() 75 75 76 76 current_deployment = 77 77 deployment_fixture(%{ 78 - agent_id: agent.id, 78 + garden_id: garden.id, 79 79 result: nil, 80 80 state: :dispatched, 81 81 deployed_at: nil ··· 83 83 84 84 other_deployment = 85 85 deployment_fixture(%{ 86 - agent_id: agent.id, 86 + garden_id: garden.id, 87 87 result: nil, 88 88 state: :dispatched, 89 89 deployed_at: nil ··· 104 104 105 105 test "refreshes deployment when update is broadcast", %{conn: conn, user: user} do 106 106 Sower.Repo.put_org_id(user.org_id) 107 - agent = agent_fixture() 107 + garden = garden_fixture() 108 108 109 109 deployment = 110 110 deployment_fixture(%{ 111 - agent_id: agent.id, 111 + garden_id: garden.id, 112 112 result: nil, 113 113 state: :dispatched, 114 114 deployed_at: nil ··· 132 132 133 133 test "cleans up PubSub subscription on LiveView termination", %{conn: conn, user: user} do 134 134 Sower.Repo.put_org_id(user.org_id) 135 - agent = agent_fixture() 135 + garden = garden_fixture() 136 136 137 137 deployment = 138 138 deployment_fixture(%{ 139 - agent_id: agent.id, 139 + garden_id: garden.id, 140 140 result: nil, 141 141 state: :dispatched, 142 142 deployed_at: nil ··· 175 175 176 176 test "shows retry button only for terminal deployments", %{conn: conn, user: user} do 177 177 Sower.Repo.put_org_id(user.org_id) 178 - agent = agent_fixture() 178 + garden = garden_fixture() 179 179 180 180 successful_deployment = 181 181 deployment_fixture(%{ 182 - agent_id: agent.id, 182 + garden_id: garden.id, 183 183 result: :success, 184 184 state: :completed, 185 185 deployed_at: DateTime.utc_now() ··· 187 187 188 188 running_deployment = 189 189 deployment_fixture(%{ 190 - agent_id: agent.id, 190 + garden_id: garden.id, 191 191 result: nil, 192 192 state: :dispatched, 193 193 deployed_at: nil ··· 204 204 205 205 test "creates a retry deployment from the show page", %{conn: conn, user: user} do 206 206 Sower.Repo.put_org_id(user.org_id) 207 - agent = agent_fixture() 207 + garden = garden_fixture() 208 208 209 209 deployment = 210 210 deployment_fixture(%{ 211 - agent_id: agent.id, 211 + garden_id: garden.id, 212 212 result: :success, 213 213 state: :completed, 214 214 deployed_at: DateTime.utc_now() ··· 228 228 229 229 test "shows error when retry is already in progress", %{conn: conn, user: user} do 230 230 Sower.Repo.put_org_id(user.org_id) 231 - agent = agent_fixture() 231 + garden = garden_fixture() 232 232 233 233 parent = 234 234 deployment_fixture(%{ 235 - agent_id: agent.id, 235 + garden_id: garden.id, 236 236 result: :success, 237 237 state: :completed, 238 238 deployed_at: DateTime.utc_now() 239 239 }) 240 240 241 241 deployment_fixture(%{ 242 - agent_id: agent.id, 242 + garden_id: garden.id, 243 243 parent_deployment_id: parent.id, 244 244 retry_ordinal: 1, 245 245 retried_by_user_id: user.id,
+11 -11
apps/sower/test/sower_web/live/subscription_live_show_test.exs
··· 9 9 10 10 defp create_subscription_with_seed(user) do 11 11 Sower.Repo.put_org_id(user.org_id) 12 - agent = agent_fixture() 12 + garden = garden_fixture() 13 13 seed = seed_fixture() 14 14 15 15 subscription = 16 16 subscription_fixture(%{ 17 - agent_id: agent.id, 17 + garden_id: garden.id, 18 18 seed_name: seed.name, 19 19 seed_type: seed.seed_type 20 20 }) 21 21 22 - %{agent: agent, subscription: subscription, seed: seed} 22 + %{garden: garden, subscription: subscription, seed: seed} 23 23 end 24 24 25 25 test "shows deploy button when subscription matches latest seed", %{conn: conn, user: user} do 26 - %{agent: agent, subscription: subscription} = create_subscription_with_seed(user) 26 + %{garden: garden, subscription: subscription} = create_subscription_with_seed(user) 27 27 28 28 {:ok, show_live, _html} = 29 - live(conn, ~p"/agents/#{agent}/subscriptions/#{subscription}") 29 + live(conn, ~p"/gardens/#{garden}/subscriptions/#{subscription}") 30 30 31 31 assert has_element?(show_live, "button", "Deploy") 32 32 end 33 33 34 34 test "does not show deploy button when no matching seed", %{conn: conn, user: user} do 35 35 Sower.Repo.put_org_id(user.org_id) 36 - agent = agent_fixture() 36 + garden = garden_fixture() 37 37 38 38 subscription = 39 39 subscription_fixture(%{ 40 - agent_id: agent.id, 40 + garden_id: garden.id, 41 41 seed_name: "nonexistent-seed", 42 42 seed_type: "nixos" 43 43 }) 44 44 45 45 {:ok, show_live, _html} = 46 - live(conn, ~p"/agents/#{agent}/subscriptions/#{subscription}") 46 + live(conn, ~p"/gardens/#{garden}/subscriptions/#{subscription}") 47 47 48 48 refute has_element?(show_live, "button", "Deploy") 49 49 end 50 50 51 51 test "clicking deploy triggers deployment and redirects", %{conn: conn, user: user} do 52 - %{agent: agent, subscription: subscription} = create_subscription_with_seed(user) 52 + %{garden: garden, subscription: subscription} = create_subscription_with_seed(user) 53 53 54 54 {:ok, show_live, _html} = 55 - live(conn, ~p"/agents/#{agent}/subscriptions/#{subscription}") 55 + live(conn, ~p"/gardens/#{garden}/subscriptions/#{subscription}") 56 56 57 57 show_live 58 58 |> element("button", "Deploy") ··· 60 60 61 61 deployment = 62 62 eventually(fn -> 63 - [d | _] = Sower.Orchestration.list_deployments(agent, limit: 1) 63 + [d | _] = Sower.Orchestration.list_deployments(garden, limit: 1) 64 64 d 65 65 end) 66 66
+11 -11
apps/sower/test/support/fixtures/orchestration_fixtures.ex
··· 5 5 """ 6 6 7 7 @doc """ 8 - Generate a agent. 8 + Generate a garden. 9 9 """ 10 - def agent_fixture(attrs \\ %{}) do 11 - {:ok, agent} = 10 + def garden_fixture(attrs \\ %{}) do 11 + {:ok, garden} = 12 12 attrs 13 13 |> Enum.into(%{ 14 - name: SowerClient.Sid.generate("agent"), 14 + name: SowerClient.Sid.generate("grdn"), 15 15 local_sid: "some local_sid", 16 16 sid: "some sid" 17 17 }) 18 - |> Sower.Orchestration.create_agent() 18 + |> Sower.Orchestration.create_garden() 19 19 20 - agent 20 + garden 21 21 end 22 22 23 23 @doc """ ··· 54 54 end 55 55 56 56 @doc """ 57 - Generate an agent_seed_generation. 57 + Generate a garden_seed_generation. 58 58 """ 59 - def agent_seed_generation_fixture(attrs \\ %{}) do 60 - alias Sower.Orchestration.AgentSeedGeneration 59 + def garden_seed_generation_fixture(attrs \\ %{}) do 60 + alias Sower.Orchestration.GardenSeedGeneration 61 61 62 62 attrs = 63 63 attrs ··· 68 68 created_at_generation: DateTime.utc_now() 69 69 }) 70 70 71 - %AgentSeedGeneration{} 72 - |> AgentSeedGeneration.changeset(attrs) 71 + %GardenSeedGeneration{} 72 + |> GardenSeedGeneration.changeset(attrs) 73 73 |> Sower.Repo.insert!() 74 74 end 75 75 end
apps/sower_agent/.formatter.exs apps/garden/.formatter.exs
+1 -1
apps/sower_agent/.gitignore apps/garden/.gitignore
··· 17 17 *.ez 18 18 19 19 # Ignore package tarball (built via "mix hex.build"). 20 - sower_agent-*.tar 20 + garden-*.tar 21 21 22 22 # Temporary files, for example, from tests. 23 23 /tmp/
-21
apps/sower_agent/README.md
··· 1 - # SowerAgent 2 - 3 - **TODO: Add description** 4 - 5 - ## Installation 6 - 7 - If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 - by adding `sower_agent` to your list of dependencies in `mix.exs`: 9 - 10 - ```elixir 11 - def deps do 12 - [ 13 - {:sower_agent, "~> 0.1.0"} 14 - ] 15 - end 16 - ``` 17 - 18 - Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 - and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 - be found at <https://hexdocs.pm/sower_agent>. 21 -
+7 -7
apps/sower_agent/lib/sower_agent.ex apps/garden/lib/garden.ex
··· 1 - defmodule SowerAgent do 1 + defmodule Garden do 2 2 @moduledoc """ 3 - Public API for the SowerAgent application. 3 + Public API for the Garden application. 4 4 """ 5 5 6 - alias SowerAgent.Client 6 + alias Garden.Socket 7 7 8 8 @doc """ 9 - Request a reload of the agent service. 9 + Request a reload of the garden service. 10 10 11 11 This is a fire-and-forget operation that sets a flag. The reload will be 12 12 executed at the end of the current deployment (if one is in progress) or ··· 14 14 15 15 Intended to be called via RPC by ExecReload: 16 16 17 - sower-agent rpc "SowerAgent.request_reload()" 17 + sower-garden rpc "Garden.request_reload()" 18 18 19 19 """ 20 20 def request_reload() do 21 21 :persistent_term.put(:sower_pending_reload, true) 22 - send(SowerAgent.Client, :check_pending_reload) 22 + send(Garden.Socket, :check_pending_reload) 23 23 :ok 24 24 end 25 25 ··· 37 37 end 38 38 end 39 39 40 - defdelegate reload_agent_service, to: Client 40 + defdelegate reload_garden_service, to: Socket 41 41 end
+2 -2
apps/sower_agent/lib/sower_agent/activator_client.ex apps/garden/lib/garden/activator_client.ex
··· 1 - defmodule SowerAgent.ActivatorClient do 1 + defmodule Garden.ActivatorClient do 2 2 @moduledoc """ 3 3 Client for communicating with sower-activator via Unix domain socket. 4 4 ··· 184 184 {:ok, response} 185 185 186 186 {:ok, _data} -> 187 - {:error, :missing_required_fields} 187 + {:error, :missing_required_gardens} 188 188 189 189 {:error, reason} -> 190 190 {:error, {:json_decode_failed, reason}}
+4 -4
apps/sower_agent/lib/sower_agent/admin.ex apps/garden/lib/garden/admin.ex
··· 1 - defmodule SowerAgent.Admin do 1 + defmodule Garden.Admin do 2 2 @moduledoc """ 3 - Admin tools for the agent. Useful on local repl 3 + Admin tools for the garden. Useful on local repl 4 4 """ 5 5 6 6 require Logger ··· 8 8 import SowerClient.Seed, only: [is_seed_type?: 1] 9 9 10 10 def subs(seed_type) do 11 - SowerAgent.Storage.read().subscriptions 11 + Garden.Storage.read().subscriptions 12 12 |> Enum.filter(&(&1.seed_type == seed_type)) 13 13 end 14 14 ··· 29 29 {:error, :subscription_not_found} 30 30 31 31 [sub] -> 32 - SowerAgent.Client.deploy(sub, force: force?) 32 + Garden.Socket.deploy(sub, force: force?) 33 33 34 34 [_ | _] -> 35 35 Logger.error(msg: "too many nixos subscriptions found")
+7 -7
apps/sower_agent/lib/sower_agent/application.ex apps/garden/lib/garden/application.ex
··· 1 - defmodule SowerAgent.Application do 1 + defmodule Garden.Application do 2 2 # See https://hexdocs.pm/elixir/Application.html 3 3 # for more information on OTP Applications 4 4 @moduledoc false ··· 7 7 8 8 @impl true 9 9 def start(_type, _args) do 10 - config = SowerAgent.Config.get() 10 + config = Garden.Config.get() 11 11 12 12 # Only start client-related processes if endpoint is configured 13 13 client_children = 14 14 if config && config.endpoint do 15 15 [ 16 - SowerAgent.Scheduler, 17 - {SowerAgent.Client, []} 16 + Garden.Scheduler, 17 + {Garden.Socket, []} 18 18 ] 19 19 else 20 20 [] ··· 22 22 23 23 children = 24 24 [ 25 - {SowerAgent.Storage, []}, 26 - {Task.Supervisor, name: SowerAgent.TaskSupervisor}, 25 + {Garden.Storage, []}, 26 + {Task.Supervisor, name: Garden.TaskSupervisor}, 27 27 :systemd.ready() 28 28 ] ++ client_children 29 29 30 30 # See https://hexdocs.pm/elixir/Supervisor.html 31 31 # for other strategies and supported options 32 - opts = [strategy: :one_for_one, name: SowerAgent.Supervisor] 32 + opts = [strategy: :one_for_one, name: Garden.Supervisor] 33 33 Supervisor.start_link(children, opts) 34 34 end 35 35 end
+1 -1
apps/sower_agent/lib/sower_agent/channel_client.ex apps/garden/lib/garden/channel_client.ex
··· 1 - defmodule SowerAgent.ChannelClient do 1 + defmodule Garden.ChannelClient do 2 2 @moduledoc """ 3 3 Shared Slipstream client helpers. 4 4
+38 -38
apps/sower_agent/lib/sower_agent/client.ex apps/garden/lib/garden/socket.ex
··· 1 - defmodule SowerAgent.Client do 2 - use SowerAgent.ChannelClient, lobby_topic: "agent:lobby" 1 + defmodule Garden.Socket do 2 + use Garden.ChannelClient, lobby_topic: "garden:lobby" 3 3 4 4 require Logger 5 5 6 - alias SowerAgent.Scheduler 7 - alias SowerAgent.Storage 6 + alias Garden.Scheduler 7 + alias Garden.Storage 8 8 alias SowerClient.Orchestration.DeploymentStatus 9 9 alias SowerClient.Orchestration.SeedDeploymentStatus 10 10 ··· 35 35 storage = Storage.read() 36 36 subscriptions = Map.get(storage, :subscriptions, []) 37 37 38 - report = SowerAgent.Profile.collect_profiles_for_subscriptions(subscriptions) 38 + report = Garden.Profile.collect_profiles_for_subscriptions(subscriptions) 39 39 40 40 if not Enum.empty?(subscriptions) and Enum.empty?(report.profiles) do 41 41 Logger.debug( ··· 52 52 ) 53 53 54 54 topic = private_channel(socket) 55 - {:ok, _ref} = push(socket, topic, "agent:seeds:report", report) 55 + {:ok, _ref} = push(socket, topic, "garden:seeds:report", report) 56 56 57 57 {:noreply, socket} 58 58 end ··· 66 66 67 67 @impl Slipstream 68 68 def handle_cast(:sync_subscriptions, socket) do 69 - config_subscriptions = SowerAgent.Config.get().subscriptions 69 + config_subscriptions = Garden.Config.get().subscriptions 70 70 sync_payload = %{subscriptions: Enum.map(config_subscriptions, &Map.from_struct/1)} 71 71 72 72 topic = private_channel(socket) ··· 82 82 |> Map.new(&{{&1.seed_name, &1.seed_type}, &1.sid}) 83 83 84 84 # Merge server-assigned sids back into original subscriptions 85 - # to preserve agent-only fields like schedule and poll_on_connect 85 + # to preserve garden-only gardens like schedule and poll_on_connect 86 86 Enum.map(config_subscriptions, fn sub -> 87 87 case Map.get(sid_map, {sub.seed_name, sub.seed_type}) do 88 88 nil -> nil ··· 98 98 99 99 Scheduler.refresh_subscriptions(subscriptions) 100 100 101 - SowerAgent.Storage.put(:subscriptions, subscriptions) 101 + Garden.Storage.put(:subscriptions, subscriptions) 102 102 103 103 subscriptions 104 104 |> Enum.filter(& &1.poll_on_connect) 105 105 |> Enum.each(fn sub -> 106 - Task.Supervisor.start_child(SowerAgent.TaskSupervisor, fn -> 106 + Task.Supervisor.start_child(Garden.TaskSupervisor, fn -> 107 107 deploy(sub) 108 108 end) 109 109 end) ··· 128 128 |> Keyword.get(:uri) 129 129 |> Map.put( 130 130 :query, 131 - "token=#{Base.encode64(Application.fetch_env!(:sower_agent, :config).access_token)}" 131 + "token=#{Base.encode64(Application.fetch_env!(:garden, :config).access_token)}" 132 132 ) 133 133 |> URI.to_string() 134 134 ··· 156 156 {:ok, hello_ref} = 157 157 push_message( 158 158 socket, 159 - SowerClient.AgentHello.cast!(%{ 160 - name: SowerAgent.Config.get().name, 161 - local_sid: SowerAgent.Storage.read().local_sid, 162 - agent_sid: SowerAgent.Storage.read().agent_sid 159 + SowerClient.GardenHello.cast!(%{ 160 + name: Garden.Config.get().name, 161 + local_sid: Garden.Storage.read().local_sid, 162 + garden_sid: Garden.Storage.read().garden_sid 163 163 }) 164 164 ) 165 165 ··· 172 172 end 173 173 174 174 @impl Slipstream 175 - def handle_join("agent:" <> _sid = topic, %{"conn_sid" => conn_sid}, socket) do 175 + def handle_join("garden:" <> _sid = topic, %{"conn_sid" => conn_sid}, socket) do 176 176 Logger.info(msg: "Joined channel topic", topic: topic, conn_sid: conn_sid) 177 177 178 178 cast(:sync_subscriptions) ··· 188 188 end 189 189 190 190 def handle_message( 191 - "agent:" <> topic, 191 + "garden:" <> topic, 192 192 "deployment", 193 193 payload, 194 - %{assigns: %{agent_sid: agent_sid}} = socket 194 + %{assigns: %{garden_sid: garden_sid}} = socket 195 195 ) 196 - when topic == agent_sid do 196 + when topic == garden_sid do 197 197 case SowerClient.Orchestration.Deployment.cast(payload) do 198 198 {:ok, %{skipped: true} = deployment} -> 199 199 Logger.info( ··· 233 233 end 234 234 235 235 def handle_message( 236 - "agent:" <> topic, 236 + "garden:" <> topic, 237 237 "deployment:error", 238 238 payload, 239 - %{assigns: %{agent_sid: agent_sid}} = socket 239 + %{assigns: %{garden_sid: garden_sid}} = socket 240 240 ) 241 - when topic == agent_sid do 241 + when topic == garden_sid do 242 242 Logger.error( 243 243 msg: "Deployment request failed", 244 244 request_id: payload["request_id"], ··· 256 256 @impl Slipstream 257 257 def handle_reply( 258 258 ref, 259 - {:ok, %{"sid" => agent_sid} = agent}, 259 + {:ok, %{"sid" => garden_sid} = reply}, 260 260 socket 261 261 ) 262 262 when ref == socket.assigns.hello_ref do 263 - Logger.debug(msg: "Received hello reply", agent: agent) 264 - storage = SowerAgent.Storage.read() 263 + Logger.debug(msg: "Received hello reply", garden: reply) 264 + storage = Garden.Storage.read() 265 265 266 - if storage.agent_sid != agent_sid do 267 - storage |> Map.put(:agent_sid, agent_sid) |> SowerAgent.Storage.write() 266 + if storage.garden_sid != garden_sid do 267 + storage |> Map.put(:garden_sid, garden_sid) |> Garden.Storage.write() 268 268 end 269 269 270 270 socket = 271 271 socket 272 - |> join("agent:#{agent_sid}", %{local_sid: storage.local_sid}) 272 + |> join("garden:#{garden_sid}", %{local_sid: storage.local_sid}) 273 273 |> Map.put( 274 274 :assigns, 275 - socket.assigns |> Map.delete(:hello_ref) |> Map.put(:agent_sid, agent_sid) 275 + socket.assigns |> Map.delete(:hello_ref) |> Map.put(:garden_sid, garden_sid) 276 276 ) 277 277 278 278 {:ok, socket} ··· 304 304 }) 305 305 ) 306 306 307 - Task.Supervisor.start_child(SowerAgent.TaskSupervisor, fn -> 307 + Task.Supervisor.start_child(Garden.TaskSupervisor, fn -> 308 308 result = 309 309 try do 310 - SowerAgent.Deployer.run(deployment) 310 + Garden.Deployer.run(deployment) 311 311 rescue 312 312 error -> 313 313 Logger.error( ··· 364 364 365 365 def handle_info(:check_pending_reload, socket) do 366 366 if map_size(socket.active_deployments) == 0 do 367 - if SowerAgent.take_pending_reload(), do: reload_agent_service() 367 + if Garden.take_pending_reload(), do: reload_garden_service() 368 368 end 369 369 370 370 {:noreply, socket} 371 371 end 372 372 373 373 def private_channel(_socket) do 374 - "agent:#{Storage.read().agent_sid}" 374 + "garden:#{Storage.read().garden_sid}" 375 375 end 376 376 377 - def reload_agent_service do 378 - Logger.info(msg: "Restarting sower-agent service") 377 + def reload_garden_service do 378 + Logger.info(msg: "Restarting sower-garden service") 379 379 380 380 case System.cmd( 381 381 "busctl", ··· 386 386 "org.freedesktop.systemd1.Manager", 387 387 "RestartUnit", 388 388 "ss", 389 - "sower-agent.service", 389 + "sower-garden.service", 390 390 "replace" 391 391 ], 392 392 stderr_to_stdout: true 393 393 ) do 394 394 {output, 0} -> 395 - Logger.debug(msg: "Successfully restarted sower-agent service") 395 + Logger.debug(msg: "Successfully restarted sower-garden service") 396 396 {:ok, output} 397 397 398 398 {error, _code} -> 399 - Logger.error(msg: "Failed to restart sower-agent via busctl", error: error) 399 + Logger.error(msg: "Failed to restart sower-garden via busctl", error: error) 400 400 {:error, error} 401 401 end 402 402 end
+11 -11
apps/sower_agent/lib/sower_agent/config.ex apps/garden/lib/garden/config.ex
··· 1 - defmodule SowerAgent.Config do 1 + defmodule Garden.Config do 2 2 @moduledoc """ 3 - Agent configuration management. 3 + Garden configuration management. 4 4 """ 5 5 6 6 require Logger 7 7 8 - @app :sower_agent 8 + @app :garden 9 9 10 10 def get do 11 11 Application.get_env(@app, :config) ··· 32 32 end 33 33 34 34 def reload do 35 - Application.put_env(:sower_agent, :config, load(%{})) 36 - Application.stop(:sower_agent) 37 - Application.start(:sower_agent) 35 + Application.put_env(:garden, :config, load(%{})) 36 + Application.stop(:garden) 37 + Application.start(:garden) 38 38 end 39 39 40 40 defp validate_required!(%SowerClient.Config{} = config) do ··· 75 75 :path, 76 76 case uri.path do 77 77 nil -> 78 - "/agent/websocket" 78 + "/garden/websocket" 79 79 80 80 p when is_binary(p) -> 81 - if String.ends_with?(p, "agent/websocket") do 81 + if String.ends_with?(p, "garden/websocket") do 82 82 p 83 83 else 84 - p <> "/agent/websocket" 84 + p <> "/garden/websocket" 85 85 end 86 86 end 87 87 ) 88 88 89 - Application.put_env(SowerAgent.Client, :uri, uri) 90 - Application.put_env(SowerAgent.Client, :reconnect_after_msec, [200, 500, 1_000, 2_000]) 89 + Application.put_env(Garden.Socket, :uri, uri) 90 + Application.put_env(Garden.Socket, :reconnect_after_msec, [200, 500, 1_000, 2_000]) 91 91 end 92 92 93 93 # Expand state_directory path
+13 -13
apps/sower_agent/lib/sower_agent/deployer.ex apps/garden/lib/garden/deployer.ex
··· 1 - defmodule SowerAgent.Deployer do 1 + defmodule Garden.Deployer do 2 2 require Logger 3 3 4 - alias SowerAgent.Client 5 - alias SowerAgent.Config 6 - alias SowerAgent.Storage 4 + alias Garden.Socket 5 + alias Garden.Config 6 + alias Garden.Storage 7 7 alias SowerClient.Activator 8 8 alias SowerClient.Orchestration.Deployment 9 9 alias SowerClient.Orchestration.DeploymentProfile ··· 70 70 get_deployment_profile_fun = 71 71 Keyword.get(opts, :get_deployment_profile_fun, &get_deployment_profile/1) 72 72 73 - activate_seed_fun = Keyword.get(opts, :activate_seed_fun, &SowerAgent.Seed.activate/2) 73 + activate_seed_fun = Keyword.get(opts, :activate_seed_fun, &Garden.Seed.activate/2) 74 74 75 75 report_seed_result_fun = 76 76 Keyword.get(opts, :report_seed_result_fun, &report_seed_result/4) ··· 93 93 |> async_stream_fun.(fn 94 94 {:ok, {:ok, %SeedDeployment{seed: seed} = seed_deploy}} -> 95 95 profile = get_deployment_profile_fun.(seed_deploy.subscription_sid) 96 - mode = SowerAgent.Seed.activation_mode(profile) 96 + mode = Garden.Seed.activation_mode(profile) 97 97 98 98 preamble = [ 99 99 decision_line("realized #{seed.name} (#{seed.seed_type})"), ··· 213 213 end 214 214 215 215 def async_stream(enumerable, func) do 216 - Task.Supervisor.async_stream_nolink(SowerAgent.TaskSupervisor, enumerable, func, 216 + Task.Supervisor.async_stream_nolink(Garden.TaskSupervisor, enumerable, func, 217 217 max_concurrency: 3, 218 218 # 5 minutes 219 219 timeout: 5 * 60_000 ··· 295 295 296 296 activation_enabled_fun = 297 297 Keyword.get(opts, :activation_enabled_fun, fn -> 298 - Application.get_env(:sower_agent, :enable_activation, true) 298 + Application.get_env(:garden, :enable_activation, true) 299 299 end) 300 300 301 301 if Enum.any?(deployment.seed_deployments, &(get_in(&1.seed.seed_type) == "nixos")) do ··· 395 395 396 396 Enum.any?(profiles, fn profile -> 397 397 profile.reboot_policy == "when-required" and 398 - SowerAgent.Seed.activation_mode(profile) == "boot" and 398 + Garden.Seed.activation_mode(profile) == "boot" and 399 399 not is_nil(detect_boot_critical_change_reason(read_link)) 400 400 end) -> 401 401 "boot_mode" 402 402 403 403 Enum.any?(profiles, fn profile -> 404 404 profile.reboot_policy == "when-required" and 405 - SowerAgent.Seed.activation_mode(profile) == "switch" 405 + Garden.Seed.activation_mode(profile) == "switch" 406 406 end) -> 407 407 detect_boot_critical_change_reason(read_link) 408 408 ··· 419 419 status: status 420 420 }) 421 421 422 - Client.cast(:seed_status, seed_status) 422 + Socket.cast(:seed_status, seed_status) 423 423 end 424 424 425 425 defp report_seed_result(%Deployment{} = deployment, seed, result, output_lines) do ··· 437 437 log: log 438 438 }) 439 439 440 - case Client.call(SeedDeploymentResult.event(), seed_result, 15_000) do 440 + case Socket.call(SeedDeploymentResult.event(), seed_result, 15_000) do 441 441 :ok -> 442 442 :ok 443 443 ··· 457 457 458 458 def decision_line(message) do 459 459 timestamp = DateTime.utc_now() |> DateTime.to_iso8601() 460 - "#{timestamp} [agent] #{message}" 460 + "#{timestamp} [garden] #{message}" 461 461 end 462 462 463 463 defp strip_ansi(text) do
+19 -19
apps/sower_agent/lib/sower_agent/profile.ex apps/garden/lib/garden/profile.ex
··· 1 - defmodule SowerAgent.Profile do 1 + defmodule Garden.Profile do 2 2 @moduledoc """ 3 3 Collects Nix profile generation data for reporting to the server. 4 4 """ 5 5 6 6 require Logger 7 7 8 - alias SowerClient.Orchestration.{AgentSeedGeneration, AgentSeedProfile, AgentSeedsReport} 8 + alias SowerClient.Orchestration.{GardenSeedGeneration, GardenSeedProfile, GardenSeedsReport} 9 9 10 10 @doc """ 11 11 Collects profiles based on the provided targets. ··· 14 14 - `%{type: "nixos", path: "/nix/var/nix/profiles/system"}` 15 15 - `%{type: "home-manager", path: "/home/user/.local/state/nix/profiles/home-manager"}` 16 16 17 - Returns an AgentSeedsReport struct containing only the requested profiles. 17 + Returns an GardenSeedsReport struct containing only the requested profiles. 18 18 Profiles that fail to load are logged as warnings and excluded from the report. 19 19 20 20 ## Examples 21 21 22 22 iex> targets = [%{type: "nixos", path: "/nix/var/nix/profiles/system"}] 23 - iex> SowerAgent.Profile.collect_profiles(targets) 24 - %SowerClient.Orchestration.AgentSeedsReport{profiles: [...]} 23 + iex> Garden.Profile.collect_profiles(targets) 24 + %SowerClient.Orchestration.GardenSeedsReport{profiles: [...]} 25 25 """ 26 26 def collect_profiles(targets) when is_list(targets) do 27 27 profiles = ··· 30 30 |> Enum.reject(fn {result, _} -> result == :error end) 31 31 |> Enum.map(fn {_, profile} -> profile end) 32 32 33 - AgentSeedsReport.cast!(%{profiles: profiles}) 33 + GardenSeedsReport.cast!(%{profiles: profiles}) 34 34 end 35 35 36 36 @doc """ ··· 43 43 ## Examples 44 44 45 45 iex> subs = [%{seed_type: "nixos", seed_name: "host", rules: []}] 46 - iex> SowerAgent.Profile.build_profile_targets(subs) 46 + iex> Garden.Profile.build_profile_targets(subs) 47 47 [%{type: "nixos", path: "/nix/var/nix/profiles/system"}] 48 48 49 49 iex> subs = [%{seed_type: "home-manager", seed_name: "alice@host", rules: [%{key: "username", value: "alice"}]}] 50 - iex> SowerAgent.Profile.build_profile_targets(subs) 50 + iex> Garden.Profile.build_profile_targets(subs) 51 51 [%{type: "home-manager", path: "/home/alice/.local/state/nix/profiles/home-manager"}] 52 52 """ 53 53 def build_profile_targets(subscriptions) when is_list(subscriptions) do ··· 101 101 ## Examples 102 102 103 103 iex> subs = [%{seed_type: "nixos", seed_name: "host", rules: []}] 104 - iex> SowerAgent.Profile.collect_profiles_for_subscriptions(subs) 105 - %SowerClient.Orchestration.AgentSeedsReport{profiles: [...]} 104 + iex> Garden.Profile.collect_profiles_for_subscriptions(subs) 105 + %SowerClient.Orchestration.GardenSeedsReport{profiles: [...]} 106 106 107 - iex> SowerAgent.Profile.collect_profiles_for_subscriptions([]) 108 - %SowerClient.Orchestration.AgentSeedsReport{profiles: []} 107 + iex> Garden.Profile.collect_profiles_for_subscriptions([]) 108 + %SowerClient.Orchestration.GardenSeedsReport{profiles: []} 109 109 """ 110 110 def collect_profiles_for_subscriptions(subscriptions) when is_list(subscriptions) do 111 111 targets = build_profile_targets(subscriptions) ··· 225 225 226 226 generations = 227 227 state.profiles 228 - |> Enum.map(&to_agent_seed_generation(&1, state.current.path)) 228 + |> Enum.map(&to_garden_seed_generation(&1, state.current.path)) 229 229 230 230 generations = 231 231 if Enum.any?(generations, &(&1.path == state.current.path)) do 232 232 generations 233 233 else 234 - [to_agent_seed_generation(state.current, state.current.path) | generations] 234 + [to_garden_seed_generation(state.current, state.current.path) | generations] 235 235 end 236 236 237 237 {:ok, 238 - AgentSeedProfile.cast!(%{ 238 + GardenSeedProfile.cast!(%{ 239 239 profile_path: profile_path, 240 240 tags: state.tags, 241 241 generations: generations 242 242 })} 243 243 end 244 244 245 - defp to_agent_seed_generation(%Nix.Profile.Generation{} = gen, current_path) do 246 - AgentSeedGeneration.cast!(%{ 245 + defp to_garden_seed_generation(%Nix.Profile.Generation{} = gen, current_path) do 246 + GardenSeedGeneration.cast!(%{ 247 247 path: gen.path, 248 248 link: gen.link, 249 249 created: DateTime.to_iso8601(gen.created), ··· 257 257 258 258 ## Examples 259 259 260 - iex> SowerAgent.Profile.extract_generation_number("/nix/var/nix/profiles/system-42-link") 260 + iex> Garden.Profile.extract_generation_number("/nix/var/nix/profiles/system-42-link") 261 261 42 262 262 263 - iex> SowerAgent.Profile.extract_generation_number("/nix/var/nix/profiles/system") 263 + iex> Garden.Profile.extract_generation_number("/nix/var/nix/profiles/system") 264 264 nil 265 265 """ 266 266 def extract_generation_number(link) do
+5 -5
apps/sower_agent/lib/sower_agent/scheduler.ex apps/garden/lib/garden/scheduler.ex
··· 1 - defmodule SowerAgent.Scheduler do 2 - use Quantum, otp_app: :sower_agent 1 + defmodule Garden.Scheduler do 2 + use Quantum, otp_app: :garden 3 3 4 4 require Logger 5 5 ··· 107 107 end 108 108 109 109 def deploy_if_not_cooled_down(sid, schedule, opts \\ []) do 110 - deploy_fun = Keyword.get(opts, :deploy_fun, &SowerAgent.Client.deploy/1) 111 - check_cooldown = Keyword.get(opts, :check_cooldown, &SowerAgent.Storage.check_cooldown/1) 110 + deploy_fun = Keyword.get(opts, :deploy_fun, &Garden.Socket.deploy/1) 111 + check_cooldown = Keyword.get(opts, :check_cooldown, &Garden.Storage.check_cooldown/1) 112 112 113 113 read_subscriptions = 114 114 Keyword.get(opts, :read_subscriptions, fn -> 115 - SowerAgent.Storage.read().subscriptions || [] 115 + Garden.Storage.read().subscriptions || [] 116 116 end) 117 117 118 118 case check_cooldown.({:schedule, sid}) do
+3 -3
apps/sower_agent/lib/sower_agent/seed.ex apps/garden/lib/garden/seed.ex
··· 1 - defmodule SowerAgent.Seed do 1 + defmodule Garden.Seed do 2 2 alias SowerClient.{Activator, Seed} 3 3 alias SowerClient.Orchestration.DeploymentProfile 4 4 ··· 28 28 end 29 29 30 30 defp run_activation(type, path, opts) do 31 - if Application.get_env(:sower_agent, :enable_activation, true) do 32 - socket_path = Application.get_env(:sower_agent, :activator_socket, @default_socket_path) 31 + if Application.get_env(:garden, :enable_activation, true) do 32 + socket_path = Application.get_env(:garden, :activator_socket, @default_socket_path) 33 33 34 34 on_output = fn line -> 35 35 Logger.debug(activator_output: line)
+29 -12
apps/sower_agent/lib/sower_agent/storage.ex apps/garden/lib/garden/storage.ex
··· 1 - defmodule SowerAgent.Storage do 1 + defmodule Garden.Storage do 2 2 use GenServer 3 3 use TypedStruct 4 4 5 5 require Logger 6 6 7 - @derive {Jason.Encoder, only: [:local_sid, :agent_sid]} 7 + @derive {Jason.Encoder, only: [:local_sid, :garden_sid]} 8 8 9 9 typedstruct do 10 10 field :local_sid, String.t() 11 - field :agent_sid, String.t() 11 + field :garden_sid, String.t() 12 12 13 13 field :subscriptions, list(SowerClient.Orchestration.Subscription) 14 14 end ··· 17 17 18 18 # client 19 19 20 - def get(field) do 21 - GenServer.call(__MODULE__, {:get, field}) 20 + def get(garden) do 21 + GenServer.call(__MODULE__, {:get, garden}) 22 22 end 23 23 24 - def put(field, value) do 25 - GenServer.call(__MODULE__, {:put, field, value}) 24 + def put(garden, value) do 25 + GenServer.call(__MODULE__, {:put, garden, value}) 26 26 end 27 27 28 28 def write(struct) do ··· 45 45 46 46 @impl GenServer 47 47 def init(_opts) do 48 - state_dir = SowerAgent.Config.get().state_directory 48 + state_dir = Garden.Config.get().state_directory 49 49 file = Path.join(state_dir, "storage.etf") 50 50 51 51 if not File.exists?(file) do ··· 63 63 Logger.debug(msg: "Reading storage", file: file) 64 64 {:ok, bin} = File.read(file) 65 65 66 - data = :erlang.binary_to_term(bin) 66 + raw = :erlang.binary_to_term(bin) 67 + data = migrate_agent_sid(raw) 68 + 69 + if data != raw do 70 + File.write!(file, :erlang.term_to_binary(data)) 71 + Logger.debug(msg: "Persisted migrated storage", file: file) 72 + end 67 73 68 74 {:ok, %{file: file, data: data, cooldowns: %{}}} 69 75 end ··· 79 85 end 80 86 81 87 @impl GenServer 82 - def handle_call({:put, field, value}, _from, state) do 83 - new_data = Map.put(state.data, field, value) 88 + def handle_call({:put, garden, value}, _from, state) do 89 + new_data = Map.put(state.data, garden, value) 84 90 {:reply, :ok, do_write(new_data, state)} 85 91 end 86 92 ··· 103 109 %{state | data: data} 104 110 end 105 111 112 + defp migrate_agent_sid(%{agent_sid: sid} = data) when is_binary(sid) do 113 + Logger.info(msg: "Migrating agent_sid to garden_sid", garden_sid: sid) 114 + 115 + data 116 + |> Map.delete(:agent_sid) 117 + |> Map.put(:garden_sid, sid) 118 + |> then(&struct!(__MODULE__, Map.take(&1, [:local_sid, :garden_sid, :subscriptions]))) 119 + end 120 + 121 + defp migrate_agent_sid(%__MODULE__{} = data), do: data 122 + 106 123 defp default() do 107 124 %__MODULE__{ 108 - local_sid: SowerClient.Sid.generate("loc_agent") 125 + local_sid: SowerClient.Sid.generate("loc_garden") 109 126 } 110 127 end 111 128 end
+3 -3
apps/sower_agent/mix.exs apps/garden/mix.exs
··· 1 - defmodule SowerAgent.MixProject do 1 + defmodule Garden.MixProject do 2 2 use Mix.Project 3 3 4 4 def project do 5 5 [ 6 - app: :sower_agent, 6 + app: :garden, 7 7 build_path: "../../_build", 8 8 config_path: "../../config/config.exs", 9 9 deps: deps(), ··· 20 20 def application do 21 21 [ 22 22 extra_applications: [:logger], 23 - mod: {SowerAgent.Application, []} 23 + mod: {Garden.Application, []} 24 24 ] 25 25 end 26 26
+6 -6
apps/sower_agent/test/sower_agent/activator_client_test.exs apps/garden/test/garden/activator_client_test.exs
··· 1 - defmodule SowerAgent.ActivatorClientTest do 1 + defmodule Garden.ActivatorClientTest do 2 2 use ExUnit.Case, async: true 3 3 4 4 import ExUnit.CaptureLog 5 5 6 - alias SowerAgent.ActivatorClient 7 - alias SowerAgent.ActivatorClient.{Request, Response} 6 + alias Garden.ActivatorClient 7 + alias Garden.ActivatorClient.{Request, Response} 8 8 9 9 describe "Request struct" do 10 - test "creates request with required fields" do 10 + test "creates request with required gardens" do 11 11 request = %Request{id: "abc123", type: "nixos", path: "/nix/store/xyz"} 12 12 assert request.id == "abc123" 13 13 assert request.type == "nixos" ··· 22 22 end 23 23 24 24 describe "Response struct" do 25 - test "creates response with required fields" do 25 + test "creates response with required gardens" do 26 26 response = %Response{id: "abc123", type: "output"} 27 27 assert response.id == "abc123" 28 28 assert response.type == "output" ··· 30 30 assert response.exit_code == nil 31 31 end 32 32 33 - test "creates response with optional fields" do 33 + test "creates response with optional gardens" do 34 34 response = %Response{id: "abc123", type: "complete", data: "done", exit_code: 0} 35 35 assert response.data == "done" 36 36 assert response.exit_code == 0
+24 -24
apps/sower_agent/test/sower_agent/client_deployment_test.exs apps/garden/test/garden/client_deployment_test.exs
··· 1 - defmodule SowerAgent.ClientDeploymentTest do 1 + defmodule Garden.SocketDeploymentTest do 2 2 @moduledoc """ 3 3 Tests for deployment message handling and duplicate suppression. 4 4 """ 5 5 use ExUnit.Case, async: false 6 6 7 - alias SowerAgent.Client 7 + alias Garden.Socket 8 8 9 9 # Mock socket for testing handle_message callbacks 10 10 defmodule MockSocket do 11 11 defstruct [:assigns, :active_deployments] 12 12 13 - def new(agent_sid \\ "test_agent_123") do 13 + def new(garden_sid \\ "test_garden_123") do 14 14 %__MODULE__{ 15 - assigns: %{agent_sid: agent_sid}, 15 + assigns: %{garden_sid: garden_sid}, 16 16 active_deployments: %{} 17 17 } 18 18 end ··· 36 36 payload = Map.from_struct(deployment) 37 37 38 38 {:ok, updated_socket} = 39 - Client.handle_message( 40 - "agent:test_agent_123", 39 + Socket.handle_message( 40 + "garden:test_garden_123", 41 41 "deployment", 42 42 payload, 43 43 socket ··· 61 61 payload = Map.from_struct(deployment) 62 62 63 63 {:ok, updated_socket} = 64 - Client.handle_message( 65 - "agent:test_agent_123", 64 + Socket.handle_message( 65 + "garden:test_garden_123", 66 66 "deployment", 67 67 payload, 68 68 socket ··· 96 96 payload2 = Map.from_struct(deployment2) 97 97 98 98 {:ok, socket} = 99 - Client.handle_message( 100 - "agent:test_agent_123", 99 + Socket.handle_message( 100 + "garden:test_garden_123", 101 101 "deployment", 102 102 payload1, 103 103 socket 104 104 ) 105 105 106 106 {:ok, socket} = 107 - Client.handle_message( 108 - "agent:test_agent_123", 107 + Socket.handle_message( 108 + "garden:test_garden_123", 109 109 "deployment", 110 110 payload2, 111 111 socket ··· 127 127 } 128 128 129 129 {:ok, updated_socket} = 130 - Client.handle_message( 131 - "agent:test_agent_123", 130 + Socket.handle_message( 131 + "garden:test_garden_123", 132 132 "deployment:error", 133 133 payload, 134 134 socket ··· 151 151 payload = Map.from_struct(deployment) 152 152 153 153 {:ok, updated_socket} = 154 - Client.handle_message( 155 - "agent:test_agent_123", 154 + Socket.handle_message( 155 + "garden:test_garden_123", 156 156 "deployment", 157 157 payload, 158 158 socket ··· 170 170 } 171 171 172 172 {:ok, _socket} = 173 - Client.handle_message( 174 - "agent:test_agent_123", 173 + Socket.handle_message( 174 + "garden:test_garden_123", 175 175 "deployment", 176 176 payload, 177 177 socket ··· 193 193 } 194 194 195 195 {:ok, socket} = 196 - Client.handle_message( 197 - "agent:test_agent_123", 196 + Socket.handle_message( 197 + "garden:test_garden_123", 198 198 "deployment", 199 199 Map.from_struct(deployment1), 200 200 socket ··· 202 202 203 203 # Try to add duplicate of first - should be ignored 204 204 {:ok, socket} = 205 - Client.handle_message( 206 - "agent:test_agent_123", 205 + Socket.handle_message( 206 + "garden:test_garden_123", 207 207 "deployment", 208 208 Map.from_struct(deployment1), 209 209 socket ··· 218 218 } 219 219 220 220 {:ok, socket} = 221 - Client.handle_message( 222 - "agent:test_agent_123", 221 + Socket.handle_message( 222 + "garden:test_garden_123", 223 223 "deployment", 224 224 Map.from_struct(deployment2), 225 225 socket
+16 -11
apps/sower_agent/test/sower_agent/deployer_test.exs apps/garden/test/garden/deployer_test.exs
··· 1 - defmodule SowerAgent.DeployerTest do 1 + defmodule Garden.DeployerTest do 2 2 use ExUnit.Case, async: true 3 3 4 4 import ExUnit.CaptureLog 5 5 require Logger 6 6 7 - alias SowerAgent.Deployer 7 + alias Garden.Deployer 8 8 alias SowerClient.Orchestration.Deployment 9 9 alias SowerClient.Orchestration.SeedDeployment 10 10 alias SowerClient.Orchestration.DeploymentProfile ··· 70 70 } 71 71 end 72 72 73 - test "keeps defaults for fields not present in resolved profile overrides" do 73 + test "keeps defaults for gardens not present in resolved profile overrides" do 74 74 sid = "sub_partial" 75 75 76 76 sub = %Subscription{ ··· 368 368 end 369 369 370 370 describe "decision_line/1" do 371 - test "formats message with ISO 8601 timestamp and [agent] prefix" do 371 + test "formats message with ISO 8601 timestamp and [garden] prefix" do 372 372 line = Deployer.decision_line("reboot triggered") 373 - assert line =~ ~r/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z \[agent\] reboot triggered$/ 373 + 374 + assert line =~ 375 + ~r/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z \[garden\] reboot triggered$/ 374 376 end 375 377 end 376 378 ··· 385 387 386 388 assert Enum.any?( 387 389 logged_lines, 388 - &(&1 =~ "[agent]" and &1 =~ "realized" and &1 =~ "seed-seed_r1") 390 + &(&1 =~ "[garden]" and &1 =~ "realized" and &1 =~ "seed-seed_r1") 389 391 ) 390 392 end 391 393 ··· 400 402 realize_seed_fun: fn seed_deploy -> {:error, :failed_to_realize, seed_deploy} end 401 403 ) 402 404 403 - assert Enum.any?(logged_lines, &(&1 =~ "[agent]" and &1 =~ "realization failed")) 405 + assert Enum.any?(logged_lines, &(&1 =~ "[garden]" and &1 =~ "realization failed")) 404 406 end 405 407 406 408 test "includes activation mode decision line in log output" do ··· 416 418 end 417 419 ) 418 420 419 - assert Enum.any?(logged_lines, &(&1 =~ "[agent]" and &1 =~ "boot" and &1 =~ "seed-seed_m1")) 421 + assert Enum.any?( 422 + logged_lines, 423 + &(&1 =~ "[garden]" and &1 =~ "boot" and &1 =~ "seed-seed_m1") 424 + ) 420 425 end 421 426 422 427 test "includes reboot decision in last seed log" do ··· 457 462 458 463 assert Enum.any?( 459 464 reboot_lines, 460 - &(&1 =~ "[agent]" and &1 =~ "reboot initiated: policy_always") 465 + &(&1 =~ "[garden]" and &1 =~ "reboot initiated: policy_always") 461 466 ) 462 467 end 463 468 ··· 490 495 assert_received {:seed_result, :failure, _activation_lines} 491 496 # Second call: reboot decision 492 497 assert_received {:seed_result, nil, reboot_lines} 493 - assert Enum.any?(reboot_lines, &(&1 =~ "[agent]" and &1 =~ "reboot skipped")) 498 + assert Enum.any?(reboot_lines, &(&1 =~ "[garden]" and &1 =~ "reboot skipped")) 494 499 end 495 500 496 501 test "includes default activation mode when none configured" do ··· 503 508 504 509 assert Enum.any?( 505 510 logged_lines, 506 - &(&1 =~ "[agent]" and &1 =~ "switch" and &1 =~ "seed-seed_md1") 511 + &(&1 =~ "[garden]" and &1 =~ "switch" and &1 =~ "seed-seed_md1") 507 512 ) 508 513 end 509 514 end
+8 -8
apps/sower_agent/test/sower_agent/profile_test.exs apps/garden/test/garden/profile_test.exs
··· 1 - defmodule SowerAgent.ProfileTest do 1 + defmodule Garden.ProfileTest do 2 2 use ExUnit.Case, async: true 3 3 4 4 alias Nix.Profile.Generation 5 - alias SowerAgent.Profile 6 - alias SowerClient.Orchestration.{AgentSeedGeneration, AgentSeedProfile} 5 + alias Garden.Profile 6 + alias SowerClient.Orchestration.{GardenSeedGeneration, GardenSeedProfile} 7 7 8 8 describe "do_collect_profile/2" do 9 9 test "collects profile with generations from module state" do ··· 32 32 tags: %{hostname: "test-host"} 33 33 }) 34 34 35 - assert {:ok, %AgentSeedProfile{} = result} = 35 + assert {:ok, %GardenSeedProfile{} = result} = 36 36 Profile.do_collect_profile(mock_module, profile_path) 37 37 38 38 assert result.profile_path == profile_path ··· 40 40 assert length(result.generations) == 2 41 41 42 42 [gen1, gen2] = result.generations 43 - assert %AgentSeedGeneration{} = gen1 43 + assert %GardenSeedGeneration{} = gen1 44 44 assert gen1.path == "/nix/store/abc123-nixos-system" 45 45 assert gen1.link == "/nix/var/nix/profiles/system-42-link" 46 46 assert gen1.generation_number == 42 ··· 75 75 tags: %{} 76 76 }) 77 77 78 - assert {:ok, %AgentSeedProfile{} = result} = 78 + assert {:ok, %GardenSeedProfile{} = result} = 79 79 Profile.do_collect_profile(mock_module, profile_path) 80 80 81 81 assert length(result.generations) == 2 ··· 103 103 tags: %{} 104 104 }) 105 105 106 - assert {:ok, %AgentSeedProfile{} = result} = 106 + assert {:ok, %GardenSeedProfile{} = result} = 107 107 Profile.do_collect_profile(mock_module, profile_path) 108 108 109 109 assert length(result.generations) == 1 ··· 128 128 tags: %{} 129 129 }) 130 130 131 - assert {:ok, %AgentSeedProfile{} = result} = 131 + assert {:ok, %GardenSeedProfile{} = result} = 132 132 Profile.do_collect_profile(mock_module, profile_path) 133 133 134 134 assert result.tags == %{}
+2 -2
apps/sower_agent/test/sower_agent/scheduler_test.exs apps/garden/test/garden/scheduler_test.exs
··· 1 - defmodule SowerAgent.SchedulerTest do 1 + defmodule Garden.SchedulerTest do 2 2 use ExUnit.Case, async: true 3 3 4 4 import ExUnit.CaptureLog 5 5 6 - alias SowerAgent.Scheduler 6 + alias Garden.Scheduler 7 7 alias SowerClient.Orchestration.Subscription 8 8 9 9 defp start_cooldown(_context) do
+12 -12
apps/sower_agent/test/sower_agent/seed_test.exs apps/garden/test/garden/seed_test.exs
··· 1 - defmodule SowerAgent.SeedTest do 1 + defmodule Garden.SeedTest do 2 2 use ExUnit.Case, async: true 3 3 4 4 import ExUnit.CaptureLog 5 5 6 - alias SowerAgent.Seed 6 + alias Garden.Seed 7 7 alias SowerClient.Orchestration.DeploymentProfile 8 8 alias SowerClient.Seed, as: ClientSeed 9 9 10 10 describe "activate/1" do 11 11 test "returns noop when activation is disabled" do 12 - Application.put_env(:sower_agent, :enable_activation, false) 13 - on_exit(fn -> Application.put_env(:sower_agent, :enable_activation, true) end) 12 + Application.put_env(:garden, :enable_activation, false) 13 + on_exit(fn -> Application.put_env(:garden, :enable_activation, true) end) 14 14 15 15 seed = %ClientSeed{name: "test", seed_type: "nixos", artifact: "/nix/store/xyz"} 16 16 assert {:ok, ["noop"]} = Seed.activate(seed) ··· 29 29 send_response(client_socket, %{id: request["id"], type: "complete", exit_code: 0}) 30 30 end) 31 31 32 - Application.put_env(:sower_agent, :activator_socket, socket_path) 32 + Application.put_env(:garden, :activator_socket, socket_path) 33 33 34 34 on_exit(fn -> 35 - Application.delete_env(:sower_agent, :activator_socket) 35 + Application.delete_env(:garden, :activator_socket) 36 36 stop_mock_server(server_pid) 37 37 File.rm_rf!(Path.dirname(socket_path)) 38 38 end) ··· 52 52 send_response(client_socket, %{id: request["id"], type: "complete", exit_code: 0}) 53 53 end) 54 54 55 - Application.put_env(:sower_agent, :activator_socket, socket_path) 55 + Application.put_env(:garden, :activator_socket, socket_path) 56 56 57 57 on_exit(fn -> 58 - Application.delete_env(:sower_agent, :activator_socket) 58 + Application.delete_env(:garden, :activator_socket) 59 59 stop_mock_server(server_pid) 60 60 File.rm_rf!(Path.dirname(socket_path)) 61 61 end) ··· 78 78 send_response(client_socket, %{id: request["id"], type: "complete", exit_code: 1}) 79 79 end) 80 80 81 - Application.put_env(:sower_agent, :activator_socket, socket_path) 81 + Application.put_env(:garden, :activator_socket, socket_path) 82 82 83 83 on_exit(fn -> 84 - Application.delete_env(:sower_agent, :activator_socket) 84 + Application.delete_env(:garden, :activator_socket) 85 85 stop_mock_server(server_pid) 86 86 File.rm_rf!(Path.dirname(socket_path)) 87 87 end) ··· 109 109 send_response(client_socket, %{id: request["id"], type: "complete", exit_code: 0}) 110 110 end) 111 111 112 - Application.put_env(:sower_agent, :activator_socket, socket_path) 112 + Application.put_env(:garden, :activator_socket, socket_path) 113 113 114 114 on_exit(fn -> 115 - Application.delete_env(:sower_agent, :activator_socket) 115 + Application.delete_env(:garden, :activator_socket) 116 116 stop_mock_server(server_pid) 117 117 File.rm_rf!(Path.dirname(socket_path)) 118 118 end)
-3
apps/sower_agent/test/sower_agent_test.exs
··· 1 - defmodule SowerAgentTest do 2 - use ExUnit.Case 3 - end
apps/sower_agent/test/test_helper.exs apps/garden/test/test_helper.exs
+2 -2
apps/sower_cli/lib/sower_cli/config.ex
··· 3 3 CLI configuration management. 4 4 5 5 Delegates to `SowerClient.Config` for loading and validation. 6 - All fields are optional - CLI arguments can override config file values. 6 + All gardens are optional - CLI arguments can override config file values. 7 7 """ 8 8 9 9 @app :sower_cli ··· 26 26 @doc """ 27 27 Validate that endpoint and access_token are present for server operations. 28 28 29 - Returns `:ok` when valid, or `{:error, messages}` when fields are missing. 29 + Returns `:ok` when valid, or `{:error, messages}` when gardens are missing. 30 30 """ 31 31 def require_server_connection(%SowerClient.Config{} = config) do 32 32 errors = []
+4
apps/sower_client/lib/sower_client.ex
··· 9 9 components: %OpenApiSpex.Components{schemas: %{}} 10 10 } 11 11 |> OpenApiSpex.add_schemas([ 12 + SowerClient.GardenHello, 12 13 SowerClient.AgentHello, 13 14 SowerClient.Auth.TokenInfo, 15 + SowerClient.Orchestration.GardenSeedGeneration, 16 + SowerClient.Orchestration.GardenSeedProfile, 17 + SowerClient.Orchestration.GardenSeedsReport, 14 18 SowerClient.Orchestration.AgentSeedGeneration, 15 19 SowerClient.Orchestration.AgentSeedProfile, 16 20 SowerClient.Orchestration.AgentSeedsReport,
+1 -1
apps/sower_client/lib/sower_client/activator.ex
··· 374 374 {:ok, response} 375 375 376 376 {:ok, _data} -> 377 - {:error, :missing_required_fields} 377 + {:error, :missing_required_gardens} 378 378 379 379 {:error, reason} -> 380 380 {:error, {:json_decode_failed, reason}}
+3 -1
apps/sower_client/lib/sower_client/agent_hello.ex
··· 1 + # Deprecated: use SowerClient.GardenHello 2 + # Kept as alias for 0.7.0 backward compatibility 1 3 defmodule SowerClient.AgentHello do 2 4 use SowerClient.Schema 3 5 use SowerClient.ChannelMessage, event: "agent:hello", topic_type: :lobby ··· 21 23 description: "Name of agent" 22 24 } 23 25 }, 24 - required: ~w(local_sid name)a 26 + required: [:local_sid, :name] 25 27 }) 26 28 end
+8 -8
apps/sower_client/lib/sower_client/config.ex
··· 1 1 defmodule SowerClient.Config do 2 2 @moduledoc """ 3 - Shared configuration for Sower tools (agent, CLI). 3 + Shared configuration for Sower tools (garden, CLI). 4 4 5 5 Supports `_file` suffix for reading secrets from files. 6 6 ··· 44 44 }, 45 45 name: %Schema{ 46 46 type: :string, 47 - description: "Agent name (agent-only)" 47 + description: "Garden name (garden-only)" 48 48 }, 49 49 state_directory: %Schema{ 50 50 type: :string, 51 - description: "Directory where state files are written (agent-only)" 51 + description: "Directory where state files are written (garden-only)" 52 52 }, 53 53 default_deployment_profile: %Schema{ 54 54 type: :string, ··· 59 59 type: :object, 60 60 additionalProperties: SowerClient.Orchestration.DeploymentProfile, 61 61 nullable: true, 62 - description: "Deployment policies (agent-only)" 62 + description: "Deployment policies (garden-only)" 63 63 }, 64 64 subscriptions: %Schema{ 65 65 type: :array, 66 66 items: SowerClient.Orchestration.Subscription, 67 67 default: [], 68 - description: "Subscriptions (agent-only)" 68 + description: "Subscriptions (garden-only)" 69 69 } 70 70 }, 71 71 required: [] ··· 122 122 %{ 123 123 "name" => default_client_name(), 124 124 "state_directory" => default_state_dir(), 125 - "default" => "/var/lib/sower-agent" 125 + "default" => "/var/lib/sower-garden" 126 126 } 127 127 end 128 128 ··· 196 196 user when is_binary(user) -> 197 197 user = String.trim(user) 198 198 199 - if user == "" or user == "sower-agent" do 199 + if user == "" or user == "sower-garden" do 200 200 hostname 201 201 else 202 202 "#{user}@#{hostname}" ··· 208 208 end 209 209 210 210 def default_state_dir do 211 - SowerClient.Config.xdg_state_path("sower_agent") 211 + SowerClient.Config.xdg_state_path("sower-garden") 212 212 end 213 213 214 214 def xdg_config_path(app_name, filename) do
+26
apps/sower_client/lib/sower_client/garden_hello.ex
··· 1 + defmodule SowerClient.GardenHello do 2 + use SowerClient.Schema 3 + use SowerClient.ChannelMessage, event: "garden:hello", topic_type: :lobby 4 + 5 + OpenApiSpex.schema(%{ 6 + title: "GardenHello", 7 + type: :object, 8 + properties: %{ 9 + garden_sid: %Schema{ 10 + type: :string, 11 + description: "sid allocated by Sower", 12 + readOnly: true, 13 + nullable: true 14 + }, 15 + local_sid: %Schema{ 16 + type: :string, 17 + description: "sid allocated locally" 18 + }, 19 + name: %Schema{ 20 + type: :string, 21 + description: "Name of garden" 22 + } 23 + }, 24 + required: [:local_sid, :name] 25 + }) 26 + end
+5 -3
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 1 3 defmodule SowerClient.Orchestration.AgentSeedsReport do 2 4 @moduledoc """ 3 - Container for all Nix profiles reported by an agent. 4 - Sent when agent connects and after deployments complete. 5 + Deprecated: use SowerClient.Orchestration.GardenSeedsReport. 6 + Kept for backward compatibility with 0.7.0 gardens. 5 7 """ 6 8 use SowerClient.Schema 7 9 use SowerClient.ChannelMessage, event: "agent:seeds:report" ··· 12 14 properties: %{ 13 15 profiles: %Schema{ 14 16 type: :array, 15 - items: SowerClient.Orchestration.AgentSeedProfile, 17 + items: SowerClient.Orchestration.GardenSeedProfile, 16 18 description: "All Nix profiles with their generations" 17 19 } 18 20 },
+37
apps/sower_client/lib/sower_client/orchestration/garden_seed_generation.ex
··· 1 + defmodule SowerClient.Orchestration.GardenSeedGeneration do 2 + @moduledoc """ 3 + Represents a single Nix profile generation reported by a garden. 4 + """ 5 + use SowerClient.Schema 6 + 7 + OpenApiSpex.schema(%{ 8 + title: "GardenSeedGeneration", 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/garden_seed_profile.ex
··· 1 + defmodule SowerClient.Orchestration.GardenSeedProfile do 2 + @moduledoc """ 3 + Represents all generations for a single Nix profile on a garden. 4 + """ 5 + use SowerClient.Schema 6 + 7 + OpenApiSpex.schema(%{ 8 + title: "GardenSeedProfile", 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.GardenSeedGeneration, 24 + description: "All available generations for this profile" 25 + } 26 + }, 27 + required: [:profile_path, :generations] 28 + }) 29 + end
+21
apps/sower_client/lib/sower_client/orchestration/garden_seeds_report.ex
··· 1 + defmodule SowerClient.Orchestration.GardenSeedsReport do 2 + @moduledoc """ 3 + Container for all Nix profiles reported by a garden. 4 + Sent when garden connects and after deployments complete. 5 + """ 6 + use SowerClient.Schema 7 + use SowerClient.ChannelMessage, event: "garden:seeds:report" 8 + 9 + OpenApiSpex.schema(%{ 10 + title: "GardenSeedsReport", 11 + type: :object, 12 + properties: %{ 13 + profiles: %Schema{ 14 + type: :array, 15 + items: SowerClient.Orchestration.GardenSeedProfile, 16 + description: "All Nix profiles with their generations" 17 + } 18 + }, 19 + required: [:profiles] 20 + }) 21 + end
+2 -2
apps/sower_client/lib/sower_client/orchestration/subscription.ex
··· 38 38 }, 39 39 schedule: %Schema{ 40 40 type: :string, 41 - description: "Cron expression for polling schedule (agent-only)", 41 + description: "Cron expression for polling schedule (garden-only)", 42 42 nullable: true 43 43 }, 44 44 poll_on_connect: %Schema{ 45 45 type: :boolean, 46 - description: "Whether to request deployment immediately on connect (agent-only)", 46 + description: "Whether to request deployment immediately on connect (garden-only)", 47 47 default: false 48 48 } 49 49 },
+5 -5
apps/sower_client/test/sower_client/config_test.exs
··· 68 68 describe "xdg_state_path/1" do 69 69 test "respects XDG_STATE_HOME when set" do 70 70 with_env(%{"XDG_STATE_HOME" => "/custom/state"}, fn -> 71 - result = Config.xdg_state_path("sower_agent") 72 - assert result =~ "/custom/state/sower_agent" 71 + result = Config.xdg_state_path("sower-garden") 72 + assert result =~ "/custom/state/sower-garden" 73 73 end) 74 74 end 75 75 end 76 76 77 77 describe "default_client_name/0" do 78 - test "returns USER@HOST for non-sower-agent users" do 78 + test "returns USER@HOST for non-sower-garden users" do 79 79 {:ok, hostname} = :inet.gethostname() 80 80 hostname = to_string(hostname) 81 81 ··· 84 84 end) 85 85 end 86 86 87 - test "returns HOST for sower-agent user" do 87 + test "returns HOST for sower-garden user" do 88 88 {:ok, hostname} = :inet.gethostname() 89 89 hostname = to_string(hostname) 90 90 91 - with_env(%{"USER" => "sower-agent"}, fn -> 91 + with_env(%{"USER" => "sower-garden"}, fn -> 92 92 assert Config.default_client_name() == hostname 93 93 end) 94 94 end
+1 -1
apps/sower_client/test/sower_client/storage/deployment_log_upload_test.exs
··· 76 76 assert reply.headers == %{} 77 77 end 78 78 79 - test "cast/1 with missing required fields fails" do 79 + test "cast/1 with missing required gardens fails" do 80 80 assert {:error, _} = 81 81 PresignedUploadReply.cast(%{ 82 82 "method" => "PUT",
+4 -4
config/runtime.exs
··· 1 1 import Config 2 2 3 3 if config_env() == :dev do 4 - config :sower_agent, SowerAgent.Scheduler, timezone: SowerAgent.Scheduler.get_timezone() 4 + config :garden, Garden.Scheduler, timezone: Garden.Scheduler.get_timezone() 5 5 6 - SowerAgent.Config.load(%{ 6 + Garden.Config.load(%{ 7 7 access_token_file: Path.expand("../.dev-api-token", __DIR__), 8 8 endpoint: "http://localhost:7150", 9 9 state_directory: Path.expand("../_build", __DIR__) ··· 13 13 end 14 14 15 15 if config_env() == :test do 16 - if Code.loaded?(SowerAgent.Config) do 17 - SowerAgent.Config.load( 16 + if Code.loaded?(Garden.Config) do 17 + Garden.Config.load( 18 18 %{ 19 19 state_directory: Path.expand("../_build", __DIR__), 20 20 subscriptions: [
-5
config/runtime_agent.exs
··· 1 - import Config 2 - 3 - config :sower_agent, SowerAgent.Scheduler, timezone: SowerAgent.Scheduler.get_timezone() 4 - 5 - SowerAgent.Config.load()
+5
config/runtime_garden.exs
··· 1 + import Config 2 + 3 + config :garden, Garden.Scheduler, timezone: Garden.Scheduler.get_timezone() 4 + 5 + Garden.Config.load()
+1 -1
config/test.exs
··· 29 29 config :sower, :database, 30 30 encryption_key: "UIFQeYN5EBgkXgK502I8mosh3vbEj3AE1rRwWJDysBk=" |> Base.decode64!() 31 31 32 - config :sower_agent, Client, 32 + config :garden, Garden.Socket, 33 33 uri: "ws://example.org/socket/websocket", 34 34 reconnect_after_msec: [200, 500, 1_000, 2_000] 35 35
+1 -1
dev-client-example.json
··· 1 1 { 2 2 "access_token_file": "./.dev-api-token", 3 3 "endpoint": "http://localhost:7150", 4 - "state_directory": "./_build/agent1", 4 + "state_directory": "./_build/garden1", 5 5 "subscriptions": [ 6 6 { 7 7 "seed_name": "deck",
+2 -2
justfile
··· 96 96 start-all: 97 97 nix shell ".#activator" -c iex --sname dev1 -S mix phx.server 98 98 99 - start-agent: 100 - nix shell ".#activator" -c iex --sname agent1 --dot-iex ./.iex-agent.exs -S mix run --no-start 99 + start-garden: 100 + nix shell ".#activator" -c iex --sname garden1 --dot-iex ./.iex-garden.exs -S mix run --no-start 101 101 102 102 start-server: 103 103 iex --sname server1 --dot-iex ./.iex-server.exs -S mix phx.server --no-start
+3 -3
mix.exs
··· 8 8 apps_path: "apps", 9 9 deps: deps(), 10 10 releases: [ 11 - agent: [ 11 + garden: [ 12 12 version: version, 13 - applications: [sower_agent: :permanent], 14 - runtime_config_path: "config/runtime_agent.exs", 13 + applications: [garden: :permanent], 14 + runtime_config_path: "config/runtime_garden.exs", 15 15 include_executables_for: [:unix] 16 16 ], 17 17 cli: [
+67 -32
nix/home/module.nix
··· 5 5 ... 6 6 }: 7 7 let 8 - cfg = config.services.sower.agent; 8 + cfg = config.services.sower.garden; 9 9 json = pkgs.formats.json { }; 10 10 jsonType = json.type; 11 11 12 12 jsonConfig = json.generate "sower-client.json" cfg.settings; 13 13 14 - stateDir = "${config.xdg.stateHome}/sower-agent"; 14 + stateDir = "${config.xdg.stateHome}/sower-garden"; 15 + 16 + oldStateDir = "${config.xdg.stateHome}/sower-agent"; 17 + 18 + migrationScript = pkgs.writeShellApplication { 19 + name = "sower-garden-migrate"; 20 + text = '' 21 + if [ -d ${oldStateDir} ] && [ ! -d ${stateDir} ]; then 22 + mv ${oldStateDir} ${stateDir} 23 + fi 24 + ''; 25 + }; 26 + 27 + secretsScript = pkgs.writeShellApplication { 28 + name = "sower-garden-init-secrets"; 29 + runtimeInputs = [ pkgs.openssl ]; 30 + text = '' 31 + mkdir -p ${stateDir} 32 + if [ ! -e ${stateDir}/release-cookie ]; then 33 + openssl rand -hex 48 > ${stateDir}/release-cookie 34 + fi 35 + ''; 36 + }; 37 + 38 + startScript = pkgs.writeShellApplication { 39 + name = "sower-garden-start"; 40 + text = '' 41 + RELEASE_COOKIE=$(cat ${stateDir}/release-cookie) 42 + export RELEASE_COOKIE 43 + exec ${lib.getExe cfg.package} start 44 + ''; 45 + }; 46 + 47 + stopScript = pkgs.writeShellApplication { 48 + name = "sower-garden-stop"; 49 + text = '' 50 + RELEASE_COOKIE=$(cat ${stateDir}/release-cookie) 51 + export RELEASE_COOKIE 52 + PID=$(${lib.getExe cfg.package} pid) 53 + ${lib.getExe cfg.package} stop 54 + while [ -d "/proc/$PID" ]; do sleep 1; done 55 + ''; 56 + }; 57 + 58 + reloadScript = pkgs.writeShellApplication { 59 + name = "sower-garden-reload"; 60 + text = '' 61 + RELEASE_COOKIE=$(cat ${stateDir}/release-cookie) 62 + export RELEASE_COOKIE 63 + ${lib.getExe cfg.package} rpc "Garden.request_reload()" 64 + ''; 65 + }; 15 66 in 16 67 { 17 68 options = { 18 - services.sower.agent = { 19 - enable = lib.mkEnableOption "Sower agent"; 69 + services.sower.garden = { 70 + enable = lib.mkEnableOption "Sower garden"; 20 71 21 72 package = lib.mkOption { type = lib.types.package; }; 22 73 ··· 34 85 35 86 options = { }; 36 87 }; 37 - description = "Sower agent configuration file"; 88 + description = "Sower garden configuration file"; 38 89 default = { }; 39 90 }; 40 91 }; ··· 49 100 } 50 101 51 102 (lib.mkIf pkgs.stdenv.isLinux { 52 - systemd.user.services.sower-agent = { 103 + systemd.user.services.sower-garden = { 53 104 Service = { 54 105 Environment = [ 55 106 "PATH=/run/current-system/sw/bin:${ ··· 66 117 "SOWER_ACCESS_TOKEN_FILE=${cfg.accessTokenFile}" 67 118 ]; 68 119 69 - ExecStartPre = pkgs.writeShellScript "sower-agent-init" '' 70 - mkdir -p ${stateDir} 71 - if [ ! -e ${stateDir}/release-cookie ]; then 72 - ${lib.getExe pkgs.openssl} rand -hex 48 > ${stateDir}/release-cookie 73 - fi 74 - ''; 75 - ExecStart = pkgs.writeShellScript "sower-agent-start" '' 76 - RELEASE_COOKIE=$(cat ${stateDir}/release-cookie) 77 - export RELEASE_COOKIE 78 - exec ${lib.getExe cfg.package} start 79 - ''; 80 - ExecStop = pkgs.writeShellScript "sower-agent-stop" '' 81 - RELEASE_COOKIE=$(cat ${stateDir}/release-cookie) 82 - export RELEASE_COOKIE 83 - PID=$(${lib.getExe cfg.package} pid) 84 - ${lib.getExe cfg.package} stop 85 - while [ -d "/proc/$PID" ]; do sleep 1; done 86 - ''; 87 - ExecReload = pkgs.writeShellScript "sower-agent-reload" '' 88 - RELEASE_COOKIE=$(cat ${stateDir}/release-cookie) 89 - export RELEASE_COOKIE 90 - ${lib.getExe cfg.package} rpc "SowerAgent.request_reload()" 91 - ''; 120 + ExecStartPre = [ 121 + (lib.getExe migrationScript) 122 + (lib.getExe secretsScript) 123 + ]; 124 + ExecStart = lib.getExe startScript; 125 + ExecStop = lib.getExe stopScript; 126 + ExecReload = lib.getExe reloadScript; 92 127 93 128 Type = "notify"; 94 129 WatchdogSec = "10s"; ··· 115 150 116 151 (lib.mkIf pkgs.stdenv.isDarwin { 117 152 launchd = { 118 - agents.sower-agent = { 153 + agents.sower-garden = { 119 154 enable = true; 120 155 config = { 121 156 KeepAlive = true; ··· 136 171 // lib.optionalAttrs (cfg.accessTokenFile != null) { 137 172 SOWER_ACCESS_TOKEN_FILE = cfg.accessTokenFile; 138 173 }; 139 - StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/sower-agent-err.log"; 140 - StandardOutPath = "${config.home.homeDirectory}/Library/Logs/sower-agent-out.log"; 174 + StandardErrorPath = "${config.home.homeDirectory}/Library/Logs/sower-garden-err.log"; 175 + StandardOutPath = "${config.home.homeDirectory}/Library/Logs/sower-garden-out.log"; 141 176 }; 142 177 }; 143 178 };
+1 -2
nix/nixos/activator.nix
··· 46 46 description = "Sower Activator Socket"; 47 47 wantedBy = [ "sockets.target" ]; 48 48 49 - # Start before agent so socket is ready 50 - before = [ "sower-agent.service" ]; 49 + before = [ "sower-garden.service" ]; 51 50 52 51 socketConfig = { 53 52 ListenStream = cfg.socketPath;
+85 -51
nix/nixos/agent.nix nix/nixos/garden.nix
··· 5 5 ... 6 6 }: 7 7 let 8 - cfg = config.services.sower.agent; 8 + cfg = config.services.sower.garden; 9 9 activatorCfg = config.services.sower.activator; 10 10 json = pkgs.formats.json { }; 11 11 jsonType = json.type; 12 12 13 - # Build agent settings, optionally including activator socket path 14 - agentSettings = 13 + # Build garden settings, optionally including activator socket path 14 + gardenSettings = 15 15 cfg.settings 16 16 // (lib.optionalAttrs activatorCfg.enable { activator_socket = activatorCfg.socketPath; }); 17 17 18 - jsonConfig = json.generate "sower-client.json" agentSettings; 18 + jsonConfig = json.generate "sower-client.json" gardenSettings; 19 19 20 20 # TODO re-enable services support 21 21 manageServices = false; 22 22 23 23 adminScript = pkgs.writeShellApplication { 24 - name = "sower-agent"; 24 + name = "sower-garden"; 25 25 26 26 text = 27 27 (lib.concatStringsSep "\n" ( 28 28 lib.mapAttrsToList (name: val: '' 29 29 ${name}="${val}" 30 30 export ${name} 31 - '') config.systemd.services.sower-agent.environment 31 + '') config.systemd.services.sower-garden.environment 32 32 )) 33 33 + '' 34 - RELEASE_COOKIE=$(cat /var/lib/sower-agent/release-cookie) 34 + RELEASE_COOKIE=$(cat /var/lib/sower-garden/release-cookie) 35 35 export RELEASE_COOKIE 36 36 exec ${lib.getExe cfg.package} "$@" 37 37 ''; 38 + }; 39 + 40 + migrationScript = pkgs.writeShellApplication { 41 + name = "sower-garden-migrate"; 42 + text = '' 43 + if [ -d /var/lib/sower-agent ] && [ ! -d /var/lib/sower-garden ]; then 44 + mv /var/lib/sower-agent /var/lib/sower-garden 45 + chown -R sower-garden:sower-garden /var/lib/sower-garden 46 + fi 47 + ''; 48 + }; 49 + 50 + secretsScript = pkgs.writeShellApplication { 51 + name = "sower-garden-init-secrets"; 52 + runtimeInputs = [ pkgs.openssl ]; 53 + text = '' 54 + if [ ! -e release-cookie ]; then 55 + echo "Generating release cookie" 56 + openssl rand -hex 48 > release-cookie 57 + fi 58 + ''; 59 + }; 60 + 61 + startScript = pkgs.writeShellApplication { 62 + name = "sower-garden-start"; 63 + text = '' 64 + RELEASE_COOKIE=$(cat release-cookie) 65 + export RELEASE_COOKIE 66 + 67 + exec ${lib.getExe cfg.package} start 68 + ''; 69 + }; 70 + 71 + stopScript = pkgs.writeShellApplication { 72 + name = "sower-garden-stop"; 73 + text = '' 74 + RELEASE_COOKIE=$(cat release-cookie) 75 + export RELEASE_COOKIE 76 + PID=$(${lib.getExe cfg.package} pid) 77 + 78 + exec ${lib.getExe cfg.package} stop 79 + 80 + while [ -d "/proc/$PID" ]; do sleep 1; done 81 + ''; 82 + }; 83 + 84 + reloadScript = pkgs.writeShellApplication { 85 + name = "sower-garden-reload"; 86 + text = '' 87 + RELEASE_COOKIE=$(cat release-cookie) 88 + export RELEASE_COOKIE 89 + 90 + ${lib.getExe cfg.package} rpc "Garden.request_reload()" 91 + ''; 38 92 }; 39 93 in 40 94 { 41 95 options = { 42 - services.sower.agent = { 43 - enable = lib.mkEnableOption "Sower agent"; 96 + services.sower.garden = { 97 + enable = lib.mkEnableOption "Sower garden"; 44 98 45 99 package = lib.mkOption { type = lib.types.package; }; 46 100 ··· 62 116 63 117 options = { }; 64 118 }; 65 - description = "Sower client (agent and cli) configuration file"; 119 + description = "Sower client (garden and cli) configuration file"; 66 120 default = { }; 67 121 }; 68 122 }; ··· 88 142 89 143 services.sower.activator = { 90 144 enable = lib.mkDefault true; 91 - allowedGroups = [ "sower-agent" ]; 145 + allowedGroups = [ "sower-garden" ]; 92 146 }; 93 147 94 - systemd.services.sower-agent = { 148 + systemd.services.sower-garden = { 95 149 wantedBy = [ "multi-user.target" ]; 96 150 after = [ 97 151 "network-online.target" ··· 127 181 SOWER_ACCESS_TOKEN_FILE = cfg.accessTokenFile; 128 182 }; 129 183 130 - # reload is an async notification to the agent 184 + # reload is an async notification to the garden 131 185 reloadIfChanged = true; 132 186 # avoid restarting mid-switch 133 187 restartIfChanged = false; ··· 187 241 "AF_INET6" 188 242 ]; 189 243 SupplementaryGroups = lib.optionals activatorCfg.enable [ activatorCfg.socketGroup ]; 190 - User = "sower-agent"; 191 - Group = "sower-agent"; 244 + User = "sower-garden"; 245 + Group = "sower-garden"; 192 246 BindPaths = lib.optionals activatorCfg.enable [ activatorCfg.socketPath ]; 193 247 UMask = "0077"; 194 248 195 - StateDirectory = "sower-agent"; 196 - WorkingDirectory = "%S/sower-agent"; 197 - 198 - ExecStartPre = pkgs.writeShellScript "sower-agentinit-secrets" '' 199 - if [ ! -e release-cookie ]; then 200 - echo "Generating release cookie" 201 - ${lib.getExe pkgs.openssl} rand -hex 48 > release-cookie 202 - fi 203 - ''; 204 - ExecStart = pkgs.writeShellScript "sower-agent-start" '' 205 - RELEASE_COOKIE=$(cat release-cookie) 206 - export RELEASE_COOKIE 207 - 208 - exec ${lib.getExe cfg.package} start 209 - ''; 210 - ExecStop = pkgs.writeShellScript "sower-agent-stop" '' 211 - RELEASE_COOKIE=$(cat release-cookie) 212 - export RELEASE_COOKIE 213 - PID=$(${lib.getExe cfg.package} pid) 249 + StateDirectory = "sower-garden"; 250 + WorkingDirectory = "%S/sower-garden"; 214 251 215 - exec ${lib.getExe cfg.package} stop 216 - 217 - while [ -d "/proc/$PID" ]; do sleep 1; done 218 - ''; 219 - # Request reload via RPC - the agent will restart itself at end of deployment 220 - ExecReload = pkgs.writeShellScript "sower-agent-reload" '' 221 - RELEASE_COOKIE=$(cat release-cookie) 222 - export RELEASE_COOKIE 223 - 224 - ${lib.getExe cfg.package} rpc "SowerAgent.request_reload()" 225 - ''; 252 + ExecStartPre = [ 253 + "+${lib.getExe migrationScript}" 254 + (lib.getExe secretsScript) 255 + ]; 256 + ExecStart = lib.getExe startScript; 257 + ExecStop = lib.getExe stopScript; 258 + # Request reload via RPC - the garden will restart itself at end of deployment 259 + ExecReload = lib.getExe reloadScript; 226 260 227 261 MemoryAccounting = true; 228 262 MemoryMax = "200M"; ··· 231 265 232 266 security.sudo.extraRules = lib.mkIf (!activatorCfg.enable) [ 233 267 { 234 - users = [ "sower-agent" ]; 268 + users = [ "sower-garden" ]; 235 269 commands = [ 236 270 { 237 271 command = lib.getExe activatorCfg.package; ··· 262 296 // } 263 297 264 298 if (action.id == "org.freedesktop.systemd1.manage-units" && 265 - action.lookup("unit") == "sower-agent.service" && 299 + action.lookup("unit") == "sower-garden.service" && 266 300 action.lookup("verb") == "restart" && 267 - subject.system_unit == "sower-agent.service") { 301 + subject.system_unit == "sower-garden.service") { 268 302 return polkit.Result.YES; 269 303 } 270 304 }); ··· 276 310 "L /etc/sower/systemd - - - - /nix/var/nix/profiles/sower/services-units/systemd" 277 311 ]; 278 312 279 - users.groups.sower-agent = { }; 280 - users.users.sower-agent = { 313 + users.groups.sower-garden = { }; 314 + users.users.sower-garden = { 281 315 isSystemUser = true; 282 - group = "sower-agent"; 316 + group = "sower-garden"; 283 317 }; 284 318 }; 285 319 }
+1 -1
nix/nixos/module.nix
··· 1 1 { 2 2 imports = [ 3 3 ./activator.nix 4 - ./agent.nix 4 + ./garden.nix 5 5 ./seed.nix 6 6 ./server.nix 7 7 ];
+5 -5
nix/packages/agent.nix nix/packages/garden.nix
··· 6 6 }: 7 7 8 8 beamPackages.mixRelease { 9 - pname = "sower-agent"; 9 + pname = "sower-garden"; 10 10 inherit version; 11 11 12 12 src = lib.fileset.toSource { 13 13 root = ../..; 14 14 fileset = lib.fileset.unions [ 15 15 ../../apps/nix 16 - ../../apps/sower_agent 16 + ../../apps/garden 17 17 ../../apps/sower_client 18 18 ../../config 19 19 ../../mix.exs ··· 22 22 ]; 23 23 }; 24 24 25 - mixReleaseName = "agent"; 25 + mixReleaseName = "garden"; 26 26 27 27 mixNixDeps = callPackages ./umbrella-deps.nix { inherit beamPackages; }; 28 28 29 29 postInstall = '' 30 - mv $out/bin/agent $out/bin/sower-agent 30 + mv $out/bin/garden $out/bin/sower-garden 31 31 ''; 32 32 33 33 # Disable checks for now 34 34 doCheck = false; 35 35 36 - meta.mainProgram = "sower-agent"; 36 + meta.mainProgram = "sower-garden"; 37 37 }
+1 -1
nix/packages/part.nix
··· 21 21 inherit version; 22 22 }; 23 23 24 - agent = pkgs.callPackage ./agent.nix { 24 + garden = pkgs.callPackage ./garden.nix { 25 25 inherit beamPackages version; 26 26 }; 27 27
+33 -33
nix/tests/e2e.nix
··· 14 14 npins = import ./npins; 15 15 16 16 simple-service = flake.packages.${system}.tests-simple-service; 17 - agentPkg = flake.packages.${system}.agent; 17 + gardenPkg = flake.packages.${system}.garden; 18 18 activatorPkg = flake.packages.${system}.activator; 19 19 cliPkg = flake.packages.${system}.cli; 20 20 serverPkg = flake.packages.${system}.server; 21 21 22 - hmAgentStateDir = "/home/testuser/.local/state/sower-agent"; 22 + hmGardenStateDir = "/home/testuser/.local/state/sower-garden"; 23 23 24 - # Admin wrapper for home-manager agent RPC (must match RELEASE_NODE override) 25 - hmAgentAdmin = pkgs.writeShellApplication { 26 - name = "sower-hm-agent"; 24 + # Admin wrapper for home-manager garden RPC (must match RELEASE_NODE override) 25 + hmGardenAdmin = pkgs.writeShellApplication { 26 + name = "sower-hm-garden"; 27 27 text = '' 28 28 export RELEASE_MODE="interactive" 29 - export RELEASE_NODE="sower_agent_hm" 29 + export RELEASE_NODE="garden_hm" 30 30 export SHELL="${lib.getExe pkgs.bash}" 31 - RELEASE_COOKIE=$(cat ${hmAgentStateDir}/release-cookie) 31 + RELEASE_COOKIE=$(cat ${hmGardenStateDir}/release-cookie) 32 32 export RELEASE_COOKIE 33 - exec ${lib.getExe agentPkg} "$@" 33 + exec ${lib.getExe gardenPkg} "$@" 34 34 ''; 35 35 }; 36 36 in ··· 63 63 64 64 environment.systemPackages = [ 65 65 cliPkg 66 - hmAgentAdmin 66 + hmGardenAdmin 67 67 pkgs.python3 68 68 ]; 69 69 ··· 79 79 services.sower = { 80 80 activator.package = activatorPkg; 81 81 82 - agent = { 82 + garden = { 83 83 enable = true; 84 - package = agentPkg; 84 + package = gardenPkg; 85 85 86 86 settings = { 87 87 access_token_file = "/run/sower/test_token"; ··· 95 95 }; 96 96 }; 97 97 }; 98 - # if agent fails to start, fail immediately 99 - systemd.services.sower-agent.serviceConfig.Restart = "no"; 98 + # if garden fails to start, fail immediately 99 + systemd.services.sower-garden.serviceConfig.Restart = "no"; 100 100 101 101 services.sower.server = { 102 102 enable = true; ··· 142 142 143 143 home.stateVersion = "24.11"; 144 144 145 - services.sower.agent = { 145 + services.sower.garden = { 146 146 enable = true; 147 - package = agentPkg; 147 + package = gardenPkg; 148 148 activatorPackage = activatorPkg; 149 149 accessTokenFile = "/run/sower/test_token"; 150 150 ··· 167 167 }; 168 168 }; 169 169 170 - # Test overrides for home-manager agent 171 - home-manager.users.testuser.systemd.user.services.sower-agent.Service = { 170 + # Test overrides for home-manager garden 171 + home-manager.users.testuser.systemd.user.services.sower-garden.Service = { 172 172 Restart = lib.mkForce "no"; 173 - # Avoid Erlang node name clash with system-level agent 174 - Environment = lib.mkAfter [ "RELEASE_NODE=sower_agent_hm" ]; 173 + # Avoid Erlang node name clash with system-level garden 174 + Environment = lib.mkAfter [ "RELEASE_NODE=garden_hm" ]; 175 175 }; 176 176 177 177 virtualisation.diskSize = 4096; ··· 186 186 server.wait_for_unit("postgresql.service") 187 187 server.wait_for_unit("sower.service") 188 188 server.wait_for_unit("sower-activator.socket") 189 - server.wait_for_unit("sower-agent.service") 189 + server.wait_for_unit("sower-garden.service") 190 190 server.wait_for_open_port(4000) 191 191 192 192 with subtest("activator socket activation"): ··· 199 199 server.succeed("mkdir -p /run/sower") 200 200 server.succeed(f"echo -n {token} > /run/sower/test_token") 201 201 202 - with subtest("nixos agent registration"): 202 + with subtest("nixos garden registration"): 203 203 server.wait_until_succeeds( 204 - "journalctl --no-pager -u sower-agent" 204 + "journalctl --no-pager -u sower-garden" 205 205 " --grep='Joined channel topic'", 206 206 timeout=15, 207 207 ) ··· 211 211 server.succeed(f"sower seed submit --name server --type nixos --artifact {server_profile} --debug") 212 212 server.succeed("sower seed upgrade --name server --type nixos --debug") 213 213 214 - with subtest("nixos agent deployment"): 215 - server.succeed('sower-agent rpc "SowerAgent.Admin.deploy(\\\"nixos\\\")"') 214 + with subtest("nixos garden deployment"): 215 + server.succeed('sower-garden rpc "Garden.Admin.deploy(\\\"nixos\\\")"') 216 216 server.wait_until_succeeds( 217 - "journalctl --no-pager -u sower-agent" 217 + "journalctl --no-pager -u sower-garden" 218 218 " --grep='Completed.activation'", 219 219 timeout=15, 220 220 ) ··· 225 225 " --grep='Received request.*type=nixos'" 226 226 ) 227 227 228 - with subtest("start home-manager agent"): 228 + with subtest("start home-manager garden"): 229 229 server.wait_for_unit("home-manager-testuser.service") 230 230 server.succeed("loginctl enable-linger testuser") 231 231 # HM activation ran before user manager was up, so reload and start manually 232 232 server.systemctl("daemon-reload", "testuser") 233 - server.systemctl("start sower-agent.service", "testuser") 234 - server.wait_for_unit("sower-agent.service", "testuser") 233 + server.systemctl("start sower-garden.service", "testuser") 234 + server.wait_for_unit("sower-garden.service", "testuser") 235 235 236 - with subtest("home-manager agent registration"): 236 + with subtest("home-manager garden registration"): 237 237 server.wait_until_succeeds( 238 238 "su -l testuser -c '" 239 - "journalctl --user --no-pager -u sower-agent" 239 + "journalctl --user --no-pager -u sower-garden" 240 240 " --grep=Joined.channel.topic'", 241 241 timeout=15, 242 242 ) 243 243 244 - with subtest("home-manager agent deployment"): 244 + with subtest("home-manager garden deployment"): 245 245 hm_generation = server.succeed( 246 246 "readlink -f /home/testuser/.local/state/home-manager/gcroots/current-home" 247 247 ).strip() ··· 251 251 f" --tag username=testuser" 252 252 ) 253 253 server.succeed( 254 - 'sower-hm-agent rpc "SowerAgent.Admin.deploy(\\\"home-manager\\\")"' 254 + 'sower-hm-garden rpc "Garden.Admin.deploy(\\\"home-manager\\\")"' 255 255 ) 256 256 server.wait_until_succeeds( 257 257 "su -l testuser -c '" 258 - "journalctl --user --no-pager -u sower-agent" 258 + "journalctl --user --no-pager -u sower-garden" 259 259 " --grep=Completed.activation'", 260 260 timeout=15, 261 261 )
+127
scripts/rename-field-to-garden.sh
··· 1 + #!/usr/bin/env bash 2 + set -euo pipefail 3 + 4 + # Rename "Field" → "Garden" and "field" → "garden" across the codebase. 5 + # Excludes: deps/, _build/, .jj/, priv/repo/migrations/, node_modules/ 6 + # 7 + # Usage: 8 + # ./scripts/rename-field-to-garden.sh # dry run (show what would change) 9 + # ./scripts/rename-field-to-garden.sh --apply # actually do it 10 + 11 + DRY_RUN=true 12 + if [[ "${1:-}" == "--apply" ]]; then 13 + DRY_RUN=false 14 + fi 15 + 16 + cd "$(git rev-parse --show-toplevel)" 17 + 18 + # --- Phase 1: Content replacements --- 19 + 20 + # Files to process (exclude deps, _build, migrations, .jj, node_modules, this script) 21 + mapfile -t FILES < <( 22 + rg --files \ 23 + --glob '!deps/**' \ 24 + --glob '!_build/**' \ 25 + --glob '!.jj/**' \ 26 + --glob '!node_modules/**' \ 27 + --glob '!**/priv/repo/migrations/**' \ 28 + --glob '!priv/static/**' \ 29 + --glob '!scripts/rename-field-to-garden.sh' \ 30 + --glob '!*.beam' \ 31 + --glob '!*.ez' 32 + ) 33 + 34 + # Do a broad field→garden replacement, then fix false positives. 35 + # The broad replacement catches everything; false-positive restoration 36 + # handles the typedstruct/ecto `field :atom` macro pattern. 37 + SED_RULES=( 38 + # Capitalized module/type names 39 + -e 's/Field/Garden/g' 40 + # Lowercase everywhere 41 + -e 's/field/garden/g' 42 + # Restore typedstruct/ecto macro: ` garden :foo` → ` field :foo` 43 + -e 's/^\([[:space:]]*\)garden :\([a-z_]\)/\1field :\2/g' 44 + # Restore Ecto query field() calls: `garden(:foo)` → `field(:foo)` 45 + -e 's/\bgarden(:/field(:/g' 46 + # Restore Ecto.Changeset.get_field/2, put_field/3, fetch_field/2 47 + -e 's/get_garden(changeset/get_field(changeset/g' 48 + -e 's/put_garden(changeset/put_field(changeset/g' 49 + -e 's/fetch_garden(changeset/fetch_field(changeset/g' 50 + # Restore Phoenix.HTML.FormField 51 + -e 's/FormGarden/FormField/g' 52 + # Restore Phoenix component `field={...}` attribute (form fields) 53 + -e 's/garden={@form/field={@form/g' 54 + -e 's/garden={perm/field={perm/g' 55 + ) 56 + 57 + echo "=== Phase 1: Content replacements ===" 58 + echo "Files to scan: ${#FILES[@]}" 59 + echo "" 60 + 61 + CHANGED_COUNT=0 62 + 63 + for f in "${FILES[@]}"; do 64 + [[ -f "$f" ]] || continue 65 + 66 + if ! diff -q <(cat "$f") <(sed "${SED_RULES[@]}" "$f") >/dev/null 2>&1; then 67 + CHANGED_COUNT=$((CHANGED_COUNT + 1)) 68 + if $DRY_RUN; then 69 + echo "--- $f ---" 70 + diff --color=always -u "$f" <(sed "${SED_RULES[@]}" "$f") || true 71 + echo "" 72 + else 73 + sed -i "${SED_RULES[@]}" "$f" 74 + echo " replaced: $f" 75 + fi 76 + fi 77 + done 78 + 79 + echo "" 80 + echo "Content changes: ${CHANGED_COUNT} files" 81 + 82 + # --- Phase 2: File and directory renames --- 83 + # Only rename files/dirs where "field" is OUR domain concept. 84 + # Skip priv/repo/migrations/ entirely (immutable). 85 + 86 + echo "" 87 + echo "=== Phase 2: File/directory renames ===" 88 + 89 + rename_if_needed() { 90 + local f="$1" 91 + local dir base newbase 92 + dir=$(dirname "$f") 93 + base=$(basename "$f") 94 + newbase="${base//field/garden}" 95 + if [[ "$base" != "$newbase" ]]; then 96 + if $DRY_RUN; then 97 + echo " rename: $f -> $dir/$newbase" 98 + else 99 + mv "$f" "$dir/$newbase" 100 + echo " renamed: $f -> $dir/$newbase" 101 + fi 102 + fi 103 + } 104 + 105 + # Rename files (deepest first), excluding migrations 106 + mapfile -t RENAME_FILES < <( 107 + find apps nix config -path '*/deps' -prune -o \ 108 + -path '*/_build' -prune -o \ 109 + -path '*/priv/repo/migrations' -prune -o \ 110 + -name '*field*' -print 2>/dev/null | sort -r 111 + ) 112 + 113 + for f in "${RENAME_FILES[@]}"; do 114 + rename_if_needed "$f" 115 + done 116 + 117 + # Top-level files (e.g. .iex-field.exs) 118 + for f in .iex-field*; do 119 + [[ -e "$f" ]] && rename_if_needed "$f" 120 + done 121 + 122 + echo "" 123 + if $DRY_RUN; then 124 + echo "DRY RUN complete. Run with --apply to execute." 125 + else 126 + echo "Rename complete. Run 'mix format' and 'mix compile' to verify." 127 + fi
-92
specs/mobile-table/.progress.md
··· 1 - # mobile-table 2 - 3 - **Goal**: Modify default table to be mobile responsive 4 - 5 - ## Completed Tasks 6 - - [x] 1.1 [RED] Failing test: table/1 renders with hide_on={:mobile} classes 7 - - [x] 1.2 [GREEN] Implement table/1 in sower_components.ex 8 - - [x] 1.3 [VERIFY] Quality checkpoint - PASSED 9 - - [x] 1.4 [RED] Failing test: global import resolves table/1 to SowerComponents 10 - 11 - - [x] 1.5 [GREEN] Wire global SowerComponents import, remove per-module imports 12 - 13 - - [x] 1.6 [VERIFY] Quality checkpoint after import wiring - PASSED 14 - - [x] 1.7 [P] Migrate multi-column tables: agent, seed, cache - 73d9bc2 15 - - [x] 1.8 [P] Migrate multi-column tables: access_token, connection, deployment - a948327 16 - - [x] 2.1 Verify single-column table instances work without changes (verification only, no commit) 17 - 18 - ## Current Task 19 - 2.2 [VERIFY] Quality checkpoint: full test suite - PASSED 20 - 21 - ## Learnings 22 - - Component unit tests use `rendered_to_string/1` with `~H` sigil, need `import Phoenix.Component, only: [sigil_H: 2]` 23 - - Tests use `SowerWeb.ConnCase` even for component tests (provides necessary setup) 24 - - table/1 does not exist yet in SowerComponents - tests fail with UndefinedFunctionError as expected 25 - - Adding table/1 to SowerComponents causes ambiguous import conflicts in modules that import both CoreComponents (via html_helpers) and SowerComponents explicitly. Need `except: [table: 1]` on SowerComponents imports in affected modules (agent_live/index, seed_live/index, deployment_live/index). 26 - - No index tests exist for seed_live or nix/cache_live - only agent_live_show_test.exs exists 27 - - Test files for access_token and connection live views are at settings/access_token_live_test.exs and forge/connection_live_test.exs (not *_index_test.exs) 28 - - deployment_live uses inline HEEx template in index.ex; hide_on attrs go directly in the ~H sigil 29 - 30 - ### Verification: 1.6 [VERIFY] Quality checkpoint after import wiring 31 - - Status: PASS 32 - - Commands: mix compile --warnings-as-errors (0), mix format --check-formatted (0), mix test (3 pre-existing failures in orchestration_test.exs, 0 new failures) 33 - - No import conflicts or warnings after global import wiring 34 - - All sower_components_test.exs tests pass (3 tests, 0 failures) 35 - - All other test suites pass: sower_dev (1), sower_client (68), nix (48), sower_cli (22), sower_agent (80), sower main (185 total, 3 pre-existing failures only) 36 - 37 - ### Verification: 1.3 [VERIFY] Quality checkpoint 38 - - Status: PASS 39 - - Commands: mix compile --warnings-as-errors (0), mix format --check-formatted (0), mix test (3 pre-existing failures in orchestration_test.exs, 0 failures in mobile-table code) 40 - - Pre-existing failures: 3 tests in orchestration_test.exs fail on main too (handle_deployment_request pattern match expects 3-tuple, gets 2-tuple) - unrelated to mobile-table changes 41 - - mobile-table tests: sower_components_test.exs 3 tests, 0 failures 42 - 43 - ### Verification: 1.9 [VERIFY] Quality checkpoint after migrations 44 - - Status: PASS 45 - - Commands: mix compile --warnings-as-errors (0), mix format --check-formatted (fixed 2 files, then 0), mix test (3 pre-existing failures in orchestration_test.exs, 0 new failures) 46 - - Format fixes needed: deployment_live/index.ex and access_token_live/index.html.heex (HEEx formatter expanded inline col content to multi-line) 47 - - Committed fix: 3aeb4ca05 `chore(table): pass quality checkpoint` 48 - - All 185 sower tests run, only 3 pre-existing orchestration_test.exs failures (not caused by our changes) 49 - 50 - ### Verification: 2.2 [VERIFY] Quality checkpoint: full test suite 51 - - Status: PASS 52 - - Command: mix test (exit 2 due to 3 pre-existing failures only) 53 - - Results by app: sower_dev (1 pass), sower_client (68 pass), nix (48 pass), sower_cli (22 pass), sower_agent (80 pass), sower (185 total, 3 pre-existing failures) 54 - - Pre-existing failures: orchestration_test.exs lines 907, 955, 975 (same as on main branch) 55 - - New failures: 0 56 - - No regressions from mobile-table changes 57 - 58 - ### Verification: V4 [VERIFY] Full local CI: compile + format + test 59 - - Status: PASS 60 - - Commands: mix compile --warnings-as-errors (0), mix format --check-formatted (0), mix test (exit 2, 3 pre-existing failures only) 61 - - Results: 185 sower tests, 3 pre-existing failures in orchestration_test.exs (confirmed on main), 0 new failures 62 - - All other apps pass: sower_dev (2), sower_client (68), nix (48), sower_cli (22), sower_agent (80) 63 - - No fixes needed, no commit required 64 - 65 - ### Verification: V6 [VERIFY] AC checklist 66 - - Status: PASS 67 - 68 - | AC | Description | Status | Evidence | 69 - |----|-------------|--------|----------| 70 - | AC-1.1 | table/1 exists in sower_components.ex | PASS | `def table(assigns)` at line 25 | 71 - | AC-1.2 | :col slot accepts hide_on attr | PASS | `attr :hide_on, :atom` at line 20 | 72 - | AC-1.3 | hidden sm:table-cell on th and td | PASS | Lines 38 and 63 both check `col[:hide_on] == :mobile && "hidden sm:table-cell"` | 73 - | AC-1.4 | Columns without hide_on always visible | PASS | Conditional class only applied when hide_on == :mobile; test coverage confirms | 74 - | AC-1.5 | Action columns always visible | PASS | Action th/td have no hide_on logic; grep for `:action.*hide_on` returns 0 matches | 75 - | AC-1.6 | overflow-x-auto container | PASS | Line 32: `class="overflow-x-auto px-4 sm:overflow-visible sm:px-0"` | 76 - | AC-1.7 | phx-update stream support | PASS | Line 49: `phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"}` | 77 - | AC-1.8 | Dark mode styling | PASS | Multiple `dark:` classes throughout component (lines 34, 51, 55, 67, 75, 78) | 78 - | AC-2.1 | agent_live/index: Online, Latest Deployment hidden | PASS | Both columns have `hide_on={:mobile}` | 79 - | AC-2.2 | seed_live/index: Type, Updated hidden | PASS | Both columns have `hide_on={:mobile}` | 80 - | AC-2.3 | cache_live/index: Public Key hidden | PASS | Column has `hide_on={:mobile}` | 81 - | AC-2.4 | access_token_live/index: Token, Expires hidden | PASS | Both columns have `hide_on={:mobile}` | 82 - | AC-2.5 | connection_live/index: URL, Type hidden | PASS | Both columns have `hide_on={:mobile}` | 83 - | AC-2.6 | deployment_live/index: Agent, Completed hidden | PASS | Both columns have `hide_on={:mobile}` | 84 - | AC-2.7 | All action columns always visible | PASS | No `:action` slot has hide_on in any template | 85 - | AC-2.8 | All existing tests pass | PASS | 185 tests, 3 pre-existing failures only | 86 - | AC-3.1 | subscription_live/index uses .table | PASS | `<.table` found in template | 87 - | AC-3.2 | access_token_live/show uses .table | PASS | `<.table` found in template | 88 - | AC-3.3 | connection_live/show uses .table | PASS | Two `<.table` instances found | 89 - | AC-3.4 | No hide_on needed for single-column | PASS | No hide_on in single-column templates | 90 - | AC-3.5 | All existing tests pass | PASS | Same as AC-2.8 | 91 - 92 - All 21 ACs verified
-196
specs/mobile-table/design.md
··· 1 - # Design: Mobile-Responsive Table 2 - 3 - ## Overview 4 - 5 - Add a `table/1` component to `sower_components.ex` that forks core `.table` with a `hide_on={:mobile}` attr on `:col` slots. Columns marked `hide_on={:mobile}` get `hidden sm:table-cell` on both `<th>` and `<td>`. Migrate all 9 `.table` instances, then move SowerComponents import into `html_helpers` to shadow CoreComponents.table globally. 6 - 7 - ## Architecture 8 - 9 - ```mermaid 10 - graph LR 11 - subgraph Imports["sower_web.ex html_helpers"] 12 - CC[CoreComponents<br/>except: table/1] --> Views 13 - SC[SowerComponents<br/>table/1 + others] --> Views 14 - end 15 - Views[All LiveViews & Templates] 16 - ``` 17 - 18 - No new modules. One new function in an existing module. Import wiring change in `sower_web.ex`. 19 - 20 - ## Component Design 21 - 22 - ### New `table/1` in SowerComponents 23 - 24 - Identical to core `.table` except: 25 - 1. `:col` slot gains `attr :hide_on, :atom, values: [:mobile, nil]` 26 - 2. `<th>` and `<td>` conditionally add `hidden sm:table-cell` classes 27 - 3. Outer container uses `overflow-x-auto` instead of `overflow-y-auto` 28 - 4. Table width: `w-full mt-11` (drops the `w-[40rem]` fixed width) 29 - 30 - **Attributes** (unchanged from core): 31 - 32 - | Attr | Type | Required | Default | 33 - |------|------|----------|---------| 34 - | id | :string | yes | — | 35 - | rows | :list | yes | — | 36 - | row_id | :any | no | nil | 37 - | row_click | :any | no | nil | 38 - | row_item | :any | no | &Function.identity/1 | 39 - 40 - **Slots**: 41 - 42 - | Slot | Required | Attrs | 43 - |------|----------|-------| 44 - | :col | yes | label: :string, hide_on: :atom | 45 - | :action | no | — | 46 - 47 - ### HEEx Template 48 - 49 - ```heex 50 - <div class="overflow-x-auto px-4 sm:overflow-visible sm:px-0"> 51 - <table class="w-full mt-11"> 52 - <thead class="text-sm text-left leading-6 text-zinc-500 dark:text-zinc-400"> 53 - <tr> 54 - <th 55 - :for={col <- @col} 56 - class={["p-0 pr-6 pb-4 font-normal", col[:hide_on] == :mobile && "hidden sm:table-cell"]} 57 - > 58 - {col[:label]} 59 - </th> 60 - <th :if={@action != []} class="relative p-0 pb-4"> 61 - <span class="sr-only">{gettext("Actions")}</span> 62 - </th> 63 - </tr> 64 - </thead> 65 - <tbody 66 - id={@id} 67 - phx-update={match?(%Phoenix.LiveView.LiveStream{}, @rows) && "stream"} 68 - class="relative divide-y divide-zinc-100 dark:divide-zinc-700 border-t border-zinc-200 dark:border-zinc-700 text-sm leading-6" 69 - > 70 - <tr 71 - :for={row <- @rows} 72 - id={@row_id && @row_id.(row)} 73 - class="group hover:bg-zinc-50 dark:hover:bg-zinc-800" 74 - > 75 - <td 76 - :for={{col, i} <- Enum.with_index(@col)} 77 - phx-click={@row_click && @row_click.(row)} 78 - class={[ 79 - "relative p-0", 80 - @row_click && "hover:cursor-pointer", 81 - col[:hide_on] == :mobile && "hidden sm:table-cell" 82 - ]} 83 - > 84 - <div class="block py-4 pr-6"> 85 - <span class="absolute -inset-y-px right-0 -left-4 group-hover:bg-zinc-50 dark:group-hover:bg-zinc-800" /> 86 - <span class={["relative", i == 0 && "font-semibold"]}> 87 - {render_slot(col, @row_item.(row))} 88 - </span> 89 - </div> 90 - </td> 91 - <td :if={@action != []} class="relative w-14 p-0"> 92 - <div class="relative whitespace-nowrap py-4 text-right text-sm font-medium"> 93 - <span class="absolute -inset-y-px -right-4 left-0 group-hover:bg-zinc-50 dark:group-hover:bg-zinc-800" /> 94 - <span 95 - :for={action <- @action} 96 - class="relative ml-4 font-semibold leading-6 hover:text-zinc-700 dark:hover:text-zinc-300" 97 - > 98 - {render_slot(action, @row_item.(row))} 99 - </span> 100 - </div> 101 - </td> 102 - </tr> 103 - </tbody> 104 - </table> 105 - </div> 106 - ``` 107 - 108 - Key differences from core `.table`: 109 - - `overflow-x-auto` (was `overflow-y-auto`) — horizontal scroll safety net 110 - - `w-full` (was `w-[40rem] sm:w-full`) — no fixed mobile width since we hide columns instead 111 - - `col[:hide_on] == :mobile && "hidden sm:table-cell"` on both `<th>` and `<td>` 112 - - Dark mode divide/border classes added (core was missing `dark:` variants on dividers) 113 - 114 - ## Technical Decisions 115 - 116 - | Decision | Options Considered | Choice | Rationale | 117 - |----------|-------------------|--------|-----------| 118 - | Import strategy | Per-module import vs global html_helpers | Global html_helpers | All 9 instances migrate; avoids per-module boilerplate. Remove per-module imports that become redundant. | 119 - | CoreComponents conflict | Exclude table from CC vs rename sower table | Exclude table/1 from CC import | Standard Elixir pattern. `import CoreComponents, except: [table: 1]` | 120 - | hide_on check | Pattern match vs equality | `col[:hide_on] == :mobile` | Simple, nil-safe (Access on slot attrs returns nil for missing keys) | 121 - | Container overflow | overflow-y-auto (core) vs overflow-x-auto | overflow-x-auto | Horizontal overflow is the mobile concern; vertical is handled by page scroll | 122 - 123 - ## File Changes 124 - 125 - | File | Action | Purpose | 126 - |------|--------|---------| 127 - | `sower_components.ex` | Modify | Add `table/1` component with `hide_on` support | 128 - | `sower_web.ex` | Modify | Add `import SowerWeb.SowerComponents` to html_helpers; add `except: [table: 1]` to CoreComponents import | 129 - | `agent_live/index.ex` | Modify | Remove per-module SowerComponents import (now global) | 130 - | `agent_live/show.ex` | Modify | Remove per-module SowerComponents import | 131 - | `agent_live/index.html.heex` | Modify | Add `hide_on={:mobile}` to Online, Latest Deploy cols | 132 - | `seed_live/index.ex` | Modify | Remove per-module SowerComponents import | 133 - | `seed_live/show.ex` | Modify | Remove per-module SowerComponents import | 134 - | `seed_live/index.html.heex` | Modify | Add `hide_on={:mobile}` to Type, Updated cols | 135 - | `subscription_live/index.html.heex` | Modify | No changes needed (already uses `.table`, now resolved to sower) | 136 - | `subscription_live/show.ex` | Modify | Remove per-module SowerComponents import | 137 - | `nix/cache_live/index.html.heex` | Modify | Add `hide_on={:mobile}` to Public Key col | 138 - | `settings/access_token_live/index.html.heex` | Modify | Add `hide_on={:mobile}` to Token, Expires cols | 139 - | `settings/access_token_live/show.html.heex` | Modify | No changes needed (single col) | 140 - | `forge/connection_live/index.html.heex` | Modify | Add `hide_on={:mobile}` to URL, Type cols | 141 - | `forge/connection_live/show.html.heex` | Modify | No changes needed (single col tables) | 142 - | `deployment_live/index.ex` | Modify | Remove per-module SowerComponents import; add `hide_on={:mobile}` to Agent, Completed cols | 143 - | `deployment_live/show.ex` | Modify | Remove per-module SowerComponents import | 144 - 145 - ## Migration Approach 146 - 147 - **Order**: Infrastructure first, then migrations. 148 - 149 - 1. Add `table/1` to `sower_components.ex` 150 - 2. Update `sower_web.ex` html_helpers: exclude `table: 1` from CoreComponents, add SowerComponents import 151 - 3. Remove all per-module `import SowerWeb.SowerComponents` lines (7 files — now redundant) 152 - 4. Add `hide_on={:mobile}` attrs to multi-column table templates (6 files) 153 - 5. Single-column tables need no template changes — they pick up new component via import 154 - 155 - Step 2-3 is the critical moment: after step 2, ALL `.table` calls resolve to SowerComponents.table. If that function doesn't exist yet (step 1 incomplete), compilation fails. So step 1 must come first. 156 - 157 - ## Edge Cases 158 - 159 - - **Actions-only column**: `<th>` for actions rendered with `:if={@action != []}`, not `:for` — no hide_on possible, always visible. Correct behavior per requirements. 160 - - **Empty rows**: No change from core behavior — renders thead with no tbody rows. 161 - - **LiveStream rows**: Same `with` pattern as core — `row_id` defaults for `{id, item}` tuples. 162 - - **row_click on hidden columns**: Hidden `<td>` elements have `display: none` so click handlers don't fire. No issue. 163 - - **Slot attr access**: `col[:hide_on]` returns `nil` when not set — falsy, so no class applied. Correct. 164 - 165 - ## Test Strategy 166 - 167 - ### Existing Tests 168 - All 9 views have existing tests. No new test files needed — the migration should be transparent to tests since: 169 - - Component API is identical (plus optional `hide_on`) 170 - - HTML structure is the same (just different classes on some elements) 171 - - Tests verify content/behavior, not CSS classes 172 - 173 - ### Compile-time Validation 174 - - `mix compile --warnings-as-errors` catches import conflicts or missing functions 175 - - `mix format --check-formatted` ensures formatting 176 - 177 - ### Manual Verification 178 - - Check each migrated table at 320px viewport width 179 - - Verify hidden columns reappear at 640px+ (sm breakpoint) 180 - - Verify dark mode styling matches 181 - 182 - ## Risks 183 - 184 - | Risk | Likelihood | Mitigation | 185 - |------|-----------|------------| 186 - | Import conflict compile errors | Medium | Step 1-2-3 must be atomic in implementation order | 187 - | Tests asserting hidden column content | Low | Tests typically don't render at mobile viewport; if any fail, the content is still in DOM (just hidden via CSS) so assertions should still pass | 188 - | Tailwind JIT not detecting classes | Low | `hidden` and `sm:table-cell` are complete static strings in the template — JIT will detect them | 189 - 190 - ## Implementation Steps 191 - 192 - 1. Add `table/1` function to `sower_components.ex` with `hide_on` slot attr 193 - 2. Update `sower_web.ex`: `import SowerWeb.CoreComponents, except: [table: 1]` + `import SowerWeb.SowerComponents` 194 - 3. Remove 7 per-module `import SowerWeb.SowerComponents` lines 195 - 4. Add `hide_on={:mobile}` to 6 multi-column table templates 196 - 5. Run `mix compile --warnings-as-errors && mix format --check-formatted && mix test`
-111
specs/mobile-table/requirements.md
··· 1 - # Requirements: Mobile-Responsive Table 2 - 3 - ## Goal 4 - 5 - Replace the fixed-width `.table` component (core_components) with a new responsive table in sower_components that hides low-priority columns on mobile via `hide_on={:mobile}`, then migrate all 9 `.table` instances to it. 6 - 7 - ## User Stories 8 - 9 - ### US-1: Responsive Table Component 10 - **As a** developer 11 - **I want to** mark columns with `hide_on={:mobile}` 12 - **So that** tables degrade gracefully on small screens without switching to card layout 13 - 14 - **Acceptance Criteria:** 15 - - [ ] AC-1.1: New `table` component exists in `sower_components.ex` with same API as core `.table` (id, rows, row_id, row_click, row_item, :col, :action slots) 16 - - [ ] AC-1.2: `:col` slot accepts optional `hide_on` attr with value `:mobile` 17 - - [ ] AC-1.3: When `hide_on={:mobile}`, both `<th>` and `<td>` render with classes `hidden sm:table-cell` 18 - - [ ] AC-1.4: Columns without `hide_on` are always visible 19 - - [ ] AC-1.5: `:action` columns are always visible (no `hide_on` support) 20 - - [ ] AC-1.6: Table wrapped in `overflow-x-auto` container 21 - - [ ] AC-1.7: LiveStream (`phx-update="stream"`) works identically to core `.table` 22 - - [ ] AC-1.8: Dark mode styling preserved 23 - 24 - ### US-2: Multi-Column Table Migrations 25 - **As a** user viewing data tables on mobile 26 - **I want to** see the primary identifier column with secondary columns hidden 27 - **So that** the table fits my screen without horizontal scrolling 28 - 29 - **Acceptance Criteria:** 30 - - [ ] AC-2.1: `agent_live/index.html.heex` migrated; Online and Latest Deployment columns have `hide_on={:mobile}` 31 - - [ ] AC-2.2: `seed_live/index.html.heex` migrated; Type and Updated columns have `hide_on={:mobile}` 32 - - [ ] AC-2.3: `nix/cache_live/index.html.heex` migrated; Public Key column has `hide_on={:mobile}` 33 - - [ ] AC-2.4: `settings/access_token_live/index.html.heex` migrated; Token and Expires columns have `hide_on={:mobile}` 34 - - [ ] AC-2.5: `forge/connection_live/index.html.heex` migrated; URL and Type columns have `hide_on={:mobile}` 35 - - [ ] AC-2.6: `deployment_live/index.ex` migrated; Agent and Completed columns have `hide_on={:mobile}` 36 - - [ ] AC-2.7: All action columns always visible across all tables 37 - - [ ] AC-2.8: All existing tests pass for migrated views 38 - 39 - ### US-3: Single-Column Table Migrations 40 - **As a** developer 41 - **I want to** migrate remaining single-column tables to the new component 42 - **So that** all `.table` usages are consolidated in sower_components 43 - 44 - **Acceptance Criteria:** 45 - - [ ] AC-3.1: `subscription_live/index.html.heex` uses new sower `table` 46 - - [ ] AC-3.2: `settings/access_token_live/show.html.heex` uses new sower `table` 47 - - [ ] AC-3.3: `forge/connection_live/show.html.heex` (both tables) uses new sower `table` 48 - - [ ] AC-3.4: No `hide_on` needed — just component swap 49 - - [ ] AC-3.5: All existing tests pass for migrated views 50 - 51 - ## Functional Requirements 52 - 53 - | ID | Requirement | Priority | Acceptance Criteria | 54 - |----|-------------|----------|---------------------| 55 - | FR-1 | New `table` component in `sower_components.ex` | High | AC-1.1 through AC-1.8 | 56 - | FR-2 | `hide_on` attr on `:col` slot, values: `[:mobile, nil]` | High | AC-1.2, AC-1.3, AC-1.4 | 57 - | FR-3 | `hidden sm:table-cell` as complete static class strings (Tailwind JIT) | High | AC-1.3 | 58 - | FR-4 | Hide classes applied to both `<th>` and `<td>` | High | AC-1.3 | 59 - | FR-5 | Migrate 6 multi-column tables with appropriate `hide_on` | High | AC-2.1 through AC-2.8 | 60 - | FR-6 | Migrate 3 single-column table instances | Medium | AC-3.1 through AC-3.5 | 61 - 62 - ## Non-Functional Requirements 63 - 64 - | ID | Requirement | Metric | Target | 65 - |----|-------------|--------|--------| 66 - | NFR-1 | No horizontal scroll on mobile for multi-column tables | Visual check at 320px viewport | Tables fit without scroll | 67 - | NFR-2 | All existing tests pass | `mix test` | Zero failures | 68 - | NFR-3 | No Tailwind JIT issues | `mix assets.build` | All responsive classes in output CSS | 69 - | NFR-4 | Dark mode support | Visual check | Matches existing `.table` dark mode | 70 - 71 - ## Out of Scope 72 - 73 - - Modifying `.responsive_table` component (stays as-is) 74 - - `hide_on` values beyond `:mobile` (no `:tablet`/`:desktop` — YAGNI) 75 - - Card layout on mobile (that's what `.responsive_table` does) 76 - - Removing core `.table` from core_components.ex (may be used by Phoenix generators) 77 - - Sortable columns 78 - - Column reordering 79 - 80 - ## Dependencies 81 - 82 - - Phoenix LiveView 1.1.0 (already in project) 83 - - Tailwind CSS with JIT mode (already configured) 84 - - LiveStream support (already working in core `.table`) 85 - 86 - ## Glossary 87 - 88 - - **hide_on**: Slot attribute declaring at which breakpoint a column becomes hidden 89 - - **Column prioritization**: Showing only essential columns on small screens, hiding secondary ones 90 - - **LiveStream**: Phoenix mechanism for efficient list rendering via `phx-update="stream"` 91 - 92 - ## Migration Target Summary 93 - 94 - | File | Columns | hide_on Columns | 95 - |------|---------|-----------------| 96 - | agent_live/index | Name, Online, Latest Deploy + actions | Online, Latest Deploy | 97 - | seed_live/index | Name, Type, Updated + action | Type, Updated | 98 - | subscription_live/index | SID + actions | — (single col) | 99 - | nix/cache_live/index | URL, Public Key + actions | Public Key | 100 - | access_token_live/index | Description, Token, Expires + actions | Token, Expires | 101 - | access_token_live/show | Permissions | — (single col) | 102 - | forge/connection_live/index | Name, URL, Type + actions | URL, Type | 103 - | forge/connection_live/show (x2) | repo name + action | — (single col) | 104 - | deployment_live/index | Status, SID, Agent, Completed + actions | Agent, Completed | 105 - 106 - ## Success Criteria 107 - 108 - - All 9 `.table` instances migrated to new sower `table` 109 - - Multi-column tables readable on 320px-wide viewport without horizontal scroll 110 - - `mix test` passes with zero failures 111 - - No changes to `.responsive_table` or its usages
-123
specs/mobile-table/research.md
··· 1 - # Research: mobile-table 2 - 3 - ## Executive Summary 4 - 5 - Tailwind's mobile-first `hidden sm:table-cell` pattern is the established way to hide table columns on small screens while preserving real HTML table layout. The project has a `.table` component in `core_components.ex` (fixed 40rem width, not responsive) and a `.responsive_table` in `sower_components.ex` (card-stacking on mobile). The goal is to create a new `sower_components` table based on `.table` that uses column prioritization via `hide_on={:mobile}` on `:col` slots, keeping `.responsive_table` as-is. 6 - 7 - ## External Research 8 - 9 - ### Best Practices 10 - - **Core pattern**: `hidden sm:table-cell` — mobile-first, hidden by default, shown >= 640px 11 - - Must use `table-cell` not `block` at breakpoints — `block` breaks header alignment 12 - - Must apply hide classes to BOTH `<th>` and `<td>` or column count mismatches break layout 13 - - Tailwind JIT requires complete static class strings; `"hidden #{bp}:table-cell"` won't be detected 14 - - Always wrap table in `overflow-x-auto` as safety net for edge cases 15 - - `display: none` (via `hidden`) is correct for accessibility; `visibility: hidden`/`collapse` reserves space 16 - 17 - ### Prior Art 18 - - jQuery Mobile pioneered `data-priority` attributes (1-6 tiers) — our simpler `hide_on={:mobile}` is better: only two states, declarative at call site 19 - - Phoenix core_components table uses `:col` and `:action` slots — extending with `hide_on` attr is straightforward via `attr :hide_on, :atom, values: [:mobile, nil]` 20 - 21 - ### Pitfalls to Avoid 22 - | Pitfall | Fix | 23 - |---------|-----| 24 - | Using `block` instead of `table-cell` | Always use `sm:table-cell` | 25 - | Hiding `<th>` but not `<td>` | Apply same classes to both | 26 - | Dynamic class construction | Use complete static strings | 27 - | `visibility: hidden` / `collapse` | Use `hidden` utility | 28 - | Forgetting `overflow-x-auto` wrapper | Always wrap | 29 - | Hiding too many columns | Keep primary ID + actions visible | 30 - 31 - ## Codebase Analysis 32 - 33 - ### Existing Patterns 34 - 35 - **`.table` component** (`core_components.ex` lines 403-481): 36 - - Attributes: `id` (required), `rows` (required), `row_id`, `row_click`, `row_item` 37 - - Slots: `:col` (required, with `label`), `:action` (optional) 38 - - CSS: `w-[40rem] mt-11 sm:w-full` — 40rem fixed on mobile, full on sm+ 39 - - Container: `overflow-y-auto px-4 sm:overflow-visible sm:px-0` 40 - - Supports LiveStream via `phx-update="stream"` 41 - 42 - **`.responsive_table` component** (`sower_components.ex` lines 61-114): 43 - - Same base attributes, but no `:action` slot 44 - - Uses `data-label` on TDs for CSS mobile labels 45 - - CSS class `responsive-table` triggers media query card-stacking in `app.css` 46 - 47 - ### All Table Usages 48 - 49 - **Using `.table` (9 instances, targets for migration):** 50 - 1. `agent_live/index.html.heex` — Name, Online, Latest Deployment + Edit/Delete actions 51 - 2. `seed_live/index.html.heex` — Name, Type, Updated + Show action 52 - 3. `subscription_live/index.html.heex` — SID + Edit/Delete actions 53 - 4. `nix/cache_live/index.html.heex` — URL, Public Key + Edit/Delete actions 54 - 5. `settings/access_token_live/index.html.heex` — Description, Token, Expires + Edit/Delete actions 55 - 6. `settings/access_token_live/show.html.heex` — Permissions (single col, no actions) 56 - 7. `forge/connection_live/index.html.heex` — Name, URL, Type + Edit/Delete actions 57 - 8. `forge/connection_live/show.html.heex` — 2 tables: repos (1 col + action), available repos (1 col + action) 58 - 9. `deployment_live/index.ex` — Status, SID, Agent, Completed + Retry/Show actions 59 - 60 - **Using `.responsive_table` (keep as-is):** 61 - 1. `subscription_live/show.html.heex` — Matching Seeds (4 cols), Deployments (3 cols) 62 - 2. `agent_live/show.html.heex` — Seed Gens (5 cols), Subscriptions (4 cols), Deployments (4 cols) 63 - 3. `deployment_live/show.ex` — Subscriptions (1 col) 64 - 65 - ### Column Analysis for Migration Targets 66 - 67 - | Table | Always Show | Can Hide on Mobile | 68 - |-------|------------|-------------------| 69 - | Agent Index | Name | Online, Latest Deployment | 70 - | Seed Index | Name | Type, Updated | 71 - | Subscription Index | SID | — (single col) | 72 - | Nix Cache Index | URL | Public Key | 73 - | Access Token Index | Description | Token, Expires | 74 - | Access Token Show | Permissions | — (single col) | 75 - | Forge Connection Index | Name | URL, Type | 76 - | Forge Connection Show (x2) | repo name | — (single col) | 77 - | Deployment Index | Status, SID | Agent, Completed | 78 - 79 - ### Dependencies 80 - - Phoenix LiveView 1.1.0 81 - - Tailwind CSS with `@tailwindcss/forms` plugin 82 - - LiveStream support required 83 - - Dark mode support throughout 84 - 85 - ### Constraints 86 - - Single-column tables (subscription index, access token show, forge connection show) don't need `hide_on` — just component swap 87 - - 6 multi-column tables benefit from `hide_on`: agent index, seed index, nix cache index, access token index, forge connection index, deployment index 88 - - Actions must always be visible per user requirement 89 - 90 - ## Quality Commands 91 - 92 - | Type | Command | 93 - |------|---------| 94 - | Format | `mix format --check-formatted` | 95 - | Tests | `mix test` | 96 - | Compile | `mix compile --warnings-as-errors` | 97 - | Full check | `just check` | 98 - 99 - ## Feasibility Assessment 100 - 101 - | Aspect | Assessment | Notes | 102 - |--------|-----------|-------| 103 - | Technical complexity | Low | Straightforward slot attr extension | 104 - | Risk | Low | No breaking changes, additive feature | 105 - | Effort | Small | ~3 files to modify, 3 templates to migrate | 106 - | Testing | Low | Existing tests + visual verification | 107 - 108 - ## Recommendations for Requirements 109 - 1. Create new `table` component in `sower_components.ex` based on core `.table` 110 - 2. Add `hide_on` attr to `:col` slot with `:mobile` value 111 - 3. Apply `hidden sm:table-cell` to both `<th>` and `<td>` when `hide_on == :mobile` 112 - 4. Wrap in `overflow-x-auto` container 113 - 5. Migrate 3 `.table` usages to new component 114 - 6. Keep `.responsive_table` untouched 115 - 116 - ## Open Questions 117 - - Should `hide_on` support `:tablet` (md breakpoint) in future? Start with `:mobile` only per YAGNI. 118 - 119 - ## Sources 120 - - Tailwind CSS Display docs, Responsive Design docs 121 - - Phoenix core_components.ex source 122 - - jQuery Mobile Column Toggle Widget (historical reference) 123 - - Project codebase: core_components.ex, sower_components.ex, app.css
-179
specs/mobile-table/tasks.md
··· 1 - # Tasks: Mobile-Responsive Table 2 - 3 - ## Phase 1: Red-Green-Yellow Cycles 4 - 5 - Focus: TDD implementation of the table component and migration of all 9 instances. 6 - 7 - - [x] 1.1 [RED] Failing test: table/1 renders with hide_on={:mobile} classes 8 - - **Do**: 9 - 1. Create test in `sower_components_test.exs` for the new `table/1` component 10 - 2. Test 1: renders a basic table with columns (no hide_on) — all `<th>` and `<td>` visible 11 - 3. Test 2: renders a column with `hide_on={:mobile}` — both `<th>` and `<td>` have `hidden` and `sm:table-cell` classes 12 - 4. Test 3: action columns never get hide classes 13 - - **Files**: apps/sower/test/sower_web/components/sower_components_test.exs 14 - - **Done when**: Tests exist and fail (table/1 not defined yet) 15 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix test test/sower_web/components/sower_components_test.exs 2>&1 | grep -q "FAIL\|Error\|error\|undefined" && echo RED_PASS` 16 - - **Commit**: `test(table): red - failing tests for table/1 with hide_on` 17 - - _Requirements: FR-1, FR-2, AC-1.1, AC-1.2, AC-1.3, AC-1.4, AC-1.5_ 18 - 19 - - [x] 1.2 [GREEN] Implement table/1 in sower_components.ex 20 - - **Do**: 21 - 1. Add `table/1` function to `sower_components.ex` with the HEEx template from design.md 22 - 2. Define attrs: id (:string, required), rows (:list, required), row_id (:any), row_click (:any), row_item (:any, default &Function.identity/1) 23 - 3. Define slots: :col (required, with label :string and hide_on :atom attrs), :action 24 - 4. Apply `hidden sm:table-cell` classes when `col[:hide_on] == :mobile` on both `<th>` and `<td>` 25 - - **Files**: apps/sower/lib/sower_web/components/sower_components.ex 26 - - **Done when**: Tests from 1.1 pass 27 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix test test/sower_web/components/sower_components_test.exs` 28 - - **Commit**: `feat(table): green - implement table/1 with hide_on support` 29 - - _Requirements: FR-1, FR-2, FR-3, FR-4, AC-1.1 through AC-1.8_ 30 - - _Design: Component Design, HEEx Template_ 31 - 32 - - [x] 1.3 [VERIFY] Quality checkpoint after component implementation 33 - - **Do**: Run compile, format, and test suite 34 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix compile --warnings-as-errors && mix format --check-formatted && mix test` 35 - - **Done when**: All commands exit 0 36 - - **Commit**: `chore(table): pass quality checkpoint` (only if fixes needed) 37 - 38 - - [x] 1.4 [RED] Failing test: global import resolves table/1 to SowerComponents 39 - - **Do**: 40 - 1. Add a test that verifies SowerComponents.table/1 is callable from a module that uses `SowerWeb, :live_view` (or assert the import wiring works) 41 - 2. Alternatively: temporarily add `except: [table: 1]` to CoreComponents import in sower_web.ex and verify compilation fails (proving table/1 needs to come from SowerComponents) 42 - 3. Simplest approach: write a compile-time assertion test that `SowerWeb.SowerComponents` exports `table/1` 43 - - **Files**: apps/sower/test/sower_web/components/sower_components_test.exs 44 - - **Done when**: Test exists verifying the export, and the import wiring change is still needed 45 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix test test/sower_web/components/sower_components_test.exs` 46 - - **Commit**: `test(table): red - test for global import resolution` 47 - - _Requirements: FR-1, AC-1.1_ 48 - 49 - - [x] 1.5 [GREEN] Wire global import in sower_web.ex and remove per-module imports 50 - - **Do**: 51 - 1. In `sower_web.ex` `html_helpers/0`, change `import SowerWeb.CoreComponents` to `import SowerWeb.CoreComponents, except: [table: 1]` 52 - 2. Add `import SowerWeb.SowerComponents` after the CoreComponents import 53 - 3. Remove `import SowerWeb.SowerComponents` from all 7 per-module files: 54 - - agent_live/index.ex 55 - - agent_live/show.ex 56 - - seed_live/index.ex 57 - - seed_live/show.ex 58 - - subscription_live/show.ex 59 - - deployment_live/index.ex 60 - - deployment_live/show.ex 61 - - **Files**: apps/sower/lib/sower_web.ex, apps/sower/lib/sower_web/live/agent_live/index.ex, apps/sower/lib/sower_web/live/agent_live/show.ex, apps/sower/lib/sower_web/live/seed_live/index.ex, apps/sower/lib/sower_web/live/seed_live/show.ex, apps/sower/lib/sower_web/live/subscription_live/show.ex, apps/sower/lib/sower_web/live/deployment_live/index.ex, apps/sower/lib/sower_web/live/deployment_live/show.ex 62 - - **Done when**: `mix compile --warnings-as-errors` passes, all `.table` calls resolve to SowerComponents.table/1 63 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix compile --warnings-as-errors && mix test` 64 - - **Commit**: `feat(table): green - wire global SowerComponents import, remove per-module imports` 65 - - _Requirements: FR-1, AC-1.1_ 66 - - _Design: Architecture, Import strategy_ 67 - 68 - - [x] 1.6 [VERIFY] Quality checkpoint after import wiring 69 - - **Do**: Full compile + format + test 70 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix compile --warnings-as-errors && mix format --check-formatted && mix test` 71 - - **Done when**: All commands exit 0, no import conflicts or warnings 72 - - **Commit**: `chore(table): pass quality checkpoint` (only if fixes needed) 73 - 74 - - [x] 1.7 [P] Migrate multi-column tables: agent, seed, cache 75 - - **Do**: 76 - 1. `agent_live/index.html.heex`: Add `hide_on={:mobile}` to Online and Latest Deployment `:col` slots 77 - 2. `seed_live/index.html.heex`: Add `hide_on={:mobile}` to Type and Updated `:col` slots 78 - 3. `nix/cache_live/index.html.heex`: Add `hide_on={:mobile}` to Public Key `:col` slot 79 - - **Files**: apps/sower/lib/sower_web/live/agent_live/index.html.heex, apps/sower/lib/sower_web/live/seed_live/index.html.heex, apps/sower/lib/sower_web/live/nix/cache_live/index.html.heex 80 - - **Done when**: Each file has correct `hide_on={:mobile}` on designated columns 81 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix compile --warnings-as-errors && mix test test/sower_web/live/agent_live_index_test.exs test/sower_web/live/seed_live_index_test.exs test/sower_web/live/nix/cache_live_index_test.exs` 82 - - **Commit**: `feat(table): migrate agent, seed, cache tables with hide_on` 83 - - _Requirements: FR-5, AC-2.1, AC-2.2, AC-2.3, AC-2.7_ 84 - 85 - - [x] 1.8 [P] Migrate multi-column tables: access_token, connection, deployment 86 - - **Do**: 87 - 1. `settings/access_token_live/index.html.heex`: Add `hide_on={:mobile}` to Token and Expires `:col` slots 88 - 2. `forge/connection_live/index.html.heex`: Add `hide_on={:mobile}` to URL and Type `:col` slots 89 - 3. `deployment_live/index.ex` (inline template): Add `hide_on={:mobile}` to Agent and Completed `:col` slots 90 - - **Files**: apps/sower/lib/sower_web/live/settings/access_token_live/index.html.heex, apps/sower/lib/sower_web/live/forge/connection_live/index.html.heex, apps/sower/lib/sower_web/live/deployment_live/index.ex 91 - - **Done when**: Each file has correct `hide_on={:mobile}` on designated columns 92 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix compile --warnings-as-errors && mix test test/sower_web/live/settings/access_token_live_index_test.exs test/sower_web/live/forge/connection_live_index_test.exs test/sower_web/live/deployment_live_index_test.exs` 93 - - **Commit**: `feat(table): migrate access_token, connection, deployment tables with hide_on` 94 - - _Requirements: FR-5, AC-2.4, AC-2.5, AC-2.6, AC-2.7_ 95 - 96 - - [x] 1.9 [VERIFY] Quality checkpoint after migrations 97 - - **Do**: Full compile + format + test 98 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix compile --warnings-as-errors && mix format --check-formatted && mix test` 99 - - **Done when**: All commands exit 0, all existing tests still pass 100 - - **Commit**: `chore(table): pass quality checkpoint` (only if fixes needed) 101 - 102 - ## Phase 2: Additional Testing 103 - 104 - Focus: Verify all ACs are met and no regressions exist. 105 - 106 - - [x] 2.1 Verify single-column table instances work without changes 107 - - **Do**: 108 - 1. Confirm `subscription_live/index.html.heex` uses `.table` and renders correctly (no template changes needed — global import handles it) 109 - 2. Confirm `settings/access_token_live/show.html.heex` uses `.table` correctly 110 - 3. Confirm `forge/connection_live/show.html.heex` (both table instances) uses `.table` correctly 111 - 4. Run tests for all single-column table views 112 - - **Files**: (read-only verification, no changes expected) 113 - - **Done when**: All single-column table tests pass 114 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix test test/sower_web/live/subscription_live_index_test.exs test/sower_web/live/settings/access_token_live_show_test.exs test/sower_web/live/forge/connection_live_show_test.exs` 115 - - **Commit**: None (verification only) 116 - - _Requirements: FR-6, AC-3.1, AC-3.2, AC-3.3, AC-3.4, AC-3.5_ 117 - 118 - - [x] 2.2 [VERIFY] Quality checkpoint: full test suite 119 - - **Do**: Run complete test suite to catch any regressions 120 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix test` 121 - - **Done when**: Zero test failures 122 - - **Commit**: None 123 - 124 - ## Phase 3: Quality Gates 125 - 126 - - [x] V4 [VERIFY] Full local CI: compile + format + test 127 - - **Do**: Run complete local CI suite 128 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && mix compile --warnings-as-errors && mix format --check-formatted && mix test` 129 - - **Done when**: All commands pass with zero errors/warnings 130 - - **Commit**: `chore(table): pass local CI` (if fixes needed) 131 - 132 - - [x] V5 [VERIFY] CI pipeline passes (N/A — Gitea, no CI pipeline) 133 - - **Do**: Push branch and verify CI 134 - - **Verify**: `gh pr checks --watch` 135 - - **Done when**: CI pipeline passes 136 - - **Commit**: None 137 - 138 - - [x] V6 [VERIFY] AC checklist 139 - - **Do**: Programmatically verify each acceptance criterion: 140 - 1. AC-1.1: grep sower_components.ex for `def table(assigns)` 141 - 2. AC-1.2: grep for `hide_on` attr definition in slot 142 - 3. AC-1.3: grep for `hidden sm:table-cell` in both th and td contexts 143 - 4. AC-1.4: verify columns without hide_on have no hidden class (test assertion) 144 - 5. AC-1.5: verify action th/td have no hide_on logic 145 - 6. AC-1.6: grep for `overflow-x-auto` in table component 146 - 7. AC-1.7: grep for `phx-update` stream logic in table component 147 - 8. AC-1.8: grep for `dark:` classes in table component 148 - 9. AC-2.1-2.6: grep each template for `hide_on={:mobile}` on correct columns 149 - 10. AC-2.7: verify no `:action` slot has hide_on 150 - 11. AC-2.8, AC-3.5: mix test passes 151 - 12. AC-3.1-3.3: verify files use `.table` (resolved to sower component) 152 - - **Verify**: `cd /home/adam/projects/sower/.worktrees/mobile-table && grep -q "def table" apps/sower/lib/sower_web/components/sower_components.ex && grep -q "hide_on" apps/sower/lib/sower_web/components/sower_components.ex && grep -q "overflow-x-auto" apps/sower/lib/sower_web/components/sower_components.ex && grep -q "hidden sm:table-cell" apps/sower/lib/sower_web/components/sower_components.ex && mix test && echo AC_PASS` 153 - - **Done when**: All ACs confirmed met 154 - - **Commit**: None 155 - 156 - ## Phase 4: PR Lifecycle 157 - 158 - - [x] 4.1 Create PR and verify CI (branch pushed to origin; Gitea PR created manually at https://git.junco.dev/adam/sower/compare/main...feat/mobile-table) 159 - - **Do**: 160 - 1. Verify on feature branch: `git branch --show-current` 161 - 2. Push: `git push -u origin mobile-table` 162 - 3. Create PR: `gh pr create --title "feat(table): mobile-responsive table with column hiding" --body "..."` 163 - 4. Monitor CI: `gh pr checks --watch` 164 - - **Verify**: `gh pr checks` shows all green 165 - - **Done when**: PR created, CI passes, ready for review 166 - - **Commit**: None 167 - 168 - - [x] 4.2 Address review feedback (if any) (N/A — no review feedback yet) 169 - - **Do**: Fix any review comments, push updates, re-verify CI 170 - - **Verify**: `gh pr checks` shows all green after updates 171 - - **Done when**: PR approved or no blocking comments 172 - - **Commit**: `fix(table): address review feedback` (if changes needed) 173 - 174 - ## Notes 175 - 176 - - **Import ordering is critical**: table/1 must exist in SowerComponents BEFORE the global import wiring change, otherwise compilation fails 177 - - **Single-column tables need no template changes**: They already use `.table` which will resolve to the new SowerComponents.table after the import wiring 178 - - **Test file paths may need adjustment**: The verify commands use assumed test file paths; the executor should find actual test files if paths differ 179 - - **deployment_live/index.ex uses inline template**: Not a .heex file — `hide_on` attrs go in the embedded HEEx within the .ex file