diff --git a/java/src/org/openqa/selenium/docker/ContainerConfig.java b/java/src/org/openqa/selenium/docker/ContainerConfig.java index fbdfe381b19aa..bc120bc14e829 100644 --- a/java/src/org/openqa/selenium/docker/ContainerConfig.java +++ b/java/src/org/openqa/selenium/docker/ContainerConfig.java @@ -44,6 +44,8 @@ public class ContainerConfig { private final boolean autoRemove; private final long shmSize; private final Map hostConfig; + private final Map labels; + private final String name; public ContainerConfig( Image image, @@ -61,6 +63,7 @@ public ContainerConfig( devices, networkName, shmSize, + ImmutableMap.of(), ImmutableMap.of()); } @@ -73,6 +76,52 @@ public ContainerConfig( String networkName, long shmSize, Map hostConfig) { + this( + image, + portBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + hostConfig, + ImmutableMap.of()); + } + + public ContainerConfig( + Image image, + Multimap> portBindings, + Map envVars, + Map volumeBinds, + List devices, + String networkName, + long shmSize, + Map hostConfig, + Map labels) { + this( + image, + portBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + hostConfig, + labels, + null); + } + + public ContainerConfig( + Image image, + Multimap> portBindings, + Map envVars, + Map volumeBinds, + List devices, + String networkName, + long shmSize, + Map hostConfig, + Map labels, + String name) { this.image = image; this.portBindings = portBindings; this.envVars = envVars; @@ -82,6 +131,12 @@ public ContainerConfig( this.autoRemove = true; this.shmSize = shmSize; this.hostConfig = hostConfig; + this.labels = labels; + this.name = name; + } + + public String getName() { + return this.name; } public Image getImage() { @@ -114,40 +169,94 @@ public ContainerConfig map(Port containerPort, Port hostPort) { ImmutableMap.of("HostPort", String.valueOf(hostPort.getPort()), "HostIp", "")); return new ContainerConfig( - image, updatedBindings, envVars, volumeBinds, devices, networkName, shmSize); + image, + updatedBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + hostConfig, + labels, + name); } public ContainerConfig env(Map envVars) { Require.nonNull("Container env vars", envVars); return new ContainerConfig( - image, portBindings, envVars, volumeBinds, devices, networkName, shmSize); + image, + portBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + hostConfig, + labels, + name); } public ContainerConfig bind(Map volumeBinds) { Require.nonNull("Container volume binds", volumeBinds); return new ContainerConfig( - image, portBindings, envVars, volumeBinds, devices, networkName, shmSize); + image, + portBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + hostConfig, + labels, + name); } public ContainerConfig network(String networkName) { Require.nonNull("Container network name", networkName); return new ContainerConfig( - image, portBindings, envVars, volumeBinds, devices, networkName, shmSize); + image, + portBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + hostConfig, + labels, + name); } public ContainerConfig shmMemorySize(long shmSize) { return new ContainerConfig( - image, portBindings, envVars, volumeBinds, devices, networkName, shmSize); + image, + portBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + hostConfig, + labels, + name); } public ContainerConfig devices(List devices) { Require.nonNull("Container device files", devices); return new ContainerConfig( - image, portBindings, envVars, volumeBinds, devices, networkName, shmSize); + image, + portBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + hostConfig, + labels, + name); } public ContainerConfig applyHostConfig(Map hostConfig, List configKeys) { @@ -158,7 +267,46 @@ public ContainerConfig applyHostConfig(Map hostConfig, List key, hostConfig::get)); return new ContainerConfig( - image, portBindings, envVars, volumeBinds, devices, networkName, shmSize, setHostConfig); + image, + portBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + setHostConfig, + labels, + name); + } + + public ContainerConfig labels(Map labels) { + Require.nonNull("Container labels", labels); + + return new ContainerConfig( + image, + portBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + hostConfig, + labels, + name); + } + + public ContainerConfig name(String name) { + return new ContainerConfig( + image, + portBindings, + envVars, + volumeBinds, + devices, + networkName, + shmSize, + hostConfig, + labels, + name); } @Override @@ -221,9 +369,13 @@ private Map toJson() { hostConfig = ImmutableMap.copyOf(copyMap); } - return ImmutableMap.of( - "Image", image.getId(), - "Env", envVars, - "HostConfig", hostConfig); + Map config = new HashMap<>(); + config.put("Image", image.getId()); + config.put("Env", envVars); + config.put("HostConfig", hostConfig); + if (!labels.isEmpty()) { + config.put("Labels", labels); + } + return config; } } diff --git a/java/src/org/openqa/selenium/docker/ContainerInfo.java b/java/src/org/openqa/selenium/docker/ContainerInfo.java index 50d9c8a8d887b..2c3a55e7d68eb 100644 --- a/java/src/org/openqa/selenium/docker/ContainerInfo.java +++ b/java/src/org/openqa/selenium/docker/ContainerInfo.java @@ -30,6 +30,7 @@ public class ContainerInfo { private final List> mountedVolumes; private final String networkName; private Map hostConfig; + private Map labels; public ContainerInfo( ContainerId id, @@ -37,11 +38,22 @@ public ContainerInfo( List> mountedVolumes, String networkName, Map hostConfig) { + this(id, ip, mountedVolumes, networkName, hostConfig, Map.of()); + } + + public ContainerInfo( + ContainerId id, + String ip, + List> mountedVolumes, + String networkName, + Map hostConfig, + Map labels) { this.ip = Require.nonNull("Container ip address", ip); this.id = Require.nonNull("Container id", id); this.mountedVolumes = Require.nonNull("Mounted volumes", mountedVolumes); this.networkName = Require.nonNull("Network name", networkName); this.hostConfig = Require.nonNull("Host config", hostConfig); + this.labels = Require.nonNull("Labels", labels); } public String getIp() { @@ -64,6 +76,10 @@ public Map getHostConfig() { return this.hostConfig; } + public Map getLabels() { + return this.labels; + } + @Override public String toString() { return "ContainerInfo{" diff --git a/java/src/org/openqa/selenium/docker/client/CreateContainer.java b/java/src/org/openqa/selenium/docker/client/CreateContainer.java index 56a47551be487..5826292fad5e6 100644 --- a/java/src/org/openqa/selenium/docker/client/CreateContainer.java +++ b/java/src/org/openqa/selenium/docker/client/CreateContainer.java @@ -22,6 +22,9 @@ import static org.openqa.selenium.remote.http.Contents.asJson; import static org.openqa.selenium.remote.http.HttpMethod.POST; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Map; import java.util.logging.Logger; @@ -62,10 +65,22 @@ public Container apply(ContainerConfig info) { Map requestJson = JSON.toType(JSON.toJson(info), MAP_TYPE); Map adaptedRequest = adapter.adaptContainerCreateRequest(requestJson); + // Build the URL with optional name parameter + String url = String.format("/v%s/containers/create", apiVersion); + if (info.getName() != null && !info.getName().trim().isEmpty()) { + String containerName = info.getName().trim(); + try { + String encodedName = URLEncoder.encode(containerName, StandardCharsets.UTF_8.toString()); + url += "?name=" + encodedName; + } catch (UnsupportedEncodingException e) { + throw new DockerException("Failed to encode container name: " + containerName, e); + } + } + HttpResponse res = DockerMessages.throwIfNecessary( client.execute( - new HttpRequest(POST, String.format("/v%s/containers/create", apiVersion)) + new HttpRequest(POST, url) .addHeader("Content-Type", JSON_UTF_8) .setContent(asJson(adaptedRequest))), "Unable to create container: ", diff --git a/java/src/org/openqa/selenium/docker/client/InspectContainer.java b/java/src/org/openqa/selenium/docker/client/InspectContainer.java index e575b78bd8a01..cb0e2e7090a16 100644 --- a/java/src/org/openqa/selenium/docker/client/InspectContainer.java +++ b/java/src/org/openqa/selenium/docker/client/InspectContainer.java @@ -80,7 +80,11 @@ public ContainerInfo apply(ContainerId id) { mounts.stream().map(mount -> (Map) mount).collect(Collectors.toList()); Map hostConfig = (Map) rawInspectInfo.getOrDefault("HostConfig", Collections.emptyMap()); + Map config = + (Map) rawInspectInfo.getOrDefault("Config", Collections.emptyMap()); + Map labels = + (Map) config.getOrDefault("Labels", Collections.emptyMap()); - return new ContainerInfo(id, ip, mountedVolumes, networkName, hostConfig); + return new ContainerInfo(id, ip, mountedVolumes, networkName, hostConfig, labels); } } diff --git a/java/src/org/openqa/selenium/grid/node/docker/DockerOptions.java b/java/src/org/openqa/selenium/grid/node/docker/DockerOptions.java index 193b8cd885240..78f1bc3841271 100644 --- a/java/src/org/openqa/selenium/grid/node/docker/DockerOptions.java +++ b/java/src/org/openqa/selenium/grid/node/docker/DockerOptions.java @@ -38,6 +38,7 @@ import java.util.logging.Logger; import java.util.regex.Matcher; import java.util.regex.Pattern; +import java.util.stream.Collectors; import org.openqa.selenium.Capabilities; import org.openqa.selenium.Platform; import org.openqa.selenium.docker.ContainerId; @@ -173,6 +174,7 @@ public Map> getDockerSessionFactories( DockerAssetsPath assetsPath = getAssetsPath(info); String networkName = getDockerNetworkName(info); Map hostConfig = getDockerHostConfig(info); + Map composeLabels = getComposeLabels(info); loadImages(docker, kinds.keySet().toArray(new String[0])); Image videoImage = getVideoImage(docker); @@ -208,7 +210,8 @@ public Map> getDockerSessionFactories( info.isPresent(), capabilities -> options.getSlotMatcher().matches(caps, capabilities), hostConfig, - hostConfigKeys)); + hostConfigKeys, + composeLabels)); } LOG.info( String.format( @@ -257,6 +260,19 @@ private Map getDockerHostConfig(Optional info) { return info.map(ContainerInfo::getHostConfig).orElse(Collections.emptyMap()); } + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") + private Map getComposeLabels(Optional info) { + if (info.isEmpty()) { + return Collections.emptyMap(); + } + + Map allLabels = info.get().getLabels(); + // Filter for Docker Compose labels (com.docker.compose.*) + return allLabels.entrySet().stream() + .filter(entry -> entry.getKey().startsWith("com.docker.compose.")) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + @SuppressWarnings("OptionalUsedAsFieldOrParameterType") private DockerAssetsPath getAssetsPath(Optional info) { if (info.isPresent()) { diff --git a/java/src/org/openqa/selenium/grid/node/docker/DockerSessionFactory.java b/java/src/org/openqa/selenium/grid/node/docker/DockerSessionFactory.java index 6f6ab71caef05..cf7968e573d14 100644 --- a/java/src/org/openqa/selenium/grid/node/docker/DockerSessionFactory.java +++ b/java/src/org/openqa/selenium/grid/node/docker/DockerSessionFactory.java @@ -41,6 +41,7 @@ import java.util.Map; import java.util.Optional; import java.util.TimeZone; +import java.util.UUID; import java.util.function.Predicate; import java.util.logging.Level; import java.util.logging.Logger; @@ -104,6 +105,7 @@ public class DockerSessionFactory implements SessionFactory { private final Predicate predicate; private final Map hostConfig; private final List hostConfigKeys; + private final Map composeLabels; public DockerSessionFactory( Tracer tracer, @@ -121,7 +123,8 @@ public DockerSessionFactory( boolean runningInDocker, Predicate predicate, Map hostConfig, - List hostConfigKeys) { + List hostConfigKeys, + Map composeLabels) { this.tracer = Require.nonNull("Tracer", tracer); this.clientFactory = Require.nonNull("HTTP client", clientFactory); this.sessionTimeout = Require.nonNull("Session timeout", sessionTimeout); @@ -138,6 +141,7 @@ public DockerSessionFactory( this.predicate = Require.nonNull("Accepted capabilities predicate", predicate); this.hostConfig = Require.nonNull("Container host config", hostConfig); this.hostConfigKeys = Require.nonNull("Browser container host config keys", hostConfigKeys); + this.composeLabels = Require.nonNull("Docker Compose labels", composeLabels); } @Override @@ -155,6 +159,17 @@ public Either apply(CreateSessionRequest sess LOG.info("Starting session for " + sessionRequest.getDesiredCapabilities()); int port = runningInDocker ? 4444 : PortProber.findFreePort(); + // Generate unique identifier for consistent naming between browser and recorder containers + // Using browserName-timestamp-UUID to avoid conflicts in concurrent session creation + String browserName = sessionRequest.getDesiredCapabilities().getBrowserName(); + if (browserName != null && !browserName.isEmpty()) { + browserName = browserName.toLowerCase(); + } else { + browserName = "unknown"; + } + long timestamp = System.currentTimeMillis(); + String uniqueId = UUID.randomUUID().toString().substring(0, 8); + String sessionIdentifier = String.format("%s-%d-%s", browserName, timestamp, uniqueId); try (Span span = tracer.getCurrentContext().createSpan("docker_session_factory.apply")) { AttributeMap attributeMap = tracer.createAttributeMap(); attributeMap.put(AttributeKey.LOGGER_CLASS.getKey(), this.getClass().getName()); @@ -163,7 +178,8 @@ public Either apply(CreateSessionRequest sess ? "Creating container..." : "Creating container, mapping container port 4444 to " + port; LOG.info(logMessage); - Container container = createBrowserContainer(port, sessionRequest.getDesiredCapabilities()); + Container container = + createBrowserContainer(port, sessionRequest.getDesiredCapabilities(), sessionIdentifier); container.start(); ContainerInfo containerInfo = container.inspect(); @@ -243,7 +259,8 @@ public Either apply(CreateSessionRequest sess String containerPath = path.get().getContainerPath(id); saveSessionCapabilities(mergedCapabilities, containerPath); String hostPath = path.get().getHostPath(id); - videoContainer = startVideoContainer(mergedCapabilities, containerIp, hostPath); + videoContainer = + startVideoContainer(mergedCapabilities, containerIp, hostPath, sessionIdentifier); } Dialect downstream = @@ -288,7 +305,8 @@ private Capabilities addForwardCdpEndpoint( .setCapability("se:forwardCdp", forwardCdpPath); } - private Container createBrowserContainer(int port, Capabilities sessionCapabilities) { + private Container createBrowserContainer( + int port, Capabilities sessionCapabilities, String sessionIdentifier) { Map browserContainerEnvVars = new HashMap<>(); // Enable env var to trigger video recording if session capabilities request and external video // container is disabled @@ -299,16 +317,23 @@ private Container createBrowserContainer(int port, Capabilities sessionCapabilit } browserContainerEnvVars.putAll(getBrowserContainerEnvVars(sessionCapabilities)); long browserContainerShmMemorySize = 2147483648L; // 2GB + + // Generate container name: browser--- + String containerName = String.format("browser-%s", sessionIdentifier); + ContainerConfig containerConfig = image(browserImage) .env(browserContainerEnvVars) .shmMemorySize(browserContainerShmMemorySize) .network(networkName) .devices(devices) - .applyHostConfig(hostConfig, hostConfigKeys); + .applyHostConfig(hostConfig, hostConfigKeys) + .labels(composeLabels) + .name(containerName); Optional path = ofNullable(this.assetsPath); if (path.isPresent() && videoImage == null && recordVideoForSession(sessionCapabilities)) { - containerConfig.bind(Collections.singletonMap(this.assetsPath.getHostPath(), "/videos")); + containerConfig = + containerConfig.bind(Collections.singletonMap(this.assetsPath.getHostPath(), "/videos")); } if (!runningInDocker) { containerConfig = containerConfig.map(Port.tcp(4444), Port.tcp(port)); @@ -349,15 +374,27 @@ private void setCapsToEnvVars( } private Container startVideoContainer( - Capabilities sessionCapabilities, String browserContainerIp, String hostPath) { + Capabilities sessionCapabilities, + String browserContainerIp, + String hostPath, + String sessionIdentifier) { if (videoImage == null || !recordVideoForSession(sessionCapabilities)) { return null; } int videoPort = 9000; Map envVars = getVideoContainerEnvVars(sessionCapabilities, browserContainerIp); Map volumeBinds = Collections.singletonMap(hostPath, "/videos"); + + // Generate container name: recorder--- + String containerName = String.format("recorder-%s", sessionIdentifier); + ContainerConfig containerConfig = - image(videoImage).env(envVars).bind(volumeBinds).network(networkName); + image(videoImage) + .env(envVars) + .bind(volumeBinds) + .network(networkName) + .labels(composeLabels) + .name(containerName); if (!runningInDocker) { videoPort = PortProber.findFreePort(); containerConfig = containerConfig.map(Port.tcp(9000), Port.tcp(videoPort));