Skip to content
Merged
18 changes: 18 additions & 0 deletions cmd/thv/app/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (

groupval "github.com/stacklok/toolhive-core/validation/group"
"github.com/stacklok/toolhive/pkg/config"
"github.com/stacklok/toolhive/pkg/container/runtime"
"github.com/stacklok/toolhive/pkg/secrets"
"github.com/stacklok/toolhive/pkg/workloads"
)
Expand Down Expand Up @@ -155,6 +156,23 @@ func completeLogsArgs(cmd *cobra.Command, args []string, _ string) ([]string, co
return completions, cobra.ShellCompDirectiveNoFileComp
}

// workloadStatusIndicator returns the status string with a visual indicator prepended
// for statuses that warrant user attention (unauthenticated, policy_stopped).
// All other statuses are returned as plain strings.
func workloadStatusIndicator(status runtime.WorkloadStatus) string {
switch status {
case runtime.WorkloadStatusUnauthenticated:
return "⚠️ " + string(status)
case runtime.WorkloadStatusPolicyStopped:
return "🚫 " + string(status)
case runtime.WorkloadStatusRunning, runtime.WorkloadStatusStopped, runtime.WorkloadStatusError,
runtime.WorkloadStatusStarting, runtime.WorkloadStatusStopping, runtime.WorkloadStatusUnhealthy,
runtime.WorkloadStatusRemoving, runtime.WorkloadStatusUnknown:
return string(status)
}
return string(status)
}

// AddGroupFlag adds a --group flag to the provided command for filtering by group.
// If withShorthand is true, adds the -g shorthand as well.
func AddGroupFlag(cmd *cobra.Command, groupVar *string, withShorthand bool) {
Expand Down
8 changes: 2 additions & 6 deletions cmd/thv/app/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (

"github.com/spf13/cobra"

rt "github.com/stacklok/toolhive/pkg/container/runtime"
"github.com/stacklok/toolhive/pkg/core"
"github.com/stacklok/toolhive/pkg/workloads"
)
Expand Down Expand Up @@ -165,11 +164,8 @@ func printTextOutput(workloadList []core.Workload) {

// Print workload information
for _, c := range workloadList {
// Highlight unauthenticated workloads with a warning indicator
status := string(c.Status)
if c.Status == rt.WorkloadStatusUnauthenticated {
status = "⚠️ " + status
}
// Highlight unauthenticated and policy-stopped workloads with indicators
status := workloadStatusIndicator(c.Status)

// Print workload information
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d\t%s\t%s\n",
Expand Down
6 changes: 1 addition & 5 deletions cmd/thv/app/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (

"github.com/spf13/cobra"

"github.com/stacklok/toolhive/pkg/container/runtime"
"github.com/stacklok/toolhive/pkg/core"
"github.com/stacklok/toolhive/pkg/workloads"
)
Expand Down Expand Up @@ -100,10 +99,7 @@ func printStatusJSONOutput(workload core.Workload) error {

func printStatusTextOutput(workload core.Workload) {
w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
status := string(workload.Status)
if workload.Status == runtime.WorkloadStatusUnauthenticated {
status = "⚠️ " + status
}
status := workloadStatusIndicator(workload.Status)

// Print workload information in key-value format
_, _ = fmt.Fprintf(w, "Name:\t%s\n", workload.Name)
Expand Down
8 changes: 6 additions & 2 deletions docs/server/docs.go

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

8 changes: 6 additions & 2 deletions docs/server/swagger.json

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.yaml

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

2 changes: 1 addition & 1 deletion pkg/api/v1/workload_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ type workloadListResponse struct {
type workloadStatusResponse struct {
// Current status of the workload
//nolint:lll // enums tag needed for swagger generation with --parseDependencyLevel
Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated"`
Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated,policy_stopped"`
}

// updateRequest represents the request to update an existing workload
Expand Down
3 changes: 3 additions & 0 deletions pkg/container/runtime/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ const (
// WorkloadStatusUnauthenticated indicates that the workload is running but
// cannot authenticate with the remote MCP server (e.g., expired refresh token).
WorkloadStatusUnauthenticated WorkloadStatus = "unauthenticated"
// WorkloadStatusPolicyStopped indicates that the workload was stopped by
// policy enforcement. The StatusContext field carries the human-readable reason.
WorkloadStatusPolicyStopped WorkloadStatus = "policy_stopped"
)

// ContainerInfo represents information about a container
Expand Down
2 changes: 1 addition & 1 deletion pkg/core/workload.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type Workload struct {
ProxyMode string `json:"proxy_mode,omitempty"`
// Status is the current status of the workload.
//nolint:lll // enums tag needed for swagger generation with --parseDependencyLevel
Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated"`
Status runtime.WorkloadStatus `json:"status" enums:"running,stopped,error,starting,stopping,unhealthy,removing,unknown,unauthenticated,policy_stopped"`
// StatusContext provides additional context about the workload's status.
// The exact meaning is determined by the status and the underlying runtime.
StatusContext string `json:"status_context,omitempty"`
Expand Down
21 changes: 21 additions & 0 deletions pkg/workloads/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,8 @@ func mapWorkloadStatusToVMCPHealth(status rt.WorkloadStatus) vmcp.BackendHealthS
return vmcp.BackendUnknown
case rt.WorkloadStatusUnauthenticated:
return vmcp.BackendUnauthenticated
case rt.WorkloadStatusPolicyStopped:
return vmcp.BackendUnhealthy
default:
Comment thread
reyortiz3 marked this conversation as resolved.
return vmcp.BackendUnknown
}
Expand Down Expand Up @@ -1086,6 +1088,15 @@ func (d *DefaultManager) restartSingleWorkload(ctx context.Context, name string,
return d.restartContainerWorkload(ctx, name, foreground)
}

// Check policy gates before restarting — the loaded RunConfig carries the same
// fields (RegistryAPIURL, RegistryURL, RemoteURL) that the gate evaluates on create.
if err := runner.EagerCheckCreateServer(ctx, runConfig); err != nil {
if statusErr := d.statuses.SetWorkloadStatus(ctx, name, rt.WorkloadStatusPolicyStopped, err.Error()); statusErr != nil {
slog.Warn("Failed to set workload status to policy_stopped", "workload", name, "error", statusErr)
}
return fmt.Errorf("server restart blocked by policy: %w", err)
Comment thread
reyortiz3 marked this conversation as resolved.
}

// Check if this is a remote workload
if runConfig.RemoteURL != "" {
return d.restartRemoteWorkload(ctx, name, runConfig, foreground)
Comment thread
reyortiz3 marked this conversation as resolved.
Expand Down Expand Up @@ -1295,6 +1306,16 @@ func (d *DefaultManager) maybeSetupContainerWorkload(ctx context.Context, name s
return "", nil, fmt.Errorf("failed to load state for %s: %w", workloadName, err)
}

// Check policy gates before restarting. This covers the case where the caller
// could not load state via the original name but we resolved the canonical name
// from container labels above, so the check must happen here.
if err := runner.EagerCheckCreateServer(ctx, mcpRunner.Config); err != nil {
if statusErr := d.statuses.SetWorkloadStatus(ctx, workloadName, rt.WorkloadStatusPolicyStopped, err.Error()); statusErr != nil {
slog.Warn("Failed to set workload status to policy_stopped", "workload", workloadName, "error", statusErr)
}
return "", nil, fmt.Errorf("server restart blocked by policy: %w", err)
}

// Set workload status to starting - use the workload name for status operations
if err := d.statuses.SetWorkloadStatus(ctx, workloadName, rt.WorkloadStatusStarting, ""); err != nil {
slog.Warn("Failed to set workload status to starting", "workload", workloadName, "error", err)
Expand Down
Loading