ansible/playbooks/app/provision_one_env.yml
ilia f5e32afd81
Some checks failed
CI / lint-and-test (pull_request) Failing after 1m20s
CI / ansible-validation (pull_request) Successful in 6m40s
CI / secret-scanning (pull_request) Successful in 2m36s
CI / dependency-scan (pull_request) Successful in 6m12s
CI / sast-scan (pull_request) Successful in 6m48s
CI / license-check (pull_request) Successful in 1m16s
CI / vault-check (pull_request) Failing after 6m13s
CI / playbook-test (pull_request) Successful in 6m34s
CI / container-scan (pull_request) Successful in 6m57s
CI / sonar-analysis (pull_request) Failing after 1m10s
CI / workflow-summary (pull_request) Successful in 1m11s
Add POTE app project support and improve IP conflict detection
- Add roles/pote: Python/venv deployment role with PostgreSQL, cron jobs
- Add playbooks/app/: Proxmox app stack provisioning and configuration
- Add roles/app_setup: Generic app deployment role (Node.js/systemd)
- Add roles/base_os: Base OS hardening role
- Enhance roles/proxmox_vm: Split LXC/KVM tasks, improve error handling
- Add IP uniqueness validation: Preflight check for duplicate IPs within projects
- Add Proxmox-side IP conflict detection: Check existing LXC net0 configs
- Update inventories/production/group_vars/all/main.yml: Add pote project config
- Add vault.example.yml: Template for POTE secrets (git key, DB, SMTP)
- Update .gitignore: Exclude deploy keys, backup files, and other secrets
- Update documentation: README, role docs, execution flow guides

Security:
- All secrets stored in encrypted vault.yml (never committed in plaintext)
- Deploy keys excluded via .gitignore
- IP conflict guardrails prevent accidental duplicate IP assignments
2025-12-28 20:52:45 -05:00

235 lines
10 KiB
YAML

---
# Helper tasks file for playbooks/app/provision_vms.yml
# Provisions a single (project, env) guest and adds it to dynamic inventory.
- name: Set environment facts
ansible.builtin.set_fact:
env_name: "{{ env_item.key }}"
env_def: "{{ env_item.value }}"
guest_name: "{{ env_item.value.name | default(project_key ~ '-' ~ env_item.key) }}"
# vmid is optional; if omitted, we will manage idempotency by unique guest_name
guest_vmid: "{{ env_item.value.vmid | default(none) }}"
- name: Normalize recreate_existing_envs to a list
ansible.builtin.set_fact:
recreate_envs_list: >-
{{
(recreate_existing_envs.split(',') | map('trim') | list)
if (recreate_existing_envs is defined and recreate_existing_envs is string)
else (recreate_existing_envs | default([]))
}}
- name: Check if Proxmox guest already exists (by VMID when provided)
community.proxmox.proxmox_vm_info:
api_host: "{{ proxmox_host }}"
api_port: "{{ proxmox_api_port | default(8006) }}"
validate_certs: "{{ proxmox_validate_certs | default(false) }}"
api_user: "{{ proxmox_user }}"
api_password: "{{ vault_proxmox_password | default(omit) }}"
api_token_id: "{{ proxmox_token_id | default(omit, true) }}"
api_token_secret: "{{ vault_proxmox_token | default(omit, true) }}"
node: "{{ proxmox_node }}"
type: lxc
vmid: "{{ guest_vmid }}"
register: proxmox_guest_info_vmid
when: guest_vmid is not none
- name: Check if Proxmox guest already exists (by name when VMID omitted)
community.proxmox.proxmox_vm_info:
api_host: "{{ proxmox_host }}"
api_port: "{{ proxmox_api_port | default(8006) }}"
validate_certs: "{{ proxmox_validate_certs | default(false) }}"
api_user: "{{ proxmox_user }}"
api_password: "{{ vault_proxmox_password | default(omit) }}"
api_token_id: "{{ proxmox_token_id | default(omit, true) }}"
api_token_secret: "{{ vault_proxmox_token | default(omit, true) }}"
node: "{{ proxmox_node }}"
type: lxc
name: "{{ guest_name }}"
register: proxmox_guest_info_name
when: guest_vmid is none
- name: Set guest_exists fact
ansible.builtin.set_fact:
guest_exists: >-
{{
((proxmox_guest_info_vmid.proxmox_vms | default([])) | length > 0)
if (guest_vmid is not none)
else ((proxmox_guest_info_name.proxmox_vms | default([])) | length > 0)
}}
- name: "Guardrail: abort if VMID exists but name does not match (prevents overwriting other guests)"
ansible.builtin.fail:
msg: >-
Refusing to use VMID {{ guest_vmid }} for {{ guest_name }} because it already exists as
"{{ (proxmox_guest_info_vmid.proxmox_vms[0].name | default('UNKNOWN')) }}".
Pick a different vmid range in app_projects or omit vmid to auto-allocate.
when:
- guest_vmid is not none
- (proxmox_guest_info_vmid.proxmox_vms | default([])) | length > 0
- (proxmox_guest_info_vmid.proxmox_vms[0].name | default('')) != guest_name
- not (allow_vmid_collision | default(false) | bool)
- name: Delete existing guest if requested (recreate)
community.proxmox.proxmox:
api_host: "{{ proxmox_host }}"
api_port: "{{ proxmox_api_port | default(8006) }}"
validate_certs: "{{ proxmox_validate_certs | default(false) }}"
api_user: "{{ proxmox_user }}"
api_password: "{{ vault_proxmox_password | default(omit) }}"
api_token_id: "{{ proxmox_token_id | default(omit, true) }}"
api_token_secret: "{{ vault_proxmox_token | default(omit, true) }}"
node: "{{ proxmox_node }}"
vmid: "{{ guest_vmid }}"
purge: true
force: true
state: absent
when:
- guest_exists | bool
- guest_vmid is not none
- recreate_existing_guests | default(false) | bool or (env_name in recreate_envs_list)
- name: Mark guest as not existing after delete
ansible.builtin.set_fact:
guest_exists: false
when:
- guest_vmid is not none
- recreate_existing_guests | default(false) | bool or (env_name in recreate_envs_list)
- name: "Preflight: detect IP conflicts on Proxmox (existing LXC net0 ip=)"
community.proxmox.proxmox_vm_info:
api_host: "{{ proxmox_host }}"
api_port: "{{ proxmox_api_port | default(8006) }}"
validate_certs: "{{ proxmox_validate_certs | default(false) }}"
api_user: "{{ proxmox_user }}"
api_password: "{{ vault_proxmox_password | default(omit) }}"
api_token_id: "{{ proxmox_token_id | default(omit, true) }}"
api_token_secret: "{{ vault_proxmox_token | default(omit, true) }}"
node: "{{ proxmox_node }}"
type: lxc
config: current
register: proxmox_all_lxc
when:
- (env_def.ip | default('')) | length > 0
- not (allow_ip_conflicts | default(false) | bool)
- not (guest_exists | default(false) | bool)
- name: Set proxmox_ip_conflicts fact
ansible.builtin.set_fact:
proxmox_ip_conflicts: >-
{%- set conflicts = [] -%}
{%- set target_ip = ((env_def.ip | string).split('/')[0]) -%}
{%- for vm in (proxmox_all_lxc.proxmox_vms | default([])) -%}
{%- set cfg_net0 = (
vm['config']['net0']
if (
vm is mapping and ('config' in vm)
and (vm['config'] is mapping) and ('net0' in vm['config'])
)
else none
) -%}
{%- set vm_netif = (vm['netif'] if (vm is mapping and ('netif' in vm)) else none) -%}
{%- set net0 = (
cfg_net0
if (cfg_net0 is not none)
else (
vm_netif['net0']
if (vm_netif is mapping and ('net0' in vm_netif))
else (
vm_netif
if (vm_netif is string)
else (vm['net0'] if (vm is mapping and ('net0' in vm)) else '')
)
)
) | string -%}
{%- set vm_ip = (net0 | regex_search('(?:^|,)ip=([^,]+)', '\\1') | default('')) | regex_replace('/.*$', '') -%}
{%- if (vm_ip | length) > 0 and vm_ip == target_ip -%}
{%- set _ = conflicts.append({'vmid': (vm.vmid | default('') | string), 'name': (vm.name | default('') | string), 'net0': net0}) -%}
{%- endif -%}
{%- endfor -%}
{{ conflicts }}
when:
- proxmox_all_lxc is defined
- (env_def.ip | default('')) | length > 0
- not (allow_ip_conflicts | default(false) | bool)
- not (guest_exists | default(false) | bool)
- name: Abort if IP is already assigned to an existing Proxmox LXC
ansible.builtin.fail:
msg: >-
Refusing to provision {{ guest_name }} because IP {{ (env_def.ip | string).split('/')[0] }}
is already present in Proxmox LXC net0 config: {{ proxmox_ip_conflicts }}.
Fix app_projects IPs or set -e allow_ip_conflicts=true.
when:
- (env_def.ip | default('')) | length > 0
- not (allow_ip_conflicts | default(false) | bool)
- not (guest_exists | default(false) | bool)
- (proxmox_ip_conflicts | default([])) | length > 0
- name: "Preflight: fail if target IP responds (avoid accidental duplicate IP)"
ansible.builtin.command: "ping -c 1 -W 1 {{ (env_def.ip | string).split('/')[0] }}"
register: ip_ping
changed_when: false
failed_when: false
when:
- (env_def.ip | default('')) | length > 0
- not (allow_ip_conflicts | default(false) | bool)
- not (guest_exists | default(false) | bool)
- name: Abort if IP appears to be in use
ansible.builtin.fail:
msg: >-
Refusing to provision {{ guest_name }} because IP {{ (env_def.ip | string).split('/')[0] }}
responded to ping. Fix app_projects IPs or set -e allow_ip_conflicts=true.
Note: this guardrail is ping-based; if your network blocks ICMP, an in-use IP may not respond.
when:
- (env_def.ip | default('')) | length > 0
- not (allow_ip_conflicts | default(false) | bool)
- not (guest_exists | default(false) | bool)
- ip_ping.rc == 0
- name: Provision LXC guest for project/env
ansible.builtin.include_role:
name: proxmox_vm
vars:
# NOTE: Use hostvars['localhost'] for defaults to avoid recursive self-references
proxmox_guest_type: "{{ project_def.guest_defaults.guest_type | default(hostvars['localhost'].proxmox_guest_type | default('lxc')) }}"
# Only pass vmid when provided; otherwise Proxmox will auto-allocate
lxc_vmid: "{{ guest_vmid if guest_vmid is not none else omit }}"
lxc_hostname: "{{ guest_name }}"
lxc_ostemplate: "{{ project_def.lxc_ostemplate | default(hostvars['localhost'].lxc_ostemplate) }}"
lxc_storage: "{{ project_def.lxc_storage | default(hostvars['localhost'].lxc_storage) }}"
lxc_network_bridge: "{{ project_def.lxc_network_bridge | default(hostvars['localhost'].lxc_network_bridge) }}"
lxc_unprivileged: "{{ project_def.lxc_unprivileged | default(hostvars['localhost'].lxc_unprivileged) }}"
lxc_features_list: "{{ project_def.lxc_features_list | default(hostvars['localhost'].lxc_features_list) }}"
lxc_cores: "{{ project_def.guest_defaults.cores | default(hostvars['localhost'].lxc_cores) }}"
lxc_memory_mb: "{{ project_def.guest_defaults.memory_mb | default(hostvars['localhost'].lxc_memory_mb) }}"
lxc_swap_mb: "{{ project_def.guest_defaults.swap_mb | default(hostvars['localhost'].lxc_swap_mb) }}"
lxc_rootfs_size_gb: "{{ project_def.guest_defaults.rootfs_size_gb | default(hostvars['localhost'].lxc_rootfs_size_gb) }}"
lxc_ip: "{{ env_def.ip }}"
lxc_gateway: "{{ env_def.gateway }}"
lxc_nameserver: "{{ project_def.lxc_nameserver | default(hostvars['localhost'].lxc_nameserver) }}"
lxc_pubkey: "{{ appuser_ssh_public_key | default('') }}"
lxc_start_after_create: "{{ project_def.lxc_start_after_create | default(hostvars['localhost'].lxc_start_after_create) }}"
- name: Wait for SSH to become available
ansible.builtin.wait_for:
host: "{{ (env_def.ip | string).split('/')[0] }}"
port: 22
timeout: 300
when: (env_def.ip | default('')) | length > 0
- name: Add guest to dynamic inventory
ansible.builtin.add_host:
name: "{{ guest_name }}"
groups:
- "app_all"
- "app_{{ project_key }}_all"
- "app_{{ project_key }}_{{ env_name }}"
ansible_host: "{{ (env_def.ip | string).split('/')[0] }}"
ansible_user: root
app_project: "{{ project_key }}"
app_env: "{{ env_name }}"