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
### 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
238 lines
10 KiB
YAML
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
|