--- # ------------------------------------------------------------------------------ # FILE: roles/llm-inference-multimodel/tasks/swapmode.yml # DESCRIPTION: Phase S — llama-swap mode hot-swap proxy (port 8001). # # This phase is ADDITIVE and IDEMPOTENT. The existing production # unit (llama-server-qwen, port 8002) is never touched here. # # All tasks are gated on llm_swapmode_enabled | default(false). # With the default (false) this entire file is a no-op. # # When llm_swapmode_enabled: true (set by host_vars or extra-vars), # this phase: # swapmode_binary — download + install binary # swapmode_config — template config.yaml # swapmode_systemd — deploy llama-swap.service unit # swapmode_firewall — open port 8001 to Hermes subnet # swapmode_verify — start service, run 4 validation gates # # Tags map 1:1 to the sub-phases for independent execution: # --tags swapmode_binary,swapmode_config,swapmode_systemd,swapmode_firewall,swapmode_verify # # IMPORTANT: swapmode_verify starts the service. Do not run # swapmode_verify unless swapmode_binary and swapmode_systemd # have already run. # # Added 2026-08-18 (t_c1e44190): llama-swap Phase 3 Ansible integration — Wong. # Approved by War Machine Phase 1 validation (3 of 4 hard gates PASS). # Phase 3 gated on all profiles migrated + production router decommissioned. # ------------------------------------------------------------------------------ # ============================================================================= # TAG: swapmode_binary # Download and install llama-swap binary from GitHub releases. # Idempotent: checks for existing binary and verifies architecture. # ============================================================================= - name: "[swapmode_binary] Detect host architecture (x86_64 / aarch64)" ansible.builtin.command: cmd: uname -m register: llm_swapmode_arch changed_when: false become: false when: llm_swapmode_enabled | default(false) tags: [swapmode_binary] - name: "[swapmode_binary] Ensure config directory exists" ansible.builtin.file: path: "{{ llm_swapmode_config_dir }}" state: directory owner: "{{ llm_swapmode_service_user }}" group: "{{ llm_swapmode_service_user }}" mode: "0755" become: true when: llm_swapmode_enabled | default(false) tags: [swapmode_binary] - name: "[swapmode_binary] Download llama-swap binary" ansible.builtin.get_url: url: "{{ llm_swapmode_binary_url }}" dest: "/tmp/llama-swap-{{ llm_swapmode_binary_version }}.tar.gz" checksum: "{{ llm_swapmode_checksum }}" mode: "0644" become: true register: llm_swapmode_download when: llm_swapmode_enabled | default(false) tags: [swapmode_binary] - name: "[swapmode_binary] Extract llama-swap binary" ansible.builtin.unarchive: src: "/tmp/llama-swap-{{ llm_swapmode_binary_version }}.tar.gz" dest: /tmp remote_src: true creates: /tmp/llama-swap become: true when: llm_swapmode_enabled | default(false) tags: [swapmode_binary] - name: "[swapmode_binary] Install llama-swap to /usr/local/bin" ansible.builtin.copy: src: /tmp/llama-swap dest: /usr/local/bin/llama-swap owner: root group: root mode: "0755" remote_src: true become: true register: llm_swapmode_binary_installed when: llm_swapmode_enabled | default(false) tags: [swapmode_binary] - name: "[swapmode_binary] Verify llama-swap binary is executable" ansible.builtin.command: cmd: /usr/local/bin/llama-swap --version register: llm_swapmode_version_check changed_when: false become: false when: llm_swapmode_enabled | default(false) tags: [swapmode_binary] - name: "[swapmode_binary] Cleanup download artifacts" ansible.builtin.file: path: "{{ item }}" state: absent become: true loop: - "/tmp/llama-swap-{{ llm_swapmode_binary_version }}.tar.gz" - /tmp/llama-swap when: llm_swapmode_enabled | default(false) tags: [swapmode_binary] # ============================================================================= # TAG: swapmode_config # Render config.yaml.j2 template and deploy to /etc/llama-swap/config.yaml # ============================================================================= - name: "[swapmode_config] Deploy llama-swap config.yaml from template" ansible.builtin.template: src: llama-swap-config.yaml.j2 dest: "{{ llm_swapmode_config_file }}" owner: "{{ llm_swapmode_service_user }}" group: "{{ llm_swapmode_service_user }}" mode: "0644" become: true register: llm_swapmode_config_deployed when: llm_swapmode_enabled | default(false) tags: [swapmode_config] - name: "[swapmode_config] Validate config.yaml syntax (YAML parse check)" ansible.builtin.command: cmd: python3 -c "import yaml; yaml.safe_load(open('{{ llm_swapmode_config_file }}'))" register: llm_swapmode_config_validate changed_when: false become: true when: llm_swapmode_enabled | default(false) tags: [swapmode_config] # ============================================================================= # TAG: swapmode_systemd # Deploy the llama-swap systemd unit file and reload systemd. # Does NOT start the service — that is swapmode_verify only. # ============================================================================= - name: "[swapmode_systemd] Deploy llama-swap systemd unit" ansible.builtin.template: src: llama-swap.service.j2 dest: "/etc/systemd/system/{{ llm_swapmode_service_name }}.service" owner: root group: root mode: "0644" become: true register: llm_swapmode_unit_deployed notify: - reload systemd when: llm_swapmode_enabled | default(false) tags: [swapmode_systemd] - name: "[swapmode_systemd] Flush handlers so daemon-reload lands before swapmode_verify starts the unit" ansible.builtin.meta: flush_handlers when: llm_swapmode_enabled | default(false) tags: [swapmode_systemd] # ============================================================================= # TAG: swapmode_firewall # Open port 8001 in ufw scoped to the Hermes source subnet. # Idempotent: named comment + state: present prevents duplicate rules. # ============================================================================= - name: "[swapmode_firewall] Check whether ufw is installed/active" ansible.builtin.command: cmd: ufw status register: llm_swapmode_ufw_status changed_when: false failed_when: false become: true when: llm_swapmode_enabled | default(false) tags: [swapmode_firewall] - name: "[swapmode_firewall] WARNING — ufw not active, port {{ llm_swapmode_port }} scoping cannot be applied" ansible.builtin.debug: msg: >- ufw does not appear to be active on this host. Firewall scoping for port {{ llm_swapmode_port }} was skipped. Bind address alone ({{ llm_swapmode_bind_address }}) limits exposure — flag to Ryan. when: - llm_swapmode_enabled | default(false) - "'Status: active' not in (llm_swapmode_ufw_status.stdout | default(''))" tags: [swapmode_firewall] - name: "[swapmode_firewall] Allow llama-swap port ({{ llm_swapmode_port }}) from Hermes source subnet" community.general.ufw: rule: allow port: "{{ llm_swapmode_port | string }}" proto: tcp src: "{{ llm_swapmode_allowed_source_cidr }}" comment: "llm-inference-multimodel: llama-swap ({{ llm_swapmode_port }}) — scoped to Hermes subnet" become: true when: - llm_swapmode_enabled | default(false) - "'Status: active' in (llm_swapmode_ufw_status.stdout | default(''))" tags: [swapmode_firewall] # ============================================================================= # TAG: swapmode_verify # Start the service, then run the 4 validation gates. # This is the ONLY phase that actually starts llama-swap. # ============================================================================= - name: "[swapmode_verify] Start llama-swap service" ansible.builtin.systemd: name: "{{ llm_swapmode_service_name }}" state: started enabled: true daemon_reload: true become: true when: llm_swapmode_enabled | default(false) tags: [swapmode_verify] # GATE 1: Health check - name: "[swapmode_verify] GATE 1 — Health check (/health endpoint)" ansible.builtin.uri: url: "http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/health" method: GET status_code: 200 register: llm_swapmode_health until: llm_swapmode_health.status == 200 retries: 30 delay: 2 become: false when: llm_swapmode_enabled | default(false) tags: [swapmode_verify] # GATE 2: Model discovery - name: "[swapmode_verify] GATE 2 — Model discovery (/v1/models)" ansible.builtin.uri: url: "http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/v1/models" method: GET status_code: 200 register: llm_swapmode_models_list become: false when: llm_swapmode_enabled | default(false) tags: [swapmode_verify] - name: "[swapmode_verify] Assert all 7 models are discoverable" ansible.builtin.assert: that: - llm_swapmode_models_list.json.data | map(attribute='id') | list | length == 7 fail_msg: >- Expected 7 models in /v1/models response, got {{ llm_swapmode_models_list.json.data | length }}. Models: {{ llm_swapmode_models_list.json.data | map(attribute='id') | list }} when: llm_swapmode_enabled | default(false) tags: [swapmode_verify] # GATE 3: Smoke test — simple completion on a CPU-offload model (no VRAM conflict) - name: "[swapmode_verify] GATE 3 — Smoke test completion (Meta-Llama-3.1-8B CPU-offload)" ansible.builtin.uri: url: "http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/v1/chat/completions" method: POST body_format: json body: model: "Meta-Llama-3.1-8B-Instruct-Q4_K_M" messages: - role: "user" content: "What is 2+2?" temperature: 0.1 max_tokens: 50 status_code: 200 register: llm_swapmode_smoke_test become: false when: llm_swapmode_enabled | default(false) tags: [swapmode_verify] # GATE 4: VRAM guard check - name: "[swapmode_verify] GATE 4 — VRAM usage check (must be < {{ llm_swapmode_vram_max_mib }} MiB)" ansible.builtin.shell: cmd: nvidia-smi --query-gpu=memory.used --format=csv,noheader,nounits | head -1 register: llm_swapmode_vram_used changed_when: false become: false when: llm_swapmode_enabled | default(false) tags: [swapmode_verify] - name: "[swapmode_verify] Assert VRAM usage is within budget" ansible.builtin.assert: that: - (llm_swapmode_vram_used.stdout | int) < llm_swapmode_vram_max_mib fail_msg: >- VRAM usage ({{ llm_swapmode_vram_used.stdout }} MiB) exceeds gate limit ({{ llm_swapmode_vram_max_mib }} MiB). Check for resource contention with production router or other services. when: llm_swapmode_enabled | default(false) tags: [swapmode_verify] # Display verification results - name: "[swapmode_verify] Display verification results" ansible.builtin.debug: msg: | ✓ GATE 1: Health check PASS ✓ GATE 2: Model discovery PASS — {{ llm_swapmode_models_list.json.data | map(attribute='id') | list | join(', ') }} ✓ GATE 3: Smoke test (Llama-3.1-8B) PASS ✓ GATE 4: VRAM guard ({{ llm_swapmode_vram_used.stdout }} MiB < {{ llm_swapmode_vram_max_mib }} MiB) PASS llama-swap service is ready at http://{{ llm_swapmode_bind_address }}:{{ llm_swapmode_port }}/ NOTE: 7 models registered (5 original + Qwen3-8B-Q4_K_M + Qwen3-8B-Q4_K_M-no_think). when: llm_swapmode_enabled | default(false) tags: [swapmode_verify]