diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 9771dd154..46c228a85 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -678,6 +678,15 @@ jobs: test-timeout: 1200s job-timeout-minutes: 25 + - label: up-provider-microsandbox-storage + runner: ubuntu-latest + free-disk-space: false + install-kind: false + requires-secret: false + install-microsandbox: true + test-timeout: 1200s + job-timeout-minutes: 25 + # Snapshot tests - label: snapshot diff --git a/e2e/tests/up/provider_microsandbox_storage.go b/e2e/tests/up/provider_microsandbox_storage.go new file mode 100644 index 000000000..f0e60ee3f --- /dev/null +++ b/e2e/tests/up/provider_microsandbox_storage.go @@ -0,0 +1,204 @@ +package up + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const microsandboxStorageMarker = "storage-host-marker" + +type microsandboxStorageConfig struct { + Image microsandboxStorageImage `json:"image"` +} + +type microsandboxStorageImage struct { + OCI *microsandboxStorageOCI `json:"Oci"` +} + +type microsandboxStorageOCI struct { + RootDisk *microsandboxStorageDisk `json:"root_disk"` +} + +type microsandboxStorageDisk struct { + Kind string `json:"kind"` + SizeMiB uint64 `json:"size_mib"` +} + +var _ = ginkgo.Describe("microsandbox storage parity", + ginkgo.Label("up-provider-microsandbox-storage"), func() { + for _, provider := range []struct{ name, source string }{ + {"builtin-storage", microsandboxResourceBuiltin}, + {"external-storage", microsandboxExternalProvider}, + } { + ginkgo.Context(provider.name, func() { + var f *framework.Framework + var workspace string + + ginkgo.BeforeEach(func(ctx context.Context) { + skipIfNoMicrosandbox(ctx) + ginkgo.GinkgoT().Setenv("DEVSY_HOME", ginkgo.GinkgoT().TempDir()) + ginkgo.GinkgoT().Setenv("DEVSY_CONFIG", "") + initialDir, err := os.Getwd() + framework.ExpectNoError(err) + f = framework.NewDefaultFramework(filepath.Join(initialDir, "bin")) + workspace, err = framework.CreateTempDir() + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, workspace) + framework.ExpectNoError( + f.DevsyProviderAdd(ctx, provider.source, "--name", provider.name), + ) + ginkgo.DeferCleanup(func(cleanupCtx context.Context) error { + cleanupCtx, cancel := context.WithTimeout( + context.WithoutCancel(cleanupCtx), + time.Minute, + ) + defer cancel() + return f.DevsyProviderDelete(cleanupCtx, provider.name) + }) + ginkgo.DeferCleanup(f.CleanupWorkspace, workspace) + }) + + ginkgo.DescribeTable( + "sizes and preserves the managed root until recreation", + func(ctx context.Context, options []string, storage string, sizeMiB uint64) { + framework.ExpectNoError(f.DevsyProviderUse(ctx, provider.name, options...)) + var requirements *config.HostRequirements + if storage != "" { + requirements = &config.HostRequirements{Storage: storage} + } + writeMicrosandboxResourceConfig(workspace, requirements) + framework.ExpectNoError(os.WriteFile( + filepath.Join( + workspace, + microsandboxStorageMarker, + ), + []byte("host-preserved\n"), + 0o600, + )) + framework.ExpectNoError( + f.DevsyUp(ctx, workspace, "--devcontainer", ".devcontainer.json"), + ) + sandbox := microsandboxResourceSandbox(ctx, f, workspace) + assertMicrosandboxStorage(ctx, f, workspace, sizeMiB) + assertMicrosandboxStorageWorkspace(ctx, f, workspace) + createdAt := microsandboxCreationTime(ctx, sandbox) + bootID := microsandboxStorageBootID(ctx, f, workspace) + out, err := f.DevsySSHOnce(ctx, workspace, + "printf 'root-preserved\\n' > /root/storage-parity-marker") + framework.ExpectNoError(err, out) + + framework.ExpectNoError(f.DevsyWorkspaceStop(ctx, workspace)) + framework.ExpectNoError(f.DevsyUp(ctx, workspace)) + gomega.Expect(microsandboxCreationTime(ctx, sandbox)). + To(gomega.Equal(createdAt)) + restartedBootID := microsandboxStorageBootID(ctx, f, workspace) + gomega.Expect(restartedBootID).NotTo(gomega.Equal(bootID)) + out, err = f.DevsySSHOnce(ctx, workspace, "cat /root/storage-parity-marker") + framework.ExpectNoError(err) + gomega.Expect(out).To(gomega.Equal("root-preserved\n")) + assertMicrosandboxStorage(ctx, f, workspace, sizeMiB) + assertMicrosandboxStorageWorkspace(ctx, f, workspace) + + framework.ExpectNoError(f.DevsyUpRecreate(ctx, workspace)) + gomega.Expect(microsandboxCreationTime(ctx, sandbox)). + NotTo(gomega.Equal(createdAt)) + gomega.Expect(microsandboxStorageBootID(ctx, f, workspace)). + NotTo(gomega.Equal(restartedBootID)) + out, err = f.DevsySSHOnce( + ctx, + workspace, + "test ! -e /root/storage-parity-marker", + ) + framework.ExpectNoError(err, out) + assertMicrosandboxStorage(ctx, f, workspace, sizeMiB) + assertMicrosandboxStorageWorkspace(ctx, f, workspace) + }, + ginkgo.Entry( + "runtime default", + nil, + "", + uint64(4096), + ginkgo.SpecTimeout(framework.TimeoutLong()), + ), + ginkgo.Entry("provider overrides host requirements", + []string{"--option", "MICROSANDBOX_STORAGE=2"}, "3gb", uint64(2048), + ginkgo.SpecTimeout(framework.TimeoutLong())), + ginkgo.Entry("zero provider storage rounds host requirements up to GiB", + []string{"--option", "MICROSANDBOX_STORAGE=0"}, "1537mb", uint64(2048), + ginkgo.SpecTimeout(framework.TimeoutLong())), + ) + }) + } + }) + +func assertMicrosandboxStorage( + ctx context.Context, + f *framework.Framework, + workspace string, + sizeMiB uint64, +) { + sandbox := microsandboxResourceSandbox(ctx, f, workspace) + out, err := microsandboxResourceCommand(ctx, "inspect", sandbox, "--format", "json") + framework.ExpectNoError(err, string(out)) + var inspection struct { + ActiveConfig *microsandboxStorageConfig `json:"active_config"` + } + framework.ExpectNoError(json.Unmarshal(out, &inspection), string(out)) + gomega.Expect(inspection.ActiveConfig).NotTo(gomega.BeNil()) + gomega.Expect(inspection.ActiveConfig.Image.OCI).NotTo(gomega.BeNil()) + rootDisk := inspection.ActiveConfig.Image.OCI.RootDisk + gomega.Expect(rootDisk).NotTo(gomega.BeNil()) + gomega.Expect(rootDisk.Kind).To(gomega.Equal("managed")) + gomega.Expect(rootDisk.SizeMiB).To(gomega.Equal(sizeMiB)) + + capacity, err := f.DevsySSHOnce(ctx, workspace, "stat -f -c '%S %b' /") + framework.ExpectNoError(err) + var blockSize, blocks uint64 + n, err := fmt.Sscanf(strings.TrimSpace(capacity), "%d %d", &blockSize, &blocks) + framework.ExpectNoError(err) + gomega.Expect(n).To(gomega.Equal(2)) + gomega.Expect(blockSize).To(gomega.BeNumerically(">", 0)) + totalBytes := blockSize * blocks + expectedBytes := sizeMiB * 1024 * 1024 + // Ext4 metadata reduces statfs total blocks; the 2 GiB and 4 GiB ranges remain disjoint. + gomega.Expect(totalBytes).To(gomega.BeNumerically(">=", expectedBytes*9/10)) + gomega.Expect(totalBytes).To(gomega.BeNumerically("<=", expectedBytes)) +} + +func assertMicrosandboxStorageWorkspace( + ctx context.Context, + f *framework.Framework, + workspace string, +) { + data, err := os.ReadFile( + filepath.Join(workspace, microsandboxStorageMarker), + ) // #nosec G304 -- test-owned workspace + framework.ExpectNoError(err) + gomega.Expect(string(data)).To(gomega.Equal("host-preserved\n")) + guestMarker := filepath.Join("/workspaces", filepath.Base(workspace), microsandboxStorageMarker) + out, err := f.DevsySSHOnce(ctx, workspace, "cat "+guestMarker) + framework.ExpectNoError(err) + gomega.Expect(out).To(gomega.Equal("host-preserved\n")) +} + +func microsandboxStorageBootID( + ctx context.Context, + f *framework.Framework, + workspace string, +) string { + out, err := f.DevsySSHOnce(ctx, workspace, "cat /proc/sys/kernel/random/boot_id") + framework.ExpectNoError(err) + bootID := strings.TrimSpace(out) + gomega.Expect(bootID).To(gomega.MatchRegexp(`^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$`)) + return bootID +} diff --git a/sites/docs-devsy-sh/content/docs/developing-providers/runtime-protocol.mdx b/sites/docs-devsy-sh/content/docs/developing-providers/runtime-protocol.mdx index 64b18a763..4930614a1 100644 --- a/sites/docs-devsy-sh/content/docs/developing-providers/runtime-protocol.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-providers/runtime-protocol.mdx @@ -81,4 +81,12 @@ The ceiling scenario uses `msb modify` on the test-owned VM to grow CPU and memo DEVSY_REQUIRE_MICROSANDBOX=true task cli:test:e2e:suite -- up-provider-microsandbox-resources ``` -These scenarios do not establish complete parity. Storage capacity, ephemeral roots, egress denial, prebuilds, dockerless operation, logs, cancellation, and runtime compatibility failures still require coverage before replacing the built-in provider. Green lifecycle, image, mount, and resource checks alone do not authorize that cutover. +The `up-provider-microsandbox-storage` label runs three persistent-root scenarios against each provider: the runtime's default capacity, an explicit provider storage option overriding `hostRequirements.storage`, and a zero provider option falling back to a fractional-GiB host requirement rounded up to whole GiB. It checks active root-disk configuration and guest filesystem capacity, allowing for filesystem metadata instead of comparing free space with the raw disk size. + +Each scenario verifies that a VM-local file survives stop/start and disappears after recreation, while a host workspace file persists through both operations. Each scenario has a ten-minute deadline, with a 20-minute CI test deadline and a 25-minute job deadline. This suite uses the same runtime and virtualization prerequisites as the lifecycle suite: + +```sh +DEVSY_REQUIRE_MICROSANDBOX=true task cli:test:e2e:suite -- up-provider-microsandbox-storage +``` + +These scenarios do not establish complete parity. Ephemeral roots, egress denial, prebuilds, dockerless operation, logs, cancellation, and runtime compatibility failures still require coverage before replacing the built-in provider. Green lifecycle, image, mount, resource, and persistent-storage checks alone do not authorize that cutover.