Skip to content

Commit 094fb11

Browse files
JAORMXclaude
andcommitted
Migrate legacy workloads to stamp runtime ownership on healthy reconciliation
Legacy workloads created before runtime ownership tracking have an empty RuntimeName field. Rather than leaving them permanently in the legacy state, opportunistically stamp them with the active runtime's name when reconciliation confirms the workload is healthy (container running, proxy OK). This ensures the cross-runtime protection from the previous commit covers existing workloads progressively. Also adds a clarifying comment on the Remote guard in handleRuntimeMissing per reviewer feedback, and improves diagnostics with a DEBUG log when GetReader fails during migration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 85babb6 commit 094fb11

2 files changed

Lines changed: 224 additions & 4 deletions

File tree

pkg/workloads/statuses/file_status.go

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,61 @@ func (f *fileStatusManager) isOwnedByActiveRuntime(ctx context.Context, workload
106106
return config.RuntimeName == f.runtime.Name()
107107
}
108108

109+
// migrateRuntimeName stamps a legacy workload (RuntimeName == "") with the
110+
// active runtime's name. This is called only after the runtime has confirmed the
111+
// workload is healthy, so we know with certainty it belongs to the active
112+
// runtime. Save failures are non-fatal — the migration will be retried on the
113+
// next reconciliation cycle.
114+
func (f *fileStatusManager) migrateRuntimeName(ctx context.Context, workloadName string) {
115+
reader, err := f.runConfigStore.GetReader(ctx, workloadName)
116+
if err != nil {
117+
slog.Debug("skipping runtime name migration: cannot read config", "workload", workloadName, "error", err)
118+
return
119+
}
120+
121+
var raw map[string]json.RawMessage
122+
decodeErr := json.NewDecoder(reader).Decode(&raw)
123+
// Always close the reader before acting on the decode result.
124+
if closeErr := reader.Close(); closeErr != nil {
125+
slog.Warn("failed to close reader for runtime name migration", "workload", workloadName, "error", closeErr)
126+
}
127+
if decodeErr != nil {
128+
return
129+
}
130+
131+
// Check if runtime_name is already set
132+
if rn, ok := raw["runtime_name"]; ok {
133+
var name string
134+
if err := json.Unmarshal(rn, &name); err == nil && name != "" {
135+
return // Already migrated
136+
}
137+
}
138+
139+
// Stamp with the active runtime name
140+
runtimeBytes, err := json.Marshal(f.runtime.Name())
141+
if err != nil {
142+
return
143+
}
144+
raw["runtime_name"] = runtimeBytes
145+
146+
writer, err := f.runConfigStore.GetWriter(ctx, workloadName)
147+
if err != nil {
148+
slog.Warn("failed to open writer for runtime name migration", "workload", workloadName, "error", err)
149+
return
150+
}
151+
defer func() {
152+
if err := writer.Close(); err != nil {
153+
slog.Warn("failed to close writer for runtime name migration", "workload", workloadName, "error", err)
154+
}
155+
}()
156+
157+
encoder := json.NewEncoder(writer)
158+
encoder.SetIndent("", " ")
159+
if err := encoder.Encode(raw); err != nil {
160+
slog.Warn("failed to write migrated RunConfig", "workload", workloadName, "error", err)
161+
}
162+
}
163+
109164
// isRemoteWorkload checks if a workload is remote by attempting to load its run configuration
110165
// and checking if it has a RemoteURL field set.
111166
// TODO: This is a temporary solution to check if a workload is remote
@@ -964,7 +1019,12 @@ func (f *fileStatusManager) validateRunningWorkload(
9641019
return unhealthyWorkload, nil
9651020
}
9661021

967-
// Runtime and proxy confirm workload is healthy - merge runtime data with file status
1022+
// Runtime and proxy confirm workload is healthy — opportunistically migrate
1023+
// legacy workloads that predate the runtime_name field so they are stamped
1024+
// with the owning runtime for future reconciliation cycles.
1025+
f.migrateRuntimeName(ctx, workloadName)
1026+
1027+
// Merge runtime data with file status
9681028
return f.mergeHealthyWorkloadData(containerInfo, result)
9691029
}
9701030

@@ -995,10 +1055,10 @@ func (f *fileStatusManager) handleRuntimeMismatch(
9951055
func (f *fileStatusManager) handleRuntimeMissing(
9961056
ctx context.Context, workloadName string, fileWorkload core.Workload,
9971057
) (core.Workload, error) {
998-
// Check if this is a remote workload using the Remote field
1058+
// Remote workloads don't exist in the container runtime, so it's normal
1059+
// for them to be missing. This also means they bypass the runtime-ownership
1060+
// check below — remote workloads have no owning runtime.
9991061
if fileWorkload.Remote {
1000-
// Remote workloads don't exist in the container runtime, so it's normal for them to be missing
1001-
// Don't mark them as unhealthy
10021062
return fileWorkload, nil
10031063
}
10041064

pkg/workloads/statuses/file_status_test.go

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
package statuses
55

66
import (
7+
"bytes"
78
"context"
89
"encoding/json"
910
"errors"
@@ -233,6 +234,14 @@ func TestFileStatusManager_GetWorkload_FileAndRuntimeCombination(t *testing.T) {
233234
return io.NopCloser(strings.NewReader(`{"name": "running-workload", "transport": "sse"}`)), nil
234235
}).AnyTimes()
235236

237+
// Mock runtime name for isOwnedByActiveRuntime and migrateRuntimeName
238+
mockRuntime.EXPECT().Name().Return("docker").AnyTimes()
239+
240+
// Mock GetWriter for migrateRuntimeName (legacy workload with no runtime_name gets migrated)
241+
mockRunConfigStore.EXPECT().GetWriter(gomock.Any(), "running-workload").DoAndReturn(func(context.Context, string) (io.WriteCloser, error) {
242+
return nopWriteCloser{&bytes.Buffer{}}, nil
243+
}).AnyTimes()
244+
236245
// Create a workload status file and set it to running
237246
err := manager.SetWorkloadStatus(ctx, "running-workload", rt.WorkloadStatusStarting, "")
238247
require.NoError(t, err)
@@ -915,6 +924,14 @@ func TestFileStatusManager_GetWorkload_HealthyRunningWorkload(t *testing.T) {
915924
return io.NopCloser(strings.NewReader(`{"name": "healthy-workload", "transport": "sse"}`)), nil
916925
}).AnyTimes()
917926

927+
// Mock runtime name for isOwnedByActiveRuntime and migrateRuntimeName
928+
mockRuntime.EXPECT().Name().Return("docker").AnyTimes()
929+
930+
// Mock GetWriter for migrateRuntimeName (legacy workload with no runtime_name gets migrated)
931+
mockRunConfigStore.EXPECT().GetWriter(gomock.Any(), "healthy-workload").DoAndReturn(func(context.Context, string) (io.WriteCloser, error) {
932+
return nopWriteCloser{&bytes.Buffer{}}, nil
933+
}).AnyTimes()
934+
918935
// Set the workload status to running in the file
919936
err := manager.SetWorkloadStatus(ctx, "healthy-workload", rt.WorkloadStatusRunning, "container started")
920937
require.NoError(t, err)
@@ -964,6 +981,9 @@ func TestFileStatusManager_GetWorkload_ProxyNotRunning(t *testing.T) {
964981
return io.NopCloser(strings.NewReader(`{"name": "proxy-down-workload", "transport": "sse"}`)), nil
965982
}).AnyTimes()
966983

984+
// Mock runtime name for isOwnedByActiveRuntime
985+
mockRuntime.EXPECT().Name().Return("docker").AnyTimes()
986+
967987
// First, create a status file manually to ensure file is found
968988
statusFile := workloadStatusFile{
969989
Status: rt.WorkloadStatusRunning,
@@ -1041,6 +1061,14 @@ func TestFileStatusManager_GetWorkload_HealthyWithProxy(t *testing.T) {
10411061
return io.NopCloser(strings.NewReader(`{"name": "healthy-with-proxy", "transport": "sse"}`)), nil
10421062
}).AnyTimes()
10431063

1064+
// Mock runtime name for isOwnedByActiveRuntime and migrateRuntimeName
1065+
mockRuntime.EXPECT().Name().Return("docker").AnyTimes()
1066+
1067+
// Mock GetWriter for migrateRuntimeName (legacy workload with no runtime_name gets migrated)
1068+
mockRunConfigStore.EXPECT().GetWriter(gomock.Any(), "healthy-with-proxy").DoAndReturn(func(context.Context, string) (io.WriteCloser, error) {
1069+
return nopWriteCloser{&bytes.Buffer{}}, nil
1070+
}).AnyTimes()
1071+
10441072
// Set the workload status to running in the file
10451073
err := manager.SetWorkloadStatus(ctx, "healthy-with-proxy", rt.WorkloadStatusRunning, "container started")
10461074
require.NoError(t, err)
@@ -2353,3 +2381,135 @@ func TestFileStatusManager_CrossRuntimeProtection(t *testing.T) {
23532381
})
23542382
}
23552383
}
2384+
2385+
// nopWriteCloser wraps a bytes.Buffer with a no-op Close.
2386+
type nopWriteCloser struct {
2387+
*bytes.Buffer
2388+
}
2389+
2390+
func (nopWriteCloser) Close() error { return nil }
2391+
2392+
func TestMigrateRuntimeName(t *testing.T) {
2393+
t.Parallel()
2394+
2395+
tests := []struct {
2396+
name string
2397+
runtimeName string
2398+
runConfigJSON string
2399+
expectWrite bool
2400+
expectedRuntime string
2401+
getReaderErr bool
2402+
getWriterErr bool
2403+
}{
2404+
{
2405+
name: "migrates empty runtime_name",
2406+
runtimeName: "docker",
2407+
runConfigJSON: `{"name": "my-workload", "image": "test:latest"}`,
2408+
expectWrite: true,
2409+
expectedRuntime: "docker",
2410+
},
2411+
{
2412+
name: "migrates explicit empty runtime_name",
2413+
runtimeName: "docker",
2414+
runConfigJSON: `{"name": "my-workload", "runtime_name": "", "image": "test:latest"}`,
2415+
expectWrite: true,
2416+
expectedRuntime: "docker",
2417+
},
2418+
{
2419+
name: "no-op when runtime_name already set",
2420+
runtimeName: "docker",
2421+
runConfigJSON: `{"name": "my-workload", "runtime_name": "docker", "image": "test:latest"}`,
2422+
expectWrite: false,
2423+
},
2424+
{
2425+
name: "no-op when runtime_name set to different runtime",
2426+
runtimeName: "docker",
2427+
runConfigJSON: `{"name": "my-workload", "runtime_name": "go-microvm", "image": "test:latest"}`,
2428+
expectWrite: false,
2429+
},
2430+
{
2431+
name: "graceful on GetReader error",
2432+
runtimeName: "docker",
2433+
getReaderErr: true,
2434+
expectWrite: false,
2435+
},
2436+
{
2437+
name: "graceful on GetWriter error",
2438+
runtimeName: "docker",
2439+
runConfigJSON: `{"name": "my-workload"}`,
2440+
getWriterErr: true,
2441+
expectWrite: false,
2442+
},
2443+
{
2444+
name: "graceful on malformed JSON",
2445+
runtimeName: "docker",
2446+
runConfigJSON: `{bad json`,
2447+
expectWrite: false,
2448+
},
2449+
{
2450+
name: "migrates non-string runtime_name",
2451+
runtimeName: "docker",
2452+
runConfigJSON: `{"name": "my-workload", "runtime_name": 42}`,
2453+
expectWrite: true,
2454+
expectedRuntime: "docker",
2455+
},
2456+
}
2457+
2458+
for _, tt := range tests {
2459+
t.Run(tt.name, func(t *testing.T) {
2460+
t.Parallel()
2461+
2462+
ctrl := gomock.NewController(t)
2463+
defer ctrl.Finish()
2464+
2465+
manager, mockRuntime, mockRunConfigStore := newTestFileStatusManager(t, ctrl)
2466+
ctx := t.Context()
2467+
2468+
workloadName := "test-workload"
2469+
mockRuntime.EXPECT().Name().Return(tt.runtimeName).AnyTimes()
2470+
2471+
if tt.getReaderErr {
2472+
mockRunConfigStore.EXPECT().GetReader(gomock.Any(), workloadName).
2473+
Return(nil, errors.New("store error"))
2474+
} else {
2475+
mockRunConfigStore.EXPECT().GetReader(gomock.Any(), workloadName).DoAndReturn(
2476+
func(context.Context, string) (io.ReadCloser, error) {
2477+
return io.NopCloser(strings.NewReader(tt.runConfigJSON)), nil
2478+
},
2479+
)
2480+
}
2481+
2482+
var writeBuf nopWriteCloser
2483+
if tt.expectWrite {
2484+
if tt.getWriterErr {
2485+
mockRunConfigStore.EXPECT().GetWriter(gomock.Any(), workloadName).
2486+
Return(nil, errors.New("write error"))
2487+
} else {
2488+
writeBuf = nopWriteCloser{&bytes.Buffer{}}
2489+
mockRunConfigStore.EXPECT().GetWriter(gomock.Any(), workloadName).
2490+
Return(writeBuf, nil)
2491+
}
2492+
} else if tt.getWriterErr {
2493+
mockRunConfigStore.EXPECT().GetWriter(gomock.Any(), workloadName).
2494+
Return(nil, errors.New("write error"))
2495+
}
2496+
2497+
manager.migrateRuntimeName(ctx, workloadName)
2498+
2499+
if tt.expectWrite && !tt.getWriterErr {
2500+
var written map[string]json.RawMessage
2501+
require.NoError(t, json.Unmarshal(writeBuf.Bytes(), &written))
2502+
2503+
var runtimeName string
2504+
require.NoError(t, json.Unmarshal(written["runtime_name"], &runtimeName))
2505+
assert.Equal(t, tt.expectedRuntime, runtimeName,
2506+
"migrated runtime_name should match active runtime")
2507+
2508+
// Verify other fields are preserved
2509+
var name string
2510+
require.NoError(t, json.Unmarshal(written["name"], &name))
2511+
assert.Equal(t, "my-workload", name, "other fields should be preserved")
2512+
}
2513+
})
2514+
}
2515+
}

0 commit comments

Comments
 (0)