ansible/playbooks/app/provision_one_env.yml
ilia c3e6caf9e8
All checks were successful
CI / skip-ci-check (push) Successful in 1m18s
CI / lint-and-test (push) Successful in 1m23s
CI / ansible-validation (push) Successful in 3m2s
CI / secret-scanning (push) Successful in 1m19s
CI / dependency-scan (push) Successful in 1m24s
CI / sast-scan (push) Successful in 2m32s
CI / license-check (push) Successful in 1m23s
CI / vault-check (push) Successful in 2m22s
CI / playbook-test (push) Successful in 2m25s
CI / container-scan (push) Successful in 1m51s
CI / sonar-analysis (push) Successful in 2m32s
CI / workflow-summary (push) Successful in 1m17s
refactor-servers-workstations-shell-monitoring (#4)
### Summary

This PR refactors the playbook layout to reduce duplication and make host intent clearer (servers vs workstations), splits monitoring by host type, and restores full Zsh setup for developers while keeping servers aliases-only.

### Key changes

- **New playbooks**
  - `playbooks/servers.yml`: baseline for server-class hosts (no desktop apps)
  - `playbooks/workstations.yml`: baseline for dev/desktop/local + **desktop apps only on `desktop` group**

- **Monitoring split**
  - `roles/monitoring_server`: server monitoring + intrusion prevention (includes `fail2ban`, sysstat)
  - `roles/monitoring_desktop`: desktop-oriented monitoring tooling
  - Updated playbooks to use the correct monitoring role per host type

- **Shell role: server-safe + developer-friendly**
  - `roles/shell` now supports two modes:
    - `shell_mode: minimal` (default): aliases-only, does not overwrite `.zshrc`
    - `shell_mode: full`: installs Oh My Zsh + Powerlevel10k + plugins and deploys a managed `.zshrc`
  - `playbooks/development.yml` and `playbooks/workstations.yml` use `shell_mode: full`
  - `playbooks/servers.yml` remains **aliases-only**

- **Applications**
  - Applications role runs only on `desktop` group (via `workstations.yml`)
  - Removed Brave installs/repo management
  - Added **CopyQ** to desktop apps (`applications_desktop_packages`)

- **Docs + architecture**
  - Added canonical doc tree under `project-docs/` (overview/architecture/standards/workflow/decisions)
  - Consolidated architecture docs: `docs/reference/architecture.md` is now a pointer to `project-docs/architecture.md`
  - Fixed broken doc links by adding the missing referenced pages under `docs/`

### Behavior changes (important)

- Desktop GUI apps install **only** on the `desktop` inventory group (not on servers, not on dev VMs unless they are in `desktop`).
- Dev/workstation Zsh is now provisioned in **full mode** (managed `.zshrc` + p10k).

### How to test (local CI parity)

```bash
make test
npm test
```

Optional dry runs (interactive sudo may be required):

```bash
make check
make check-local
```

### Rollout guidance

- Apply to a single host first:
  - Workstations: `make workstations HOST=<devhost>`
  - Servers: `make servers HOST=<serverhost>`
- Then expand to group runs.

Reviewed-on: #4
2026-01-01 22:11:24 -05:00

238 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 }}"
# EOF