Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Multiple credential types #8

Merged
merged 48 commits into from Nov 22, 2019

Conversation

@chriskilding
Copy link
Contributor

chriskilding commented Aug 5, 2019

Support multiple credential types.

Implements JENKINS-58339

Goals

Credential types:

  • SSH Private Key (PEM format)
    • PKCS#1 encoding
    • PKCS#8 encoding
    • OpenSSH encoding
  • Certificate (PKCS#12 format)
  • Username with Password
  • Secret Text

Binding mechanisms:

  • Manual job configurator (Web UI):
    • Secret Text
    • Username with Password
    • SSH Private Key
    • Certificate
  • withCredentials (scripted pipeline DSL):
    • Secret Text
    • Username with Password
    • SSH Private Key
    • Certificate
  • environment (declarative pipeline DSL):
    • Secret Text
    • Username with Password
    • SSH Private Key

Non-goals

  • Support for proprietary third party credential types, like AWS access keys. (They may be added in future, once the standard credential types have given us a template for how to store and retrieve multiple credential types.)
  • Support for environment bindings that Declarative Pipeline itself does not support. (Declarative Pipeline does not currently support certificate bindings.)

Bugs

Showstoppers that have been detected within this PR.

  • Name fields of AWS Secrets Manager Secrets are not distinct
  • Not all credential types bind properly in Declarative Pipeline (environment step)
  • Credentials not working with git plugin (on agent nodes)
Chris Kilding
@chriskilding chriskilding self-assigned this Aug 5, 2019
@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Aug 13, 2019

Noteworthy discoveries about credential classes that implement multiple credential interfaces:

  • Jenkins credentials list view just presents them as having a single type of its choosing. For example it showed me that my multi-type credential instances were string credentials. This is a harmless cosmetic bug that could be fixed upstream by modifying the UI credential type detector.
  • Jenkins credentials binding plugin allows us to bind a multi-type credential to any compatible type. For example it allowed me to bind my multi-type credential either as a Secret text credential (bound to “Variable”) or as a Username and Password (separated or conjoined) credential (bound to “Username Variable” and “Password Variable”).

This provides the groundwork we need to implement the feature.

Chris Kilding added 14 commits Aug 14, 2019
@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Aug 23, 2019

A candidate implementation is now complete.

The following questions would need to be answered before proceeding:

  • Is the metadata schema following the right naming conventions for AWS tags? (Currently the plugin looks for lower-case bare keys in the secret’s tags which correspond to the credential’s getter method, like “username”. Should it be “Username”? Should it be prefixed with something?)
  • Are the examples clear enough in the README?
@mathewpeterson

This comment has been minimized.

Copy link

mathewpeterson commented Aug 23, 2019

I definitely agree that the aws tags should have some sort of vendor prefix in order to avoid collision.

@nathanieltalbot

This comment has been minimized.

Copy link

nathanieltalbot commented Aug 23, 2019

Noticing a bug with this and I'm not sure if it's on my end -- I installed the plugin from the Jenkins build: https://ci.jenkins.io/job/Plugins/job/aws-secrets-manager-credentials-provider-plugin/job/feature%252Fmultiple-credential-types/ uninstalled the version I had, restarted, installed that .hpi, restarted. Now, nothing from AWS shows up when I open the credentials page, and furthermore, none of the credentials I had stored in Jenkins show up on the main page. They're still visible when clicking on the Jenkins store, but plugins seem to have a hard time finding them as well.

image

Uninstalling the plugin, all credentials appear again -- and installing the 0.01 version they show up too -- the issue is only with the version I installed from the 0.02 beta

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Aug 24, 2019

I have an idea what might cause that, and it’s installation-specific.

If the plugin fails to authenticate with AWS (eg wrong AWS access key, wrong IAM permissions), the end result is that the plugin has to throw an unchecked exception when you ask it to list credentials, to say that it could not retrieve them.

Unfortunately it seems that due to the way the rest of Jenkins interacts with the Credentials Provider API, that exception propagates all the way to the top - probably beyond where it should.

As a result, when you view a page containing any credentials from the offending provider, the exception stops Jenkins fetching credentials from other providers too, leading to a blank credentials list even when you know that there should be entries there.

So my first guess is that on your system, the plugin had a wobble when authenticating with AWS. Try some things around that and see if the error still happens. (I slightly wonder if upgrading to the experimental .hpi caused it to forget the AWS settings in the global Jenkins configuration. Hopefully this is not the case.)

@nathanieltalbot

This comment has been minimized.

Copy link

nathanieltalbot commented Aug 25, 2019

Interesting -- that would make sense, although the plugin installed from the plugin repository has never had any auth issues. When I uninstalled the experimental .hpi and installed the 0.01 from the plugin repository, the issue did not happen, so AWS settings were retained--maybe there is an issue with how the plugin gets those credentials when doing a manual .hpi install versus doing a repository install. I'll do some testing on that front.

@nathanieltalbot

This comment has been minimized.

Copy link

nathanieltalbot commented Aug 26, 2019

Installed the .hpi artifact from the master pipeline build on jenkins, and the issue did not occur, so it seems it's specific to this branch. I'll do some more testing and see if I can get a specific error.

@nathanieltalbot

This comment has been minimized.

Copy link

nathanieltalbot commented Aug 26, 2019

I was able to test this once again and had the same result -- however I was able to get stack traces from the manage page -- below is the stack trace from the github plugin on this Jenkins instance:

java.lang.NullPointerException
	at io.jenkins.plugins.credentials.secretsmanager.CredentialsSupplierFactory$AwsCredentialsSupplier.get(CredentialsSupplierFactory.java:113)
	at io.jenkins.plugins.credentials.secretsmanager.CredentialsSupplierFactory$LazyAwsCredentialsSupplier.get(CredentialsSupplierFactory.java:87)
	at io.jenkins.plugins.credentials.secretsmanager.CredentialsSupplierFactory$LazyAwsCredentialsSupplier.get(CredentialsSupplierFactory.java:46)
	at com.google.common.base.Suppliers$ExpiringMemoizingSupplier.get(Suppliers.java:173)
	at io.jenkins.plugins.credentials.secretsmanager.AwsCredentialsProvider.getCredentials(AwsCredentialsProvider.java:40)
	at com.cloudbees.plugins.credentials.CredentialsProvider.getCredentials(CredentialsProvider.java:1135)
	at com.cloudbees.plugins.credentials.CredentialsProvider.getCredentialIds(CredentialsProvider.java:1167)
	at com.cloudbees.plugins.credentials.CredentialsProvider.listCredentials(CredentialsProvider.java:470)
	at com.cloudbees.plugins.credentials.common.AbstractIdCredentialsListBoxModel.includeMatchingAs(AbstractIdCredentialsListBoxModel.java:499)
	at org.jenkinsci.plugins.github.config.GitHubServerConfig$DescriptorImpl.doFillCredentialsIdItems(GitHubServerConfig.java:359)
	at java.lang.invoke.MethodHandle.invokeWithArguments(MethodHandle.java:627)
	at org.kohsuke.stapler.Function$MethodFunction.invoke(Function.java:396)
	at org.kohsuke.stapler.Function$InstanceFunction.invoke(Function.java:408)
	at org.kohsuke.stapler.Function.bindAndInvoke(Function.java:212)
	at org.kohsuke.stapler.Function.bindAndInvokeAndServeResponse(Function.java:145)
	at org.kohsuke.stapler.MetaClass$11.doDispatch(MetaClass.java:537)
	at org.kohsuke.stapler.NameBasedDispatcher.dispatch(NameBasedDispatcher.java:58)
	at org.kohsuke.stapler.Stapler.tryInvoke(Stapler.java:739)
Caused: javax.servlet.ServletException
	at org.kohsuke.stapler.Stapler.tryInvoke(Stapler.java:789)
	at org.kohsuke.stapler.Stapler.invoke(Stapler.java:870)
	at org.kohsuke.stapler.MetaClass$4.doDispatch(MetaClass.java:282)
	at org.kohsuke.stapler.NameBasedDispatcher.dispatch(NameBasedDispatcher.java:58)
	at org.kohsuke.stapler.Stapler.tryInvoke(Stapler.java:739)
	at org.kohsuke.stapler.Stapler.invoke(Stapler.java:870)
	at org.kohsuke.stapler.Stapler.invoke(Stapler.java:668)
	at org.kohsuke.stapler.Stapler.service(Stapler.java:238)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:790)
	at org.eclipse.jetty.servlet.ServletHolder.handle(ServletHolder.java:865)
	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1655)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:154)
	at org.jenkinsci.plugins.ssegateway.Endpoint$SSEListenChannelFilter.doFilter(Endpoint.java:243)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:151)
	at io.jenkins.blueocean.ResourceCacheControl.doFilter(ResourceCacheControl.java:134)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:151)
	at io.jenkins.blueocean.auth.jwt.impl.JwtAuthenticationFilter.doFilter(JwtAuthenticationFilter.java:61)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:151)
	at hudson.plugins.scm_sync_configuration.extensions.ScmSyncConfigurationFilter$1.call(ScmSyncConfigurationFilter.java:49)
	at hudson.plugins.scm_sync_configuration.extensions.ScmSyncConfigurationFilter$1.call(ScmSyncConfigurationFilter.java:44)
	at hudson.plugins.scm_sync_configuration.ScmSyncConfigurationDataProvider.provideRequestDuring(ScmSyncConfigurationDataProvider.java:106)
	at hudson.plugins.scm_sync_configuration.extensions.ScmSyncConfigurationFilter.doFilter(ScmSyncConfigurationFilter.java:44)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:151)
	at hudson.plugins.greenballs.GreenBallFilter.doFilter(GreenBallFilter.java:59)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:151)
	at jenkins.telemetry.impl.UserLanguages$AcceptLanguageFilter.doFilter(UserLanguages.java:128)
	at hudson.util.PluginServletFilter$1.doFilter(PluginServletFilter.java:151)
	at hudson.util.PluginServletFilter.doFilter(PluginServletFilter.java:157)
	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1642)
	at hudson.security.csrf.CrumbFilter.doFilter(CrumbFilter.java:99)
	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1642)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:84)
	at hudson.security.UnwrapSecurityExceptionFilter.doFilter(UnwrapSecurityExceptionFilter.java:51)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at jenkins.security.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:117)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at org.acegisecurity.providers.anonymous.AnonymousProcessingFilter.doFilter(AnonymousProcessingFilter.java:125)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at org.acegisecurity.ui.rememberme.RememberMeProcessingFilter.doFilter(RememberMeProcessingFilter.java:142)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at org.acegisecurity.ui.AbstractProcessingFilter.doFilter(AbstractProcessingFilter.java:271)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at jenkins.security.BasicHeaderProcessor.doFilter(BasicHeaderProcessor.java:93)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at org.acegisecurity.context.HttpSessionContextIntegrationFilter.doFilter(HttpSessionContextIntegrationFilter.java:249)
	at hudson.security.HttpSessionContextIntegrationFilter2.doFilter(HttpSessionContextIntegrationFilter2.java:67)
	at hudson.security.ChainedServletFilter$1.doFilter(ChainedServletFilter.java:87)
	at hudson.security.ChainedServletFilter.doFilter(ChainedServletFilter.java:90)
	at hudson.security.HudsonFilter.doFilter(HudsonFilter.java:171)
	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1642)
	at org.kohsuke.stapler.compression.CompressionFilter.doFilter(CompressionFilter.java:49)
	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1642)
	at hudson.util.CharacterEncodingFilter.doFilter(CharacterEncodingFilter.java:82)
	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1642)
	at org.kohsuke.stapler.DiagnosticThreadNameFilter.doFilter(DiagnosticThreadNameFilter.java:30)
	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1642)
	at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:533)
	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:146)
	at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:524)
	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:132)
	at org.eclipse.jetty.server.handler.ScopedHandler.nextHandle(ScopedHandler.java:257)
	at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:1595)
	at org.eclipse.jetty.server.handler.ScopedHandler.nextHandle(ScopedHandler.java:255)
	at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1340)
	at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:203)
	at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:473)
	at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:1564)
	at org.eclipse.jetty.server.handler.ScopedHandler.nextScope(ScopedHandler.java:201)
	at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1242)
	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:144)
	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:132)
	at org.eclipse.jetty.server.Server.handle(Server.java:503)
	at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:364)
	at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:260)
	at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:305)
	at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:103)
	at org.eclipse.jetty.io.ChannelEndPoint$2.run(ChannelEndPoint.java:118)
	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.runTask(EatWhatYouKill.java:333)
	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.doProduce(EatWhatYouKill.java:310)
	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.tryProduce(EatWhatYouKill.java:168)
	at org.eclipse.jetty.util.thread.strategy.EatWhatYouKill.run(EatWhatYouKill.java:126)
	at org.eclipse.jetty.util.thread.ReservedThreadExecutor$ReservedThread.run(ReservedThreadExecutor.java:366)
	at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:765)
	at org.eclipse.jetty.util.thread.QueuedThreadPool$2.run(QueuedThreadPool.java:683)
	at java.lang.Thread.run(Thread.java:748)
@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Aug 26, 2019

Nice work, I'll try to reproduce it locally when I next get the opportunity.

In the meantime, the line throwing the NPE is getting the tags from a SecretListEntry and turning them into a Map<String, String>. So the tags may have ended up as null in the SecretListEntry (or perhaps one of the keys was null, or perhaps a value for a key was null)...

Map<String, String> tags = s.getTags().stream().collect(Collectors.toMap(Tag::getKey, Tag::getValue));
@nathanieltalbot

This comment has been minimized.

Copy link

nathanieltalbot commented Aug 27, 2019

Hi Chris,
I think I figured out why this issue was happening -- it was on my end, but it is something that could affect other users. It turns out that when creating a secret in the command line and not specifying any tags, the cli apparently does not create an empty tag array, but does not create a tag array at all. When using the tag-resource command on the cli, you can add tags for existing secrets and the issue is resolved -- all secrets propagated with no errors for me in that case. However, when I created a tester secret there was no tag array created for it:

        {
            "ARN": "12345",
            "Name": "tester",
            "Description": "test",
            "LastChangedDate": 1566914015.965,
            "SecretVersionsToStages": {
                "12345": [
                    "AWSCURRENT"
                ]
            }
        }

and the issue happened again.

The workaround solution I found was to add --tags "[]" when using the create-secret command if you do not want to tag the secret -- this will properly create the empty array.

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Aug 27, 2019

Ah right, so it is possible to end up with a ‘null’ tags property after all. I’ll update the code to defend against this possibility. (I also added defensive checks against tags where the key is null or the value is null, which could also have resulted in NPEs).

@itsSaad

This comment has been minimized.

Copy link

itsSaad commented Oct 18, 2019

Thankyou guys for this amazing feature and the equally amazing hard work. We are definitely waiting for this to be released and will be very excited to try this out.

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Oct 18, 2019

In terms of caching I think the plugin is already doing about as much as it can...

It’s caching all invocations of ListSecrets already (5min duration).

The scripted pipeline results in 1 GetSecretValue call per cred, at the start of each build, then caches for the build’s duration.

The declarative pipeline is the same except it’s 2 calls per cred.

If a plugin (eg git-client-plugin) elects to snapshot a credential from secrets manager, a single GetSecretValue call will happen at snapshot time, and then the snapshotted cred will exist as long as that plugin wants it.

This is all pretty efficient... I can’t think of what else I could cache. And to exceed the 700rps limit, I can only think of 2 scenarios:

  • A Jenkins installation that is spawning say ~200 pipeline jobs at the same time with say 3/4 creds each.
  • Credentials consumer plugins that directly call the CredentialsProvider and then handle the returned cred objects in a custom way. (They might keep calling getSecret() and not cache it. We can’t stop them.)

In these scenarios AWS SDK should throw a LimitExceededException. I trap AmazonClientExceptions (the general AWS exception supertype) to throw a more friendly CredentialsUnavailableException for the field that could not be resolved. But I do not currently include the message from the AmazonClientException (as it can be long).

Maybe I should adjust this behaviour to include the AWS message, or a short form of it?

@coltrey

This comment has been minimized.

Copy link

coltrey commented Oct 18, 2019

Maybe I should adjust this behaviour to include the AWS message, or a short form of it?

I'd say include the detailed message at INFO or greater to allow an administrator to create a system log that will catch it for troubleshooting.

@coltrey

This comment has been minimized.

Copy link

coltrey commented Oct 24, 2019

@chriskilding Does the readme reflect the tagging conventions? I'm not seeing it for all credential types...is it required for all?

tagging convention of jenkins:credentials:

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Oct 24, 2019

The Username With Password and SSH Key credential types have metadata fields and I believe all of those should reflect the tagging naming conventions. (Unless you can highlight any I’ve missed?)

The Secret Text and Certificate types don’t have any metadata fields.

@chriskilding chriskilding marked this pull request as ready for review Oct 24, 2019
@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Oct 24, 2019

I’m pleased to announce that me and @chroche have completed our private testing of this feature, and squashed the initial bugs that we could find.

It’s public review time...

Please post your feedback, any bugs you find when you run it on your own Jenkins, and any considerations about the design below.

After public review, when we’re happy to go forward with the design, I will merge to master and release a beta build to the Jenkins Experimental update site.

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Oct 25, 2019

@itsSaad @nathanieltalbot @jeffpearce I believe each of you have Jenkins installations and an interest in running this plugin - would you be happy to test drive this feature and let me know if it works well for you?

@jeffpearce

This comment has been minimized.

Copy link

jeffpearce commented Oct 25, 2019

@chriskilding chriskilding requested a review from jeffpearce Oct 28, 2019
Copy link

jeffpearce left a comment

LGTM

… nor secretBinary
@nathanieltalbot

This comment has been minimized.

Copy link

nathanieltalbot commented Oct 28, 2019

@chriskilding I've been using it on our main Jenkins instance for almost two months now with no major issues -- I haven't updated to the most recent build, (I'm using a version from around September 13) but thus far it has performed very well for pipeline builds. Happy to test with specific use cases if needed.

On a related note, it ironically seems that ci.jenkins.io is down, so I can't access the ci builds -- is it down for others, or has it moved elsewhere?

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Oct 28, 2019

Hi Nat, I’d be interested specifically if you could test the latest build with a job that has a Git checkout step, or sshagent step, running on a slave node. (Credential snapshotting and serialisation is something I’ve only addressed in the latest builds - but I’d appreciate confirmation of whether it is indeed fixed.)

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Oct 28, 2019

And yeah ci.jenkins.io was down for all when I pushed a few hours ago. Some of the project leads are re-prioritising their upcoming work to focus on the infrastructure and outages, which is good news.

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Nov 4, 2019

@Quaiks I believe you made the original request for this feature, so would you be able to have a play with it and let me know if it works for your company?

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Nov 7, 2019

In the interests of time (and some upcoming Jenkins deployments at work) I intend to merge this in the next couple of weeks.

So if you have any feedback, please post it below before the 22nd November. If I don’t hear anything by then I’ll assume it’s good to go and merge to master.

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Nov 22, 2019

Alright, looks like it's merge time...

Thanks to everyone who participated on this PR to help make it happen 🎉

@chriskilding chriskilding merged commit 06ad79e into master Nov 22, 2019
1 check passed
1 check passed
continuous-integration/jenkins/branch This commit looks good
Details
@chriskilding chriskilding deleted the feature/multiple-credential-types branch Nov 22, 2019
@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Nov 22, 2019

I've uploaded a release candidate 0.1.0-beta-1 to Jenkins Experiments. We are smoke testing that now. If all goes to plan the stable release should follow in a few days time.

@chriskilding

This comment has been minimized.

Copy link
Contributor Author

chriskilding commented Nov 28, 2019

Stable version 0.1.0 has been uploaded to the main Jenkins update site. This concludes the feature.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
8 participants
You can’t perform that action at this time.