diff --git a/.github/workflows/execd-test.yml b/.github/workflows/execd-test.yml index 0e62657..e300442 100644 --- a/.github/workflows/execd-test.yml +++ b/.github/workflows/execd-test.yml @@ -33,10 +33,28 @@ jobs: # make multi-build - - name: Run tests + - name: Run tests with coverage run: | cd components/execd - make test + go test -v -coverpkg=./... -coverprofile=coverage.out -covermode=atomic ./pkg/... + + - name: Calculate coverage and generate summary + id: coverage + run: | + cd components/execd + # Extract total coverage percentage + TOTAL_COVERAGE=$(go tool cover -func=coverage.out | grep total | awk '{print $3}') + echo "total_coverage=$TOTAL_COVERAGE" >> $GITHUB_OUTPUT + + # Generate GitHub Actions job summary + echo "## 📊 execd Test Coverage Report" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "**Total Line Coverage:** $TOTAL_COVERAGE" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Coverage report generated for commit \`${{ github.sha }}\`" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "*Coverage targets: Core packages >80%, API layer >70%*" >> $GITHUB_STEP_SUMMARY smoke: strategy: diff --git a/components/execd/pkg/runtime/context.go b/components/execd/pkg/runtime/context.go index 5b3b528..155b9ca 100644 --- a/components/execd/pkg/runtime/context.go +++ b/components/execd/pkg/runtime/context.go @@ -52,6 +52,7 @@ func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) { kernel := &jupyterKernel{ kernelID: session.Kernel.ID, client: client, + language: req.Language, } c.storeJupyterKernel(session.ID, kernel) @@ -63,6 +64,70 @@ func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) { return session.ID, nil } +func (c *Controller) DeleteContext(session string) error { + return c.deleteSessionAndCleanup(session) +} + +func (c *Controller) GetContext(session string) CodeContext { + kernel := c.getJupyterKernel(session) + return CodeContext{ + ID: session, + Language: kernel.language, + } +} + +func (c *Controller) ListContext(language string) ([]CodeContext, error) { + switch language { + case Command.String(), BackgroundCommand.String(), SQL.String(): + return nil, fmt.Errorf("unsupported language context operation: %s", language) + case "": + return c.listAllContexts() + default: + return c.listLanguageContexts(Language(language)) + } +} + +func (c *Controller) DeleteLanguageContext(language Language) error { + contexts, err := c.listLanguageContexts(language) + if err != nil { + return err + } + + seen := make(map[string]struct{}) + for _, context := range contexts { + if _, ok := seen[context.ID]; ok { + continue + } + seen[context.ID] = struct{}{} + + if err := c.deleteSessionAndCleanup(context.ID); err != nil { + return fmt.Errorf("error deleting context %s: %w", context.ID, err) + } + } + return nil +} + +func (c *Controller) deleteSessionAndCleanup(session string) error { + if c.getJupyterKernel(session) == nil { + return ErrContextNotFound + } + + if err := c.jupyterClient().DeleteSession(session); err != nil { + return err + } + + c.mu.Lock() + defer c.mu.Unlock() + + delete(c.jupyterClientMap, session) + for lang, id := range c.defaultLanguageJupyterSessions { + if id == session { + delete(c.defaultLanguageJupyterSessions, lang) + } + } + return nil +} + func (c *Controller) newContextID() string { return strings.ReplaceAll(uuid.New().String(), "-", "") } @@ -112,16 +177,7 @@ func (c *Controller) createDefaultLanguageContext(language Language) error { // createContext performs the actual context creation workflow. func (c *Controller) createContext(request CreateContextRequest) (*jupyter.Client, *jupytersession.Session, error) { - httpClient := &http.Client{ - Transport: &jupyter.AuthTransport{ - Token: c.token, - Base: http.DefaultTransport, - }, - } - - client := jupyter.NewClient(c.baseURL, - jupyter.WithToken(c.token), - jupyter.WithHTTPClient(httpClient)) + client := c.jupyterClient() kernel, err := c.searchKernel(client, request.Language) if err != nil { @@ -165,3 +221,64 @@ func (c *Controller) storeJupyterKernel(sessionID string, kernel *jupyterKernel) c.jupyterClientMap[sessionID] = kernel } + +func (c *Controller) jupyterClient() *jupyter.Client { + httpClient := &http.Client{ + Transport: &jupyter.AuthTransport{ + Token: c.token, + Base: http.DefaultTransport, + }, + } + + return jupyter.NewClient(c.baseURL, + jupyter.WithToken(c.token), + jupyter.WithHTTPClient(httpClient)) +} + +func (c *Controller) listAllContexts() ([]CodeContext, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + contexts := make([]CodeContext, 0) + for session, kernel := range c.jupyterClientMap { + if kernel != nil { + contexts = append(contexts, CodeContext{ + ID: session, + Language: kernel.language, + }) + } + } + + for language, defaultContext := range c.defaultLanguageJupyterSessions { + contexts = append(contexts, CodeContext{ + ID: defaultContext, + Language: language, + }) + } + + return contexts, nil +} + +func (c *Controller) listLanguageContexts(language Language) ([]CodeContext, error) { + c.mu.RLock() + defer c.mu.RUnlock() + + contexts := make([]CodeContext, 0) + for session, kernel := range c.jupyterClientMap { + if kernel != nil && kernel.language == language { + contexts = append(contexts, CodeContext{ + ID: session, + Language: language, + }) + } + } + + if defaultContext := c.defaultLanguageJupyterSessions[language]; defaultContext != "" { + contexts = append(contexts, CodeContext{ + ID: defaultContext, + Language: language, + }) + } + + return contexts, nil +} diff --git a/components/execd/pkg/runtime/context_test.go b/components/execd/pkg/runtime/context_test.go new file mode 100644 index 0000000..6a27ad1 --- /dev/null +++ b/components/execd/pkg/runtime/context_test.go @@ -0,0 +1,189 @@ +// Copyright 2025 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 runtime + +import ( + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestListContextsAndNewIpynbPath(t *testing.T) { + c := NewController("http://example", "token") + c.jupyterClientMap["session-python"] = &jupyterKernel{language: Python} + c.defaultLanguageJupyterSessions[Go] = "session-go-default" + + pyContexts, err := c.listLanguageContexts(Python) + if err != nil { + t.Fatalf("listLanguageContexts returned error: %v", err) + } + if len(pyContexts) != 1 || pyContexts[0].ID != "session-python" || pyContexts[0].Language != Python { + t.Fatalf("unexpected python contexts: %#v", pyContexts) + } + + allContexts, err := c.listAllContexts() + if err != nil { + t.Fatalf("listAllContexts returned error: %v", err) + } + if len(allContexts) != 2 { + t.Fatalf("expected two contexts, got %d", len(allContexts)) + } + + tmpDir := filepath.Join(t.TempDir(), "nested") + path, err := c.newIpynbPath("abc123", tmpDir) + if err != nil { + t.Fatalf("newIpynbPath error: %v", err) + } + if _, statErr := os.Stat(tmpDir); statErr != nil { + t.Fatalf("expected directory to be created: %v", statErr) + } + expected := filepath.Join(tmpDir, "abc123.ipynb") + if path != expected { + t.Fatalf("unexpected ipynb path: got %s want %s", path, expected) + } +} + +func TestNewContextID_UniqueAndLength(t *testing.T) { + c := NewController("", "") + id1 := c.newContextID() + id2 := c.newContextID() + + if id1 == "" || id2 == "" { + t.Fatalf("expected non-empty ids") + } + if id1 == id2 { + t.Fatalf("expected unique ids, got identical: %s", id1) + } + if len(id1) != 32 || len(id2) != 32 { + t.Fatalf("expected 32-char ids, got %d and %d", len(id1), len(id2)) + } +} + +func TestNewIpynbPath_ErrorWhenCwdIsFile(t *testing.T) { + c := NewController("", "") + tmpFile := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(tmpFile, []byte("x"), 0o644); err != nil { + t.Fatalf("prepare file: %v", err) + } + + if _, err := c.newIpynbPath("abc", tmpFile); err == nil { + t.Fatalf("expected error when cwd is a file") + } +} + +func TestListContextUnsupportedLanguage(t *testing.T) { + c := NewController("", "") + _, err := c.ListContext(Command.String()) + if err == nil { + t.Fatalf("expected error for command language") + } + if _, err := c.ListContext(BackgroundCommand.String()); err == nil { + t.Fatalf("expected error for background-command language") + } + if _, err := c.ListContext(SQL.String()); err == nil { + t.Fatalf("expected error for sql language") + } +} + +func TestDeleteContext_NotFound(t *testing.T) { + c := NewController("", "") + err := c.DeleteContext("missing") + if err == nil { + t.Fatalf("expected ErrContextNotFound") + } + if !errors.Is(err, ErrContextNotFound) { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestDeleteContext_RemovesCacheOnSuccess(t *testing.T) { + sessionID := "sess-123" + + // mock jupyter server that accepts DELETE + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Fatalf("unexpected method: %s", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/api/sessions/"+sessionID) { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := NewController(server.URL, "token") + c.jupyterClientMap[sessionID] = &jupyterKernel{language: Python} + c.defaultLanguageJupyterSessions[Python] = sessionID + + if err := c.DeleteContext(sessionID); err != nil { + t.Fatalf("DeleteContext returned error: %v", err) + } + + if kernel := c.getJupyterKernel(sessionID); kernel != nil { + t.Fatalf("expected cache to be cleared, found: %+v", kernel) + } + if _, ok := c.defaultLanguageJupyterSessions[Python]; ok { + t.Fatalf("expected default session entry to be removed") + } +} + +func TestDeleteLanguageContext_RemovesCacheOnSuccess(t *testing.T) { + lang := Python + session1 := "sess-1" + session2 := "sess-2" + + // mock jupyter server to accept two deletes + deleteCalls := make(map[string]int) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Fatalf("unexpected method: %s", r.Method) + } + if strings.Contains(r.URL.Path, session1) { + deleteCalls[session1]++ + } else if strings.Contains(r.URL.Path, session2) { + deleteCalls[session2]++ + } else { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + c := NewController(server.URL, "token") + c.jupyterClientMap[session1] = &jupyterKernel{language: lang} + c.jupyterClientMap[session2] = &jupyterKernel{language: lang} + c.defaultLanguageJupyterSessions[lang] = session2 + + if err := c.DeleteLanguageContext(lang); err != nil { + t.Fatalf("DeleteLanguageContext returned error: %v", err) + } + + if _, ok := c.jupyterClientMap[session1]; ok { + t.Fatalf("expected session1 removed from cache") + } + if _, ok := c.jupyterClientMap[session2]; ok { + t.Fatalf("expected session2 removed from cache") + } + if _, ok := c.defaultLanguageJupyterSessions[lang]; ok { + t.Fatalf("expected default entry removed") + } + if deleteCalls[session1] != 1 || deleteCalls[session2] != 1 { + t.Fatalf("unexpected delete calls: %+v", deleteCalls) + } +} diff --git a/components/execd/pkg/runtime/ctrl.go b/components/execd/pkg/runtime/ctrl.go index 665e2dc..20bbecc 100644 --- a/components/execd/pkg/runtime/ctrl.go +++ b/components/execd/pkg/runtime/ctrl.go @@ -49,6 +49,7 @@ type jupyterKernel struct { mu sync.Mutex kernelID string client *jupyter.Client + language Language } type commandKernel struct { diff --git a/components/execd/pkg/runtime/errors.go b/components/execd/pkg/runtime/errors.go new file mode 100644 index 0000000..2517167 --- /dev/null +++ b/components/execd/pkg/runtime/errors.go @@ -0,0 +1,19 @@ +// Copyright 2025 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 runtime + +import "errors" + +var ErrContextNotFound = errors.New("context not found") diff --git a/components/execd/pkg/runtime/helpers_test.go b/components/execd/pkg/runtime/helpers_test.go new file mode 100644 index 0000000..1f50a4a --- /dev/null +++ b/components/execd/pkg/runtime/helpers_test.go @@ -0,0 +1,116 @@ +// Copyright 2025 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 runtime + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "io" + "sync/atomic" + "testing" + "time" +) + +type stubDriver struct { + columns []string + rows [][]driver.Value + execRowsAffected int64 + queryErr error + execErr error + pingErr error + execCalled int32 + queryCalled int32 +} + +type stubConn struct { + d *stubDriver +} + +func (c *stubConn) Prepare(string) (driver.Stmt, error) { return nil, errors.New("not implemented") } +func (c *stubConn) Close() error { return nil } +func (c *stubConn) Begin() (driver.Tx, error) { return nil, errors.New("not implemented") } + +func (c *stubConn) Ping(context.Context) error { + return c.d.pingErr +} + +func (c *stubConn) ExecContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Result, error) { + atomic.AddInt32(&c.d.execCalled, 1) + if c.d.execErr != nil { + return nil, c.d.execErr + } + return driver.RowsAffected(c.d.execRowsAffected), nil +} + +func (c *stubConn) QueryContext(_ context.Context, _ string, _ []driver.NamedValue) (driver.Rows, error) { + atomic.AddInt32(&c.d.queryCalled, 1) + if c.d.queryErr != nil { + return nil, c.d.queryErr + } + return &stubRows{ + columns: c.d.columns, + rows: c.d.rows, + }, nil +} + +type stubRows struct { + columns []string + rows [][]driver.Value + idx int +} + +func (r *stubRows) Columns() []string { return r.columns } +func (r *stubRows) Close() error { return nil } +func (r *stubRows) Next(dest []driver.Value) error { + if r.idx >= len(r.rows) { + return io.EOF + } + row := r.rows[r.idx] + r.idx++ + for i, v := range row { + dest[i] = v + } + return nil +} + +type stubConnector struct { + d *stubDriver +} + +func (c *stubConnector) Connect(context.Context) (driver.Conn, error) { + return &stubConn{d: c.d}, nil +} + +func (c *stubConnector) Driver() driver.Driver { + return c +} + +func (c *stubConnector) Open(string) (driver.Conn, error) { + return &stubConn{d: c.d}, nil +} + +func newStubDB(t *testing.T, d *stubDriver) *sql.DB { + t.Helper() + driverName := fmt.Sprintf("stub-%d", time.Now().UnixNano()) + sql.Register(driverName, &stubConnector{d: d}) + db, err := sql.Open(driverName, "") + if err != nil { + t.Fatalf("open stub db: %v", err) + } + return db +} diff --git a/components/execd/pkg/runtime/jupyter.go b/components/execd/pkg/runtime/jupyter.go index d992e3e..c80c04a 100644 --- a/components/execd/pkg/runtime/jupyter.go +++ b/components/execd/pkg/runtime/jupyter.go @@ -47,7 +47,7 @@ func (c *Controller) runJupyter(ctx context.Context, request *ExecuteCodeRequest kernel := c.getJupyterKernel(targetSessionID) if kernel == nil { - return errors.New("session not found") + return ErrContextNotFound } request.SetDefaultHooks() diff --git a/components/execd/pkg/runtime/sql_test.go b/components/execd/pkg/runtime/sql_test.go new file mode 100644 index 0000000..a8eca89 --- /dev/null +++ b/components/execd/pkg/runtime/sql_test.go @@ -0,0 +1,145 @@ +// Copyright 2025 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 runtime + +import ( + "context" + "database/sql/driver" + "encoding/json" + "testing" + "time" + + "github.com/alibaba/opensandbox/execd/pkg/jupyter/execute" +) + +func TestExecuteSelectSQLQuery_Success(t *testing.T) { + driver := &stubDriver{ + columns: []string{"id", "name"}, + rows: [][]driver.Value{ + {int64(1), "alice"}, + {int64(2), "bob"}, + }, + } + db := newStubDB(t, driver) + + c := NewController("", "") + c.db = db + + var ( + gotResult map[string]any + gotError *execute.ErrorOutput + completed bool + ) + + req := &ExecuteCodeRequest{ + Code: "SELECT * FROM users", + Hooks: ExecuteResultHook{ + OnExecuteResult: func(result map[string]any, _ int) { + gotResult = result + }, + OnExecuteError: func(err *execute.ErrorOutput) { + gotError = err + }, + OnExecuteComplete: func(time.Duration) { + completed = true + }, + }, + } + + if err := c.executeSelectSQLQuery(context.Background(), req); err != nil { + t.Fatalf("executeSelectSQLQuery returned error: %v", err) + } + + if gotError != nil { + t.Fatalf("unexpected error hook: %+v", gotError) + } + if !completed { + t.Fatalf("expected completion hook to be triggered") + } + + raw, ok := gotResult["text/plain"] + if !ok { + t.Fatalf("expected text/plain payload") + } + var qr QueryResult + if err := json.Unmarshal([]byte(raw.(string)), &qr); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + if len(qr.Columns) != 2 || qr.Columns[0] != "id" || qr.Columns[1] != "name" { + t.Fatalf("unexpected columns: %#v", qr.Columns) + } + if len(qr.Rows) != 2 || qr.Rows[0][0] != "1" || qr.Rows[1][1] != "bob" { + t.Fatalf("unexpected rows: %#v", qr.Rows) + } +} + +func TestExecuteUpdateSQLQuery_Success(t *testing.T) { + driver := &stubDriver{ + execRowsAffected: 3, + } + db := newStubDB(t, driver) + + c := NewController("", "") + c.db = db + + var ( + gotResult map[string]any + gotError *execute.ErrorOutput + completed bool + ) + + req := &ExecuteCodeRequest{ + Code: "UPDATE users SET name='alice' WHERE id=1", + Hooks: ExecuteResultHook{ + OnExecuteResult: func(result map[string]any, _ int) { + gotResult = result + }, + OnExecuteError: func(err *execute.ErrorOutput) { + gotError = err + }, + OnExecuteComplete: func(time.Duration) { + completed = true + }, + }, + } + + if err := c.executeUpdateSQLQuery(context.Background(), req); err != nil { + t.Fatalf("executeUpdateSQLQuery returned error: %v", err) + } + + if gotError != nil { + t.Fatalf("unexpected error hook: %+v", gotError) + } + if !completed { + t.Fatalf("expected completion hook to be triggered") + } + + raw, ok := gotResult["text/plain"] + if !ok { + t.Fatalf("expected text/plain payload") + } + var qr QueryResult + if err := json.Unmarshal([]byte(raw.(string)), &qr); err != nil { + t.Fatalf("unmarshal result: %v", err) + } + + if len(qr.Columns) != 1 || qr.Columns[0] != "affected_rows" { + t.Fatalf("unexpected columns: %#v", qr.Columns) + } + if len(qr.Rows) != 1 || len(qr.Rows[0]) != 1 || qr.Rows[0][0] != float64(3) { + t.Fatalf("unexpected affected rows: %#v", qr.Rows) + } +} diff --git a/components/execd/pkg/runtime/types.go b/components/execd/pkg/runtime/types.go index 7234492..cb82a11 100644 --- a/components/execd/pkg/runtime/types.go +++ b/components/execd/pkg/runtime/types.go @@ -75,3 +75,8 @@ type CreateContextRequest struct { Language Language `json:"language"` Cwd string `json:"cwd"` } + +type CodeContext struct { + ID string `json:"id,omitempty"` + Language Language `json:"language"` +} diff --git a/components/execd/pkg/runtime/types_test.go b/components/execd/pkg/runtime/types_test.go new file mode 100644 index 0000000..6f84bbf --- /dev/null +++ b/components/execd/pkg/runtime/types_test.go @@ -0,0 +1,42 @@ +// Copyright 2025 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 runtime + +import ( + "reflect" + "testing" +) + +func TestExecuteCodeRequest_SetDefaultHooks(t *testing.T) { + customResult := func(map[string]any, int) {} + + req := &ExecuteCodeRequest{ + Hooks: ExecuteResultHook{ + OnExecuteResult: customResult, + }, + } + + req.SetDefaultHooks() + + if req.Hooks.OnExecuteStdout == nil || req.Hooks.OnExecuteStderr == nil || req.Hooks.OnExecuteError == nil { + t.Fatalf("expected default hooks to be populated") + } + if req.Hooks.OnExecuteResult == nil { + t.Fatalf("expected OnExecuteResult to remain set") + } + if reflect.ValueOf(req.Hooks.OnExecuteResult).Pointer() != reflect.ValueOf(customResult).Pointer() { + t.Fatalf("default hooks should not override existing ones") + } +} diff --git a/components/execd/pkg/web/controller/codeinterpreting.go b/components/execd/pkg/web/controller/codeinterpreting.go index cfb3211..5eb581e 100644 --- a/components/execd/pkg/web/controller/codeinterpreting.go +++ b/components/execd/pkg/web/controller/codeinterpreting.go @@ -17,6 +17,7 @@ package controller import ( "context" "encoding/json" + "errors" "fmt" "net/http" "sync" @@ -120,6 +121,97 @@ func (c *CodeInterpretingController) RunCode() { time.Sleep(flag.ApiGracefulShutdownTimeout) } +// GetContext returns a specific code context by id. +func (c *CodeInterpretingController) GetContext() { + contextID := c.Ctx.Input.Param(":contextId") + if contextID == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'contextId'", + ) + } + + codeContext := codeRunner.GetContext(contextID) + c.RespondSuccess(codeContext) +} + +// ListContexts returns active code contexts, optionally filtered by language. +func (c *CodeInterpretingController) ListContexts() { + language := c.GetString("language") + + contexts, err := codeRunner.ListContext(language) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + err.Error(), + ) + return + } + + c.RespondSuccess(contexts) +} + +// DeleteContextsByLanguage deletes all contexts for a given language. +func (c *CodeInterpretingController) DeleteContextsByLanguage() { + language := c.GetString("language") + if language == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing query parameter 'language'", + ) + return + } + + err := codeRunner.DeleteLanguageContext(runtime.Language(language)) + if err != nil { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error deleting code context %s. %v", language, err), + ) + return + } + + c.RespondSuccess(nil) +} + +// DeleteContext deletes a specific code context by id. +func (c *CodeInterpretingController) DeleteContext() { + contextID := c.Ctx.Input.Param(":contextId") + if contextID == "" { + c.RespondError( + http.StatusBadRequest, + model.ErrorCodeMissingQuery, + "missing path parameter 'contextId'", + ) + return + } + + err := codeRunner.DeleteContext(contextID) + if err != nil { + if errors.Is(err, runtime.ErrContextNotFound) { + c.RespondError( + http.StatusNotFound, + model.ErrorCodeContextNotFound, + fmt.Sprintf("context %s not found", contextID), + ) + return + } else { + c.RespondError( + http.StatusInternalServerError, + model.ErrorCodeRuntimeError, + fmt.Sprintf("error deleting code context %s. %v", contextID, err), + ) + return + } + } + + c.RespondSuccess(nil) +} + // buildExecuteCodeRequest converts a RunCodeRequest to runtime format. func (c *CodeInterpretingController) buildExecuteCodeRequest(request model.RunCodeRequest) *runtime.ExecuteCodeRequest { req := &runtime.ExecuteCodeRequest{ diff --git a/components/execd/pkg/web/model/error.go b/components/execd/pkg/web/model/error.go index a217a20..80e0ef2 100644 --- a/components/execd/pkg/web/model/error.go +++ b/components/execd/pkg/web/model/error.go @@ -25,6 +25,7 @@ const ( ErrorCodeInvalidFileMetadata ErrorCode = "INVALID_FILE_METADATA" ErrorCodeFileNotFound ErrorCode = "FILE_NOT_FOUND" ErrorCodeUnknown ErrorCode = "UNKNOWN" + ErrorCodeContextNotFound ErrorCode = "CONTEXT_NOT_FOUND" ) type ErrorResponse struct { diff --git a/components/execd/pkg/web/router.go b/components/execd/pkg/web/router.go index 2c88154..b81386d 100644 --- a/components/execd/pkg/web/router.go +++ b/components/execd/pkg/web/router.go @@ -73,6 +73,9 @@ func init() { codeInterpreting := web.NewNamespace("/code", web.NSRouter("", &controller.CodeInterpretingController{}, "post:RunCode;delete:InterruptCode"), web.NSRouter("/context", &controller.CodeInterpretingController{}, "post:CreateContext"), + web.NSRouter("/contexts", &controller.CodeInterpretingController{}, "get:ListContexts;delete:DeleteContextsByLanguage"), + web.NSRouter("/contexts/:contextId", &controller.CodeInterpretingController{}, "delete:DeleteContext"), + web.NSRouter("/contexts/:contextId", &controller.CodeInterpretingController{}, "get:GetContext"), ) command := web.NewNamespace("/command",