Skip to content

Commit ac93cde

Browse files
JAORMXclaude
andcommitted
Harden runtime-ownership check against ambiguity
isOwnedByActiveRuntime could only ever deny ownership when it read a RunConfig whose runtime_name positively named a different runtime. Every ambiguous outcome must default to "owned" so we never silently suppress reconciliation for a workload we are responsible for. Check Exists() before reading so a genuinely-missing RunConfig is handled distinctly from a present-but-unparseable one, trim whitespace-only runtime_name to the legacy path, and document why the bias toward ownership is required. Run the eager policy gate before stamping RuntimeName so a policy denial never dereferences the runtime. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 094fb11 commit ac93cde

4 files changed

Lines changed: 134 additions & 14 deletions

File tree

cmd/thv/app/run.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -243,17 +243,17 @@ func runSingleServer(ctx context.Context, runFlags *RunFlags, serverOrImage stri
243243
return err
244244
}
245245

246-
// Record which runtime owns this workload so that reconciliation logic
247-
// does not corrupt its status file when a different runtime is active.
248-
runnerConfig.RuntimeName = rt.Name()
249-
250246
// Enforce policy in the main process before saving state or spawning a
251247
// detached worker, so violations surface synchronously with a non-zero
252248
// exit code rather than silently failing in the background log.
253249
if err := runner.EagerCheckCreateServer(ctx, runnerConfig); err != nil {
254250
return fmt.Errorf("server creation blocked by policy: %w", err)
255251
}
256252

253+
// Record which runtime owns this workload so that reconciliation logic
254+
// does not corrupt its status file when a different runtime is active.
255+
runnerConfig.RuntimeName = rt.Name()
256+
257257
// Always save the run config to disk before starting (both foreground and detached modes)
258258
// NOTE: Save before secrets processing to avoid storing secrets in the state store
259259
if err := runnerConfig.SaveState(ctx); err != nil {

pkg/api/v1/workload_service.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,16 +100,16 @@ func (s *WorkloadService) CreateWorkloadFromRequest(ctx context.Context, req *cr
100100
return nil, err
101101
}
102102

103-
// Record which runtime owns this workload for cross-runtime reconciliation.
104-
runConfig.RuntimeName = s.containerRuntime.Name()
105-
106103
// Enforce policy before saving state or starting the workload, so
107104
// violations are returned as API errors rather than creating the server
108105
// in a broken state.
109106
if err := runner.EagerCheckCreateServer(ctx, runConfig); err != nil {
110107
return nil, fmt.Errorf("server creation blocked by policy: %w", err)
111108
}
112109

110+
// Record which runtime owns this workload for cross-runtime reconciliation.
111+
runConfig.RuntimeName = s.containerRuntime.Name()
112+
113113
// Save the workload state
114114
if err := runConfig.SaveState(ctx); err != nil {
115115
slog.Error("failed to save workload config", "error", err)

pkg/workloads/statuses/file_status.go

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,30 @@ type fileStatusManager struct {
7676

7777
// isOwnedByActiveRuntime checks whether the named workload was created by the
7878
// currently active runtime. It loads the runtime_name field from the persisted
79-
// RunConfig and compares it against f.runtime.Name(). Legacy workloads that
80-
// predate the runtime_name field (empty string) are conservatively treated as
81-
// owned by the active runtime so that existing validation behaviour is preserved.
79+
// RunConfig and compares it against f.runtime.Name().
80+
//
81+
// Ownership can only be denied when we positively identify a *different* owning
82+
// runtime: the RunConfig must exist, decode cleanly, and carry a non-empty
83+
// runtime_name that differs from the active runtime. Every other case --
84+
// missing/unreadable config, decode failure, or a legacy workload that predates
85+
// the runtime_name field (empty string) -- is conservatively treated as owned by
86+
// the active runtime, preserving the pre-feature validation behaviour. This bias
87+
// matters: a false "not ours" would silently suppress reconciliation for a
88+
// workload we are responsible for, leaving it stuck in a stale status.
8289
func (f *fileStatusManager) isOwnedByActiveRuntime(ctx context.Context, workloadName string) bool {
90+
// A workload with no RunConfig cannot have been tagged with another
91+
// runtime's name, so it can only be ours (or a legacy workload). Checking
92+
// existence explicitly lets us distinguish "no config" from "config present
93+
// but unparseable" and mirrors isRemoteWorkload's handling.
94+
exists, err := f.runConfigStore.Exists(ctx, workloadName)
95+
if err != nil || !exists {
96+
return true
97+
}
98+
8399
reader, err := f.runConfigStore.GetReader(ctx, workloadName)
84100
if err != nil {
85-
// RunConfig missing or unreadable -- assume ours so we don't silently
86-
// skip validation for workloads we should be checking.
101+
// RunConfig unreadable -- assume ours so we don't silently skip
102+
// validation for workloads we should be checking.
87103
return true
88104
}
89105
defer func() {
@@ -99,10 +115,12 @@ func (f *fileStatusManager) isOwnedByActiveRuntime(ctx context.Context, workload
99115
return true // can't determine ownership -- assume ours
100116
}
101117

102-
// Empty means legacy workload; treat as owned by active runtime.
103-
if config.RuntimeName == "" {
118+
// Empty means legacy workload (predates runtime_name); treat as ours.
119+
if strings.TrimSpace(config.RuntimeName) == "" {
104120
return true
105121
}
122+
123+
// Only deny ownership when another runtime is positively identified.
106124
return config.RuntimeName == f.runtime.Name()
107125
}
108126

pkg/workloads/statuses/file_status_test.go

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2389,6 +2389,108 @@ type nopWriteCloser struct {
23892389

23902390
func (nopWriteCloser) Close() error { return nil }
23912391

2392+
// TestIsOwnedByActiveRuntime exercises every branch of the ownership check.
2393+
// Ownership may only be denied when a *different* runtime is positively
2394+
// identified; every ambiguous case must default to "owned" so we never silently
2395+
// suppress reconciliation for a workload we are responsible for.
2396+
func TestIsOwnedByActiveRuntime(t *testing.T) {
2397+
t.Parallel()
2398+
2399+
const workloadName = "test-workload"
2400+
const activeRuntime = "docker"
2401+
2402+
tests := []struct {
2403+
name string
2404+
// existsResult/existsErr control the runConfigStore.Exists mock.
2405+
existsResult bool
2406+
existsErr error
2407+
// when the config exists, getReaderErr or runConfigJSON drive GetReader.
2408+
getReaderErr error
2409+
runConfigJSON string
2410+
expectOwned bool
2411+
}{
2412+
{
2413+
name: "owned by active runtime",
2414+
existsResult: true,
2415+
runConfigJSON: `{"runtime_name": "docker"}`,
2416+
expectOwned: true,
2417+
},
2418+
{
2419+
name: "owned by a different runtime is not ours",
2420+
existsResult: true,
2421+
runConfigJSON: `{"runtime_name": "go-microvm"}`,
2422+
expectOwned: false,
2423+
},
2424+
{
2425+
name: "legacy workload with empty runtime_name is assumed ours",
2426+
existsResult: true,
2427+
runConfigJSON: `{"name": "test-workload"}`,
2428+
expectOwned: true,
2429+
},
2430+
{
2431+
name: "whitespace-only runtime_name is treated as legacy",
2432+
existsResult: true,
2433+
runConfigJSON: `{"runtime_name": " "}`,
2434+
expectOwned: true,
2435+
},
2436+
{
2437+
name: "missing run config is assumed ours",
2438+
existsResult: false,
2439+
expectOwned: true,
2440+
},
2441+
{
2442+
name: "exists error is assumed ours",
2443+
existsErr: errors.New("store unavailable"),
2444+
expectOwned: true,
2445+
},
2446+
{
2447+
name: "unreadable run config is assumed ours",
2448+
existsResult: true,
2449+
getReaderErr: errors.New("read error"),
2450+
expectOwned: true,
2451+
},
2452+
{
2453+
name: "undecodable run config is assumed ours",
2454+
existsResult: true,
2455+
runConfigJSON: `{bad json`,
2456+
expectOwned: true,
2457+
},
2458+
}
2459+
2460+
for _, tt := range tests {
2461+
t.Run(tt.name, func(t *testing.T) {
2462+
t.Parallel()
2463+
2464+
ctrl := gomock.NewController(t)
2465+
defer ctrl.Finish()
2466+
2467+
manager, mockRuntime, mockRunConfigStore := newTestFileStatusManager(t, ctrl)
2468+
ctx := t.Context()
2469+
2470+
// Name() is only consulted when a non-empty runtime_name is compared.
2471+
mockRuntime.EXPECT().Name().Return(activeRuntime).AnyTimes()
2472+
2473+
mockRunConfigStore.EXPECT().Exists(gomock.Any(), workloadName).
2474+
Return(tt.existsResult, tt.existsErr)
2475+
2476+
// GetReader is only reached when the config exists and Exists succeeds.
2477+
if tt.existsErr == nil && tt.existsResult {
2478+
if tt.getReaderErr != nil {
2479+
mockRunConfigStore.EXPECT().GetReader(gomock.Any(), workloadName).
2480+
Return(nil, tt.getReaderErr)
2481+
} else {
2482+
mockRunConfigStore.EXPECT().GetReader(gomock.Any(), workloadName).DoAndReturn(
2483+
func(context.Context, string) (io.ReadCloser, error) {
2484+
return io.NopCloser(strings.NewReader(tt.runConfigJSON)), nil
2485+
})
2486+
}
2487+
}
2488+
2489+
assert.Equal(t, tt.expectOwned, manager.isOwnedByActiveRuntime(ctx, workloadName))
2490+
})
2491+
}
2492+
}
2493+
23922494
func TestMigrateRuntimeName(t *testing.T) {
23932495
t.Parallel()
23942496

0 commit comments

Comments
 (0)