Repository: aws/aws-sam-cli

Issue:
sam 0.19.0 crashes on trying to create .aws-sam directory when not permitted
### Description

While running `sam validate` from a freshly built docker image, it crashes while trying to create the `.aws-sam` config directory.

### Steps to reproduce

In our CI pipeline, we run `sam` from within a docker container built according to this `Dockerfile`:

```
FROM alpine:3.9

RUN apk add --no-cache \
    build-base \
    python \
    python-dev \
    py-pip \
    groff \
    docker && \
    pip install awscli aws-sam-cli
```

AWS credentials are passed in as environment variables, volumes mounted from the Jenkins workspace into the docker image, and then we simply run:

`sam validate -t ./.../template.yaml`

### Observed result

Logs from the Jenkins build:

```
$ docker run -t -d -u 3000:100 --network=host -w /data/hudson/workspace/loper-portal_feature_jenkinstest -v /data/hudson/workspace/loper-portal_feature_jenkinstest:/data/hudson/workspace/loper-portal_feature_jenkinstest:rw,z -v /data/hudson/workspace/loper-portal_feature_jenkinstest@tmp:/data/hudson/workspace/loper-portal_feature_jenkinstest@tmp:rw,z -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** -e ******** xxxx-aws-sam-cli cat
$ docker top 04fa30b17ceb0ae6d45b66190f32f4bad8dedd57386352a31e61f0da6ac18aa2 -eo pid,comm
[Pipeline] {
[Pipeline] withEnv
[Pipeline] {
[Pipeline] withCredentials
Masking supported pattern matches of $AWS_ACCESS_KEY_ID or $AWS_SECRET_ACCESS_KEY
[Pipeline] {
[Pipeline] stage
[Pipeline] { (Validate CloudFormation template)
[Pipeline] sh
+ sam validate --debug -t cloudformation/template.yaml
Traceback (most recent call last):
  File "/usr/bin/sam", line 11, in <module>
    sys.exit(cli())
  File "/usr/lib/python2.7/site-packages/click/core.py", line 722, in __call__
    return self.main(*args, **kwargs)
  File "/usr/lib/python2.7/site-packages/click/core.py", line 697, in main
    rv = self.invoke(ctx)
  File "/usr/lib/python2.7/site-packages/click/core.py", line 1063, in invoke
    Command.invoke(self, ctx)
  File "/usr/lib/python2.7/site-packages/click/core.py", line 895, in invoke
    return ctx.invoke(self.callback, **ctx.params)
  File "/usr/lib/python2.7/site-packages/click/core.py", line 535, in invoke
    return callback(*args, **kwargs)
  File "/usr/lib/python2.7/site-packages/click/decorators.py", line 64, in new_func
    return ctx.invoke(f, obj, *args[1:], **kwargs)
  File "/usr/lib/python2.7/site-packages/click/core.py", line 535, in invoke
    return callback(*args, **kwargs)
  File "/usr/lib/python2.7/site-packages/samcli/cli/main.py", line 83, in cli
    if global_cfg.telemetry_enabled is None:
  File "/usr/lib/python2.7/site-packages/samcli/cli/global_config.py", line 113, in telemetry_enabled
    self._telemetry_enabled = self._get_value(TELEMETRY_ENABLED_KEY)
  File "/usr/lib/python2.7/site-packages/samcli/cli/global_config.py", line 145, in _get_value
    cfg_path = self._get_config_file_path(CONFIG_FILENAME)
  File "/usr/lib/python2.7/site-packages/samcli/cli/global_config.py", line 170, in _get_config_file_path
    self._create_dir()
  File "/usr/lib/python2.7/site-packages/samcli/cli/global_config.py", line 167, in _create_dir
    self.config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
  File "/usr/lib/python2.7/site-packages/pathlib2/__init__.py", line 1540, in mkdir
    _try_except_filenotfounderror(_try_func, _exc_func)
  File "/usr/lib/python2.7/site-packages/pathlib2/__init__.py", line 113, in _try_except_filenotfounderror
    try_func()
  File "/usr/lib/python2.7/site-packages/pathlib2/__init__.py", line 1531, in _try_func
    self._accessor.mkdir(self, mode)
  File "/usr/lib/python2.7/site-packages/pathlib2/__init__.py", line 585, in wrapped
    return strfunc(str(pathobj), *args)
OSError: [Errno 13] Permission denied: '/.aws-sam'
[Pipeline] }
[Pipeline] // stage
[Pipeline] }
[Pipeline] // withCredentials
[Pipeline] }
[Pipeline] // withEnv
[Pipeline] }
```
### Expected result

A non-crashing sam :)

### Additional environment details (Ex: Windows, Mac, Amazon Linux etc)

1. OS: alpine linux, docker container
2. `sam --version`: 0.19.0

### Others

This was added in the 0.19.0 Telemetry release, where in commit dbd534a `samcli/cli/global_config.py` was added, that tries to write the config dir if it doesn't exist. 

Actually we should've pinned the versions used in our `Dockerfile`. Not pinning it had us miss the addition of the Telemetry feature in 0.19.0. Although I'd love to contribute, enabling Telemetry would undoubtedly go against some company policy. There _is_ the message in the first run of the new version (if it was able to write the config dir), but in our setup that's quite easy to miss. Basically I'm advocating an opt-in instead



Discussion:
I think this is less of an opt-in issue, since this config file can be written to in non-telemetry contexts (layer support has a similar global directory feature that's been in place for a while) but we definitely need a better failure case.

Resolving patch:
diff --git a/samcli/cli/global_config.py b/samcli/cli/global_config.py
--- a/samcli/cli/global_config.py
+++ b/samcli/cli/global_config.py
@@ -47,7 +47,6 @@ def config_dir(self):
             # Internal Environment variable to customize SAM CLI App Dir. Currently used only by integ tests.
             app_dir = os.getenv("__SAM_CLI_APP_DIR")
             self._config_dir = Path(app_dir) if app_dir else Path(click.get_app_dir('AWS SAM', force_posix=True))
-
         return Path(self._config_dir)
 
     @property
@@ -76,7 +75,7 @@ def installation_id(self):
         try:
             self._installation_id = self._get_or_set_uuid(INSTALLATION_ID_KEY)
             return self._installation_id
-        except (ValueError, IOError):
+        except (ValueError, IOError, OSError):
             return None
 
     @property
@@ -112,7 +111,7 @@ def telemetry_enabled(self):
         try:
             self._telemetry_enabled = self._get_value(TELEMETRY_ENABLED_KEY)
             return self._telemetry_enabled
-        except (ValueError, IOError) as ex:
+        except (ValueError, IOError, OSError) as ex:
             LOG.debug("Error when retrieving telemetry_enabled flag", exc_info=ex)
             return False
 
@@ -164,6 +163,10 @@ def _set_value(self, key, value):
             return self._set_json_cfg(cfg_path, key, value, json_body)
 
     def _create_dir(self):
+        """
+        Creates configuration directory if it does not already exist, otherwise does nothing.
+        May raise an OSError if we do not have permissions to create the directory.
+        """
         self.config_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
 
     def _get_config_file_path(self, filename):


Test patch:
diff --git a/tests/unit/cli/test_global_config.py b/tests/unit/cli/test_global_config.py
--- a/tests/unit/cli/test_global_config.py
+++ b/tests/unit/cli/test_global_config.py
@@ -19,6 +19,16 @@ def test_config_write_error(self):
             installation_id = gc.installation_id
             self.assertIsNone(installation_id)
 
+    def test_unable_to_create_dir(self):
+        m = mock_open()
+        m.side_effect = OSError("Permission DENIED")
+        gc = GlobalConfig()
+        with patch('samcli.cli.global_config.Path.mkdir', m):
+            installation_id = gc.installation_id
+            self.assertIsNone(installation_id)
+            telemetry_enabled = gc.telemetry_enabled
+            self.assertFalse(telemetry_enabled)
+
     def test_setter_cannot_open_path(self):
         m = mock_open()
         m.side_effect = IOError("fail")