Some checks failed
CI / lint-and-test (pull_request) Successful in 1m21s
CI / ansible-validation (pull_request) Successful in 9m3s
CI / secret-scanning (pull_request) Successful in 3m19s
CI / dependency-scan (pull_request) Successful in 7m13s
CI / sast-scan (pull_request) Successful in 6m38s
CI / license-check (pull_request) Successful in 1m16s
CI / vault-check (pull_request) Failing after 6m40s
CI / playbook-test (pull_request) Successful in 9m28s
CI / container-scan (pull_request) Successful in 7m59s
CI / sonar-analysis (pull_request) Failing after 1m11s
CI / workflow-summary (pull_request) Successful in 1m11s
- 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
235 lines
10 KiB
YAML
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 }}" |