TUN-4052: Add component tests to assert service mode behavior

This commit is contained in:
cthuang 2021-03-12 13:37:53 +00:00
parent 6a9ba61242
commit 9df60276a9
7 changed files with 109 additions and 15 deletions

View File

@ -210,6 +210,9 @@ stretch: &stretch
- python3.7 - python3.7
- python3-pip - python3-pip
- python3-setuptools - python3-setuptools
# procps installs the ps command which is needed in test_sysv_service because the init script
# uses ps pid to determine if the agent is running
- procps
pre-cache-copy-paths: pre-cache-copy-paths:
- component-tests/requirements.txt - component-tests/requirements.txt
pre-cache: pre-cache:

View File

@ -31,6 +31,9 @@ If you are using Visual Studio, follow https://code.visualstudio.com/docs/python
to turn on formatter and https://marketplace.visualstudio.com/items?itemName=cbrevik.toggle-format-on-save to turn on formatter and https://marketplace.visualstudio.com/items?itemName=cbrevik.toggle-format-on-save
to turn on format on save. to turn on format on save.
6. If you have cloudflared running as a service on your machine, you can either stop the service or ignore the service tests
via `--ignore test_service.py`
# How to run # How to run
Specify path to config file via env var `COMPONENT_TESTS_CONFIG`. This is required. Specify path to config file via env var `COMPONENT_TESTS_CONFIG`. This is required.
## All tests ## All tests

View File

@ -1,3 +1,3 @@
METRICS_PORT = 51000 METRICS_PORT = 51000
MAX_RETRIES = 3 MAX_RETRIES = 5
BACKOFF_SECS = 5 BACKOFF_SECS = 7

View File

@ -5,6 +5,7 @@ from util import start_cloudflared, wait_tunnel_ready, send_requests, LOGGER
class TestLogging: class TestLogging:
# TODO: Test logging when running as a service https://jira.cfops.it/browse/TUN-4082
# Rolling logger rotate log files after 1 MB # Rolling logger rotate log files after 1 MB
rotate_after_size = 1000 * 1000 rotate_after_size = 1000 * 1000
default_log_file = "cloudflared.log" default_log_file = "cloudflared.log"

View File

@ -43,9 +43,11 @@ class TestReconnect():
if expect_connections > 0: if expect_connections > 0:
# Don't check if tunnel returns 200 here because there is a race condition between wait_tunnel_ready # Don't check if tunnel returns 200 here because there is a race condition between wait_tunnel_ready
# retrying to get 200 response and reconnecting # retrying to get 200 response and reconnecting
wait_tunnel_ready(expect_connections=expect_connections) wait_tunnel_ready(
require_min_connections=expect_connections)
else: else:
check_tunnel_not_connected() check_tunnel_not_connected()
sleep(self.default_reconnect_secs + 10) sleep(self.default_reconnect_secs + 10)
wait_tunnel_ready(tunnel_url=config.get_url()) wait_tunnel_ready(tunnel_url=config.get_url(),
require_min_connections=self.default_ha_conns)

View File

@ -0,0 +1,77 @@
#!/usr/bin/env python
from contextlib import contextmanager
import os
from pathlib import Path
import platform
import pytest
import subprocess
from util import start_cloudflared, cloudflared_cmd, wait_tunnel_ready, LOGGER
def select_platform(plat):
return pytest.mark.skipif(
platform.system() != plat, reason=f"Only runs on {plat}")
def default_config_dir():
return os.path.join(Path.home(), ".cloudflared")
def default_config_file():
return os.path.join(default_config_dir(), "config.yml")
class TestServiceMode():
@select_platform("Darwin")
@pytest.mark.skipif(os.path.exists(default_config_file()), reason=f"There is already a config file in default path")
def test_launchd_service(self, component_tests_config):
# On Darwin cloudflared service defaults to run classic tunnel command
additional_config = {
"hello-world": True,
}
config = component_tests_config(
additional_config=additional_config, named_tunnel=False)
with self.run_service(Path(default_config_dir()), config):
self.launchctl_cmd("list")
self.launchctl_cmd("start")
wait_tunnel_ready(tunnel_url=config.get_url())
self.launchctl_cmd("stop")
os.remove(default_config_file())
self.launchctl_cmd("list", success=False)
@select_platform("Linux")
@pytest.mark.skipif(os.path.exists("/etc/cloudflared/config.yml"), reason=f"There is already a config file in default path")
def test_sysv_service(self, tmp_path, component_tests_config):
config = component_tests_config()
with self.run_service(tmp_path, config, root=True):
self.sysv_cmd("start")
self.sysv_cmd("status")
wait_tunnel_ready(tunnel_url=config.get_url())
self.sysv_cmd("stop")
# Service install copies config file to /etc/cloudflared/config.yml
subprocess.run(["sudo", "rm", "/etc/cloudflared/config.yml"])
self.sysv_cmd("status", success=False)
@contextmanager
def run_service(self, tmp_path, config, root=False):
try:
service = start_cloudflared(
tmp_path, config, cfd_args=["service", "install"], cfd_pre_args=[], capture_output=False, root=root)
yield service
finally:
start_cloudflared(
tmp_path, config, cfd_args=["service", "uninstall"], cfd_pre_args=[], capture_output=False, root=root)
def launchctl_cmd(self, action, success=True):
cmd = subprocess.run(
["launchctl", action, "com.cloudflare.cloudflared"], check=success)
if not success:
assert cmd.returncode != 0, f"Expect {cmd.args} to fail, but it succeed"
def sysv_cmd(self, action, success=True):
cmd = subprocess.run(
["sudo", "service", "cloudflared", action], check=success)
if not success:
assert cmd.returncode != 0, f"Expect {cmd.args} to fail, but it succeed"

View File

@ -12,26 +12,34 @@ from constants import METRICS_PORT, MAX_RETRIES, BACKOFF_SECS
LOGGER = logging.getLogger(__name__) LOGGER = logging.getLogger(__name__)
def write_config(path, config): def write_config(directory, config):
config_path = path / "config.yaml" config_path = directory / "config.yml"
with open(config_path, 'w') as outfile: with open(config_path, 'w') as outfile:
yaml.dump(config, outfile) yaml.dump(config, outfile)
return config_path return config_path
def start_cloudflared(path, config, cfd_args=["run"], cfd_pre_args=["tunnel"], new_process=False, allow_input=False, capture_output=True): def start_cloudflared(directory, config, cfd_args=["run"], cfd_pre_args=["tunnel"], new_process=False, allow_input=False, capture_output=True, root=False):
config_path = write_config(path, config.full_config) config_path = write_config(directory, config.full_config)
cmd = [config.cloudflared_binary] cmd = cloudflared_cmd(config, config_path, cfd_args, cfd_pre_args, root)
cmd += cfd_pre_args
cmd += ["--config", config_path]
cmd += cfd_args
LOGGER.info(f"Run cmd {cmd} with config {config}")
if new_process: if new_process:
return run_cloudflared_background(cmd, allow_input, capture_output) return run_cloudflared_background(cmd, allow_input, capture_output)
# By setting check=True, it will raise an exception if the process exits with non-zero exit code # By setting check=True, it will raise an exception if the process exits with non-zero exit code
return subprocess.run(cmd, check=True, capture_output=capture_output) return subprocess.run(cmd, check=True, capture_output=capture_output)
def cloudflared_cmd(config, config_path, cfd_args, cfd_pre_args, root):
cmd = []
if root:
cmd += ["sudo"]
cmd += [config.cloudflared_binary]
cmd += cfd_pre_args
cmd += ["--config", config_path]
cmd += cfd_args
LOGGER.info(f"Run cmd {cmd} with config {config}")
return cmd
@contextmanager @contextmanager
def run_cloudflared_background(cmd, allow_input, capture_output): def run_cloudflared_background(cmd, allow_input, capture_output):
output = subprocess.PIPE if capture_output else subprocess.DEVNULL output = subprocess.PIPE if capture_output else subprocess.DEVNULL
@ -44,13 +52,13 @@ def run_cloudflared_background(cmd, allow_input, capture_output):
@retry(stop_max_attempt_number=MAX_RETRIES, wait_fixed=BACKOFF_SECS * 1000) @retry(stop_max_attempt_number=MAX_RETRIES, wait_fixed=BACKOFF_SECS * 1000)
def wait_tunnel_ready(tunnel_url=None, expect_connections=4): def wait_tunnel_ready(tunnel_url=None, require_min_connections=1):
metrics_url = f'http://localhost:{METRICS_PORT}/ready' metrics_url = f'http://localhost:{METRICS_PORT}/ready'
with requests.Session() as s: with requests.Session() as s:
resp = send_request(s, metrics_url, True) resp = send_request(s, metrics_url, True)
assert resp.json()[ assert resp.json()[
"readyConnections"] >= expect_connections, f"Ready endpoint returned {resp.json()} but we expect at least {expect_connections} connections" "readyConnections"] >= require_min_connections, f"Ready endpoint returned {resp.json()} but we expect at least {require_min_connections} connections"
if tunnel_url is not None: if tunnel_url is not None:
send_request(s, tunnel_url, True) send_request(s, tunnel_url, True)