From bfa59c8d24a2a68495d7e5c1dbb435f1c51bbcc7 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Wed, 20 May 2026 23:22:14 -0400 Subject: [PATCH 1/2] feat(api,sdk,cli): add unauthenticated version endpoint across the stack Add GET /api/ambient/v1/version returning build metadata (git SHA, build time, git tag) without requiring authentication. Uses pre-auth middleware pattern from the proxy plugin with a pre-marshaled JSON response for zero per-request allocation. Full-stack implementation: - ambient-api-server: version plugin with RegisterPreAuthMiddleware - Makefile/Dockerfile: inject git_tag via ldflags and build-args - Go SDK: Client.ServerVersion() + standalone FetchServerVersion() - Python SDK: fetch_server_version() with ServerVersion dataclass - acpctl: version command now shows client and server version Closes #1598 Co-Authored-By: Claude Opus 4.6 (1M context) --- components/ambient-api-server/Dockerfile | 9 ++- components/ambient-api-server/Makefile | 9 ++- .../cmd/ambient-api-server/main.go | 1 + components/ambient-api-server/pkg/api/api.go | 6 ++ .../plugins/version/plugin.go | 39 +++++++++++ .../ambient-cli/cmd/acpctl/version/cmd.go | 29 +++++++- .../ambient-sdk/go-sdk/client/version_api.go | 67 +++++++++++++++++++ .../ambient_platform/_version_api.py | 34 ++++++++++ 8 files changed, 188 insertions(+), 6 deletions(-) create mode 100644 components/ambient-api-server/plugins/version/plugin.go create mode 100644 components/ambient-sdk/go-sdk/client/version_api.go create mode 100644 components/ambient-sdk/python-sdk/ambient_platform/_version_api.py diff --git a/components/ambient-api-server/Dockerfile b/components/ambient-api-server/Dockerfile index fb32013ac..3a93e42a7 100755 --- a/components/ambient-api-server/Dockerfile +++ b/components/ambient-api-server/Dockerfile @@ -13,7 +13,14 @@ COPY plugins/ plugins/ COPY openapi/ openapi/ # Build the binary -RUN go build -ldflags="-s -w" -o ambient-api-server ./cmd/ambient-api-server +ARG GIT_VERSION= +ARG BUILD_TIME= +ARG GIT_TAG= +RUN go build -ldflags="-s -w \ + -X github.com/ambient-code/platform/components/ambient-api-server/pkg/api.Version=${GIT_VERSION} \ + -X github.com/ambient-code/platform/components/ambient-api-server/pkg/api.BuildTime=${BUILD_TIME} \ + -X github.com/ambient-code/platform/components/ambient-api-server/pkg/api.GitTag=${GIT_TAG}" \ + -o ambient-api-server ./cmd/ambient-api-server # Runtime stage FROM registry.access.redhat.com/ubi9/ubi-minimal:latest diff --git a/components/ambient-api-server/Makefile b/components/ambient-api-server/Makefile index 44e1644ac..90c737e95 100644 --- a/components/ambient-api-server/Makefile +++ b/components/ambient-api-server/Makefile @@ -9,7 +9,8 @@ git_sha:=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") git_dirty:=$(shell git diff --quiet 2>/dev/null || echo "-modified") build_version:=$(git_sha)$(git_dirty) build_time:=$(shell date -u '+%Y-%m-%d %H:%M:%S UTC') -ldflags=-X github.com/ambient-code/platform/components/ambient-api-server/pkg/api.Version=$(build_version) -X 'github.com/ambient-code/platform/components/ambient-api-server/pkg/api.BuildTime=$(build_time)' +git_tag:=$(shell git describe --tags --always --dirty 2>/dev/null || echo "unknown") +ldflags=-X github.com/ambient-code/platform/components/ambient-api-server/pkg/api.Version=$(build_version) -X 'github.com/ambient-code/platform/components/ambient-api-server/pkg/api.BuildTime=$(build_time)' -X github.com/ambient-code/platform/components/ambient-api-server/pkg/api.GitTag=$(git_tag) .PHONY: binary binary: @@ -102,4 +103,8 @@ migrate: binary .PHONY: build-image build-image: binary @echo "Building container image..." - $(CONTAINER_ENGINE) build -t vteam_api_server:latest . + $(CONTAINER_ENGINE) build \ + --build-arg GIT_VERSION=$(build_version) \ + --build-arg GIT_TAG=$(git_tag) \ + --build-arg BUILD_TIME="$(build_time)" \ + -t vteam_api_server:latest . diff --git a/components/ambient-api-server/cmd/ambient-api-server/main.go b/components/ambient-api-server/cmd/ambient-api-server/main.go index 9af9e0215..4beb97521 100755 --- a/components/ambient-api-server/cmd/ambient-api-server/main.go +++ b/components/ambient-api-server/cmd/ambient-api-server/main.go @@ -26,6 +26,7 @@ import ( _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/scheduledSessions" _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/sessions" _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/users" + _ "github.com/ambient-code/platform/components/ambient-api-server/plugins/version" ) func main() { diff --git a/components/ambient-api-server/pkg/api/api.go b/components/ambient-api-server/pkg/api/api.go index 139b2f2f3..e9a8ef654 100644 --- a/components/ambient-api-server/pkg/api/api.go +++ b/components/ambient-api-server/pkg/api/api.go @@ -14,3 +14,9 @@ const ( ) var NewID = trexapi.NewID + +var ( + Version = "" + BuildTime = "" + GitTag = "" +) diff --git a/components/ambient-api-server/plugins/version/plugin.go b/components/ambient-api-server/plugins/version/plugin.go new file mode 100644 index 000000000..1f933fba8 --- /dev/null +++ b/components/ambient-api-server/plugins/version/plugin.go @@ -0,0 +1,39 @@ +package version + +import ( + "encoding/json" + "net/http" + "strings" + + localapi "github.com/ambient-code/platform/components/ambient-api-server/pkg/api" + pkgserver "github.com/openshift-online/rh-trex-ai/pkg/server" +) + +const versionPath = "/api/ambient/v1/version" + +var responseBytes []byte + +func init() { + responseBytes, _ = json.Marshal(versionResponse{ + Version: localapi.Version, + BuildTime: localapi.BuildTime, + GitTag: localapi.GitTag, + }) + + pkgserver.RegisterPreAuthMiddleware(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.TrimSuffix(r.URL.Path, "/") == versionPath { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(responseBytes) + return + } + next.ServeHTTP(w, r) + }) + }) +} + +type versionResponse struct { + Version string `json:"version"` + BuildTime string `json:"build_time"` + GitTag string `json:"git_tag"` +} diff --git a/components/ambient-cli/cmd/acpctl/version/cmd.go b/components/ambient-cli/cmd/acpctl/version/cmd.go index f8c8f9fbd..34142ecdb 100644 --- a/components/ambient-cli/cmd/acpctl/version/cmd.go +++ b/components/ambient-cli/cmd/acpctl/version/cmd.go @@ -1,19 +1,42 @@ -// Package version implements the version subcommand displaying build metadata. +// Package version implements the acpctl version command. package version import ( + "context" "fmt" + "time" + "github.com/ambient-code/platform/components/ambient-cli/pkg/config" "github.com/ambient-code/platform/components/ambient-cli/pkg/info" + sdkclient "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/client" "github.com/spf13/cobra" ) var Cmd = &cobra.Command{ Use: "version", - Short: "Print the version", + Short: "Print the client and server version", Args: cobra.NoArgs, Run: func(cmd *cobra.Command, _ []string) { - fmt.Fprintf(cmd.OutOrStdout(), "acpctl %s (commit: %s, built: %s)\n", + fmt.Fprintf(cmd.OutOrStdout(), "Client: %s (commit: %s, built: %s)\n", info.Version, info.Commit, info.BuildDate) + + cfg, err := config.Load() + if err != nil { + return + } + apiURL := cfg.GetAPIUrl() + if apiURL == "" { + return + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) + defer cancel() + + sv, err := sdkclient.FetchServerVersion(ctx, apiURL, cfg.InsecureTLSVerify) + if err != nil { + fmt.Fprintf(cmd.OutOrStdout(), "Server: unavailable (%v)\n", err) + return + } + fmt.Fprintf(cmd.OutOrStdout(), "Server: %s (tag: %s, built: %s)\n", sv.Version, sv.GitTag, sv.BuildTime) }, } diff --git a/components/ambient-sdk/go-sdk/client/version_api.go b/components/ambient-sdk/go-sdk/client/version_api.go new file mode 100644 index 000000000..96c795719 --- /dev/null +++ b/components/ambient-sdk/go-sdk/client/version_api.go @@ -0,0 +1,67 @@ +package client + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type ServerVersion struct { + Version string `json:"version"` + BuildTime string `json:"build_time"` + GitTag string `json:"git_tag"` +} + +func (c *Client) ServerVersion(ctx context.Context) (*ServerVersion, error) { + var result ServerVersion + if err := c.do(ctx, http.MethodGet, "/version", nil, http.StatusOK, &result); err != nil { + return nil, err + } + return &result, nil +} + +func FetchServerVersion(ctx context.Context, baseURL string, insecureSkipVerify bool) (*ServerVersion, error) { + url := strings.TrimSuffix(baseURL, "/") + "/api/ambient/v1/version" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + req.Header.Set("Accept", "application/json") + + httpClient := &http.Client{Timeout: 10 * time.Second} + if insecureSkipVerify { + t := http.DefaultTransport.(*http.Transport).Clone() + if t.TLSClientConfig == nil { + t.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12} + } + t.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec + httpClient.Transport = t + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("server returned HTTP %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read response: %w", err) + } + + var result ServerVersion + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("unmarshal response: %w", err) + } + return &result, nil +} diff --git a/components/ambient-sdk/python-sdk/ambient_platform/_version_api.py b/components/ambient-sdk/python-sdk/ambient_platform/_version_api.py new file mode 100644 index 000000000..c90358aab --- /dev/null +++ b/components/ambient-sdk/python-sdk/ambient_platform/_version_api.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Optional + +import httpx + + +@dataclass(frozen=True) +class ServerVersion: + version: str = "" + build_time: str = "" + git_tag: str = "" + + @classmethod + def from_dict(cls, data: dict) -> ServerVersion: + return cls( + version=data.get("version", ""), + build_time=data.get("build_time", ""), + git_tag=data.get("git_tag", ""), + ) + + +def fetch_server_version( + base_url: str, + *, + timeout: float = 10.0, + verify_ssl: bool = True, +) -> ServerVersion: + url = base_url.rstrip("/") + "/api/ambient/v1/version" + with httpx.Client(timeout=timeout, verify=verify_ssl) as client: + response = client.get(url, headers={"Accept": "application/json"}) + response.raise_for_status() + return ServerVersion.from_dict(response.json()) From 8bdf93afc8c5c76218b86402c073bf438dcb29f9 Mon Sep 17 00:00:00 2001 From: Ambient Code Bot Date: Wed, 20 May 2026 23:33:59 -0400 Subject: [PATCH 2/2] docs: add implementation plan for version endpoint Spec covering the unauthenticated /version endpoint design: pre-auth middleware pattern, pre-marshaled response, full-stack implementation units (api-server, Makefile, Dockerfile, Go SDK, Python SDK, CLI). Co-Authored-By: Claude Opus 4.6 (1M context) --- ...26-05-20-001-feat-version-endpoint-plan.md | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 docs/plans/2026-05-20-001-feat-version-endpoint-plan.md diff --git a/docs/plans/2026-05-20-001-feat-version-endpoint-plan.md b/docs/plans/2026-05-20-001-feat-version-endpoint-plan.md new file mode 100644 index 000000000..0e7ca9b45 --- /dev/null +++ b/docs/plans/2026-05-20-001-feat-version-endpoint-plan.md @@ -0,0 +1,294 @@ +--- +title: "feat: Add unauthenticated /version endpoint to ambient-api-server" +type: feat +status: active +date: 2026-05-20 +--- + +# feat: Add unauthenticated /version endpoint to ambient-api-server + +## Overview + +The ambient-api-server needs a `/version` endpoint that returns build metadata (git SHA, build time, git tag) without requiring authentication. The Makefile already has ldflags infrastructure but the target Go variables don't exist yet. The endpoint should be consumable by CLI (`acpctl version`) and both Go and Python SDKs. + +--- + +## Problem Frame + +Operators and developers have no way to verify which version of the API server is running. The Makefile injects build metadata via ldflags, but the Go variables that receive those values don't exist, and there is no HTTP endpoint to expose them. This blocks basic operational needs: deployment verification, debugging version mismatches, and CLI client/server version comparison. + +--- + +## Requirements Trace + +- R1. Expose `GET /api/ambient/v1/version` returning `version`, `build_time`, `git_tag`, and `api_version` as JSON +- R2. The endpoint must be unauthenticated — no bearer token required +- R3. Build metadata must be injected at compile time via ldflags (Makefile and Dockerfile) +- R4. The Go SDK must expose both an authenticated client method and a standalone function (for use without a full client) +- R5. The Python SDK must expose a standalone `fetch_server_version()` function +- R6. `acpctl version` must show both client and server version in a single command +- R7. The response must be pre-marshaled at startup to avoid per-request serialization overhead + +--- + +## Scope Boundaries + +- No CRD changes +- No database changes +- No authentication middleware changes beyond bypassing auth for this single path +- No frontend changes (CLI and SDK only) + +--- + +## Context & Research + +### Relevant Code and Patterns + +- `components/ambient-api-server/plugins/proxy/plugin.go` — existing `RegisterPreAuthMiddleware` pattern for unauthenticated paths +- `components/ambient-api-server/cmd/ambient-api-server/main.go` — side-effect plugin imports +- `components/ambient-api-server/pkg/api/api.go` — package-level vars for ldflags targets +- `components/ambient-api-server/Makefile` — existing ldflags injection for `Version` and `BuildTime` +- `components/ambient-cli/cmd/acpctl/version/cmd.go` — existing CLI version command (client-only) +- `components/ambient-sdk/go-sdk/client/` — Go SDK client structure +- `components/ambient-sdk/python-sdk/ambient_platform/` — Python SDK structure +- `pkgserver.RegisterPreAuthMiddleware` from rh-trex-ai — registers middleware that runs before auth, used by the proxy plugin for the same purpose + +### Institutional Learnings + +- The plugin system uses `init()` side-effects — import the plugin package with `_` in `main.go` and it self-registers +- Pre-auth middleware is the correct hook for unauthenticated endpoints in the rh-trex-ai framework + +--- + +## Key Technical Decisions + +- **Pre-auth middleware, not a route handler**: The version endpoint must bypass authentication entirely. Using `RegisterPreAuthMiddleware` (same pattern as the proxy plugin) intercepts the request before the auth stack runs. This avoids modifying the auth middleware's bypass list. +- **Pre-marshaled JSON response**: Marshal the response once at startup into a `[]byte` and write it directly on each request. Eliminates per-request allocation and serialization for a response that never changes. +- **Standalone SDK function alongside client method**: The CLI needs to fetch server version without constructing a full authenticated SDK client. Provide both `Client.ServerVersion()` (for authenticated clients) and `FetchServerVersion()` (standalone, no auth needed). +- **`git describe --tags --always --dirty` for git_tag**: Produces a human-readable version string that includes the nearest tag, distance from it, and dirty state. + +--- + +## Open Questions + +### Resolved During Planning + +- **Where to add the endpoint?**: As a new plugin (`plugins/version/`) following the existing plugin pattern, not inline in an existing plugin. +- **How to bypass auth?**: `RegisterPreAuthMiddleware` — matches the proxy plugin pattern exactly. + +### Deferred to Implementation + +- None — this is a straightforward, well-patterned change. + +---do + +## Implementation Units + +- U1. **Declare ldflags target variables in api package** + +**Goal:** Create the Go variables that `go build -ldflags -X` will populate with build metadata. + +**Requirements:** R3 + +**Dependencies:** None + +**Files:** +- Modify: `components/ambient-api-server/pkg/api/api.go` + +**Approach:** +- Add package-level `var` block with `Version`, `BuildTime`, and `GitTag` string variables, initialized to empty strings +- These are the targets for `-X` ldflags in the Makefile and Dockerfile + +**Patterns to follow:** +- Existing `var NewID` declaration in the same file + +**Test scenarios:** +- Test expectation: none — pure variable declarations with no behavior + +**Verification:** +- `go build` succeeds with the new variables +- `go vet ./...` passes + +--- + +- U2. **Create version plugin with pre-auth middleware** + +**Goal:** Serve `GET /api/ambient/v1/version` as a JSON response without authentication. + +**Requirements:** R1, R2, R7 + +**Dependencies:** U1 + +**Files:** +- Create: `components/ambient-api-server/plugins/version/plugin.go` +- Modify: `components/ambient-api-server/cmd/ambient-api-server/main.go` + +**Approach:** +- Define a `versionResponse` struct with `json` tags matching the wire format +- In `init()`, marshal the response once into a package-level `[]byte` +- Register a `PreAuthMiddleware` that matches `GET` on `/api/ambient/v1/version` (with trailing slash tolerance via `strings.TrimSuffix`), writes the pre-marshaled bytes with `Content-Type: application/json`, and calls `next.ServeHTTP` for all other requests +- Add `_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/version"` to `main.go` imports + +**Patterns to follow:** +- `plugins/proxy/plugin.go` — same `RegisterPreAuthMiddleware` pattern, same `init()` side-effect import + +**Test scenarios:** +- Happy path: `GET /api/ambient/v1/version` returns 200 with JSON body containing `version`, `build_time`, `git_tag` fields +- Happy path: Response `Content-Type` is `application/json` +- Happy path: Endpoint is accessible without an `Authorization` header +- Edge case: `GET /api/ambient/v1/version/` (trailing slash) returns the same response +- Edge case: `POST /api/ambient/v1/version` falls through to the next handler (not intercepted) + +**Verification:** +- `curl http://localhost:8000/api/ambient/v1/version` returns valid JSON with all three fields +- No auth token required + +--- + +- U3. **Update Makefile and Dockerfile to inject git_tag** + +**Goal:** Pass `GitTag` via ldflags at build time, both locally and in container builds. + +**Requirements:** R3 + +**Dependencies:** U1 + +**Files:** +- Modify: `components/ambient-api-server/Makefile` +- Modify: `components/ambient-api-server/Dockerfile` + +**Approach:** +- Makefile: Add `git_tag` variable using `git describe --tags --always --dirty`, append `-X ...GitTag=$(git_tag)` to `ldflags` +- Makefile: Update `build-image` target to pass `--build-arg GIT_VERSION`, `--build-arg BUILD_TIME`, `--build-arg GIT_TAG` +- Dockerfile: Add `ARG` declarations for `GIT_VERSION`, `BUILD_TIME`, `GIT_TAG`, and expand the `go build -ldflags` line to include all three `-X` flags + +**Patterns to follow:** +- Existing `build_version` and `build_time` Makefile variables + +**Test scenarios:** +- Happy path: `make binary` produces a binary; running it and hitting `/version` shows non-empty `version` and `build_time` +- Happy path: `make build-image` passes build args and the container's `/version` endpoint returns populated fields +- Edge case: Building from a detached HEAD (no tags) — `git describe --tags --always` falls back to the short SHA + +**Verification:** +- `make binary && ./ambient-api-server` serves a version endpoint with populated build metadata + +--- + +- U4. **Add Go SDK version client** + +**Goal:** Provide Go SDK consumers with both authenticated and standalone methods to fetch server version. + +**Requirements:** R4 + +**Dependencies:** U2 + +**Files:** +- Create: `components/ambient-sdk/go-sdk/client/version_api.go` + +**Approach:** +- `ServerVersion` struct with `json` tags matching the wire format +- `Client.ServerVersion(ctx)` method using the existing `c.do()` helper for authenticated requests +- `FetchServerVersion(ctx, baseURL, insecureSkipVerify)` standalone function that constructs the full URL, makes a plain HTTP GET, and unmarshals the response — no auth required +- The standalone function constructs its own `http.Client` with optional TLS skip and a 10s timeout + +**Patterns to follow:** +- Existing `Client.do()` method pattern in the Go SDK + +**Test scenarios:** +- Happy path: `FetchServerVersion` against a running server returns a populated `ServerVersion` +- Error path: `FetchServerVersion` against an unreachable host returns a wrapped error +- Error path: Server returns non-200 status — function returns an error with the status code +- Edge case: `baseURL` with trailing slash is handled (trimmed before appending path) + +**Verification:** +- `go build ./...` in `go-sdk/` succeeds +- `go vet ./...` passes + +--- + +- U5. **Add Python SDK version function** + +**Goal:** Provide Python SDK consumers with a standalone function to fetch server version. + +**Requirements:** R5 + +**Dependencies:** U2 + +**Files:** +- Create: `components/ambient-sdk/python-sdk/ambient_platform/_version_api.py` + +**Approach:** +- `ServerVersion` frozen dataclass with `version`, `build_time`, `git_tag` fields +- `from_dict` classmethod for safe deserialization with defaults +- `fetch_server_version(base_url, timeout, verify_ssl)` function using `httpx.Client` + +**Patterns to follow:** +- Existing Python SDK module structure in `ambient_platform/` + +**Test scenarios:** +- Happy path: `fetch_server_version` against a running server returns a populated `ServerVersion` +- Error path: Unreachable host raises `httpx.ConnectError` +- Error path: Non-200 response raises `httpx.HTTPStatusError` + +**Verification:** +- Module imports without error: `python -c "from ambient_platform._version_api import fetch_server_version"` + +--- + +- U6. **Update acpctl version command to show server version** + +**Goal:** `acpctl version` displays both client build info and server version in one output. + +**Requirements:** R6 + +**Dependencies:** U4 + +**Files:** +- Modify: `components/ambient-cli/cmd/acpctl/version/cmd.go` + +**Approach:** +- Change output prefix from `acpctl` to `Client:` for the existing client version line +- After printing client info, load CLI config to get the API URL +- If API URL is available, call `sdkclient.FetchServerVersion` with a 5-second timeout +- Print `Server: (tag: , built: )` on success, or `Server: unavailable ()` on failure +- Gracefully degrade: if no config or no API URL, silently skip the server line + +**Patterns to follow:** +- Existing `config.Load()` and `cfg.GetAPIUrl()` usage in other CLI commands + +**Test scenarios:** +- Happy path: With a running server, `acpctl version` prints both `Client:` and `Server:` lines +- Edge case: No config file — only `Client:` line is printed (no error) +- Edge case: Config exists but no API URL — only `Client:` line +- Error path: Server unreachable — prints `Server: unavailable (...)` with the error message +- Happy path: Server version fields are formatted as `Server: (tag: , built: