docker_login: allow to function with credential helpers (#63158)

* Allow docker_login to function with credential helpers.

* Improve various details and update docs.
pull/64637/head
Felix Fontein 6 years ago committed by GitHub
parent 4a995f26a3
commit dd5415017e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -22,9 +22,10 @@ short_description: Log into a Docker registry.
version_added: "2.0" version_added: "2.0"
description: description:
- Provides functionality similar to the "docker login" command. - Provides functionality similar to the "docker login" command.
- Authenticate with a docker registry and add the credentials to your local Docker config file. Adding the - Authenticate with a docker registry and add the credentials to your local Docker config file respectively the
credentials to the config files allows future connections to the registry using tools such as Ansible's Docker credentials store associated to the registry. Adding the credentials to the config files resp. the credential
modules, the Docker CLI and Docker SDK for Python without needing to provide credentials. store allows future connections to the registry using tools such as Ansible's Docker modules, the Docker CLI
and Docker SDK for Python without needing to provide credentials.
- Running in check mode will perform the authentication without updating the config file. - Running in check mode will perform the authentication without updating the config file.
options: options:
registry_url: registry_url:
@ -49,7 +50,8 @@ options:
email: email:
required: False required: False
description: description:
- "The email address for the registry account." - Does nothing, do not use.
- Will be removed in Ansible 2.14.
type: str type: str
reauthorize: reauthorize:
description: description:
@ -81,8 +83,9 @@ extends_documentation_fragment:
- docker.docker_py_1_documentation - docker.docker_py_1_documentation
requirements: requirements:
- "L(Docker SDK for Python,https://docker-py.readthedocs.io/en/stable/) >= 1.8.0 (use L(docker-py,https://pypi.org/project/docker-py/) for Python 2.6)" - "L(Docker SDK for Python,https://docker-py.readthedocs.io/en/stable/) >= 1.8.0 (use L(docker-py,https://pypi.org/project/docker-py/) for Python 2.6)"
- "L(Python bindings for docker credentials store API) >= 0.2.1
(use L(docker-pycreds,https://pypi.org/project/docker-pycreds/) when using Docker SDK for Python < 4.0.0)"
- "Docker API >= 1.20" - "Docker API >= 1.20"
- "Only to be able to logout, that is for I(state) = C(absent): the C(docker) command line utility"
author: author:
- Olaf Kilian (@olsaki) <olaf.kilian@symanex.com> - Olaf Kilian (@olsaki) <olaf.kilian@symanex.com>
- Chris Houseknecht (@chouseknecht) - Chris Houseknecht (@chouseknecht)
@ -119,7 +122,6 @@ login_results:
returned: when state='present' returned: when state='present'
type: dict type: dict
sample: { sample: {
"email": "testuer@yahoo.com",
"serveraddress": "localhost:5000", "serveraddress": "localhost:5000",
"username": "testuser" "username": "testuser"
} }
@ -130,22 +132,153 @@ import json
import os import os
import re import re
import traceback import traceback
from ansible.module_utils._text import to_bytes, to_text
try: try:
from docker.errors import DockerException from docker.errors import DockerException
from docker import auth
# Earlier versions of docker/docker-py put decode_auth
# in docker.auth.auth instead of docker.auth
if hasattr(auth, 'decode_auth'):
from docker.auth import decode_auth
else:
from docker.auth.auth import decode_auth
except ImportError: except ImportError:
# missing Docker SDK for Python handled in ansible.module_utils.docker.common # missing Docker SDK for Python handled in ansible.module_utils.docker.common
pass pass
from ansible.module_utils._text import to_bytes, to_text
from ansible.module_utils.docker.common import ( from ansible.module_utils.docker.common import (
AnsibleDockerClient, AnsibleDockerClient,
HAS_DOCKER_PY,
DEFAULT_DOCKER_REGISTRY, DEFAULT_DOCKER_REGISTRY,
DockerBaseClass, DockerBaseClass,
EMAIL_REGEX, EMAIL_REGEX,
RequestException, RequestException,
) )
NEEDS_DOCKER_PYCREDS = False
# Early versions of docker/docker-py rely on docker-pycreds for
# the credential store api.
if HAS_DOCKER_PY:
try:
from docker.credentials.errors import StoreError, CredentialsNotFound
from docker.credentials import Store
except ImportError:
try:
from dockerpycreds.errors import StoreError, CredentialsNotFound
from dockerpycreds.store import Store
except ImportError as exc:
HAS_DOCKER_ERRROR = str(exc)
NEEDS_DOCKER_PYCREDS = True
if NEEDS_DOCKER_PYCREDS:
# docker-pycreds missing, so we need to create some place holder classes
# to allow instantiation.
class StoreError(Exception):
pass
class CredentialsNotFound(Exception):
pass
class DockerFileStore(object):
'''
A custom credential store class that implements only the functionality we need to
update the docker config file when no credential helpers is provided.
'''
program = "<legacy config>"
def __init__(self, config_path):
self._config_path = config_path
# Make sure we have a minimal config if none is available.
self._config = dict(
auths=dict()
)
try:
# Attempt to read the existing config.
with open(self._config_path, "r") as f:
config = json.load(f)
except (ValueError, IOError):
# No config found or an invalid config found so we'll ignore it.
config = dict()
# Update our internal config with what ever was loaded.
self._config.update(config)
@property
def config_path(self):
'''
Return the config path configured in this DockerFileStore instance.
'''
return self._config_path
def get(self, server):
'''
Retrieve credentials for `server` if there are any in the config file.
Otherwise raise a `StoreError`
'''
server_creds = self._config['auths'].get(server)
if not server_creds:
raise CredentialsNotFound('No matching credentials')
(username, password) = decode_auth(server_creds['auth'])
return dict(
Username=username,
Secret=password
)
def _write(self):
'''
Write config back out to disk.
'''
# Make sure directory exists
dir = os.path.dirname(self._config_path)
if not os.path.exists(dir):
os.makedirs(dir)
# Write config
with open(self._config_path, "w") as f:
json.dump(self._config, f, indent=4, sort_keys=True)
def store(self, server, username, password):
'''
Add a credentials for `server` to the current configuration.
'''
b64auth = base64.b64encode(
to_bytes(username) + b':' + to_bytes(password)
)
auth = to_text(b64auth)
# build up the auth structure
new_auth = dict(
auths=dict()
)
new_auth['auths'][server] = dict(
auth=auth
)
self._config.update(new_auth)
self._write()
def erase(self, server):
'''
Remove credentials for the given server from the configuration.
'''
self._config['auths'].pop(server)
self._write()
class LoginManager(DockerBaseClass): class LoginManager(DockerBaseClass):
@ -164,8 +297,15 @@ class LoginManager(DockerBaseClass):
self.email = parameters.get('email') self.email = parameters.get('email')
self.reauthorize = parameters.get('reauthorize') self.reauthorize = parameters.get('reauthorize')
self.config_path = parameters.get('config_path') self.config_path = parameters.get('config_path')
self.state = parameters.get('state')
def run(self):
'''
Do the actuall work of this task here. This allows instantiation for partial
testing.
'''
if parameters['state'] == 'present': if self.state == 'present':
self.login() self.login()
else: else:
self.logout() self.logout()
@ -200,116 +340,107 @@ class LoginManager(DockerBaseClass):
self.fail("Logging into %s for user %s failed - %s" % (self.registry_url, self.username, str(exc))) self.fail("Logging into %s for user %s failed - %s" % (self.registry_url, self.username, str(exc)))
# If user is already logged in, then response contains password for user # If user is already logged in, then response contains password for user
# This returns correct password if user is logged in and wrong password is given.
if 'password' in response: if 'password' in response:
del response['password'] # This returns correct password if user is logged in and wrong password is given.
# So if it returns another password as we passed, and the user didn't request to
# reauthorize, still do it.
if not self.reauthorize and response['password'] != self.password:
try:
response = self.client.login(
self.username,
password=self.password,
email=self.email,
registry=self.registry_url,
reauth=True,
dockercfg_path=self.config_path
)
except Exception as exc:
self.fail("Logging into %s for user %s failed - %s" % (self.registry_url, self.username, str(exc)))
response.pop('password', None)
self.results['login_result'] = response self.results['login_result'] = response
if not self.check_mode: self.update_credentials()
self.update_config_file()
def logout(self): def logout(self):
''' '''
Log out of the registry. On success update the config file. Log out of the registry. On success update the config file.
TODO: port to API once docker.py supports this.
:return: None :return: None
''' '''
cmd = [self.client.module.get_bin_path('docker', True), "logout", self.registry_url] # Get the configuration store.
# TODO: docker does not support config file in logout, restore this when they do store = self.get_credential_store_instance(self.registry_url, self.config_path)
# if self.config_path and self.config_file_exists(self.config_path):
# cmd.extend(["--config", self.config_path])
(rc, out, err) = self.client.module.run_command(cmd) try:
if rc != 0: current = store.get(self.registry_url)
self.fail("Could not log out: %s" % err) except CredentialsNotFound:
if 'Not logged in to ' in out: # get raises an exception on not found.
self.log("Credentials for %s not present, doing nothing." % (self.registry_url))
self.results['changed'] = False self.results['changed'] = False
elif 'Removing login credentials for ' in out: return
self.results['changed'] = True
else:
self.client.module.warn('Unable to determine whether logout was successful.')
# Adding output to actions, so that user can inspect what was actually returned
self.results['actions'].append(to_text(out))
def config_file_exists(self, path): if not self.check_mode:
if os.path.exists(path): store.erase(self.registry_url)
self.log("Configuration file %s exists" % (path)) self.results['changed'] = True
return True
self.log("Configuration file %s not found." % (path))
return False
def create_config_file(self, path): def update_credentials(self):
''' '''
Create a config file with a JSON blob containing an auths key. If the authorization is not stored attempt to store authorization values via
the appropriate credential helper or to the config file.
:return: None :return: None
''' '''
self.log("Creating docker config file %s" % (path)) # Check to see if credentials already exist.
config_path_dir = os.path.dirname(path) store = self.get_credential_store_instance(self.registry_url, self.config_path)
if not os.path.exists(config_path_dir):
try:
os.makedirs(config_path_dir)
except Exception as exc:
self.fail("Error: failed to create %s - %s" % (config_path_dir, str(exc)))
self.write_config(path, dict(auths=dict()))
def write_config(self, path, config):
try: try:
with open(path, "w") as file: current = store.get(self.registry_url)
json.dump(config, file, indent=5, sort_keys=True) except CredentialsNotFound:
except Exception as exc: # get raises an exception on not found.
self.fail("Error: failed to write config to %s - %s" % (path, str(exc))) current = dict(
Username='',
Secret=''
)
def update_config_file(self): if current['Username'] != self.username or current['Secret'] != self.password or self.reauthorize:
''' if not self.check_mode:
If the authorization not stored in the config file or reauthorize is True, store.store(self.registry_url, self.username, self.password)
update the config file with the new authorization. self.log("Writing credentials to configured helper %s for %s" % (store.program, self.registry_url))
self.results['actions'].append("Wrote credentials to configured helper %s for %s" % (
store.program, self.registry_url))
self.results['changed'] = True
:return: None def get_credential_store_instance(self, registry, dockercfg_path):
''' '''
Return an instance of docker.credentials.Store used by the given registry.
path = self.config_path :return: A Store or None
if not self.config_file_exists(path): :rtype: Union[docker.credentials.Store, NoneType]
self.create_config_file(path) '''
# Older versions of docker-py don't have this feature.
try: try:
# read the existing config credstore_env = self.client.credstore_env
with open(path, "r") as file: except AttributeError:
config = json.load(file) credstore_env = None
except ValueError:
self.log("Error reading config from %s" % (path))
config = dict()
if not config.get('auths'): config = auth.load_config(config_path=dockercfg_path)
self.log("Adding auths dict to config.")
config['auths'] = dict()
if not config['auths'].get(self.registry_url): if hasattr(auth, 'get_credential_store'):
self.log("Adding registry_url %s to auths." % (self.registry_url)) store_name = auth.get_credential_store(config, registry)
config['auths'][self.registry_url] = dict() elif 'credsStore' in config:
store_name = config['credsStore']
b64auth = base64.b64encode( else:
to_bytes(self.username) + b':' + to_bytes(self.password) store_name = None
)
auth = to_text(b64auth)
encoded_credentials = dict( # Make sure that there is a credential helper before trying to instantiate a
auth=auth, # Store object.
email=self.email if store_name:
) self.log("Found credential store %s" % store_name)
return Store(store_name, environment=credstore_env)
if config['auths'][self.registry_url] != encoded_credentials or self.reauthorize: return DockerFileStore(dockercfg_path)
# Update the config file with the new authorization
config['auths'][self.registry_url] = encoded_credentials
self.log("Updating config file %s with new authorization for %s" % (path, self.registry_url))
self.results['actions'].append("Updated config file %s with new authorization for %s" % (
path, self.registry_url))
self.results['changed'] = True
self.write_config(path, config)
def main(): def main():
@ -318,7 +449,7 @@ def main():
registry_url=dict(type='str', default=DEFAULT_DOCKER_REGISTRY, aliases=['registry', 'url']), registry_url=dict(type='str', default=DEFAULT_DOCKER_REGISTRY, aliases=['registry', 'url']),
username=dict(type='str'), username=dict(type='str'),
password=dict(type='str', no_log=True), password=dict(type='str', no_log=True),
email=dict(type='str'), email=dict(type='str', removed_in_version='2.14'),
reauthorize=dict(type='bool', default=False, aliases=['reauth']), reauthorize=dict(type='bool', default=False, aliases=['reauth']),
state=dict(type='str', default='present', choices=['present', 'absent']), state=dict(type='str', default='present', choices=['present', 'absent']),
config_path=dict(type='path', default='~/.docker/config.json', aliases=['dockercfg_path']), config_path=dict(type='path', default='~/.docker/config.json', aliases=['dockercfg_path']),
@ -342,7 +473,9 @@ def main():
login_result={} login_result={}
) )
LoginManager(client, results) manager = LoginManager(client, results)
manager.run()
if 'actions' in results: if 'actions' in results:
del results['actions'] del results['actions']
client.module.exit_json(**results) client.module.exit_json(**results)

@ -26,14 +26,14 @@
- login_failed is failed - login_failed is failed
- "('login attempt to http://' ~ registry_frontend_address ~ '/v2/ failed') in login_failed.msg" - "('login attempt to http://' ~ registry_frontend_address ~ '/v2/ failed') in login_failed.msg"
#- name: Log in (check mode) - name: Log in (check mode)
# docker_login: docker_login:
# registry_url: "{{ registry_frontend_address }}" registry_url: "{{ registry_frontend_address }}"
# username: testuser username: testuser
# password: hunter2 password: hunter2
# state: present state: present
# register: login_1 register: login_1
# check_mode: yes check_mode: yes
- name: Log in - name: Log in
docker_login: docker_login:
@ -63,17 +63,44 @@
- name: Make sure that login worked - name: Make sure that login worked
assert: assert:
that: that:
#- login_1 is changed - login_1 is changed
- login_2 is changed - login_2 is changed
- login_3 is not changed - login_3 is not changed
- login_4 is not changed - login_4 is not changed
#- name: Log out (check mode) - name: Log in again with wrong password (check mode)
# docker_login: docker_login:
# registry_url: "{{ registry_frontend_address }}" registry_url: "{{ registry_frontend_address }}"
# state: absent username: testuser
# register: logout_1 password: "1234"
# check_mode: yes state: present
register: login_failed_check
ignore_errors: yes
check_mode: yes
- name: Log in again with wrong password
docker_login:
registry_url: "{{ registry_frontend_address }}"
username: testuser
password: "1234"
state: present
register: login_failed
ignore_errors: yes
- name: Make sure that login failed again
assert:
that:
- login_failed_check is failed
- "('login attempt to http://' ~ registry_frontend_address ~ '/v2/ failed') in login_failed_check.msg"
- login_failed is failed
- "('login attempt to http://' ~ registry_frontend_address ~ '/v2/ failed') in login_failed.msg"
- name: Log out (check mode)
docker_login:
registry_url: "{{ registry_frontend_address }}"
state: absent
register: logout_1
check_mode: yes
- name: Log out - name: Log out
docker_login: docker_login:
@ -97,7 +124,7 @@
- name: Make sure that login worked - name: Make sure that login worked
assert: assert:
that: that:
#- logout_1 is changed - logout_1 is changed
- logout_2 is changed - logout_2 is changed
- logout_3 is not changed - logout_3 is not changed
- logout_4 is not changed - logout_4 is not changed

Loading…
Cancel
Save