Skip to content

Commit 8a81e02

Browse files
committed
Fix current host IP address being reported as remote host
1 parent d5ac2e2 commit 8a81e02

File tree

3 files changed

+120
-49
lines changed

3 files changed

+120
-49
lines changed

cylc/flow/hostuserutil.py

Lines changed: 63 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,12 @@
5050
import socket
5151
import sys
5252
from time import time
53-
from typing import List, Optional, Tuple
53+
from typing import (
54+
Dict,
55+
List,
56+
Optional,
57+
Tuple,
58+
)
5459

5560
from cylc.flow.cfgspec.glbl_cfg import glbl_cfg
5661

@@ -81,13 +86,21 @@ def get_inst(cls, new=False, expire=None):
8186
cls._instance = cls(expire)
8287
return cls._instance
8388

84-
def __init__(self, expire):
89+
def __init__(self, expire: float):
8590
self.expire_time = time() + expire
86-
self._host = None # preferred name of localhost
87-
self._host_exs = {} # host: socket.gethostbyname_ex(host), ...
88-
self._remote_hosts = {} # host: is_remote, ...
89-
self.user_pwent = None
90-
self.remote_users = {}
91+
self._host: Optional[str] = None # preferred name of localhost
92+
self._host_exs: Dict[ # host: socket.gethostbyname_ex(host), ...
93+
str, Tuple[str, List[str], List[str]]
94+
] = {}
95+
self._remote_hosts: Dict[str, bool] = {} # host: is_remote, ...
96+
self.user_pwent: Optional[pwd.struct_passwd] = None
97+
self.remote_users: Dict[str, bool] = {}
98+
99+
# On MacOS we have seen different results of socket.gethostbyname_ex()
100+
# before and after calling socket.getfqdn() for the 1st time. See
101+
# https://github.com/actions/runner-images/issues/8649#issuecomment-1855919367
102+
# Call it here at init to ensure we get consistent results from now on.
103+
socket.getfqdn()
91104

92105
@staticmethod
93106
def get_local_ip_address(target):
@@ -117,23 +130,31 @@ def get_host_ip_by_name(target):
117130
def _get_host_info(
118131
self, target: Optional[str] = None
119132
) -> Tuple[str, List[str], List[str]]:
120-
"""Return the extended info of the current host."""
133+
"""Return the extended info of the current host.
134+
135+
This should return the same result for all possible names or
136+
IP addresses of the same host, as well as caching the results,
137+
unlike socket.gethostbyname_ex() alone.
138+
"""
121139
if target is None:
122140
target = socket.getfqdn()
123-
if IS_MAC_OS and target in {
124-
'1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.'
125-
'0.0.0.0.0.0.ip6.arpa',
126-
'1.0.0.127.in-addr.arpa',
127-
}:
128-
# Python's socket bindings don't play nicely with mac os
129-
# so by default we get the above ip6.arpa address from
130-
# socket.getfqdn, note this does *not* match `hostname -f`.
131-
# https://github.com/cylc/cylc-flow/issues/2689
132-
# https://github.com/cylc/cylc-flow/issues/3595
133-
target = socket.gethostname()
134141
if target not in self._host_exs:
135142
try:
136-
self._host_exs[target] = socket.gethostbyname_ex(target)
143+
if IS_MAC_OS and target in {
144+
'1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.'
145+
'0.0.0.0.0.0.ip6.arpa',
146+
'1.0.0.127.in-addr.arpa',
147+
}:
148+
# Python's socket bindings don't play nicely with mac os
149+
# so by default we get the above ip6.arpa address from
150+
# socket.getfqdn, note this does *not* match `hostname -f`.
151+
# https://github.com/cylc/cylc-flow/issues/2689
152+
# https://github.com/cylc/cylc-flow/issues/3595
153+
name = socket.gethostname()
154+
else:
155+
# Normalise the name or IP address to a FQDN
156+
name = socket.getfqdn(socket.gethostbyaddr(target)[0])
157+
self._host_exs[target] = socket.gethostbyname_ex(name)
137158
except IOError as exc:
138159
if exc.filename is None:
139160
exc.filename = target
@@ -190,26 +211,32 @@ def _get_user_pwent(self):
190211
self.remote_users.update(((self.user_pwent.pw_name, False),))
191212
return self.user_pwent
192213

193-
def is_remote_host(self, name):
194-
"""Return True if name has different IP address than the current host.
214+
def is_remote_host(self, host: Optional[str]) -> bool:
215+
"""Return True if the host is not the current host.
216+
217+
If the given host's primary name does not match the current host's or
218+
'localhost', the host is considered remote.
219+
220+
Args:
221+
host: Either a host name or an IP address.
195222
196223
Return False if name is None.
197224
Return True if host is unknown.
198225
199226
"""
200-
if name not in self._remote_hosts:
201-
if not name or name.startswith("localhost"):
202-
# e.g. localhost4.localdomain4
203-
self._remote_hosts[name] = False
227+
if not host:
228+
return False
229+
if host not in self._remote_hosts:
230+
try:
231+
host_name = self._get_host_info(host)[0].lower()
232+
except IOError:
233+
self._remote_hosts[host] = True
204234
else:
205-
try:
206-
host_info = self._get_host_info(name)
207-
except IOError:
208-
self._remote_hosts[name] = True
209-
else:
210-
self._remote_hosts[name] = (
211-
host_info != self._get_host_info())
212-
return self._remote_hosts[name]
235+
this_name = self._get_host_info()[0].lower()
236+
self._remote_hosts[host] = (
237+
host_name not in {this_name, 'localhost'}
238+
)
239+
return self._remote_hosts[host]
213240

214241
def is_remote_user(self, name):
215242
"""Return True if name is not a name of the current user.
@@ -281,9 +308,9 @@ def is_remote_platform(platform):
281308
return HostUtil.get_inst()._is_remote_platform(platform)
282309

283310

284-
def is_remote_host(name):
311+
def is_remote_host(host: Optional[str]) -> bool:
285312
"""Shorthand for HostUtil.get_inst().is_remote_host(name)."""
286-
return HostUtil.get_inst().is_remote_host(name)
313+
return HostUtil.get_inst().is_remote_host(host)
287314

288315

289316
def is_remote_user(name):

tests/unit/test_hostuserutil.py

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,47 @@
1616

1717
import os
1818
import re
19+
from secrets import token_hex
20+
import socket
1921

2022
import pytest
2123

2224
from cylc.flow.hostuserutil import (
25+
HostUtil,
2326
get_fqdn_by_host,
2427
get_host,
28+
get_host_ip_by_name,
2529
get_user,
2630
get_user_home,
2731
is_remote_host,
28-
is_remote_user
32+
is_remote_user,
2933
)
3034

3135

36+
LOCALHOST_ALIASES = socket.gethostbyname_ex('localhost')[1]
37+
38+
3239
def test_is_remote_user_on_current_user():
3340
"""is_remote_user with current user."""
3441
assert not is_remote_user(None)
3542
assert not is_remote_user(os.getenv('USER'))
3643

3744

38-
def test_is_remote_host_on_localhost(monkeypatch):
45+
@pytest.mark.parametrize(
46+
'host',
47+
[
48+
None,
49+
'localhost',
50+
pytest.param(os.getenv('HOSTNAME'), id="HOSTNAME-env-var"),
51+
pytest.param(get_host(), id="get_host()"),
52+
pytest.param(get_host_ip_by_name('localhost'), id="localhost-ip"),
53+
pytest.param(get_host_ip_by_name(get_host()), id="get_host-ip"),
54+
*LOCALHOST_ALIASES,
55+
],
56+
)
57+
def test_is_remote_host__localhost(host):
3958
"""is_remote_host with localhost."""
40-
assert not is_remote_host(None)
41-
assert not is_remote_host('localhost')
42-
assert not is_remote_host('localhost4.localhost42')
43-
assert not is_remote_host(os.getenv('HOSTNAME'))
44-
assert not is_remote_host(get_host())
59+
assert not is_remote_host(host)
4560

4661

4762
def test_get_fqdn_by_host_on_bad_host():
@@ -73,3 +88,16 @@ def test_get_user():
7388
def test_get_user_home():
7489
"""get_user_home."""
7590
assert os.getenv('HOME') == get_user_home()
91+
92+
93+
def test_get_host_info__basic():
94+
hu = HostUtil(expire=3600)
95+
assert hu._get_host_info() == socket.gethostbyname_ex(socket.getfqdn())
96+
# Check it handles IP address:
97+
ip = get_host_ip_by_name('localhost')
98+
assert hu._get_host_info(ip) == socket.gethostbyname_ex('localhost')
99+
# Check raised exception for bad host:
100+
bad_host = f'nonexist{token_hex(8)}.com'
101+
with pytest.raises(IOError) as exc:
102+
hu._get_host_info(bad_host)
103+
assert bad_host in str(exc.value)

tests/unit/test_task_remote_mgr.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,30 @@
1717
from contextlib import suppress
1818
from pathlib import Path
1919
from time import sleep
20+
from typing import (
21+
Any,
22+
Optional,
23+
)
24+
from unittest.mock import (
25+
MagicMock,
26+
Mock,
27+
)
28+
2029
import pytest
21-
from typing import (Any, Optional)
22-
from unittest.mock import MagicMock, Mock
2330

2431
from cylc.flow.exceptions import PlatformError
2532
from cylc.flow.network.client_factory import CommsMeth
2633
from cylc.flow.task_remote_mgr import (
27-
REMOTE_FILE_INSTALL_DONE, REMOTE_INIT_IN_PROGRESS, TaskRemoteMgr)
28-
from cylc.flow.workflow_files import WorkflowFiles, get_workflow_srv_dir
34+
REMOTE_FILE_INSTALL_DONE,
35+
REMOTE_INIT_IN_PROGRESS,
36+
TaskRemoteMgr,
37+
)
38+
from cylc.flow.workflow_files import (
39+
WorkflowFiles,
40+
get_workflow_srv_dir,
41+
)
42+
43+
from .test_hostuserutil import LOCALHOST_ALIASES
2944

3045

3146
Fixture = Any
@@ -325,8 +340,9 @@ def test_eval_platform_bad(task_remote_mgr_eval):
325340
'eval_str, remote_cmd_map, expected',
326341
[
327342
*shared_eval_params,
328-
pytest.param(
329-
'localhost4.localdomain4', {}, 'localhost', id="localhost_variant"
343+
*(
344+
pytest.param(alias, {}, 'localhost', id=alias)
345+
for alias in LOCALHOST_ALIASES
330346
),
331347
pytest.param(
332348
'`other cmd`', {'other cmd': 'nicole brennan'}, 'nicole brennan',

0 commit comments

Comments
 (0)