Custom Module #
Ansible menyediakan ratusan module bawaan yang memenuhi sebagian besar kebutuhan otomasi — dari manajemen package, file, service, hingga integrasi cloud. Namun di dunia nyata, ada situasi di mana tidak ada module yang cukup tepat: API internal perusahaan, sistem legacy tanpa client library, atau logika bisnis spesifik yang harus dibungkus agar bisa dipanggil dari banyak playbook. Saat itulah kita memerlukan custom module — kode Python yang kita tulis sendiri, tetapi diperlakukan oleh Ansible persis seperti module bawaan. Artikel ini membahas cara membangun module yang idempotent, mudah diuji, dan siap didistribusikan.
Kapan Membuat Custom Module #
Sebelum menulis kode, evaluasi dulu apakah kita benar-benar membutuhkan module baru. Ansible memiliki tiga lapis eksekusi di remote node: command/shell untuk perintah cepat, script untuk logika yang tidak perlu reusable, dan module Python untuk logika yang harus idempotent dan terdistribusi. Banyak engineer langsung menggunakan command untuk semua hal, lalu berakhir dengan playbook yang tidak bisa diuji, tidak mendukung --check, dan tidak portabel.
Gunakan command/shell jika:
✓ Logika sederhana, satu atau dua perintah
✓ Idempotency tidak penting (sekali jalan, selesai)
✓ Tidak akan dipanggil dari banyak playbook
Gunakan script/executable jika:
✓ Logika kompleks tetapi tidak perlu reusable
✓ Kita lebih nyaman dengan Bash/Go/Rust
✓ Output script tidak perlu diproses Ansible
Buat custom module jika:
✓ Harus idempotent dan mendukung check mode
✓ Akan dipanggil dari banyak role atau playbook
✓ Berinteraksi dengan API atau service yang tidak memiliki module
✓ Output perlu di-parse dan di-loop (register, with_items)
Decision tree di bawah membantu kita memilih lapisan yang tepat:
flowchart TD
A["Butuh eksekusi di remote?"] -->|Tidak| B["Filter/Lookup plugin"]
A -->|Ya| C{"Idempotent & reusable?"}
C -->|Tidak| D{"Logika kompleks?"}
D -->|Tidak| E["command/shell module"]
D -->|Ya| F["script module"]
C -->|Ya| G{"Output perlu parsing?"}
G -->|Tidak| H["command + creates/removes"]
G -->|Ya| I["Custom module Python"]
Anatomi Custom Module #
Module Ansible pada dasarnya adalah script Python yang menerima argumen dari Ansible (melalui stdin dalam format JSON), menjalankan logika, lalu menulis JSON ke stdout. Tidak ada framework khusus — yang kita butuhkan adalah AnsibleModule helper dari ansible.module_utils.basic yang menangani boilerplate parsing argumen, formatting output, dan error handling.
Berikut diagram alur eksekusi module saat dipanggil dari playbook:
sequenceDiagram
participant PB as "Playbook"
participant EX as "ansible-executor"
participant TR as "Transfer (SFTP)"
participant MN as "Managed Node"
participant MOD as "Custom Module"
PB->>EX: "task: my_module name=foo value=bar"
EX->>TR: "Kirim file module ke managed node"
TR->>MN: "SCP/SFTP module.py ke /tmp/"
EX->>MN: "Execute: python module.py"
MN->>MOD: "Jalankan module dengan args (stdin JSON)"
MOD->>MOD: "Validasi argumen via AnsibleModule"
MOD->>MOD: "Jalankan logika bisnis"
alt Sukses
MOD-->>MN: "exit_json({changed: true, ...})"
else Gagal
MOD-->>MN: "fail_json(msg='...')"
end
MN-->>EX: "Output JSON"
EX->>EX: "Parse result, catat changed/failed"
EX->>PB: "result registered ke variable"
Dokumentasi Module (DOCUMENTATION, EXAMPLES, RETURN) #
Tiga string khusus di awal module — DOCUMENTATION, EXAMPLES, RETURN — bukan hanya dokumentasi biasa. String-string ini dibaca oleh ansible-doc, di-render ke halaman dokumentasi, dan dipakai oleh beberapa plugin untuk validasi. Jika kita melewati ketiganya, module kita akan menjadi “kotak hitam” yang tidak bisa di-introspect.
#!/usr/bin/python
# -*- coding: utf-8 -*-
# library/my_module.py
DOCUMENTATION = r'''
---
module: my_module
short_description: Mengelola konfigurasi aplikasi via REST API internal
description:
- Module ini membuat, memperbarui, atau menghapus konfigurasi aplikasi
dengan memanggil REST API internal perusahaan.
- Dirancang untuk idempotency penuh: menjalankan module dua kali
dengan argumen yang sama tidak akan mengubah state di run kedua.
version_added: "1.0.0"
options:
name:
description: Nama konfigurasi yang akan dikelola.
required: true
type: str
value:
description: Nilai konfigurasi. Wajib diisi saat state=present.
required: false
type: str
state:
description: State yang diinginkan untuk konfigurasi.
choices: [present, absent]
default: present
type: str
api_url:
description: URL base API internal, tanpa trailing slash.
required: true
type: str
api_token:
description: Token autentikasi untuk API. Disarankan disimpan di Ansible Vault.
required: true
type: str
no_log: true
author:
- Tim Infrastruktur
'''
EXAMPLES = r'''
- name: Set konfigurasi max_connections
my_module:
name: "max_connections"
value: "100"
api_url: "https://api.internal.com"
api_token: "{{ vault_api_token }}"
state: present
- name: Hapus konfigurasi deprecated
my_module:
name: "deprecated_setting"
api_url: "https://api.internal.com"
api_token: "{{ vault_api_token }}"
state: absent
'''
RETURN = r'''
config:
description: Detail konfigurasi yang berhasil dibuat atau diperbarui.
returned: when state is present and changed
type: dict
sample:
name: max_connections
value: "100"
created_at: "2024-03-15T14:30:00Z"
'''
Info — Tiga string di atas harus ditulis dalam format YAML, dibungkus dengan
r'''...'''(raw string) agar backslash dan karakter khusus tidak di-escape oleh Python.ansible-doc -t module my_moduleakan merender string ini menjadi halaman dokumentasi yang bisa dibaca developer lain.
Implementasi Lengkap dengan AnsibleModule #
Bagian ini menunjukkan module yang mengelola konfigurasi via REST API. Perhatikan beberapa hal penting: AnsibleModule menerima argument_spec untuk deklarasi argumen, supports_check_mode=True agar --check bekerja, dan setiap percabangan eksekusi mengembalikan result dengan flag changed yang akurat.
# library/my_module.py
from ansible.module_utils.basic import AnsibleModule
import json
try:
import requests
HAS_REQUESTS = True
except ImportError:
HAS_REQUESTS = False
def get_config(api_url, api_token, name):
"""Ambil konfigurasi yang ada. Return None jika tidak ditemukan."""
headers = {"Authorization": f"Bearer {api_token}"}
response = requests.get(
f"{api_url}/api/config/{name}",
headers=headers,
timeout=10
)
if response.status_code == 404:
return None
response.raise_for_status()
return response.json()
def create_or_update_config(api_url, api_token, name, value):
"""Buat atau update konfigurasi. Return (data, changed)."""
headers = {
"Authorization": f"Bearer {api_token}",
"Content-Type": "application/json"
}
payload = {"name": name, "value": value}
existing = get_config(api_url, api_token, name)
# Idempotency: jika value sudah sama, tidak ada perubahan
if existing and existing.get("value") == value:
return existing, False
if existing:
response = requests.put(
f"{api_url}/api/config/{name}",
headers=headers, json=payload, timeout=10
)
else:
response = requests.post(
f"{api_url}/api/config",
headers=headers, json=payload, timeout=10
)
response.raise_for_status()
return response.json(), True
def delete_config(api_url, api_token, name):
"""Hapus konfigurasi. Return changed (bool)."""
existing = get_config(api_url, api_token, name)
if not existing:
return False # Sudah tidak ada, tidak ada perubahan
headers = {"Authorization": f"Bearer {api_token}"}
response = requests.delete(
f"{api_url}/api/config/{name}",
headers=headers, timeout=10
)
response.raise_for_status()
return True
def main():
module_args = dict(
name=dict(type='str', required=True),
value=dict(type='str', required=False),
state=dict(type='str', default='present', choices=['present', 'absent']),
api_url=dict(type='str', required=True),
api_token=dict(type='str', required=True, no_log=True),
)
module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True,
required_if=[
('state', 'present', ['value']),
]
)
if not HAS_REQUESTS:
module.fail_json(
msg="Library 'requests' diperlukan. Install dengan: pip install requests"
)
name = module.params['name']
value = module.params.get('value')
state = module.params['state']
api_url = module.params['api_url'].rstrip('/')
api_token = module.params['api_token']
result = dict(changed=False)
try:
if state == 'present':
if module.check_mode:
# Simulasikan: cek apakah akan berubah tanpa benar-benar mengubah
existing = get_config(api_url, api_token, name)
result['changed'] = (
not existing or existing.get('value') != value
)
module.exit_json(**result)
data, changed = create_or_update_config(
api_url, api_token, name, value
)
result['changed'] = changed
result['config'] = data
elif state == 'absent':
if module.check_mode:
existing = get_config(api_url, api_token, name)
result['changed'] = existing is not None
module.exit_json(**result)
result['changed'] = delete_config(api_url, api_token, name)
except requests.exceptions.ConnectionError as e:
module.fail_json(msg=f"Tidak bisa terhubung ke API: {e}")
except requests.exceptions.HTTPError as e:
module.fail_json(
msg=f"API mengembalikan error {e.response.status_code}: {e.response.text}"
)
except Exception as e:
module.fail_json(msg=f"Error tidak terduga: {e}")
module.exit_json(**result)
if __name__ == '__main__':
main()
Peran AnsibleModule Helper
#
AnsibleModule menangani semua detail yang akan membosankan jika kita menulisnya sendiri:
- Parsing argumen dari JSON
stdin(Ansible mengirim argumen sebagai JSON, bukan environment variable). - Validasi tipe data sesuai
argument_spec— jikanamedideklarasikantype='str'dan user mengirim integer, module secara otomatis gagal dengan pesan jelas. - Mode
check_modedandiff— saat user menjalankanansible-playbook --check, module kita tidak boleh benar-benar mengubah state; cukup laporkan “akan berubah” viaresult['changed'] = Truelalumodule.exit_json(**result). - Output formatting —
module.exit_json(**result)menulis JSON kestdoutdengan format yang diharapkan Ansible.module.fail_json(msg="...")menulis JSON denganfailed: truekestdout(bukan stderr), dan exit code bukan nol. - Automatic no_log untuk parameter yang ditandai
no_log=True— kita tidak perlu menyaring sendiri saat logging.
Anti-Pattern dan Solusi yang Benar #
Berikut tiga pola yang paling sering muncul saat engineer pertama kali menulis custom module. Masing-masing punya konsekuensi yang tidak langsung terasa, tapi akan menggigit saat playbook dipakai di production.
1. ANTI-PATTERN: command/shell untuk Semua Hal #
# ANTI-PATTERN: pakai shell untuk "manage" resource
- name: "Create config via API"
shell: |
curl -X POST https://api.internal.com/api/config \
-H "Authorization: Bearer {{ vault_api_token }}" \
-H "Content-Type: application/json" \
-d '{"name":"{{ item.name }}","value":"{{ item.value }}"}'
loop: "{{ configs }}"
changed_when: false # Ansible tidak tahu ini changed atau tidak!
# BENAR: pakai custom module yang proper
- name: "Create config via API"
my_module:
name: "{{ item.name }}"
value: "{{ item.value }}"
api_url: "https://api.internal.com"
api_token: "{{ vault_api_token }}"
state: present
loop: "{{ configs }}"
Perbedaan konsekuensinya: shell di atas tidak tahu apakah state berubah, tidak mendukung check_mode, dan akan menjalankan curl setiap kali playbook dijalankan — tidak ada idempotency. Custom module hanya berubah saat value benar-benar berbeda, dan ansible-playbook --check bisa mendeteksi perubahan yang akan terjadi tanpa benar-benar memanggil API.
2. ANTI-PATTERN: Hard-code Path dan Logika di dalam Task #
# ANTI-PATTERN: hard-code path dan perintah di playbook
- name: "Deploy app config"
hosts: appservers
tasks:
- name: "Write config file"
copy:
dest: "/etc/myapp/config.yaml"
content: |
database:
host: db.internal.com
port: 5432
max_connections: 100
- name: "Restart service if changed"
shell: "systemctl restart myapp"
Logika “cek max_connections apakah perlu diubah” ada di playbook, tersebar di banyak tempat, sulit diuji. Kalau format config berubah, semua playbook harus di-update.
# BENAR: logika dipindah ke module yang reusable
# myapp_config.py
def main():
module_args = dict(
path=dict(type='str', default='/etc/myapp/config.yaml'),
database_host=dict(type='str', required=True),
max_connections=dict(type='int', default=100),
)
# ... baca file, parse YAML, bandingkan dengan desired state,
# tulis hanya jika ada perubahan, return changed=True/False
# Pemakaian jadi sederhana, logika tersembunyi di module
- name: "Deploy app config"
myapp_config:
path: "/etc/myapp/config.yaml"
database_host: "db.internal.com"
max_connections: 100
notify: restart myapp
3. ANTI-PATTERN: Module yang Tidak Mendukung Check Mode #
# ANTI-PATTERN: tidak ada dukungan check_mode
def main():
module = AnsibleModule(argument_spec=module_args) # tanpa supports_check_mode
# Langsung jalankan, tidak peduli --check atau tidak
response = requests.post(url, json=payload)
module.exit_json(changed=True)
Module ini akan benar-benar membuat resource setiap kali dijalankan, termasuk saat user hanya ingin ansible-playbook --check untuk dry-run. Ini mengalahkan seluruh tujuan check mode.
# BENAR: hormati check_mode
def main():
module = AnsibleModule(
argument_spec=module_args,
supports_check_mode=True # WAJIB
)
if module.check_mode:
# Cek apa yang akan terjadi, tapi jangan eksekusi
result['changed'] = will_change()
module.exit_json(**result)
# Eksekusi normal
result['changed'] = do_change()
module.exit_json(**result)
Warning — Module yang tidak menghormati
check_modeakan mengeksekusi perubahan nyata saat user menjalankanansible-playbook --checkatau--diff. Ini adalah bug serius yang bisa menyebabkan outage. Selalu implementasi check mode untuk setiap aksi yang mengubah state.
Tabel Perbandingan Tipe Module #
| Aspek | Action Plugin | New-Style Module | Script Module |
|---|---|---|---|
| Bahasa | Python | Python | Bebas (Bash, Ruby, dll.) |
| Lokasi eksekusi | Controller (Python di Ansible node) | Managed node | Managed node |
| Transfer file | Tidak perlu | Otomatis via SFTP | Otomatis via SFTP |
| Komunikasi args | Native Python | JSON via stdin | JSON via stdin |
| Idempotency | Wajib implement manual | Wajib implement manual | Tidak ada (sekali jalan) |
| Check mode | Manual | Manual (tapi lebih mudah) | Tidak berlaku |
| Kompleksitas | Tinggi | Sedang | Rendah |
| Use case | Manipulasi inventory/variable, side-effect di controller | Module untuk konfigurasi remote | Script ad-hoc, satu kali pakai |
New-style module (Python, dijalankan di remote) adalah tipe yang paling umum dan paling direkomendasikan untuk otomasi sehari-hari. Action plugin digunakan saat kita perlu menjalankan kode di controller — misalnya, membuat file inventory dinamis atau memproses variabel sebelum diteruskan ke module lain.
Lokasi Module: Project, Role, atau Collection #
Module custom bisa diletakkan di tiga lokasi, masing-masing dengan trade-off yang berbeda:
project_root/ # Berlaku untuk project ini saja
├── library/
│ ├── my_module.py
│ └── another_module.py
├── module_utils/ # Helper yang dipakai banyak module
│ └── api_client.py
└── playbooks/
└── site.yml
roles/
└── my_role/
├── library/ # Module khusus role ini
│ └── my_role_module.py
├── tasks/
│ └── main.yml
└── module_utils/ # Helper khusus role
└── role_helper.py
my_namespace/
└── my_collection/ # Distribusi lintas project
└── plugins/
├── modules/
│ └── my_module.py
├── module_utils/
│ └── api_client.py
└── ...
Tip — Untuk module yang dipakai di lebih dari dua project, kemas dalam Collection sejak awal. Migrasi dari
library/di root project ke Collection di kemudian hari jauh lebih merepotkan karena semua playbook yang reference module harus di-update ke FQCN.
Struktur Direktori Project yang Sehat #
infrastructure-ansible/
├── ansible.cfg
├── requirements.yml # Dependency collection
├── inventory/
│ ├── production/
│ └── staging/
├── playbooks/
│ ├── site.yml
│ └── deploy_app.yml
├── roles/
│ ├── common/
│ ├── webserver/
│ └── database/
├── library/ # Custom module project-specific
│ ├── company_user.py
│ └── internal_api.py
├── module_utils/ # Helper untuk module di atas
│ └── company_client.py
└── tests/
└── test_modules.py
Menguji Custom Module #
Module yang tidak diuji adalah module yang akan gagal di production. Pengujian minimal yang harus dilakukan:
Unit Test dengan pytest #
# tests/test_my_module.py
import json
import sys
from unittest.mock import patch, MagicMock
# Mock AnsibleModule sebelum import module
sys.modules['ansible'] = MagicMock()
sys.modules['ansible.module_utils'] = MagicMock()
sys.modules['ansible.module_utils.basic'] = MagicMock()
import library.my_module as my_module
def test_create_config_success(monkeypatch):
"""Test create config baru."""
fake_response = MagicMock()
fake_response.status_code = 201
fake_response.json.return_value = {"name": "foo", "value": "bar"}
with patch.object(my_module, 'requests') as mock_requests:
mock_requests.get.return_value = MagicMock(status_code=404)
mock_requests.post.return_value = fake_response
result = my_module.create_or_update_config(
"https://api.test", "token", "foo", "bar"
)
data, changed = result
assert changed is True
assert data["name"] == "foo"
def test_create_config_idempotent():
"""Test kedua: tidak ada perubahan jika value sudah sama."""
with patch.object(my_module, 'get_config') as mock_get:
mock_get.return_value = {"name": "foo", "value": "bar"}
data, changed = my_module.create_or_update_config(
"https://api.test", "token", "foo", "bar"
)
assert changed is False
assert data["value"] == "bar"
Integration Test dengan Ansible langsung #
# Jalankan module manual dengan argumen dari file
echo '{
"ANSIBLE_MODULE_ARGS": {
"name": "test_setting",
"value": "42",
"state": "present",
"api_url": "https://api.test",
"api_token": "fake-token"
}
}' | python library/my_module.py
# Output yang diharapkan (sukses):
# {"changed": true, "config": {...}}
# Output saat check mode:
ANSIBLE_CHECK_MODE=true echo '...' | python library/my_module.py
# {"changed": true} # tanpa benar-benar mengubah state
Integrasi dengan Role #
Setelah module didistribusikan, integrasikan ke dalam role agar tim bisa memanggilnya dengan sintaks yang familiar:
# roles/webserver/tasks/main.yml
- name: Deploy app configuration
my_module:
name: "max_connections"
value: "{{ webserver_max_connections | default(100) }}"
api_url: "{{ app_api_url }}"
api_token: "{{ vault_api_token }}"
state: present
no_log: true
- name: Ensure deprecated config absent
my_module:
name: "old_setting"
api_url: "{{ app_api_url }}"
api_token: "{{ vault_api_token }}"
state: absent
no_log: true
Danger — Selalu tambahkan
no_log: truesaat module kita menerima parameter sensitif sepertiapi_token,password, atauprivate_key. Tanpano_log, nilai token akan muncul di log playbook dan bisa terekspos ke sistem log terpusat. Ini adalah security risk serius yang sering diabaikan.
Distribusi via Collection #
Untuk module yang dipakai di banyak project, distribusikan sebagai Ansible Collection. Ini memberikan kita versioning, dependency management, dan instalasi terstandar:
# my_company/infrastructure/galaxy.yml
namespace: my_company
name: infrastructure
version: 1.2.0
readme: README.md
description: >
Kumpulan module dan plugin internal untuk infrastruktur My Company.
Termasuk module untuk konfigurasi aplikasi, integrasi CMDB, dan
wrapper untuk API internal.
authors:
- Tim SRE <[email protected]>
license:
- GPL-2.0-or-later
tags:
- infrastructure
- internal
dependencies:
community.general: ">=7.0.0"
Module di-collection diakses dengan FQCN (Fully Qualified Collection Name):
- name: "Set config via collection module"
my_company.infrastructure.my_module:
name: "max_connections"
value: "100"
api_url: "{{ app_api_url }}"
api_token: "{{ vault_api_token }}"
state: present
no_log: true
Detail lengkap tentang collection, versioning, dan publikasi ke Private Automation Hub dibahas di artikel Collection yang melanjutkan topologi module dan plugin.
Ringkasan #
- Custom module adalah script Python yang menerima argumen via JSON
stdindan mengembalikan JSON kestdout— Ansible memperlakukannya persis seperti module bawaan.- Gunakan
AnsibleModuledariansible.module_utils.basic— helper ini meng-handle parsing argumen, validasi tipe, output formatting, dancheck_modeboilerplate.- Selalu implementasikan
supports_check_mode=Truedan hormatimodule.check_mode— module yang mengubah state tanpa check mode membuat dry-run tidak berguna.no_log=Truepada parameter sensitif seperti token, password, dan API key — mencegah kebocoran credential ke log.- Tulis
DOCUMENTATION,EXAMPLES, danRETURNdi module — string ini di-render olehansible-docdan dipakai untuk introspeksi module.- Idempotency adalah nyawa module — jalankan dua kali dengan argumen yang sama, run kedua harus return
changed=False.- Pilih lokasi berdasarkan cakupan:
library/project untuk satu project, role-scoped untuk satu role, Collection untuk distribusi lintas project.- Tulis unit test dengan
pytestdan integration test yang memanggil module langsung lewatstdin— module tanpa test akan gagal di production.- Pilih custom module,
script, ataucommand/shellberdasarkan tiga hal: idempotency requirement, reusability, dan kebutuhan parsing output.