Skip to content

Commit 9df6027

Browse files
committed
TUN-4052: Add component tests to assert service mode behavior
1 parent 6a9ba61 commit 9df6027

File tree

7 files changed

+109
-15
lines changed

7 files changed

+109
-15
lines changed

cfsetup.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,9 @@ stretch: &stretch
210210
- python3.7
211211
- python3-pip
212212
- python3-setuptools
213+
# procps installs the ps command which is needed in test_sysv_service because the init script
214+
# uses ps pid to determine if the agent is running
215+
- procps
213216
pre-cache-copy-paths:
214217
- component-tests/requirements.txt
215218
pre-cache:

component-tests/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ If you are using Visual Studio, follow https://code.visualstudio.com/docs/python
3131
to turn on formatter and https://marketplace.visualstudio.com/items?itemName=cbrevik.toggle-format-on-save
3232
to turn on format on save.
3333

34+
6. If you have cloudflared running as a service on your machine, you can either stop the service or ignore the service tests
35+
via `--ignore test_service.py`
36+
3437
# How to run
3538
Specify path to config file via env var `COMPONENT_TESTS_CONFIG`. This is required.
3639
## All tests

component-tests/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
METRICS_PORT = 51000
2-
MAX_RETRIES = 3
3-
BACKOFF_SECS = 5
2+
MAX_RETRIES = 5
3+
BACKOFF_SECS = 7

component-tests/test_logging.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66

77
class TestLogging:
8+
# TODO: Test logging when running as a service https://jira.cfops.it/browse/TUN-4082
89
# Rolling logger rotate log files after 1 MB
910
rotate_after_size = 1000 * 1000
1011
default_log_file = "cloudflared.log"

component-tests/test_reconnect.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,11 @@ def assert_reconnect(self, config, cloudflared, repeat):
4343
if expect_connections > 0:
4444
# Don't check if tunnel returns 200 here because there is a race condition between wait_tunnel_ready
4545
# retrying to get 200 response and reconnecting
46-
wait_tunnel_ready(expect_connections=expect_connections)
46+
wait_tunnel_ready(
47+
require_min_connections=expect_connections)
4748
else:
4849
check_tunnel_not_connected()
4950

5051
sleep(self.default_reconnect_secs + 10)
51-
wait_tunnel_ready(tunnel_url=config.get_url())
52+
wait_tunnel_ready(tunnel_url=config.get_url(),
53+
require_min_connections=self.default_ha_conns)

component-tests/test_service.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
#!/usr/bin/env python
2+
from contextlib import contextmanager
3+
import os
4+
from pathlib import Path
5+
import platform
6+
import pytest
7+
import subprocess
8+
9+
from util import start_cloudflared, cloudflared_cmd, wait_tunnel_ready, LOGGER
10+
11+
12+
def select_platform(plat):
13+
return pytest.mark.skipif(
14+
platform.system() != plat, reason=f"Only runs on {plat}")
15+
16+
17+
def default_config_dir():
18+
return os.path.join(Path.home(), ".cloudflared")
19+
20+
21+
def default_config_file():
22+
return os.path.join(default_config_dir(), "config.yml")
23+
24+
25+
class TestServiceMode():
26+
@select_platform("Darwin")
27+
@pytest.mark.skipif(os.path.exists(default_config_file()), reason=f"There is already a config file in default path")
28+
def test_launchd_service(self, component_tests_config):
29+
# On Darwin cloudflared service defaults to run classic tunnel command
30+
additional_config = {
31+
"hello-world": True,
32+
}
33+
config = component_tests_config(
34+
additional_config=additional_config, named_tunnel=False)
35+
with self.run_service(Path(default_config_dir()), config):
36+
self.launchctl_cmd("list")
37+
self.launchctl_cmd("start")
38+
wait_tunnel_ready(tunnel_url=config.get_url())
39+
self.launchctl_cmd("stop")
40+
41+
os.remove(default_config_file())
42+
self.launchctl_cmd("list", success=False)
43+
44+
@select_platform("Linux")
45+
@pytest.mark.skipif(os.path.exists("/etc/cloudflared/config.yml"), reason=f"There is already a config file in default path")
46+
def test_sysv_service(self, tmp_path, component_tests_config):
47+
config = component_tests_config()
48+
with self.run_service(tmp_path, config, root=True):
49+
self.sysv_cmd("start")
50+
self.sysv_cmd("status")
51+
wait_tunnel_ready(tunnel_url=config.get_url())
52+
self.sysv_cmd("stop")
53+
# Service install copies config file to /etc/cloudflared/config.yml
54+
subprocess.run(["sudo", "rm", "/etc/cloudflared/config.yml"])
55+
self.sysv_cmd("status", success=False)
56+
57+
@contextmanager
58+
def run_service(self, tmp_path, config, root=False):
59+
try:
60+
service = start_cloudflared(
61+
tmp_path, config, cfd_args=["service", "install"], cfd_pre_args=[], capture_output=False, root=root)
62+
yield service
63+
finally:
64+
start_cloudflared(
65+
tmp_path, config, cfd_args=["service", "uninstall"], cfd_pre_args=[], capture_output=False, root=root)
66+
67+
def launchctl_cmd(self, action, success=True):
68+
cmd = subprocess.run(
69+
["launchctl", action, "com.cloudflare.cloudflared"], check=success)
70+
if not success:
71+
assert cmd.returncode != 0, f"Expect {cmd.args} to fail, but it succeed"
72+
73+
def sysv_cmd(self, action, success=True):
74+
cmd = subprocess.run(
75+
["sudo", "service", "cloudflared", action], check=success)
76+
if not success:
77+
assert cmd.returncode != 0, f"Expect {cmd.args} to fail, but it succeed"

component-tests/util.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,34 @@
1212
LOGGER = logging.getLogger(__name__)
1313

1414

15-
def write_config(path, config):
16-
config_path = path / "config.yaml"
15+
def write_config(directory, config):
16+
config_path = directory / "config.yml"
1717
with open(config_path, 'w') as outfile:
1818
yaml.dump(config, outfile)
1919
return config_path
2020

2121

22-
def start_cloudflared(path, config, cfd_args=["run"], cfd_pre_args=["tunnel"], new_process=False, allow_input=False, capture_output=True):
23-
config_path = write_config(path, config.full_config)
24-
cmd = [config.cloudflared_binary]
25-
cmd += cfd_pre_args
26-
cmd += ["--config", config_path]
27-
cmd += cfd_args
28-
LOGGER.info(f"Run cmd {cmd} with config {config}")
22+
def start_cloudflared(directory, config, cfd_args=["run"], cfd_pre_args=["tunnel"], new_process=False, allow_input=False, capture_output=True, root=False):
23+
config_path = write_config(directory, config.full_config)
24+
cmd = cloudflared_cmd(config, config_path, cfd_args, cfd_pre_args, root)
2925
if new_process:
3026
return run_cloudflared_background(cmd, allow_input, capture_output)
3127
# By setting check=True, it will raise an exception if the process exits with non-zero exit code
3228
return subprocess.run(cmd, check=True, capture_output=capture_output)
3329

3430

31+
def cloudflared_cmd(config, config_path, cfd_args, cfd_pre_args, root):
32+
cmd = []
33+
if root:
34+
cmd += ["sudo"]
35+
cmd += [config.cloudflared_binary]
36+
cmd += cfd_pre_args
37+
cmd += ["--config", config_path]
38+
cmd += cfd_args
39+
LOGGER.info(f"Run cmd {cmd} with config {config}")
40+
return cmd
41+
42+
3543
@contextmanager
3644
def run_cloudflared_background(cmd, allow_input, capture_output):
3745
output = subprocess.PIPE if capture_output else subprocess.DEVNULL
@@ -44,13 +52,13 @@ def run_cloudflared_background(cmd, allow_input, capture_output):
4452

4553

4654
@retry(stop_max_attempt_number=MAX_RETRIES, wait_fixed=BACKOFF_SECS * 1000)
47-
def wait_tunnel_ready(tunnel_url=None, expect_connections=4):
55+
def wait_tunnel_ready(tunnel_url=None, require_min_connections=1):
4856
metrics_url = f'http://localhost:{METRICS_PORT}/ready'
4957

5058
with requests.Session() as s:
5159
resp = send_request(s, metrics_url, True)
5260
assert resp.json()[
53-
"readyConnections"] >= expect_connections, f"Ready endpoint returned {resp.json()} but we expect at least {expect_connections} connections"
61+
"readyConnections"] >= require_min_connections, f"Ready endpoint returned {resp.json()} but we expect at least {require_min_connections} connections"
5462
if tunnel_url is not None:
5563
send_request(s, tunnel_url, True)
5664

0 commit comments

Comments
 (0)