Skip to content

Add a way to pass secrets to processes in a controlled fashion#406

Merged
kostko merged 7 commits intogenialis:masterfrom
kostko:feature/secrets
Dec 19, 2017
Merged

Add a way to pass secrets to processes in a controlled fashion#406
kostko merged 7 commits intogenialis:masterfrom
kostko:feature/secrets

Conversation

@kostko
Copy link
Contributor

@kostko kostko commented Dec 5, 2017

DEPLOYMENT CONSIDERATIONS:

  • Secrets are encrypted using SECRET_KEY, keep it secure and backed up as it should already be ;-)
  • Setting FLOW_DOCKER_MAPPINGS has been removed in favor of new FLOW_DOCKER_VOLUME_EXTRA_OPTIONS and FLOW_DOCKER_EXTRA_VOLUMES.

@kostko kostko requested review from dblenkus, jberci and tjanez December 5, 2017 13:55
Copy link
Contributor

@jberci jberci left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@codecov-io
Copy link

codecov-io commented Dec 5, 2017

Codecov Report

Merging #406 into master will decrease coverage by 0.54%.
The diff coverage is 46.08%.

Impacted file tree graph

@@            Coverage Diff             @@
##           master     #406      +/-   ##
==========================================
- Coverage   80.09%   79.55%   -0.55%     
==========================================
  Files         181      186       +5     
  Lines        9899    10065     +166     
  Branches     1059     1069      +10     
==========================================
+ Hits         7929     8007      +78     
- Misses       1789     1875      +86     
- Partials      181      183       +2
Impacted Files Coverage Δ
resolwe/test_helpers/test_runner.py 43.83% <ø> (-0.55%) ⬇️
resolwe/flow/execution_engines/bash/__init__.py 90.32% <ø> (ø) ⬆️
resolwe/test/testcases/setting_overrides.py 100% <ø> (ø) ⬆️
resolwe/flow/executors/run.py 0% <0%> (ø) ⬆️
resolwe/flow/executors/docker/run.py 0% <0%> (ø) ⬆️
resolwe/flow/migrations/0010_add_secret.py 100% <100%> (ø)
resolwe/flow/routing.py 100% <100%> (ø) ⬆️
resolwe/flow/models/__init__.py 100% <100%> (ø) ⬆️
resolwe/flow/tests/test_secrets.py 100% <100%> (ø)
resolwe/flow/managers/__init__.py 54.16% <100%> (ø) ⬆️
... and 16 more

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update 07f46c1...5c35716. Read the comment docs.

"type": "string",
"pattern": "^basic:secret:$"
},
"value": {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Enforce the secret format, i.e. "pattern": "^[0-9a-fA-F]{24}$". Otherwise some users may try to save secret directly in to the field.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

file content that should be created. Keys are filenames, values
are the strings that should be written into those files.
"""
data = Data.objects.get(pk=data_id)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Prefetch process.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As we are getting just a single object this doesn't make any difference in performance or the number of queries as even prefetching requires an additional query. We could use select_related here instead of prefetch to combine everything into a single join.

mappings.append({
'src': os.path.join(SETTINGS['FLOW_EXECUTOR']['RUNTIME_DIR'], str(self.data_id), 'secrets'),
'dest': '/secrets',
'mode': 'ro,Z'
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not ok to set SELinux mode on the systems that don't support it. We had such problems mounting data dir from Amazon EBS. Runtime dir will be often on shared storage, so you have to take care of that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, but we are using this also above for mounting passwd/group?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, those files are normally on local disk so it is ok. The difference here is that runtime dir can be on shared storage.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I will move this into configuration as we have for data/upload dirs.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this means that you have to support different mounting points in the container.

What if we add a setting to enable/disable SELinux config, e.g. FLOW_ENABLE_SELINUX_CONF, and the you can construct mount here?

cc @tjanez

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But then we must change this everywhere as this is very strange to have some modes in settings and others are derived from this?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But this means that you have to support different mounting points in the container.

I don't think we should support this even with existing data/upload etc. endpoints. They should be fixed.

Copy link
Contributor Author

@kostko kostko Dec 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've pushed a version which moves this into settings (I don't like this though). We can move it back if this makes sense.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But then we must change this everywhere

Yes.

Actually we support this for data/upload dir:

script = script.replace(map_['src'], map_['dest'])

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know, but this (doing search and replace in arbitrary scripts) is a very ugly hack that should be removed.

pk=data_id
).process)
files[ExecutorFiles.DATA] = model_to_dict(data)
files[ExecutorFiles.PROCESS] = model_to_dict(data.process)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1


# extend the settings with whatever the executor wants
self.executor.extend_settings(data_id, files)
self.executor.extend_settings(data_id, files, extra)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like name extra as it doesn't apply that it contains sensitive data. Maybe secrets or protected_files?

Copy link
Contributor

@jberci jberci Dec 5, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the other hand, the docstring for extend_settings specifies this plainly as a field for general raw files (as opposed to JSON serializations in the files parameter). Secrets are just one kind of opaque blob, so usage agrees with the docstring's specification.

Copy link
Member

@dblenkus dblenkus Dec 5, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But we also prevent listing of such dirs and restrict masks of files. So it may have unwanted consequences if you put such file directly into executor dir.
I would prefer to narrow use of this, make it secure (it already is) and clearly state that.

contributor=self.contributor
)
except Secret.DoesNotExist:
raise PermissionDenied("Access to secret not allowed")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or does not exist.

return secret.value


class Secret(models.Model):
Copy link
Member

@dblenkus dblenkus Dec 5, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we will need an extra to describe where the secret comes from. E.g. if you get user's API token first time, you don't have to ask for it in following requests.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add an arbitrary JSON metadata column for that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

run:
program: |
# Echo secret to output. NEVER do this in an actual process!
re-save secret "$(cat /secrets/{{token.handle}})"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be useful to add Jinja filter for this, i.e. {{ token | get_secret }}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For which part? The whole command is specific for bash. Or did you mean just the {{token.handle}}?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean that you write {{ token | get_secret }} in the process and it is replaced with $(cat /secrets/{{token.handle}}) by Jinja filter.

That would make it much more user friendly.

secret = self.create(value=value, contributor=contributor)
return str(secret.handle)

def get_secret(self, handle, contributor=None):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a use-case where contributor is not defined? If not i would prefer to make it mandatory.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@kostko kostko force-pushed the feature/secrets branch 3 times, most recently from 0e5addb to 991af17 Compare December 6, 2017 11:39
@kostko
Copy link
Contributor Author

kostko commented Dec 6, 2017

Now also pushed removal of FLOW_DOCKER_MAPPINGS.

@kostko kostko changed the title WIP: Add a way to pass secrets to processes in a controlled fashion Add a way to pass secrets to processes in a controlled fashion Dec 6, 2017
mappings = SETTINGS.get('FLOW_DOCKER_MAPPINGS', {})
for map_ in mappings:
script = script.replace(map_['src'], map_['dest'])
# XXX: This is a huge hack and should be removed.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we mount everything on the same path as it is outside?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is the correct way to go as it is much cleaner to have fixed volumes inside the container, regardless of what is outside. In this way you can always rely on specific paths when you run inside Docker.

It would be much better to always defer path translation to the executor (actually to the prepare part of the executor) as it is executor specific (e.g., the local executor does nothing, the Docker executor uses fixed volumes, etc.). Not just blindly using *_DIR from settings.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hack has been removed in the latest commit.

:param value: Secret value to store
:param contributor: User owning the secret
:param metadata: Optional metadata dictionary (must be JSON serializable)
:param expires: Optional expiry time
Copy link
Contributor

@hadalin hadalin Dec 6, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe state which units are used for expires?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also maybe state it defaults to None.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

@kostko
Copy link
Contributor Author

kostko commented Dec 6, 2017

@jberci please review my last commit, which fixes some issues with incorrect singleton use in Channels workers, which got exposed when removing hacky path translation.

@kostko kostko force-pushed the feature/secrets branch 2 times, most recently from d37e16b to c525cf4 Compare December 6, 2017 19:05
@jberci
Copy link
Contributor

jberci commented Dec 7, 2017

LGTM so far. The global manager instance patching is still a bit hacky on the whole, but the Channel handler proxy will probably take care of that, since the manager will then be constructed once, as it was pre-channelization.

@kostko kostko force-pushed the feature/secrets branch 2 times, most recently from 95c31dc to 5aa051e Compare December 7, 2017 15:40
@kostko
Copy link
Contributor Author

kostko commented Dec 7, 2017

@jberci I've now pushed an updated version of the "Remove hacky path translation in Docker executor" commit, which separates the Channels consumer from the manager and uses the global manager instance instead.

@kostko kostko force-pushed the feature/secrets branch 2 times, most recently from e818f13 to 3d74236 Compare December 7, 2017 18:13
class SecretManager(models.Manager):
"""Manager for Secret objects."""

def create_secret(self, value, contributor, metadata=None, expires=None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this method always be used when creating secrets? If yes, then update docs and maybe also raise error when using the usual .create(. In this case it would also be nicer to return handle, secret to remove the need to query the secret by its handle if you intend to use the secret directly.

Alternatively if .create is allowed, then override it and call it in create_secret.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both ways are ok I guess, but prevent confusion.

value = EncryptedTextField()

#: secret metadata (not encrypted)
metadata = JSONField(default=dict)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since this is arbitrary data, should a naming convention be followed to prevent conflicts?

))

# Ensure we have the correct executor loaded.
self.executor = self.load_executor(executor) # pylint: disable=attribute-defined-outside-init
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this still needed now when only the singleton manager is used?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has already been changed (discover_engines is now called instead), but yes we need re-discovery as the executor may change with each message.

logger.exception("Unhandled exception in executor")

# Send error report.
self.update_data_status(process_error=[str(error)], status=DATA_META['STATUS_ERROR'])
Copy link
Member

@dblenkus dblenkus Dec 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A quick question: are process_* fields extended when updated or are they overwritten? @jberci?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jberci, ping

for template in mappings_template]
# Setup Docker volumes.
def add_volume(volumes, kind, base_dir_name, volume, path=None, read_only=True):
"""Add a new volume to the volumes list."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add description of arguments.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an internal helper, but ok, if you think that it should be added ;-)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can add just a quick description of arguments. There are just a lot of them, so it takes some time to understand them :)


mappings.append({'src': passwd_file.name, 'dest': '/etc/passwd', 'mode': 'ro,Z'})
mappings.append({'src': group_file.name, 'dest': '/etc/group', 'mode': 'ro,Z'})
volumes.append({'src': passwd_file.name, 'dest': '/etc/passwd', 'options': 'ro,Z'})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be nice to make these options configurable.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.volumes? Or move self.volumes = volumes.copy() to the end of the function.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.volumes is no longer needed now that we got rid of hacky path translation, I forgot to remove it.

# NOTE: Since the tools are shared among all containers they must use the shared SELinux
# label (z option)
self.mappings_tools = [{'src': tool, 'dest': '/usr/local/bin/resolwe/{}'.format(i), 'mode': 'ro,z'}
self.mappings_tools = [{'src': tool, 'dest': '/usr/local/bin/resolwe/{}'.format(i), 'options': 'ro,z'}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This SELinux label will also be a problem on a shared storage.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I left this as it was before? So this was already a problem before?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it was already a problem before, I just pointed it out as the part for determining modes is being refactored.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I'll add another extra option for tools and one for passwd/group.



# update FLOW_DOCKER_MAPPINGS setting if necessary
def _get_updated_docker_mappings(previous_settings=getattr(settings, 'FLOW_EXECUTOR', {})):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice :)

inputs['proc'] = {
'data_id': data.id,
'data_dir': settings.FLOW_EXECUTOR['DATA_DIR'],
'data_dir': self.manager.get_executor().resolve_data_path(data, '..'),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add dedicated function for this instead of passing .. as filename?

Copy link
Member

@dblenkus dblenkus Dec 11, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kostko make the filename argument optional and return root data dir if it is not defined.

EDIT: Actually both parameters should be optional in this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.



class BaseManager(BaseConsumer):
class BaseManager(object):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should go into a separate commit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

@kostko kostko force-pushed the feature/secrets branch 2 times, most recently from 9f5a974 to b770740 Compare December 11, 2017 11:36
Copy link
Member

@dblenkus dblenkus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work!

Copy link
Contributor

@tjanez tjanez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kostko, very nice job!

Thanks for improving so many things in Resolwe.

Most of the comments are style improvements, so they should be easy to fix.

logger.exception("Unhandled exception in executor")

# Send error report.
self.update_data_status(process_error=[str(error)], status=DATA_META['STATUS_ERROR'])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jberci, ping


if finish_fields is not None:
self._send_manager_command(ExecutorProtocol.FINISH, extra_fields=finish_fields)
# the feedback key deletes itself once the list is drained
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm... I don't understand this comment.

I know it's not yours but since you touched it, can you make it more elaborate?
Also, make it a sentence.

Copy link
Contributor Author

@kostko kostko Dec 15, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I found it confusing as well. It seems to be talking about the Redis list used for communication, which is deleted when all elements are popped? @jberci can you confirm this is the case?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is the case, the comment is about Redis deleting the list once all items have been popped. This behaviour was not obvious to me when I was doing this. If memory serves, the code was also structured so that one would expect explicit key deletion at the end, so I put that comment there. Feel free to get rid of it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the explanation. I like @kostko's rewording and I think we can leave it in.

with open(file_path, 'wt') as json_file:
json.dump(files[file_name], json_file, cls=SettingsJSONifier)

# Save the secrets in the runtime dir, with permissions to prevent listing the given directory.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you wrap this line at 100 characters?


# Save the secrets in the runtime dir, with permissions to prevent listing the given directory.
secrets_dir = os.path.join(runtime_dir, ExecutorFiles.SECRETS_DIR)
os.makedirs(secrets_dir, mode=stat.S_IWUSR | stat.S_IXUSR)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me, mode=0o300 is actually more readable than mode=stat.S_IWUSR | stat.S_IXUSR.

If you agree, change it.

self.executor.extend_settings(data_id, files, secrets)

# save the settings into the various files in the data dir
# save the settings into the various files in the runtime dir
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, convert this to a sentence since you touched it 🙂 .

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It should have been a sentence in the first place. I'll fix it.

@@ -0,0 +1,24 @@
"""Manager Channels consumer."""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!


:param data: Data object instance
:param filename: Filename to resolve
:return: Resolved filename, which can be used to access the given data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, wrap the docstring at 72 characters.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we have linters for these things? I never look at where I wrap things :-)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not yet implemented in pycodestyle, but the PR @ PyCQA/pycodestyle#674 seems on right track and that it will got approval from pycodestyle's maintainer.

"""Resolve upload path for use with the executor.

:param filename: Filename to resolve
:return: Resolved filename, which can be used to access the given uploaded
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, wrap the docstring at 72 characters.


:param data: Data object instance
:param filename: Filename to resolve
:return: Resolved filename, which can be used to access the given data
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, wrap the docstring at 72 characters.

"""Resolve upload path for use with the executor.

:param filename: Filename to resolve
:return: Resolved filename, which can be used to access the given uploaded
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please, wrap the docstring at 72 characters.

@kostko kostko force-pushed the feature/secrets branch 2 times, most recently from cfa388e to 0b5b928 Compare December 15, 2017 01:35
volumes += SETTINGS.get('FLOW_DOCKER_EXTRA_VOLUMES', [])

# Create Docker --volume parameters from volumes.
command_args['volumes'] = ' '.join(['--volume="{src}":"{dest}":{options}'.format(**volume)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kostko Will this work if options is empty? Or do you have to omit the colon?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it works if the options are empty (no need to omit the colon).

Copy link
Contributor

@tjanez tjanez left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good!

@kostko kostko merged commit 5c35716 into genialis:master Dec 19, 2017
@kostko kostko deleted the feature/secrets branch December 19, 2017 11:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants