playbooks/roles/vhosts/qmd/tasks/main.yml
Haitao Pan 6091b9dbcf fix(qmd): pin Homebrew node@24 for build and status on macOS
`qmd status` aborted with ERR_DLOPEN_FAILED — better-sqlite3 was compiled
against NODE_MODULE_VERSION 137 (node@24) but the validate-status task ran
under nvm's Node 20 (NODE_MODULE_VERSION 115), because the user's PATH puts
nvm node ahead of Homebrew and the task pinned no PATH.

Pin `/opt/homebrew/bin` (node@24) ahead of nvm on Darwin for the npm
install, npm build, and validate-status tasks so the native module is
built and loaded against one consistent Node ABI — the same node@24 the
launchd plist already uses. Linux PATH is left unchanged via an
ansible_os_family conditional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:43:05 +08:00

329 lines
11 KiB
YAML

---
- name: Assert QMD is supported on Debian or Darwin family
ansible.builtin.assert:
that:
- ansible_facts.os_family in ["Debian", "Darwin"]
fail_msg: "roles/vhosts/qmd currently supports Debian-based and Darwin hosts only."
- name: Ensure QMD config and cache directories exist
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
mode: "{{ item.mode }}"
loop:
- path: "{{ qmd_config_dir }}"
mode: "{{ qmd_config_dir_mode }}"
- path: "{{ qmd_cache_dir }}"
mode: "{{ qmd_cache_dir_mode }}"
- path: "{{ qmd_binary_path | dirname }}"
mode: "0755"
- path: "{{ qmd_mcp_service_unit_path | dirname }}"
mode: "0755"
- name: Resolve QMD service user uid
ansible.builtin.command:
cmd: "id -u {{ qmd_user }}"
register: qmd_service_uid_result
changed_when: false
when: ansible_os_family != 'Darwin'
- name: Set QMD service user uid
ansible.builtin.set_fact:
qmd_service_uid: "{{ qmd_service_uid_result.stdout | trim }}"
when: ansible_os_family != 'Darwin'
- name: Deploy QMD collection index config
ansible.builtin.template:
src: index.yml.j2
dest: "{{ qmd_index_config_path }}"
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
mode: "{{ qmd_index_config_mode }}"
- name: Deploy QMD external embedding environment
ansible.builtin.template:
src: qmd.env.j2
dest: "{{ qmd_env_path }}"
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
mode: "0600"
diff: false
- name: Inspect QMD binary
ansible.builtin.stat:
path: "{{ qmd_binary_path }}"
register: qmd_binary
- name: Ensure QMD source parent directory exists
ansible.builtin.file:
path: "{{ qmd_source_dir | dirname }}"
state: directory
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
mode: "0755"
- name: Validate packaged QMD runtime
ansible.builtin.stat:
path: "{{ qmd_runtime_archive }}"
register: qmd_runtime_archive_stat
when: qmd_runtime_archive | length > 0
- name: Require packaged QMD runtime
ansible.builtin.assert:
that:
- qmd_runtime_archive | length > 0
- qmd_runtime_archive_stat.stat.exists | default(false)
fail_msg: "A valid QMD_RUNTIME_ARCHIVE is required in prebuilt-only mode."
when: ai_workspace_prebuilt_components_required
- name: Inspect installed QMD runtime marker
ansible.builtin.slurp:
path: "{{ qmd_runtime_marker }}"
register: qmd_runtime_marker_content
failed_when: false
when: qmd_runtime_archive | length > 0
- name: Install packaged QMD runtime
ansible.builtin.unarchive:
src: "{{ qmd_runtime_archive }}"
dest: "{{ qmd_source_dir | dirname }}"
remote_src: true
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
when:
- qmd_runtime_archive | length > 0
- qmd_runtime_archive_stat.stat.exists | default(false)
- >-
(qmd_runtime_marker_content.content | default('') | b64decode | trim)
!= (qmd_runtime_archive_stat.stat.checksum | default(''))
or not ((qmd_source_dir ~ '/bin/qmd') is file)
- name: Record installed QMD runtime checksum
ansible.builtin.copy:
dest: "{{ qmd_runtime_marker }}"
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
mode: "0644"
content: "{{ qmd_runtime_archive_stat.stat.checksum }}\n"
when:
- qmd_runtime_archive | length > 0
- qmd_runtime_archive_stat.stat.exists | default(false)
- name: Checkout pinned QMD source
ansible.builtin.git:
repo: "{{ qmd_source_repo }}"
dest: "{{ qmd_source_dir }}"
version: "{{ qmd_version }}"
force: true
become: true
become_user: "{{ qmd_user }}"
register: qmd_source_checkout
when: qmd_runtime_archive | length == 0
- name: Install QMD source dependencies
ansible.builtin.command:
cmd: npm install
chdir: "{{ qmd_source_dir }}"
# Pin Homebrew node@24 ahead of any nvm/other node on Darwin so the native
# better-sqlite3 module is compiled against the same Node ABI that the
# launchd service and `qmd status` run with. Otherwise a host with nvm node
# first builds the module for the wrong NODE_MODULE_VERSION and qmd aborts
# with ERR_DLOPEN_FAILED. Linux PATH is left untouched.
environment:
PATH: "{{ '/opt/homebrew/bin:/usr/local/bin:' if ansible_os_family == 'Darwin' else '' }}{{ ansible_env.PATH }}"
become: true
become_user: "{{ qmd_user }}"
when:
- qmd_runtime_archive | length == 0
- qmd_source_checkout.changed | default(false) or not (qmd_binary.stat.exists | default(false))
- name: Build QMD from pinned source
ansible.builtin.command:
cmd: npm run build
chdir: "{{ qmd_source_dir }}"
environment:
PATH: "{{ '/opt/homebrew/bin:/usr/local/bin:' if ansible_os_family == 'Darwin' else '' }}{{ ansible_env.PATH }}"
become: true
become_user: "{{ qmd_user }}"
when:
- qmd_runtime_archive | length == 0
- qmd_source_checkout.changed | default(false) or not (qmd_binary.stat.exists | default(false))
- name: Link pinned QMD binary
ansible.builtin.file:
src: "{{ qmd_source_dir }}/bin/qmd"
dest: "{{ qmd_binary_path }}"
state: link
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
force: true
when:
- qmd_runtime_archive | length > 0 or qmd_source_checkout.changed | default(false) or not (qmd_binary.stat.exists | default(false))
- name: Reinspect QMD binary after source install
ansible.builtin.stat:
path: "{{ qmd_binary_path }}"
register: qmd_binary_after_source
- name: Install fallback QMD shim when QMD binary is absent
ansible.builtin.copy:
dest: "{{ qmd_binary_path }}"
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
mode: "0755"
content: |
#!/usr/bin/env python3
import argparse
import http.server
import json
import socketserver
import sys
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"status": "ok", "service": "qmd-fallback"}).encode())
def do_POST(self):
self.do_GET()
def log_message(self, *_):
return
def main():
if len(sys.argv) > 1 and sys.argv[1] == "status":
print("QMD fallback status: ok")
return
if len(sys.argv) > 1 and sys.argv[1] == "mcp":
parser = argparse.ArgumentParser()
parser.add_argument("mcp")
parser.add_argument("--http", action="store_true")
parser.add_argument("--port", type=int, default=8181)
args = parser.parse_args()
with socketserver.TCPServer(("127.0.0.1", args.port), Handler) as server:
server.serve_forever()
return
print("QMD fallback shim")
if __name__ == "__main__":
main()
when:
- not ai_workspace_prebuilt_components_required
- not (qmd_binary_after_source.stat.exists | default(false))
- name: Reinspect QMD binary
ansible.builtin.stat:
path: "{{ qmd_binary_path }}"
register: qmd_binary
- name: Fail when QMD binary is missing or not executable
ansible.builtin.assert:
that:
- qmd_binary.stat.exists | default(false)
- qmd_binary.stat.executable | default(false)
fail_msg: "QMD binary is missing or not executable: {{ qmd_binary_path }}"
- name: Deploy QMD MCP user systemd unit
ansible.builtin.template:
src: qmd-mcp.user.service.j2
dest: "{{ qmd_mcp_service_unit_path }}"
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
mode: "0644"
register: qmd_mcp_user_service_unit
when: ansible_os_family != 'Darwin'
- name: Enable QMD service user linger
ansible.builtin.command:
cmd: "loginctl enable-linger {{ qmd_user }}"
creates: "/var/lib/systemd/linger/{{ qmd_user }}"
when:
- not ansible_check_mode
- ansible_os_family != 'Darwin'
- name: Ensure QMD service user manager is running
ansible.builtin.systemd:
name: "user@{{ qmd_service_uid }}.service"
state: started
when:
- not ansible_check_mode
- ansible_os_family != 'Darwin'
- name: Reload QMD user systemd manager
ansible.builtin.command:
cmd: systemctl --user daemon-reload
environment:
HOME: "{{ qmd_home }}"
XDG_RUNTIME_DIR: "/run/user/{{ qmd_service_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ qmd_service_uid }}/bus"
become: true
become_user: "{{ qmd_user }}"
changed_when: false
when:
- not ansible_check_mode
- ansible_os_family != 'Darwin'
- name: Ensure QMD MCP daemon is enabled and running
ansible.builtin.command:
cmd: >-
systemctl --user enable
{{ '--now' if not (qmd_mcp_user_service_unit.changed | default(false)) else '' }}
{{ qmd_mcp_service_name }}.service
environment:
HOME: "{{ qmd_home }}"
XDG_RUNTIME_DIR: "/run/user/{{ qmd_service_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ qmd_service_uid }}/bus"
become: true
become_user: "{{ qmd_user }}"
register: qmd_mcp_service_enable
changed_when: >-
'Created symlink' in (qmd_mcp_service_enable.stdout | default('')) or
'Created symlink' in (qmd_mcp_service_enable.stderr | default(''))
when:
- not ansible_check_mode
- ansible_os_family != 'Darwin'
- name: Restart QMD MCP daemon after unit changes
ansible.builtin.command:
cmd: "systemctl --user restart {{ qmd_mcp_service_name }}.service"
environment:
HOME: "{{ qmd_home }}"
XDG_RUNTIME_DIR: "/run/user/{{ qmd_service_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ qmd_service_uid }}/bus"
become: true
become_user: "{{ qmd_user }}"
when:
- qmd_mcp_user_service_unit.changed | default(false)
- not ansible_check_mode
- ansible_os_family != 'Darwin'
- name: Import macOS specific QMD tasks
ansible.builtin.import_tasks: macos.yml
when: ansible_os_family == 'Darwin'
- name: Validate QMD status
ansible.builtin.command:
cmd: "{{ qmd_binary_path }} status"
# qmd's /bin/sh wrapper invokes `node`; on Darwin pin Homebrew node@24 first
# so it matches the ABI better-sqlite3 was built with (see npm tasks above),
# instead of falling through to an nvm node and failing ERR_DLOPEN_FAILED.
environment:
PATH: "{{ '/opt/homebrew/bin:/usr/local/bin:' if ansible_os_family == 'Darwin' else '' }}{{ ansible_env.PATH }}"
HOME: "{{ qmd_home }}"
QMD_EMBED_API_BASE_URL: "{{ qmd_embed_api_base_url }}"
QMD_EMBED_MODEL: "{{ qmd_embed_model }}"
become: true
become_user: "{{ qmd_user }}"
register: qmd_status
changed_when: false
check_mode: false
- name: Show QMD validation summary
ansible.builtin.debug:
msg:
- "QMD MCP URL: {{ qmd_mcp_url }}"
- "QMD index config: {{ qmd_index_config_path }}"
- "QMD embedding model: {{ qmd_embed_model }}"
- "{{ qmd_status.stdout | default('') }}"