Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/build_and_test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ jobs:
~/go/bin
key: unittest-${{ hashFiles('**/go.mod', '**/go.sum', '**/Makefile') }}-${{ matrix.os }}
- run: make test-coverage
- if: failure()
run: cat ollama.log || true
- name: Upload coverage to Codecov
if: matrix.os == 'ubuntu-latest'
uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5.4.3
Expand Down Expand Up @@ -337,7 +335,7 @@ jobs:
- name: Download Envoy via func-e
run: go tool -modfile=tools/go.mod func-e run --version
env:
FUNC_E_HOME: /tmp/envoy-gateway # hard-coded directory in EG
FUNC_E_DATA_HOME: ~/.local/share/aigw
- name: Install Goose
env:
GOOSE_VERSION: v1.10.0
Expand Down
28 changes: 21 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -15,30 +15,44 @@ FROM golang:1.25 AS envoy-downloader
ARG TARGETOS
ARG TARGETARCH
ARG COMMAND_NAME
# Hard-coded directory for envoy-gateway resources
# See https://github.com/envoyproxy/gateway/blob/d95ce4ce564cfff47ed1fd6c97e29c1058aa4a61/internal/infrastructure/host/proxy_infra.go#L16
WORKDIR /tmp/envoy-gateway
# Download Envoy binary to AIGW_DATA_HOME for the nonroot user
WORKDIR /build
RUN if [ "$COMMAND_NAME" = "aigw" ]; then \
go install github.com/tetratelabs/func-e/cmd/func-e@latest && \
func-e --platform ${TARGETOS}/${TARGETARCH} --home-dir . run --version; \
FUNC_E_DATA_HOME=/home/nonroot/.local/share/aigw func-e --platform ${TARGETOS}/${TARGETARCH} run --version; \
fi \
&& mkdir -p certs \
&& chown -R 65532:65532 . \
&& chmod -R 755 .
# Create directories for the nonroot user
&& mkdir -p /home/nonroot /tmp/envoy-gateway/certs \
&& chown -R 65532:65532 /home/nonroot /tmp/envoy-gateway \
&& chmod -R 755 /home/nonroot /tmp/envoy-gateway

FROM gcr.io/distroless/${VARIANT}-debian12:nonroot
ARG COMMAND_NAME
ARG TARGETOS
ARG TARGETARCH

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

USER nonroot:nonroot

# Set AIGW_RUN_ID=0 for predictable file paths in containers.
# This creates the following directory structure:
# ~/.config/aigw/ - XDG config (e.g., envoy-version preference)
# ~/.local/share/aigw/ - XDG data (downloaded Envoy binaries via func-e)
# ~/.local/state/aigw/runs/0/ - XDG state (aigw.log, envoy-gateway-config.yaml, extproc-config.yaml, resources/)
# ~/.local/state/aigw/envoy-runs/0/ - XDG state (func-e stdout.log, stderr.log)
# /tmp/aigw-0/ - XDG runtime (uds.sock, admin-address.txt)
ENV AIGW_RUN_ID=0

# The healthcheck subcommand performs an HTTP GET to localhost:1064/healthlthy for "aigw run".
# NOTE: This is only for aigw in practice since this is ignored by Kubernetes.
HEALTHCHECK --interval=10s --timeout=5s --start-period=5s --retries=3 \
CMD ["/app", "healthcheck"]

ENTRYPOINT ["/app"]

# Default CMD for aigw - uses AIGW_RUN_ID from environment
CMD ["run"]
28 changes: 28 additions & 0 deletions cmd/aigw/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"strings"

"github.com/a8m/envsubst"

Expand Down Expand Up @@ -64,3 +66,29 @@ func readConfig(path string, mcpServers *autoconfig.MCPServers, debug bool) (str
}
return envsubst.String(config)
}

// expandPath expands environment variables and tilde in paths, then converts to absolute path.
// Returns empty string if input is empty.
// Replaces ~/ with ${HOME}/ before expanding environment variables.
func expandPath(path string) string {
if path == "" {
return ""
}

// Replace ~/ with ${HOME}/
if strings.HasPrefix(path, "~/") {
path = "${HOME}/" + path[2:]
}

// Expand environment variables
expanded := os.ExpandEnv(path)

// Convert to absolute path
abs, err := filepath.Abs(expanded)
if err != nil {
// If we can't get absolute path, return expanded path
return expanded
}

return abs
}
67 changes: 67 additions & 0 deletions cmd/aigw/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,73 @@ func TestReadConfig(t *testing.T) {
})
}

func TestExpandPath(t *testing.T) {
homeDir, err := os.UserHomeDir()
require.NoError(t, err)

tests := []struct {
name string
path string
envVars map[string]string
expected string
}{
{
name: "empty path returns empty",
path: "",
expected: "",
},
{
name: "tilde path",
path: "~/test/file.txt",
expected: filepath.Join(homeDir, "test/file.txt"),
},
{
name: "tilde slash returns HOME",
path: "~/",
expected: homeDir,
},
{
name: "absolute path unchanged",
path: "/absolute/path/file.txt",
expected: "/absolute/path/file.txt",
},
{
name: "env var expansion",
path: "${HOME}/test",
expected: filepath.Join(homeDir, "test"),
},
{
name: "custom env var",
path: "${CUSTOM_DIR}/file.txt",
envVars: map[string]string{"CUSTOM_DIR": "/custom"},
expected: "/custom/file.txt",
},
{
name: "tilde with env var",
path: "~/test/${USER}",
envVars: map[string]string{"USER": "testuser"},
expected: filepath.Join(homeDir, "test/testuser"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
for k, v := range tt.envVars {
t.Setenv(k, v)
}

actual := expandPath(tt.path)
require.Equal(t, tt.expected, actual)
})
}
t.Run("relative/path", func(t *testing.T) {
cwd, err := os.Getwd()
require.NoError(t, err)
expected := filepath.Join(cwd, "relative/path")
actual := expandPath("relative/path")
require.Equal(t, expected, actual)
})
}

func TestRecreateDir(t *testing.T) {
tests := []struct {
name string
Expand Down
90 changes: 87 additions & 3 deletions cmd/aigw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,26 @@ import (
"io"
"log"
"os"
"time"

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

"github.com/envoyproxy/ai-gateway/cmd/extproc/mainlib"
"github.com/envoyproxy/ai-gateway/internal/autoconfig"
"github.com/envoyproxy/ai-gateway/internal/version"
"github.com/envoyproxy/ai-gateway/internal/xdg"
)

type (
// cmd corresponds to the top-level `aigw` command.
cmd struct {
// Global XDG flags
ConfigHome string `name:"config-home" env:"AIGW_CONFIG_HOME" help:"Configuration files directory. Defaults to ~/.config/aigw" type:"path"`
DataHome string `name:"data-home" env:"AIGW_DATA_HOME" help:"Downloaded Envoy binaries directory. Defaults to ~/.local/share/aigw" type:"path"`
StateHome string `name:"state-home" env:"AIGW_STATE_HOME" help:"Persistent state and logs directory. Defaults to ~/.local/state/aigw" type:"path"`
RuntimeDir string `name:"runtime-dir" env:"AIGW_RUNTIME_DIR" help:"Ephemeral runtime files directory. Defaults to /tmp/aigw-$UID" type:"path"`

// Version is the sub-command to show the version.
Version struct{} `cmd:"" help:"Show version."`
// Run is the sub-command parsed by the `cmdRun` struct.
Expand All @@ -34,16 +42,74 @@ type (
// cmdRun corresponds to `aigw run` command.
cmdRun struct {
Debug bool `help:"Enable debug logging emitted to stderr."`
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"`
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"`
AdminPort int `help:"HTTP port for the admin server (serves /metrics and /health endpoints)." default:"1064"`
McpConfig string `name:"mcp-config" help:"Path to MCP servers configuration file." type:"path"`
McpJSON string `name:"mcp-json" help:"JSON string of MCP servers configuration."`
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."`
mcpConfig *autoconfig.MCPServers `kong:"-"` // Internal field: normalized MCP JSON data
dirs *xdg.Directories `kong:"-"` // Internal field: XDG directories, set by BeforeApply
runOpts *runOpts `kong:"-"` // Internal field: run options, set by Validate
}
// cmdHealthcheck corresponds to `aigw healthcheck` command.
cmdHealthcheck struct{}
)

// BeforeApply is called by Kong before applying defaults to set XDG directory defaults.
func (c *cmd) BeforeApply(_ *kong.Context) error {
// Expand paths unconditionally (handles ~/, env vars, and converts to absolute)
// Set defaults only if not set (empty string)
if c.ConfigHome == "" {
c.ConfigHome = "~/.config/aigw"
}
c.ConfigHome = expandPath(c.ConfigHome)

if c.DataHome == "" {
c.DataHome = "~/.local/share/aigw"
}
c.DataHome = expandPath(c.DataHome)

if c.StateHome == "" {
c.StateHome = "~/.local/state/aigw"
}
c.StateHome = expandPath(c.StateHome)

if c.RuntimeDir == "" {
c.RuntimeDir = "/tmp/aigw-${UID}"
}
c.RuntimeDir = expandPath(c.RuntimeDir)

// Populate Run.dirs with expanded XDG directories for use in Run.BeforeApply
c.Run.dirs = &xdg.Directories{
ConfigHome: c.ConfigHome,
DataHome: c.DataHome,
StateHome: c.StateHome,
RuntimeDir: c.RuntimeDir,
}

return nil
}

// BeforeApply is called by Kong before applying defaults to set computed default values.
func (c *cmdRun) BeforeApply(_ *kong.Context) error {
// Set RunID default if not provided
if c.RunID == "" {
c.RunID = generateRunID(time.Now())
}

// Set Path to default config.yaml if it exists and Path not provided
if c.Path == "" && c.dirs != nil {
defaultPath := c.dirs.ConfigHome + "/config.yaml"
if _, err := os.Stat(defaultPath); err == nil {
c.Path = defaultPath
}
}
// Expand Path (handles ~/, env vars, and converts to absolute)
c.Path = expandPath(c.Path)

return nil
}

// Validate is called by Kong after parsing to validate the cmdRun arguments.
func (c *cmdRun) Validate() error {
if c.McpConfig != "" && c.McpJSON != "" {
Expand All @@ -53,6 +119,8 @@ func (c *cmdRun) Validate() error {
return fmt.Errorf("you must supply at least OPENAI_API_KEY, AZURE_OPENAI_API_KEY, ANTHROPIC_API_KEY, or a config file path")
}

c.McpConfig = expandPath(c.McpConfig)

var mcpJSON string
if c.McpConfig != "" {
raw, err := os.ReadFile(c.McpConfig)
Expand All @@ -71,11 +139,18 @@ func (c *cmdRun) Validate() error {
}
c.mcpConfig = &mcpConfig
}

opts, err := newRunOpts(c.dirs, c.RunID, c.Path, mainlib.Main)
if err != nil {
return fmt.Errorf("failed to create run options: %w", err)
}
c.runOpts = opts

return nil
}

type (
runFn func(context.Context, cmdRun, runOpts, io.Writer, io.Writer) error
runFn func(context.Context, cmdRun, *runOpts, io.Writer, io.Writer) error
healthcheckFn func(context.Context, io.Writer, io.Writer) error
)

Expand Down Expand Up @@ -106,11 +181,12 @@ func doMain(ctx context.Context, stdout, stderr io.Writer, args []string, exitFn
}
parsed, err := parser.Parse(args)
parser.FatalIfErrorf(err)

switch parsed.Command() {
case "version":
_, _ = fmt.Fprintf(stdout, "Envoy AI Gateway CLI: %s\n", version.Version)
case "run", "run <path>":
err = rf(ctx, c.Run, runOpts{extProcLauncher: mainlib.Main}, stdout, stderr)
err = rf(ctx, c.Run, c.Run.runOpts, stdout, stderr)
if err != nil {
log.Fatalf("Error running: %v", err)
}
Expand All @@ -123,3 +199,11 @@ func doMain(ctx context.Context, stdout, stderr io.Writer, args []string, exitFn
panic("unreachable")
}
}

// generateRunID generates a unique run identifier based on the current time.
// Defaults to the same convention as func-e: "YYYYMMDD_HHMMSS_UUU" format.
// Last 3 digits of microseconds to allow concurrent runs.
func generateRunID(now time.Time) string {
micro := now.Nanosecond() / 1000 % 1000
return fmt.Sprintf("%s_%03d", now.Format("20060102_150405"), micro)
}
Loading
Loading