Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions cmd/thv/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,10 @@ func runSingleServer(ctx context.Context, runFlags *RunFlags, serverOrImage stri
return fmt.Errorf("server creation blocked by policy: %w", err)
}

// Record which runtime owns this workload so that reconciliation logic
// does not corrupt its status file when a different runtime is active.
runnerConfig.RuntimeName = rt.Name()

// Always save the run config to disk before starting (both foreground and detached modes)
// NOTE: Save before secrets processing to avoid storing secrets in the state store
if err := runnerConfig.SaveState(ctx); err != nil {
Expand Down Expand Up @@ -469,6 +473,7 @@ func runFromConfigFile(ctx context.Context) error {

// Set the runtime in the config
runConfig.Deployer = rt
runConfig.RuntimeName = rt.Name()

// Create workload manager
workloadManager, err := workloads.NewManagerFromRuntime(rt)
Expand Down
4 changes: 4 additions & 0 deletions docs/server/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions docs/server/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions docs/server/swagger.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions pkg/api/v1/workload_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ func (s *WorkloadService) CreateWorkloadFromRequest(ctx context.Context, req *cr
return nil, fmt.Errorf("server creation blocked by policy: %w", err)
}

// Record which runtime owns this workload for cross-runtime reconciliation.
runConfig.RuntimeName = s.containerRuntime.Name()

// Save the workload state
if err := runConfig.SaveState(ctx); err != nil {
slog.Error("failed to save workload config", "error", err)
Expand Down
3 changes: 3 additions & 0 deletions pkg/api/v1/workloads_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ func TestCreateWorkload(t *testing.T) {
mockRuntime := runtimemocks.NewMockRuntime(ctrl)
mockGroupManager := groupsmocks.NewMockManager(ctrl)

mockRuntime.EXPECT().Name().Return("docker").AnyTimes()
tt.setupMock(t, mockWorkloadManager, mockRuntime, mockGroupManager)
expectedServerOrImage := tt.expectedServerOrImage
if expectedServerOrImage == "" {
Expand All @@ -281,6 +282,7 @@ func TestCreateWorkload(t *testing.T) {
workloadService: &WorkloadService{
groupManager: mockGroupManager,
workloadManager: mockWorkloadManager,
containerRuntime: mockRuntime,
imageRetriever: mockRetriever,
imagePuller: func(_ context.Context, _ string) error { return nil },
configProvider: config.NewDefaultProvider(),
Expand Down Expand Up @@ -502,6 +504,7 @@ func TestUpdateWorkload(t *testing.T) {
workloadService: &WorkloadService{
groupManager: mockGroupManager,
workloadManager: mockWorkloadManager,
containerRuntime: mockRuntime,
imageRetriever: mockRetriever,
imagePuller: func(_ context.Context, _ string) error { return nil },
configProvider: config.NewDefaultProvider(),
Expand Down
15 changes: 15 additions & 0 deletions pkg/container/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -651,6 +651,21 @@ func (c *Client) AttachToWorkload(ctx context.Context, workloadName string) (io.
return resp.Conn, stdoutReader, nil
}

// Name returns the name of the concrete runtime this client is connected to
// (e.g. "docker", "podman", "colima"), as detected at connection time.
//
// This intentionally returns the detected runtimeType rather than the static
// RuntimeName constant used for factory registration. RuntimeName collapses all
// Docker-API-compatible runtimes under a single "docker" registration, but for
// workload-ownership tracking each concrete runtime must identify itself
// distinctly: Docker and Podman are separate daemons with separate containers,
// so a workload created under one must not be claimed by the other during
// status reconciliation. Returning the constant would let Podman-owned and
// Docker-owned workloads share the identity "docker" and corrupt each other.
func (c *Client) Name() string {
return string(c.runtimeType)
}

// IsRunning checks the health of the container runtime.
// This is used to verify that the runtime is operational and can manage workloads.
func (c *Client) IsRunning(ctx context.Context) error {
Expand Down
62 changes: 62 additions & 0 deletions pkg/container/docker/client_name_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
// SPDX-License-Identifier: Apache-2.0

package docker

import (
"testing"

"github.com/stretchr/testify/assert"

rt "github.com/stacklok/toolhive/pkg/container/runtime"
)

// TestClientName verifies that Name reports the concrete detected runtime type
// rather than the static factory-registration constant. Distinct identities are
// required so that workload-ownership tracking does not let Docker- and
// Podman-owned workloads (separate daemons) corrupt each other's status.
func TestClientName(t *testing.T) {
t.Parallel()

tests := []struct {
name string
runtimeType rt.Type
want string
}{
{name: "docker", runtimeType: rt.TypeDocker, want: "docker"},
{name: "podman", runtimeType: rt.TypePodman, want: "podman"},
{name: "colima", runtimeType: rt.TypeColima, want: "colima"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()

c := &Client{runtimeType: tt.runtimeType}
assert.Equal(t, tt.want, c.Name())
})
}
}

// TestClientNameOwnershipRoundTrip asserts the property the cross-runtime
// protection relies on: the identity stamped at create time and the identity
// compared at reconcile time come from the same Name call, so a workload created
// under Podman is recognized as Podman-owned (and not as Docker-owned) later.
func TestClientNameOwnershipRoundTrip(t *testing.T) {
t.Parallel()

podman := &Client{runtimeType: rt.TypePodman}
docker := &Client{runtimeType: rt.TypeDocker}

// Identity recorded on the RunConfig when the workload is created.
stampedAtCreate := podman.Name()

// Same Podman runtime later reconciling: must recognize ownership.
assert.Equal(t, stampedAtCreate, podman.Name(),
"podman must recognize its own workloads across calls")

// A different runtime (Docker) reconciling the same workload: must NOT
// claim ownership, otherwise it would corrupt the Podman workload's status.
assert.NotEqual(t, stampedAtCreate, docker.Name(),
"docker must not claim ownership of a podman-created workload")
}
5 changes: 5 additions & 0 deletions pkg/container/kubernetes/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,11 @@ func (*Client) StopWorkload(_ context.Context, _ string) error {
return nil
}

// Name returns the registered name of this runtime.
func (*Client) Name() string {
return RuntimeName
}

// IsRunning checks the health of the container runtime.
// This is used to verify that the runtime is operational and can manage workloads.
func (c *Client) IsRunning(ctx context.Context) error {
Expand Down
14 changes: 14 additions & 0 deletions pkg/container/runtime/mocks/mock_runtime.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions pkg/container/runtime/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,11 @@ type Runtime interface {
// IsRunning checks the health of the container runtime.
// This is used to verify that the runtime is operational and can manage workloads.
IsRunning(ctx context.Context) error

// Name returns the registered name of this runtime (e.g., "docker", "kubernetes").
// Used to track which runtime owns a workload so that reconciliation logic
// does not corrupt status files of workloads managed by a different runtime.
Name() string
}

// Monitor defines the interface for container monitoring
Expand Down
9 changes: 8 additions & 1 deletion pkg/mcp/server/run_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,14 @@ func buildServerConfig(

// Build the configuration
envVarValidator := &runner.DetachedEnvVarValidator{}
return runner.NewRunConfigBuilder(ctx, imageMetadata, envVars, envVarValidator, opts...)
cfg, err := runner.NewRunConfigBuilder(ctx, imageMetadata, envVars, envVarValidator, opts...)
if err != nil {
return nil, err
}

// Record which runtime owns this workload for cross-runtime reconciliation.
cfg.RuntimeName = rt.Name()
return cfg, nil
}

// configureTransport sets up transport configuration from metadata
Expand Down
5 changes: 5 additions & 0 deletions pkg/runner/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ type RunConfig struct {
// means unversioned (backward-compat with older operators, or non-operator callers).
MCPServerGeneration int64 `json:"mcpserver_generation,omitempty" yaml:"mcpserver_generation,omitempty"`

// RuntimeName is the registered name of the container runtime that owns this
// workload (e.g., "docker", "kubernetes"). Used during reconciliation to avoid
// corrupting status files of workloads managed by a different runtime.
RuntimeName string `json:"runtime_name,omitempty" yaml:"runtime_name,omitempty"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: existing workloads are never auto-migrated.

RuntimeName is stamped only at creation time (SaveState). Existing workloads on disk have RuntimeName = "" and will never be updated unless the workload is deleted and recreated. This is fine operationally, but means the cross-runtime protection only applies to workloads created after this PR is deployed — which ties back to the correctness concern in isOwnedByActiveRuntime.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed this in the follow-up commit. Legacy workloads now get their RuntimeName stamped opportunistically during reconciliation, but only when the runtime confirms the workload is healthy. So we're not blindly claiming ownership... we only stamp when we have strong evidence the workload actually belongs to the active runtime. The empty-string fallback in isOwnedByActiveRuntime stays as a safety net, but it should be a transient state now rather than permanent.


// Image is the Docker image to run
Image string `json:"image" yaml:"image"`

Expand Down
Loading
Loading