Commit 47506a75 authored by Yuan Gao's avatar Yuan Gao
Browse files

Add support for Amazon Elastic Container Service

* Support new option: netns, enable file system to mount in given network namespace
* Support new option: awscredsuri, enable sourcing iam authorization from aws credentials relative uri
* List openssl and util-linux as package dependency for IAM/AP authorization and command nsenter to mount file system to given network namespace
parent 60e865f5
......@@ -8,3 +8,4 @@
__pycache__/
build/
.DS_Store
......@@ -41,7 +41,7 @@ Other distributions require building the package from source and installing it.
- To build and install an RPM:
```
$ sudo yum -y install git rpm-build
$ sudo yum -y install git rpm-build make
$ git clone https://github.com/aws/efs-utils
$ cd efs-utils
$ make rpm
......@@ -87,6 +87,12 @@ To mount with the recommended default options, simply run:
$ sudo mount -t efs file-system-id efs-mount-point/
```
To mount file system within a given network namespace, run:
```
$ sudo mount -t efs -o netns=netns-path file-system-id efs-mount-point/
```
To mount over TLS, simply add the `tls` option:
```
......
......@@ -11,7 +11,7 @@ set -ex
BASE_DIR=$(pwd)
BUILD_ROOT=${BASE_DIR}/build/debbuild
VERSION=1.22
VERSION=1.23
echo 'Cleaning deb build workspace'
rm -rf ${BUILD_ROOT}
......
......@@ -7,4 +7,4 @@
#
[global]
version=1.22
version=1.23
Package: amazon-efs-utils
Architecture: all
Version: 1.22
Version: 1.23
Section: utils
Depends: python|python2, nfs-common, stunnel4 (>= 4.56)
Depends: python|python2, nfs-common, stunnel4 (>= 4.56), openssl (>= 1.0.2), util-linux
Priority: optional
Copyright: MIT License
Maintainer: Amazon.com, Inc. <efs-utils@amazon.com>
......
......@@ -20,7 +20,7 @@
%endif
Name : amazon-efs-utils
Version : 1.22
Version : 1.23
Release : 1%{?dist}
Summary : This package provides utilities for simplifying the use of EFS file systems
......@@ -36,6 +36,8 @@ BuildArch : noarch
Requires : nfs-utils
Requires : stunnel >= 4.56
Requires : %{python_requires}
Requires : openssl >= 1.0.2
Requires : util-linux
%if %{with_systemd}
BuildRequires : systemd
......
......@@ -107,6 +107,11 @@ sudo mount -t efs fs-abcd1234 /mnt/efs
Mount an EFS file system with file system ID "fs-abcd1234" at mount point \
"/mnt/efs" without encryption of data in transit\&.
.TP
sudo mount -t efs -o netns=/proc/1/net/ns fs-abcd1234 /mnt/efs
Mount an EFS file system with file system ID "fs-abcd1234" at mount point \
"/mnt/efs" without encryption of data in transit in given network namespace \
'/proc/1/net/ns'
.TP
sudo mount -t efs fs-abcd1234:/child /mnt/efs
Mount a non-root directory of an EFS file system with file system ID \
"fs-abcd1234" at mount point "/mnt/efs" without encryption of data in transit\&.
......
......@@ -69,7 +69,7 @@ except ImportError:
from urllib.error import URLError, HTTPError
VERSION = '1.22'
VERSION = '1.23'
SERVICE = 'elasticfilesystem'
CONFIG_FILE = '/etc/amazon/efs/efs-utils.conf'
......@@ -137,6 +137,8 @@ FS_ID_RE = re.compile('^(?P<fs_id>fs-[0-9a-f]+)$')
EFS_FQDN_RE = re.compile(r'^(?P<fs_id>fs-[0-9a-f]+)\.efs\.(?P<region>[a-z0-9-]+)\.(?P<dns_name_suffix>[a-z0-9.]+)$')
AP_ID_RE = re.compile('^fsap-[0-9a-f]{17}$')
CREDENTIALS_KEYS = ['AccessKeyId', 'SecretAccessKey', 'Token']
ECS_URI_ENV = 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'
ECS_TASK_METADATA_API = 'http://169.254.170.2'
INSTANCE_METADATA_TOKEN_URL = 'http://169.254.169.254/latest/api/token'
INSTANCE_METADATA_SERVICE_URL = 'http://169.254.169.254/latest/dynamic/instance-identity/document/'
......@@ -151,12 +153,14 @@ NOT_BEFORE_MINS = 15
NOT_AFTER_HOURS = 3
EFS_ONLY_OPTIONS = [
'iam',
'noocsp',
'ocsp',
'accesspoint',
'awscredsuri',
'awsprofile',
'cafile',
'iam',
'netns',
'noocsp',
'ocsp',
'tls',
'tlsport',
'verify'
......@@ -262,7 +266,7 @@ def get_region_helper(config):
return dns_name_format.split('.')[-3]
def get_aws_security_credentials(use_iam, awsprofile):
def get_aws_security_credentials(use_iam, awsprofile=None, aws_creds_uri=None):
"""
Lookup AWS security credentials (access key ID and secret access key). Adapted credentials provider chain from:
https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html and
......@@ -271,51 +275,87 @@ def get_aws_security_credentials(use_iam, awsprofile):
if not use_iam:
return None, None
# attempt to lookup AWS security credentials in AWS credentials file (~/.aws/credentials) and configs file (~/.aws/config)
# attempt to lookup AWS security credentials through the credentials URI the ECS agent generated
if aws_creds_uri:
return get_aws_security_credentials_from_ecs(aws_creds_uri, True)
# attempt to lookup AWS security credentials in AWS credentials file (~/.aws/credentials)
# and configs file (~/.aws/config) with given awsprofile
if awsprofile:
return get_aws_security_credentials_from_awsprofile(awsprofile, True)
# attempt to lookup AWS security credentials through AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variable
if ECS_URI_ENV in os.environ:
credentials, credentials_source = get_aws_security_credentials_from_ecs(os.environ[ECS_URI_ENV], False)
if credentials and credentials_source:
return credentials, credentials_source
# attempt to lookup AWS security credentials with IAM role name attached to instance
# through IAM role name security credentials lookup uri
iam_role_name = get_iam_role_name()
if iam_role_name:
credentials, credentials_source = get_aws_security_credentials_from_instance_metadata(iam_role_name)
if credentials and credentials_source:
return credentials, credentials_source
error_msg = 'AWS Access Key ID and Secret Access Key are not found in AWS credentials file (%s), config file (%s), ' \
'from ECS credentials relative uri, or from the instance security credentials service' % \
(AWS_CREDENTIALS_FILE, AWS_CONFIG_FILE)
fatal_error(error_msg, error_msg)
def get_aws_security_credentials_from_awsprofile(awsprofile, is_fatal=False):
for file_path in [AWS_CREDENTIALS_FILE, AWS_CONFIG_FILE]:
if os.path.exists(file_path):
credentials = credentials_file_helper(file_path, awsprofile)
if credentials['AccessKeyId']:
return credentials, os.path.basename(file_path) + ':' + awsprofile
# Fail if credentials cannot be fetched from the given awsprofile
if is_fatal:
log_message = 'AWS security credentials not found in %s or %s under named profile [%s]' % \
(AWS_CREDENTIALS_FILE, AWS_CONFIG_FILE, awsprofile)
fatal_error(log_message)
else:
return None, None
dict_keys = ['AccessKeyId', 'SecretAccessKey', 'Token']
# through ECS security credentials uri found in AWS_CONTAINER_CREDENTIALS_RELATIVE_URI environment variable
ecs_uri_env = 'AWS_CONTAINER_CREDENTIALS_RELATIVE_URI'
if ecs_uri_env in os.environ:
ecs_uri = ECS_TASK_METADATA_API + os.environ[ecs_uri_env]
def get_aws_security_credentials_from_ecs(aws_creds_uri, is_fatal=False):
ecs_uri = ECS_TASK_METADATA_API + aws_creds_uri
ecs_unsuccessful_resp = 'Unsuccessful retrieval of AWS security credentials at %s.' % ecs_uri
ecs_url_error_msg = 'Unable to reach %s to retrieve AWS security credentials. See %s for more info.', ecs_uri, \
SECURITY_CREDS_ECS_URI_HELP_URL
ecs_url_error_msg = 'Unable to reach %s to retrieve AWS security credentials. See %s for more info.' \
% (ecs_uri, SECURITY_CREDS_ECS_URI_HELP_URL)
ecs_security_dict = url_request_helper(ecs_uri, ecs_unsuccessful_resp, ecs_url_error_msg)
if ecs_security_dict and all(k in ecs_security_dict for k in dict_keys):
return ecs_security_dict, 'ecs:' + os.environ[ecs_uri_env]
if ecs_security_dict and all(k in ecs_security_dict for k in CREDENTIALS_KEYS):
return ecs_security_dict, 'ecs:' + aws_creds_uri
# through IAM role name security credentials lookup uri (after lookup for IAM role name attached to instance)
iam_role_unsuccessful_resp = 'Unsuccessful retrieval of IAM role name at %s.' % INSTANCE_IAM_URL
iam_role_url_error_msg = 'Unable to reach %s to retrieve IAM role name. See %s for more info.', INSTANCE_IAM_URL, \
SECURITY_CREDS_IAM_ROLE_HELP_URL
iam_role_name = url_request_helper(INSTANCE_IAM_URL, iam_role_unsuccessful_resp, iam_role_url_error_msg)
if iam_role_name:
# Fail if credentials cannot be fetched from the given aws_creds_uri
if is_fatal:
fatal_error(ecs_unsuccessful_resp, ecs_unsuccessful_resp)
else:
return None, None
def get_aws_security_credentials_from_instance_metadata(iam_role_name):
security_creds_lookup_url = INSTANCE_IAM_URL + str(iam_role_name)
unsuccessful_resp = 'Unsuccessful retrieval of AWS security credentials at %s.' % security_creds_lookup_url
url_error_msg = 'Unable to reach %s to retrieve AWS security credentials. See %s for more info.',\
security_creds_lookup_url, SECURITY_CREDS_IAM_ROLE_HELP_URL
iam_security_dict = url_request_helper(security_creds_lookup_url, unsuccessful_resp, url_error_msg)
if iam_security_dict and all(k in iam_security_dict for k in dict_keys):
if iam_security_dict and all(k in iam_security_dict for k in CREDENTIALS_KEYS):
return iam_security_dict, 'metadata:'
else:
return None, None
error_msg = 'AWS Access Key ID and Secret Access Key are not found in AWS credentials file (%s), config file (%s), ' \
'from ECS credentials relative uri, or from the instance security credentials service' % \
(AWS_CREDENTIALS_FILE, AWS_CONFIG_FILE)
fatal_error(error_msg, error_msg)
def get_iam_role_name():
iam_role_unsuccessful_resp = 'Unsuccessful retrieval of IAM role name at %s.' % INSTANCE_IAM_URL
iam_role_url_error_msg = 'Unable to reach %s to retrieve IAM role name. See %s for more info.', INSTANCE_IAM_URL, \
SECURITY_CREDS_IAM_ROLE_HELP_URL
iam_role_name = url_request_helper(INSTANCE_IAM_URL, iam_role_unsuccessful_resp, iam_role_url_error_msg)
return iam_role_name
def credentials_file_helper(file_path, awsprofile):
......@@ -717,8 +757,13 @@ def bootstrap_tls(config, init_system, dns_name, fs_id, mountpoint, options, sta
cert_details = {}
if use_iam or ap_id:
awsprofile = get_aws_profile(options, use_iam)
security_credentials, credentials_source = get_aws_security_credentials(use_iam, awsprofile)
aws_creds_uri = options.get('awscredsuri')
if aws_creds_uri:
kwargs = {'aws_creds_uri': aws_creds_uri}
else:
kwargs = {'awsprofile': get_aws_profile(options, use_iam)}
security_credentials, credentials_source = get_aws_security_credentials(use_iam, **kwargs)
# additional symbol appended to avoid naming collisions
cert_details['mountStateDir'] = get_mount_specific_filename(fs_id, mountpoint, tls_port) + '+'
......@@ -749,6 +794,8 @@ def bootstrap_tls(config, init_system, dns_name, fs_id, mountpoint, options, sta
stunnel_config_file = write_stunnel_config_file(config, state_file_dir, fs_id, mountpoint, tls_port, dns_name, verify_level,
ocsp_enabled, options, cert_details=cert_details)
tunnel_args = ['stunnel', stunnel_config_file]
if 'netns' in options:
tunnel_args = ['nsenter', '--net=' + options['netns']] + tunnel_args
# launch the tunnel in a process group so if it has any child processes, they can be killed easily by the mount watchdog
logging.info('Starting TLS tunnel: "%s"', ' '.join(tunnel_args))
......@@ -804,6 +851,9 @@ def mount_nfs(dns_name, path, mountpoint, options):
command = ['/sbin/mount.nfs4', mount_path, mountpoint, '-o', get_nfs_mount_options(options)]
if 'netns' in options:
command = ['nsenter', '--net=' + options['netns']] + command
logging.info('Executing: "%s"', ' '.join(command))
proc = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, close_fds=True)
......@@ -1370,6 +1420,12 @@ def check_options_validity(options):
if 'awsprofile' in options and 'iam' not in options:
fatal_error('The "iam" option is required when mounting with named profile option, "awsprofile"')
if 'awscredsuri' in options and 'iam' not in options:
fatal_error('The "iam" option is required when mounting with "awscredsuri"')
if 'awscredsuri' in options and 'awsprofile' in options:
fatal_error('The "awscredsuri" and "awsprofile" options are mutually exclusive')
def main():
parse_arguments_early_exit()
......
......@@ -45,7 +45,7 @@ except ImportError:
from urllib.error import URLError
from urllib.request import urlopen
VERSION = '1.22'
VERSION = '1.23'
SERVICE = 'elasticfilesystem'
CONFIG_FILE = '/etc/amazon/efs/efs-utils.conf'
......
......@@ -31,6 +31,7 @@ WRONG_SESSION_TOKEN_VAL = 'WRONG_SESSION_TOKEN'
AWS_CONFIG_FILE = 'fake_aws_config'
DEFAULT_PROFILE = 'DEFAULT'
AWSPROFILE = 'test_profile'
AWSCREDSURI = '/v2/credentials/{uuid}'
class MockHeaders(object):
......@@ -118,7 +119,7 @@ def test_get_aws_security_credentials_do_not_use_iam():
assert not credentials_source
def test_get_aws_security_credentials_get_ecs(mocker):
def test_get_aws_security_credentials_get_ecs_from_env_url(mocker):
mocker.patch.dict(os.environ, {})
mocker.patch('os.path.exists', return_value=False)
response = json.dumps({
......@@ -139,6 +140,23 @@ def test_get_aws_security_credentials_get_ecs(mocker):
assert credentials_source == 'ecs:fake_uri'
def test_get_aws_security_credentials_get_ecs_from_option_url(mocker):
response = json.dumps({
'AccessKeyId': ACCESS_KEY_ID_VAL,
'Expiration': 'EXPIRATION_DATE',
'RoleArn': 'TASK_ROLE_ARN',
'SecretAccessKey': SECRET_ACCESS_KEY_VAL,
'Token': SESSION_TOKEN_VAL
})
mocker.patch('mount_efs.urlopen', return_value=MockUrlLibResponse(data=response))
credentials, credentials_source = mount_efs.get_aws_security_credentials(True, None, AWSCREDSURI)
assert credentials['AccessKeyId'] == ACCESS_KEY_ID_VAL
assert credentials['SecretAccessKey'] == SECRET_ACCESS_KEY_VAL
assert credentials['Token'] == SESSION_TOKEN_VAL
assert credentials_source == 'ecs:' + AWSCREDSURI
def test_get_aws_security_credentials_get_instance_metadata(mocker):
mocker.patch.dict(os.environ, {})
mocker.patch('os.path.exists', return_value=False)
......@@ -151,7 +169,6 @@ def test_get_aws_security_credentials_get_instance_metadata(mocker):
'Token': SESSION_TOKEN_VAL,
'Expiration': '2019-10-25T21:17:24Z'
})
mocker.patch.dict(os.environ, {})
mocker.patch('mount_efs.urlopen', return_value=MockUrlLibResponse(data=response))
credentials, credentials_source = mount_efs.get_aws_security_credentials(True, None)
......@@ -192,6 +209,18 @@ def test_get_aws_security_credentials_credentials_not_found_in_files(mocker, cap
assert 'under named profile [default]' in err
def test_get_aws_security_credentials_credentials_not_found_in_aws_creds_uri(mocker, capsys):
mocker.patch('mount_efs.urlopen')
with pytest.raises(SystemExit) as ex:
mount_efs.get_aws_security_credentials(True, 'default', AWSCREDSURI)
assert 0 != ex.value.code
out, err = capsys.readouterr()
assert 'Unsuccessful retrieval of AWS security credentials at' in err
def test_credentials_file_helper_awsprofile_found_with_token(tmpdir):
fake_file, config = _config_helper(tmpdir, add_test_profile=True)
......
......@@ -23,6 +23,7 @@ BAD_AP_ID_TOO_SHORT = 'fsap-0123456789abcdef'
BAD_AP_ID_BAD_CHAR = 'fsap-0123456789abcdefg'
PORT = 3000
AWSPROFILE = 'test_profile'
AWSCREDSURI = '/v2/credentials/{uuid}'
TLSPORT_INCORRECT = 'incorrect'
......@@ -32,7 +33,7 @@ def dummy_contextmanager(*args, **kwargs):
def _test_main(mocker, tls=False, root=True, ap_id=None, iam=False, awsprofile=None, ocsp=False, noocsp=False, port=None,
tlsport=None):
tlsport=None, awscredsuri=None):
options = {}
if tls:
options['tls'] = None
......@@ -50,6 +51,8 @@ def _test_main(mocker, tls=False, root=True, ap_id=None, iam=False, awsprofile=N
options['port'] = port
if tlsport is not None:
options['tlsport'] = tlsport
if awscredsuri is not None:
options['awscredsuri'] = AWSCREDSURI
if root:
mocker.patch('os.geteuid', return_value=0)
......@@ -142,6 +145,11 @@ def test_main_awsprofile_without_iam(mocker, capsys):
_test_main_assert_error(mocker, capsys, expected_err, tls=True, awsprofile=AWSPROFILE)
def test_main_awscredsuri_without_iam(mocker, capsys):
expected_err = 'The "iam" option is required when mounting with "awscredsuri"'
_test_main_assert_error(mocker, capsys, expected_err, tls=True, awscredsuri=AWSCREDSURI)
def test_main_tls_ocsp_option(mocker):
_test_main(mocker, tls=True, ocsp=True)
......@@ -164,6 +172,12 @@ def test_main_port_with_tls(mocker, capsys):
_test_main_assert_error(mocker, capsys, expected_err, tls=True, port=PORT)
def test_main_aws_creds_uri_with_aws_profile(mocker, capsys):
expected_err = 'The "awscredsuri" and "awsprofile" options are mutually exclusive'
_test_main_assert_error(mocker, capsys, expected_err, tls=True, iam=True,
awscredsuri=AWSCREDSURI, awsprofile=AWSPROFILE)
def test_main_tlsport_is_integer(mocker):
_test_main(mocker, tls=True, tlsport=PORT)
......
......@@ -23,6 +23,13 @@ NFS_MOUNT_POINT_IDX = 2
NFS_OPTION_FLAG_IDX = 3
NFS_OPTIONS_IDX = 4
# indices of different arguments to the NFS call to certain network namespace
NETNS_NSENTER_ARG_IDX = 0
NETNS_PATH_ARG_IDX = 1
NETNS_NFS_OFFSET = 2
NETNS = '/proc/1/net/ns'
def _mock_popen(mocker, returncode=0):
popen_mock = MagicMock()
......@@ -67,3 +74,23 @@ def test_mount_nfs_failure(mocker):
mount_efs.mount_nfs(DNS_NAME, '/', '/mnt', DEFAULT_OPTIONS)
assert 0 != ex.value.code
def test_mount_nfs_tls_netns(mocker):
mock = _mock_popen(mocker)
options = dict(DEFAULT_OPTIONS)
options['tls'] = None
options['netns'] = NETNS
mount_efs.mount_nfs(DNS_NAME, '/', '/mnt', options)
args, _ = mock.call_args
args = args[0]
assert 'nsenter' == args[NETNS_NSENTER_ARG_IDX]
assert '--net=' + NETNS == args[NETNS_PATH_ARG_IDX]
assert '/sbin/mount.nfs4' == args[NFS_BIN_ARG_IDX + NETNS_NFS_OFFSET]
assert DNS_NAME not in args[NFS_MOUNT_PATH_IDX + NETNS_NFS_OFFSET]
assert '127.0.0.1' in args[NFS_MOUNT_PATH_IDX + NETNS_NFS_OFFSET]
assert '/mnt' in args[NFS_MOUNT_POINT_IDX + NETNS_NFS_OFFSET]
\ No newline at end of file
......@@ -16,10 +16,60 @@ FS_ID = 'fs-deadbeef'
PID = 1234
PORT = 54323
COMMAND = ['stunnel', '/some/config/file']
NETNS = '/proc/1/net/ns'
NETNS_COMMAND = ['nsenter', '--net=' + NETNS] + COMMAND
FILES = ['/tmp/foo', '/tmp/bar']
DATETIME_FORMAT = '%y%m%d%H%M%SZ'
def test_write_tls_tunnel_state_file_netns(tmpdir):
state_file_dir = str(tmpdir)
mount_point = '/home/user/foo/mount'
current_time = datetime.utcnow()
cert_creation_time = current_time.strftime(DATETIME_FORMAT)
cert_details = {
'accessPoint': 'fsap-fedcba9876543210',
'certificate': '/tmp/baz',
'privateKey': '/tmp/key.pem',
'mountStateDir': 'fs-deadbeef.mount.dir.12345',
'commonName': 'fs-deadbeef.efs.us-east-1.amazonaws.com',
'region': 'us-east-1',
'fsId': FS_ID,
'certificateCreationTime': cert_creation_time,
'useIam': True
}
state_file = mount_efs.write_tls_tunnel_state_file(FS_ID, mount_point, PORT, PID, NETNS_COMMAND, FILES,
state_file_dir, cert_details)
assert FS_ID in state_file
assert os.sep not in state_file[state_file.find(FS_ID):]
assert os.path.exists(state_file_dir)
state_file = os.path.join(state_file_dir, state_file)
assert os.path.exists(state_file)
with open(state_file) as f:
state = json.load(f)
assert PID == state.get('pid')
assert NETNS_COMMAND == state.get('cmd')
assert FILES == state.get('files')
assert cert_details['commonName'] == state.get('commonName')
assert cert_details['certificate'] == state.get('certificate')
assert cert_details['certificateCreationTime'] == state.get('certificateCreationTime')
assert cert_details['mountStateDir'] == state.get('mountStateDir')
assert cert_details['privateKey'] == state.get('privateKey')
assert cert_details['region'] == state.get('region')
assert cert_details['accessPoint'] == state.get('accessPoint')
assert cert_details['fsId'] == state.get('fsId')
assert cert_details['useIam'] == state.get('useIam')
def test_write_tls_tunnel_state_file(tmpdir):
state_file_dir = str(tmpdir)
......
......@@ -177,7 +177,6 @@ def test_get_aws_security_credentials_instance_metadata(mocker):
'Token': SESSION_TOKEN_VAL,
'Expiration': '2019-10-25T21:17:24Z'
})
mocker.patch.dict(os.environ, {})
mocker.patch('watchdog.urlopen', return_value=MockUrlLibResponse(data=response))
credentials = watchdog.get_aws_security_credentials('metadata:')
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment