Completed
Pull Request — master (#2842)
by Edward
05:16
created

DownloadGitRepoAction   B

Complexity

Total Complexity 38

Size/Duplication

Total Lines 179
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
dl 0
loc 179
rs 8.3999
c 1
b 0
f 0
wmc 38

11 Methods

Rating   Name   Duplication   Size   Complexity  
A __init__() 0 2 1
B _eval_repo_url() 0 11 5
A _eval_repo_name() 0 15 2
A _is_desired_pack() 0 14 4
A _cleanup_repo() 0 5 2
A run() 0 17 3
A _clone_repo() 0 19 3
B _move_pack() 0 27 5
A _get_pack_name_and_url() 0 17 4
B _apply_pack_permissions() 0 20 5
A _validate_result() 0 21 4
1
# Licensed to the StackStorm, Inc ('StackStorm') under one or more
2
# contributor license agreements.  See the NOTICE file distributed with
3
# this work for additional information regarding copyright ownership.
4
# The ASF licenses this file to You under the Apache License, Version 2.0
5
# (the "License"); you may not use this file except in compliance with
6
# the License.  You may obtain a copy of the License at
7
#
8
#     http://www.apache.org/licenses/LICENSE-2.0
9
#
10
# Unless required by applicable law or agreed to in writing, software
11
# distributed under the License is distributed on an "AS IS" BASIS,
12
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
# See the License for the specific language governing permissions and
14
# limitations under the License.
15
16
import os
17
import shutil
18
import hashlib
19
import stat
20
21
import six
22
from git.repo import Repo
23
from lockfile import LockFile
24
25
from st2actions.runners.pythonrunner import Action
26
from st2common.services.packs import search_pack_index
27
from st2common.util.green import shell
28
29
MANIFEST_FILE = 'pack.yaml'
30
CONFIG_FILE = 'config.yaml'
31
GITINFO_FILE = '.gitinfo'
32
PACK_RESERVE_CHARACTER = '.'
33
PACK_VERSION_SEPARATOR = '#'
34
35
PACK_GROUP_CFG_KEY = 'pack_group'
36
37
38
class DownloadGitRepoAction(Action):
39
    def __init__(self, config=None, action_service=None):
40
        super(DownloadGitRepoAction, self).__init__(config=config, action_service=action_service)
41
42
    def run(self, packs, abs_repo_base, verifyssl=True):
43
        result = {}
44
45
        for pack in packs:
46
            pack_name, pack_url, pack_version = self._get_pack_name_and_url(pack)
47
48
            lock_name = hashlib.md5(pack_name).hexdigest() + '.lock'
49
50
            with LockFile('/tmp/%s' % (lock_name)):
51
                abs_local_path = self._clone_repo(repo_name=pack_name, repo_url=pack_url,
52
                                                  verifyssl=verifyssl, branch=pack_version)
53
                try:
54
                    result[pack_name] = self._move_pack(abs_repo_base, pack_name, abs_local_path)
55
                finally:
56
                    self._cleanup_repo(abs_local_path)
57
58
        return self._validate_result(result=result, repo_url=pack_url)
59
60
    @staticmethod
61
    def _clone_repo(repo_name, repo_url, verifyssl=True, branch=None):
62
        user_home = os.path.expanduser('~')
63
        abs_local_path = os.path.join(user_home, repo_name)
64
65
        # Switch to non-interactive mode
66
        os.environ['GIT_TERMINAL_PROMPT'] = '0'
67
68
        # Disable SSL cert checking if explictly asked
69
        if not verifyssl:
70
            os.environ['GIT_SSL_NO_VERIFY'] = 'true'
71
72
        if not branch:
73
            branch = 'master'
74
75
        # Shallow clone the repo to avoid getting all the metadata. We only need HEAD of a
76
        # specific branch so save some download time.
77
        Repo.clone_from(repo_url, abs_local_path, branch=branch, depth=1)
78
        return abs_local_path
79
80
    def _move_pack(self, abs_repo_base, pack_name, abs_local_path):
81
        desired, message = DownloadGitRepoAction._is_desired_pack(abs_local_path, pack_name)
82
        if desired:
83
            to = abs_repo_base
84
            dest_pack_path = os.path.join(abs_repo_base, pack_name)
85
            if os.path.exists(dest_pack_path):
86
                self.logger.debug('Removing existing pack %s in %s to replace.', pack_name,
87
                                  dest_pack_path)
88
89
                # Ensure to preserve any existing configuration
90
                old_config_file = os.path.join(dest_pack_path, CONFIG_FILE)
91
                new_config_file = os.path.join(abs_local_path, CONFIG_FILE)
92
93
                if os.path.isfile(old_config_file):
94
                    shutil.move(old_config_file, new_config_file)
95
96
                shutil.rmtree(dest_pack_path)
97
98
            self.logger.debug('Moving pack from %s to %s.', abs_local_path, to)
99
            shutil.move(abs_local_path, to)
100
            # post move fix all permissions.
101
            self._apply_pack_permissions(pack_path=dest_pack_path)
102
            message = 'Success.'
103
        elif message:
104
            message = 'Failure : %s' % message
105
106
        return (desired, message)
107
108
    def _apply_pack_permissions(self, pack_path):
109
        """
110
        Will recursively apply permission 770 to pack and its contents.
111
        """
112
        # 1. switch owner group to configured group
113
        pack_group = self.config.get(PACK_GROUP_CFG_KEY, None)
114
        if pack_group:
115
            shell.run_command(['sudo', 'chgrp', '-R', pack_group, pack_path])
116
117
        # 2. Setup the right permissions and group ownership
118
        # These mask is same as mode = 775
119
        mode = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH
120
        os.chmod(pack_path, mode)
121
122
        # Yuck! Since os.chmod does not support chmod -R walk manually.
123
        for root, dirs, files in os.walk(pack_path):
124
            for d in dirs:
125
                os.chmod(os.path.join(root, d), mode)
126
            for f in files:
127
                os.chmod(os.path.join(root, f), mode)
128
129
    @staticmethod
130
    def _is_desired_pack(abs_pack_path, pack_name):
131
        # path has to exist.
132
        if not os.path.exists(abs_pack_path):
133
            return (False, 'Pack "%s" not found or it\'s missing a "pack.yaml" file.' %
134
                    (pack_name))
135
        # should not include reserve characters
136
        if PACK_RESERVE_CHARACTER in pack_name:
137
            return (False, 'Pack name "%s" contains reserve character "%s"' %
138
                    (pack_name, PACK_RESERVE_CHARACTER))
139
        # must contain a manifest file. Empty file is ok for now.
140
        if not os.path.isfile(os.path.join(abs_pack_path, MANIFEST_FILE)):
141
            return (False, 'Pack is missing a manifest file (%s).' % (MANIFEST_FILE))
142
        return (True, '')
143
144
    @staticmethod
145
    def _cleanup_repo(abs_local_path):
146
        # basic lock checking etc?
147
        if os.path.isdir(abs_local_path):
148
            shutil.rmtree(abs_local_path)
149
150
    @staticmethod
151
    def _validate_result(result, repo_url):
152
        atleast_one_success = False
153
        sanitized_result = {}
154
        for k, v in six.iteritems(result):
155
            atleast_one_success |= v[0]
156
            sanitized_result[k] = v[1]
157
158
        if not atleast_one_success:
159
            message_list = []
160
            message_list.append('The pack has not been downloaded from "%s".\n' % (repo_url))
161
            message_list.append('Errors:')
162
163
            for pack, value in result.items():
164
                success, error = value
165
                message_list.append(error)
166
167
            message = '\n'.join(message_list)
168
            raise Exception(message)
169
170
        return sanitized_result
171
172
    @staticmethod
173
    def _get_pack_name_and_url(pack):
174
        try:
175
            name_or_url, version = pack.split(PACK_VERSION_SEPARATOR)
176
        except ValueError:
177
            name_or_url = pack
178
            version = None
179
180
        if len(name_or_url.split('/')) == 1:
181
            pack = search_pack_index(pack=name_or_url)
182
            if not pack:
183
                raise Exception('No record of the "%s" pack in the index.' % name_or_url)
184
            return (pack.name, pack.repo_url, version)
185
        else:
186
            return (DownloadGitRepoAction._eval_repo_name(name_or_url),
187
                    DownloadGitRepoAction._eval_repo_url(name_or_url),
188
                    version)
189
190
    @staticmethod
191
    def _eval_repo_url(repo_url):
192
        """Allow passing short GitHub style URLs"""
193
        if not repo_url:
194
            raise Exception('No valid repo_url provided or could be inferred.')
195
        has_git_extension = repo_url.endswith('.git')
196
        if len(repo_url.split('/')) == 2 and "git@" not in repo_url:
197
            url = "https://github.com/{}".format(repo_url)
198
        else:
199
            url = repo_url
200
        return url if has_git_extension else "{}.git".format(url)
201
202
    @staticmethod
203
    def _eval_repo_name(repo_url):
204
        """
205
        Evaluate the name of the repo.
206
        https://github.com/StackStorm/st2contrib.git -> st2contrib
207
        https://github.com/StackStorm/st2contrib -> st2contrib
208
        [email protected]:StackStorm/st2contrib.git -> st2contrib
209
        [email protected]:StackStorm/st2contrib -> st2contrib
210
        """
211
        last_forward_slash = repo_url.rfind('/')
212
        next_dot = repo_url.find('.', last_forward_slash)
213
        # If dot does not follow last_forward_slash return till the end
214
        if next_dot < last_forward_slash:
215
            return repo_url[last_forward_slash + 1:]
216
        return repo_url[last_forward_slash + 1:next_dot]
217