summaryrefslogtreecommitdiff
path: root/test/units/galaxy
diff options
context:
space:
mode:
authorLee Garrett <lgarrett@rocketjump.eu>2020-12-19 18:12:24 +0100
committerLee Garrett <lgarrett@rocketjump.eu>2020-12-19 18:12:24 +0100
commitb0c840f0b8eabf61dee4b8be2e00a8747ed0f2f0 (patch)
tree425c0ec94f93ae7cebc8ecfbe99d2ad7f0180904 /test/units/galaxy
downloaddebian-ansible-core-b0c840f0b8eabf61dee4b8be2e00a8747ed0f2f0.zip
New upstream version 2.10.4
Diffstat (limited to 'test/units/galaxy')
-rw-r--r--test/units/galaxy/__init__.py0
-rw-r--r--test/units/galaxy/test_api.py912
-rw-r--r--test/units/galaxy/test_collection.py1326
-rw-r--r--test/units/galaxy/test_collection_install.py816
-rw-r--r--test/units/galaxy/test_token.py55
-rw-r--r--test/units/galaxy/test_user_agent.py18
6 files changed, 3127 insertions, 0 deletions
diff --git a/test/units/galaxy/__init__.py b/test/units/galaxy/__init__.py
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/test/units/galaxy/__init__.py
diff --git a/test/units/galaxy/test_api.py b/test/units/galaxy/test_api.py
new file mode 100644
index 00000000..f333a64b
--- /dev/null
+++ b/test/units/galaxy/test_api.py
@@ -0,0 +1,912 @@
+# -*- coding: utf-8 -*-
+# Copyright: (c) 2019, Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import json
+import os
+import re
+import pytest
+import tarfile
+import tempfile
+import time
+
+from io import BytesIO, StringIO
+from units.compat.mock import MagicMock
+
+from ansible import context
+from ansible.errors import AnsibleError
+from ansible.galaxy import api as galaxy_api
+from ansible.galaxy.api import CollectionVersionMetadata, GalaxyAPI, GalaxyError
+from ansible.galaxy.token import BasicAuthToken, GalaxyToken, KeycloakToken
+from ansible.module_utils._text import to_native, to_text
+from ansible.module_utils.six.moves.urllib import error as urllib_error
+from ansible.utils import context_objects as co
+from ansible.utils.display import Display
+
+
+@pytest.fixture(autouse='function')
+def reset_cli_args():
+ co.GlobalCLIArgs._Singleton__instance = None
+ # Required to initialise the GalaxyAPI object
+ context.CLIARGS._store = {'ignore_certs': False}
+ yield
+ co.GlobalCLIArgs._Singleton__instance = None
+
+
+@pytest.fixture()
+def collection_artifact(tmp_path_factory):
+ ''' Creates a collection artifact tarball that is ready to be published '''
+ output_dir = to_text(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Output'))
+
+ tar_path = os.path.join(output_dir, 'namespace-collection-v1.0.0.tar.gz')
+ with tarfile.open(tar_path, 'w:gz') as tfile:
+ b_io = BytesIO(b"\x00\x01\x02\x03")
+ tar_info = tarfile.TarInfo('test')
+ tar_info.size = 4
+ tar_info.mode = 0o0644
+ tfile.addfile(tarinfo=tar_info, fileobj=b_io)
+
+ yield tar_path
+
+
+def get_test_galaxy_api(url, version, token_ins=None, token_value=None):
+ token_value = token_value or "my token"
+ token_ins = token_ins or GalaxyToken(token_value)
+ api = GalaxyAPI(None, "test", url)
+ # Warning, this doesn't test g_connect() because _availabe_api_versions is set here. That means
+ # that urls for v2 servers have to append '/api/' themselves in the input data.
+ api._available_api_versions = {version: '%s' % version}
+ api.token = token_ins
+
+ return api
+
+
+def test_api_no_auth():
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/")
+ actual = {}
+ api._add_auth_token(actual, "")
+ assert actual == {}
+
+
+def test_api_no_auth_but_required():
+ expected = "No access token or username set. A token can be set with --api-key or at "
+ with pytest.raises(AnsibleError, match=expected):
+ GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/")._add_auth_token({}, "", required=True)
+
+
+def test_api_token_auth():
+ token = GalaxyToken(token=u"my_token")
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/", token=token)
+ actual = {}
+ api._add_auth_token(actual, "", required=True)
+ assert actual == {'Authorization': 'Token my_token'}
+
+
+def test_api_token_auth_with_token_type(monkeypatch):
+ token = KeycloakToken(auth_url='https://api.test/')
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my_token'
+ monkeypatch.setattr(token, 'get', mock_token_get)
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/", token=token)
+ actual = {}
+ api._add_auth_token(actual, "", token_type="Bearer", required=True)
+ assert actual == {'Authorization': 'Bearer my_token'}
+
+
+def test_api_token_auth_with_v3_url(monkeypatch):
+ token = KeycloakToken(auth_url='https://api.test/')
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my_token'
+ monkeypatch.setattr(token, 'get', mock_token_get)
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/", token=token)
+ actual = {}
+ api._add_auth_token(actual, "https://galaxy.ansible.com/api/v3/resource/name", required=True)
+ assert actual == {'Authorization': 'Bearer my_token'}
+
+
+def test_api_token_auth_with_v2_url():
+ token = GalaxyToken(token=u"my_token")
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/", token=token)
+ actual = {}
+ # Add v3 to random part of URL but response should only see the v2 as the full URI path segment.
+ api._add_auth_token(actual, "https://galaxy.ansible.com/api/v2/resourcev3/name", required=True)
+ assert actual == {'Authorization': 'Token my_token'}
+
+
+def test_api_basic_auth_password():
+ token = BasicAuthToken(username=u"user", password=u"pass")
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/", token=token)
+ actual = {}
+ api._add_auth_token(actual, "", required=True)
+ assert actual == {'Authorization': 'Basic dXNlcjpwYXNz'}
+
+
+def test_api_basic_auth_no_password():
+ token = BasicAuthToken(username=u"user")
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/", token=token)
+ actual = {}
+ api._add_auth_token(actual, "", required=True)
+ assert actual == {'Authorization': 'Basic dXNlcjo='}
+
+
+def test_api_dont_override_auth_header():
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/")
+ actual = {'Authorization': 'Custom token'}
+ api._add_auth_token(actual, "", required=True)
+ assert actual == {'Authorization': 'Custom token'}
+
+
+def test_initialise_galaxy(monkeypatch):
+ mock_open = MagicMock()
+ mock_open.side_effect = [
+ StringIO(u'{"available_versions":{"v1":"v1/"}}'),
+ StringIO(u'{"token":"my token"}'),
+ ]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/")
+ actual = api.authenticate("github_token")
+
+ assert len(api.available_api_versions) == 2
+ assert api.available_api_versions['v1'] == u'v1/'
+ assert api.available_api_versions['v2'] == u'v2/'
+ assert actual == {u'token': u'my token'}
+ assert mock_open.call_count == 2
+ assert mock_open.mock_calls[0][1][0] == 'https://galaxy.ansible.com/api/'
+ assert 'ansible-galaxy' in mock_open.mock_calls[0][2]['http_agent']
+ assert mock_open.mock_calls[1][1][0] == 'https://galaxy.ansible.com/api/v1/tokens/'
+ assert 'ansible-galaxy' in mock_open.mock_calls[1][2]['http_agent']
+ assert mock_open.mock_calls[1][2]['data'] == 'github_token=github_token'
+
+
+def test_initialise_galaxy_with_auth(monkeypatch):
+ mock_open = MagicMock()
+ mock_open.side_effect = [
+ StringIO(u'{"available_versions":{"v1":"v1/"}}'),
+ StringIO(u'{"token":"my token"}'),
+ ]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/", token=GalaxyToken(token='my_token'))
+ actual = api.authenticate("github_token")
+
+ assert len(api.available_api_versions) == 2
+ assert api.available_api_versions['v1'] == u'v1/'
+ assert api.available_api_versions['v2'] == u'v2/'
+ assert actual == {u'token': u'my token'}
+ assert mock_open.call_count == 2
+ assert mock_open.mock_calls[0][1][0] == 'https://galaxy.ansible.com/api/'
+ assert 'ansible-galaxy' in mock_open.mock_calls[0][2]['http_agent']
+ assert mock_open.mock_calls[1][1][0] == 'https://galaxy.ansible.com/api/v1/tokens/'
+ assert 'ansible-galaxy' in mock_open.mock_calls[1][2]['http_agent']
+ assert mock_open.mock_calls[1][2]['data'] == 'github_token=github_token'
+
+
+def test_initialise_automation_hub(monkeypatch):
+ mock_open = MagicMock()
+ mock_open.side_effect = [
+ StringIO(u'{"available_versions":{"v2": "v2/", "v3":"v3/"}}'),
+ ]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+ token = KeycloakToken(auth_url='https://api.test/')
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my_token'
+ monkeypatch.setattr(token, 'get', mock_token_get)
+
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/", token=token)
+
+ assert len(api.available_api_versions) == 2
+ assert api.available_api_versions['v2'] == u'v2/'
+ assert api.available_api_versions['v3'] == u'v3/'
+
+ assert mock_open.mock_calls[0][1][0] == 'https://galaxy.ansible.com/api/'
+ assert 'ansible-galaxy' in mock_open.mock_calls[0][2]['http_agent']
+ assert mock_open.mock_calls[0][2]['headers'] == {'Authorization': 'Bearer my_token'}
+
+
+def test_initialise_unknown(monkeypatch):
+ mock_open = MagicMock()
+ mock_open.side_effect = [
+ urllib_error.HTTPError('https://galaxy.ansible.com/api/', 500, 'msg', {}, StringIO(u'{"msg":"raw error"}')),
+ urllib_error.HTTPError('https://galaxy.ansible.com/api/api/', 500, 'msg', {}, StringIO(u'{"msg":"raw error"}')),
+ ]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/", token=GalaxyToken(token='my_token'))
+
+ expected = "Error when finding available api versions from test (%s) (HTTP Code: 500, Message: msg)" \
+ % api.api_server
+ with pytest.raises(AnsibleError, match=re.escape(expected)):
+ api.authenticate("github_token")
+
+
+def test_get_available_api_versions(monkeypatch):
+ mock_open = MagicMock()
+ mock_open.side_effect = [
+ StringIO(u'{"available_versions":{"v1":"v1/","v2":"v2/"}}'),
+ ]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ api = GalaxyAPI(None, "test", "https://galaxy.ansible.com/api/")
+ actual = api.available_api_versions
+ assert len(actual) == 2
+ assert actual['v1'] == u'v1/'
+ assert actual['v2'] == u'v2/'
+
+ assert mock_open.call_count == 1
+ assert mock_open.mock_calls[0][1][0] == 'https://galaxy.ansible.com/api/'
+ assert 'ansible-galaxy' in mock_open.mock_calls[0][2]['http_agent']
+
+
+def test_publish_collection_missing_file():
+ fake_path = u'/fake/ÅÑŚÌβŁÈ/path'
+ expected = to_native("The collection path specified '%s' does not exist." % fake_path)
+
+ api = get_test_galaxy_api("https://galaxy.ansible.com/api/", "v2")
+ with pytest.raises(AnsibleError, match=expected):
+ api.publish_collection(fake_path)
+
+
+def test_publish_collection_not_a_tarball():
+ expected = "The collection path specified '{0}' is not a tarball, use 'ansible-galaxy collection build' to " \
+ "create a proper release artifact."
+
+ api = get_test_galaxy_api("https://galaxy.ansible.com/api/", "v2")
+ with tempfile.NamedTemporaryFile(prefix=u'ÅÑŚÌβŁÈ') as temp_file:
+ temp_file.write(b"\x00")
+ temp_file.flush()
+ with pytest.raises(AnsibleError, match=expected.format(to_native(temp_file.name))):
+ api.publish_collection(temp_file.name)
+
+
+def test_publish_collection_unsupported_version():
+ expected = "Galaxy action publish_collection requires API versions 'v2, v3' but only 'v1' are available on test " \
+ "https://galaxy.ansible.com/api/"
+
+ api = get_test_galaxy_api("https://galaxy.ansible.com/api/", "v1")
+ with pytest.raises(AnsibleError, match=expected):
+ api.publish_collection("path")
+
+
+@pytest.mark.parametrize('api_version, collection_url', [
+ ('v2', 'collections'),
+ ('v3', 'artifacts/collections'),
+])
+def test_publish_collection(api_version, collection_url, collection_artifact, monkeypatch):
+ api = get_test_galaxy_api("https://galaxy.ansible.com/api/", api_version)
+
+ mock_call = MagicMock()
+ mock_call.return_value = {'task': 'http://task.url/'}
+ monkeypatch.setattr(api, '_call_galaxy', mock_call)
+
+ actual = api.publish_collection(collection_artifact)
+ assert actual == 'http://task.url/'
+ assert mock_call.call_count == 1
+ assert mock_call.mock_calls[0][1][0] == 'https://galaxy.ansible.com/api/%s/%s/' % (api_version, collection_url)
+ assert mock_call.mock_calls[0][2]['headers']['Content-length'] == len(mock_call.mock_calls[0][2]['args'])
+ assert mock_call.mock_calls[0][2]['headers']['Content-type'].startswith(
+ 'multipart/form-data; boundary=')
+ assert mock_call.mock_calls[0][2]['args'].startswith(b'--')
+ assert mock_call.mock_calls[0][2]['method'] == 'POST'
+ assert mock_call.mock_calls[0][2]['auth_required'] is True
+
+
+@pytest.mark.parametrize('api_version, collection_url, response, expected', [
+ ('v2', 'collections', {},
+ 'Error when publishing collection to test (%s) (HTTP Code: 500, Message: msg Code: Unknown)'),
+ ('v2', 'collections', {
+ 'message': u'Galaxy error messäge',
+ 'code': 'GWE002',
+ }, u'Error when publishing collection to test (%s) (HTTP Code: 500, Message: Galaxy error messäge Code: GWE002)'),
+ ('v3', 'artifact/collections', {},
+ 'Error when publishing collection to test (%s) (HTTP Code: 500, Message: msg Code: Unknown)'),
+ ('v3', 'artifact/collections', {
+ 'errors': [
+ {
+ 'code': 'conflict.collection_exists',
+ 'detail': 'Collection "mynamespace-mycollection-4.1.1" already exists.',
+ 'title': 'Conflict.',
+ 'status': '400',
+ },
+ {
+ 'code': 'quantum_improbability',
+ 'title': u'Rändom(?) quantum improbability.',
+ 'source': {'parameter': 'the_arrow_of_time'},
+ 'meta': {'remediation': 'Try again before'},
+ },
+ ],
+ }, u'Error when publishing collection to test (%s) (HTTP Code: 500, Message: Collection '
+ u'"mynamespace-mycollection-4.1.1" already exists. Code: conflict.collection_exists), (HTTP Code: 500, '
+ u'Message: Rändom(?) quantum improbability. Code: quantum_improbability)')
+])
+def test_publish_failure(api_version, collection_url, response, expected, collection_artifact, monkeypatch):
+ api = get_test_galaxy_api('https://galaxy.server.com/api/', api_version)
+
+ expected_url = '%s/api/%s/%s' % (api.api_server, api_version, collection_url)
+
+ mock_open = MagicMock()
+ mock_open.side_effect = urllib_error.HTTPError(expected_url, 500, 'msg', {},
+ StringIO(to_text(json.dumps(response))))
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ with pytest.raises(GalaxyError, match=re.escape(to_native(expected % api.api_server))):
+ api.publish_collection(collection_artifact)
+
+
+@pytest.mark.parametrize('server_url, api_version, token_type, token_ins, import_uri, full_import_uri', [
+ ('https://galaxy.server.com/api', 'v2', 'Token', GalaxyToken('my token'),
+ '1234',
+ 'https://galaxy.server.com/api/v2/collection-imports/1234/'),
+ ('https://galaxy.server.com/api/automation-hub/', 'v3', 'Bearer', KeycloakToken(auth_url='https://api.test/'),
+ '1234',
+ 'https://galaxy.server.com/api/automation-hub/v3/imports/collections/1234/'),
+])
+def test_wait_import_task(server_url, api_version, token_type, token_ins, import_uri, full_import_uri, monkeypatch):
+ api = get_test_galaxy_api(server_url, api_version, token_ins=token_ins)
+
+ if token_ins:
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my token'
+ monkeypatch.setattr(token_ins, 'get', mock_token_get)
+
+ mock_open = MagicMock()
+ mock_open.return_value = StringIO(u'{"state":"success","finished_at":"time"}')
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ api.wait_import_task(import_uri)
+
+ assert mock_open.call_count == 1
+ assert mock_open.mock_calls[0][1][0] == full_import_uri
+ assert mock_open.mock_calls[0][2]['headers']['Authorization'] == '%s my token' % token_type
+
+ assert mock_display.call_count == 1
+ assert mock_display.mock_calls[0][1][0] == 'Waiting until Galaxy import task %s has completed' % full_import_uri
+
+
+@pytest.mark.parametrize('server_url, api_version, token_type, token_ins, import_uri, full_import_uri', [
+ ('https://galaxy.server.com/api/', 'v2', 'Token', GalaxyToken('my token'),
+ '1234',
+ 'https://galaxy.server.com/api/v2/collection-imports/1234/'),
+ ('https://galaxy.server.com/api/automation-hub', 'v3', 'Bearer', KeycloakToken(auth_url='https://api.test/'),
+ '1234',
+ 'https://galaxy.server.com/api/automation-hub/v3/imports/collections/1234/'),
+])
+def test_wait_import_task_multiple_requests(server_url, api_version, token_type, token_ins, import_uri, full_import_uri, monkeypatch):
+ api = get_test_galaxy_api(server_url, api_version, token_ins=token_ins)
+
+ if token_ins:
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my token'
+ monkeypatch.setattr(token_ins, 'get', mock_token_get)
+
+ mock_open = MagicMock()
+ mock_open.side_effect = [
+ StringIO(u'{"state":"test"}'),
+ StringIO(u'{"state":"success","finished_at":"time"}'),
+ ]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ mock_vvv = MagicMock()
+ monkeypatch.setattr(Display, 'vvv', mock_vvv)
+
+ monkeypatch.setattr(time, 'sleep', MagicMock())
+
+ api.wait_import_task(import_uri)
+
+ assert mock_open.call_count == 2
+ assert mock_open.mock_calls[0][1][0] == full_import_uri
+ assert mock_open.mock_calls[0][2]['headers']['Authorization'] == '%s my token' % token_type
+ assert mock_open.mock_calls[1][1][0] == full_import_uri
+ assert mock_open.mock_calls[1][2]['headers']['Authorization'] == '%s my token' % token_type
+
+ assert mock_display.call_count == 1
+ assert mock_display.mock_calls[0][1][0] == 'Waiting until Galaxy import task %s has completed' % full_import_uri
+
+ assert mock_vvv.call_count == 1
+ assert mock_vvv.mock_calls[0][1][0] == \
+ 'Galaxy import process has a status of test, wait 2 seconds before trying again'
+
+
+@pytest.mark.parametrize('server_url, api_version, token_type, token_ins, import_uri, full_import_uri,', [
+ ('https://galaxy.server.com/api/', 'v2', 'Token', GalaxyToken('my token'),
+ '1234',
+ 'https://galaxy.server.com/api/v2/collection-imports/1234/'),
+ ('https://galaxy.server.com/api/automation-hub/', 'v3', 'Bearer', KeycloakToken(auth_url='https://api.test/'),
+ '1234',
+ 'https://galaxy.server.com/api/automation-hub/v3/imports/collections/1234/'),
+])
+def test_wait_import_task_with_failure(server_url, api_version, token_type, token_ins, import_uri, full_import_uri, monkeypatch):
+ api = get_test_galaxy_api(server_url, api_version, token_ins=token_ins)
+
+ if token_ins:
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my token'
+ monkeypatch.setattr(token_ins, 'get', mock_token_get)
+
+ mock_open = MagicMock()
+ mock_open.side_effect = [
+ StringIO(to_text(json.dumps({
+ 'finished_at': 'some_time',
+ 'state': 'failed',
+ 'error': {
+ 'code': 'GW001',
+ 'description': u'Becäuse I said so!',
+
+ },
+ 'messages': [
+ {
+ 'level': 'error',
+ 'message': u'Somé error',
+ },
+ {
+ 'level': 'warning',
+ 'message': u'Some wärning',
+ },
+ {
+ 'level': 'info',
+ 'message': u'Somé info',
+ },
+ ],
+ }))),
+ ]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ mock_vvv = MagicMock()
+ monkeypatch.setattr(Display, 'vvv', mock_vvv)
+
+ mock_warn = MagicMock()
+ monkeypatch.setattr(Display, 'warning', mock_warn)
+
+ mock_err = MagicMock()
+ monkeypatch.setattr(Display, 'error', mock_err)
+
+ expected = to_native(u'Galaxy import process failed: Becäuse I said so! (Code: GW001)')
+ with pytest.raises(AnsibleError, match=re.escape(expected)):
+ api.wait_import_task(import_uri)
+
+ assert mock_open.call_count == 1
+ assert mock_open.mock_calls[0][1][0] == full_import_uri
+ assert mock_open.mock_calls[0][2]['headers']['Authorization'] == '%s my token' % token_type
+
+ assert mock_display.call_count == 1
+ assert mock_display.mock_calls[0][1][0] == 'Waiting until Galaxy import task %s has completed' % full_import_uri
+
+ assert mock_vvv.call_count == 1
+ assert mock_vvv.mock_calls[0][1][0] == u'Galaxy import message: info - Somé info'
+
+ assert mock_warn.call_count == 1
+ assert mock_warn.mock_calls[0][1][0] == u'Galaxy import warning message: Some wärning'
+
+ assert mock_err.call_count == 1
+ assert mock_err.mock_calls[0][1][0] == u'Galaxy import error message: Somé error'
+
+
+@pytest.mark.parametrize('server_url, api_version, token_type, token_ins, import_uri, full_import_uri', [
+ ('https://galaxy.server.com/api/', 'v2', 'Token', GalaxyToken('my_token'),
+ '1234',
+ 'https://galaxy.server.com/api/v2/collection-imports/1234/'),
+ ('https://galaxy.server.com/api/automation-hub/', 'v3', 'Bearer', KeycloakToken(auth_url='https://api.test/'),
+ '1234',
+ 'https://galaxy.server.com/api/automation-hub/v3/imports/collections/1234/'),
+])
+def test_wait_import_task_with_failure_no_error(server_url, api_version, token_type, token_ins, import_uri, full_import_uri, monkeypatch):
+ api = get_test_galaxy_api(server_url, api_version, token_ins=token_ins)
+
+ if token_ins:
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my token'
+ monkeypatch.setattr(token_ins, 'get', mock_token_get)
+
+ mock_open = MagicMock()
+ mock_open.side_effect = [
+ StringIO(to_text(json.dumps({
+ 'finished_at': 'some_time',
+ 'state': 'failed',
+ 'error': {},
+ 'messages': [
+ {
+ 'level': 'error',
+ 'message': u'Somé error',
+ },
+ {
+ 'level': 'warning',
+ 'message': u'Some wärning',
+ },
+ {
+ 'level': 'info',
+ 'message': u'Somé info',
+ },
+ ],
+ }))),
+ ]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ mock_vvv = MagicMock()
+ monkeypatch.setattr(Display, 'vvv', mock_vvv)
+
+ mock_warn = MagicMock()
+ monkeypatch.setattr(Display, 'warning', mock_warn)
+
+ mock_err = MagicMock()
+ monkeypatch.setattr(Display, 'error', mock_err)
+
+ expected = 'Galaxy import process failed: Unknown error, see %s for more details \\(Code: UNKNOWN\\)' % full_import_uri
+ with pytest.raises(AnsibleError, match=expected):
+ api.wait_import_task(import_uri)
+
+ assert mock_open.call_count == 1
+ assert mock_open.mock_calls[0][1][0] == full_import_uri
+ assert mock_open.mock_calls[0][2]['headers']['Authorization'] == '%s my token' % token_type
+
+ assert mock_display.call_count == 1
+ assert mock_display.mock_calls[0][1][0] == 'Waiting until Galaxy import task %s has completed' % full_import_uri
+
+ assert mock_vvv.call_count == 1
+ assert mock_vvv.mock_calls[0][1][0] == u'Galaxy import message: info - Somé info'
+
+ assert mock_warn.call_count == 1
+ assert mock_warn.mock_calls[0][1][0] == u'Galaxy import warning message: Some wärning'
+
+ assert mock_err.call_count == 1
+ assert mock_err.mock_calls[0][1][0] == u'Galaxy import error message: Somé error'
+
+
+@pytest.mark.parametrize('server_url, api_version, token_type, token_ins, import_uri, full_import_uri', [
+ ('https://galaxy.server.com/api', 'v2', 'Token', GalaxyToken('my token'),
+ '1234',
+ 'https://galaxy.server.com/api/v2/collection-imports/1234/'),
+ ('https://galaxy.server.com/api/automation-hub', 'v3', 'Bearer', KeycloakToken(auth_url='https://api.test/'),
+ '1234',
+ 'https://galaxy.server.com/api/automation-hub/v3/imports/collections/1234/'),
+])
+def test_wait_import_task_timeout(server_url, api_version, token_type, token_ins, import_uri, full_import_uri, monkeypatch):
+ api = get_test_galaxy_api(server_url, api_version, token_ins=token_ins)
+
+ if token_ins:
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my token'
+ monkeypatch.setattr(token_ins, 'get', mock_token_get)
+
+ def return_response(*args, **kwargs):
+ return StringIO(u'{"state":"waiting"}')
+
+ mock_open = MagicMock()
+ mock_open.side_effect = return_response
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ mock_vvv = MagicMock()
+ monkeypatch.setattr(Display, 'vvv', mock_vvv)
+
+ monkeypatch.setattr(time, 'sleep', MagicMock())
+
+ expected = "Timeout while waiting for the Galaxy import process to finish, check progress at '%s'" % full_import_uri
+ with pytest.raises(AnsibleError, match=expected):
+ api.wait_import_task(import_uri, 1)
+
+ assert mock_open.call_count > 1
+ assert mock_open.mock_calls[0][1][0] == full_import_uri
+ assert mock_open.mock_calls[0][2]['headers']['Authorization'] == '%s my token' % token_type
+ assert mock_open.mock_calls[1][1][0] == full_import_uri
+ assert mock_open.mock_calls[1][2]['headers']['Authorization'] == '%s my token' % token_type
+
+ assert mock_display.call_count == 1
+ assert mock_display.mock_calls[0][1][0] == 'Waiting until Galaxy import task %s has completed' % full_import_uri
+
+ # expected_wait_msg = 'Galaxy import process has a status of waiting, wait {0} seconds before trying again'
+ assert mock_vvv.call_count > 9 # 1st is opening Galaxy token file.
+
+ # FIXME:
+ # assert mock_vvv.mock_calls[1][1][0] == expected_wait_msg.format(2)
+ # assert mock_vvv.mock_calls[2][1][0] == expected_wait_msg.format(3)
+ # assert mock_vvv.mock_calls[3][1][0] == expected_wait_msg.format(4)
+ # assert mock_vvv.mock_calls[4][1][0] == expected_wait_msg.format(6)
+ # assert mock_vvv.mock_calls[5][1][0] == expected_wait_msg.format(10)
+ # assert mock_vvv.mock_calls[6][1][0] == expected_wait_msg.format(15)
+ # assert mock_vvv.mock_calls[7][1][0] == expected_wait_msg.format(22)
+ # assert mock_vvv.mock_calls[8][1][0] == expected_wait_msg.format(30)
+
+
+@pytest.mark.parametrize('api_version, token_type, version, token_ins', [
+ ('v2', None, 'v2.1.13', None),
+ ('v3', 'Bearer', 'v1.0.0', KeycloakToken(auth_url='https://api.test/api/automation-hub/')),
+])
+def test_get_collection_version_metadata_no_version(api_version, token_type, version, token_ins, monkeypatch):
+ api = get_test_galaxy_api('https://galaxy.server.com/api/', api_version, token_ins=token_ins)
+
+ if token_ins:
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my token'
+ monkeypatch.setattr(token_ins, 'get', mock_token_get)
+
+ mock_open = MagicMock()
+ mock_open.side_effect = [
+ StringIO(to_text(json.dumps({
+ 'download_url': 'https://downloadme.com',
+ 'artifact': {
+ 'sha256': 'ac47b6fac117d7c171812750dacda655b04533cf56b31080b82d1c0db3c9d80f',
+ },
+ 'namespace': {
+ 'name': 'namespace',
+ },
+ 'collection': {
+ 'name': 'collection',
+ },
+ 'version': version,
+ 'metadata': {
+ 'dependencies': {},
+ }
+ }))),
+ ]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ actual = api.get_collection_version_metadata('namespace', 'collection', version)
+
+ assert isinstance(actual, CollectionVersionMetadata)
+ assert actual.namespace == u'namespace'
+ assert actual.name == u'collection'
+ assert actual.download_url == u'https://downloadme.com'
+ assert actual.artifact_sha256 == u'ac47b6fac117d7c171812750dacda655b04533cf56b31080b82d1c0db3c9d80f'
+ assert actual.version == version
+ assert actual.dependencies == {}
+
+ assert mock_open.call_count == 1
+ assert mock_open.mock_calls[0][1][0] == '%s%s/collections/namespace/collection/versions/%s/' \
+ % (api.api_server, api_version, version)
+
+ # v2 calls dont need auth, so no authz header or token_type
+ if token_type:
+ assert mock_open.mock_calls[0][2]['headers']['Authorization'] == '%s my token' % token_type
+
+
+@pytest.mark.parametrize('api_version, token_type, token_ins, response', [
+ ('v2', None, None, {
+ 'count': 2,
+ 'next': None,
+ 'previous': None,
+ 'results': [
+ {
+ 'version': '1.0.0',
+ 'href': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/1.0.0',
+ },
+ {
+ 'version': '1.0.1',
+ 'href': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/1.0.1',
+ },
+ ],
+ }),
+ # TODO: Verify this once Automation Hub is actually out
+ ('v3', 'Bearer', KeycloakToken(auth_url='https://api.test/'), {
+ 'count': 2,
+ 'next': None,
+ 'previous': None,
+ 'data': [
+ {
+ 'version': '1.0.0',
+ 'href': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/1.0.0',
+ },
+ {
+ 'version': '1.0.1',
+ 'href': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/1.0.1',
+ },
+ ],
+ }),
+])
+def test_get_collection_versions(api_version, token_type, token_ins, response, monkeypatch):
+ api = get_test_galaxy_api('https://galaxy.server.com/api/', api_version, token_ins=token_ins)
+
+ if token_ins:
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my token'
+ monkeypatch.setattr(token_ins, 'get', mock_token_get)
+
+ mock_open = MagicMock()
+ mock_open.side_effect = [
+ StringIO(to_text(json.dumps(response))),
+ ]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ actual = api.get_collection_versions('namespace', 'collection')
+ assert actual == [u'1.0.0', u'1.0.1']
+
+ assert mock_open.call_count == 1
+ assert mock_open.mock_calls[0][1][0] == 'https://galaxy.server.com/api/%s/collections/namespace/collection/' \
+ 'versions/' % api_version
+ if token_ins:
+ assert mock_open.mock_calls[0][2]['headers']['Authorization'] == '%s my token' % token_type
+
+
+@pytest.mark.parametrize('api_version, token_type, token_ins, responses', [
+ ('v2', None, None, [
+ {
+ 'count': 6,
+ 'next': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/?page=2',
+ 'previous': None,
+ 'results': [
+ {
+ 'version': '1.0.0',
+ 'href': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/1.0.0',
+ },
+ {
+ 'version': '1.0.1',
+ 'href': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/1.0.1',
+ },
+ ],
+ },
+ {
+ 'count': 6,
+ 'next': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/?page=3',
+ 'previous': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions',
+ 'results': [
+ {
+ 'version': '1.0.2',
+ 'href': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/1.0.2',
+ },
+ {
+ 'version': '1.0.3',
+ 'href': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/1.0.3',
+ },
+ ],
+ },
+ {
+ 'count': 6,
+ 'next': None,
+ 'previous': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/?page=2',
+ 'results': [
+ {
+ 'version': '1.0.4',
+ 'href': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/1.0.4',
+ },
+ {
+ 'version': '1.0.5',
+ 'href': 'https://galaxy.server.com/api/v2/collections/namespace/collection/versions/1.0.5',
+ },
+ ],
+ },
+ ]),
+ ('v3', 'Bearer', KeycloakToken(auth_url='https://api.test/'), [
+ {
+ 'count': 6,
+ 'links': {
+ 'next': '/api/v3/collections/namespace/collection/versions/?page=2',
+ 'previous': None,
+ },
+ 'data': [
+ {
+ 'version': '1.0.0',
+ 'href': '/api/v3/collections/namespace/collection/versions/1.0.0',
+ },
+ {
+ 'version': '1.0.1',
+ 'href': '/api/v3/collections/namespace/collection/versions/1.0.1',
+ },
+ ],
+ },
+ {
+ 'count': 6,
+ 'links': {
+ 'next': '/api/v3/collections/namespace/collection/versions/?page=3',
+ 'previous': '/api/v3/collections/namespace/collection/versions',
+ },
+ 'data': [
+ {
+ 'version': '1.0.2',
+ 'href': '/api/v3/collections/namespace/collection/versions/1.0.2',
+ },
+ {
+ 'version': '1.0.3',
+ 'href': '/api/v3/collections/namespace/collection/versions/1.0.3',
+ },
+ ],
+ },
+ {
+ 'count': 6,
+ 'links': {
+ 'next': None,
+ 'previous': '/api/v3/collections/namespace/collection/versions/?page=2',
+ },
+ 'data': [
+ {
+ 'version': '1.0.4',
+ 'href': '/api/v3/collections/namespace/collection/versions/1.0.4',
+ },
+ {
+ 'version': '1.0.5',
+ 'href': '/api/v3/collections/namespace/collection/versions/1.0.5',
+ },
+ ],
+ },
+ ]),
+])
+def test_get_collection_versions_pagination(api_version, token_type, token_ins, responses, monkeypatch):
+ api = get_test_galaxy_api('https://galaxy.server.com/api/', api_version, token_ins=token_ins)
+
+ if token_ins:
+ mock_token_get = MagicMock()
+ mock_token_get.return_value = 'my token'
+ monkeypatch.setattr(token_ins, 'get', mock_token_get)
+
+ mock_open = MagicMock()
+ mock_open.side_effect = [StringIO(to_text(json.dumps(r))) for r in responses]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ actual = api.get_collection_versions('namespace', 'collection')
+ assert actual == [u'1.0.0', u'1.0.1', u'1.0.2', u'1.0.3', u'1.0.4', u'1.0.5']
+
+ assert mock_open.call_count == 3
+ assert mock_open.mock_calls[0][1][0] == 'https://galaxy.server.com/api/%s/collections/namespace/collection/' \
+ 'versions/' % api_version
+ assert mock_open.mock_calls[1][1][0] == 'https://galaxy.server.com/api/%s/collections/namespace/collection/' \
+ 'versions/?page=2' % api_version
+ assert mock_open.mock_calls[2][1][0] == 'https://galaxy.server.com/api/%s/collections/namespace/collection/' \
+ 'versions/?page=3' % api_version
+
+ if token_type:
+ assert mock_open.mock_calls[0][2]['headers']['Authorization'] == '%s my token' % token_type
+ assert mock_open.mock_calls[1][2]['headers']['Authorization'] == '%s my token' % token_type
+ assert mock_open.mock_calls[2][2]['headers']['Authorization'] == '%s my token' % token_type
+
+
+@pytest.mark.parametrize('responses', [
+ [
+ {
+ 'count': 2,
+ 'results': [{'name': '3.5.1', }, {'name': '3.5.2'}],
+ 'next_link': None,
+ 'next': None,
+ 'previous_link': None,
+ 'previous': None
+ },
+ ],
+ [
+ {
+ 'count': 2,
+ 'results': [{'name': '3.5.1'}],
+ 'next_link': '/api/v1/roles/432/versions/?page=2&page_size=50',
+ 'next': '/roles/432/versions/?page=2&page_size=50',
+ 'previous_link': None,
+ 'previous': None
+ },
+ {
+ 'count': 2,
+ 'results': [{'name': '3.5.2'}],
+ 'next_link': None,
+ 'next': None,
+ 'previous_link': '/api/v1/roles/432/versions/?&page_size=50',
+ 'previous': '/roles/432/versions/?page_size=50',
+ },
+ ]
+])
+def test_get_role_versions_pagination(monkeypatch, responses):
+ api = get_test_galaxy_api('https://galaxy.com/api/', 'v1')
+
+ mock_open = MagicMock()
+ mock_open.side_effect = [StringIO(to_text(json.dumps(r))) for r in responses]
+ monkeypatch.setattr(galaxy_api, 'open_url', mock_open)
+
+ actual = api.fetch_role_related('versions', 432)
+ assert actual == [{'name': '3.5.1'}, {'name': '3.5.2'}]
+
+ assert mock_open.call_count == len(responses)
+
+ assert mock_open.mock_calls[0][1][0] == 'https://galaxy.com/api/v1/roles/432/versions/?page_size=50'
+ if len(responses) == 2:
+ assert mock_open.mock_calls[1][1][0] == 'https://galaxy.com/api/v1/roles/432/versions/?page=2&page_size=50'
diff --git a/test/units/galaxy/test_collection.py b/test/units/galaxy/test_collection.py
new file mode 100644
index 00000000..fda6fe69
--- /dev/null
+++ b/test/units/galaxy/test_collection.py
@@ -0,0 +1,1326 @@
+# -*- coding: utf-8 -*-
+# Copyright: (c) 2019, Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import json
+import os
+import pytest
+import re
+import tarfile
+import uuid
+
+from hashlib import sha256
+from io import BytesIO
+from units.compat.mock import MagicMock, mock_open, patch
+
+from ansible import context
+from ansible.cli.galaxy import GalaxyCLI
+from ansible.errors import AnsibleError
+from ansible.galaxy import api, collection, token
+from ansible.module_utils._text import to_bytes, to_native, to_text
+from ansible.module_utils.six.moves import builtins
+from ansible.utils import context_objects as co
+from ansible.utils.display import Display
+from ansible.utils.hashing import secure_hash_s
+
+
+@pytest.fixture(autouse='function')
+def reset_cli_args():
+ co.GlobalCLIArgs._Singleton__instance = None
+ yield
+ co.GlobalCLIArgs._Singleton__instance = None
+
+
+@pytest.fixture()
+def collection_input(tmp_path_factory):
+ ''' Creates a collection skeleton directory for build tests '''
+ test_dir = to_text(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections Input'))
+ namespace = 'ansible_namespace'
+ collection = 'collection'
+ skeleton = os.path.join(os.path.dirname(os.path.split(__file__)[0]), 'cli', 'test_data', 'collection_skeleton')
+
+ galaxy_args = ['ansible-galaxy', 'collection', 'init', '%s.%s' % (namespace, collection),
+ '-c', '--init-path', test_dir, '--collection-skeleton', skeleton]
+ GalaxyCLI(args=galaxy_args).run()
+ collection_dir = os.path.join(test_dir, namespace, collection)
+ output_dir = to_text(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections Output'))
+
+ return collection_dir, output_dir
+
+
+@pytest.fixture()
+def collection_artifact(monkeypatch, tmp_path_factory):
+ ''' Creates a temp collection artifact and mocked open_url instance for publishing tests '''
+ mock_open = MagicMock()
+ monkeypatch.setattr(collection, 'open_url', mock_open)
+
+ mock_uuid = MagicMock()
+ mock_uuid.return_value.hex = 'uuid'
+ monkeypatch.setattr(uuid, 'uuid4', mock_uuid)
+
+ tmp_path = tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections')
+ input_file = to_text(tmp_path / 'collection.tar.gz')
+
+ with tarfile.open(input_file, 'w:gz') as tfile:
+ b_io = BytesIO(b"\x00\x01\x02\x03")
+ tar_info = tarfile.TarInfo('test')
+ tar_info.size = 4
+ tar_info.mode = 0o0644
+ tfile.addfile(tarinfo=tar_info, fileobj=b_io)
+
+ return input_file, mock_open
+
+
+@pytest.fixture()
+def galaxy_yml(request, tmp_path_factory):
+ b_test_dir = to_bytes(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections'))
+ b_galaxy_yml = os.path.join(b_test_dir, b'galaxy.yml')
+ with open(b_galaxy_yml, 'wb') as galaxy_obj:
+ galaxy_obj.write(to_bytes(request.param))
+
+ yield b_galaxy_yml
+
+
+@pytest.fixture()
+def tmp_tarfile(tmp_path_factory, manifest_info):
+ ''' Creates a temporary tar file for _extract_tar_file tests '''
+ filename = u'ÅÑŚÌβŁÈ'
+ temp_dir = to_bytes(tmp_path_factory.mktemp('test-%s Collections' % to_native(filename)))
+ tar_file = os.path.join(temp_dir, to_bytes('%s.tar.gz' % filename))
+ data = os.urandom(8)
+
+ with tarfile.open(tar_file, 'w:gz') as tfile:
+ b_io = BytesIO(data)
+ tar_info = tarfile.TarInfo(filename)
+ tar_info.size = len(data)
+ tar_info.mode = 0o0644
+ tfile.addfile(tarinfo=tar_info, fileobj=b_io)
+
+ b_data = to_bytes(json.dumps(manifest_info, indent=True), errors='surrogate_or_strict')
+ b_io = BytesIO(b_data)
+ tar_info = tarfile.TarInfo('MANIFEST.json')
+ tar_info.size = len(b_data)
+ tar_info.mode = 0o0644
+ tfile.addfile(tarinfo=tar_info, fileobj=b_io)
+
+ sha256_hash = sha256()
+ sha256_hash.update(data)
+
+ with tarfile.open(tar_file, 'r') as tfile:
+ yield temp_dir, tfile, filename, sha256_hash.hexdigest()
+
+
+@pytest.fixture()
+def galaxy_server():
+ context.CLIARGS._store = {'ignore_certs': False}
+ galaxy_api = api.GalaxyAPI(None, 'test_server', 'https://galaxy.ansible.com',
+ token=token.GalaxyToken(token='key'))
+ return galaxy_api
+
+
+@pytest.fixture()
+def manifest_template():
+ def get_manifest_info(namespace='ansible_namespace', name='collection', version='0.1.0'):
+ return {
+ "collection_info": {
+ "namespace": namespace,
+ "name": name,
+ "version": version,
+ "authors": [
+ "shertel"
+ ],
+ "readme": "README.md",
+ "tags": [
+ "test",
+ "collection"
+ ],
+ "description": "Test",
+ "license": [
+ "MIT"
+ ],
+ "license_file": None,
+ "dependencies": {},
+ "repository": "https://github.com/{0}/{1}".format(namespace, name),
+ "documentation": None,
+ "homepage": None,
+ "issues": None
+ },
+ "file_manifest_file": {
+ "name": "FILES.json",
+ "ftype": "file",
+ "chksum_type": "sha256",
+ "chksum_sha256": "files_manifest_checksum",
+ "format": 1
+ },
+ "format": 1
+ }
+
+ return get_manifest_info
+
+
+@pytest.fixture()
+def manifest_info(manifest_template):
+ return manifest_template()
+
+
+@pytest.fixture()
+def files_manifest_info():
+ return {
+ "files": [
+ {
+ "name": ".",
+ "ftype": "dir",
+ "chksum_type": None,
+ "chksum_sha256": None,
+ "format": 1
+ },
+ {
+ "name": "README.md",
+ "ftype": "file",
+ "chksum_type": "sha256",
+ "chksum_sha256": "individual_file_checksum",
+ "format": 1
+ }
+ ],
+ "format": 1}
+
+
+@pytest.fixture()
+def manifest(manifest_info):
+ b_data = to_bytes(json.dumps(manifest_info))
+
+ with patch.object(builtins, 'open', mock_open(read_data=b_data)) as m:
+ with open('MANIFEST.json', mode='rb') as fake_file:
+ yield fake_file, sha256(b_data).hexdigest()
+
+
+@pytest.fixture()
+def mock_collection(galaxy_server):
+ def create_mock_collection(namespace='ansible_namespace', name='collection', version='0.1.0', local=True, local_installed=True):
+ b_path = None
+ force = False
+
+ if local:
+ mock_collection = collection.CollectionRequirement(namespace, name, b_path, galaxy_server, [version], version, force, skip=local_installed)
+ else:
+ download_url = 'https://galaxy.ansible.com/download/{0}-{1}-{2}.tar.gz'.format(namespace, name, version)
+ digest = '19415a6a6df831df61cffde4a09d1d89ac8d8ca5c0586e85bea0b106d6dff29a'
+ dependencies = {}
+ metadata = api.CollectionVersionMetadata(namespace, name, version, download_url, digest, dependencies)
+ mock_collection = collection.CollectionRequirement(namespace, name, b_path, galaxy_server, [version], version, force, metadata=metadata)
+
+ return mock_collection
+ return create_mock_collection
+
+
+def test_build_collection_no_galaxy_yaml():
+ fake_path = u'/fake/ÅÑŚÌβŁÈ/path'
+ expected = to_native("The collection galaxy.yml path '%s/galaxy.yml' does not exist." % fake_path)
+
+ with pytest.raises(AnsibleError, match=expected):
+ collection.build_collection(fake_path, 'output', False)
+
+
+def test_build_existing_output_file(collection_input):
+ input_dir, output_dir = collection_input
+
+ existing_output_dir = os.path.join(output_dir, 'ansible_namespace-collection-0.1.0.tar.gz')
+ os.makedirs(existing_output_dir)
+
+ expected = "The output collection artifact '%s' already exists, but is a directory - aborting" \
+ % to_native(existing_output_dir)
+ with pytest.raises(AnsibleError, match=expected):
+ collection.build_collection(input_dir, output_dir, False)
+
+
+def test_build_existing_output_without_force(collection_input):
+ input_dir, output_dir = collection_input
+
+ existing_output = os.path.join(output_dir, 'ansible_namespace-collection-0.1.0.tar.gz')
+ with open(existing_output, 'w+') as out_file:
+ out_file.write("random garbage")
+ out_file.flush()
+
+ expected = "The file '%s' already exists. You can use --force to re-create the collection artifact." \
+ % to_native(existing_output)
+ with pytest.raises(AnsibleError, match=expected):
+ collection.build_collection(input_dir, output_dir, False)
+
+
+def test_build_existing_output_with_force(collection_input):
+ input_dir, output_dir = collection_input
+
+ existing_output = os.path.join(output_dir, 'ansible_namespace-collection-0.1.0.tar.gz')
+ with open(existing_output, 'w+') as out_file:
+ out_file.write("random garbage")
+ out_file.flush()
+
+ collection.build_collection(input_dir, output_dir, True)
+
+ # Verify the file was replaced with an actual tar file
+ assert tarfile.is_tarfile(existing_output)
+
+
+@pytest.mark.parametrize('galaxy_yml', [b'namespace: value: broken'], indirect=True)
+def test_invalid_yaml_galaxy_file(galaxy_yml):
+ expected = to_native(b"Failed to parse the galaxy.yml at '%s' with the following error:" % galaxy_yml)
+
+ with pytest.raises(AnsibleError, match=expected):
+ collection._get_galaxy_yml(galaxy_yml)
+
+
+@pytest.mark.parametrize('galaxy_yml', [b'namespace: test_namespace'], indirect=True)
+def test_missing_required_galaxy_key(galaxy_yml):
+ expected = "The collection galaxy.yml at '%s' is missing the following mandatory keys: authors, name, " \
+ "readme, version" % to_native(galaxy_yml)
+
+ with pytest.raises(AnsibleError, match=expected):
+ collection._get_galaxy_yml(galaxy_yml)
+
+
+@pytest.mark.parametrize('galaxy_yml', [b"""
+namespace: namespace
+name: collection
+authors: Jordan
+version: 0.1.0
+readme: README.md
+invalid: value"""], indirect=True)
+def test_warning_extra_keys(galaxy_yml, monkeypatch):
+ display_mock = MagicMock()
+ monkeypatch.setattr(Display, 'warning', display_mock)
+
+ collection._get_galaxy_yml(galaxy_yml)
+
+ assert display_mock.call_count == 1
+ assert display_mock.call_args[0][0] == "Found unknown keys in collection galaxy.yml at '%s': invalid"\
+ % to_text(galaxy_yml)
+
+
+@pytest.mark.parametrize('galaxy_yml', [b"""
+namespace: namespace
+name: collection
+authors: Jordan
+version: 0.1.0
+readme: README.md"""], indirect=True)
+def test_defaults_galaxy_yml(galaxy_yml):
+ actual = collection._get_galaxy_yml(galaxy_yml)
+
+ assert actual['namespace'] == 'namespace'
+ assert actual['name'] == 'collection'
+ assert actual['authors'] == ['Jordan']
+ assert actual['version'] == '0.1.0'
+ assert actual['readme'] == 'README.md'
+ assert actual['description'] is None
+ assert actual['repository'] is None
+ assert actual['documentation'] is None
+ assert actual['homepage'] is None
+ assert actual['issues'] is None
+ assert actual['tags'] == []
+ assert actual['dependencies'] == {}
+ assert actual['license_ids'] == []
+
+
+@pytest.mark.parametrize('galaxy_yml', [(b"""
+namespace: namespace
+name: collection
+authors: Jordan
+version: 0.1.0
+readme: README.md
+license: MIT"""), (b"""
+namespace: namespace
+name: collection
+authors: Jordan
+version: 0.1.0
+readme: README.md
+license:
+- MIT""")], indirect=True)
+def test_galaxy_yml_list_value(galaxy_yml):
+ actual = collection._get_galaxy_yml(galaxy_yml)
+ assert actual['license_ids'] == ['MIT']
+
+
+def test_build_ignore_files_and_folders(collection_input, monkeypatch):
+ input_dir = collection_input[0]
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'vvv', mock_display)
+
+ git_folder = os.path.join(input_dir, '.git')
+ retry_file = os.path.join(input_dir, 'ansible.retry')
+
+ tests_folder = os.path.join(input_dir, 'tests', 'output')
+ tests_output_file = os.path.join(tests_folder, 'result.txt')
+
+ os.makedirs(git_folder)
+ os.makedirs(tests_folder)
+
+ with open(retry_file, 'w+') as ignore_file:
+ ignore_file.write('random')
+ ignore_file.flush()
+
+ with open(tests_output_file, 'w+') as tests_file:
+ tests_file.write('random')
+ tests_file.flush()
+
+ actual = collection._build_files_manifest(to_bytes(input_dir), 'namespace', 'collection', [])
+
+ assert actual['format'] == 1
+ for manifest_entry in actual['files']:
+ assert manifest_entry['name'] not in ['.git', 'ansible.retry', 'galaxy.yml', 'tests/output', 'tests/output/result.txt']
+
+ expected_msgs = [
+ "Skipping '%s/galaxy.yml' for collection build" % to_text(input_dir),
+ "Skipping '%s' for collection build" % to_text(retry_file),
+ "Skipping '%s' for collection build" % to_text(git_folder),
+ "Skipping '%s' for collection build" % to_text(tests_folder),
+ ]
+ assert mock_display.call_count == 4
+ assert mock_display.mock_calls[0][1][0] in expected_msgs
+ assert mock_display.mock_calls[1][1][0] in expected_msgs
+ assert mock_display.mock_calls[2][1][0] in expected_msgs
+ assert mock_display.mock_calls[3][1][0] in expected_msgs
+
+
+def test_build_ignore_older_release_in_root(collection_input, monkeypatch):
+ input_dir = collection_input[0]
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'vvv', mock_display)
+
+ # This is expected to be ignored because it is in the root collection dir.
+ release_file = os.path.join(input_dir, 'namespace-collection-0.0.0.tar.gz')
+
+ # This is not expected to be ignored because it is not in the root collection dir.
+ fake_release_file = os.path.join(input_dir, 'plugins', 'namespace-collection-0.0.0.tar.gz')
+
+ for filename in [release_file, fake_release_file]:
+ with open(filename, 'w+') as file_obj:
+ file_obj.write('random')
+ file_obj.flush()
+
+ actual = collection._build_files_manifest(to_bytes(input_dir), 'namespace', 'collection', [])
+ assert actual['format'] == 1
+
+ plugin_release_found = False
+ for manifest_entry in actual['files']:
+ assert manifest_entry['name'] != 'namespace-collection-0.0.0.tar.gz'
+ if manifest_entry['name'] == 'plugins/namespace-collection-0.0.0.tar.gz':
+ plugin_release_found = True
+
+ assert plugin_release_found
+
+ expected_msgs = [
+ "Skipping '%s/galaxy.yml' for collection build" % to_text(input_dir),
+ "Skipping '%s' for collection build" % to_text(release_file)
+ ]
+ assert mock_display.call_count == 2
+ assert mock_display.mock_calls[0][1][0] in expected_msgs
+ assert mock_display.mock_calls[1][1][0] in expected_msgs
+
+
+def test_build_ignore_patterns(collection_input, monkeypatch):
+ input_dir = collection_input[0]
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'vvv', mock_display)
+
+ actual = collection._build_files_manifest(to_bytes(input_dir), 'namespace', 'collection',
+ ['*.md', 'plugins/action', 'playbooks/*.j2'])
+ assert actual['format'] == 1
+
+ expected_missing = [
+ 'README.md',
+ 'docs/My Collection.md',
+ 'plugins/action',
+ 'playbooks/templates/test.conf.j2',
+ 'playbooks/templates/subfolder/test.conf.j2',
+ ]
+
+ # Files or dirs that are close to a match but are not, make sure they are present
+ expected_present = [
+ 'docs',
+ 'roles/common/templates/test.conf.j2',
+ 'roles/common/templates/subfolder/test.conf.j2',
+ ]
+
+ actual_files = [e['name'] for e in actual['files']]
+ for m in expected_missing:
+ assert m not in actual_files
+
+ for p in expected_present:
+ assert p in actual_files
+
+ expected_msgs = [
+ "Skipping '%s/galaxy.yml' for collection build" % to_text(input_dir),
+ "Skipping '%s/README.md' for collection build" % to_text(input_dir),
+ "Skipping '%s/docs/My Collection.md' for collection build" % to_text(input_dir),
+ "Skipping '%s/plugins/action' for collection build" % to_text(input_dir),
+ "Skipping '%s/playbooks/templates/test.conf.j2' for collection build" % to_text(input_dir),
+ "Skipping '%s/playbooks/templates/subfolder/test.conf.j2' for collection build" % to_text(input_dir),
+ ]
+ assert mock_display.call_count == len(expected_msgs)
+ assert mock_display.mock_calls[0][1][0] in expected_msgs
+ assert mock_display.mock_calls[1][1][0] in expected_msgs
+ assert mock_display.mock_calls[2][1][0] in expected_msgs
+ assert mock_display.mock_calls[3][1][0] in expected_msgs
+ assert mock_display.mock_calls[4][1][0] in expected_msgs
+ assert mock_display.mock_calls[5][1][0] in expected_msgs
+
+
+def test_build_ignore_symlink_target_outside_collection(collection_input, monkeypatch):
+ input_dir, outside_dir = collection_input
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'warning', mock_display)
+
+ link_path = os.path.join(input_dir, 'plugins', 'connection')
+ os.symlink(outside_dir, link_path)
+
+ actual = collection._build_files_manifest(to_bytes(input_dir), 'namespace', 'collection', [])
+ for manifest_entry in actual['files']:
+ assert manifest_entry['name'] != 'plugins/connection'
+
+ assert mock_display.call_count == 1
+ assert mock_display.mock_calls[0][1][0] == "Skipping '%s' as it is a symbolic link to a directory outside " \
+ "the collection" % to_text(link_path)
+
+
+def test_build_copy_symlink_target_inside_collection(collection_input):
+ input_dir = collection_input[0]
+
+ os.makedirs(os.path.join(input_dir, 'playbooks', 'roles'))
+ roles_link = os.path.join(input_dir, 'playbooks', 'roles', 'linked')
+
+ roles_target = os.path.join(input_dir, 'roles', 'linked')
+ roles_target_tasks = os.path.join(roles_target, 'tasks')
+ os.makedirs(roles_target_tasks)
+ with open(os.path.join(roles_target_tasks, 'main.yml'), 'w+') as tasks_main:
+ tasks_main.write("---\n- hosts: localhost\n tasks:\n - ping:")
+ tasks_main.flush()
+
+ os.symlink(roles_target, roles_link)
+
+ actual = collection._build_files_manifest(to_bytes(input_dir), 'namespace', 'collection', [])
+
+ linked_entries = [e for e in actual['files'] if e['name'].startswith('playbooks/roles/linked')]
+ assert len(linked_entries) == 1
+ assert linked_entries[0]['name'] == 'playbooks/roles/linked'
+ assert linked_entries[0]['ftype'] == 'dir'
+
+
+def test_build_with_symlink_inside_collection(collection_input):
+ input_dir, output_dir = collection_input
+
+ os.makedirs(os.path.join(input_dir, 'playbooks', 'roles'))
+ roles_link = os.path.join(input_dir, 'playbooks', 'roles', 'linked')
+ file_link = os.path.join(input_dir, 'docs', 'README.md')
+
+ roles_target = os.path.join(input_dir, 'roles', 'linked')
+ roles_target_tasks = os.path.join(roles_target, 'tasks')
+ os.makedirs(roles_target_tasks)
+ with open(os.path.join(roles_target_tasks, 'main.yml'), 'w+') as tasks_main:
+ tasks_main.write("---\n- hosts: localhost\n tasks:\n - ping:")
+ tasks_main.flush()
+
+ os.symlink(roles_target, roles_link)
+ os.symlink(os.path.join(input_dir, 'README.md'), file_link)
+
+ collection.build_collection(input_dir, output_dir, False)
+
+ output_artifact = os.path.join(output_dir, 'ansible_namespace-collection-0.1.0.tar.gz')
+ assert tarfile.is_tarfile(output_artifact)
+
+ with tarfile.open(output_artifact, mode='r') as actual:
+ members = actual.getmembers()
+
+ linked_folder = next(m for m in members if m.path == 'playbooks/roles/linked')
+ assert linked_folder.type == tarfile.SYMTYPE
+ assert linked_folder.linkname == '../../roles/linked'
+
+ linked_file = next(m for m in members if m.path == 'docs/README.md')
+ assert linked_file.type == tarfile.SYMTYPE
+ assert linked_file.linkname == '../README.md'
+
+ linked_file_obj = actual.extractfile(linked_file.name)
+ actual_file = secure_hash_s(linked_file_obj.read())
+ linked_file_obj.close()
+
+ assert actual_file == '63444bfc766154e1bc7557ef6280de20d03fcd81'
+
+
+def test_publish_no_wait(galaxy_server, collection_artifact, monkeypatch):
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ artifact_path, mock_open = collection_artifact
+ fake_import_uri = 'https://galaxy.server.com/api/v2/import/1234'
+
+ mock_publish = MagicMock()
+ mock_publish.return_value = fake_import_uri
+ monkeypatch.setattr(galaxy_server, 'publish_collection', mock_publish)
+
+ collection.publish_collection(artifact_path, galaxy_server, False, 0)
+
+ assert mock_publish.call_count == 1
+ assert mock_publish.mock_calls[0][1][0] == artifact_path
+
+ assert mock_display.call_count == 1
+ assert mock_display.mock_calls[0][1][0] == \
+ "Collection has been pushed to the Galaxy server %s %s, not waiting until import has completed due to " \
+ "--no-wait being set. Import task results can be found at %s" % (galaxy_server.name, galaxy_server.api_server,
+ fake_import_uri)
+
+
+def test_publish_with_wait(galaxy_server, collection_artifact, monkeypatch):
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ artifact_path, mock_open = collection_artifact
+ fake_import_uri = 'https://galaxy.server.com/api/v2/import/1234'
+
+ mock_publish = MagicMock()
+ mock_publish.return_value = fake_import_uri
+ monkeypatch.setattr(galaxy_server, 'publish_collection', mock_publish)
+
+ mock_wait = MagicMock()
+ monkeypatch.setattr(galaxy_server, 'wait_import_task', mock_wait)
+
+ collection.publish_collection(artifact_path, galaxy_server, True, 0)
+
+ assert mock_publish.call_count == 1
+ assert mock_publish.mock_calls[0][1][0] == artifact_path
+
+ assert mock_wait.call_count == 1
+ assert mock_wait.mock_calls[0][1][0] == '1234'
+
+ assert mock_display.mock_calls[0][1][0] == "Collection has been published to the Galaxy server test_server %s" \
+ % galaxy_server.api_server
+
+
+def test_find_existing_collections(tmp_path_factory, monkeypatch):
+ test_dir = to_text(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections'))
+ collection1 = os.path.join(test_dir, 'namespace1', 'collection1')
+ collection2 = os.path.join(test_dir, 'namespace2', 'collection2')
+ fake_collection1 = os.path.join(test_dir, 'namespace3', 'collection3')
+ fake_collection2 = os.path.join(test_dir, 'namespace4')
+ os.makedirs(collection1)
+ os.makedirs(collection2)
+ os.makedirs(os.path.split(fake_collection1)[0])
+
+ open(fake_collection1, 'wb+').close()
+ open(fake_collection2, 'wb+').close()
+
+ collection1_manifest = json.dumps({
+ 'collection_info': {
+ 'namespace': 'namespace1',
+ 'name': 'collection1',
+ 'version': '1.2.3',
+ 'authors': ['Jordan Borean'],
+ 'readme': 'README.md',
+ 'dependencies': {},
+ },
+ 'format': 1,
+ })
+ with open(os.path.join(collection1, 'MANIFEST.json'), 'wb') as manifest_obj:
+ manifest_obj.write(to_bytes(collection1_manifest))
+
+ mock_warning = MagicMock()
+ monkeypatch.setattr(Display, 'warning', mock_warning)
+
+ actual = collection.find_existing_collections(test_dir)
+
+ assert len(actual) == 2
+ for actual_collection in actual:
+ assert actual_collection.skip is True
+
+ if str(actual_collection) == 'namespace1.collection1':
+ assert actual_collection.namespace == 'namespace1'
+ assert actual_collection.name == 'collection1'
+ assert actual_collection.b_path == to_bytes(collection1)
+ assert actual_collection.api is None
+ assert actual_collection.versions == set(['1.2.3'])
+ assert actual_collection.latest_version == '1.2.3'
+ assert actual_collection.dependencies == {}
+ else:
+ assert actual_collection.namespace == 'namespace2'
+ assert actual_collection.name == 'collection2'
+ assert actual_collection.b_path == to_bytes(collection2)
+ assert actual_collection.api is None
+ assert actual_collection.versions == set(['*'])
+ assert actual_collection.latest_version == '*'
+ assert actual_collection.dependencies == {}
+
+ assert mock_warning.call_count == 1
+ assert mock_warning.mock_calls[0][1][0] == "Collection at '%s' does not have a MANIFEST.json file, cannot " \
+ "detect version." % to_text(collection2)
+
+
+def test_download_file(tmp_path_factory, monkeypatch):
+ temp_dir = to_bytes(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections'))
+
+ data = b"\x00\x01\x02\x03"
+ sha256_hash = sha256()
+ sha256_hash.update(data)
+
+ mock_open = MagicMock()
+ mock_open.return_value = BytesIO(data)
+ monkeypatch.setattr(collection, 'open_url', mock_open)
+
+ expected = os.path.join(temp_dir, b'file')
+ actual = collection._download_file('http://google.com/file', temp_dir, sha256_hash.hexdigest(), True)
+
+ assert actual.startswith(expected)
+ assert os.path.isfile(actual)
+ with open(actual, 'rb') as file_obj:
+ assert file_obj.read() == data
+
+ assert mock_open.call_count == 1
+ assert mock_open.mock_calls[0][1][0] == 'http://google.com/file'
+
+
+def test_download_file_hash_mismatch(tmp_path_factory, monkeypatch):
+ temp_dir = to_bytes(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections'))
+
+ data = b"\x00\x01\x02\x03"
+
+ mock_open = MagicMock()
+ mock_open.return_value = BytesIO(data)
+ monkeypatch.setattr(collection, 'open_url', mock_open)
+
+ expected = "Mismatch artifact hash with downloaded file"
+ with pytest.raises(AnsibleError, match=expected):
+ collection._download_file('http://google.com/file', temp_dir, 'bad', True)
+
+
+def test_extract_tar_file_invalid_hash(tmp_tarfile):
+ temp_dir, tfile, filename, dummy = tmp_tarfile
+
+ expected = "Checksum mismatch for '%s' inside collection at '%s'" % (to_native(filename), to_native(tfile.name))
+ with pytest.raises(AnsibleError, match=expected):
+ collection._extract_tar_file(tfile, filename, temp_dir, temp_dir, "fakehash")
+
+
+def test_extract_tar_file_missing_member(tmp_tarfile):
+ temp_dir, tfile, dummy, dummy = tmp_tarfile
+
+ expected = "Collection tar at '%s' does not contain the expected file 'missing'." % to_native(tfile.name)
+ with pytest.raises(AnsibleError, match=expected):
+ collection._extract_tar_file(tfile, 'missing', temp_dir, temp_dir)
+
+
+def test_extract_tar_file_missing_parent_dir(tmp_tarfile):
+ temp_dir, tfile, filename, checksum = tmp_tarfile
+ output_dir = os.path.join(temp_dir, b'output')
+ output_file = os.path.join(output_dir, to_bytes(filename))
+
+ collection._extract_tar_file(tfile, filename, output_dir, temp_dir, checksum)
+ os.path.isfile(output_file)
+
+
+def test_extract_tar_file_outside_dir(tmp_path_factory):
+ filename = u'ÅÑŚÌβŁÈ'
+ temp_dir = to_bytes(tmp_path_factory.mktemp('test-%s Collections' % to_native(filename)))
+ tar_file = os.path.join(temp_dir, to_bytes('%s.tar.gz' % filename))
+ data = os.urandom(8)
+
+ tar_filename = '../%s.sh' % filename
+ with tarfile.open(tar_file, 'w:gz') as tfile:
+ b_io = BytesIO(data)
+ tar_info = tarfile.TarInfo(tar_filename)
+ tar_info.size = len(data)
+ tar_info.mode = 0o0644
+ tfile.addfile(tarinfo=tar_info, fileobj=b_io)
+
+ expected = re.escape("Cannot extract tar entry '%s' as it will be placed outside the collection directory"
+ % to_native(tar_filename))
+ with tarfile.open(tar_file, 'r') as tfile:
+ with pytest.raises(AnsibleError, match=expected):
+ collection._extract_tar_file(tfile, tar_filename, os.path.join(temp_dir, to_bytes(filename)), temp_dir)
+
+
+def test_require_one_of_collections_requirements_with_both():
+ cli = GalaxyCLI(args=['ansible-galaxy', 'collection', 'verify', 'namespace.collection', '-r', 'requirements.yml'])
+
+ with pytest.raises(AnsibleError) as req_err:
+ cli._require_one_of_collections_requirements(('namespace.collection',), 'requirements.yml')
+
+ with pytest.raises(AnsibleError) as cli_err:
+ cli.run()
+
+ assert req_err.value.message == cli_err.value.message == 'The positional collection_name arg and --requirements-file are mutually exclusive.'
+
+
+def test_require_one_of_collections_requirements_with_neither():
+ cli = GalaxyCLI(args=['ansible-galaxy', 'collection', 'verify'])
+
+ with pytest.raises(AnsibleError) as req_err:
+ cli._require_one_of_collections_requirements((), '')
+
+ with pytest.raises(AnsibleError) as cli_err:
+ cli.run()
+
+ assert req_err.value.message == cli_err.value.message == 'You must specify a collection name or a requirements file.'
+
+
+def test_require_one_of_collections_requirements_with_collections():
+ cli = GalaxyCLI(args=['ansible-galaxy', 'collection', 'verify', 'namespace1.collection1', 'namespace2.collection1:1.0.0'])
+ collections = ('namespace1.collection1', 'namespace2.collection1:1.0.0',)
+
+ requirements = cli._require_one_of_collections_requirements(collections, '')['collections']
+
+ assert requirements == [('namespace1.collection1', '*', None, None), ('namespace2.collection1', '1.0.0', None, None)]
+
+
+@patch('ansible.cli.galaxy.GalaxyCLI._parse_requirements_file')
+def test_require_one_of_collections_requirements_with_requirements(mock_parse_requirements_file, galaxy_server):
+ cli = GalaxyCLI(args=['ansible-galaxy', 'collection', 'verify', '-r', 'requirements.yml', 'namespace.collection'])
+ mock_parse_requirements_file.return_value = {'collections': [('namespace.collection', '1.0.5', galaxy_server)]}
+ requirements = cli._require_one_of_collections_requirements((), 'requirements.yml')['collections']
+
+ assert mock_parse_requirements_file.call_count == 1
+ assert requirements == [('namespace.collection', '1.0.5', galaxy_server)]
+
+
+@patch('ansible.cli.galaxy.GalaxyCLI.execute_verify', spec=True)
+def test_call_GalaxyCLI(execute_verify):
+ galaxy_args = ['ansible-galaxy', 'collection', 'verify', 'namespace.collection']
+
+ GalaxyCLI(args=galaxy_args).run()
+
+ assert execute_verify.call_count == 1
+
+
+@patch('ansible.cli.galaxy.GalaxyCLI.execute_verify')
+def test_call_GalaxyCLI_with_implicit_role(execute_verify):
+ galaxy_args = ['ansible-galaxy', 'verify', 'namespace.implicit_role']
+
+ with pytest.raises(SystemExit):
+ GalaxyCLI(args=galaxy_args).run()
+
+ assert not execute_verify.called
+
+
+@patch('ansible.cli.galaxy.GalaxyCLI.execute_verify')
+def test_call_GalaxyCLI_with_role(execute_verify):
+ galaxy_args = ['ansible-galaxy', 'role', 'verify', 'namespace.role']
+
+ with pytest.raises(SystemExit):
+ GalaxyCLI(args=galaxy_args).run()
+
+ assert not execute_verify.called
+
+
+@patch('ansible.cli.galaxy.verify_collections', spec=True)
+def test_execute_verify_with_defaults(mock_verify_collections):
+ galaxy_args = ['ansible-galaxy', 'collection', 'verify', 'namespace.collection:1.0.4']
+ GalaxyCLI(args=galaxy_args).run()
+
+ assert mock_verify_collections.call_count == 1
+
+ requirements, search_paths, galaxy_apis, validate, ignore_errors = mock_verify_collections.call_args[0]
+
+ assert requirements == [('namespace.collection', '1.0.4', None, None)]
+ for install_path in search_paths:
+ assert install_path.endswith('ansible_collections')
+ assert galaxy_apis[0].api_server == 'https://galaxy.ansible.com'
+ assert validate is True
+ assert ignore_errors is False
+
+
+@patch('ansible.cli.galaxy.verify_collections', spec=True)
+def test_execute_verify(mock_verify_collections):
+ GalaxyCLI(args=[
+ 'ansible-galaxy', 'collection', 'verify', 'namespace.collection:1.0.4', '--ignore-certs',
+ '-p', '~/.ansible', '--ignore-errors', '--server', 'http://galaxy-dev.com',
+ ]).run()
+
+ assert mock_verify_collections.call_count == 1
+
+ requirements, search_paths, galaxy_apis, validate, ignore_errors = mock_verify_collections.call_args[0]
+
+ assert requirements == [('namespace.collection', '1.0.4', None, None)]
+ for install_path in search_paths:
+ assert install_path.endswith('ansible_collections')
+ assert galaxy_apis[0].api_server == 'http://galaxy-dev.com'
+ assert validate is False
+ assert ignore_errors is True
+
+
+def test_verify_file_hash_deleted_file(manifest_info):
+ data = to_bytes(json.dumps(manifest_info))
+ digest = sha256(data).hexdigest()
+
+ namespace = manifest_info['collection_info']['namespace']
+ name = manifest_info['collection_info']['name']
+ version = manifest_info['collection_info']['version']
+ server = 'http://galaxy.ansible.com'
+
+ error_queue = []
+
+ with patch.object(builtins, 'open', mock_open(read_data=data)) as m:
+ with patch.object(collection.os.path, 'isfile', MagicMock(return_value=False)) as mock_isfile:
+ collection_req = collection.CollectionRequirement(namespace, name, './', server, [version], version, False)
+ collection_req._verify_file_hash(b'path/', 'file', digest, error_queue)
+
+ assert mock_isfile.called_once
+
+ assert len(error_queue) == 1
+ assert error_queue[0].installed is None
+ assert error_queue[0].expected == digest
+
+
+def test_verify_file_hash_matching_hash(manifest_info):
+
+ data = to_bytes(json.dumps(manifest_info))
+ digest = sha256(data).hexdigest()
+
+ namespace = manifest_info['collection_info']['namespace']
+ name = manifest_info['collection_info']['name']
+ version = manifest_info['collection_info']['version']
+ server = 'http://galaxy.ansible.com'
+
+ error_queue = []
+
+ with patch.object(builtins, 'open', mock_open(read_data=data)) as m:
+ with patch.object(collection.os.path, 'isfile', MagicMock(return_value=True)) as mock_isfile:
+ collection_req = collection.CollectionRequirement(namespace, name, './', server, [version], version, False)
+ collection_req._verify_file_hash(b'path/', 'file', digest, error_queue)
+
+ assert mock_isfile.called_once
+
+ assert error_queue == []
+
+
+def test_verify_file_hash_mismatching_hash(manifest_info):
+
+ data = to_bytes(json.dumps(manifest_info))
+ digest = sha256(data).hexdigest()
+ different_digest = 'not_{0}'.format(digest)
+
+ namespace = manifest_info['collection_info']['namespace']
+ name = manifest_info['collection_info']['name']
+ version = manifest_info['collection_info']['version']
+ server = 'http://galaxy.ansible.com'
+
+ error_queue = []
+
+ with patch.object(builtins, 'open', mock_open(read_data=data)) as m:
+ with patch.object(collection.os.path, 'isfile', MagicMock(return_value=True)) as mock_isfile:
+ collection_req = collection.CollectionRequirement(namespace, name, './', server, [version], version, False)
+ collection_req._verify_file_hash(b'path/', 'file', different_digest, error_queue)
+
+ assert mock_isfile.called_once
+
+ assert len(error_queue) == 1
+ assert error_queue[0].installed == digest
+ assert error_queue[0].expected == different_digest
+
+
+def test_consume_file(manifest):
+
+ manifest_file, checksum = manifest
+ assert checksum == collection._consume_file(manifest_file)
+
+
+def test_consume_file_and_write_contents(manifest, manifest_info):
+
+ manifest_file, checksum = manifest
+
+ write_to = BytesIO()
+ actual_hash = collection._consume_file(manifest_file, write_to)
+
+ write_to.seek(0)
+ assert to_bytes(json.dumps(manifest_info)) == write_to.read()
+ assert actual_hash == checksum
+
+
+def test_get_tar_file_member(tmp_tarfile):
+
+ temp_dir, tfile, filename, checksum = tmp_tarfile
+
+ with collection._get_tar_file_member(tfile, filename) as (tar_file_member, tar_file_obj):
+ assert isinstance(tar_file_member, tarfile.TarInfo)
+ assert isinstance(tar_file_obj, tarfile.ExFileObject)
+
+
+def test_get_nonexistent_tar_file_member(tmp_tarfile):
+ temp_dir, tfile, filename, checksum = tmp_tarfile
+
+ file_does_not_exist = filename + 'nonexistent'
+
+ with pytest.raises(AnsibleError) as err:
+ collection._get_tar_file_member(tfile, file_does_not_exist)
+
+ assert to_text(err.value.message) == "Collection tar at '%s' does not contain the expected file '%s'." % (to_text(tfile.name), file_does_not_exist)
+
+
+def test_get_tar_file_hash(tmp_tarfile):
+ temp_dir, tfile, filename, checksum = tmp_tarfile
+
+ assert checksum == collection._get_tar_file_hash(tfile.name, filename)
+
+
+def test_get_json_from_tar_file(tmp_tarfile):
+ temp_dir, tfile, filename, checksum = tmp_tarfile
+
+ assert 'MANIFEST.json' in tfile.getnames()
+
+ data = collection._get_json_from_tar_file(tfile.name, 'MANIFEST.json')
+
+ assert isinstance(data, dict)
+
+
+def test_verify_collection_not_installed(mock_collection):
+
+ local_collection = mock_collection(local_installed=False)
+ remote_collection = mock_collection(local=False)
+
+ with patch.object(collection.display, 'display') as mocked_display:
+ local_collection.verify(remote_collection, './', './')
+
+ assert mocked_display.called
+ assert mocked_display.call_args[0][0] == "'%s.%s' has not been installed, nothing to verify" % (local_collection.namespace, local_collection.name)
+
+
+def test_verify_successful_debug_info(monkeypatch, mock_collection):
+ local_collection = mock_collection()
+ remote_collection = mock_collection(local=False)
+
+ monkeypatch.setattr(collection, '_get_tar_file_hash', MagicMock())
+ monkeypatch.setattr(collection.CollectionRequirement, '_verify_file_hash', MagicMock())
+ monkeypatch.setattr(collection, '_get_json_from_tar_file', MagicMock())
+
+ with patch.object(collection.display, 'vvv') as mock_display:
+ local_collection.verify(remote_collection, './', './')
+
+ namespace = local_collection.namespace
+ name = local_collection.name
+ version = local_collection.latest_version
+
+ assert mock_display.call_count == 4
+ assert mock_display.call_args_list[0][0][0] == "Verifying '%s.%s:%s'." % (namespace, name, version)
+ assert mock_display.call_args_list[1][0][0] == "Installed collection found at './%s/%s'" % (namespace, name)
+ located = "Remote collection found at 'https://galaxy.ansible.com/download/%s-%s-%s.tar.gz'" % (namespace, name, version)
+ assert mock_display.call_args_list[2][0][0] == located
+ verified = "Successfully verified that checksums for '%s.%s:%s' match the remote collection" % (namespace, name, version)
+ assert mock_display.call_args_list[3][0][0] == verified
+
+
+def test_verify_different_versions(mock_collection):
+
+ local_collection = mock_collection(version='0.1.0')
+ remote_collection = mock_collection(local=False, version='3.0.0')
+
+ with patch.object(collection.display, 'display') as mock_display:
+ local_collection.verify(remote_collection, './', './')
+
+ namespace = local_collection.namespace
+ name = local_collection.name
+ installed_version = local_collection.latest_version
+ compared_version = remote_collection.latest_version
+
+ msg = "%s.%s has the version '%s' but is being compared to '%s'" % (namespace, name, installed_version, compared_version)
+
+ assert mock_display.call_count == 1
+ assert mock_display.call_args[0][0] == msg
+
+
+@patch.object(builtins, 'open', mock_open())
+def test_verify_modified_manifest(monkeypatch, mock_collection, manifest_info):
+ local_collection = mock_collection()
+ remote_collection = mock_collection(local=False)
+
+ monkeypatch.setattr(collection, '_get_tar_file_hash', MagicMock(side_effect=['manifest_checksum']))
+ monkeypatch.setattr(collection, '_consume_file', MagicMock(side_effect=['manifest_checksum_modified', 'files_manifest_checksum']))
+ monkeypatch.setattr(collection, '_get_json_from_tar_file', MagicMock(side_effect=[manifest_info, {'files': []}]))
+ monkeypatch.setattr(collection.os.path, 'isfile', MagicMock(return_value=True))
+
+ with patch.object(collection.display, 'display') as mock_display:
+ with patch.object(collection.display, 'vvv') as mock_debug:
+ local_collection.verify(remote_collection, './', './')
+
+ namespace = local_collection.namespace
+ name = local_collection.name
+
+ assert mock_display.call_count == 3
+ assert mock_display.call_args_list[0][0][0] == 'Collection %s.%s contains modified content in the following files:' % (namespace, name)
+ assert mock_display.call_args_list[1][0][0] == '%s.%s' % (namespace, name)
+ assert mock_display.call_args_list[2][0][0] == ' MANIFEST.json'
+
+ # The -vvv output should show details (the checksums do not match)
+ assert mock_debug.call_count == 5
+ assert mock_debug.call_args_list[-1][0][0] == ' Expected: manifest_checksum\n Found: manifest_checksum_modified'
+
+
+@patch.object(builtins, 'open', mock_open())
+def test_verify_modified_files_manifest(monkeypatch, mock_collection, manifest_info):
+ local_collection = mock_collection()
+ remote_collection = mock_collection(local=False)
+
+ monkeypatch.setattr(collection, '_get_tar_file_hash', MagicMock(side_effect=['manifest_checksum']))
+ monkeypatch.setattr(collection, '_consume_file', MagicMock(side_effect=['manifest_checksum', 'files_manifest_checksum_modified']))
+ monkeypatch.setattr(collection, '_get_json_from_tar_file', MagicMock(side_effect=[manifest_info, {'files': []}]))
+ monkeypatch.setattr(collection.os.path, 'isfile', MagicMock(return_value=True))
+
+ with patch.object(collection.display, 'display') as mock_display:
+ with patch.object(collection.display, 'vvv') as mock_debug:
+ local_collection.verify(remote_collection, './', './')
+
+ namespace = local_collection.namespace
+ name = local_collection.name
+
+ assert mock_display.call_count == 3
+ assert mock_display.call_args_list[0][0][0] == 'Collection %s.%s contains modified content in the following files:' % (namespace, name)
+ assert mock_display.call_args_list[1][0][0] == '%s.%s' % (namespace, name)
+ assert mock_display.call_args_list[2][0][0] == ' FILES.json'
+
+ # The -vvv output should show details (the checksums do not match)
+ assert mock_debug.call_count == 5
+ assert mock_debug.call_args_list[-1][0][0] == ' Expected: files_manifest_checksum\n Found: files_manifest_checksum_modified'
+
+
+@patch.object(builtins, 'open', mock_open())
+def test_verify_modified_files(monkeypatch, mock_collection, manifest_info, files_manifest_info):
+
+ local_collection = mock_collection()
+ remote_collection = mock_collection(local=False)
+
+ monkeypatch.setattr(collection, '_get_tar_file_hash', MagicMock(side_effect=['manifest_checksum']))
+ fakehashes = ['manifest_checksum', 'files_manifest_checksum', 'individual_file_checksum_modified']
+ monkeypatch.setattr(collection, '_consume_file', MagicMock(side_effect=fakehashes))
+ monkeypatch.setattr(collection, '_get_json_from_tar_file', MagicMock(side_effect=[manifest_info, files_manifest_info]))
+ monkeypatch.setattr(collection.os.path, 'isfile', MagicMock(return_value=True))
+
+ with patch.object(collection.display, 'display') as mock_display:
+ with patch.object(collection.display, 'vvv') as mock_debug:
+ local_collection.verify(remote_collection, './', './')
+
+ namespace = local_collection.namespace
+ name = local_collection.name
+
+ assert mock_display.call_count == 3
+ assert mock_display.call_args_list[0][0][0] == 'Collection %s.%s contains modified content in the following files:' % (namespace, name)
+ assert mock_display.call_args_list[1][0][0] == '%s.%s' % (namespace, name)
+ assert mock_display.call_args_list[2][0][0] == ' README.md'
+
+ # The -vvv output should show details (the checksums do not match)
+ assert mock_debug.call_count == 5
+ assert mock_debug.call_args_list[-1][0][0] == ' Expected: individual_file_checksum\n Found: individual_file_checksum_modified'
+
+
+@patch.object(builtins, 'open', mock_open())
+def test_verify_identical(monkeypatch, mock_collection, manifest_info, files_manifest_info):
+
+ local_collection = mock_collection()
+ remote_collection = mock_collection(local=False)
+
+ monkeypatch.setattr(collection, '_get_tar_file_hash', MagicMock(side_effect=['manifest_checksum']))
+ monkeypatch.setattr(collection, '_consume_file', MagicMock(side_effect=['manifest_checksum', 'files_manifest_checksum', 'individual_file_checksum']))
+ monkeypatch.setattr(collection, '_get_json_from_tar_file', MagicMock(side_effect=[manifest_info, files_manifest_info]))
+ monkeypatch.setattr(collection.os.path, 'isfile', MagicMock(return_value=True))
+
+ with patch.object(collection.display, 'display') as mock_display:
+ with patch.object(collection.display, 'vvv') as mock_debug:
+ local_collection.verify(remote_collection, './', './')
+
+ # Successful verification is quiet
+ assert mock_display.call_count == 0
+
+ # The -vvv output should show the checksums not matching
+ namespace = local_collection.namespace
+ name = local_collection.name
+ version = local_collection.latest_version
+ success_msg = "Successfully verified that checksums for '%s.%s:%s' match the remote collection" % (namespace, name, version)
+
+ assert mock_debug.call_count == 4
+ assert mock_debug.call_args_list[-1][0][0] == success_msg
+
+
+@patch.object(os.path, 'isdir', return_value=True)
+def test_verify_collections_no_version(mock_isdir, mock_collection, monkeypatch):
+ namespace = 'ansible_namespace'
+ name = 'collection'
+ version = '*' # Occurs if MANIFEST.json does not exist
+
+ local_collection = mock_collection(namespace=namespace, name=name, version=version)
+ monkeypatch.setattr(collection.CollectionRequirement, 'from_path', MagicMock(return_value=local_collection))
+
+ collections = [('%s.%s' % (namespace, name), version, None)]
+
+ with pytest.raises(AnsibleError) as err:
+ collection.verify_collections(collections, './', local_collection.api, False, False)
+
+ err_msg = 'Collection %s.%s does not appear to have a MANIFEST.json. ' % (namespace, name)
+ err_msg += 'A MANIFEST.json is expected if the collection has been built and installed via ansible-galaxy.'
+ assert err.value.message == err_msg
+
+
+@patch.object(collection.CollectionRequirement, 'verify')
+def test_verify_collections_not_installed(mock_verify, mock_collection, monkeypatch):
+ namespace = 'ansible_namespace'
+ name = 'collection'
+ version = '1.0.0'
+
+ local_collection = mock_collection(local_installed=False)
+
+ found_remote = MagicMock(return_value=mock_collection(local=False))
+ monkeypatch.setattr(collection.CollectionRequirement, 'from_name', found_remote)
+
+ collections = [('%s.%s' % (namespace, name), version, None, None)]
+ search_path = './'
+ validate_certs = False
+ ignore_errors = False
+ apis = [local_collection.api]
+
+ with patch.object(collection, '_download_file') as mock_download_file:
+ with pytest.raises(AnsibleError) as err:
+ collection.verify_collections(collections, search_path, apis, validate_certs, ignore_errors)
+
+ assert err.value.message == "Collection %s.%s is not installed in any of the collection paths." % (namespace, name)
+
+
+@patch.object(collection.CollectionRequirement, 'verify')
+def test_verify_collections_not_installed_ignore_errors(mock_verify, mock_collection, monkeypatch):
+ namespace = 'ansible_namespace'
+ name = 'collection'
+ version = '1.0.0'
+
+ local_collection = mock_collection(local_installed=False)
+
+ found_remote = MagicMock(return_value=mock_collection(local=False))
+ monkeypatch.setattr(collection.CollectionRequirement, 'from_name', found_remote)
+
+ collections = [('%s.%s' % (namespace, name), version, None)]
+ search_path = './'
+ validate_certs = False
+ ignore_errors = True
+ apis = [local_collection.api]
+
+ with patch.object(collection, '_download_file') as mock_download_file:
+ with patch.object(Display, 'warning') as mock_warning:
+ collection.verify_collections(collections, search_path, apis, validate_certs, ignore_errors)
+
+ skip_message = "Failed to verify collection %s.%s but skipping due to --ignore-errors being set." % (namespace, name)
+ original_err = "Error: Collection %s.%s is not installed in any of the collection paths." % (namespace, name)
+
+ assert mock_warning.called
+ assert mock_warning.call_args[0][0] == skip_message + " " + original_err
+
+
+@patch.object(os.path, 'isdir', return_value=True)
+@patch.object(collection.CollectionRequirement, 'verify')
+def test_verify_collections_no_remote(mock_verify, mock_isdir, mock_collection, monkeypatch):
+ namespace = 'ansible_namespace'
+ name = 'collection'
+ version = '1.0.0'
+
+ monkeypatch.setattr(os.path, 'isfile', MagicMock(side_effect=[False, True]))
+ monkeypatch.setattr(collection.CollectionRequirement, 'from_path', MagicMock(return_value=mock_collection()))
+
+ collections = [('%s.%s' % (namespace, name), version, None)]
+ search_path = './'
+ validate_certs = False
+ ignore_errors = False
+ apis = []
+
+ with pytest.raises(AnsibleError) as err:
+ collection.verify_collections(collections, search_path, apis, validate_certs, ignore_errors)
+
+ assert err.value.message == "Failed to find remote collection %s.%s:%s on any of the galaxy servers" % (namespace, name, version)
+
+
+@patch.object(os.path, 'isdir', return_value=True)
+@patch.object(collection.CollectionRequirement, 'verify')
+def test_verify_collections_no_remote_ignore_errors(mock_verify, mock_isdir, mock_collection, monkeypatch):
+ namespace = 'ansible_namespace'
+ name = 'collection'
+ version = '1.0.0'
+
+ monkeypatch.setattr(os.path, 'isfile', MagicMock(side_effect=[False, True]))
+ monkeypatch.setattr(collection.CollectionRequirement, 'from_path', MagicMock(return_value=mock_collection()))
+
+ collections = [('%s.%s' % (namespace, name), version, None)]
+ search_path = './'
+ validate_certs = False
+ ignore_errors = True
+ apis = []
+
+ with patch.object(Display, 'warning') as mock_warning:
+ collection.verify_collections(collections, search_path, apis, validate_certs, ignore_errors)
+
+ skip_message = "Failed to verify collection %s.%s but skipping due to --ignore-errors being set." % (namespace, name)
+ original_err = "Error: Failed to find remote collection %s.%s:%s on any of the galaxy servers" % (namespace, name, version)
+
+ assert mock_warning.called
+ assert mock_warning.call_args[0][0] == skip_message + " " + original_err
+
+
+def test_verify_collections_tarfile(monkeypatch):
+
+ monkeypatch.setattr(os.path, 'isfile', MagicMock(return_value=True))
+
+ invalid_format = 'ansible_namespace-collection-0.1.0.tar.gz'
+ collections = [(invalid_format, '*', None)]
+
+ with pytest.raises(AnsibleError) as err:
+ collection.verify_collections(collections, './', [], False, False)
+
+ msg = "'%s' is not a valid collection name. The format namespace.name is expected." % invalid_format
+ assert err.value.message == msg
+
+
+def test_verify_collections_path(monkeypatch):
+
+ monkeypatch.setattr(os.path, 'isfile', MagicMock(return_value=False))
+
+ invalid_format = 'collections/collection_namespace/collection_name'
+ collections = [(invalid_format, '*', None)]
+
+ with pytest.raises(AnsibleError) as err:
+ collection.verify_collections(collections, './', [], False, False)
+
+ msg = "'%s' is not a valid collection name. The format namespace.name is expected." % invalid_format
+ assert err.value.message == msg
+
+
+def test_verify_collections_url(monkeypatch):
+
+ monkeypatch.setattr(os.path, 'isfile', MagicMock(return_value=False))
+
+ invalid_format = 'https://galaxy.ansible.com/download/ansible_namespace-collection-0.1.0.tar.gz'
+ collections = [(invalid_format, '*', None)]
+
+ with pytest.raises(AnsibleError) as err:
+ collection.verify_collections(collections, './', [], False, False)
+
+ msg = "'%s' is not a valid collection name. The format namespace.name is expected." % invalid_format
+ assert err.value.message == msg
+
+
+@patch.object(os.path, 'isdir', return_value=True)
+@patch.object(collection.CollectionRequirement, 'verify')
+def test_verify_collections_name(mock_verify, mock_isdir, mock_collection, monkeypatch):
+ local_collection = mock_collection()
+ monkeypatch.setattr(collection.CollectionRequirement, 'from_path', MagicMock(return_value=local_collection))
+
+ monkeypatch.setattr(os.path, 'isfile', MagicMock(side_effect=[False, True, False]))
+
+ located_remote_from_name = MagicMock(return_value=mock_collection(local=False))
+ monkeypatch.setattr(collection.CollectionRequirement, 'from_name', located_remote_from_name)
+
+ with patch.object(collection, '_download_file') as mock_download_file:
+
+ collections = [('%s.%s' % (local_collection.namespace, local_collection.name), '%s' % local_collection.latest_version, None)]
+ search_path = './'
+ validate_certs = False
+ ignore_errors = False
+ apis = [local_collection.api]
+
+ collection.verify_collections(collections, search_path, apis, validate_certs, ignore_errors)
+
+ assert mock_download_file.call_count == 1
+ assert located_remote_from_name.call_count == 1
diff --git a/test/units/galaxy/test_collection_install.py b/test/units/galaxy/test_collection_install.py
new file mode 100644
index 00000000..629a3564
--- /dev/null
+++ b/test/units/galaxy/test_collection_install.py
@@ -0,0 +1,816 @@
+# -*- coding: utf-8 -*-
+# Copyright: (c) 2019, Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import copy
+import json
+import os
+import pytest
+import re
+import shutil
+import stat
+import tarfile
+import yaml
+
+from io import BytesIO, StringIO
+from units.compat.mock import MagicMock
+
+import ansible.module_utils.six.moves.urllib.error as urllib_error
+
+from ansible import context
+from ansible.cli.galaxy import GalaxyCLI
+from ansible.errors import AnsibleError
+from ansible.galaxy import collection, api
+from ansible.module_utils._text import to_bytes, to_native, to_text
+from ansible.utils import context_objects as co
+from ansible.utils.display import Display
+
+
+def call_galaxy_cli(args):
+ orig = co.GlobalCLIArgs._Singleton__instance
+ co.GlobalCLIArgs._Singleton__instance = None
+ try:
+ GalaxyCLI(args=['ansible-galaxy', 'collection'] + args).run()
+ finally:
+ co.GlobalCLIArgs._Singleton__instance = orig
+
+
+def artifact_json(namespace, name, version, dependencies, server):
+ json_str = json.dumps({
+ 'artifact': {
+ 'filename': '%s-%s-%s.tar.gz' % (namespace, name, version),
+ 'sha256': '2d76f3b8c4bab1072848107fb3914c345f71a12a1722f25c08f5d3f51f4ab5fd',
+ 'size': 1234,
+ },
+ 'download_url': '%s/download/%s-%s-%s.tar.gz' % (server, namespace, name, version),
+ 'metadata': {
+ 'namespace': namespace,
+ 'name': name,
+ 'dependencies': dependencies,
+ },
+ 'version': version
+ })
+ return to_text(json_str)
+
+
+def artifact_versions_json(namespace, name, versions, galaxy_api, available_api_versions=None):
+ results = []
+ available_api_versions = available_api_versions or {}
+ api_version = 'v2'
+ if 'v3' in available_api_versions:
+ api_version = 'v3'
+ for version in versions:
+ results.append({
+ 'href': '%s/api/%s/%s/%s/versions/%s/' % (galaxy_api.api_server, api_version, namespace, name, version),
+ 'version': version,
+ })
+
+ if api_version == 'v2':
+ json_str = json.dumps({
+ 'count': len(versions),
+ 'next': None,
+ 'previous': None,
+ 'results': results
+ })
+
+ if api_version == 'v3':
+ response = {'meta': {'count': len(versions)},
+ 'data': results,
+ 'links': {'first': None,
+ 'last': None,
+ 'next': None,
+ 'previous': None},
+ }
+ json_str = json.dumps(response)
+ return to_text(json_str)
+
+
+def error_json(galaxy_api, errors_to_return=None, available_api_versions=None):
+ errors_to_return = errors_to_return or []
+ available_api_versions = available_api_versions or {}
+
+ response = {}
+
+ api_version = 'v2'
+ if 'v3' in available_api_versions:
+ api_version = 'v3'
+
+ if api_version == 'v2':
+ assert len(errors_to_return) <= 1
+ if errors_to_return:
+ response = errors_to_return[0]
+
+ if api_version == 'v3':
+ response['errors'] = errors_to_return
+
+ json_str = json.dumps(response)
+ return to_text(json_str)
+
+
+@pytest.fixture(autouse='function')
+def reset_cli_args():
+ co.GlobalCLIArgs._Singleton__instance = None
+ yield
+ co.GlobalCLIArgs._Singleton__instance = None
+
+
+@pytest.fixture()
+def collection_artifact(request, tmp_path_factory):
+ test_dir = to_text(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections Input'))
+ namespace = 'ansible_namespace'
+ collection = 'collection'
+
+ skeleton_path = os.path.join(os.path.dirname(os.path.split(__file__)[0]), 'cli', 'test_data', 'collection_skeleton')
+ collection_path = os.path.join(test_dir, namespace, collection)
+
+ call_galaxy_cli(['init', '%s.%s' % (namespace, collection), '-c', '--init-path', test_dir,
+ '--collection-skeleton', skeleton_path])
+ dependencies = getattr(request, 'param', None)
+ if dependencies:
+ galaxy_yml = os.path.join(collection_path, 'galaxy.yml')
+ with open(galaxy_yml, 'rb+') as galaxy_obj:
+ existing_yaml = yaml.safe_load(galaxy_obj)
+ existing_yaml['dependencies'] = dependencies
+
+ galaxy_obj.seek(0)
+ galaxy_obj.write(to_bytes(yaml.safe_dump(existing_yaml)))
+ galaxy_obj.truncate()
+
+ # Create a file with +x in the collection so we can test the permissions
+ execute_path = os.path.join(collection_path, 'runme.sh')
+ with open(execute_path, mode='wb') as fd:
+ fd.write(b"echo hi")
+ os.chmod(execute_path, os.stat(execute_path).st_mode | stat.S_IEXEC)
+
+ call_galaxy_cli(['build', collection_path, '--output-path', test_dir])
+
+ collection_tar = os.path.join(test_dir, '%s-%s-0.1.0.tar.gz' % (namespace, collection))
+ return to_bytes(collection_path), to_bytes(collection_tar)
+
+
+@pytest.fixture()
+def galaxy_server():
+ context.CLIARGS._store = {'ignore_certs': False}
+ galaxy_api = api.GalaxyAPI(None, 'test_server', 'https://galaxy.ansible.com')
+ return galaxy_api
+
+
+def test_build_requirement_from_path(collection_artifact):
+ actual = collection.CollectionRequirement.from_path(collection_artifact[0], True)
+
+ assert actual.namespace == u'ansible_namespace'
+ assert actual.name == u'collection'
+ assert actual.b_path == collection_artifact[0]
+ assert actual.api is None
+ assert actual.skip is True
+ assert actual.versions == set([u'*'])
+ assert actual.latest_version == u'*'
+ assert actual.dependencies == {}
+
+
+@pytest.mark.parametrize('version', ['1.1.1', '1.1.0', '1.0.0'])
+def test_build_requirement_from_path_with_manifest(version, collection_artifact):
+ manifest_path = os.path.join(collection_artifact[0], b'MANIFEST.json')
+ manifest_value = json.dumps({
+ 'collection_info': {
+ 'namespace': 'namespace',
+ 'name': 'name',
+ 'version': version,
+ 'dependencies': {
+ 'ansible_namespace.collection': '*'
+ }
+ }
+ })
+ with open(manifest_path, 'wb') as manifest_obj:
+ manifest_obj.write(to_bytes(manifest_value))
+
+ actual = collection.CollectionRequirement.from_path(collection_artifact[0], True)
+
+ # While the folder name suggests a different collection, we treat MANIFEST.json as the source of truth.
+ assert actual.namespace == u'namespace'
+ assert actual.name == u'name'
+ assert actual.b_path == collection_artifact[0]
+ assert actual.api is None
+ assert actual.skip is True
+ assert actual.versions == set([to_text(version)])
+ assert actual.latest_version == to_text(version)
+ assert actual.dependencies == {'ansible_namespace.collection': '*'}
+
+
+def test_build_requirement_from_path_invalid_manifest(collection_artifact):
+ manifest_path = os.path.join(collection_artifact[0], b'MANIFEST.json')
+ with open(manifest_path, 'wb') as manifest_obj:
+ manifest_obj.write(b"not json")
+
+ expected = "Collection file at '%s' does not contain a valid json string." % to_native(manifest_path)
+ with pytest.raises(AnsibleError, match=expected):
+ collection.CollectionRequirement.from_path(collection_artifact[0], True)
+
+
+def test_build_requirement_from_path_no_version(collection_artifact, monkeypatch):
+ manifest_path = os.path.join(collection_artifact[0], b'MANIFEST.json')
+ manifest_value = json.dumps({
+ 'collection_info': {
+ 'namespace': 'namespace',
+ 'name': 'name',
+ 'version': '',
+ 'dependencies': {}
+ }
+ })
+ with open(manifest_path, 'wb') as manifest_obj:
+ manifest_obj.write(to_bytes(manifest_value))
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ actual = collection.CollectionRequirement.from_path(collection_artifact[0], True)
+
+ # While the folder name suggests a different collection, we treat MANIFEST.json as the source of truth.
+ assert actual.namespace == u'namespace'
+ assert actual.name == u'name'
+ assert actual.b_path == collection_artifact[0]
+ assert actual.api is None
+ assert actual.skip is True
+ assert actual.versions == set(['*'])
+ assert actual.latest_version == u'*'
+ assert actual.dependencies == {}
+
+ assert mock_display.call_count == 1
+
+ actual_warn = ' '.join(mock_display.mock_calls[0][1][0].split('\n'))
+ expected_warn = "Collection at '%s' does not have a valid version set, falling back to '*'. Found version: ''" \
+ % to_text(collection_artifact[0])
+ assert expected_warn in actual_warn
+
+
+def test_build_requirement_from_tar(collection_artifact):
+ actual = collection.CollectionRequirement.from_tar(collection_artifact[1], True, True)
+
+ assert actual.namespace == u'ansible_namespace'
+ assert actual.name == u'collection'
+ assert actual.b_path == collection_artifact[1]
+ assert actual.api is None
+ assert actual.skip is False
+ assert actual.versions == set([u'0.1.0'])
+ assert actual.latest_version == u'0.1.0'
+ assert actual.dependencies == {}
+
+
+def test_build_requirement_from_tar_fail_not_tar(tmp_path_factory):
+ test_dir = to_bytes(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections Input'))
+ test_file = os.path.join(test_dir, b'fake.tar.gz')
+ with open(test_file, 'wb') as test_obj:
+ test_obj.write(b"\x00\x01\x02\x03")
+
+ expected = "Collection artifact at '%s' is not a valid tar file." % to_native(test_file)
+ with pytest.raises(AnsibleError, match=expected):
+ collection.CollectionRequirement.from_tar(test_file, True, True)
+
+
+def test_build_requirement_from_tar_no_manifest(tmp_path_factory):
+ test_dir = to_bytes(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections Input'))
+
+ json_data = to_bytes(json.dumps(
+ {
+ 'files': [],
+ 'format': 1,
+ }
+ ))
+
+ tar_path = os.path.join(test_dir, b'ansible-collections.tar.gz')
+ with tarfile.open(tar_path, 'w:gz') as tfile:
+ b_io = BytesIO(json_data)
+ tar_info = tarfile.TarInfo('FILES.json')
+ tar_info.size = len(json_data)
+ tar_info.mode = 0o0644
+ tfile.addfile(tarinfo=tar_info, fileobj=b_io)
+
+ expected = "Collection at '%s' does not contain the required file MANIFEST.json." % to_native(tar_path)
+ with pytest.raises(AnsibleError, match=expected):
+ collection.CollectionRequirement.from_tar(tar_path, True, True)
+
+
+def test_build_requirement_from_tar_no_files(tmp_path_factory):
+ test_dir = to_bytes(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections Input'))
+
+ json_data = to_bytes(json.dumps(
+ {
+ 'collection_info': {},
+ }
+ ))
+
+ tar_path = os.path.join(test_dir, b'ansible-collections.tar.gz')
+ with tarfile.open(tar_path, 'w:gz') as tfile:
+ b_io = BytesIO(json_data)
+ tar_info = tarfile.TarInfo('MANIFEST.json')
+ tar_info.size = len(json_data)
+ tar_info.mode = 0o0644
+ tfile.addfile(tarinfo=tar_info, fileobj=b_io)
+
+ expected = "Collection at '%s' does not contain the required file FILES.json." % to_native(tar_path)
+ with pytest.raises(AnsibleError, match=expected):
+ collection.CollectionRequirement.from_tar(tar_path, True, True)
+
+
+def test_build_requirement_from_tar_invalid_manifest(tmp_path_factory):
+ test_dir = to_bytes(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Collections Input'))
+
+ json_data = b"not a json"
+
+ tar_path = os.path.join(test_dir, b'ansible-collections.tar.gz')
+ with tarfile.open(tar_path, 'w:gz') as tfile:
+ b_io = BytesIO(json_data)
+ tar_info = tarfile.TarInfo('MANIFEST.json')
+ tar_info.size = len(json_data)
+ tar_info.mode = 0o0644
+ tfile.addfile(tarinfo=tar_info, fileobj=b_io)
+
+ expected = "Collection tar file member MANIFEST.json does not contain a valid json string."
+ with pytest.raises(AnsibleError, match=expected):
+ collection.CollectionRequirement.from_tar(tar_path, True, True)
+
+
+def test_build_requirement_from_name(galaxy_server, monkeypatch):
+ mock_get_versions = MagicMock()
+ mock_get_versions.return_value = ['2.1.9', '2.1.10']
+ monkeypatch.setattr(galaxy_server, 'get_collection_versions', mock_get_versions)
+
+ actual = collection.CollectionRequirement.from_name('namespace.collection', [galaxy_server], '*', True, True)
+
+ assert actual.namespace == u'namespace'
+ assert actual.name == u'collection'
+ assert actual.b_path is None
+ assert actual.api == galaxy_server
+ assert actual.skip is False
+ assert actual.versions == set([u'2.1.9', u'2.1.10'])
+ assert actual.latest_version == u'2.1.10'
+ assert actual.dependencies == {}
+
+ assert mock_get_versions.call_count == 1
+ assert mock_get_versions.mock_calls[0][1] == ('namespace', 'collection')
+
+
+def test_build_requirement_from_name_with_prerelease(galaxy_server, monkeypatch):
+ mock_get_versions = MagicMock()
+ mock_get_versions.return_value = ['1.0.1', '2.0.1-beta.1', '2.0.1']
+ monkeypatch.setattr(galaxy_server, 'get_collection_versions', mock_get_versions)
+
+ actual = collection.CollectionRequirement.from_name('namespace.collection', [galaxy_server], '*', True, True)
+
+ assert actual.namespace == u'namespace'
+ assert actual.name == u'collection'
+ assert actual.b_path is None
+ assert actual.api == galaxy_server
+ assert actual.skip is False
+ assert actual.versions == set([u'1.0.1', u'2.0.1'])
+ assert actual.latest_version == u'2.0.1'
+ assert actual.dependencies == {}
+
+ assert mock_get_versions.call_count == 1
+ assert mock_get_versions.mock_calls[0][1] == ('namespace', 'collection')
+
+
+def test_build_requirment_from_name_with_prerelease_explicit(galaxy_server, monkeypatch):
+ mock_get_info = MagicMock()
+ mock_get_info.return_value = api.CollectionVersionMetadata('namespace', 'collection', '2.0.1-beta.1', None, None,
+ {})
+ monkeypatch.setattr(galaxy_server, 'get_collection_version_metadata', mock_get_info)
+
+ actual = collection.CollectionRequirement.from_name('namespace.collection', [galaxy_server], '2.0.1-beta.1', True,
+ True)
+
+ assert actual.namespace == u'namespace'
+ assert actual.name == u'collection'
+ assert actual.b_path is None
+ assert actual.api == galaxy_server
+ assert actual.skip is False
+ assert actual.versions == set([u'2.0.1-beta.1'])
+ assert actual.latest_version == u'2.0.1-beta.1'
+ assert actual.dependencies == {}
+
+ assert mock_get_info.call_count == 1
+ assert mock_get_info.mock_calls[0][1] == ('namespace', 'collection', '2.0.1-beta.1')
+
+
+def test_build_requirement_from_name_second_server(galaxy_server, monkeypatch):
+ mock_get_versions = MagicMock()
+ mock_get_versions.return_value = ['1.0.1', '1.0.2', '1.0.3']
+ monkeypatch.setattr(galaxy_server, 'get_collection_versions', mock_get_versions)
+
+ broken_server = copy.copy(galaxy_server)
+ broken_server.api_server = 'https://broken.com/'
+ mock_404 = MagicMock()
+ mock_404.side_effect = api.GalaxyError(urllib_error.HTTPError('https://galaxy.server.com', 404, 'msg', {},
+ StringIO()), "custom msg")
+ monkeypatch.setattr(broken_server, 'get_collection_versions', mock_404)
+
+ actual = collection.CollectionRequirement.from_name('namespace.collection', [broken_server, galaxy_server],
+ '>1.0.1', False, True)
+
+ assert actual.namespace == u'namespace'
+ assert actual.name == u'collection'
+ assert actual.b_path is None
+ # assert actual.api == galaxy_server
+ assert actual.skip is False
+ assert actual.versions == set([u'1.0.2', u'1.0.3'])
+ assert actual.latest_version == u'1.0.3'
+ assert actual.dependencies == {}
+
+ assert mock_404.call_count == 1
+ assert mock_404.mock_calls[0][1] == ('namespace', 'collection')
+
+ assert mock_get_versions.call_count == 1
+ assert mock_get_versions.mock_calls[0][1] == ('namespace', 'collection')
+
+
+def test_build_requirement_from_name_missing(galaxy_server, monkeypatch):
+ mock_open = MagicMock()
+ mock_open.side_effect = api.GalaxyError(urllib_error.HTTPError('https://galaxy.server.com', 404, 'msg', {},
+ StringIO()), "")
+
+ monkeypatch.setattr(galaxy_server, 'get_collection_versions', mock_open)
+
+ expected = "Failed to find collection namespace.collection:*"
+ with pytest.raises(AnsibleError, match=expected):
+ collection.CollectionRequirement.from_name('namespace.collection', [galaxy_server, galaxy_server], '*', False,
+ True)
+
+
+def test_build_requirement_from_name_401_unauthorized(galaxy_server, monkeypatch):
+ mock_open = MagicMock()
+ mock_open.side_effect = api.GalaxyError(urllib_error.HTTPError('https://galaxy.server.com', 401, 'msg', {},
+ StringIO()), "error")
+
+ monkeypatch.setattr(galaxy_server, 'get_collection_versions', mock_open)
+
+ expected = "error (HTTP Code: 401, Message: msg)"
+ with pytest.raises(api.GalaxyError, match=re.escape(expected)):
+ collection.CollectionRequirement.from_name('namespace.collection', [galaxy_server, galaxy_server], '*', False)
+
+
+def test_build_requirement_from_name_single_version(galaxy_server, monkeypatch):
+ mock_get_info = MagicMock()
+ mock_get_info.return_value = api.CollectionVersionMetadata('namespace', 'collection', '2.0.0', None, None,
+ {})
+ monkeypatch.setattr(galaxy_server, 'get_collection_version_metadata', mock_get_info)
+
+ actual = collection.CollectionRequirement.from_name('namespace.collection', [galaxy_server], '2.0.0', True,
+ True)
+
+ assert actual.namespace == u'namespace'
+ assert actual.name == u'collection'
+ assert actual.b_path is None
+ assert actual.api == galaxy_server
+ assert actual.skip is False
+ assert actual.versions == set([u'2.0.0'])
+ assert actual.latest_version == u'2.0.0'
+ assert actual.dependencies == {}
+
+ assert mock_get_info.call_count == 1
+ assert mock_get_info.mock_calls[0][1] == ('namespace', 'collection', '2.0.0')
+
+
+def test_build_requirement_from_name_multiple_versions_one_match(galaxy_server, monkeypatch):
+ mock_get_versions = MagicMock()
+ mock_get_versions.return_value = ['2.0.0', '2.0.1', '2.0.2']
+ monkeypatch.setattr(galaxy_server, 'get_collection_versions', mock_get_versions)
+
+ mock_get_info = MagicMock()
+ mock_get_info.return_value = api.CollectionVersionMetadata('namespace', 'collection', '2.0.1', None, None,
+ {})
+ monkeypatch.setattr(galaxy_server, 'get_collection_version_metadata', mock_get_info)
+
+ actual = collection.CollectionRequirement.from_name('namespace.collection', [galaxy_server], '>=2.0.1,<2.0.2',
+ True, True)
+
+ assert actual.namespace == u'namespace'
+ assert actual.name == u'collection'
+ assert actual.b_path is None
+ assert actual.api == galaxy_server
+ assert actual.skip is False
+ assert actual.versions == set([u'2.0.1'])
+ assert actual.latest_version == u'2.0.1'
+ assert actual.dependencies == {}
+
+ assert mock_get_versions.call_count == 1
+ assert mock_get_versions.mock_calls[0][1] == ('namespace', 'collection')
+
+ assert mock_get_info.call_count == 1
+ assert mock_get_info.mock_calls[0][1] == ('namespace', 'collection', '2.0.1')
+
+
+def test_build_requirement_from_name_multiple_version_results(galaxy_server, monkeypatch):
+ mock_get_versions = MagicMock()
+ mock_get_versions.return_value = ['2.0.0', '2.0.1', '2.0.2', '2.0.3', '2.0.4', '2.0.5']
+ monkeypatch.setattr(galaxy_server, 'get_collection_versions', mock_get_versions)
+
+ actual = collection.CollectionRequirement.from_name('namespace.collection', [galaxy_server], '!=2.0.2',
+ True, True)
+
+ assert actual.namespace == u'namespace'
+ assert actual.name == u'collection'
+ assert actual.b_path is None
+ assert actual.api == galaxy_server
+ assert actual.skip is False
+ assert actual.versions == set([u'2.0.0', u'2.0.1', u'2.0.3', u'2.0.4', u'2.0.5'])
+ assert actual.latest_version == u'2.0.5'
+ assert actual.dependencies == {}
+
+ assert mock_get_versions.call_count == 1
+ assert mock_get_versions.mock_calls[0][1] == ('namespace', 'collection')
+
+
+@pytest.mark.parametrize('versions, requirement, expected_filter, expected_latest', [
+ [['1.0.0', '1.0.1'], '*', ['1.0.0', '1.0.1'], '1.0.1'],
+ [['1.0.0', '1.0.5', '1.1.0'], '>1.0.0,<1.1.0', ['1.0.5'], '1.0.5'],
+ [['1.0.0', '1.0.5', '1.1.0'], '>1.0.0,<=1.0.5', ['1.0.5'], '1.0.5'],
+ [['1.0.0', '1.0.5', '1.1.0'], '>=1.1.0', ['1.1.0'], '1.1.0'],
+ [['1.0.0', '1.0.5', '1.1.0'], '!=1.1.0', ['1.0.0', '1.0.5'], '1.0.5'],
+ [['1.0.0', '1.0.5', '1.1.0'], '==1.0.5', ['1.0.5'], '1.0.5'],
+ [['1.0.0', '1.0.5', '1.1.0'], '1.0.5', ['1.0.5'], '1.0.5'],
+ [['1.0.0', '2.0.0', '3.0.0'], '>=2', ['2.0.0', '3.0.0'], '3.0.0'],
+])
+def test_add_collection_requirements(versions, requirement, expected_filter, expected_latest):
+ req = collection.CollectionRequirement('namespace', 'name', None, 'https://galaxy.com', versions, requirement,
+ False)
+ assert req.versions == set(expected_filter)
+ assert req.latest_version == expected_latest
+
+
+def test_add_collection_requirement_to_unknown_installed_version(monkeypatch):
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ req = collection.CollectionRequirement('namespace', 'name', None, 'https://galaxy.com', ['*'], '*', False,
+ skip=True)
+
+ req.add_requirement('parent.collection', '1.0.0')
+ assert req.latest_version == '*'
+
+ assert mock_display.call_count == 1
+
+ actual_warn = ' '.join(mock_display.mock_calls[0][1][0].split('\n'))
+ assert "Failed to validate the collection requirement 'namespace.name:1.0.0' for parent.collection" in actual_warn
+
+
+def test_add_collection_wildcard_requirement_to_unknown_installed_version():
+ req = collection.CollectionRequirement('namespace', 'name', None, 'https://galaxy.com', ['*'], '*', False,
+ skip=True)
+ req.add_requirement(str(req), '*')
+
+ assert req.versions == set('*')
+ assert req.latest_version == '*'
+
+
+def test_add_collection_requirement_with_conflict(galaxy_server):
+ expected = "Cannot meet requirement ==1.0.2 for dependency namespace.name from source '%s'. Available versions " \
+ "before last requirement added: 1.0.0, 1.0.1\n" \
+ "Requirements from:\n" \
+ "\tbase - 'namespace.name:==1.0.2'" % galaxy_server.api_server
+ with pytest.raises(AnsibleError, match=expected):
+ collection.CollectionRequirement('namespace', 'name', None, galaxy_server, ['1.0.0', '1.0.1'], '==1.0.2',
+ False)
+
+
+def test_add_requirement_to_existing_collection_with_conflict(galaxy_server):
+ req = collection.CollectionRequirement('namespace', 'name', None, galaxy_server, ['1.0.0', '1.0.1'], '*', False)
+
+ expected = "Cannot meet dependency requirement 'namespace.name:1.0.2' for collection namespace.collection2 from " \
+ "source '%s'. Available versions before last requirement added: 1.0.0, 1.0.1\n" \
+ "Requirements from:\n" \
+ "\tbase - 'namespace.name:*'\n" \
+ "\tnamespace.collection2 - 'namespace.name:1.0.2'" % galaxy_server.api_server
+ with pytest.raises(AnsibleError, match=re.escape(expected)):
+ req.add_requirement('namespace.collection2', '1.0.2')
+
+
+def test_add_requirement_to_installed_collection_with_conflict():
+ source = 'https://galaxy.ansible.com'
+ req = collection.CollectionRequirement('namespace', 'name', None, source, ['1.0.0', '1.0.1'], '*', False,
+ skip=True)
+
+ expected = "Cannot meet requirement namespace.name:1.0.2 as it is already installed at version '1.0.1'. " \
+ "Use --force to overwrite"
+ with pytest.raises(AnsibleError, match=re.escape(expected)):
+ req.add_requirement(None, '1.0.2')
+
+
+def test_add_requirement_to_installed_collection_with_conflict_as_dep():
+ source = 'https://galaxy.ansible.com'
+ req = collection.CollectionRequirement('namespace', 'name', None, source, ['1.0.0', '1.0.1'], '*', False,
+ skip=True)
+
+ expected = "Cannot meet requirement namespace.name:1.0.2 as it is already installed at version '1.0.1'. " \
+ "Use --force-with-deps to overwrite"
+ with pytest.raises(AnsibleError, match=re.escape(expected)):
+ req.add_requirement('namespace.collection2', '1.0.2')
+
+
+def test_install_skipped_collection(monkeypatch):
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ req = collection.CollectionRequirement('namespace', 'name', None, 'source', ['1.0.0'], '*', False, skip=True)
+ req.install(None, None)
+
+ assert mock_display.call_count == 1
+ assert mock_display.mock_calls[0][1][0] == "Skipping 'namespace.name' as it is already installed"
+
+
+def test_install_collection(collection_artifact, monkeypatch):
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ collection_tar = collection_artifact[1]
+ output_path = os.path.join(os.path.split(collection_tar)[0], b'output')
+ collection_path = os.path.join(output_path, b'ansible_namespace', b'collection')
+ os.makedirs(os.path.join(collection_path, b'delete_me')) # Create a folder to verify the install cleans out the dir
+
+ temp_path = os.path.join(os.path.split(collection_tar)[0], b'temp')
+ os.makedirs(temp_path)
+
+ req = collection.CollectionRequirement.from_tar(collection_tar, True, True)
+ req.install(to_text(output_path), temp_path)
+
+ # Ensure the temp directory is empty, nothing is left behind
+ assert os.listdir(temp_path) == []
+
+ actual_files = os.listdir(collection_path)
+ actual_files.sort()
+ assert actual_files == [b'FILES.json', b'MANIFEST.json', b'README.md', b'docs', b'playbooks', b'plugins', b'roles',
+ b'runme.sh']
+
+ assert stat.S_IMODE(os.stat(os.path.join(collection_path, b'plugins')).st_mode) == 0o0755
+ assert stat.S_IMODE(os.stat(os.path.join(collection_path, b'README.md')).st_mode) == 0o0644
+ assert stat.S_IMODE(os.stat(os.path.join(collection_path, b'runme.sh')).st_mode) == 0o0755
+
+ assert mock_display.call_count == 2
+ assert mock_display.mock_calls[0][1][0] == "Installing 'ansible_namespace.collection:0.1.0' to '%s'" \
+ % to_text(collection_path)
+ assert mock_display.mock_calls[1][1][0] == "ansible_namespace.collection (0.1.0) was installed successfully"
+
+
+def test_install_collection_with_download(galaxy_server, collection_artifact, monkeypatch):
+ collection_tar = collection_artifact[1]
+ output_path = os.path.join(os.path.split(collection_tar)[0], b'output')
+ collection_path = os.path.join(output_path, b'ansible_namespace', b'collection')
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ mock_download = MagicMock()
+ mock_download.return_value = collection_tar
+ monkeypatch.setattr(collection, '_download_file', mock_download)
+
+ monkeypatch.setattr(galaxy_server, '_available_api_versions', {'v2': 'v2/'})
+ temp_path = os.path.join(os.path.split(collection_tar)[0], b'temp')
+ os.makedirs(temp_path)
+
+ meta = api.CollectionVersionMetadata('ansible_namespace', 'collection', '0.1.0', 'https://downloadme.com',
+ 'myhash', {})
+ req = collection.CollectionRequirement('ansible_namespace', 'collection', None, galaxy_server,
+ ['0.1.0'], '*', False, metadata=meta)
+ req.install(to_text(output_path), temp_path)
+
+ # Ensure the temp directory is empty, nothing is left behind
+ assert os.listdir(temp_path) == []
+
+ actual_files = os.listdir(collection_path)
+ actual_files.sort()
+ assert actual_files == [b'FILES.json', b'MANIFEST.json', b'README.md', b'docs', b'playbooks', b'plugins', b'roles',
+ b'runme.sh']
+
+ assert mock_display.call_count == 2
+ assert mock_display.mock_calls[0][1][0] == "Installing 'ansible_namespace.collection:0.1.0' to '%s'" \
+ % to_text(collection_path)
+ assert mock_display.mock_calls[1][1][0] == "ansible_namespace.collection (0.1.0) was installed successfully"
+
+ assert mock_download.call_count == 1
+ assert mock_download.mock_calls[0][1][0] == 'https://downloadme.com'
+ assert mock_download.mock_calls[0][1][1] == temp_path
+ assert mock_download.mock_calls[0][1][2] == 'myhash'
+ assert mock_download.mock_calls[0][1][3] is True
+
+
+def test_install_collections_from_tar(collection_artifact, monkeypatch):
+ collection_path, collection_tar = collection_artifact
+ temp_path = os.path.split(collection_tar)[0]
+ shutil.rmtree(collection_path)
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ collection.install_collections([(to_text(collection_tar), '*', None, None)], to_text(temp_path),
+ [u'https://galaxy.ansible.com'], True, False, False, False, False)
+
+ assert os.path.isdir(collection_path)
+
+ actual_files = os.listdir(collection_path)
+ actual_files.sort()
+ assert actual_files == [b'FILES.json', b'MANIFEST.json', b'README.md', b'docs', b'playbooks', b'plugins', b'roles',
+ b'runme.sh']
+
+ with open(os.path.join(collection_path, b'MANIFEST.json'), 'rb') as manifest_obj:
+ actual_manifest = json.loads(to_text(manifest_obj.read()))
+
+ assert actual_manifest['collection_info']['namespace'] == 'ansible_namespace'
+ assert actual_manifest['collection_info']['name'] == 'collection'
+ assert actual_manifest['collection_info']['version'] == '0.1.0'
+
+ # Filter out the progress cursor display calls.
+ display_msgs = [m[1][0] for m in mock_display.mock_calls if 'newline' not in m[2] and len(m[1]) == 1]
+ assert len(display_msgs) == 4
+ assert display_msgs[0] == "Process install dependency map"
+ assert display_msgs[1] == "Starting collection install process"
+ assert display_msgs[2] == "Installing 'ansible_namespace.collection:0.1.0' to '%s'" % to_text(collection_path)
+
+
+def test_install_collections_existing_without_force(collection_artifact, monkeypatch):
+ collection_path, collection_tar = collection_artifact
+ temp_path = os.path.split(collection_tar)[0]
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ # If we don't delete collection_path it will think the original build skeleton is installed so we expect a skip
+ collection.install_collections([(to_text(collection_tar), '*', None, None)], to_text(temp_path),
+ [u'https://galaxy.ansible.com'], True, False, False, False, False)
+
+ assert os.path.isdir(collection_path)
+
+ actual_files = os.listdir(collection_path)
+ actual_files.sort()
+ assert actual_files == [b'README.md', b'docs', b'galaxy.yml', b'playbooks', b'plugins', b'roles', b'runme.sh']
+
+ # Filter out the progress cursor display calls.
+ display_msgs = [m[1][0] for m in mock_display.mock_calls if 'newline' not in m[2] and len(m[1]) == 1]
+ assert len(display_msgs) == 3
+
+ assert display_msgs[0] == "Process install dependency map"
+ assert display_msgs[1] == "Starting collection install process"
+ assert display_msgs[2] == "Skipping 'ansible_namespace.collection' as it is already installed"
+
+ for msg in display_msgs:
+ assert 'WARNING' not in msg
+
+
+def test_install_missing_metadata_warning(collection_artifact, monkeypatch):
+ collection_path, collection_tar = collection_artifact
+ temp_path = os.path.split(collection_tar)[0]
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ for file in [b'MANIFEST.json', b'galaxy.yml']:
+ b_path = os.path.join(collection_path, file)
+ if os.path.isfile(b_path):
+ os.unlink(b_path)
+
+ collection.install_collections([(to_text(collection_tar), '*', None, None)], to_text(temp_path),
+ [u'https://galaxy.ansible.com'], True, False, False, False, False)
+
+ display_msgs = [m[1][0] for m in mock_display.mock_calls if 'newline' not in m[2] and len(m[1]) == 1]
+
+ assert 'WARNING' in display_msgs[0]
+
+
+# Makes sure we don't get stuck in some recursive loop
+@pytest.mark.parametrize('collection_artifact', [
+ {'ansible_namespace.collection': '>=0.0.1'},
+], indirect=True)
+def test_install_collection_with_circular_dependency(collection_artifact, monkeypatch):
+ collection_path, collection_tar = collection_artifact
+ temp_path = os.path.split(collection_tar)[0]
+ shutil.rmtree(collection_path)
+
+ mock_display = MagicMock()
+ monkeypatch.setattr(Display, 'display', mock_display)
+
+ collection.install_collections([(to_text(collection_tar), '*', None, None)], to_text(temp_path),
+ [u'https://galaxy.ansible.com'], True, False, False, False, False)
+
+ assert os.path.isdir(collection_path)
+
+ actual_files = os.listdir(collection_path)
+ actual_files.sort()
+ assert actual_files == [b'FILES.json', b'MANIFEST.json', b'README.md', b'docs', b'playbooks', b'plugins', b'roles',
+ b'runme.sh']
+
+ with open(os.path.join(collection_path, b'MANIFEST.json'), 'rb') as manifest_obj:
+ actual_manifest = json.loads(to_text(manifest_obj.read()))
+
+ assert actual_manifest['collection_info']['namespace'] == 'ansible_namespace'
+ assert actual_manifest['collection_info']['name'] == 'collection'
+ assert actual_manifest['collection_info']['version'] == '0.1.0'
+
+ # Filter out the progress cursor display calls.
+ display_msgs = [m[1][0] for m in mock_display.mock_calls if 'newline' not in m[2] and len(m[1]) == 1]
+ assert len(display_msgs) == 4
+ assert display_msgs[0] == "Process install dependency map"
+ assert display_msgs[1] == "Starting collection install process"
+ assert display_msgs[2] == "Installing 'ansible_namespace.collection:0.1.0' to '%s'" % to_text(collection_path)
+ assert display_msgs[3] == "ansible_namespace.collection (0.1.0) was installed successfully"
diff --git a/test/units/galaxy/test_token.py b/test/units/galaxy/test_token.py
new file mode 100644
index 00000000..94449e28
--- /dev/null
+++ b/test/units/galaxy/test_token.py
@@ -0,0 +1,55 @@
+# -*- coding: utf-8 -*-
+# Copyright: (c) 2019, Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+# Make coding more python3-ish
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import os
+import pytest
+
+import ansible.constants as C
+from ansible.galaxy.token import GalaxyToken, NoTokenSentinel
+from ansible.module_utils._text import to_bytes, to_text
+
+
+@pytest.fixture()
+def b_token_file(request, tmp_path_factory):
+ b_test_dir = to_bytes(tmp_path_factory.mktemp('test-ÅÑŚÌβŁÈ Token'))
+ b_token_path = os.path.join(b_test_dir, b"token.yml")
+
+ token = getattr(request, 'param', None)
+ if token:
+ with open(b_token_path, 'wb') as token_fd:
+ token_fd.write(b"token: %s" % to_bytes(token))
+
+ orig_token_path = C.GALAXY_TOKEN_PATH
+ C.GALAXY_TOKEN_PATH = to_text(b_token_path)
+ try:
+ yield b_token_path
+ finally:
+ C.GALAXY_TOKEN_PATH = orig_token_path
+
+
+def test_token_explicit(b_token_file):
+ assert GalaxyToken(token="explicit").get() == "explicit"
+
+
+@pytest.mark.parametrize('b_token_file', ['file'], indirect=True)
+def test_token_explicit_override_file(b_token_file):
+ assert GalaxyToken(token="explicit").get() == "explicit"
+
+
+@pytest.mark.parametrize('b_token_file', ['file'], indirect=True)
+def test_token_from_file(b_token_file):
+ assert GalaxyToken().get() == "file"
+
+
+def test_token_from_file_missing(b_token_file):
+ assert GalaxyToken().get() is None
+
+
+@pytest.mark.parametrize('b_token_file', ['file'], indirect=True)
+def test_token_none(b_token_file):
+ assert GalaxyToken(token=NoTokenSentinel).get() is None
diff --git a/test/units/galaxy/test_user_agent.py b/test/units/galaxy/test_user_agent.py
new file mode 100644
index 00000000..da0103f3
--- /dev/null
+++ b/test/units/galaxy/test_user_agent.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# Copyright: (c) 2019, Ansible Project
+# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
+
+from __future__ import (absolute_import, division, print_function)
+__metaclass__ = type
+
+import platform
+
+from ansible.galaxy import user_agent
+from ansible.module_utils.ansible_release import __version__ as ansible_version
+
+
+def test_user_agent():
+ res = user_agent.user_agent()
+ assert res.startswith('ansible-galaxy/%s' % ansible_version)
+ assert platform.system() in res
+ assert 'python:' in res