Developing an Ansible Role for Sonatype Nexus Repository v3.x

Developing an Ansible Role for Sonatype Nexus Repository v3.x

January 11, 2018
By Samuel Hervé

28 minute read time

This article explains how to automate the installation and configuration of Sonatype Nexus Repository version 3.x with Ansible.

Ansible is a deployment tool, which enables playbooks to automate applications and infrastructure deployments. The key advantage is its flexibility to change applications with versatility, as well as apprehending them as a service. However, Ansible has some weaknesses too. Following a voluntary simple design, it functions solely as an application of parameters, without taking into account information availability and security concerns, which need to be handled by other systems. That is why developers prefer to use Ansible in combination with a stateful agent system like Puppet, or a centralized management tool like Ansible Tower.

Automation with Ansible

Ansible focuses on the application-level setup, scripting a provisioning that can be run on top of any infrastructure-supporting tool (PaaS, containers, bare-metal, vagrant, etc.). It only needs an SSH connection and a sudo account to the remote system.

Provisioning scripts in Ansible are written in a declarative style, using YAML files grouped as roles. The atomic instructions in those roles are expressed using many core modules provided by Ansible. Please look at the Ansible documentation for an in-depth introduction.

Re-Provisioning and Updating Configuration

One of the DevOps models to handle configuration updates is to provision a brand new environment from scratch and completely discard the old one (think container images). This implies a reliable management of your data life cycle. In our particular case of Sonatype Nexus Repository, this consists of several gigs of uploaded/proxied artifacts, some audit logs, and OrientDB blobs containing the configuration. Therefore, depending on one’s environment constraints, it can make sense to update the configuration of an already-provisioned Sonatype Nexus Repository instance. The declarative nature of Ansible’s core instructions is in line with this purpose, but any custom logic written in a role should be idempotent, take the "create or maybe update" path.

One must also note that some parts of the Sonatype Nexus Repository configuration cannot be updated. Some examples include:

How to Make Sonatype Nexus Repository Groovy API Fit Well with Ansible

The basic steps of the installation are pretty straightforward, and can all be written using simple Ansible core modules:

(these steps are in tasks/nexus_install.yml)

And then comes the surprise: Sonatype Nexus Repository configuration is not available in a simple text file format, which can be edited with simple Ansible instructions. It is stored in an embedded OrientDB database that must not be altered directly.

The way the Integration API works is as follows:

  1. Write a Groovy script that handles your configuration change;
  2. Upload it to Sonatype Nexus with an HTTP PUT request, creating a REST resource for this script;
  3. Call the script through its HTTP GET/POST resource.

URI Module to the Rescue

Ansible's uri module makes HTTP requests, providing automation to all this.

The first step is to upload the Groovy script on Sonatype Nexus Repository. Note the script may already be there. Therefore, on re-runs of the playbook, we try to delete it before taking any action, just in case:

Through tasks/declare_script_each.yml, follow on:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
---
- name: Removing (potential) previously declared Groovy script
uri:`<br>url: "http://localhost:8081/service/siesta/rest/v1/script/{{item}}"`
user: 'admin'`<br>password: ""<br>```method: DELETE
force_basic_auth: yes`<br>status_code: 204,404<br>- name: Declaring Groovy script {{item}}<br>```uri:
url: "http://localhost:8081/service/siesta/rest/v1/script"`<br>user: 'admin'<br>```password: ""
body_format: json`<br>method: POST<br>```force_basic_auth: yes
status_code: 204`<br>body:<br>```name: "{{item}}"
type: 'groovy'`<br>content: ""`

The HTTP requests are executed from inside the target host, which is why localhost is used here. force basic_auth: yes makes the HTTP client not wait for a 401 before providing credentials, as Sonatype Nexus immediately replies with 403 when no credentials are passed. status_code is the expected HTTP status replied by Sonatype Nexus. Since the Groovy script may not necessarily exist at that point, we must also accept the 404 status code.

The next step is to call the Groovy script created through the previous HTTP call. Most scripts will take some parameters as input (e.g. create user ), and this is where Ansible and Groovy will help. Both coming from the ages of REST things, they can speak and understand JSON fluently.

On the Groovy script side :

1
2
3
import``groovy.json.JsonSlurper
parsed_args =``new``JsonSlurper().parseText(args)
security.setAnonymousAccess(Boolean.valueOf(parsed_args.anonymous_access))

And to call this script from Ansible passing arguments:

1
2
3
4
5
- include: call_script.yml
vars:`<br>script_name: setup_anonymous_access<br>```args: # this structure will be parsed by the groovy JsonSlurper above
```anonymous_access: true`

with call_script.yml:

1
2
3
4
5
6
7
8
9
10
11
12
---
- name: Calling Groovy script
uri:`<br>url: "http://localhost:8081/service/siesta/rest/v1/script//run"`
user: 'admin'`<br>password: ""<br>```headers:
Content-Type: "text/plain"`<br>method: POST<br>```status_code: 200,204
force_basic_auth: yes`<br>body: ""`

This allows us to cleanly pass structured parameters from Ansible to the Groovy scripts, keeping the objects’ structure, arrays and basic types.

Sonatype Nexus Repository Groovy Scripts Development Tips and Tricks

Here are some hints that can help a developer while working on the Groovy scripts.

Have a Classpath Setup in Your IDE

As described in the Sonatype Nexus Repository documentation, having Sonatype Nexus Repository scripting in your IDE's classpath can really help you work. If you automate the Sonatype Nexus Repository setup as much as possible, you will inevitably stumble against some undocumented internal APIs. Additionally, some parts of the API do not have any source available (e.g. LDAP). In such cases, a decompiler can be useful.

Since our role on GitHub uses Maven with all the necessary dependencies, you can simply open it with IntelliJ and edit the scripts in files/groovy.

Scripting API Entry Points

As documented, there are four implicit entry points to access Sonatype Nexus Repository internals from your script:

Those are useful for simple operations, but for anything more complicated, you will need to resolve services more in-depth:

Take Examples From Sonatype Nexus Repository Source Code

Some parts of Sonatype Nexus Repository (7.4%, according to GitHub) are also written using Groovy, containing lots of nice code examples: CoreApiImpl.groovy.

Creating HTTP requests from the configuration web interface (AJAX requests) also provides some hints about the expected data structures, parameters or values of some settings.

Last but not least, setting up a remote debugger from your IDE to a live Sonatype Nexus Repository instance can help, since there are many places where a generic data structure is used (like Map<String, Object>) and only runtime inspection can quickly tell the actual needed types.

Detailed Examples

Here are some commented examples of Groovy scripts taken from the Ansible role.

Setting Up a Capability

Capabilities are features of Sonatype Nexus Repository that can be configured using a unified user interface. In our case, this covers:

  1. anonymous access
  2. base public URL
  3. branding (custom HTML header/footer).

Instructions:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import``groovy.json.JsonSlurper
import``org.sonatype.nexus.capability.CapabilityReference
import``org.sonatype.nexus.capability.CapabilityType
import``org.sonatype.nexus.internal.capability.DefaultCapabilityReference
import``org.sonatype.nexus.internal.capability.DefaultCapabilityRegistry
// unmarshall the parameters as JSON
parsed_args =``new``JsonSlurper().parseText(args)
// Type casts, JSON serialization insists on keeping those as 'boolean'
parsed_args.capability_properties[``'headerEnabled'``] = parsed_args.capability_properties[``'headerEnabled'``].toString()
parsed_args.capability_properties[``'footerEnabled'``] = parsed_args.capability_properties[``'footerEnabled'``].toString()
// Resolve a @Singleton from the container context
def``capabilityRegistry = container.lookup(DefaultCapabilityRegistry.``class``.getName())
def``capabilityType = CapabilityType.capabilityType(parsed_args.capability_typeId)
// Try to find an existing capability to update it
DefaultCapabilityReference existing = capabilityRegistry.all.``find``{
CapabilityReference capabilityReference -&amp;amp;amp;amp;amp;amp;amp;gt;`<br>capabilityReference.context().descriptor().type() == capabilityType<br>}<br>// update<br>if(existing) {`<br>```log.info(parsed_args.typeId +' capability updated to: {}',`<br>```capabilityRegistry.update(existing.id(), existing.active, existing.notes(), parsed_args.capability_properties).toString()`<br>```)`<br>`}else{// or create<br>```log.info(parsed_args.typeId +``' capability created as: {}'``,
capabilityRegistry.`<br>add(capabilityType, true,'configured through api', parsed_args.capability_properties).toString()<br>```)
}

Setting Up a Maven Repository Proxy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
import``groovy.json.JsonSlurper
import``org.sonatype.nexus.repository.config.Configuration
// unmarshall the parameters as JSON
parsed_args =``new``JsonSlurper().parseText(args)
// The two following data structures are good examples of things to look for via runtime inspection
// either in client Ajax calls or breakpoints in a live server
authentication = parsed_args.remote_username ==``null``?``null``: [
type:``'username'``,`<br>username: parsed_args.remote_username,<br>```password: parsed_args.remote_password
]
configuration =``new``Configuration(
repositoryName: parsed_args.name,`<br>recipeName:'maven2-proxy',<br>```online: true,
attributes: [`<br>maven  : [<br>```versionPolicy: parsed_args.version_policy.toUpperCase(),
layoutPolicy : parsed_args.layout_policy.toUpperCase()`<br>],<br>```proxy  : [
remoteUrl: parsed_args.remote_url,`<br>contentMaxAge:1440.0,<br>```metadataMaxAge:``1440.0
],`<br>httpclient: [<br>```blocked: false,
autoBlock: true,`<br>authentication: authentication,<br>```connection: [
useTrustStore: false`<br>]<br>```],
storage: [`<br>blobStoreName: parsed_args.blob_store,<br>```strictContentTypeValidation: Boolean.valueOf(parsed_args.strict_content_validation)
],`<br>negativeCache: [<br>```enabled: true,
timeToLive:``1440.0`<br>]<br>```]
)
// try to find an existing repository to update
def``existingRepository = repository.getRepositoryManager().``get``(parsed_args.name)
if``(existingRepository !=``null``) {
// repositories need to be stopped before any configuration change`<br>existingRepository.stop()<br>```// the blobStore part cannot be updated, so we keep the existing value
configuration.attributes[``'storage'``][``'blobStoreName'``] = existingRepository.configuration.attributes[``'storage'``][``'blobStoreName'``]`<br>existingRepository.update(configuration)<br>```// re-enable the repo
existingRepository.start()`<br>`}``else``{`<br>repository.getRepositoryManager().create(configuration)<br>}`

Setting Up a Role

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import``groovy.json.JsonSlurper
import``org.sonatype.nexus.security.user.UserManager
import``org.sonatype.nexus.security.role.NoSuchRoleException
// unmarshall the parameters as JSON
parsed_args =``new``JsonSlurper().parseText(args)
// some indirect way to retrieve the service we need
authManager = security.getSecuritySystem().getAuthorizationManager(UserManager.DEFAULT_SOURCE)
// Try to locate an existing role to update
def``existingRole =``null
try``{
existingRole = authManager.getRole(parsed_args.id)`<br>`}``catch``(NoSuchRoleException ignored) {`<br>// could not find role<br>}<br>// Collection-type cast in groovy, here from String[] to Set&amp;amp;amp;amp;amp;amp;lt;String&amp;amp;amp;amp;amp;amp;gt;<br>privileges = (parsed_args.privileges ==null?newHashSet() : parsed_args.privileges.toSet())<br>roles = (parsed_args.roles ==null?newHashSet() : parsed_args.roles.toSet())<br>if(existingRole !=null) {`<br>```existingRole.setName(parsed_args.name)`<br>```existingRole.setDescription(parsed_args.description)`<br>```existingRole.setPrivileges(privileges)`<br>```existingRole.setRoles(roles)`<br>```authManager.updateRole(existingRole)`<br>`}else{`<br>```// Another collection-type cast, from Set&amp;amp;amp;amp;amp;amp;amp;lt;String&amp;amp;amp;amp;amp;amp;amp;gt; to List&amp;amp;amp;amp;amp;amp;amp;lt;String&amp;amp;amp;amp;amp;amp;amp;gt;`<br>```security.addRole(parsed_args.id, parsed_args.name, parsed_args.description, privileges.toList(), roles.toList``())<br>}`

The resulting role is available at Ansible Galaxy and on GitHub.

It features the setup of:

Resources for This Article

Written by Samuel Hervé

Samuel Hervé is a Java/JEE Developer, Consultant , and Technical Lead, happily providing thorough technical expertise to the service industry for over nine years. I'm best skilled at web technologies, full-stack distributed JEE apps, Adobe AEM/CQ, and Geographic Information Systems.

I have a thing for agile work environments, multidisciplinary teams, and open-source technologies.