diff --git a/docs/reference/cli/ai-gateway_start.md b/docs/reference/cli/ai-gateway_start.md index b3f9d6c663b99..c39b210fc85f0 100644 --- a/docs/reference/cli/ai-gateway_start.md +++ b/docs/reference/cli/ai-gateway_start.md @@ -161,6 +161,15 @@ Output JSON logs to a given file. Output Stackdriver compatible logs to a given file. +### -c, --config + +| | | +|-------------|---------------------------------| +| Type | yaml-config-path | +| Environment | $CODER_CONFIG_PATH | + +Specify a YAML file to load configuration from. + ### --ai-gateway-max-concurrency | | | diff --git a/enterprise/cli/aigatewaystart.go b/enterprise/cli/aigatewaystart.go index c32c6bdd51673..2475cffe59ad6 100644 --- a/enterprise/cli/aigatewaystart.go +++ b/enterprise/cli/aigatewaystart.go @@ -28,6 +28,7 @@ import ( aibridgemetrics "github.com/coder/coder/v2/aibridge/metrics" agpl "github.com/coder/coder/v2/cli" "github.com/coder/coder/v2/cli/clilog" + "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/coderd/aibridged" coderdtracing "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" @@ -69,6 +70,9 @@ var aiGatewayInheritedEnvs = map[string]struct{}{ "CODER_TRACE_HONEYCOMB_API_KEY": {}, "CODER_TRACE_LOGS": {}, + // Config + "CODER_CONFIG_PATH": {}, + // AI Gateway "CODER_AI_GATEWAY_ALLOW_BYOK": {}, "CODER_AI_GATEWAY_CIRCUIT_BREAKER_ENABLED": {}, @@ -112,6 +116,10 @@ func (r *RootCmd) aiGatewayStart() *serpent.Command { signalCtx, stop := inv.SignalNotifyContext(inv.Context(), agpl.StopSignals...) defer stop() + if vals.Config != "" { + cliui.Warnf(inv.Stderr, "YAML support is experimental and offers no compatibility guarantees.") + } + resolvedKey, err := resolveAIGatewayKey(key, keyFile) if err != nil { return err diff --git a/enterprise/cli/aigatewaystart_internal_test.go b/enterprise/cli/aigatewaystart_internal_test.go index 89e64d69984f3..d30e06fe09a2e 100644 --- a/enterprise/cli/aigatewaystart_internal_test.go +++ b/enterprise/cli/aigatewaystart_internal_test.go @@ -592,6 +592,7 @@ func TestAIGatewayStart_InheritedOptions(t *testing.T) { // Groups the gateway sources options from. sourceGroups := map[string]struct{}{ + "Config": {}, "Logging": {}, "Tracing": {}, "AI Gateway": {}, diff --git a/enterprise/cli/aigatewaystart_test.go b/enterprise/cli/aigatewaystart_test.go index 4df5c856a59ee..b15a4f2aeb161 100644 --- a/enterprise/cli/aigatewaystart_test.go +++ b/enterprise/cli/aigatewaystart_test.go @@ -12,6 +12,8 @@ import ( "net/http/httptest" "net/http/httputil" "net/url" + "os" + "path/filepath" "regexp" "strings" "sync" @@ -239,6 +241,19 @@ func requireEventualAIGatewayStatus(ctx context.Context, t *testing.T, probeURL }, testutil.WaitLong, testutil.IntervalFast, "%s never returned %d", probeURL, want) } +// startUnreachableCoderd starts a coderd stub that answers 503 to every +// request, so the gateway daemon keeps retrying its control connection instead +// of completing. It returns the stub's URL. +func startUnreachableCoderd(t *testing.T) string { + t.Helper() + + coderSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusServiceUnavailable) + })) + t.Cleanup(coderSrv.Close) + return coderSrv.URL +} + // TestAIGatewayStartE2E drives every part of the standalone gateway plumbing // once through public surface only: the CLI starts with flags, connects to // coderd with a gateway key, reports health and readiness, proxies an LLM @@ -308,15 +323,12 @@ func TestAIGatewayStartE2E_InvalidKey(t *testing.T) { func TestAIGatewayStart_HealthBeforeReady(t *testing.T) { t.Parallel() - // Given: a coderd that always answers 503 - coderSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusServiceUnavailable) - })) - t.Cleanup(coderSrv.Close) + // Given: a coderd that always answers 503, so the daemon keeps retrying. + coderURL := startUnreachableCoderd(t) ctx := testutil.Context(t, testutil.WaitShort) // When: the gateway starts and binds its listener. - baseURL, _ := startAIGatewayCommand(ctx, t, coderSrv.URL, "test-key") + baseURL, _ := startAIGatewayCommand(ctx, t, coderURL, "test-key") // Then: healthz is already 200 while readyz stays 503. // The startup log line is emitted after the bind, so no retry is needed. @@ -679,3 +691,63 @@ func TestAIGatewayStartE2E_InFlightRequestSurvivesDisconnect(t *testing.T) { // dropped, so this interception's usage rows are expected to be lost. Only // the caller-visible outcome is asserted. } + +// TestAIGatewayStart_ConfigYAML verifies that a YAML file supplied via +// --config (CODER_CONFIG_PATH) configures the running standalone Gateway. +// It enables the inherited Prometheus listener through YAML and asserts that the +// listener comes up. +func TestAIGatewayStart_ConfigYAML(t *testing.T) { + t.Parallel() + + // Fake coderd that answers 503 so the daemon keeps retrying to connect. + coderURL := startUnreachableCoderd(t) + configFile := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(configFile, []byte( + "introspection:\n prometheus:\n enable: true\n address: 127.0.0.1:0\n", + ), 0o600) + require.NoError(t, err) + + ctx := testutil.Context(t, testutil.WaitShort) + inv, _ := newCLI(t, + "ai-gateway", "start", + "--url", coderURL, + "--key", "test-key", + "--http-address", "127.0.0.1:0", + "--config", configFile, + ) + inv = inv.WithContext(ctx) + pty := ptytest.New(t).Attach(inv) + waiter := clitest.StartWithWaiter(t, inv) + + // The Prometheus listener is only started when prometheus.enable + // option is set which is enabled through YAML. + promLine := pty.ExpectRegexMatch(ctx, `http server listening\s+addr=[0-9.]+:[0-9]+\s+name=prometheus`) + matches := regexp.MustCompile(`addr=([0-9.]+:[0-9]+)`).FindStringSubmatch(promLine) + require.Len(t, matches, 2, "prometheus address not found in startup log: %q", promLine) + promURL := "http://" + matches[1] + "/metrics" + requireAIGatewayStatus(ctx, t, promURL, http.StatusOK) + + waiter.Cancel() + require.NoError(t, waiter.Wait()) +} + +// TestAIGatewayStart_ConfigYAML_Invalid verifies that a YAML file with an +// unknown option fails the command with a descriptive error. +func TestAIGatewayStart_ConfigYAML_Invalid(t *testing.T) { + t.Parallel() + + configFile := filepath.Join(t.TempDir(), "config.yaml") + err := os.WriteFile(configFile, []byte( + "introspection:\n prometheus:\n unknown_field: true\n", + ), 0o600) + require.NoError(t, err) + + inv, _ := newCLI(t, + "ai-gateway", "start", + "--key", "test-key", + "--config", configFile, + ) + + err = inv.Run() + require.ErrorContains(t, err, `unknown option "introspection.prometheus.unknown_field"`) +} diff --git a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden index ee183747dd7d8..5a7c1760cc708 100644 --- a/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden +++ b/enterprise/cli/testdata/coder_ai-gateway_start_--help.golden @@ -63,6 +63,12 @@ AI GATEWAY OPTIONS: making the request) and X-Ai-Bridge-Actor-Metadata-Username (their username). +CONFIG OPTIONS: +Use a YAML configuration file when your server launch become unwieldy. + + -c, --config yaml-config-path, $CODER_CONFIG_PATH + Specify a YAML file to load configuration from. + INTROSPECTION / LOGGING OPTIONS: --log-human string, $CODER_LOGGING_HUMAN (default: /dev/stderr) Output human-readable logs to a given file.