Skip to content

Commit fc9905c

Browse files
committed
feat(workspace): prototype implementation of ADR-0006
Implement workspace container architecture per ADR-0006: - Add session-proxy sidecar for streaming command execution - Add workspace MCP server for agent command interface - Add workspace container settings to ProjectSettings CRD - Add backend handlers for workspace container configuration - Add frontend UI for workspace container settings - Update operator to create workspace pods with user-configurable images Security improvements from review: - Add rate limiting to session proxy (max concurrent execs) - Add exec timeout (configurable via environment) - Fix authentication checks in backend handlers - Add proper error handling for unstructured K8s access - Update session status on Vertex secret copy failure Assisted-by: Claude Code (Opus 4.5)
1 parent fcc6091 commit fc9905c

File tree

27 files changed

+2381
-16
lines changed

27 files changed

+2381
-16
lines changed
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Package handlers implements HTTP handlers for the backend API.
2+
package handlers
3+
4+
import (
5+
"context"
6+
"log"
7+
"net/http"
8+
9+
"github.com/gin-gonic/gin"
10+
"k8s.io/apimachinery/pkg/api/errors"
11+
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
13+
)
14+
15+
// WorkspaceContainerSettings represents workspace container customization.
16+
// Workspace container mode is always enabled (ADR-0006); these settings allow optional customization.
17+
type WorkspaceContainerSettings struct {
18+
Image string `json:"image,omitempty"`
19+
Resources *WorkspaceContainerResourceLimits `json:"resources,omitempty"`
20+
}
21+
22+
// WorkspaceContainerResourceLimits represents resource limits for workspace containers
23+
type WorkspaceContainerResourceLimits struct {
24+
CPURequest string `json:"cpuRequest,omitempty"`
25+
CPULimit string `json:"cpuLimit,omitempty"`
26+
MemoryRequest string `json:"memoryRequest,omitempty"`
27+
MemoryLimit string `json:"memoryLimit,omitempty"`
28+
}
29+
30+
// GetWorkspaceContainerSettings returns the workspace container settings for a project
31+
func GetWorkspaceContainerSettings(c *gin.Context) {
32+
project := c.GetString("project")
33+
if project == "" {
34+
c.JSON(http.StatusBadRequest, gin.H{"error": "project name required"})
35+
return
36+
}
37+
38+
// Get user-scoped dynamic client
39+
reqK8s, reqDyn := GetK8sClientsForRequest(c)
40+
if reqK8s == nil || reqDyn == nil {
41+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing authentication token"})
42+
return
43+
}
44+
45+
ctx := context.Background()
46+
gvr := GetProjectSettingsResource()
47+
48+
// Get the ProjectSettings CR (singleton per namespace)
49+
obj, err := reqDyn.Resource(gvr).Namespace(project).Get(ctx, "projectsettings", v1.GetOptions{})
50+
if err != nil {
51+
if errors.IsNotFound(err) {
52+
// No ProjectSettings CR exists, return empty settings (uses platform defaults)
53+
c.JSON(http.StatusOK, WorkspaceContainerSettings{})
54+
return
55+
}
56+
log.Printf("Failed to get ProjectSettings for %s: %v", project, err)
57+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get project settings"})
58+
return
59+
}
60+
61+
// Extract workspaceContainer from spec
62+
spec, specFound, err := unstructured.NestedMap(obj.Object, "spec")
63+
if err != nil || !specFound {
64+
// No spec or error reading it, return empty settings
65+
c.JSON(http.StatusOK, WorkspaceContainerSettings{})
66+
return
67+
}
68+
wcMap, found, err := unstructured.NestedMap(spec, "workspaceContainer")
69+
if err != nil || !found {
70+
// No custom settings, uses platform defaults
71+
c.JSON(http.StatusOK, WorkspaceContainerSettings{})
72+
return
73+
}
74+
75+
// Build response with optional customizations
76+
settings := WorkspaceContainerSettings{}
77+
if image, ok := wcMap["image"].(string); ok {
78+
settings.Image = image
79+
}
80+
81+
// Extract resources if present
82+
if resources, found, err := unstructured.NestedMap(wcMap, "resources"); err == nil && found {
83+
settings.Resources = &WorkspaceContainerResourceLimits{}
84+
if v, ok := resources["cpuRequest"].(string); ok {
85+
settings.Resources.CPURequest = v
86+
}
87+
if v, ok := resources["cpuLimit"].(string); ok {
88+
settings.Resources.CPULimit = v
89+
}
90+
if v, ok := resources["memoryRequest"].(string); ok {
91+
settings.Resources.MemoryRequest = v
92+
}
93+
if v, ok := resources["memoryLimit"].(string); ok {
94+
settings.Resources.MemoryLimit = v
95+
}
96+
}
97+
98+
c.JSON(http.StatusOK, settings)
99+
}
100+
101+
// UpdateWorkspaceContainerSettings updates the workspace container settings for a project
102+
func UpdateWorkspaceContainerSettings(c *gin.Context) {
103+
project := c.GetString("project")
104+
if project == "" {
105+
c.JSON(http.StatusBadRequest, gin.H{"error": "project name required"})
106+
return
107+
}
108+
109+
var req WorkspaceContainerSettings
110+
if err := c.ShouldBindJSON(&req); err != nil {
111+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
112+
return
113+
}
114+
115+
// Get user-scoped dynamic client
116+
reqK8s, reqDyn := GetK8sClientsForRequest(c)
117+
if reqK8s == nil || reqDyn == nil {
118+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing authentication token"})
119+
return
120+
}
121+
122+
ctx := context.Background()
123+
gvr := GetProjectSettingsResource()
124+
125+
// Get or create the ProjectSettings CR
126+
obj, err := reqDyn.Resource(gvr).Namespace(project).Get(ctx, "projectsettings", v1.GetOptions{})
127+
if err != nil {
128+
if errors.IsNotFound(err) {
129+
// Create new ProjectSettings with workspaceContainer
130+
obj = &unstructured.Unstructured{
131+
Object: map[string]interface{}{
132+
"apiVersion": "vteam.ambient-code/v1alpha1",
133+
"kind": "ProjectSettings",
134+
"metadata": map[string]interface{}{
135+
"name": "projectsettings",
136+
"namespace": project,
137+
},
138+
"spec": map[string]interface{}{
139+
"groupAccess": []interface{}{}, // Required field
140+
},
141+
},
142+
}
143+
} else {
144+
log.Printf("Failed to get ProjectSettings for %s: %v", project, err)
145+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get project settings"})
146+
return
147+
}
148+
}
149+
150+
// Build workspaceContainer map with optional customizations
151+
wcMap := map[string]interface{}{}
152+
if req.Image != "" {
153+
wcMap["image"] = req.Image
154+
}
155+
if req.Resources != nil {
156+
resources := map[string]interface{}{}
157+
if req.Resources.CPURequest != "" {
158+
resources["cpuRequest"] = req.Resources.CPURequest
159+
}
160+
if req.Resources.CPULimit != "" {
161+
resources["cpuLimit"] = req.Resources.CPULimit
162+
}
163+
if req.Resources.MemoryRequest != "" {
164+
resources["memoryRequest"] = req.Resources.MemoryRequest
165+
}
166+
if req.Resources.MemoryLimit != "" {
167+
resources["memoryLimit"] = req.Resources.MemoryLimit
168+
}
169+
if len(resources) > 0 {
170+
wcMap["resources"] = resources
171+
}
172+
}
173+
174+
// Set workspaceContainer in spec
175+
if err := unstructured.SetNestedMap(obj.Object, wcMap, "spec", "workspaceContainer"); err != nil {
176+
log.Printf("Failed to set workspaceContainer in spec: %v", err)
177+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
178+
return
179+
}
180+
181+
// Create or update the ProjectSettings CR
182+
if obj.GetResourceVersion() == "" {
183+
// Create new
184+
_, err = reqDyn.Resource(gvr).Namespace(project).Create(ctx, obj, v1.CreateOptions{})
185+
if err != nil {
186+
log.Printf("Failed to create ProjectSettings for %s: %v", project, err)
187+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project settings"})
188+
return
189+
}
190+
log.Printf("Created ProjectSettings with workspaceContainer for project %s", project)
191+
} else {
192+
// Update existing
193+
_, err = reqDyn.Resource(gvr).Namespace(project).Update(ctx, obj, v1.UpdateOptions{})
194+
if err != nil {
195+
log.Printf("Failed to update ProjectSettings for %s: %v", project, err)
196+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project settings"})
197+
return
198+
}
199+
log.Printf("Updated workspaceContainer settings for project %s", project)
200+
}
201+
202+
c.JSON(http.StatusOK, gin.H{
203+
"message": "Workspace container settings updated",
204+
"image": req.Image,
205+
})
206+
}

components/backend/routes.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ func registerRoutes(r *gin.Engine) {
9696
projectGroup.GET("/integration-secrets", handlers.ListIntegrationSecrets)
9797
projectGroup.PUT("/integration-secrets", handlers.UpdateIntegrationSecrets)
9898

99+
// Workspace container settings (ADR-0006)
100+
projectGroup.GET("/workspace-container", handlers.GetWorkspaceContainerSettings)
101+
projectGroup.PUT("/workspace-container", handlers.UpdateWorkspaceContainerSettings)
102+
99103
// GitLab authentication endpoints (project-scoped)
100104
projectGroup.POST("/auth/gitlab/connect", handlers.ConnectGitLabGlobal)
101105
projectGroup.GET("/auth/gitlab/status", handlers.GetGitLabStatusGlobal)

components/frontend/package-lock.json

Lines changed: 30 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"@radix-ui/react-progress": "^1.1.7",
1919
"@radix-ui/react-select": "^2.2.6",
2020
"@radix-ui/react-slot": "^1.2.3",
21+
"@radix-ui/react-switch": "^1.2.6",
2122
"@radix-ui/react-tabs": "^1.1.13",
2223
"@radix-ui/react-toast": "^1.2.15",
2324
"@radix-ui/react-tooltip": "^1.2.8",
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { BACKEND_URL } from '@/lib/config';
2+
import { buildForwardHeadersAsync } from '@/lib/auth';
3+
4+
export async function GET(
5+
request: Request,
6+
{ params }: { params: Promise<{ name: string }> }
7+
) {
8+
try {
9+
const { name } = await params;
10+
const headers = await buildForwardHeadersAsync(request);
11+
12+
const resp = await fetch(
13+
`${BACKEND_URL}/projects/${encodeURIComponent(name)}/workspace-container`,
14+
{ headers }
15+
);
16+
const data = await resp.json().catch(() => ({}));
17+
return Response.json(data, { status: resp.status });
18+
} catch (error) {
19+
console.error('Error fetching workspace container settings:', error);
20+
return Response.json(
21+
{ error: 'Failed to fetch workspace container settings' },
22+
{ status: 500 }
23+
);
24+
}
25+
}
26+
27+
export async function PUT(
28+
request: Request,
29+
{ params }: { params: Promise<{ name: string }> }
30+
) {
31+
try {
32+
const { name } = await params;
33+
const headers = await buildForwardHeadersAsync(request);
34+
const body = await request.json();
35+
36+
const resp = await fetch(
37+
`${BACKEND_URL}/projects/${encodeURIComponent(name)}/workspace-container`,
38+
{
39+
method: 'PUT',
40+
headers: {
41+
...headers,
42+
'Content-Type': 'application/json',
43+
},
44+
body: JSON.stringify(body),
45+
}
46+
);
47+
const data = await resp.json().catch(() => ({}));
48+
return Response.json(data, { status: resp.status });
49+
} catch (error) {
50+
console.error('Error updating workspace container settings:', error);
51+
return Response.json(
52+
{ error: 'Failed to update workspace container settings' },
53+
{ status: 500 }
54+
);
55+
}
56+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import * as SwitchPrimitives from "@radix-ui/react-switch"
5+
6+
import { cn } from "@/lib/utils"
7+
8+
const Switch = React.forwardRef<
9+
React.ComponentRef<typeof SwitchPrimitives.Root>,
10+
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
11+
>(({ className, ...props }, ref) => (
12+
<SwitchPrimitives.Root
13+
className={cn(
14+
"peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
15+
className
16+
)}
17+
{...props}
18+
ref={ref}
19+
>
20+
<SwitchPrimitives.Thumb
21+
className={cn(
22+
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0"
23+
)}
24+
/>
25+
</SwitchPrimitives.Root>
26+
))
27+
Switch.displayName = SwitchPrimitives.Root.displayName
28+
29+
export { Switch }

0 commit comments

Comments
 (0)