Browse Source

CLI: Add support for uploading documents to Shipyard

This PS enables Pegleg to upload documents directly to Shipyard
thus ensuring that unencrypted data never gets stored in disk.

The flow for this new CLI command is as follows:

- Collect documents as per the provided site repository
- Decrypt the collected documets(TODO)
- Upload document to Shipyard:
  - one collection per repository will be uploaded to Shipyard

    Eg-
        pegleg site -r /opt/aic-clcp-site-manifests \
        -e global=/opt/aic-clcp-manifests upload <site-name>

        Two collections will be created in shipyard since there are two
        repositories provided. The name of the collections will be the
        name of repositories provided.
  - Commit the documents in shipyard buffer.

Change-Id: I6275252b044ebb82d8bb2009c0bea6ebf7033bce
changes/46/609546/41
Nishant Kumar 8 months ago
parent
commit
9113d249ff

+ 40
- 0
doc/source/cli/cli.rst View File

@@ -389,6 +389,44 @@ A more complex example involves excluding certain linting checks:
389 389
     lint <site_name> \
390 390
     -x P001 -x P002 -w P003
391 391
 
392
+Upload
393
+-------
394
+
395
+Uploads documents to `Shipyard`_.
396
+
397
+**site_name** (Required).
398
+
399
+Name of the site. The ``site_name`` must match a ``site`` name in the site
400
+repository folder structure
401
+
402
+**--os-<various>=<value>** (Required).
403
+
404
+Shipyard needs these options for authenticating with OpenStack Keystone.
405
+This option can be set as environment variables or it can be passed via
406
+the command line.
407
+
408
+Please reference Shipyard's `CLI documentation`_ for information related to these options.
409
+
410
+**--context-marker=<uuid>** (Optional).
411
+
412
+Specifies a UUID (8-4-4-4-12 format) that will be used to correlate logs,
413
+transactions, etc. in downstream activities triggered by this interaction.
414
+
415
+Usage:
416
+
417
+::
418
+
419
+    ./pegleg.sh site <options> upload <site_name> --context-marker=<uuid>
420
+
421
+Examples
422
+^^^^^^^^
423
+
424
+::
425
+
426
+    ./pegleg.sh site -r <site_repo> -e <extra_repo> \
427
+      upload <site_name> <options>
428
+
429
+
392 430
 .. _command-line-repository-overrides:
393 431
 
394 432
 Secrets
@@ -641,3 +679,5 @@ P003 - All repos contain expected directories.
641 679
 .. _Deckhand: https://airship-deckhand.readthedocs.io/en/latest/users/rendering.html
642 680
 .. _Deckhand Validations: https://airship-deckhand.readthedocs.io/en/latest/overview.html#validation
643 681
 .. _Pegleg Managed Documents: https://airship-specs.readthedocs.io/en/latest/specs/approved/pegleg-secrets.html#peglegmanageddocument
682
+.. _Shipyard: https://github.com/openstack/airship-shipyard
683
+.. _CLI documentation: https://airship-shipyard.readthedocs.io/en/latest/CLI.html#openstack-keystone-authorization-environment-variables

+ 7
- 0
doc/source/exceptions.rst View File

@@ -56,3 +56,10 @@ Git Exceptions
56 56
    :members:
57 57
    :show-inheritance:
58 58
    :undoc-members:
59
+
60
+Authentication Exceptions
61
+-------------------------
62
+
63
+.. autoexception:: pegleg.engine.util.shipyard_helper.AuthValuesError
64
+   :members:
65
+   :undoc-members:

+ 51
- 0
pegleg/cli.py View File

@@ -20,6 +20,7 @@ import click
20 20
 
21 21
 from pegleg import config
22 22
 from pegleg import engine
23
+from pegleg.engine.util.shipyard_helper import ShipyardHelper
23 24
 
24 25
 LOG = logging.getLogger(__name__)
25 26
 
@@ -325,6 +326,56 @@ def lint_site(*, fail_on_missing_sub_src, exclude_lint, warn_lint, site_name):
325 326
         warn_lint=warn_lint)
326 327
 
327 328
 
329
+@site.command('upload', help='Upload documents to Shipyard')
330
+# Keystone authentication parameters
331
+@click.option('--os-project-domain-name',
332
+              envvar='OS_PROJECT_DOMAIN_NAME',
333
+              required=False,
334
+              default='default')
335
+@click.option('--os-user-domain-name',
336
+              envvar='OS_USER_DOMAIN_NAME',
337
+              required=False,
338
+              default='default')
339
+@click.option('--os-project-name', envvar='OS_PROJECT_NAME', required=False)
340
+@click.option('--os-username', envvar='OS_USERNAME', required=False)
341
+@click.option('--os-password', envvar='OS_PASSWORD', required=False)
342
+@click.option(
343
+    '--os-auth-url', envvar='OS_AUTH_URL', required=False)
344
+# Option passed to Shipyard client context
345
+@click.option(
346
+    '--context-marker',
347
+    help='Specifies a UUID (8-4-4-4-12 format) that will be used to correlate '
348
+    'logs, transactions, etc. in downstream activities triggered by this '
349
+    'interaction ',
350
+    required=False,
351
+    type=click.UUID)
352
+@SITE_REPOSITORY_ARGUMENT
353
+@click.pass_context
354
+def upload(ctx, *, os_project_domain_name,
355
+           os_user_domain_name, os_project_name, os_username,
356
+           os_password, os_auth_url, context_marker, site_name):
357
+    if not ctx.obj:
358
+        ctx.obj = {}
359
+
360
+    # Build API parameters required by Shipyard API Client.
361
+    auth_vars = {
362
+        'project_domain_name': os_project_domain_name,
363
+        'user_domain_name': os_user_domain_name,
364
+        'project_name': os_project_name,
365
+        'username': os_username,
366
+        'password': os_password,
367
+        'auth_url': os_auth_url
368
+    }
369
+
370
+    ctx.obj['API_PARAMETERS'] = {
371
+        'auth_vars': auth_vars
372
+    }
373
+    ctx.obj['context_marker'] = str(context_marker)
374
+    ctx.obj['site_name'] = site_name
375
+
376
+    click.echo(ShipyardHelper(ctx).upload_documents())
377
+
378
+
328 379
 @main.group(help='Commands related to types')
329 380
 @MAIN_REPOSITORY_OPTION
330 381
 @REPOSITORY_CLONE_PATH_OPTION

+ 15
- 0
pegleg/engine/util/files.py View File

@@ -13,11 +13,13 @@
13 13
 # limitations under the License.
14 14
 
15 15
 import click
16
+import collections
16 17
 import os
17 18
 import yaml
18 19
 import logging
19 20
 
20 21
 from pegleg import config
22
+from pegleg.engine import util
21 23
 from pegleg.engine.util import pegleg_managed_document as md
22 24
 
23 25
 LOG = logging.getLogger(__name__)
@@ -36,6 +38,7 @@ __all__ = [
36 38
     'search',
37 39
     'slurp',
38 40
     'check_file_save_location',
41
+    'collect_files_by_repo',
39 42
 ]
40 43
 
41 44
 DIR_DEPTHS = {
@@ -366,3 +369,15 @@ def check_file_save_location(save_location):
366 369
             raise click.ClickException(
367 370
                 'save_location %s already exists, '
368 371
                 'but is not a directory'.format(save_location))
372
+
373
+
374
+def collect_files_by_repo(site_name):
375
+    """ Collects file by repo name in memory."""
376
+
377
+    collected_files_by_repo = collections.defaultdict(list)
378
+    for repo_base, filename in util.definition.site_files_by_repo(
379
+            site_name):
380
+        repo_name = os.path.normpath(repo_base).split(os.sep)[-1]
381
+        documents = util.files.read(filename)
382
+        collected_files_by_repo[repo_name].extend(documents)
383
+    return collected_files_by_repo

+ 181
- 0
pegleg/engine/util/shipyard_helper.py View File

@@ -0,0 +1,181 @@
1
+# Copyright 2018 AT&T Intellectual Property.  All other rights reserved.
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License");
4
+# you may not use this file except in compliance with the License.
5
+# You may obtain a copy of the License at
6
+#
7
+#     http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS,
11
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+# See the License for the specific language governing permissions and
13
+# limitations under the License.
14
+
15
+import json
16
+import logging
17
+import uuid
18
+
19
+import yaml
20
+
21
+from pegleg.engine.util import files
22
+from pegleg.engine.exceptions import PeglegBaseException
23
+
24
+from shipyard_client.api_client.shipyard_api_client import ShipyardClient
25
+from shipyard_client.api_client.shipyardclient_context import \
26
+    ShipyardClientContext
27
+
28
+LOG = logging.getLogger(__name__)
29
+
30
+
31
+class AuthValuesError(PeglegBaseException):
32
+    """Shipyard authentication failed. """
33
+
34
+    def __init__(self, *, diagnostic):
35
+        self.diagnostic = diagnostic
36
+
37
+
38
+class DocumentUploadError(PeglegBaseException):
39
+    """ Exception occurs while uploading documents"""
40
+
41
+    def __init__(self, message):
42
+        self.message = message
43
+
44
+
45
+class ShipyardHelper(object):
46
+    """
47
+    A helper class for Shipyard. It performs the following operation:
48
+    1. Validates the authentication parameters required for Keystone
49
+    2. Uploads the document to Shipyard buffer
50
+    3. Commits the document
51
+    4. Formats response from Shipyard api_client
52
+    """
53
+
54
+    def __init__(self, context):
55
+        """
56
+        Initializes params to be used by Shipyard
57
+
58
+        :param context: ShipyardHelper context object that contains
59
+                        params for initializing ShipyardClient with
60
+                        correct client context and the site_name.
61
+        """
62
+        self.ctx = context
63
+        self.api_parameters = self.ctx.obj['API_PARAMETERS']
64
+        self.auth_vars = self.api_parameters.get('auth_vars')
65
+        self.context_marker = self.ctx.obj['context_marker']
66
+        if self.context_marker is None:
67
+            self.context_marker = str(uuid.uuid4())
68
+            LOG.debug("context_marker is %s" % self.context_marker)
69
+        self.site_name = self.ctx.obj['site_name']
70
+        self.client_context = ShipyardClientContext(
71
+            self.auth_vars, self.context_marker)
72
+        self.api_client = ShipyardClient(self.client_context)
73
+
74
+    def upload_documents(self):
75
+        """ Uploads documents to Shipyard """
76
+
77
+        collected_documents = files.collect_files_by_repo(self.site_name)
78
+
79
+        LOG.info("Uploading %s collection(s) " % len(collected_documents))
80
+        for idx, document in enumerate(collected_documents):
81
+            # Append flag is not required for the first
82
+            # collection being uploaded to Shipyard. It
83
+            # is needed for subsequent collections.
84
+            if idx == 0:
85
+                buffer_mode = None
86
+            else:
87
+                buffer_mode = 'append'
88
+
89
+            data = yaml.safe_dump_all(collected_documents[document])
90
+
91
+            try:
92
+                self.validate_auth_vars()
93
+                # Get current buffer status.
94
+                response = self.api_client.get_configdocs_status()
95
+                buff_stat = response.json()
96
+                # If buffer is empty then proceed with existing buffer value
97
+                # else pass the 'replace' flag.
98
+                for stat in range(len(buff_stat)):
99
+                    if (buff_stat[stat]['new_status'] != 'unmodified' and
100
+                            buffer_mode != 'append'):
101
+                        buffer_mode = 'replace'
102
+                resp_text = self.api_client.post_configdocs(
103
+                    collection_id=document,
104
+                    buffer_mode=buffer_mode,
105
+                    document_data=data
106
+                )
107
+
108
+            except AuthValuesError as ave:
109
+                resp_text = "Error: {}".format(ave.diagnostic)
110
+                raise DocumentUploadError(resp_text)
111
+            except Exception as ex:
112
+                resp_text = (
113
+                    "Error: Unable to invoke action due to: {}"
114
+                    .format(str(ex)))
115
+                LOG.debug(resp_text, exc_info=True)
116
+                raise DocumentUploadError(resp_text)
117
+
118
+            # FIXME: Standardize status_code in Deckhand to avoid this
119
+            # workaround.
120
+            code = 0
121
+            if hasattr(resp_text, 'status_code'):
122
+                code = resp_text.status_code
123
+            elif hasattr(resp_text, 'code'):
124
+                code = resp_text.code
125
+            if code >= 400:
126
+                if hasattr(resp_text, 'content'):
127
+                    raise DocumentUploadError(resp_text.content)
128
+                else:
129
+                    raise DocumentUploadError(resp_text)
130
+            else:
131
+                output = self.formatted_response_handler(resp_text)
132
+                LOG.info("Uploaded document in buffer %s " % output)
133
+
134
+        # Commit in the last iteration of the loop when all the documents
135
+        # have been pushed to Shipyard buffer.
136
+        if idx == len(collected_documents) - 1:
137
+            return self.commit_documents()
138
+
139
+    def commit_documents(self):
140
+        """ Commit Shipyard buffer documents """
141
+
142
+        LOG.info("Commiting Shipyard buffer documents")
143
+
144
+        try:
145
+            resp_text = self.formatted_response_handler(
146
+                self.api_client.commit_configdocs()
147
+            )
148
+        except Exception as ex:
149
+            resp_text = (
150
+                "Error: Unable to invoke action due to: {}".format(str(ex)))
151
+            raise DocumentUploadError(resp_text)
152
+        return resp_text
153
+
154
+    def validate_auth_vars(self):
155
+        """Checks that the required authorization varible have been entered"""
156
+        required_auth_vars = ['auth_url']
157
+        err_txt = []
158
+        for var in required_auth_vars:
159
+            if self.auth_vars[var] is None:
160
+                err_txt.append(
161
+                    'Missing the required authorization variable: '
162
+                    '--os-{}'.format(var.replace('_', '-')))
163
+        if err_txt:
164
+            for var in self.auth_vars:
165
+                if (self.auth_vars.get(var) is None and
166
+                        var not in required_auth_vars):
167
+                    err_txt.append('- Also not set: --os-{}'.format(
168
+                        var.replace('_', '-')))
169
+            raise AuthValuesError(diagnostic='\n'.join(err_txt))
170
+
171
+    def formatted_response_handler(self, response):
172
+        """Base format handler for either json or yaml depending on call"""
173
+        call = response.headers['Content-Type']
174
+        if 'json' in call:
175
+            try:
176
+                return json.dumps(response.json(), indent=4)
177
+            except ValueError:
178
+                return (
179
+                    "This is not json and could not be printed as such. \n" +
180
+                    response.text
181
+                )

+ 1
- 0
requirements.txt View File

@@ -4,3 +4,4 @@ jsonschema==2.6.0
4 4
 pyyaml==3.12
5 5
 cryptography==2.3.1
6 6
 git+https://github.com/openstack/airship-deckhand.git@7d697012fcbd868b14670aa9cf895acfad5a7f8d
7
+git+https://github.com/openstack/airship-shipyard.git@44f7022df6438de541501c2fdd5c46df198b82bf#egg=shipyard_client&subdirectory=src/bin/shipyard_client

+ 14
- 0
tests/unit/engine/test_util_files.py View File

@@ -12,10 +12,14 @@
12 12
 # See the License for the specific language governing permissions and
13 13
 # limitations under the License.
14 14
 
15
+import mock
16
+
15 17
 from pegleg import config
16 18
 from pegleg.engine.util import files
17 19
 from tests.unit.fixtures import create_tmp_deployment_files
18 20
 
21
+TEST_DATA = [('/tmp/test_repo', 'test_file.yaml')]
22
+TEST_DATA_2 = [{'schema': 'pegleg/SiteDefinition/v1', 'data': 'test'}]
19 23
 
20 24
 def test_no_non_yamls(tmpdir):
21 25
     p = tmpdir.mkdir("deployment_files").mkdir("global")
@@ -51,3 +55,13 @@ def test_list_all_files(create_tmp_deployment_files):
51 55
     assert len(actual_files) == len(expected_files)
52 56
     for idx, file in enumerate(actual_files):
53 57
         assert file.endswith(expected_files[idx])
58
+
59
+@mock.patch('pegleg.engine.util.definition.site_files_by_repo',autospec=True,
60
+            return_value=TEST_DATA)
61
+@mock.patch('pegleg.engine.util.files.read', autospec=True,
62
+            return_value=TEST_DATA_2)
63
+def test_collect_files_by_repo(*args):
64
+    result = files.collect_files_by_repo('test-site')
65
+
66
+    assert 'test_repo' in result
67
+    assert 'schema' in result['test_repo'][0]

+ 172
- 0
tests/unit/engine/util/test_shipyard_helper.py View File

@@ -0,0 +1,172 @@
1
+# Copyright 2018 AT&T Intellectual Property.  All other rights reserved.
2
+#
3
+# Licensed under the Apache License, Version 2.0 (the "License");
4
+# you may not use this file except in compliance with the License.
5
+# You may obtain a copy of the License at
6
+#
7
+#     http://www.apache.org/licenses/LICENSE-2.0
8
+#
9
+# Unless required by applicable law or agreed to in writing, software
10
+# distributed under the License is distributed on an "AS IS" BASIS,
11
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+# See the License for the specific language governing permissions and
13
+# limitations under the License.
14
+
15
+import json
16
+import mock
17
+import pytest
18
+
19
+from tests.unit import test_utils
20
+from mock import ANY
21
+
22
+from pegleg.engine import util
23
+from pegleg.engine.util.shipyard_helper import ShipyardHelper
24
+from pegleg.engine.util.shipyard_helper import ShipyardClient
25
+
26
+# Dummy data to be used as collected documents
27
+DATA = {'test-repo':
28
+           {'test-data': 'RandomData'}}
29
+
30
+class context():
31
+    obj = {}
32
+
33
+
34
+class FakeResponse():
35
+    code = 404
36
+
37
+def _get_context():
38
+    ctx = context()
39
+    ctx.obj = {}
40
+    auth_vars =  {
41
+        'project_domain_name': 'projDomainTest',
42
+        'user_domain_name': 'userDomainTest',
43
+        'project_name': 'projectTest',
44
+        'username': 'usernameTest',
45
+        'password': 'passwordTest',
46
+        'auth_url': 'urlTest'
47
+    }
48
+    ctx.obj['API_PARAMETERS'] = {
49
+        'auth_vars': auth_vars
50
+    }
51
+    ctx.obj['context_marker'] = '88888888-4444-4444-4444-121212121212'
52
+    ctx.obj['site_name'] = 'test-site'
53
+    return ctx
54
+
55
+def _get_bad_context():
56
+    ctx = context()
57
+    ctx.obj = {}
58
+    auth_vars =  {
59
+        'project_domain_name': 'projDomainTest',
60
+        'user_domain_name': 'userDomainTest',
61
+        'project_name': 'projectTest',
62
+        'username': 'usernameTest',
63
+        'password': 'passwordTest',
64
+        'auth_url': None
65
+    }
66
+    ctx.obj['API_PARAMETERS'] = {
67
+        'auth_vars': auth_vars
68
+    }
69
+    ctx.obj['context_marker'] = '88888888-4444-4444-4444-121212121212'
70
+    ctx.obj['site_name'] = 'test-site'
71
+    return ctx
72
+
73
+
74
+def test_shipyard_helper_init_():
75
+    """ Tests ShipyardHelper init method """
76
+    # Scenario:
77
+    #
78
+    # 1) Get a dummy context Object
79
+    # 2) Check that site name is as expected
80
+    # 3) Check api client is instance of ShipyardClient
81
+
82
+    context = _get_context()
83
+    shipyard_helper = ShipyardHelper(context)
84
+
85
+    assert shipyard_helper.site_name == context.obj['site_name']
86
+    assert isinstance(shipyard_helper.api_client, ShipyardClient)
87
+
88
+@mock.patch('pegleg.engine.util.files.collect_files_by_repo', autospec=True,
89
+            return_value=DATA)
90
+@mock.patch.object(ShipyardHelper, 'formatted_response_handler',
91
+                   autospec=True, return_value=None)
92
+def test_upload_documents(*args):
93
+    """ Tests upload document """
94
+    # Scenario:
95
+    #
96
+    # 1) Get a dummy context Object
97
+    # 2) Mock external calls
98
+    # 3) Check documents uploaded to Shipyard with correct parameters
99
+
100
+    context = _get_context()
101
+    shipyard_helper = ShipyardHelper(context)
102
+
103
+    with mock.patch('pegleg.engine.util.shipyard_helper.ShipyardClient',
104
+                    autospec=True) as mock_shipyard:
105
+        mock_api_client = mock_shipyard.return_value
106
+        mock_api_client.post_configdocs.return_value = 'Success'
107
+        result = ShipyardHelper(context).upload_documents()
108
+
109
+        # Validate Shipyard call to post configdocs was invoked with correct
110
+        # collection name and buffer mode.
111
+        mock_api_client.post_configdocs.assert_called_with('test-repo', None, ANY)
112
+        mock_api_client.post_configdocs.assert_called_once()
113
+
114
+@mock.patch('pegleg.engine.util.files.collect_files_by_repo', autospec=True,
115
+            return_value=DATA)
116
+@mock.patch.object(ShipyardHelper, 'formatted_response_handler',
117
+                   autospec=True, return_value=None)
118
+def test_upload_documents_fail(*args):
119
+    """ Tests Document upload error """
120
+    # Scenario:
121
+    #
122
+    # 1) Get a bad context object with empty auth_url
123
+    # 2) Mock external calls
124
+    # 3) Check DocumentUploadError is raised
125
+
126
+    context = _get_context()
127
+    shipyard_helper = ShipyardHelper(context)
128
+
129
+    with mock.patch('pegleg.engine.util.shipyard_helper.ShipyardClient',
130
+                    autospec=True) as mock_shipyard:
131
+        mock_api_client = mock_shipyard.return_value
132
+        mock_api_client.post_configdocs.return_value = FakeResponse()
133
+        with pytest.raises(util.shipyard_helper.DocumentUploadError):
134
+            ShipyardHelper(context).upload_documents()
135
+
136
+@mock.patch('pegleg.engine.util.files.collect_files_by_repo', autospec=True,
137
+            return_value=DATA)
138
+@mock.patch.object(ShipyardHelper, 'formatted_response_handler',
139
+                   autospec=True, return_value=None)
140
+def test_fail_auth(*args):
141
+    """ Tests Auth Failure """
142
+    # Scenario:
143
+    #
144
+    # 1) Get a bad context object with empty auth_url
145
+    # 2) Check AuthValuesError is raised
146
+
147
+    context = _get_bad_context()
148
+    shipyard_helper = ShipyardHelper(context)
149
+
150
+    with pytest.raises(util.shipyard_helper.AuthValuesError):
151
+        ShipyardHelper(context).validate_auth_vars()
152
+
153
+@mock.patch.object(ShipyardHelper, 'formatted_response_handler',
154
+                   autospec=True, return_value=None)
155
+def test_commit_documents(*args):
156
+    """Tests commit document """
157
+    # Scenario:
158
+    #
159
+    # 1) Get a dummy context Object
160
+    # 2) Mock external calls
161
+    # 3) Check commit documents was called
162
+
163
+    context = _get_context()
164
+    shipyard_helper = ShipyardHelper(context)
165
+
166
+    with mock.patch('pegleg.engine.util.shipyard_helper.ShipyardClient',
167
+                    autospec=True) as mock_shipyard:
168
+        mock_api_client = mock_shipyard.return_value
169
+        mock_api_client.commit_configdocs.return_value = 'Success'
170
+        result = ShipyardHelper(context).commit_documents()
171
+
172
+        mock_api_client.commit_configdocs.assert_called_once()

+ 17
- 0
tests/unit/test_cli.py View File

@@ -16,6 +16,7 @@ import os
16 16
 import shutil
17 17
 
18 18
 from click.testing import CliRunner
19
+from mock import ANY
19 20
 import mock
20 21
 import pytest
21 22
 
@@ -354,6 +355,22 @@ class TestSiteCliActions(BaseCLIActionTest):
354 355
         repo_path = self.treasuremap_path
355 356
         self._validate_render_site_action(repo_path)
356 357
 
358
+    def test_upload_documents_shipyard_using_local_repo_path(self):
359
+        """Validates ShipyardHelper is called with correct arguments."""
360
+        # Scenario:
361
+        #
362
+        # 1) Mock out ShipyardHelper
363
+        # 2) Check ShipyardHelper was called with correct arguments
364
+
365
+        repo_path = self.treasuremap_path
366
+
367
+        with mock.patch('pegleg.cli.ShipyardHelper') as mock_obj:
368
+            result = self.runner.invoke(cli.site,
369
+                ['-r', repo_path, 'upload', self.site_name])
370
+
371
+        assert result.exit_code == 0
372
+        mock_obj.assert_called_once()
373
+
357 374
 
358 375
 class TestRepoCliActions(BaseCLIActionTest):
359 376
     """Tests repo-level CLI actions."""

Loading…
Cancel
Save