Skip to content

Commit 978e01f

Browse files
committed
TUN-6010: Add component tests for --edge-ip-version
1 parent 1275930 commit 978e01f

File tree

4 files changed

+279
-7
lines changed

4 files changed

+279
-7
lines changed

component-tests/cli.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import json
2+
import subprocess
3+
from time import sleep
4+
5+
from setup import get_config_from_file
6+
7+
SINGLE_CASE_TIMEOUT = 600
8+
9+
class CloudflaredCli:
10+
def __init__(self, config, config_path, logger):
11+
self.basecmd = [config.cloudflared_binary, "tunnel"]
12+
if config_path is not None:
13+
self.basecmd += ["--config", str(config_path)]
14+
origincert = get_config_from_file()["origincert"]
15+
if origincert:
16+
self.basecmd += ["--origincert", origincert]
17+
self.logger = logger
18+
19+
def _run_command(self, subcmd, subcmd_name, needs_to_pass=True):
20+
cmd = self.basecmd + subcmd
21+
# timeout limits the time a subprocess can run. This is useful to guard against running a tunnel when
22+
# command/args are in wrong order.
23+
result = run_subprocess(cmd, subcmd_name, self.logger, check=needs_to_pass, capture_output=True, timeout=15)
24+
return result
25+
26+
def list_tunnels(self):
27+
cmd_args = ["list", "--output", "json"]
28+
listed = self._run_command(cmd_args, "list")
29+
return json.loads(listed.stdout)
30+
31+
def get_tunnel_info(self, tunnel_id):
32+
info = self._run_command(["info", "--output", "json", tunnel_id], "info")
33+
return json.loads(info.stdout)
34+
35+
def __enter__(self):
36+
self.basecmd += ["run"]
37+
self.process = subprocess.Popen(self.basecmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
38+
self.logger.info(f"Run cmd {self.basecmd}")
39+
return self.process
40+
41+
def __exit__(self, exc_type, exc_value, exc_traceback):
42+
terminate_gracefully(self.process, self.logger, self.basecmd)
43+
self.logger.debug(f"{self.basecmd} logs: {self.process.stderr.read()}")
44+
45+
46+
def terminate_gracefully(process, logger, cmd):
47+
process.terminate()
48+
process_terminated = wait_for_terminate(process)
49+
if not process_terminated:
50+
process.kill()
51+
logger.warning(f"{cmd}: cloudflared did not terminate within wait period. Killing process. logs: \
52+
stdout: {process.stdout.read()}, stderr: {process.stderr.read()}")
53+
54+
55+
def wait_for_terminate(opened_subprocess, attempts=10, poll_interval=1):
56+
"""
57+
wait_for_terminate polls the opened_subprocess every x seconds for a given number of attempts.
58+
It returns true if the subprocess was terminated and false if it didn't.
59+
"""
60+
for _ in range(attempts):
61+
if _is_process_stopped(opened_subprocess):
62+
return True
63+
sleep(poll_interval)
64+
return False
65+
66+
67+
def _is_process_stopped(process):
68+
return process.poll() is not None
69+
70+
71+
def cert_path():
72+
return get_config_from_file()["origincert"]
73+
74+
75+
class SubprocessError(Exception):
76+
def __init__(self, program, exit_code, cause):
77+
self.program = program
78+
self.exit_code = exit_code
79+
self.cause = cause
80+
81+
82+
def run_subprocess(cmd, cmd_name, logger, timeout=SINGLE_CASE_TIMEOUT, **kargs):
83+
kargs["timeout"] = timeout
84+
try:
85+
result = subprocess.run(cmd, **kargs)
86+
logger.debug(f"{cmd} log: {result.stdout}", extra={"cmd": cmd_name})
87+
return result
88+
except subprocess.CalledProcessError as e:
89+
err = f"{cmd} return exit code {e.returncode}, stderr" + e.stderr.decode("utf-8")
90+
logger.error(err, extra={"cmd": cmd_name, "return_code": e.returncode})
91+
raise SubprocessError(cmd[0], e.returncode, e)
92+
except subprocess.TimeoutExpired as e:
93+
err = f"{cmd} timeout after {e.timeout} seconds, stdout: {e.stdout}, stderr: {e.stderr}"
94+
logger.error(err, extra={"cmd": cmd_name, "return_code": "timeout"})
95+
raise e
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import ipaddress
2+
import socket
3+
4+
import pytest
5+
6+
from constants import protocols
7+
from cli import CloudflaredCli
8+
from util import get_tunnel_connector_id, LOGGER, wait_tunnel_ready, write_config
9+
10+
11+
class TestEdgeDiscovery:
12+
def _extra_config(self, protocol, edge_ip_version):
13+
config = {
14+
"protocol": protocol,
15+
}
16+
if edge_ip_version:
17+
config["edge-ip-version"] = edge_ip_version
18+
return config
19+
20+
@pytest.mark.parametrize("protocol", protocols())
21+
def test_default_only(self, tmp_path, component_tests_config, protocol):
22+
"""
23+
This test runs a tunnel to connect via IPv4-only edge addresses (default is unset "--edge-ip-version 4")
24+
"""
25+
if self.has_ipv6_only():
26+
pytest.skip("Host has IPv6 only support and current default is IPv4 only")
27+
self.expect_address_connections(
28+
tmp_path, component_tests_config, protocol, None, self.expect_ipv4_address)
29+
30+
@pytest.mark.parametrize("protocol", protocols())
31+
def test_ipv4_only(self, tmp_path, component_tests_config, protocol):
32+
"""
33+
This test runs a tunnel to connect via IPv4-only edge addresses
34+
"""
35+
if self.has_ipv6_only():
36+
pytest.skip("Host has IPv6 only support")
37+
self.expect_address_connections(
38+
tmp_path, component_tests_config, protocol, "4", self.expect_ipv4_address)
39+
40+
@pytest.mark.parametrize("protocol", protocols())
41+
def test_ipv6_only(self, tmp_path, component_tests_config, protocol):
42+
"""
43+
This test runs a tunnel to connect via IPv6-only edge addresses
44+
"""
45+
if self.has_ipv4_only():
46+
pytest.skip("Host has IPv4 only support")
47+
self.expect_address_connections(
48+
tmp_path, component_tests_config, protocol, "6", self.expect_ipv6_address)
49+
50+
@pytest.mark.parametrize("protocol", protocols())
51+
def test_auto_ip64(self, tmp_path, component_tests_config, protocol):
52+
"""
53+
This test runs a tunnel to connect via auto with a preference of IPv6 then IPv4 addresses for a dual stack host
54+
55+
This test also assumes that the host has IPv6 preference.
56+
"""
57+
if not self.has_dual_stack(address_family_preference=socket.AddressFamily.AF_INET6):
58+
pytest.skip("Host does not support dual stack with IPv6 preference")
59+
self.expect_address_connections(
60+
tmp_path, component_tests_config, protocol, "auto", self.expect_ipv6_address)
61+
62+
@pytest.mark.parametrize("protocol", protocols())
63+
def test_auto_ip46(self, tmp_path, component_tests_config, protocol):
64+
"""
65+
This test runs a tunnel to connect via auto with a preference of IPv4 then IPv6 addresses for a dual stack host
66+
67+
This test also assumes that the host has IPv4 preference.
68+
"""
69+
if not self.has_dual_stack(address_family_preference=socket.AddressFamily.AF_INET):
70+
pytest.skip("Host does not support dual stack with IPv4 preference")
71+
self.expect_address_connections(
72+
tmp_path, component_tests_config, protocol, "auto", self.expect_ipv4_address)
73+
74+
def expect_address_connections(self, tmp_path, component_tests_config, protocol, edge_ip_version, assert_address_type):
75+
config = component_tests_config(
76+
self._extra_config(protocol, edge_ip_version))
77+
config_path = write_config(tmp_path, config.full_config)
78+
LOGGER.debug(config)
79+
with CloudflaredCli(config, config_path, LOGGER):
80+
wait_tunnel_ready(tunnel_url=config.get_url(),
81+
require_min_connections=4)
82+
cfd_cli = CloudflaredCli(config, config_path, LOGGER)
83+
tunnel_id = config.get_tunnel_id()
84+
info = cfd_cli.get_tunnel_info(tunnel_id)
85+
connector_id = get_tunnel_connector_id()
86+
connector = next(
87+
(c for c in info["conns"] if c["id"] == connector_id), None)
88+
assert connector, f"Expected connection info from get tunnel info for the connected instance: {info}"
89+
conns = connector["conns"]
90+
assert conns == None or len(
91+
conns) == 4, f"There should be 4 connections registered: {conns}"
92+
for conn in conns:
93+
origin_ip = conn["origin_ip"]
94+
assert origin_ip, f"No available origin_ip for this connection: {conn}"
95+
assert_address_type(origin_ip)
96+
97+
def expect_ipv4_address(self, address):
98+
assert type(ipaddress.ip_address(
99+
address)) is ipaddress.IPv4Address, f"Expected connection from origin to be a valid IPv4 address: {address}"
100+
101+
def expect_ipv6_address(self, address):
102+
assert type(ipaddress.ip_address(
103+
address)) is ipaddress.IPv6Address, f"Expected connection from origin to be a valid IPv6 address: {address}"
104+
105+
def get_addresses(self):
106+
"""
107+
Returns a list of addresses for the host.
108+
"""
109+
host_addresses = socket.getaddrinfo(
110+
"region1.v2.argotunnel.com", 7844, socket.AF_UNSPEC, socket.SOCK_STREAM)
111+
assert len(
112+
host_addresses) > 0, "No addresses returned from getaddrinfo"
113+
return host_addresses
114+
115+
def has_dual_stack(self, address_family_preference=None):
116+
"""
117+
Returns true if the host has dual stack support and can optionally check
118+
the provided IP family preference.
119+
"""
120+
dual_stack = not self.has_ipv6_only() and not self.has_ipv4_only()
121+
if address_family_preference:
122+
address = self.get_addresses()[0]
123+
return dual_stack and address[0] == address_family_preference
124+
125+
return dual_stack
126+
127+
def has_ipv6_only(self):
128+
"""
129+
Returns True if the host has only IPv6 address support.
130+
"""
131+
return self.attempt_connection(socket.AddressFamily.AF_INET6) and not self.attempt_connection(socket.AddressFamily.AF_INET)
132+
133+
def has_ipv4_only(self):
134+
"""
135+
Returns True if the host has only IPv4 address support.
136+
"""
137+
return self.attempt_connection(socket.AddressFamily.AF_INET) and not self.attempt_connection(socket.AddressFamily.AF_INET6)
138+
139+
def attempt_connection(self, address_family):
140+
"""
141+
Returns True if a successful socket connection can be made to the
142+
remote host with the provided address family to validate host support
143+
for the provided address family.
144+
"""
145+
address = None
146+
for a in self.get_addresses():
147+
if a[0] == address_family:
148+
address = a
149+
break
150+
if address is None:
151+
# Couldn't even lookup the address family so we can't connect
152+
return False
153+
af, socktype, proto, canonname, sockaddr = address
154+
s = None
155+
try:
156+
s = socket.socket(af, socktype, proto)
157+
except OSError:
158+
return False
159+
try:
160+
s.connect(sockaddr)
161+
except OSError:
162+
s.close()
163+
return False
164+
s.close()
165+
return True

component-tests/test_service.py

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#!/usr/bin/env python
22
import os
33
import pathlib
4-
import platform
54
import subprocess
65
from contextlib import contextmanager
76
from pathlib import Path
@@ -10,12 +9,7 @@
109

1110
import test_logging
1211
from conftest import CfdModes
13-
from util import start_cloudflared, wait_tunnel_ready, write_config
14-
15-
16-
def select_platform(plat):
17-
return pytest.mark.skipif(
18-
platform.system() != plat, reason=f"Only runs on {plat}")
12+
from util import select_platform, start_cloudflared, wait_tunnel_ready, write_config
1913

2014

2115
def default_config_dir():

component-tests/util.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import logging
22
import os
3+
import platform
34
import subprocess
45
from contextlib import contextmanager
56
from time import sleep
67

8+
import pytest
9+
710
import requests
811
import yaml
912
from retrying import retry
@@ -12,6 +15,10 @@
1215

1316
LOGGER = logging.getLogger(__name__)
1417

18+
def select_platform(plat):
19+
return pytest.mark.skipif(
20+
platform.system() != plat, reason=f"Only runs on {plat}")
21+
1522

1623
def write_config(directory, config):
1724
config_path = directory / "config.yml"
@@ -111,6 +118,17 @@ def check_tunnel_not_connected():
111118
LOGGER.warning(f"Failed to connect to {url}, error: {e}")
112119

113120

121+
def get_tunnel_connector_id():
122+
url = f'http://localhost:{METRICS_PORT}/ready'
123+
124+
try:
125+
resp = requests.get(url, timeout=1)
126+
return resp.json()["connectorId"]
127+
# cloudflared might already terminated
128+
except requests.exceptions.ConnectionError as e:
129+
LOGGER.warning(f"Failed to connect to {url}, error: {e}")
130+
131+
114132
# In some cases we don't need to check response status, such as when sending batch requests to generate logs
115133
def send_requests(url, count, require_ok=True):
116134
errors = 0

0 commit comments

Comments
 (0)