Merge remote-tracking branch 'github/letsencrypt/master' into standalone2

This commit is contained in:
Jakub Warmuz 2015-10-12 19:36:46 +00:00
commit 73ae361559
No known key found for this signature in database
GPG key ID: 2A7BAD3A489B52EA
17 changed files with 220 additions and 83 deletions

View file

@ -21,6 +21,15 @@ env:
- TOXENV=lint
- TOXENV=cover
# Only build pushes to the master branch, PRs, and branches beginning with
# `test-`. This reduces the number of simultaneous Travis runs, which speeds
# turnaround time on review since there is a cap of 5 simultaneous runs.
branches:
only:
- master
- /^test-.*$/
sudo: false # containers
addons:
# make sure simplehttp simple verification works (custom /etc/hosts)

View file

@ -5,4 +5,5 @@ include CONTRIBUTING.md
include LICENSE.txt
include linter_plugin.py
include letsencrypt/EULA
recursive-include docs *
recursive-include letsencrypt/tests/testdata *

View file

@ -194,7 +194,7 @@ class JSONDeSerializable(object):
:rtype: str
"""
return self.json_dumps(sort_keys=True, indent=4, separators=(',', ': '))
return self.json_dumps(sort_keys=True, indent=4)
@classmethod
def json_dump_default(cls, python_object):

View file

@ -1,6 +1,8 @@
"""Tests for acme.jose.interfaces."""
import unittest
import six
class JSONDeSerializableTest(unittest.TestCase):
# pylint: disable=too-many-instance-attributes
@ -90,8 +92,9 @@ class JSONDeSerializableTest(unittest.TestCase):
self.assertEqual('["foo1", "foo2"]', self.seq.json_dumps())
def test_json_dumps_pretty(self):
self.assertEqual(
self.seq.json_dumps_pretty(), '[\n "foo1",\n "foo2"\n]')
filler = ' ' if six.PY2 else ''
self.assertEqual(self.seq.json_dumps_pretty(),
'[\n "foo1",{0}\n "foo2"\n]'.format(filler))
def test_json_dump_default(self):
from acme.jose.interfaces import JSONDeSerializable

View file

@ -1,10 +1,12 @@
"""JSON Web Key."""
import abc
import binascii
import json
import logging
import cryptography.exceptions
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives.asymmetric import rsa
@ -27,6 +29,32 @@ class JWK(json_util.TypedJSONObjectWithFields):
cryptography_key_types = ()
"""Subclasses should override."""
required = NotImplemented
"""Required members of public key's representation as defined by JWK/JWA."""
_thumbprint_json_dumps_params = {
# "no whitespace or line breaks before or after any syntactic
# elements"
'indent': 0,
'separators': (',', ':'),
# "members ordered lexicographically by the Unicode [UNICODE]
# code points of the member names"
'sort_keys': True,
}
def thumbprint(self, hash_function=hashes.SHA256):
"""Compute JWK Thumbprint.
https://tools.ietf.org/html/rfc7638
"""
digest = hashes.Hash(hash_function(), backend=default_backend())
digest.update(json.dumps(
dict((k, v) for k, v in six.iteritems(self.to_json())
if k in self.required),
**self._thumbprint_json_dumps_params).encode())
return digest.finalize()
@abc.abstractmethod
def public_key(self): # pragma: no cover
"""Generate JWK with public key.
@ -60,7 +88,7 @@ class JWK(json_util.TypedJSONObjectWithFields):
exceptions[loader] = error
# no luck
raise errors.Error("Unable to deserialize key: {0}".format(exceptions))
raise errors.Error('Unable to deserialize key: {0}'.format(exceptions))
@classmethod
def load(cls, data, password=None, backend=None):
@ -81,17 +109,17 @@ class JWK(json_util.TypedJSONObjectWithFields):
try:
key = cls._load_cryptography_key(data, password, backend)
except errors.Error as error:
logger.debug("Loading symmetric key, assymentric failed: %s", error)
logger.debug('Loading symmetric key, assymentric failed: %s', error)
return JWKOct(key=data)
if cls.typ is not NotImplemented and not isinstance(
key, cls.cryptography_key_types):
raise errors.Error("Unable to deserialize {0} into {1}".format(
raise errors.Error('Unable to deserialize {0} into {1}'.format(
key.__class__, cls.__class__))
for jwk_cls in six.itervalues(cls.TYPES):
if isinstance(key, jwk_cls.cryptography_key_types):
return jwk_cls(key=key)
raise errors.Error("Unsupported algorithm: {0}".format(key.__class__))
raise errors.Error('Unsupported algorithm: {0}'.format(key.__class__))
@JWK.register
@ -105,6 +133,7 @@ class JWKES(JWK): # pragma: no cover
typ = 'ES'
cryptography_key_types = (
ec.EllipticCurvePublicKey, ec.EllipticCurvePrivateKey)
required = ('crv', JWK.type_field_name, 'x', 'y')
def fields_to_partial_json(self):
raise NotImplementedError()
@ -122,6 +151,7 @@ class JWKOct(JWK):
"""Symmetric JWK."""
typ = 'oct'
__slots__ = ('key',)
required = ('k', JWK.type_field_name)
def fields_to_partial_json(self):
# TODO: An "alg" member SHOULD also be present to identify the
@ -150,6 +180,7 @@ class JWKRSA(JWK):
typ = 'RSA'
cryptography_key_types = (rsa.RSAPublicKey, rsa.RSAPrivateKey)
__slots__ = ('key',)
required = ('e', JWK.type_field_name, 'n')
def __init__(self, *args, **kwargs):
if 'key' in kwargs and not isinstance(
@ -204,7 +235,7 @@ class JWKRSA(JWK):
jobj.get(x) for x in ('p', 'q', 'dp', 'dq', 'qi'))
if tuple(param for param in all_params if param is None):
raise errors.Error(
"Some private parameters are missing: {0}".format(
'Some private parameters are missing: {0}'.format(
all_params))
p, q, dp, dq, qi = tuple(
cls._decode_param(x) for x in all_params)

View file

@ -25,9 +25,24 @@ class JWKTest(unittest.TestCase):
self.assertRaises(errors.Error, JWKRSA.load, DSA_PEM)
class JWKOctTest(unittest.TestCase):
class JWKTestBaseMixin(object):
"""Mixin test for JWK subclass tests."""
thumbprint = NotImplemented
def test_thumbprint_private(self):
self.assertEqual(self.thumbprint, self.jwk.thumbprint())
def test_thumbprint_public(self):
self.assertEqual(self.thumbprint, self.jwk.public_key().thumbprint())
class JWKOctTest(unittest.TestCase, JWKTestBaseMixin):
"""Tests for acme.jose.jwk.JWKOct."""
thumbprint = (b"=,\xdd;I\x1a+i\x02x\x8a\x12?06IM\xc2\x80"
b"\xe4\xc3\x1a\xfc\x89\xf3)'\xce\xccm\xfd5")
def setUp(self):
from acme.jose.jwk import JWKOct
self.jwk = JWKOct(key=b'foo')
@ -52,10 +67,13 @@ class JWKOctTest(unittest.TestCase):
self.assertTrue(self.jwk.public_key() is self.jwk)
class JWKRSATest(unittest.TestCase):
class JWKRSATest(unittest.TestCase, JWKTestBaseMixin):
"""Tests for acme.jose.jwk.JWKRSA."""
# pylint: disable=too-many-instance-attributes
thumbprint = (b'\x08\xfa1\x87\x1d\x9b6H/*\x1eW\xc2\xe3\xf6P'
b'\xefs\x0cKB\x87\xcf\x85yO\x045\x0e\x91\x80\x0b')
def setUp(self):
from acme.jose.jwk import JWKRSA
self.jwk256 = JWKRSA(key=RSA256_KEY.public_key())
@ -87,6 +105,7 @@ class JWKRSATest(unittest.TestCase):
'dq': 'bHh2u7etM8LKKCF2pY2UdQ',
'qi': 'oi45cEkbVoJjAbnQpFY87Q',
})
self.jwk = self.private
def test_init_auto_comparable(self):
self.assertTrue(isinstance(

View file

@ -267,6 +267,22 @@ Please:
.. _PEP 8 - Style Guide for Python Code:
https://www.python.org/dev/peps/pep-0008
Submitting a pull request
=========================
Steps:
1. Write your code!
2. Make sure your environment is set up properly and that you're in your
virtualenv. You can do this by running ``./bootstrap/dev/venv.sh``.
(this is a **very important** step)
3. Run ``./pep8.travis.sh`` to do a cursory check of your code style.
Fix any errors.
4. Run ``tox -e lint`` to check for pylint errors. Fix any errors.
5. Run ``tox`` to run the entire test suite including coverage. Fix any errors.
6. If your code touches communication with an ACME server/Boulder, you
should run the integration tests, see `integration`_.
7. Submit the PR.
Updating the documentation
==========================

View file

@ -1,33 +1,18 @@
#!/bin/bash
# An extremely simplified version of `a2enmod` for enabling modules in the
# httpd docker image. First argument is the server_root and the second is the
# module to be enabled.
# httpd docker image. First argument is the Apache ServerRoot which should be
# an absolute path. The second is the module to be enabled, such as `ssl`.
APACHE_CONFDIR=$1
confdir=$1
module=$2
enable () {
echo "LoadModule "$1"_module /usr/local/apache2/modules/mod_"$1".so" >> \
$APACHE_CONFDIR"/test.conf"
available_base="/mods-available/"$1".conf"
available_conf=$APACHE_CONFDIR$available_base
enabled_dir=$APACHE_CONFDIR"/mods-enabled"
enabled_conf=$enabled_dir"/"$1".conf"
if [ -e "$available_conf" -a -d "$enabled_dir" -a ! -e "$enabled_conf" ]
then
ln -s "..$available_base" $enabled_conf
fi
}
if [ $2 == "ssl" ]
echo "LoadModule ${module}_module " \
"/usr/local/apache2/modules/mod_${module}.so" >> "${confdir}/test.conf"
availbase="/mods-available/${module}.conf"
availconf=$confdir$availbase
enabldir="$confdir/mods-enabled"
enablconf="$enabldir/${module}.conf"
if [ -e $availconf -a -d $enabldir -a ! -e $enablconf ]
then
# Enables ssl and all its dependencies
enable "setenvif"
enable "mime"
enable "socache_shmcb"
enable "ssl"
elif [ $2 == "rewrite" ]
then
enable "rewrite"
else
exit 1
ln -s "..$availbase" $enablconf
fi

View file

@ -39,7 +39,7 @@ def create_le_config(parent_dir):
def extract_configs(configs, parent_dir):
"""Extracts configs to a new dir under parent_dir and returns it"""
config_dir = os.path.join(parent_dir, "renewal")
config_dir = os.path.join(parent_dir, "configs")
if os.path.isdir(configs):
shutil.copytree(configs, config_dir, symlinks=True)

View file

@ -839,9 +839,23 @@ def _plugins_parsing(helpful, plugins):
helpful.add_plugin_args(plugins)
def _setup_logging(args):
level = -args.verbose_count * 10
fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s"
def setup_log_file_handler(args, logfile, fmt):
"""Setup file debug logging."""
log_file_path = os.path.join(args.logs_dir, logfile)
handler = logging.handlers.RotatingFileHandler(
log_file_path, maxBytes=2 ** 20, backupCount=10)
# rotate on each invocation, rollover only possible when maxBytes
# is nonzero and backupCount is nonzero, so we set maxBytes as big
# as possible not to overrun in single CLI invocation (1MB).
handler.doRollover() # TODO: creates empty letsencrypt.log.1 file
handler.setLevel(logging.DEBUG)
handler_formatter = logging.Formatter(fmt=fmt)
handler_formatter.converter = time.gmtime # don't use localtime
handler.setFormatter(handler_formatter)
return handler, log_file_path
def _cli_log_handler(args, level, fmt):
if args.text_mode:
handler = colored_logging.StreamHandler()
handler.setFormatter(logging.Formatter(fmt))
@ -850,30 +864,26 @@ def _setup_logging(args):
# dialog box is small, display as less as possible
handler.setFormatter(logging.Formatter("%(message)s"))
handler.setLevel(level)
return handler
def setup_logging(args, cli_handler_factory, logfile):
"""Setup logging."""
fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s"
level = -args.verbose_count * 10
file_handler, log_file_path = setup_log_file_handler(
args, logfile=logfile, fmt=fmt)
cli_handler = cli_handler_factory(args, level, fmt)
# TODO: use fileConfig?
# unconditionally log to file for debugging purposes
# TODO: change before release?
log_file_name = os.path.join(args.logs_dir, 'letsencrypt.log')
file_handler = logging.handlers.RotatingFileHandler(
log_file_name, maxBytes=2 ** 20, backupCount=10)
# rotate on each invocation, rollover only possible when maxBytes
# is nonzero and backupCount is nonzero, so we set maxBytes as big
# as possible not to overrun in single CLI invocation (1MB).
file_handler.doRollover() # TODO: creates empty letsencrypt.log.1 file
file_handler.setLevel(logging.DEBUG)
file_handler_formatter = logging.Formatter(fmt=fmt)
file_handler_formatter.converter = time.gmtime # don't use localtime
file_handler.setFormatter(file_handler_formatter)
root_logger = logging.getLogger()
root_logger.setLevel(logging.DEBUG) # send all records to handlers
root_logger.addHandler(handler)
root_logger.addHandler(cli_handler)
root_logger.addHandler(file_handler)
logger.debug("Root logging level set at %d", level)
logger.info("Saving debug log to %s", log_file_name)
logger.info("Saving debug log to %s", log_file_path)
def _handle_exception(exc_type, exc_value, trace, args):
@ -942,7 +952,7 @@ def main(cli_args=sys.argv[1:]):
# private key! #525
le_util.make_or_verify_dir(
args.logs_dir, 0o700, os.geteuid(), "--strict-permissions" in cli_args)
_setup_logging(args)
setup_logging(args, _cli_log_handler, logfile='letsencrypt.log')
# do not log `args`, as it contains sensitive data (e.g. revoke --key)!
logger.debug("Arguments: %r", cli_args)

View file

@ -27,6 +27,7 @@ CLI_DEFAULTS = dict(
auth_cert_path="./cert.pem",
auth_chain_path="./chain.pem",
strict_permissions=False,
)
"""Defaults for CLI flags and `.IConfig` attributes."""

View file

@ -129,12 +129,18 @@ binary for temporary key/certificate generation.""".replace("\n", "")
ct=response.CONTENT_TYPE, port=port)
if self.conf("test-mode"):
logger.debug("Test mode. Executing the manual command: %s", command)
# sh shipped with OS X does't support echo -n
if sys.platform == "darwin":
executable = "/bin/bash"
else:
executable = None
try:
self._httpd = subprocess.Popen(
command,
# don't care about setting stdout and stderr,
# we're in test mode anyway
shell=True,
executable=executable,
# "preexec_fn" is UNIX specific, but so is "command"
preexec_fn=os.setsid)
except OSError as error: # ValueError should not happen!

View file

@ -1,4 +1,5 @@
"""Plugin utilities."""
import logging
import socket
import psutil
@ -7,6 +8,9 @@ import zope.component
from letsencrypt import interfaces
logger = logging.getLogger(__name__)
def already_listening(port):
"""Check if a process is already listening on the port.
@ -17,9 +21,20 @@ def already_listening(port):
run as root.
:param int port: The TCP port in question.
:returns: True or False."""
:returns: True or False.
listeners = [conn.pid for conn in psutil.net_connections()
"""
try:
net_connections = psutil.net_connections()
except psutil.AccessDenied as error:
logger.info("Access denied when trying to list network "
"connections: %s. Are you root?", error)
# this function is just a pre-check that often causes false
# positives and problems in testing (c.f. #680 on Mac, #255
# generally); we will fail later in bind() anyway
return False
listeners = [conn.pid for conn in net_connections
if conn.status == 'LISTEN' and
conn.type == socket.SOCK_STREAM and
conn.laddr[1] == port]

View file

@ -8,6 +8,7 @@ within lineages of successor certificates, according to configuration.
"""
import argparse
import logging
import os
import sys
@ -17,10 +18,13 @@ import zope.component
from letsencrypt import account
from letsencrypt import configuration
from letsencrypt import constants
from letsencrypt import colored_logging
from letsencrypt import cli
from letsencrypt import client
from letsencrypt import crypto_util
from letsencrypt import errors
from letsencrypt import le_util
from letsencrypt import notify
from letsencrypt import storage
@ -28,6 +32,9 @@ from letsencrypt.display import util as display_util
from letsencrypt.plugins import disco as plugins_disco
logger = logging.getLogger(__name__)
class _AttrDict(dict):
"""Attribute dictionary.
@ -105,6 +112,12 @@ def renew(cert, old_version):
# (where fewer than all names were renewed)
def _cli_log_handler(args, level, fmt): # pylint: disable=unused-argument
handler = colored_logging.StreamHandler()
handler.setFormatter(logging.Formatter(fmt))
return handler
def _paths_parser(parser):
add = parser.add_argument_group("paths").add_argument
add("--config-dir", default=cli.flag_default("config_dir"),
@ -120,11 +133,16 @@ def _paths_parser(parser):
def _create_parser():
parser = argparse.ArgumentParser()
#parser.add_argument("--cron", action="store_true", help="Run as cronjob.")
# pylint: disable=protected-access
parser.add_argument(
"-v", "--verbose", dest="verbose_count", action="count",
default=cli.flag_default("verbose_count"), help="This flag can be used "
"multiple times to incrementally increase the verbosity of output, "
"e.g. -vvv.")
return _paths_parser(parser)
def main(config=None, args=sys.argv[1:]):
def main(config=None, cli_args=sys.argv[1:]):
"""Main function for autorenewer script."""
# TODO: Distinguish automated invocation from manual invocation,
# perhaps by looking at sys.argv[0] and inhibiting automated
@ -134,8 +152,13 @@ def main(config=None, args=sys.argv[1:]):
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
cli_config = configuration.RenewerConfiguration(
_create_parser().parse_args(args))
args = _create_parser().parse_args(cli_args)
uid = os.geteuid()
le_util.make_or_verify_dir(args.logs_dir, 0o700, uid)
cli.setup_logging(args, _cli_log_handler, logfile='renewer.log')
cli_config = configuration.RenewerConfiguration(args)
config = storage.config_with_defaults(config)
# Now attempt to read the renewer config file and augment or replace
@ -146,6 +169,9 @@ def main(config=None, args=sys.argv[1:]):
# specify a config file on the command line, which, if provided, should
# take precedence over this one.
config.merge(configobj.ConfigObj(cli_config.renewer_config_file))
# Ensure that all of the needed folders have been created before continuing
le_util.make_or_verify_dir(
cli_config.work_dir, constants.CONFIG_DIRS_MODE, uid)
for i in os.listdir(cli_config.renewal_configs_dir):
print "Processing", i

View file

@ -45,8 +45,15 @@ class BaseRenewableCertTest(unittest.TestCase):
self.cli_config = configuration.RenewerConfiguration(
namespace=mock.MagicMock(
config_dir=self.tempdir, no_simple_http_tls=False))
config_dir=self.tempdir,
work_dir=self.tempdir,
logs_dir=self.tempdir,
no_simple_http_tls=False,
)
)
# TODO: maybe provide RenewerConfiguration.make_dirs?
# TODO: main() should create those dirs, c.f. #902
os.makedirs(os.path.join(self.tempdir, "live", "example.org"))
os.makedirs(os.path.join(self.tempdir, "archive", "example.org"))
os.makedirs(os.path.join(self.tempdir, "renewal"))
@ -63,6 +70,9 @@ class BaseRenewableCertTest(unittest.TestCase):
self.test_rc = storage.RenewableCert(
self.config, self.defaults, self.cli_config)
def tearDown(self):
shutil.rmtree(self.tempdir)
def _write_out_ex_kinds(self):
for kind in ALL_FOUR:
where = getattr(self.test_rc, kind)
@ -80,11 +90,6 @@ class BaseRenewableCertTest(unittest.TestCase):
class RenewableCertTests(BaseRenewableCertTest):
# pylint: disable=too-many-public-methods
"""Tests for letsencrypt.renewer.*."""
def setUp(self):
super(RenewableCertTests, self).setUp()
def tearDown(self):
shutil.rmtree(self.tempdir)
def test_initialization(self):
self.assertEqual(self.test_rc.lineagename, "example.org")
@ -667,11 +672,17 @@ class RenewableCertTests(BaseRenewableCertTest):
# This should fail because the renewal itself appears to fail
self.assertFalse(renewer.renew(self.test_rc, 1))
def _common_cli_args(self):
return [
"--config-dir", self.cli_config.config_dir,
"--work-dir", self.cli_config.work_dir,
"--logs-dir", self.cli_config.logs_dir,
]
@mock.patch("letsencrypt.renewer.notify")
@mock.patch("letsencrypt.storage.RenewableCert")
@mock.patch("letsencrypt.renewer.renew")
def test_main(self, mock_renew, mock_rc, mock_notify):
"""Test for main() function."""
from letsencrypt import renewer
mock_rc_instance = mock.MagicMock()
mock_rc_instance.should_autodeploy.return_value = True
@ -693,8 +704,7 @@ class RenewableCertTests(BaseRenewableCertTest):
"example.com.conf"), "w") as f:
f.write("cert = cert.pem\nprivkey = privkey.pem\n")
f.write("chain = chain.pem\nfullchain = fullchain.pem\n")
renewer.main(self.defaults, args=[
'--config-dir', self.cli_config.config_dir])
renewer.main(self.defaults, cli_args=self._common_cli_args())
self.assertEqual(mock_rc.call_count, 2)
self.assertEqual(mock_rc_instance.update_all_links_to.call_count, 2)
self.assertEqual(mock_notify.notify.call_count, 4)
@ -707,8 +717,7 @@ class RenewableCertTests(BaseRenewableCertTest):
mock_happy_instance.should_autorenew.return_value = False
mock_happy_instance.latest_common_version.return_value = 10
mock_rc.return_value = mock_happy_instance
renewer.main(self.defaults, args=[
'--config-dir', self.cli_config.config_dir])
renewer.main(self.defaults, cli_args=self._common_cli_args())
self.assertEqual(mock_rc.call_count, 4)
self.assertEqual(mock_happy_instance.update_all_links_to.call_count, 0)
self.assertEqual(mock_notify.notify.call_count, 4)
@ -719,8 +728,7 @@ class RenewableCertTests(BaseRenewableCertTest):
with open(os.path.join(self.cli_config.renewal_configs_dir,
"bad.conf"), "w") as f:
f.write("incomplete = configfile\n")
renewer.main(self.defaults, args=[
'--config-dir', self.cli_config.config_dir])
renewer.main(self.defaults, cli_args=self._common_cli_args())
# The errors.CertStorageError is caught inside and nothing happens.

View file

@ -14,6 +14,12 @@ export PATH="/usr/sbin:$PATH" # /usr/sbin/nginx
export GOPATH="${GOPATH:-/tmp/go}"
export PATH="$GOPATH/bin:$PATH"
if [ `uname` == 'Darwin' ]; then
readlink="greadlink"
else
readlink="readlink"
fi
common() {
letsencrypt_test \
--authenticator standalone \
@ -49,7 +55,7 @@ dir="$root/conf/archive/le1.wtf"
for x in cert chain fullchain privkey;
do
latest="$(ls -1t $dir/ | grep -e "^${x}" | head -n1)"
live="$(readlink -f "$root/conf/live/le1.wtf/${x}.pem")"
live="$($readlink -f "$root/conf/live/le1.wtf/${x}.pem")"
[ "${dir}/${latest}" = "$live" ] # renewer fails this test
done

View file

@ -70,7 +70,7 @@ echo "Testing packages"
cd "dist.$version"
# start local PyPI
python -m SimpleHTTPServer $PORT &
# cd .. is NOT done on purpose: we make sure that all subpacakges are
# cd .. is NOT done on purpose: we make sure that all subpackages are
# installed from local PyPI rather than current directory (repo root)
virtualenv --no-site-packages ../venv
. ../venv/bin/activate
@ -82,15 +82,16 @@ pip install \
# stop local PyPI
kill $!
# freeze before installing anythin else, so that we know end-user KGS
mkdir kgs
kgs="kgs/$version"
# freeze before installing anything else, so that we know end-user KGS
# make sure "twine upload" doesn't catch "kgs"
mkdir ../kgs
kgs="../kgs/$version"
pip freeze | tee $kgs
pip install nose
# TODO: letsencrypt_apache fails due to symlink, c.f. #838
nosetests letsencrypt $SUBPKGS || true
echo "New root: $root"
echo "KGS is at $root/$kgs"
echo "KGS is at $root/kgs"
echo "In order to upload packages run the following command:"
echo twine upload "$root/dist.$version/*/*"