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
62 changes: 62 additions & 0 deletions docs/user/backup-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ This creates a timestamped backup directory at `/var/backup/foreman-backup-YYYYM

| Option | Description |
|--------|-------------|
| `--base-backup BASE_DIR` | Create an incremental backup based on a base backup. Only files changed since the base backup are included. The base backup directory must contain `.snar` snapshot files (generated by foremanctl backups). |
| `--skip-pulp-content` | Skip backing up `/var/lib/pulp`. This is for debugging purposes or if you plan to copy `/var/lib/pulp` using other methods such as rsync or shared storage. **Warning:** You will not have a complete backup if you use this option. |
| `--wait-for-tasks` | Wait for running Foreman and Pulp tasks to complete instead of failing immediately. The backup will poll until all tasks finish before proceeding. |

Expand Down Expand Up @@ -69,6 +70,38 @@ Allow in-progress tasks to complete before starting backup:
foremanctl backup /var/backup --wait-for-tasks
```

### Incremental Backup

Create a full backup first:

```bash
foremanctl backup /var/backup
# Creates: /var/backup/foreman-backup-20260629T120000/
```

Then create incremental backups referencing the base backup:

```bash
foremanctl backup /var/backup --base-backup /var/backup/foreman-backup-20260629T120000
# Creates: /var/backup/foreman-backup-20260630T080000/ (incremental)
```

**How it works:**
- The incremental backup contains only files changed since the previous backup
- `.snar` snapshot files track which files were in the previous backup
- Both `config.snar` (foremanctl state) and `pulp.snar` (Pulp content) are copied from the previous backup
- Incremental backups are typically much smaller and faster than full backups

**Typical backup strategy:**
- Weekly full backup (Sunday)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does performing a full backup wipe out the previous incremental backups?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, performing a full backup doesn't delete anything. All backups (full or incremental) are just timestamped directories that coexist independently. You'd need to manually clean up old backups when you no longer need them.

- Daily incremental backups (Monday-Saturday)
- Each incremental references the previous day's backup

**Important:**
- You can chain incrementals: full -> inc1 -> inc2 -> inc3
- All backups in the chain are required for restore
- The previous backup must have been created with foremanctl (contains .snar files)

## Backup Contents

### Databases
Expand Down Expand Up @@ -269,6 +302,35 @@ foremanctl restore /var/backup/foreman-backup-20260617T104115 --validate

This validates the backup and checks system requirements before proceeding.

### Restore Incremental Backups

To restore from an incremental backup, restore each backup in the chain sequentially, starting with the full backup that began the chain:

```bash
# Step 1: Restore the full (base) backup

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The most recent full backup or do you have to start with the first ever full backup?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would be the first full backup in the incremental chain (the most recent full backup) and the chain will continue with each incremental backup until you run a full backup starting a new chain.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we then word it as such? Restore the most recent full backup ?

foremanctl restore /var/backup/foreman-backup-20260629T120000

# Step 2: Restore the first incremental
foremanctl restore /var/backup/foreman-backup-20260630T080000 --force

# Step 3: Restore the second incremental
foremanctl restore /var/backup/foreman-backup-20260701T080000 --force
```

**Important:**
- Incremental backups contain only files changed since the previous backup
- All backups in the chain must be restored in order (full -> inc1 -> inc2)
- The restore command automatically detects incremental backups from metadata
- The `--force` flag is required for subsequent restores since the system is already deployed

**Automatic validation:**
The restore command validates the backup chain:
- Verifies the base backup directory exists
- Confirms the base backup metadata matches the expected timestamp
- Warns if the base backup has not been restored yet

If any validation fails, a clear error message explains which backup is missing or incorrect.

## Prerequisites

Before restoring, ensure:
Expand Down
11 changes: 10 additions & 1 deletion src/playbooks/backup/metadata.obsah.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
help: |
Create offline backup of Foreman databases and configuration
Create offline backup of databases and configuration

variables:
backup_dir:
Expand All @@ -23,3 +23,12 @@ variables:
help: Wait for running tasks to complete instead of failing immediately
action: store_true
persist: false

backup_incremental:
parameter: --base-backup
help: |
Path to base backup directory for incremental backup.
Creates a differential backup containing only files changed since
the base backup. Requires .snar files from base backup.
type: AbsolutePath
persist: false
2 changes: 1 addition & 1 deletion src/playbooks/health/metadata.obsah.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
help: |
Check the health of your running Foreman server
Check the health of your running server
variables:
health_skip_check_foreman_tasks_param:
help: |
Expand Down
6 changes: 3 additions & 3 deletions src/playbooks/restore/metadata.obsah.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ help: |
Restore from a backup

Validates backup contents, extracts configuration files, restores databases,
restores Pulp content, and redeploys the system.
restores content, and redeploys the system.

variables:
backup_dir:
parameter: backup_dir
restore_backup_dir:
parameter: restore_backup_dir
help: Directory containing the backup files
type: AbsolutePath
persist: false
Expand Down
4 changes: 2 additions & 2 deletions src/roles/backup/tasks/database_dumps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
--port={{ item.port }}
--username={{ item.user }}
--format=custom
--file={{ backup_dir_full }}/{{ item.database }}.dump
{{ item.database }}
--file="{{ backup_dir_full }}/{{ item.database }}.dump"
"{{ item.database }}"
environment:
PGPASSWORD: "{{ item.password }}"
loop: "{{ backup_databases_config }}"
Expand Down
73 changes: 65 additions & 8 deletions src/roles/backup/tasks/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,35 @@
ansible.builtin.set_fact:
backup_dir_full: "{{ backup_dir }}/foreman-backup-{{ backup_timestamp }}"

- name: Validate backup_incremental backup parameters
when: backup_incremental is defined and backup_incremental | length > 0
block:
- name: Check previous backup files exist
ansible.builtin.stat:
path: "{{ backup_incremental }}/{{ item }}"
register: backup_previous_files_check
loop:
- "metadata.yml"
- "config.snar"
- "pulp.snar"
failed_when: false

- name: Fail if previous backup is invalid
ansible.builtin.fail:
msg: |
Previous backup directory does not exist or is not a valid foremanctl backup: {{ backup_incremental }}
Missing files: {{ backup_previous_files_check.results | selectattr('stat.exists', 'equalto', false) | map(attribute='item') | list }}

The previous backup must contain metadata.yml and .snar files generated by foremanctl.
Create a new full backup first:
foremanctl backup {{ backup_dir }}
when: backup_previous_files_check.results | selectattr('stat.exists', 'equalto', false) | list | length > 0

Comment on lines +32 to +33

@vsedmik vsedmik Aug 19, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

During testing of incremental backup/restore, restoring an incremental backup fails with:

  E         TASK [restore : Validate base backup timestamp matches] ************************                                                                                                                                                 
  E         fatal: [localhost]: FAILED! =>                                                                                                                                                                                                   
  E             changed: false                                                                                                                                                                                                               
  E             msg: |-                                                                                                                                                                                                                      
  E                 BACKUP CHAIN MISMATCH:                                                                                                                                                                                                   
  E                                                                                                                                                                                                                                          
  E                 The incremental backup references a different base backup than provided.                                                                                                                                                 
  E                                                                                                                                                                                                                                          
  E                 Incremental backup: /tmp/backup-orHsvwfvkD/foreman-backup-20260819T035326                                                                                                                                                
  E                 Expected base timestamp:                                                                                                                                                                                                 
  E                 Actual base timestamp: 20260819T035145                                                                                                                                                                                   
  E                                                                                                                                                                                                                                          
  E                 This incremental backup is not based on the backup at /tmp/backup-orHsvwfvkD/foreman-backup-20260819T035145.                                                                                                             
  E                 Verify you are using the correct base backup directory.

Cause: The incremental backup's metadata.yml has an empty base_backup_timestamp:

incremental:
    base_backup_dir: /tmp/backup-xyz/foreman-backup-20260819T035145
    base_backup_timestamp: ''   # <-- EMPTY!
    is_incremental: true

This happens because src/roles/backup/tasks/metadata.yaml references backup_previous_metadata:

base_backup_timestamp: "{{ (backup_previous_metadata | default({})).timestamp | default('') }}"

But there's no task that actually reads the previous backup's metadata.yml to populate this variable. The validation tasks only use ansible.builtin.stat to check file existence, but never load the file contents.

Fix suggestion: Add tasks to read and parse the previous backup's metadata after validation in src/roles/backup/tasks/main.yaml:

Suggested change
when: backup_previous_files_check.results | selectattr('stat.exists', 'equalto', false) | list | length > 0
when: backup_previous_files_check.results | selectattr('stat.exists', 'equalto', false) | list | length > 0
- name: Read previous backup metadata
ansible.builtin.slurp:
src: "{{ incremental }}/metadata.yml"
register: previous_metadata_content
- name: Parse previous backup metadata
ansible.builtin.set_fact:
backup_previous_metadata: "{{ previous_metadata_content.content | b64decode | from_yaml }}"

With this fix, the incremental backup's metadata correctly stores:

base_backup_timestamp: 20260819T054714

And the restore pass.

@Chyenne8 Chyenne8 Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're correct! When I simplified the validation, I removed the metadata read/parse tasks that metadata.yaml depends on and didn't add it back after moving the validation.

- name: Load previous backup metadata
ansible.builtin.include_vars:
file: "{{ backup_incremental }}/metadata.yml"
name: backup_previous_metadata

- name: Ensure backup directory exists
ansible.builtin.file:
path: "{{ backup_dir }}"
Expand Down Expand Up @@ -92,25 +121,53 @@
ansible.builtin.include_tasks:
file: database_dumps.yaml

- name: Archive foremanctl state on controller
community.general.archive:
path: "{{ obsah_state_path }}"
dest: "{{ obsah_state_path }}/foremanctl-state.tar.gz"
format: gz
- name: Copy pulp.snar from previous backup
ansible.builtin.copy:
src: "{{ backup_incremental }}/pulp.snar"
dest: "{{ backup_dir_full }}/pulp.snar"
remote_src: true
mode: '0644'
when:
- backup_incremental is defined and backup_incremental | length > 0
- not skip_pulp_content | default(false)

- name: Fetch config.snar from previous backup to controller
ansible.builtin.fetch:
src: "{{ backup_incremental }}/config.snar"
dest: "{{ obsah_state_path | dirname }}/config.snar"
flat: true
when: backup_incremental is defined and backup_incremental | length > 0

- name: Backup foremanctl state directory # noqa: command-instead-of-module
ansible.builtin.command:
cmd: >
tar -czf "{{ obsah_state_path | dirname }}/foremanctl-state.tar.gz"
--listed-incremental="{{ obsah_state_path | dirname }}/config.snar"
-C "{{ obsah_state_path | dirname }}"
"{{ obsah_state_path | basename }}"
delegate_to: localhost
become: false
changed_when: true

- name: Copy foremanctl state archive to target
ansible.builtin.copy:
src: "{{ obsah_state_path }}/foremanctl-state.tar.gz"
src: "{{ obsah_state_path | dirname }}/foremanctl-state.tar.gz"
dest: "{{ backup_dir_full }}/foremanctl-state.tar.gz"
mode: '0644'

- name: Clean up foremanctl state archive on controller
- name: Copy config.snar to backup directory
ansible.builtin.copy:
src: "{{ obsah_state_path | dirname }}/config.snar"
dest: "{{ backup_dir_full }}/config.snar"
mode: '0644'

- name: Clean up foremanctl state files on controller
ansible.builtin.file:
path: "{{ obsah_state_path }}/foremanctl-state.tar.gz"
path: "{{ item }}"
state: absent
loop:
- "{{ obsah_state_path | dirname }}/foremanctl-state.tar.gz"
- "{{ obsah_state_path | dirname }}/config.snar"
delegate_to: localhost
become: false

Expand Down
5 changes: 4 additions & 1 deletion src/roles/backup/tasks/metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@
os_version: "{{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}"
foremanctl_version: "{{ ansible_facts.packages['foremanctl'][0].version | default('unknown') if 'foremanctl' in ansible_facts.packages else 'unknown' }}"
type: offline
incremental: false
incremental:
is_incremental: "{{ (backup_incremental is defined and backup_incremental | length > 0) | bool }}"
base_backup_dir: "{{ backup_incremental | default('') }}"
base_backup_timestamp: "{{ (backup_previous_metadata | default({})).timestamp | default('') }}"
timestamp: "{{ backup_timestamp }}"
databases: "{{ backup_databases_to_backup }}"
database_mapping: "{{ backup_databases_config | items2dict(key_name='name', value_name='database') }}"
Expand Down
5 changes: 3 additions & 2 deletions src/roles/backup/tasks/pulp_content.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@
- name: Backup pulp content directory with encryption keys # noqa: command-instead-of-module
ansible.builtin.command:
cmd: >
tar -czf {{ backup_dir_full }}/pulp-content.tar.gz
--directory={{ backup_pulp_storage_path }}
tar -czf "{{ backup_dir_full }}/pulp-content.tar.gz"
--listed-incremental="{{ backup_dir_full }}/pulp.snar"
--directory="{{ backup_pulp_storage_path }}"
--exclude=media/exports
--exclude=media/imports
--exclude=media/sync_imports
Expand Down
27 changes: 18 additions & 9 deletions src/roles/restore/tasks/main.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,35 @@
- name: Perform restore operations
when: not validate | default(false)
block:
- name: Prepare system for restore
ansible.builtin.include_tasks:
file: prepare_system.yaml

- name: Restore foremanctl state
ansible.builtin.include_tasks:
file: restore_foremanctl_state.yaml
when: "'foremanctl_state' in restore_backup_metadata.backed_up_components | default(['foremanctl_state'])"

- name: Prepare system for restore
ansible.builtin.include_tasks:
file: prepare_system.yaml

- name: Restore databases
ansible.builtin.include_tasks:
file: restore_databases.yaml
when:
- restore_database_mode == 'internal'
- "'databases' in restore_backup_metadata.backed_up_components | default(['databases'])"

- name: Restore Pulp content
ansible.builtin.include_tasks:
file: restore_pulp_content.yaml
when: "'pulp_content' in restore_backup_metadata.backed_up_components | default([])"

- name: Record successful restore timestamp
ansible.builtin.copy:
content: "{{ restore_backup_metadata.timestamp }}"
dest: /var/lib/foremanctl/.last_restore_timestamp
mode: '0644'

- name: Display restore completion
ansible.builtin.debug:
msg: |
Restore completed successfully!
Backup: {{ restore_backup_dir }}
Type: {{ 'Incremental' if restore_is_incremental else 'Full' }}
Timestamp: {{ restore_backup_metadata.timestamp }}

rescue:
- name: Ensure services are stopped on failure
Expand Down
15 changes: 8 additions & 7 deletions src/roles/restore/tasks/restore_databases.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
containers.podman.podman_secret:
name: postgresql-admin-password
data: "{{ postgresql_admin_password }}"
force: true

- name: Start PostgreSQL for restore
ansible.builtin.systemd:
Expand All @@ -27,14 +28,13 @@
dump_file: "{{ restore_backup_metadata.database_mapping[item.name] }}.dump"
database: "{{ item.database }}"
user: "{{ item.user }}"
loop: "{{ databases }}"
loop: "{{ databases | selectattr('name', 'in', restore_backup_metadata.databases) | list }}"
loop_control:
label: "{{ item.name }}"
when: item.name in restore_backup_metadata.database_mapping

- name: Verify dump files exist
ansible.builtin.stat:
path: "{{ backup_dir }}/{{ item.dump_file }}"
path: "{{ restore_backup_dir }}/{{ item.dump_file }}"
register: restore_dump_files_check
failed_when: not restore_dump_files_check.stat.exists
loop: "{{ restore_databases_to_restore }}"
Expand Down Expand Up @@ -85,13 +85,14 @@
pg_restore
--host={{ database_host }}
--port={{ database_port }}
--username={{ item.user }}
--username=postgres
--dbname={{ item.database }}
{{ backup_dir }}/{{ item.dump_file }}
"{{ restore_backup_dir }}/{{ item.dump_file }}"
environment:
PGPASSWORD: "{{ postgresql_admin_password }}"
loop: "{{ restore_databases_to_restore }}"
loop_control:
label: "{{ item.dump_file }} {{ item.database }}"
label: "{{ item.dump_file }} -> {{ item.database }}"
changed_when: true
failed_when: false
register: restore_pg_restore_result
failed_when: restore_pg_restore_result.rc != 0 and 'WARNING' not in restore_pg_restore_result.stderr
Loading