Add workspace creation feature
This commit is contained in:
83
internal/api/handlers.go
Normal file
83
internal/api/handlers.go
Normal 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
34
internal/api/server.go
Normal 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
15
internal/api/types.go
Normal 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
17
internal/k8s/client.go
Normal 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
171
internal/k8s/workspace.go
Normal 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 }
|
||||
Reference in New Issue
Block a user