autodeploy.py 11.1 KB
Newer Older
Matthias's avatar
Matthias committed
1
from flask_restful import Resource
Matthias's avatar
Matthias committed
2
from flask import request as flaskRequest
Matthias's avatar
Matthias committed
3
4
import json
import subprocess
5
from re import match
6
from os import environ, listdir, path, getenv
7
8
from shutil import rmtree
from tempfile import mkdtemp
9
from autodeploy_service_app.app import app
Matthias's avatar
Matthias committed
10
11
12


class AutoDeploy(Resource):
Matthias's avatar
Matthias committed
13
    def post(self):
14
        '''
Matthias's avatar
Matthias committed
15
16
17
18
        Deploy on k8s
        ---
        tags:
          - AutoDeploy
Matthias's avatar
Matthias committed
19
        requestBody:
Matthias's avatar
Matthias committed
20
21
22
23
24
25
26
27
            required: true
        responses:
          200:
            description: Success, job report gets returned
            schema:
              properties:
                status:
                  type: string
28
29
                  example: success/failure/ignore
                  enum: ['success', 'failure', 'ignore']
Matthias's avatar
Matthias committed
30
31
32
33
34
35
36
37
38
39
40
41
                report:
                  type: string
                  example: 'institutionXYZ-235323B: something failed!\n
                    institutionXYZ-235323C: that one went totally wrong'
          500:
            description: Impossible to get results
            schema:
              properties:
                error:
                  type: string
                  example: Unexpected Kafka error

42
        '''
Matthias's avatar
Matthias committed
43

44
45
        status = 'ignore'
        body = ''
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
46
        output = []
Matthias's avatar
Matthias committed
47
48

        try:
49
50
            headers = flaskRequest.headers
            secure_token = getenv('SECURE_TOKEN')
51
            if secure_token and not headers.get('X-Gitlab-Token'):
52
53
                app.logger.info('Request does not have an X-Gitlab-Token in headers')
                return '{}', 403
54
            if secure_token and headers.get('X-Gitlab-Token').rstrip() is not secure_token.rstrip():
55
56
                app.logger.warning('Request does not have a valid X-Gitlab-Token in headers')
                return '{}', 403
57
58
59
60
61
            body = json.loads(flaskRequest.data.decode('utf-8'))
            tag = ''
            branch = ''
            if body['object_attributes']['tag']:
                tag = body['object_attributes']['ref']
62
            else:
63
64
                branch = body['object_attributes']['ref']
            branch = body['object_attributes']['ref']
65
66
67
68
69
            if body['object_attributes']['status'] == 'success':
                projectName = body['project']['path_with_namespace'].split('/')[-1]
                repositoryUrl = body['project']['git_http_url']
                repositoryPath = body['project']['path_with_namespace']
                app.logger.info('deploy-request received from ' + repositoryUrl)
70

71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
                # if tag = semver: deploy on prod+stage
                if match(r'^(([0-9]+)\.([0-9]+)\.([0-9]+))$', tag):
                    app.logger.debug(
                        'commit with semver-tag detected: installing on prod+stage'
                    )
                    pullChartUri = (
                        environ['GITLAB_REGISTRY']
                        + '/'
                        + repositoryPath
                        + ':'
                        + tag
                        + '-chart'
                    )
                    msgs, status = installFromRepo(pullChartUri)
                    output.extend(msgs)
                # deploy on test
                elif branch == 'master':
                    app.logger.debug(
                        'commit on master without semver-tag detected: installing on test'
                    )
                    msgs, status = installFromDir(repositoryUrl, projectName)
                    output.extend(msgs)
93

Matthias's avatar
Matthias committed
94
        except subprocess.CalledProcessError as ex:
95
            msg = 'command {} failed with return code {} -- STDOUT: {} -- STDERR: {}'.format(
96
                ex.cmd, ex.returncode, ex.stdout, ex.stderr
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
97
            )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
98
            output.append(msg)
99
            status = 'failure'
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
100
            app.logger.warning(msg)
Matthias's avatar
Matthias committed
101
        except json.JSONDecodeError as ex:
102
            msg = 'the json in the body could not be decoded: {}'.format(str(ex))
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
103
            output.append(msg)
104
            status = 'failure'
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
105
            app.logger.warning(msg)
Matthias's avatar
Matthias committed
106
        except Exception as ex:
107
            msg = 'an exception occured: {}'.format(str(ex))
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
108
            output.append(msg)
109
            status = 'failure'
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
110
            app.logger.warning(msg)
111
112
        returnVal = {'status': status, 'log': output, 'body': body}
        if status == 'success':
113
            app.logger.info(json.dumps(returnVal))
Matthias's avatar
Matthias committed
114
            return returnVal, 200
115
        elif status == 'ignore':
116
117
            app.logger.debug(json.dumps(returnVal))
            return returnVal, 500
Matthias's avatar
Matthias committed
118
        else:
119
            app.logger.info(json.dumps(returnVal))
Matthias's avatar
Matthias committed
120
            return returnVal, 500
121

Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
122

Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
123
def _upgrade_installation(chartsDir, projectName, filenameBase, filename=None):
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
124
    app.logger.debug(
125
126
        'upgrading new deployment for {}{}'.format(
            projectName, '' if filename is None else ': ' + filename
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
127
128
129
        )
    )
    try:
130
        cmd = 'helm upgrade -i {}-deployment {}{}'.format(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
131
            filenameBase,
132
            ''
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
133
            if filename is None
134
            else '-f {} '.format(path.join(chartsDir, 'helm-values', filename)),
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
135
136
            chartsDir,
        )
137
138
139
        proc = subprocess.run(
            cmd, shell=True, capture_output=True, text=True, check=True
        )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
140
        app.logger.info(
141
142
            'successfully installed helm chart{}'.format(
                '' if filename is None else ' with ' + filename
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
143
144
            )
        )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
145
        return (
146
            'installing {}-deployment\n{}\n{}'.format(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
147
148
                filenameBase, proc.stdout, proc.stderr
            ),
149
            'success',
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
150
        )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
151
    except subprocess.CalledProcessError as ex:
152
        msg = 'upgrading helm chart failed with return code {} -- STDOUT: {} -- STDERR: {}'.format(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
153
            ex.returncode, ex.stdout, ex.stderr
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
154
        )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
155
        app.logger.warning(msg)
156
        return msg, 'failure'
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
157
158


159
def installFromRepo(pullChartUri):
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
160
    output = []
161
162
    status = ''
    app.logger.debug('pulling helm charts')
163
    try:
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
164
        proc = subprocess.run(
165
            'helm chart pull ' + pullChartUri,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
166
167
168
            shell=True,
            capture_output=True,
            text=True,
169
            check=True,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
170
        )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
171
        output.append(
172
            'pulling charts from {}: {} (stderr: {})'.format(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
173
174
                pullChartUri, proc.stdout, proc.stderr
            )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
175
        )
176
    except subprocess.CalledProcessError as ex:
177
        msg = 'pulling helm chart failed with return code {} -- STDOUT: {} -- STDERR: {}'.format(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
178
            ex.returncode, ex.stdout, ex.stderr
179
180
        )
        app.logger.warning(msg)
181
        return output, 'failure'
182
    pulledChartsDir = mkdtemp()
183
    app.logger.debug('exporting helm charts')
184
    try:
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
185
        proc = subprocess.run(
186
            'helm chart export ' + pullChartUri + ' -d ' + pulledChartsDir,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
187
188
189
            shell=True,
            capture_output=True,
            text=True,
190
            check=True,
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
191
        )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
192
        output.append(
193
            'exporting charts to local directory: {} (stderr: {})'.format(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
194
195
                proc.stdout, proc.stderr
            )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
196
        )
197
    except subprocess.CalledProcessError as ex:
198
        msg = 'exporting helm chart failed with return code {} -- STDOUT: {} -- STDERR: {}'.format(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
199
            ex.returncode, ex.stdout, ex.stderr
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
200
        )
201
        app.logger.warning(msg)
202
        return output, 'failure'
203
    projectName = listdir(pulledChartsDir)[0]
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
204
205
    helmChartDir = path.join(pulledChartsDir, projectName)
    helmValuesDir = path.join(helmChartDir, 'helm-values')
206
    if path.exists(helmValuesDir):
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
207
        app.logger.debug('helm value files detected')
208
        valuesFound = False
209
210
        for filename in listdir(helmValuesDir):
            filenameBase = path.splitext(filename)[0]
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
211
            if '-prod.' in filename and status != 'failure':
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
212
213
214
                app.logger.info(
                    'upgrading helm chart {} for prod environment'.format(projectName)
                )
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
215
                msg, status = _upgrade_installation(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
216
                    helmChartDir, projectName, filenameBase, filename
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
217
218
                )
                output.append(msg)
219
                valuesFound = True
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
220
            elif '-stage.' in filename and status != 'failure':
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
221
222
223
                app.logger.info(
                    'upgrading helm chart {} for stage environment'.format(projectName)
                )
224
                msg, status = _upgrade_installation(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
225
                    helmChartDir, projectName, filenameBase, filename
226
227
                )
                output.append(msg)
228
229
230
231
232
233
                valuesFound = True
        if not valuesFound:
            msg = 'no helm chart for prod or stage environment found'
            app.logger.info(msg)
            output.append(msg)
            status = 'ignore'
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
234
235
236
237
238
    else:
        msg = 'Could not find path to exported helm chart'
        app.logger.warn(msg)
        output.append(msg)
        status = 'failure'
239
    rmtree(pulledChartsDir)
240
    try:
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
241
242
243
244
245
246
247
        proc = subprocess.run(
            'helm chart remove {}'.format(pullChartUri),
            shell=True,
            capture_output=True,
            text=True,
            check=True,
        )
248
    except subprocess.CalledProcessError as ex:
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
249
250
251
252
253
        msg = (
            'removing helm chart from local repository'
            + 'failed with return code {} -- STDOUT: {} -- STDERR: {}'.format(
                ex.returncode, ex.stdout, ex.stderr
            )
254
255
        )
        app.logger.warning(msg)
256
257
258
259
260
    return output, status


def installFromDir(repositoryUrl, projectName):
    output = []
261
262
    status = ''
    app.logger.debug('cloning repository')
263
    repoDir = mkdtemp()
264
    try:
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
265
        subprocess.run(
266
            'git clone {} {}'.format(repositoryUrl, repoDir),
267
268
269
270
271
            shell=True,
            capture_output=True,
            text=True,
            check=True,
        )
272
        output.append('git clone {}'.format(repositoryUrl))
273
    except subprocess.CalledProcessError as ex:
274
        msg = 'cloning git repo failed with return code {} -- STDOUT: {} -- STDERR: {}'.format(
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
275
            ex.returncode, ex.stdout, ex.stderr
276
277
        )
        app.logger.warning(msg)
278
        return output, 'failure'
279
280

    helmChartDir = path.join(repoDir, 'helm-charts')
281
282
283
284
285
    if not path.exists(helmChartDir):
        msg = 'No helm-charts directory exists'
        app.logger.warn(msg)
        output.append(msg)
        return output, 'failure'
286
287
    helmValueDir = path.join(helmChartDir, 'helm-values')
    if path.exists(helmValueDir):
288
        app.logger.debug('helm value files detected')
289
290
        for filename in listdir(helmValueDir):
            filenameBase = path.splitext(filename)[0]
291
            if '-test.' in filename:
292
293
294
                app.logger.info(
                    'upgrading helm chart {} for test environment'.format(projectName)
                )
295
                msg, status = _upgrade_installation(
296
                    helmChartDir, projectName, filenameBase, filename
297
298
299
300
                )
                output.append(msg)
    else:
        # this section should be obsolete since we shouldn't have helm-charts w/o valuefiles
301
        app.logger.warn('no helm value files detected. this is DEPRECATED')
302
        msg, status = _upgrade_installation(helmChartDir, projectName, projectName)
303
        output.append(msg)
304
    app.logger.debug('Removing temp dir')
305
    rmtree(repoDir)
Sebastian Schüpbach's avatar
Sebastian Schüpbach committed
306
    return output, status