From b40f762f3dea6299ad7ade3f92cf98f2bb0249ab Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Wed, 11 Mar 2026 14:46:44 +0100 Subject: [PATCH 1/2] feat: add runtime interface and fake implementation Signed-off-by: Philippe Martin Co-Authored-By: Claude Code (Claude Sonnet 4.5) --- pkg/runtime/errors.go | 32 +++ pkg/runtime/fake/fake.go | 195 ++++++++++++++++++ pkg/runtime/fake/fake_test.go | 377 ++++++++++++++++++++++++++++++++++ pkg/runtime/registry.go | 98 +++++++++ pkg/runtime/registry_test.go | 236 +++++++++++++++++++++ pkg/runtime/runtime.go | 70 +++++++ 6 files changed, 1008 insertions(+) create mode 100644 pkg/runtime/errors.go create mode 100644 pkg/runtime/fake/fake.go create mode 100644 pkg/runtime/fake/fake_test.go create mode 100644 pkg/runtime/registry.go create mode 100644 pkg/runtime/registry_test.go create mode 100644 pkg/runtime/runtime.go diff --git a/pkg/runtime/errors.go b/pkg/runtime/errors.go new file mode 100644 index 0000000..39f1a97 --- /dev/null +++ b/pkg/runtime/errors.go @@ -0,0 +1,32 @@ +// Copyright 2026 Red Hat, Inc. +// +// 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 ( + // ErrRuntimeNotFound is returned when a requested runtime type is not registered. + ErrRuntimeNotFound = errors.New("runtime not found") + + // ErrInstanceNotFound is returned when a requested runtime instance does not exist. + ErrInstanceNotFound = errors.New("runtime instance not found") + + // ErrRuntimeUnavailable is returned when a runtime exists but cannot be used + // (e.g., podman binary not found, docker daemon not running). + ErrRuntimeUnavailable = errors.New("runtime unavailable") + + // ErrInvalidParams is returned when create parameters are invalid or incomplete. + ErrInvalidParams = errors.New("invalid runtime parameters") +) diff --git a/pkg/runtime/fake/fake.go b/pkg/runtime/fake/fake.go new file mode 100644 index 0000000..3bd9bf5 --- /dev/null +++ b/pkg/runtime/fake/fake.go @@ -0,0 +1,195 @@ +// Copyright 2026 Red Hat, Inc. +// +// 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 fake provides a fake runtime implementation for testing. +package fake + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/kortex-hub/kortex-cli/pkg/runtime" +) + +// fakeRuntime is an in-memory fake runtime for testing. +type fakeRuntime struct { + mu sync.RWMutex + instances map[string]*instanceState + nextID int +} + +// instanceState tracks the state of a fake runtime instance. +type instanceState struct { + id string + name string + state string + info map[string]string + source string + config string +} + +// Ensure fakeRuntime implements runtime.Runtime at compile time. +var _ runtime.Runtime = (*fakeRuntime)(nil) + +// New creates a new fake runtime instance. +func New() runtime.Runtime { + return &fakeRuntime{ + instances: make(map[string]*instanceState), + nextID: 1, + } +} + +// Type returns the runtime type identifier. +func (f *fakeRuntime) Type() string { + return "fake" +} + +// Create creates a new fake runtime instance. +func (f *fakeRuntime) Create(ctx context.Context, params runtime.CreateParams) (runtime.RuntimeInfo, error) { + if params.Name == "" { + return runtime.RuntimeInfo{}, fmt.Errorf("%w: name is required", runtime.ErrInvalidParams) + } + if params.SourcePath == "" { + return runtime.RuntimeInfo{}, fmt.Errorf("%w: source path is required", runtime.ErrInvalidParams) + } + if params.ConfigPath == "" { + return runtime.RuntimeInfo{}, fmt.Errorf("%w: config path is required", runtime.ErrInvalidParams) + } + + f.mu.Lock() + defer f.mu.Unlock() + + // Generate sequential ID + id := fmt.Sprintf("fake-%03d", f.nextID) + f.nextID++ + + // Check if instance already exists with same name + for _, inst := range f.instances { + if inst.name == params.Name { + return runtime.RuntimeInfo{}, fmt.Errorf("instance with name %s already exists", params.Name) + } + } + + // Create instance state + state := &instanceState{ + id: id, + name: params.Name, + state: "created", + source: params.SourcePath, + config: params.ConfigPath, + info: map[string]string{ + "created_at": time.Now().Format(time.RFC3339), + "source": params.SourcePath, + "config": params.ConfigPath, + }, + } + + f.instances[id] = state + + return runtime.RuntimeInfo{ + ID: id, + State: state.state, + Info: copyMap(state.info), + }, nil +} + +// Start starts a fake runtime instance. +func (f *fakeRuntime) Start(ctx context.Context, id string) (runtime.RuntimeInfo, error) { + f.mu.Lock() + defer f.mu.Unlock() + + inst, exists := f.instances[id] + if !exists { + return runtime.RuntimeInfo{}, fmt.Errorf("%w: %s", runtime.ErrInstanceNotFound, id) + } + + if inst.state == "running" { + return runtime.RuntimeInfo{}, fmt.Errorf("instance %s is already running", id) + } + + inst.state = "running" + inst.info["started_at"] = time.Now().Format(time.RFC3339) + + return runtime.RuntimeInfo{ + ID: inst.id, + State: inst.state, + Info: copyMap(inst.info), + }, nil +} + +// Stop stops a fake runtime instance. +func (f *fakeRuntime) Stop(ctx context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + + inst, exists := f.instances[id] + if !exists { + return fmt.Errorf("%w: %s", runtime.ErrInstanceNotFound, id) + } + + if inst.state != "running" { + return fmt.Errorf("instance %s is not running", id) + } + + inst.state = "stopped" + inst.info["stopped_at"] = time.Now().Format(time.RFC3339) + + return nil +} + +// Remove removes a fake runtime instance. +func (f *fakeRuntime) Remove(ctx context.Context, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + + inst, exists := f.instances[id] + if !exists { + return fmt.Errorf("%w: %s", runtime.ErrInstanceNotFound, id) + } + + if inst.state == "running" { + return fmt.Errorf("instance %s is still running, stop it first", id) + } + + delete(f.instances, id) + return nil +} + +// Info retrieves information about a fake runtime instance. +func (f *fakeRuntime) Info(ctx context.Context, id string) (runtime.RuntimeInfo, error) { + f.mu.RLock() + defer f.mu.RUnlock() + + inst, exists := f.instances[id] + if !exists { + return runtime.RuntimeInfo{}, fmt.Errorf("%w: %s", runtime.ErrInstanceNotFound, id) + } + + return runtime.RuntimeInfo{ + ID: inst.id, + State: inst.state, + Info: copyMap(inst.info), + }, nil +} + +// copyMap creates a shallow copy of a string map. +func copyMap(m map[string]string) map[string]string { + result := make(map[string]string, len(m)) + for k, v := range m { + result[k] = v + } + return result +} diff --git a/pkg/runtime/fake/fake_test.go b/pkg/runtime/fake/fake_test.go new file mode 100644 index 0000000..9d43f3e --- /dev/null +++ b/pkg/runtime/fake/fake_test.go @@ -0,0 +1,377 @@ +// Copyright 2026 Red Hat, Inc. +// +// 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 fake + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "testing" + + "github.com/kortex-hub/kortex-cli/pkg/runtime" +) + +func TestFakeRuntime_Type(t *testing.T) { + t.Parallel() + + rt := New() + if rt.Type() != "fake" { + t.Errorf("Expected type 'fake', got '%s'", rt.Type()) + } +} + +func TestFakeRuntime_CreateStartStopRemove(t *testing.T) { + t.Parallel() + + rt := New() + ctx := context.Background() + + params := runtime.CreateParams{ + Name: "test-instance", + SourcePath: "/path/to/source", + ConfigPath: "/path/to/config", + } + + // Create instance + info, err := rt.Create(ctx, params) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + if info.ID == "" { + t.Error("Expected non-empty instance ID") + } + if info.State != "created" { + t.Errorf("Expected state 'created', got '%s'", info.State) + } + if !strings.HasPrefix(info.ID, "fake-") { + t.Errorf("Expected ID to start with 'fake-', got '%s'", info.ID) + } + + instanceID := info.ID + + // Start instance + info, err = rt.Start(ctx, instanceID) + if err != nil { + t.Fatalf("Start failed: %v", err) + } + + if info.State != "running" { + t.Errorf("Expected state 'running', got '%s'", info.State) + } + + // Stop instance + err = rt.Stop(ctx, instanceID) + if err != nil { + t.Fatalf("Stop failed: %v", err) + } + + // Verify stopped state + info, err = rt.Info(ctx, instanceID) + if err != nil { + t.Fatalf("Info failed: %v", err) + } + if info.State != "stopped" { + t.Errorf("Expected state 'stopped', got '%s'", info.State) + } + + // Remove instance + err = rt.Remove(ctx, instanceID) + if err != nil { + t.Fatalf("Remove failed: %v", err) + } + + // Verify instance is gone + _, err = rt.Info(ctx, instanceID) + if !errors.Is(err, runtime.ErrInstanceNotFound) { + t.Errorf("Expected ErrInstanceNotFound after remove, got %v", err) + } +} + +func TestFakeRuntime_InfoRetrievesCorrectState(t *testing.T) { + t.Parallel() + + rt := New() + ctx := context.Background() + + params := runtime.CreateParams{ + Name: "info-test", + SourcePath: "/source", + ConfigPath: "/config", + } + + info, err := rt.Create(ctx, params) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + instanceID := info.ID + + // Info should return created state + info, err = rt.Info(ctx, instanceID) + if err != nil { + t.Fatalf("Info failed: %v", err) + } + if info.State != "created" { + t.Errorf("Expected state 'created', got '%s'", info.State) + } + + // Start and verify running state + _, err = rt.Start(ctx, instanceID) + if err != nil { + t.Fatalf("Start failed: %v", err) + } + + info, err = rt.Info(ctx, instanceID) + if err != nil { + t.Fatalf("Info failed: %v", err) + } + if info.State != "running" { + t.Errorf("Expected state 'running', got '%s'", info.State) + } + + // Verify info contains expected metadata + if info.Info["source"] != "/source" { + t.Errorf("Expected source '/source', got '%s'", info.Info["source"]) + } + if info.Info["config"] != "/config" { + t.Errorf("Expected config '/config', got '%s'", info.Info["config"]) + } + if info.Info["created_at"] == "" { + t.Error("Expected created_at timestamp") + } + if info.Info["started_at"] == "" { + t.Error("Expected started_at timestamp") + } +} + +func TestFakeRuntime_DuplicateCreate(t *testing.T) { + t.Parallel() + + rt := New() + ctx := context.Background() + + params := runtime.CreateParams{ + Name: "duplicate-test", + SourcePath: "/source", + ConfigPath: "/config", + } + + // Create first instance + _, err := rt.Create(ctx, params) + if err != nil { + t.Fatalf("First create failed: %v", err) + } + + // Try to create duplicate + _, err = rt.Create(ctx, params) + if err == nil { + t.Fatal("Expected error when creating duplicate instance, got nil") + } + + if !strings.Contains(err.Error(), "already exists") { + t.Errorf("Expected 'already exists' error, got '%s'", err.Error()) + } +} + +func TestFakeRuntime_UnknownInstanceID(t *testing.T) { + t.Parallel() + + rt := New() + ctx := context.Background() + + // Try to start non-existent instance + _, err := rt.Start(ctx, "unknown-id") + if !errors.Is(err, runtime.ErrInstanceNotFound) { + t.Errorf("Expected ErrInstanceNotFound, got %v", err) + } + + // Try to stop non-existent instance + err = rt.Stop(ctx, "unknown-id") + if !errors.Is(err, runtime.ErrInstanceNotFound) { + t.Errorf("Expected ErrInstanceNotFound, got %v", err) + } + + // Try to remove non-existent instance + err = rt.Remove(ctx, "unknown-id") + if !errors.Is(err, runtime.ErrInstanceNotFound) { + t.Errorf("Expected ErrInstanceNotFound, got %v", err) + } + + // Try to get info for non-existent instance + _, err = rt.Info(ctx, "unknown-id") + if !errors.Is(err, runtime.ErrInstanceNotFound) { + t.Errorf("Expected ErrInstanceNotFound, got %v", err) + } +} + +func TestFakeRuntime_InvalidParams(t *testing.T) { + t.Parallel() + + rt := New() + ctx := context.Background() + + tests := []struct { + name string + params runtime.CreateParams + }{ + { + name: "missing name", + params: runtime.CreateParams{ + SourcePath: "/source", + ConfigPath: "/config", + }, + }, + { + name: "missing source path", + params: runtime.CreateParams{ + Name: "test", + ConfigPath: "/config", + }, + }, + { + name: "missing config path", + params: runtime.CreateParams{ + Name: "test", + SourcePath: "/source", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := rt.Create(ctx, tt.params) + if !errors.Is(err, runtime.ErrInvalidParams) { + t.Errorf("Expected ErrInvalidParams, got %v", err) + } + }) + } +} + +func TestFakeRuntime_StateTransitionErrors(t *testing.T) { + t.Parallel() + + rt := New() + ctx := context.Background() + + params := runtime.CreateParams{ + Name: "state-test", + SourcePath: "/source", + ConfigPath: "/config", + } + + info, err := rt.Create(ctx, params) + if err != nil { + t.Fatalf("Create failed: %v", err) + } + + instanceID := info.ID + + // Can't stop created instance + err = rt.Stop(ctx, instanceID) + if err == nil { + t.Error("Expected error when stopping created instance") + } + + // Can't remove running instance + _, err = rt.Start(ctx, instanceID) + if err != nil { + t.Fatalf("Start failed: %v", err) + } + + err = rt.Remove(ctx, instanceID) + if err == nil { + t.Error("Expected error when removing running instance") + } + + // Can't start already running instance + _, err = rt.Start(ctx, instanceID) + if err == nil { + t.Error("Expected error when starting already running instance") + } +} + +func TestFakeRuntime_SequentialIDs(t *testing.T) { + t.Parallel() + + rt := New() + ctx := context.Background() + + // Create multiple instances and verify sequential IDs + var ids []string + for i := 1; i <= 3; i++ { + params := runtime.CreateParams{ + Name: fmt.Sprintf("instance-%d", i), + SourcePath: "/source", + ConfigPath: "/config", + } + + info, err := rt.Create(ctx, params) + if err != nil { + t.Fatalf("Create %d failed: %v", i, err) + } + + ids = append(ids, info.ID) + } + + // Verify IDs are sequential + expectedIDs := []string{"fake-001", "fake-002", "fake-003"} + for i, id := range ids { + if id != expectedIDs[i] { + t.Errorf("Expected ID %s, got %s", expectedIDs[i], id) + } + } +} + +func TestFakeRuntime_ParallelOperations(t *testing.T) { + t.Parallel() + + rt := New() + ctx := context.Background() + + const numInstances = 10 + var wg sync.WaitGroup + wg.Add(numInstances) + + // Create multiple instances in parallel + for i := 0; i < numInstances; i++ { + i := i + go func() { + defer wg.Done() + + params := runtime.CreateParams{ + Name: fmt.Sprintf("parallel-%d", i), + SourcePath: "/source", + ConfigPath: "/config", + } + + info, err := rt.Create(ctx, params) + if err != nil { + t.Errorf("Create failed for instance %d: %v", i, err) + return + } + + // Start the instance + _, err = rt.Start(ctx, info.ID) + if err != nil { + t.Errorf("Start failed for instance %d: %v", i, err) + } + }() + } + + wg.Wait() +} diff --git a/pkg/runtime/registry.go b/pkg/runtime/registry.go new file mode 100644 index 0000000..9f52dc5 --- /dev/null +++ b/pkg/runtime/registry.go @@ -0,0 +1,98 @@ +// Copyright 2026 Red Hat, Inc. +// +// 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 ( + "fmt" + "sync" +) + +// Registry manages available runtime implementations. +type Registry interface { + // Register adds a runtime to the registry. + // Returns an error if a runtime with the same type is already registered. + Register(runtime Runtime) error + + // Get retrieves a runtime by type. + // Returns ErrRuntimeNotFound if the runtime type is not registered. + Get(runtimeType string) (Runtime, error) + + // List returns all registered runtime types. + List() []string +} + +// registry is the concrete implementation of Registry. +type registry struct { + mu sync.RWMutex + runtimes map[string]Runtime +} + +// Ensure registry implements Registry interface at compile time. +var _ Registry = (*registry)(nil) + +// NewRegistry creates a new empty runtime registry. +func NewRegistry() Registry { + return ®istry{ + runtimes: make(map[string]Runtime), + } +} + +// Register adds a runtime to the registry. +func (r *registry) Register(runtime Runtime) error { + if runtime == nil { + return fmt.Errorf("runtime cannot be nil") + } + + runtimeType := runtime.Type() + if runtimeType == "" { + return fmt.Errorf("runtime type cannot be empty") + } + + r.mu.Lock() + defer r.mu.Unlock() + + if _, exists := r.runtimes[runtimeType]; exists { + return fmt.Errorf("runtime already registered: %s", runtimeType) + } + + r.runtimes[runtimeType] = runtime + return nil +} + +// Get retrieves a runtime by type. +func (r *registry) Get(runtimeType string) (Runtime, error) { + r.mu.RLock() + defer r.mu.RUnlock() + + runtime, exists := r.runtimes[runtimeType] + if !exists { + return nil, fmt.Errorf("%w: %s", ErrRuntimeNotFound, runtimeType) + } + + return runtime, nil +} + +// List returns all registered runtime types. +func (r *registry) List() []string { + r.mu.RLock() + defer r.mu.RUnlock() + + types := make([]string, 0, len(r.runtimes)) + for runtimeType := range r.runtimes { + types = append(types, runtimeType) + } + + return types +} diff --git a/pkg/runtime/registry_test.go b/pkg/runtime/registry_test.go new file mode 100644 index 0000000..05e10fe --- /dev/null +++ b/pkg/runtime/registry_test.go @@ -0,0 +1,236 @@ +// Copyright 2026 Red Hat, Inc. +// +// 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" + "errors" + "fmt" + "testing" +) + +// fakeRuntime is a minimal Runtime implementation for testing. +type fakeRuntime struct { + typeID string +} + +func (f *fakeRuntime) Type() string { + return f.typeID +} + +func (f *fakeRuntime) Create(ctx context.Context, params CreateParams) (RuntimeInfo, error) { + return RuntimeInfo{}, nil +} + +func (f *fakeRuntime) Start(ctx context.Context, id string) (RuntimeInfo, error) { + return RuntimeInfo{}, nil +} + +func (f *fakeRuntime) Stop(ctx context.Context, id string) error { + return nil +} + +func (f *fakeRuntime) Remove(ctx context.Context, id string) error { + return nil +} + +func (f *fakeRuntime) Info(ctx context.Context, id string) (RuntimeInfo, error) { + return RuntimeInfo{}, nil +} + +func TestRegistry_RegisterAndGet(t *testing.T) { + t.Parallel() + + reg := NewRegistry() + rt := &fakeRuntime{typeID: "test-runtime"} + + // Register the runtime + err := reg.Register(rt) + if err != nil { + t.Fatalf("Failed to register runtime: %v", err) + } + + // Retrieve the runtime + retrieved, err := reg.Get("test-runtime") + if err != nil { + t.Fatalf("Failed to get runtime: %v", err) + } + + if retrieved.Type() != "test-runtime" { + t.Errorf("Expected runtime type 'test-runtime', got '%s'", retrieved.Type()) + } +} + +func TestRegistry_DuplicateRegistration(t *testing.T) { + t.Parallel() + + reg := NewRegistry() + rt1 := &fakeRuntime{typeID: "test-runtime"} + rt2 := &fakeRuntime{typeID: "test-runtime"} + + // Register first runtime + err := reg.Register(rt1) + if err != nil { + t.Fatalf("Failed to register first runtime: %v", err) + } + + // Try to register duplicate + err = reg.Register(rt2) + if err == nil { + t.Fatal("Expected error when registering duplicate runtime, got nil") + } + + expectedMsg := "runtime already registered: test-runtime" + if err.Error() != expectedMsg { + t.Errorf("Expected error message '%s', got '%s'", expectedMsg, err.Error()) + } +} + +func TestRegistry_GetUnknownRuntime(t *testing.T) { + t.Parallel() + + reg := NewRegistry() + + // Try to get non-existent runtime + _, err := reg.Get("unknown-runtime") + if err == nil { + t.Fatal("Expected error when getting unknown runtime, got nil") + } + + if !errors.Is(err, ErrRuntimeNotFound) { + t.Errorf("Expected ErrRuntimeNotFound, got %v", err) + } +} + +func TestRegistry_List(t *testing.T) { + t.Parallel() + + reg := NewRegistry() + + // Empty registry + types := reg.List() + if len(types) != 0 { + t.Errorf("Expected empty list, got %d types", len(types)) + } + + // Register multiple runtimes + rt1 := &fakeRuntime{typeID: "runtime-1"} + rt2 := &fakeRuntime{typeID: "runtime-2"} + + if err := reg.Register(rt1); err != nil { + t.Fatalf("Failed to register runtime-1: %v", err) + } + if err := reg.Register(rt2); err != nil { + t.Fatalf("Failed to register runtime-2: %v", err) + } + + // List should contain both + types = reg.List() + if len(types) != 2 { + t.Errorf("Expected 2 types, got %d", len(types)) + } + + // Check both types are present (order not guaranteed) + typeMap := make(map[string]bool) + for _, t := range types { + typeMap[t] = true + } + + if !typeMap["runtime-1"] || !typeMap["runtime-2"] { + t.Errorf("Expected both runtime-1 and runtime-2 in list, got %v", types) + } +} + +func TestRegistry_RegisterNil(t *testing.T) { + t.Parallel() + + reg := NewRegistry() + + err := reg.Register(nil) + if err == nil { + t.Fatal("Expected error when registering nil runtime, got nil") + } + + expectedMsg := "runtime cannot be nil" + if err.Error() != expectedMsg { + t.Errorf("Expected error message '%s', got '%s'", expectedMsg, err.Error()) + } +} + +func TestRegistry_RegisterEmptyType(t *testing.T) { + t.Parallel() + + reg := NewRegistry() + rt := &fakeRuntime{typeID: ""} + + err := reg.Register(rt) + if err == nil { + t.Fatal("Expected error when registering runtime with empty type, got nil") + } + + expectedMsg := "runtime type cannot be empty" + if err.Error() != expectedMsg { + t.Errorf("Expected error message '%s', got '%s'", expectedMsg, err.Error()) + } +} + +func TestRegistry_ThreadSafety(t *testing.T) { + t.Parallel() + + reg := NewRegistry() + + // Spawn multiple goroutines that register, get, and list concurrently + const numGoroutines = 10 + done := make(chan bool, numGoroutines) + + for i := 0; i < numGoroutines; i++ { + i := i // capture loop variable + go func() { + defer func() { done <- true }() + + // Register a unique runtime + rt := &fakeRuntime{typeID: fmt.Sprintf("runtime-%d", i)} + if err := reg.Register(rt); err != nil { + t.Errorf("Failed to register runtime-%d: %v", i, err) + return + } + + // Try to get it + retrieved, err := reg.Get(fmt.Sprintf("runtime-%d", i)) + if err != nil { + t.Errorf("Failed to get runtime-%d: %v", i, err) + return + } + + if retrieved.Type() != fmt.Sprintf("runtime-%d", i) { + t.Errorf("Wrong runtime type: expected runtime-%d, got %s", i, retrieved.Type()) + } + + // List all runtimes + _ = reg.List() + }() + } + + // Wait for all goroutines to complete + for i := 0; i < numGoroutines; i++ { + <-done + } + + // Verify all runtimes were registered + types := reg.List() + if len(types) != numGoroutines { + t.Errorf("Expected %d registered runtimes, got %d", numGoroutines, len(types)) + } +} diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go new file mode 100644 index 0000000..2c92407 --- /dev/null +++ b/pkg/runtime/runtime.go @@ -0,0 +1,70 @@ +// Copyright 2026 Red Hat, Inc. +// +// 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 provides interfaces and types for managing AI agent workspace runtimes. +// A runtime is an execution environment (e.g., container, process) that hosts a workspace instance. +package runtime + +import "context" + +// Runtime manages the lifecycle of workspace instances in a specific execution environment. +// Implementations might use containers (podman, docker), processes, or other isolation mechanisms. +type Runtime interface { + // Type returns the runtime type identifier (e.g., "podman", "docker", "process", "fake"). + Type() string + + // Create creates a new runtime instance without starting it. + // Returns RuntimeInfo with the assigned instance ID and initial state. + Create(ctx context.Context, params CreateParams) (RuntimeInfo, error) + + // Start starts a previously created runtime instance. + // Returns updated RuntimeInfo with running state. + Start(ctx context.Context, id string) (RuntimeInfo, error) + + // Stop stops a running runtime instance without removing it. + // The instance can be started again later. + Stop(ctx context.Context, id string) error + + // Remove removes a runtime instance and cleans up all associated resources. + // The instance must be stopped before removal. + Remove(ctx context.Context, id string) error + + // Info retrieves current information about a runtime instance. + Info(ctx context.Context, id string) (RuntimeInfo, error) +} + +// CreateParams contains parameters for creating a new runtime instance. +type CreateParams struct { + // Name is the human-readable name for the instance. + Name string + + // SourcePath is the absolute path to the workspace source directory. + SourcePath string + + // ConfigPath is the absolute path to the workspace configuration directory. + ConfigPath string +} + +// RuntimeInfo contains information about a runtime instance. +type RuntimeInfo struct { + // ID is the runtime-assigned instance identifier. + ID string + + // State is the current runtime state (e.g., "created", "running", "stopped"). + State string + + // Info contains runtime-specific metadata as key-value pairs. + // Examples: container_id, pid, created_at, network addresses. + Info map[string]string +} From 7d16ec7cc7602f1d9a22bb3fc8ae8dcbac334616 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Wed, 11 Mar 2026 15:09:35 +0100 Subject: [PATCH 2/2] fix: review Signed-off-by: Philippe Martin --- pkg/runtime/fake/fake.go | 8 ++++---- pkg/runtime/registry_test.go | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/runtime/fake/fake.go b/pkg/runtime/fake/fake.go index 3bd9bf5..0908a18 100644 --- a/pkg/runtime/fake/fake.go +++ b/pkg/runtime/fake/fake.go @@ -72,10 +72,6 @@ func (f *fakeRuntime) Create(ctx context.Context, params runtime.CreateParams) ( f.mu.Lock() defer f.mu.Unlock() - // Generate sequential ID - id := fmt.Sprintf("fake-%03d", f.nextID) - f.nextID++ - // Check if instance already exists with same name for _, inst := range f.instances { if inst.name == params.Name { @@ -83,6 +79,10 @@ func (f *fakeRuntime) Create(ctx context.Context, params runtime.CreateParams) ( } } + // Generate sequential ID + id := fmt.Sprintf("fake-%03d", f.nextID) + f.nextID++ + // Create instance state state := &instanceState{ id: id, diff --git a/pkg/runtime/registry_test.go b/pkg/runtime/registry_test.go index 05e10fe..1b72674 100644 --- a/pkg/runtime/registry_test.go +++ b/pkg/runtime/registry_test.go @@ -144,8 +144,8 @@ func TestRegistry_List(t *testing.T) { // Check both types are present (order not guaranteed) typeMap := make(map[string]bool) - for _, t := range types { - typeMap[t] = true + for _, typ := range types { + typeMap[typ] = true } if !typeMap["runtime-1"] || !typeMap["runtime-2"] {