Hello, I'm Ganesh. I'm working on FreeDevTools online, currently building a single platform for all development tools, cheat codes, and TL; DRs — a free, open-source hub where developers can quickly find and use tools without the hassle of searching the internet.
When you are using Ansible to orchestrate servers, you often need to step outside standard modules and run a custom Python or Bash script.
The problem is the "Black Box" effect. You run a script, Ansible says changed: [localhost], and you move on. But did the script actually work? Did it update your configuration file? If so, what specific lines changed?
Standard Ansible output is often too quiet. In this post, I will walk through how to go from a execution to a comprehensive output that shows you exactly what your custom script is doing, including file diffs and clean text formatting.
The Problem
Let's say you have a Python script (custom_generator.py) that generates a configuration file. A basic Ansible task looks like this:
- name: Run custom configuration script
ansible.builtin.command:
cmd: "python3 /opt/scripts/custom_generator.py --create"
When you run this, Ansible reports "Changed." That is it. You don't know if the configuration file was created, if it overwrote existing settings, or if the script silently handled an exception.
To fix this, we need a robust pattern:
Check -> Backup -> Execute -> Compare -> Display.
Step 1: The Setup and Backup
Before we run the script, we need to know the current state of the server. We check if the target configuration file exists and, if it does, we create a temporary backup. This allows us to compare "Before" vs. "After" later on.
- name: Check if config file exists
ansible.builtin.stat:
path: /opt/app/config.conf
register: pre_check
- name: Backup existing config
ansible.builtin.copy:
src: /opt/app/config.conf
dest: /tmp/config.conf.bak
remote_src: true
when: pre_check.stat.exists
Step 2: Executing the Script
Now we run the script. A critical detail often missed here is the working directory. If your script writes a file using a relative path, Ansible might dump it in the user's home directory (like /root/) instead of where the script lives.
Always use chdir to ensure the script runs in the correct context:
- name: Generate configuration
become: true
ansible.builtin.command:
cmd: "python3 {{ custom_script_path }} --create"
chdir: "{{ custom_script_path | dirname }}"
Step 3: Difference Check
This is the most important part. We want to see what changed.
If the file is new, we want to see the whole file (marked as additions).
If the file existed, we only want to see the lines that changed.
We can achieve this using the Linux diff command and some Jinja2 logic. If the backup file doesn't exist, we compare the new file against /dev/null.
- name: Diff Changes
ansible.builtin.shell: >
diff -u {{ '/tmp/config.conf.bak' if pre_check.stat.exists else '/dev/null' }}
/opt/app/config.conf || true
register: config_diff
changed_when: config_diff.stdout != ""
Note: We add || true because the diff command returns an exit code of 1 when it finds differences, which would normally cause Ansible to crash. We want to capture that output, not fail.
Step 4: Handling "No Changes"
Sometimes, your script runs but the configuration remains exactly the same. In that case, diff returns nothing. However, purely for verification purposes, you might still want to see the content of the file to ensure it looks correct.
We add a fallback task that reads the full file content only if the diff was empty.
- name: Read full content (fallback for no changes)
ansible.builtin.command: "cat /opt/app/config.conf"
register: full_content
changed_when: false
when: config_diff.stdout == ""
Step 5: Formated Display
Finally, we display the data. Standard Ansible debug output is messy—it returns a list of strings wrapped in JSON brackets and quotes, which is hard to read.
To make it human-readable, we use the | join('\n') Jinja2 filter. We also use a conditional logic to decide whether to show the Diff (if changes occurred) or the Full Content (if no changes occurred).
Here is the final consolidated block:
- name: Display Location and Content
ansible.builtin.debug:
msg: |
Config File Location: /opt/app/config.conf
----------------------------------------------------
{{ (config_diff.stdout_lines if config_diff.stdout != '' else full_content.stdout_lines) | join('\n') }}
The Result
By implementing this block, your Ansible output transforms from a vague "Changed" status into a clear, actionable report.
If the file is new or changed:
Config File Location: /opt/app/config.conf
----------------------------------------------------
--- /tmp/config.conf.bak
+++ /opt/app/config.conf
@@ -1,5 +1,5 @@
# App Config
- port=80
+ port=8080
debug=false
If the file is unchanged:
Config File Location: /opt/app/config.conf
----------------------------------------------------
# App Config
port=8080
debug=false
This approach gives you complete visibility into your custom scripts, ensuring you never have to guess what is happening on your remote servers.
I’ve been building for FreeDevTools.
A collection of UI/UX-focused tools crafted to simplify workflows, save time, and reduce friction when searching for tools and materials.
Any feedback or contributions are welcome!
It’s online, open-source, and ready for anyone to use.
👉 Check it out: FreeDevTools
⭐ Star it on GitHub: freedevtools

Top comments (0)