Repository: docker/docker-py

Issue:
Publish service port in host mode
Hello,

I'm trying to create a service publishing a port in host mode with the Python SDK, but i don't see option to do so :(

It is possible via docker command line (https://docs.docker.com/engine/reference/commandline/service_create/#options) : 
`docker service create -p mode=host,published=80,target=80 --name test httpd`

It is also possible via docker-compose:
````
services:
  test:
    image: httpd
    ports:
      - mode: host
        target: 80
        published: 80
    deploy:
      mode: replicated
      replicas: 1
````

Is there a way to achieve the same result via python SDK ?

Thanks !


Discussion:
Hi @greenmaid. You should use https://docker-py.readthedocs.io/en/stable/api.html#docker.types.EndpointSpec. Regards.
Hello @feliperuhland , thanks for your reply !

In fact, EndpointSpec only support "vip" and "dnsrr" modes. I'm looking for the "host" mode. IMHO it is even not the same kind of 'mode' we are speaking about.... If i understand well, "vip"/"dnsrr" mode refers to internal swarm load-balancing mode, the "host" mode i'm looking for refers to external port publishing...

A more precise link from docker documentation: https://docs.docker.com/engine/reference/commandline/service_create/#publish-service-ports-externally-to-the-swarm--p-publish

Regards

@greenmaid You're right - we don't have support for it currently, unfortunately. Thanks for bringing it to our attention.

Resolving patch:
diff --git a/docker/api/service.py b/docker/api/service.py
--- a/docker/api/service.py
+++ b/docker/api/service.py
@@ -3,7 +3,7 @@
 from ..types import ServiceMode
 
 
-def _check_api_features(version, task_template, update_config):
+def _check_api_features(version, task_template, update_config, endpoint_spec):
 
     def raise_version_error(param, min_version):
         raise errors.InvalidVersion(
@@ -23,6 +23,11 @@ def raise_version_error(param, min_version):
             if 'Order' in update_config:
                 raise_version_error('UpdateConfig.order', '1.29')
 
+    if endpoint_spec is not None:
+        if utils.version_lt(version, '1.32') and 'Ports' in endpoint_spec:
+            if any(p.get('PublishMode') for p in endpoint_spec['Ports']):
+                raise_version_error('EndpointSpec.Ports[].mode', '1.32')
+
     if task_template is not None:
         if 'ForceUpdate' in task_template and utils.version_lt(
                 version, '1.25'):
@@ -125,7 +130,9 @@ def create_service(
             )
             endpoint_spec = endpoint_config
 
-        _check_api_features(self._version, task_template, update_config)
+        _check_api_features(
+            self._version, task_template, update_config, endpoint_spec
+        )
 
         url = self._url('/services/create')
         headers = {}
@@ -370,7 +377,9 @@ def update_service(self, service, version, task_template=None, name=None,
             )
             endpoint_spec = endpoint_config
 
-        _check_api_features(self._version, task_template, update_config)
+        _check_api_features(
+            self._version, task_template, update_config, endpoint_spec
+        )
 
         if fetch_current_spec:
             inspect_defaults = True
diff --git a/docker/types/services.py b/docker/types/services.py
--- a/docker/types/services.py
+++ b/docker/types/services.py
@@ -450,8 +450,9 @@ class EndpointSpec(dict):
           ``'vip'`` if not provided.
         ports (dict): Exposed ports that this service is accessible on from the
           outside, in the form of ``{ published_port: target_port }`` or
-          ``{ published_port: (target_port, protocol) }``. Ports can only be
-          provided if the ``vip`` resolution mode is used.
+          ``{ published_port: <port_config_tuple> }``. Port config tuple format
+          is ``(target_port [, protocol [, publish_mode]])``.
+          Ports can only be provided if the ``vip`` resolution mode is used.
     """
     def __init__(self, mode=None, ports=None):
         if ports:
@@ -477,8 +478,15 @@ def convert_service_ports(ports):
 
         if isinstance(v, tuple):
             port_spec['TargetPort'] = v[0]
-            if len(v) == 2:
+            if len(v) >= 2 and v[1] is not None:
                 port_spec['Protocol'] = v[1]
+            if len(v) == 3:
+                port_spec['PublishMode'] = v[2]
+            if len(v) > 3:
+                raise ValueError(
+                    'Service port configuration can have at most 3 elements: '
+                    '(target_port, protocol, mode)'
+                )
         else:
             port_spec['TargetPort'] = v
 


Test patch:
diff --git a/tests/integration/api_service_test.py b/tests/integration/api_service_test.py
--- a/tests/integration/api_service_test.py
+++ b/tests/integration/api_service_test.py
@@ -353,7 +353,6 @@ def test_create_service_with_endpoint_spec(self):
             task_tmpl, name=name, endpoint_spec=endpoint_spec
         )
         svc_info = self.client.inspect_service(svc_id)
-        print(svc_info)
         ports = svc_info['Spec']['EndpointSpec']['Ports']
         for port in ports:
             if port['PublishedPort'] == 12562:
@@ -370,6 +369,26 @@ def test_create_service_with_endpoint_spec(self):
 
         assert len(ports) == 3
 
+    @requires_api_version('1.32')
+    def test_create_service_with_endpoint_spec_host_publish_mode(self):
+        container_spec = docker.types.ContainerSpec(BUSYBOX, ['true'])
+        task_tmpl = docker.types.TaskTemplate(container_spec)
+        name = self.get_service_name()
+        endpoint_spec = docker.types.EndpointSpec(ports={
+            12357: (1990, None, 'host'),
+        })
+        svc_id = self.client.create_service(
+            task_tmpl, name=name, endpoint_spec=endpoint_spec
+        )
+        svc_info = self.client.inspect_service(svc_id)
+        ports = svc_info['Spec']['EndpointSpec']['Ports']
+        assert len(ports) == 1
+        port = ports[0]
+        assert port['PublishedPort'] == 12357
+        assert port['TargetPort'] == 1990
+        assert port['Protocol'] == 'tcp'
+        assert port['PublishMode'] == 'host'
+
     def test_create_service_with_env(self):
         container_spec = docker.types.ContainerSpec(
             BUSYBOX, ['true'], env={'DOCKER_PY_TEST': 1}
diff --git a/tests/unit/dockertypes_test.py b/tests/unit/dockertypes_test.py
--- a/tests/unit/dockertypes_test.py
+++ b/tests/unit/dockertypes_test.py
@@ -11,6 +11,7 @@
     ContainerConfig, ContainerSpec, EndpointConfig, HostConfig, IPAMConfig,
     IPAMPool, LogConfig, Mount, ServiceMode, Ulimit,
 )
+from docker.types.services import convert_service_ports
 
 try:
     from unittest import mock
@@ -423,3 +424,77 @@ def test_parse_mount_bind_windows(self):
         assert mount['Source'] == "C:/foo/bar"
         assert mount['Target'] == "/baz"
         assert mount['Type'] == 'bind'
+
+
+class ServicePortsTest(unittest.TestCase):
+    def test_convert_service_ports_simple(self):
+        ports = {8080: 80}
+        assert convert_service_ports(ports) == [{
+            'Protocol': 'tcp',
+            'PublishedPort': 8080,
+            'TargetPort': 80,
+        }]
+
+    def test_convert_service_ports_with_protocol(self):
+        ports = {8080: (80, 'udp')}
+
+        assert convert_service_ports(ports) == [{
+            'Protocol': 'udp',
+            'PublishedPort': 8080,
+            'TargetPort': 80,
+        }]
+
+    def test_convert_service_ports_with_protocol_and_mode(self):
+        ports = {8080: (80, 'udp', 'ingress')}
+
+        assert convert_service_ports(ports) == [{
+            'Protocol': 'udp',
+            'PublishedPort': 8080,
+            'TargetPort': 80,
+            'PublishMode': 'ingress',
+        }]
+
+    def test_convert_service_ports_invalid(self):
+        ports = {8080: ('way', 'too', 'many', 'items', 'here')}
+
+        with pytest.raises(ValueError):
+            convert_service_ports(ports)
+
+    def test_convert_service_ports_no_protocol_and_mode(self):
+        ports = {8080: (80, None, 'host')}
+
+        assert convert_service_ports(ports) == [{
+            'Protocol': 'tcp',
+            'PublishedPort': 8080,
+            'TargetPort': 80,
+            'PublishMode': 'host',
+        }]
+
+    def test_convert_service_ports_multiple(self):
+        ports = {
+            8080: (80, None, 'host'),
+            9999: 99,
+            2375: (2375,)
+        }
+
+        converted_ports = convert_service_ports(ports)
+        assert {
+            'Protocol': 'tcp',
+            'PublishedPort': 8080,
+            'TargetPort': 80,
+            'PublishMode': 'host',
+        } in converted_ports
+
+        assert {
+            'Protocol': 'tcp',
+            'PublishedPort': 9999,
+            'TargetPort': 99,
+        } in converted_ports
+
+        assert {
+            'Protocol': 'tcp',
+            'PublishedPort': 2375,
+            'TargetPort': 2375,
+        } in converted_ports
+
+        assert len(converted_ports) == 3