diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.platform.test.ts b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.platform.test.ts index 86374b5a3..dc58080cd 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.platform.test.ts +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.platform.test.ts @@ -196,7 +196,22 @@ describe("WorkspaceWizard platform compatibility", () => { unmount() }) - it("stays silent and still launches when the platform lookup rejects", async () => { + it("confirms compatibility and lists platforms for a multi-arch image", async () => { + getImagePlatforms.mockResolvedValue(["linux/amd64", "linux/arm64"]) + const { getByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await gotoReviewWithImage(getByText, "ubuntu:22.04") + + await waitFor(() => + expect(getByText(/Compatible with your machine \(linux\/arm64\)/i)).toBeTruthy(), + ) + expect(getByText(/linux\/amd64, linux\/arm64/i)).toBeTruthy() + unmount() + }) + + it("shows a neutral note and still launches when the platform lookup rejects", async () => { getImagePlatforms.mockRejectedValue(new Error("registry unreachable")) const { getByText, queryByText, unmount } = render(WorkspaceWizard, { props: { open: true }, @@ -204,7 +219,9 @@ describe("WorkspaceWizard platform compatibility", () => { await flushAsync() await gotoReviewWithImage(getByText, "ubuntu:22.04") - await flushAsync() + await waitFor(() => + expect(getByText(/Couldn't verify compatibility/i)).toBeTruthy(), + ) expect(queryByText(/no build for your machine/i)).toBeNull() expect(document.querySelector('input[type="checkbox"]')).toBeNull() @@ -217,4 +234,19 @@ describe("WorkspaceWizard platform compatibility", () => { ) unmount() }) + + it("shows a neutral note when the registry returns no platform data", async () => { + getImagePlatforms.mockResolvedValue([]) + const { getByText, queryByText, unmount } = render(WorkspaceWizard, { + props: { open: true }, + }) + await flushAsync() + await gotoReviewWithImage(getByText, "ubuntu:22.04") + + await waitFor(() => + expect(getByText(/Couldn't verify compatibility/i)).toBeTruthy(), + ) + expect(queryByText(/no build for your machine/i)).toBeNull() + unmount() + }) }) diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte index 626b869b4..4a35ae28b 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceWizard.svelte @@ -212,6 +212,8 @@ let hostPlatform = $state("") let imagePlatforms = $state([]) let checkedRef = $state("") let compatLoading = $state(false) +let compatChecked = $state(false) +let compatLookupFailed = $state(false) let emulationEnabled = $state(false) let initializedProviders = $derived( @@ -261,6 +263,22 @@ let emulationTarget = $derived( imagePlatforms.includes("linux/amd64") ? "linux/amd64" : imagePlatforms[0] ?? "", ) +let imageCompatible = $derived( + sourceType === "image" && + compatChecked && + hostPlatform !== "" && + imagePlatforms.length > 0 && + isImageCompatible(imagePlatforms, hostPlatform), +) + +// Lookup resolved but produced nothing actionable: either it errored or the +// registry returned no platform data, so we can't make a compatibility claim. +let compatUnknown = $derived( + sourceType === "image" && + compatChecked && + (compatLookupFailed || imagePlatforms.length === 0 || hostPlatform === ""), +) + let stepStates = $derived.by(() => { const states: Record = { provider: "pending", @@ -319,6 +337,8 @@ function reset() { checkedRef = "" imagePlatforms = [] compatLoading = false + compatChecked = false + compatLookupFailed = false emulationEnabled = false clearWatchdog() unlisten?.() @@ -342,6 +362,8 @@ $effect(() => { const ref = imageRef.trim() checkedRef = ref compatLoading = true + compatChecked = false + compatLookupFailed = false imagePlatforms = [] emulationEnabled = false getImagePlatforms(ref) @@ -353,9 +375,11 @@ $effect(() => { // renderer defect isn't silently masked as "image compatible". console.warn(`Image platform lookup failed for ${ref}:`, err) imagePlatforms = [] + compatLookupFailed = true }) .finally(() => { compatLoading = false + compatChecked = true }) } }) @@ -577,7 +601,7 @@ function selectTemplate(t: { name: string; source: string }) {
- + {refType === "branch" ? "Branch" : refType === "commit" ? "Commit" : "Pull Request"} @@ -897,12 +921,6 @@ function selectTemplate(t: { name: string; source: string }) { value={workspaceName} oninput={(e) => (workspaceName = e.currentTarget.value)} /> -

- Auto-suggested to avoid conflicts. Edit to use a custom name. -

-

- Resolved id: {resolvedId || "—"} -

{#if resolvedIdInvalid} @@ -984,7 +1002,29 @@ function selectTemplate(t: { name: string; source: string }) { {#if sourceType === "image" && compatLoading} -

Checking image compatibility…

+

+ + Checking image compatibility… +

+ {/if} + + {#if imageCompatible} +

+ + + Compatible with your machine ({hostPlatform}) + + Image supports {imagePlatforms.join(", ")} + + +

+ {/if} + + {#if compatUnknown} +

+ Couldn't verify compatibility for your machine ({hostPlatform || + "unknown"}); the image will be pulled as-is. +

{/if} {#if imageIncompatible} @@ -993,6 +1033,11 @@ function selectTemplate(t: { name: string; source: string }) { This image has no build for your machine ({hostPlatform}) and will fail to start unless you run it under emulation below. + {#if imagePlatforms.length > 0} + + Image supports {imagePlatforms.join(", ")}. + + {/if} diff --git a/e2e/tests/up-docker-compose/helper.go b/e2e/tests/up-docker-compose/helper.go index 78c505cbc..257ddb4d6 100644 --- a/e2e/tests/up-docker-compose/helper.go +++ b/e2e/tests/up-docker-compose/helper.go @@ -69,7 +69,11 @@ func (btc *baseTestContext) resetTaggedImage( ctx context.Context, sourceImage, targetImage string, ) error { - if err := btc.dockerHelper.Pull(ctx, sourceImage, nil, io.Discard, io.Discard); err != nil { + if err := btc.dockerHelper.Pull(ctx, docker.PullOptions{ + Image: sourceImage, + Stdout: io.Discard, + Stderr: io.Discard, + }); err != nil { return err } _ = btc.dockerHelper.Run( diff --git a/pkg/docker/helper.go b/pkg/docker/helper.go index 9c8e04553..e72c3a020 100644 --- a/pkg/docker/helper.go +++ b/pkg/docker/helper.go @@ -140,17 +140,25 @@ func (r *DockerHelper) Stop(ctx context.Context, id string) error { return nil } -func (r *DockerHelper) Pull( - ctx context.Context, - image string, - stdin io.Reader, - stdout io.Writer, - stderr io.Writer, -) error { - cmd := r.buildCmd(ctx, "pull", image) - cmd.Stdin = stdin - cmd.Stdout = stdout - cmd.Stderr = stderr +// PullOptions configures an image pull. +type PullOptions struct { + Image string + Platform string + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +func (r *DockerHelper) Pull(ctx context.Context, opts PullOptions) error { + args := []string{"pull"} + if opts.Platform != "" { + args = append(args, "--platform", opts.Platform) + } + args = append(args, opts.Image) + cmd := r.buildCmd(ctx, args...) + cmd.Stdin = opts.Stdin + cmd.Stdout = opts.Stdout + cmd.Stderr = opts.Stderr return cmd.Run() } @@ -416,8 +424,7 @@ func (r *DockerHelper) FindContainerJSON(ctx context.Context, labels []string) ( } for _, label := range labels { - key := strings.Split(label, "=")[0] - value := strings.Join(strings.Split(label, "=")[1:], "=") + key, value, _ := strings.Cut(label, "=") found = containers[0].Config.Labels[key] == value } diff --git a/pkg/docker/helper_test.go b/pkg/docker/helper_test.go index 440cbcd7a..10103b5c3 100644 --- a/pkg/docker/helper_test.go +++ b/pkg/docker/helper_test.go @@ -1,8 +1,11 @@ package docker import ( + "context" + "io" "os" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -81,6 +84,41 @@ esac assert.False(t, got, "should not detect GPU when Podman CDI has no nvidia device") } +func TestPull_PlatformArg(t *testing.T) { + tmp := t.TempDir() + argsFile := filepath.Join(tmp, "args.txt") + bin := writeScript(t, tmp, "docker-fake", `#!/bin/sh +echo "$@" > `+argsFile+` +`) + + t.Run("includes --platform when set", func(t *testing.T) { + h := &DockerHelper{DockerCommand: bin} + require.NoError(t, h.Pull(context.Background(), PullOptions{ + Image: "ubuntu:22.04", + Platform: "linux/amd64", + Stdout: io.Discard, + Stderr: io.Discard, + })) + //nolint:gosec // test reads a temp file path it controls + got, err := os.ReadFile(argsFile) + require.NoError(t, err) + assert.Equal(t, "pull --platform linux/amd64 ubuntu:22.04", strings.TrimSpace(string(got))) + }) + + t.Run("omits --platform when empty", func(t *testing.T) { + h := &DockerHelper{DockerCommand: bin} + require.NoError(t, h.Pull(context.Background(), PullOptions{ + Image: "ubuntu:22.04", + Stdout: io.Discard, + Stderr: io.Discard, + })) + //nolint:gosec // test reads a temp file path it controls + got, err := os.ReadFile(argsFile) + require.NoError(t, err) + assert.Equal(t, "pull ubuntu:22.04", strings.TrimSpace(string(got))) + }) +} + func TestGPUSupportEnabled_CommandFailure(t *testing.T) { tmp := t.TempDir() bin := writeScript(t, tmp, "bad-runtime", `#!/bin/sh diff --git a/pkg/driver/docker/docker.go b/pkg/driver/docker/docker.go index 10af27ddd..155433d1b 100644 --- a/pkg/driver/docker/docker.go +++ b/pkg/driver/docker/docker.go @@ -327,7 +327,12 @@ func (d *dockerDriver) EnsureImage( writer := log.Writer(log.LevelDebug) defer func() { _ = writer.Close() }() - return d.Docker.Pull(ctx, options.Image, nil, writer, writer) + return d.Docker.Pull(ctx, docker.PullOptions{ + Image: options.Image, + Platform: options.Platform, + Stdout: writer, + Stderr: writer, + }) } return nil }