Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
206 changes: 206 additions & 0 deletions components/backend/handlers/workspace_container.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// Package handlers implements HTTP handlers for the backend API.
package handlers

import (
"context"
"log"
"net/http"

"github.com/gin-gonic/gin"
"k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)

// WorkspaceContainerSettings represents workspace container customization.
// Workspace container mode is always enabled (ADR-0006); these settings allow optional customization.
type WorkspaceContainerSettings struct {
Image string `json:"image,omitempty"`
Resources *WorkspaceContainerResourceLimits `json:"resources,omitempty"`
}

// WorkspaceContainerResourceLimits represents resource limits for workspace containers
type WorkspaceContainerResourceLimits struct {
CPURequest string `json:"cpuRequest,omitempty"`
CPULimit string `json:"cpuLimit,omitempty"`
MemoryRequest string `json:"memoryRequest,omitempty"`
MemoryLimit string `json:"memoryLimit,omitempty"`
}

// GetWorkspaceContainerSettings returns the workspace container settings for a project
func GetWorkspaceContainerSettings(c *gin.Context) {
project := c.GetString("project")
if project == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project name required"})
return
}

// Get user-scoped dynamic client
reqK8s, reqDyn := GetK8sClientsForRequest(c)
if reqK8s == nil || reqDyn == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing authentication token"})
return
}

ctx := context.Background()
gvr := GetProjectSettingsResource()

// Get the ProjectSettings CR (singleton per namespace)
obj, err := reqDyn.Resource(gvr).Namespace(project).Get(ctx, "projectsettings", v1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
// No ProjectSettings CR exists, return empty settings (uses platform defaults)
c.JSON(http.StatusOK, WorkspaceContainerSettings{})
return
}
log.Printf("Failed to get ProjectSettings for %s: %v", project, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get project settings"})
return
}

// Extract workspaceContainer from spec
spec, specFound, err := unstructured.NestedMap(obj.Object, "spec")
if err != nil || !specFound {
// No spec or error reading it, return empty settings
c.JSON(http.StatusOK, WorkspaceContainerSettings{})
return
}
wcMap, found, err := unstructured.NestedMap(spec, "workspaceContainer")
if err != nil || !found {
// No custom settings, uses platform defaults
c.JSON(http.StatusOK, WorkspaceContainerSettings{})
return
}

// Build response with optional customizations
settings := WorkspaceContainerSettings{}
if image, ok := wcMap["image"].(string); ok {
settings.Image = image
}

// Extract resources if present
if resources, found, err := unstructured.NestedMap(wcMap, "resources"); err == nil && found {
settings.Resources = &WorkspaceContainerResourceLimits{}
if v, ok := resources["cpuRequest"].(string); ok {
settings.Resources.CPURequest = v
}
if v, ok := resources["cpuLimit"].(string); ok {
settings.Resources.CPULimit = v
}
if v, ok := resources["memoryRequest"].(string); ok {
settings.Resources.MemoryRequest = v
}
if v, ok := resources["memoryLimit"].(string); ok {
settings.Resources.MemoryLimit = v
}
}

c.JSON(http.StatusOK, settings)
}

// UpdateWorkspaceContainerSettings updates the workspace container settings for a project
func UpdateWorkspaceContainerSettings(c *gin.Context) {
project := c.GetString("project")
if project == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "project name required"})
return
}

var req WorkspaceContainerSettings
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body"})
return
}

// Get user-scoped dynamic client
reqK8s, reqDyn := GetK8sClientsForRequest(c)
if reqK8s == nil || reqDyn == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or missing authentication token"})
return
}

ctx := context.Background()
gvr := GetProjectSettingsResource()

// Get or create the ProjectSettings CR
obj, err := reqDyn.Resource(gvr).Namespace(project).Get(ctx, "projectsettings", v1.GetOptions{})
if err != nil {
if errors.IsNotFound(err) {
// Create new ProjectSettings with workspaceContainer
obj = &unstructured.Unstructured{
Object: map[string]interface{}{
"apiVersion": "vteam.ambient-code/v1alpha1",
"kind": "ProjectSettings",
"metadata": map[string]interface{}{
"name": "projectsettings",
"namespace": project,
},
"spec": map[string]interface{}{
"groupAccess": []interface{}{}, // Required field
},
},
}
} else {
log.Printf("Failed to get ProjectSettings for %s: %v", project, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get project settings"})
return
}
}

// Build workspaceContainer map with optional customizations
wcMap := map[string]interface{}{}
if req.Image != "" {
wcMap["image"] = req.Image
}
if req.Resources != nil {
resources := map[string]interface{}{}
if req.Resources.CPURequest != "" {
resources["cpuRequest"] = req.Resources.CPURequest
}
if req.Resources.CPULimit != "" {
resources["cpuLimit"] = req.Resources.CPULimit
}
if req.Resources.MemoryRequest != "" {
resources["memoryRequest"] = req.Resources.MemoryRequest
}
if req.Resources.MemoryLimit != "" {
resources["memoryLimit"] = req.Resources.MemoryLimit
}
if len(resources) > 0 {
wcMap["resources"] = resources
}
}

// Set workspaceContainer in spec
if err := unstructured.SetNestedMap(obj.Object, wcMap, "spec", "workspaceContainer"); err != nil {
log.Printf("Failed to set workspaceContainer in spec: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update settings"})
return
}

// Create or update the ProjectSettings CR
if obj.GetResourceVersion() == "" {
// Create new
_, err = reqDyn.Resource(gvr).Namespace(project).Create(ctx, obj, v1.CreateOptions{})
if err != nil {
log.Printf("Failed to create ProjectSettings for %s: %v", project, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create project settings"})
return
}
log.Printf("Created ProjectSettings with workspaceContainer for project %s", project)
} else {
// Update existing
_, err = reqDyn.Resource(gvr).Namespace(project).Update(ctx, obj, v1.UpdateOptions{})
if err != nil {
log.Printf("Failed to update ProjectSettings for %s: %v", project, err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update project settings"})
return
}
log.Printf("Updated workspaceContainer settings for project %s", project)
}

c.JSON(http.StatusOK, gin.H{
"message": "Workspace container settings updated",
"image": req.Image,
})
}
4 changes: 4 additions & 0 deletions components/backend/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ func registerRoutes(r *gin.Engine) {
projectGroup.GET("/integration-secrets", handlers.ListIntegrationSecrets)
projectGroup.PUT("/integration-secrets", handlers.UpdateIntegrationSecrets)

// Workspace container settings (ADR-0006)
projectGroup.GET("/workspace-container", handlers.GetWorkspaceContainerSettings)
projectGroup.PUT("/workspace-container", handlers.UpdateWorkspaceContainerSettings)

// GitLab authentication endpoints (project-scoped)
projectGroup.POST("/auth/gitlab/connect", handlers.ConnectGitLabGlobal)
projectGroup.GET("/auth/gitlab/status", handlers.GetGitLabStatusGlobal)
Expand Down
30 changes: 30 additions & 0 deletions components/frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions components/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { BACKEND_URL } from '@/lib/config';
import { buildForwardHeadersAsync } from '@/lib/auth';

export async function GET(
request: Request,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const headers = await buildForwardHeadersAsync(request);

const resp = await fetch(
`${BACKEND_URL}/projects/${encodeURIComponent(name)}/workspace-container`,
{ headers }
);
const data = await resp.json().catch(() => ({}));
return Response.json(data, { status: resp.status });
} catch (error) {
console.error('Error fetching workspace container settings:', error);
return Response.json(
{ error: 'Failed to fetch workspace container settings' },
{ status: 500 }
);
}
}

export async function PUT(
request: Request,
{ params }: { params: Promise<{ name: string }> }
) {
try {
const { name } = await params;
const headers = await buildForwardHeadersAsync(request);
const body = await request.json();

const resp = await fetch(
`${BACKEND_URL}/projects/${encodeURIComponent(name)}/workspace-container`,
{
method: 'PUT',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
const data = await resp.json().catch(() => ({}));
return Response.json(data, { status: resp.status });
} catch (error) {
console.error('Error updating workspace container settings:', error);
return Response.json(
{ error: 'Failed to update workspace container settings' },
{ status: 500 }
);
}
}
29 changes: 29 additions & 0 deletions components/frontend/src/components/ui/switch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"use client"

import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"

import { cn } from "@/lib/utils"

const Switch = React.forwardRef<
React.ComponentRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"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",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"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"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName

export { Switch }
Loading
Loading