Deployment and lifecycle management for Nix
0
fork

Configure Feed

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

agent/cli: use a shared config

+628 -228
-3
.envrc
··· 29 29 # # shellcheck disable=SC2090 30 30 export ERL_AFLAGS 31 31 32 - export SOWER_AGENT_CONFIG=$PWD/dev-agent.json 33 - export SOWER_AGENT_ACCESS_TOKEN_FILE=$PWD/.dev-api-token 34 - 35 32 export MIX_OS_DEPS_COMPILE_PARTITION_COUNT=8
+60 -194
apps/sower_agent/lib/sower_agent/config.ex
··· 1 1 defmodule SowerAgent.Config do 2 - alias OpenApiSpex.Schema 3 - require Logger 4 - require OpenApiSpex 2 + @moduledoc """ 3 + Agent configuration management. 4 + """ 5 5 6 - # Could you define a behavior with different runtime/compiletime callbacks 7 - # with a simple Mod.func call in config which would be an entrypoint 6 + require Logger 8 7 9 8 @app :sower_agent 10 9 11 - OpenApiSpex.schema(%{ 12 - title: "Config", 13 - type: :object, 14 - properties: %{ 15 - access_token: %Schema{ 16 - type: :string, 17 - description: "Sower access token", 18 - readOnly: true 19 - }, 20 - config_path: %Schema{ 21 - type: :string, 22 - description: "config file path", 23 - readOnly: true 24 - }, 25 - endpoint: %Schema{ 26 - type: :string, 27 - format: :uri, 28 - description: "Sower endpoint", 29 - example: "https://my.sower.dev" 30 - }, 31 - name: %Schema{ 32 - type: :string, 33 - description: "Agent name", 34 - default: "system hostname" 35 - }, 36 - state_directory: %Schema{ 37 - type: :string, 38 - description: "directory where state files are written", 39 - default: "/var/lib/sower_agent" 40 - }, 41 - subscriptions: %Schema{ 42 - type: :array, 43 - items: SowerClient.Schemas.Orchestration.Subscription, 44 - default: [] 45 - } 46 - }, 47 - required: [:access_token, :endpoint] 48 - }) 49 - 50 - def get() do 10 + def get do 51 11 Application.get_env(@app, :config) 52 12 end 53 13 54 14 def load(config_map \\ %{}) do 55 - Application.ensure_all_started(:logger) 56 - 57 - spec = 58 - %OpenApiSpex.OpenApi{ 59 - info: %OpenApiSpex.Info{title: "Config", version: "1.0.0"}, 60 - paths: %{}, 61 - components: nil 62 - } 63 - |> OpenApiSpex.resolve_schema_modules() 64 - |> OpenApiSpex.add_schemas([SowerAgent.Config]) 65 - 66 15 cfg = 67 - defaults() 68 - |> Map.merge(config_map) 69 - |> add_config_file() 70 - |> parse_files_to_values() 71 - |> OpenApiSpex.cast_value(spec.components.schemas["Config"], spec) 72 - |> case do 73 - {:ok, cfg} -> 74 - cfg 75 - 76 - {:error, errors} -> 77 - Logger.error(msg: "Failed to read configuration", errors: errors) 78 - Kernel.exit(1) 79 - end 80 - # pull apart and put back together 81 - |> Map.to_list() 82 - # process side effects 83 - |> Enum.reduce([], fn config, acc -> process_side_effects(config, acc) end) 84 - |> Map.new() 85 - |> then(&struct(__MODULE__, &1)) 16 + SowerClient.Config.load(config_map, 17 + defaults: defaults() 18 + ) 19 + |> validate_required!() 20 + |> process_side_effects() 86 21 87 22 Application.put_env(@app, :config, cfg) 88 - end 89 - 90 - @doc """ 91 - external_config is processed for each child in the config. 92 - It allows for mapping from a config file format to elixir native config manually. 93 - """ 94 - def process_side_effects({:endpoint, endpoint}, acc) do 95 - uri = URI.parse(endpoint) 96 - 97 - uri = 98 - Map.put(uri, :scheme, String.replace(uri.scheme, "http", "ws")) 99 - |> Map.put( 100 - :path, 101 - case uri.path do 102 - nil -> 103 - "/agent/websocket" 104 - 105 - p when is_binary(p) -> 106 - if String.ends_with?(p, "agent/websocket") do 107 - p 108 - else 109 - p <> "/agent/websocket" 110 - end 111 - end 112 - ) 113 - 114 - Application.put_env(SowerAgent.Client, :uri, uri) 115 - Application.put_env(SowerAgent.Client, :reconnect_after_msec, [200, 500, 1_000, 2_000]) 116 - 117 - acc 118 - end 119 - 120 - def process_side_effects({:state_directory, dir}, acc) do 121 - Keyword.put(acc, :state_directory, Path.expand(dir)) 122 - end 123 - 124 - def process_side_effects({:__struct__, _}, acc), do: acc 125 - def process_side_effects({key, val}, acc), do: Keyword.put(acc, key, val) 126 - 127 - def add_config_file(cfg) do 128 23 cfg 129 - |> Map.merge(read_config_file(cfg["config_path"])) 130 24 end 131 25 132 - def defaults() do 26 + def defaults do 133 27 %{ 134 28 "name" => default_agent_name(), 135 - "config_path" => System.get_env("SOWER_AGENT_CONFIG", default_config_file()), 136 29 "state_directory" => default_state_dir() 137 30 } 138 31 end 139 32 140 - def default_agent_name() do 33 + def default_agent_name do 141 34 :inet.gethostname() |> then(fn {:ok, hostname} -> to_string(hostname) end) 142 35 end 143 36 144 - def default_state_dir() do 145 - case System.get_env("USER") do 146 - user when user != "root" -> 147 - System.get_env("XDG_STATE_HOME", Path.join(System.fetch_env!("HOME"), ".local/state")) 148 - |> Path.join("sower_agent") 149 - 150 - _ -> 151 - "/var/lib/sower_agent" 152 - end 37 + def default_state_dir do 38 + SowerClient.Config.xdg_state_path("sower_agent") 153 39 end 154 40 155 - def read_config_file(file) when not is_nil(file) do 156 - file = Path.absname(file) 157 - 158 - if File.exists?(file) do 159 - file 160 - |> File.read!() 161 - |> Jason.decode!() 162 - |> normalize_subscription_rules() 163 - else 164 - Logger.warning(msg: "Config file is missing!", file: file) 165 - %{} 166 - end 41 + def reload do 42 + Application.put_env(:sower_agent, :config, load(%{})) 43 + Application.stop(:sower_agent) 44 + Application.start(:sower_agent) 167 45 end 168 46 169 - defp normalize_subscription_rules(%{"subscriptions" => subscriptions} = config) 170 - when is_list(subscriptions) do 171 - normalized_subscriptions = 172 - Enum.map(subscriptions, fn subscription -> 173 - case subscription do 174 - %{"rules" => rules} when is_list(rules) -> 175 - normalized_rules = 176 - Enum.map(rules, fn rule -> 177 - case rule do 178 - rule when is_binary(rule) -> 179 - SowerClient.SubscriptionRuleFormat.parse!(rule) 47 + defp validate_required!(%SowerClient.Config{} = config) do 48 + errors = [] 180 49 181 - rule when is_map(rule) -> 182 - rule 183 - end 184 - end) 50 + errors = 51 + if is_nil(config.endpoint) do 52 + ["endpoint is required" | errors] 53 + else 54 + errors 55 + end 185 56 186 - Map.put(subscription, "rules", normalized_rules) 57 + errors = 58 + if is_nil(config.access_token) do 59 + ["access_token is required" | errors] 60 + else 61 + errors 62 + end 187 63 188 - subscription -> 189 - subscription 190 - end 191 - end) 192 - 193 - Map.put(config, "subscriptions", normalized_subscriptions) 194 - end 195 - 196 - defp normalize_subscription_rules(config), do: config 197 - 198 - def default_config_file() do 199 - case System.get_env("USER") do 200 - user when user != "root" -> 201 - System.get_env("XDG_CONFIG_HOME", Path.join(System.fetch_env!("HOME"), ".config")) 202 - |> Path.join("sower/agent.json") 64 + case errors do 65 + [] -> 66 + config 203 67 204 68 _ -> 205 - "/etc/sower/agent.json" 69 + Logger.error(msg: "Configuration validation failed", errors: errors) 70 + Kernel.exit(1) 206 71 end 207 72 end 208 73 209 - def reload() do 210 - Application.put_env(:sower_agent, :config, load(%{})) 211 - Application.stop(:sower_agent) 212 - Application.start(:sower_agent) 213 - end 74 + defp process_side_effects(%SowerClient.Config{} = config) do 75 + # Configure websocket client 76 + uri = URI.parse(config.endpoint) 77 + 78 + uri = 79 + Map.put(uri, :scheme, String.replace(uri.scheme, "http", "ws")) 80 + |> Map.put( 81 + :path, 82 + case uri.path do 83 + nil -> 84 + "/agent/websocket" 214 85 215 - defp parse_files_to_values(config_map) do 216 - config_map 217 - |> Enum.map(fn {key, value} -> 218 - key = 219 - if is_atom(key) do 220 - Atom.to_string(key) 221 - else 222 - key 86 + p when is_binary(p) -> 87 + if String.ends_with?(p, "agent/websocket") do 88 + p 89 + else 90 + p <> "/agent/websocket" 91 + end 223 92 end 93 + ) 224 94 225 - if String.ends_with?(key, "_file") do 226 - real_key = String.trim(key, "_file") 227 - value = value |> Path.absname() |> File.read!() |> String.trim() 228 - {real_key, value} 229 - else 230 - {key, value} 231 - end 232 - end) 233 - |> Map.new() 95 + Application.put_env(SowerAgent.Client, :uri, uri) 96 + Application.put_env(SowerAgent.Client, :reconnect_after_msec, [200, 500, 1_000, 2_000]) 97 + 98 + # Expand state_directory path 99 + %{config | state_directory: Path.expand(config.state_directory)} 234 100 end 235 101 end
+5
apps/sower_cli/lib/sower_cli.ex
··· 6 6 require Logger 7 7 8 8 def main(argv) do 9 + # Load config at startup 10 + SowerCli.Config.load() 11 + 12 + Application.get_all_env(:sower_cli) 13 + 9 14 config() 10 15 |> Optimus.parse!(argv) 11 16 |> run()
+22 -7
apps/sower_cli/lib/sower_cli/build.ex
··· 42 42 end 43 43 44 44 defp build_steps(%{eval_only: true}), do: [:eval] 45 - defp build_steps(%{push: true}), do: [:eval, :build, :push] 46 45 defp build_steps(%{seed: true}), do: [:eval, :build, :push, :seed] 46 + defp build_steps(%{push: true}), do: [:eval, :build, :push] 47 47 defp build_steps(_), do: [:eval, :build] 48 48 49 49 defp validate_options(steps, options) do 50 50 cond do 51 51 :push in steps and is_nil(options.cache) -> 52 - Output.error("--cache is required for --push") 53 - {:error, :missing_cache} 52 + config = SowerCli.Config.get() 54 53 55 - :seed in steps and is_nil(options.endpoint) -> 56 - Output.error("--endpoint is required for --seed") 57 - {:error, :missing_endpoint} 54 + if is_nil(config.cache) do 55 + Output.error("--cache is required for --push") 56 + {:error, :missing_cache} 57 + else 58 + :ok 59 + end 60 + 61 + :seed in steps -> 62 + config = SowerCli.Config.get() 63 + 64 + try do 65 + SowerCli.Config.require_server_connection!(config) 66 + :ok 67 + rescue 68 + e in ArgumentError -> 69 + Output.error("#{e.message}") 70 + {:error, :missing_server_config} 71 + end 58 72 59 73 true -> 60 74 :ok ··· 129 143 defp run_steps([:push | rest], %__MODULE__{} = state) do 130 144 Output.step("Pushing to cache") 131 145 132 - {:ok, {cache_module, cache_config}} = Cache.parse_url(state.options.cache) 146 + cache_url = state.options.cache || SowerCli.Config.get().cache 147 + {:ok, {cache_module, cache_config}} = Cache.parse_url(cache_url) 133 148 134 149 store_paths = 135 150 state.builds
+55
apps/sower_cli/lib/sower_cli/config.ex
··· 1 + defmodule SowerCli.Config do 2 + @moduledoc """ 3 + CLI configuration management. 4 + 5 + Delegates to `SowerClient.Config` for loading and validation. 6 + All fields are optional - CLI arguments can override config file values. 7 + """ 8 + 9 + @app :sower_cli 10 + 11 + def load(overrides \\ %{}) do 12 + config = 13 + SowerClient.Config.load(overrides, 14 + config_path_env: "SOWER_CLI_CONFIG" 15 + ) 16 + 17 + Application.put_env(@app, :config, config) 18 + config 19 + end 20 + 21 + def get do 22 + Application.get_env(@app, :config) 23 + end 24 + 25 + @doc """ 26 + Validate that endpoint and access_token are present for server operations. 27 + 28 + Raises an error if either field is missing. 29 + """ 30 + def require_server_connection!(%SowerClient.Config{} = config) do 31 + errors = [] 32 + 33 + errors = 34 + if is_nil(config.endpoint) do 35 + ["endpoint is required (set via config file or --endpoint flag)" | errors] 36 + else 37 + errors 38 + end 39 + 40 + errors = 41 + if is_nil(config.access_token) do 42 + ["access_token is required (set via config file or access_token_file)" | errors] 43 + else 44 + errors 45 + end 46 + 47 + case errors do 48 + [] -> 49 + :ok 50 + 51 + _ -> 52 + raise ArgumentError, Enum.join(errors, ", ") 53 + end 54 + end 55 + end
+1
apps/sower_cli/mix.exs
··· 34 34 [ 35 35 {:nix, in_umbrella: true}, 36 36 {:optimus, "~> 0.5"}, 37 + {:sower_client, in_umbrella: true}, 37 38 {:typedstruct, "~> 0.5.4"} 38 39 ] 39 40 end
+55
apps/sower_cli/test/sower_cli/config_test.exs
··· 1 + defmodule SowerCli.ConfigTest do 2 + use ExUnit.Case, async: true 3 + 4 + describe "load/1" do 5 + test "loads config and stores in application env" do 6 + config = 7 + SowerCli.Config.load(%{"endpoint" => "https://test.com", "access_token_file" => nil}) 8 + 9 + assert %SowerClient.Config{} = config 10 + assert config.endpoint == "https://test.com" 11 + 12 + # Verify it's stored in app env 13 + assert SowerCli.Config.get() == config 14 + end 15 + end 16 + 17 + describe "get/0" do 18 + test "returns cached config from application env" do 19 + config = 20 + SowerCli.Config.load(%{"cache" => "attic://server:cache", "access_token_file" => nil}) 21 + 22 + cached = SowerCli.Config.get() 23 + 24 + assert cached == config 25 + assert cached.cache == "attic://server:cache" 26 + end 27 + end 28 + 29 + describe "require_server_connection!/1" do 30 + test "raises when endpoint is missing" do 31 + config = %SowerClient.Config{access_token: "token123"} 32 + 33 + assert_raise ArgumentError, ~r/endpoint is required/, fn -> 34 + SowerCli.Config.require_server_connection!(config) 35 + end 36 + end 37 + 38 + test "raises when access_token is missing" do 39 + config = %SowerClient.Config{endpoint: "https://test.com"} 40 + 41 + assert_raise ArgumentError, ~r/access_token is required/, fn -> 42 + SowerCli.Config.require_server_connection!(config) 43 + end 44 + end 45 + 46 + test "returns :ok when both are present" do 47 + config = %SowerClient.Config{ 48 + endpoint: "https://test.com", 49 + access_token: "token123" 50 + } 51 + 52 + assert :ok = SowerCli.Config.require_server_connection!(config) 53 + end 54 + end 55 + end
+235
apps/sower_client/lib/sower_client/config.ex
··· 1 + defmodule SowerClient.Config do 2 + @moduledoc """ 3 + Shared configuration for Sower tools (agent, CLI). 4 + 5 + Supports `_file` suffix for reading secrets from files. 6 + 7 + Default config location: `~/.config/sower/client.json` or `/etc/sower/client.json` 8 + """ 9 + 10 + use TypedStruct 11 + alias OpenApiSpex.Schema 12 + require Logger 13 + require OpenApiSpex 14 + 15 + OpenApiSpex.schema(%{ 16 + title: "Config", 17 + type: :object, 18 + properties: %{ 19 + access_token: %Schema{ 20 + type: :string, 21 + description: "Sower access token", 22 + readOnly: true 23 + }, 24 + cache: %Schema{ 25 + type: :string, 26 + description: "Default cache URL for pushing builds", 27 + example: "attic://server:cache" 28 + }, 29 + config_path: %Schema{ 30 + type: :string, 31 + description: "config file path", 32 + readOnly: true 33 + }, 34 + endpoint: %Schema{ 35 + type: :string, 36 + format: :uri, 37 + description: "Sower server endpoint", 38 + example: "https://my.sower.dev" 39 + }, 40 + name: %Schema{ 41 + type: :string, 42 + description: "Agent name (agent-only)", 43 + default: "system hostname" 44 + }, 45 + state_directory: %Schema{ 46 + type: :string, 47 + description: "Directory where state files are written (agent-only)", 48 + default: "/var/lib/sower_agent" 49 + }, 50 + subscriptions: %Schema{ 51 + type: :array, 52 + items: SowerClient.Schemas.Orchestration.Subscription, 53 + default: [], 54 + description: "Agent subscriptions (agent-only)" 55 + } 56 + }, 57 + required: [] 58 + }) 59 + 60 + @doc """ 61 + Load configuration from file and overrides. 62 + """ 63 + def load(overrides \\ %{}, opts \\ []) do 64 + Application.ensure_all_started(:logger) 65 + 66 + spec = build_spec() 67 + 68 + config_path = resolve_config_path(opts) 69 + 70 + config = 71 + Keyword.get(opts, :defaults, %{}) 72 + |> Map.merge(read_config_file(config_path)) 73 + |> then(fn cfg -> 74 + if File.exists?(config_path) do 75 + Map.put(cfg, :config_path, config_path) 76 + else 77 + cfg 78 + end 79 + end) 80 + |> normalize_subscription_rules() 81 + |> Map.merge(overrides) 82 + |> parse_file_values() 83 + |> OpenApiSpex.cast_value(spec.components.schemas["Config"], spec) 84 + |> case do 85 + {:ok, cfg} -> 86 + cfg 87 + 88 + {:error, errors} -> 89 + Logger.error(msg: "Failed to read configuration", errors: errors) 90 + Kernel.exit(1) 91 + end 92 + 93 + config 94 + end 95 + 96 + def build_spec do 97 + %OpenApiSpex.OpenApi{ 98 + info: %OpenApiSpex.Info{title: "Config", version: "1.0.0"}, 99 + paths: %{}, 100 + components: nil 101 + } 102 + |> OpenApiSpex.resolve_schema_modules() 103 + |> OpenApiSpex.add_schemas([__MODULE__]) 104 + end 105 + 106 + def read_config_file(path) do 107 + if File.exists?(path) do 108 + path 109 + |> File.read!() 110 + |> Jason.decode!() 111 + else 112 + Logger.warning(msg: "Config file is missing, using defaults", file: path) 113 + %{} 114 + end 115 + end 116 + 117 + @doc """ 118 + Resolve config file path from options and environment. 119 + 120 + Config path resolution order: 121 + 1. Explicit `:config_path` option 122 + 2. Environment variable (if `:config_path_env` option is set) 123 + 3. From env `$SOWER_CONFIG_FILE` 124 + 4. XDG path: `~/.config/sower/client.json` or `/etc/sower/client.json` 125 + """ 126 + def resolve_config_path(opts) do 127 + explicit_path = Keyword.get(opts, :config_path) 128 + env_var = Keyword.get(opts, :config_path_env) 129 + default_path = default_config_path() 130 + 131 + path = 132 + cond do 133 + explicit_path -> explicit_path 134 + env_var -> System.get_env(env_var, default_path) 135 + true -> default_path 136 + end 137 + 138 + Path.absname(path) 139 + end 140 + 141 + @doc """ 142 + Default config file path. 143 + 144 + Uses `$SOWER_CONFIG_FILE` if set 145 + 146 + Otherwise, returns `~/.config/sower/client.json` for non-root users, 147 + `/etc/sower/client.json` for root. 148 + """ 149 + def default_config_path do 150 + System.get_env( 151 + "SOWER_CONFIG_FILE", 152 + xdg_config_path("sower", "client.json") 153 + ) 154 + end 155 + 156 + def xdg_config_path(app_name, filename) do 157 + case System.get_env("USER") do 158 + user when user != "root" -> 159 + System.get_env("XDG_CONFIG_HOME", Path.join(System.fetch_env!("HOME"), ".config")) 160 + |> Path.join(app_name) 161 + |> Path.join(filename) 162 + 163 + _ -> 164 + Path.join(["/etc", app_name, filename]) 165 + end 166 + end 167 + 168 + def xdg_state_path(app_name) do 169 + case System.get_env("USER") do 170 + user when user != "root" -> 171 + System.get_env("XDG_STATE_HOME", Path.join(System.fetch_env!("HOME"), ".local/state")) 172 + |> Path.join(app_name) 173 + 174 + _ -> 175 + Path.join("/var/lib", app_name) 176 + end 177 + end 178 + 179 + @doc """ 180 + Parse `_file` suffixes in config keys. 181 + 182 + Converts keys ending in `_file` to their base name and reads the file content. 183 + For example, `access_token_file: "/path/to/token"` becomes `access_token: "contents"`. 184 + """ 185 + def parse_file_values(config_map) do 186 + config_map 187 + |> Enum.map(fn {key, value} -> 188 + key = 189 + if is_atom(key) do 190 + Atom.to_string(key) 191 + else 192 + key 193 + end 194 + 195 + if not is_nil(value) and String.ends_with?(key, "_file") do 196 + real_key = String.trim_trailing(key, "_file") 197 + value = value |> Path.absname() |> File.read!() |> String.trim() 198 + {real_key, value} 199 + else 200 + {key, value} 201 + end 202 + end) 203 + |> Map.new() 204 + end 205 + 206 + # parse subscriptions and rules 207 + defp normalize_subscription_rules(%{"subscriptions" => subscriptions} = config) 208 + when is_list(subscriptions) do 209 + normalized_subscriptions = 210 + Enum.map(subscriptions, fn subscription -> 211 + case subscription do 212 + %{"rules" => rules} when is_list(rules) -> 213 + normalized_rules = 214 + Enum.map(rules, fn rule -> 215 + case rule do 216 + rule when is_binary(rule) -> 217 + SowerClient.SubscriptionRuleFormat.parse!(rule) 218 + 219 + rule when is_map(rule) -> 220 + rule 221 + end 222 + end) 223 + 224 + Map.put(subscription, "rules", normalized_rules) 225 + 226 + subscription -> 227 + subscription 228 + end 229 + end) 230 + 231 + Map.put(config, "subscriptions", normalized_subscriptions) 232 + end 233 + 234 + defp normalize_subscription_rules(config), do: config 235 + end
+2 -1
apps/sower_client/mix.exs
··· 31 31 {:jason, "~> 1.0"}, 32 32 {:open_api_spex, "~> 3.22"}, 33 33 {:req, "~> 0.5.14"}, 34 - {:slipstream, "~> 1.0"} 34 + {:slipstream, "~> 1.0"}, 35 + {:typedstruct, "~> 0.5", runtime: false} 35 36 ] 36 37 end 37 38 end
+177
apps/sower_client/test/sower_client/config_test.exs
··· 1 + defmodule SowerClient.ConfigTest do 2 + use ExUnit.Case, async: true 3 + 4 + import ExUnit.CaptureLog 5 + 6 + alias SowerClient.Config 7 + 8 + describe "xdg_config_path/2" do 9 + test "respects XDG_CONFIG_HOME when set" do 10 + with_env(%{"XDG_CONFIG_HOME" => "/custom/config"}, fn -> 11 + result = Config.xdg_config_path("sower", "client.json") 12 + assert result =~ "/custom/config/sower/client.json" 13 + end) 14 + end 15 + end 16 + 17 + describe "xdg_state_path/1" do 18 + test "respects XDG_STATE_HOME when set" do 19 + with_env(%{"XDG_STATE_HOME" => "/custom/state"}, fn -> 20 + result = Config.xdg_state_path("sower_agent") 21 + assert result =~ "/custom/state/sower_agent" 22 + end) 23 + end 24 + end 25 + 26 + describe "parse_file_values/1" do 27 + setup do 28 + tmp_dir = System.tmp_dir!() 29 + token_file = Path.join(tmp_dir, "test_token_#{:rand.uniform(1000)}") 30 + File.write!(token_file, "secret-token-123\n") 31 + 32 + on_exit(fn -> File.rm(token_file) end) 33 + 34 + %{token_file: token_file} 35 + end 36 + 37 + test "expands _file suffix and reads file content", %{token_file: token_file} do 38 + config = %{"access_token_file" => token_file, "endpoint" => "https://example.com"} 39 + 40 + result = Config.parse_file_values(config) 41 + 42 + assert result["access_token"] == "secret-token-123" 43 + assert result["endpoint"] == "https://example.com" 44 + refute Map.has_key?(result, "access_token_file") 45 + end 46 + 47 + test "handles atom keys", %{token_file: token_file} do 48 + config = %{access_token_file: token_file, endpoint: "https://example.com"} 49 + 50 + result = Config.parse_file_values(config) 51 + 52 + assert result["access_token"] == "secret-token-123" 53 + assert result["endpoint"] == "https://example.com" 54 + end 55 + 56 + test "leaves non-file keys unchanged" do 57 + config = %{"endpoint" => "https://example.com", "name" => "myhost"} 58 + 59 + result = Config.parse_file_values(config) 60 + 61 + assert result == config 62 + end 63 + end 64 + 65 + describe "read_config_file/1" do 66 + setup do 67 + tmp_dir = System.tmp_dir!() 68 + config_file = Path.join(tmp_dir, "test_config_#{:rand.uniform(1000)}.json") 69 + 70 + config_data = %{ 71 + "endpoint" => "https://my.sower.dev", 72 + "cache" => "attic://server:cache" 73 + } 74 + 75 + File.write!(config_file, Jason.encode!(config_data)) 76 + 77 + on_exit(fn -> File.rm(config_file) end) 78 + 79 + %{config_file: config_file, config_data: config_data} 80 + end 81 + 82 + test "reads and parses JSON config file", %{ 83 + config_file: config_file, 84 + config_data: config_data 85 + } do 86 + result = Config.read_config_file(config_file) 87 + assert result == config_data 88 + end 89 + 90 + test "returns empty map when file doesn't exist" do 91 + log = 92 + capture_log(fn -> 93 + result = Config.read_config_file("/nonexistent/path.json") 94 + 95 + assert result == %{} 96 + end) 97 + 98 + assert log =~ "Config file is missing, using defaults" 99 + end 100 + end 101 + 102 + describe "load/2" do 103 + setup do 104 + tmp_dir = System.tmp_dir!() 105 + config_file = Path.join(tmp_dir, "test_config_#{:rand.uniform(1000)}.json") 106 + 107 + config_data = %{ 108 + "endpoint" => "https://my.sower.dev", 109 + "cache" => "attic://server:cache", 110 + "name" => "testhost" 111 + } 112 + 113 + File.write!(config_file, Jason.encode!(config_data)) 114 + 115 + on_exit(fn -> File.rm(config_file) end) 116 + 117 + %{config_file: config_file} 118 + end 119 + 120 + test "loads config with defaults and overrides", %{config_file: config_file} do 121 + config = 122 + Config.load( 123 + %{"name" => "override-host"}, 124 + config_path: config_file, 125 + defaults: %{"state_directory" => "/var/lib/test"} 126 + ) 127 + 128 + assert %Config{} = config 129 + assert config.endpoint == "https://my.sower.dev" 130 + assert config.cache == "attic://server:cache" 131 + assert config.name == "override-host" 132 + assert config.state_directory == "/var/lib/test" 133 + end 134 + 135 + test "returns struct when no config file exists" do 136 + log = 137 + capture_log(fn -> 138 + config = 139 + Config.load( 140 + %{}, 141 + config_path: "/nonexistent/path.json", 142 + defaults: %{"endpoint" => "https://default.com"} 143 + ) 144 + 145 + assert %Config{} = config 146 + assert config.endpoint == "https://default.com" 147 + end) 148 + 149 + assert log =~ "Config file is missing, using defaults" 150 + end 151 + end 152 + 153 + # Helper to temporarily set environment variables 154 + defp with_env(env_vars, fun) do 155 + original_env = 156 + Enum.map(env_vars, fn {key, _value} -> 157 + {key, System.get_env(key)} 158 + end) 159 + |> Map.new() 160 + 161 + try do 162 + Enum.each(env_vars, fn {key, value} -> 163 + System.put_env(key, value) 164 + end) 165 + 166 + fun.() 167 + after 168 + Enum.each(original_env, fn {key, value} -> 169 + if value do 170 + System.put_env(key, value) 171 + else 172 + System.delete_env(key) 173 + end 174 + end) 175 + end 176 + end 177 + end
-19
dev-agent.json
··· 1 - { 2 - "access_token_file": "./.dev-api-token", 3 - "endpoint": "http://localhost:7150", 4 - "state_directory": "./_build/agent1", 5 - "subscriptions": [ 6 - { 7 - "seed_name": "deck", 8 - "seed_type": "nixos", 9 - "rules": ["source=dev"], 10 - "schedule": "* * * * *", 11 - "poll_on_connect": true 12 - }, 13 - { 14 - "seed_name": "deck", 15 - "seed_type": "home-manager", 16 - "poll_on_connect": false 17 - } 18 - ] 19 - }
+16 -4
dev-client.json
··· 1 1 { 2 + "access_token_file": "./.dev-api-token", 2 3 "endpoint": "http://localhost:7150", 3 - "api-token-file": ".dev-api-token", 4 - "services": { 5 - "services": ["sower-server"] 6 - } 4 + "state_directory": "./_build/agent1", 5 + "subscriptions": [ 6 + { 7 + "seed_name": "deck", 8 + "seed_type": "nixos", 9 + "rules": ["source=dev"], 10 + "schedule": "* * * * *", 11 + "poll_on_connect": true 12 + }, 13 + { 14 + "seed_name": "deck", 15 + "seed_type": "home-manager", 16 + "poll_on_connect": false 17 + } 18 + ] 7 19 }