Skip to content

Commit f9567d6

Browse files
committed
feat(workspace): prototype implementation of ADR-0006
Implement workspace container architecture allowing users to configure the container used by agent tools: - Operator: Add workspace container injection and sidecar management - Backend: Add routes for workspace container settings - Frontend: Add workspace container settings UI - Runner: Update wrapper for workspace container coordination - Manifests: Add PVC watch permissions, CRD updates Assisted-by: Claude Code (Opus 4.5)
1 parent fcc6091 commit f9567d6

File tree

39 files changed

+2763
-11
lines changed

39 files changed

+2763
-11
lines changed
Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
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+
_, reqDyn := GetK8sClientsForRequest(c)
40+
if 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, _, _ := unstructured.NestedMap(obj.Object, "spec")
63+
wcMap, found, _ := unstructured.NestedMap(spec, "workspaceContainer")
64+
if !found {
65+
// No custom settings, uses platform defaults
66+
c.JSON(http.StatusOK, WorkspaceContainerSettings{})
67+
return
68+
}
69+
70+
// Build response with optional customizations
71+
settings := WorkspaceContainerSettings{}
72+
if image, ok := wcMap["image"].(string); ok {
73+
settings.Image = image
74+
}
75+
76+
// Extract resources if present
77+
if resources, found, _ := unstructured.NestedMap(wcMap, "resources"); found {
78+
settings.Resources = &WorkspaceContainerResourceLimits{}
79+
if v, ok := resources["cpuRequest"].(string); ok {
80+
settings.Resources.CPURequest = v
81+
}
82+
if v, ok := resources["cpuLimit"].(string); ok {
83+
settings.Resources.CPULimit = v
84+
}
85+
if v, ok := resources["memoryRequest"].(string); ok {
86+
settings.Resources.MemoryRequest = v
87+
}
88+
if v, ok := resources["memoryLimit"].(string); ok {
89+
settings.Resources.MemoryLimit = v
90+
}
91+
}
92+
93+
c.JSON(http.StatusOK, settings)
94+
}
95+
96+
// UpdateWorkspaceContainerSettings updates the workspace container settings for a project
97+
func UpdateWorkspaceContainerSettings(c *gin.Context) {
98+
project := c.GetString("project")
99+
if project == "" {
100+
c.JSON(http.StatusBadRequest, gin.H{"error": "project name required"})
101+
return
102+
}
103+
104+
var req WorkspaceContainerSettings
105+
if err := c.ShouldBindJSON(&req); err != nil {
106+
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
107+
return
108+
}
109+
110+
// Get user-scoped dynamic client
111+
_, reqDyn := GetK8sClientsForRequest(c)
112+
if reqDyn == nil {
113+
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing authentication token"})
114+
return
115+
}
116+
117+
ctx := context.Background()
118+
gvr := GetProjectSettingsResource()
119+
120+
// Get or create the ProjectSettings CR
121+
obj, err := reqDyn.Resource(gvr).Namespace(project).Get(ctx, "projectsettings", v1.GetOptions{})
122+
if err != nil {
123+
if errors.IsNotFound(err) {
124+
// Create new ProjectSettings with workspaceContainer
125+
obj = &unstructured.Unstructured{
126+
Object: map[string]interface{}{
127+
"apiVersion": "vteam.ambient-code/v1alpha1",
128+
"kind": "ProjectSettings",
129+
"metadata": map[string]interface{}{
130+
"name": "projectsettings",
131+
"namespace": project,
132+
},
133+
"spec": map[string]interface{}{
134+
"groupAccess": []interface{}{}, // Required field
135+
},
136+
},
137+
}
138+
} else {
139+
log.Printf("Failed to get ProjectSettings for %s: %v", project, err)
140+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get project settings"})
141+
return
142+
}
143+
}
144+
145+
// Build workspaceContainer map with optional customizations
146+
wcMap := map[string]interface{}{}
147+
if req.Image != "" {
148+
wcMap["image"] = req.Image
149+
}
150+
if req.Resources != nil {
151+
resources := map[string]interface{}{}
152+
if req.Resources.CPURequest != "" {
153+
resources["cpuRequest"] = req.Resources.CPURequest
154+
}
155+
if req.Resources.CPULimit != "" {
156+
resources["cpuLimit"] = req.Resources.CPULimit
157+
}
158+
if req.Resources.MemoryRequest != "" {
159+
resources["memoryRequest"] = req.Resources.MemoryRequest
160+
}
161+
if req.Resources.MemoryLimit != "" {
162+
resources["memoryLimit"] = req.Resources.MemoryLimit
163+
}
164+
if len(resources) > 0 {
165+
wcMap["resources"] = resources
166+
}
167+
}
168+
169+
// Set workspaceContainer in spec
170+
if err := unstructured.SetNestedMap(obj.Object, wcMap, "spec", "workspaceContainer"); err != nil {
171+
log.Printf("Failed to set workspaceContainer in spec: %v", err)
172+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
173+
return
174+
}
175+
176+
// Create or update the ProjectSettings CR
177+
if obj.GetResourceVersion() == "" {
178+
// Create new
179+
_, err = reqDyn.Resource(gvr).Namespace(project).Create(ctx, obj, v1.CreateOptions{})
180+
if err != nil {
181+
log.Printf("Failed to create ProjectSettings for %s: %v", project, err)
182+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project settings"})
183+
return
184+
}
185+
log.Printf("Created ProjectSettings with workspaceContainer for project %s", project)
186+
} else {
187+
// Update existing
188+
_, err = reqDyn.Resource(gvr).Namespace(project).Update(ctx, obj, v1.UpdateOptions{})
189+
if err != nil {
190+
log.Printf("Failed to update ProjectSettings for %s: %v", project, err)
191+
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project settings"})
192+
return
193+
}
194+
log.Printf("Updated workspaceContainer settings for project %s", project)
195+
}
196+
197+
c.JSON(http.StatusOK, gin.H{
198+
"message": "Workspace container settings updated",
199+
"image": req.Image,
200+
})
201+
}

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)