Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -196,15 +196,32 @@ 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 },
})
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()

Expand All @@ -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()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,8 @@ let hostPlatform = $state("")
let imagePlatforms = $state<string[]>([])
let checkedRef = $state("")
let compatLoading = $state(false)
let compatChecked = $state(false)
let compatLookupFailed = $state(false)
let emulationEnabled = $state(false)

let initializedProviders = $derived(
Expand Down Expand Up @@ -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<Step, StepState> = {
provider: "pending",
Expand Down Expand Up @@ -319,6 +337,8 @@ function reset() {
checkedRef = ""
imagePlatforms = []
compatLoading = false
compatChecked = false
compatLookupFailed = false
emulationEnabled = false
clearWatchdog()
unlisten?.()
Expand All @@ -342,6 +362,8 @@ $effect(() => {
const ref = imageRef.trim()
checkedRef = ref
compatLoading = true
compatChecked = false
compatLookupFailed = false
imagePlatforms = []
emulationEnabled = false
getImagePlatforms(ref)
Expand All @@ -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
})
}
})
Expand Down Expand Up @@ -577,7 +601,7 @@ function selectTemplate(t: { name: string; source: string }) {
<div class="space-y-1.5">
<Label class="text-sm">Ref Type</Label>
<Select.Root type="single" bind:value={refType}>
<Select.Trigger class="w-full">
<Select.Trigger class="h-8 w-full rounded-lg">
{refType === "branch" ? "Branch" : refType === "commit" ? "Commit" : "Pull Request"}
</Select.Trigger>
<Select.Content>
Expand Down Expand Up @@ -897,12 +921,6 @@ function selectTemplate(t: { name: string; source: string }) {
value={workspaceName}
oninput={(e) => (workspaceName = e.currentTarget.value)}
/>
<p class="text-xs text-muted-foreground">
Auto-suggested to avoid conflicts. Edit to use a custom name.
</p>
<p class="text-xs text-muted-foreground">
Resolved id: <span class="font-mono">{resolvedId || "—"}</span>
</p>
</div>

{#if resolvedIdInvalid}
Expand Down Expand Up @@ -984,7 +1002,29 @@ function selectTemplate(t: { name: string; source: string }) {
</div>

{#if sourceType === "image" && compatLoading}
<p class="text-sm text-muted-foreground">Checking image compatibility…</p>
<p class="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
Checking image compatibility…
</p>
{/if}

{#if imageCompatible}
<p class="flex items-center gap-2 text-sm text-emerald-700 dark:text-emerald-400">
<Check class="h-4 w-4 shrink-0" />
<span>
Compatible with your machine ({hostPlatform})
<span class="block text-xs text-muted-foreground">
Image supports {imagePlatforms.join(", ")}
</span>
</span>
</p>
{/if}

{#if compatUnknown}
<p class="text-sm text-muted-foreground">
Couldn't verify compatibility for your machine ({hostPlatform ||
"unknown"}); the image will be pulled as-is.
</p>
{/if}

{#if imageIncompatible}
Expand All @@ -993,6 +1033,11 @@ function selectTemplate(t: { name: string; source: string }) {
<Alert.Description class="text-amber-700 dark:text-amber-400">
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}
<span class="block text-xs">
Image supports {imagePlatforms.join(", ")}.
</span>
{/if}
</Alert.Description>
</Alert.Root>

Expand Down
6 changes: 5 additions & 1 deletion e2e/tests/up-docker-compose/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
33 changes: 20 additions & 13 deletions pkg/docker/helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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
}
Expand Down
38 changes: 38 additions & 0 deletions pkg/docker/helper_test.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package docker

import (
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion pkg/driver/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading