diff --git a/src/main/java/io/github/hectorvent/floci/services/lambda/launcher/ImageCacheService.java b/src/main/java/io/github/hectorvent/floci/services/lambda/launcher/ImageCacheService.java index 1ef0c3c273..6a7bb0aad9 100644 --- a/src/main/java/io/github/hectorvent/floci/services/lambda/launcher/ImageCacheService.java +++ b/src/main/java/io/github/hectorvent/floci/services/lambda/launcher/ImageCacheService.java @@ -63,7 +63,7 @@ public String ensureImageExists(String imageUri, String platform) { } InspectImageResponse localImage = inspectLocalImage(imageUri); if (matchesPlatform(localImage, requestedPlatform)) { - resolvedImage = resolvedImageReference(imageUri, requestedPlatform, localImage); + resolvedImage = resolvedImageReference(imageUri, localImage); resolvedImages.put(imageKey, resolvedImage); LOG.infov("Image already present locally, skipping pull: {0}", imageUri); return resolvedImage; @@ -79,8 +79,7 @@ public String ensureImageExists(String imageUri, String platform) { pullImage.exec(new PullImageResultCallback()) .awaitCompletion(5, TimeUnit.MINUTES); }); - resolvedImage = resolvedImageReference(imageUri, requestedPlatform, - inspectLocalImage(imageUri)); + resolvedImage = resolvedImageReference(imageUri, inspectLocalImage(imageUri)); resolvedImages.put(imageKey, resolvedImage); LOG.infov("Image pulled successfully: {0}", imageUri); return resolvedImage; @@ -198,11 +197,19 @@ private static boolean matchesPlatform(InspectImageResponse image, String platfo && parts[1].equals(image.getArch()); } - private static String resolvedImageReference(String imageUri, String platform, - InspectImageResponse image) { - if (!matchesPlatform(image, platform)) { - throw new DockerClientException( - "Docker image does not match requested platform " + platform + ": " + imageUri); + /** + * Resolves the reference the container is created from. Inspecting the image cannot confirm + * the platform on Docker 29, whose containerd image store is the default: for an image whose + * platform is not the host's it answers with an empty Os and Architecture until another + * variant of the same tag is present, and with the host's variant once one is, because the + * tag and its id both name the index rather than the variant that will run. Neither answer + * says anything about the variant the daemon selected. The platform is enforced by the daemon + * itself at both points that matter, choosing the variant to pull and choosing the variant to + * create the container from, so this only has to hand back an id. + */ + private static String resolvedImageReference(String imageUri, InspectImageResponse image) { + if (image == null) { + throw new DockerClientException("Docker did not report the image: " + imageUri); } String imageId = image.getId(); if (imageId == null || imageId.isBlank()) { diff --git a/src/test/java/io/github/hectorvent/floci/core/common/docker/ContainerPlatformDockerIntegrationTest.java b/src/test/java/io/github/hectorvent/floci/core/common/docker/ContainerPlatformDockerIntegrationTest.java index d7aedf3a85..81a12c9637 100644 --- a/src/test/java/io/github/hectorvent/floci/core/common/docker/ContainerPlatformDockerIntegrationTest.java +++ b/src/test/java/io/github/hectorvent/floci/core/common/docker/ContainerPlatformDockerIntegrationTest.java @@ -1,6 +1,9 @@ package io.github.hectorvent.floci.core.common.docker; import com.github.dockerjava.api.DockerClient; +import com.github.dockerjava.api.async.ResultCallback; +import com.github.dockerjava.core.command.WaitContainerResultCallback; +import com.github.dockerjava.api.model.Frame; import io.quarkus.test.junit.QuarkusTest; import jakarta.inject.Inject; import org.jboss.logging.Logger; @@ -8,8 +11,11 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.util.List; import java.util.Locale; import java.util.UUID; +import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -43,15 +49,19 @@ void createUsesRequestedForeignPlatformImage() { String platform = "linux/" + requestedArchitecture; ContainerSpec spec = containerBuilder.newContainer(IMAGE) .withName("floci-platform-test-" + UUID.randomUUID()) + .withCmd(List.of("uname", "-m")) .build(); String containerId = null; try { containerId = lifecycleManager.create(spec, platform); - String imageId = dockerClient.inspectContainerCmd(containerId).exec().getImageId(); - assertEquals(requestedArchitecture, - dockerClient.inspectImageCmd(imageId).exec().getArch()); + String machine = architectureReportedByContainer(containerId); + if (machine != null) { + assertEquals(armHost ? "x86_64" : "aarch64", machine); + return; + } + assertEquals(requestedArchitecture, architectureReportedByDaemon(containerId, hostArchitecture)); } finally { if (containerId != null) { dockerClient.removeContainerCmd(containerId).withForce(true).exec(); @@ -59,6 +69,79 @@ void createUsesRequestedForeignPlatformImage() { } } + /** + * Runs the created container, whose command is {@code uname -m}, and returns what it printed. + * This is the only reading that holds on every engine, because the container is the variant + * the daemon actually selected. Running one built for another architecture needs emulation on + * the host, which not every machine has: without it the start either throws or the container + * exits nonzero on an exec-format error, and both return {@code null} for the caller to fall + * back on. + */ + private String architectureReportedByContainer(String containerId) { + try { + dockerClient.startContainerCmd(containerId).exec(); + } catch (RuntimeException e) { + LOG.warnv(e, "Could not start the foreign-platform container, falling back to inspect"); + return null; + } + Integer status; + try { + status = dockerClient.waitContainerCmd(containerId) + .exec(new WaitContainerResultCallback()) + .awaitStatusCode(60, TimeUnit.SECONDS); + } catch (RuntimeException e) { + LOG.warnv(e, "Waiting on the foreign-platform container failed, falling back to inspect"); + return null; + } + if (status == null || status != 0) { + LOG.warnv("The foreign-platform container exited with {0}, falling back to inspect: {1}", + status, logs(containerId)); + return null; + } + return logs(containerId).trim(); + } + + /** + * The architecture the daemon reports for the image the container was created from. Docker 29's + * containerd image store, its default, answers for the index rather than the selected variant: + * an empty string until a second variant of the tag is local, and the host's architecture once + * one is. Neither can confirm anything here, so both skip the test instead of failing it. + */ + private String architectureReportedByDaemon(String containerId, String hostArchitecture) { + String imageId = dockerClient.inspectContainerCmd(containerId).exec().getImageId(); + String reported = dockerClient.inspectImageCmd(imageId).exec().getArch(); + if (reported == null || reported.isBlank()) { + return Assumptions.abort("this daemon reports no architecture for a foreign-platform image"); + } + if (reported.equalsIgnoreCase(hostArchitecture) + || (hostArchitecture.equals("aarch64") && reported.equals("arm64")) + || (hostArchitecture.equals("x86_64") && reported.equals("amd64"))) { + return Assumptions.abort( + "this daemon reports the host architecture for a foreign-platform image"); + } + return reported; + } + + /** Best effort, as in the sibling Docker tests: a read that fails reports itself in the text. */ + private String logs(String containerId) { + StringBuilder out = new StringBuilder(); + try { + dockerClient.logContainerCmd(containerId).withStdOut(true).withStdErr(true) + .exec(new ResultCallback.Adapter() { + @Override + public void onNext(Frame frame) { + out.append(new String(frame.getPayload(), StandardCharsets.UTF_8)); + } + }).awaitCompletion(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + out.append("(interrupted while reading logs)"); + } catch (Exception e) { + out.append("(could not read logs: ").append(e.getMessage()).append(')'); + } + return out.toString(); + } + private boolean isDockerAvailable() { try { dockerClient.pingCmd().exec(); diff --git a/src/test/java/io/github/hectorvent/floci/services/lambda/launcher/ImageCacheServiceTest.java b/src/test/java/io/github/hectorvent/floci/services/lambda/launcher/ImageCacheServiceTest.java index 645b6b5a4f..54ebdc3be1 100644 --- a/src/test/java/io/github/hectorvent/floci/services/lambda/launcher/ImageCacheServiceTest.java +++ b/src/test/java/io/github/hectorvent/floci/services/lambda/launcher/ImageCacheServiceTest.java @@ -96,6 +96,53 @@ void pullsRequestedPlatformWhenLocalImageArchitectureDoesNotMatch() throws Excep verify(callback).awaitCompletion(5, TimeUnit.MINUTES); } + @Test + void acceptsThePulledImageWhenInspectionReportsTheHostVariant() throws Exception { + DockerClient dockerClient = mock(DockerClient.class); + InspectImageCmd inspectImage = mock(InspectImageCmd.class); + PullImageCmd pullImage = mock(PullImageCmd.class); + PullImageResultCallback callback = mock(PullImageResultCallback.class); + when(dockerClient.inspectImageCmd(IMAGE)).thenReturn(inspectImage); + // Docker 29's containerd image store answers for the index, so with the host's variant + // already local it keeps reporting that architecture after the foreign pull. + when(inspectImage.exec()).thenReturn( + new InspectImageResponse().withOs("linux").withArch("arm64"), + new InspectImageResponse().withId("sha256:index").withOs("linux").withArch("arm64")); + when(dockerClient.pullImageCmd(IMAGE)).thenReturn(pullImage); + when(pullImage.withAuthConfig(any())).thenReturn(pullImage); + when(pullImage.withPlatform("linux/amd64")).thenReturn(pullImage); + when(pullImage.exec(any(PullImageResultCallback.class))).thenReturn(callback); + + String resolvedImage = newService(dockerClient) + .ensureImageExists(IMAGE, "linux/amd64"); + + assertEquals("sha256:index", resolvedImage); + verify(pullImage).withPlatform("linux/amd64"); + } + + @Test + void acceptsThePulledImageWhenInspectionReportsNoPlatform() throws Exception { + DockerClient dockerClient = mock(DockerClient.class); + InspectImageCmd inspectImage = mock(InspectImageCmd.class); + PullImageCmd pullImage = mock(PullImageCmd.class); + PullImageResultCallback callback = mock(PullImageResultCallback.class); + when(dockerClient.inspectImageCmd(IMAGE)).thenReturn(inspectImage); + // The same store with no other variant local: the index it pulled describes no platform + // at all. + when(inspectImage.exec()).thenThrow(new NotFoundException("image not found")) + .thenReturn(new InspectImageResponse().withId("sha256:index").withOs("").withArch("")); + when(dockerClient.pullImageCmd(IMAGE)).thenReturn(pullImage); + when(pullImage.withAuthConfig(any())).thenReturn(pullImage); + when(pullImage.withPlatform("linux/amd64")).thenReturn(pullImage); + when(pullImage.exec(any(PullImageResultCallback.class))).thenReturn(callback); + + String resolvedImage = newService(dockerClient) + .ensureImageExists(IMAGE, "linux/amd64"); + + assertEquals("sha256:index", resolvedImage); + verify(pullImage).withPlatform("linux/amd64"); + } + @Test void skipsPullWhenLocalImageMatchesRequestedPlatform() { DockerClient dockerClient = mock(DockerClient.class);