From 46d98af51db9f8c54aaad29c12998403f1c5bda3 Mon Sep 17 00:00:00 2001 From: Nathan Date: Fri, 17 Apr 2026 19:19:11 -0400 Subject: [PATCH] feat: add OpenApply role with provisioning, configuration, and service management for Proxmox LXC --- .ansible/.lock | 0 ansible/group_vars/all/openapply.yml | 37 ++++++ ansible/group_vars/proxmox_cluster.yml | 5 + ansible/playbooks/deploy-openapply.yml | 111 ++++++++++++++++++ ansible/requirements.yml | 8 +- ansible/roles/openapply_app/defaults/main.yml | 39 ++++++ ansible/roles/openapply_app/handlers/main.yml | 9 ++ .../roles/openapply_app/tasks/deploy_code.yml | 94 +++++++++++++++ ansible/roles/openapply_app/tasks/main.yml | 15 +++ ansible/roles/openapply_app/tasks/prereqs.yml | 37 ++++++ .../roles/openapply_app/tasks/setup_env.yml | 42 +++++++ .../roles/openapply_app/tasks/validate.yml | 17 +++ ansible/roles/openapply_app/tasks/verify.yml | 24 ++++ .../openapply_app/templates/openapply.env.j2 | 6 + .../templates/openapply.service.j2 | 17 +++ ansible/roles/openapply_app/vars/main.yml | 3 + 16 files changed, 463 insertions(+), 1 deletion(-) create mode 100644 .ansible/.lock create mode 100644 ansible/group_vars/all/openapply.yml create mode 100644 ansible/group_vars/proxmox_cluster.yml create mode 100644 ansible/playbooks/deploy-openapply.yml create mode 100644 ansible/roles/openapply_app/defaults/main.yml create mode 100644 ansible/roles/openapply_app/handlers/main.yml create mode 100644 ansible/roles/openapply_app/tasks/deploy_code.yml create mode 100644 ansible/roles/openapply_app/tasks/main.yml create mode 100644 ansible/roles/openapply_app/tasks/prereqs.yml create mode 100644 ansible/roles/openapply_app/tasks/setup_env.yml create mode 100644 ansible/roles/openapply_app/tasks/validate.yml create mode 100644 ansible/roles/openapply_app/tasks/verify.yml create mode 100644 ansible/roles/openapply_app/templates/openapply.env.j2 create mode 100644 ansible/roles/openapply_app/templates/openapply.service.j2 create mode 100644 ansible/roles/openapply_app/vars/main.yml diff --git a/.ansible/.lock b/.ansible/.lock new file mode 100644 index 0000000..e69de29 diff --git a/ansible/group_vars/all/openapply.yml b/ansible/group_vars/all/openapply.yml new file mode 100644 index 0000000..4722bf8 --- /dev/null +++ b/ansible/group_vars/all/openapply.yml @@ -0,0 +1,37 @@ +--- +# Proxmox API settings (credentials come from vault variables) +openapply_pve_api_host: "{{ vault_proxmox_api_host | default('') }}" +openapply_pve_api_user: "{{ vault_proxmox_api_user | default('') }}" +openapply_pve_api_token_id: "{{ vault_proxmox_api_token_id | default('') }}" +openapply_pve_api_token_secret: "{{ vault_proxmox_api_token_secret | default('') }}" +openapply_proxmox_validate_certs: false + +# Proxmox target and LXC specification +openapply_pve_node: pve01 +openapply_lxc_vmid: 105 +openapply_lxc_hostname: openapply-prod +openapply_lxc_template: local:vztmpl/ubuntu-24.04-standard_24.04-1_amd64.tar.zst +openapply_lxc_storage: local-lvm +openapply_lxc_cores: 2 +openapply_lxc_memory_mb: 4096 +openapply_lxc_swap_mb: 512 +openapply_lxc_unprivileged: true +openapply_lxc_onboot: true +openapply_lxc_features: + nesting: 1 + +# LXC networking +openapply_lxc_bridge: vmbr0 +openapply_lxc_ip_cidr: 10.0.0.105/24 +openapply_lxc_gateway: 10.0.0.2 +openapply_lxc_management_ip: 10.0.0.105 +openapply_lxc_ssh_user: root +openapply_lxc_ssh_port: 22 +openapply_lxc_nic_firewall: true +openapply_use_proxmox_nic: false + +# LXC credentials (from vault) +openapply_lxc_password: "{{ vault_openapply_lxc_root_password | default('') }}" + +# Controller runtime preflight +openapply_validate_controller_python_deps: true diff --git a/ansible/group_vars/proxmox_cluster.yml b/ansible/group_vars/proxmox_cluster.yml new file mode 100644 index 0000000..1ef4277 --- /dev/null +++ b/ansible/group_vars/proxmox_cluster.yml @@ -0,0 +1,5 @@ +--- +# Cluster-scoped overrides for Proxmox-hosted workloads +# Override these values here when multiple Proxmox nodes diverge. + +openapply_proxmox_validate_certs: false diff --git a/ansible/playbooks/deploy-openapply.yml b/ansible/playbooks/deploy-openapply.yml new file mode 100644 index 0000000..14bf1c7 --- /dev/null +++ b/ansible/playbooks/deploy-openapply.yml @@ -0,0 +1,111 @@ +--- +- name: Provision OpenApply LXC on Proxmox + hosts: localhost + gather_facts: false + connection: local + + pre_tasks: + - name: Validate required infrastructure variables + ansible.builtin.assert: + that: + - openapply_pve_api_host | length > 0 + - openapply_pve_api_user | length > 0 + - openapply_pve_api_token_id | length > 0 + - openapply_pve_api_token_secret | length > 0 + - openapply_pve_node | length > 0 + - openapply_lxc_vmid | int > 0 + - openapply_lxc_hostname | length > 0 + - openapply_lxc_template | length > 0 + - openapply_lxc_storage | length > 0 + - openapply_lxc_ip_cidr | length > 0 + - openapply_lxc_gateway | length > 0 + - openapply_lxc_management_ip | length > 0 + - openapply_lxc_password | length > 0 + fail_msg: >- + Required Proxmox/OpenApply LXC variables are missing. Check + group_vars/all/openapply.yml and vault variables. + + - name: Validate Proxmox Python dependencies on controller + ansible.builtin.command: python3 -c "import proxmoxer, requests" + register: openapply_controller_python_deps + changed_when: false + failed_when: openapply_controller_python_deps.rc != 0 + when: openapply_validate_controller_python_deps | bool + + tasks: + - name: Ensure OpenApply LXC is present and started + community.proxmox.proxmox: + api_host: "{{ openapply_pve_api_host }}" + api_user: "{{ openapply_pve_api_user }}" + api_token_id: "{{ openapply_pve_api_token_id }}" + api_token_secret: "{{ openapply_pve_api_token_secret }}" + validate_certs: "{{ openapply_proxmox_validate_certs }}" + node: "{{ openapply_pve_node }}" + vmid: "{{ openapply_lxc_vmid }}" + hostname: "{{ openapply_lxc_hostname }}" + ostemplate: "{{ openapply_lxc_template }}" + storage: "{{ openapply_lxc_storage }}" + cores: "{{ openapply_lxc_cores }}" + memory: "{{ openapply_lxc_memory_mb }}" + swap: "{{ openapply_lxc_swap_mb }}" + password: "{{ openapply_lxc_password }}" + onboot: "{{ openapply_lxc_onboot }}" + unprivileged: "{{ openapply_lxc_unprivileged }}" + netif: + net0: "name=eth0,bridge={{ openapply_lxc_bridge }},ip={{ openapply_lxc_ip_cidr }},gw={{ openapply_lxc_gateway }}" + features: "{{ openapply_lxc_features }}" + state: started + register: openapply_lxc_status + + - name: Reconcile LXC NIC configuration via Proxmox API + when: openapply_use_proxmox_nic | bool + block: + - name: Ensure net0 configuration through proxmox_nic + community.proxmox.proxmox_nic: + api_host: "{{ openapply_pve_api_host }}" + api_user: "{{ openapply_pve_api_user }}" + api_token_id: "{{ openapply_pve_api_token_id }}" + api_token_secret: "{{ openapply_pve_api_token_secret }}" + validate_certs: "{{ openapply_proxmox_validate_certs }}" + vmid: "{{ openapply_lxc_vmid }}" + name: "{{ openapply_lxc_hostname }}" + interface: net0 + bridge: "{{ openapply_lxc_bridge }}" + firewall: "{{ openapply_lxc_nic_firewall }}" + state: present + rescue: + - name: Continue when proxmox_nic is unsupported for this target + ansible.builtin.debug: + msg: >- + proxmox_nic could not be applied to vmid {{ openapply_lxc_vmid }}; + continuing with proxmox container network configuration only. + + - name: Add OpenApply LXC to runtime inventory + ansible.builtin.add_host: + name: "{{ openapply_lxc_hostname }}" + ansible_host: "{{ openapply_lxc_management_ip }}" + ansible_user: "{{ openapply_lxc_ssh_user }}" + ansible_port: "{{ openapply_lxc_ssh_port }}" + ansible_python_interpreter: /usr/bin/python3 + groups: lxc_guests + + - name: Display provisioning summary + ansible.builtin.debug: + msg: + - "LXC hostname: {{ openapply_lxc_hostname }}" + - "LXC management IP: {{ openapply_lxc_management_ip }}" + - "LXC vmid: {{ openapply_lxc_vmid }}" + - "LXC changed: {{ openapply_lxc_status.changed | default(false) }}" + +- name: Configure OpenApply application inside guest + hosts: lxc_guests + gather_facts: true + become: true + + pre_tasks: + - name: Wait for SSH connectivity to LXC guest + ansible.builtin.wait_for_connection: + timeout: 300 + + roles: + - role: openapply_app diff --git a/ansible/requirements.yml b/ansible/requirements.yml index 805adcc..322245b 100644 --- a/ansible/requirements.yml +++ b/ansible/requirements.yml @@ -8,8 +8,14 @@ # Last updated: 2026-01-10 collections: + # Community Proxmox Collection + # Used for: proxmox lifecycle, kvm, and nic management modules + # Docs: https://docs.ansible.com/ansible/latest/collections/community/proxmox/ + - name: community.proxmox + version: ">=1.3.0" + # Community General Collection - # Used for: proxmox modules, docker modules, general utilities + # Used for: docker modules and general utilities # Docs: https://docs.ansible.com/ansible/latest/collections/community/general/ - name: community.general version: ">=8.0.0" diff --git a/ansible/roles/openapply_app/defaults/main.yml b/ansible/roles/openapply_app/defaults/main.yml new file mode 100644 index 0000000..2e738e9 --- /dev/null +++ b/ansible/roles/openapply_app/defaults/main.yml @@ -0,0 +1,39 @@ +--- +openapply_app_repo_url: https://github.com/sergeykhval/openapply.git +openapply_app_repo_version: main + +openapply_app_service_name: openapply +openapply_app_service_user: openapply +openapply_app_service_group: openapply + +openapply_app_root: /opt/openapply +openapply_app_service_port: 80 + +openapply_app_node_major: "20" +openapply_app_install_firebase_cli: true + +openapply_app_enable_firewall: true +openapply_app_allowed_tcp_ports: + - 22 + - 80 + - 443 + +openapply_app_build_subdirs: + - astro + - spa + +openapply_app_force_rebuild: false +openapply_app_git_ssh_key_file: "" + +openapply_app_start_command: >- + pnpm --dir {{ openapply_app_root }}/astro preview --host 0.0.0.0 --port {{ openapply_app_service_port }} + +openapply_app_env: + NODE_ENV: production + PORT: "{{ openapply_app_service_port }}" + +openapply_app_firebase_token: "{{ vault_openapply_firebase_token | default('') }}" +openapply_app_verify_status_codes: + - 200 + - 301 + - 302 diff --git a/ansible/roles/openapply_app/handlers/main.yml b/ansible/roles/openapply_app/handlers/main.yml new file mode 100644 index 0000000..3114924 --- /dev/null +++ b/ansible/roles/openapply_app/handlers/main.yml @@ -0,0 +1,9 @@ +--- +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true + +- name: Restart OpenApply service + ansible.builtin.systemd: + name: "{{ openapply_app_service_name }}" + state: restarted diff --git a/ansible/roles/openapply_app/tasks/deploy_code.yml b/ansible/roles/openapply_app/tasks/deploy_code.yml new file mode 100644 index 0000000..95aaf7c --- /dev/null +++ b/ansible/roles/openapply_app/tasks/deploy_code.yml @@ -0,0 +1,94 @@ +--- +- name: Ensure OpenApply group exists + ansible.builtin.group: + name: "{{ openapply_app_service_group }}" + state: present + +- name: Ensure OpenApply service user exists + ansible.builtin.user: + name: "{{ openapply_app_service_user }}" + group: "{{ openapply_app_service_group }}" + shell: /usr/sbin/nologin + create_home: false + system: true + state: present + +- name: Ensure OpenApply root directory exists + ansible.builtin.file: + path: "{{ openapply_app_root }}" + state: directory + owner: "{{ openapply_app_service_user }}" + group: "{{ openapply_app_service_group }}" + mode: "0755" + +- name: Render OpenApply environment file + ansible.builtin.template: + src: openapply.env.j2 + dest: "{{ openapply_app_root }}/.env" + owner: "{{ openapply_app_service_user }}" + group: "{{ openapply_app_service_group }}" + mode: "0640" + +- name: Sync OpenApply source code + ansible.builtin.git: + repo: "{{ openapply_app_repo_url }}" + version: "{{ openapply_app_repo_version }}" + dest: "{{ openapply_app_root }}" + update: true + force: false + accept_hostkey: true + key_file: "{{ openapply_app_git_ssh_key_file | default(omit, true) }}" + become: true + become_user: "{{ openapply_app_service_user }}" + register: openapply_repo_checkout + +- name: Check node_modules presence + ansible.builtin.stat: + path: "{{ openapply_app_root }}/node_modules" + register: openapply_node_modules + +- name: Install OpenApply dependencies via pnpm + ansible.builtin.command: + cmd: pnpm install --frozen-lockfile + chdir: "{{ openapply_app_root }}" + become: true + become_user: "{{ openapply_app_service_user }}" + when: + - openapply_repo_checkout.changed or openapply_app_force_rebuild | bool or not openapply_node_modules.stat.exists + changed_when: true + +- name: Check which build directories exist + ansible.builtin.stat: + path: "{{ openapply_app_root }}/{{ item }}" + register: openapply_build_dir_stats + loop: "{{ openapply_app_build_subdirs }}" + +- name: Build OpenApply subprojects + ansible.builtin.command: + cmd: pnpm run build + chdir: "{{ openapply_app_root }}/{{ item.item }}" + become: true + become_user: "{{ openapply_app_service_user }}" + loop: "{{ openapply_build_dir_stats.results }}" + when: + - item.stat.exists + - openapply_repo_checkout.changed or openapply_app_force_rebuild | bool + changed_when: true + +- name: Install OpenApply systemd unit + ansible.builtin.template: + src: openapply.service.j2 + dest: "/etc/systemd/system/{{ openapply_app_service_name }}.service" + owner: root + group: root + mode: "0644" + notify: + - Reload systemd + - Restart OpenApply service + +- name: Ensure OpenApply service is enabled and started + ansible.builtin.systemd: + name: "{{ openapply_app_service_name }}" + enabled: true + state: started + daemon_reload: true diff --git a/ansible/roles/openapply_app/tasks/main.yml b/ansible/roles/openapply_app/tasks/main.yml new file mode 100644 index 0000000..7d28286 --- /dev/null +++ b/ansible/roles/openapply_app/tasks/main.yml @@ -0,0 +1,15 @@ +--- +- name: Validate role input + ansible.builtin.import_tasks: validate.yml + +- name: Install base prerequisites + ansible.builtin.import_tasks: prereqs.yml + +- name: Configure runtime dependencies + ansible.builtin.import_tasks: setup_env.yml + +- name: Deploy and build OpenApply + ansible.builtin.import_tasks: deploy_code.yml + +- name: Verify running service + ansible.builtin.import_tasks: verify.yml diff --git a/ansible/roles/openapply_app/tasks/prereqs.yml b/ansible/roles/openapply_app/tasks/prereqs.yml new file mode 100644 index 0000000..81f6a4c --- /dev/null +++ b/ansible/roles/openapply_app/tasks/prereqs.yml @@ -0,0 +1,37 @@ +--- +- name: Install OpenApply prerequisite packages + ansible.builtin.apt: + name: + - ca-certificates + - curl + - git + - gnupg + - ufw + - build-essential + state: present + update_cache: true + cache_valid_time: 3600 + +- name: Configure UFW for production web access + when: openapply_app_enable_firewall | bool + block: + - name: Set default incoming firewall policy + community.general.ufw: + direction: incoming + default: deny + + - name: Set default outgoing firewall policy + community.general.ufw: + direction: outgoing + default: allow + + - name: Allow required TCP ports + community.general.ufw: + rule: allow + port: "{{ item }}" + proto: tcp + loop: "{{ openapply_app_allowed_tcp_ports }}" + + - name: Enable UFW + community.general.ufw: + state: enabled diff --git a/ansible/roles/openapply_app/tasks/setup_env.yml b/ansible/roles/openapply_app/tasks/setup_env.yml new file mode 100644 index 0000000..575c0ad --- /dev/null +++ b/ansible/roles/openapply_app/tasks/setup_env.yml @@ -0,0 +1,42 @@ +--- +- name: Ensure apt keyrings directory exists + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + +- name: Download NodeSource signing key + ansible.builtin.get_url: + url: https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key + dest: /tmp/nodesource.gpg.key + mode: "0644" + +- name: Install NodeSource keyring + ansible.builtin.command: + cmd: gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg /tmp/nodesource.gpg.key + creates: /etc/apt/keyrings/nodesource.gpg + +- name: Add NodeSource apt repository + ansible.builtin.apt_repository: + repo: "{{ openapply_app_nodesource_repo_map[ansible_os_family] }}" + filename: nodesource + state: present + +- name: Install Node.js runtime + ansible.builtin.apt: + name: nodejs + state: present + update_cache: true + +- name: Install pnpm globally + community.general.npm: + name: pnpm + global: true + state: present + +- name: Install Firebase CLI globally + community.general.npm: + name: firebase-tools + global: true + state: present + when: openapply_app_install_firebase_cli | bool diff --git a/ansible/roles/openapply_app/tasks/validate.yml b/ansible/roles/openapply_app/tasks/validate.yml new file mode 100644 index 0000000..78f5248 --- /dev/null +++ b/ansible/roles/openapply_app/tasks/validate.yml @@ -0,0 +1,17 @@ +--- +- name: Ensure guest OS family is supported + ansible.builtin.assert: + that: + - ansible_os_family == "Debian" + fail_msg: "openapply_app currently supports Debian-family guests (Ubuntu 22.04/24.04)." + +- name: Ensure required role variables are present + ansible.builtin.assert: + that: + - openapply_app_repo_url | length > 0 + - openapply_app_repo_version | length > 0 + - openapply_app_root | length > 0 + - openapply_app_service_name | length > 0 + - openapply_app_service_user | length > 0 + - openapply_app_start_command | length > 0 + fail_msg: "Required OpenApply role variables are missing." diff --git a/ansible/roles/openapply_app/tasks/verify.yml b/ansible/roles/openapply_app/tasks/verify.yml new file mode 100644 index 0000000..add2209 --- /dev/null +++ b/ansible/roles/openapply_app/tasks/verify.yml @@ -0,0 +1,24 @@ +--- +- name: Gather service facts + ansible.builtin.service_facts: + +- name: Assert OpenApply service is running + ansible.builtin.assert: + that: + - "ansible_facts.services[openapply_app_service_name + '.service'] is defined" + - "ansible_facts.services[openapply_app_service_name + '.service'].state == 'running'" + fail_msg: "OpenApply systemd service is not running." + +- name: Wait for OpenApply port to become reachable + ansible.builtin.wait_for: + host: 127.0.0.1 + port: "{{ openapply_app_service_port }}" + timeout: 120 + +- name: Validate HTTP response from OpenApply endpoint + ansible.builtin.uri: + url: "http://127.0.0.1:{{ openapply_app_service_port }}/" + method: GET + status_code: "{{ openapply_app_verify_status_codes }}" + register: openapply_http_probe + changed_when: false diff --git a/ansible/roles/openapply_app/templates/openapply.env.j2 b/ansible/roles/openapply_app/templates/openapply.env.j2 new file mode 100644 index 0000000..aaa2d24 --- /dev/null +++ b/ansible/roles/openapply_app/templates/openapply.env.j2 @@ -0,0 +1,6 @@ +{% for key, value in openapply_app_env.items() %} +{{ key }}={{ value }} +{% endfor %} +{% if openapply_app_firebase_token | length > 0 %} +FIREBASE_TOKEN={{ openapply_app_firebase_token }} +{% endif %} diff --git a/ansible/roles/openapply_app/templates/openapply.service.j2 b/ansible/roles/openapply_app/templates/openapply.service.j2 new file mode 100644 index 0000000..562f8ac --- /dev/null +++ b/ansible/roles/openapply_app/templates/openapply.service.j2 @@ -0,0 +1,17 @@ +[Unit] +Description=OpenApply web service +Wants=network-online.target +After=network-online.target + +[Service] +Type=simple +User={{ openapply_app_service_user }} +Group={{ openapply_app_service_group }} +WorkingDirectory={{ openapply_app_root }} +EnvironmentFile={{ openapply_app_root }}/.env +ExecStart={{ openapply_app_start_command }} +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/openapply_app/vars/main.yml b/ansible/roles/openapply_app/vars/main.yml new file mode 100644 index 0000000..53f96f8 --- /dev/null +++ b/ansible/roles/openapply_app/vars/main.yml @@ -0,0 +1,3 @@ +--- +openapply_app_nodesource_repo_map: + Debian: "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_{{ openapply_app_node_major }}.x nodistro main"