Skip to content

Commit 6604b49

Browse files
refactor: separate XDG directories for config, data, state and runtime (#1368)
**Description** This refactors aigw to use distinct directories following XDG Base Directory Specification conventions: - --config-home/$AIGW_CONFIG_HOME: Configuration files (default: ~/.config/aigw) - --data-home/$AIGW_DATA_HOME: Envoy binaries via func-e (default: ~/.local/share/aigw) - --state-home/$AIGW_STATE_HOME: Run logs and state (default: ~/.local/state/aigw) - --runtime-dir/$AIGW_RUNTIME_DIR: Ephemeral files like UDS (default: /tmp/aigw-${UID}) This separation aligns with XDG principles where configuration, data, state, and runtime files are independently configurable for different storage tiers. This is particularly useful for Docker deployments to map volumes appropriately. This also adds --run-id/$AIGW_RUN_ID to override the default YYYYMMDD_HHMMSS_UUU timestamp format with a custom identifier. Setting this to '0' enables predictable paths for Docker/Kubernetes single-run scenarios. **Related Issues/PRs (if applicable)** once envoyproxy/gateway#7225 is merged we have some maintenance to remove the /tmp/envoy-gateway/certs tech debt --------- Signed-off-by: Adrian Cole <[email protected]>
1 parent 25cd552 commit 6604b49

File tree

15 files changed

+942
-108
lines changed

15 files changed

+942
-108
lines changed

.github/workflows/build_and_test.yaml

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,6 @@ jobs:
6868
~/go/bin
6969
key: unittest-${{ hashFiles('**/go.mod', '**/go.sum', '**/Makefile') }}-${{ matrix.os }}
7070
- run: make test-coverage
71-
- if: failure()
72-
run: cat ollama.log || true
7371
- name: Upload coverage to Codecov
7472
if: matrix.os == 'ubuntu-latest'
7573
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
@@ -337,7 +335,7 @@ jobs:
337335
- name: Download Envoy via func-e
338336
run: go tool -modfile=tools/go.mod func-e run --version
339337
env:
340-
FUNC_E_HOME: /tmp/envoy-gateway # hard-coded directory in EG
338+
FUNC_E_DATA_HOME: ~/.local/share/aigw
341339
- name: Install Goose
342340
env:
343341
GOOSE_VERSION: v1.10.0

Dockerfile

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,30 +15,44 @@ FROM golang:1.25 AS envoy-downloader
1515
ARG TARGETOS
1616
ARG TARGETARCH
1717
ARG COMMAND_NAME
18-
# Hard-coded directory for envoy-gateway resources
19-
# See https://github.com/envoyproxy/gateway/blob/d95ce4ce564cfff47ed1fd6c97e29c1058aa4a61/internal/infrastructure/host/proxy_infra.go#L16
20-
WORKDIR /tmp/envoy-gateway
18+
# Download Envoy binary to AIGW_DATA_HOME for the nonroot user
19+
WORKDIR /build
2120
RUN if [ "$COMMAND_NAME" = "aigw" ]; then \
2221
go install github.com/tetratelabs/func-e/cmd/func-e@latest && \
23-
func-e --platform ${TARGETOS}/${TARGETARCH} --home-dir . run --version; \
22+
FUNC_E_DATA_HOME=/home/nonroot/.local/share/aigw func-e --platform ${TARGETOS}/${TARGETARCH} run --version; \
2423
fi \
25-
&& mkdir -p certs \
26-
&& chown -R 65532:65532 . \
27-
&& chmod -R 755 .
24+
# Create directories for the nonroot user
25+
&& mkdir -p /home/nonroot /tmp/envoy-gateway/certs \
26+
&& chown -R 65532:65532 /home/nonroot /tmp/envoy-gateway \
27+
&& chmod -R 755 /home/nonroot /tmp/envoy-gateway
2828

2929
FROM gcr.io/distroless/${VARIANT}-debian12:nonroot
3030
ARG COMMAND_NAME
3131
ARG TARGETOS
3232
ARG TARGETARCH
3333

34+
# Copy pre-downloaded Envoy binary and EG certs directory
35+
COPY --from=envoy-downloader /home/nonroot /home/nonroot
3436
COPY --from=envoy-downloader /tmp/envoy-gateway /tmp/envoy-gateway
3537
COPY ./out/${COMMAND_NAME}-${TARGETOS}-${TARGETARCH} /app
3638

3739
USER nonroot:nonroot
3840

41+
# Set AIGW_RUN_ID=0 for predictable file paths in containers.
42+
# This creates the following directory structure:
43+
# ~/.config/aigw/ - XDG config (e.g., envoy-version preference)
44+
# ~/.local/share/aigw/ - XDG data (downloaded Envoy binaries via func-e)
45+
# ~/.local/state/aigw/runs/0/ - XDG state (aigw.log, envoy-gateway-config.yaml, extproc-config.yaml, resources/)
46+
# ~/.local/state/aigw/envoy-runs/0/ - XDG state (func-e stdout.log, stderr.log)
47+
# /tmp/aigw-0/ - XDG runtime (uds.sock, admin-address.txt)
48+
ENV AIGW_RUN_ID=0
49+
3950
# The healthcheck subcommand performs an HTTP GET to localhost:1064/healthlthy for "aigw run".
4051
# NOTE: This is only for aigw in practice since this is ignored by Kubernetes.
4152
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 \
4253
CMD ["/app", "healthcheck"]
4354

4455
ENTRYPOINT ["/app"]
56+
57+
# Default CMD for aigw - uses AIGW_RUN_ID from environment
58+
CMD ["run"]

cmd/aigw/config.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import (
99
"errors"
1010
"fmt"
1111
"os"
12+
"path/filepath"
1213
"reflect"
14+
"strings"
1315

1416
"github.com/a8m/envsubst"
1517

@@ -64,3 +66,29 @@ func readConfig(path string, mcpServers *autoconfig.MCPServers, debug bool) (str
6466
}
6567
return envsubst.String(config)
6668
}
69+
70+
// expandPath expands environment variables and tilde in paths, then converts to absolute path.
71+
// Returns empty string if input is empty.
72+
// Replaces ~/ with ${HOME}/ before expanding environment variables.
73+
func expandPath(path string) string {
74+
if path == "" {
75+
return ""
76+
}
77+
78+
// Replace ~/ with ${HOME}/
79+
if strings.HasPrefix(path, "~/") {
80+
path = "${HOME}/" + path[2:]
81+
}
82+
83+
// Expand environment variables
84+
expanded := os.ExpandEnv(path)
85+
86+
// Convert to absolute path
87+
abs, err := filepath.Abs(expanded)
88+
if err != nil {
89+
// If we can't get absolute path, return expanded path
90+
return expanded
91+
}
92+
93+
return abs
94+
}

cmd/aigw/config_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,73 @@ func TestReadConfig(t *testing.T) {
135135
})
136136
}
137137

138+
func TestExpandPath(t *testing.T) {
139+
homeDir, err := os.UserHomeDir()
140+
require.NoError(t, err)
141+
142+
tests := []struct {
143+
name string
144+
path string
145+
envVars map[string]string
146+
expected string
147+
}{
148+
{
149+
name: "empty path returns empty",
150+
path: "",
151+
expected: "",
152+
},
153+
{
154+
name: "tilde path",
155+
path: "~/test/file.txt",
156+
expected: filepath.Join(homeDir, "test/file.txt"),
157+
},
158+
{
159+
name: "tilde slash returns HOME",
160+
path: "~/",
161+
expected: homeDir,
162+
},
163+
{
164+
name: "absolute path unchanged",
165+
path: "/absolute/path/file.txt",
166+
expected: "/absolute/path/file.txt",
167+
},
168+
{
169+
name: "env var expansion",
170+
path: "${HOME}/test",
171+
expected: filepath.Join(homeDir, "test"),
172+
},
173+
{
174+
name: "custom env var",
175+
path: "${CUSTOM_DIR}/file.txt",
176+
envVars: map[string]string{"CUSTOM_DIR": "/custom"},
177+
expected: "/custom/file.txt",
178+
},
179+
{
180+
name: "tilde with env var",
181+
path: "~/test/${USER}",
182+
envVars: map[string]string{"USER": "testuser"},
183+
expected: filepath.Join(homeDir, "test/testuser"),
184+
},
185+
}
186+
for _, tt := range tests {
187+
t.Run(tt.name, func(t *testing.T) {
188+
for k, v := range tt.envVars {
189+
t.Setenv(k, v)
190+
}
191+
192+
actual := expandPath(tt.path)
193+
require.Equal(t, tt.expected, actual)
194+
})
195+
}
196+
t.Run("relative/path", func(t *testing.T) {
197+
cwd, err := os.Getwd()
198+
require.NoError(t, err)
199+
expected := filepath.Join(cwd, "relative/path")
200+
actual := expandPath("relative/path")
201+
require.Equal(t, expected, actual)
202+
})
203+
}
204+
138205
func TestRecreateDir(t *testing.T) {
139206
tests := []struct {
140207
name string

cmd/aigw/main.go

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,26 @@ import (
1212
"io"
1313
"log"
1414
"os"
15+
"time"
1516

1617
"github.com/alecthomas/kong"
1718
ctrl "sigs.k8s.io/controller-runtime"
1819

1920
"github.com/envoyproxy/ai-gateway/cmd/extproc/mainlib"
2021
"github.com/envoyproxy/ai-gateway/internal/autoconfig"
2122
"github.com/envoyproxy/ai-gateway/internal/version"
23+
"github.com/envoyproxy/ai-gateway/internal/xdg"
2224
)
2325

2426
type (
2527
// cmd corresponds to the top-level `aigw` command.
2628
cmd struct {
29+
// Global XDG flags
30+
ConfigHome string `name:"config-home" env:"AIGW_CONFIG_HOME" help:"Configuration files directory. Defaults to ~/.config/aigw" type:"path"`
31+
DataHome string `name:"data-home" env:"AIGW_DATA_HOME" help:"Downloaded Envoy binaries directory. Defaults to ~/.local/share/aigw" type:"path"`
32+
StateHome string `name:"state-home" env:"AIGW_STATE_HOME" help:"Persistent state and logs directory. Defaults to ~/.local/state/aigw" type:"path"`
33+
RuntimeDir string `name:"runtime-dir" env:"AIGW_RUNTIME_DIR" help:"Ephemeral runtime files directory. Defaults to /tmp/aigw-$UID" type:"path"`
34+
2735
// Version is the sub-command to show the version.
2836
Version struct{} `cmd:"" help:"Show version."`
2937
// Run is the sub-command parsed by the `cmdRun` struct.
@@ -34,16 +42,74 @@ type (
3442
// cmdRun corresponds to `aigw run` command.
3543
cmdRun struct {
3644
Debug bool `help:"Enable debug logging emitted to stderr."`
37-
Path string `arg:"" name:"path" optional:"" help:"Path to the AI Gateway configuration yaml file. Optional when at least OPENAI_API_KEY, AZURE_OPENAI_API_KEY, or ANTHROPIC_API_KEY is set." type:"path"`
45+
Path string `arg:"" name:"path" optional:"" help:"Path to the AI Gateway configuration yaml file. Defaults to $AIGW_CONFIG_HOME/config.yaml if exists, otherwise optional when at least OPENAI_API_KEY, AZURE_OPENAI_API_KEY or ANTHROPIC_API_KEY is set." type:"path"`
3846
AdminPort int `help:"HTTP port for the admin server (serves /metrics and /health endpoints)." default:"1064"`
3947
McpConfig string `name:"mcp-config" help:"Path to MCP servers configuration file." type:"path"`
4048
McpJSON string `name:"mcp-json" help:"JSON string of MCP servers configuration."`
49+
RunID string `name:"run-id" env:"AIGW_RUN_ID" help:"Run identifier for this invocation. Defaults to timestamp-based ID or $AIGW_RUN_ID. Use '0' for Docker/Kubernetes."`
4150
mcpConfig *autoconfig.MCPServers `kong:"-"` // Internal field: normalized MCP JSON data
51+
dirs *xdg.Directories `kong:"-"` // Internal field: XDG directories, set by BeforeApply
52+
runOpts *runOpts `kong:"-"` // Internal field: run options, set by Validate
4253
}
4354
// cmdHealthcheck corresponds to `aigw healthcheck` command.
4455
cmdHealthcheck struct{}
4556
)
4657

58+
// BeforeApply is called by Kong before applying defaults to set XDG directory defaults.
59+
func (c *cmd) BeforeApply(_ *kong.Context) error {
60+
// Expand paths unconditionally (handles ~/, env vars, and converts to absolute)
61+
// Set defaults only if not set (empty string)
62+
if c.ConfigHome == "" {
63+
c.ConfigHome = "~/.config/aigw"
64+
}
65+
c.ConfigHome = expandPath(c.ConfigHome)
66+
67+
if c.DataHome == "" {
68+
c.DataHome = "~/.local/share/aigw"
69+
}
70+
c.DataHome = expandPath(c.DataHome)
71+
72+
if c.StateHome == "" {
73+
c.StateHome = "~/.local/state/aigw"
74+
}
75+
c.StateHome = expandPath(c.StateHome)
76+
77+
if c.RuntimeDir == "" {
78+
c.RuntimeDir = "/tmp/aigw-${UID}"
79+
}
80+
c.RuntimeDir = expandPath(c.RuntimeDir)
81+
82+
// Populate Run.dirs with expanded XDG directories for use in Run.BeforeApply
83+
c.Run.dirs = &xdg.Directories{
84+
ConfigHome: c.ConfigHome,
85+
DataHome: c.DataHome,
86+
StateHome: c.StateHome,
87+
RuntimeDir: c.RuntimeDir,
88+
}
89+
90+
return nil
91+
}
92+
93+
// BeforeApply is called by Kong before applying defaults to set computed default values.
94+
func (c *cmdRun) BeforeApply(_ *kong.Context) error {
95+
// Set RunID default if not provided
96+
if c.RunID == "" {
97+
c.RunID = generateRunID(time.Now())
98+
}
99+
100+
// Set Path to default config.yaml if it exists and Path not provided
101+
if c.Path == "" && c.dirs != nil {
102+
defaultPath := c.dirs.ConfigHome + "/config.yaml"
103+
if _, err := os.Stat(defaultPath); err == nil {
104+
c.Path = defaultPath
105+
}
106+
}
107+
// Expand Path (handles ~/, env vars, and converts to absolute)
108+
c.Path = expandPath(c.Path)
109+
110+
return nil
111+
}
112+
47113
// Validate is called by Kong after parsing to validate the cmdRun arguments.
48114
func (c *cmdRun) Validate() error {
49115
if c.McpConfig != "" && c.McpJSON != "" {
@@ -53,6 +119,8 @@ func (c *cmdRun) Validate() error {
53119
return fmt.Errorf("you must supply at least OPENAI_API_KEY, AZURE_OPENAI_API_KEY, ANTHROPIC_API_KEY, or a config file path")
54120
}
55121

122+
c.McpConfig = expandPath(c.McpConfig)
123+
56124
var mcpJSON string
57125
if c.McpConfig != "" {
58126
raw, err := os.ReadFile(c.McpConfig)
@@ -71,11 +139,18 @@ func (c *cmdRun) Validate() error {
71139
}
72140
c.mcpConfig = &mcpConfig
73141
}
142+
143+
opts, err := newRunOpts(c.dirs, c.RunID, c.Path, mainlib.Main)
144+
if err != nil {
145+
return fmt.Errorf("failed to create run options: %w", err)
146+
}
147+
c.runOpts = opts
148+
74149
return nil
75150
}
76151

77152
type (
78-
runFn func(context.Context, cmdRun, runOpts, io.Writer, io.Writer) error
153+
runFn func(context.Context, cmdRun, *runOpts, io.Writer, io.Writer) error
79154
healthcheckFn func(context.Context, io.Writer, io.Writer) error
80155
)
81156

@@ -106,11 +181,12 @@ func doMain(ctx context.Context, stdout, stderr io.Writer, args []string, exitFn
106181
}
107182
parsed, err := parser.Parse(args)
108183
parser.FatalIfErrorf(err)
184+
109185
switch parsed.Command() {
110186
case "version":
111187
_, _ = fmt.Fprintf(stdout, "Envoy AI Gateway CLI: %s\n", version.Version)
112188
case "run", "run <path>":
113-
err = rf(ctx, c.Run, runOpts{extProcLauncher: mainlib.Main}, stdout, stderr)
189+
err = rf(ctx, c.Run, c.Run.runOpts, stdout, stderr)
114190
if err != nil {
115191
log.Fatalf("Error running: %v", err)
116192
}
@@ -123,3 +199,11 @@ func doMain(ctx context.Context, stdout, stderr io.Writer, args []string, exitFn
123199
panic("unreachable")
124200
}
125201
}
202+
203+
// generateRunID generates a unique run identifier based on the current time.
204+
// Defaults to the same convention as func-e: "YYYYMMDD_HHMMSS_UUU" format.
205+
// Last 3 digits of microseconds to allow concurrent runs.
206+
func generateRunID(now time.Time) string {
207+
micro := now.Nanosecond() / 1000 % 1000
208+
return fmt.Sprintf("%s_%03d", now.Format("20060102_150405"), micro)
209+
}

0 commit comments

Comments
 (0)