From 0a97f0035688ed5afabf9b7016c848a17ff8f145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20=C5=BD=C3=A1=C4=8Dek?= Date: Fri, 15 May 2026 21:37:10 +0200 Subject: [PATCH] Auto-register borg repo on the controller per host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backup role now logs into borgcontroller and creates (or looks up) a repository with alias=inventory_hostname, registering root's pubkey and the requested storageSize. The resulting SSH URI is injected into the borgmatic config so each host gets a remote-managed repo without manual config. backup_hosts entries gain a `storage_size_gb` field (stripped before templating) and lose the manual `repositories` entry — the role fills it. borgcontroller_{username,password} are expected from Semaphore secrets. Also gitignores .claude/ local state. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + group_vars/all/backup.yml | 17 +++-- roles/backup/tasks/borgcontroller.yml | 90 ++++++++++++++++++++++++ roles/backup/tasks/main.yml | 38 +++++++--- roles/backup/templates/borgmatic.yaml.j2 | 2 +- 5 files changed, 131 insertions(+), 19 deletions(-) create mode 100644 roles/backup/tasks/borgcontroller.yml diff --git a/.gitignore b/.gitignore index 107f158..0a95ae1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ venv/ # MikroTik backup output mikrotik/backups/ mikrotik/output/ + +# Claude Code local state +.claude/ diff --git a/group_vars/all/backup.yml b/group_vars/all/backup.yml index 2b91d52..717d29c 100644 --- a/group_vars/all/backup.yml +++ b/group_vars/all/backup.yml @@ -1,15 +1,18 @@ --- -# Per-host borgmatic config. Keys must match inventory_hostname. -# Hosts not listed here are skipped by the `backup` role. -# The value under each host is rendered verbatim as the borgmatic -# config file (see https://torsion.org/borgmatic/docs/reference/configuration/). +# Borg Controller — auto-creates a repo per host on a BorgWarehouse-backed server. +# borgcontroller_username / borgcontroller_password come from Semaphore secrets. +borgcontroller_url: https://borgcontroller.internet-master.cz + +# Per-host borgmatic config. Hosts not listed here are skipped by the `backup` role. +# `storage_size_gb` is stripped before rendering and used to size the controller-side +# repo. `repositories` is auto-filled from the controller — don't set it manually. +# Other keys are passed through verbatim to borgmatic, see +# https://torsion.org/borgmatic/docs/reference/configuration/ backup_hosts: testipaclient: + storage_size_gb: 10 source_directories: - /home/jakub - repositories: - - path: /var/backups/borg/jakub-home - label: jakub-home keep_daily: 7 keep_weekly: 4 keep_monthly: 6 diff --git a/roles/backup/tasks/borgcontroller.yml b/roles/backup/tasks/borgcontroller.yml new file mode 100644 index 0000000..5a3a859 --- /dev/null +++ b/roles/backup/tasks/borgcontroller.yml @@ -0,0 +1,90 @@ +--- +- name: Login to borg controller + ansible.builtin.uri: + url: "{{ borgcontroller_url }}/api/auth/login" + method: POST + body_format: json + body: + username: "{{ borgcontroller_username }}" + password: "{{ borgcontroller_password }}" + status_code: 200 + delegate_to: localhost + become: false + register: _bc_login + no_log: true + +- name: Get borg server SSH endpoint + ansible.builtin.uri: + url: "{{ borgcontroller_url }}/api/config" + method: GET + headers: + Cookie: "{{ _bc_login.cookies_string }}" + delegate_to: localhost + become: false + register: _bc_config + +- name: List repositories + ansible.builtin.uri: + url: "{{ borgcontroller_url }}/api/repositories" + method: GET + headers: + Cookie: "{{ _bc_login.cookies_string }}" + delegate_to: localhost + become: false + register: _bc_repos + +- name: Find existing repository for this host + ansible.builtin.set_fact: + _bc_existing: >- + {{ _bc_repos.json | selectattr('alias', 'eq', inventory_hostname) | list }} + +- name: Create repository if missing + ansible.builtin.uri: + url: "{{ borgcontroller_url }}/api/repositories" + method: POST + body_format: json + body: + alias: "{{ inventory_hostname }}" + sshPublicKey: "{{ root_ssh.ssh_public_key }}" + storageSize: "{{ backup_hosts[inventory_hostname].storage_size_gb | int }}" + headers: + Cookie: "{{ _bc_login.cookies_string }}" + status_code: [200, 201] + delegate_to: localhost + become: false + when: _bc_existing | length == 0 + +- name: Update repository SSH key if root's key changed + ansible.builtin.uri: + url: "{{ borgcontroller_url }}/api/repositories/{{ _bc_existing[0].id }}" + method: PATCH + body_format: json + body: + sshPublicKey: "{{ root_ssh.ssh_public_key }}" + headers: + Cookie: "{{ _bc_login.cookies_string }}" + status_code: 200 + delegate_to: localhost + become: false + when: + - _bc_existing | length > 0 + - _bc_existing[0].sshPublicKey != root_ssh.ssh_public_key + +- name: Re-list repositories after possible create/update + ansible.builtin.uri: + url: "{{ borgcontroller_url }}/api/repositories" + method: GET + headers: + Cookie: "{{ _bc_login.cookies_string }}" + delegate_to: localhost + become: false + register: _bc_repos_after + +- name: Resolve repository for this host + ansible.builtin.set_fact: + _bc_repo: >- + {{ (_bc_repos_after.json | selectattr('alias', 'eq', inventory_hostname) | list)[0] }} + +- name: Build borg SSH URI + ansible.builtin.set_fact: + borgcontroller_repo_uri: "ssh://{{ _bc_repo.repository }}@{{ _bc_config.json.borgSshHost.split('@')[1] }}/./repos" diff --git a/roles/backup/tasks/main.yml b/roles/backup/tasks/main.yml index ebfa3f4..ddcfac3 100644 --- a/roles/backup/tasks/main.yml +++ b/roles/backup/tasks/main.yml @@ -21,14 +21,6 @@ group: root mode: '0750' - - name: Deploy borgmatic config - ansible.builtin.template: - src: borgmatic.yaml.j2 - dest: /etc/borgmatic/config.yaml - owner: root - group: root - mode: '0640' - - name: Ensure root has an SSH key for the borg server ansible.builtin.user: name: root @@ -38,9 +30,33 @@ ssh_key_comment: "borgmatic@{{ inventory_hostname }}" register: root_ssh - - name: Show root's SSH public key (add this to the borg server's authorized_keys) - ansible.builtin.debug: - msg: "{{ root_ssh.ssh_public_key }}" + - name: Register / look up repository on borg controller + ansible.builtin.include_tasks: borgcontroller.yml + when: + - borgcontroller_username is defined + - borgcontroller_password is defined + + - name: Build borgmatic config (strip controller-only keys, inject repository) + ansible.builtin.set_fact: + _borgmatic_config: >- + {{ + (backup_hosts[inventory_hostname] + | dict2items + | rejectattr('key', 'in', ['storage_size_gb']) + | items2dict) + | combine( + {'repositories': [{'path': borgcontroller_repo_uri, 'label': inventory_hostname}]} + if borgcontroller_repo_uri is defined else {} + ) + }} + + - name: Deploy borgmatic config + ansible.builtin.template: + src: borgmatic.yaml.j2 + dest: /etc/borgmatic/config.yaml + owner: root + group: root + mode: '0640' - name: Enable and start borgmatic timer ansible.builtin.systemd: diff --git a/roles/backup/templates/borgmatic.yaml.j2 b/roles/backup/templates/borgmatic.yaml.j2 index be8f742..a93e5f1 100644 --- a/roles/backup/templates/borgmatic.yaml.j2 +++ b/roles/backup/templates/borgmatic.yaml.j2 @@ -1,3 +1,3 @@ #jinja2: trim_blocks: True, lstrip_blocks: True # Managed by Ansible — do not edit by hand. -{{ backup_hosts[inventory_hostname] | to_nice_yaml(indent=2, width=1000) }} +{{ _borgmatic_config | to_nice_yaml(indent=2, width=1000) }}