savoirfairelinux/ansible-nexus3-oss

Multiple runs make repo unconfigurable in GUI

elconas opened this issue ยท 24 comments

Hello, when running the create_repo_docker_proxy_each role multiple times, the Nexus GUI returns "Warning Missing entity-metadata" when Itry to make manual changes to the repo created by Nexus. I can see the following:

  • Empty Server
  • Running create_repo_docker_proxy_each first time, everything is OK, repo can be configured via GUI (e.g. disable force basic auth)
  • Run the create_repo_docker_proxy_each once more and then I get "Warning Missing entity-metadata" in the GUI and the following Exception in the log. Now can I not even delete the repo anymore via Gui!
2017-11-23 05:20:37,828+0000 ERROR [qtp300886853-190]  admin org.sonatype.nexus.extdirect.internal.ExtDirectServlet - Failed to invoke action method: coreui_Repository.update, java-method: org.sonatype.nexus.coreui.RepositoryComponent.update
java.lang.IllegalStateException: Missing entity-metadata
	at com.google.common.base.Preconditions.checkState(Preconditions.java:444)
	at org.sonatype.nexus.common.entity.EntityHelper.metadata(EntityHelper.java:46)
	at org.sonatype.nexus.common.entity.EntityHelper.id(EntityHelper.java:62)
	at org.sonatype.nexus.orient.entity.EntityAdapter.recordIdentity(EntityAdapter.java:363)
	at org.sonatype.nexus.orient.entity.EntityAdapter.editEntity(EntityAdapter.java:286)
	at org.sonatype.nexus.repository.config.internal.ConfigurationStoreImpl.lambda$2(ConfigurationStoreImpl.java:88)
	at org.sonatype.nexus.orient.transaction.OrientOperations.lambda$2(OrientOperations.java:63)
	at org.sonatype.nexus.transaction.OperationPoint.lambda$0(OperationPoint.java:53)
	at org.sonatype.nexus.transaction.OperationPoint.proceed(OperationPoint.java:64)
	at org.sonatype.nexus.transaction.TransactionalWrapper.proceedWithTransaction(TransactionalWrapper.java:56)
	at org.sonatype.nexus.transaction.Operations.transactional(Operations.java:200)
	at org.sonatype.nexus.transaction.Operations.run(Operations.java:155)
	at org.sonatype.nexus.orient.transaction.OrientOperations.run(OrientOperations.java:63)
	at org.sonatype.nexus.repository.config.internal.ConfigurationStoreImpl.update(ConfigurationStoreImpl.java:88)
	at org.sonatype.nexus.common.stateguard.MethodInvocationAction.run(MethodInvocationAction.java:39)
	at org.sonatype.nexus.common.stateguard.StateGuard$GuardImpl.run(StateGuard.java:270)
	at org.sonatype.nexus.common.stateguard.GuardedInterceptor.invoke(GuardedInterceptor.java:53)
	at org.sonatype.nexus.repository.manager.internal.RepositoryManagerImpl.update(RepositoryManagerImpl.java:348)
	at org.sonatype.nexus.common.stateguard.MethodInvocationAction.run(MethodInvocationAction.java:39)
	at org.sonatype.nexus.common.stateguard.StateGuard$GuardImpl.run(StateGuard.java:270)
	at org.sonatype.nexus.common.stateguard.GuardedInterceptor.invoke(GuardedInterceptor.java:53)
	at org.sonatype.nexus.repository.manager.RepositoryManager$update$3.call(Unknown Source)
	at org.sonatype.nexus.coreui.RepositoryComponent.update(RepositoryComponent.groovy:234)
	at com.palominolabs.metrics.guice.ExceptionMeteredInterceptor.invoke(ExceptionMeteredInterceptor.java:49)
	at com.palominolabs.metrics.guice.TimedInterceptor.invoke(TimedInterceptor.java:47)
	at org.sonatype.nexus.validation.internal.ValidationInterceptor.invoke(ValidationInterceptor.java:53)
	at org.apache.shiro.guice.aop.AopAllianceMethodInvocationAdapter.proceed(AopAllianceMethodInvocationAdapter.java:49)
	at org.apache.shiro.authz.aop.AuthorizingAnnotationMethodInterceptor.invoke(AuthorizingAnnotationMethodInterceptor.java:68)
	at org.apache.shiro.guice.aop.AopAllianceMethodInterceptorAdapter.invoke(AopAllianceMethodInterceptorAdapter.java:36)
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
	at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
	at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
	at java.lang.reflect.Method.invoke(Method.java:498)
	at com.softwarementors.extjs.djn.router.dispatcher.DispatcherBase.invokeJavaMethod(DispatcherBase.java:142)
	at com.softwarementors.extjs.djn.router.dispatcher.DispatcherBase.invokeMethod(DispatcherBase.java:133)
	at org.sonatype.nexus.extdirect.internal.ExtDirectServlet$3.invokeMethod(ExtDirectServlet.java:233)
	at com.softwarementors.extjs.djn.router.dispatcher.DispatcherBase.dispatch(DispatcherBase.java:63)
	at com.softwarementors.extjs.djn.router.processor.standard.StandardRequestProcessorBase.dispatchStandardMethod(StandardRequestProcessorBase.java:73)
	at com.softwarementors.extjs.djn.router.processor.standard.json.JsonRequestProcessor.processIndividualRequest(JsonRequestProcessor.java:502)
	at com.softwarementors.extjs.djn.router.processor.standard.json.JsonRequestProcessor.processIndividualRequestsInThisThread(JsonRequestProcessor.java:150)
	at com.softwarementors.extjs.djn.router.processor.standard.json.JsonRequestProcessor.process(JsonRequestProcessor.java:133)
	at com.softwarementors.extjs.djn.router.RequestRouter.processJsonRequest(RequestRouter.java:83)
	at com.softwarementors.extjs.djn.servlet.DirectJNgineServlet.processRequest(DirectJNgineServlet.java:617)
	at com.softwarementors.extjs.djn.servlet.DirectJNgineServlet.doPost(DirectJNgineServlet.java:580)
	at org.sonatype.nexus.extdirect.internal.ExtDirectServlet.doPost(ExtDirectServlet.java:138)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:707)
	at javax.servlet.http.HttpServlet.service(HttpServlet.java:790)
	at com.google.inject.servlet.ServletDefinition.doServiceImpl(ServletDefinition.java:286)
	at com.google.inject.servlet.ServletDefinition.doService(ServletDefinition.java:276)
	at com.google.inject.servlet.ServletDefinition.service(ServletDefinition.java:181)
	at com.google.inject.servlet.DynamicServletPipeline.service(DynamicServletPipeline.java:71)
	at com.google.inject.servlet.FilterChainInvocation.doFilter(FilterChainInvocation.java:85)
	at org.apache.shiro.web.servlet.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:112)
	at com.google.inject.servlet.FilterChainInvocation.doFilter(FilterChainInvocation.java:82)
	at org.apache.shiro.web.servlet.ProxiedFilterChain.doFilter(ProxiedFilterChain.java:61)
	at org.apache.shiro.web.servlet.AdviceFilter.executeChain(AdviceFilter.java:108)
	at org.apache.shiro.web.servlet.AdviceFilter.doFilterInternal(AdviceFilter.java:137)
	at org.apache.shiro.web.servlet.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:125)
	at org.apache.shiro.web.servlet.ProxiedFilterChain.doFilter(ProxiedFilterChain.java:66)
	at org.apache.shiro.web.servlet.AdviceFilter.executeChain(AdviceFilter.java:108)
	at org.apache.shiro.web.servlet.AdviceFilter.doFilterInternal(AdviceFilter.java:137)
	at org.apache.shiro.web.servlet.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:125)
	at org.apache.shiro.web.servlet.ProxiedFilterChain.doFilter(ProxiedFilterChain.java:66)
	at org.apache.shiro.web.servlet.AbstractShiroFilter.executeChain(AbstractShiroFilter.java:449)
	at org.sonatype.nexus.security.SecurityFilter.executeChain(SecurityFilter.java:85)
	at org.apache.shiro.web.servlet.AbstractShiroFilter$1.call(AbstractShiroFilter.java:365)
	at org.apache.shiro.subject.support.SubjectCallable.doCall(SubjectCallable.java:90)
	at org.apache.shiro.subject.support.SubjectCallable.call(SubjectCallable.java:83)
	at org.apache.shiro.subject.support.DelegatingSubject.execute(DelegatingSubject.java:383)
	at org.apache.shiro.web.servlet.AbstractShiroFilter.doFilterInternal(AbstractShiroFilter.java:362)
	at org.sonatype.nexus.security.SecurityFilter.doFilterInternal(SecurityFilter.java:101)
	at org.apache.shiro.web.servlet.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:125)
	at com.google.inject.servlet.FilterChainInvocation.doFilter(FilterChainInvocation.java:82)
	at com.sonatype.nexus.licensing.internal.LicensingRedirectFilter.doFilter(LicensingRedirectFilter.java:108)
	at com.google.inject.servlet.FilterChainInvocation.doFilter(FilterChainInvocation.java:82)
	at com.codahale.metrics.servlet.AbstractInstrumentedFilter.doFilter(AbstractInstrumentedFilter.java:97)
	at com.google.inject.servlet.FilterChainInvocation.doFilter(FilterChainInvocation.java:82)
	at org.sonatype.nexus.internal.web.ErrorPageFilter.doFilter(ErrorPageFilter.java:68)
	at com.google.inject.servlet.FilterChainInvocation.doFilter(FilterChainInvocation.java:82)
	at org.sonatype.nexus.internal.web.EnvironmentFilter.doFilter(EnvironmentFilter.java:102)
	at com.google.inject.servlet.FilterChainInvocation.doFilter(FilterChainInvocation.java:82)
	at org.sonatype.nexus.internal.web.HeaderPatternFilter.doFilter(HeaderPatternFilter.java:98)
	at com.google.inject.servlet.FilterChainInvocation.doFilter(FilterChainInvocation.java:82)
	at com.google.inject.servlet.DynamicFilterPipeline.dispatch(DynamicFilterPipeline.java:104)
	at com.google.inject.servlet.GuiceFilter.doFilter(GuiceFilter.java:135)
	at org.sonatype.nexus.bootstrap.osgi.DelegatingFilter.doFilter(DelegatingFilter.java:73)
	at org.eclipse.jetty.servlet.ServletHandler$CachedChain.doFilter(ServletHandler.java:1751)
	at org.eclipse.jetty.servlet.ServletHandler.doHandle(ServletHandler.java:582)
	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:143)
	at org.eclipse.jetty.security.SecurityHandler.handle(SecurityHandler.java:548)
	at org.eclipse.jetty.server.session.SessionHandler.doHandle(SessionHandler.java:226)
	at org.eclipse.jetty.server.handler.ContextHandler.doHandle(ContextHandler.java:1180)
	at org.eclipse.jetty.servlet.ServletHandler.doScope(ServletHandler.java:512)
	at org.eclipse.jetty.server.session.SessionHandler.doScope(SessionHandler.java:185)
	at org.eclipse.jetty.server.handler.ContextHandler.doScope(ContextHandler.java:1112)
	at org.eclipse.jetty.server.handler.ScopedHandler.handle(ScopedHandler.java:141)
	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:134)
	at com.codahale.metrics.jetty9.InstrumentedHandler.handle(InstrumentedHandler.java:175)
	at org.eclipse.jetty.server.handler.HandlerCollection.handle(HandlerCollection.java:119)
	at org.eclipse.jetty.server.handler.HandlerWrapper.handle(HandlerWrapper.java:134)
	at org.eclipse.jetty.server.Server.handle(Server.java:534)
	at org.eclipse.jetty.server.HttpChannel.handle(HttpChannel.java:320)
	at org.eclipse.jetty.server.HttpConnection.onFillable(HttpConnection.java:251)
	at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:283)
	at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:108)
	at org.eclipse.jetty.io.ssl.SslConnection.onFillable(SslConnection.java:251)
	at org.eclipse.jetty.io.AbstractConnection$ReadCallback.succeeded(AbstractConnection.java:283)
	at org.eclipse.jetty.io.FillInterest.fillable(FillInterest.java:108)
	at org.eclipse.jetty.io.SelectChannelEndPoint$2.run(SelectChannelEndPoint.java:93)
	at org.eclipse.jetty.util.thread.strategy.ExecuteProduceConsume.executeProduceConsume(ExecuteProduceConsume.java:303)
	at org.eclipse.jetty.util.thread.strategy.ExecuteProduceConsume.produceConsume(ExecuteProduceConsume.java:148)
	at org.eclipse.jetty.util.thread.strategy.ExecuteProduceConsume.run(ExecuteProduceConsume.java:136)
	at org.eclipse.jetty.util.thread.QueuedThreadPool.runJob(QueuedThreadPool.java:671)
	at org.eclipse.jetty.util.thread.QueuedThreadPool$2.run(QueuedThreadPool.java:589)
	at java.lang.Thread.run(Thread.java:748)

Config for the Repo:

    nexus_repos_docker_proxy:
     - name: docker-dockerhub
       http_port: "40010"
       v1_enabled: True
       index_type: "HUB"
       proxy_url: "https://registry-1.docker.io"
       use_nexus_certificates_to_access_index: false

I also tested with a private docker repo. Same issue here. It happens with nexus 3.6.0 and 3.6.1. It also happens with Nexus 3.3.0.

One more not. If I change the "update()" to a noop, everything works. So the problem seems to be in the "update()" call

if (existingRepository != null) {
    existingRepository.stop()
    //configuration.attributes['storage']['blobStoreName'] = existingRepository.configuration.attributes['storage']['blobStoreName']
    //existingRepository.update(configuration)
    existingRepository.start()
} else {
    repository.getRepositoryManager().create(configuration)
}

re-setting the existing configuration does also work

if (existingRepository != null) {
    existingRepository.stop()
    //configuration.attributes['storage']['blobStoreName'] = existingRepository.configuration.attributes['storage']['blobStoreName']
    existingRepository.update(existingRepository.configuration)
    existingRepository.start()
} else {
    repository.getRepositoryManager().create(configuration)
}

modifying the existing config in place and setting it again does also work

if (existingRepository != null) {
    existingRepository.stop()
    //configuration.attributes['storage']['blobStoreName'] = existingRepository.configuration.attributes['storage']['blobStoreName']
    existingRepository.configuration.attributes['docker']['v1Enabled']=false
    existingRepository.update(existingRepository.configuration)
    existingRepository.start()
} else {
    repository.getRepositoryManager().create(configuration)
}

+1
Thanks for the tip. I actually tested and the problem is the same with maven repositories (did not test other types but I guess we will have the same problem).

Yes, this seems a comming problem now.

I also reported this in #37

There's an ticket NEXUS-14948 for this and a discussion on the users group, here.

Be aware that also any changes you make using the intergation api (groovy scripts) will be reverted when Nexus restarts.

I also see this with Nexus 3.3.0 (using docker version)

@dannyk81 Regarding the statement:

Be aware that also any changes you make using the intergation api (groovy scripts) will be reverted when Nexus restarts.

Is this only to for the writePolicy setting or all settings ?

From my tests with Nexus 3.6.0 and 3.6.1, any parameter change in repository settings gets reverted.

The ticket mentioned the write policy specifically, but I've seen that it affects all settings.

I made a first test tonight and was actually able to fix the fact that the repo is not saveable/deleteable from gui after a second playbook run by changing the groovy script (i.e. get the current config and only update values which your are allowed to effectively change). Meanwhile, I did not try to change any values from my vars in playbook. I made the test against docker proxy repo only for now. I'm going to check if the values get reverted. Coming back.

Yes, it seems modifying in place seems to work:

    existingRepository.stop()
    conig = existingRepository.configuration
    .. now modify config ...
    existingRepository.update(config)
    existingRepository.start()

working/testing a fix to change only allowed parameters. I'll keep you updated and submit a merge request if successful.

Actually I am (generally) a little concerned that groovy scripts in nexus can make the server unusabe. It should not be possible for users to get Nexus in an inconsistent state.

We agree on that point !

Meanwhile, since I'm not a nexus dev and only a late user of this ansible role, I'm trying to find a way to not screw-up my production.

My latest tests:

  • Good: I was able to fix the "repository is unusable from GUI" problem.
  • Bad: the modifications made by a subsequent run do not survive a nexus restart (tried by changing a docker proxy url endpoint).

Looking at the nexus javadoc to see if I can find something interesting there.

Hi guys. I did not take the time to start a PR since the issue is not resolved (the settings are still reverted back to previous when nexus restart). But this is how I managed to keep the repos usable in the gui (example for docker hosted):

import groovy.json.JsonSlurper
import org.sonatype.nexus.repository.config.Configuration

parsed_args = new JsonSlurper().parseText(args)

def existingRepository = repository.getRepositoryManager().get(parsed_args.name)

if (existingRepository != null) {
    def newConfig = existingRepository.configuration.copy()
    // We only update values we are allowed to change (cf. greyed out options in gui)
    newConfig.attributes['docker']['forceBasicAuth'] = parsed_args.force_basic_auth
    newConfig.attributes['docker']['v1Enabled'] = parsed_args.v1_enabled
    newConfig.attributes['storage']['writePolicy'] = parsed_args.write_policy.toUpperCase()
    newConfig.attributes['storage']['strictContentTypeValidation'] = Boolean.valueOf(parsed_args.strict_content_validation)
    if (parsed_args.http_port) {
        newConfig.attributes['docker']['httpPort'] = parsed_args.http_port
    } else {
        newConfig.attributes['docker']['httpPort'] = ""
    }
    existingRepository.stop()
    existingRepository.update(newConfig)
    existingRepository.start()
} else {
    configuration = new Configuration(
            repositoryName: parsed_args.name,
            recipeName: 'docker-hosted',
            online: true,
            attributes: [
                    docker: [
                            forceBasicAuth: parsed_args.force_basic_auth,
                            v1Enabled : parsed_args.v1_enabled
                    ],
                    storage: [
                            writePolicy: parsed_args.write_policy.toUpperCase(),
                            blobStoreName: parsed_args.blob_store,
                            strictContentTypeValidation: Boolean.valueOf(parsed_args.strict_content_validation)
                    ]
            ]
    )
    if (parsed_args.http_port) {
        configuration.attributes['docker']['httpPort'] = parsed_args.http_port
    }
    repository.getRepositoryManager().create(configuration)
}

Thanks @zeitounator! this is indeed a way forward.

I suggest we apply these changes to all repo scripts, however please note that @samherve is no longer maintaing this project, so any PR we submit would simply not be merged.

As he suggests, perhaps we should fork this project and start pushing the PRs that are pending here.

In any case, I'm hoping Sonatype will provide root cause for both issues we've been experiencing (vote for the ticket here), as suggested above - it's unresanable that an incorrect usage by a groovy script would render a system inoperable and the reverted settings after restart is still a mistery and in our case a source for a lot of headache...

Hi @dannyk81. I missed the information about @samherve stopping maintenance. Thanks for sharing. This is sad. I just added a comment in #36 and I think we should wait some more days (like end of next week ?) for a clear answer from @support-savoirfairelinux before we take a decision. Meanwhile, if we should come to this, I think we should hard fork in an organization somewhere and not keep the new reference repository under a single user name. What's your opinion about it ?

I agree, let's wait for some time for feedback.

Worst case, we'll hard fork this as you suggest.

Problem fixed after sonatype response on the ticket. We were making an incorrect use of the groovy available interfaces (even though it should be locked up better and better documented as well.....). Once again, the working solution with docker proxy repo as example

import groovy.json.JsonSlurper
import org.sonatype.nexus.repository.config.Configuration

parsed_args = new JsonSlurper().parseText(args)

repositoryManager = repository.repositoryManager

existingRepository = repositoryManager.get(parsed_args.name)

if (existingRepository != null) {
    newConfig = existingRepository.configuration.copy()
    // We only update values we are allowed to change (cf. greyed out options in gui)
    newConfig.attributes['docker']['forceBasicAuth'] = parsed_args.force_basic_auth
    newConfig.attributes['docker']['v1Enabled'] = parsed_args.v1_enabled
    newConfig.attributes['storage']['writePolicy'] = parsed_args.write_policy.toUpperCase()
    newConfig.attributes['storage']['strictContentTypeValidation'] = Boolean.valueOf(parsed_args.strict_content_validation)
    if (parsed_args.http_port) {
        newConfig.attributes['docker']['httpPort'] = parsed_args.http_port
    } else {
        newConfig.attributes['docker']['httpPort'] = ""
    }

    repositoryManager.update(newConfig)

} else {

    configuration = new Configuration(
            repositoryName: parsed_args.name,
            recipeName: 'docker-hosted',
            online: true,
            attributes: [
                    docker: [
                            forceBasicAuth: parsed_args.force_basic_auth,
                            v1Enabled : parsed_args.v1_enabled
                    ],
                    storage: [
                            writePolicy: parsed_args.write_policy.toUpperCase(),
                            blobStoreName: parsed_args.blob_store,
                            strictContentTypeValidation: Boolean.valueOf(parsed_args.strict_content_validation)
                    ]
            ]
    )
    if (parsed_args.http_port) {
        configuration.attributes['docker']['httpPort'] = parsed_args.http_port
    }

    repositoryManager.create(configuration)

}

Wow! great news ๐Ÿ˜ƒ @zeitounator

I'll have to update my scripts per above! shame there's still no response from @support-savoirfairelinux... we need to preserve and maintain this work somehow.

Hi guys. Seems like @support-savoirfairelinux is not willing to respond. I would still like to give them a chance to do so until the end of the week.

Meanwhile, as a beginning, I have started to merge the pull requests found in this repo that I already use for production on my own github repo. You can find the result there on the master branch (ongoing work): https://github.com/zeitounator/ansible-nexus3-oss. I have some more additions to share which are not shown there (I use an other gitlab repo for work which is not public)

I am planning to merge all the remaining PRs from here (the ones i did not pick yet on my prod), the necessary changes to groovy scripts for persistence of changes discussed in this issue and the work I have done on my own and not yet shared.

If there is no answer from @support-savoirfairelinux by then, I suggest to hard fork in a central location under a team name. If someone has an existing one to suggest, I'll be happy to join and/or collaborate. If not, I was about to create one under my own self-employed company name (ThoTeam) and invite the good willing people who have some time to spend on maintaining this. Let me know what you think.

Regards

Hi @zeitounator!

+1 on your propsal and thanks for initiative, it seems that if we want to keep this work alive and relevant it should be a good idea.

I would be more than happy to contribute/assist.

I went ahead and hard forked the repo since I didn't get a response from the current owners and no proposition for an existing organization to host it. I'm trying to integrate as fast as possible all the leftover pull requests. I will put a comment in each of them once it is done. The new hard forked repository is here: https://github.com/ansible-ThoTeam/nexus3-oss

Here is my solution:

import groovy.json.JsonSlurper
import org.sonatype.nexus.repository.config.Configuration

parsed_args = new JsonSlurper().parseText(args)

configuration = new Configuration(
        repositoryName: parsed_args.name,
        recipeName: 'docker-hosted',
        online: true,
        attributes: [
                docker: [
                        httpPort: parsed_args.http_port,
                        v1Enabled : parsed_args.v1_enabled
                ],
                storage: [
                        writePolicy: parsed_args.write_policy.toUpperCase(),
                        blobStoreName: parsed_args.blob_store,
                        strictContentTypeValidation: Boolean.valueOf(parsed_args.strict_content_validation)
                ]
        ]
)

def existingRepository = repository.getRepositoryManager().get(parsed_args.name)

if (existingRepository != null) {
    existingRepository.stop()
    config = existingRepository.configuration;
    blobStoreName = config.attributes['storage']['blobStoreName'];
    config.attributes = configuration.attributes
    config.attributes['storage']['blobStoreName'] = blobStoreName
    existingRepository.update(config)
    existingRepository.start()
} else {
    repository.getRepositoryManager().create(configuration)
}

Hi @tycoonm, As you may not have seen, this repo is totally silent since end of maintenance was announced almost 2 years ago. This issue has been fixed very long ago in the following hard fork repo which also contains tons of other new features: https://github.com/ansible-ThoTeam/nexus3-oss. You're welcome to come by and visit if you are interested. Cheers.