声明式管理 Proxmox API 对象与 Samba DNS 上游
yaml / yaml (pull_request) Failing after 25s
terraform / validate (pull_request) Successful in 51s
ansible / collection-test (pull_request) Successful in 1m27s
ansible / lint (pull_request) Successful in 3m45s

This commit is contained in:
2026-09-17 13:22:50 +00:00
parent 71eea7d8fc
commit 2e05b1a96a
15 changed files with 277 additions and 106 deletions
@@ -1,4 +1,13 @@
---
# Shared authentication for typed community.proxmox API modules. Secrets are
# runtime inputs; never put them in inventory or vars files.
pve_api_host: "{{ lookup('env', 'PROXMOX_HOST') }}"
pve_api_user: "{{ lookup('env', 'PROXMOX_USER') }}"
pve_api_token_id: "{{ lookup('env', 'PROXMOX_TOKEN_ID') }}"
pve_api_token_secret: "{{ lookup('env', 'PROXMOX_TOKEN_SECRET') }}"
pve_api_validate_certs: true
pve_api_python_interpreter: "{{ lookup('env', 'PVE_API_PYTHON') | default(ansible_playbook_python, true) }}"
ansible_user: root
ansible_python_interpreter: /usr/bin/python3
@@ -19,20 +19,13 @@ all:
pve2:
ansible_host: 192.168.10.7
# ThinkCentre, Ryzen 2400GE (Raven Ridge), 8G. THIS is the node that
# randomly froze. Kernel workaround below; also set BIOS
# "Power Supply Idle Control" -> "Typical Current Idle" (the more
# reliable half of the fix, and it can only be done physically).
pve_kernel_cmdline_extra: "idle=nomwait processor.max_cstate=1"
# ThinkCentre, Ryzen 2400GE (Raven Ridge), 8G.
pve_install_disk: nvme0n1
pve_bulk_disk: sda
pve3:
ansible_host: 192.168.10.9
# Same Raven Ridge silicon as pve2 but has never frozen. Deliberately
# left WITHOUT the C-state workaround: processor.max_cstate=1 blocks
# deeper idle states and costs power, so it is not applied pre-emptively.
# If pve3 ever freezes, set pve_kernel_cmdline_extra here too.
# ThinkCentre, Ryzen 2400GE (Raven Ridge), 8G.
pve_install_disk: nvme0n1
pve_bulk_disk: sda
@@ -0,0 +1,2 @@
proxmoxer==2.3.0
requests==2.34.2
@@ -13,3 +13,5 @@ collections:
- name: ansible.posix # mount, authorized_key
- name: community.general # proxmox_kvm (guest lifecycle). NOTE: deb822_repository
# is ansible.builtin, NOT community.general — see CLAUDE.md
- name: community.proxmox
version: 2.0.0 # typed PVE API modules for HA, SDN and guest lifecycle
@@ -14,12 +14,26 @@ pve_ha_resources:
max_restart: 3
max_relocate: 2
# pve3 is the normal home for the critical router. Non-strict preserves pve1
# and pve2 as failover targets when pve3 is unavailable.
pve_ha_rules:
- name: prefer-vyos-pve3
type: node-affinity
resources: ["vm:100"]
nodes: ["pve3:100", "pve1:10", "pve2:10"]
strict: false
comment: "Prefer critical VyOS router on pve3, away from CI nodes"
pve_ha_portable_vms:
- vmid: 100
node: pve3
cpu: x86-64-v2-AES
# --- Watchdog ---------------------------------------------------------------
# ⚠ WHY THIS MATTERS: adding ANY HA resource arms fencing cluster-wide. PVE's
# default is `softdog`, a SOFTWARE watchdog — a kernel timer, which CANNOT fire if
# the kernel itself is frozen. That is precisely the failure this cluster has
# actually seen (pve2's Raven Ridge idle freeze). A hardware watchdog is
# independent silicon and fires regardless.
# the kernel itself is frozen. A hardware watchdog is independent silicon and
# fires regardless.
#
# Verified available 2026-07-26:
# pve1 (Intel i3-6100U) -> iTCO_wdt (timeout 30s)
@@ -2,7 +2,7 @@
# Deliberately does NOT reboot. Swapping the watchdog under an armed cluster is a
# manual, ordered operation (see ../../../README-ha.md); a surprise rolling reboot
# of all three nodes is exactly what you do not want an idempotent play to do.
- name: reboot required for watchdog
- name: Reboot required for watchdog
ansible.builtin.debug:
msg: >-
Watchdog config changed on {{ inventory_hostname }}. It takes effect on the
@@ -1,10 +1,19 @@
---
# Proxmox HA. Read roles/pve_ha/defaults/main.yml first — it explains why the
# resource list is short and why the watchdog choice is not cosmetic.
- name: Validate Proxmox API inputs
ansible.builtin.assert:
that:
- pve_api_host | length > 0
- pve_api_user | length > 0
- pve_api_token_id | length > 0
- pve_api_token_secret | length > 0
fail_msg: >-
Export PROXMOX_HOST, PROXMOX_USER, PROXMOX_TOKEN_ID and
PROXMOX_TOKEN_SECRET before running ha.yml.
run_once: true
delegate_to: localhost
no_log: true
tags: [ha]
# --- Watchdog ---------------------------------------------------------------
# Done BEFORE registering resources: adding a resource arms fencing, and arming
# fencing on a software watchdog is the weakest configuration.
- name: Select the hardware watchdog module
ansible.builtin.lineinfile:
path: /etc/default/pve-ha-manager
@@ -12,13 +21,9 @@
line: "WATCHDOG_MODULE={{ pve_ha_watchdog_module }}"
create: false
when: pve_ha_watchdog_module | length > 0
notify: reboot required for watchdog
notify: Reboot required for watchdog
tags: [ha, watchdog]
# watchdog-mux opens /dev/watchdog, which belongs to whichever watchdog registered
# FIRST. PVE loads softdog at boot, so it wins and the hardware module ends up as
# an unused watchdog1. Blacklisting softdog is what actually makes the hardware
# one take effect — setting WATCHDOG_MODULE alone does nothing.
- name: Blacklist softdog so the hardware watchdog claims /dev/watchdog
ansible.builtin.copy:
dest: /etc/modprobe.d/pve-ha-watchdog.conf
@@ -29,48 +34,147 @@
# {{ pve_ha_watchdog_module }} register as watchdog0 and own /dev/watchdog.
blacklist softdog
when: pve_ha_watchdog_module | length > 0
notify: reboot required for watchdog
notify: Reboot required for watchdog
tags: [ha, watchdog]
# ⚠ NOT DONE LIVE ON PURPOSE. Swapping the watchdog on a running node means
# stopping watchdog-mux and unloading softdog while fencing is armed — get the
# order wrong and the node self-fences (reboots). The safe live procedure is
# documented in ../../README-ha.md; otherwise it simply takes effect on the next
# reboot, which is why the handler only WARNS rather than rebooting anything.
# --- Resources ---------------------------------------------------------------
- name: Read current HA resources
ansible.builtin.command: ha-manager status
register: pve_ha_status
changed_when: false
failed_when: false
- name: Read current HA VM configurations
ansible.builtin.uri:
url: >-
https://{{ pve_api_host }}:8006/api2/json/nodes/{{ item.node }}/qemu/{{ item.vmid }}/config
headers:
Authorization: >-
PVEAPIToken={{ pve_api_user }}!{{ pve_api_token_id }}={{ pve_api_token_secret }}
ca_path: /etc/ssl/certs/ca-certificates.crt
return_content: true
loop: "{{ pve_ha_portable_vms }}"
loop_control:
label: "VM {{ item.vmid }}"
register: pve_ha_vm_config
run_once: true
delegate_to: localhost
no_log: true
tags: [ha]
- name: Register HA resources
ansible.builtin.command: >-
ha-manager add {{ item.sid }}
--state {{ item.state }}
--max_restart {{ item.max_restart }}
--max_relocate {{ item.max_relocate }}
- name: Reconcile portable CPU models for HA VMs
community.proxmox.proxmox_kvm:
api_host: "{{ pve_api_host }}"
api_user: "{{ pve_api_user }}"
api_token_id: "{{ pve_api_token_id }}"
api_token_secret: "{{ pve_api_token_secret }}"
validate_certs: "{{ pve_api_validate_certs }}"
ca_path: /etc/ssl/certs/ca-certificates.crt
node: "{{ item.node }}"
vmid: "{{ item.vmid }}"
cpu: "{{ item.cpu }}"
update: true
state: present
loop: "{{ pve_ha_portable_vms }}"
loop_control:
label: "VM {{ item.vmid }} -> {{ item.cpu }}"
index_var: vm_index
when: pve_ha_vm_config.results[vm_index].json.data.cpu != item.cpu
run_once: true
delegate_to: localhost
no_log: true
vars:
ansible_python_interpreter: "{{ pve_api_python_interpreter }}"
tags: [ha]
- name: Read current HA resources
ansible.builtin.uri:
url: "https://{{ pve_api_host }}:8006/api2/json/cluster/ha/resources"
headers:
Authorization: >-
PVEAPIToken={{ pve_api_user }}!{{ pve_api_token_id }}={{ pve_api_token_secret }}
ca_path: /etc/ssl/certs/ca-certificates.crt
return_content: true
register: pve_ha_resource_state
run_once: true
delegate_to: localhost
no_log: true
tags: [ha]
- name: Reconcile HA resources through the PVE API
community.proxmox.proxmox_cluster_ha_resources:
api_host: "{{ pve_api_host }}"
api_user: "{{ pve_api_user }}"
api_token_id: "{{ pve_api_token_id }}"
api_token_secret: "{{ pve_api_token_secret }}"
validate_certs: "{{ pve_api_validate_certs }}"
ca_path: /etc/ssl/certs/ca-certificates.crt
name: "{{ item.sid }}"
state: present
hastate: "{{ item.state }}"
max_restart: "{{ item.max_restart }}"
max_relocate: "{{ item.max_relocate }}"
loop: "{{ pve_ha_resources }}"
loop_control:
label: "{{ item.sid }}"
# `ha-manager add` errors if the resource already exists, so gate on the status
# output. This is what keeps a second run at changed=0.
when: "'service ' ~ item.sid not in (pve_ha_status.stdout | default(''))"
when: >-
_current_resource | length == 0 or
_current_resource.state != item.state or
_current_resource.max_restart | int != item.max_restart | int or
_current_resource.max_relocate | int != item.max_relocate | int
run_once: true
delegate_to: localhost
no_log: true
vars:
ansible_python_interpreter: "{{ pve_api_python_interpreter }}"
_current_resource: >-
{{ pve_ha_resource_state.json.data |
selectattr('sid', 'equalto', item.sid) | first | default({}) }}
tags: [ha]
- name: Verify HA is armed and the resource is known
ansible.builtin.command: ha-manager status
register: pve_ha_verify
changed_when: false
- name: Reconcile HA placement rules through the PVE API
community.proxmox.proxmox_cluster_ha_rules:
api_host: "{{ pve_api_host }}"
api_user: "{{ pve_api_user }}"
api_token_id: "{{ pve_api_token_id }}"
api_token_secret: "{{ pve_api_token_secret }}"
validate_certs: "{{ pve_api_validate_certs }}"
ca_path: /etc/ssl/certs/ca-certificates.crt
name: "{{ item.name }}"
state: present
type: "{{ item.type }}"
resources: "{{ item.resources }}"
nodes: "{{ item.nodes }}"
strict: "{{ item.strict }}"
comment: "{{ item.comment }}"
disable: false
loop: "{{ pve_ha_rules }}"
loop_control:
label: "{{ item.name }}"
run_once: true
delegate_to: localhost
no_log: true
vars:
ansible_python_interpreter: "{{ pve_api_python_interpreter }}"
tags: [ha]
- name: Show HA state
ansible.builtin.debug:
msg: "{{ pve_ha_verify.stdout_lines }}"
- name: Read HA placement rules through the PVE API
community.proxmox.proxmox_cluster_ha_rules_info:
api_host: "{{ pve_api_host }}"
api_user: "{{ pve_api_user }}"
api_token_id: "{{ pve_api_token_id }}"
api_token_secret: "{{ pve_api_token_secret }}"
validate_certs: "{{ pve_api_validate_certs }}"
ca_path: /etc/ssl/certs/ca-certificates.crt
register: pve_ha_rule_state
run_once: true
delegate_to: localhost
no_log: true
vars:
ansible_python_interpreter: "{{ pve_api_python_interpreter }}"
tags: [ha]
- name: Assert declared placement rules exist
ansible.builtin.assert:
that:
- >-
(pve_ha_rules | map(attribute='name') | list |
difference(pve_ha_rule_state.rules | default([]) |
map(attribute='rule') | list) | length) == 0
fail_msg: A declared HA placement rule is absent after reconciliation
run_once: true
delegate_to: localhost
tags: [ha]
@@ -1,7 +1,6 @@
---
# Per-host kernel cmdline additions, driven by `pve_kernel_cmdline_extra` in the
# inventory. Currently only pve2 sets it (Raven Ridge random-freeze workaround);
# pve3 is identical silicon but is deliberately left alone -- see inventory.
# inventory. No node currently sets it; keep the role for future kernel parameters.
#
# This is a no-op on hosts that do not define the variable.
@@ -1,60 +1,81 @@
---
# SDN config is cluster-wide (/etc/pve/sdn/), so everything runs once.
- name: Read existing zones
ansible.builtin.command:
cmd: "pvesh get /cluster/sdn/zones --output-format json"
register: _zones
changed_when: false
check_mode: false
- name: Validate Proxmox API inputs
ansible.builtin.assert:
that:
- pve_api_host | length > 0
- pve_api_user | length > 0
- pve_api_token_id | length > 0
- pve_api_token_secret | length > 0
fail_msg: Missing Proxmox API environment variables
run_once: true
delegate_to: localhost
no_log: true
- name: Create the VLAN zone
ansible.builtin.command:
cmd: >-
pvesh create /cluster/sdn/zones --zone {{ pve_sdn_zone }} --type vlan
--bridge {{ pve_sdn_bridge }} --mtu {{ pve_sdn_mtu }}
--nodes {{ groups['pve'] | join(',') }}
when: pve_sdn_zone not in (_zones.stdout | from_json | map(attribute='zone') | list)
- name: Read current PVE SDN zones
ansible.builtin.uri:
url: "https://{{ pve_api_host }}:8006/api2/json/cluster/sdn/zones"
headers:
Authorization: >-
PVEAPIToken={{ pve_api_user }}!{{ pve_api_token_id }}={{ pve_api_token_secret }}
ca_path: /etc/ssl/certs/ca-certificates.crt
return_content: true
register: pve_sdn_zone_state
run_once: true
delegate_to: localhost
no_log: true
- name: Read existing vnets
ansible.builtin.command:
cmd: "pvesh get /cluster/sdn/vnets --output-format json"
register: _vnets
changed_when: false
check_mode: false
- name: Reconcile the PVE VLAN zone
community.proxmox.proxmox_zone:
api_host: "{{ pve_api_host }}"
api_user: "{{ pve_api_user }}"
api_token_id: "{{ pve_api_token_id }}"
api_token_secret: "{{ pve_api_token_secret }}"
validate_certs: "{{ pve_api_validate_certs }}"
ca_path: /etc/ssl/certs/ca-certificates.crt
zone: "{{ pve_sdn_zone }}"
type: vlan
bridge: "{{ pve_sdn_bridge }}"
mtu: "{{ pve_sdn_mtu }}"
nodes: "{{ groups['pve'] | join(',') }}"
update: true
state: present
when: >-
_current_zone | length == 0 or
_current_zone.type != 'vlan' or
_current_zone.bridge != pve_sdn_bridge or
_current_zone.mtu | int != pve_sdn_mtu | int or
_current_zone.nodes != (groups['pve'] | join(','))
run_once: true
delegate_to: localhost
no_log: true
vars:
ansible_python_interpreter: "{{ pve_api_python_interpreter }}"
_current_zone: >-
{{ pve_sdn_zone_state.json.data |
selectattr('zone', 'equalto', pve_sdn_zone) | first | default({}) }}
- name: Create the VNets
ansible.builtin.command:
cmd: >-
pvesh create /cluster/sdn/vnets --vnet {{ item.name }}
--zone {{ pve_sdn_zone }} --tag {{ item.tag }}
--alias '{{ item.alias }}'
# The module owns the SDN global lock and applies pending configuration before
# releasing it; a separate unconditional `pvesh set /cluster/sdn` is harmful
# because it reports changed on every run.
- name: Reconcile PVE VNets
community.proxmox.proxmox_vnet:
api_host: "{{ pve_api_host }}"
api_user: "{{ pve_api_user }}"
api_token_id: "{{ pve_api_token_id }}"
api_token_secret: "{{ pve_api_token_secret }}"
validate_certs: "{{ pve_api_validate_certs }}"
ca_path: /etc/ssl/certs/ca-certificates.crt
vnet: "{{ item.name }}"
zone: "{{ pve_sdn_zone }}"
tag: "{{ item.tag }}"
alias: "{{ item.alias }}"
update: true
state: present
loop: "{{ pve_sdn_vnets }}"
loop_control:
label: "{{ item.name }} (vlan {{ item.tag }})"
when: item.name not in (_vnets.stdout | from_json | map(attribute='vnet') | list)
run_once: true
- name: Apply the SDN configuration
# SDN changes stay PENDING until applied; without this the VNet bridges are
# never actually created on the nodes.
ansible.builtin.command:
cmd: "pvesh set /cluster/sdn"
register: _apply
changed_when: true
run_once: true
- name: Report
ansible.builtin.shell:
cmd: "pvesh get /cluster/sdn/vnets --output-format json | python3 -c \"import json,sys;[print(' ',v['vnet'],'vlan',v.get('tag'),'zone',v.get('zone')) for v in json.load(sys.stdin)]\""
register: _rep
changed_when: false
run_once: true
- name: Show it
ansible.builtin.debug:
msg: "{{ _rep.stdout_lines }}"
label: "{{ item.name }} (VLAN {{ item.tag }})"
run_once: true
delegate_to: localhost
no_log: true
vars:
ansible_python_interpreter: "{{ pve_api_python_interpreter }}"
@@ -28,6 +28,9 @@ pve_vms:
description: "VyOS router: OSPF peer to the NEC IX, gateway for the SDN VNets."
cores: 2
memory: 2048
# Portable across Intel pve1 and AMD pve2/pve3. `host` transferred memory
# but failed target-side resume during the 2026-09-17 migration.
cpu: x86-64-v2-AES
disk: "pve-rg:8"
# Headless by design: UEFI + serial console, NO emulated VGA. VyOS's own
# kernel cmdline carries console=ttyS0, and the installer was answered with
@@ -56,6 +59,7 @@ pve_vms:
# are AMD.
cores: 4
memory: 4096
cpu: host
disk: "pve-rg:40"
bios: ovmf
machine: q35
@@ -40,7 +40,7 @@
--description '{{ item.item.description }}'
--machine {{ item.item.machine | default('q35') }}
--bios {{ item.item.bios | default('ovmf') }}
--memory {{ item.item.memory }} --cores {{ item.item.cores }} --cpu host
--memory {{ item.item.memory }} --cores {{ item.item.cores }} --cpu {{ item.item.cpu | default('x86-64-v2-AES') }}
--scsihw virtio-scsi-single
--scsi0 {{ item.item.disk }},discard=on,ssd=1
--efidisk0 {{ item.item.disk.split(':')[0] }}:1,efitype=4m,pre-enrolled-keys=0
@@ -0,0 +1,10 @@
---
- name: 更新现有 AD 域控 DNS 上游
hosts: samba_dc
become: true
gather_facts: false
tasks:
- name: 仅应用 DNS 转发器配置
ansible.builtin.import_role:
name: samba_ad_dc
tasks_from: dns_forwarder
@@ -7,7 +7,7 @@ samba_ad_realm: "AD.DDUPAN.TOP"
samba_ad_domain: "DDUPAN"
samba_ad_dc_hostname: "dc1"
samba_ad_dc_ip: "192.168.10.5"
samba_ad_dns_forwarder: "192.168.10.1"
samba_ad_dns_forwarder: "192.168.10.127" # Blocky:DN42 条件转发及公网解析
samba_ad_reverse_zone: "10.168.192.in-addr.arpa" # reverse of 192.168.10.0/24
# Static records now live in ../../dns/records.yml and are reconciled as complete
@@ -0,0 +1,10 @@
---
- name: 更新 Samba DNS 转发器并校验配置
ansible.builtin.lineinfile:
path: /etc/samba/smb.conf
regexp: '^\s*dns forwarder\s*='
insertafter: '^\[global\]'
line: "\tdns forwarder = {{ samba_ad_dns_forwarder }}"
validate: '/usr/bin/testparm -s %s'
backup: true
notify: restart samba-ad-dc
@@ -87,6 +87,9 @@
creates: /var/lib/samba/private/sam.ldb
no_log: true # keep the admin password out of logs
- name: 持续管理已初始化域控的 DNS 上游
ansible.builtin.import_tasks: dns_forwarder.yml
- name: Install the generated krb5.conf system-wide
ansible.builtin.copy:
src: /var/lib/samba/private/krb5.conf