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 @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
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;
import org.junit.jupiter.api.Assumptions;
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;

Expand Down Expand Up @@ -43,22 +49,99 @@ 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();
}
}
}

/**
* 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<Frame>() {
@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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading