222 lines
7.0 KiB
Go
222 lines
7.0 KiB
Go
// Package opensandboxbackend implements the VM executor backend with the official OpenSandbox SDK.
|
|
package opensandboxbackend
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
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"
|
|
const terminalMetadata = "ci.ddupan.top/terminal"
|
|
|
|
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)
|
|
PatchSandboxMetadata(context.Context, string, opensandbox.MetadataPatch) (*opensandbox.SandboxInfo, error)
|
|
DeleteSandbox(context.Context, string) error
|
|
}
|
|
|
|
// MarkTerminal persists the accepted Gitea terminal state on the sandbox. The
|
|
// lifecycle reconciler performs deletion separately so the runner receives the
|
|
// successful UpdateTask response before its VM is stopped.
|
|
func (b Backend) MarkTerminal(ctx context.Context, assignmentID string) error {
|
|
executor, err := b.Find(ctx, assignmentID)
|
|
if err != nil || executor == nil {
|
|
return err
|
|
}
|
|
value := "true"
|
|
_, err = b.Lifecycle.PatchSandboxMetadata(ctx, executor.Name, opensandbox.MetadataPatch{
|
|
terminalMetadata: &value,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("mark sandbox %s terminal: %w", executor.Name, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// CleanupTerminated removes sandboxes whose terminal result was accepted by
|
|
// Gitea. The marker is stored by OpenSandbox, so cleanup survives restarts.
|
|
func (b Backend) CleanupTerminated(ctx context.Context) (int, error) {
|
|
if err := b.validate(); err != nil {
|
|
return 0, err
|
|
}
|
|
result, err := b.Lifecycle.ListSandboxes(ctx, opensandbox.ListOptions{
|
|
Metadata: map[string]string{terminalMetadata: "true"},
|
|
PageSize: 100,
|
|
})
|
|
if err != nil {
|
|
return 0, fmt.Errorf("list terminal sandboxes: %w", err)
|
|
}
|
|
cleaned := 0
|
|
for _, sandbox := range result.Items {
|
|
if sandbox.Metadata[assignmentMetadata] == "" {
|
|
continue
|
|
}
|
|
if err := b.Delete(ctx, executor(sandbox)); err != nil {
|
|
return cleaned, fmt.Errorf("delete terminal sandbox %s: %w", sandbox.ID, err)
|
|
}
|
|
cleaned++
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
// Lifecycle periodically reconciles durable terminal markers into deletes.
|
|
type LifecycleReconciler struct {
|
|
Backend Backend
|
|
Interval time.Duration
|
|
OnError func(error)
|
|
}
|
|
|
|
func (l LifecycleReconciler) Run(ctx context.Context) error {
|
|
interval := l.Interval
|
|
if interval <= 0 {
|
|
interval = 2 * time.Second
|
|
}
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
if _, err := l.Backend.CleanupTerminated(ctx); err != nil && ctx.Err() == nil && l.OnError != nil {
|
|
l.OnError(err)
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
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, launch taskworker.LaunchSpec) (*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)
|
|
for key, value := range launch.Environment {
|
|
environment[key] = value
|
|
}
|
|
sandboxMetadata := clone(launch.Metadata.Annotations)
|
|
for key, value := range launch.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
|
|
}
|