Skip to content

Commit b1e80a6

Browse files
jeremistuartc
andauthored
feat: support multiple local adaptor repos in registry (#4714)
* feat: support multiple local adaptor repos in registry OPENFN_ADAPTORS_REPO now accepts a colon-separated list of paths so that a private adaptor repo can be loaded alongside the canonical OpenFn adaptors monorepo. Order is precedence: when two repos ship a package with the same dirname, the entry from the earlier path wins and a single summary warning is logged for the shadowed entries. The bootstrap layer normalises the env var into the `local_adaptors_repos` list config key, which is the sole input the registry consumes. Single-path values still work; they just become a one-element list. A missing or unreadable repo path is logged as an error and skipped, so a typo in one entry does not take down the others. The bundled ws-worker still expects a single path for `@local` adaptor execution, so colon-separated values only widen the registry view (picker UI, metadata). RUNNINGLOCAL.md and .env.example document the limitation. * fix: use comma separator for OPENFN_ADAPTORS_REPO and run mix format Colon collides with Windows drive letters (c:/repo); comma matches the ws-worker parser so the registry view and @Local resolution agree. Single-path callers are unchanged. Also applies mix format to satisfy CI and tightens the docs note about missing repos (they are skipped, not fatal). * improve shadowed-adaptor warning and trim local-adaptor docs The shadow warning now names the winning and shadowed repo per package instead of just listing package names twice, so a local override is diagnosable from the log alone. Consolidate the local-adaptors explanation into RUNNINGLOCAL.md: strip the duplicated prose from .env.example and reduce the bootstrap.ex comment to the ws-worker separator rationale. * docs: add changelog entry for multi-root local adaptors (#4714) --------- Co-authored-by: Stuart Corbishley <corbish@gmail.com>
1 parent 61f98fd commit b1e80a6

9 files changed

Lines changed: 283 additions & 60 deletions

File tree

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,11 +292,11 @@
292292
# For this reason, you have to make sure that the directory exists and it is writable
293293
# ADAPTORS_REGISTRY_JSON_PATH=/path/to/adaptor_registry_cache.json
294294
#
295-
# These 2 envs are used to enable local adaptors mode. OPENFN_ADAPTORS_REPO points
296-
# to the repo directory which must have a `packages` subdir. LOCAL_ADAPTORS env is
297-
# the flag used to enable/disable this mode
295+
# Enable local adaptors mode. OPENFN_ADAPTORS_REPO takes one repo path, or a
296+
# comma-separated list to merge several. See RUNNINGLOCAL.md for the details.
298297
# LOCAL_ADAPTORS=true
299298
# OPENFN_ADAPTORS_REPO=/path/to/repo/
299+
# OPENFN_ADAPTORS_REPO=/path/to/private,/path/to/canonical
300300
#
301301
# Control whether metrics reported by the Workflow editor or Job editor are
302302
# written to the Lightning logs.

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,12 @@ and this project adheres to
1717

1818
### Added
1919

20+
- Support a comma-separated list of paths in `OPENFN_ADAPTORS_REPO`, merging
21+
multiple local adaptor repos in precedence order (earlier paths win on name
22+
collisions, and shadowed entries are logged). Lets a private repo override or
23+
extend the canonical adaptors in local mode.
24+
[#4714](https://github.com/OpenFn/lightning/pull/4714)
25+
2026
### Changed
2127

2228
### Fixed

RUNNINGLOCAL.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,21 @@ To start, set up the following environment variables:
159159
- `LOCAL_ADAPTORS`: Used to enable or disable the local adaptors mode. Set it to
160160
`true` to enable.
161161
- `OPENFN_ADAPTORS_REPO`: This should point to the adaptors monorepo. This is
162-
the same variable used when you pass `-m` to the CLI.
162+
the same variable used when you pass `-m` to the CLI. It also accepts a
163+
comma-separated list of paths to merge multiple repos into the registry; the
164+
first path wins on dirname collisions, with a warning logged for shadowed
165+
entries. Both the registry view and the bundled `ws-worker` resolve `@local`
166+
adaptors against the same list, so a workflow run picks up the same package
167+
the picker shows.
163168

164169
Example configuration:
165170

166171
```sh
167172
export LOCAL_ADAPTORS=true
168173
export OPENFN_ADAPTORS_REPO=/path/to/repo/
174+
175+
# Or, merge a private adaptor repo with the canonical one (first wins):
176+
export OPENFN_ADAPTORS_REPO=/path/to/private,/path/to/canonical
169177
```
170178

171179
You can also run the server directly in local mode with:
@@ -174,8 +182,9 @@ You can also run the server directly in local mode with:
174182
LOCAL_ADAPTORS=true mix phx.server
175183
```
176184

177-
Ensure that the `OPENFN_ADAPTORS_REPO` directory is correctly set up with the
178-
necessary `packages` subdirectory, otherwise the app wont start
185+
Each path in `OPENFN_ADAPTORS_REPO` must contain a `packages` subdirectory.
186+
Paths that are missing or unreadable are logged and skipped, so the rest of
187+
the list still loads.
179188

180189
### Problems with Apple Silicon
181190

lib/lightning/adaptor_registry.ex

Lines changed: 89 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,9 @@ defmodule Lightning.AdaptorRegistry do
9191
def handle_continue(opts, _state) do
9292
adaptors =
9393
case Enum.into(opts, %{}) do
94-
%{local_adaptors_repo: repo_path} when is_binary(repo_path) ->
95-
read_adaptors_from_local_repo(repo_path)
94+
%{local_adaptors_repos: repo_paths}
95+
when is_list(repo_paths) and repo_paths != [] ->
96+
read_adaptors_from_local_repos(repo_paths)
9697

9798
%{use_cache: use_cache}
9899
when use_cache === true or is_binary(use_cache) ->
@@ -150,10 +151,21 @@ defmodule Lightning.AdaptorRegistry do
150151
and uses the cached file for every subsequent start.
151152
It can either be a boolean, or a string - the latter being a file path
152153
to set where the cache file is located.
154+
- `:local_adaptors_repos` - an ordered list of paths to local adaptor
155+
monorepos (each containing a `packages/` subdirectory). When set, the
156+
registry skips NPM and lists adaptors from these directories instead.
157+
Earlier paths win on dirname collisions; shadowed entries are summarised
158+
in a single warning log.
153159
- `:name` (defaults to AdaptorRegistry) - the name of the process, useful
154160
for testing and/or running multiple versions of the registry
155161
"""
156-
@spec start_link(opts :: [use_cache: boolean() | binary(), name: term()]) ::
162+
@spec start_link(
163+
opts :: [
164+
use_cache: boolean() | binary(),
165+
local_adaptors_repos: [binary()],
166+
name: term()
167+
]
168+
) ::
157169
{:error, any} | {:ok, pid}
158170
def start_link(opts \\ [use_cache: true]) do
159171
Logger.info("Starting AdaptorRegistry")
@@ -270,20 +282,76 @@ defmodule Lightning.AdaptorRegistry do
270282
}
271283
end
272284

273-
defp read_adaptors_from_local_repo(repo_path) do
274-
Logger.debug("Using local adaptors repo at #{repo_path}")
275-
276-
repo_path
277-
|> Path.join("packages")
278-
|> File.ls!()
279-
|> Enum.map(fn package ->
280-
%{
281-
name: "@openfn/language-" <> package,
282-
repo: "file://" <> Path.join([repo_path, "packages", package]),
283-
latest: "local",
284-
versions: []
285-
}
286-
end)
285+
defp read_adaptors_from_local_repos(repo_paths) when is_list(repo_paths) do
286+
Logger.debug("Using local adaptors repos at #{inspect(repo_paths)}")
287+
288+
repo_paths
289+
|> Enum.flat_map(&adaptors_in_repo/1)
290+
|> dedupe_first_wins()
291+
end
292+
293+
defp adaptors_in_repo(repo_path) do
294+
packages_path = Path.join(repo_path, "packages")
295+
296+
case File.ls(packages_path) do
297+
{:ok, entries} ->
298+
Enum.map(entries, fn package ->
299+
%{
300+
name: "@openfn/language-" <> package,
301+
repo: "file://" <> Path.join([repo_path, "packages", package]),
302+
latest: "local",
303+
versions: []
304+
}
305+
end)
306+
307+
{:error, reason} ->
308+
Logger.error(
309+
"Skipping local adaptors repo #{inspect(repo_path)}: " <>
310+
"cannot list #{inspect(packages_path)} (#{:file.format_error(reason)})"
311+
)
312+
313+
[]
314+
end
315+
end
316+
317+
# First-occurrence wins: when two roots ship a package with the same
318+
# `@openfn/language-<dirname>` name, the entry from the earlier root is kept.
319+
# Listing your private repo before the canonical one therefore lets you
320+
# override individual adaptors locally without forking the whole canonical
321+
# tree. Shadowed entries are summarised in a single warning so the override
322+
# case (the intended use of ordering) does not flood logs with one line per
323+
# package.
324+
defp dedupe_first_wins(adaptors) do
325+
{kept_reversed, winners, shadowed} =
326+
Enum.reduce(adaptors, {[], %{}, []}, fn adaptor,
327+
{kept, winners, shadowed} ->
328+
if Map.has_key?(winners, adaptor.name) do
329+
{kept, winners, [adaptor | shadowed]}
330+
else
331+
{[adaptor | kept], Map.put(winners, adaptor.name, adaptor), shadowed}
332+
end
333+
end)
334+
335+
log_shadowed(Enum.reverse(shadowed), winners)
336+
Enum.reverse(kept_reversed)
337+
end
338+
339+
defp log_shadowed([], _winners), do: :ok
340+
341+
defp log_shadowed(shadowed, winners) do
342+
details =
343+
shadowed
344+
|> Enum.group_by(& &1.name)
345+
|> Enum.map_join("; ", fn {name, losers} ->
346+
winner = Map.fetch!(winners, name)
347+
loser_repos = Enum.map_join(losers, ", ", & &1.repo)
348+
"#{name} (using #{winner.repo}, shadowed #{loser_repos})"
349+
end)
350+
351+
Logger.warning(
352+
"AdaptorRegistry: #{length(shadowed)} adaptor(s) shadowed by earlier " <>
353+
"local-adaptors repo entries: #{details}"
354+
)
287355
end
288356

289357
@doc """
@@ -362,8 +430,9 @@ defmodule Lightning.AdaptorRegistry do
362430
end
363431

364432
def local_adaptors_enabled? do
365-
config = Lightning.Config.adaptor_registry()
366-
367-
if config[:local_adaptors_repo], do: true, else: false
433+
case Lightning.Config.adaptor_registry()[:local_adaptors_repos] do
434+
[_ | _] -> true
435+
_ -> false
436+
end
368437
end
369438
end

lib/lightning/config/bootstrap.ex

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -203,21 +203,26 @@ defmodule Lightning.Config.Bootstrap do
203203
config :lightning, :adaptor_service,
204204
adaptors_path: env!("ADAPTORS_PATH", :string, "./priv/openfn")
205205

206-
local_adaptors_repo =
207-
env!(
208-
"OPENFN_ADAPTORS_REPO",
209-
:string,
210-
Utils.get_env([
211-
:lightning,
212-
Lightning.AdaptorRegistry,
213-
:local_adaptors_repo
214-
])
215-
)
206+
# Comma-separated to match the ws-worker parser, so the picker view and
207+
# @local resolution agree on the same repo list. See RUNNINGLOCAL.md.
208+
local_adaptors_repos =
209+
env!("OPENFN_ADAPTORS_REPO", :string, nil)
210+
|> case do
211+
nil ->
212+
[]
213+
214+
value when is_binary(value) ->
215+
value
216+
|> String.split(",", trim: true)
217+
|> Enum.map(&String.trim/1)
218+
|> Enum.reject(&(&1 == ""))
219+
|> Enum.map(&Path.expand/1)
220+
end
216221

217-
use_local_adaptors_repo? =
222+
use_local_adaptors_repos? =
218223
env!("LOCAL_ADAPTORS", &Utils.ensure_boolean/1, false)
219224
|> tap(fn v ->
220-
if v && !is_binary(local_adaptors_repo) do
225+
if v && local_adaptors_repos == [] do
221226
raise """
222227
LOCAL_ADAPTORS is set to true, but OPENFN_ADAPTORS_REPO is not set.
223228
"""
@@ -231,8 +236,8 @@ defmodule Lightning.Config.Bootstrap do
231236
:string,
232237
Utils.get_env([:lightning, Lightning.AdaptorRegistry, :use_cache])
233238
),
234-
local_adaptors_repo:
235-
use_local_adaptors_repo? && Path.expand(local_adaptors_repo)
239+
local_adaptors_repos:
240+
if(use_local_adaptors_repos?, do: local_adaptors_repos, else: [])
236241

237242
config :lightning,
238243
schemas_path:

test/lightning/adaptor_registry_test.exs

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -131,18 +131,16 @@ defmodule Lightning.AdaptorRegistryTest do
131131
end
132132

133133
@tag :tmp_dir
134-
test "lists directory names of the when local_adaptors_repo is set", %{
135-
tmp_dir: tmp_dir,
136-
test: test
137-
} do
134+
test "lists directory names from a single-element local_adaptors_repos list",
135+
%{tmp_dir: tmp_dir, test: test} do
138136
expected_adaptors = ["foo", "bar", "baz"]
139137

140138
Enum.each(expected_adaptors, fn adaptor ->
141139
[tmp_dir, "packages", adaptor] |> Path.join() |> File.mkdir_p!()
142140
end)
143141

144142
start_supervised!(
145-
{AdaptorRegistry, [name: test, local_adaptors_repo: tmp_dir]}
143+
{AdaptorRegistry, [name: test, local_adaptors_repos: [tmp_dir]]}
146144
)
147145

148146
results = AdaptorRegistry.all(test)
@@ -158,6 +156,109 @@ defmodule Lightning.AdaptorRegistryTest do
158156
assert expected_result in results
159157
end
160158
end
159+
160+
@tag :tmp_dir
161+
test "merges adaptors from multiple local_adaptors_repos", %{
162+
tmp_dir: tmp_dir,
163+
test: test
164+
} do
165+
repo_a = Path.join(tmp_dir, "a")
166+
repo_b = Path.join(tmp_dir, "b")
167+
[repo_a, "packages", "alpha"] |> Path.join() |> File.mkdir_p!()
168+
[repo_b, "packages", "beta"] |> Path.join() |> File.mkdir_p!()
169+
170+
start_supervised!(
171+
{AdaptorRegistry, [name: test, local_adaptors_repos: [repo_a, repo_b]]}
172+
)
173+
174+
names = AdaptorRegistry.all(test) |> Enum.map(& &1.name) |> Enum.sort()
175+
176+
assert names == ["@openfn/language-alpha", "@openfn/language-beta"]
177+
end
178+
179+
@tag :tmp_dir
180+
test "first repo wins on collision and emits a warning", %{
181+
tmp_dir: tmp_dir,
182+
test: test
183+
} do
184+
repo_a = Path.join(tmp_dir, "a")
185+
repo_b = Path.join(tmp_dir, "b")
186+
[repo_a, "packages", "http"] |> Path.join() |> File.mkdir_p!()
187+
[repo_b, "packages", "http"] |> Path.join() |> File.mkdir_p!()
188+
189+
log =
190+
ExUnit.CaptureLog.capture_log(fn ->
191+
start_supervised!(
192+
{AdaptorRegistry,
193+
[name: test, local_adaptors_repos: [repo_a, repo_b]]}
194+
)
195+
196+
# force the GenServer to finish handle_continue
197+
AdaptorRegistry.all(test)
198+
end)
199+
200+
results = AdaptorRegistry.all(test)
201+
assert length(results) == 1
202+
203+
assert hd(results).repo ==
204+
"file://" <> Path.join([repo_a, "packages", "http"])
205+
206+
assert log =~ "@openfn/language-http"
207+
assert log =~ "shadowed"
208+
209+
assert log =~ "using"
210+
assert log =~ "file://" <> Path.join([repo_a, "packages", "http"])
211+
assert log =~ "file://" <> Path.join([repo_b, "packages", "http"])
212+
end
213+
214+
@tag :tmp_dir
215+
test "soft-fails when a repo path is missing or unreadable", %{
216+
tmp_dir: tmp_dir,
217+
test: test
218+
} do
219+
good_repo = Path.join(tmp_dir, "good")
220+
missing_repo = Path.join(tmp_dir, "does-not-exist")
221+
[good_repo, "packages", "alpha"] |> Path.join() |> File.mkdir_p!()
222+
223+
log =
224+
ExUnit.CaptureLog.capture_log(fn ->
225+
start_supervised!(
226+
{AdaptorRegistry,
227+
[name: test, local_adaptors_repos: [missing_repo, good_repo]]}
228+
)
229+
230+
AdaptorRegistry.all(test)
231+
end)
232+
233+
names = AdaptorRegistry.all(test) |> Enum.map(& &1.name)
234+
assert names == ["@openfn/language-alpha"]
235+
assert log =~ "Skipping local adaptors repo"
236+
assert log =~ missing_repo
237+
end
238+
end
239+
240+
describe "local_adaptors_enabled?/0" do
241+
test "returns true when a non-empty plural list is configured" do
242+
Mox.stub(Lightning.MockConfig, :adaptor_registry, fn ->
243+
[local_adaptors_repos: ["/some/path"]]
244+
end)
245+
246+
assert AdaptorRegistry.local_adaptors_enabled?()
247+
end
248+
249+
test "returns false when the list is empty" do
250+
Mox.stub(Lightning.MockConfig, :adaptor_registry, fn ->
251+
[local_adaptors_repos: []]
252+
end)
253+
254+
refute AdaptorRegistry.local_adaptors_enabled?()
255+
end
256+
257+
test "returns false when the key is absent" do
258+
Mox.stub(Lightning.MockConfig, :adaptor_registry, fn -> [] end)
259+
260+
refute AdaptorRegistry.local_adaptors_enabled?()
261+
end
161262
end
162263

163264
describe "resolve_package_name/1" do
@@ -178,10 +279,10 @@ defmodule Lightning.AdaptorRegistryTest do
178279
end
179280

180281
@tag :tmp_dir
181-
test "returns local as the version when local_adaptors_repo config is set",
282+
test "returns local as the version when local_adaptors_repos config is set",
182283
%{tmp_dir: tmp_dir} do
183284
Mox.stub(Lightning.MockConfig, :adaptor_registry, fn ->
184-
[local_adaptors_repo: tmp_dir]
285+
[local_adaptors_repos: [tmp_dir]]
185286
end)
186287

187288
assert AdaptorRegistry.resolve_package_name("@openfn/language-foo@1.2.3") ==

0 commit comments

Comments
 (0)