11 min read
In the last article, I explained why I moved all my domains to Porkbun. One of the reasons was the API and its compatibility with Ansible. I frequently use Ansible to set up and manage my home server as well as a few remote ones. This often involves creating and managing DNS records. However, the method I demonstrated in the New DNS Registrar article is quite verbose, requiring at least three tasks with numerous parameters. I believe we can improve upon it.
Ansible modules
Let's write an Ansible module to summarize the logic of the three tasks.
In order to create a module we need a single file and a some python knowledge.
First we create a library
folder and inside it we create a file with the name of our module.
In the case of our porkbun module, we choose porkbun_record.py
.
We start our module with a class which handles the porkbun api.
PorkbunAPI class
We need methods to get, create, update, and delete records.
For each of those methods, we need the api_key
and the secret_api_key
.
Therefore, we pass the keys in the constructor (the __init__
method).
To use the keys, we have to store them for later use.
In addition to the keys, we define the API_URL
and specify the default headers for our method calls.
Now, our constructor should look like the following:
class PorkbunAPI: API_URL = "https://porkbun.com/api/json/v3/dns" def __init__(self, api_key, secret_api_key): self.headers = { 'Content-Type': 'application/json', } self.base_params = { 'apikey': api_key, 'secretapikey': secret_api_key }
We need methods to get, create, update, and delete records. For each of those methods, we need the api_key
and the secret_api_key
.
Therefore, we pass the keys in the constructor (the __init__
method).
To use the keys, we have to store them for later use. In addition to the keys, we define the API_URL
and specify the default headers for our method calls.
Now, our constructor should look like the following:
import jsonfrom ansible.module_utils.urls import open_url
After that, we can begin the implementation of our methods.
def get_records(self, domain): response = open_url(f'{self.API_URL}/retrieve/{domain}', method="POST", headers=self.headers, data=json.dumps(self.base_params)) result = json.loads(response.read()) return result['records']
As we can see, we use the open_url
method to make a POST
request to the Porkbun API.
We use f
to format the URL with our API_URL
and the domain we want to retrieve the records for.
We also pass the headers
and the data
to the open_url
method, which we defined in the constructor.
The open_url
method returns a file-like object that we can read and parse with the json
module.
The DNS records are stored in the records
key of the JSON response.
def get_record(self, domain, record_type, name): records = self.get_records(domain) for record in records: if record['type'] == record_type and record['name'] == name + "." + domain: return record return None
For the get_record
method, we use the get_records
method we just implemented.
We iterate over the records and return the first record that matches the record_type
and name
parameters.
def create_record(self, domain, record_type, name, content, ttl): data = { **self.base_params, 'type': record_type, 'name': name, 'content': content, 'ttl': ttl } response = open_url(f'{self.API_URL}/create/{domain}', method='POST', headers=self.headers, data=json.dumps(data)) return json.loads(response.read())
For the create_record
method, we create a dictionary with the base_params
and the parameters we want to pass to the API.
We then make another POST
request to the API, as we did in the get_records
method.
The update_record
method is very similar to the create_record
method and does not require further explanation.
def update_record(self, domain, record_type, name, content, ttl): data = { **self.base_params, 'content': content, 'ttl': ttl } response = open_url(f'{self.API_URL}/editByNameType/{domain}/{record_type}/{name}', method='POST', headers=self.headers, data=json.dumps(data)) return json.loads(response.read())
For the delete_record
method, we require the record_id
, which is returned by the get_records
method.
def delete_record(self, domain, record_id): response = open_url(f'{self.API_URL}/delete/{domain}/{record_id}', method='POST', headers=self.headers, data=json.dumps(self.base_params)) return json.loads(response.read())
Now that we have implemented the PorkbunAPI
class, we can move on to the module itself.
The main method
An Ansible module requires a main
method, which is the entry point of the module.
It also requires some metadata, which we define in the ANSIBLE_METADATA
variable.
ANSIBLE_METADATA = { 'metadata_version': '1.1', 'status': ['preview'], 'supported_by': 'community'}
In the main
method, we define the parameters that we want to pass to the module.
In our case this are the parameters which are required for our PorkbunAPI
class.
We define the parameters with the argument_spec
variable, which is a parameter of the AnsibleModule
class.
But before we can use the AnsibleModule
class, we need to import it at the top of our file.
from ansible.module_utils.basic import AnsibleModule
Now we can start to implement the main
method.
def main(): module = AnsibleModule( argument_spec=dict( state=dict(default='present', choices=['present', 'absent']), domain=dict(required=True, type='str'), record_type=dict(required=True, type='str', choices=[ 'A', 'MX', 'CNAME', 'ALIAS', 'TXT', 'NS', 'AAAA', 'SRV', 'TLSA', 'CAA']), name=dict(required=True, type='str'), content=dict(required=True, type='str'), ttl=dict(required=False, type='int', default=600), api_key=dict(required=True, type='str', no_log=True), secret_api_key=dict(required=True, type='str', no_log=True), ), )
The state
parameter is used to determine whether we want to create or delete a record.
After we can use the PorkbunAPI
class to create an instance of the API.
api_key = module.params['api_key']secret_api_key = module.params['secret_api_key']porkbun = PorkbunAPI(api_key, secret_api_key)
Now we can fetch the state of the record, we want to manage.
domain = module.params['domain']record_type = module.params['record_type']name = module.params['name']record = porkbun.get_record(domain, record_type, name)
Now it is time to implement the logic for the present
state.
state = module.params['state']if state == 'present': if record is None: porkbun.create_record(domain, record_type, name, content, ttl) module.exit_json(changed=True, msg="DNS record created")
If the record does not exist, we create it.
The module.exit_json
method is used to return the result of the module.
The changed
key indicates whether the module changed the state of the system.
The msg
key contains a message that is displayed to the user.
If the record exists, we check if the content is the same as the one we want to set.
If it is not, we update the record.
elif record['content'] != content or int(record['ttl']) != ttl: porkbun.update_record(domain, record_type, name, content, ttl) module.exit_json(changed=True, msg="DNS record updated")
If the content is the same, we do not need to do anything and we can exit the module.
else: module.exit_json(changed=False, msg="DNS record already exists")
Now we can implement the logic for the absent
state.
elif state == 'absent': if record is not None: porkbun.delete_record(domain, record['id']) module.exit_json(changed=True, msg="DNS record deleted")
If the record exists, we delete it. If the record does not exist, we do not need to do anything and we can exit the module.
else: module.exit_json(changed=False, msg="DNS record does not exist")
Finally, we need to call the main
method.
if __name__ == '__main__': main()
Now we can test our module.
Testing the module
To test our module, we create a playbook with the following content.
Ok, we are ready to test our module.
ansible-playbook playbook.yml
If everything works as expected, we should see that the module created the DNS record. That is it, we have created an Ansible module to manage DNS records with the Porkbun API.
After we have tested our module, we can publish it to Ansible Galaxy. But before we can publish it, we should add some documentation to our module.
Documentation
We start with the DOCUMENTATION
variable.
The DOCUMENTATION
variable is a YAML string which contains the documentation for our module.
DOCUMENTATION = r'''---module: porkbun_recordshort_description: Manage DNS records on Porkbundescription: - This module allows you to create, update, and delete DNS records on Porkbun using the Porkbun API.options: state: description: - Whether the record should exist or not. choices: [ 'present', 'absent' ] default: 'present' domain: description: - The domain to add the DNS record to. required: true type: str record_type: description: - The type of DNS record to manage. choices: [ 'A', 'MX', 'CNAME', 'ALIAS', 'TXT', 'NS', 'AAAA', 'SRV', 'TLSA', 'CAA' ] required: true type: str name: description: - The name of the DNS record. required: true type: str content: description: - The content of the DNS record. required: true type: str ttl: description: - The time-to-live of the DNS record. default: 600 type: int api_key: description: - The API key for the Porkbun API. required: true type: str no_log: true secret_api_key: description: - The secret API key for the Porkbun API. required: true type: str no_log: true'''
Next we define the EXAMPLES
variable.
EXAMPLES = r'''# Create an A record- porkbun_dns: state: present domain: example.com record_type: A name: www content: 192.0.2.1 ttl: 3600 api_key: your_api_key secret_api_key: your_secret_api_key# Delete a TXT record- porkbun_dns: state: absent domain: example.com record_type: TXT name: www content: "v=spf1 -all" api_key: your_api_key secret_api_key: your_secret_api_key# Update an existing MX record- porkbun_dns: state: present domain: example.com record_type: MX name: mail content: "10 mail.example.com." ttl: 7200 api_key: your_api_key secret_api_key: your_secret_api_key'''
We also define the RETURN
variable.
RETURNS = r'''changed: description: Whether or not the DNS record was changed returned: always type: boolmsg: description: A message describing what happened returned: always type: str'''
We also need a README.md
file, which should contain a short documentation of our module.
Publishing
Now we can publish our module to Ansible Galaxy.
To publish our module, we have to create a collection.
Collections use a different folder structure than our module does.
Therefore, we have to move our module to a folder called plugins/modules
folder.
We also have to create a galaxy.yml
file in the root of our collection.
The galaxy.yml
file contains metadata about our collection.
Now we can build our collection.
ansible-galaxy collection build
The build command creates a tarball with the namespace, name and version of the collection (sdorra-porkbun-1.0.0.tar.gz
).
This tarball can be uploaded to Ansible Galaxy.
To upload the tarball, we have to create an account on Ansible Galaxy and create an API key.
After we have our API key, we can upload the tarball.
ansible-galaxy collection publish sdorra-porkbun-1.0.0.tar.gz --api-key <your-api-key>
Usage with Ansible Galaxy
After we have published our collection, we can install it with the ansible-galaxy
command.
ansible-galaxy collection install sdorra.porkbun
And use it in our playbooks:
Conclusion
In this article, we have seen how to create an Ansible module for managing DNS records on Porkbun. We have also learned how to publish the module to Ansible Galaxy. For the complete source code of the module, please visit the GitHub repository. The final version of the module is also available on Ansible Galaxy.
Posted in: ansible, porkbun, module