// Package opensandboxbackend implements the VM executor backend with the official OpenSandbox SDK. package opensandboxbackend import ( "context" "errors" "fmt" "net/http" opensandbox "github.com/alibaba/OpenSandbox/sdks/sandbox/go" "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskassignment" "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskidentity" "git.ddupan.top/panxiao81/gitea-dynamic-runner/internal/taskworker" ) const assignmentMetadata = "ci.ddupan.top/assignment-id" type Lifecycle interface { ListSandboxes(context.Context, opensandbox.ListOptions) (*opensandbox.ListSandboxesResponse, error) CreateSandbox(context.Context, opensandbox.CreateSandboxRequest) (*opensandbox.SandboxInfo, error) GetSandbox(context.Context, string) (*opensandbox.SandboxInfo, error) DeleteSandbox(context.Context, string) error } type Config struct { Pool string Timeout int Entrypoint []string Env map[string]string } type Backend struct { Lifecycle Lifecycle Config Config } func NewLifecycleClient(baseURL, apiKey string, client *http.Client) *opensandbox.LifecycleClient { if client != nil { return opensandbox.NewLifecycleClient(baseURL, apiKey, opensandbox.WithHTTPClient(client)) } return opensandbox.NewLifecycleClient(baseURL, apiKey) } func (b Backend) Find(ctx context.Context, assignmentID string) (*taskworker.Executor, error) { if err := b.validate(); err != nil { return nil, err } result, err := b.Lifecycle.ListSandboxes(ctx, opensandbox.ListOptions{ Metadata: map[string]string{assignmentMetadata: assignmentID}, PageSize: 2, }) if err != nil { return nil, fmt.Errorf("list assignment sandboxes: %w", err) } if len(result.Items) > 1 { return nil, fmt.Errorf("assignment %s owns %d sandboxes", assignmentID, len(result.Items)) } if len(result.Items) == 0 { return nil, nil } return executor(result.Items[0]), nil } func (b Backend) Create(ctx context.Context, assignment taskassignment.Assignment, metadata taskworker.Metadata) (*taskworker.Executor, error) { if err := b.validate(); err != nil { return nil, err } if assignment.Backend != taskassignment.BackendVM { return nil, fmt.Errorf("OpenSandbox backend cannot create %q assignment", assignment.Backend) } environment := clone(b.Config.Env) environment["CI_ASSIGNMENT_ID"] = assignment.ID environment["CI_SPIFFE_ID"] = assignment.Identity.SPIFFEID sandboxMetadata := clone(metadata.Annotations) for key, value := range metadata.Labels { sandboxMetadata[key] = value } request := opensandbox.CreateSandboxRequest{ Timeout: &b.Config.Timeout, Entrypoint: append([]string{}, b.Config.Entrypoint...), Env: environment, Metadata: sandboxMetadata, Extensions: map[string]string{"poolRef": b.Config.Pool}, ResourceLimits: opensandbox.ResourceLimits{}, } sandbox, err := b.Lifecycle.CreateSandbox(ctx, request) if err != nil { return nil, fmt.Errorf("create assignment sandbox: %w", err) } return executor(*sandbox), nil } func (b Backend) BindIdentity(ctx context.Context, executor *taskworker.Executor, identity taskidentity.Identity) error { if executor == nil || executor.Name == "" { return errors.New("sandbox ID is required for identity binding") } sandbox, err := b.Lifecycle.GetSandbox(ctx, executor.Name) if err != nil { return fmt.Errorf("verify sandbox identity metadata: %w", err) } if sandbox.Metadata["ci.ddupan.top/spiffe-id"] != identity.SPIFFEID { return fmt.Errorf("sandbox %s has inconsistent SPIFFE identity metadata", executor.Name) } return nil } func (b Backend) Delete(ctx context.Context, executor *taskworker.Executor) error { if executor == nil || executor.Name == "" { return nil } err := b.Lifecycle.DeleteSandbox(ctx, executor.Name) var apiError *opensandbox.APIError if errors.As(err, &apiError) && apiError.StatusCode == http.StatusNotFound { return nil } return err } func (b Backend) validate() error { if b.Lifecycle == nil || b.Config.Pool == "" || b.Config.Timeout < 1 || len(b.Config.Entrypoint) == 0 { return errors.New("OpenSandbox Lifecycle client, pool, timeout, and entrypoint are required") } return nil } func executor(sandbox opensandbox.SandboxInfo) *taskworker.Executor { return &taskworker.Executor{Name: sandbox.ID, IdentityTarget: sandbox.ID, Phase: phase(sandbox.Status.State)} } func phase(state opensandbox.SandboxState) taskworker.Phase { switch state { case opensandbox.StateRunning: return taskworker.PhaseRunning case opensandbox.StateTerminated, opensandbox.StateFailed: // A successful executor reports Gitea before it exits. If Gitea is not // terminal when the sandbox stops, termination is an execution failure. return taskworker.PhaseFailed default: return taskworker.PhasePending } } func clone(source map[string]string) map[string]string { result := make(map[string]string, len(source)) for key, value := range source { result[key] = value } return result }