From 6227d8cba7add77f6bbcde44a902e85ab87d5706 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 11 Oct 2026 11:15:59 -0600 Subject: [PATCH 1/2] feat(runtime): negotiate recreation on RunImage --- README.md | 51 +++++- cmd/devsy-fake-runtime/main.go | 6 + conformance/fake/fake.go | 13 +- conformance/fake/integration_test.go | 11 +- conformance/fake/lifecycle.go | 49 +++--- conformance/fake/recreate_test.go | 219 +++++++++++++++++++++++++ conformance/fake/recreate_wire_test.go | 54 ++++++ conformance/fake/suite_test.go | 44 +++-- conformance/recreate.go | 97 +++++++++++ conformance/suite.go | 23 ++- proto/devsy/runtime/v1/runtime.proto | 16 +- runtimev1/runtime.pb.go | 52 ++++-- runtimev1/validate.go | 40 +++-- runtimev1/validate_test.go | 102 ++++++++++++ 14 files changed, 701 insertions(+), 76 deletions(-) create mode 100644 conformance/fake/recreate_test.go create mode 100644 conformance/fake/recreate_wire_test.go create mode 100644 conformance/recreate.go diff --git a/README.md b/README.md index 6a1441f..85d1d35 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ case before making changes to workspace resources. Hosts must forward both values and runtimes must handle them before enabling an implementation that depends on workspace ownership. These fields describe provisioning intent; they do not authorize resource replacement. Duplicate -creation and explicit Delete remain separate operations. +creation is rejected unless a runtime implements the explicit API 1.3 +authorization described below. Explicit Delete remains a separate operation. ### Reuse validation in API 1.2 @@ -99,6 +100,38 @@ Hosts skip this optional RPC when it is not advertised. The conformance suite checks successful reuse and cancellation without resource mutation; backend suites must cover their own incompatible contracts. +### Explicit recreation in API 1.3 + +A host that implements recreate-on-run sets `InfoRequest.supports_recreate_on_run`. +A supporting runtime may then advertise `RECREATE_MODE_ON_RUN` with API minor 3 +or newer. Without that opt-in (including an empty request from an older host), +it must advertise `RECREATE_MODE_DELETE`. Runtimes without this capability keep +their existing DELETE or STOP behavior; unknown request fields are ignored by +older providers. `ValidateInfo` rejects ON_RUN below API 1.3 and still rejects +unknown recreate modes. + +ON_RUN means the host leaves the existing resource intact before RunImage. +Only an explicitly authorized recreation supplies `RunImageRequest.allow_recreate=true`. +False or omitted never authorizes replacement; an otherwise-valid duplicate +returns AlreadyExists without mutating the existing workspace. Even when true, +the runtime must validate the complete effective configuration before destroying +the old resource. This does not promise rollback if later materialization or +launch fails. Fresh creation does not require permission to replace anything. + +Info opt-in advertises host capability; it is not replacement permission. The +SDK forwards requests unchanged and does not automatically negotiate or downgrade +capabilities. Implement the response selection in the runtime's existing Info +method. RunImage authorization is per request, never dependent on a previous +Info call or process-local session state: hosts may launch a fresh runtime +process for each operation. Host orchestration sends the permission bit only +for explicit recreation with negotiated ON_RUN. Legacy modes retain their +existing host-managed recreation path. + +This preserves old-host/new-provider interoperability through DELETE, and +new-host/old-provider interoperability through the older provider's existing +mode. It cannot retrofit validation-before-deletion into an old host's legacy +recreation path. + ## Development ```sh @@ -155,7 +188,11 @@ Logs support. Select mount profiles with `--mount-types all|none|bind|volume|tmp or a comma-separated subset. Unsupported mounts fail before workspace creation. Exec and Logs require a running workspace. Start and Stop are idempotent for existing resources; Delete is idempotent even for absent resources. Duplicate -RunImage returns AlreadyExists. Lifecycle failures include RuntimeError details. +RunImage returns AlreadyExists unless the optional `--recreate-on-run` profile +is enabled and that creation request explicitly sets `allow_recreate=true`. +This profile advertises ON_RUN only when discovery opts in; discovery alone +never authorizes replacement. The default profile retains STOP behavior. +Lifecycle failures include RuntimeError details. Output frames are limited to 32 KiB and preserve binary bytes. The SDK tests exercise lifecycle state across real plugin restarts, fault modes, @@ -179,7 +216,9 @@ conformance.Run(t, conformance.Options{ `Connect` must create a fresh client per subtest and register bounded process and connection cleanup with `t.Cleanup`. `Workspace` must provide an image and a -unique workspace ID that is initially absent. Configure the image's entrypoint +unique workspace ID that is initially absent, with `allow_recreate=false`. +The suite sets authorization explicitly only for negotiated replacement tests. +Configure the image's entrypoint so its finite log output matches `LogData` if the runtime advertises Logs. `Command` receives the scenario and workspace ID; return exact argv with `tty=false`. The suite does not choose a shell or construct commands for you. @@ -190,6 +229,9 @@ resources, malformed Exec frames, binary preservation, separate output channels, 12 MiB of simultaneous stdin/stdout/stderr, consumer backpressure, early exit, nonzero and signal exits, cancellation, deadlines, and finite merged Logs. Logs and provisioning preflight checks follow the advertised capabilities. +When ON_RUN is negotiated, running and stopped workspace checks require +unauthorized duplicates and invalid authorized requests to preserve the full +Find response, followed by a valid explicitly authorized replacement. Streaming comparisons use bounded buffers and require a single terminal exit frame after all output. Data frames must be nonempty and follow the SDK's recommended 32 KiB bound. Send goroutines are canceled and joined on failure. @@ -219,7 +261,8 @@ The fake executable's `conformance` mode uses scenario names as argv[0], with accepts exactly `/bin/sh`, `-c`, `printf 'shell stdout\n'`; the fake simulates that output rather than executing a host shell. `error ` injects a structured error, and `crash` emits readiness then exits after one input frame. -CI runs the full reusable suite against this executable on Linux, macOS, and +CI runs the full reusable suite against both the default and `--recreate-on-run` +profiles of this executable on Linux, macOS, and Windows, along with fault, mount-profile, crash/restart, and process-reap tests. Host launcher trust, startup cancellation, descendant process ownership, diff --git a/cmd/devsy-fake-runtime/main.go b/cmd/devsy-fake-runtime/main.go index e9500d4..61c396b 100644 --- a/cmd/devsy-fake-runtime/main.go +++ b/cmd/devsy-fake-runtime/main.go @@ -23,6 +23,12 @@ func main() { "private state directory (required; one active plugin owner)", ) flag.StringVar(&config.Mode, "mode", fake.Normal, "fixture scenario; see SDK README") + flag.BoolVar( + &config.RecreateOnRun, + "recreate-on-run", + false, + "enable negotiated recreation fixture", + ) delay := flag.Duration( "handshake-delay", time.Minute, diff --git a/conformance/fake/fake.go b/conformance/fake/fake.go index ebd0e29..5da1230 100644 --- a/conformance/fake/fake.go +++ b/conformance/fake/fake.go @@ -39,6 +39,8 @@ type Config struct { Mode string // MountTypes defaults to all when nil; an empty slice advertises no mounts. MountTypes []runtimev1.MountType + // RecreateOnRun enables negotiated, explicitly authorized replacement. + RecreateOnRun bool } // Driver implements the protocol without launching a real container or command. @@ -83,7 +85,10 @@ func New(config Config) (*Driver, error) { } // Info reports supported capabilities or the selected discovery failure. -func (d *Driver) Info(context.Context, *runtimev1.InfoRequest) (*runtimev1.InfoResponse, error) { +func (d *Driver) Info( + _ context.Context, + req *runtimev1.InfoRequest, +) (*runtimev1.InfoResponse, error) { if d.config.Mode == CrashAfterHandshake { os.Exit(23) } @@ -96,6 +101,12 @@ func (d *Driver) Info(context.Context, *runtimev1.InfoRequest) (*runtimev1.InfoR ProvisioningPreflight: true, Logs: true, ReusePreflight: true, }, } + if d.config.RecreateOnRun { + info.Capabilities.RecreateMode = runtimev1.RecreateMode_RECREATE_MODE_DELETE + if req.GetSupportsRecreateOnRun() { + info.Capabilities.RecreateMode = runtimev1.RecreateMode_RECREATE_MODE_ON_RUN + } + } if d.config.Mode == IncompatibleVersion { info.ApiMajor++ } diff --git a/conformance/fake/integration_test.go b/conformance/fake/integration_test.go index 5948c4a..05ac17d 100644 --- a/conformance/fake/integration_test.go +++ b/conformance/fake/integration_test.go @@ -8,6 +8,7 @@ import ( "os/exec" "path/filepath" "runtime" + "strconv" "testing" "time" @@ -61,10 +62,11 @@ func launchWithTimeout(t *testing.T, dir, mode string, timeout time.Duration) *h } type launchOptions struct { - directory string - mode string - timeout time.Duration - mounts string + directory string + mode string + timeout time.Duration + mounts string + recreateOnRun bool } func launchProcess(t *testing.T, options launchOptions) *hplugin.Client { @@ -83,6 +85,7 @@ func launchProcess(t *testing.T, options launchOptions) *hplugin.Client { options.mode, "--mount-types", options.mounts, + "--recreate-on-run="+strconv.FormatBool(options.recreateOnRun), ), AllowedProtocols: []hplugin.Protocol{hplugin.ProtocolGRPC}, StartTimeout: options.timeout, diff --git a/conformance/fake/lifecycle.go b/conformance/fake/lifecycle.go index cc2fd16..002c058 100644 --- a/conformance/fake/lifecycle.go +++ b/conformance/fake/lifecycle.go @@ -39,40 +39,22 @@ func (d *Driver) Find( return &runtimev1.FindResponse{Found: container != nil, Container: container}, nil } -// RunImage creates a running workspace and rejects duplicate creation. +// RunImage creates a workspace or explicitly replaces one in the ON_RUN profile. +// The fixture validates its supported configuration before writing any state. func (d *Driver) RunImage( ctx context.Context, req *runtimev1.RunImageRequest, ) (*runtimev1.RunImageResponse, error) { d.mu.Lock() defer d.mu.Unlock() - if err := validateWorkspace(ctx, req.GetWorkspaceId()); err != nil { - return nil, err - } - if req.GetImage() == "" { - return nil, runtimeError( - codes.InvalidArgument, - runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_INVALID_ARGUMENT, - "image is required", - false, - ) - } - if d.config.Mode == FailRun { - return nil, runtimeError( - codes.Internal, - runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_RUNTIME_FAILURE, - "fake image creation failed", - false, - ) - } - if err := d.validateMounts(req); err != nil { + if err := d.validateRunImage(ctx, req); err != nil { return nil, err } container, err := d.load(req.GetWorkspaceId()) if err != nil { return nil, storageError() } - if container != nil { + if container != nil && (!d.config.RecreateOnRun || !req.GetAllowRecreate()) { return nil, runtimeError( codes.AlreadyExists, runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_ALREADY_EXISTS, @@ -87,6 +69,29 @@ func (d *Driver) RunImage( return &runtimev1.RunImageResponse{}, nil } +func (d *Driver) validateRunImage(ctx context.Context, req *runtimev1.RunImageRequest) error { + if err := validateWorkspace(ctx, req.GetWorkspaceId()); err != nil { + return err + } + if req.GetImage() == "" { + return runtimeError( + codes.InvalidArgument, + runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_INVALID_ARGUMENT, + "image is required", + false, + ) + } + if d.config.Mode == FailRun { + return runtimeError( + codes.Internal, + runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_RUNTIME_FAILURE, + "fake image creation failed", + false, + ) + } + return d.validateMounts(req) +} + // Start normalizes an already-running workspace to success. func (d *Driver) Start( ctx context.Context, diff --git a/conformance/fake/recreate_test.go b/conformance/fake/recreate_test.go new file mode 100644 index 0000000..46a5b64 --- /dev/null +++ b/conformance/fake/recreate_test.go @@ -0,0 +1,219 @@ +package fake_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + "time" + + "github.com/devsy-org/devsy-runtime-sdk/conformance/fake" + "github.com/devsy-org/devsy-runtime-sdk/runtimev1" + hplugin "github.com/hashicorp/go-plugin" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/proto" +) + +func TestRecreateDiscoveryDoesNotGrantPermission(t *testing.T) { + for _, enabled := range []bool{false, true} { + driver, err := fake.New(fake.Config{StateDir: t.TempDir(), RecreateOnRun: enabled}) + if err != nil { + t.Fatal(err) + } + assertRecreateProfileNegotiation(t, driver, enabled) + request := &runtimev1.RunImageRequest{ + WorkspaceId: workspaceID, Image: testImage, AllowRecreate: enabled, + } + if _, err := driver.RunImage(testContext(t), request); err != nil { + t.Fatal(err) + } + // Even prior opt-in does not authorize false. Default STOP also rejects true. + request.AllowRecreate = !enabled + _, err = driver.RunImage(testContext(t), request) + assertRuntimeError(t, err, codes.AlreadyExists, + runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_ALREADY_EXISTS) + } +} + +func assertRecreateProfileNegotiation(t *testing.T, driver *fake.Driver, enabled bool) { + t.Helper() + for _, request := range []*runtimev1.InfoRequest{ + nil, {}, {SupportsRecreateOnRun: true}, {}, {SupportsRecreateOnRun: true}, + } { + info, err := driver.Info(testContext(t), request) + if err != nil { + t.Fatal(err) + } + want := runtimev1.RecreateMode_RECREATE_MODE_STOP + if enabled { + want = runtimev1.RecreateMode_RECREATE_MODE_DELETE + if request.GetSupportsRecreateOnRun() { + want = runtimev1.RecreateMode_RECREATE_MODE_ON_RUN + } + } + if info.GetCapabilities().GetRecreateMode() != want { + t.Fatalf("enabled=%v request=%v: got %v, want %v", enabled, request, info, want) + } + if err := runtimev1.ValidateInfo(info); err != nil { + t.Fatal(err) + } + info.Capabilities.RecreateMode = runtimev1.RecreateMode_RECREATE_MODE_UNSPECIFIED + info.Capabilities.MountTypes[0] = runtimev1.MountType_MOUNT_TYPE_UNSPECIFIED + } +} + +func launchRecreate(t *testing.T, directory string) *hplugin.Client { + t.Helper() + return launchProcess(t, launchOptions{ + directory: directory, mode: fake.Normal, timeout: 5 * time.Second, + mounts: allMounts, recreateOnRun: true, + }) +} + +func TestRecreateAuthorizationAcrossProcesses(t *testing.T) { + for _, state := range []string{"running", "stopped"} { + t.Run(state, func(t *testing.T) { + directory := t.TempDir() + process := launchRecreate(t, directory) + driver := connect(t, process) + createWorkspace(t, driver) + if state == "stopped" { + if _, err := driver.Stop(testContext(t), &runtimev1.StopRequest{ + WorkspaceId: workspaceID, + }); err != nil { + t.Fatal(err) + } + } + assertRecreateRPCNegotiation(t, driver) + assertRejectedRecreationPreservesState(t, driver, directory) + process.Kill() + if !process.Exited() { + t.Fatal("initial process was not reaped") + } + assertFreshProcessRecreation(t, directory) + }) + } +} + +func assertRecreateRPCNegotiation(t *testing.T, driver runtimev1.RuntimeDriverClient) { + t.Helper() + info, err := driver.Info( + testContext(t), + &runtimev1.InfoRequest{SupportsRecreateOnRun: true}, + ) + mode := info.GetCapabilities().GetRecreateMode() + if err != nil || mode != runtimev1.RecreateMode_RECREATE_MODE_ON_RUN { + t.Fatalf("opt-in discovery: %v, %v", info, err) + } + legacy, err := driver.Info(testContext(t), &runtimev1.InfoRequest{}) + mode = legacy.GetCapabilities().GetRecreateMode() + if err != nil || mode != runtimev1.RecreateMode_RECREATE_MODE_DELETE { + t.Fatalf("legacy discovery after opt-in: %v, %v", legacy, err) + } +} + +func assertFreshProcessRecreation(t *testing.T, directory string) { + t.Helper() + process := launchRecreate(t, directory) + driver := connect(t, process) + // No Info call in this process: authorization belongs to each RunImage. + assertRejectedRecreationPreservesState(t, driver, directory) + request := &runtimev1.RunImageRequest{ + WorkspaceId: workspaceID, Image: testImage, User: "2000", + Labels: []string{"generation=replaced"}, AllowRecreate: true, + } + if _, err := driver.RunImage(testContext(t), request); err != nil { + t.Fatal(err) + } + after := findRecreateWorkspace(t, driver) + if after.GetContainer().GetConfig().GetLabels()["generation"] != "replaced" || + after.GetContainer().GetConfig().GetUser() != "2000" || + after.GetContainer().GetState().GetStatus() != "running" { + t.Fatalf("replacement did not apply new configuration: %v", after) + } + process.Kill() + if !process.Exited() { + t.Fatal("replacement process was not reaped") + } + driver = connect(t, launchRecreate(t, directory)) + if !proto.Equal(after, findRecreateWorkspace(t, driver)) { + t.Fatal("replacement did not persist across process restart") + } + assertRejectedRecreationPreservesState(t, driver, directory) +} + +func assertRejectedRecreationPreservesState( + t *testing.T, + driver runtimev1.RuntimeDriverClient, + directory string, +) { + t.Helper() + before := findRecreateWorkspace(t, driver) + disk := snapshotRecreateDirectory(t, directory) + for _, test := range []struct { + request *runtimev1.RunImageRequest + code codes.Code + category runtimev1.RuntimeErrorCode + }{ + { + &runtimev1.RunImageRequest{WorkspaceId: workspaceID, Image: testImage}, + codes.AlreadyExists, runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_ALREADY_EXISTS, + }, + { + &runtimev1.RunImageRequest{WorkspaceId: workspaceID, AllowRecreate: true}, + codes.InvalidArgument, runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_INVALID_ARGUMENT, + }, + { + &runtimev1.RunImageRequest{ + WorkspaceId: workspaceID, Image: testImage, AllowRecreate: true, + WorkspaceMount: &runtimev1.Mount{Type: runtimev1.MountType(99)}, + }, + codes.Unimplemented, runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_UNSUPPORTED, + }, + } { + _, err := driver.RunImage(testContext(t), test.request) + assertRuntimeError(t, err, test.code, test.category) + if !proto.Equal(before, findRecreateWorkspace(t, driver)) { + t.Fatal("rejected request changed complete Find result") + } + after := snapshotRecreateDirectory(t, directory) + if len(disk) != len(after) { + t.Fatal("rejected request changed persisted file count") + } + for name, data := range disk { + if !bytes.Equal(data, after[name]) { + t.Fatalf("rejected request changed persisted file %q", name) + } + } + } +} + +func findRecreateWorkspace( + t *testing.T, + driver runtimev1.RuntimeDriverClient, +) *runtimev1.FindResponse { + t.Helper() + found, err := driver.Find(testContext(t), &runtimev1.FindRequest{WorkspaceId: workspaceID}) + if err != nil || !found.GetFound() { + t.Fatalf("find recreation workspace: %v, %v", found, err) + } + return found +} + +func snapshotRecreateDirectory(t *testing.T, directory string) map[string][]byte { + t.Helper() + entries, err := os.ReadDir(directory) + if err != nil { + t.Fatal(err) + } + result := make(map[string][]byte, len(entries)) + for _, entry := range entries { + // #nosec G304 -- Directory and files belong to this private fixture instance. + data, err := os.ReadFile(filepath.Join(directory, entry.Name())) + if err != nil { + t.Fatal(err) + } + result[entry.Name()] = data + } + return result +} diff --git a/conformance/fake/recreate_wire_test.go b/conformance/fake/recreate_wire_test.go new file mode 100644 index 0000000..dd59ce4 --- /dev/null +++ b/conformance/fake/recreate_wire_test.go @@ -0,0 +1,54 @@ +package fake_test + +import ( + "bytes" + "testing" + + "github.com/devsy-org/devsy-runtime-sdk/runtimev1" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" +) + +func TestLegacyInfoDescriptorIgnoresOptIn(t *testing.T) { + // API 1.2 InfoRequest is exactly an empty proto3 message at df5943d. + // This separate descriptor has no new field, unlike a same-schema round trip. + file, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ + Name: new("legacy_info.proto"), Syntax: new("proto3"), + Package: new("devsy.runtime.v1"), + MessageType: []*descriptorpb.DescriptorProto{{Name: new("InfoRequest")}}, + }, nil) + if err != nil { + t.Fatal(err) + } + legacy := dynamicpb.NewMessage(file.Messages().Get(0)) + wire, err := proto.Marshal(&runtimev1.InfoRequest{SupportsRecreateOnRun: true}) + if err != nil { + t.Fatal(err) + } + if err := proto.Unmarshal(wire, legacy); err != nil { + t.Fatal(err) + } + if legacy.Descriptor().Fields().Len() != 0 || !bytes.Equal(legacy.GetUnknown(), wire) { + t.Fatal("new opt-in was not retained as an unrecognized legacy field") + } + assertLegacyHostDoesNotOptIn(t, legacy) +} + +func assertLegacyHostDoesNotOptIn(t *testing.T, legacy *dynamicpb.Message) { + t.Helper() + // An old host starts with its own empty request, not an echoed unknown payload. + legacy.Reset() + wire, err := proto.Marshal(legacy) + if err != nil || len(wire) != 0 { + t.Fatalf("empty legacy discovery payload: %x, %v", wire, err) + } + current := new(runtimev1.InfoRequest) + if err := proto.Unmarshal(wire, current); err != nil { + t.Fatal(err) + } + if current.GetSupportsRecreateOnRun() { + t.Fatal("old host unexpectedly opted into ON_RUN") + } +} diff --git a/conformance/fake/suite_test.go b/conformance/fake/suite_test.go index 5125c35..f8d8460 100644 --- a/conformance/fake/suite_test.go +++ b/conformance/fake/suite_test.go @@ -2,6 +2,7 @@ package fake_test import ( "testing" + "time" "github.com/devsy-org/devsy-runtime-sdk/conformance" "github.com/devsy-org/devsy-runtime-sdk/conformance/fake" @@ -9,23 +10,34 @@ import ( ) func TestProtocolConformance(t *testing.T) { - conformance.Run(t, conformance.Options{ - Connect: func(t *testing.T) runtimev1.RuntimeDriverClient { - process := launch(t, t.TempDir(), fake.Conformance) - t.Cleanup(func() { - process.Kill() - if !process.Exited() { - t.Error("conformance plugin was not reaped") - } + for _, profile := range []struct { + name string + recreateOnRun bool + }{{"default", false}, {"on-run", true}} { + t.Run(profile.name, func(t *testing.T) { + conformance.Run(t, conformance.Options{ + Connect: func(t *testing.T) runtimev1.RuntimeDriverClient { + process := launchProcess(t, launchOptions{ + directory: t.TempDir(), mode: fake.Conformance, + timeout: 5 * time.Second, mounts: allMounts, + recreateOnRun: profile.recreateOnRun, + }) + t.Cleanup(func() { + process.Kill() + if !process.Exited() { + t.Error("conformance plugin was not reaped") + } + }) + return connect(t, process) + }, + Workspace: func(t *testing.T) *runtimev1.RunImageRequest { + return &runtimev1.RunImageRequest{WorkspaceId: t.Name(), Image: "fake:image"} + }, + Command: conformanceCommand, + LogData: []byte("fake stdout\x00\xff\nfake stderr\n"), }) - return connect(t, process) - }, - Workspace: func(t *testing.T) *runtimev1.RunImageRequest { - return &runtimev1.RunImageRequest{WorkspaceId: t.Name(), Image: "fake:image"} - }, - Command: conformanceCommand, - LogData: []byte("fake stdout\x00\xff\nfake stderr\n"), - }) + }) + } } func conformanceCommand(scenario conformance.Scenario, workspaceID string) *runtimev1.ExecStart { diff --git a/conformance/recreate.go b/conformance/recreate.go new file mode 100644 index 0000000..d6dbdb8 --- /dev/null +++ b/conformance/recreate.go @@ -0,0 +1,97 @@ +package conformance + +import ( + "testing" + + "github.com/devsy-org/devsy-runtime-sdk/runtimev1" + "google.golang.org/protobuf/proto" +) + +func recreateOnRun(t *testing.T, options Options) { + for _, state := range []string{"running", "stopped"} { + t.Run(state, func(t *testing.T) { + s := open(t, options) + info, err := s.client.Info(s.ctx, &runtimev1.InfoRequest{SupportsRecreateOnRun: true}) + if err != nil { + t.Fatal(err) + } + if err := runtimev1.ValidateInfo(info); err != nil { + t.Fatal(err) + } + if info.GetCapabilities(). + GetRecreateMode() != + runtimev1.RecreateMode_RECREATE_MODE_ON_RUN { + t.Skip("runtime does not advertise recreate on run") + } + if s.info.GetCapabilities(). + GetRecreateMode() != + runtimev1.RecreateMode_RECREATE_MODE_DELETE { + t.Fatal("ON_RUN runtime must advertise DELETE without opt-in") + } + s.create(t) + if state == "stopped" { + if _, err := s.client.Stop(s.ctx, &runtimev1.StopRequest{ + WorkspaceId: s.request.GetWorkspaceId(), + }); err != nil { + t.Fatal(err) + } + } + s.assertState(t, state) + s.checkRecreation(t) + }) + } +} + +func (s *session) checkRecreation(t *testing.T) { + t.Helper() + before := s.recreateSnapshot(t) + _, err := s.client.RunImage(s.ctx, s.request) + RequireRuntimeError(t, err, runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_ALREADY_EXISTS) + s.assertRecreateSnapshot(t, before) + invalid := proto.CloneOf(s.request) + invalid.AllowRecreate, invalid.Image = true, "" + _, err = s.client.RunImage(s.ctx, invalid) + RequireRuntimeError(t, err, runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_INVALID_ARGUMENT) + s.assertRecreateSnapshot(t, before) + replacement := proto.CloneOf(s.request) + replacement.AllowRecreate = true + const generationKey = "devsy.conformance.recreate-generation" + if _, exists := before.GetContainer().GetConfig().GetLabels()[generationKey]; exists { + t.Fatal("adapter workspace already uses the conformance recreation label") + } + replacement.Labels = append(replacement.Labels, generationKey+"="+t.Name()) + if _, err := s.client.RunImage(s.ctx, replacement); err != nil { + t.Fatal(err) + } + s.assertState(t, "running") + after := s.recreateSnapshot(t) + if after.GetContainer().GetConfig().GetLabels()[generationKey] != t.Name() { + t.Fatal("authorized replacement did not apply the requested generation") + } + replacement.AllowRecreate = false + _, err = s.client.RunImage(s.ctx, replacement) + RequireRuntimeError(t, err, runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_ALREADY_EXISTS) + s.assertRecreateSnapshot(t, after) +} + +func (s *session) recreateSnapshot(t *testing.T) *runtimev1.FindResponse { + t.Helper() + found, err := s.client.Find( + s.ctx, + &runtimev1.FindRequest{WorkspaceId: s.request.GetWorkspaceId()}, + ) + if err != nil { + t.Fatal(err) + } + if !found.GetFound() { + t.Fatal("recreation workspace disappeared") + } + return found +} + +func (s *session) assertRecreateSnapshot(t *testing.T, before *runtimev1.FindResponse) { + t.Helper() + if after := s.recreateSnapshot(t); !proto.Equal(before, after) { + t.Fatalf("rejected recreation mutated workspace: before=%v after=%v", before, after) + } +} diff --git a/conformance/suite.go b/conformance/suite.go index 4f2e95a..9a3ddaa 100644 --- a/conformance/suite.go +++ b/conformance/suite.go @@ -62,6 +62,7 @@ func Run(t *testing.T, options Options) { {"discovery", discovery}, {"lifecycle", lifecycle}, {"reuse-preflight", reusePreflight}, + {"recreate-on-run", recreateOnRun}, {"binary-echo", binaryEcho}, {"empty-stdin", emptyInput}, {"arguments", arguments}, @@ -101,6 +102,22 @@ func open(t *testing.T, options Options) *session { if request == nil || request.GetWorkspaceId() == "" || request.GetImage() == "" { t.Fatal("Workspace requires a unique workspace_id and image") } + info := discoveryWithoutOptIn(ctx, t, client) + if request.GetAllowRecreate() { + t.Fatal("Workspace must not authorize replacement for initial creation") + } + return &session{ + ctx: ctx, client: client, request: proto.CloneOf(request), + info: info, options: options, + } +} + +func discoveryWithoutOptIn( + ctx context.Context, + t *testing.T, + client runtimev1.RuntimeDriverClient, +) *runtimev1.InfoResponse { + t.Helper() info, err := client.Info(ctx, &runtimev1.InfoRequest{}) if err != nil { t.Fatal(err) @@ -108,10 +125,10 @@ func open(t *testing.T, options Options) *session { if err := runtimev1.ValidateInfo(info); err != nil { t.Fatal(err) } - return &session{ - ctx: ctx, client: client, request: proto.CloneOf(request), - info: info, options: options, + if info.GetCapabilities().GetRecreateMode() == runtimev1.RecreateMode_RECREATE_MODE_ON_RUN { + t.Fatal("runtime advertised ON_RUN without host opt-in") } + return info } func (s *session) create(t *testing.T) { diff --git a/proto/devsy/runtime/v1/runtime.proto b/proto/devsy/runtime/v1/runtime.proto index bd59cab..fd53105 100644 --- a/proto/devsy/runtime/v1/runtime.proto +++ b/proto/devsy/runtime/v1/runtime.proto @@ -26,7 +26,12 @@ service RuntimeDriver { rpc Logs(LogsRequest) returns (stream OutputChunk); } -message InfoRequest {} +message InfoRequest { + // The host understands RECREATE_MODE_ON_RUN (API 1.3). This is capability + // negotiation, not permission to replace any workspace. An ON_RUN-capable + // runtime must report RECREATE_MODE_DELETE when this field is false or absent. + bool supports_recreate_on_run = 1; +} message InfoResponse { uint32 api_major = 1; @@ -69,6 +74,10 @@ enum RecreateMode { RECREATE_MODE_UNSPECIFIED = 0; RECREATE_MODE_DELETE = 1; RECREATE_MODE_STOP = 2; + // The host leaves an existing workspace intact before RunImage. Replacement + // requires allow_recreate and validation of the complete effective config + // before destruction. Advertise only to opted-in hosts; requires runtime API 1.3. + RECREATE_MODE_ON_RUN = 3; } message PreflightRequest { @@ -170,6 +179,11 @@ message RunImageRequest { // The host will provision the developer environment after starting this image. // Runtimes must not assume the final developer identity exists in the image. bool dockerless = 21; + // Explicit permission for an ON_RUN runtime to replace an existing workspace + // (API 1.3). False or absent never authorizes replacement. Even when true, the + // complete effective config must pass validation before destruction. Later + // materialization or launch failure does not promise rollback of the old resource. + bool allow_recreate = 22; } message Mount { diff --git a/runtimev1/runtime.pb.go b/runtimev1/runtime.pb.go index 7b56743..6ef7da3 100644 --- a/runtimev1/runtime.pb.go +++ b/runtimev1/runtime.pb.go @@ -81,6 +81,10 @@ const ( RecreateMode_RECREATE_MODE_UNSPECIFIED RecreateMode = 0 RecreateMode_RECREATE_MODE_DELETE RecreateMode = 1 RecreateMode_RECREATE_MODE_STOP RecreateMode = 2 + // The host leaves an existing workspace intact before RunImage. Replacement + // requires allow_recreate and validation of the complete effective config + // before destruction. Advertise only to opted-in hosts; requires runtime API 1.3. + RecreateMode_RECREATE_MODE_ON_RUN RecreateMode = 3 ) // Enum value maps for RecreateMode. @@ -89,11 +93,13 @@ var ( 0: "RECREATE_MODE_UNSPECIFIED", 1: "RECREATE_MODE_DELETE", 2: "RECREATE_MODE_STOP", + 3: "RECREATE_MODE_ON_RUN", } RecreateMode_value = map[string]int32{ "RECREATE_MODE_UNSPECIFIED": 0, "RECREATE_MODE_DELETE": 1, "RECREATE_MODE_STOP": 2, + "RECREATE_MODE_ON_RUN": 3, } ) @@ -253,9 +259,13 @@ func (RuntimeErrorCode) EnumDescriptor() ([]byte, []int) { } type InfoRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + // The host understands RECREATE_MODE_ON_RUN (API 1.3). This is capability + // negotiation, not permission to replace any workspace. An ON_RUN-capable + // runtime must report RECREATE_MODE_DELETE when this field is false or absent. + SupportsRecreateOnRun bool `protobuf:"varint,1,opt,name=supports_recreate_on_run,json=supportsRecreateOnRun,proto3" json:"supports_recreate_on_run,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *InfoRequest) Reset() { @@ -288,6 +298,13 @@ func (*InfoRequest) Descriptor() ([]byte, []int) { return file_devsy_runtime_v1_runtime_proto_rawDescGZIP(), []int{0} } +func (x *InfoRequest) GetSupportsRecreateOnRun() bool { + if x != nil { + return x.SupportsRecreateOnRun + } + return false +} + type InfoResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ApiMajor uint32 `protobuf:"varint,1,opt,name=api_major,json=apiMajor,proto3" json:"api_major,omitempty"` @@ -1196,7 +1213,12 @@ type RunImageRequest struct { RemoteUser string `protobuf:"bytes,20,opt,name=remote_user,json=remoteUser,proto3" json:"remote_user,omitempty"` // The host will provision the developer environment after starting this image. // Runtimes must not assume the final developer identity exists in the image. - Dockerless bool `protobuf:"varint,21,opt,name=dockerless,proto3" json:"dockerless,omitempty"` + Dockerless bool `protobuf:"varint,21,opt,name=dockerless,proto3" json:"dockerless,omitempty"` + // Explicit permission for an ON_RUN runtime to replace an existing workspace + // (API 1.3). False or absent never authorizes replacement. Even when true, the + // complete effective config must pass validation before destruction. Later + // materialization or launch failure does not promise rollback of the old resource. + AllowRecreate bool `protobuf:"varint,22,opt,name=allow_recreate,json=allowRecreate,proto3" json:"allow_recreate,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1378,6 +1400,13 @@ func (x *RunImageRequest) GetDockerless() bool { return false } +func (x *RunImageRequest) GetAllowRecreate() bool { + if x != nil { + return x.AllowRecreate + } + return false +} + type Mount struct { state protoimpl.MessageState `protogen:"open.v1"` Type MountType `protobuf:"varint,1,opt,name=type,proto3,enum=devsy.runtime.v1.MountType" json:"type,omitempty"` @@ -2380,8 +2409,9 @@ var File_devsy_runtime_v1_runtime_proto protoreflect.FileDescriptor const file_devsy_runtime_v1_runtime_proto_rawDesc = "" + "\n" + - "\x1edevsy/runtime/v1/runtime.proto\x12\x10devsy.runtime.v1\"\r\n" + - "\vInfoRequest\"\xa0\x02\n" + + "\x1edevsy/runtime/v1/runtime.proto\x12\x10devsy.runtime.v1\"F\n" + + "\vInfoRequest\x127\n" + + "\x18supports_recreate_on_run\x18\x01 \x01(\bR\x15supportsRecreateOnRun\"\xa0\x02\n" + "\fInfoResponse\x12\x1b\n" + "\tapi_major\x18\x01 \x01(\rR\bapiMajor\x12\x1b\n" + "\tapi_minor\x18\x02 \x01(\rR\bapiMinor\x12\x1f\n" + @@ -2444,7 +2474,7 @@ const file_devsy_runtime_v1_runtime_proto_rawDesc = "" + "\x19TargetArchitectureRequest\x12!\n" + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\"@\n" + "\x1aTargetArchitectureResponse\x12\"\n" + - "\farchitecture\x18\x01 \x01(\tR\farchitecture\"\xed\x06\n" + + "\farchitecture\x18\x01 \x01(\tR\farchitecture\"\x94\a\n" + "\x0fRunImageRequest\x12!\n" + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12\x14\n" + "\x05image\x18\x02 \x01(\tR\x05image\x12.\n" + @@ -2474,7 +2504,8 @@ const file_devsy_runtime_v1_runtime_proto_rawDesc = "" + "remoteUser\x12\x1e\n" + "\n" + "dockerless\x18\x15 \x01(\bR\n" + - "dockerless\x1a>\n" + + "dockerless\x12%\n" + + "\x0eallow_recreate\x18\x16 \x01(\bR\rallowRecreate\x1a>\n" + "\x10EnvironmentEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\r\n" + @@ -2546,11 +2577,12 @@ const file_devsy_runtime_v1_runtime_proto_rawDesc = "" + "\x16MOUNT_TYPE_UNSPECIFIED\x10\x00\x12\x13\n" + "\x0fMOUNT_TYPE_BIND\x10\x01\x12\x15\n" + "\x11MOUNT_TYPE_VOLUME\x10\x02\x12\x14\n" + - "\x10MOUNT_TYPE_TMPFS\x10\x03*_\n" + + "\x10MOUNT_TYPE_TMPFS\x10\x03*y\n" + "\fRecreateMode\x12\x1d\n" + "\x19RECREATE_MODE_UNSPECIFIED\x10\x00\x12\x18\n" + "\x14RECREATE_MODE_DELETE\x10\x01\x12\x16\n" + - "\x12RECREATE_MODE_STOP\x10\x02*]\n" + + "\x12RECREATE_MODE_STOP\x10\x02\x12\x18\n" + + "\x14RECREATE_MODE_ON_RUN\x10\x03*]\n" + "\x13GpuRequirementValue\x12\x13\n" + "\x0fGPU_UNSPECIFIED\x10\x00\x12\r\n" + "\tGPU_FALSE\x10\x01\x12\x10\n" + diff --git a/runtimev1/validate.go b/runtimev1/validate.go index d2ef14e..eeff2e4 100644 --- a/runtimev1/validate.go +++ b/runtimev1/validate.go @@ -10,7 +10,7 @@ const ( // APIMajor changes when the runtime wire contract breaks compatibility. APIMajor uint32 = 1 // APIMinor tracks compatible additions within the current major generation. - APIMinor uint32 = 2 + APIMinor uint32 = 3 // ChunkSize is the recommended maximum payload for each Exec data frame. ChunkSize = 32 * 1024 ) @@ -37,29 +37,39 @@ func ValidateInfo(info *InfoResponse) error { if info.DriverName == "" || info.DriverVersion == "" || info.RuntimeName == "" { return errors.New("runtime Info requires driver name, driver version, and runtime name") } - if info.GetCapabilities().GetReusePreflight() && info.ApiMinor < 2 { - return errors.New("reuse preflight requires API 1.2 or newer") - } - return validateCapabilities(info.DriverName, info.Capabilities) + return validateCapabilities(info.DriverName, info.ApiMinor, info.Capabilities) } -func validateCapabilities(driverName string, caps *Capabilities) error { +func validateCapabilities(driverName string, apiMinor uint32, caps *Capabilities) error { if caps == nil { return errors.New("runtime Info capabilities are missing") } - if caps.RecreateMode != RecreateMode_RECREATE_MODE_DELETE && - caps.RecreateMode != RecreateMode_RECREATE_MODE_STOP { - return fmt.Errorf( - "runtime %q has unsupported recreate mode %d", - driverName, - caps.RecreateMode, - ) + if caps.ReusePreflight && apiMinor < 2 { + return errors.New("reuse preflight requires API 1.2 or newer") + } + if err := validateRecreateMode(driverName, apiMinor, caps.RecreateMode); err != nil { + return err } for _, mount := range caps.MountTypes { - if mount != MountType_MOUNT_TYPE_BIND && mount != MountType_MOUNT_TYPE_VOLUME && - mount != MountType_MOUNT_TYPE_TMPFS { + switch mount { + case MountType_MOUNT_TYPE_BIND, MountType_MOUNT_TYPE_VOLUME, MountType_MOUNT_TYPE_TMPFS: + default: return fmt.Errorf("runtime %q has unsupported mount type %d", driverName, mount) } } return nil } + +func validateRecreateMode(driverName string, apiMinor uint32, mode RecreateMode) error { + switch mode { + case RecreateMode_RECREATE_MODE_DELETE, RecreateMode_RECREATE_MODE_STOP: + return nil + case RecreateMode_RECREATE_MODE_ON_RUN: + if apiMinor < 3 { + return errors.New("recreate on run requires API 1.3 or newer") + } + return nil + default: + return fmt.Errorf("runtime %q has unsupported recreate mode %d", driverName, mode) + } +} diff --git a/runtimev1/validate_test.go b/runtimev1/validate_test.go index 6d6fe3d..e8eebef 100644 --- a/runtimev1/validate_test.go +++ b/runtimev1/validate_test.go @@ -1,6 +1,7 @@ package runtimev1_test import ( + "bytes" "strings" "testing" @@ -107,6 +108,107 @@ func validateInfoCases(t *testing.T, cases []infoValidationCase) { } } +// ValidateInfo checks response compatibility; provider Info must separately +// gate ON_RUN on the request's opt-in because this API accepts only a response. +func TestValidateInfoRecreateOnRunVersion(t *testing.T) { + for _, minor := range []uint32{0, 1, 2, 3, 99} { + info := validInfo() + info.ApiMinor = minor + info.Capabilities.RecreateMode = v1.RecreateMode_RECREATE_MODE_ON_RUN + err := v1.ValidateInfo(info) + if minor < 3 { + if err == nil || !strings.Contains(err.Error(), "requires API 1.3") { + t.Errorf("ON_RUN at API 1.%d: got %v, want API 1.3 requirement", minor, err) + } + } else if err != nil { + t.Errorf("ON_RUN at API 1.%d: %v", minor, err) + } + for _, mode := range []v1.RecreateMode{ + v1.RecreateMode_RECREATE_MODE_DELETE, + v1.RecreateMode_RECREATE_MODE_STOP, + } { + info.Capabilities.RecreateMode = mode + if err := v1.ValidateInfo(info); err != nil { + t.Errorf("legacy mode %s at API 1.%d: %v", mode, minor, err) + } + } + } +} + +func TestInfoRecreateOptInWire(t *testing.T) { + var absent *v1.InfoRequest + if absent.GetSupportsRecreateOnRun() { + t.Fatal("nil discovery request opted into ON_RUN") + } + for _, wire := range [][]byte{nil, {0x08, 0x00}} { + decoded := new(v1.InfoRequest) + unmarshalWire(t, wire, decoded) + if decoded.GetSupportsRecreateOnRun() { + t.Fatal("omitted or explicit false discovery field opted into ON_RUN") + } + if encoded := marshalWire(t, decoded); len(encoded) != 0 { + t.Fatalf("zero-value Info request changed legacy payload: %x", encoded) + } + } + input := &v1.InfoRequest{SupportsRecreateOnRun: true} + wire := marshalWire(t, input) + if !bytes.Equal(wire, []byte{0x08, 0x01}) { + t.Fatalf("opt-in must use field 1: %x", wire) + } + decoded := new(v1.InfoRequest) + unmarshalWire(t, wire, decoded) + if !decoded.GetSupportsRecreateOnRun() { + t.Fatal("discovery opt-in lost in transit") + } +} + +func TestRunImageRecreateAuthorizationWire(t *testing.T) { + var absent *v1.RunImageRequest + if absent.GetAllowRecreate() { + t.Fatal("nil creation request authorized replacement") + } + for _, wire := range [][]byte{nil, {0xb0, 0x01, 0x00}} { + decoded := new(v1.RunImageRequest) + unmarshalWire(t, wire, decoded) + if decoded.GetAllowRecreate() { + t.Fatal("omitted or explicit false creation field authorized replacement") + } + if encoded := marshalWire(t, decoded); len(encoded) != 0 { + t.Fatalf("zero-value authorization changed legacy payload: %x", encoded) + } + } + wire := marshalWire(t, &v1.RunImageRequest{AllowRecreate: true}) + if !bytes.Equal(wire, []byte{0xb0, 0x01, 0x01}) { + t.Fatalf("authorization must use field 22: %x", wire) + } + input := &v1.RunImageRequest{ + WorkspaceId: "preserved-workspace", Image: "preserved-image", RemoteUser: "developer", + Dockerless: true, Privileged: new(false), AllowRecreate: true, + } + wire = marshalWire(t, input) + decoded := new(v1.RunImageRequest) + unmarshalWire(t, wire, decoded) + if !proto.Equal(input, decoded) { + t.Fatal("creation authorization or existing creation fields changed in transit") + } +} + +func marshalWire(t *testing.T, message proto.Message) []byte { + t.Helper() + wire, err := proto.Marshal(message) + if err != nil { + t.Fatal(err) + } + return wire +} + +func unmarshalWire(t *testing.T, wire []byte, message proto.Message) { + t.Helper() + if err := proto.Unmarshal(wire, message); err != nil { + t.Fatal(err) + } +} + func TestOptionalFlagsPreservePresence(t *testing.T) { input := &v1.RunImageRequest{Privileged: new(false)} data, err := proto.Marshal(input) From d4d2d468c410fd41fb40ac879f72da9796251832 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 11 Oct 2026 11:40:41 -0600 Subject: [PATCH 2/2] test(conformance): preserve state after invalid recreate mounts --- conformance/recreate.go | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/conformance/recreate.go b/conformance/recreate.go index d6dbdb8..03b99fc 100644 --- a/conformance/recreate.go +++ b/conformance/recreate.go @@ -53,6 +53,7 @@ func (s *session) checkRecreation(t *testing.T) { _, err = s.client.RunImage(s.ctx, invalid) RequireRuntimeError(t, err, runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_INVALID_ARGUMENT) s.assertRecreateSnapshot(t, before) + s.checkInvalidRecreateMounts(t, before) replacement := proto.CloneOf(s.request) replacement.AllowRecreate = true const generationKey = "devsy.conformance.recreate-generation" @@ -74,6 +75,46 @@ func (s *session) checkRecreation(t *testing.T) { s.assertRecreateSnapshot(t, after) } +func (s *session) checkInvalidRecreateMounts(t *testing.T, before *runtimev1.FindResponse) { + t.Helper() + for _, test := range []struct { + name string + workspaceMount *runtimev1.Mount + mount *runtimev1.Mount + }{ + { + name: "unsupported workspace mount", + workspaceMount: &runtimev1.Mount{ + Type: runtimev1.MountType(99), + Target: "/unsupported-workspace-mount", + }, + }, + { + name: "unsupported additional mount", + mount: &runtimev1.Mount{ + Type: runtimev1.MountType(99), + Target: "/unsupported-additional-mount", + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + invalidMount := proto.CloneOf(s.request) + invalidMount.AllowRecreate = true + if test.workspaceMount != nil { + invalidMount.WorkspaceMount = proto.CloneOf(test.workspaceMount) + } else { + invalidMount.Mounts = append(invalidMount.Mounts, proto.CloneOf(test.mount)) + } + if invalidMount.GetImage() == "" { + t.Fatal("invalid-mount request must retain a nonempty image") + } + _, err := s.client.RunImage(s.ctx, invalidMount) + RequireRuntimeError(t, err, runtimev1.RuntimeErrorCode_RUNTIME_ERROR_CODE_UNSUPPORTED) + s.assertRecreateSnapshot(t, before) + }) + } +} + func (s *session) recreateSnapshot(t *testing.T) *runtimev1.FindResponse { t.Helper() found, err := s.client.Find(