Skip to content

internal/config,watcher: add -config-dir#873

Merged
mostlygeek merged 2 commits into
mainfrom
config-dir
Jun 25, 2026
Merged

internal/config,watcher: add -config-dir#873
mostlygeek merged 2 commits into
mainfrom
config-dir

Conversation

@mostlygeek

Copy link
Copy Markdown
Owner

Over time the llama-swap configuration file can get really long and challenging to work with. The -config-dir flag is used for a directory of configuration YAML fragments.

These fragements are merged together and into a full configuration and tested for validity. All previous configuration functionality remains unchanged.

Over time the llama-swap configuration file can get really long and
challenging to work with. The -config-dir flag is used for a directory
of configuration YAML fragments.

These fragements are merged together and into a full configuration and
tested for validity. All previous configuration functionality remains
unchanged.
@coderabbitai

coderabbitai Bot commented Jun 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: da25eead-2f8f-48ff-a0d0-fd02f28947fb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch config-dir

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jun 24, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces -config-dir, allowing large llama-swap configurations to be split into multiple YAML fragments that are merged before validation. It also adds a poll-based DirWatcher so hot-reload (-watch-config) works for the new directory mode.

  • Config merging (internal/config/merge.go): YAML files in the directory are read in sorted filename order and deep-merged at the yaml.Node level; identity-keyed maps (models, groups, peers, etc.) reject duplicate keys with a clear error, scalars must agree across files, and sequences are concatenated. The merged document is fed through the existing LoadConfigFromReader pipeline unchanged.
  • DirWatcher (internal/watcher/dirwatcher.go): A new poll-based watcher mirrors the single-file Watcher design — silences directory-disappearance events to tolerate atomic writes, fires on recovery, and respects context cancellation. Both watchers run concurrently when both flags are set; concurrent reloads are serialized with an existing mutex in llama-swap.go.
  • Refactor (config.go, load.go, commands.go, macros.go): Command-parsing, macro, and loading logic are extracted into separate files to reduce config.go size; no functional changes.

Confidence Score: 4/5

Safe to merge; both issues are edge cases that produce at worst an unhelpful error message or a harmless duplicate list entry, neither causes silent misconfiguration.

The merge engine is well-thought-out: identity maps prevent model/group collisions, scalar conflicts are caught, and all sources flow through the existing validation pipeline. The two findings are limited in impact — the symlink overlap detection falls back to a duplicate merge error rather than silently double-loading, and duplicate apiKeys entries don't affect auth correctness. The DirWatcher design and its test coverage are solid.

internal/config/merge.go — the overlap detection and sequence deduplication are worth a second look before shipping.

Important Files Changed

Filename Overview
internal/config/merge.go New file implementing multi-source config merging; overlap detection uses filepath.Abs which does not resolve symlinks, and sequence fields like apiKeys are concatenated without deduplication.
internal/config/merge_test.go Comprehensive test suite covering most merge scenarios including duplicate keys, scalar conflicts, empty dirs, and macro propagation; no coverage for the symlink overlap edge case.
internal/watcher/dirwatcher.go New poll-based directory watcher that detects YAML file add/remove/modify; correctly silences directory-disappearance events and fires on recovery; design mirrors the existing single-file Watcher.
internal/watcher/dirwatcher_test.go Well-structured tests covering baseline no-fire, add, remove, mtime change, non-YAML ignore, missing-dir recovery, and context cancellation.
llama-swap.go Adds -config-dir flag, wires LoadConfigSources for both initial load and hot-reload, and launches DirWatcher alongside the existing single-file Watcher when -watch-config is set.
internal/config/load.go LoadConfigFromReader extracted from config.go; no functional changes, only file reorganisation.
internal/config/commands.go SanitizeCommand and StripComments extracted from config.go; no functional changes.
internal/config/macros.go Macro-related regex vars and helper functions extracted from config.go; no functional changes.
internal/config/config.go Imports and code blocks moved to load.go, commands.go, and macros.go; struct definitions and LoadConfig unchanged.

Reviews (1): Last reviewed commit: "internal/config,watcher: add -config-dir" | Re-trigger Greptile

Comment thread internal/config/merge.go
Comment on lines +50 to +62
absConfig, err := filepath.Abs(configPath)
if err != nil {
return Config{}, fmt.Errorf("failed to resolve -config path: %w", err)
}
for _, f := range dirFiles {
absF, err := filepath.Abs(f)
if err != nil {
return Config{}, fmt.Errorf("failed to resolve config dir file %s: %w", f, err)
}
if absConfig == absF {
return Config{}, fmt.Errorf("-config path %s is also present in -config-dir %s; remove it from one", configPath, configDir)
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Symlink not resolved in overlap detection

filepath.Abs only cleans the path (resolves .., prepends cwd); it does not follow symlinks. If -config is a symlink pointing to a file inside -config-dir, the comparison absConfig == absF will always be false because the two absolute paths differ even though they resolve to the same inode. The file will be parsed twice: once as the -config source and once as a member of the directory scan. The merge will then fail with a generic "duplicate models" or "conflict at" error rather than the precise "is also present in -config-dir" message.

Using filepath.EvalSymlinks on both paths before comparing would catch this case correctly.

Comment thread internal/config/merge.go
Comment on lines +237 to +239
case dstVal.Kind == yaml.SequenceNode && srcVal.Kind == yaml.SequenceNode:
dstVal.Content = append(dstVal.Content, srcVal.Content...)
return nil

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Sequence fields are concatenated without deduplication

Sequence-typed fields like apiKeys and hooks.on_startup.preload are concatenated verbatim. If the same value appears in two fragment files (e.g. apiKeys: [shared-key] in both a.yaml and b.yaml), the merged config will contain duplicates. For apiKeys this is harmless at auth-check time, but it silently inflates the list and produces no diagnostic. A user who accidentally copies an apiKeys entry into a second fragment will see no warning.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🧹 Nitpick comments (1)
internal/watcher/dirwatcher_test.go (1)

133-155: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider adding a present→empty (all files removed) test.

TestDirWatcher_MissingDirRecovers covers directory removal, but not the case where the directory remains but becomes empty. Given the Run doc claims empty is treated as transient, a test asserting the intended behavior would lock in whichever policy is chosen (see the related comment in dirwatcher.go).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/watcher/dirwatcher_test.go` around lines 133 - 155, Add a test for
the present→empty transition in DirWatcher so the behavior is locked in when a
watched directory stays present but all YAML files are removed. Extend the
coverage in dirwatcher_test.go alongside TestDirWatcher_MissingDirRecovers by
asserting the callback behavior after deleting the last file from an existing
directory, using startDirWatcher, DirWatcher.Run semantics, and
waitForCount/atomic counter checks to verify the intended empty-directory
policy.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/config/load.go`:
- Around line 388-397: The API key validation in the RequiredAPIKeys loop is
leaking secrets by including the full key in the error message. Update the
validation in load.go so the `strings.Contains(apikey, " ")` failure reports the
key index from the `for i, apikey := range config.RequiredAPIKeys` loop instead
of echoing `apikey`, and keep the rest of the `Config` validation logic
unchanged.

In `@internal/config/merge_test.go`:
- Around line 204-214: The test currently only checks PORT substitution, so it
does not actually cover env macro expansion. Update
TestLoadConfigSources_EnvMacrosSubstituted to add a real ${env.*} case using
t.Setenv and verify it survives multi-source loading, ideally through a
flow-style YAML field such as apiKeys in writeYAML/LoadConfigSources. Keep the
existing PORT assertions, but also assert the env macro is substituted in the
merged config object to preserve the current env substitution behavior.
- Around line 216-233: The test in
TestLoadConfigSources_SortedOrderDeterministic only verifies that both models
are present, so it won’t fail if the merge order changes; update it to assert an
order-sensitive merged outcome from LoadConfigSources, such as the deterministic
startPort-based result or another concatenated field like apiKeys, and compare
the exact merged value with assert.Equal using the existing FindConfig checks as
needed to locate the merged models.

In `@internal/config/merge.go`:
- Around line 72-85: LoadConfigSources is parsing raw source files in
parseSource before env macro substitution, so valid fragments like flow-style
lists with ${env.*} are rejected by yaml.Unmarshal. Update parseSource to read
the file content, run substituteEnvMacros on it first, then unmarshal the
substituted text, and keep the existing mergeNodes flow in LoadConfigSources
unchanged. Add a regression test covering LoadConfigSources with a fragment such
as apiKeys: [${env.API_KEY}] to verify it now parses correctly.

In `@internal/watcher/dirwatcher.go`:
- Around line 65-99: DirWatcher.Run currently only treats a missing directory as
transient, but the documented policy also says an empty directory should stay
quiet. Update the transition logic in Run (using scanDir and prev.equal) so that
present→empty is suppressed the same way as present→missing, or adjust the doc
comment if the empty-directory case is intentionally observable; keep the
behavior aligned with the policy around OnChange firing.

---

Nitpick comments:
In `@internal/watcher/dirwatcher_test.go`:
- Around line 133-155: Add a test for the present→empty transition in DirWatcher
so the behavior is locked in when a watched directory stays present but all YAML
files are removed. Extend the coverage in dirwatcher_test.go alongside
TestDirWatcher_MissingDirRecovers by asserting the callback behavior after
deleting the last file from an existing directory, using startDirWatcher,
DirWatcher.Run semantics, and waitForCount/atomic counter checks to verify the
intended empty-directory policy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 20397283-9635-45ee-8447-40eb4f7c899b

📥 Commits

Reviewing files that changed from the base of the PR and between 316ad63 and 36e6eee.

📒 Files selected for processing (9)
  • internal/config/commands.go
  • internal/config/config.go
  • internal/config/load.go
  • internal/config/macros.go
  • internal/config/merge.go
  • internal/config/merge_test.go
  • internal/watcher/dirwatcher.go
  • internal/watcher/dirwatcher_test.go
  • llama-swap.go
💤 Files with no reviewable changes (1)
  • internal/config/config.go

Comment thread internal/config/load.go
Comment on lines +204 to +214
func TestLoadConfigSources_EnvMacrosSubstituted(t *testing.T) {
dir := t.TempDir()
// Use ${PORT} in cmd so the pipeline allocates a port and substitutes it;
// verifies env/macro substitution runs on the merged document.
writeYAML(t, dir, "a.yaml", "models:\n m1:\n cmd: serve --port ${PORT}\n proxy: \"http://localhost:${PORT}\"\n")
cfg, err := LoadConfigSources("", dir)
require.NoError(t, err)
m := cfg.Models["m1"]
assert.NotContains(t, m.Cmd, "${PORT}", "PORT macro should have been substituted")
assert.NotContains(t, m.Proxy, "${PORT}", "PORT macro should have been substituted in proxy")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make this cover an actual env macro.

This test name says env macros, but it only verifies ${PORT} allocation. Add a ${env.*} case via t.Setenv, especially through flow-style YAML such as apiKeys: [${env.LLAMA_SWAP_TEST_API_KEY}], so the multi-source loader preserves existing env substitution behavior.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/merge_test.go` around lines 204 - 214, The test currently
only checks PORT substitution, so it does not actually cover env macro
expansion. Update TestLoadConfigSources_EnvMacrosSubstituted to add a real
${env.*} case using t.Setenv and verify it survives multi-source loading,
ideally through a flow-style YAML field such as apiKeys in
writeYAML/LoadConfigSources. Keep the existing PORT assertions, but also assert
the env macro is substituted in the merged config object to preserve the current
env substitution behavior.

Comment on lines +216 to +233
func TestLoadConfigSources_SortedOrderDeterministic(t *testing.T) {
// Two files defining distinct models, scanned in z..a order by filename.
// Determine merged result is the same regardless of how the FS returns them.
dir := t.TempDir()
writeYAML(t, dir, "z.yaml", "models:\n"+modelCfg("zmodel", "echo z"))
writeYAML(t, dir, "a.yaml", "models:\n"+modelCfg("amodel", "echo a"))

const runs = 3
for i := 0; i < runs; i++ {
cfg, err := LoadConfigSources("", dir)
require.NoError(t, err)
// startPort-based allocation: first allocated model gets 5800.
// Sorted order means amodel gets 5800, zmodel gets 5801.
_, _, ok := cfg.FindConfig("amodel")
assert.True(t, ok)
_, _, ok = cfg.FindConfig("zmodel")
assert.True(t, ok)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert an order-sensitive merged result.

This test only checks that both models exist, so it would pass even if listYAMLFiles stopped sorting. Assert something order-sensitive, such as concatenated apiKeys, with assert.Equal instead of presence checks.

Suggested direction
-	writeYAML(t, dir, "z.yaml", "models:\n"+modelCfg("zmodel", "echo z"))
-	writeYAML(t, dir, "a.yaml", "models:\n"+modelCfg("amodel", "echo a"))
+	writeYAML(t, dir, "z.yaml", "models:\n"+modelCfg("zmodel", "echo z")+"\napiKeys: [key-z]\n")
+	writeYAML(t, dir, "a.yaml", "models:\n"+modelCfg("amodel", "echo a")+"\napiKeys: [key-a]\n")
 
-	const runs = 3
-	for i := 0; i < runs; i++ {
-		cfg, err := LoadConfigSources("", dir)
-		require.NoError(t, err)
-		// startPort-based allocation: first allocated model gets 5800.
-		// Sorted order means amodel gets 5800, zmodel gets 5801.
-		_, _, ok := cfg.FindConfig("amodel")
-		assert.True(t, ok)
-		_, _, ok = cfg.FindConfig("zmodel")
-		assert.True(t, ok)
-	}
+	cfg, err := LoadConfigSources("", dir)
+	require.NoError(t, err)
+	assert.Equal(t, []string{"key-a", "key-z"}, cfg.RequiredAPIKeys)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func TestLoadConfigSources_SortedOrderDeterministic(t *testing.T) {
// Two files defining distinct models, scanned in z..a order by filename.
// Determine merged result is the same regardless of how the FS returns them.
dir := t.TempDir()
writeYAML(t, dir, "z.yaml", "models:\n"+modelCfg("zmodel", "echo z"))
writeYAML(t, dir, "a.yaml", "models:\n"+modelCfg("amodel", "echo a"))
const runs = 3
for i := 0; i < runs; i++ {
cfg, err := LoadConfigSources("", dir)
require.NoError(t, err)
// startPort-based allocation: first allocated model gets 5800.
// Sorted order means amodel gets 5800, zmodel gets 5801.
_, _, ok := cfg.FindConfig("amodel")
assert.True(t, ok)
_, _, ok = cfg.FindConfig("zmodel")
assert.True(t, ok)
}
func TestLoadConfigSources_SortedOrderDeterministic(t *testing.T) {
// Two files defining distinct models, scanned in z..a order by filename.
// Determine merged result is the same regardless of how the FS returns them.
dir := t.TempDir()
writeYAML(t, dir, "z.yaml", "models:\n"+modelCfg("zmodel", "echo z")+"\napiKeys: [key-z]\n")
writeYAML(t, dir, "a.yaml", "models:\n"+modelCfg("amodel", "echo a")+"\napiKeys: [key-a]\n")
cfg, err := LoadConfigSources("", dir)
require.NoError(t, err)
assert.Equal(t, []string{"key-a", "key-z"}, cfg.RequiredAPIKeys)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/config/merge_test.go` around lines 216 - 233, The test in
TestLoadConfigSources_SortedOrderDeterministic only verifies that both models
are present, so it won’t fail if the merge order changes; update it to assert an
order-sensitive merged outcome from LoadConfigSources, such as the deterministic
startPort-based result or another concatenated field like apiKeys, and compare
the exact merged value with assert.Equal using the existing FindConfig checks as
needed to locate the merged models.

Comment thread internal/config/merge.go
Comment thread internal/watcher/dirwatcher.go
- load.go: stop leaking API keys in error messages; report index instead
- merge.go: substitute env macros before YAML parse so flow-style lists
  like [${env.API_KEY}] parse correctly
- dirwatcher.go: suppress present to empty transitions per documented
  policy; fix TOCTOU race in scanDir between ReadDir and Stat
@mostlygeek mostlygeek merged commit 32bc781 into main Jun 25, 2026
3 checks passed
@mostlygeek mostlygeek deleted the config-dir branch June 25, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant