Skip to content

Commit e394889

Browse files
adferrandbmw
authored andcommitted
Add executable scripts to start certbot and acme server in certbot-ci (certbot#7073)
During review of certbot#6989, we saw that some of our test bash scripts were still used in the Boulder project in particular. It is about `tests/integration/_common.sh` in particular, to expose the `certbot_test` bash function, that is an appropriate way to execute a local version of certbot in test mode: define a custom server, remove several checks, full log and so on. This PR is an attempt to assert this goal: exposing a new `certbot_test` executable for test purpose. More generally, this PR is about giving well suited scripts to quickly make manual tests against certbot without launching the full automated pytest suite. The idea here is to leverage the existing logic in certbot-ci, and expose it as executable scripts. This is done thanks to the `console_scripts` entry of setuptools entrypoint feature, that install scripts in the `PATH`, when `pip install` is invoked, that delegate to specific functions in the installed packages. Two scripts are defined this way: * `certbot_test`: it executes certbot in test mode in a very similar way than the original `certbot_test` in `_common.sh`, by delegating to `certbot_integration_tests.utils.certbot_call:main`. By default this execution will target a pebble directory url started locally. The url, and also http-01/tls-alpn-01 challenge ports can be configured using ad-hoc environment variables. All arguments passed to `certbot_test` are transferred to the underlying certbot command. * `acme_server`: it set up a fully running instance of an ACME server, ready for tests (in particular, all FQDN resolves to localhost in order to target a locally running `certbot_test` command) by delegating to `certbot_integration_tests.utils.acme_server:main`. The choice of the ACME server is given by the first parameter passed to `acme_server`, it can be `pebble`, `boulder-v1` or `boulder-v2`. The command keeps running on foreground, displaying the logs of the ACME server on stdout/stderr. The server is shut down and resources cleaned upon entering CTRL+C. This two commands can be run also through the underlying python modules, that are executable. Finally, a typical workflow on certbot side to run manual tests would be: ``` cd certbot tools/venv.py source venv/bin/activate acme_server pebble & certbot_test certonly --standalone -d test.example.com ``` On boulder side it could be: ``` # Follow certbot dev environment setup instructions, then ... cd boulder docker-compose run --use-aliases -e FAKE_DNS=172.17.0.1 --service-ports boulder ./start.py SERVER=http://localhost:4001/directory certbot_test certonly --standalone -d test.example.com ``` * Configure certbot-ci to expose a certbot_test console script calling certbot in test mode against a local pebble instance * Add a command to start pebble/boulder * Use explicit start * Add execution permission to acme_server * Add a docstring to certbot_test function * Change executable name * Increase sleep to 3600s * Implement a context manager to handle the acme server * Add certbot_test workspace in .gitignore * Add documentation * Remove one function in context, split logic of certbot_test towards capturing non capturing * Use an explicit an properly configured ACMEServer as handler. * Add doc. Put constants.
1 parent d75908c commit e394889

File tree

11 files changed

+274
-109
lines changed

11 files changed

+274
-109
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,6 @@ tests/letstest/venv/
4444

4545
# docker files
4646
.docker
47+
48+
# certbot tests
49+
.certbot_test_workspace

certbot-ci/certbot_integration_tests/certbot_tests/context.py

Lines changed: 6 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
"""Module to handle the context of integration tests."""
22
import os
33
import shutil
4-
import subprocess
54
import sys
65
import tempfile
7-
from distutils.version import LooseVersion
86

9-
from certbot_integration_tests.utils import misc
7+
from certbot_integration_tests.utils import misc, certbot_call
108

119

1210
class IntegrationTestsContext(object):
@@ -30,11 +28,6 @@ def __init__(self, request):
3028
# is listening on challtestsrv_port.
3129
self.challtestsrv_port = acme_xdist['challtestsrv_port']
3230

33-
# Certbot version does not depend on the test context. But getting its value requires
34-
# calling certbot from a subprocess. Since it will be called a lot of times through
35-
# _common_test_no_force_renew, we cache its value as a member of the fixture context.
36-
self.certbot_version = misc.get_certbot_version()
37-
3831
self.workspace = tempfile.mkdtemp()
3932
self.config_dir = os.path.join(self.workspace, 'conf')
4033
self.hook_probe = tempfile.mkstemp(dir=self.workspace)[1]
@@ -60,71 +53,18 @@ def cleanup(self):
6053
"""Cleanup the integration test context."""
6154
shutil.rmtree(self.workspace)
6255

63-
def _common_test_no_force_renew(self, args):
64-
"""
65-
Base command to execute certbot in a distributed integration test context,
66-
not renewing certificates by default.
67-
"""
68-
new_environ = os.environ.copy()
69-
new_environ['TMPDIR'] = self.workspace
70-
71-
additional_args = []
72-
if self.certbot_version >= LooseVersion('0.30.0'):
73-
additional_args.append('--no-random-sleep-on-renew')
74-
75-
command = [
76-
'certbot',
77-
'--server', self.directory_url,
78-
'--no-verify-ssl',
79-
'--http-01-port', str(self.http_01_port),
80-
'--https-port', str(self.tls_alpn_01_port),
81-
'--manual-public-ip-logging-ok',
82-
'--config-dir', self.config_dir,
83-
'--work-dir', os.path.join(self.workspace, 'work'),
84-
'--logs-dir', os.path.join(self.workspace, 'logs'),
85-
'--non-interactive',
86-
'--no-redirect',
87-
'--agree-tos',
88-
'--register-unsafely-without-email',
89-
'--debug',
90-
'-vv'
91-
]
92-
93-
command.extend(args)
94-
command.extend(additional_args)
95-
96-
print('Invoke command:\n{0}'.format(subprocess.list2cmdline(command)))
97-
return subprocess.check_output(command, universal_newlines=True,
98-
cwd=self.workspace, env=new_environ)
99-
100-
def _common_test(self, args):
101-
"""
102-
Base command to execute certbot in a distributed integration test context,
103-
renewing certificates by default.
104-
"""
105-
command = ['--renew-by-default']
106-
command.extend(args)
107-
return self._common_test_no_force_renew(command)
108-
109-
def certbot_no_force_renew(self, args):
56+
def certbot(self, args, force_renew=True):
11057
"""
11158
Execute certbot with given args, not renewing certificates by default.
11259
:param args: args to pass to certbot
60+
:param force_renew: set to False to not renew by default
11361
:return: output of certbot execution
11462
"""
11563
command = ['--authenticator', 'standalone', '--installer', 'null']
11664
command.extend(args)
117-
return self._common_test_no_force_renew(command)
118-
119-
def certbot(self, args):
120-
"""
121-
Execute certbot with given args, renewing certificates by default.
122-
:param args: args to pass to certbot
123-
:return: output of certbot execution
124-
"""
125-
command = ['--renew-by-default']
126-
command.extend(args)
127-
return self.certbot_no_force_renew(command)
65+
return certbot_call.certbot_test(
66+
command, self.directory_url, self.http_01_port, self.tls_alpn_01_port,
67+
self.config_dir, self.workspace, force_renew=force_renew)
12868

12969
def get_domain(self, subdomain='le'):
13070
"""

certbot-ci/certbot_integration_tests/certbot_tests/test_main.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -229,8 +229,8 @@ def test_graceful_renew_it_is_not_time(context):
229229

230230
assert_cert_count_for_lineage(context.config_dir, certname, 1)
231231

232-
context.certbot_no_force_renew([
233-
'renew', '--deploy-hook', 'echo deploy >> "{0}"'.format(context.hook_probe)])
232+
context.certbot(['renew', '--deploy-hook', 'echo deploy >> "{0}"'.format(context.hook_probe)],
233+
force_renew=False)
234234

235235
assert_cert_count_for_lineage(context.config_dir, certname, 1)
236236
with pytest.raises(AssertionError):
@@ -250,8 +250,8 @@ def test_graceful_renew_it_is_time(context):
250250
with open(join(context.config_dir, 'renewal', '{0}.conf'.format(certname)), 'w') as file:
251251
file.writelines(lines)
252252

253-
context.certbot_no_force_renew([
254-
'renew', '--deploy-hook', 'echo deploy >> "{0}"'.format(context.hook_probe)])
253+
context.certbot(['renew', '--deploy-hook', 'echo deploy >> "{0}"'.format(context.hook_probe)],
254+
force_renew=False)
255255

256256
assert_cert_count_for_lineage(context.config_dir, certname, 2)
257257
assert_hook_execution(context.hook_probe, 'deploy')

certbot-ci/certbot_integration_tests/conftest.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,9 @@ def _setup_primary_node(config):
8686

8787
# By calling setup_acme_server we ensure that all necessary acme server instances will be
8888
# fully started. This runtime is reflected by the acme_xdist returned.
89-
acme_xdist = acme_lib.setup_acme_server(config.option.acme_server, workers)
90-
print('ACME xdist config:\n{0}'.format(acme_xdist))
89+
acme_server = acme_lib.setup_acme_server(config.option.acme_server, workers)
90+
config.add_cleanup(acme_server.stop)
91+
print('ACME xdist config:\n{0}'.format(acme_server.acme_xdist))
92+
acme_server.start()
9193

92-
return acme_xdist
94+
return acme_server.acme_xdist

certbot-ci/certbot_integration_tests/nginx_tests/context.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import subprocess
33

44
from certbot_integration_tests.certbot_tests import context as certbot_context
5-
from certbot_integration_tests.utils import misc
5+
from certbot_integration_tests.utils import misc, certbot_call
66
from certbot_integration_tests.nginx_tests import nginx_config as config
77

88

@@ -33,11 +33,14 @@ def certbot_test_nginx(self, args):
3333
"""
3434
Main command to execute certbot using the nginx plugin.
3535
:param list args: list of arguments to pass to nginx
36+
:param bool force_renew: set to False to not renew by default
3637
"""
3738
command = ['--authenticator', 'nginx', '--installer', 'nginx',
3839
'--nginx-server-root', self.nginx_root]
3940
command.extend(args)
40-
return self._common_test(command)
41+
return certbot_call.certbot_test(
42+
command, self.directory_url, self.http_01_port, self.tls_alpn_01_port,
43+
self.config_dir, self.workspace, force_renew=True)
4144

4245
def _start_nginx(self, default_server):
4346
self.nginx_config = config.construct_nginx_config(

certbot-ci/certbot_integration_tests/nginx_tests/test_main.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ def context(request):
3030
('nginx6.{0}.wtf,nginx7.{0}.wtf', ['--preferred-challenges', 'http'], {'default_server': False}),
3131
], indirect=['context'])
3232
def test_certificate_deployment(certname_pattern, params, context):
33+
# type: (str, list, nginx_context.IntegrationTestsContext) -> None
3334
"""
3435
Test various scenarios to deploy a certificate to nginx using certbot.
3536
"""
@@ -45,10 +46,7 @@ def test_certificate_deployment(certname_pattern, params, context):
4546

4647
assert server_cert == certbot_cert
4748

48-
command = ['--authenticator', 'nginx', '--installer', 'nginx',
49-
'--nginx-server-root', context.nginx_root,
50-
'rollback', '--checkpoints', '1']
51-
context._common_test_no_force_renew(command)
49+
context.certbot_test_nginx(['rollback', '--checkpoints', '1'])
5250

5351
with open(context.nginx_config_path, 'r') as file_h:
5452
current_nginx_config = file_h.read()

certbot-ci/certbot_integration_tests/utils/acme_server.py

100644100755
Lines changed: 73 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
#!/usr/bin/env python
12
"""Module to setup an ACME CA server environment able to run multiple tests in parallel"""
23
from __future__ import print_function
34
import tempfile
4-
import atexit
5+
import time
56
import os
67
import subprocess
78
import shutil
8-
import stat
99
import sys
1010
from os.path import join
1111

@@ -14,33 +14,52 @@
1414
import yaml
1515

1616
from certbot_integration_tests.utils import misc
17+
from certbot_integration_tests.utils.constants import *
1718

18-
# These ports are set implicitly in the docker-compose.yml files of Boulder/Pebble.
19-
CHALLTESTSRV_PORT = 8055
20-
HTTP_01_PORT = 5002
2119

20+
class ACMEServer(object):
21+
"""
22+
Handler exposing methods to start and stop the ACME server, and get its configuration
23+
(eg. challenges ports). ACMEServer is also a context manager, and so can be used to
24+
ensure ACME server is started/stopped upon context enter/exit.
25+
"""
26+
def __init__(self, acme_xdist, start, stop):
27+
self.acme_xdist = acme_xdist
28+
self.start = start
29+
self.stop = stop
30+
31+
def __enter__(self):
32+
self.start()
33+
return self.acme_xdist
34+
35+
def __exit__(self, exc_type, exc_val, exc_tb):
36+
self.stop()
2237

23-
def setup_acme_server(acme_server, nodes):
38+
39+
def setup_acme_server(acme_server, nodes, proxy=True):
2440
"""
2541
This method will setup an ACME CA server and an HTTP reverse proxy instance, to allow parallel
2642
execution of integration tests against the unique http-01 port expected by the ACME CA server.
27-
Instances are properly closed and cleaned when the Python process exits using atexit.
2843
Typically all pytest integration tests will be executed in this context.
29-
This method returns an object describing ports and directory url to use for each pytest node
30-
with the relevant pytest xdist node.
44+
An ACMEServer instance will be returned, giving access to the ports and directory url to use
45+
for each pytest node, and its start and stop methods are appropriately configured to
46+
respectively start the server, and stop it with proper resources cleanup.
3147
:param str acme_server: the type of acme server used (boulder-v1, boulder-v2 or pebble)
3248
:param str[] nodes: list of node names that will be setup by pytest xdist
33-
:return: a dict describing the challenge ports that have been setup for the nodes
34-
:rtype: dict
49+
:param bool proxy: set to False to not start the Traefik proxy
50+
:return: a properly configured ACMEServer instance
51+
:rtype: ACMEServer
3552
"""
3653
acme_type = 'pebble' if acme_server == 'pebble' else 'boulder'
3754
acme_xdist = _construct_acme_xdist(acme_server, nodes)
38-
workspace = _construct_workspace(acme_type)
55+
workspace, stop = _construct_workspace(acme_type)
3956

40-
_prepare_traefik_proxy(workspace, acme_xdist)
41-
_prepare_acme_server(workspace, acme_type, acme_xdist)
57+
def start():
58+
if proxy:
59+
_prepare_traefik_proxy(workspace, acme_xdist)
60+
_prepare_acme_server(workspace, acme_type, acme_xdist)
4261

43-
return acme_xdist
62+
return ACMEServer(acme_xdist, start, stop)
4463

4564

4665
def _construct_acme_xdist(acme_server, nodes):
@@ -49,10 +68,10 @@ def _construct_acme_xdist(acme_server, nodes):
4968

5069
# Directory and ACME port are set implicitly in the docker-compose.yml files of Boulder/Pebble.
5170
if acme_server == 'pebble':
52-
acme_xdist['directory_url'] = 'https://localhost:14000/dir'
71+
acme_xdist['directory_url'] = PEBBLE_DIRECTORY_URL
5372
else: # boulder
54-
port = 4001 if acme_server == 'boulder-v2' else 4000
55-
acme_xdist['directory_url'] = 'http://localhost:{0}/directory'.format(port)
73+
acme_xdist['directory_url'] = BOULDER_V2_DIRECTORY_URL \
74+
if acme_server == 'boulder-v2' else BOULDER_V1_DIRECTORY_URL
5675

5776
acme_xdist['http_port'] = {node: port for (node, port)
5877
in zip(nodes, range(5200, 5200 + len(nodes)))}
@@ -82,10 +101,7 @@ def cleanup():
82101

83102
shutil.rmtree(workspace)
84103

85-
# Here with atexit we ensure that clean function is called no matter what.
86-
atexit.register(cleanup)
87-
88-
return workspace
104+
return workspace, cleanup
89105

90106

91107
def _prepare_acme_server(workspace, acme_type, acme_xdist):
@@ -136,7 +152,6 @@ def _prepare_traefik_proxy(workspace, acme_xdist):
136152
print('=> Starting traefik instance deployment...')
137153
instance_path = join(workspace, 'traefik')
138154
traefik_subnet = '10.33.33'
139-
traefik_api_port = 8056
140155
try:
141156
os.mkdir(instance_path)
142157

@@ -159,12 +174,12 @@ def _prepare_traefik_proxy(workspace, acme_xdist):
159174
config:
160175
- subnet: {traefik_subnet}.0/24
161176
'''.format(traefik_subnet=traefik_subnet,
162-
traefik_api_port=traefik_api_port,
177+
traefik_api_port=TRAEFIK_API_PORT,
163178
http_01_port=HTTP_01_PORT))
164179

165180
_launch_command(['docker-compose', 'up', '--force-recreate', '-d'], cwd=instance_path)
166181

167-
misc.check_until_timeout('http://localhost:{0}/api'.format(traefik_api_port))
182+
misc.check_until_timeout('http://localhost:{0}/api'.format(TRAEFIK_API_PORT))
168183
config = {
169184
'backends': {
170185
node: {
@@ -178,7 +193,7 @@ def _prepare_traefik_proxy(workspace, acme_xdist):
178193
} for node in acme_xdist['http_port'].keys()
179194
}
180195
}
181-
response = requests.put('http://localhost:{0}/api/providers/rest'.format(traefik_api_port),
196+
response = requests.put('http://localhost:{0}/api/providers/rest'.format(TRAEFIK_API_PORT),
182197
data=json.dumps(config))
183198
response.raise_for_status()
184199

@@ -195,3 +210,35 @@ def _launch_command(command, cwd=os.getcwd()):
195210
except subprocess.CalledProcessError as e:
196211
sys.stderr.write(e.output)
197212
raise
213+
214+
215+
def main():
216+
args = sys.argv[1:]
217+
server_type = args[0] if args else 'pebble'
218+
possible_values = ('pebble', 'boulder-v1', 'boulder-v2')
219+
if server_type not in possible_values:
220+
raise ValueError('Invalid server value {0}, should be one of {1}'
221+
.format(server_type, possible_values))
222+
223+
acme_server = setup_acme_server(server_type, [], False)
224+
process = None
225+
226+
try:
227+
with acme_server as acme_xdist:
228+
print('--> Instance of {0} is running, directory URL is {0}'
229+
.format(acme_xdist['directory_url']))
230+
print('--> Press CTRL+C to stop the ACME server.')
231+
232+
docker_name = 'pebble_pebble_1' if 'pebble' in server_type else 'boulder_boulder_1'
233+
process = subprocess.Popen(['docker', 'logs', '-f', docker_name])
234+
235+
while True:
236+
time.sleep(3600)
237+
except KeyboardInterrupt:
238+
if process:
239+
process.terminate()
240+
process.wait()
241+
242+
243+
if __name__ == '__main__':
244+
main()

0 commit comments

Comments
 (0)