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

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 }