Add list-certs command (#3669)

* Switch to using absolute path in symlink

* save archive_dir to config and read it back

* cli_config.archive_dir --> cli_config.default_archive_dir

* Use archive_dir specified in renewal config file

* add helpful broken symlink info

* add docstring to method

* Add tests

* remove extraneous test imports

* fix tests

* py2.6 syntax fix

* git problems

* Add list-certs command

* no dict comprehension in python2.6

* add test coverage

* More py26 wrangling

* update tests for py3 and lint

* remove extra dep from test

* test coverage

* test shouldn't be based on dict representation order

* Redo report UX and add tests to cover

* remove storage str test

* lint and use mock properly

* mock properly

* address code review comments

* lineage --> certificate name and print fullchain and privkey paths

* make py26 happy

* actually make py26 happy

* don't wrap text
This commit is contained in:
Erica Portnoy 2016-11-08 15:21:42 -08:00 committed by Brad Warren
parent a7bfefc6d0
commit af46f644a7
8 changed files with 197 additions and 13 deletions

View file

@ -1,8 +1,17 @@
"""Tools for managing certificates."""
import datetime
import logging
import pytz
import traceback
import zope.component
from certbot import configuration
from certbot import interfaces
from certbot import renewal
from certbot import storage
logger = logging.getLogger(__name__)
def update_live_symlinks(config):
"""Update the certificate file family symlinks to use archive_dir.
@ -20,3 +29,78 @@ def update_live_symlinks(config):
storage.RenewableCert(renewal_file,
configuration.RenewerConfiguration(renewer_config),
update_symlinks=True)
def _report_lines(msgs):
"""Format a results report for a category of single-line renewal outcomes"""
return " " + "\n ".join(str(msg) for msg in msgs)
def _report_human_readable(parsed_certs):
"""Format a results report for a parsed cert"""
certinfo = []
for cert in parsed_certs:
now = pytz.UTC.fromutc(datetime.datetime.utcnow())
if cert.target_expiry <= now:
expiration_text = "EXPIRED"
else:
diff = cert.target_expiry - now
if diff.days == 1:
expiration_text = "1 day"
elif diff.days < 1:
expiration_text = "under 1 day"
else:
expiration_text = "{0} days".format(diff.days)
valid_string = "{0} ({1})".format(cert.target_expiry, expiration_text)
certinfo.append(" Certificate Name: {0}\n"
" Domains: {1}\n"
" Valid Until: {2}\n"
" Certificate Path: {3}\n"
" Private Key Path: {4}".format(
cert.lineagename,
" ".join(cert.names()),
valid_string,
cert.fullchain,
cert.privkey))
return "\n".join(certinfo)
def _describe_certs(parsed_certs, parse_failures):
"""Print information about the certs we know about"""
out = []
notify = out.append
if not parsed_certs and not parse_failures:
notify("No certs found.")
else:
if parsed_certs:
notify("Found the following certs:")
notify(_report_human_readable(parsed_certs))
if parse_failures:
notify("\nThe following renewal configuration files "
"were invalid:")
notify(_report_lines(parse_failures))
disp = zope.component.getUtility(interfaces.IDisplay)
disp.notification("\n".join(out), pause=False, wrap=False)
def certificates(config):
"""Display information about certs configured with Certbot
:param config: Configuration.
:type config: :class:`certbot.interfaces.IConfig`
"""
renewer_config = configuration.RenewerConfiguration(config)
parsed_certs = []
parse_failures = []
for renewal_file in renewal.renewal_conf_files(renewer_config):
try:
renewal_candidate = storage.RenewableCert(renewal_file,
configuration.RenewerConfiguration(config))
parsed_certs.append(renewal_candidate)
except Exception as e: # pylint: disable=broad-except
logger.warning("Renewal configuration file %s produced an "
"unexpected error: %s. Skipping.", renewal_file, e)
logger.debug("Traceback was:\n%s", traceback.format_exc())
parse_failures.append(renewal_file)
# Describe all the certs
_describe_certs(parsed_certs, parse_failures)

View file

@ -69,6 +69,7 @@ cert. Major SUBCOMMANDS are:
config_changes Show changes made to server config during installation
update_symlinks Update cert symlinks based on renewal config file
plugins Display information about installed plugins
certificates Display information about certs configured with Certbot
""".format(cli_command)
@ -324,7 +325,8 @@ class HelpfulArgumentParser(object):
"install": main.install, "plugins": main.plugins_cmd,
"register": main.register, "renew": main.renew,
"revoke": main.revoke, "rollback": main.rollback,
"everything": main.run, "update_symlinks": main.update_symlinks}
"everything": main.run, "update_symlinks": main.update_symlinks,
"certificates": main.certificates}
# List of topics for which additional help can be provided
HELP_TOPICS = ["all", "security", "paths", "automation", "testing"] + list(self.VERBS)

View file

@ -484,6 +484,11 @@ def update_symlinks(config, unused_plugins):
"""
cert_manager.update_live_symlinks(config)
def certificates(config, unused_plugins):
"""Display information about certs configured with Certbot
"""
cert_manager.certificates(config)
def revoke(config, unused_plugins): # TODO: coop with renewal config
"""Revoke a previously obtained certificate."""
# For user-agent construction

View file

@ -44,7 +44,7 @@ def _reconstitute(config, full_path):
"""Try to instantiate a RenewableCert, updating config with relevant items.
This is specifically for use in renewal and enforces several checks
and policies to ensure that we can try to proceed with the renwal
and policies to ensure that we can try to proceed with the renewal
request. The config argument is modified by including relevant options
read from the renewal configuration file.

View file

@ -263,6 +263,15 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
self._update_symlinks()
self._check_symlinks()
@property
def target_expiry(self):
"""The current target certificate's expiration datetime
:returns: Expiration datetime of the current target certificate
:rtype: :class:`datetime.datetime`
"""
return crypto_util.notAfter(self.current_target("cert"))
@property
def archive_dir(self):
"""Returns the default or specified archive directory"""
@ -671,9 +680,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
if self.has_pending_deployment():
interval = self.configuration.get("deploy_before_expiry",
"5 days")
expiry = crypto_util.notAfter(self.current_target("cert"))
now = pytz.UTC.fromutc(datetime.datetime.utcnow())
if expiry < add_time_interval(now, interval):
if self.target_expiry < add_time_interval(now, interval):
return True
return False

View file

@ -8,25 +8,21 @@ import unittest
import configobj
import mock
from certbot import configuration
from certbot.storage import ALL_FOUR
class CertManagerTest(unittest.TestCase):
"""Tests for certbot.cert_manager
class BaseCertManagerTest(unittest.TestCase):
"""Base class for setting up Cert Manager tests.
"""
def setUp(self):
self.tempdir = tempfile.mkdtemp()
os.makedirs(os.path.join(self.tempdir, "renewal"))
mock_namespace = mock.MagicMock(
self.cli_config = mock.MagicMock(
config_dir=self.tempdir,
work_dir=self.tempdir,
logs_dir=self.tempdir,
)
self.cli_config = configuration.RenewerConfiguration(
namespace=mock_namespace
quiet=False,
)
self.domains = {
@ -67,6 +63,9 @@ class CertManagerTest(unittest.TestCase):
def tearDown(self):
shutil.rmtree(self.tempdir)
class UpdateLiveSymlinksTest(BaseCertManagerTest):
"""Tests for certbot.cert_manager.update_live_symlinks
"""
def test_update_live_symlinks(self):
"""Test update_live_symlinks"""
# pylint: disable=too-many-statements
@ -97,5 +96,87 @@ class CertManagerTest(unittest.TestCase):
self.assertEqual(os.readlink(self.configs[domain][kind]),
archive_paths[domain][kind])
class CertificatesTest(BaseCertManagerTest):
"""Tests for certbot.cert_manager.certificates
"""
def _certificates(self, *args, **kwargs):
from certbot.cert_manager import certificates
return certificates(*args, **kwargs)
@mock.patch('certbot.cert_manager.logger')
@mock.patch('zope.component.getUtility')
def test_certificates_parse_fail(self, mock_utility, mock_logger):
self._certificates(self.cli_config)
self.assertTrue(mock_logger.warning.called) #pylint: disable=no-member
self.assertTrue(mock_utility.called)
@mock.patch('certbot.cert_manager.logger')
@mock.patch('zope.component.getUtility')
def test_certificates_quiet(self, mock_utility, mock_logger):
self.cli_config.quiet = True
self._certificates(self.cli_config)
self.assertFalse(mock_utility.notification.called)
self.assertTrue(mock_logger.warning.called) #pylint: disable=no-member
@mock.patch('certbot.cert_manager.logger')
@mock.patch('zope.component.getUtility')
@mock.patch("certbot.storage.RenewableCert")
@mock.patch('certbot.cert_manager._report_human_readable')
def test_certificates_parse_success(self, mock_report, mock_renewable_cert,
mock_utility, mock_logger):
mock_report.return_value = ""
self._certificates(self.cli_config)
self.assertFalse(mock_logger.warning.called) #pylint: disable=no-member
self.assertTrue(mock_report.called)
self.assertTrue(mock_utility.called)
self.assertTrue(mock_renewable_cert.called)
@mock.patch('certbot.cert_manager.logger')
@mock.patch('zope.component.getUtility')
def test_certificates_no_files(self, mock_utility, mock_logger):
tempdir = tempfile.mkdtemp()
cli_config = mock.MagicMock(
config_dir=tempdir,
work_dir=tempdir,
logs_dir=tempdir,
quiet=False,
)
os.makedirs(os.path.join(tempdir, "renewal"))
self._certificates(cli_config)
self.assertFalse(mock_logger.warning.called) #pylint: disable=no-member
self.assertTrue(mock_utility.called)
shutil.rmtree(tempdir)
def test_report_human_readable(self):
from certbot import cert_manager
import datetime, pytz
expiry = pytz.UTC.fromutc(datetime.datetime.utcnow())
cert = mock.MagicMock(lineagename="nameone")
cert.target_expiry = expiry
cert.names.return_value = ["nameone", "nametwo"]
parsed_certs = [cert]
# pylint: disable=protected-access
out = cert_manager._report_human_readable(parsed_certs)
self.assertTrue('EXPIRED' in out)
cert.target_expiry += datetime.timedelta(hours=2)
# pylint: disable=protected-access
out = cert_manager._report_human_readable(parsed_certs)
self.assertTrue('under 1 day' in out)
cert.target_expiry += datetime.timedelta(days=1)
# pylint: disable=protected-access
out = cert_manager._report_human_readable(parsed_certs)
self.assertTrue('1 day' in out)
self.assertFalse('under' in out)
cert.target_expiry += datetime.timedelta(days=2)
# pylint: disable=protected-access
out = cert_manager._report_human_readable(parsed_certs)
self.assertTrue('3 days' in out)
if __name__ == "__main__":
unittest.main() # pragma: no cover

View file

@ -277,6 +277,11 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods
self._call_no_clientmock(['update_symlinks'])
self.assertEqual(1, mock_cert_manager.call_count)
@mock.patch('certbot.cert_manager.certificates')
def test_certificates(self, mock_cert_manager):
self._call_no_clientmock(['certificates'])
self.assertEqual(1, mock_cert_manager.call_count)
def test_plugins(self):
flags = ['--init', '--prepare', '--authenticators', '--installers']
for args in itertools.chain(

View file

@ -751,6 +751,5 @@ class RenewableCertTests(BaseRenewableCertTest):
storage.RenewableCert(self.config.filename, self.cli_config,
update_symlinks=True)
if __name__ == "__main__":
unittest.main() # pragma: no cover