From b70d4bd0e0c604117de1e47a534b5626f4623307 Mon Sep 17 00:00:00 2001 From: counterweight Date: Mon, 15 Dec 2025 19:28:02 +0100 Subject: [PATCH] memos --- .../services/memos/deploy_memos_playbook.yml | 319 ++++++++++++------ ansible/services/memos/memos_vars.yml | 29 +- .../memos/setup_backup_memos_to_lapy.yml | 106 ++++++ 3 files changed, 350 insertions(+), 104 deletions(-) create mode 100644 ansible/services/memos/setup_backup_memos_to_lapy.yml diff --git a/ansible/services/memos/deploy_memos_playbook.yml b/ansible/services/memos/deploy_memos_playbook.yml index d3276f5..da56bd6 100644 --- a/ansible/services/memos/deploy_memos_playbook.yml +++ b/ansible/services/memos/deploy_memos_playbook.yml @@ -1,105 +1,111 @@ -- name: Deploy memos and configure Caddy reverse proxy - hosts: memos-box +- name: Deploy Memos on memos-box + hosts: memos_box_local become: yes vars_files: - ../../infra_vars.yml - ../../services_config.yml + - ../../infra_secrets.yml - ./memos_vars.yml vars: memos_subdomain: "{{ subdomains.memos }}" - caddy_sites_dir: "{{ caddy_sites_dir }}" memos_domain: "{{ memos_subdomain }}.{{ root_domain }}" tasks: - - name: Install required packages + - name: Ensure required packages are installed apt: name: - wget - - curl - - unzip + - tar state: present - update_cache: yes + update_cache: true - - name: Get latest memos release version - uri: - url: https://api.github.com/repos/usememos/memos/releases/latest - return_content: yes - register: memos_latest_release - - - name: Set memos version and find download URL - set_fact: - memos_version: "{{ memos_latest_release.json.tag_name | regex_replace('^v', '') }}" - - - name: Find linux-amd64 download URL - set_fact: - memos_download_url: "{{ memos_latest_release.json.assets | json_query('[?contains(name, `linux-amd64`) && (contains(name, `.tar.gz`) || contains(name, `.zip`))].browser_download_url') | first }}" - - - name: Display memos version to install - debug: - msg: "Installing memos version {{ memos_version }} from {{ memos_download_url }}" - - - name: Download memos binary - get_url: - url: "{{ memos_download_url }}" - dest: /tmp/memos_archive - mode: '0644' - register: memos_download - - - name: Extract memos binary - unarchive: - src: /tmp/memos_archive - dest: /tmp/memos_extract - remote_src: yes - creates: /tmp/memos_extract/memos - - - name: Install memos binary - copy: - src: /tmp/memos_extract/memos - dest: /usr/local/bin/memos - mode: '0755' - remote_src: yes - notify: Restart memos - - - name: Remove temporary files - file: - path: "{{ item }}" - state: absent - loop: - - /tmp/memos_archive - - /tmp/memos_extract - - - name: Ensure memos user exists + - name: Create memos system user user: - name: memos + name: "{{ memos_user }}" system: yes - shell: /usr/sbin/nologin - home: /var/lib/memos - create_home: yes - state: present + shell: /bin/false + home: "{{ memos_data_dir }}" + create_home: no + comment: "Memos Service" - name: Create memos data directory file: path: "{{ memos_data_dir }}" state: directory - owner: memos - group: memos + owner: "{{ memos_user }}" + group: "{{ memos_user }}" mode: '0750' - - name: Create memos systemd service file + - name: Create memos config directory + file: + path: "{{ memos_config_dir }}" + state: directory + owner: root + group: root + mode: '0755' + + - name: Download memos binary archive + get_url: + url: "{{ memos_url }}" + dest: "/tmp/memos.tar.gz" + mode: '0644' + + - name: Extract memos binary + unarchive: + src: "/tmp/memos.tar.gz" + dest: "/tmp" + remote_src: yes + + - name: Move memos binary to /usr/local/bin + copy: + src: "/tmp/memos" + dest: "{{ memos_bin_path }}" + remote_src: yes + mode: '0755' + owner: root + group: root + + - name: Clean up temporary files + file: + path: "{{ item }}" + state: absent + loop: + - /tmp/memos.tar.gz + - /tmp/memos + + - name: Create memos environment file + copy: + dest: "{{ memos_config_dir }}/memos.env" + content: | + MEMOS_MODE=prod + MEMOS_ADDR=0.0.0.0 + MEMOS_PORT={{ memos_port }} + MEMOS_DATA={{ memos_data_dir }} + MEMOS_DRIVER=sqlite + owner: root + group: root + mode: '0644' + notify: Restart memos + + - name: Create memos systemd service copy: dest: /etc/systemd/system/memos.service content: | [Unit] - Description=memos service + Description=Memos - A privacy-first, lightweight note-taking service After=network.target [Service] Type=simple - User=memos - Group=memos - ExecStart=/usr/local/bin/memos --port {{ memos_port }} --data {{ memos_data_dir }} - Restart=on-failure - RestartSec=5s + User={{ memos_user }} + Group={{ memos_user }} + WorkingDirectory={{ memos_data_dir }} + EnvironmentFile={{ memos_config_dir }}/memos.env + ExecStart={{ memos_bin_path }} + Restart=always + RestartSec=3 + StandardOutput=journal + StandardError=journal [Install] WantedBy=multi-user.target @@ -108,35 +114,52 @@ mode: '0644' notify: Restart memos + - name: Reload systemd daemon + systemd: + daemon_reload: yes + - name: Enable and start memos service systemd: name: memos enabled: yes state: started - daemon_reload: yes - name: Wait for memos to be ready uri: - url: "http://localhost:{{ memos_port }}/api/v1/status" + url: "http://127.0.0.1:{{ memos_port }}/healthz" + method: GET status_code: 200 - register: memos_ready - until: memos_ready.status == 200 - retries: 30 - delay: 2 - ignore_errors: yes + register: memos_health + retries: 10 + delay: 3 + until: memos_health.status == 200 - - name: Allow HTTPS through UFW - ufw: - rule: allow - port: '443' - proto: tcp + - name: Display memos status + debug: + msg: "Memos is running on port {{ memos_port }}. Access via Tailscale at http://{{ memos_tailscale_hostname }}:{{ memos_port }}" - - name: Allow HTTP through UFW (for Let's Encrypt) - ufw: - rule: allow - port: '80' - proto: tcp + handlers: + - name: Restart memos + systemd: + name: memos + state: restarted + +- name: Configure Caddy reverse proxy for Memos on vipy (proxying via Tailscale) + hosts: vipy + become: yes + vars_files: + - ../../infra_vars.yml + - ../../services_config.yml + - ../../infra_secrets.yml + - ./memos_vars.yml + vars: + memos_subdomain: "{{ subdomains.memos }}" + caddy_sites_dir: "{{ caddy_sites_dir }}" + memos_domain: "{{ memos_subdomain }}.{{ root_domain }}" + uptime_kuma_api_url: "https://{{ subdomains.uptime_kuma }}.{{ root_domain }}" + + tasks: - name: Ensure Caddy sites-enabled directory exists file: path: "{{ caddy_sites_dir }}" @@ -153,12 +176,17 @@ state: present backup: yes - - name: Create Caddy reverse proxy configuration for memos + - name: Create Caddy reverse proxy configuration for memos (via Tailscale) copy: dest: "{{ caddy_sites_dir }}/memos.conf" content: | {{ memos_domain }} { - reverse_proxy localhost:{{ memos_port }} + reverse_proxy {{ memos_tailscale_hostname }}:{{ memos_port }} { + # Use Tailscale MagicDNS to resolve the upstream hostname + transport http { + resolvers 100.100.100.100 + } + } } owner: root group: root @@ -167,9 +195,112 @@ - name: Reload Caddy to apply new config command: systemctl reload caddy - handlers: - - name: Restart memos - systemd: - name: memos - state: restarted + - name: Create Uptime Kuma monitor setup script for Memos + delegate_to: localhost + become: no + copy: + dest: /tmp/setup_memos_monitor.py + content: | + #!/usr/bin/env python3 + import sys + import traceback + import yaml + from uptime_kuma_api import UptimeKumaApi, MonitorType + + try: + # Load configs + with open('/tmp/ansible_memos_config.yml', 'r') as f: + config = yaml.safe_load(f) + + url = config['uptime_kuma_url'] + username = config['username'] + password = config['password'] + monitor_url = config['monitor_url'] + monitor_name = config['monitor_name'] + + # Connect to Uptime Kuma + api = UptimeKumaApi(url, timeout=30) + api.login(username, password) + + # Get all monitors + monitors = api.get_monitors() + + # Find or create "services" group + group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) + if not group: + group_result = api.add_monitor(type='group', name='services') + # Refresh to get the group with id + monitors = api.get_monitors() + group = next((m for m in monitors if m.get('name') == 'services' and m.get('type') == 'group'), None) + + # Check if monitor already exists + existing_monitor = None + for monitor in monitors: + if monitor.get('name') == monitor_name: + existing_monitor = monitor + break + + # Get ntfy notification ID + notifications = api.get_notifications() + ntfy_notification_id = None + for notif in notifications: + if notif.get('type') == 'ntfy': + ntfy_notification_id = notif.get('id') + break + + if existing_monitor: + print(f"Monitor '{monitor_name}' already exists (ID: {existing_monitor['id']})") + print("Skipping - monitor already configured") + else: + print(f"Creating monitor '{monitor_name}'...") + api.add_monitor( + type=MonitorType.HTTP, + name=monitor_name, + url=monitor_url, + parent=group['id'], + interval=60, + maxretries=3, + retryInterval=60, + notificationIDList={ntfy_notification_id: True} if ntfy_notification_id else {} + ) + + api.disconnect() + print("SUCCESS") + + except Exception as e: + error_msg = str(e) if str(e) else repr(e) + print(f"ERROR: {error_msg}", file=sys.stderr) + traceback.print_exc(file=sys.stderr) + sys.exit(1) + mode: '0755' + - name: Create temporary config for monitor setup + delegate_to: localhost + become: no + copy: + dest: /tmp/ansible_memos_config.yml + content: | + uptime_kuma_url: "{{ uptime_kuma_api_url }}" + username: "{{ uptime_kuma_username }}" + password: "{{ uptime_kuma_password }}" + monitor_url: "https://{{ memos_domain }}/healthz" + monitor_name: "Memos" + mode: '0644' + + - name: Run Uptime Kuma monitor setup + command: python3 /tmp/setup_memos_monitor.py + delegate_to: localhost + become: no + register: monitor_setup + changed_when: "'SUCCESS' in monitor_setup.stdout" + ignore_errors: yes + + - name: Clean up temporary files + delegate_to: localhost + become: no + file: + path: "{{ item }}" + state: absent + loop: + - /tmp/setup_memos_monitor.py + - /tmp/ansible_memos_config.yml diff --git a/ansible/services/memos/memos_vars.yml b/ansible/services/memos/memos_vars.yml index d027842..dcd0aac 100644 --- a/ansible/services/memos/memos_vars.yml +++ b/ansible/services/memos/memos_vars.yml @@ -1,18 +1,27 @@ -# General -memos_data_dir: /var/lib/memos +# Memos configuration +memos_version: "0.25.3" memos_port: 5230 +memos_user: "memos" +memos_data_dir: "/var/lib/memos" +memos_config_dir: "/etc/memos" +memos_bin_path: "/usr/local/bin/memos" +memos_arch: "linux_amd64" +memos_url: "https://github.com/usememos/memos/releases/download/v{{ memos_version }}/memos_v{{ memos_version }}_{{ memos_arch }}.tar.gz" -# (caddy_sites_dir and subdomain now in services_config.yml) +# Tailscale for memos-box (used by vipy Caddy proxy) +memos_tailscale_hostname: "memos-box" +memos_tailscale_ip: "100.64.0.4" -# Remote access -remote_host_name: "memos-box" -remote_host: "{{ hostvars.get(remote_host_name, {}).get('ansible_host', remote_host_name) }}" -remote_user: "{{ hostvars.get(remote_host_name, {}).get('ansible_user', 'counterweight') }}" -remote_key_file: "{{ hostvars.get(remote_host_name, {}).get('ansible_ssh_private_key_file', '') }}" -remote_port: "{{ hostvars.get(remote_host_name, {}).get('ansible_port', 22) }}" +# (caddy_sites_dir and subdomain in services_config.yml) + +# Remote access (for backup from lapy) +backup_host_name: "memos_box_local" +backup_host: "{{ hostvars.get(backup_host_name, {}).get('ansible_host', backup_host_name) }}" +backup_user: "{{ hostvars.get(backup_host_name, {}).get('ansible_user', 'counterweight') }}" +backup_key_file: "{{ hostvars.get(backup_host_name, {}).get('ansible_ssh_private_key_file', '') }}" +backup_port: "{{ hostvars.get(backup_host_name, {}).get('ansible_port', 22) }}" # Local backup local_backup_dir: "{{ lookup('env', 'HOME') }}/memos-backups" backup_script_path: "{{ lookup('env', 'HOME') }}/.local/bin/memos_backup.sh" - diff --git a/ansible/services/memos/setup_backup_memos_to_lapy.yml b/ansible/services/memos/setup_backup_memos_to_lapy.yml new file mode 100644 index 0000000..3e3718e --- /dev/null +++ b/ansible/services/memos/setup_backup_memos_to_lapy.yml @@ -0,0 +1,106 @@ +- name: Configure local backup for Memos from memos-box + hosts: lapy + gather_facts: no + vars_files: + - ../../infra_vars.yml + - ./memos_vars.yml + vars: + backup_data_path: "{{ memos_data_dir }}" + + tasks: + - name: Debug remote backup vars + debug: + msg: + - "backup_host={{ backup_host }}" + - "backup_user={{ backup_user }}" + - "backup_data_path='{{ backup_data_path }}'" + - "local_backup_dir={{ local_backup_dir }}" + + - name: Ensure local backup directory exists + file: + path: "{{ local_backup_dir }}" + state: directory + mode: '0755' + + - name: Ensure ~/.local/bin exists + file: + path: "{{ lookup('env', 'HOME') }}/.local/bin" + state: directory + mode: '0755' + + - name: Create backup script + copy: + dest: "{{ backup_script_path }}" + mode: '0750' + content: | + #!/bin/bash + set -euo pipefail + + TIMESTAMP=$(date +'%Y-%m-%d') + BACKUP_DIR="{{ local_backup_dir }}/$TIMESTAMP" + mkdir -p "$BACKUP_DIR" + + {% if backup_key_file %} + SSH_CMD="ssh -i {{ backup_key_file }} -p {{ backup_port }}" + {% else %} + SSH_CMD="ssh -p {{ backup_port }}" + {% endif %} + + rsync -az -e "$SSH_CMD" --delete {{ backup_user }}@{{ backup_host }}:{{ backup_data_path }}/ "$BACKUP_DIR/" + + # Rotate old backups (keep 14 days) + # Calculate cutoff date (14 days ago) and delete backups older than that + CUTOFF_DATE=$(date -d '14 days ago' +'%Y-%m-%d') + for dir in "{{ local_backup_dir }}"/20*; do + if [ -d "$dir" ]; then + dir_date=$(basename "$dir") + if [ "$dir_date" != "$TIMESTAMP" ] && [ "$dir_date" \< "$CUTOFF_DATE" ]; then + rm -rf "$dir" + fi + fi + done + + - name: Ensure cronjob for backup exists + cron: + name: "Memos backup" + user: "{{ lookup('env', 'USER') }}" + job: "{{ backup_script_path }}" + minute: 15 + hour: "9,12,15,18" + + - name: Run the backup script to make the first backup + command: "{{ backup_script_path }}" + + - name: Verify backup was created + block: + - name: Get today's date + command: date +'%Y-%m-%d' + register: today_date + changed_when: false + + - name: Check backup directory exists and contains files + stat: + path: "{{ local_backup_dir }}/{{ today_date.stdout }}" + register: backup_dir_stat + + - name: Verify backup directory exists + assert: + that: + - backup_dir_stat.stat.exists + - backup_dir_stat.stat.isdir + fail_msg: "Backup directory {{ local_backup_dir }}/{{ today_date.stdout }} was not created" + success_msg: "Backup directory {{ local_backup_dir }}/{{ today_date.stdout }} exists" + + - name: Check if backup directory contains files + find: + paths: "{{ local_backup_dir }}/{{ today_date.stdout }}" + recurse: yes + register: backup_files + + - name: Verify backup directory is not empty + assert: + that: + - backup_files.files | length > 0 + fail_msg: "Backup directory {{ local_backup_dir }}/{{ today_date.stdout }} exists but is empty" + success_msg: "Backup directory contains {{ backup_files.files | length }} file(s)" +