Add workspace creation feature

This commit is contained in:
starryskymeow
2025-07-04 18:03:00 +08:00
parent 3ed03208fb
commit fa18edc20f
8 changed files with 610 additions and 0 deletions

83
internal/api/handlers.go Normal file
View File

@@ -0,0 +1,83 @@
package api
import (
"net/http"
"gitea.starryskymeow.cn/xkm/educode-controller/internal/k8s"
"github.com/gin-gonic/gin"
"k8s.io/client-go/kubernetes"
)
const Namespace = "default" // Or get from config
type Handler struct {
clientset *kubernetes.Clientset
}
func NewHandler(clientset *kubernetes.Clientset) *Handler {
return &Handler{clientset: clientset}
}
func (h *Handler) createWorkspace(c *gin.Context) {
var req CreateWorkspaceRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
image := k8s.DefaultImage
if req.Image != "" {
image = req.Image
}
k8sReq := &k8s.WorkspaceRequest{
Image: image,
Env: req.Env,
WorkspaceID: req.WorkspaceID,
Clientset: h.clientset,
Namespace: Namespace,
}
if req.ResourceLimits != nil {
k8sReq.ResourceLimits = &k8s.ResourceLimits{
CPU: req.ResourceLimits.CPU,
Memory: req.ResourceLimits.Memory,
}
}
err := k8s.CreateWorkspace(k8sReq)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"status": "creating", "workspaceId": req.WorkspaceID})
}
func (h *Handler) deleteWorkspace(c *gin.Context) {
workspaceID := c.Param("workspaceID")
err := k8s.DeleteWorkspace(h.clientset, Namespace, workspaceID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusAccepted, gin.H{"status": "deleting", "workspaceId": workspaceID})
}
func (h *Handler) getWorkspace(c *gin.Context) {
workspaceID := c.Param("workspaceID")
// TODO: Call k8s manager to get resource status
c.JSON(http.StatusOK, gin.H{"status": "ok", "workspaceId": workspaceID})
}
func (h *Handler) extendWorkspace(c *gin.Context) {
workspaceID := c.Param("workspaceID")
// TODO: Call k8s manager to patch resource annotation
c.JSON(http.StatusOK, gin.H{"status": "extended", "workspaceId": workspaceID})
}

34
internal/api/server.go Normal file
View File

@@ -0,0 +1,34 @@
package api
import (
"github.com/gin-gonic/gin"
"k8s.io/client-go/kubernetes"
)
// NewServer creates and configures a new Gin server.
func NewServer(clientset *kubernetes.Clientset) *gin.Engine {
r := gin.Default()
// Create a new handler with the clientset
h := NewHandler(clientset)
// Health check endpoint
r.GET("/healthz", func(c *gin.Context) {
c.JSON(200, gin.H{
"status": "ok",
})
})
v1 := r.Group("/api/v1")
{
workspaces := v1.Group("/workspaces")
{
workspaces.POST("", h.createWorkspace)
workspaces.DELETE("/:workspaceID", h.deleteWorkspace)
workspaces.GET("/:workspaceID", h.getWorkspace)
workspaces.PATCH("/:workspaceID", h.extendWorkspace)
}
}
return r
}

15
internal/api/types.go Normal file
View File

@@ -0,0 +1,15 @@
package api
// CreateWorkspaceRequest defines the request body for creating a new workspace.
type CreateWorkspaceRequest struct {
Image string `json:"image"`
Env map[string]string `json:"env"`
ResourceLimits *ResourceLimits `json:"resourceLimits"`
WorkspaceID string `json:"workspaceId"`
}
// ResourceLimits defines the CPU and memory resource limits.
type ResourceLimits struct {
CPU string `json:"cpu"`
Memory string `json:"memory"`
}

17
internal/k8s/client.go Normal file
View File

@@ -0,0 +1,17 @@
package k8s
import (
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
// NewClient creates a new Kubernetes clientset.
func NewClient() (*kubernetes.Clientset, error) {
// Try to load in-cluster config
config, err := rest.InClusterConfig()
if err != nil {
return nil, err
}
return kubernetes.NewForConfig(config)
}

171
internal/k8s/workspace.go Normal file
View File

@@ -0,0 +1,171 @@
package k8s
import (
"context"
"fmt"
"time"
appsv1 "k8s.io/api/apps/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/intstr"
"k8s.io/client-go/kubernetes"
)
const (
DefaultImage = "ghcr.io/dreamstarsky/educode:cpu-latest"
DefaultCPU = "500m"
DefaultMemory = "512Mi"
DefaultStorage = "5Gi"
)
type WorkspaceRequest struct {
Image string
Env map[string]string
ResourceLimits *ResourceLimits
WorkspaceID string
Clientset *kubernetes.Clientset
Namespace string
}
type ResourceLimits struct {
CPU string
Memory string
}
func CreateWorkspace(req *WorkspaceRequest) error {
// Define StatefulSet
sts := &appsv1.StatefulSet{
ObjectMeta: metav1.ObjectMeta{
Name: req.WorkspaceID,
Namespace: req.Namespace,
Annotations: map[string]string{
"educode/expires-at": time.Now().Add(1 * time.Hour).Format(time.RFC3339),
},
},
Spec: appsv1.StatefulSetSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{"app": req.WorkspaceID},
},
ServiceName: req.WorkspaceID,
Replicas: int32Ptr(1),
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{"app": req.WorkspaceID},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "code-server",
Image: req.Image,
Ports: []corev1.ContainerPort{
{ContainerPort: 8080}, // Default code-server port
{ContainerPort: 22}, // SSH port
},
Env: getEnvVars(req.Env),
Resources: getResourceRequirements(req.ResourceLimits),
VolumeMounts: []corev1.VolumeMount{
{Name: "workspace-storage", MountPath: "/home/coder"},
},
},
},
},
},
VolumeClaimTemplates: []corev1.PersistentVolumeClaim{
{
ObjectMeta: metav1.ObjectMeta{Name: "workspace-storage"},
Spec: corev1.PersistentVolumeClaimSpec{
AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce},
Resources: corev1.VolumeResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceStorage: resource.MustParse(DefaultStorage),
},
},
},
},
},
},
}
// Define Service
svc := &corev1.Service{
ObjectMeta: metav1.ObjectMeta{
Name: req.WorkspaceID,
Namespace: req.Namespace,
},
Spec: corev1.ServiceSpec{
Selector: map[string]string{"app": req.WorkspaceID},
Ports: []corev1.ServicePort{
{Name: "http", Port: 8080, TargetPort: intstr.FromInt(8080)},
{Name: "ssh", Port: 22, TargetPort: intstr.FromInt(22)},
},
Type: corev1.ServiceTypeClusterIP,
},
}
// Create resources
_, err := req.Clientset.AppsV1().StatefulSets(req.Namespace).Create(context.TODO(), sts, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("failed to create statefulset: %w", err)
}
_, err = req.Clientset.CoreV1().Services(req.Namespace).Create(context.TODO(), svc, metav1.CreateOptions{})
if err != nil {
// Cleanup StatefulSet if Service creation fails
req.Clientset.AppsV1().StatefulSets(req.Namespace).Delete(context.TODO(), req.WorkspaceID, metav1.DeleteOptions{})
return fmt.Errorf("failed to create service: %w", err)
}
return nil
}
func DeleteWorkspace(clientset *kubernetes.Clientset, namespace, workspaceID string) error {
// Delete StatefulSet
err := clientset.AppsV1().StatefulSets(namespace).Delete(context.TODO(), workspaceID, metav1.DeleteOptions{})
if err != nil {
return fmt.Errorf("failed to delete statefulset: %w", err)
}
// Delete Service
err = clientset.CoreV1().Services(namespace).Delete(context.TODO(), workspaceID, metav1.DeleteOptions{})
if err != nil {
return fmt.Errorf("failed to delete service: %w", err)
}
// PVC will be deleted automatically when the StatefulSet is deleted, if the reclaim policy is set to Delete.
// If not, it might need manual cleanup. For now, we assume it's handled.
return nil
}
func getEnvVars(env map[string]string) []corev1.EnvVar {
var envVars []corev1.EnvVar
for k, v := range env {
envVars = append(envVars, corev1.EnvVar{Name: k, Value: v})
}
return envVars
}
func getResourceRequirements(limits *ResourceLimits) corev1.ResourceRequirements {
req := corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse(DefaultCPU),
corev1.ResourceMemory: resource.MustParse(DefaultMemory),
},
}
if limits != nil {
req.Limits = corev1.ResourceList{}
if limits.CPU != "" {
req.Limits[corev1.ResourceCPU] = resource.MustParse(limits.CPU)
}
if limits.Memory != "" {
req.Limits[corev1.ResourceMemory] = resource.MustParse(limits.Memory)
}
}
return req
}
func int32Ptr(i int32) *int32 { return &i }