Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions .github/workflows/execd-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
paths:
- 'components/execd/**'

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
Expand All @@ -29,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:
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/real-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ on:
push:
branches: [ main ]

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
python-e2e:
name: Python E2E (docker bridge)
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/server-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
paths:
- 'server/**'

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/verify-license.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ on:
pull_request:
branches: [ main ]

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
verify-license:
runs-on: ubuntu-latest
Expand Down
112 changes: 102 additions & 10 deletions components/execd/pkg/runtime/context.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -63,6 +64,45 @@ func (c *Controller) CreateContext(req *CreateContextRequest) (string, error) {
return session.ID, nil
}

func (c *Controller) DeleteContext(session string) error {
kernel := c.getJupyterKernel(session)
if kernel == nil {
return ErrContextNotFound
}

c.mu.Lock()
defer c.mu.Unlock()

return c.jupyterClient().DeleteSession(session)
}

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
}

client := c.jupyterClient()
for _, context := range contexts {
err := client.DeleteSession(context.ID)
if err != nil {
return fmt.Errorf("error deleting context %s: %w", context.ID, err)
}
}
return nil
}

func (c *Controller) newContextID() string {
return strings.ReplaceAll(uuid.New().String(), "-", "")
}
Expand Down Expand Up @@ -112,16 +152,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 {
Expand Down Expand Up @@ -165,3 +196,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()

var contexts []CodeContext
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()

var contexts []CodeContext
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
}
110 changes: 110 additions & 0 deletions components/execd/pkg/runtime/context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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"
"os"
"path/filepath"
"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)
}
}
1 change: 1 addition & 0 deletions components/execd/pkg/runtime/ctrl.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ type jupyterKernel struct {
mu sync.Mutex
kernelID string
client *jupyter.Client
language Language
}

type commandKernel struct {
Expand Down
19 changes: 19 additions & 0 deletions components/execd/pkg/runtime/errors.go
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading