Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions logs.log
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
2026-01-25 22:12:41,674 p=57299 u=rohit n=ansible INFO| ansible-playbook [core 2.19.4]
config file = None
configured module search path = ['/Users/rohit/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /Users/rohit/venvs/ansible312/lib/python3.12/site-packages/ansible
ansible collection location = /Users/rohit/.ansible/collections:/usr/share/ansible/collections
executable location = /Users/rohit/venvs/ansible312/bin/ansible-playbook
python version = 3.12.12 (main, Oct 9 2025, 11:07:00) [Clang 17.0.0 (clang-1700.0.13.3)] (/Users/rohit/venvs/ansible312/bin/python3.12)
jinja version = 3.1.6
pyyaml version = 6.0.3 (with libyaml v0.2.5)
2026-01-25 22:12:41,674 p=57299 u=rohit n=ansible INFO| No config file found; using defaults
2026-01-25 22:12:41,674 p=57299 u=rohit n=ansible ERROR| [ERROR]: the playbook: demo_backup_restore.yml could not be found

31 changes: 29 additions & 2 deletions roles/backup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ This role supports full and differential backups, storing them locally or in a r
- Backs up the configuration **only if** there are changes compared to the last saved version.
- Works with both local and Git-based data stores.
- Helps reduce storage and SCM noise by saving only when diff exists.
- **Ignores timestamps and metadata** - only detects actual configuration changes.

---

Expand All @@ -34,8 +35,8 @@ This role supports full and differential backups, storing them locally or in a r
| `data_store.scm.origin.path` | Directory path inside the repo to save backup | `str` | No | N/A |
| `data_store.scm.origin.ssh_key_file` | Path to the SSH private key file for Git authentication | `str` | Yes (if using SCM SSH) | N/A |
| `data_store.scm.origin.ssh_key_content` | The content of the SSH private key | `str` | Yes (if using SCM SSH) | N/A |
| `type` | Type of backup to perform. Options: `"full"`, `"incremental"`, or `"diff"` | `str` | No | `"full"` |

> Note: Either `data_store.local` or `data_store.scm` must be provided.

---

Expand Down Expand Up @@ -136,6 +137,32 @@ This role supports full and differential backups, storing them locally or in a r
path: "backups/{{ ansible_date_time.date }}/{{ inventory_hostname }}"
```

### Create Differential Backup (Only Publish if Config Changed)

```yaml
- name: Create Network Backup and Push to GitHub
hosts: network
gather_facts: false
tasks:
- name: Create Network Backup
ansible.builtin.include_role:
name: network.backup.backup
vars:
type: "diff" # Enable differential backup
data_store:
scm:
origin:
user:
name: "your_name"
email: "your_email@example.com"
url: "git@github.com:youruser/your-backup-repo.git"
ssh_key_file: "/path/to/ssh/key"
filename: "{{ ansible_date_time.date }}_{{ inventory_hostname }}.txt"
path: "backups/{{ ansible_date_time.date }}/{{ inventory_hostname }}"
```

> **Note**: With `type: "diff"`, the backup will only be published to SCM if actual configuration changes are detected. Timestamps and metadata differences are ignored. See [Differential Backup Documentation](Differential_Backup_Documentation.md) for more details.

## License

GNU General Public License v3.0 or later.
Expand All @@ -144,4 +171,4 @@ See [LICENSE](https://www.gnu.org/licenses/gpl-3.0.txt) to see the full text.

## Author Information

- Ansible Network Content Team
- Ansible Network Content Team
7 changes: 7 additions & 0 deletions roles/backup/meta/argument_specs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ argument_specs:
type: dict
required: false
options:
parent_directory:
type: str
required: false
description:
- Parent directory where the Git repository will be cloned (e.g., /tmp or role_path).
- If not specified, defaults to role_path.
- Best practice: Use temp directory (e.g., /tmp) for isolated operations.
origin:
type: dict
required: true
Expand Down
39 changes: 36 additions & 3 deletions roles/backup/tasks/backup.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,69 @@
- name: Build Local Backup Dir Path
ansible.builtin.include_tasks: path.yaml
when: data_store.scm.origin is not defined
tags: always

- name: Include retrieve tasks
ansible.builtin.include_tasks: retrieve.yaml
when: data_store['scm']['origin'] is defined
run_once: true
tags: always

- name: Get scm url
- name: Get scm url - Use actual repo path from retrieve.yaml
ansible.builtin.set_fact:
network_backup_path_root: "{{ role_path }}/{{ data_store.scm.origin.url.split('/')[-1] | regex_replace('\\.git$', '') }}"
network_backup_path_root: "{{ network_backup_path | default(role_path ~ '/' ~ (data_store.scm.origin.url.split('/')[-1] | regex_replace('\\.git$', ''))) }}"
when: data_store['scm']['origin'] is defined
tags: always

- name: Get file name
ansible.builtin.set_fact:
network_backup_path: "{{ network_backup_path_root }}/{{ data_store.scm.origin.path | default(role_path, true) }}"
when: data_store['scm']['origin'] is defined
tags: always

- name: Get timestamp
ansible.builtin.set_fact:
timestamp: "{{ lookup('pipe', 'date +%Y-%m-%d_%H-%M-%S') }}"
tags: always

- name: Set default filename
ansible.builtin.set_fact:
network_backup_filename: >-
{{ data_store.scm.origin.filename | default(inventory_hostname ~ '_' ~ timestamp ~ '.txt', true) }}
when: network_backup_filename is undefined
tags: always

- name: Read previous backup for differential comparison (before current backup is created)
ansible.builtin.include_tasks: differential_scm_read_previous.yaml
when:
- data_store['scm']['origin'] is defined
- type | default('full') == "diff"
tags: always

- name: Include tasks
ansible.builtin.include_tasks: network.yaml
tags: always

- name: Check for differential backup (SCM only)
ansible.builtin.include_tasks: differential_scm.yaml
when: data_store['scm']['origin'] is defined
tags: always

- name: Include build tasks
ansible.builtin.include_tasks: publish.yaml
when: data_store.scm.origin is defined
when:
- data_store.scm.origin is defined
- backup_has_changes | default(true)
run_once: true
tags: always

- name: Cleanup cloned repository when publish is skipped
ansible.builtin.file:
path: "{{ network_backup_path_root }}"
state: absent
when:
- data_store.scm.origin is defined
- not (backup_has_changes | default(true))
run_once: true
delegate_to: localhost
tags: always
1 change: 1 addition & 0 deletions roles/backup/tasks/cli_backup.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
dir_path: "{{ network_backup_path }}"
filename: "{{ network_backup_filename | default(inventory_hostname) }}"
register: network_backup
tags: always
99 changes: 99 additions & 0 deletions roles/backup/tasks/differential_scm.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
---
# Differential backup logic for SCM (Git) data stores
# Compares current backup with previous backup, normalizing timestamps/metadata
# Only publishes if there are actual configuration changes

- name: Set default backup type to full if not specified
ansible.builtin.set_fact:
backup_type: "{{ type | default('full') }}"
tags: always

# Previous backup was already read in differential_scm_read_previous.yaml
# before the current backup was created (to avoid overwriting the file)
# Check if previous backup exists (for conditional logic)
- name: Check if previous backup file exists
ansible.builtin.stat:
path: "{{ network_backup_path }}/{{ network_backup_filename }}"
register: previous_backup_stat
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
tags: always

- name: Debug normalized previous backup result (from pre-read)
ansible.builtin.debug:
msg: "Normalized previous backup result (read before current backup): {{ normalized_previous_backup | default('NOT SET - previous backup may not exist') }}"
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
tags: always

- name: Normalize current backup file (remove timestamps and metadata)
ansible.builtin.shell: |
sed -E \
-e '/^!Command:/d' \
-e '/^!Running configuration last done at:/d' \
-e '/^! Last configuration change at /d' \
-e '/^!Time:/d' \
-e '/^!NVRAM config last updated at:/d' \
-e '/^!No configuration change since last restart/d' \
"{{ network_backup_path }}/{{ network_backup_filename }}" | grep -v '^[[:space:]]*$'
register: normalized_current_result
changed_when: false
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
tags: always

- name: Debug normalized current backup result
ansible.builtin.debug:
msg: "Normalized current backup result: {{ normalized_current_result.stdout }}"
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
tags: always

- name: Set normalized content facts (use pre-read previous backup)
ansible.builtin.set_fact:
normalized_previous: "{{ normalized_previous_backup | default('') }}"
normalized_current: "{{ normalized_current_result.stdout | default('') }}"
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
tags: always

- name: Compare normalized backups (using pre-read previous backup)
ansible.builtin.set_fact:
backup_has_changes: "{{ normalized_previous != normalized_current }}"
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
- normalized_previous_backup is defined
- normalized_previous_backup != ""
tags: always

- name: Set backup_has_changes to true if no previous backup exists
ansible.builtin.set_fact:
backup_has_changes: true
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
- normalized_previous_backup is not defined or normalized_previous_backup == ""
tags: always

- name: Set backup_has_changes to true for full backup type
ansible.builtin.set_fact:
backup_has_changes: true
when:
- data_store['scm']['origin'] is defined
- backup_type != "diff"
tags: always

- name: Display differential backup result
ansible.builtin.debug:
msg: "Differential backup: {{ 'Changes detected - will publish' if backup_has_changes else 'No changes detected - skipping publish' }}"
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
tags: always

55 changes: 55 additions & 0 deletions roles/backup/tasks/differential_scm_read_previous.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
# Read previous backup BEFORE current backup is created
# This prevents the current backup from overwriting the previous backup file
# before we can read it for comparison

- name: Set default backup type to full if not specified
ansible.builtin.set_fact:
backup_type: "{{ type | default('full') }}"
tags: always

- name: Check if previous backup file exists
ansible.builtin.stat:
path: "{{ network_backup_path }}/{{ network_backup_filename }}"
register: previous_backup_stat
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
tags: always

- name: Normalize previous backup file (remove timestamps and metadata)
ansible.builtin.shell: |
sed -E \
-e '/^!Command:/d' \
-e '/^!Running configuration last done at:/d' \
-e '/^! Last configuration change at /d' \
-e '/^!Time:/d' \
-e '/^!NVRAM config last updated at:/d' \
-e '/^!No configuration change since last restart/d' \
"{{ network_backup_path }}/{{ network_backup_filename }}" | grep -v '^[[:space:]]*$'
register: normalized_previous_result
changed_when: false
failed_when: false
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
- previous_backup_stat.stat.exists | default(false)
tags: always

- name: Store normalized previous backup for later comparison
ansible.builtin.set_fact:
normalized_previous_backup: "{{ normalized_previous_result.stdout | default('') }}"
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
- previous_backup_stat.stat.exists | default(false)
tags: always

- name: Set normalized_previous to empty if no previous backup exists
ansible.builtin.set_fact:
normalized_previous_backup: ""
when:
- data_store['scm']['origin'] is defined
- backup_type == "diff"
- not (previous_backup_stat.stat.exists | default(false))
tags: always
2 changes: 2 additions & 0 deletions roles/backup/tasks/main.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
---
- name: Include tasks
ansible.builtin.include_tasks: validation.yaml
tags: always

- name: Run the platform specific tasks
ansible.builtin.include_tasks: "backup.yaml"
tags: always
1 change: 1 addition & 0 deletions roles/backup/tasks/network.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
---
- name: Invoke backup task
ansible.builtin.include_tasks: cli_backup.yaml
tags: always
10 changes: 8 additions & 2 deletions roles/backup/tasks/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,42 +3,48 @@
ansible.builtin.set_fact:
time: "{{ lookup('pipe', 'date \"+%Y-%m-%d-%H-%M\"') }}"
run_once: true
tags: always

- name: Create default tag
ansible.builtin.set_fact:
default_tag:
annotation: "{{ time }}"
message: "backup_on: {{ time }}"
tags: always

- name: Set default tag
ansible.builtin.set_fact:
default_tag: "{}"
when:
- tag is defined
- tag == "default"
tags: always

- name: Publish the changes with tag
ansible.scm.git_publish:
path: "{{ network_backup_path }}"
path: "{{ network_backup_path_root }}"
token: "{{ data_store.scm.origin.get('token') if data_store.scm.origin.get('token') else omit }}"
user: "{{ data_store['scm']['origin']['user'] | d({}) }}"
tag: "{{ tag }}"
timeout: 120
ssh_key_file: "{{ data_store.scm.origin.get('ssh_key_file') if data_store.scm.origin.get('ssh_key_file') else omit }}"
ssh_key_content: "{{ data_store.scm.origin.get('ssh_key_content') if data_store.scm.origin.get('ssh_key_content') else omit }}"
when: tag is defined
tags: always

- name: Publish the changes
ansible.scm.git_publish:
path: "{{ network_backup_path }}"
path: "{{ network_backup_path_root }}"
token: "{{ data_store.scm.origin.get('token') if data_store.scm.origin.get('token') else omit }}"
user: "{{ data_store['scm']['origin']['user'] | d({}) }}"
timeout: 120
ssh_key_file: "{{ data_store.scm.origin.get('ssh_key_file') if data_store.scm.origin.get('ssh_key_file') else omit }}"
ssh_key_content: "{{ data_store.scm.origin.get('ssh_key_content') if data_store.scm.origin.get('ssh_key_content') else omit }}"
when: tag is not defined
tags: always

- name: Remove cloned repository directory
ansible.builtin.file:
path: "{{ network_backup_path_root }}"
state: absent
tags: always
Loading