Skip to content

Commit 065cf54

Browse files
authored
Merge pull request #3496 from docker/feat/hooks-dropin-and-config-dir-env
feat: hooks.d drop-in directory and config-dir env override
2 parents 00a5556 + b4eb9ba commit 065cf54

7 files changed

Lines changed: 244 additions & 5 deletions

File tree

cmd/root/config_dir_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package root
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestResolveConfigDir_Precedence(t *testing.T) {
10+
// Not parallel: t.Setenv mutates process-wide env vars. Both vars are
11+
// explicitly neutralized first so an env leak from the developer's
12+
// shell cannot skew the assertions.
13+
t.Setenv(envConfigDir, "")
14+
t.Setenv(cagentEnvConfigDir, "")
15+
assert.Empty(t, resolveConfigDir(""))
16+
17+
t.Setenv(cagentEnvConfigDir, "/from-cagent-env")
18+
assert.Equal(t, "/from-cagent-env", resolveConfigDir(""))
19+
20+
t.Setenv(envConfigDir, "/from-docker-agent-env")
21+
assert.Equal(t, "/from-docker-agent-env", resolveConfigDir(""))
22+
23+
assert.Equal(t, "/from-flag", resolveConfigDir("/from-flag"))
24+
}

cmd/root/root.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,19 @@ type rootFlags struct {
3636
dataDir string
3737
}
3838

39+
const (
40+
envConfigDir = "DOCKER_AGENT_CONFIG_DIR"
41+
cagentEnvConfigDir = "CAGENT_CONFIG_DIR"
42+
)
43+
44+
// resolveConfigDir picks the config directory override with flag > env >
45+
// default precedence. The env fallbacks let external tools point docker-agent
46+
// at a non-default config dir (e.g. to locate hooks.d) without controlling
47+
// its argv.
48+
func resolveConfigDir(flagValue string) string {
49+
return cmp.Or(flagValue, os.Getenv(envConfigDir), os.Getenv(cagentEnvConfigDir))
50+
}
51+
3952
func NewRootCmd() *cobra.Command {
4053
var flags rootFlags
4154

@@ -54,7 +67,7 @@ New to docker agent? Take the hands-on tour: docker agent getting-started`,
5467
if dir := flags.cacheDir; dir != "" {
5568
paths.SetCacheDir(dir)
5669
}
57-
if dir := flags.configDir; dir != "" {
70+
if dir := resolveConfigDir(flags.configDir); dir != "" {
5871
paths.SetConfigDir(dir)
5972
}
6073
if dir := flags.dataDir; dir != "" {

cmd/root/run.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -327,7 +327,7 @@ func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []s
327327
// User settings only apply if the flag wasn't explicitly set by the user
328328
userSettings := userconfig.Get()
329329
f.applyUserSettings(ctx, userSettings)
330-
f.runConfig.GlobalHooks = userSettings.GlobalHooks()
330+
f.runConfig.GlobalHooks = config.MergeHooks(userSettings.GlobalHooks(), config.LoadHookDropIns())
331331

332332
// Apply alias options if this is an alias reference
333333
// Alias options only apply if the flag wasn't explicitly set by the user

docs/configuration/hooks/index.md

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,10 +142,31 @@ Global hooks use the same schema as agent-level hooks and are additive. If an ev
142142

143143
1. Agent-config hooks from the agent YAML
144144
2. Global hooks from `settings.hooks`
145-
3. CLI hooks from `--hook-*` flags
145+
3. Hook drop-ins from `<config-dir>/hooks.d/` (lexicographic file order)
146+
4. CLI hooks from `--hook-*` flags
146147

147148
Global hooks cannot be suppressed by an individual agent. Use them for user-wide audit logging, personal guardrails, notifications, and setup/cleanup behavior that should apply everywhere.
148149

150+
### Hook drop-in files (`hooks.d`)
151+
152+
External tools that integrate with docker-agent (terminal emulators, IDEs, audit or observability sidecars) shouldn't have to rewrite your `config.yaml` to install a hook. Instead, docker-agent also loads every `*.yaml` / `*.yml` file from the `hooks.d` directory next to your user config (default: `~/.config/cagent/hooks.d/`). Each file is a standalone hooks block with the same schema as the content of `settings.hooks`:
153+
154+
```yaml
155+
# ~/.config/cagent/hooks.d/50-mytool.yaml
156+
session_start:
157+
- type: command
158+
command: mytool notify --event session-start
159+
stop:
160+
- type: command
161+
command: mytool notify --event stop
162+
```
163+
164+
- Files are loaded in lexicographic order and merged additively after `settings.hooks` (use numeric prefixes like `10-`, `50-` to control ordering).
165+
- A malformed file is skipped with a logged warning; a broken drop-in never breaks a run.
166+
- Installing an integration means writing one self-contained file; uninstalling means deleting it. No shared-file edits, no conflicts between tools.
167+
168+
The config directory can be relocated with the `--config-dir` flag or the `DOCKER_AGENT_CONFIG_DIR` (legacy `CAGENT_CONFIG_DIR`) environment variable, which external tools can use to locate `hooks.d` under non-default config dirs.
169+
149170
## Built-in Hooks
150171

151172
In addition to shell `command` hooks, docker-agent ships a small library of **built-in hooks** — in-process Go functions that run without spawning a subprocess. They're invoked with `type: builtin`, where `command` is the builtin's registered name and `args` are passed through as the builtin's parameters.
@@ -911,4 +932,4 @@ $ docker agent run agentcatalog/coder \
911932
> [!NOTE]
912933
> **Merging behavior**
913934
>
914-
> Agent-config, global, and CLI hooks are additive. For each event, hooks run in this order: agent-config hooks first, then global hooks from `settings.hooks`, then CLI hooks. No source replaces another, and individual agents cannot opt out of global hooks.
935+
> Agent-config, global, drop-in, and CLI hooks are additive. For each event, hooks run in this order: agent-config hooks first, then global hooks from `settings.hooks`, then [hook drop-ins](#hook-drop-in-files-hooksd) from `hooks.d/`, then CLI hooks. No source replaces another, and individual agents cannot opt out of global hooks.

docs/features/cli/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,7 +558,7 @@ These flags are available on every `docker agent` command:
558558
| `--log-file <path>` | Custom debug log location (only used with `--debug`) |
559559
| `-o, --otel` | Enable OpenTelemetry observability: traces, metrics, and logs. Requires `OTEL_EXPORTER_OTLP_ENDPOINT` to export to a collector. |
560560
| `--cache-dir <path>` | Override the cache directory (default: `~/Library/Caches/cagent` on macOS) |
561-
| `--config-dir <path>` | Override the config directory (default: `~/.config/cagent`) |
561+
| `--config-dir <path>` | Override the config directory (default: `~/.config/cagent`). Also reads `DOCKER_AGENT_CONFIG_DIR` (legacy `CAGENT_CONFIG_DIR`) env var. |
562562
| `--data-dir <path>` | Override the data directory (default: `~/.cagent`) |
563563
| `--help` | Show help for any command |
564564

pkg/config/hooks_dropin.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package config
2+
3+
import (
4+
"log/slog"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/goccy/go-yaml"
9+
10+
"github.com/docker/docker-agent/pkg/config/latest"
11+
"github.com/docker/docker-agent/pkg/paths"
12+
)
13+
14+
// LoadHookDropIns loads user-level hook drop-in files from the hooks.d
15+
// directory under the config dir. Each *.yaml / *.yml file holds a standalone
16+
// HooksConfig (the same schema as the userconfig `settings.hooks` block).
17+
// Files are merged additively in lexicographic order, so external tools can
18+
// install and uninstall hooks by adding or deleting one self-contained file
19+
// instead of rewriting the shared config.yaml. Returns nil when no drop-in
20+
// hooks exist.
21+
func LoadHookDropIns() *latest.HooksConfig {
22+
return loadHookDropIns(filepath.Join(paths.GetConfigDir(), "hooks.d"))
23+
}
24+
25+
func loadHookDropIns(dir string) *latest.HooksConfig {
26+
entries, err := os.ReadDir(dir)
27+
if err != nil {
28+
if !os.IsNotExist(err) {
29+
slog.Warn("Failed to read hooks drop-in directory", "dir", dir, "error", err)
30+
}
31+
return nil
32+
}
33+
34+
// os.ReadDir returns entries sorted by filename, which pins the
35+
// documented lexicographic merge order.
36+
var merged *latest.HooksConfig
37+
for _, entry := range entries {
38+
if entry.IsDir() {
39+
continue
40+
}
41+
if ext := filepath.Ext(entry.Name()); ext != ".yaml" && ext != ".yml" {
42+
continue
43+
}
44+
path := filepath.Join(dir, entry.Name())
45+
hooks, err := readHookDropIn(path)
46+
if err != nil {
47+
// A broken drop-in must never break the run: warn and skip.
48+
slog.Warn("Skipping invalid hooks drop-in file", "path", path, "error", err)
49+
continue
50+
}
51+
merged = MergeHooks(merged, hooks)
52+
}
53+
return merged
54+
}
55+
56+
// readHookDropIn parses one drop-in file. Parsing is strict so a typo'd
57+
// event name surfaces as a warning instead of being silently ignored.
58+
func readHookDropIn(path string) (*latest.HooksConfig, error) {
59+
data, err := os.ReadFile(path)
60+
if err != nil {
61+
return nil, err
62+
}
63+
var hooks latest.HooksConfig
64+
if err := yaml.UnmarshalWithOptions(data, &hooks, yaml.Strict()); err != nil {
65+
return nil, err
66+
}
67+
if err := hooks.Validate(); err != nil {
68+
return nil, err
69+
}
70+
return &hooks, nil
71+
}

pkg/config/hooks_dropin_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package config
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
11+
"github.com/docker/docker-agent/pkg/paths"
12+
)
13+
14+
func writeHookDropIn(t *testing.T, dir, name, content string) {
15+
t.Helper()
16+
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
17+
}
18+
19+
func TestLoadHookDropIns_MissingDir(t *testing.T) {
20+
t.Parallel()
21+
assert.Nil(t, loadHookDropIns(filepath.Join(t.TempDir(), "hooks.d")))
22+
}
23+
24+
func TestLoadHookDropIns_LexicographicOrder(t *testing.T) {
25+
t.Parallel()
26+
dir := t.TempDir()
27+
writeHookDropIn(t, dir, "20-second.yaml", "stop:\n - type: command\n command: echo second\n")
28+
writeHookDropIn(t, dir, "10-first.yaml", "stop:\n - type: command\n command: echo first\n")
29+
30+
hooks := loadHookDropIns(dir)
31+
require.NotNil(t, hooks)
32+
require.Len(t, hooks.Stop, 2)
33+
assert.Equal(t, "echo first", hooks.Stop[0].Command)
34+
assert.Equal(t, "echo second", hooks.Stop[1].Command)
35+
}
36+
37+
func TestLoadHookDropIns_MergesAcrossEvents(t *testing.T) {
38+
t.Parallel()
39+
dir := t.TempDir()
40+
writeHookDropIn(t, dir, "a.yaml", `
41+
session_start:
42+
- type: command
43+
command: echo start
44+
pre_tool_use:
45+
- matcher: shell
46+
hooks:
47+
- type: command
48+
command: audit.sh
49+
`)
50+
writeHookDropIn(t, dir, "b.yml", "stop:\n - type: command\n command: echo stop\n")
51+
52+
hooks := loadHookDropIns(dir)
53+
require.NotNil(t, hooks)
54+
require.Len(t, hooks.SessionStart, 1)
55+
assert.Equal(t, "echo start", hooks.SessionStart[0].Command)
56+
require.Len(t, hooks.PreToolUse, 1)
57+
assert.Equal(t, "shell", hooks.PreToolUse[0].Matcher)
58+
require.Len(t, hooks.Stop, 1)
59+
assert.Equal(t, "echo stop", hooks.Stop[0].Command)
60+
}
61+
62+
func TestLoadHookDropIns_SkipsMalformedFiles(t *testing.T) {
63+
t.Parallel()
64+
dir := t.TempDir()
65+
writeHookDropIn(t, dir, "10-broken-yaml.yaml", "stop: [unclosed\n")
66+
writeHookDropIn(t, dir, "20-unknown-key.yaml", "not_a_hook_event:\n - type: command\n command: echo hi\n")
67+
writeHookDropIn(t, dir, "30-invalid-hook-type.yaml", "stop:\n - type: teleport\n command: echo hi\n")
68+
writeHookDropIn(t, dir, "40-valid.yaml", "stop:\n - type: command\n command: echo ok\n")
69+
70+
hooks := loadHookDropIns(dir)
71+
require.NotNil(t, hooks)
72+
require.Len(t, hooks.Stop, 1)
73+
assert.Equal(t, "echo ok", hooks.Stop[0].Command)
74+
}
75+
76+
func TestLoadHookDropIns_IgnoresNonYAMLAndSubdirs(t *testing.T) {
77+
t.Parallel()
78+
dir := t.TempDir()
79+
writeHookDropIn(t, dir, "README.md", "# not yaml")
80+
writeHookDropIn(t, dir, "50-disabled.yaml.bak", "stop:\n - type: command\n command: echo no\n")
81+
require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub.yaml"), 0o700))
82+
writeHookDropIn(t, filepath.Join(dir, "sub.yaml"), "inner.yaml", "stop:\n - type: command\n command: echo nested\n")
83+
84+
assert.Nil(t, loadHookDropIns(dir))
85+
}
86+
87+
func TestLoadHookDropIns_EmptyAndCommentOnlyFiles(t *testing.T) {
88+
t.Parallel()
89+
dir := t.TempDir()
90+
writeHookDropIn(t, dir, "a.yaml", "")
91+
writeHookDropIn(t, dir, "b.yaml", "# comment only\n")
92+
93+
assert.Nil(t, loadHookDropIns(dir))
94+
}
95+
96+
func TestLoadHookDropIns_UsesConfigDir(t *testing.T) {
97+
// Not parallel: overrides the process-global config dir.
98+
configDir := t.TempDir()
99+
paths.SetConfigDir(configDir)
100+
t.Cleanup(func() { paths.SetConfigDir("") })
101+
102+
hooksDir := filepath.Join(configDir, "hooks.d")
103+
require.NoError(t, os.MkdirAll(hooksDir, 0o700))
104+
writeHookDropIn(t, hooksDir, "a.yaml", "stop:\n - type: command\n command: echo hi\n")
105+
106+
hooks := LoadHookDropIns()
107+
require.NotNil(t, hooks)
108+
require.Len(t, hooks.Stop, 1)
109+
assert.Equal(t, "echo hi", hooks.Stop[0].Command)
110+
}

0 commit comments

Comments
 (0)