Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
72a9a61
spec(ambient-model): add ScheduledSession Kind, session sub-resources…
markturansky Apr 24, 2026
dfa679d
Merge branch 'main' into spec/scheduled-session-and-backend-surface
mergify[bot] Apr 24, 2026
251a139
Merge branch 'main' into spec/scheduled-session-and-backend-surface
mergify[bot] Apr 24, 2026
e37bb97
Merge branch 'main' into spec/scheduled-session-and-backend-surface
mergify[bot] Apr 24, 2026
221bc26
Merge branch 'main' into spec/scheduled-session-and-backend-surface
mergify[bot] Apr 24, 2026
bf20e7d
Merge branch 'main' into spec/scheduled-session-and-backend-surface
mergify[bot] Apr 27, 2026
d6bcbf3
Merge branch 'main' into spec/scheduled-session-and-backend-surface
mergify[bot] Apr 27, 2026
ef997e0
Merge branch 'main' into spec/scheduled-session-and-backend-surface
mergify[bot] Apr 27, 2026
24c2456
docs(spec): update ambient-model spec — coverage matrix, Session ERD,…
markturansky Apr 28, 2026
00be060
docs(guide): update implementation guides — runner proxy, generic pro…
markturansky Apr 28, 2026
5951585
feat(api-server): add ScheduledSession plugin — CRUD, suspend/resume/…
markturansky Apr 28, 2026
2d6e469
feat(api-server/sessions): add workspace, files, git, repos, and oper…
markturansky Apr 28, 2026
24ed505
feat(api-server/proxy): add generic backend proxy plugin
markturansky Apr 28, 2026
9030faa
feat(sdk): add ScheduledSession Go SDK client
markturansky Apr 28, 2026
54f8dad
feat(cli): add scheduled-session command with all 9 subcommands
markturansky Apr 28, 2026
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
2 changes: 2 additions & 0 deletions components/ambient-api-server/cmd/ambient-api-server/main.go
100644 → 100755
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import (
_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/rbac"
_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/roleBindings"
_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/roles"
_ "github.com/ambient-code/platform/components/ambient-api-server/plugins/proxy"
_ "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"
)
Expand Down
117 changes: 117 additions & 0 deletions components/ambient-api-server/plugins/proxy/plugin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
// Package proxy implements a generic reverse-proxy for backend routes not natively
// served by the ambient-api-server. Any request whose path does NOT start with
// "/api/ambient/" is forwarded verbatim to BACKEND_URL (default http://localhost:8080).
//
// This satisfies the "Generic Proxy Surface" in the ambient-model spec: SDK/CLI
// clients reach the full backend surface through a single authenticated endpoint
// without requiring every backend route to be natively implemented.
package proxy

import (
"io"
"net"
"net/http"
"net/url"
"os"
"strings"
"time"

"github.com/golang/glog"
pkgserver "github.com/openshift-online/rh-trex-ai/pkg/server"
)

// backendHTTPClient is used for all proxy requests to the backend.
// Separate from the session runner client so timeouts can be tuned independently.
var backendHTTPClient = &http.Client{
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
ResponseHeaderTimeout: 30 * time.Second,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
},
Timeout: 60 * time.Second,
}

func init() {
backendURL := os.Getenv("BACKEND_URL")
if backendURL == "" {
backendURL = "http://localhost:8080"
}
pkgserver.RegisterPreAuthMiddleware(newBackendProxy(backendURL))
}

// newBackendProxy returns a middleware that forwards non-ambient requests to backendURL.
// Exported so tests can call it directly without going through the init() global.
func newBackendProxy(backendURL string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isNativePath(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
proxyRequest(w, r, backendURL)
})
}
}

// isNativePath returns true for paths handled natively by ambient-api-server.
func isNativePath(p string) bool {
return strings.HasPrefix(p, "/api/ambient/") ||
p == "/api/ambient" ||
p == "/metrics" ||
p == "/favicon.ico"
}

// proxyRequest forwards r verbatim to backendURL+r.URL.Path preserving all
// headers, query string, and body. The response is written back unchanged.
func proxyRequest(w http.ResponseWriter, r *http.Request, backendURL string) {
target, err := url.Parse(backendURL)
if err != nil {
glog.Errorf("proxy: invalid backend URL %q: %v", backendURL, err)
http.Error(w, "proxy configuration error", http.StatusInternalServerError)
return
}

// Build the upstream URL: backend scheme+host + original path + query.
upstreamURL := *target
upstreamURL.Path = r.URL.Path
upstreamURL.RawQuery = r.URL.RawQuery

req, err := http.NewRequestWithContext(r.Context(), r.Method, upstreamURL.String(), r.Body)
if err != nil {
glog.Errorf("proxy: build request for %s: %v", upstreamURL.String(), err)
http.Error(w, "failed to build upstream request", http.StatusInternalServerError)
return
}

// Copy all headers from the original request (including Authorization).
for k, vals := range r.Header {
for _, v := range vals {
req.Header.Add(k, v)
}
}

resp, err := backendHTTPClient.Do(req)
if err != nil {
glog.Warningf("proxy: backend %s unreachable for %s %s: %v",
backendURL, r.Method, r.URL.Path, err)
http.Error(w, "backend unavailable", http.StatusBadGateway)
return
}
defer func() { _ = resp.Body.Close() }()

// Copy all response headers.
for k, vals := range resp.Header {
for _, v := range vals {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = io.Copy(w, resp.Body)
}

// NewBackendProxyMiddleware is the exported constructor used in tests.
// Tests call this directly instead of relying on init() and env vars.
func NewBackendProxyMiddleware(backendURL string) func(http.Handler) http.Handler {
return newBackendProxy(backendURL)
}
188 changes: 188 additions & 0 deletions components/ambient-api-server/plugins/proxy/proxy_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package proxy_test

import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/ambient-code/platform/components/ambient-api-server/plugins/proxy"
)

// buildHandler wraps a mock backend with the proxy middleware and a sentinel native handler.
func buildHandler(backendURL string) http.Handler {
nativeHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("native"))
})
mw := proxy.NewBackendProxyMiddleware(backendURL)
return mw(nativeHandler)
}

// ---------------------------------------------------------------------------
// Native paths pass through (not forwarded to backend)
// ---------------------------------------------------------------------------

func TestNativePath_ApiAmbient_PassesThrough(t *testing.T) {
handler := buildHandler("http://does-not-exist.invalid")
req := httptest.NewRequest(http.MethodGet, "/api/ambient/v1/sessions", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Code != http.StatusOK || rr.Body.String() != "native" {
t.Errorf("expected native handler, got %d: %s", rr.Code, rr.Body)
}
}

func TestNativePath_ApiAmbientExact_PassesThrough(t *testing.T) {
handler := buildHandler("http://does-not-exist.invalid")
req := httptest.NewRequest(http.MethodGet, "/api/ambient", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Code != http.StatusOK || rr.Body.String() != "native" {
t.Errorf("expected native handler, got %d: %s", rr.Code, rr.Body)
}
}

func TestNativePath_Metrics_PassesThrough(t *testing.T) {
handler := buildHandler("http://does-not-exist.invalid")
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Code != http.StatusOK || rr.Body.String() != "native" {
t.Errorf("expected native handler for /metrics, got %d: %s", rr.Code, rr.Body)
}
}

// ---------------------------------------------------------------------------
// Non-native paths are forwarded to the backend
// ---------------------------------------------------------------------------

func TestProxyPath_ForwardsToBackend(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("from-backend"))
}))
defer backend.Close()

handler := buildHandler(backend.URL)
req := httptest.NewRequest(http.MethodGet, "/api/projects/proj-1/sessions", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", rr.Code, rr.Body)
}
if rr.Body.String() != "from-backend" {
t.Errorf("expected backend body, got %s", rr.Body)
}
}

func TestProxyPath_PreservesMethod(t *testing.T) {
var capturedMethod string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedMethod = r.Method
w.WriteHeader(http.StatusCreated)
}))
defer backend.Close()

handler := buildHandler(backend.URL)
req := httptest.NewRequest(http.MethodPost, "/api/projects/proj-1/sessions", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if capturedMethod != http.MethodPost {
t.Errorf("expected POST, backend saw %s", capturedMethod)
}
if rr.Code != http.StatusCreated {
t.Errorf("expected 201, got %d", rr.Code)
}
}

func TestProxyPath_ForwardsHeaders(t *testing.T) {
var capturedAuth string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedAuth = r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

handler := buildHandler(backend.URL)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
req.Header.Set("Authorization", "Bearer test-token")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if capturedAuth != "Bearer test-token" {
t.Errorf("expected auth header forwarded, got %q", capturedAuth)
}
}

func TestProxyPath_PreservesQueryString(t *testing.T) {
var capturedQuery string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
capturedQuery = r.URL.RawQuery
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

handler := buildHandler(backend.URL)
req := httptest.NewRequest(http.MethodGet, "/api/projects/proj-1/sessions?page=2&size=10", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if capturedQuery != "page=2&size=10" {
t.Errorf("expected query preserved, got %q", capturedQuery)
}
}

func TestProxyPath_ForwardsBody(t *testing.T) {
var capturedBody string
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
capturedBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

handler := buildHandler(backend.URL)
req := httptest.NewRequest(http.MethodPost, "/api/auth/login",
strings.NewReader(`{"user":"test"}`))
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if capturedBody != `{"user":"test"}` {
t.Errorf("expected body forwarded, got %q", capturedBody)
}
}

func TestProxyPath_BackendDown_Returns502(t *testing.T) {
handler := buildHandler("http://127.0.0.1:1") // nothing listening on port 1
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Code != http.StatusBadGateway {
t.Errorf("expected 502, got %d", rr.Code)
}
}

func TestProxyPath_ResponseHeadersCopied(t *testing.T) {
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Custom-Header", "proxy-value")
w.WriteHeader(http.StatusOK)
}))
defer backend.Close()

handler := buildHandler(backend.URL)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)

if rr.Header().Get("X-Custom-Header") != "proxy-value" {
t.Errorf("expected response header copied, got %q", rr.Header().Get("X-Custom-Header"))
}
}
63 changes: 63 additions & 0 deletions components/ambient-api-server/plugins/scheduledSessions/dao.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package scheduledSessions

import (
"context"

"github.com/openshift-online/rh-trex-ai/pkg/db"
"gorm.io/gorm"
)

type ScheduledSessionDao interface {
Get(ctx context.Context, id string) (*ScheduledSession, error)
Create(ctx context.Context, ss *ScheduledSession) (*ScheduledSession, error)
Replace(ctx context.Context, ss *ScheduledSession) (*ScheduledSession, error)
Delete(ctx context.Context, id string) error
ListByProject(ctx context.Context, projectId string) (ScheduledSessionList, error)
}

type sqlScheduledSessionDao struct {
sessionFactory *db.SessionFactory
}

func NewScheduledSessionDao(sessionFactory *db.SessionFactory) ScheduledSessionDao {
return &sqlScheduledSessionDao{sessionFactory: sessionFactory}
}

func (d *sqlScheduledSessionDao) db(ctx context.Context) *gorm.DB {
return (*d.sessionFactory).New(ctx)
}

func (d *sqlScheduledSessionDao) Get(ctx context.Context, id string) (*ScheduledSession, error) {
ss := &ScheduledSession{}
err := d.db(ctx).Where("id = ?", id).First(ss).Error
if err != nil {
return nil, err
}
return ss, nil
}

func (d *sqlScheduledSessionDao) Create(ctx context.Context, ss *ScheduledSession) (*ScheduledSession, error) {
err := d.db(ctx).Create(ss).Error
if err != nil {
return nil, err
}
return ss, nil
}

func (d *sqlScheduledSessionDao) Replace(ctx context.Context, ss *ScheduledSession) (*ScheduledSession, error) {
err := d.db(ctx).Save(ss).Error
if err != nil {
return nil, err
}
return ss, nil
}

func (d *sqlScheduledSessionDao) Delete(ctx context.Context, id string) error {
return d.db(ctx).Delete(&ScheduledSession{}, "id = ?", id).Error
}

func (d *sqlScheduledSessionDao) ListByProject(ctx context.Context, projectId string) (ScheduledSessionList, error) {
var list ScheduledSessionList
err := d.db(ctx).Where("project_id = ? AND deleted_at IS NULL", projectId).Find(&list).Error
return list, err
}
Loading
Loading