yao/tai/sandbox/k8s.go
Max d25bfefee0 Enhance Sandbox V2 testing and integration with Workspace
- Introduce new unit test targets for Workspace and Sandbox V2 integration in the Makefile, ensuring comprehensive testing of the new features.
- Update CI workflows to support MongoDB service for Sandbox V2 tests and improve Docker image handling.
- Implement Workspace as a first-class entity, allowing for persistent storage decoupled from container lifecycles.
- Enhance the Box struct to manage workspace IDs and update related methods for improved functionality.
- Refactor tests to accommodate new Workspace features, ensuring robust testing of the integration.

These changes significantly improve the testing framework and functionality of the Sandbox V2, enhancing the overall architecture and user experience.
2026-03-05 21:00:50 +08:00

404 lines
9.9 KiB
Go

package sandbox
import (
"bytes"
"context"
"fmt"
"io"
"path/filepath"
"strings"
"time"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/errors"
apiresource "k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
"k8s.io/client-go/tools/remotecommand"
)
// K8sOption configures a K8s sandbox.
type K8sOption struct {
Namespace string // default "default"
KubeConfig string // path to kubeconfig file
}
type k8sSandbox struct {
cli kubernetes.Interface
cfg *rest.Config
ns string
labels map[string]string
}
// NewK8s creates a Sandbox backed by Kubernetes via Tai's TCP proxy.
// addr should be "host:port" pointing to Tai's K8s proxy endpoint.
// kubeConfigPath must be an absolute path or will be resolved relative to the caller's working directory.
func NewK8s(addr string, opts ...K8sOption) (Sandbox, error) {
ns := "default"
var kubeConfigPath string
if len(opts) > 0 {
if opts[0].Namespace != "" {
ns = opts[0].Namespace
}
if opts[0].KubeConfig != "" {
kubeConfigPath = opts[0].KubeConfig
if !filepath.IsAbs(kubeConfigPath) {
abs, err := filepath.Abs(kubeConfigPath)
if err != nil {
return nil, fmt.Errorf("resolve kubeconfig path: %w", err)
}
kubeConfigPath = abs
}
}
}
if kubeConfigPath == "" {
return nil, fmt.Errorf("kubeconfig path is required for K8s sandbox")
}
cfg, err := clientcmd.BuildConfigFromFlags("", kubeConfigPath)
if err != nil {
return nil, fmt.Errorf("build kubeconfig: %w", err)
}
// Override the server address to point at the Tai proxy
if addr != "" {
cfg.Host = "https://" + addr
// When connecting through Tai TCP proxy, skip TLS verification
cfg.TLSClientConfig.Insecure = true
cfg.TLSClientConfig.CAData = nil
cfg.TLSClientConfig.CAFile = ""
}
cli, err := kubernetes.NewForConfig(cfg)
if err != nil {
return nil, fmt.Errorf("create k8s client: %w", err)
}
// Verify connectivity
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_, err = cli.CoreV1().Namespaces().Get(ctx, ns, metav1.GetOptions{})
if err != nil && !errors.IsNotFound(err) {
return nil, fmt.Errorf("k8s connectivity check: %w", err)
}
return &k8sSandbox{
cli: cli,
cfg: cfg,
ns: ns,
labels: map[string]string{
"managed-by": "yao-tai-sdk",
},
}, nil
}
func (s *k8sSandbox) Create(ctx context.Context, opts CreateOptions) (string, error) {
name := opts.Name
if name == "" {
name = fmt.Sprintf("sandbox-%d", time.Now().UnixNano())
}
// K8s names must be DNS-compatible
name = strings.ToLower(name)
name = strings.ReplaceAll(name, "_", "-")
envVars := make([]corev1.EnvVar, 0, len(opts.Env))
for k, v := range opts.Env {
envVars = append(envVars, corev1.EnvVar{Name: k, Value: v})
}
container := corev1.Container{
Name: "main",
Image: opts.Image,
Args: opts.Cmd,
Env: envVars,
WorkingDir: opts.WorkingDir,
}
if opts.Memory > 0 || opts.CPUs > 0 {
container.Resources = buildResources(opts.Memory, opts.CPUs)
}
labels := make(map[string]string)
for k, v := range s.labels {
labels[k] = v
}
labels["sandbox-name"] = name
for k, v := range opts.Labels {
labels[k] = v
}
pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: name,
Namespace: s.ns,
Labels: labels,
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{container},
RestartPolicy: corev1.RestartPolicyNever,
},
}
if opts.User != "" {
uid, err := parseUID(opts.User)
if err == nil {
pod.Spec.SecurityContext = &corev1.PodSecurityContext{
RunAsUser: &uid,
}
}
}
created, err := s.cli.CoreV1().Pods(s.ns).Create(ctx, pod, metav1.CreateOptions{})
if err != nil {
return "", fmt.Errorf("create pod: %w", err)
}
return created.Name, nil
}
func (s *k8sSandbox) Start(ctx context.Context, id string) error {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 60*time.Second)
defer cancel()
}
ticker := time.NewTicker(1 * time.Second)
defer ticker.Stop()
for {
pod, err := s.cli.CoreV1().Pods(s.ns).Get(ctx, id, metav1.GetOptions{})
if err != nil {
return fmt.Errorf("get pod: %w", err)
}
if pod.Status.Phase == corev1.PodRunning || pod.Status.Phase == corev1.PodSucceeded || pod.Status.Phase == corev1.PodFailed {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("pod %s did not reach Running: %w", id, ctx.Err())
case <-ticker.C:
}
}
}
func (s *k8sSandbox) Stop(ctx context.Context, id string, timeout time.Duration) error {
secs := int64(timeout.Seconds())
return s.cli.CoreV1().Pods(s.ns).Delete(ctx, id, metav1.DeleteOptions{
GracePeriodSeconds: &secs,
})
}
func (s *k8sSandbox) Remove(ctx context.Context, id string, force bool) error {
opts := metav1.DeleteOptions{}
if force {
zero := int64(0)
opts.GracePeriodSeconds = &zero
}
err := s.cli.CoreV1().Pods(s.ns).Delete(ctx, id, opts)
if errors.IsNotFound(err) {
return nil
}
return err
}
func (s *k8sSandbox) Exec(ctx context.Context, id string, cmd []string, opts ExecOptions) (*ExecResult, error) {
execCmd := cmd
if opts.WorkDir != "" || len(opts.Env) > 0 {
var prefix string
for k, v := range opts.Env {
prefix += fmt.Sprintf("export %s=%q; ", k, v)
}
cdPart := ""
if opts.WorkDir != "" {
cdPart = fmt.Sprintf("cd %s && ", opts.WorkDir)
}
execCmd = []string{"sh", "-c", cdPart + prefix + strings.Join(cmd, " ")}
}
req := s.cli.CoreV1().RESTClient().Post().
Resource("pods").
Name(id).
Namespace(s.ns).
SubResource("exec").
VersionedParams(&corev1.PodExecOptions{
Container: "main",
Command: execCmd,
Stdout: true,
Stderr: true,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(s.cfg, "POST", req.URL())
if err != nil {
return nil, fmt.Errorf("create executor: %w", err)
}
var stdout, stderr bytes.Buffer
err = exec.StreamWithContext(ctx, remotecommand.StreamOptions{
Stdout: &stdout,
Stderr: &stderr,
})
exitCode := 0
if err != nil {
if exitErr, ok := err.(interface{ ExitStatus() int }); ok {
exitCode = exitErr.ExitStatus()
err = nil
} else {
return nil, fmt.Errorf("exec stream: %w", err)
}
}
return &ExecResult{
ExitCode: exitCode,
Stdout: stdout.String(),
Stderr: stderr.String(),
}, nil
}
func (s *k8sSandbox) ExecStream(ctx context.Context, id string, cmd []string, opts ExecOptions) (*StreamHandle, error) {
execCmd := cmd
if opts.WorkDir != "" || len(opts.Env) > 0 {
var prefix string
for k, v := range opts.Env {
prefix += fmt.Sprintf("export %s=%q; ", k, v)
}
cdPart := ""
if opts.WorkDir != "" {
cdPart = fmt.Sprintf("cd %s && ", opts.WorkDir)
}
execCmd = []string{"sh", "-c", cdPart + prefix + strings.Join(cmd, " ")}
}
req := s.cli.CoreV1().RESTClient().Post().
Resource("pods").
Name(id).
Namespace(s.ns).
SubResource("exec").
VersionedParams(&corev1.PodExecOptions{
Container: "main",
Command: execCmd,
Stdin: true,
Stdout: true,
Stderr: true,
}, scheme.ParameterCodec)
exec, err := remotecommand.NewSPDYExecutor(s.cfg, "POST", req.URL())
if err != nil {
return nil, fmt.Errorf("create executor: %w", err)
}
stdinR, stdinW := io.Pipe()
stdoutR, stdoutW := io.Pipe()
stderrR, stderrW := io.Pipe()
execCtx, cancel := context.WithCancel(ctx)
done := make(chan error, 1)
var exitCode int
go func() {
err := exec.StreamWithContext(execCtx, remotecommand.StreamOptions{
Stdin: stdinR,
Stdout: stdoutW,
Stderr: stderrW,
})
if err != nil {
if exitErr, ok := err.(interface{ ExitStatus() int }); ok {
exitCode = exitErr.ExitStatus()
err = nil
}
}
stdoutW.Close()
stderrW.Close()
done <- err
}()
return &StreamHandle{
Stdin: stdinW,
Stdout: stdoutR,
Stderr: stderrR,
Wait: func() (int, error) {
err := <-done
return exitCode, err
},
Cancel: func() {
cancel()
stdinR.Close()
},
}, nil
}
func (s *k8sSandbox) Inspect(ctx context.Context, id string) (*ContainerInfo, error) {
pod, err := s.cli.CoreV1().Pods(s.ns).Get(ctx, id, metav1.GetOptions{})
if err != nil {
return nil, err
}
return &ContainerInfo{
ID: string(pod.UID),
Name: pod.Name,
Image: pod.Spec.Containers[0].Image,
Status: string(pod.Status.Phase),
IP: pod.Status.PodIP,
Labels: pod.Labels,
}, nil
}
func (s *k8sSandbox) List(ctx context.Context, opts ListOptions) ([]ContainerInfo, error) {
labelSelector := "managed-by=yao-tai-sdk"
if len(opts.Labels) > 0 {
for k, v := range opts.Labels {
labelSelector += "," + k + "=" + v
}
}
pods, err := s.cli.CoreV1().Pods(s.ns).List(ctx, metav1.ListOptions{
LabelSelector: labelSelector,
})
if err != nil {
return nil, err
}
result := make([]ContainerInfo, 0, len(pods.Items))
for _, pod := range pods.Items {
ci := ContainerInfo{
ID: string(pod.UID),
Name: pod.Name,
Status: string(pod.Status.Phase),
IP: pod.Status.PodIP,
Labels: pod.Labels,
}
if len(pod.Spec.Containers) > 0 {
ci.Image = pod.Spec.Containers[0].Image
}
result = append(result, ci)
}
return result, nil
}
func (s *k8sSandbox) Close() error {
return nil // REST client doesn't need explicit close
}
// parseUID extracts a numeric UID from a user string like "1000" or "1000:1000".
func parseUID(user string) (int64, error) {
parts := strings.SplitN(user, ":", 2)
var uid int64
_, err := fmt.Sscanf(parts[0], "%d", &uid)
return uid, err
}
func buildResources(memory int64, cpus float64) corev1.ResourceRequirements {
limits := corev1.ResourceList{}
if memory > 0 {
limits[corev1.ResourceMemory] = *apiresource.NewQuantity(memory, apiresource.BinarySI)
}
if cpus > 0 {
limits[corev1.ResourceCPU] = *apiresource.NewMilliQuantity(int64(cpus*1000), apiresource.DecimalSI)
}
return corev1.ResourceRequirements{Limits: limits}
}