Skip to content

Commit 2d45924

Browse files
committed
added gitlab support and param over-rides. mostly copied from custom script package
1 parent ce244ce commit 2d45924

10 files changed

Lines changed: 320 additions & 23 deletions

README.md

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,33 @@
22
[![Code Climate](https://codeclimate.com/github/QualiSystems/Ansible-Shell/badges/gpa.svg)](https://codeclimate.com/github/QualiSystems/Ansible-Shell)
33
[![Dependency Status](https://dependencyci.com/github/QualiSystems/Ansible-Shell/badge)](https://dependencyci.com/github/QualiSystems/Ansible-Shell)
44

5-
# Ansible-Shell
6-
A CloudShell 'Shell' that allows integration with Ansible.
5+
# Ansible-Shell-Extended
6+
A CloudShell 'Shell' that allows integration with Ansible. This is an extended repo of the official configuration management package.
7+
Custom changes to driver and package have been added.
8+
9+
## Custom Param Overrides
10+
The following configuration management parameters can be over-ridden by adding the following:
11+
- REPO_URL
12+
- REPO_USER
13+
- REPO_PASSWORD (will be a plain text parameter)
14+
- CONNECTION_METHOD
15+
16+
## Gitlab Support
17+
- Gitlab links are supported, but for Private Repos require the URL to be in format of their REST api
18+
- http://<SERVER_IP>/api/<API_VERSION>/projects/<PROJECT_ID>/repository/files/<PROJECT_PATH>/raw?ref=<GIT_BRANCH>
19+
- example - http://10.160.7.7/api/v4/projects/4/repository/files/hello_world.sh/raw?ref=master
20+
- The password field needs to be populated with gitlab access token, which will be sent along with request as header
21+
- Access Token only needed for private repos, password field can be left blank for public repos
22+
- The "User" field can be left blank for gitlab auth. Only access token needed
23+
- Public Repos appear to work fine with both "raw" url link as well as the API formatted URL with no token
24+
- Gitlab docs - https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html
25+
26+
## To Install
27+
- Download python package from releases and place in local pypi server on Quali Server
28+
- Path: C:\Program Files (x86)\QualiSystems\CloudShell\Server\Config\Pypi Server Repository
29+
- Delete venv (if it exists) to force creation of new venv with updated package
30+
- Path: C:\ProgramData\QualiSystems\venv\Ansible_Driver_{{driver_uid}}
31+
32+
## Changelog
33+
- 25/06/2020 - Extending package to disable SSL Verification
34+
- 25/12/2020 - Added Gitlab Support & Parameter Over-rides

package/cloudshell/cm/ansible/ansible_shell.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from cloudshell.cm.ansible.domain.exceptions import AnsibleException
77
from cloudshell.cm.ansible.domain.ansible_command_executor import AnsibleCommandExecutor, ReservationOutputWriter
88
from cloudshell.cm.ansible.domain.ansible_config_file import AnsibleConfigFile
9-
from cloudshell.cm.ansible.domain.ansible_configuration import AnsibleConfigurationParser
9+
from cloudshell.cm.ansible.domain.ansible_configuration import AnsibleConfigurationParser, AnsibleConfiguration
1010
from cloudshell.cm.ansible.domain.file_system_service import FileSystemService
1111
from cloudshell.cm.ansible.domain.filename_extractor import FilenameExtractor
1212
from cloudshell.cm.ansible.domain.host_vars_file import HostVarsFile
@@ -118,7 +118,8 @@ def _download_playbook(self, ansi_conf, cancellation_sampler, logger):
118118
:rtype str
119119
"""
120120
repo = ansi_conf.playbook_repo
121-
auth = HttpAuth(repo.username, repo.password) if repo.username else None
121+
# we need password field to be passed for gitlab auth tokens (which require token and not user)
122+
auth = HttpAuth(repo.username, repo.password) if repo.password else None
122123
playbook_name = self.downloader.get(ansi_conf.playbook_repo.url, auth, logger, cancellation_sampler)
123124
return playbook_name
124125

package/cloudshell/cm/ansible/domain/ansible_configuration.py

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
import json
2-
32
from cloudshell.api.cloudshell_api import CloudShellAPISession
43

54

5+
# OPTIONAL SCRIPT PARAMETERS, IF PRESENT WILL OVERRIDE THE DEFAULT READ-ONLY VALUES
6+
REPO_URL_PARAM = "REPO_URL"
7+
REPO_USER_PARAM = "REPO_USER"
8+
REPO_PASSWORD_PARAM = "REPO_PASSWORD"
9+
CONNECTION_METHOD_PARAM = "CONNECTION_METHOD"
10+
11+
612
class AnsibleConfiguration(object):
713
def __init__(self, playbook_repo=None, hosts_conf=None, additional_cmd_args=None, timeout_minutes = None):
814
"""
@@ -16,6 +22,9 @@ def __init__(self, playbook_repo=None, hosts_conf=None, additional_cmd_args=None
1622
self.hosts_conf = hosts_conf or []
1723
self.additional_cmd_args = additional_cmd_args
1824

25+
def get_pretty_json(self):
26+
return json.dumps(self, default=lambda o: getattr(o, '__dict__', str(o)), indent=4)
27+
1928

2029
class PlaybookRepository(object):
2130
def __init__(self):
@@ -36,6 +45,29 @@ def __init__(self):
3645
self.parameters = {}
3746

3847

48+
def over_ride_defaults(ansi_conf, params_dict):
49+
"""
50+
go over custom params and over-ride values
51+
:param AnsibleConfiguration ansi_conf:
52+
:param dict params_dict:
53+
:return same config:
54+
:rtype AnsibleConfiguration
55+
"""
56+
if params_dict.get(REPO_URL_PARAM):
57+
ansi_conf.playbook_repo.url = params_dict[REPO_URL_PARAM]
58+
59+
if params_dict.get(REPO_USER_PARAM):
60+
ansi_conf.playbook_repo.username = params_dict[REPO_USER_PARAM]
61+
62+
if params_dict.get(REPO_PASSWORD_PARAM):
63+
ansi_conf.playbook_repo.password = params_dict[REPO_PASSWORD_PARAM]
64+
65+
if params_dict.get(CONNECTION_METHOD_PARAM):
66+
ansi_conf.hosts_conf[0].connection_method = params_dict[CONNECTION_METHOD_PARAM].lower()
67+
68+
return ansi_conf
69+
70+
3971
class AnsibleConfigurationParser(object):
4072

4173
def __init__(self, api):
@@ -72,7 +104,9 @@ def json_to_object(self, json_str):
72104
host_conf.access_key = self._get_access_key(json_host)
73105
host_conf.groups = json_host.get('groups')
74106
if json_host.get('parameters'):
75-
host_conf.parameters = dict((i['name'], i['value']) for i in json_host['parameters'])
107+
all_params_dict = dict((i['name'], i['value']) for i in json_host['parameters'])
108+
host_conf.parameters = all_params_dict
109+
ansi_conf = over_ride_defaults(ansi_conf, all_params_dict)
76110
ansi_conf.hosts_conf.append(host_conf)
77111

78112
return ansi_conf
@@ -94,7 +128,7 @@ def _get_access_key(self, json_host):
94128
@staticmethod
95129
def _validate(json_obj):
96130
"""
97-
:type json_obj: json
131+
:type json_obj: dict
98132
:rtype bool
99133
"""
100134
basic_msg = 'Failed to parse ansible configuration input json: '

package/cloudshell/cm/ansible/domain/filename_extractor.py

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,43 @@ class FilenameExtractor(object):
88
def __init__(self):
99
self._filename_pattern = "(?P<filename>\s*[\w,\s-]+\.(yaml|yml|zip)\s*)"
1010
self.filename_patterns = {
11-
"content-disposition": "\s*((?i)inline|attachment|extension-token)\s*;\s*filename=" + self._filename_pattern,
12-
"x-artifactory-filename": self._filename_pattern
11+
"content-disposition": "\s*((?i)inline|attachment|extension-token)\s*;\s*filename=" +
12+
self._filename_pattern,
13+
"x-artifactory-filename": self._filename_pattern,
14+
"X-Gitlab-File-Name": self._filename_pattern
1315
}
1416

15-
def get_filename(self,response):
16-
file_name = None;
17+
def get_filename(self, response):
18+
file_name = None
1719
for header_value, pattern in self.filename_patterns.iteritems():
18-
matching = re.match(pattern, response.headers.get(header_value,""))
20+
matching = re.match(pattern, response.headers.get(header_value, ""))
1921
if matching:
2022
file_name = matching.group('filename')
21-
break
22-
#fallback, couldn't find file name from header, get it from url
23+
if file_name:
24+
return file_name.strip()
25+
26+
# Fallback, couldn't find file name from header, get it from url
27+
28+
# === raw url search ===
29+
# ex. - https://raw.githubusercontent.com/QualiSystemsLab/master/my_playbook.yml
2330
if not file_name:
2431
file_name_from_url = urllib.unquote(response.url[response.url.rfind('/') + 1:])
2532
matching = re.match(self._filename_pattern, file_name_from_url)
2633
if matching:
2734
file_name = matching.group('filename')
28-
if not file_name:
29-
raise AnsibleException("playbook file of supported types: '.yml', '.yaml', '.zip' was not found")
30-
return file_name.strip()
35+
if file_name:
36+
return file_name.strip()
37+
38+
# === Gitlab REST url search ===
39+
# ex. - 'http://192.168.85.62/api/v4/projects/2/repository/files/my_playbook.yml/raw?ref=master'
40+
url_split = response.url.split("/")
41+
if url_split >= 2:
42+
file_name_from_url = url_split[-2]
43+
matching = re.match(self._filename_pattern, file_name_from_url)
44+
if matching:
45+
file_name = matching.group('filename')
46+
if file_name:
47+
return file_name.strip()
48+
49+
raise AnsibleException("playbook file of supported types: '.yml', '.yaml', '.zip' was not found")
3150

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import re
2+
3+
4+
def is_gitlab_rest_url(url):
5+
""""
6+
should be of the following form:
7+
http://192.168.85.62/api/{api_version}/projects/{project_id}/repository/files/{file_path}/raw?ref={git branch}
8+
ex input - http://192.168.85.62/api/v4/projects/4/repository/files/hello_world.sh/raw?ref=master
9+
:param str url: the user input url
10+
"""
11+
EXAMPLE = "http://<SERVER_IP>/api/4/projects/<PROJECT_ID>/repository/files/<PROJECT_PATH>/raw?ref=<GIT_BRANCH>"
12+
13+
# DETERMINE INTENT TO USE GITLAB
14+
initial_pattern_check = "api/v\d/projects/\d/repository/files"
15+
matching = re.search(initial_pattern_check, url)
16+
if not matching:
17+
return False
18+
19+
# VALIDATE ENTIRE GITLAB URL STRING
20+
gitlab_api_pattern = "https?://.+/api/v\d/projects/\d/repository/files/.+/raw\?ref=.+"
21+
matching = re.match(gitlab_api_pattern, url)
22+
if not matching:
23+
raise Exception("Gitlab Rest API URL failed validation. Should be of form: {}".format(EXAMPLE))
24+
25+
return True
26+
27+
28+
if __name__ == "__main__":
29+
input_url = "http://192.168.85.62/api/v4/projects/4/repository/files/hello_world.sh/raw?ref=master"
30+
is_gitlab = is_gitlab_rest_url(input_url)
31+
print(is_gitlab)
Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,66 @@
11
import requests
2+
from requests import Response
3+
from cloudshell.cm.ansible.domain.gitlab_api_url_validator import is_gitlab_rest_url
24

35

46
class HttpRequestService(object):
5-
def get_response(self, url, auth):
6-
# return requests.get(url, auth=(auth.username, auth.password) if auth else None, stream=True)
7-
return requests.get(url, auth=(auth.username, auth.password) if auth else None, stream=True, verify=False)
7+
def get_response(self, url, auth, logger=None):
8+
"""
9+
:param url:
10+
:param auth:
11+
:param logging.Logger logger:
12+
:return:
13+
"""
14+
is_gitlab_url = is_gitlab_rest_url(url)
15+
if is_gitlab_url:
16+
response = self._get_gitlab_response(url, auth, logger)
17+
self._validate_response_status_code(response)
18+
self._invalidate_gitlab_login_page(response)
19+
else:
20+
auth = (auth.username, auth.password) if auth else None
21+
response = requests.get(url, auth=auth, stream=True, verify=False)
22+
self._validate_response_status_code(response)
23+
self._invalidate_html(response.content)
24+
return response
25+
26+
@staticmethod
27+
def _get_gitlab_response(url, auth, logger=None):
28+
"""
29+
:param url:
30+
:param auth:
31+
:param logging.Logger logger:
32+
:return:
33+
"""
34+
# GITLAB REST API CALL - ADDING TOKEN HEADER
35+
if logger:
36+
logger.info("downloading script via Gitlab Rest API call: {}".format(url))
37+
if auth:
38+
headers = {"PRIVATE-TOKEN": auth.password}
39+
return requests.get(url, stream=True, verify=False, headers=headers)
40+
else:
41+
return requests.get(url, stream=True, verify=False)
42+
43+
@staticmethod
44+
def _validate_response_status_code(response):
45+
if 200 > response.status_code > 300:
46+
raise Exception('Failed to download script file: ' + str(response.status_code) + ' ' + response.reason +
47+
'. Please make sure the URL is valid, and the credentials are correct and necessary.')
48+
49+
@staticmethod
50+
def _is_content_html(content):
51+
return content.lstrip('\n\r').lower().startswith('<!doctype html>')
52+
53+
def _invalidate_html(self, content):
54+
if self._is_content_html(content):
55+
raise Exception('Failed to download script file: url points to an html file')
56+
57+
def _invalidate_gitlab_login_page(self, response):
58+
"""
59+
60+
:param Response response: requests response object
61+
:return:
62+
"""
63+
if self._is_content_html(response.content) and "users/sign_in" in response.url:
64+
raise Exception('Authentication failed. Reached Gitlab Login. Gitlab Access Token required.')
65+
66+

package/cloudshell/cm/ansible/domain/playbook_downloader.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22

33
from cloudshell.cm.ansible.domain.cancellation_sampler import CancellationSampler
4+
from cloudshell.cm.ansible.domain.http_request_service import HttpRequestService
45
from file_system_service import FileSystemService
56
from logging import Logger
67

@@ -17,6 +18,9 @@ class PlaybookDownloader(object):
1718
def __init__(self, file_system, zip_service, http_request_service, filename_extractor):
1819
"""
1920
:param FileSystemService file_system:
21+
:param zip_service:
22+
:param HttpRequestService http_request_service:
23+
:param filename_extractor:
2024
"""
2125
self.file_system = file_system
2226
self.zip_service = zip_service
@@ -51,7 +55,7 @@ def _download(self, url, auth, logger, cancel_sampler):
5155
:return The downloaded file name
5256
"""
5357
logger.info('Downloading file from \'%s\' ...'%url)
54-
response = self.http_request_service.get_response(url, auth)
58+
response = self.http_request_service.get_response(url, auth, logger)
5559
file_name = self.filename_extractor.get_filename(response)
5660

5761
with self.file_system.create_file(file_name) as file:

package/requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,3 @@ cloudshell-shell-core>=3.1.0,<3.2.0
33
requests==2.14.2
44
pywinrm==0.2.2
55
paramiko==2.1.2
6-

0 commit comments

Comments
 (0)