Skip to content

Commit 27fb83e

Browse files
Pedro Piñera Buendíaclaude
authored andcommitted
Support multiple named providers with a configurable default
Follows the Finch pool pattern: configure multiple providers by name with their options, and designate one as the default. config :terrarium, default: :daytona, providers: [ daytona: {Terrarium.Daytona, api_key: "..."}, e2b: {Terrarium.E2B, api_key: "..."}, local: Terrarium.Providers.Local ] Provider config opts are merged with call-site opts, with call-site taking precedence. Config-dependent tests are isolated in a separate async: false test file. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 937a15c commit 27fb83e

3 files changed

Lines changed: 131 additions & 38 deletions

File tree

lib/terrarium.ex

Lines changed: 71 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,26 @@ defmodule Terrarium do
1515
1616
## Configuration
1717
18-
Configure a default provider so you don't have to pass it on every call:
18+
Configure multiple providers and set a default:
1919
20-
# config/config.exs
21-
config :terrarium, provider: Terrarium.Providers.Local
22-
23-
# config/prod.exs
24-
config :terrarium, provider: Terrarium.Daytona
20+
config :terrarium,
21+
default: :daytona,
22+
providers: [
23+
daytona: {Terrarium.Daytona, api_key: System.fetch_env!("DAYTONA_API_KEY")},
24+
e2b: {Terrarium.E2B, api_key: System.fetch_env!("E2B_API_KEY")},
25+
local: Terrarium.Providers.Local
26+
]
2527
2628
## Usage
2729
28-
# Using the configured default provider
30+
# Uses the default provider
2931
{:ok, sandbox} = Terrarium.create(image: "debian:12")
3032
31-
# Or explicitly passing a provider
32-
{:ok, sandbox} = Terrarium.create(MyApp.Sandbox.Daytona, image: "debian:12")
33+
# Uses a named provider
34+
{:ok, sandbox} = Terrarium.create(:e2b, image: "debian:12")
35+
36+
# Uses an explicit provider module
37+
{:ok, sandbox} = Terrarium.create(Terrarium.Daytona, image: "debian:12", api_key: "...")
3338
3439
# Execute commands
3540
{:ok, result} = Terrarium.exec(sandbox, "echo hello")
@@ -45,43 +50,52 @@ defmodule Terrarium do
4550
alias Terrarium.Sandbox
4651

4752
@doc """
48-
Creates a new sandbox using the given provider, or the configured default.
53+
Creates a new sandbox.
54+
55+
Can be called in three ways:
56+
57+
- `Terrarium.create(opts)` — uses the configured default provider
58+
- `Terrarium.create(:name, opts)` — uses a named provider from config
59+
- `Terrarium.create(ProviderModule, opts)` — uses the module directly
60+
61+
Provider-specific options from config are merged with call-site opts,
62+
with call-site opts taking precedence.
4963
5064
## Options
5165
5266
Options are provider-specific. Common options include:
5367
5468
- `:image` — the base image for the sandbox
5569
- `:resources` — CPU, memory, and disk configuration
70+
- `:provider` — inline provider as a module or `{module, opts}` tuple
5671
5772
## Examples
5873
59-
# With configured default provider
6074
{:ok, sandbox} = Terrarium.create(image: "debian:12")
61-
62-
# With explicit provider
75+
{:ok, sandbox} = Terrarium.create(:e2b, image: "debian:12")
6376
{:ok, sandbox} = Terrarium.create(MyProvider, image: "debian:12")
6477
"""
65-
@spec create(module() | keyword(), keyword()) :: {:ok, Sandbox.t()} | {:error, term()}
78+
@spec create(module() | atom() | keyword(), keyword()) :: {:ok, Sandbox.t()} | {:error, term()}
6679
def create(provider_or_opts \\ [], opts \\ [])
6780

68-
def create(provider, opts) when is_atom(provider) do
69-
provider.create(opts)
81+
def create(name, opts) when is_atom(name) and name != nil do
82+
{provider, provider_opts} = resolve_named_or_module(name)
83+
provider.create(Keyword.merge(provider_opts, opts))
7084
end
7185

7286
def create(opts, []) when is_list(opts) do
73-
{provider, opts} = resolve_provider(opts)
74-
provider.create(opts)
87+
{provider, provider_opts, opts} = resolve_from_opts(opts)
88+
provider.create(Keyword.merge(provider_opts, opts))
7589
end
7690

7791
@doc """
7892
Creates a new sandbox, raising on error.
7993
"""
80-
@spec create!(module() | keyword(), keyword()) :: Sandbox.t()
94+
@spec create!(module() | atom() | keyword(), keyword()) :: Sandbox.t()
8195
def create!(provider_or_opts \\ [], opts \\ [])
8296

83-
def create!(provider, opts) when is_atom(provider) do
84-
case create(provider, opts) do
97+
def create!(name, opts) when is_atom(name) and name != nil do
98+
case create(name, opts) do
8599
{:ok, sandbox} -> sandbox
86100
{:error, reason} -> raise "Failed to create sandbox: #{inspect(reason)}"
87101
end
@@ -191,21 +205,44 @@ defmodule Terrarium do
191205
provider.ls(sandbox, path)
192206
end
193207

194-
defp resolve_provider(opts) do
208+
# Resolves an atom that could be either a named provider from config
209+
# or a direct provider module.
210+
defp resolve_named_or_module(name) do
211+
providers = Application.get_env(:terrarium, :providers, [])
212+
213+
case Keyword.fetch(providers, name) do
214+
{:ok, {module, opts}} -> {module, opts}
215+
{:ok, module} when is_atom(module) -> {module, []}
216+
:error -> {name, []}
217+
end
218+
end
219+
220+
# Resolves the provider from opts (inline :provider key) or falls back
221+
# to the configured default.
222+
defp resolve_from_opts(opts) do
195223
case Keyword.pop(opts, :provider) do
196224
{nil, opts} ->
197-
case Application.get_env(:terrarium, :provider) do
198-
nil ->
199-
raise ArgumentError,
200-
"no default provider configured. Either pass a provider module explicitly " <>
201-
"or set one in your config: config :terrarium, provider: Terrarium.Providers.Local"
202-
203-
provider ->
204-
{provider, opts}
205-
end
206-
207-
{provider, opts} ->
208-
{provider, opts}
225+
{provider, provider_opts} = resolve_default!()
226+
{provider, provider_opts, opts}
227+
228+
{{module, provider_opts}, opts} when is_atom(module) ->
229+
{module, provider_opts, opts}
230+
231+
{module, opts} when is_atom(module) ->
232+
{provider, provider_opts} = resolve_named_or_module(module)
233+
{provider, provider_opts, opts}
234+
end
235+
end
236+
237+
defp resolve_default! do
238+
case Application.get_env(:terrarium, :default) do
239+
nil ->
240+
raise ArgumentError,
241+
"no default provider configured. Either pass a provider explicitly " <>
242+
"or configure one: config :terrarium, default: :local, providers: [local: Terrarium.Providers.Local]"
243+
244+
name ->
245+
resolve_named_or_module(name)
209246
end
210247
end
211248
end

test/terrarium/config_test.exs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
defmodule Terrarium.ConfigTest do
2+
use ExUnit.Case, async: false
3+
4+
alias Terrarium.Sandbox
5+
6+
# These tests modify Application config and must run sequentially.
7+
8+
setup do
9+
on_exit(fn ->
10+
Application.delete_env(:terrarium, :default)
11+
Application.delete_env(:terrarium, :providers)
12+
end)
13+
end
14+
15+
describe "named providers from config" do
16+
test "resolves a named provider" do
17+
Application.put_env(:terrarium, :providers, test: Terrarium.TestProvider)
18+
19+
assert {:ok, %Sandbox{provider: Terrarium.TestProvider}} = Terrarium.create(:test)
20+
end
21+
22+
test "resolves a named provider with {module, opts} tuple" do
23+
Application.put_env(:terrarium, :providers, test: {Terrarium.TestProvider, some: "config"})
24+
25+
assert {:ok, %Sandbox{provider: Terrarium.TestProvider}} = Terrarium.create(:test)
26+
end
27+
end
28+
29+
describe "default provider from config" do
30+
test "uses the configured default provider" do
31+
Application.put_env(:terrarium, :default, :test)
32+
Application.put_env(:terrarium, :providers, test: Terrarium.TestProvider)
33+
34+
assert {:ok, %Sandbox{provider: Terrarium.TestProvider}} = Terrarium.create()
35+
end
36+
37+
test "merges config opts with call-site opts" do
38+
Application.put_env(:terrarium, :default, :test)
39+
Application.put_env(:terrarium, :providers, test: {Terrarium.TestProvider, from_config: true})
40+
41+
assert {:ok, %Sandbox{provider: Terrarium.TestProvider}} =
42+
Terrarium.create(from_call: true)
43+
end
44+
end
45+
end

test/terrarium_test.exs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,27 @@ defmodule TerrariumTest do
33

44
alias Terrarium.Sandbox
55

6-
describe "create/2" do
6+
describe "create/2 with explicit module" do
77
test "delegates to the provider's create callback" do
88
assert {:ok, %Sandbox{id: "test-123", provider: Terrarium.TestProvider}} =
99
Terrarium.create(Terrarium.TestProvider)
1010
end
11+
end
1112

12-
test "uses the provider from opts when no module is passed" do
13+
describe "create/1 with inline :provider option" do
14+
test "accepts a provider module in opts" do
1315
assert {:ok, %Sandbox{provider: Terrarium.TestProvider}} =
1416
Terrarium.create(provider: Terrarium.TestProvider)
1517
end
1618

17-
test "raises when no provider is configured and none is passed" do
19+
test "accepts a {module, opts} tuple in opts" do
20+
assert {:ok, %Sandbox{provider: Terrarium.TestProvider}} =
21+
Terrarium.create(provider: {Terrarium.TestProvider, some: "config"})
22+
end
23+
end
24+
25+
describe "create/1 with no provider" do
26+
test "raises when no default is configured" do
1827
assert_raise ArgumentError, ~r/no default provider configured/, fn ->
1928
Terrarium.create()
2029
end
@@ -38,7 +47,9 @@ defmodule TerrariumTest do
3847
describe "exec/3" do
3948
test "delegates to the provider's exec callback" do
4049
sandbox = %Sandbox{id: "test-123", provider: Terrarium.TestProvider}
41-
assert {:ok, %Terrarium.Process.Result{exit_code: 0, stdout: "hello\n"}} = Terrarium.exec(sandbox, "echo hello")
50+
51+
assert {:ok, %Terrarium.Process.Result{exit_code: 0, stdout: "hello\n"}} =
52+
Terrarium.exec(sandbox, "echo hello")
4253
end
4354
end
4455

0 commit comments

Comments
 (0)