e0e362d700
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
SDK Tests / SDK CI (push) Has been cancelled
SDK Tests / CLI Tests (push) Has been cancelled
SDK Tests / Python SDK Quality (code-interpreter) (push) Has been cancelled
SDK Tests / Python SDK Quality (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Python SDK Tests (sandbox) (push) Has been cancelled
SDK Tests / CLI Quality (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Has been cancelled
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Has been cancelled
SDK Tests / Go SDK Quality And Tests (push) Has been cancelled
312 lines
9.7 KiB
Go
312 lines
9.7 KiB
Go
// Copyright 2026 Alibaba Group Holding Ltd.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package controller
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/alibaba/opensandbox/execd/pkg/isolation"
|
|
"github.com/alibaba/opensandbox/execd/pkg/jupyter/execute"
|
|
"github.com/alibaba/opensandbox/execd/pkg/runtime"
|
|
"github.com/alibaba/opensandbox/execd/pkg/telemetry"
|
|
"github.com/alibaba/opensandbox/execd/pkg/web/model"
|
|
)
|
|
|
|
// isolatedRunner is set by InitIsolatedRunner during startup.
|
|
var isolatedRunner *runtime.IsolatedRunner
|
|
|
|
// isolatedProbeResult stores the probe result for capabilities reporting.
|
|
var isolatedProbeResult *isolation.ProbeResult
|
|
|
|
// InitIsolatedRunner wires the isolated session runner.
|
|
func InitIsolatedRunner(r *runtime.IsolatedRunner) {
|
|
isolatedRunner = r
|
|
}
|
|
|
|
// InitIsolatedProbe stores the probe result for the capabilities endpoint.
|
|
func InitIsolatedProbe(p *isolation.ProbeResult) {
|
|
isolatedProbeResult = p
|
|
}
|
|
|
|
// IsolatedSessionController handles /v1/isolated/* endpoints.
|
|
type IsolatedSessionController struct {
|
|
*basicController
|
|
}
|
|
|
|
// NewIsolatedSessionController creates a controller bound to ctx.
|
|
func NewIsolatedSessionController(ctx *gin.Context) *IsolatedSessionController {
|
|
return &IsolatedSessionController{
|
|
basicController: newBasicController(ctx),
|
|
}
|
|
}
|
|
|
|
func (c *IsolatedSessionController) probed() bool {
|
|
return isolatedRunner != nil && isolatedRunner.Available()
|
|
}
|
|
|
|
// Create handles POST /v1/isolated/session.
|
|
func (c *IsolatedSessionController) Create() {
|
|
if !c.probed() {
|
|
c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable")
|
|
return
|
|
}
|
|
|
|
var req model.CreateIsolatedSessionRequest
|
|
if err := c.bindJSON(&req); err != nil {
|
|
c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
binds := make([]isolation.BindMount, 0, len(req.Binds))
|
|
for _, b := range req.Binds {
|
|
binds = append(binds, isolation.BindMount{
|
|
Source: b.Source,
|
|
Dest: b.Dest,
|
|
ReadOnly: b.ReadOnly,
|
|
})
|
|
}
|
|
|
|
opts := &runtime.IsolatedSessionOptions{
|
|
Profile: req.Profile,
|
|
WorkspacePath: req.Workspace.Path,
|
|
WorkspaceMode: req.Workspace.Mode,
|
|
ExtraWritable: req.ExtraWritable,
|
|
Binds: binds,
|
|
ShareNet: req.ShareNet,
|
|
EnvPassthroughMode: req.EnvPassthrough.Mode,
|
|
EnvPassthroughKeys: req.EnvPassthrough.Keys,
|
|
Uid: req.Uid,
|
|
Gid: req.Gid,
|
|
UidMode: req.UidMode,
|
|
IdleTimeoutSeconds: req.IdleTimeoutSeconds,
|
|
}
|
|
|
|
sessionID, err := isolatedRunner.CreateIsolatedSession(opts)
|
|
if err != nil {
|
|
status := http.StatusInternalServerError
|
|
if strings.Contains(err.Error(), "not in allowlist") ||
|
|
strings.Contains(err.Error(), "not allowed") ||
|
|
strings.Contains(err.Error(), "unknown isolation profile") ||
|
|
strings.Contains(err.Error(), "must be an existing path") ||
|
|
strings.Contains(err.Error(), "must be an absolute path") ||
|
|
strings.Contains(err.Error(), "source is required") {
|
|
status = http.StatusBadRequest
|
|
}
|
|
c.RespondError(status, model.ErrorCodeRuntimeError, err.Error())
|
|
return
|
|
}
|
|
|
|
c.ctx.JSON(http.StatusCreated, model.IsolatedCreateSessionResponse{
|
|
SessionID: sessionID,
|
|
CreatedAt: time.Now(),
|
|
})
|
|
}
|
|
|
|
// Get handles GET /v1/isolated/session/:sessionId.
|
|
func (c *IsolatedSessionController) Get() {
|
|
if !c.probed() {
|
|
c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable")
|
|
return
|
|
}
|
|
|
|
sessionID := c.ctx.Param("sessionId")
|
|
state, err := isolatedRunner.GetIsolatedSession(sessionID)
|
|
if err != nil {
|
|
if errors.Is(err, runtime.ErrContextNotFound) {
|
|
c.RespondError(http.StatusNotFound, model.ErrorCodeSessionNotFound, "session not found")
|
|
return
|
|
}
|
|
c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error())
|
|
return
|
|
}
|
|
|
|
c.RespondSuccess(model.SessionState{
|
|
Status: state.Status,
|
|
CreatedAt: state.CreatedAt,
|
|
LastRunAt: state.LastRunAt,
|
|
IdleRemainingSeconds: state.IdleRemainingSeconds,
|
|
})
|
|
}
|
|
|
|
// List handles GET /v1/isolated/sessions.
|
|
func (c *IsolatedSessionController) List() {
|
|
if !c.probed() {
|
|
c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable")
|
|
return
|
|
}
|
|
|
|
sessions := isolatedRunner.ListIsolatedSessions()
|
|
items := make([]model.IsolatedSessionSummary, 0, len(sessions))
|
|
for _, s := range sessions {
|
|
items = append(items, model.IsolatedSessionSummary{
|
|
SessionID: s.SessionID,
|
|
Status: s.Status,
|
|
CreatedAt: s.CreatedAt,
|
|
LastRunAt: s.LastRunAt,
|
|
IdleRemainingSeconds: s.IdleRemainingSeconds,
|
|
})
|
|
}
|
|
|
|
c.RespondSuccess(model.ListIsolatedSessionsResponse{Sessions: items})
|
|
}
|
|
|
|
// Run handles POST /v1/isolated/session/:sessionId/run (SSE streaming).
|
|
func (c *IsolatedSessionController) Run() {
|
|
if !c.probed() {
|
|
c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable")
|
|
return
|
|
}
|
|
|
|
sessionID := c.ctx.Param("sessionId")
|
|
|
|
var req model.IsolatedRunRequest
|
|
if err := c.bindJSON(&req); err != nil {
|
|
c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
if err := req.Validate(); err != nil {
|
|
c.RespondError(http.StatusBadRequest, model.ErrorCodeInvalidRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
var ctx context.Context
|
|
var cancel context.CancelFunc
|
|
if req.TimeoutSeconds > 0 {
|
|
ctx, cancel = context.WithTimeout(c.ctx.Request.Context(), time.Duration(req.TimeoutSeconds)*time.Second)
|
|
} else {
|
|
ctx, cancel = context.WithCancel(c.ctx.Request.Context())
|
|
}
|
|
defer cancel()
|
|
|
|
// SSE stdout callback.
|
|
onStdout := func(line string) {
|
|
if line == "" {
|
|
return
|
|
}
|
|
event := model.ServerStreamEvent{
|
|
Type: model.StreamEventTypeStdout,
|
|
Text: line,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}
|
|
c.writeSingleEvent("IsolatedStdout", event.ToJSON(), false, event.Summary())
|
|
}
|
|
|
|
startTime := time.Now()
|
|
err := isolatedRunner.RunInIsolatedSession(ctx, sessionID, req.Code, req.Envs, onStdout)
|
|
durationMs := float64(time.Since(startTime)) / float64(time.Millisecond)
|
|
|
|
if err != nil {
|
|
if errors.Is(err, runtime.ErrContextNotFound) {
|
|
c.RespondError(http.StatusNotFound, model.ErrorCodeSessionNotFound, "session not found")
|
|
return
|
|
}
|
|
telemetry.RecordIsolatedRun(ctx, "error", durationMs)
|
|
ename := "RuntimeError"
|
|
evalue := err.Error()
|
|
if strings.HasPrefix(evalue, "command exited with code ") {
|
|
ename = "ExitError"
|
|
evalue = strings.TrimPrefix(evalue, "command exited with code ")
|
|
}
|
|
event := model.ServerStreamEvent{
|
|
Type: model.StreamEventTypeError,
|
|
Text: err.Error(),
|
|
Timestamp: time.Now().UnixMilli(),
|
|
Error: &execute.ErrorOutput{
|
|
EName: ename,
|
|
EValue: evalue,
|
|
},
|
|
}
|
|
c.writeSingleEvent("IsolatedError", event.ToJSON(), true, event.Summary())
|
|
return
|
|
}
|
|
telemetry.RecordIsolatedRun(ctx, "success", durationMs)
|
|
event := model.ServerStreamEvent{
|
|
Type: model.StreamEventTypeComplete,
|
|
Timestamp: time.Now().UnixMilli(),
|
|
}
|
|
c.writeSingleEvent("IsolatedComplete", event.ToJSON(), true, event.Summary())
|
|
}
|
|
|
|
// Delete handles DELETE /v1/isolated/session/:sessionId.
|
|
func (c *IsolatedSessionController) Delete() {
|
|
if !c.probed() {
|
|
c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeServiceUnavailable, "isolation unavailable")
|
|
return
|
|
}
|
|
|
|
sessionID := c.ctx.Param("sessionId")
|
|
if err := isolatedRunner.DeleteIsolatedSession(sessionID); err != nil {
|
|
if errors.Is(err, runtime.ErrContextNotFound) {
|
|
c.RespondError(http.StatusNotFound, model.ErrorCodeSessionNotFound, "session not found")
|
|
return
|
|
}
|
|
c.RespondError(http.StatusInternalServerError, model.ErrorCodeRuntimeError, err.Error())
|
|
return
|
|
}
|
|
|
|
c.RespondSuccess(nil)
|
|
}
|
|
|
|
// Diff handles GET /v1/isolated/session/:sessionId/diff.
|
|
func (c *IsolatedSessionController) Diff() {
|
|
c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeNotSupported, "diff not implemented yet (phase 2)")
|
|
}
|
|
|
|
// Commit handles POST /v1/isolated/session/:sessionId/commit.
|
|
func (c *IsolatedSessionController) Commit() {
|
|
c.RespondError(http.StatusServiceUnavailable, model.ErrorCodeNotSupported, "commit not implemented yet (phase 2)")
|
|
}
|
|
|
|
// Capabilities handles GET /v1/isolated/capabilities.
|
|
func (c *IsolatedSessionController) Capabilities() {
|
|
if isolatedRunner == nil {
|
|
resp := model.CapabilitiesResponse{
|
|
Available: false,
|
|
CommitSupported: false,
|
|
DiffSupported: false,
|
|
}
|
|
if isolatedProbeResult != nil {
|
|
resp.Message = isolatedProbeResult.Message
|
|
}
|
|
c.RespondSuccess(resp)
|
|
return
|
|
}
|
|
caps := isolatedRunner.Capabilities()
|
|
resp := model.CapabilitiesResponse{
|
|
Available: caps.Available,
|
|
Isolator: caps.Isolator,
|
|
Version: caps.Version,
|
|
CommitSupported: caps.CommitSupported,
|
|
DiffSupported: caps.DiffSupported,
|
|
}
|
|
// Probe results indicate overlay capability, not diff/commit implementation.
|
|
// Diff and commit are Phase 2; do not advertise them as supported.
|
|
resp.CommitSupported = false
|
|
resp.DiffSupported = false
|
|
c.RespondSuccess(resp)
|
|
}
|
|
|
|
// Filesystem proxy handlers are in isolated_session_files.go.
|