Custom Module

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_module akan 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 — jika name dideklarasikan type='str' dan user mengirim integer, module secara otomatis gagal dengan pesan jelas.
  • Mode check_mode dan diff — saat user menjalankan ansible-playbook --check, module kita tidak boleh benar-benar mengubah state; cukup laporkan “akan berubah” via result['changed'] = True lalu module.exit_json(**result).
  • Output formatting — module.exit_json(**result) menulis JSON ke stdout dengan format yang diharapkan Ansible. module.fail_json(msg="...") menulis JSON dengan failed: true ke stdout (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_mode akan mengeksekusi perubahan nyata saat user menjalankan ansible-playbook --check atau --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: true saat module kita menerima parameter sensitif seperti api_token, password, atau private_key. Tanpa no_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 stdin dan mengembalikan JSON ke stdout — Ansible memperlakukannya persis seperti module bawaan.
  • Gunakan AnsibleModule dari ansible.module_utils.basic — helper ini meng-handle parsing argumen, validasi tipe, output formatting, dan check_mode boilerplate.
  • Selalu implementasikan supports_check_mode=True dan hormati module.check_mode — module yang mengubah state tanpa check mode membuat dry-run tidak berguna.
  • no_log=True pada parameter sensitif seperti token, password, dan API key — mencegah kebocoran credential ke log.
  • Tulis DOCUMENTATION, EXAMPLES, dan RETURN di module — string ini di-render oleh ansible-doc dan 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 pytest dan integration test yang memanggil module langsung lewat stdin — module tanpa test akan gagal di production.
  • Pilih custom module, script, atau command/shell berdasarkan tiga hal: idempotency requirement, reusability, dan kebutuhan parsing output.

← Sebelumnya: Best Practice   Berikutnya: Custom Plugin →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact