37 Commits
Author SHA1 Message Date
JARVISandClaude Opus 5.5 78ac06378e multica_agent_host: op shim reads the service account token (WDWL-37)
Deliver vault_op_service_account_claude to the agent hosts and install a
role-owned ~multica/.local/bin/op shim that exports OP_SERVICE_ACCOUNT_TOKEN
to /usr/bin/op only. op run / op plugin run get env -u spliced after "--"
so the child never sees the token. The daemon unit is untouched (WDWL-18).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:36:23 -05:00
rblundon 0d674e0c4b update vault 2026-10-23 17:34:02 -05:00
rblundon de9e340151 added 1password token to vault 2026-10-23 17:15:03 -05:00
JARVISandClaude Opus 5.5 be7d4a4378 multica_agent_host: install the 1Password CLI from 1Password's signed repo (WDWL-37)
Agents get `op` so op:// references resolve at command time and secret
values stay out of prompts and transcripts. Installed per 1Password's
apt instructions (signed-by keyring, debsig policy + keyring,
1password-cli), with the key fingerprint-checked before it is trusted;
EL via their yum repo. multica_op_version pins, "" tracks the repo.
Tag: multica_op.

Optional, default-off hook writes OP_SERVICE_ACCOUNT_TOKEN from the
playbook (ansible-vault) to a 0600 file owned by the daemon user. It is
deliberately not wired into the daemon environment: the user unit
belongs to the multica CLI, and the delivery design is pending.

Rolled out with --tags multica_op to haunted-mansion and spaceship-earth;
second run changed=0, op 2.41.1 as multica, debsig verifies the .deb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 17:11:27 -05:00
JARVISandClaude Opus 5.5 01e6ad90f0 traefik: serve gitea on mad-tea-party.wdw-labs.cloud too, add SAN (WDWL-27)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:51:28 -05:00
JARVISandClaude Opus 5.5 008fd5bf7b gitea: template compose from role vars; bump to 28.2.0 / postgres 17.11 (WDWL-27)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 10:51:28 -05:00
JARVISandClaude Opus 5.5 fc2ad85e19 obsidian_headless: grant the service user on shared folders too (WDWL-22)
Content multica creates in inbox/ is owned multica:multica, so obsync fell
into "other" (---) and Sync could neither read nor upload Wong's
submissions. Found after the real Sync sign-in on spaceship-earth; the
earlier verify only tested the obsync -> multica direction.

Shared folders now carry user:obsync:rwx as an access and a default entry
alongside the shared user's, and verify tests the multica -> obsync
direction (read + rewrite, as merge needs). Second run changed=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 21:28:52 -05:00
JARVISandClaude Opus 5.5 2d039ae32f multica_agent_host: merge declared keys into the daemon user's Claude settings (WDWL-22)
The npm-installed Claude Code on spaceship-earth updated itself from the
pinned 2.1.304 to 2.1.308 right after its first login, so every play would
downgrade it and it would update again. New multica_claude_settings is
merged (recursively, not templated, since Claude writes the file too) into
~multica/.claude/settings.json.

provision-agent-host.yml sets DISABLE_AUTOUPDATER=1 and mirrors the model
mix already live on haunted-mansion: model sonnet, subagents haiku,
MAX_THINKING_TOKENS 4000, BASH_MAX_OUTPUT_LENGTH 20000, thinking off.

spaceship-earth: back on 2.1.304 and staying there; second run changed=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:38:00 -05:00
JARVISandClaude Opus 5.5 94c77daf13 multica_agent_host: daemon lifecycle via systemd; fix login guard (WDWL-22)
Found provisioning spaceship-earth:

- Login was not idempotent: `multica auth status` prints to stderr (0.6.5),
  and the guard read stdout only, so every run re-logged in and restarted
  the daemon. The guard now reads both streams; the status read is no_log
  because it echoes a token prefix.
- `multica daemon start` / `daemon restart` fork the daemon inside the
  caller's session (Ansible's SSH session scope), outside the systemd user
  unit, which stays inactive: the same orphan seen on haunted-mansion on
  2026-10-21. Start/restart now go through `systemctl --user`, after a
  daemon-reload when autostart was (re)registered. A daemon found running
  outside its unit is stopped first. The unit name is read from
  `autostart status`, so --profile hosts resolve their own unit.

spaceship-earth: second run changed=0, daemon in multica-daemon.service.
Not yet run against haunted-mansion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:04:46 -05:00
rblundon 61dba61025 Merge branch 'main' of gitea.mk-labs.cloud:rblundon/homelab 2026-10-22 16:00:51 -05:00
rblundon b73107be79 update vault 2026-10-22 15:59:59 -05:00
JARVISandClaude Opus 5.5 15aeb8e222 spaceship-earth: per-host Multica PAT, root LV growth, headless Obsidian (WDWL-22)
- provision-agent-host.yml: multica_pat now comes from
  vault_multica_pats[inventory_hostname_short], one daemon-<host> token per
  host. A pre_task asserts the key exists (never prints the value) so a
  missing entry fails by host name instead of as a no_log "censored".
- provision-wong-host.yml: spaceship-earth only. Grows the root partition,
  PV and LV/filesystem with growpart + community.general.lvg/lvol;
  idempotent (second run changed=0). Then applies obsidian_headless.
- roles/obsidian_headless: Obsidian's official headless Sync client
  (obsidian-headless 0.0.14) as system user obsync under a sandboxed
  systemd unit with UMask=0077. Vault at /srv/obsidian/vault: everpure/
  and wip/ obsync-only, inbox/ granted to multica via access + default
  ACLs, vault root traverse-only. Verifies the isolation as multica.
  Sign-in to Obsidian Sync stays manual (see the role README).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 15:41:54 -05:00
rblundon 04a0937910 inventory update 2026-10-22 12:32:18 -05:00
rblundon 01cd7b8d1e added multica agent hosts 2026-10-22 12:30:35 -05:00
rblundon 5559cd425d deploy claude with ansible 2026-10-21 11:50:53 -05:00
rblundon c396c89efb Merge branch 'main' of gitea.mk-labs.cloud:rblundon/homelab 2026-10-21 11:17:17 -05:00
rblundon 7450892a68 added claude role 2026-10-21 11:17:14 -05:00
rblundon 4f868d4f12 same for open-webui 2026-10-20 23:16:10 -05:00
rblundon f69199b429 update hindsight secret reference 2026-10-20 23:13:51 -05:00
rblundonandClaude Sonnet 5.5 ebe4681438 Add LiteLLM gateway (skyliner) via ArgoCD/Helm
- cluster/applications/litellm: multi-source Application (oci litellm-helm 1.106.1),
  values, ExternalSecrets (1Password wdw-labs), HTTPRoute, ExternalName services
  for fantasmic/wishes, namespace
- gateway: https-skyliner listener + skyliner.wdw-labs.cloud certificate,
  ReferenceGrant for litellm namespace
- cert-manager: add wdw-labs.cloud to ClusterIssuer dnsZones
- external-dns: add wdw-labs.cloud zone (Technitium RFC2136)
- onepassword-connect: add wdw-labs vault to ClusterSecretStore

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0118eUSMQitZMHN7qaCvynA3
2026-10-21 03:55:57 +00:00
rblundon b1ce761291 updated multica deployment 2026-10-20 14:23:27 -05:00
rblundon 39e2b87941 multica deployment 2026-10-20 13:41:11 -05:00
rblundon 63c5f7eed7 fix application deployment path 2026-10-06 14:28:59 -05:00
rblundon f2b4e2b93a switched to default project 2026-10-06 14:24:12 -05:00
rblundon 8b81c090f6 obsidian deployment 2026-10-06 13:49:13 -05:00
rblundon ffc8feddff added version to hindsight api 2026-10-01 22:09:09 -05:00
rblundon d1a3461dba Fix typo 2026-10-01 20:07:21 -05:00
rblundon db9face9f3 added hindsight worker ID 2026-10-01 20:06:11 -05:00
rblundon 8ef6dac5ad Upgrade Hindsight to version 0.10.2 2026-10-01 20:02:13 -05:00
rblundon 881e2e24a8 Add shared nginx-proxy role; deploy Hermes in Docker; rename Paperclip
nginx-proxy (new role):
- Shared nginx + certbot under /opt/nginx, replacing the copies that
  lived inside the Paperclip compose project
- Apps publish via tasks_from: add_vhost (cert marker -> issue cert ->
  write vhost -> nginx -t -> reload); a vhost that fails nginx -t is
  rolled back
- Certs issued one per domain via Cloudflare DNS challenge; domains
  registered as marker files in cert-domains/; optional LE staging
- Stock nginx.conf plus conf.d (directory mounts, not single-file
  mounts); catch-all server drops unknown Host/SNI
- Containers renamed to nginx and certbot

hermes_docker (new role):
- Official Hermes image, host networking, gateway and dashboard bound
  to loopback and published through nginx-proxy
- Dashboard login via basic-auth provider, secrets from the vault
- Main model set from hermes_model (fantasmic endpoint, key from vault);
  config.yaml overrides are merged from a 0600 staged file that is
  removed afterwards, so the key never appears in a command line
- Data dir owner-only (HERMES_HOME_MODE=0700)

paperclip-docker:
- Remove nginx, certbot and docker-compose download tasks; deploy with
  community.docker.docker_compose_v2 (idempotent, remove_orphans)
- Stop passing the Cloudflare token and certbot email to the Paperclip
  container via env_file
- Bind Postgres to 127.0.0.1 (host networking exposed it on all
  interfaces); stop chowning pgdata, which the postgres image owns
- Handler recreates the paperclip service so .env changes apply
- Domain m-v.wdw-labs.cloud -> cranium-command.wdw-labs.cloud

Playbooks:
- Add day1_deploy_nginx_proxy.yml and day1_deploy_hermes_docker.yml
- deploy_paperclip_stack.yml: run nginx-proxy before paperclip-docker,
  CLI-based container checks (no Docker SDK dependency)
- Remove deploy-hermes.yml

Host vars: add nginx_proxy_certbot_email and nginx_proxy_certbot_staging
(staging stays on until the switch to real certs).
2026-09-30 23:12:40 -05:00
rblundon 5b48536c32 Updated providers for paperclip and hindsight 2026-09-29 18:20:50 -05:00
rblundon d368c92055 feat: Build Paperclip from source instead of published image
- Create playbooks/build_paperclip_image.yml to build image locally from repo
- Clone/update paperclipai/paperclip main branch on deploy host
- Build with --target production for lean, current, self-managed image
- Update docker-compose to use paperclip-local:latest instead of reeoss image
- Gives full control over Paperclip version and dependency updates
- OpenCode v1.2.24 local adapter pre-installed in source build
- Enables rapid iteration and custom patches on Paperclip platform

Deployment flow:
1. ansible-playbook playbooks/build_paperclip_image.yml  # Build from source
2. ansible-playbook playbooks/deploy_paperclip_stack.yml # Deploy with local image
2026-09-27 20:13:02 -05:00
rblundon 6112b6daea feat: Add paperclip-docker Ansible role with OpenCode vLLM integration
- Create roles/paperclip-docker with full Docker Compose stack deployment
- Template OpenCode v1.2.24 config (provider structure) with Gemma-4-26B model
- Integrate vLLM endpoint (localhost:8000) with vault-injected API key auth
- Wire secrets from ansible vault: vllm_api_key, cloudflare_token, paperclip auth secrets
- Add pre-deployment checks (PostgreSQL, nginx, certbot cert management)
- Implement idempotent stack lifecycle (containers, volumes, networking)
- Deploy to RTX 3090 via muppet-vision.wdw-labs.cloud with nginx SSL termination

Paperclip now serves as CEO reasoning engine with local Gemma inference,
accessible to Hermes platform agents via OpenCode adapter on shared vLLM.
2026-09-27 19:39:08 -05:00
rblundon f701ad823f feat: Add paperclip-docker Ansible role for RTX 3090
Deploy Paperclip AI with PostgreSQL, nginx reverse proxy, and Cloudflare
SSL/TLS on illuminations.wdw-labs.cloud. Configured for local_trusted
mode (LAN-only, no auth) with host networking and auto-generated secrets.

Stack: PostgreSQL 16 + Paperclip + nginx + certbot
Hostname: muppet-vision.wdw-labs.cloud
vLLM endpoint: localhost:8000 (DeepSeek-R1-32B Q4)
2026-09-27 16:47:04 -05:00
Hermes Agent service account 8778a3bbb0 Add Paperclip AI deployment playbook 2026-09-25 17:45:11 -05:00
rblundon f864be59cf added inventory entry
Merge branch 'main' of gitea.mk-labs.cloud:rblundon/homelab
2026-09-25 12:59:16 -05:00
rblundon 23381f87e2 update inventory 2026-09-25 12:56:28 -05:00
150 changed files with 9587 additions and 434 deletions

No files matched your search

@@ -0,0 +1,702 @@
# Account Dossier Interactive Dashboard — Design Document
**Version:** 0.1 (MVP)
**Author:** Claude
**Date:** 2026-10-07
**Status:** Design Review (Pre-Prototype)
---
## 1. Overview & Purpose
**What:** Interactive HTML/React dashboard that surfaces account intelligence for a single Pure Storage customer or prospect. Provides a "10,000 ft view" with drill-down to ground-level detail.
**Why:** SEs need to move quickly from account snapshot → discovery prep → conversation context without context-switching between Sheets, notes, and browser tabs. One dashboard per customer becomes the source of reference for that account.
**MVP Scope:** The 10 essential questions (List 1 + List 2 from account intelligence framework)
**Future Scope:** Relationships, opportunities, financial modeling, industry trends, sentiment analysis, timeline views
---
## 2. User Personas & Use Cases
### Primary User
**Ryan** (You, Enterprise SE)
- Prepping for discovery calls
- Hunting for competitive displacement opportunities
- Planning expansion/renewal strategy
- Sharing account context with team (future)
### Use Cases (in priority order, all equally weighted for MVP)
| Use Case | What They Need | Where Dashboard Helps |
|----------|---|---|
| **Pre-call Prep (15 min before discovery)** | Quick account snapshot + key metrics + conversation hooks | Overview pane + drill-down to specific List items; see what changed since last call |
| **Competitive Displacement Hunting** | Incumbent vendors + footprint + gaps + consolidation leverage | List 2 drill-down showing Pure coverage % + high-value data not on Pure; see EMC systems aging |
| **Expansion/Renewal Planning** | Growth trajectory + budget cycle + stakeholder sentiment + upcoming initiatives | Opportunity tab (future) + timeline + stakeholder relationships; see refresh window + budget headroom |
### Secondary User (Future)
**Account team** (Sales, Solution Architect, Customer Success)
- Read-only view of account summary
- Understand strategic context before customer calls
---
## 3. Architecture Overview
### 3.1 Data Flow Diagram
```
┌─────────────────────────────────────────────────────────────┐
│ Per-Customer Data Sources (DRY: No Duplication) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 📊 Google Sheet (Structured Data) │
│ ├─ Account tab (List 1 metrics) │
│ ├─ Pure-Deployment tab (List 2 metrics) │
│ ├─ Relationships tab (Contacts, sentiment, history) │
│ ├─ Interactions tab (Call/email log with sentiment) │
│ ├─ Financials tab (ARR, contracts, opportunities) │
│ ├─ Industry-Trends tab (Curated insights) │
│ └─ Metadata tab (Sync status, ownership, sharing) │
│ │
│ 📝 Obsidian Markdown File (Unstructured Context) │
│ ├─ Discovery notes (narrative, conversation context) │
│ ├─ Strategy (account themes, expansion hooks) │
│ ├─ Relationship notes (person-by-person context) │
│ └─ Internal flags (competitive threats, risks) │
│ │
└─────────────────────────────────────────────────────────────┘
↓
(via API or manual sync)
↓
┌─────────────────────────────────────────────────────────────┐
│ Interactive Dashboard (HTML/React Artifact) │
│ Reads from both sources, displays in 3-pane layout │
│ No data copying; all reads from source │
└─────────────────────────────────────────────────────────────┘
↓
(displayed to user)
↓
┌─────────────────────────────────────────────────────────────┐
│ User Actions: │
│ • View account snapshot │
│ • Drill into List 1 / List 2 metrics │
│ • Check stakeholder sentiment │
│ • Review opportunity pipeline │
│ • Log new interaction │
│ • Open full Sheets / Obsidian for detailed editing │
└─────────────────────────────────────────────────────────────┘
```
---
## 4. UI/UX Design
### 4.1 Layout: 3-Pane Architecture
```
┌──────────────────────────────────────────────────────────────────────┐
│ HEADER: Account Name | Status | Vertical | Location | [Share] [Edit] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ LEFT PANE │ CENTER PANE │ RIGHT PANE │
│ (Overview) │ (Details & Drill-down) │ (Context) │
│ │ │ │
│ ┌─────────────────┐ │ ┌────────────────────┐ │ ┌────────────┐ │
│ │ Account Health │ │ │ Tab Bar: │ │ │ Latest │ │
│ │ Scorecard │ │ │ Overview | List1 │ │ │ Interactions │
│ │ ◆ 4 KPIs │ │ │ List2 | Oppty │ │ │ │ │
│ │ (visual) │ │ │ │ │ │ ┌────────┐ │ │
│ │ │ │ │ ┌──────────────┐ │ │ │ Sarah │ │ │
│ ├─────────────────┤ │ │ │ Item 1 Card │ │ │ │ Chen │ │ │
│ │ Key Dates │ │ │ │ [Summary] │ │ │ │ Call │ │ │
│ │ Timeline │ │ │ │ [10K ft] │ │ │ │ 2d ago │ │ │
│ │ • Refresh: 6mo │ │ │ │ [Status] │ │ │ │ ⭐⭐⭐ │ │ │
│ │ • Budget: 3mo │ │ │ └──────────────┘ │ │ │ (Drill) │ │ │
│ │ • Contract: 8mo │ │ │ │ │ │└────────┘ │ │
│ │ │ │ │ ┌──────────────┐ │ │ │ │ │
│ ├─────────────────┤ │ │ │ Item 2 Card │ │ │ ├──────────┤ │
│ │ Actions │ │ │ │ [Summary] │ │ │ │ [Log new]│ │
│ │ [QBR Prep] │ │ │ │ [10K ft] │ │ │ │ │ │
│ │ [Edit Account] │ │ │ │ [Status] │ │ │ │ Related │ │
│ │ [Open Sheets] │ │ │ └──────────────┘ │ │ │ Notes │ │
│ │ [View Notes] │ │ │ │ │ │ (from MD) │ │
│ │ [Share] │ │ │ .... (3 more) │ │ │ │ │
│ │ │ │ │ │ │ │ [View all]│ │
│ └─────────────────┘ │ └────────────────────┘ │ └────────────┘ │
│ │ │ │
└──────────────────────────────────────────────────────────────────────┘
```
### 4.2 Left Pane: Account Overview (10,000 ft)
**Purpose:** At a glance, is this account healthy? What's the most critical thing to know?
**Components:**
#### Health Scorecard (4 KPIs)
Visual status cards, each with:
- **Metric name** + current value
- **Trend indicator** (↑ up, ↓ down, → flat)
- **Status badge** (green / yellow / red)
- **Drillable** — click to zoom into that metric
**KPIs (MVP):**
1. **Pure Coverage** — % of total storage on Pure
- Value: 30% | Trend: ↑ +5% YoY | Status: 🟡 Opportunity
- Rationale: Shows expansion potential; top sales driver
2. **Fleet Age** — Average age of Pure systems
- Value: 4.2 yr | Trend: → flat | Status: 🟡 Refresh Window Approaching
- Rationale: Indicates refresh opportunity timing
3. **Expansion Opportunity Size** — $ value of consolidation potential
- Value: $3.5M | Trend: ↑ Growing | Status: 🟢 Pipeline-Ready
- Rationale: Deal size at a glance; guides priority
4. **Engagement Momentum** — Recency of last interaction + champion sentiment
- Value: 5 days | Trend: ↑ Active | Status: 🟢 Engaged
- Rationale: Is this account warm or cold?
#### Key Dates Timeline
Visual timeline of critical dates:
- **Refresh Date**: Next Pure system refresh
- **Budget Cycle**: When capex decisions happen
- **Contract Renewal**: When Pure contract is up
- **Next Review**: When you should reconnect
Format: Horizontal timeline or vertical list with "X days until" callouts.
#### Action Buttons (Quick nav)
- **QBR Prep** → Opens checklist template
- **Edit Account** → Opens Sheets in new tab
- **View Notes** → Opens Obsidian vault or embedded Markdown
- **Share** → (Future) generates shareable summary link
- **Log Interaction** → Quick-add form (bottom right pane)
---
### 4.3 Center Pane: Details & Drill-Down
**Purpose:** Zoom from 10K ft into List 1 and List 2 metrics. See details, trends, and rationale.
**Tab Structure:**
#### Tab 1: Overview (Summary of all 10 items)
Shows all 5 List 1 items + all 5 List 2 items as **cards in a grid**:
- Card layout: Title | 10K ft summary | Status badge | Click to drill
- Color-coded by item type (List 1 = blue accent, List 2 = green accent)
- Scanning-friendly; can see account at a glance
#### Tab 2: Account Profile (List 1 Deep Dive)
Five expandable cards, one per List 1 item:
**Card 1: Data Footprint & Workload Breakdown**
```
┌─ Data Footprint & Workload Breakdown ───────────────────┐
│ 10K FT VIEW: │
│ 500TB current | +18% YoY | 2yr forecast: 700TB │
│ Workloads: 40% OLTP | 35% Analytics | 25% Unstructured │
│ │
│ [CLICK TO EXPAND ▼] │
│ │
│ EXPANDED VIEW: │
│ ┌─ Breakdown Table ──────────────────────────────────┐ │
│ │ Tier │ Current │ Growth │ 2yr Forecast │ Note │
│ │ OLTP │ 200TB │ +15% │ 265TB │ TX DB │
│ │ Analytics │ 175TB │ +25% │ 275TB │ DW │
│ │ Unstructured│ 125TB │ +12% │ 160TB │ Lake │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Capacity Growth Chart ────────────────────────────┐ │
│ │ [Line chart: 3yr history + 2yr forecast] │ │
│ │ Shows growth trajectory, where Pure could capture │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ CONTEXT FROM NOTES: │
│ "Customer mentioned big analytics push for FY27..." │
│ │
│ [Related Links: 10-K | Analyst Report] │
└─────────────────────────────────────────────────────────┘
```
**Card 2: Current Storage Vendors & Installed Infrastructure**
```
┌─ Current Storage Vendors ──────────────────────────────┐
│ 10K FT VIEW: │
│ NetApp 50% | Pure 30% | Dell 20% (of 1200TB total) │
│ Consolidation Opportunity: HIGH │
│ │
│ [CLICK TO EXPAND ▼] │
│ │
│ EXPANDED VIEW: │
│ ┌─ Vendor Breakdown (Pie Chart) ─────────────────────┐ │
│ │ [Visual pie showing % by vendor] │ │
│ │ NetApp = oldest systems, refresh due │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Incumbent Systems Detail ────────────────────────┐ │
│ │ Vendor: EMC Symmetrix DMX-4 │ │
│ │ Capacity: 200TB | Age: 6 years | Status: AGING │ │
│ │ Use Case: Core banking OLTP │ │
│ │ 💡 Displacement Target: Move to Pure FlashArray │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ COMPETITIVE CONTEXT: │
│ "This EMC system is at end-of-life; customer risk- │
│ averse but willing to consolidate if migration is │
│ proven. Reference customer opportunity." │
│ │
│ [Related: Competitive Intel | Displacement Playbook] │
└──────────────────────────────────────────────────────────┘
```
**Cards 3-5:** Similar drill-down structure for Business Drivers, Architecture, Budget/Authority
#### Tab 3: Pure Deployment (List 2 Deep Dive)
Five cards showing Pure-specific metrics, one per List 2 item.
**Card 1: Current Pure Installed Base**
```
┌─ Current Pure Installed Base ──────────────────────────┐
│ 10K FT VIEW: │
│ 2 FlashArray systems (150TB) | Healthy | 4.2yr avg age │
│ Headroom: 52TB | Refresh window: 18mo │
│ │
│ [CLICK TO EXPAND ▼] │
│ │
│ EXPANDED VIEW: │
│ ┌─ Systems Inventory Table ──────────────────────────┐ │
│ │ System | Type | Capacity | Util | Headroom | Age │ │
│ │ FA-P1 | FA | 75TB | 65% | 26TB | 5.1yr │ │
│ │ FA-DR | FA | 75TB | 40% | 45TB | 3.3yr │ │
│ │ Refresh Needed │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ ┌─ Utilization & Headroom Heatmap ──────────────────┐ │
│ │ [Visual: System cards with fill % + headroom bar] │ │
│ │ FA-P1: ████████ 65% USED (26TB headroom) │ │
│ │ FA-DR: █████░░░ 40% USED (45TB headroom) │ │
│ └────────────────────────────────────────────────────┘ │
│ │
│ EXPANSION IMPLICATION: │
│ "FA-P1 will need refresh/capacity add in 18 months; │
│ FA-DR has headroom for growth without additional spend" │
│ │
│ [Related: Pure1 Dashboard | Refresh Planning Guide] │
└──────────────────────────────────────────────────────────┘
```
**Cards 2-5:** Performance/Satisfaction, Coverage Gaps, Refresh Cycles, Stakeholders
---
### 4.4 Right Pane: Unstructured Context & Interactions
**Purpose:** Connect quantitative data (left/center) to narrative context (right). See "why" behind the numbers.
**Components:**
#### Latest Interactions (Log)
Shows most recent 3-5 interactions with sentiment:
```
┌─ Interactions ────────────────────────────────────────┐
│ ⭐⭐⭐⭐⭐ Sarah Chen (VP Storage) │
│ Call — 2026-10-02 · 5 days ago │
│ "Discussed multi-site replication, pain with NetApp" │
│ Status: Strong Advocate for Pure adoption │
│ │
│ ⭐⭐⭐ Mike Lopez (CIO) │
│ Email — 2026-09-15 · 22 days ago │
│ "Budget constraints; interested in OpEx models" │
│ Status: Neutral, cost-focused │
│ │
│ ⭐⭐ Jennifer Park (CFO) │
│ Meeting — 2026-08-20 · 47 days ago │
│ "Positive on storage consolidation, capex tight" │
│ Status: Positive but budget-constrained │
│ │
│ [+ Log New Interaction] │
└───────────────────────────────────────────────────────┘
```
#### Related Notes (from Markdown)
Embedded snippet from Obsidian Markdown file:
```
┌─ Discovery Notes ──────────────────────────────────────┐
│ STRATEGY THEME: Consolidation play │
│ Customer is post-acquisition; consolidating 3 data │
│ centers into 2. Big opportunity to move from multi- │
│ vendor (EMC + NetApp) to Pure standardization. │
│ │
│ RISK: Customer risk-averse on primary storage. │
│ LEVER: Success story at reference customer + DR copy. │
│ │
│ NEXT STEP: Sarah is scheduled for Q4 arch review. │
│ Come with: Multi-site replication architecture, │
│ consolidation cost model. │
│ │
│ [View Full Notes] [Edit in Obsidian] │
└───────────────────────────────────────────────────────┘
```
#### Quick Actions
- **[Log Interaction]** → Popup form to add call/email/meeting
- **[View All Notes]** → Opens Obsidian file
- **[View Sheets]** → Opens Google Sheets
- **[Share Account]** → (Future) generates link
---
### 4.5 Responsive Behavior & Interactivity
#### Drill-Down Interactions
- **Cards expand in place** — no modal/new page; content slides down
- **Breadcrumb trail** — e.g., "Account > List 1 > Data Footprint" so you know where you are
- **Collapse others** — When one card expands, others collapse (reduces noise)
- **Smooth transitions** — Cards animate open/closed
#### Status Badges & Visual Indicators
| Status | Color | Meaning |
|--------|-------|---------|
| 🟢 Green | Good / On Track / Advocate | Positive signal |
| 🟡 Yellow | Caution / Aging / Neutral | Attention needed |
| 🔴 Red | Risk / Critical / Urgent | Action required |
| ⚪ Gray | Unknown / TBD | Need more info |
#### Data-Driven Highlights
Conditional visual emphasis on metrics:
- **Refresh window approaching** (>4yr age) → Highlighted in yellow
- **Coverage gap opportunity** (low % Pure) → Highlighted in orange
- **Budget cycle in next 90 days** → Underlined with "upcoming" tag
- **Strong champion sentiment** → Starbursts/badge next to name
---
## 5. Component Breakdown & Data Mapping
### 5.1 Component Hierarchy
```
DashboardApp
├── Header
│ ├── AccountName
│ ├── StatusBadge (Prospect/Customer)
│ ├── MetadataBar (Vertical, Location, Last Updated)
│ └── HeaderActions ([Share] [Edit] [Settings])
│
├── MainLayout (3-pane)
│ │
│ ├── LeftPane (Overview)
│ │ ├── HealthScorecard
│ │ │ ├── KPICard (Pure Coverage)
│ │ │ ├── KPICard (Fleet Age)
│ │ │ ├── KPICard (Opportunity Size)
│ │ │ └── KPICard (Engagement Momentum)
│ │ ├── TimelineComponent
│ │ │ ├── TimelineEvent (Refresh Date)
│ │ │ ├── TimelineEvent (Budget Cycle)
│ │ │ ├── TimelineEvent (Contract Renewal)
│ │ │ └── TimelineEvent (Next Review)
│ │ └── ActionButtons
│ │ ├── Button (QBR Prep)
│ │ ├── Button (Edit Account)
│ │ ├── Button (View Notes)
│ │ └── Button (Share)
│ │
│ ├── CenterPane (Details & Drill-Down)
│ │ ├── TabBar
│ │ │ ├── Tab (Overview)
│ │ │ ├── Tab (List 1: Account Profile)
│ │ │ ├── Tab (List 2: Pure Deployment)
│ │ │ └── Tab (Opportunities — future)
│ │ │
│ │ └── TabContent (varies by tab)
│ │ └── DetailsCard (repeating)
│ │ ├── CardHeader (title + status)
│ │ ├── CardSummary (10K ft view)
│ │ ├── ExpandButton
│ │ └── ExpandedContent (on click)
│ │ ├── DetailTable / Chart
│ │ ├── TrendVisualization
│ │ └── RelatedContext
│ │
│ └── RightPane (Context)
│ ├── InteractionLog
│ │ └── InteractionItem (repeating)
│ │ ├── Sentiment (stars)
│ │ ├── Contact Info
│ │ ├── Timestamp
│ │ └── Summary
│ ├── RelatedNotes
│ │ └── NotesSnippet
│ └── QuickActions
│ ├── Button (Log Interaction)
│ ├── Button (View All Notes)
│ └── Button (View Sheets)
```
### 5.2 Data Source Mapping
| Component | Data Source | Sheet Location | Notes |
|-----------|---|---|---|
| **Account Name** | Account tab | Name | |
| **Status Badges** | Account tab | Status, Vertical, Location | |
| **Health Scorecard** | Account + Pure-Deployment tabs | Multiple cells + formulas | Calculated KPIs |
| **Timeline** | Account tab | Columns: Next Refresh Date, Budget Cycle, Contract End, Next Review | |
| **List 1 Cards** | Account tab | Tabs: Item 1-5 data | Five expandable items |
| **List 2 Cards** | Pure-Deployment tab | Tabs: Item 1-5 data | Five expandable items |
| **Interaction Log** | Interactions tab | Contact, Type, Date, Sentiment, Summary | Sorted by date DESC |
| **Related Notes** | Markdown file (Obsidian) | Fetched via API or embed | Text snippet + link |
---
## 6. Visual Design Principles
### Color Palette (Themeable)
- **Accent Blue** (Primary): List 1 items, account overview
- **Accent Green** (Secondary): List 2 items, Pure deployment
- **Status Green** (Success): Healthy, positive, on-track
- **Status Yellow** (Warning): Approaching refresh, caution needed
- **Status Red** (Critical): Urgent action required
- **Neutral Gray**: Unknown, TBD, secondary content
- **Text Dark Gray** (Light mode) / **Text Light Gray** (Dark mode)
- **Background**: Light/dark mode adaptive
### Typography
- **Header**: Large, bold, account name stands out
- **Tab titles**: Medium weight, clear tab distinction
- **Card titles**: Semi-bold, clickable indicator (▼)
- **Data values**: Monospace for numbers (easier to scan)
- **Labels**: Smaller, muted color
### Spacing & Layout
- **Panes**: Equal width (33% each) or responsive flex at smaller widths
- **Cards**: Padding ~20px, margin ~16px between cards
- **Drill-down**: Animated 200ms expand/collapse
- **Mobile**: Stack panes vertically (future consideration)
---
## 7. Navigation & User Flows
### Flow 1: Pre-Call Prep (15 min before discovery)
```
User lands on dashboard
↓
Glances at Health Scorecard (30 sec) — "Pure coverage 30%, fleet aging, $3.5M upside"
↓
Checks Key Dates Timeline (30 sec) — "Budget cycle in 3 mo, refresh in 18 mo"
↓
Clicks "QBR Prep" action → Opens checklist template in new tab
↓
Scrolls to Center pane, clicks "Current Vendors" card to see EMC footprint
↓
Reads right pane: "Customer risk-averse on primary storage; use reference story"
↓
Click [Log Interaction] → Pre-fills with today's call, ready to document post-call
```
### Flow 2: Competitive Displacement Hunt
```
User is looking for displacement opportunities
↓
Opens Account dashboard
↓
Glances at Scorecard: "Pure coverage 30%" → Opportunity
↓
Clicks "Data Footprint" card → Sees workload breakdown
↓
Clicks "Current Vendors" card → Sees EMC system (200TB, 6yr old, primary OLTP)
↓
Clicks "Coverage Gaps" card → Confirms: "EMC Symmetrix not on Pure yet"
↓
Reads notes: "Customer risk-averse but willing to migrate if proven"
↓
Action: Find reference customer, prepare migration playbook for next call
```
### Flow 3: Expansion Planning
```
User reviewing account for renewal/expansion
↓
Glances at Scorecard: Fleet age, Opportunity size, Engagement momentum
↓
Clicks "Fleet Age" card → Sees FA-P1 refresh window in 18 months
↓
Clicks "Refresh Cycles" card → Confirms capex cycle and window
↓
Checks Stakeholder info in right pane → "Sarah (VP Storage) is champion, strong advocate"
↓
Clicks "Opportunities" tab (future) → See pipeline: Analytics consolidation (Q2 2027, $1.5M)
↓
Action: Schedule architecture review with Sarah for Q1; prepare proposal for analytics move
```
---
## 8. Data Sync & Freshness
### MVP Approach: Manual Sync
- **Sheet → Dashboard**: Copy-paste JSON export or Google Sheets API call (daily/on-demand)
- **Markdown → Dashboard**: Fetch from Obsidian API or GitHub if synced
- **Freshness**: Dashboard is "point-in-time snapshot" at time of load
- **Update frequency**: User refreshes page or manually syncs from edit screens
### Future: Live Sync
- Dashboard queries Google Sheets API in real-time
- Interactions logged directly to Sheets (no manual re-entry)
- Markdown fetched from Obsidian Sync API
- Auto-refresh KPIs every 5 minutes
---
## 9. Extensibility Points (Future Phases)
### Phase 2: Relationships & Interaction History
- **Tab 4: Relationships** — Org chart, sentiment timeline, interaction history
- **Interaction log** — Searchable, filterable by contact, type, sentiment
- **Sentiment analysis** — Chart showing mood trend over time
### Phase 3: Opportunities & Pipeline
- **Tab 5: Opportunities** — Consolidation roadmap, deal timeline, revenue forecast
- **Milestone tracking** — Countdown to RFP, contract close, etc.
### Phase 4: Financial Modeling
- **10-K integration** — Embed PDF link, key metrics extracted
- **Opportunity forecasting** — If analytics consolidates, revenue impact
- **Budget impact model** — Capex vs. OpEx trade-off calculator
### Phase 5: Industry & Competitive Context
- **Industry trends** — Curated insights relevant to account
- **Competitor tracking** — Win/loss intelligence, displacement playbooks
- **Market context** — Industry growth rates, regulatory changes
---
## 10. Technical Architecture
### Technology Stack
- **Frontend**: React (or vanilla JS if keeping simple)
- **Styling**: Tailwind CSS (or custom CSS-in-JS)
- **Data sources**:
- Google Sheets API (for Account, Pure-Deployment, Relationships, Financials tabs)
- Markdown file (from Obsidian or GitHub)
- **Deployment**: Claude artifact (publishable, shareable URL) or self-hosted on homelab
- **Build**: Vite (fast dev) or Create React App
### API Integration (MVP)
```
Frontend (React)
↓
├─→ Google Sheets API v4
│ └─ Fetch: Account!A:Z, PureDeployment!A:Z, Relationships!A:Z, etc.
│ Rate: On-demand or 5-min cache
│
└─→ Markdown fetch (via Obsidian API or raw GitHub/Drive link)
└─ Fetch: Markdown content, parse for snippets
```
### State Management
- **Client-side state**: React hooks (useState, useEffect)
- **Server state**: Google Sheets is source of truth
- **UI state**: Which tab active, which card expanded
- **No local storage** (MVP): Always fresh from source
---
## 11. Accessibility & Usability
### Keyboard Navigation
- Tab through cards, tabs, buttons
- Enter/Space to expand cards
- Escape to collapse all
- Ctrl/Cmd+Click to open links in new tab
### Screen Readers
- Semantic HTML (headers, nav, main, article)
- ARIA labels on custom components
- Heading hierarchy (H1 = account name, H2 = tabs, H3 = card titles)
### Color Contrast
- All text meets WCAG AA standards
- Status indicators have labels (not color alone)
- Links underlined or clearly differentiated
### Mobile Responsiveness (Future)
- Stack panes vertically on small screens
- Touch-friendly button sizes (44px minimum)
- Collapsible sections for readability
---
## 12. Success Metrics (MVP)
| Metric | Target | How to Measure |
|--------|--------|---|
| **Time to account snapshot** | <2 min to full context | User time log |
| **Drill-down effectiveness** | Find key metric in <30 sec | User task test |
| **Information completeness** | All 10 items readily visible | Checklist |
| **Visual clarity** | No confusion on next steps | User feedback |
| **Engagement** | Used for all 3 use cases equally | Usage logs |
---
## 13. Known Constraints & Trade-offs
| Constraint | Impact | Mitigation |
|-----------|--------|---|
| **Manual sync (MVP)** | Data can lag if Sheets updated but dashboard not refreshed | Clear "last updated" timestamp; button to manual sync |
| **No real-time collaboration** | If account team edits Sheets while viewing dashboard, changes won't appear until reload | Future: implement live sync |
| **Single-customer dashboard** | Can't compare metrics across accounts in MVP | Future: roll-up view with filtering |
| **API rate limits (Sheets)** | Can't refresh dashboard every second | Cache on client; user triggers refresh manually |
---
## 14. Success Criteria for Prototype
✅ **Must Have (MVP)**
- [ ] All 10 items (List 1 + List 2) visible and organized
- [ ] 3-pane layout functioning (left/center/right)
- [ ] Cards expand/collapse on click with animation
- [ ] Health scorecard with 4 KPIs calculated
- [ ] Timeline showing key dates
- [ ] Sample data flowing in from mock Sheets
- [ ] Navigation between tabs (Overview, List 1, List 2)
- [ ] Status badges and visual indicators rendering correctly
⚡ **Should Have (Nice to Have)**
- [ ] Trend indicators (↑ ↓ →) on KPI cards
- [ ] Sample charts (pie, line) in expanded views
- [ ] Markdown snippet embedded in right pane
- [ ] Interaction log with sentiment stars
- [ ] Dark mode toggle
🎯 **Nice to Have (Future)**
- [ ] Google Sheets API actually connected (MVP uses mock data)
- [ ] Relationships tab structure visible
- [ ] QBR Prep modal/checklist
- [ ] Share functionality
- [ ] Export to PDF
---
## 15. Design Review Checklist
**Questions for Ryan before prototype:**
1. ✅ Is the 3-pane layout clear? Does it support the 10K → ground zoom?
2. ✅ Are the 4 KPIs the right ones, or would you change them?
3. ✅ Does the drill-down interaction (click card → expand) feel right, or prefer modal?
4. ✅ Should the right pane be scrollable independently, or scroll with center pane?
5. ✅ Any major UX concerns with the navigation flow?
6. ✅ Is the timeline component clear (horizontal bar vs. vertical list)?
7. ✅ Color palette acceptable, or want different accent colors?
8. ✅ Any tabs or components you'd add/remove for MVP?
---
**Document Status:** Ready for Design Review & Feedback
**Next Step:** Prototype build (pending approval of this design)
@@ -0,0 +1,257 @@
# Top-5 Account Intelligence Lists for Enterprise Storage SE
**For:** Pre-Sales Systems Engineers at Pure Storage
**Focus Verticals:** Financial Services, Retail/Manufacturing
**Target Accounts:** Fortune 1000 / Global 2000
---
## LIST 1: ANY CUSTOMER OR PROSPECT
### First Discovery Call + Competitive Displacement + Expansion Foundation
---
### 1. **Data Footprint and Workload Breakdown**
**What to capture:**
- Total addressable storage capacity (current + 2-year projection)
- Workload tier split: critical OLTP (databases, transactional), warm operational analytics (data warehouses, ML training sets), unstructured (data lakes, backups, archives)
- Annual growth rate
- Typical IO patterns (sequential vs. random, read/write mix)
- Workload sources: core business apps, digital channels, analytics platforms, third-party integrations
**Why it ranks #1:**
This is your credibility foundation for the discovery call. Large enterprises in financial services and retail/manufacturing operate at scale—you need to know if they're managing 10PB or 500PB of storage before you can ask intelligent questions about consolidation, resilience, or performance. For competitive displacement, it tells you the total addressable market and which workloads are most valuable to move. For expansion, it shows you where growth is (analytics workloads often outpace transactional growth in these verticals).
**Vertical differences:**
- **Financial services:** Regulatory data tiers (Basel III, Dodd-Frank, GDPR-driven data governance), transaction volumes, archive-to-compliance mandates, and fraud detection datasets are outsized here.
- **Retail/manufacturing:** Omnichannel transaction data, supply chain event data, and IoT/sensor streams create different growth patterns—often unstructured-heavy.
**Where to get it:**
Account team, analyst kickoff, customer IT strategy documents. If public: earnings calls (capex guidance), analyst reports on data center expansion. **Hard to verify fully from public sources—lean on the account team and discovery conversation.** Pure1 for existing customers (capacity trends, growth rates).
---
### 2. **Current Storage Vendor(s) and Installed Infrastructure**
**What to capture:**
- Primary vendor(s), total installed capacity per vendor, system age/generation
- Deployment model (on-prem, cloud-hosted)
- Vendor concentration (single-vendor, multi-vendor)
- Capacity consolidation potential (old systems due refresh, overlapping vendors)
- Competitive systems they've ruled out or are evaluating
**Why it ranks #2:**
This is the competitive map. In discovery, you need to know who you're up against to ask credible questions about their stack. For displacement, this is your roadmap—old NetApp or EMC systems are your opening; vendor sprawl is your consolidation story. For expansion, it shows where Pure is (or isn't) and where you can grow.
**Applies to both verticals:**
Regulatory and operational requirements often enforce heterogeneous stacks (legacy core systems, newer analytics platforms), creating multi-vendor complexity and Pure's consolidation angle.
**Where to get it:**
Account team, customer IT contacts, Gartner/IDC reports, job postings (hiring for specific platforms signals infrastructure). Pure's competitive intel teams (if available in your region). **Harder to get from public sources—interview in discovery.**
---
### 3. **Business Drivers and Strategic Imperatives**
**What to capture:**
- What's moving their infrastructure strategy in the next 12–24 months?
- Digital transformation (cloud-first, API-led architecture)
- M&A (data center consolidation, platform rationalization)
- Regulatory change (data residency, compliance modernization)
- Cost reduction (capex or opex pressure)
- Performance demands (real-time analytics, AI/ML acceleration)
- Geographic expansion (multi-site, disaster recovery)
**Why it ranks #3:**
This explains *why* they'll spend and *when*. For discovery, it tells you what your conversation should be anchored to—cost pressure is a different conversation than compliance urgency. For displacement, it reveals openings (legacy vendor can't support cloud migration; incumbent doesn't scale for analytics). For expansion, it shows where budget flows and growth cycles.
**Vertical differences:**
- **Financial services:** Regulatory change (Fed data residency mandates, CCAR/stress test infrastructure), fraud detection (AI/ML workloads), and open banking/fintech competition drive transformation.
- **Retail/manufacturing:** Omnichannel unification, supply chain visibility, and sustainability reporting (ESG data) are primary drivers; cost pressure is often secondary.
**Where to get it:**
Earnings calls (CFO and CRO commentary), industry analyst reports, customer executives in discovery (CEOs and CFOs speak to this), account team (relationship intelligence). Regulatory announcements for financial services. **Public sources vary—direct ask is usually most productive.**
---
### 4. **Infrastructure Architecture and Resilience Model**
**What to capture:**
- Topology: single data center, active-active multi-site, hub-and-spoke?
- Failover strategy (RPO/RTO targets, synchronous vs. asynchronous replication)
- Cloud presence (public cloud, hybrid, private cloud)
- Containerization and Kubernetes adoption
- Storage tiers and tiering automation
- Backup/archive strategy (separate systems, integrated)
**Why it ranks #4:**
This determines *which* Pure solutions fit and how. Knowing they're multi-site tells you Evergreen model and replication are critical; knowing they have Kubernetes tells you Portworx is on the menu; knowing they're chasing sub-second RTO tells you FlashArray's performance story matters. For discovery, it frames the conversation around resilience or performance. For displacement, it shows where Pure's architecture outperforms incumbent (e.g., Evergreen's native replication vs. bolted-on replication). For expansion, it guides where to add capacity.
**Vertical differences:**
- **Financial services:** Recovery requirements are often strict (RTO <1hr, RPO <15min); multi-site is standard; compliance mandates geographic separation.
- **Retail/manufacturing:** Multi-site is common (stores, plants, distribution); RTO is important but often longer than financial services; hybrid cloud adoption is accelerating.
**Where to get it:**
Account team, technical RFI/RFQ documents, infrastructure diagrams (security-sensitive—may not be public). Discovery conversation with IT infrastructure/storage teams. **Hard from public sources.**
---
### 5. **Budget Cycle, Capital Allocation, and Buying Authority**
**What to capture:**
- Capex budget cycle and approval gates (who approves? quarterly, annual, rolling?)
- Capex vs. opex preference (traditional capex, Evergreen-style subscription, OpEx-only mandate)
- Budget envelope for infrastructure over next 2 years
- Preferred buying patterns (competitive bid, existing vendor preference, tech refresh cycle)
- Influencers and decision-makers (CIO, VP Storage, business unit leaders, CFO)
**Why it ranks #5:**
This is the "when and how to sell" lever. It's less about discovery credibility (though knowing budget is essential) and more about deal realism. For discovery, it tells you timing and buying authority to route follow-ups. For displacement, it tells you if capex is available or if you need to frame Evergreen as an alternative. For expansion, it reveals budget headroom and buying rhythm.
**Applies to both verticals:**
Financial services often have tighter capex scrutiny and longer approval cycles; retail/manufacturing may have faster refresh cycles but more budget constraints post-acquisition integration.
**Where to get it:**
Account team relationship notes, past deal history, 10-K/10-Q filings (capex guidance), analyst reports, budget forecasts shared in customer relationships. **Rarely fully public—account team and direct relationship intelligence are key.**
---
## LIST 2: EXISTING CUSTOMERS — PURE STORAGE DEPLOYMENT SPECIFICS
### Expansion + Retention + Competitive Defense
---
### 1. **Current Pure Installed Base, Deployment Pattern, and Capacity**
**What to capture:**
- FlashArray systems (models, capacity per system, tier across data centers or cloud regions)
- FlashBlade deployments (scale-out analytics use cases, capacity)
- Portworx deployments (if any; containerized workloads, environments)
- Age of systems (when deployed; refresh cycle status)
- Geographic footprint (on-prem across which data centers, any cloud-hosted Pure?)
- Utilization and headroom (at capacity, growing into headroom, or sparse?)
**Why it ranks #1:**
You can't expand without knowing your baseline. For expansion, this is your starting point—you need to know what they own to identify the next sale (add capacity, new tier, new workload). For retention, it's your health baseline (old systems more likely to churn). For competitive defense, it shows Pure's footprint and where competitors might try to take share.
**Key indicators for retention/expansion:**
Systems older than 5 years are refresh candidates; sparse utilization may indicate underutilized tier or expansion opportunity; multi-site Pure deployments are higher-value accounts.
**Where to get it:**
**Pure1 (your best data source—capacity, performance, age, location),** internal Pure account data, customer IT/storage teams. Direct ask in customer reviews and strategic business reviews (QBR/SBR).
---
### 2. **Performance, Utilization, and Customer Satisfaction**
**What to capture:**
- Pure1 performance metrics (latency, IOPS achieved vs. provisioned, dedupe/compression ratios, capacity growth rate)
- Storage team feedback (support experience, feature requests, known pain points)
- Performance satisfaction (are performance SLAs being met?)
- Operational efficiency gains (cost per GB, power/cooling reduction)
- Customer sentiment (NPS or equivalent; any churn signals?)
**Why it ranks #2:**
This tells you expansion potential vs. churn risk. If they're unsatisfied, expansion is at risk (competitor threat, bargaining power). If they're thriving on Pure, expansion is easier (proven business case). For retention, this is your early warning system. For expansion, high satisfaction is your foundation for upsells (new workload, new tier, cloud-native).
**Vertical differences:**
- **Financial services:** Customers often have strict uptime/performance mandates.
- **Retail/manufacturing:** Often cost-conscious and may trade performance for efficiency.
**Where to get it:**
**Pure1 (performance analytics),** NPS/CSAT surveys, QBR discussions, support ticket sentiment, sales/SE relationship notes. Direct ask: "How is Pure performing for you?"
---
### 3. **Data Footprint Not Yet on Pure / Coverage Gap Analysis**
**What to capture:**
- Percentage of their total storage on Pure vs. other vendors
- Workload segments not on Pure (e.g., older OLTP databases on EMC, analytics on Hadoop/object storage, backups on separate vendor)
- Highest-value data not on Pure (revenue-generating apps, critical analytics)
- Consolidation opportunities (multi-vendor overlap, legacy systems ready to refresh)
- Workloads Pure could address but hasn't yet (emerging use cases, cloud data, new business initiatives)
**Why it ranks #3:**
This is your expansion TAM within the account. For expansion, it shows where growth comes from (consolidation opportunity, new workload, tier migration). For competitive defense, it identifies where competitors will attack (the gap is their opening). For retention, it's leverage—you want to own more of their data.
**Vertical differences:**
- **Financial services:** Critical OLTP often stays on incumbent (lower risk perception); newer analytics and fraud detection data are Pure opportunities.
- **Retail/manufacturing:** Supply chain/IoT data and unstructured analytics are often on non-Pure platforms; consolidation gains are high here.
**Where to get it:**
**Pure1 (shows only Pure capacity—you need to ask for total footprint),** customer IT (data inventory), discovery/expansion conversations. **Often not transparent from public sources—direct conversation essential.**
---
### 4. **Upcoming Infrastructure Initiatives and Technology Refresh Cycles**
**What to capture:**
- Planned data center consolidation or modernization
- System refresh dates (when is their oldest Pure system past end-of-support? When's the next capex cycle?)
- Cloud migration plans (moving workloads to cloud; implications for on-prem storage)
- New workloads (AI/ML platforms, real-time analytics, new business lines)
- Multi-site expansion or disaster recovery modernization
**Why it ranks #4:**
Timing is everything in expansion. Refresh cycles are your biggest lever (old systems = new capex = new opportunity). Cloud migration can cut Pure TAM or open Portworx opportunities. New workloads are expansion hooks. For expansion, this is your calendar. For retention, knowing refresh timing lets you plan defense early.
**Applies to both verticals:**
Financial services often have annual capex cycles tied to business planning; retail/manufacturing may refresh faster or slower depending on store/plant footprint changes.
**Where to get it:**
Customer IT roadmap (often multi-year), Pure account team, QBR discussions, earnings announcements (for large public customers, CapEx guidance), customer executives. Sometimes in customer's published IT strategy.
---
### 5. **Stakeholder Relationships, Expansion Readiness, and Competitive Threats**
**What to capture:**
- Key stakeholders (CIO, VP Storage, business unit leaders, security/compliance officers)
- Champion(s) (who advocates for Pure?)
- Relationship temperature (trust, satisfaction, barriers)
- Competitive interest (have they mentioned other vendors? RFPs? Evaluation?)
- Budget headroom (will they invest more in storage in next 12–24 months?)
- Expansion appetite (are they open to moving more workloads to Pure, or focused on optimization?)
**Why it ranks #5:**
This is your "how to expand" lever. Knowing who the champion is tells you how to navigate. Knowing if they're under competitive attack tells you how urgent your defense is. Knowing budget appetite tells you if expansion is realistic. For expansion, this is your relationship risk/opportunity assessment. For retention, it's your early warning for churn or your pathway for growth.
**Applies to both verticals:**
In large enterprises, storage decisions often flow through business unit leaders (not just IT); understanding where Pure fits in their strategic priorities is key.
**Where to get it:**
Account team relationship notes, past deal history, QBR feedback, sales/SE interactions, industry/analyst events. Direct ask in customer conversations: "Where do you see us helping you grow?"
---
## Summary: Why This Order Matters
**List 1** (any customer/prospect) moves from **foundational understanding** (what and who) → **context** (why and how) → **execution** (when and who decides). This sequence builds credibility in discovery while revealing competitive and expansion levers.
**List 2** (existing customer Pure deployments) moves from **your current position** (what you have) → **relationship health** (how it's performing) → **opportunity size** (what you could have) → **timing** (when they'll buy) → **execution** (how to navigate the sale). This sequence is your expansion playbook.
### Key Callout
In both lists, **items 1–3 should drive 80% of your discovery and account strategy effort**. Items 4–5 refine timing and approach. **Start here, then layer in the operational details** (support SLAs, feature adoption, roadmap alignment) as you deepen the relationship.
---
## Quick Reference: Source Hierarchy by Item
| Item | Primary Source | Secondary Source | Public Availability |
|------|---|---|---|
| **List 1 #1** – Data Footprint | Account team, customer IT | Earnings calls, analyst reports | Moderate (public guidance varies) |
| **List 1 #2** – Current Vendors | Account team, IT contacts | Job postings, Gartner reports | Low (often internal knowledge) |
| **List 1 #3** – Business Drivers | Customer executives, analyst reports | Earnings calls, regulatory filings | Moderate (CEOs/CFOs speak to this) |
| **List 1 #4** – Architecture | Technical RFI/RFQ, account team | Discovery conversation | Low (security-sensitive) |
| **List 1 #5** – Budget/Authority | Account team, past deal history | 10-K/10-Q, customer relationships | Low (relationship intelligence) |
| **List 2 #1** – Pure Installed Base | **Pure1** (best source), account data | Customer IT, QBR | Account-internal only |
| **List 2 #2** – Performance/Satisfaction | **Pure1**, NPS surveys | Support tickets, QBR discussions | Account-internal only |
| **List 2 #3** – Coverage Gaps | Customer IT inventory, discovery | Account team insights | Low (direct conversation required) |
| **List 2 #4** – Refresh Cycles | Customer IT roadmap, account team | QBR, earnings announcements | Low-Moderate (customer-dependent) |
| **List 2 #5** – Stakeholder/Competitive | Account team, sales history | QBR feedback, customer conversations | Account-internal only |
---
**Use this as your go-to reference before discovery calls, account strategy reviews, and expansion planning.**
+471 -364
View File
@@ -1,365 +1,472 @@
$ANSIBLE_VAULT;1.1;AES256
66393233316132396639356564316439343234383066633231646134313361666463656536323732
6630616536646439613533363430306466306233643730350a343364633233333335643833326163
64393933613963313533623733316339396236363663343635346663323366663166363839663837
3331363062653239380a326566623264623837326636383939346430666537613361333638366630
63353062393335316663633739313532366363623739653631366539323435336361353331386230
39366634643964336233353961316630616462663166316266613037623363346335373638656365
38353733396636386133373836346336383231663661346137373164386338623733393566373563
35653933343036633365643535303934326537356136666539316137363433643266346630386439
38613332646238366536333536343031356532656336613530663830613264346339353034323362
66613530626361323535653232313730373463373332313561616631393461353730653464343063
38616338363938346161616636316232313838616463326432353639613837343162646363343232
33323064363139376566343866626364373662393138353666646234373461666163363139313631
64343261326566363265323463663538343034306136326234386664333837333937333136653563
61333531353434633339383661636363363535316366353330313566323133616438373161303135
35353630613037316466353832333033393030636331386438393133366333653832393731366363
62373638303737393162303461646239653865653834613662666636373364633165383062643831
34386232376361323638353361666530366432356331353963303930326535663536373339333062
32396266373430343339636635366434313635313766363863336464633961666332353834626163
61653637316163636465343630353431313863653033643237356434313564366361373435376662
63643737353830663236643862613533623237373531646136383763303766336139303632666235
64623834323966363363663730626437323432623966663537346162656265363562643836633731
65663631633462663764393132326165346639353033633035636432613039336164303538396632
66396264393865306666643636353638613661313230313337383634663839363439656533333932
36633432306131396539386539633063653230363932376264323537396434353364643432653661
32643162363066636432336363323534316436613838646562313538326566666239633234646236
30346534636533623365326564613561363362333364363037646561656635623935653466613565
65646361313436356261643762313339333864356338386136306162386262636464393130303963
38646464316432326431326661343632396235626234366133353461623862316662326432356234
37626366383861373831633639616465663564643866356664623066386535646163336134356534
33366664616232353863626465626364313530353335306565336665663866303736323162393362
63626261653161663664363833313461653034326330653835393737616135646462366665383935
30363639306330636634386433646231363530633061336364313338653632323831393630383934
37316362326338313733646332336263386239626539383330353362616132333161613464313066
34663434326662326233363432306433363666356132383866346336336261636435366332666135
34616231613638363339356333616536643266636363643131653330396162306264303566396461
64363763376365356533636430643866333361363062376237653237663731663934306265646630
33393637656335643366383564373966343265393630333835303731316339373133633462383364
33383435383331303264313334393532373932333334343862326635346135613932356337373034
62316262326331313135376465343336373266663338396533666431616462613932663861646238
62653563623535633738383033326235383666646333653731316233376231623661306462303732
32643064373236613336396233323435393939386530323331336138353364663762356538316562
33376530623664623733386133333433303031373337313366386236376539613964316135343865
33363963366165333238356663663435386439336366646138313034343636653463323938633136
39363966376238306662303265643034306136663661393738633436393432303139313132616534
66323432313635386162333838323136623634653264643438303264636430633232323434666532
32616664663063653735316237643539633133356661333132323238376333356464313262653836
39303566316332663737323437633031353330333365383837636336643763313433313937396531
38363536343438663966663436613132663661613134383431633765383164373762343435316161
62303631646235343063383230343232383336356562303563373933346530393333316634316437
32316330306163396434663031393965663163666537353031613365353437666466333464626238
35313739646535356665323734393965303064306132626261363062363438383164346261393463
30643438623363323161323230306230386332363635386234666639623566643536626637616533
37396136643930633262333331656363376433333234343630306535313262306235663263663362
32653434363035613732363136303363393939323337613661333439393637646262383039386661
62326163323562333339323636363565623664396164383332633666386130613766393138346134
33343338393536316431353439353062663164643634396363353131303038353965393466383030
36656465383938353936346361393963356630666630373236626237303064303062383638373730
35633866646535313432353338623462323235346433653431313031363163393666626432363238
39623361316132626230633336636163623466313666346631656134343762656566353432353264
30353436356237653231363564626134633039363035313232616333336436393638396233626638
32663230396539323761313838313466376165646430346634383332346134653662393161363337
62646161343665383364306665333164666231386531626465373366623761643161656462303733
37653438616233353432626466623163316565353764323762613635333832343634323665356336
35353162326233333836396337356466636131383838313436626336663132346339623261366465
30623261303933396562353331636638376135663330643638643536346261626632626139386535
66653332366361336636666437643165656239613031303638333232303836383132616636633938
31343034643037623731643931316463303639656266323231313666356336333133323135363330
63373365303131353161303630633738353536393631353034666139383435303461316131646138
61333731356538366366613831303565613365633965323235366166313534653965366433656533
62666136313662366638356237343734336333313034396465346632336262306531633535643238
35333831366532386235316565303936616264373337356134643066396531383533353336303131
31393837623564386535323532653733393734393164373235396566333565356237356438313762
32623765326639386262393639376461326163333237313232386138643130643231626466643663
39643061393566353434333136366335393536376234366266376265333234643536633035653933
36316132663539306465343039323935356361373439346437386234386464623962643464643562
61336434613834336161633237383361303930313464613666313834356330343138633735386530
36616233323366323961653965613438346136373738366266316134356266623664313539636235
37313033373466383134346361646562366531333338386330653736626530396238356639303131
38363738396236386461316433316261326435646130383336316234363461393237623633633336
30386630376565646337383738663939663462623232316635346635653830306664653336343033
64316430396664393532313766326437636636626232613036666666656430323136356436333564
36303334303562393832336433343438396430373833623137363736386665343866313064353063
32303634393131326464656535633734386462646339663533666430336265653965333538633866
61623666643839653239373335633735373738363736313665323365613635313766656635613832
33616461643539636165383233636533626230343138663630323731626139393230383464313430
61333438393337316239376435313337313437333931623238616133666138363235386533633437
35356163363231656536353934643539643562343732626630383565623730626533313230656164
35333735666135343364663233626163363930383262363266303265303638396239636361366534
31333863333565356135613232393165353266343632633532343061663331633337343538376265
39316630613439356262396634316361356436336634396337353339616536356336653930613966
34656439653366363562636639346430623561303463356337363830373966366632303337663564
35663632313265323365636238303364366230353039353561616636633664643233343430336237
63373264643935616331616632633065366638363833306337633563653065363464343137623533
36373231363739373335346464623533393336613634333636613937366136326464336332346166
61376263623835646163353134643963663964373732313833346163323138633230393537636664
30366234303334656130336630346130656237306161376566336534653630616439323764373665
61383338326163336164353265326163646165623235626137623237306666333832306461613630
66373331356465346261643466323662393661623433383265376666623932343861323139383531
64633536373362643935633734366235396433333237306166646164363930613862613365303663
38343833336137353634313362666665306666393635663633353934363832343739616331386130
66336561633039326434313833303465366638303961626138333165623331386230616130626639
34613962366230333065633761333335613636363533656461626632343631666563383738623330
30333834346233653938633330663166616331376436356533366461336264643264336139343262
35393665656230663232366133393037643536366234343537326631623332373131323739363638
39653162646366316639313631393631666261623230313538613666393732626438393763646330
36346661313131313630343432616365666633353762623261613039623331396330623939626132
34626333386538326434356432623965666662663437646237373537326534653634346239653634
31373038303639333037613637393862356263323066666630313262366633313932396465633337
66653930303934616236323064613761353935613835356561313334323762633064306661346666
37653262343865386236343634316336386630393739626437333065323433613531393738313432
31376233353463373237653164386363633334366332356538343966663939656165323465333030
39656532363363333432626638626438396539336461326338353732376235316133616666316261
37353063343366376433653961333233306461303133376661303332386230346231383837396133
37323137343066383966343535633363643233663530613566313330336232366638396165373631
30626531363033313833303836366434613736396339643032663066333865306535323739666162
64626133616433653864376662623464343131303938303237316264393765303035663833376464
32663264383236303766323935306463643138396237373338653238633464616238306132633735
31626538653262326533326266336633623532623935383266373533363466313033393235663538
66653038646233303665343634383666343363383238326533366136363838303332323230316662
35383235646638653539633961663036663933306463626335356631646662636230356261363261
65633261353830373865636630353932323937666331353635373736376436333361613330366633
30663939356165393132636131663966373433623063356265353131306532643066306630656363
66636636353262633437663264613266613663656137386231306231646264363661613035343538
37353633643065643236376537336238663137623735613038623766393231643131653436333262
31626463646432613563393665346532386161366435396364663239386236616233356131323536
33633936623762666534633862363466353736386137636363633733623366346337613365636439
66663035313430386464623833646135333062313830396637323961386135363461326539623432
32653865623530313637393561343465636430373162333162646631643235653931333830326266
36376631316165343631326165623838306239623764363262376634663236393933343838376663
39663834306165313330393739363133396436376437643232346336386531356638343063376465
61613037623137306666383231376539656361326132396662613061376134376266633764336266
64336334313335643635303632666431383637306334376462643630646339396435313830313363
33383162316261663035393962306234613865613366353465373035656434366261383133653331
63643235616362663663343330303765363263366130393837613939323264373937333162636639
31643438666338646135663538343231643235646364623761653064633566656663383465626133
31373935646266303565303539376162623132316438623565316537306337636630313861623937
34313832636533623033616139373965303839356530353935643363613464356364343162336466
66376130653162666661313139613530306666633432346639656466653364376435636461626362
32653633303561346233643463373534653434323134353434373839373937626663336464303866
33363264623038313835396231373132396163363662626264346461333539326365326165323066
62333139383334333334353031616430323339623066363232313937323465356266323934313761
66623835653961303830383030643537393130653935313265333062393034336562633535323263
65616237373232336534393834653162363461336262653862666637326266663966356665363036
35383437326465663635303664643236633435303862633965346133376536316233386333313634
33643565326633386565653961646463383866646636303537643436623734393234633938333933
65366164633165623333623362393639656661326332306538663738356364373734316563653038
34646531616662386232613034366332656262343164333531353037363036646262623663666236
38613238666136363431623664633863636365396236666532383930336636353031396232656435
61346238643431653231623861373964383931336535363262373437353532393165316562386134
36363263666135646237383666373833373737396330616163376439663736663937666161313831
63663531656635663339306365656663636633343733636165386230376332616331313638386538
32386466323232363533613334333333346161376430373436373961316564343061326164306138
33616263666262323430303730626266396535626439623364376239346564323730323534323938
33346364393033353865393864326361643734353234613563393138363334383536396535393166
34623163616336653436393639313965353237633566313039303137326234383230323235363234
37626161356166356365366164363863636563316332393638616535376466343537373966643839
32613930643533336264626136626465303339376632323034386161663661376466616233633065
66313739346162363838346663623266383130383736656334323430623463666439386532643630
33666639613830386136363535363830333234653961663739343537306634616531616263623762
64666230373830636238353062666330623061613663376638343763626264363130313464383661
38326530333362616163363735323861376366333665623536383566653837306131623732373639
31316661353332633630326162663738636562336666326637353764323431613666303038373532
62343661336338306561356235396636343130633365303466613637633363613862663233633731
66623530353132666261316637303763363830623734346262333633646238613131346564303734
62336434353432326239333232383833633962313537626430663130393733623162626131656366
64333535623138326239336165666562376663663334323036323539653734333835386331653438
63353861666239396437346361306634613462386335376137333963333838616138633730393865
62353539376136316564666136646639363635663736636439393462633165646632623664383663
31613137306461616361323832393036323933626531363536336261356636303531633239333362
32663134363263383039646162643539663737333861386437326337616362343963373532346238
37346137363933623839373838353939386630303461346438666534616434333031373730393537
30313134643963623564356266656430613430626238613266316335336265613132616562626261
35386435313933626634616463616166646466363939313639646264346464363337656339323366
36366665363739356564363232313762323565323134616134666337336534353464373637373130
63613265366436313131356332316531633732356461383064383031613337343363646432373936
33633339386632653032663837346130623636356464326637303338376132623734333932396232
61333033386265356630316134383066343164613130666664643732643362666561346132656266
31623633333039633837383264363937623435643061393935393762346430396335373864633634
33336136353332663366313334353739303539633364663231636539333132303966383432376262
61356563323232613433653262623663336634626532653465306638316633663564633862666666
30366133616336326661626238653933383164336366333438626235636631336165386664343736
62663961346664656333306435323833366632346366356238653731653937626333653630623334
64326662346138386433333232643262333835326263343239353264373038613634356436396630
38323931643361663238623766323930666130356339363564366661663033303831363138343737
66633535326131396236653261303836613364306537633637323031663166316338323533323731
30326235323066396663613531653061643661336631613835626266626436386662353465383065
64396562343966303362636136616438353661626466636635323961613438646634336563636534
38343566396530643961356434643933636235643561353232643062303232323437666261363061
62636530396466303466653333633930376465366561376363316137323263333561343334383364
39313863643062643766396564363137386231373136346138396162376264653538303464633161
61663363623937356138356430666461623130323466623162653863393736326264393836336637
34663931646566333535666664653237643732316663323230383239393763376266356135326438
35656266353865623663373366373830613361373664346632363031356265313364623866643438
61363866353934636337616239633330623734666138396166313864333939663563636138653930
35653137363033326432373661613434313137623163356134613265393238346438306165313639
36666662393165353565633531663536613037623230373063316639663632643139353235303462
31393263656265656131613164363035343233626433656135353331613532363236616439363731
62666432356363323937666435326638323437346136366131613636653430306131623966356263
39303739306233303862636535633431363630393432613663633836396566653039383735303336
62623331623034636363636661653236386337326666656532343737336262336462613762326531
34303066303834386366636430343161653665343362363038396562626133636135656538306435
36393364333066346238396362663664643236373532336263656233386663323663623137343462
64386337333130316434663564613665666238623132343437656637653035373738313735366630
66383639616166393265616434623463326437313530326130376339313662303836636664366232
32366634633030333130316435616233396231663937343732313066373834326464623139363663
31323931626364303230666162316436653065366137663631376265383063316534343736373261
31613637316235386539343766323439653062633137663730343236343661346162653366656332
32663932313063383561636266373766633535656131386133386135663863396261306530326632
63653936626236316539613262386231616433393064323461626536363831666461316131383837
30646266646266393666396362326238613231303335336532303836363264323233343534636635
66313538643033343262373463363866346566353263303966323933383963363463393761383865
64663932343830643531643466303438343161396133666463353762393737613036646166333265
66376231613232666164663964636134653061633330383863373836306366393838393235656331
35613231306263373230326634623262326333356263353961633836396531633431383163633361
30356534666466653734333437383964346564346165326664633738653338313263633837316531
36613034323433643839333264323864613033313137663131623265643364333664646235666232
33393039313666323266643362323337316465306564303230646561303434666630616137633831
34346439616634343337306636643733316464376631616266376437636439396337306637333432
39306333363035393436316434656436353738303861633933376531383862316466373736323639
35336137373866336631386436646231653366366435363932376434303063613961353261343661
33383638393165336438376662306431333837356435626137356130323836396335636166306662
36386161353739353637353861306666383966323339303262616239633930373633323937356632
65383032613031666665623631613430666662656336663931646533636230303261646530623765
64643939326435643539373564336531623236653731636636346361363064333963376566616530
62326639663632666634326233363635383830643163373938646165656163643864336436373466
36353832306632386230373832333234643638313238626333303963383962343265366137656136
37333261343161633562346638323632616566646162633133663466346535656463393932386135
62653332313066363965386335356430326539316366633537356364666230326237306236393563
35323363633936353034323232353366373566666332323737653237323135646665626139393436
65333762653536656161386532363765336538653763666236343166653933626666633130393033
31643531646633623663313237353333313136663863663430306131316165663765653732663164
36326531626365326330643064336230313466343731376437316563303339336333326636633066
61386135626430616661313236623030316362373338643233326365646531633265626238383830
65346132643537366537626132666165616138656139626132396639376230333262643766386363
39383034663034326165643636613237623234613666333532383733623462303331326238636461
34383139383466356139333934353837613964326538336463643832623062633034613762363061
61346361656363366136353336326433326266336564316366393565626262303637316564356566
39376661383763373436306238393666653561306538333638306233356139346634653363346164
31303835383062376638626266303237323832623735653066353936376339633637333562333561
36646462653834316131323166366661386161646538346464386232306239363030366363633663
65306439616339626635326531636435356134376561303235393337373564373937623636643432
61393535643038626562366331353831663338333838383066323632383633346564396566653330
34386563393832313537623061666466366661333934613766366165366330353835323637643635
38613363396663356564646132613536653033616337386566623662333832383938303138316662
34303339323166383863363831636233323335313565393933636435396666313337663037323432
63333139333165646262666339343736383966346133356138326437386334626461636530336432
61356631366163366561303636333230643732316261376365386463333565623533663966336365
39333365393766306536366231366435363030353263393534653534373064636361333532323735
61313163323831653362356333386638343566356261353534303738613730373632363534666337
62313339613138646361356431616236613435393233343732626263653332663265393934616134
35313165613766643938393839373261633439396661623961353934373130623865353639633038
34353631346433663131653965326337663561613330323562666336656237633163356562323931
62393833663233333538383063303937386365306135343962623333663435663431396438666362
61386266353838653532393233353939363738306634666537313761313835333864633764666262
30333032623766633334383031636633636539336237613235376466356430663938653565626235
64373537656566306136633630366130363630633462656330623633393735386630343437336436
65343635366531646130616534623136636666323139326462306533653532643962656530336633
35616537303932343539336638333730663639396330653761346136363431346536666138336462
66396565613166623934316532383835316137303134363466306163356233356530323231666464
30353933306530323734306564626234343864373964333264353366326265316333343330356532
38363837646635633461653562303264353633343461633339376665616331613733666663353130
65396363613731366234326466323738663563646166653237613364323734616465643764633537
35373865353532383566363632366564353536643739663761303565333138383638653665663664
65643366316461613630366437623736353739356538336237613431306363663234373265623962
34653565373335653563356135313835643266356261623037336536613733323733363933376538
36626134396563623733656534363331626262643339633932373035626134343531623634666463
61623036313334616639633930393562663631653565656136666537393731333430663062643362
63633663396562343965313261373965356163393538666466303661363531393266316462626166
38616536653665366462383064373766396438616665346666376232653031323566313164383164
36643231646439663637333165376439333432383532316661333766363136636236326338386537
33306130353634346136356234363438383865313136393839663066333935623565333730613538
63323831663866363831383930303434333936646564316435303931396362303534386335343330
65383635346662626363626365666166636361633365643735303762393832316436646139303835
63633733656361643233323332613632653837663262306661626438316262653931333061336366
35353939353835333361623261613738383734656132613139393264393038373765343131333330
37663962313566366463623437323965326365623437363038633661313461383634626661666236
32633335323861643037383261393164393933353531636134323765353962633732396230636331
32613865373739366530303538313566346434633933393330346637346136373036306666336164
35373732346334353432616561623031663331346431383235306537386466623339356366663335
36333733626433653465336431666530626665373564336339626163633131353330656437643638
31616563616665343635356231633135663665326131636664373338323736393364353636613762
66636135633766323866376235633535613735613465303239343036663438333331626431623435
61323537386434323638666537643236623632626430666263376534643336613635663762643736
30323237353265613062373265643562373637383337326264653639306263373865333262376665
62646331373931373762303461366163393839633135393964313937616437323865653735383630
32653936336534666565373437666130396265363561333635316461663766346336623865376133
35373665303433326265623531613038636166643130616637653165376263643634376439613765
35363031373630333966656466616235616337306335363132386335613462363664653634633864
61303635653932353730663666386263633662633736313461643932386161313762663761313336
62653665313033656537643936373465633932626166366430643763313030393838393039323230
62633334383938343433306262393536653930653030393033306661666264313630643564333166
35383237633932656331653030363434313534613637373465663264643061303538653666653861
35373936643037333866636131373338363062663035323531626431633362663364396365353139
66383737666437353764333231303662393630643933376161366430376530613365363830373534
38376633333936626430393163323830346166643537326430616236393733653761363235356363
35333131663032383861336262653936376565646662313965303265623763613330653461333835
63613563323135633438383931343731656333303362316533376339376636623037376431336366
61393236383364356162633062666265653534326363363862666539623761623065386537616563
62666561316437303763376635346536666437373361386666643139643737663333323933613661
38326566663932333930616435626133616531306461356466326437623235613233393434626563
34373966633834373430386132353163366465626262353863353335323830393266393562393133
65383632653438646435343333386261653066613663623232373564666465613136353039313036
61646462626433646330396664363938376530376438646262343231393262383733636233636333
35633862336566636439653464613564333162613836343636316334316665383164353131373431
63623030306564346562346237333934616134346536303365396533626262333937396432393830
38313763393463646437666137353835373735646365373934363936346564326362376565353133
36653362333432326133393837316331666663663263396461363239306239363733633137396633
38393865613431653337313665313762653635656531353465623436343132303064303564393066
63616639353962366666616261393766643364333634346630616436376565313236316539633537
66653239636561393433383639646462616433653166613130373134376535633937353366383230
61613335663434333835653236343633633038346335333861356637353965396632393833646635
36653034356234663831333764303338663464316362646339376338393236336161616263363538
63356631613239326161343031643936623366643432663732346438333265666535623664396333
62303239363339626566613439396234303536333333653433393666383635643235376165666234
30336236393962666335353233666463346530323531316438373130303933383465316638646461
62643066376666363236333231386237376466633932313836323163363061313333633434663763
64323365353336333736363436376232653436633739613437343538633632356665656364616637
62313666346436656564663335636635393632353430666236313863613464626434323939383538
65386265343434303632313739353239323565333734656566356164643430613538333234383566
37646431316363316139646435333732313339623666613738663039613239613738393565333330
33376432373933656435303737653762666464363865633831373330393435633332636261336139
34336236373535636165353262323966363164633135613534353661316364616637663465363864
39306163346365643339643937396165666366663339336438373031613937636464383531613962
34373433663533626665656364623634373335313033646165303764396563356235343033383138
62326361353630393938643764616636313461633734646661386536356235656665393864386465
37666262346561656436343036646330363664306135333464663265306165353039396665336664
62346631366330653762646538323565613864383534636532633033643533323736373931643130
32343435333333613734626234363132373734353035326232366264336161383631353133663230
32636533333866343763336439373336356237303636376334333433376630353338333261333037
31666537363666653238383939663464346662636133346561326335346163363061393830616237
36643430626534653331653665316535303139343763663965363164636238366533303038653935
32356432316633373137316237663331336463363431393033323635646564366639346230353363
38346663363039363962323137383366303862356530353238656563306131643236626536656334
38303462306562366532346163323061393437353063326539393466616439346564383036303235
35633731393661323962633631373061303930323638326565636162316436646337383266626561
32326430363031396530396238353862333133363731623736376239626561626165663337373261
39353461343461643238646635633562653865323336366634613264616662323232653861663038
64396330626633303031333334343335393039623135353266383561313231643433393963326637
39313530636361373831306234383166346266656261663830636631333564356536323565336266
38306561376366626236306633613564386166616630613032633163613837313462343662653261
63353437663436303634633336636532646439636465663362346138313665336334313039613631
65326135383831613531323265353831313562346161663265366434623236636635333038366536
33646631663662393331323162343438626666366636613438383665633136326439376166373462
33363864613136643461663436396362643066633437376631623031613366656238396165313832
31313131653263666334393664343239306235373862313339373563643137393633343663613936
61356564636238363136623031336638333566633766636362303938653531306131396665303033
63353362636463636236643464343562383161343432383766396330623764393837613435396162
31303162356437663932663964663239623764666366663061313535346438373334636263653531
34643133356638653031373036343162653135663734623035353033633561366266623566383233
36356434643538643430383532393762333535636639353361353763333363313131646264336332
34373331343930633962623963666365306132356334646636626461316236343839383266363635
65623434336239313330343437646333353362303232346638623161616133636636626236643465
36303636363965363765656533386534633839346363363738386532386531326538643134363132
66613235373362633166343565323766306335336365333439323764623964393263623236623832
34346132383136303038363764333039626234616132386464666633663536656230666133363533
38306665643361666539636666316432623430623939663636343164386438313765633031313534
65393633323837326166343936326263343833646331326464376138633461613532303135393036
65353830373065393038343039323937303634346665393135383639303162396565646232663736
39376434646634353330383933303164653431373433346335666131386165343035303964626665
35383035653631346638326637326235393833623264323030373238646335346332353362393230
61616664613562383639306564376661306665396138613066326631616531623132633966633832
65363637376264336635633132633332373634383864626564623966356464373864393832323738
37616338646633326636323461376137663632376262363738303336616463326238333465343533
31316132386530326539
31303035626165353032623535326564616362663265363634346230393632623233333132636230
3263666137666332353136626438663063633165663565360a333934333364653766626263383464
33356463653166666236623638303561376439373261306334373134386262636534623966633436
3330613861353861350a393035363566383862643563376436643665626135373830616231656365
61623530363761616533376434326230363132666433356238646139393730343264636362333463
63396239313137376262356664323562306634633064616166316331333862326436393861346632
33393031646532376462313837343762663266363366353665366266623434326538646239623234
65623362333166326566303739623466376537353561643034616563356664363266643462323435
63623436353162376366343836626439623963313764346562363061653832646130396463396530
35346631666633383435656563366164306432663131326633343563313134323937313961613437
32383163613439636561383064346238303535613339366234633234633430353232613761636562
36323461623465653765303533303230303064363762393865326533613336346366386630353339
61343164333262646239636161313231386133363037666539653666656461653666356239663234
66373736616232336466623030373366393366393032656138306465636431383937646461383136
66303733626633386335623835383135363265383062343937306564613234366435383236356537
66303638626365343066343537306636623935393637616237306337363538616262323735363230
34343561336634393334323763393836633736383739323661303531626133383834333138656362
35656463316136373861613636366165383530383465326666616139333130663230356166373633
62613535656161633530393031663063363031656431303338613864653739323932646333633236
36346464303564343661653138323433306563306566623966313664643030653934343230626333
38343732653264376530343933326365363864666539306562366434363161633132373165393234
65333339356330323562343032343334616439343963623066626538633238663132383965656231
38346230666230396434613036333466633334386337373437383063336364366130383064633831
38303630656162376331623335323565356530326539333536623835383132353462353161663061
37353263646537633561656133393562623034363830663961623331623263303861383465383865
66303765313830383431636236313937616666303833383738353438643461373166343130353661
31623166356134343833313736623738303837303332346361316532633434666266386661326337
64396633373537656135373938663433333230353632366538356135666139363232613664356436
36346363343638303764623931373263613461303266323766646434626336313933303237353538
35643165666232653831356530313330633831323461303165623436343861656431313266333662
31313461663438643062376262343365633534323837336434616431656131626530666637666432
63383461303562633063343863633832313530373334303234363366363966303866303138316131
32363034653836633636393864643966333032323830376465663937386238666539373733333838
38363964363135343764353432383736656561653164396361393337616335633635316536326333
36376164653565646138356433656639343938643039326165366461643230393364396439666266
62346163653531383934666562613563633163613239646265623236356132653230303032376364
37323532303666333964346132613262653236313239623531613933383362623233366632313736
39313162303930383637663237636237633539663763623661326530656132633233346361663136
34316462666661343839333634666130346662623136656536366661306433306134323163323561
31626264623939313033623738313265626637353837613735343166306233333837613336353639
61356461656561383565623965353738643033333637613039353838623337336138343937613663
64376163623562633135366563623533383466656234333535393030323631343163643363623463
35303131383937643632656434366339616266653134656163633661623330666637656335666335
61373362626463323131666231326636343866336337626637306465613463666335366431373339
32343136353463336164363664333861326465363334616530643234353165353235653665633135
64656163333462366437616337343438623835313139623839626237323433303861323963333937
64323437623630613136646161303363313033316634666531323534383363366232313166343433
61373832623464653865323364623837303637303234646462633336316630303766633733653131
35393834336166323334663033613938616232656134313735373434353263306130363130613966
34626130646534623864316630383235303263626462343732646633303764366234303135383638
38363362623137623532663365333563363138306336336537623236613766383861313865376131
61626231663930303162653334666231366435356231303730666664613737303961316163633339
34393730633966323565343039626231303736313836346237386662653537613132383962643035
62363233663366383365616632316161386531646230346331343162663537393033646330376538
37636234633136633363626634303662663765643664623432636530386364663961353330336164
63306339386364623331663930373335623766306536353663643130613731323261616339616661
33373138623333633962386161333166393466643565306564326234653165353362313038326538
62353136663533336361323966633737376133366338396431333435323261363035393561316136
36323738646138363861636439316531646235396466316138366266316361356664353533346630
61633538353661646437633433313463326332613261643536386666656637376163393266376530
65323834393131643731333863623134613638633235333064653238383363653432363736396462
38393939383139326362623937643364303833663861383130323235313865653831656133613439
63303533613065626434663066643531306139626133636532633932633037323536373439613461
61353234356162363936313065636136653636663437663536613563656333353039323863623861
61393630363237653963323534316130333363663561376133323039326137323766636161653831
65663937663434333337393863323134386638613435656130396366636139333130353334313762
63326661373932653561373635633332366332306133626133303066376137633531656562313835
30393266393161643836326664343731633935356264356136313362386535376466343333656264
64383663346365373830393333313735623536383732363037616231653164306337323832633661
37663437653763373563396465333836613964343536636564343665313534616230613162303338
66303337663361643635663639633832386263663539303361343934613864396534653135353766
33373862643332356530303964623839663732663536656134316262346334623361653266323263
31636133356635623166306463306637383333383235323334373736656262393661323564656166
36666161326436396133336662633465636532633262616132366162346365653433623639323830
61366466636165303563323738333831373931346563366561333235343539313930396637616461
61633561636136613665386162646130663864316238356130313931376238353234633137383734
36343231333266393030333630663863373663386333353039643661353861316366666339366634
66306334653339303762666135353032643034633739373330333830366436666138343738343534
64333137303963373438336361663136623064363665343835316661623831396532376362366134
34386463666532616131643331643436613630353561346430383736616437336466663232616165
32303434373035323235666564343136333161323839363361326162626465653736306332396530
33303637616637663566313665356335326266616534613234343839326138393664393062336430
36626533666137336366613761356438616136643436326437633936663961613434646263336439
34383538353063616464303931633561323834623761373763343962343663653265613030663036
32393538626530373838653934626333336161383235333265636665336638353839623061336133
30643439396638343261613831326131363837366462333031626666623031346236643437386532
35376133323462336262613563653830396461626362343861333139353038313966636363323836
65326466653365666338613566323461376333393162366638313733663631353933343962623737
38323365373263643138393339323937313864356131313032613463306135336134366230346339
31363966306339383264346464646361306463333031626534346662396336336338616563616338
37626434366161656238646433323133653430626564616336613461363661633138663932653038
30646435313436303937643730336434373666386163383439623338393862623838356537393836
65626165373363653638633164653761353265366461316239383732633837306636343137623962
33396435343837326338336538646534613131346463393862623234643363323030373164623533
61316466336132343433346538623761616535306564306566396339393830353235323735343463
64336462313630303631393839383833323337326337333166383431306638626166313064336336
39366163613634343933343462393932643666326434336666636262656633393762653536623838
61353438343165633038363135613665663531646261393534353938653262303763663166643737
62653935393338663065396462616534326237396434333566396466643238393734313036613532
61336663393537303031316161363165626661366166616333333761376532306462396463393932
35633266303665343836626335653238313065336435383634383566643162653337613531636165
64616635373533616230656335366131623262396163393561346432636363356132636339393861
65303735333935663232393162653739353964666164343434643737666538316633383930663232
63303230373233383261633863393638343562343831323834383163616333393762613265333961
61333032366433613032373263643662653066316162663438666666633430373135653738303763
62363139316638363463646631656266363333613966333365353666393233623336376338393931
66353438633435313061336435326336313734656230363061303530383937373965356563326462
31386661656533303861366333313939303337636264373432343466396633623533323662346536
39373865613639336265663862653735666239616331363531393138623763326363656437313963
65393934366430626464633632323738336636376533333963343536616135366131383934376138
36303562326463373261333538313135366435376138313539316236643334356636373063663930
62366534643333326362613163356532616266313436626162643132303266353962396365666162
66386231393161343561393864633566656361333439336663333763363339613439383231346236
63323635656635353135363535613134383465363331343730646239343936376333353636616639
63313439346262306634316236383138346166336365393531393930306139306637313831316666
37636133643839356366363532333466336438643739636136656338653533616231626431613439
37643565333863383633656233326432636662386437663731373564616135663661313265373430
65303830313161366437633233326136303330373163343736333835333736643035623062656136
66383436313165626234326438656665303831386563656330336332653130633964626633623764
30393933643362653039386332366463633363353834363866343061343236663738613061613739
39623736393364383635373964323163353338613466663366333631383531333736313265656566
31313537343039333164326232333166613238626533363832323732333261323864333532383737
63393664303362636662643130373565356364386362386261626431306631326633373230636531
35386134326435326539663138343532646533623130363163343164356461366662303833353732
65653638613437653835653139636563313263656464373166373163633861333761326562313436
32656130313938663433646265656566646434656337376136383837313263396537346130343536
31343466356463316338393862643063653331363434646563653238316532343764636538393461
38666365306532643437333164383661313365363831376233646165656633633236313962643233
33313763623430336132363639663734363762643732383866373762393533336162666361326638
64633231623338383633386636343338643138343362636636393239373231643031663865613331
62646366353439653465616337346363373736616137306339663433303234626536323633633233
65623131343039613366346665393235626364333063663830393436333633626464326465353832
30663561306138333831313033396165313063613063626563326562316636393036666638656134
30306665393137373865663161323435366365393731303764353539656266653035643964373462
37326639613161373436663335356666323964383737623133623962666138373465346462623635
30353664336664353539346264363563383330363964316332663734613566363035383735633333
33613830343633343631313362393037646564636333316231346166393935343739323962633636
39623063613631383561363030666131636437326537346333653739363434656334383638343635
33343763363234323137396239653239386533613263366565343865616131666331376465666562
32383130636462363861326264376362626264393666393366346165633131643734366130313334
37666336376433313833336131623234306631363832343039613333666134396461636565353432
37633633616464373639343064613635643466306661306535373935643563623163306336376637
33313638303436336537663463373535663133356362643763643430313462383538383939393133
33336632633835346139373731626430626561316366363665353039353134386265323634623861
65653837393465666230346437336538623765393131656638323238616537326432326531323166
38363530343335643637666262363763326466376265656534636135376135336563343735306162
31646231303932616439306666396537316161633032636638653238616637353738373539616664
66666661633137306331366230616531336461353963623934313635356338633030376461636261
61316561633366386661383666393961646264396537383439626262373833343036663365393339
36383862656331643035303635303935346366383534663563653237386435306439653931353566
63383964353666616433353833626132626362383065306561393539666637653834646633663865
33346365333262633362326537646633383031313536626462666363373236313663313264636335
39396330376366636339313133666437393833316161663264373265633962636338306534626335
38663962303130646239653439626366326661646239333235336635346131376264303562336130
31663165313665356639613937323165616631656535353564366265316637373135353366623332
39383333356636323763623630616562633364623638666665663831336665323031343037623930
36643762646139356661386262373134386431643536663038636236623636633364666333383135
33613638613261316333333033386461386332303363303138313031666264623039393935633731
32323937356639343834656465303938623338356633393233353630653039346534643366346535
62353134643563323966326434353764366436653036623161356239326162343966356164613533
35626138633537376637376334396535643132336631636137653666363264306166333733393831
32373563366135336463613734623565666365383266613931656238333631303530363965643938
39396330326339336336313236336432396262643066396133633036653938613133616531393036
35323265323966386465663331613062313763663131643663346465623031626662646133333734
64616131633665653135666562373666333362306233323539393234303866396333646536363238
31363633363431616263376339376238313162363437376530653366626439333364623764373864
30333832383532613836376361663934313561373733343166383266616236643034616662313933
65366637656338653437616166396637323633386334613461363062386630316135366433323935
31326232323361366563343039663837366337373862353833613336336633636635616565653164
30316531663432353238663037323866393766373866353766663434633937303032653362323664
30653334393439363638623539363337666161393532363933653961323662343761313430616538
35646563373563363761663635646338383566363731363264653330663635386232613565613264
33306266346361356632326332306534643865323032346330663966333964313535383261316161
61633535303739343763333031353736643664323731363839353934326132656364366566343462
30356631313636616165623662356162643261646139373366333433623661313164316664653539
66373137666130666535356435353738663133353864323537396433346261373131383764373863
62373836333962333665376539356632383361353561616334653139666363643937633837353839
36653034333930326135363565363662313731383135636231343666666536363934623034396131
31323166323763326231333063656139333730373030653631323365343637343536303365643965
66623733656165333030643139646439343030666633366534396337663236623763376232346465
39333839336538646262383034646335323066613231353731666132313734663637396563373332
34663462306565393934383436363865343932613336396238313231323636303765646164356565
65663463636331633336626438656334393366343338383564383231353364316361386265666566
63376464656263336461333661383333306566663130363939323131316663633662653738306366
35626361376163393864653332326463653564373563396531643532343766366261653332313131
33613463313136336434303039356662313233366137633062636232346365366361636633316565
34363562613265643437383033393062663165313536366634303661303533663862363234313839
30613032666164313231633837343532306331366437376530633364656531633534643335616364
31653764646236656637373036656532366631363164346262353034336564373233626263383333
37343166653865306565383036333032653335353161626630376436626363663065343336653936
30613339393064393532643862333530656432343631353766353338363464626535366164666661
33366634383339666135613430333832316262376162336530343363663666386431653232616664
30653637353432376132316561373031373330373266333731393338613635653466343464313334
63636435646466393766653135333037623638356630636239303564353337323135613233336461
30383537326139343538643633326331366531353732643730326465363566623265303030376534
34313635346134636337623737633838336239643938316264616435303935363036323339316466
66653238613463353964383231366563316666376237333631393435666133343636666435643535
38373635363134346636333563653063656537646331316264626139323631383039303564376461
32663633623538636232306532623261633563376562366133366665633862313431323032643666
65363362363065626536653430653562316539396264633865346233343430613333303865636139
66656632363564363739306666316666346239336432363762373730353637333465363337653466
62313765336130356532636261366232666462616464383439366334646364383861616166383836
33346162613132656262663062383962323637333332373062323936313365323864326439373565
64386235633531663366376332353866366333643736663833396361633836353061323333656132
36363761353566643432363634323861356534393664373761383565306436303239613532623337
66366666613932356138636230306434396365356338343437643534343634313432643238353234
63393338303961353264613666346339396439313838633431366134613931666565333263316632
37636365616532656339303963633135613830356130376463336335633331333065336538643963
34616565353630393932393231646433666363356134623363323462386136353139363931326237
66336637643530616630656231316662356138336266363434363463306236623462303764643162
31306634313630663530666464303337386664666361633539383531346533626335333939353432
35316664303938656436363631666465336561626431616663646239313938623961626230366533
31373961373332313264616433316366626464323462326462613736643137633031646536353766
66383465376262393133663931663835656236313334656563386266396237363963663430356137
31383563616165356539356530353566383431656163663130343865623639306165306531363966
33626638353532643063656132326239363166336334356236313334646538306162386530666131
33333935653933643331363636623738366263643162396131616364333963323534333263373439
38396231636532663934326465376161626565333732353037306131343763343162636539373763
30656135383263653238623062373231636436613733373131646433616336396531316230373937
39383231386438316432373035626639343266633335653835656262643433393434373333643863
36303833396363663430393164666439316465313662343162323435653632663737326435356239
38646531613439633737326539383361356237336561373264663838313535396236373033343535
35303234656666663766323261613739623930396239636639393936653537383432613165636137
34613964313136323430383564326265386135316339323962633830373931613037303436633361
62366438313738633263343833303237383239333534656538393432666332376461326631303637
30613037666237366666616163303662346230633131393938643636383636313436616539633337
63363332333662303761396339313766383132373831346266323234313338343430656136643638
66633964636337353736373464376631306139663539333464303438616334306666623061346537
33356665666661356363383066376665386266363434636262643430626130323163306634306633
37343735626361393036343566346230363137326563643666316630636139343865346430356131
61643535396264663336663866653762336330396135666162303665643733653630346565623236
35336630616531383931386430336238663963316237643765666534636264623533643930376637
37633031393532646362333733356165373431363330323863343362643231636361343862633666
35633834323137626137346230313662373166373039653532636163613231313737396262643364
38656465346336333965663463393963303136636131383432376465303463616564616631346139
33336535666133653563303039353737333162616239643261323333316334336335306230303037
32396339343137386632636636353730336464386537353530353563656464376532326133613933
33313734346132313066303538303761633333303564343262313636346238323236626566356535
34376232616335336539333662373563306637613232643732376635323036303666393737316139
34303663323064623863393262316533376263636332653964343235613338616137366461386132
39383837663538653533343663613963613066376432626464373061653139323139313937353031
34663961313435653263306263366366366632356236366162623239363336376166353137623136
37366434326539333535646339323738336238383835303163363334663737333135643564333763
61343236616530376335323537636666363537303130383064663666623461303339633564333836
33373031306639653063313063613763343332303861666533383239366463306538346566356466
65323161646264326330663164633239336562373564616131306330343765636339353237363134
36626462313962303132636463326534633035666335376133376131623863666563633066336537
65623264653066623566626534633662396163393438306239353864616565333531623939663961
63653461633565326463363165356664373164383631656131633663343336663137636561343363
61353339613033336462366131366565386338663435346462346136326634646134613234626666
64386465353962656532656631613735383664653932376334663262306230306130613936353732
36663538336634346531396438386437633038313830633164353664306533343837353332636530
30666231666532303437393264623030373461306266316335613465313531396436376563653932
63383764623635383634646163363435366430383864666632313437356135303934633662343366
32643834363937393162393666373361306332613233663564653633373935613661633131666432
66623931643436633964353433383264616462366235336266643630376532336566636632303038
65363838313539623132636464336632386164306632316230363136643038353135373635626164
33613239623438663832376137616530643762393666373063623734633164663961386666613137
36616638653038353235376138323666653866393035393937353631653532353734646663623932
39653162623833316262633033336363633030336561663231363730396535623738613834376636
35633434306438643937396532663364343661623134646239306362623432313930643333643834
35626231393431653238646635616161343733363662376638666166613835356236393135623439
31623465653532386438376235303036383434616135653438343431333832396563353066643161
65303361383437323032653033393663386338353164613137643662663231336332643639613530
34306535643161363064646432663961666537346464393530326665353237356134633162343839
31396466386335656236343639316136323536363231376632383364353236376339376535326164
36343334643433636238373363663435383966666133636164623863653232383064326237333234
38376435373066306462613935313735316232383364333733636261323835646237363262323862
39356465363132336235613465663762323230623133373237383961363936623763643430643235
31363931333964656539383739646234666433346663653965373833353633366566303864623863
63336330343666386135343730623432393934663532656565386531393762633734653438633461
65323366613334626134636631353631323238643138356164656461643866363061303062626433
64373662643532303266346333633366383133333339373865363137356632666232363039646538
61613130363763303563393735373761336164626432356664393063386162646233306166336563
31633739316662356630353938383861613964333661316237616533363530376230613464333265
38636133646235643438303163396132616537663630356635346332316364343966393131383931
31646536303761383866366366303131363634383062623731373434646430323237313730383531
61366533666162373962326132396166326564623464646136653730326430623631363762396231
61343333613162373865353165663331336232656139633766356239343162326664313639363135
39613639373736346531656636646136323237646232343139323164323665363664363665316630
30373164333166346437323661646463323032336265656339656336306431636537363433363533
34636464353332663138313132363739353065336564333532366634646238303435646237303533
61616231333832623036326133316534343566323134376461623338653836346435366433346438
39366563336436333466636433396230663136306666393334313039656365316264313561613863
32656238373536343132333135653131643936666231393165643662616434623532393638363666
34346634326631373739313463356632396366653161616461333432303333363265373561613266
30316264366465643633323237393562373963396135616465356533653464333536383935663738
62353537663663363862303038306461363364353133303533663732356366323631363138383632
62613662616538326435353534373865373231393833633566333534306231363734356435633835
36343962363739343235373834656135386662326136396331333964306633623931646539313232
33663162306462333562613632666431316330396630393165633530333737356635323633623430
64653231663665646436656263323032353530613731336331383131643937623333656138646664
35366638333562373431663038643331346634316431646130663666643464343037373266353966
34663736333839333835383437363562326436363962313066613864663665373438353932316330
62373066373634346434616234376535306133386331663438613061623965383333323364663664
32336438376262376331663565643466396237323362386261643162633636316138306331343764
39356361396535373961613930623030353766666133633738366263643830343463633563626462
30373839373862323164313265316139336364633836356534623835653633646439346436663966
30343137353765653239626265356338653031633166313432353865373530373437643531333339
32613636336333373239336230326639663466383466393930336161376663386534393866396161
61306563373637653962393233663130326535373737396361633236363239633333393838316633
34656435366662366666313632333739353962343132343265363962343236636439663866623961
32323935363538313531376637356636623666346535313939626333386564323765303961343961
35306234333738386266343333373733326634666362396432323561613437306533333536323337
38626439626338376536343439343431356338353264633661613035393636633863303133616464
35353861653463386634313437323639633965323432636164343862353030323033386566623963
32303962353632326239613537623834373534356238633433633131313763353662353036333132
34613236316331373937666631386462653539623135656533386164613064383666346239356635
65666164386635346166383963366135623934336636613963393862656131396466386335616161
63633334386665666165643664363065396665353061323063613635643864313330643433313661
36616632633037323338636630643037643334613236653137343530653032326335306238323766
62356266656235333434396435646332363639356163363365623136306432643131663131386139
39666137313563373462653638646230643762393962373963623161366233393462343036373738
63636464393263356331663030646433663737323233646461323963383335363962616337373934
39623865623835623163323164626162393038303931343036383738306463643431383364663564
36623563363363326665303731643531666434323338373935643736306138666665313035306136
37666230373034333261643431333262646238396436346366383063616531653961623834346336
30306336323665366131323462626330343136346334343435313630653135333637383731613963
66636537386336313235386234356238306361623962363263313039393362353665346664363637
63646433393464623663653431323133393235313834646266396265343633663732396638363532
63663336653638643638336566383239303563393764303065636138376336623166356634396432
64353763386230626566646634316437383963653462346435336536363462333038303966663931
38613732306431323937393336386133396362323939356330363132653736373433373834663433
66366333613263396131396434386565333065623335353661363336373530393934646630306337
62623062633038316530303865373662323332353065386430643530383335316565356135373666
32323731663461303138613064346138613333353534376232303538323132313039386264323732
61653033356337653735323330303462303439666464393262623062663736626661306139303837
34663330313539336663363830363963613561336664633330326265343333653839663462353230
39373938626266616239643939353937303635343661346535663564616136386337633533376137
61636135393836643764633838633361393230303730383932333564363739633137343930336331
63343937383763363436366335663763343534383963363266346637363437336635376566366230
36633535356239386238646136376431383061633939656663373735383235326663623365306438
64366433336638333235663437353463633937396136663764623166353864613162303832383566
38303664303962396362643631336536353264616335643332353137366431396464326635396634
38333038373933663236623632363431386337616566313765663633323163663933303337663938
32613133343730396666653461373161313139303063393639613932393864376337323363376332
66623266613962663066353335326230616438616562626337363965626564663666353064353530
61393934643933343132306265653235383430653837316365383265353565343762616135633866
64633766653133653335333061303233393038633665363566633238323530373239656661666161
64393262656162356665646661383861303765383232363639303065363061303332336562333732
31303363653136313732646362303838343838373533373338313838653633643136666631363638
31613738396463663931393064643132373564373837333337343832633837336663336262616665
63326134376538313466353333623633383861613534373534336464396261643233656130643765
31393631653630333233623339666535323031613939613835303764646161646132306635643161
37313361323165393034366365396139643233386637323734633463613931656238663537373562
37396265373364316561393433363635326365356338623038656439623539623139326235336632
32396264366634646330346232376636313038656164323134653866383864363864383965356666
65336363333035383637303934383533313663326431323637626133363338363538633634333035
34653961623933376534613239663037396432316237313137396339623239393635383966663164
65636266633439376135613832383634343633323366663833323533386239643762303638623738
35626335373135636261616138363739633134623738633966366139643365323264326239646636
37373231656436343431613830303662336138376437613938343439633534666563323930663938
65643036653265396237343164336365666337633361616433393561356537316438336331666365
63313964373235346534326639643264613539326366653938646663333365666538373530363064
63326431393130336165613632393662663563393066346562383534613833346661363933386135
63366636346539383636356662306162353161646132336630303739313866366434383464396136
39643163623433636164626563306138653265623563353262333831303438363739393762333464
32313664353563663364366636313366313639313134356332336562363864663862666632623832
66333236343136653439353066326434633032353139323563616666336239343730336530613761
62376231633237313732393835346665396634316263356630396463343137363936623064306461
39663935353463323565386333613464363438653237633666613261343833386336333261323561
33303662613239333362383661636235643861393734333463343464633930396434303562613034
36383130356635623665306362623235373364666137636336313066323933613836373431623635
30326465323366306566636637313631363963316566323039396430396139336234306330316465
34623339396136643938353066633036643837363562646539653533303035333562373436373031
30353438636430376265613038303633656230353934636163383465303363613936613236663366
63626637633432303463306666356562363065313335356530346539323961313035393865666633
36646332376238663166666264376132336637316561616532623534633430646139373532663138
66313066363435303864623866393462346137633730323066623337663335366461393632626138
38636635626365613561313334616230363936383231356465383439313138613136356462316634
63666131386339666464356533386333663562373733353434616666666639386365633732303737
30663364636336343362316135323236346639663939373365396561663766313362393838633737
36323835343731663235363234306332616336313035636235393038316438366538616162626634
36343836373966383538353034313264313732653635396233613835636461646231353365616163
35383835333466343934333065303538366361616436626338333137343332613965323964323836
66323665333461323864346635393636333163396334663264393337313863646139396664613163
30653637613864646631666364373162313939353339613732643637353635653665326164383261
30336135313766323933383232323630356139343934643863356434333663653830636561613731
65613861333639393434646436646131353563663864376564626439316261303161336336383739
32356635656637633066303638376436353261616232313065303431653036656235323337353933
39623930396239323766346362336131653137663533343133333937626535656236343639613363
62396338636336323531653564366133643866366432353566366661393263666164653630363738
65316562333131643966376634396335383131366233326231386432623364323235613635373635
35303931636466303130303233326531343539366238376363333138666234636532363965383239
62383734363734643165373734343963626538396237323530643333666131366365353538666662
37643939643661383138383638363431343330363931306162303963366230636364633066373537
39353462613265363439633961346331323864326536656536376534666534623936653231313263
30336232636434646666393566653164653834336563313236323965306663663431656239656163
66393136333937393863396531376561656538316164356265343532663262336539363662643962
61643031383037653964383661386238636633646232323565363162643239393330373263326432
30643561623733336234373962376239346663363537656462366134623563363762613462393333
34346431393136613866386338303066343463316233633933363636656630363638363264376263
38343564376564616665666432376336383833633765383263383137306235303934373230653862
34333233303037326432396365393339613261663266373465306635616435616363316563326436
38626532363862373832386335323335646462633138396162663134393036666166633337373638
36376562656434363835303336343638306538373733353062313530316363353161663732303836
63393363306336376564633462373265313539313130373434383539363462623131613862333936
62336630346133366264663665663431313437633762663530636266623935313766346233633438
36326164333839323562313930653038363262326539393932303237663563346664363032336630
38353530346166356231323062663666623961623835656265306561373332613937303739663965
61303765353437333465323165396438623838333635303164653764336633363633303935336264
33383934396630663564313163633466393861326330646461386536373336633135623030646266
37333134616531636135373063396330666538313736623563363432323936633163643162333466
31323739663931653235346235663832396437303932383066613633316362613563363530396463
64623363366237616632326531633932343231366430366633333633336462336634613033363830
36333661343765303336393263656231313739323038363038303436396130396335343539336561
65396532663637663234373431616536653661626665363634303265393931353232316236386433
63656266613965366439326630336533363166323935393230333930353166393465623438393830
39343332613462323437653865343333353338326636363735366462303766343433623136363739
35393630646537646333363931393764653036306261306633353463333939613837316265643961
62346234373039343066336534363065636339313031353264313537393063346566366263373339
65623539316635386532346435303631303030656262396535663433346138333963353732616562
32643334663139616439303639313333303064396563376361303933393437356664636331343631
33326565656632656638656331653435643433646533383036653264663232313730346331333139
33393238363165656137383130373137616132393366373930353161363232656532363936303038
34353463303666363730353235323034386230373333653263373238343863333233663261616431
34316135363537613762383036366333386165346361386165663939623639323535313237353365
63373135633538303232643065323232353434356365393464373963623066316138636661616632
34333332386432633933303465353564636361316435356163633765383833303437656535356130
62363863633433323231353932663534313166373734353837303861336162633438656237653137
31356330306236353432373431306631306561633264656663383266653266633661633237643461
34383566636162396234303233303534613461366466626135643336633635363037376638346338
65356334303633306536643966336561646531623633663664653461326237383331303232666666
33363765393334326130633634396163616639383339306137393364333233633539353263306663
38313866363737653932616339653230613830336132643361373933323466303038613831353066
30653963343637326364363537343437376661303937316464656234636161313235613839346464
38386636336262356561623566343931626237653663353137633935653830383033393035666565
62626239333731623337616636386361376565663966613562383639303338643737333063333161
61613937393865393037313932363639343562303561653633343266353237356666663966633331
33333430636439313464373632396163326531636231623066396462373738653366646238636535
37343461323565376339376362356364663239653463343362613966653337343733613366306431
64353862653365323336393330333863323564613664613838653036336466323239343161663430
63373039333433396238376134353666356632396434303166346134333562323862653361626134
33353138623863623739376134643561303565373261326133626630313035313563663763313131
31613832643063356261363639616265316332353864393764383532333538393938336433353333
62366334343139656139623165323935646439383437353636326666393935663333653533666132
39396539376263623834373137323430643538383336313932376166386465303361383166366463
30373466333663343835343730383331316536383261333934323166333765633339383161303630
65336631393638333638383431663035336162333062613630323130643663383135643161373736
31643465366539626136316565373666623631356434313633653633323533373531666265306235
62366237653136626238393462633165336532633734336662373130336538343162383366633166
63333333643038613561633137366233316432363836316333353839313432613837323364393031
32653165616432386565656666383737356430666239313363396263306335316435356232356238
32613032376536323130646439613832343364306431616337343730616239383730613139373365
37373638393264633935383166313130633864646635643734653832313037613132663666353666
33376466313838633530666136343132316530613662346431633063396165386133666230363333
66346630633366396137356430636330373562336638393936303438333531656661616161373939
35663037373332366262323731633166383336393236396461363535643535356138303266323862
39643332373534376234636133353735346662366435663930643464636135636633306366383835
38333137656264623765323239306365306661346466633638373735613735633063383139373464
34653934376565656438633235386433636434316565646634303332313636613662363661306138
31313562616364633832623934306461313739376236323334623761326335343334353561303762
66633937363234313039343534353435623035636234363462306363343035636536363262353165
33623930343234613964663332383833383433383537636163616137373134373032333439633937
38623033653437316336663931306235623264363934633139396132366430363133616163663033
63613631663963303635316161306131326137613439353564353438396564313635623030643935
39333866633130393062303332346566653461353036313134626131363766323632323465656363
32336131643466623139356639613035623531653061393666353431323638373735623066333564
63303664373765633830346534333439663963393163333764343162303265663465346130376164
61376631393438386430626663653936383330643131376431303136333030346466336439326466
64663962373731306462643662373166383333313164626135633136373930393734373932663663
34386235653037326332653432653435636132636263363938666561333436643734653264636431
31653439663264623837326561383464623163666237653433623433373336383931303066663265
34313663663966383133666534323366323739653336643437303564386363386363373033356464
35633364623062353561613331306463396162333037343037363966323330636131386530376338
33376234636462613239386134396335376563623039323033323064306537653136653239636161
38353766633562623135343461653433663338353939633330353462323737353536366564306134
39663034653039633463386230393032616638353737656438653662653833643861393235636331
30383039393130623362303533613535313365663434313662323633373039393762633731666232
37643635313865336637376666363432336563613735646433653337346566613764366466346365
646230383034313737303435323762633061
+12
View File
@@ -0,0 +1,12 @@
---
# Single-origin layout: one hostname, Caddy routes /health, /ws, /api/daemon/ws to the backend
multica_layout: single
multica_domain: multica.wdw-labs.cloud
# TLS: acme_http | cloudflare_dns | internal
caddy_tls_mode: cloudflare_dns
caddy_acme_email: "ryan.blundon@protonmail.com"
# Pin for reproducible deploys (git tag + matching image tag)
# multica_version: v0.4.5
# multica_image_tag: v0.4.5
@@ -0,0 +1,8 @@
---
# Copy to vault.yml and encrypt: ansible-vault encrypt group_vars/multica/vault.yml
# Generate secrets with: openssl rand -hex 32 (postgres: openssl rand -hex 16)
vault_multica_jwt_secret: "CHANGEME"
vault_multica_postgres_password: "CHANGEME"
vault_multica_vcs_secret_key: "CHANGEME"
vault_cloudflare_api_token: "CHANGEME" # only for caddy_tls_mode: cloudflare_dns
# vault_multica_resend_api_key: "" # optional: email verification codes
@@ -0,0 +1,26 @@
---
# Paperclip Docker deployment
paperclip_domain: cranium-command.wdw-labs.cloud
paperclip_base_path: /opt/paperclip
paperclip_port: 3100
certbot_email: ryan.blundon@protonmail.com
# vLLM inference endpoint (local)
vllm_endpoint: "http://127.0.0.1:8000"
hermes_model:
provider: custom
default: Gemma-4-26B-A4B-Hermes-VLM-MLX # from http://fantasmic.wdw-labs.cloud:8000/v1/models
base_url: http://fantasmic.wdw-labs.cloud:8000/v1
api_key: "{{ vault_fantasmic_api_key }}"
# # DNS entries (if using your existing dns-manager role)
# dns_entries:
# - name: illuminations
# domain: wdw-labs.cloud
# type: A
# value: "{{ ansible_host }}"
# - name: muppet-vision
# domain: wdw-labs.cloud
# type: A
# value: "{{ ansible_host }}"
+244
View File
@@ -0,0 +1,244 @@
---
# ------------------------------------------------------------------------------
# FILE: ansible/host_vars/illuminations/vars.yml
# HOST: illuminations (10.1.71.130)
# ROLE: llama.cpp LLM inference host — Ryzen 7 5800XT / RTX 3090 (ATX rebuild,
# 2026-08-04). Superseded the prior AMD RX 5700 / Ollama config below;
# drive was transplanted into new hardware, not reinstalled.
# ------------------------------------------------------------------------------
ansible_host: 10.1.71.130
ansible_user: jarvis
ansible_ssh_private_key_file: ~/.ssh/id_jarvis
ansible_become: true
# LVM root expansion — xlarge template uses sda3 partition, standard VG/LV names
common_expand_root_lvm: true
common_root_pv: /dev/sda3
common_root_vg: ubuntu-vg
common_root_lv: ubuntu-lv
# --- Staged GGUF models for the llama.cpp router (:8002) ---------------------
# Data-driven list consumed by roles/llm-inference-multimodel tasks/models.yml
# (loop -> tasks/stage_model.yml). Each entry is idempotently staged into
# /opt/models: stat + EXACT-size check vs HF manifest; skip (no download, no
# restart) when present + size matches. Source repos are public bartowski GGUFs
# on HuggingFace (no auth). A router restart is notified ONLY when a new GGUF
# is actually downloaded.
# Added 2026-08-12 (War Machine): codify Phi-3.5-mini-instruct-Q8_0 and
# Meta-Llama-3.1-8B-Instruct-Q4_K_M as router models alongside the production
# Qwen3.6-35B-A3B-UD-Q4_K_S. The live files were already present/correct on
# astro-orbiter; this pass codifies them. Future adds = append to this list.
# Router --models-max override for astro-orbiter.
# Default in defaults/main.yml is 1 (conservative). Bumped to 4 on 2026-08-12
# (t_33acbb2e) so the router can keep more than one GGUF resident on-demand
# and LRU-evict when needed.
#
# VRAM NOTE (t_33acbb2e, updated t_55c164f5, updated t_34b96e83, updated t_f5f7e9ad, updated t_441470b9, updated t_c5cef2b2):
# With models-max=4 and all 6 GGUFs registered, worst case is all 6 loaded simultaneously:
# Qwen3.8-27B Q4_K_M: ~20.0GB (weights ~17.1GB + KV ~2.9GB @ 65536 ctx, q4_0) ← CORRECTED (ctx rolled back from 128K to 65536, t_c9fed26c 2026-08-18)
# Phi-3.5-mini-instruct Q8_0: ~4.3GB (weights ~3.8GB + KV ~0.5GB @ 32K ctx)
# Meta-Llama-3.1-8B Q4_K_M: ~5.6GB (weights ~4.6GB + KV ~0.2GB @ 8K ctx)
# Qwen2.5-Coder-14B Q4_K_M: ~9.0GB (weights ~8.4GB + KV ~0.6GB @ 16K ctx)
# nomic-embed-text-v1.5 Q4_K_M: ~0.09GB (~84MB, embedding only — no KV cache)
# Qwen3-8B Q4_K_M: ~5.5GB (weights ~4.68GB + KV ~0.5GB @ 32K ctx, q4_0)
# Total worst-case: ~44.5GB >> 24GB RTX 3090
#
# OOM RISK: Full co-residency is impossible on 24GB. LRU eviction prevents this
# in practice: models-max=4 means the router can REGISTER 6 models but only keeps
# up to 4 LOADED simultaneously — the router will evict the LRU model when a new
# one is needed. nomic-embed-text-v1.5 is pinned via sleep-idle-seconds=-1 and
# load-on-startup=true but it uses only ~84MB, so it never meaningfully changes
# the budget. In single-user homelab operation, only one generative model is active
# at a time alongside the always-resident embedding model.
# Qwen3.8-27B alone uses ~17,804 MiB (weights+KV @ 65536 ctx); co-residency
# with Coder (~9GB) = ~27GB > 24GB. LRU eviction handles this automatically.
# Ryan should be aware this means model-switching always incurs a ~30-60s
# cold-load latency when switching between Qwen3.8-27B and any other model.
# Proceeding to models-max=4 as instructed; flagged for Ryan's attention.
# Router --models-max override for astro-orbiter.
# UPDATED (t_f5f7e9ad, 2026-08-16): Set to 2 because Qwen3.8-27B-Q4_K_M
# uses 17,804 MiB at 65536 ctx. Only nomic-embed (558MB, pinned) and ONE
# generative model can be resident simultaneously. Co-residency of Qwen3.8
# with any auxiliary model (Phi 8.3GB, Llama 5.9GB, Coder 9GB) exceeds 24GB.
# models-max=2: slot 1 = nomic-embed (pinned, always loaded), slot 2 = LRU
# generative model (Qwen3.8 primary, cold-loaded on first request ~30-60s;
# auxiliary models evict it on demand, and vice versa).
# NOTE: Qwen3.8 does NOT have load-on-startup — it loads on first request.
# This avoids an LRU eviction race with nomic-embed at startup.
# UPDATED (t_72646029, 2026-08-17): CPU offload for Coder + Llama changes the
# constraint. Coder and Llama now use CPU inference (n-gpu-layers=0). GPU-resident
# VRAM: Qwen3.8 (~17,804 MiB at 65536 ctx) + nomic-embed (558 MiB, pinned) plus
# the CUDA-context buffers llama.cpp 6ea215d allocates for the CPU models (~1.4-1.7GB
# each) = ~20,004 MiB steady-state, below the 24,576 MiB physical limit.
# CORRECTED (t_c5cef2b2, 2026-08-19): ctx-size was rolled back from 131072 to 65536
# (t_c9fed26c 2026-08-18). Qwen3.8 VRAM at 65536: 17,804 MiB (not 20,302 MiB).
# models-max raised to 4: nomic (slot 1, pinned) + Qwen3.8 (slot 2, GPU) +
# Llama (slot 3, CPU) + Coder (slot 4, CPU). Phi (GPU, ~8.3GB) and new
# Qwen3-8B (GPU, ~5.5GB) can also be requested but evict Qwen3.8 due to VRAM.
# models-max=4 is required so CPU-offloaded models count as loaded without
# evicting Qwen3.8.
llm_router_models_max: 4
llm_staged_models:
- filename: "Phi-3.5-mini-instruct-Q8_0.gguf"
url: "https://huggingface.co/bartowski/Phi-3.5-mini-instruct-GGUF/resolve/main/Phi-3.5-mini-instruct-Q8_0.gguf"
size_bytes: 4061222688
source_repo: "bartowski/Phi-3.5-mini-instruct-GGUF"
- filename: "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf"
url: "https://huggingface.co/bartowski/Meta-Llama-3.1-8B-Instruct-GGUF/resolve/main/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf"
size_bytes: 4920739232
source_repo: "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF"
- filename: "Qwen2.5-Coder-14B-Instruct-Q4_K_M.gguf"
url: "https://huggingface.co/bartowski/Qwen2.5-Coder-14B-Instruct-GGUF/resolve/main/Qwen2.5-Coder-14B-Instruct-Q4_K_M.gguf"
size_bytes: 8988111072
source_repo: "bartowski/Qwen2.5-Coder-14B-Instruct-GGUF"
- filename: "nomic-embed-text-v1.5-Q4_K_M.gguf"
url: "https://huggingface.co/nomic-ai/nomic-embed-text-v1.5-GGUF/resolve/main/nomic-embed-text-v1.5.Q4_K_M.gguf"
size_bytes: 84106624
source_repo: "nomic-ai/nomic-embed-text-v1.5-GGUF"
# Added t_c5cef2b2 (2026-08-19, War Machine): Qwen3-8B dense 8B model for
# aux tasks (routing, rewriting, structured extraction, tool-call construction).
# Source: bartowski/Qwen_Qwen3-8B-GGUF (public, no auth). HF filename is
# Qwen_Qwen3-8B-Q4_K_M.gguf; stored locally as Qwen3-8B-Q4_K_M.gguf.
# Exact size verified from HF manifest (content-length): 5,027,784,224 bytes.
# VRAM: ~4.68GB weights + ~0.5GB KV @ 32K ctx (q4_0) ≈ 5.2GB total.
# Thinking mode ON by default; use /no_think for latency-sensitive aux tasks.
- filename: "Qwen3-8B-Q4_K_M.gguf"
url: "https://huggingface.co/bartowski/Qwen_Qwen3-8B-GGUF/resolve/main/Qwen_Qwen3-8B-Q4_K_M.gguf"
size_bytes: 5027784224
source_repo: "bartowski/Qwen_Qwen3-8B-GGUF"
# --- deploy-vllm role: vllm_models override (t_r1d32b_swap, 2026-09-01) -----
# Ansible's hash_behaviour is "replace" (see ansible.cfg) — a host_vars list
# variable REPLACES the role default list wholesale, it does not deep-merge.
#
# SWAP (Ryan direction, 2026-09-01): Qwen2.5-32B-Instruct-AWQ retired,
# replaced with DeepSeek-R1-Distill-Qwen-32B-AWQ, max_model_len=32768.
# "Single model only" — nomic-embed-text-v1.5 (embedding, :8020) and
# Qwen3-8B-AWQ (aux, :8010, already disabled) are BOTH disabled here.
# DeepSeek gets the full 24GB card to itself. Nothing in production
# consumed nomic-embed at the time of this swap (Hindsight uses its own
# bundled 384-dim embedder; OpenViking pointed at the old llama-swap
# endpoint, already stopped) — confirmed with Ryan before disabling.
#
# Model choice: casperhansen/deepseek-r1-distill-qwen-32b-awq — same
# quantizer/toolchain (AutoAWQ) as the outgoing Qwen2.5-32B-Instruct-AWQ,
# widely used, 4-bit GEMM AWQ, ~19.3GB on disk (4 safetensors shards).
# Architecture: Qwen2ForCausalLM (DeepSeek-R1 distilled onto Qwen2.5-32B
# base) — same vLLM code path as the outgoing model, no new serving
# support needed. Native max_position_embeddings=131072; we cap at 32768
# per the task's explicit max-model-len requirement.
#
# VRAM math: ~19.3GB weights (4-bit AWQ) + KV cache at 32768 ctx (GQA,
# 8 KV heads, 128 head_dim, 64 layers, fp16 KV by default) ≈ 19.3GB +
# ~4GB KV+overhead ≈ 23.3GB — tight but the FULL 24GB card is now
# available (no co-resident nomic-embed/Qwen3-8B taking a share, unlike
# the outgoing Qwen2.5-32B config). gpu_memory_utilization=0.95 (role
# default) + enforce_eager retained as the proven-stable mitigation from
# t_e6facb19/t_ca1af9fb (avoids CUDA graph capture VRAM spike; this host's
# only validated way to avoid crash-loop-to-stabilize behavior on this
# card). If 0.95 OOMs at 32768 ctx once tested live, drop to 0.90 next
# (documented fallback, same pattern as the outgoing model).
#
# DeepSeek-R1 output note: reasoning traces stream in <think> tags before
# the final answer — this is expected R1-distill behavior, not a bug.
# Model card recommends temperature 0.5-0.7 (not 0, not vLLM's greedy
# default) to avoid repetition/incoherence; not set here (server-side
# default), left to be set client-side per the model card's guidance —
# flagging for whoever wires this into Hermes profile configs next.
vllm_models:
- id: "Gemma-4-26B-A4B-it-AWQ"
hf_repo: "cyankiwi/gemma-4-26B-A4B-it-AWQ-4bit"
role: primary
# NO quantization field set (unlike the AutoAWQ-quantized DeepSeek/
# Qwen2.5 models above) — live test (2026-09-01) found this repo's
# config.json declares quant_method: "compressed-tensors" (llm-compressor
# tool output, not classic AutoAWQ), even though the repo name says
# "AWQ-4bit". Passing --quantization awq explicitly caused a hard
# pydantic ValidationError at every single startup attempt: "Quantization
# method specified in the model config (compressed-tensors) does not
# match the quantization method specified in the `quantization` argument
# (awq)." vLLM auto-detects the quant method correctly from the model's
# own config.json when --quantization is omitted — confirmed fix, clean
# start. Lesson: don't trust a HF repo's naming convention ("...-AWQ...")
# for the `quantization:` field here — check config.json's quant_method.
port: 8000
# Ryan direction (2026-09-01, t_gemma4_swap): DeepSeek-R1-Distill-Qwen-32B
# retired after confirming its `auto` tool-choice reliability is a known,
# documented DeepSeek-R1-distillation limitation (trained on pure
# reasoning traces, no function-calling data — GitHub-confirmed upstream,
# not a vLLM config gap). Replaced with Gemma 4 26B A4B (Google,
# Apache 2.0, US-origin — matches Ryan's standing model-origin
# preference, unlike Qwen/DeepSeek). Chose MoE (26B A4B, 3.8B active)
# over the dense 31B variant: ~3.7GB smaller on-disk AWQ footprint
# (17.2GB vs 20.9GB) buys more KV-cache headroom on this tight 24GB
# card, and decode should be faster (memory-bandwidth-bound on active
# params, not total params). Tradeoff accepted: MoE scores lower than
# dense on the Tau2 tool-use benchmark (68.2% vs 76.9%) but still beats
# every other size in the family except the 31B on most reasoning
# benchmarks. Model choice: cyankiwi/gemma-4-26B-A4B-it-AWQ-4bit —
# AutoAWQ 4-bit group_size=32, MoE expert layers (gate/up/down/router)
# explicitly excluded from quantization ("ignore" list in config.json)
# per standard llm-compressor MoE quant practice — only the dense
# attention/projection layers are 4-bit, experts stay higher precision.
# Native architecture: Gemma4ForConditionalGeneration (registered
# natively in this host's installed vLLM 0.28.0 — vllm/model_executor/
# models/registry.py line 415 — no plugin/trust-remote-code needed).
# Native max_position_embeddings: 262144 (256K) — Hermes's 64K floor is
# comfortably covered without any context-extension trick.
max_model_len: 65536
# VRAM math (not yet live-validated — see swap validation log below
# once run): AWQ weights ~17.2GB on disk (dense attn 4-bit + MoE
# experts higher-precision, per config.json's compressed-tensors
# ignore list). Starting the KV cache dtype at int4_per_token_head
# from the outset (rather than fp16 -> fp8 -> int4 trial-and-error like
# the DeepSeek swap) since that same escalation pattern is expected to
# repeat on this VRAM-constrained card for any 20+ GB model at >32K ctx.
kv_cache_dtype: int4_per_token_head
gpu_memory_utilization: 0.95
enforce_eager: true
# Native tool-calling + reasoning support (no `hermes` workaround
# needed, unlike DeepSeek-R1-Distill): Gemma4EngineToolParser and
# Gemma4ParserReasoningAdapter are both registered natively in this
# host's vLLM 0.28.0 (vllm/tool_parsers/__init__.py,
# vllm/reasoning/__init__.py) — purpose-built for this model's actual
# output format, not a same-family approximation.
enable_auto_tool_choice: true
tool_call_parser: gemma4
reasoning_parser: gemma4
enabled: true
- id: "Qwen3-8B-AWQ"
hf_repo: "Qwen/Qwen3-8B-AWQ"
role: aux
quantization: awq
port: 8010
max_model_len: 32768
gpu_memory_utilization: 0.15
enforce_eager: true
enabled: false # single-model deployment — see swap note above
- id: "nomic-embed-text-v1.5"
hf_repo: "nomic-ai/nomic-embed-text-v1.5"
role: embedding
quantization: none
port: 8020
max_model_len: 2048
gpu_memory_utilization: 0.05
trust_remote_code: true
enabled: false # single-model deployment — see swap note above
# --- deploy-vllm role: boot persistence (unchanged) -------------------------
# Still permanent/boot-persistent — same policy as the outgoing Qwen2.5-32B
# deployment (t_5508360a), just now serving one model instead of two.
vllm_service_enabled: true
vllm_service_state: started
# Paperclip Docker deployment
nginx_proxy_certbot_email: ryan.blundon@protonmail.com
# nginx_proxy_certbot_staging: true # remove after first successful real issuance
hermes_model:
provider: custom
default: Gemma-4-26B-A4B-Hermes-VLM-MLX # from http://fantasmic.wdw-labs.cloud:8000/v1/models
base_url: http://fantasmic.wdw-labs.cloud:8000/v1
api_key: "{{ vault_fantasmic_api_key }}"
+31
View File
@@ -63,6 +63,14 @@ astro_orbiter:
hosts:
astro-orbiter:
claude_servers:
hosts:
haunted-mansion:
ansible_host: 10.1.71.137
ansible_user: wed
ansible_become: true
ansible_ssh_private_key_file: ~/.ssh/ansible
hermes_server:
hosts:
carousel-of-progress:
@@ -78,6 +86,29 @@ honcho_server:
ansible_user: wed
ansible_become: true
paperclip_server:
hosts:
illuminations:
ansible_host: 10.1.71.130
ansible_user: wed
ansible_become: true
multica_agent_hosts:
hosts:
haunted-mansion:
ansible_host: haunted-mansion.wdw-labs.cloud
multica_device_name: haunted-mansion
spaceship-earth:
ansible_host: spaceship-earth.wdw-labs.cloud
multica_device_name: spaceship-earth
multica:
hosts:
muppet-vision:
ansible_host: 10.1.71.136
ansible_user: wed
ansible_become: true
jmri_server:
hosts:
main-street-station:
@@ -0,0 +1,40 @@
---
- name: Build Paperclip Docker image from source
hosts: illuminations
become: true
vars:
paperclip_repo_dir: /opt/paperclip-repo
paperclip_image_name: paperclip-local
paperclip_image_tag: latest
tasks:
- name: Install git (if not present)
apt:
name: git
state: present
- name: Clone or update Paperclip repository
git:
repo: https://github.com/paperclipai/paperclip.git
dest: "{{ paperclip_repo_dir }}"
update: yes
register: git_clone
- name: Build Paperclip Docker image
shell: |
cd {{ paperclip_repo_dir }}
docker build --target production -t {{ paperclip_image_name }}:{{ paperclip_image_tag }} .
register: docker_build
changed_when: "'Successfully tagged' in docker_build.stdout"
- name: Show build result
debug:
msg: "Built image: {{ paperclip_image_name }}:{{ paperclip_image_tag }}"
- name: Show image details
shell: docker images {{ paperclip_image_name }}:{{ paperclip_image_tag }}
register: image_info
- name: Image info
debug:
msg: "{{ image_info.stdout_lines }}"
@@ -0,0 +1,7 @@
---
# Run after day1_deploy_nginx_proxy.yml (the role publishes its vhost through it).
- name: Deploy Hermes Agent (Docker)
hosts: illuminations
become: true
roles:
- hermes_docker
@@ -0,0 +1,6 @@
---
- name: Deploy shared nginx reverse proxy and certbot
hosts: illuminations
become: true
roles:
- nginx-proxy
+6
View File
@@ -0,0 +1,6 @@
---
- name: Deploy Multica + Caddy
hosts: multica
become: true
roles:
- role: multica_docker
@@ -0,0 +1,82 @@
---
- name: Deploy Paperclip stack (behind the shared nginx-proxy)
hosts: illuminations
become: true
vars_files:
- "{{ playbook_dir }}/../group_vars/all/vars"
- "{{ playbook_dir }}/../group_vars/all/vault"
roles:
- docker-host
- nginx-proxy # must come before paperclip-docker (it publishes its vhost through it)
- paperclip-docker
post_tasks:
# CLI-based on purpose: community.docker.docker_container_info needs the
# Docker SDK for Python, which the paperclip-docker role no longer installs.
- name: Verify stack containers are running
ansible.builtin.command: "docker inspect -f '{% raw %}{{.State.Running}}{% endraw %}' {{ item }}"
register: container_status
loop:
- paperclip-db
- paperclip
- nginx
- certbot
changed_when: false
failed_when: container_status.rc != 0 or container_status.stdout | trim != 'true'
loop_control:
label: "{{ item }}"
- name: Verify Docker is available
ansible.builtin.command: docker --version
register: docker_version
changed_when: false
- name: Show Docker version
ansible.builtin.debug:
msg: "Docker version: {{ docker_version.stdout }}"
- name: Verify docker-compose plugin is available
ansible.builtin.command: docker compose version
register: compose_version
changed_when: false
ignore_errors: true
- name: Show docker-compose version
ansible.builtin.debug:
msg: "Docker Compose version: {{ compose_version.stdout | default('docker-compose plugin not found') }}"
- name: Display deployment summary
ansible.builtin.debug:
msg: |
Paperclip stack deployed successfully!
Access points:
- HTTPS: https://{{ paperclip_domain }}
- HTTP (redirects to HTTPS): http://{{ paperclip_domain }}
Services:
- Paperclip: {{ paperclip_host }}:{{ paperclip_port }}
- nginx (shared reverse proxy): 0.0.0.0:443 / 0.0.0.0:80, config in {{ nginx_proxy_conf_dir }}
- certbot: Running certificate renewal checks
Data location:
- {{ paperclip_base_path }}/data
Certificates:
- {{ nginx_proxy_certs_dir }}/live/{{ paperclip_domain }}/
vLLM endpoint:
- {{ vllm_endpoint }}
OpenCode configuration:
- Config: {{ paperclip_base_path }}/.config/opencode/opencode.jsonc
- Model: Gemma-4-26B (AWQ)
- Endpoint: {{ vllm_endpoint }}/v1
Next steps:
1. Verify DNS resolution for {{ paperclip_domain }}
2. Visit https://{{ paperclip_domain }} to access Paperclip
3. OpenCode should auto-initialize with vLLM at {{ vllm_endpoint }}
4. Run day1_deploy_hermes_docker.yml (publishes its dashboard through the same nginx)
5. Configure agents and skills
+11
View File
@@ -0,0 +1,11 @@
# install-claude.yml
---
# Usage: ansible-playbook -i inventory.ini install-claude.yml
- name: Install Claude Code on Ubuntu servers
hosts: claude_servers
become: false # the role escalates only where it needs to
roles:
- role: claude-code
vars:
claude_code_install_method: apt # or: native
claude_code_apt_channel: stable # or: latest
@@ -0,0 +1,84 @@
---
# Example playbook. The role takes multica_pat as a VALUE — pick ONE of the
# lookups below (or any other) and delete the rest. Changing secret provider
# later is a change to this file only; the role is untouched.
- name: Provision Multica agent hosts
hosts: multica_agent_hosts
become: true
vars:
multica_server_url: "https://multica.wdw-labs.cloud"
multica_app_url: "https://multica.wdw-labs.cloud"
multica_version: "0.6.5"
multica_node_version: "24.22.1"
multica_workspaces_root: "/opt/multica/workspaces"
# No NAS mount under the workspaces root yet, so the mount guard is off.
# When utilidor is mounted, switch both lines together — never set the
# root onto a mount without also setting the guard, or a play that runs
# while the mount is down fills the root filesystem instead of failing.
# multica_workspaces_root: "/mnt/utilidor/multica/workspaces"
# multica_workspaces_root_mount: "/mnt/utilidor"
multica_agent_clis:
- claude
# Merged into the daemon user's ~/.claude/settings.json. Mirrors the model
# mix already live on haunted-mansion (unpinned runs on sonnet, subagents
# on haiku), and stops the npm install self-updating past the pin.
multica_claude_settings:
model: sonnet
alwaysThinkingEnabled: false
env:
DISABLE_AUTOUPDATER: "1"
CLAUDE_CODE_SUBAGENT_MODEL: haiku
MAX_THINKING_TOKENS: "4000"
BASH_MAX_OUTPUT_LENGTH: "20000"
# --- Secret source ----------------------------------------------------
# This homelab keeps Ansible secrets in ansible-vault encrypted files, so
# that is the live form. One PAT per host (named daemon-<host>), held in
# the global vault (group_vars/all/vault) as a dictionary keyed by short
# hostname:
# vault_multica_pats: { haunted-mansion: mul_…, spaceship-earth: mul_… }
# A host with no entry fails the pre_task below, by name, before anything
# runs. The indirection keeps the name greppable and the file swappable.
multica_pat: "{{ vault_multica_pats[inventory_hostname_short] }}"
#
# Alternatives, if the backend ever consolidates. Each is a one-line
# change here; the role is untouched by any of them.
#
# HashiCorp Vault (community.hashi_vault):
# multica_pat: "{{ lookup('community.hashi_vault.vault_kv2_get',
# 'multica/agent-host').secret.pat }}"
#
# 1Password (community.general):
# multica_pat: "{{ lookup('community.general.onepassword',
# 'Multica Agent Host PAT', field='credential') }}"
#
# Environment variable on the control node:
# multica_pat: "{{ lookup('env', 'MULTICA_PAT') }}"
# 1Password service account token for `op` (WDWL-37). One shared token,
# vault_op_service_account_claude in group_vars/all/vault, scoped by
# 1Password to the agents' vault. The role writes it to a 0600 file and
# installs an `op` shim that reads it; it never reaches the daemon unit.
multica_op_deliver_token: true
multica_op_service_account_token: "{{ vault_op_service_account_claude }}"
# The role's own PAT assert is no_log, so a missing dictionary entry would
# surface there only as "censored". This checks key presence, never the
# value, so its failure can name the host.
pre_tasks:
- name: Require a per-host Multica PAT in the vault
ansible.builtin.assert:
that:
- vault_multica_pats is defined
- vault_multica_pats is mapping
- inventory_hostname_short in vault_multica_pats
fail_msg: >-
No Multica PAT for {{ inventory_hostname_short }}: add
vault_multica_pats['{{ inventory_hostname_short }}'] to
group_vars/all/vault.
quiet: true
tags: [always, multica_pat_check]
roles:
- role: multica_agent_host
+84
View File
@@ -0,0 +1,84 @@
---
# =============================================================================
# provision-wong-host.yml — spaceship-earth (WDWL-22)
#
# spaceship-earth runs exactly ONE agent: wong, the personal vault's librarian
# and its only writer. No other agent may be pinned to its runtime. The
# isolation is enforced by the OS, not by agent instructions: Obsidian Sync
# runs as `obsync`, and Wong's daemon user (multica) can reach inbox/ only.
#
# Run order on a fresh host:
# 1. ansible-playbook playbooks/provision-agent-host.yml --limit spaceship-earth
# (multica daemon user, Node.js, multica CLI, Claude Code)
# 2. ansible-playbook playbooks/provision-wong-host.yml
# (root LV growth, headless Obsidian, vault folder isolation)
#
# Step 2 needs Node.js and the multica user from step 1, and asserts both.
# The Obsidian account sign-in is manual; see roles/obsidian_headless/README.md.
# =============================================================================
- name: Grow the root filesystem to fill the disk
hosts: spaceship-earth
become: true
tags: [wong_disk]
vars:
root_vg: ubuntu-vg
root_lv: ubuntu-lv
tasks:
- name: Disk | Find the PV backing the root VG
ansible.builtin.set_fact:
_root_pv: >-
{{ ansible_facts['lvm']['pvs'] | dict2items
| selectattr('value.vg', 'equalto', root_vg)
| map(attribute='key') | first }}
- name: Disk | Split the PV into disk and partition number
ansible.builtin.set_fact:
_root_disk: "{{ _root_pv | regex_replace('p?[0-9]+$', '') }}"
_root_partno: "{{ _root_pv | regex_search('[0-9]+$') }}"
- name: Disk | Install growpart
ansible.builtin.package:
name: cloud-guest-utils
state: present
# growpart exits 1 with "NOCHANGE" when the partition already fills the disk.
- name: Disk | Grow the partition
ansible.builtin.command:
argv: [growpart, "{{ _root_disk }}", "{{ _root_partno }}"]
register: _growpart
changed_when: _growpart.rc == 0 and 'CHANGED' in _growpart.stdout
failed_when: _growpart.rc != 0 and 'NOCHANGE' not in _growpart.stdout
- name: Disk | Grow the PV and VG
community.general.lvg:
vg: "{{ root_vg }}"
pvs: "{{ _root_pv }}"
pvresize: true
- name: Disk | Extend the root LV and its filesystem into the free space
community.general.lvol:
vg: "{{ root_vg }}"
lv: "{{ root_lv }}"
size: +100%FREE
resizefs: true
- name: Headless Obsidian Sync with vault folder isolation
hosts: spaceship-earth
become: true
tags: [obsidian]
vars:
obsidian_headless_vault_path: /srv/obsidian/vault
obsidian_headless_device_name: spaceship-earth
obsidian_headless_private_folders:
- everpure
- wip
obsidian_headless_shared_folders:
- path: inbox
user: multica
roles:
- role: obsidian_headless
+35
View File
@@ -0,0 +1,35 @@
# claude_code role
Installs [Claude Code](https://code.claude.com/docs/en/setup) on Ubuntu 20.04+ / Debian 10+ (x64 or ARM64, 4 GB+ RAM).
## Methods
| `claude_code_install_method` | What it does | Updates |
|---|---|---|
| `apt` (default) | Adds Anthropic's signed apt repo (stable or latest channel) and installs the `claude-code` package system-wide. Verifies the signing-key fingerprint before trusting it. | `apt upgrade` / set `claude_code_package_state: latest` |
| `native` | Runs Anthropic's installer as `claude_code_user`; binary lands in `~/.local/bin/claude`. | Self-updates in the background |
## Key variables (see `defaults/main.yml`)
- `claude_code_apt_channel`: `stable` (about a week behind, skips bad releases) or `latest`
- `claude_code_package_state`: `present` or `latest`
- `claude_code_native_version`: `latest`, `stable`, or a pinned version like `2.1.89`
- `claude_code_settings`: optional dict written to `~/.claude/settings.json` (overwrites the file)
## Use
```bash
cp inventory.ini.example inventory.ini # edit hosts
ansible-playbook -i inventory.ini install-claude.yml --check # dry run
ansible-playbook -i inventory.ini install-claude.yml
```
The remote user needs sudo for the `apt` method.
## After install: authenticate (not automated)
Run `claude` once on the server and follow the login prompt, or export `ANTHROPIC_API_KEY`
for the user (store it in Ansible Vault, not in the repo). A Pro, Max, Team, Enterprise or
Console account is required; the free claude.ai plan does not include Claude Code.
Verify with `claude --version` and `claude doctor`.
@@ -0,0 +1,39 @@
# roles/claude_code/defaults/main.yml
---
# How to install Claude Code:
# apt - signed Anthropic apt repo, system-wide, upgraded via apt (recommended for servers)
# native - Anthropic's installer script, per-user in ~/.local/bin, self-updating
claude_code_install_method: apt
# ---- apt method -------------------------------------------------------------
# Release channel: "stable" (~1 week behind, skips bad releases) or "latest"
claude_code_apt_channel: stable
# "present" installs once; "latest" upgrades on every run
claude_code_package_state: present
claude_code_apt_key_url: https://downloads.claude.ai/keys/claude-code.asc
claude_code_apt_key_path: /etc/apt/keyrings/claude-code.asc
# Fingerprint published in Anthropic's docs; the role refuses to continue if it differs
claude_code_apt_key_fingerprint: "31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE"
claude_code_apt_repo_base: https://downloads.claude.ai/claude-code/apt
# ---- native method ----------------------------------------------------------
# User the native installer runs as
claude_code_user: "{{ ansible_user_id }}"
# "latest", "stable", or a specific version such as "2.1.89"
claude_code_native_version: latest
# ---- common -----------------------------------------------------------------
claude_code_prereq_packages:
- curl
- ca-certificates
- gnupg
# Optional: content for ~/.claude/settings.json of claude_code_settings_user.
# Empty dict = don't touch the file. NOTE: when set, the file is overwritten.
# Example: { "autoUpdatesChannel": "stable", "env": { "DISABLE_AUTOUPDATER": "1" } }
claude_code_settings: {}
claude_code_settings_user: "{{ claude_code_user }}"
# Run `claude --version` at the end and fail if it doesn't work
claude_code_verify: true
+15
View File
@@ -0,0 +1,15 @@
# roles/claude_code/meta/main.yml
---
galaxy_info:
role_name: claude_code
author: ryan
description: Install Claude Code on Ubuntu/Debian servers (signed apt repo or native installer)
license: MIT
min_ansible_version: "2.14"
platforms:
- name: Ubuntu
versions: [focal, jammy, noble]
- name: Debian
versions: [buster, bullseye, bookworm]
galaxy_tags: [claude, ai, cli]
dependencies: []
+70
View File
@@ -0,0 +1,70 @@
# roles/claude_code/tasks/apt.yml
---
- name: Create apt keyrings directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
owner: root
group: root
become: true
- name: Download Anthropic release signing key
ansible.builtin.get_url:
url: "{{ claude_code_apt_key_url }}"
dest: "{{ claude_code_apt_key_path }}"
mode: "0644"
owner: root
group: root
become: true
- name: Read key fingerprint
ansible.builtin.shell:
cmd: |
set -o pipefail
gpg --show-keys --with-colons {{ claude_code_apt_key_path | quote }} | awk -F':' '/^fpr/ {print $10; exit}'
executable: /bin/bash
register: claude_code_key_fpr
changed_when: false
check_mode: false
become: true
- name: Refuse to continue if the key fingerprint does not match
ansible.builtin.assert:
that:
- claude_code_key_fpr.stdout | trim | upper == claude_code_apt_key_fingerprint | upper
fail_msg: >-
Signing key fingerprint mismatch: got '{{ claude_code_key_fpr.stdout | trim }}',
expected '{{ claude_code_apt_key_fingerprint }}'. Removing the key file.
quiet: true
register: claude_code_key_check
ignore_errors: true
- name: Remove untrusted key file
ansible.builtin.file:
path: "{{ claude_code_apt_key_path }}"
state: absent
become: true
when: claude_code_key_check is failed
- name: Stop if key check failed
ansible.builtin.fail:
msg: "{{ claude_code_key_check.msg }}"
when: claude_code_key_check is failed
- name: Configure Claude Code apt repository
ansible.builtin.apt_repository:
repo: >-
deb [signed-by={{ claude_code_apt_key_path }}]
{{ claude_code_apt_repo_base }}/{{ claude_code_apt_channel }}
{{ claude_code_apt_channel }} main
filename: claude-code
state: present
update_cache: true
become: true
- name: Install claude-code package
ansible.builtin.apt:
name: claude-code
state: "{{ claude_code_package_state }}"
become: true
+44
View File
@@ -0,0 +1,44 @@
# roles/claude_code/tasks/main.yml
---
- name: Assert a supported install method
ansible.builtin.assert:
that:
- claude_code_install_method in ['apt', 'native']
- claude_code_apt_channel in ['stable', 'latest']
fail_msg: "claude_code_install_method must be apt|native and claude_code_apt_channel stable|latest"
quiet: true
- name: Assert a supported OS (Ubuntu 20.04+ / Debian 10+)
ansible.builtin.assert:
that:
- ansible_facts['os_family'] == 'Debian'
- >-
(ansible_facts['distribution'] == 'Ubuntu' and ansible_facts['distribution_version'] is version('20.04', '>='))
or (ansible_facts['distribution'] == 'Debian' and ansible_facts['distribution_version'] is version('10', '>='))
- ansible_facts['architecture'] in ['x86_64', 'aarch64']
fail_msg: "Claude Code needs Ubuntu 20.04+/Debian 10+ on x64 or ARM64"
quiet: true
- name: Install prerequisite packages
ansible.builtin.apt:
name: "{{ claude_code_prereq_packages }}"
state: present
update_cache: true
cache_valid_time: 3600
become: true
- name: Install via apt repository
ansible.builtin.include_tasks: apt.yml
when: claude_code_install_method == 'apt'
- name: Install via native installer
ansible.builtin.include_tasks: native.yml
when: claude_code_install_method == 'native'
- name: Write Claude Code settings
ansible.builtin.include_tasks: settings.yml
when: claude_code_settings | length > 0
- name: Verify installation
ansible.builtin.include_tasks: verify.yml
when: claude_code_verify | bool
@@ -0,0 +1,20 @@
# roles/claude_code/tasks/native.yml
---
- name: Install Claude Code with the native installer
ansible.builtin.shell:
cmd: set -o pipefail; curl -fsSL https://claude.ai/install.sh | bash -s {{ claude_code_native_version | quote }}
executable: /bin/bash
creates: "~{{ claude_code_user }}/.local/bin/claude"
become: true
become_user: "{{ claude_code_user }}"
- name: Put ~/.local/bin on PATH for login shells
ansible.builtin.copy:
dest: /etc/profile.d/claude-code.sh
content: |
# Managed by Ansible (claude_code role)
case ":$PATH:" in *":$HOME/.local/bin:"*) ;; *) export PATH="$HOME/.local/bin:$PATH" ;; esac
mode: "0644"
owner: root
group: root
become: true
@@ -0,0 +1,22 @@
# roles/claude_code/tasks/settings.yml
---
- name: Look up settings user
ansible.builtin.getent:
database: passwd
key: "{{ claude_code_settings_user }}"
- name: Create ~/.claude directory
ansible.builtin.file:
path: "{{ ansible_facts['getent_passwd'][claude_code_settings_user][4] }}/.claude"
state: directory
mode: "0700"
owner: "{{ claude_code_settings_user }}"
become: true
- name: Write ~/.claude/settings.json
ansible.builtin.copy:
dest: "{{ ansible_facts['getent_passwd'][claude_code_settings_user][4] }}/.claude/settings.json"
content: "{{ claude_code_settings | to_nice_json }}\n"
mode: "0600"
owner: "{{ claude_code_settings_user }}"
become: true
@@ -0,0 +1,17 @@
# roles/claude_code/tasks/verify.yml
---
- name: Check claude --version
ansible.builtin.shell:
cmd: |
if command -v claude >/dev/null 2>&1; then claude --version
else "$HOME/.local/bin/claude" --version; fi
executable: /bin/bash
become: "{{ claude_code_install_method == 'native' }}"
become_user: "{{ claude_code_user if claude_code_install_method == 'native' else omit }}"
register: claude_code_version_out
changed_when: false
check_mode: false
- name: Show installed version
ansible.builtin.debug:
msg: "{{ claude_code_version_out.stdout }}"
+4
View File
@@ -6,6 +6,10 @@
# Directory layout on mad-tea-party
gitea_compose_dir: /opt/gitea
# Image versions
gitea_version: "28.2.0"
gitea_postgres_version: "17.11"
# Gitea application settings
gitea_domain: gitea.mk-labs.cloud
gitea_root_url: "https://{{ gitea_domain }}"
+3 -3
View File
@@ -15,9 +15,9 @@
# ── Compose & env ──────────────────────────────────────────────────────────────
- name: Deploy Gitea compose file from boilerplate
ansible.builtin.copy:
src: "{{ playbook_dir }}/../../boilerplates/gitea/compose.yaml"
- name: Deploy Gitea compose file
ansible.builtin.template:
src: compose.yaml.j2
dest: "{{ gitea_compose_dir }}/compose.yaml"
owner: root
group: root
@@ -0,0 +1,64 @@
# ------------------------------------------------------------------------------
# FILE: ansible/roles/gitea/templates/compose.yaml.j2
# DESCRIPTION: Gitea + PostgreSQL compose stack. Values come from role defaults.
# Postgres stays on 17.x; a major bump needs dump + restore.
# ------------------------------------------------------------------------------
services:
gitea:
image: docker.io/gitea/gitea:{{ gitea_version }}
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__database__DB_TYPE=postgres
- GITEA__database__HOST=gitea_db
- GITEA__database__NAME={{ gitea_db_name }}
- GITEA__database__USER={{ gitea_db_user }}
- GITEA__database__PASSWD=${DATABASE_PASSWORD}
- GITEA__server__SSH_PORT={{ gitea_ssh_port }}
- GITEA__server__ROOT_URL={{ gitea_root_url }}
- GITEA__server__DOMAIN={{ gitea_domain }}
- GITEA__server__HTTP_PORT={{ gitea_http_port }}
- GITEA__service__TRUSTED_PROXIES={{ gitea_trusted_proxies }}
networks:
- gitea_backend
ports:
- "{{ gitea_http_port }}:{{ gitea_http_port }}"
- "{{ gitea_ssh_port }}:22"
volumes:
- gitea_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
depends_on:
gitea_db:
condition: service_healthy
gitea_db:
image: docker.io/library/postgres:{{ gitea_postgres_version }}
restart: unless-stopped
environment:
- TZ={{ gitea_timezone }}
- POSTGRES_USER={{ gitea_db_user }}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB={{ gitea_db_name }}
networks:
- gitea_backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ gitea_db_user }}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
volumes:
- gitea_db:/var/lib/postgresql/data
volumes:
gitea_data:
driver: local
gitea_db:
driver: local
networks:
gitea_backend:
driver: bridge
@@ -0,0 +1,91 @@
---
hermes_image: nousresearch/hermes-agent:latest
# missing = only pull if absent (no surprise upgrades on redeploy).
# Set to "always" when you actually want to upgrade the image.
hermes_pull_policy: missing
hermes_container_name: hermes
hermes_project_dir: /opt/hermes
hermes_data_dir: "{{ hermes_project_dir }}/data"
# The container's hermes user. The image's start-up hook chowns the data
# volume to it; the role only needs the UID to stage files it can read.
hermes_uid: 10000
# config.yaml, .env and sessions live here and can hold API keys, so keep it
# owner-only. Passed to the container as HERMES_HOME_MODE (see compose).
hermes_data_dir_mode: "0700"
hermes_secrets_env_file: "{{ hermes_project_dir }}/hermes-secrets.env"
# Same network namespace as Paperclip and nginx
hermes_network_mode: host
hermes_memory_limit: 8G
hermes_cpu_limit: "4.0"
# Playwright/Chromium needs shared memory (upstream troubleshooting note)
hermes_shm_size: 1g
# --- Gateway / API server (loopback only; Paperclip reaches it locally) ---
hermes_gateway_port: 8642
hermes_api_server_enabled: true
hermes_api_server_host: 127.0.0.1
# Leave empty to run keyless on loopback. If you set one, Paperclip (or any
# other client) must send it as a bearer token.
# e.g. hermes_api_server_key: "{{ vault_hermes_api_server_key }}"
hermes_api_server_key: ""
# --- Dashboard (bound to loopback, published through nginx) ---
hermes_dashboard_enabled: true
hermes_dashboard_host: 127.0.0.1
hermes_dashboard_port: 9119
hermes_domain: haunted-mansion.wdw-labs.cloud
# Publish the dashboard through the nginx-proxy role (cert + vhost + reload).
# Requires day1_deploy_nginx_proxy.yml to have run first.
hermes_publish_vhost: true
hermes_public_url: "https://{{ hermes_domain }}"
hermes_dashboard_username: admin
# Vault variables this role requires (see README block at bottom of file)
hermes_required_vault_vars:
- vault_hermes_dashboard_password_hash
- vault_hermes_dashboard_secret
# --- Preflight ---
hermes_preflight_ports:
- "{{ hermes_gateway_port }}"
- "{{ hermes_dashboard_port }}"
hermes_wait_timeout: 120
# --- config.yaml overrides (deep-merged, only the keys listed here) ---
# nginx runs with host networking, so it reaches the dashboard from loopback.
# Model settings are NOT managed by default (your wizard-written config stays
# untouched). To manage them, add a model block, for example:
# model:
# provider: custom
# model: <served-model-name>
# base_url: http://10.1.71.130:8000/v1
# api_key: "{{ vault_vllm_api_key }}"
hermes_config_overrides:
dashboard:
trusted_proxies:
- "127.0.0.1"
# Main model (written to config.yaml under model:). Leave empty to manage it
# by hand with `hermes model` or the dashboard. Example:
# hermes_model:
# provider: custom
# default: <served model name from the endpoint's /v1/models>
# base_url: http://fantasmic.wdw-labs.cloud:8000/v1
# api_key: "{{ vault_fantasmic_api_key }}"
# # context_length: 65536 # only if Hermes complains about the context window
# The api_key lands in config.yaml in plain text (owner-only, see hermes_data_dir_mode).
hermes_model: {}
# What the merge task actually applies: base overrides + the model block.
hermes_effective_overrides: >-
{{ hermes_config_overrides
| combine({'model': hermes_model} if (hermes_model | length > 0) else {}, recursive=True) }}
# Required vault vars:
# vault_hermes_dashboard_password_hash - output of hash_password() (see below)
# vault_hermes_dashboard_secret - openssl rand -base64 32
# Generate the hash with:
# docker exec -it -w /opt/hermes hermes /opt/hermes/.venv/bin/python -c \
# "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"
@@ -0,0 +1,40 @@
"""Deep-merge overrides (a JSON file, path in argv[1]) into config.yaml.
Run inside the hermes container as the hermes user. Only keys present in the
overrides are touched. Prints CHANGED when the file was rewritten.
"""
import json
import os
import sys
import yaml
PATH = "/opt/data/config.yaml"
with open(sys.argv[1]) as fh:
overrides = json.load(fh)
if os.path.exists(PATH):
with open(PATH) as fh:
current = yaml.safe_load(fh) or {}
else:
current = {}
def merge(dst, src):
changed = False
for key, val in src.items():
if isinstance(val, dict) and isinstance(dst.get(key), dict):
changed |= merge(dst[key], val)
elif dst.get(key) != val:
dst[key] = val
changed = True
return changed
if merge(current, overrides):
with open(PATH, "w") as fh:
yaml.safe_dump(current, fh, sort_keys=False)
print("CHANGED")
else:
print("UNCHANGED")
sys.exit(0)
@@ -0,0 +1,5 @@
---
# Config.yaml is read at start, so a plain restart is enough.
# Compose/env changes are handled by docker compose itself (it recreates).
- name: Restart hermes
ansible.builtin.command: "docker restart {{ hermes_container_name }}"
+148
View File
@@ -0,0 +1,148 @@
---
- name: Preflight | Required vault variables are defined
ansible.builtin.assert:
that: "{{ item }} is defined and ({{ item }} | length) > 0"
fail_msg: "{{ item }} is not defined. Add it to your ansible vault."
quiet: true
loop: "{{ hermes_required_vault_vars }}"
tags: [hermes, preflight]
- name: Preflight | Confirm docker CLI is available
ansible.builtin.command: docker --version
changed_when: false
tags: [hermes, preflight]
- name: Preflight | Get existing container state
ansible.builtin.command: "docker inspect -f '{% raw %}{{.State.Running}}{% endraw %}' {{ hermes_container_name }}"
register: hermes_existing
failed_when: false
changed_when: false
tags: [hermes, preflight]
# Only enforce free ports when our container is not already running,
# so redeploys stay idempotent.
- name: Preflight | Ensure required ports are free
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: stopped
timeout: 3
loop: "{{ hermes_preflight_ports }}"
when: hermes_existing.stdout | default('') | trim != 'true'
tags: [hermes, preflight]
- name: Create project directory
ansible.builtin.file:
path: "{{ hermes_project_dir }}"
state: directory
mode: "0755"
tags: [hermes]
# No owner here: the image's start-up hook chowns the volume to the hermes user.
- name: Create data directory (owner-only)
ansible.builtin.file:
path: "{{ hermes_data_dir }}"
state: directory
mode: "{{ hermes_data_dir_mode }}"
tags: [hermes]
# Compose reads this as root on the host; the container never sees the file.
- name: Template secrets env file
ansible.builtin.template:
src: hermes-secrets.env.j2
dest: "{{ hermes_secrets_env_file }}"
owner: root
group: root
mode: "0600"
no_log: true
tags: [hermes]
- name: Template docker compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ hermes_project_dir }}/docker-compose.yml"
mode: "0644"
tags: [hermes]
- name: Deploy hermes
community.docker.docker_compose_v2:
project_src: "{{ hermes_project_dir }}"
pull: "{{ hermes_pull_policy }}"
state: present
tags: [hermes]
- name: Verify gateway health
ansible.builtin.uri:
url: "http://127.0.0.1:{{ hermes_gateway_port }}/health"
status_code: 200
register: hermes_health
until: hermes_health.status == 200
retries: "{{ (hermes_wait_timeout | int) // 5 }}"
delay: 5
when: hermes_api_server_enabled | bool
tags: [hermes, verify]
- name: Wait for dashboard port
ansible.builtin.wait_for:
host: "{{ hermes_dashboard_host }}"
port: "{{ hermes_dashboard_port }}"
timeout: "{{ hermes_wait_timeout }}"
when: hermes_dashboard_enabled | bool
tags: [hermes, verify]
# Overrides can contain secrets (the model api_key), so they are staged in a
# 0600 file owned by the container user instead of travelling in a command
# line (visible in `ps`), and removed again afterwards. The merge script runs
# inside the container with the image's own venv (PyYAML is available there),
# as the hermes user, so config.yaml stays owned by it.
- name: Apply config.yaml overrides
when: hermes_effective_overrides | length > 0
tags: [hermes, config]
block:
- name: Stage overrides file
ansible.builtin.copy:
content: "{{ hermes_effective_overrides | to_json }}"
dest: "{{ hermes_data_dir }}/.ansible-overrides.json"
owner: "{{ hermes_uid }}"
group: "{{ hermes_uid }}"
mode: "0600"
changed_when: false # transient file; only the merge result should count as a change
no_log: true
- name: Merge into config.yaml
ansible.builtin.command:
argv:
- docker
- exec
- -i
- --user
- hermes
- "{{ hermes_container_name }}"
- /opt/hermes/.venv/bin/python
- "-"
- /opt/data/.ansible-overrides.json
stdin: "{{ lookup('ansible.builtin.file', 'merge_config.py') }}"
register: hermes_config_merge
changed_when: "'CHANGED' in hermes_config_merge.stdout and 'UNCHANGED' not in hermes_config_merge.stdout"
notify: Restart hermes
always:
- name: Remove staged overrides file
ansible.builtin.file:
path: "{{ hermes_data_dir }}/.ansible-overrides.json"
state: absent
changed_when: false
- name: Publish dashboard through nginx-proxy
ansible.builtin.include_role:
name: nginx-proxy
tasks_from: add_vhost
vars:
vhost_name: hermes
vhost_domain: "{{ hermes_domain }}"
vhost_upstream_port: "{{ hermes_dashboard_port }}"
vhost_proxy_timeout: 3600s # long-lived dashboard WebSockets
when:
- hermes_publish_vhost | bool
- hermes_dashboard_enabled | bool
tags: [hermes, nginx]
@@ -0,0 +1,31 @@
# Managed by Ansible (hermes_docker role)
services:
hermes:
image: {{ hermes_image }}
container_name: {{ hermes_container_name }}
restart: unless-stopped
network_mode: {{ hermes_network_mode }}
command: gateway run
shm_size: "{{ hermes_shm_size }}"
env_file:
- {{ hermes_secrets_env_file }}
environment:
HERMES_HOME_MODE: "{{ hermes_data_dir_mode }}"
{% if hermes_dashboard_enabled | bool %}
HERMES_DASHBOARD: "1"
HERMES_DASHBOARD_HOST: "{{ hermes_dashboard_host }}"
HERMES_DASHBOARD_PORT: "{{ hermes_dashboard_port }}"
HERMES_DASHBOARD_PUBLIC_URL: "{{ hermes_public_url }}"
{% endif %}
{% if hermes_api_server_enabled | bool %}
API_SERVER_ENABLED: "true"
API_SERVER_HOST: "{{ hermes_api_server_host }}"
API_SERVER_PORT: "{{ hermes_gateway_port }}"
{% endif %}
volumes:
- {{ hermes_data_dir }}:/opt/data
deploy:
resources:
limits:
memory: {{ hermes_memory_limit }}
cpus: "{{ hermes_cpu_limit }}"
@@ -0,0 +1,8 @@
# Managed by Ansible (hermes_docker role) - do not edit
# Single quotes keep compose from interpolating '$' inside hashes/secrets.
HERMES_DASHBOARD_BASIC_AUTH_USERNAME={{ hermes_dashboard_username }}
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH='{{ vault_hermes_dashboard_password_hash }}'
HERMES_DASHBOARD_BASIC_AUTH_SECRET='{{ vault_hermes_dashboard_secret }}'
{% if hermes_api_server_key | length > 0 %}
API_SERVER_KEY='{{ hermes_api_server_key }}'
{% endif %}
@@ -0,0 +1,66 @@
---
hermes_image: nousresearch/hermes-agent:latest
# missing = only pull if absent (no surprise upgrades on redeploy).
# Set to "always" when you actually want to upgrade the image.
hermes_pull_policy: missing
hermes_container_name: hermes
hermes_project_dir: /opt/hermes
hermes_data_dir: "{{ hermes_project_dir }}/data"
hermes_secrets_env_file: "{{ hermes_project_dir }}/hermes-secrets.env"
# Same network namespace as Paperclip and nginx
hermes_network_mode: host
hermes_memory_limit: 8G
hermes_cpu_limit: "4.0"
# Playwright/Chromium needs shared memory (upstream troubleshooting note)
hermes_shm_size: 1g
# --- Gateway / API server (loopback only; Paperclip reaches it locally) ---
hermes_gateway_port: 8642
hermes_api_server_enabled: true
hermes_api_server_host: 127.0.0.1
# Leave empty to run keyless on loopback. If you set one, Paperclip (or any
# other client) must send it as a bearer token.
# e.g. hermes_api_server_key: "{{ vault_hermes_api_server_key }}"
hermes_api_server_key: "{{ vault_hermes_api_server_key }}"
# --- Dashboard (bound to loopback, published through nginx) ---
hermes_dashboard_enabled: true
hermes_dashboard_host: 127.0.0.1
hermes_dashboard_port: 9119
hermes_domain: haunted-mansion.wdw-labs.cloud
hermes_public_url: "https://{{ hermes_domain }}"
hermes_dashboard_username: admin
# Vault variables this role requires (see README block at bottom of file)
hermes_required_vault_vars:
- vault_hermes_dashboard_password_hash
- vault_hermes_dashboard_secret
# --- Preflight ---
hermes_preflight_ports:
- "{{ hermes_gateway_port }}"
- "{{ hermes_dashboard_port }}"
hermes_wait_timeout: 120
# --- config.yaml overrides (deep-merged, only the keys listed here) ---
# nginx runs with host networking, so it reaches the dashboard from loopback.
# Model settings are NOT managed by default (your wizard-written config stays
# untouched). To manage them, add a model block, for example:
# model:
# provider: custom
# model: <served-model-name>
# base_url: http://10.1.71.130:8000/v1
# api_key: "{{ vault_vllm_api_key }}"
hermes_config_overrides:
dashboard:
trusted_proxies:
- "127.0.0.1"
# Required vault vars:
# vault_hermes_dashboard_password_hash - output of hash_password() (see below)
# vault_hermes_dashboard_secret - openssl rand -base64 32
# Generate the hash with:
# docker exec -it -w /opt/hermes hermes /opt/hermes/.venv/bin/python -c \
# "from plugins.dashboard_auth.basic import hash_password; print(hash_password('PW'))"
@@ -0,0 +1,39 @@
"""Deep-merge overrides (JSON in $HERMES_CONFIG_OVERRIDES) into config.yaml.
Run inside the hermes container as the hermes user. Only keys present in the
overrides are touched. Prints CHANGED when the file was rewritten.
"""
import json
import os
import sys
import yaml
PATH = "/opt/data/config.yaml"
overrides = json.loads(os.environ["HERMES_CONFIG_OVERRIDES"])
if os.path.exists(PATH):
with open(PATH) as fh:
current = yaml.safe_load(fh) or {}
else:
current = {}
def merge(dst, src):
changed = False
for key, val in src.items():
if isinstance(val, dict) and isinstance(dst.get(key), dict):
changed |= merge(dst[key], val)
elif dst.get(key) != val:
dst[key] = val
changed = True
return changed
if merge(current, overrides):
with open(PATH, "w") as fh:
yaml.safe_dump(current, fh, sort_keys=False)
print("CHANGED")
else:
print("UNCHANGED")
sys.exit(0)
@@ -0,0 +1,5 @@
---
# Config.yaml is read at start, so a plain restart is enough.
# Compose/env changes are handled by docker compose itself (it recreates).
- name: Restart hermes
ansible.builtin.command: "docker restart {{ hermes_container_name }}"
@@ -0,0 +1,49 @@
# ---- Hermes dashboard: paste inside the http { } block of nginx.conf.j2 ----
# Redirect HTTP to HTTPS
server {
listen 80;
server_name {{ hermes_domain }};
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name {{ hermes_domain }};
ssl_certificate /etc/nginx/certs/live/{{ hermes_domain }}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/{{ hermes_domain }}/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
# No auth_basic here: the dashboard has its own login (basic auth provider).
location / {
proxy_pass http://127.0.0.1:{{ hermes_dashboard_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Port $server_port;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout 3600s;
proxy_read_timeout 3600s;
proxy_buffering off;
proxy_request_buffering off;
}
}
@@ -0,0 +1,108 @@
---
- name: Preflight | Required vault variables are defined
ansible.builtin.assert:
that: "{{ item }} is defined and ({{ item }} | length) > 0"
fail_msg: "{{ item }} is not defined. Add it to your ansible vault."
quiet: true
loop: "{{ hermes_required_vault_vars }}"
tags: [hermes, preflight]
- name: Preflight | Confirm docker CLI is available
ansible.builtin.command: docker --version
changed_when: false
tags: [hermes, preflight]
- name: Preflight | Get existing container state
community.docker.docker_container_info:
name: "{{ hermes_container_name }}"
register: hermes_existing
tags: [hermes, preflight]
# Only enforce free ports when our container is not already running,
# so redeploys stay idempotent.
- name: Preflight | Ensure required ports are free
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: stopped
timeout: 3
loop: "{{ hermes_preflight_ports }}"
when: not (hermes_existing.container.State.Running | default(false))
tags: [hermes, preflight]
- name: Create project and data directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ hermes_project_dir }}"
- "{{ hermes_data_dir }}"
tags: [hermes]
# Compose reads this as root on the host; the container never sees the file.
- name: Template secrets env file
ansible.builtin.template:
src: hermes-secrets.env.j2
dest: "{{ hermes_secrets_env_file }}"
owner: root
group: root
mode: "0600"
no_log: true
tags: [hermes]
- name: Template docker compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ hermes_project_dir }}/docker-compose.yml"
mode: "0644"
tags: [hermes]
- name: Deploy hermes
community.docker.docker_compose_v2:
project_src: "{{ hermes_project_dir }}"
pull: "{{ hermes_pull_policy }}"
state: present
tags: [hermes]
- name: Verify gateway health
ansible.builtin.uri:
url: "http://127.0.0.1:{{ hermes_gateway_port }}/health"
status_code: 200
register: hermes_health
until: hermes_health.status == 200
retries: "{{ (hermes_wait_timeout | int) // 5 }}"
delay: 5
when: hermes_api_server_enabled | bool
tags: [hermes, verify]
- name: Wait for dashboard port
ansible.builtin.wait_for:
host: "{{ hermes_dashboard_host }}"
port: "{{ hermes_dashboard_port }}"
timeout: "{{ hermes_wait_timeout }}"
when: hermes_dashboard_enabled | bool
tags: [hermes, verify]
# Runs inside the container with the image's own venv (PyYAML is available
# there) as the hermes user, so config.yaml stays owned by UID 10000.
- name: Merge config.yaml overrides
ansible.builtin.command:
argv:
- docker
- exec
- -i
- --user
- hermes
- -e
- "HERMES_CONFIG_OVERRIDES={{ hermes_config_overrides | to_json }}"
- "{{ hermes_container_name }}"
- /opt/hermes/.venv/bin/python
- "-"
stdin: "{{ lookup('ansible.builtin.file', 'merge_config.py') }}"
register: hermes_config_merge
changed_when: "'CHANGED' in hermes_config_merge.stdout and 'UNCHANGED' not in hermes_config_merge.stdout"
no_log: true
when: hermes_config_overrides | length > 0
notify: Restart hermes
tags: [hermes, config]
@@ -0,0 +1,30 @@
# Managed by Ansible (hermes_docker role)
services:
hermes:
image: {{ hermes_image }}
container_name: {{ hermes_container_name }}
restart: unless-stopped
network_mode: {{ hermes_network_mode }}
command: gateway run
shm_size: "{{ hermes_shm_size }}"
env_file:
- {{ hermes_secrets_env_file }}
environment:
{% if hermes_dashboard_enabled | bool %}
HERMES_DASHBOARD: "1"
HERMES_DASHBOARD_HOST: "{{ hermes_dashboard_host }}"
HERMES_DASHBOARD_PORT: "{{ hermes_dashboard_port }}"
HERMES_DASHBOARD_PUBLIC_URL: "{{ hermes_public_url }}"
{% endif %}
{% if hermes_api_server_enabled | bool %}
API_SERVER_ENABLED: "true"
API_SERVER_HOST: "{{ hermes_api_server_host }}"
API_SERVER_PORT: "{{ hermes_gateway_port }}"
{% endif %}
volumes:
- {{ hermes_data_dir }}:/opt/data
deploy:
resources:
limits:
memory: {{ hermes_memory_limit }}
cpus: "{{ hermes_cpu_limit }}"
@@ -0,0 +1,8 @@
# Managed by Ansible (hermes_docker role) - do not edit
# Single quotes keep compose from interpolating '$' inside hashes/secrets.
HERMES_DASHBOARD_BASIC_AUTH_USERNAME={{ hermes_dashboard_username }}
HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH='{{ vault_hermes_dashboard_password_hash }}'
HERMES_DASHBOARD_BASIC_AUTH_SECRET='{{ vault_hermes_dashboard_secret }}'
{% if hermes_api_server_key | length > 0 %}
API_SERVER_KEY='{{ hermes_api_server_key }}'
{% endif %}
@@ -117,4 +117,4 @@ baseline_journald_max_retention: "30day"
# ---------------------------------------------------------------------------
# MOTD branding
# ---------------------------------------------------------------------------
baseline_motd_banner: "mk-labs"
baseline_motd_banner: "wdw-labs"
@@ -0,0 +1,9 @@
---
# The role's public variables are named in the issue's spec (multica_version,
# multica_server_url, multica_workspaces_root, multica_agent_clis, ...) and
# match the product's own namespace, including its MULTICA_* environment
# variables. Prefixing them with the role name instead would make every call
# site read multica_agent_host_multica_version. Internal facts use a leading
# underscore (_multica_*) to stay clearly private.
skip_list:
- var-naming[no-role-prefix]
+488
View File
@@ -0,0 +1,488 @@
# multica_agent_host
Provisions a Multica agent host from bare Linux to a **registered, running
daemon with its provider runtimes online**, and re-runs cleanly on a host that
is already provisioned.
Reaches "daemon running, runtimes registered" unattended. The one step it
cannot do is the provider CLI's own subscription login, which needs a browser
once per host.
## If your repo runs ansible-lint
This role keeps the variable names from its spec (`multica_version`,
`multica_server_url`, `multica_workspaces_root`, ...), which match the
product's own `MULTICA_*` namespace. ansible-lint's `var-naming[no-role-prefix]`
wants them prefixed with the role name instead — `multica_agent_host_multica_version`
at every call site.
The role ships the exemption at `roles/multica_agent_host/.ansible-lint`, but
**ansible-lint only reads that file from the directory you invoke it in**. If
you lint from your repo root, copy these two lines into your repo's own
`.ansible-lint`:
```yaml
skip_list:
- var-naming[no-role-prefix]
```
Without it a repo-root lint run reports ~78 violations against a role that is
otherwise clean at the `production` profile.
## What it does
| Step | Handled |
|---|---|
| Dedicated `multica` service account (real home, linger) | yes |
| Node.js, pinned, from nodejs.org with SHASUMS256 verification | yes, when an npm-installed agent CLI is selected |
| `multica` CLI, pinned, from the GitHub release with `checksums.txt` verification | yes |
| Agent CLIs (`claude`, `hermes`, …) | yes |
| 1Password CLI (`op`), from 1Password's signed apt/yum repository | yes (`multica_op_install`) |
| 1Password service account token, as a 0600 file for the daemon user, plus an `op` shim that reads it | opt-in (`multica_op_deliver_token`); not exported to the daemon |
| `workspaces_root`, on the intended mount, writable by the daemon user | yes |
| Multica authentication (`multica login --token`) | yes |
| CLI config (`server_url`, `app_url`, `workspaces_root`, `device_name`, …) | yes |
| Boot autostart (systemd user unit + `loginctl enable-linger`) | yes |
| Daemon start | yes |
| Runtime registration | follows automatically |
| **Provider CLI subscription login (e.g. `claude`)** | **no — browser, once per host** |
| Provider CLI credentials from a secret manager | out of scope; the role reports it as a remaining step |
## Requirements
- Linux target, `x86_64` or `aarch64`.
- systemd, for boot autostart. Without it the CLI falls back to an XDG autostart
entry, which does not help a headless host — set `multica_autostart: false`
and supervise the daemon yourself.
- Privilege escalation on the target (`become: true`).
- Ansible 2.14+.
## Secrets
**The role does not know where your secrets live.** It consumes one plain
variable:
```yaml
multica_pat: "{{ ... }}" # mul_… user PAT, or mcn_… Cloud Node PAT
```
The playbook supplies it however your environment already does. In this
homelab that means an **`ansible-vault` encrypted var**, which is what the
example playbook uses; a `community.hashi_vault` lookup, a 1Password lookup and
a plain environment variable are written out beside it as commented
alternatives.
See [Getting and storing the token](#getting-and-storing-the-token) below for
the end-to-end procedure.
Three consequences worth stating:
1. The role is reusable across environments that do not share a secrets backend.
2. Consolidating providers later is a one-line playbook change; this role is
untouched.
3. It sidesteps the HashiCorp Vault / `ansible-vault` ambiguity entirely — a
role that takes a value rather than fetching one works with either today.
`no_log: true` is set on the login task regardless of where the token comes
from. Ansible logs command arguments by default; without it the PAT lands in
play output, in any CI log, and in every callback plugin.
This matters just as much with `ansible-vault` as with a remote lookup: an
encrypted var is decrypted in memory for the duration of the play, so
encryption at rest does nothing for the runtime path. `no_log` is what covers
it.
**Prefer a Cloud Node PAT (`mcn_…`) over a user PAT** where its scope is
narrower — a daemon host does not need everything a person can do. Confirm the
scope difference against your Multica deployment before deciding.
## Getting and storing the token
### Minting it
**There is no CLI command for this.** `multica user` only manages your profile
and `multica auth` only reports status and logs out; nothing in the command
tree mints a token of either kind — the CLI only ever consumes one. A PAT is
created in the Multica web app under account settings, in a Tokens section.
(The exact menu path could not be confirmed from a headless runtime, so treat
that as "where to look", not a click path.)
Two prefixes exist, and `multica login --token` accepts either:
| Prefix | Kind |
|---|---|
| `mul_` | User PAT — acts as you, with everything your account can reach |
| `mcn_` | "Cloud Node" PAT |
**On a self-hosted server, check whether `mcn_` is offered at all before
planning around it.** The string `mcn_` appears in exactly one place in the
shipped CLI — the `login --token` help text — and the only other place the
product uses "Cloud" as a noun is `multica setup cloud`, which points at
Multica Cloud (multica.ai) as opposed to `multica setup self-host`. That is
suggestive, not conclusive: Cloud Node PATs may well be a Multica Cloud
concept with no equivalent on a self-hosted deployment. The web UI's token
screen is the authority — whatever token types it offers you is the real
answer.
Either prefix satisfies the role; preflight validates both.
### Scoping a token on a self-hosted deployment
If `mul_` is the only kind available, a user PAT on a long-lived daemon host
means every run that host claims carries your full authority, and revoking it
later cuts off your own access too.
The lever that does exist is a **dedicated service member**. Workspace roles
are `owner`, `admin` and `member`:
```bash
multica workspace member list --output json # see who exists and their roles
multica workspace member invite agents@example.com --role member
```
Have that account accept the invitation, log in as it, mint *its* PAT, and give
that to the host. `member` is coarser than a purpose-built node credential, but
it is strictly narrower than `owner`, and revoking it touches nothing you use.
Do **not** reuse the session token your desktop CLI already holds. It is not a
PAT, the server renews it on its own schedule, and it ties the host's identity
to your desktop login — revoking one breaks the other.
### Storing it with ansible-vault
One-time, if you do not already have a vault password file:
```bash
mkdir -p ~/.ansible
openssl rand -base64 48 > ~/.ansible/vault-pass-homelab
chmod 600 ~/.ansible/vault-pass-homelab
```
Point `ansible.cfg` at it so no play needs `--ask-vault-pass`:
```ini
[defaults]
vault_password_file = ~/.ansible/vault-pass-homelab
```
Then create the encrypted var file:
```bash
mkdir -p group_vars/multica_agent_hosts
ansible-vault create group_vars/multica_agent_hosts/vault.yml
```
with a single line in it:
```yaml
vault_multica_pat: "mul_...."
```
The playbook already reads `multica_pat: "{{ vault_multica_pat }}"`. The
indirection is deliberate: the `vault_` prefix makes it obvious at a glance
which variables are encrypted, and it keeps the name greppable without
decrypting anything.
**Alternative, if you would rather not keep a whole encrypted file:** encrypt
just the string and paste the block into an ordinary `group_vars` file.
```bash
ansible-vault encrypt_string --stdin-name 'vault_multica_pat'
```
Paste the token, press Ctrl-D. Because it reads stdin, the token never enters
your shell history — which `ansible-vault encrypt_string 'mul_...'` as an
argument would.
### Checks worth running once
```bash
# the file is actually encrypted, not plaintext you forgot to encrypt
head -1 group_vars/multica_agent_hosts/vault.yml # => $ANSIBLE_VAULT;1.1;AES256
# the var resolves and the role's prefix check will pass, without printing it
ansible -i inventory/hosts.yml multica_agent_hosts -m debug \
-a 'msg="pat resolves: {{ (vault_multica_pat | default("")) is match("^(mul_|mcn_)") }}"'
```
That second command prints only `true` or `false`, never the token.
Add the vault password file to `.gitignore` if it lives anywhere near the repo.
The encrypted `vault.yml` is safe to commit; the password file never is.
## Ansible owns the version
`multica_version` is pinned and `disable_auto_update` is set to `true` in CLI
config. Re-running the play **is** the upgrade path. Without this the CLI
self-updates out from under the role's declared state and the host silently
stops matching what the repo says it is.
The same reasoning applies to `multica_node_version` (full semver, not a major
track) and to `package_version` on each npm-installed agent CLI — left empty by
default, which tracks `latest`; set it to own that version too.
## Variables
See `defaults/main.yml` for the annotated list. The ones you will actually set:
| Variable | Default | Notes |
|---|---|---|
| `multica_server_url` | — | **Required.** |
| `multica_pat` | — | **Required.** See above. |
| `multica_version` | `0.6.5` | Pinned CLI release. |
| `multica_node_version` | `24.22.1` | Pinned, full semver. |
| `multica_workspaces_root` | `/opt/multica/workspaces` | Local disk. Repoint at a mount when one exists. |
| `multica_workspaces_root_mount` | `""` | Set it and the play aborts if the mount is missing. |
| `multica_agent_clis` | `[claude]` | Keys of `multica_agent_cli_catalog`. |
| `multica_device_name` | short inventory hostname | Shows up in `runtime list` → `device_info`. |
| `multica_user` | `multica` | Daemon service account. |
| `multica_profile` | `""` | `--profile`, to isolate config/daemon state/workspaces. |
| `multica_allow_remote_install_scripts` | `false` | Required for `hermes`; see below. |
| `multica_op_install` | `true` | 1Password CLI from 1Password's apt/yum repo. |
| `multica_op_version` | `""` | `""` tracks the repo (upgrades on every run); set e.g. `2.41.1` to pin. |
| `multica_op_deliver_token` | `false` | Write `multica_op_service_account_token` to `multica_op_token_file`. |
| `multica_op_shim` | `true` | With the token delivered, install the `op` shim at `multica_op_shim_path`. |
### Adding an agent CLI
Extend `multica_agent_cli_catalog`. Two install methods are implemented:
```yaml
multica_agent_cli_catalog:
codex:
provider: codex # must match `provider` in `multica runtime list`
binary: codex
method: npm
package: "@openai/codex"
package_version: ""
version_command: "codex --version"
manual_login: true
manual_login_hint: "run `codex login` once as the daemon user"
```
`method: script` fetches `install_url` and runs it as the daemon user. That is a
supply-chain decision, so the role **refuses** unless
`multica_allow_remote_install_scripts: true`. `hermes` uses this method
(`https://hermes-agent.nousresearch.com/install.sh`). Vendoring the installer
into your repo and switching to a local `script` source is the stricter option.
Agent CLI binaries are symlinked into `/usr/local/bin`. This matters: the daemon
runs from a systemd **user unit**, which gets systemd's default `PATH` and does
*not* source `/etc/profile.d`. A CLI reachable only from an interactive login
shell is invisible to the daemon. `MULTICA_<PROVIDER>_PATH` is the escape hatch
if you need the binary somewhere else.
Both install methods resolve the binary with `command -v` as the daemon user
and link *that* path, rather than assuming where they put it. This matters when
a CLI was already installed by other means — the install step is skipped, so
assuming the npm prefix would link to nothing. The role asserts the binary is
resolvable before linking, and linking the resolved path repairs a dangling
link left behind by an earlier run.
## Moving the workspaces root onto a mount later
`workspaces_root` holds per-run working directories, not durable state — the
daemon recreates what it needs. Moving it is therefore a drain-and-repoint, not
a migration:
```bash
multica daemon disk-usage --output json # see what is actually there first
sudo -iu multica multica daemon stop # let in-flight runs finish first
```
Then set `multica_workspaces_root` and `multica_workspaces_root_mount` together
in the playbook and re-run. The role creates the new directory, proves the
daemon user can write to it, updates `config set workspaces_root`, and restarts
the daemon.
**Set both variables in the same change.** Pointing the root at a mount path
without setting the guard means a play that runs while the mount is down
silently writes run workspaces onto the root filesystem — which nobody notices
until it fills. The guard turns that into a failed assertion instead.
The old directory is left behind deliberately; remove it by hand once the new
one has taken runs.
## Idempotency
Every step reads before it writes:
| Step | Guard |
|---|---|
| Node install | `node --version` equals the pin |
| CLI install | `multica --version` equals the pin |
| Agent CLI install | `command -v <binary>` and its version command |
| Config | `multica config show` parsed and compared key by key |
| Login | `multica auth status` already names `multica_server_url` (it prints to **stderr**; the guard reads both streams) |
| Autostart | `multica daemon autostart status` |
| Daemon start | `systemctl --user is-active <unit>`; a daemon running *outside* the unit is stopped first, then the unit is started |
A binary, config or credential change notifies a `systemctl --user restart`
handler, which is flushed *before* verification reads the daemon's state back.
The role never calls `multica daemon start` or `multica daemon restart`. Both
fork the daemon inside the caller's session (Ansible's SSH session scope),
outside the unit's cgroup. The unit then shows `inactive`, nothing supervises
the process, and a later `systemctl --user start` collides with it on
`daemon.lock`.
A converged host reports zero changed tasks.
## Verification
The role ends in assertions, not hope:
- `multica daemon status --output json` reports `status: running`, and its
`cli_version` and `server_url` match the declared values.
- `multica runtime list --output json` contains an **online** runtime for every
entry in `multica_agent_clis`, filtered to this host's `daemon_id`, with
`device_info` naming `multica_device_name`. Retried, because registration is
asynchronous after the daemon starts.
- `workspaces_root` exists, is a directory, is owned by the daemon user, is
actually writable by it (ownership is not writability on a root-squashed
network mount), and sits on `multica_workspaces_root_mount` when that is set.
It finishes by printing the manual steps that remain — the per-host provider CLI
logins.
## Testing against a throwaway VM
Molecule is fine but a scratch VM is proportionate:
```bash
# 1. clean host
ansible-playbook -i inventory/hosts.yml playbooks/provision-agent-host.yml -l scratch-vm
# 2. re-run: must report changed=0
ansible-playbook -i inventory/hosts.yml playbooks/provision-agent-host.yml -l scratch-vm
# 3. reboot, then confirm the daemon came back on its own
ssh scratch-vm 'sudo -iu multica multica daemon status --output json'
# 4. confirm from the workspace side
multica runtime list --output json | jq '.[] | select(.device_info | startswith("scratch-vm"))'
```
Then complete the browser login for each provider CLI once:
```bash
ssh -t scratch-vm 'sudo -iu multica claude'
```
That step only becomes automatable if the host moves from a subscription to an
API key.
## 1Password CLI
`op` lets an agent resolve `op://vault/item/field` references at the moment
it runs a command (`op run -- cmd`, `op inject -i tpl -o out`, `op read`), so
the secret value never has to appear in a prompt, an issue or a transcript.
The 1Password desktop-app integration (and the Claude Code plugin built on it)
needs a GUI session, so headless agent hosts use a **service account** instead.
Install, Debian/Ubuntu, exactly as 1Password documents it, with one addition:
- the key at `multica_op_key_url` is fingerprint-checked against
`multica_op_key_fingerprint` before it is dearmored, so apt never trusts a
substituted key;
- dearmored to `/usr/share/keyrings/1password-archive-keyring.gpg` (apt,
`signed-by=`) and to `/usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg`
with the policy in `/etc/debsig/policies/AC2D62742012EA22/` (debsig-verify);
- `1password-cli` from `downloads.1password.com/linux/debian/<arch> stable`.
EL uses 1Password's yum repository with `gpgcheck` and `repo_gpgcheck`.
The play then runs `op --version` as the daemon user, in the daemon user's
environment.
### The service account token
The role can deliver the token, but by default does not:
```yaml
multica_op_deliver_token: true
multica_op_service_account_token: "{{ vault_op_service_account_claude }}"
```
writes it to `multica_op_token_file`
(`~multica/.config/op-service-account/token`, directory 0700, file 0600,
owned by the daemon user) and installs an `op` shim. **The role does not put
the token into the daemon's environment**, for two reasons:
1. The daemon's systemd user unit is written and owned by the multica CLI
(`multica daemon autostart`). The role must not template or edit it, or it
silently freezes the CLI's `ExecStart`, environment and restart semantics.
2. Anything in the daemon's environment is inherited by every agent process
it spawns, where a casual `env` or `printenv` puts it in a transcript.
`op` reads the token only from `OP_SERVICE_ACCOUNT_TOKEN`; it has no
token-file option. So the role installs a shim at `multica_op_shim_path`
(`~multica/.local/bin/op`, root-owned 0755), which the CLI-written unit
already puts first on `PATH`. The shim:
- reads the token file and exports `OP_SERVICE_ACCOUNT_TOKEN` to
`multica_op_binary` (`/usr/bin/op`) only, then `exec`s it. Agents call plain
`op run` / `op inject` / `op read` and the token never enters their shell;
- fails closed (exit 78, no token in the message) when the file is missing
or empty;
- for `op run` and `op plugin run`, splices `/usr/bin/env -u
OP_SERVICE_ACCOUNT_TOKEN` in after the first `--`. `op run` otherwise
hands its whole environment, token included, to the command it starts.
Because it needs that `--` to find the command, the shim refuses
`op run <command>` without one (exit 64).
The play checks, as the daemon user, that `op` resolves to the shim and that
the token is not in that environment.
The shim keeps the token out of transcripts by accident; it is not a
boundary. Any process running as the daemon user can read the token file on
purpose. What actually limits exposure is the service account's vault access
in 1Password: keep it read-only. Check it as the daemon user:
```bash
op vault list --permission read_items # vaults it can read
op vault list --permission edit_items # should be empty
```
## Known gaps
- **Provider CLI login.** Unavoidable for subscription auth.
- **Hermes credentials.** Hermes reads 1Password natively; wiring that is not
implemented here because the right item layout is deployment-specific. The
role installs Hermes and reports the credential step as outstanding.
- **Non-systemd hosts.** Autostart degrades to an XDG entry that a headless host
never triggers.
## Tags
| Tag | Covers |
|---|---|
| `multica_workspaces` | workspaces root |
| `multica_node` | Node.js |
| `multica_cli` | multica CLI |
| `multica_agents` | agent CLIs |
| `multica_config` | `multica config set` |
| `multica_auth` | `multica login` |
| `multica_daemon` | autostart + start |
| `multica_verify` | the assertions |
| `multica_op` | 1Password CLI, and the token file and shim when enabled |
| `multica` | all of the above |
Preflight and the service-account step are tagged `always`: they build
`_multica_uid` and `_multica_user_env`, which every `become_user` task below
depends on, so a `--tags` subset still runs them. Both are idempotent.
## Offline checks
From the repo root:
```bash
ansible-lint playbooks/provision-agent-host.yml roles/multica_agent_host
ansible-playbook --syntax-check playbooks/provision-agent-host.yml
ansible-playbook -i localhost, tests/expressions.yml
```
`tests/expressions.yml` runs the role's filter chains — `config show` parsing,
version extraction, the online-runtime difference, the mount containment check
— against fixtures captured from a real `multica` CLI, with no target host.
It catches the class of bug where a filter chain silently returns the wrong
type or matches nothing; it does not replace the scratch-VM run above.
@@ -0,0 +1,190 @@
---
# =============================================================================
# multica_agent_host — defaults
#
# Everything the role needs is a plain variable. The role never reaches into a
# secrets backend itself; see `multica_pat` below.
# =============================================================================
# -----------------------------------------------------------------------------
# Service account
# -----------------------------------------------------------------------------
# The daemon runs as a dedicated unprivileged user with a real home directory.
# It needs a real home (CLI config, agent CLI credentials) and systemd linger,
# so this is NOT a `system: true` account.
multica_manage_user: true
multica_user: multica
multica_group: multica
multica_user_home: "/home/{{ multica_user }}"
multica_user_shell: /bin/bash
# Extra groups for the daemon user (e.g. docker). Left empty by default.
multica_user_extra_groups: []
# -----------------------------------------------------------------------------
# multica CLI — Ansible owns the version
# -----------------------------------------------------------------------------
# Pin it. Re-running the play IS the upgrade path; see multica_disable_auto_update.
multica_version: "0.6.5"
multica_install_dir: /usr/local/bin
multica_release_download_base_url: "https://github.com/multica-ai/multica/releases/download"
# Verify the release tarball against the release's own checksums.txt.
multica_verify_checksum: true
# -----------------------------------------------------------------------------
# Server / workspace
# -----------------------------------------------------------------------------
multica_server_url: "" # REQUIRED, e.g. https://multica.wdw-labs.cloud
multica_app_url: "" # optional frontend URL
multica_workspace_id: "" # optional: pin this profile's default workspace
multica_profile: "" # optional: --profile <name> (isolates config + daemon state)
# -----------------------------------------------------------------------------
# Daemon configuration (mirrors `multica config set` keys)
# -----------------------------------------------------------------------------
# Local disk by default. Repoint this at a mount when one exists, and set
# multica_workspaces_root_mount at the same time so the play fails loudly if
# the mount is missing rather than silently filling the root filesystem.
multica_workspaces_root: "/opt/multica/workspaces"
# Optional guard: assert workspaces_root actually sits on this mount point.
# e.g. "/mnt/utilidor". Empty disables the check.
multica_workspaces_root_mount: ""
multica_workspaces_root_mode: "0750"
multica_device_name: "{{ inventory_hostname.split('.')[0] }}"
multica_runtime_name: "" # "" = leave unset, CLI derives it
multica_max_concurrent_tasks: "" # "" = leave unset
multica_poll_interval: "" # "" = leave unset
multica_heartbeat_interval: "" # "" = leave unset
multica_agent_timeout: "" # "" = leave unset ("0s" disables the cap)
# Ansible owns the version: stop the CLI self-updating out from under the
# role's declared state. Set false only if you want the daemon to self-update.
multica_disable_auto_update: true
# Boot autostart. On Linux `multica daemon autostart enable` writes a systemd
# *user* unit, which only starts at boot when the user has lingering enabled.
multica_autostart: true
multica_enable_linger: true
# -----------------------------------------------------------------------------
# Authentication — backend-agnostic by design
# -----------------------------------------------------------------------------
# REQUIRED. A Multica personal access token: `mul_...` (user PAT) or
# `mcn_...` (Cloud Node PAT). The role takes a VALUE; it does not know or care
# where it came from. Supply it from the playbook with whatever your
# environment already uses:
#
# HashiCorp Vault:
# multica_pat: "{{ lookup('community.hashi_vault.vault_kv2_get',
# 'multica/agent-host').secret.pat }}"
# ansible-vault encrypted var:
# multica_pat: "{{ vaulted_multica_pat }}"
# 1Password:
# multica_pat: "{{ lookup('community.general.onepassword',
# 'Multica Agent Host PAT', field='credential') }}"
# Environment:
# multica_pat: "{{ lookup('env', 'MULTICA_PAT') }}"
#
# Consolidating secret providers later is a one-line playbook change; this role
# is untouched by it.
multica_pat: ""
# Force `multica login --token` even when the host already reports an
# authenticated session against multica_server_url.
multica_force_relogin: false
# -----------------------------------------------------------------------------
# Agent CLIs
# -----------------------------------------------------------------------------
# Which provider CLIs this host should offer. Each must be a key in
# multica_agent_cli_catalog. The daemon registers one runtime per CLI it finds.
multica_agent_clis:
- claude
# Some agent CLIs are installed by piping a vendor script into a shell. That is
# a real supply-chain decision, so it is opt-in and off by default.
multica_allow_remote_install_scripts: false
multica_agent_cli_catalog:
claude:
provider: claude # matches `provider` in `multica runtime list`
binary: claude
method: npm
package: "@anthropic-ai/claude-code"
# Pinned: npm "latest" is 2.1.306 while "stable" is 2.1.304, so an empty
# value silently tracks whichever npm serves that day. 2.1.304 is the
# version running on haunted-mansion.
package_version: "2.1.304"
version_command: "claude --version"
# Subscription auth needs a browser once per host. Not automatable here.
manual_login: true
manual_login_hint: "run `claude` once as {{ multica_user }} and complete the login"
hermes:
provider: hermes
binary: hermes
method: script
install_url: "https://hermes-agent.nousresearch.com/install.sh"
install_dir: "{{ multica_user_home }}/.hermes/hermes-agent"
version_command: "hermes --version"
manual_login: true
manual_login_hint: "configure Hermes credentials as {{ multica_user }} (Hermes reads 1Password natively; confirm against its docs)"
# Keys MERGED (recursively) into the daemon user's ~/.claude/settings.json.
# Merged, not templated: Claude Code writes that file itself. Keys not named
# here are left alone. Empty = don't touch the file.
#
# An npm-installed Claude Code updates itself in place, which silently breaks
# the package_version pin above (and the next play downgrades it again).
# Setting env.DISABLE_AUTOUPDATER: "1" here is what makes the pin hold.
multica_claude_settings: {}
# -----------------------------------------------------------------------------
# 1Password CLI (op)
# -----------------------------------------------------------------------------
# Lets agents resolve op:// references with `op run` / `op inject` / `op read`,
# so secret values never pass through a prompt or a transcript. Installed from
# 1Password's signed apt/yum repository.
multica_op_install: true
# "" = whatever the repository serves, upgraded on every run. Set e.g. "2.41.1"
# to pin. 1Password's apt index carries only the newest build, so a pin older
# than that cannot be installed on a fresh host.
multica_op_version: ""
multica_op_key_url: "https://downloads.1password.com/linux/keys/1password.asc"
# Code signing for 1Password <codesign@1password.com>. The play refuses a key
# at multica_op_key_url with any other fingerprint.
multica_op_key_fingerprint: "3FEF9748469ADBE15DA7CA80AC2D62742012EA22"
multica_op_debsig_policy_url: "https://downloads.1password.com/linux/debian/debsig/1password.pol"
multica_op_apt_base_url: "https://downloads.1password.com/linux/debian"
multica_op_rpm_base_url: "https://downloads.1password.com/linux/rpm/stable"
# Service account token delivery. Off by default. When on, the token is written
# to multica_op_token_file (0600, owned by the daemon user) and read from there
# by the op shim below. The role does NOT export it into the daemon
# environment; see the README. Supply the VALUE from the playbook, e.g.
# multica_op_service_account_token: "{{ vault_op_service_account_claude }}"
multica_op_deliver_token: false
multica_op_service_account_token: ""
multica_op_token_file: "{{ multica_user_home }}/.config/op-service-account/token"
# With the token delivered, install a shim at multica_op_shim_path (first on
# the daemon unit's PATH) that exports the token to multica_op_binary only.
multica_op_shim: true
multica_op_shim_path: "{{ multica_user_home }}/.local/bin/op"
multica_op_binary: /usr/bin/op
# -----------------------------------------------------------------------------
# Node.js (installed only when a selected agent CLI needs npm)
# -----------------------------------------------------------------------------
# Full semver — this is a pin, not a major-version track.
multica_node_version: "24.22.1"
multica_node_dist_base_url: "https://nodejs.org/dist"
multica_node_prefix: /usr/local/lib/nodejs
# npm global prefix for the daemon user, so agent CLI installs need no root.
multica_npm_prefix: "{{ multica_user_home }}/.npm-global"
# -----------------------------------------------------------------------------
# Verification
# -----------------------------------------------------------------------------
multica_verify: true
# How long to wait for runtimes to appear online after the daemon starts.
multica_verify_retries: 24
multica_verify_delay: 5
@@ -0,0 +1,11 @@
---
# Through systemd, not `multica daemon restart`: that respawns the daemon in
# the caller's session, outside the unit (see tasks/daemon.yml).
- name: Restart multica daemon
ansible.builtin.command:
argv: [systemctl, --user, restart, "{{ _multica_unit | default('multica-daemon.service') }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
changed_when: true
listen: restart multica daemon
@@ -0,0 +1,22 @@
---
galaxy_info:
role_name: multica_agent_host
namespace: wdwlabs
author: WDW Labs
description: >-
Provision a Multica agent host from bare Linux to a registered, running
daemon with its provider runtimes online.
license: MIT
min_ansible_version: "2.14"
platforms:
- name: Debian
versions: [bullseye, bookworm, trixie]
- name: Ubuntu
versions: [jammy, noble]
- name: EL
versions: ["9"]
galaxy_tags:
- multica
- agents
- daemon
dependencies: []
@@ -0,0 +1,42 @@
---
# Resolve where the binary ACTUALLY is before linking to it.
#
# The npm path used to link unconditionally from multica_npm_prefix. When the
# CLI was already installed by some other means — a distro package, a vendor
# installer, a previous hand-rollout — nothing was installed at that prefix and
# `file: state=link force=true` happily created a DANGLING symlink in
# /usr/local/bin. PATH resolution skips a dangling link, so discovery still
# worked by luck wherever the real binary was already on systemd's PATH, and
# the role's actual guarantee silently did not hold.
- name: "Agents | Resolve the installed binary | {{ _agent_key }}"
ansible.builtin.command:
argv: ["/bin/sh", "-c", "command -v {{ _agent.binary }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _agent_which
changed_when: false
failed_when: false
check_mode: false
- name: "Agents | Require the binary to be resolvable | {{ _agent_key }}"
ansible.builtin.assert:
that:
- _agent_which.rc == 0
- (_agent_which.stdout | trim) | length > 0
fail_msg: >-
{{ _agent.binary }} is not on {{ multica_user }}'s PATH after the install
step. The daemon discovers provider CLIs by PATH, so it would register no
{{ _agent_key }} runtime.
quiet: true
# Linking the resolved path also repairs a dangling link left by an older run:
# `command -v` skips the broken link, so this overwrites it with a working one.
- name: "Agents | Link onto the default PATH | {{ _agent_key }}"
ansible.builtin.file:
src: "{{ _agent_which.stdout | trim }}"
dest: "{{ multica_install_dir }}/{{ _agent.binary }}"
state: link
force: true
become: true
when: (_agent_which.stdout | trim) != (multica_install_dir ~ '/' ~ _agent.binary)
@@ -0,0 +1,46 @@
---
- name: "Agents | Resolve catalog entry | {{ _agent_key }}"
ansible.builtin.set_fact:
_agent: "{{ multica_agent_cli_catalog[_agent_key] }}"
- name: "Agents | Read the installed version | {{ _agent_key }}"
ansible.builtin.command:
argv: ["/bin/sh", "-c", "command -v {{ _agent.binary }} >/dev/null 2>&1 && {{ _agent.version_command }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _agent_installed
changed_when: false
failed_when: false
check_mode: false
# Version formats differ per CLI ("2.1.304 (Claude Code)", "Hermes Agent
# v0.21.5+..."), so this matches the pinned version anywhere in the output —
# but on a version-component boundary. A plain substring test would accept
# 2.1.3045 for a pin of 2.1.304, and silently never upgrade.
- name: "Agents | Decide whether an install is needed | {{ _agent_key }}"
ansible.builtin.set_fact:
_agent_needs_install: >-
{{ _agent_installed.rc != 0
or (_agent.package_version | default('') | length > 0
and not ((_agent_installed.stdout | default('')) is search(
'(^|[^0-9.])' ~ (_agent.package_version | regex_escape) ~ '([^0-9.]|$)'))) }}
- name: "Agents | Install via npm as the daemon user | {{ _agent_key }}"
ansible.builtin.command:
argv:
- "{{ multica_install_dir }}/npm"
- install
- --global
- --no-fund
- --no-audit
- "{{ _agent.package }}{{ ('@' ~ _agent.package_version) if _agent.package_version | default('') | length > 0 else '' }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: _agent_needs_install | bool
changed_when: true
notify: restart multica daemon
- name: "Agents | Resolve and link onto the default PATH | {{ _agent_key }}"
ansible.builtin.include_tasks: _link.yml
@@ -0,0 +1,53 @@
---
- name: "Agents | Resolve catalog entry | {{ _agent_key }}"
ansible.builtin.set_fact:
_agent: "{{ multica_agent_cli_catalog[_agent_key] }}"
- name: "Agents | Read the installed version | {{ _agent_key }}"
ansible.builtin.command:
argv: ["/bin/sh", "-c", "command -v {{ _agent.binary }} >/dev/null 2>&1 && {{ _agent.version_command }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _agent_installed
changed_when: false
failed_when: false
check_mode: false
# Piping a vendor script into a shell is a supply-chain decision, so the role
# refuses rather than making it quietly on the operator's behalf.
- name: "Agents | Refuse to run a remote installer unless allowed | {{ _agent_key }}"
ansible.builtin.fail:
msg: >-
{{ _agent_key }} installs by executing {{ _agent.install_url }}.
Set multica_allow_remote_install_scripts: true to permit that, vendor the
installer yourself, or drop {{ _agent_key }} from multica_agent_clis.
when:
- _agent_installed.rc != 0
- not (multica_allow_remote_install_scripts | bool)
- name: "Agents | Install via the vendor script | {{ _agent_key }}"
when:
- _agent_installed.rc != 0
- multica_allow_remote_install_scripts | bool
block:
- name: "Agents | Fetch the installer | {{ _agent_key }}"
ansible.builtin.get_url:
url: "{{ _agent.install_url }}"
dest: "{{ multica_user_home }}/.cache/{{ _agent_key }}-install.sh"
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0700"
become: true
- name: "Agents | Run it as the daemon user | {{ _agent_key }}"
ansible.builtin.command:
argv: ["/bin/bash", "{{ multica_user_home }}/.cache/{{ _agent_key }}-install.sh"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
changed_when: true
notify: restart multica daemon
- name: "Agents | Resolve and link onto the default PATH | {{ _agent_key }}"
ansible.builtin.include_tasks: _link.yml
@@ -0,0 +1,7 @@
---
- name: Agents | Install each selected agent CLI
ansible.builtin.include_tasks: "agent_cli/{{ multica_agent_cli_catalog[_agent_key].method }}.yml"
loop: "{{ multica_agent_clis }}"
loop_control:
loop_var: _agent_key
label: "{{ _agent_key }}"
@@ -0,0 +1,50 @@
---
# `multica auth status` prints the server and user when authenticated and exits
# non-zero otherwise, so it is the idempotency guard for login.
#
# It prints to STDERR, not stdout (0.6.5). A stdout-only guard never matches,
# so every run re-logs in and restarts the daemon. It also echoes a token
# prefix, hence no_log.
- name: Auth | Read the current authentication state
ansible.builtin.command:
argv: "{{ _multica_cmd + ['auth', 'status'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_auth_status
changed_when: false
failed_when: false
check_mode: false
no_log: true
- name: Auth | Decide whether a login is needed
ansible.builtin.set_fact:
_multica_needs_login: >-
{{ multica_force_relogin | bool
or _multica_auth_status.rc != 0
or multica_server_url.rstrip('/') not in
((_multica_auth_status.stdout | default('')) ~ (_multica_auth_status.stderr | default(''))) }}
# no_log is mandatory: without it Ansible records the full argv — including the
# PAT — in play output, CI logs, and every callback plugin.
- name: Auth | Log in with the personal access token
ansible.builtin.command:
argv: "{{ _multica_cmd + ['login', '--token', multica_pat] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: _multica_needs_login | bool
changed_when: true
no_log: true
notify: restart multica daemon
- name: Auth | Pin the default workspace
ansible.builtin.command:
argv: "{{ _multica_cmd + ['workspace', 'switch', multica_workspace_id] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: multica_workspace_id | length > 0
register: _multica_ws_switch
changed_when: _multica_needs_login | bool
notify: restart multica daemon
@@ -0,0 +1,34 @@
---
- name: Claude settings | Ensure ~/.claude exists
ansible.builtin.file:
path: "{{ multica_user_home }}/.claude"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0700"
become: true
- name: Claude settings | Read the current settings
ansible.builtin.slurp:
src: "{{ multica_user_home }}/.claude/settings.json"
register: _multica_claude_settings_raw
failed_when: false
become: true
- name: Claude settings | Merge the declared keys over them
ansible.builtin.set_fact:
_multica_claude_settings_current: >-
{{ (_multica_claude_settings_raw.content | b64decode | from_json)
if _multica_claude_settings_raw.content is defined else {} }}
- name: Claude settings | Write them back if anything differs
ansible.builtin.copy:
dest: "{{ multica_user_home }}/.claude/settings.json"
content: "{{ _multica_claude_settings_current | combine(multica_claude_settings, recursive=true) | to_nice_json }}\n"
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0600"
become: true
when: >-
_multica_claude_settings_current | combine(multica_claude_settings, recursive=true)
!= _multica_claude_settings_current
@@ -0,0 +1,56 @@
---
# The CLI owns its config file format, so the role drives `multica config set`
# rather than templating config.json. Each key is read back first, so a
# converged host reports zero changes.
- name: Config | Build the desired configuration
ansible.builtin.set_fact:
_multica_desired_config: >-
{{ {
'server_url': multica_server_url,
'app_url': multica_app_url,
'workspaces_root': multica_workspaces_root,
'device_name': multica_device_name,
'runtime_name': multica_runtime_name,
'max_concurrent_tasks': multica_max_concurrent_tasks,
'poll_interval': multica_poll_interval,
'heartbeat_interval': multica_heartbeat_interval,
'agent_timeout': multica_agent_timeout,
'disable_auto_update': ('true' if multica_disable_auto_update | bool else 'false'),
} | dict2items
| rejectattr('value', 'equalto', '')
| list | items2dict }}
- name: Config | Read the current configuration
ansible.builtin.command:
argv: "{{ _multica_cmd + ['config', 'show'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_config_show
changed_when: false
check_mode: false
# `config show` prints "key:<spaces>value", with "(not set)" for unset keys.
- name: Config | Parse it
ansible.builtin.set_fact:
_multica_current_config: >-
{{ dict(
_multica_config_show.stdout_lines
| select('match', '^[a-z_]+:')
| map('regex_replace', '^([a-z_]+):\s*(.*?)\s*$', '\1|\2')
| map('split', '|')
) }}
- name: Config | Apply the keys that differ
ansible.builtin.command:
argv: "{{ _multica_cmd + ['config', 'set', item.key, item.value] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
loop: "{{ _multica_desired_config | dict2items }}"
loop_control:
label: "{{ item.key }}={{ item.value }}"
when: (_multica_current_config[item.key] | default('(not set)')) != item.value
changed_when: true
notify: restart multica daemon
@@ -0,0 +1,138 @@
---
# On Linux `multica daemon autostart enable` writes a systemd *user* unit.
# A user unit does not start at boot unless the user lingers — without this the
# daemon only comes back when someone logs in, which on a headless agent host
# is never.
- name: Daemon | Enable systemd lingering for the daemon user
ansible.builtin.command:
argv: ["loginctl", "enable-linger", "{{ multica_user }}"]
creates: "/var/lib/systemd/linger/{{ multica_user }}"
become: true
when:
- multica_enable_linger | bool
- multica_autostart | bool
- name: Daemon | Wait for the user's runtime directory
ansible.builtin.wait_for:
path: "/run/user/{{ _multica_uid }}"
state: present
timeout: 30
become: true
when:
- multica_enable_linger | bool
- multica_autostart | bool
- name: Daemon | Read the current autostart registration
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'autostart', 'status'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_autostart_status
changed_when: false
failed_when: false
check_mode: false
- name: Daemon | Register boot autostart
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'autostart', 'enable'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when:
- multica_autostart | bool
- _multica_autostart_status.rc != 0
or 'enabled' not in (_multica_autostart_status.stdout | default('') | lower)
register: _multica_autostart_enable
changed_when: true
# Lifecycle goes through systemd, never `multica daemon start|restart`. Those
# fork the daemon inside the CALLER's session (here, Ansible's SSH session
# scope), outside the unit's cgroup: the unit stays inactive, nothing
# supervises the process, and the next `systemctl --user start` collides with
# it on daemon.lock. Seen on haunted-mansion (2026-10-21) and spaceship-earth
# (2026-10-22).
#
# The unit name comes from `autostart status` ("Location: .../<name>.service")
# so a --profile host resolves its own unit.
- name: Daemon | Re-read the autostart registration
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'autostart', 'status'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_autostart_reread
changed_when: false
failed_when: false
check_mode: false
when: _multica_autostart_enable is changed
- name: Daemon | Resolve the systemd user unit
ansible.builtin.set_fact:
_multica_unit: >-
{{ ((_s.stdout | default('')) ~ '\n' ~ (_s.stderr | default('')))
| regex_search('Location:\s*\S*/([^/\s]+\.service)', '\1')
| default(['multica-daemon.service'], true) | first }}
vars:
_s: "{{ _multica_autostart_reread if _multica_autostart_enable is changed else _multica_autostart_status }}"
# `autostart enable` may rewrite the unit file while the user manager still
# holds the old definition.
- name: Daemon | Reload the user manager after a (re)registration
ansible.builtin.command:
argv: [systemctl, --user, daemon-reload]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: _multica_autostart_enable is changed
changed_when: true
- name: Daemon | Read the unit state
ansible.builtin.command:
argv: [systemctl, --user, is-active, "{{ _multica_unit }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_unit_state
changed_when: false
failed_when: false
check_mode: false
- name: Daemon | Read daemon status
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'status', '--output', 'json'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_daemon_status
changed_when: false
failed_when: false
check_mode: false
# Running, but not under the unit: an orphan. Stop it so the unit can own the
# lock and the process.
- name: Daemon | Stop a daemon running outside its unit
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'stop'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when:
- (_multica_unit_state.stdout | trim) != 'active'
- _multica_daemon_status.rc == 0
- ((_multica_daemon_status.stdout | from_json).status | default('')) == 'running'
changed_when: true
- name: Daemon | Start the unit if it is not active
ansible.builtin.command:
argv: [systemctl, --user, start, "{{ _multica_unit }}"]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: (_multica_unit_state.stdout | trim) != 'active'
changed_when: true
# Anything that changed the binary, the config or the credentials restarts the
# daemon here, before verification reads its state back.
- name: Daemon | Flush handlers
ansible.builtin.meta: flush_handlers
@@ -0,0 +1,62 @@
---
- name: Preflight
ansible.builtin.import_tasks: preflight.yml
# Prerequisite for every other block: always runs, even under a --tags subset.
tags: [always]
- name: Service account
ansible.builtin.import_tasks: user.yml
# Builds _multica_uid / _multica_user_env, which every later become_user task
# needs; creating an existing user is a no-op.
tags: [always]
- name: Workspaces root
ansible.builtin.import_tasks: workspaces_root.yml
tags: [multica, multica_workspaces]
- name: Node.js
ansible.builtin.import_tasks: node.yml
when: _multica_node_required | bool
tags: [multica, multica_node]
- name: Multica CLI
ansible.builtin.import_tasks: multica_cli.yml
tags: [multica, multica_cli]
- name: Agent CLIs
ansible.builtin.import_tasks: agent_clis.yml
tags: [multica, multica_agents]
- name: Claude Code settings
ansible.builtin.import_tasks: claude_settings.yml
when:
- "'claude' in multica_agent_clis"
- multica_claude_settings | length > 0
tags: [multica, multica_agents, multica_claude_settings]
- name: 1Password CLI
ansible.builtin.import_tasks: op_cli.yml
when: multica_op_install | bool
tags: [multica, multica_op]
- name: 1Password service account token
ansible.builtin.import_tasks: op_token.yml
when: multica_op_deliver_token | bool
tags: [multica, multica_op]
- name: CLI configuration
ansible.builtin.import_tasks: config.yml
tags: [multica, multica_config]
- name: Authentication
ansible.builtin.import_tasks: auth.yml
tags: [multica, multica_auth]
- name: Daemon
ansible.builtin.import_tasks: daemon.yml
tags: [multica, multica_daemon]
- name: Verify
ansible.builtin.import_tasks: verify.yml
when: multica_verify | bool
tags: [multica, multica_verify]
@@ -0,0 +1,72 @@
---
- name: CLI | Check whether the binary is already present
ansible.builtin.stat:
path: "{{ _multica_bin }}"
register: _multica_cli_stat
check_mode: false
- name: CLI | Read the installed version
ansible.builtin.command:
argv: ["{{ _multica_bin }}", "--version"]
register: _multica_cli_installed
when: _multica_cli_stat.stat.exists
changed_when: false
failed_when: false
check_mode: false
# `multica --version` prints e.g. "multica 0.6.5 (commit: ..., built: ...)".
- name: CLI | Extract the installed version string
ansible.builtin.set_fact:
_multica_cli_version_found: >-
{{ ((_multica_cli_installed.stdout_lines | default([]) | first | default('')).split() + ['', ''])[1]
if (_multica_cli_installed.rc | default(1)) == 0 else '' }}
- name: CLI | Decide whether an install is needed
ansible.builtin.set_fact:
_multica_cli_needs_install: "{{ (_multica_cli_version_found | trim) != multica_version }}"
- name: CLI | Install the pinned release
when: _multica_cli_needs_install | bool
block:
- name: CLI | Create a staging directory
ansible.builtin.tempfile:
state: directory
suffix: multica-cli
register: _multica_stage
become: true
- name: CLI | Download the release tarball
ansible.builtin.get_url:
url: "{{ _multica_release_url }}"
dest: "{{ _multica_stage.path }}/{{ _multica_tarball }}"
checksum: "{{ ('sha256:' ~ _multica_checksums_url) if multica_verify_checksum | bool else omit }}"
mode: "0644"
become: true
- name: CLI | Unpack the binary
ansible.builtin.unarchive:
src: "{{ _multica_stage.path }}/{{ _multica_tarball }}"
dest: "{{ _multica_stage.path }}"
remote_src: true
become: true
# Replacing the binary while the daemon runs is fine — the handler restarts
# it, and the daemon also watches its own binary for version changes.
- name: CLI | Install it onto the PATH
ansible.builtin.copy:
src: "{{ _multica_stage.path }}/multica"
dest: "{{ _multica_bin }}"
remote_src: true
owner: root
group: root
mode: "0755"
become: true
notify: restart multica daemon
always:
- name: CLI | Remove the staging directory
ansible.builtin.file:
path: "{{ _multica_stage.path }}"
state: absent
become: true
when: _multica_stage.path is defined
@@ -0,0 +1,93 @@
---
# Installed from the official nodejs.org tarball rather than a distro repo so
# that multica_node_version is a real pin on every distribution.
- name: Node | Check whether the pinned runtime is already present
ansible.builtin.stat:
path: "{{ multica_install_dir }}/node"
register: _multica_node_stat
check_mode: false
- name: Node | Read the installed version
ansible.builtin.command:
argv: ["{{ multica_install_dir }}/node", "--version"]
register: _multica_node_installed
when: _multica_node_stat.stat.exists
changed_when: false
failed_when: false
check_mode: false
- name: Node | Decide whether an install is needed
ansible.builtin.set_fact:
_multica_node_needs_install: >-
{{ (not _multica_node_stat.stat.exists)
or (_multica_node_installed.rc | default(1)) != 0
or (_multica_node_installed.stdout | default('') | trim) != ('v' ~ multica_node_version) }}
- name: Node | Install the pinned runtime
when: _multica_node_needs_install | bool
block:
- name: Node | Create the install prefix
ansible.builtin.file:
path: "{{ multica_node_prefix }}"
state: directory
owner: root
group: root
mode: "0755"
become: true
# get_url resolves `sha256:<url>` against the file's basename in SHASUMS256.txt.
- name: Node | Download the tarball with checksum verification
ansible.builtin.get_url:
url: "{{ multica_node_dist_base_url }}/v{{ multica_node_version }}/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
dest: "/tmp/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
checksum: "sha256:{{ multica_node_dist_base_url }}/v{{ multica_node_version }}/SHASUMS256.txt"
mode: "0644"
become: true
- name: Node | Unpack it
ansible.builtin.unarchive:
src: "/tmp/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
dest: "{{ multica_node_prefix }}"
remote_src: true
owner: root
group: root
creates: "{{ multica_node_prefix }}/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}/bin/node"
become: true
- name: Node | Remove the downloaded tarball
ansible.builtin.file:
path: "/tmp/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}.tar.xz"
state: absent
become: true
# Symlinked into /usr/local/bin, which is on systemd's default PATH — the
# daemon's autostart unit does not source /etc/profile.d.
- name: Node | Link node, npm and npx onto the default PATH
ansible.builtin.file:
src: "{{ multica_node_prefix }}/node-v{{ multica_node_version }}-linux-{{ _multica_node_arch }}/bin/{{ item }}"
dest: "{{ multica_install_dir }}/{{ item }}"
state: link
force: true
become: true
loop: [node, npm, npx]
- name: Node | Create the daemon user's npm global prefix
ansible.builtin.file:
path: "{{ multica_npm_prefix }}"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0755"
become: true
- name: Node | Point the daemon user's npm at that prefix
ansible.builtin.lineinfile:
path: "{{ multica_user_home }}/.npmrc"
regexp: '^prefix='
line: "prefix={{ multica_npm_prefix }}"
create: true
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0644"
become: true
@@ -0,0 +1,187 @@
---
# 1Password CLI from 1Password's own signed package repository, never a
# curl-piped binary. Mirrors https://developer.1password.com/docs/cli/get-started
# (Linux, APT / YUM), with the signing key's fingerprint checked before apt or
# dnf is allowed to trust it.
- name: 1Password CLI | Require a supported OS family
ansible.builtin.assert:
that:
- ansible_facts['os_family'] in ['Debian', 'RedHat']
fail_msg: >-
The 1Password CLI install supports Debian- and RedHat-family hosts only
(found {{ ansible_facts['os_family'] }}). Set multica_op_install: false.
quiet: true
# --- Debian / Ubuntu ---------------------------------------------------------
- name: 1Password CLI | Install gnupg and debsig-verify
ansible.builtin.apt:
name: [gnupg, debsig-verify]
state: present
become: true
when: ansible_facts['os_family'] == 'Debian'
- name: 1Password CLI | Fetch the signing key
ansible.builtin.get_url:
url: "{{ multica_op_key_url }}"
dest: /usr/share/keyrings/1password-archive-keyring.asc
owner: root
group: root
mode: "0644"
register: _multica_op_key_asc
become: true
when: ansible_facts['os_family'] == 'Debian'
- name: 1Password CLI | Read the signing key's fingerprint
ansible.builtin.command:
argv: [gpg, --show-keys, --with-colons, /usr/share/keyrings/1password-archive-keyring.asc]
register: _multica_op_key_info
changed_when: false
check_mode: false
become: true
when:
- ansible_facts['os_family'] == 'Debian'
- not (ansible_check_mode and _multica_op_key_asc is changed)
# Under --check on a fresh host the key was only pretend-downloaded, so there is
# nothing to read; the real run checks it.
# A key that does not match is never dearmored, so apt never trusts it.
- name: 1Password CLI | Require the expected signing key
ansible.builtin.assert:
that:
- multica_op_key_fingerprint in (_multica_op_key_info.stdout_lines
| select('match', '^fpr:') | map('split', ':') | map(attribute=9) | list)
fail_msg: >-
{{ multica_op_key_url }} is not the key with fingerprint
{{ multica_op_key_fingerprint }}. Refusing to trust it.
quiet: true
when:
- ansible_facts['os_family'] == 'Debian'
- _multica_op_key_info is not skipped
- name: 1Password CLI | Look for the dearmored keyrings
ansible.builtin.stat:
path: "{{ item }}"
loop:
- /usr/share/keyrings/1password-archive-keyring.gpg
- /usr/share/debsig/keyrings/AC2D62742012EA22/debsig.gpg
register: _multica_op_keyrings
become: true
when: ansible_facts['os_family'] == 'Debian'
- name: 1Password CLI | Create the debsig policy and keyring directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- /etc/debsig/policies/AC2D62742012EA22
- /usr/share/debsig/keyrings/AC2D62742012EA22
register: _multica_op_debsig_dirs
become: true
when: ansible_facts['os_family'] == 'Debian'
# Same key, two consumers: apt (repository metadata) and debsig-verify (the
# signature on each .deb).
- name: 1Password CLI | Dearmor the key for apt and debsig
ansible.builtin.command:
argv: [gpg, --batch, --yes, --dearmor, --output, "{{ item.item }}", /usr/share/keyrings/1password-archive-keyring.asc]
loop: "{{ _multica_op_keyrings.results }}"
loop_control:
label: "{{ item.item }}"
when:
- ansible_facts['os_family'] == 'Debian'
- _multica_op_key_asc is changed or not item.stat.exists
changed_when: true
become: true
- name: 1Password CLI | Install the debsig policy
ansible.builtin.get_url:
url: "{{ multica_op_debsig_policy_url }}"
dest: /etc/debsig/policies/AC2D62742012EA22/1password.pol
owner: root
group: root
mode: "0644"
become: true
when:
- ansible_facts['os_family'] == 'Debian'
- not (ansible_check_mode and _multica_op_debsig_dirs is changed)
- name: 1Password CLI | Add the apt repository
ansible.builtin.apt_repository:
repo: >-
deb [arch={{ _multica_arch }} signed-by=/usr/share/keyrings/1password-archive-keyring.gpg]
{{ multica_op_apt_base_url }}/{{ _multica_arch }} stable main
filename: 1password
state: present
update_cache: true
register: _multica_op_repo_deb
become: true
when: ansible_facts['os_family'] == 'Debian'
- name: 1Password CLI | Install the package
ansible.builtin.apt:
name: >-
{{ '1password-cli=' ~ multica_op_version ~ '*'
if multica_op_version | length > 0 else '1password-cli' }}
state: "{{ 'present' if multica_op_version | length > 0 else 'latest' }}"
allow_downgrade: "{{ multica_op_version | length > 0 }}"
register: _multica_op_pkg_deb
become: true
when:
- ansible_facts['os_family'] == 'Debian'
- not (ansible_check_mode and _multica_op_repo_deb is changed)
# --- EL ----------------------------------------------------------------------
- name: 1Password CLI | Add the yum repository
ansible.builtin.yum_repository:
name: 1password
description: 1Password Stable Channel
baseurl: "{{ multica_op_rpm_base_url }}/$basearch"
gpgkey: "{{ multica_op_key_url }}"
gpgcheck: true
repo_gpgcheck: true
enabled: true
register: _multica_op_repo_rpm
become: true
when: ansible_facts['os_family'] == 'RedHat'
- name: 1Password CLI | Install the package
ansible.builtin.dnf:
name: >-
{{ '1password-cli-' ~ multica_op_version
if multica_op_version | length > 0 else '1password-cli' }}
state: "{{ 'present' if multica_op_version | length > 0 else 'latest' }}"
allow_downgrade: "{{ multica_op_version | length > 0 }}"
register: _multica_op_pkg_rpm
become: true
when:
- ansible_facts['os_family'] == 'RedHat'
- not (ansible_check_mode and _multica_op_repo_rpm is changed)
# --- Verify ------------------------------------------------------------------
# As the daemon user, through its own environment: that is the context agents
# call `op` from.
- name: 1Password CLI | Check the installed version as the daemon user
ansible.builtin.command:
argv: [op, --version]
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_op_version_out
changed_when: false
check_mode: false
failed_when: >-
_multica_op_version_out.rc != 0
or (multica_op_version | length > 0
and _multica_op_version_out.stdout | trim != multica_op_version)
# Under --check, only when the binary is already there to ask.
when: >-
not ansible_check_mode
or (_multica_op_pkg_deb is not skipped and _multica_op_pkg_deb is not changed)
or (_multica_op_pkg_rpm is not skipped and _multica_op_pkg_rpm is not changed)
@@ -0,0 +1,81 @@
---
# Delivers the 1Password service account token to a file only the daemon user
# can read. It deliberately stops there: nothing here puts the token into the
# daemon's environment. The daemon's systemd user unit belongs to the multica
# CLI, so the role must not edit it, and exporting the token to the daemon
# would hand it to every agent process too. See the README.
# no_log: a failed assert would otherwise render the token it inspected.
- name: 1Password token | Require a service account token
ansible.builtin.assert:
that:
- multica_op_service_account_token | length > 0
- multica_op_service_account_token is match('^ops_')
fail_msg: >-
multica_op_deliver_token is true but multica_op_service_account_token is
not a 1Password service account token (ops_...).
quiet: true
no_log: true
- name: 1Password token | Create the token directory
ansible.builtin.file:
path: "{{ multica_op_token_file | dirname }}"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0700"
become: true
- name: 1Password token | Write the token file
ansible.builtin.copy:
dest: "{{ multica_op_token_file }}"
content: "{{ multica_op_service_account_token }}\n"
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0600"
no_log: true
become: true
# --- op shim -----------------------------------------------------------------
# `op` reads the token only from OP_SERVICE_ACCOUNT_TOKEN. The shim, first on
# the daemon unit's PATH, exports it for the real binary alone, so agents call
# plain `op` and the token never enters their shell environment. The daemon
# unit itself stays the CLI's (WDWL-18).
- name: 1Password token | Create the daemon user's bin directory
ansible.builtin.file:
path: "{{ multica_op_shim_path | dirname }}"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0755"
become: true
when: multica_op_shim | bool
- name: 1Password token | Install the op shim
ansible.builtin.template:
src: op-shim.sh.j2
dest: "{{ multica_op_shim_path }}"
owner: root
group: root
mode: "0755"
become: true
when: multica_op_shim | bool
# As the daemon user, through its own environment: plain `op` must resolve to
# the shim, and the token must not be in that environment.
- name: 1Password token | Check op resolves to the shim as the daemon user
ansible.builtin.shell:
cmd: 'command -v op; env | grep -c "^OP_SERVICE_ACCOUNT_TOKEN=" || true'
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_op_shim_check
changed_when: false
check_mode: false
failed_when: >-
_multica_op_shim_check.rc != 0
or _multica_op_shim_check.stdout_lines != [multica_op_shim_path, '0']
when:
- multica_op_shim | bool
- not ansible_check_mode
@@ -0,0 +1,86 @@
---
- name: Preflight | Require a Linux target
ansible.builtin.assert:
that:
- ansible_facts['system'] == 'Linux'
fail_msg: >-
multica_agent_host supports Linux hosts only
(found {{ ansible_facts['system'] }}).
quiet: true
- name: Preflight | Require a supported CPU architecture
ansible.builtin.assert:
that:
- ansible_facts['architecture'] in _multica_arch_map
fail_msg: >-
No multica CLI release asset for architecture
{{ ansible_facts['architecture'] }}.
Supported: {{ _multica_arch_map.keys() | list | join(', ') }}.
quiet: true
- name: Preflight | Require the server URL
ansible.builtin.assert:
that:
- multica_server_url | length > 0
- multica_server_url is match('^https?://')
fail_msg: "multica_server_url must be set to an http(s) URL."
quiet: true
# no_log: a failed assert would otherwise render the variables it inspected.
- name: Preflight | Require a Multica personal access token
ansible.builtin.assert:
that:
- multica_pat | length > 0
- multica_pat is match('^(mul_|mcn_)')
fail_msg: >-
multica_pat must be set to a Multica personal access token
(mul_... user PAT, or mcn_... Cloud Node PAT). Supply it from the
playbook; see this role's README for Vault / 1Password / env examples.
quiet: true
no_log: true
- name: Preflight | Require every selected agent CLI to be in the catalog
ansible.builtin.assert:
that:
- multica_agent_clis | difference(multica_agent_cli_catalog.keys() | list) | length == 0
fail_msg: >-
Unknown agent CLI(s):
{{ multica_agent_clis | difference(multica_agent_cli_catalog.keys() | list) | join(', ') }}.
Add them to multica_agent_cli_catalog or drop them from multica_agent_clis.
quiet: true
- name: Preflight | Require at least one agent CLI
ansible.builtin.assert:
that:
- multica_agent_clis | length > 0
fail_msg: >-
multica_agent_clis is empty; the daemon would register no runtimes and
the host could never claim a run.
quiet: true
- name: Preflight | Resolve release artefact names and the invocation prefix
ansible.builtin.set_fact:
_multica_arch: "{{ _multica_arch_map[ansible_facts['architecture']] }}"
_multica_node_arch: "{{ _multica_node_arch_map[ansible_facts['architecture']] }}"
_multica_bin: "{{ multica_install_dir }}/multica"
_multica_cmd: >-
{{ [multica_install_dir ~ '/multica']
+ (['--profile', multica_profile] if multica_profile | length > 0 else []) }}
_multica_node_required: >-
{{ multica_agent_clis
| map('extract', multica_agent_cli_catalog, 'method')
| select('equalto', 'npm') | list | length > 0 }}
- name: Preflight | Name the release tarball
ansible.builtin.set_fact:
_multica_tarball: "multica-cli-{{ multica_version }}-linux-{{ _multica_arch }}.tar.gz"
_multica_release_url: >-
{{ multica_release_download_base_url }}/v{{ multica_version }}/multica-cli-{{ multica_version }}-linux-{{ _multica_arch }}.tar.gz
_multica_checksums_url: >-
{{ multica_release_download_base_url }}/v{{ multica_version }}/checksums.txt
- name: Preflight | Require base tooling on the target
ansible.builtin.package:
name: "{{ ['tar', 'xz-utils' if ansible_facts['os_family'] == 'Debian' else 'xz', 'ca-certificates', 'git'] }}"
state: present
become: true
@@ -0,0 +1,63 @@
---
- name: User | Create the daemon group
ansible.builtin.group:
name: "{{ multica_group }}"
state: present
become: true
when: multica_manage_user | bool
# Deliberately not a system account: the daemon needs a real home for CLI
# config and agent CLI credentials, and systemd linger needs a real user.
- name: User | Create the daemon user
ansible.builtin.user:
name: "{{ multica_user }}"
group: "{{ multica_group }}"
groups: "{{ multica_user_extra_groups }}"
append: true
home: "{{ multica_user_home }}"
shell: "{{ multica_user_shell }}"
create_home: true
state: present
become: true
when: multica_manage_user | bool
- name: User | Look up the daemon user
ansible.builtin.getent:
database: passwd
key: "{{ multica_user }}"
become: true
- name: User | Record uid and the per-user runtime environment
ansible.builtin.set_fact:
_multica_uid: "{{ ansible_facts.getent_passwd[multica_user][1] }}"
# systemd user units — which is what `multica daemon autostart enable` writes on
# Linux — are unreachable over a plain `sudo -u` shell without these. Without
# XDG_RUNTIME_DIR, `systemctl --user` fails with "Failed to connect to bus".
- name: User | Build the daemon user's command environment
ansible.builtin.set_fact:
_multica_user_env:
HOME: "{{ multica_user_home }}"
USER: "{{ multica_user }}"
LOGNAME: "{{ multica_user }}"
XDG_RUNTIME_DIR: "/run/user/{{ _multica_uid }}"
DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ _multica_uid }}/bus"
PATH: "{{ multica_npm_prefix }}/bin:{{ multica_user_home }}/.local/bin:{{ multica_install_dir }}:/usr/local/bin:/usr/bin:/bin"
- name: User | Ensure the user's local bin directory exists
ansible.builtin.file:
path: "{{ multica_user_home }}/.local/bin"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0755"
become: true
- name: User | Ensure the user's cache directory exists
ansible.builtin.file:
path: "{{ multica_user_home }}/.cache"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "0700"
become: true
@@ -0,0 +1,120 @@
---
# Assertions, not hope. Each of these has failed in practice for a different
# reason, so each gets its own check and its own error message.
- name: Verify | Daemon reports running
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'status', '--output', 'json'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_verify_daemon
until:
- _multica_verify_daemon.rc == 0
- (_multica_verify_daemon.stdout | from_json).status | default('') == 'running'
retries: "{{ multica_verify_retries }}"
delay: "{{ multica_verify_delay }}"
changed_when: false
check_mode: false
- name: Verify | Daemon is the pinned version and sees the configured workspaces root
ansible.builtin.assert:
that:
- (_multica_verify_daemon.stdout | from_json).cli_version == multica_version
- (_multica_verify_daemon.stdout | from_json).server_url | regex_replace('/$', '')
== multica_server_url | regex_replace('/$', '')
fail_msg: >-
Daemon is running but does not match declared state:
{{ _multica_verify_daemon.stdout }}
quiet: true
- name: Verify | Every selected agent CLI is registered as an online runtime
ansible.builtin.command:
argv: "{{ _multica_cmd + ['runtime', 'list', '--output', 'json'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
register: _multica_verify_runtimes
until:
- _multica_verify_runtimes.rc == 0
- _multica_expected_providers
| difference(_multica_verify_runtimes.stdout | from_json
| selectattr('daemon_id', 'equalto', (_multica_verify_daemon.stdout | from_json).daemon_id)
| selectattr('status', 'equalto', 'online')
| map(attribute='provider') | list)
| length == 0
retries: "{{ multica_verify_retries }}"
delay: "{{ multica_verify_delay }}"
changed_when: false
check_mode: false
vars:
_multica_expected_providers: >-
{{ multica_agent_clis | map('extract', multica_agent_cli_catalog, 'provider') | list }}
- name: Verify | Runtimes name this host
ansible.builtin.assert:
that:
- _multica_verify_runtimes.stdout | from_json
| selectattr('daemon_id', 'equalto', (_multica_verify_daemon.stdout | from_json).daemon_id)
| rejectattr('device_info', 'search', '^' ~ (multica_device_name | regex_escape))
| list | length == 0
fail_msg: >-
Runtimes registered under this daemon do not report device_name
"{{ multica_device_name }}".
quiet: true
- name: Verify | Stat the workspaces root
ansible.builtin.stat:
path: "{{ multica_workspaces_root }}"
register: _multica_verify_ws
become: true
- name: Verify | Re-prove the daemon user can write to the workspaces root
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'd="$1"; f="$d/.multica-write-test.$$"; : > "$f" && rm -f "$f"'
- sh
- "{{ multica_workspaces_root }}"
become: true
become_user: "{{ multica_user }}"
register: _multica_verify_ws_write
changed_when: false
failed_when: false
check_mode: false
- name: Verify | Workspaces root assertions
ansible.builtin.assert:
that:
- _multica_verify_ws.stat.exists
- _multica_verify_ws.stat.isdir
- _multica_verify_ws.stat.pw_name == multica_user
- _multica_verify_ws_write.rc == 0
fail_msg: >-
{{ multica_workspaces_root }} is missing, not a directory, or not
owned/writable by {{ multica_user }}.
quiet: true
- name: Verify | Workspaces root is on the expected mount
ansible.builtin.assert:
that:
- multica_workspaces_root_mount in (ansible_facts['mounts'] | map(attribute='mount') | list)
fail_msg: "{{ multica_workspaces_root_mount }} is not mounted."
quiet: true
when: multica_workspaces_root_mount | length > 0
- name: Verify | Report what still needs a human
ansible.builtin.debug:
msg: >-
{{ multica_device_name }} is provisioned: multica {{ multica_version }},
daemon running, runtimes online for
{{ multica_agent_clis | join(', ') }}.
Remaining manual step(s):
{{ multica_agent_clis
| select('in', multica_agent_cli_catalog)
| map('extract', multica_agent_cli_catalog)
| selectattr('manual_login', 'defined')
| selectattr('manual_login')
| map(attribute='manual_login_hint')
| list | join('; ') | default('none', true) }}
@@ -0,0 +1,41 @@
---
# Guard against the mount not being present: without it the daemon happily
# writes run workspaces onto the root filesystem and nobody notices until it
# fills up.
- name: Workspaces root | Assert it lands on the expected mount
ansible.builtin.assert:
that:
- multica_workspaces_root_mount in (ansible_facts['mounts'] | map(attribute='mount') | list)
- multica_workspaces_root == multica_workspaces_root_mount
or multica_workspaces_root.startswith(multica_workspaces_root_mount.rstrip('/') ~ '/')
fail_msg: >-
multica_workspaces_root ({{ multica_workspaces_root }}) is not on
{{ multica_workspaces_root_mount }}, which is not currently mounted or
does not contain it. Mount it before provisioning.
quiet: true
when: multica_workspaces_root_mount | length > 0
- name: Workspaces root | Create the directory
ansible.builtin.file:
path: "{{ multica_workspaces_root }}"
state: directory
owner: "{{ multica_user }}"
group: "{{ multica_group }}"
mode: "{{ multica_workspaces_root_mode }}"
become: true
# Ownership is not writability: a read-only or root-squashed network mount
# passes the file module and fails the first run.
- name: Workspaces root | Prove the daemon user can write to it
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'd="$1"; f="$d/.multica-write-test.$$"; : > "$f" && rm -f "$f"'
- sh
- "{{ multica_workspaces_root }}"
become: true
become_user: "{{ multica_user }}"
changed_when: false
register: _multica_ws_write
failed_when: _multica_ws_write.rc != 0
@@ -0,0 +1,55 @@
#!/bin/sh
# {{ ansible_managed }}
#
# 1Password CLI shim for the Multica daemon user (multica_agent_host role,
# WDWL-37). Reads the service account token from a 0600 file and exports it
# to {{ multica_op_binary }} only, so it never sits in an agent's shell
# environment.
token_file="{{ multica_op_token_file }}"
if [ ! -r "$token_file" ]; then
echo "op: no 1Password service account token at $token_file" >&2
exit 78
fi
IFS= read -r OP_SERVICE_ACCOUNT_TOKEN < "$token_file"
if [ -z "$OP_SERVICE_ACCOUNT_TOKEN" ]; then
echo "op: 1Password service account token file $token_file is empty" >&2
exit 78
fi
export OP_SERVICE_ACCOUNT_TOKEN
# `op run` (and `op plugin run`) hand their own environment, token included,
# to the command they start. Find the subcommand, skipping global flags that
# take a value, and stop at the first `--`.
sub1=""; sub2=""; skip=0; help=0
for a in "$@"; do
if [ "$skip" = 1 ]; then skip=0; continue; fi
case "$a" in
--) break ;;
-h|--help) help=1 ;;
--account|--config|--encoding|--format|--session) skip=1 ;;
-*) ;;
*) if [ -z "$sub1" ]; then sub1=$a; elif [ -z "$sub2" ]; then sub2=$a; fi ;;
esac
done
if [ "$sub1" = run ] || { [ "$sub1" = plugin ] && [ "$sub2" = run ]; }; then
# Rebuild the arguments with `env -u` spliced in after the first `--`, so
# the child starts without the token.
n=$#; i=0; spliced=0
while [ "$i" -lt "$n" ]; do
a=$1; shift
set -- "$@" "$a"
if [ "$spliced" = 0 ] && [ "$a" = "--" ]; then
set -- "$@" /usr/bin/env -u OP_SERVICE_ACCOUNT_TOKEN
spliced=1
fi
i=$((i + 1))
done
if [ "$spliced" = 0 ] && [ "$help" = 0 ]; then
echo "op: use 'op run [flags] -- <command>'; the shim needs the '--' to keep the service account token out of <command>" >&2
exit 64
fi
fi
exec "{{ multica_op_binary }}" "$@"
@@ -0,0 +1,16 @@
---
# Internal, not meant to be overridden.
# ansible_architecture -> release asset arch
_multica_arch_map: # noqa: var-naming[no-role-prefix]
x86_64: amd64
amd64: amd64
aarch64: arm64
arm64: arm64
# ansible_architecture -> nodejs.org dist arch
_multica_node_arch_map: # noqa: var-naming[no-role-prefix]
x86_64: x64
amd64: x64
aarch64: arm64
arm64: arm64
+61
View File
@@ -0,0 +1,61 @@
# multica_docker
Deploys [Multica](https://multica.ai/docs/self-host-quickstart) (Docker Compose self-host) and a containerised
[Caddy](https://caddyserver.com) reverse proxy with automatic HTTPS.
Mirrors the official quickstart: clones the repo, creates `.env` from `.env.example` (first run only, like
`make selfhost`), manages specific keys in it, runs `docker-compose.selfhost.yml`, waits for `/readyz`, then
fronts it with Caddy using the documented single-origin or two-domain config.
## Requirements
- Docker Engine + Compose v2 on the target (the role checks; it does not install Docker)
- `ansible-galaxy collection install -r requirements.yml` (needs `community.docker >= 3.10`)
- DNS for your hostname(s) pointing at the target
## Quick start
```bash
cp group_vars/multica/vault.yml.example group_vars/multica/vault.yml # fill in, then:
ansible-vault encrypt group_vars/multica/vault.yml
# edit inventory.ini + group_vars/multica/vars.yml
ansible-playbook deploy_multica.yml --ask-vault-pass
```
## Key variables
| Variable | Default | Notes |
|---|---|---|
| `multica_layout` | `single` | `single` = one hostname; `split` = app + api hostnames |
| `multica_domain` | – | Hostname for `single` |
| `multica_app_domain` / `multica_api_domain` | – | Hostnames for `split` |
| `caddy_tls_mode` | `acme_http` | `acme_http`, `cloudflare_dns` (builds custom Caddy image), `internal` (Caddy CA, LAN only) |
| `caddy_http_port` / `caddy_https_port` | `80` / `443` | Change if the host already runs nginx etc. |
| `multica_version` / `multica_image_tag` | `main` / `latest` | Pin both to the same release tag for reproducible deploys |
| `multica_pull_latest` | `false` | `true` pulls images every run (needed to move `latest`) |
| `multica_backup_enabled` | `false` | `pg_dump` before changes to a running stack |
| `multica_disable_telemetry` | `false` | Sets `DO_NOT_TRACK=true` |
| `multica_extra_env` | `{}` | Extra `.env` keys, e.g. `ALLOW_SIGNUP: "false"` |
Secrets (vault): `vault_multica_jwt_secret`, `vault_multica_postgres_password`, `vault_multica_vcs_secret_key`,
`vault_cloudflare_api_token` (cloudflare_dns only), optional `vault_multica_resend_api_key`.
## Behaviour worth knowing
- **Idempotent**: `.env` is created once; only managed keys are rewritten. Compose recreates containers only when config changes.
- **Ports 3000/8080 stay bound to 127.0.0.1** (as upstream intends). Caddy uses `network_mode: host` to reach them.
- **Existing install?** The role overwrites `JWT_SECRET` (logs everyone out) and `POSTGRES_PASSWORD`. Postgres only reads its
password at first init, so for an existing database copy the *current* values from `.env` into the vault.
- **Upgrades**: `-e multica_backup_enabled=true -e multica_pull_latest=true` (or bump `multica_version`/`multica_image_tag`).
Migrations run automatically on backend start; the role waits for `/readyz`.
- **Tags**: `multica_preflight`, `multica`, `caddy`, `multica_verify`.
## After the play: connect a computer
The daemon runs on the machine with your AI coding tools, not the server:
```bash
multica setup self-host --server-url https://<domain> --app-url https://<domain> # single layout
```
First login code (no email configured): `docker compose -f docker-compose.selfhost.yml logs backend | grep "Verification code"`
@@ -0,0 +1,70 @@
---
# ───────────────────────── Multica ─────────────────────────
multica_install_dir: /opt/multica
multica_repo_url: https://github.com/multica-ai/multica.git
# Git ref for the compose file / .env.example. Pin to a release tag for reproducible deploys.
multica_version: main
multica_git_update: true
# Image tag for backend + frontend (written to MULTICA_IMAGE_TAG). Keep in step with multica_version when pinning.
multica_image_tag: latest
multica_compose_project: multica
# true = `docker compose pull` every run (needed to move a floating `latest` tag forward)
multica_pull_latest: false
# Sets DO_NOT_TRACK=true on the backend (disables first-party anonymous telemetry)
multica_disable_telemetry: false
# Secrets (supply via ansible-vault). Role fails fast if empty.
multica_jwt_secret: "{{ vault_multica_jwt_secret | default('') }}"
multica_postgres_password: "{{ vault_multica_postgres_password | default('') }}"
multica_vcs_secret_key: "{{ vault_multica_vcs_secret_key | default('') }}"
# Optional: enables emailed verification codes instead of reading them from backend logs
multica_resend_api_key: "{{ vault_multica_resend_api_key | default('') }}"
# Any extra .env keys, e.g. { ALLOW_SIGNUP: "false", GOOGLE_CLIENT_ID: "..." }
multica_extra_env: {}
# Postgres identity used by the optional backup task (match your .env if changed)
multica_postgres_user: multica
multica_postgres_db: multica
# Take a pg_dump before changing a running stack. Recommended before upgrades: -e multica_backup_enabled=true
multica_backup_enabled: false
multica_backup_dir: /var/backups/multica
# Hide secret-bearing task output
multica_no_log: true
# ───────────────────────── Routing ─────────────────────────
# single: one hostname (multica_domain)
# split : two hostnames (multica_app_domain + multica_api_domain)
multica_layout: single
multica_domain: ""
multica_app_domain: ""
multica_api_domain: ""
# ───────────────────────── Caddy ─────────────────────────
caddy_install_dir: /opt/caddy
caddy_container_name: caddy
# Caddy image tag. "2" tracks latest 2.x
caddy_version: "2"
# acme_http – automatic HTTPS via HTTP-01/TLS-ALPN-01 (needs public 80/443)
# cloudflare_dns – DNS-01 via Cloudflare; builds a custom Caddy image with the caddy-dns/cloudflare module
# internal – Caddy's internal CA (LAN only; clients must trust Caddy's root cert)
caddy_tls_mode: acme_http
caddy_acme_email: ""
caddy_cloudflare_api_token: "{{ vault_cloudflare_api_token | default('') }}"
# cloudflare_dns only: resolvers Caddy uses for the DNS-01 propagation check. Needed with split-horizon/local DNS,
# where the host resolver returns internal nameserver names that can't be resolved. [] = use the host resolver.
caddy_dns_resolvers:
- 1.1.1.1
- 1.0.0.1
# Rebuild the custom image on every run (cloudflare_dns only)
caddy_force_rebuild: false
# Caddy runs with host networking so it can reach Multica's 127.0.0.1-bound ports.
# Change these if 80/443 are already taken on the host (e.g. nginx).
caddy_http_port: 80
caddy_https_port: 443
# Post-deploy checks through the public hostname(s). Needs DNS to resolve from the target host.
multica_verify_public: true
@@ -0,0 +1,13 @@
---
- name: Validate Caddyfile
community.docker.docker_container_exec:
container: "{{ caddy_container_name }}"
command: caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
changed_when: false
listen: Reload caddy
- name: Reload Caddy
community.docker.docker_container_exec:
container: "{{ caddy_container_name }}"
command: caddy reload --config /etc/caddy/Caddyfile --adapter caddyfile
listen: Reload caddy
@@ -0,0 +1,14 @@
---
galaxy_info:
role_name: multica_docker
author: Ryan
company: WDW Labs
license: MIT
description: Deploy Multica (Docker Compose self-host) behind a containerised Caddy reverse proxy
min_ansible_version: "2.15"
platforms:
- name: Ubuntu
versions: [jammy, noble]
- name: Debian
versions: [bookworm]
dependencies: []
@@ -0,0 +1,51 @@
---
- name: Create Caddy directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ caddy_install_dir }}"
- "{{ caddy_install_dir }}/conf"
# Mount the conf *directory* (not the file): atomic template writes swap the inode,
# which a single-file bind mount would not see.
- name: Render Caddyfile
ansible.builtin.template:
src: Caddyfile.j2
dest: "{{ caddy_install_dir }}/conf/Caddyfile"
mode: "0644"
notify: Reload caddy
- name: Render Dockerfile for Cloudflare DNS module
ansible.builtin.template:
src: Dockerfile.caddy.j2
dest: "{{ caddy_install_dir }}/Dockerfile"
mode: "0644"
when: caddy_tls_mode == 'cloudflare_dns'
- name: Render Caddy environment file (Cloudflare token)
ansible.builtin.template:
src: caddy.env.j2
dest: "{{ caddy_install_dir }}/caddy.env"
mode: "0600"
when: caddy_tls_mode == 'cloudflare_dns'
no_log: "{{ multica_no_log }}"
- name: Render Caddy compose file
ansible.builtin.template:
src: docker-compose.caddy.yml.j2
dest: "{{ caddy_install_dir }}/docker-compose.yml"
mode: "0644"
- name: Start / update Caddy
community.docker.docker_compose_v2:
project_name: caddy
project_src: "{{ caddy_install_dir }}"
state: present
build: "{{ 'always' if caddy_force_rebuild | bool else 'policy' }}"
recreate: auto
register: _caddy_up
- name: Apply pending Caddy reloads now
ansible.builtin.meta: flush_handlers
@@ -0,0 +1,16 @@
---
- name: Pre-flight checks
ansible.builtin.import_tasks: preflight.yml
tags: [multica_preflight]
- name: Deploy Multica
ansible.builtin.import_tasks: multica.yml
tags: [multica]
- name: Deploy Caddy
ansible.builtin.import_tasks: caddy.yml
tags: [caddy]
- name: Verify deployment
ansible.builtin.import_tasks: verify.yml
tags: [multica_verify]
@@ -0,0 +1,147 @@
---
- name: Create Multica install directory
ansible.builtin.file:
path: "{{ multica_install_dir }}"
state: directory
mode: "0755"
- name: Clone / update Multica repository
ansible.builtin.git:
repo: "{{ multica_repo_url }}"
dest: "{{ multica_install_dir }}"
version: "{{ multica_version }}"
update: "{{ multica_git_update }}"
force: false
register: _multica_git
# Optional: dump Postgres before touching a stack that is already running
- name: Back up Postgres before changes
when:
- multica_backup_enabled | bool
- _multica_compose_stat.stat.exists
- (_multica_ps.stdout | default('') | length) > 0
block:
- name: Create backup directory
ansible.builtin.file:
path: "{{ multica_backup_dir }}"
state: directory
mode: "0700"
- name: Gather date facts
ansible.builtin.setup:
gather_subset: [date_time]
- name: Dump database (redirect to file first so pg_dump's exit status counts)
ansible.builtin.shell: >-
docker compose -p {{ multica_compose_project }} -f {{ _multica_compose_file }}
exec -T postgres pg_dump -U {{ multica_postgres_user }} {{ multica_postgres_db }}
> {{ multica_backup_dir }}/multica-{{ ansible_date_time.iso8601_basic_short }}.sql
&& gzip {{ multica_backup_dir }}/multica-{{ ansible_date_time.iso8601_basic_short }}.sql
args:
chdir: "{{ multica_install_dir }}"
changed_when: true
# `make selfhost` only creates .env when missing; we do the same, then manage specific keys.
- name: Create .env from .env.example (first run only)
ansible.builtin.copy:
src: "{{ multica_install_dir }}/.env.example"
dest: "{{ multica_install_dir }}/.env"
remote_src: true
force: false
mode: "0600"
- name: Set managed keys in .env
ansible.builtin.lineinfile:
path: "{{ multica_install_dir }}/.env"
regexp: "^{{ item.key }}="
line: "{{ item.key }}={{ item.value }}"
mode: "0600"
loop: "{{ _multica_env | dict2items }}"
loop_control:
label: "{{ item.key }}"
no_log: "{{ multica_no_log }}"
# .env.example ships a DATABASE_URL with the default password embedded. POSTGRES_PASSWORD alone does not
# change it, so keep the URL's password in step (no-op if the line is absent or already matches).
- name: Sync password inside DATABASE_URL
ansible.builtin.replace:
path: "{{ multica_install_dir }}/.env"
regexp: '^(DATABASE_URL=[a-z]+://[^:/@]+:)[^@]*(@.*)$'
replace: '\g<1>{{ multica_postgres_password }}\g<2>'
mode: "0600"
no_log: "{{ multica_no_log }}"
# Compose re-reads .env and recreates containers whose config changed (equivalent of `up -d`).
- name: Start / update Multica stack
community.docker.docker_compose_v2:
project_name: "{{ multica_compose_project }}"
project_src: "{{ multica_install_dir }}"
files:
- "{{ _multica_compose_file }}"
state: present
pull: "{{ 'always' if multica_pull_latest | bool else 'missing' }}"
recreate: auto
register: _multica_up
# Postgres only reads POSTGRES_PASSWORD when the data volume is first initialised. If the volume pre-dates this
# run (or the vault value changed), the DB user keeps the old password and the backend can't log in.
# Test over TCP (the local socket is trust-auth and proves nothing) and repair via the socket if needed.
- name: Wait for Postgres to accept connections
ansible.builtin.command:
argv: ["docker", "compose", "-p", "{{ multica_compose_project }}", "-f", "{{ _multica_compose_file }}",
"exec", "-T", "postgres", "pg_isready", "-U", "{{ multica_postgres_user }}", "-d", "{{ multica_postgres_db }}"]
chdir: "{{ multica_install_dir }}"
register: _multica_pg_ready
until: _multica_pg_ready.rc == 0
retries: 24
delay: 5
changed_when: false
- name: Test database login with the configured password (over TCP)
ansible.builtin.command:
argv: ["docker", "compose", "-p", "{{ multica_compose_project }}", "-f", "{{ _multica_compose_file }}",
"exec", "-T", "-e", "PGPASSWORD", "postgres", "sh", "-c",
"psql -h \"$(hostname -i | cut -d' ' -f1)\" -U {{ multica_postgres_user }} -d {{ multica_postgres_db }} -tAc 'select 1'"]
chdir: "{{ multica_install_dir }}"
environment:
PGPASSWORD: "{{ multica_postgres_password }}"
register: _multica_pg_auth
changed_when: false
failed_when:
- _multica_pg_auth.rc != 0
- "'password authentication failed' not in _multica_pg_auth.stderr"
no_log: "{{ multica_no_log }}"
- name: Reset database user password to match .env
when: _multica_pg_auth.rc != 0
block:
- name: ALTER USER via local socket (password passed on stdin)
ansible.builtin.command:
argv: ["docker", "compose", "-p", "{{ multica_compose_project }}", "-f", "{{ _multica_compose_file }}",
"exec", "-T", "postgres", "psql", "-v", "ON_ERROR_STOP=1",
"-U", "{{ multica_postgres_user }}", "-d", "{{ multica_postgres_db }}"]
chdir: "{{ multica_install_dir }}"
stdin: "ALTER USER {{ multica_postgres_user }} PASSWORD '{{ multica_postgres_password }}';"
changed_when: true
no_log: "{{ multica_no_log }}"
- name: Restart backend so it reconnects
ansible.builtin.command:
argv: ["docker", "compose", "-p", "{{ multica_compose_project }}", "-f", "{{ _multica_compose_file }}",
"restart", "backend"]
chdir: "{{ multica_install_dir }}"
changed_when: true
- name: Wait for backend /readyz (db + migrations ok)
ansible.builtin.uri:
url: "http://127.0.0.1:{{ _multica_backend_port }}/readyz"
return_content: true
status_code: 200
register: _multica_ready
until: >-
_multica_ready.status == 200 and
(_multica_ready.json.checks.db | default('')) == 'ok' and
(_multica_ready.json.checks.migrations | default('')) == 'ok'
retries: 30
delay: 5
changed_when: false
@@ -0,0 +1,88 @@
---
- name: Assert layout and hostnames
ansible.builtin.assert:
that:
- multica_layout in ['single', 'split']
- caddy_tls_mode in ['acme_http', 'cloudflare_dns', 'internal']
- multica_layout == 'split' or (multica_domain | length) > 0
- multica_layout == 'single' or ((multica_app_domain | length) > 0 and (multica_api_domain | length) > 0)
fail_msg: >-
Set multica_domain (layout=single) or multica_app_domain + multica_api_domain (layout=split),
and use a valid caddy_tls_mode.
- name: Assert required secrets are provided
ansible.builtin.assert:
that:
- (multica_jwt_secret | length) >= 32
- (multica_postgres_password | length) >= 16
- (multica_vcs_secret_key | length) >= 16
- caddy_tls_mode != 'cloudflare_dns' or (caddy_cloudflare_api_token | length) > 0
fail_msg: >-
Missing secrets. Define vault_multica_jwt_secret (>=32 chars), vault_multica_postgres_password (>=16),
vault_multica_vcs_secret_key (>=16) and, for cloudflare_dns, vault_cloudflare_api_token.
Generate with: openssl rand -hex 32
quiet: true
- name: Check Docker Compose v2 is available
ansible.builtin.command: docker compose version
register: _multica_compose_ver
changed_when: false
- name: Ensure git is installed
ansible.builtin.package:
name: git
state: present
# ── Port checks (skipped when our own stack already holds the ports) ──
- name: Look for an existing Multica deployment
ansible.builtin.stat:
path: "{{ multica_install_dir }}/{{ _multica_compose_file }}"
register: _multica_compose_stat
- name: List running Multica containers
ansible.builtin.command:
cmd: "docker compose -p {{ multica_compose_project }} -f {{ _multica_compose_file }} ps -q"
chdir: "{{ multica_install_dir }}"
register: _multica_ps
changed_when: false
when: _multica_compose_stat.stat.exists
- name: Check for a running Caddy container
ansible.builtin.command: "docker ps -q --filter name=^{{ caddy_container_name }}$"
register: _caddy_ps
changed_when: false
- name: Ensure Multica ports are free on first deploy
when: not (_multica_compose_stat.stat.exists and ((_multica_ps.stdout | default('')) | length) > 0)
block:
- name: Wait for 3000/8080 to be unused
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: stopped
timeout: 3
loop:
- "{{ _multica_frontend_port }}"
- "{{ _multica_backend_port }}"
rescue:
- name: Fail with hint
ansible.builtin.fail:
msg: "Port 3000 or 8080 is already in use on the target host and Multica is not running. Free it before deploying."
- name: Ensure Caddy ports are free on first deploy
when: (_caddy_ps.stdout | length) == 0
block:
- name: Wait for Caddy HTTP/HTTPS ports to be unused
ansible.builtin.wait_for:
host: 127.0.0.1
port: "{{ item }}"
state: stopped
timeout: 3
loop: "{{ [caddy_http_port, caddy_https_port] | map('int') | list }}"
rescue:
- name: Fail with hint
ansible.builtin.fail:
msg: >-
Port {{ caddy_http_port }} or {{ caddy_https_port }} is already in use on the target host
(another reverse proxy such as nginx?). Set caddy_http_port / caddy_https_port to free ports,
or deploy Caddy on a different host.
@@ -0,0 +1,32 @@
---
- name: Verify through the public hostname(s)
when: multica_verify_public | bool
block:
- name: Check health endpoint via Caddy
ansible.builtin.uri:
url: "{{ _multica_public_health_url }}"
status_code: 200
validate_certs: "{{ caddy_tls_mode != 'internal' }}"
register: _multica_pub_health
until: _multica_pub_health.status == 200
retries: 18
delay: 10
changed_when: false
- name: Fetch /api/config via the app hostname
ansible.builtin.uri:
url: "{{ _multica_app_url }}/api/config"
return_content: true
validate_certs: "{{ caddy_tls_mode != 'internal' }}"
register: _multica_cfg
changed_when: false
- name: Assert daemons will be told the public URL, not localhost
ansible.builtin.assert:
that:
- _multica_cfg.json.daemon_server_url is defined
- "'localhost' not in _multica_cfg.json.daemon_server_url"
fail_msg: >-
daemon_server_url is missing or points at localhost. Check FRONTEND_ORIGIN / MULTICA_APP_URL in
{{ multica_install_dir }}/.env and recreate the stack.
success_msg: "daemon_server_url = {{ _multica_cfg.json.daemon_server_url }}"
@@ -0,0 +1,73 @@
# Managed by Ansible (multica_docker) - local changes will be overwritten
{% macro tls_block() %}
{% if caddy_tls_mode == 'internal' %}
tls internal
{% elif caddy_tls_mode == 'cloudflare_dns' %}
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
{% if caddy_dns_resolvers | length > 0 %}
resolvers {{ caddy_dns_resolvers | join(' ') }}
{% endif %}
}
{% endif %}
{% endmacro %}
{
{% if caddy_acme_email | length > 0 %}
email {{ caddy_acme_email }}
{% endif %}
{% if caddy_http_port | int != 80 %}
http_port {{ caddy_http_port }}
{% endif %}
{% if caddy_https_port | int != 443 %}
https_port {{ caddy_https_port }}
{% endif %}
}
{% if multica_layout == 'single' %}
{{ multica_domain }} {
{{ tls_block() }} # CLI reachability probe: `multica setup` GETs <server-url>/health and needs a 200
handle /health {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }}
}
# Browser realtime WebSocket (the web image cannot proxy WS upgrades)
handle /ws {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
# Daemon long-connection WebSocket: daemons dial {server-url}/api/daemon/ws
handle /api/daemon/ws {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
# Everything else goes to the frontend, which forwards API and login requests
handle {
reverse_proxy 127.0.0.1:{{ _multica_frontend_port }}
}
}
{% else %}
{{ multica_app_domain }} {
{{ tls_block() }} # Hand browser WebSockets straight to the backend
@ws path /ws /ws/*
handle @ws {
reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
# Everything else goes to the frontend, which forwards API and login requests
handle {
reverse_proxy 127.0.0.1:{{ _multica_frontend_port }}
}
}
{{ multica_api_domain }} {
{{ tls_block() }} reverse_proxy 127.0.0.1:{{ _multica_backend_port }} {
flush_interval -1
}
}
{% endif %}
@@ -0,0 +1,6 @@
# Managed by Ansible (multica_docker)
FROM caddy:{{ caddy_version }}-builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:{{ caddy_version }}
COPY --from=builder /usr/bin/caddy /usr/bin/caddy
@@ -0,0 +1 @@
CLOUDFLARE_API_TOKEN={{ caddy_cloudflare_api_token }}
@@ -0,0 +1,26 @@
# Managed by Ansible (multica_docker)
name: caddy
services:
caddy:
container_name: {{ caddy_container_name }}
{% if caddy_tls_mode == 'cloudflare_dns' %}
build:
context: .
dockerfile: Dockerfile
image: local/caddy-cloudflare:{{ caddy_version }}
env_file:
- caddy.env
{% else %}
image: caddy:{{ caddy_version }}
{% endif %}
# Host networking: Multica's ports are bound to 127.0.0.1 only
network_mode: host
restart: unless-stopped
volumes:
- {{ caddy_install_dir }}/conf:/etc/caddy:ro
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:
@@ -0,0 +1,32 @@
---
# Fixed by Multica's docker-compose.selfhost.yml (bound to 127.0.0.1)
_multica_frontend_port: 3000
_multica_backend_port: 8080
_multica_compose_file: docker-compose.selfhost.yml
_multica_app_host: "{{ multica_domain if multica_layout == 'single' else multica_app_domain }}"
_multica_api_host: "{{ multica_domain if multica_layout == 'single' else multica_api_domain }}"
_multica_port_suffix: "{{ '' if caddy_https_port | int == 443 else ':' ~ caddy_https_port }}"
_multica_app_url: "https://{{ _multica_app_host }}{{ _multica_port_suffix }}"
_multica_api_url: "https://{{ _multica_api_host }}{{ _multica_port_suffix }}"
_multica_env_core:
JWT_SECRET: "{{ multica_jwt_secret }}"
POSTGRES_PASSWORD: "{{ multica_postgres_password }}"
MULTICA_VCS_SECRET_KEY: "{{ multica_vcs_secret_key }}"
MULTICA_IMAGE_TAG: "{{ multica_image_tag }}"
FRONTEND_ORIGIN: "{{ _multica_app_url }}"
MULTICA_APP_URL: "{{ _multica_app_url }}"
# Split layout only: daemons/CLI talk to the API hostname
_multica_env_split: "{{ {'MULTICA_PUBLIC_URL': _multica_api_url} if multica_layout == 'split' else {} }}"
_multica_env_telemetry: "{{ {'DO_NOT_TRACK': 'true'} if multica_disable_telemetry | bool else {} }}"
_multica_env_resend: "{{ {'RESEND_API_KEY': multica_resend_api_key} if (multica_resend_api_key | length) > 0 else {} }}"
_multica_env: >-
{{ _multica_env_core
| combine(_multica_env_split, _multica_env_telemetry, _multica_env_resend, multica_extra_env) }}
# Health URL used by post-deploy verification
_multica_public_health_url: >-
{{ _multica_app_url ~ '/health' if multica_layout == 'single' else _multica_api_url ~ '/readyz' }}
+28
View File
@@ -0,0 +1,28 @@
# nginx-proxy
Shared nginx + certbot (Cloudflare DNS challenge) under /opt/nginx. Apps publish
themselves with `tasks_from: add_vhost`; see the header of tasks/add_vhost.yml.
Layout on the host:
/opt/nginx/
docker-compose.yml .env (0600)
conf.d/ 00-global.conf + one <app>.conf per vhost -> /etc/nginx/conf.d
certs/ certbot state (live/, archive/, renewal/) -> /etc/letsencrypt, /etc/nginx/certs
cert-domains/ one empty file per domain (filename = domain)
scripts/ certbot-init.sh
Required variables: `nginx_proxy_certbot_email`, and `vault_cloudflare_api_token`
(or `nginx_proxy_cloudflare_api_token`).
Prerequisite (manual): a DNS record for each app domain pointing at this host.
Staging: set `nginx_proxy_certbot_staging: true` to test the DNS challenge without
using Let's Encrypt rate limits. Staging certs are skipped-as-existing afterwards, so
to move a domain to a real cert: `docker exec certbot certbot delete --cert-name <domain>`
and re-run its play.
Retire a domain: delete its cert-domains/ marker and conf.d/<app>.conf, reload nginx,
then `docker exec certbot certbot delete --cert-name <domain>`.
Pin nginx older than 1.25.1? Set `nginx_proxy_http2: false` (the template uses `http2 on;`).
@@ -0,0 +1,27 @@
---
nginx_proxy_base_path: /opt/nginx
nginx_proxy_certs_dir: "{{ nginx_proxy_base_path }}/certs"
nginx_proxy_conf_dir: "{{ nginx_proxy_base_path }}/conf.d"
nginx_proxy_domains_dir: "{{ nginx_proxy_base_path }}/cert-domains"
nginx_proxy_scripts_dir: "{{ nginx_proxy_base_path }}/scripts"
nginx_proxy_nginx_container: nginx
nginx_proxy_certbot_container: certbot
nginx_proxy_nginx_image: nginx:latest
nginx_proxy_certbot_image: certbot/dns-cloudflare:latest
# Required: set these in group_vars/host_vars (token comes from the vault)
nginx_proxy_certbot_email: ""
nginx_proxy_cloudflare_api_token: "{{ vault_cloudflare_api_token }}"
# true = Let's Encrypt staging (untrusted certs, no rate-limit risk). Use while iterating.
nginx_proxy_certbot_staging: false
# nginx reloads this often so renewed certs get picked up
nginx_proxy_reload_interval: 6h
nginx_proxy_client_max_body_size: 100M
# "http2 on;" needs nginx >= 1.25.1 (fine for nginx:latest). Set false if you pin older.
nginx_proxy_http2: true
nginx_proxy_pull_policy: missing
@@ -0,0 +1,92 @@
#!/bin/sh
# Issues certificates (one per domain) via the Cloudflare DNS challenge.
#
# certbot-init.sh -> every domain with a marker file in $DOMAINS_DIR
# certbot-init.sh a.example.com -> only the domains given as arguments
#
# Idempotent: domains that already have a certificate are skipped.
# Renewal is NOT handled here; `certbot renew` covers every existing lineage.
set -eu
DOMAINS_DIR="${DOMAINS_DIR:-/etc/cert-domains.d}"
EMAIL="${CERTBOT_EMAIL:-}"
TOKEN="${CLOUDFLARE_API_TOKEN:-}"
STAGING_ARG=""
if [ "${CERTBOT_STAGING:-0}" = "1" ]; then
STAGING_ARG="--server https://acme-staging-v02.api.letsencrypt.org/directory"
echo "NOTE: using the Let's Encrypt STAGING server (certificates will not be trusted)"
fi
if [ "$#" -gt 0 ]; then
DOMAINS="$*"
elif [ -d "$DOMAINS_DIR" ]; then
DOMAINS=$(ls -1 "$DOMAINS_DIR" | grep -E '^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?\.[A-Za-z]{2,}$' || true)
else
DOMAINS=""
fi
DOMAINS=$(printf '%s\n' "$DOMAINS" | tr ', \t' '\n\n\n' | grep -v '^$' | sort -u || true)
if [ -z "$DOMAINS" ]; then
echo "No domains registered in $DOMAINS_DIR; nothing to do"
exit 0
fi
if [ -z "$EMAIL" ]; then
echo "ERROR: CERTBOT_EMAIL not set"
exit 1
fi
if [ -z "$TOKEN" ]; then
echo "ERROR: CLOUDFLARE_API_TOKEN not set"
exit 1
fi
# Temporary credentials file, shared by all requests and always cleaned up
CREDS_FILE=$(mktemp /tmp/cloudflare.XXXXXX)
trap 'rm -f "$CREDS_FILE"' EXIT
chmod 600 "$CREDS_FILE"
printf 'dns_cloudflare_api_token = %s\n' "$TOKEN" > "$CREDS_FILE"
FAILED=""
ISSUED=0
SKIPPED=0
for DOMAIN in $DOMAINS; do
CERT_PATH="/etc/letsencrypt/live/${DOMAIN}/fullchain.pem"
if [ -f "$CERT_PATH" ]; then
echo "Certificate for $DOMAIN already exists at $CERT_PATH"
SKIPPED=$((SKIPPED + 1))
continue
fi
echo "Requesting certificate for $DOMAIN via Cloudflare DNS challenge..."
# --cert-name pins the lineage directory to the domain name so the
# live/<domain>/ path nginx expects never gets a -0001 suffix.
# STAGING_ARG is intentionally unquoted (it expands to two words).
# shellcheck disable=SC2086
if certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials "$CREDS_FILE" \
--agree-tos \
--no-eff-email \
--email "$EMAIL" \
--non-interactive \
--cert-name "$DOMAIN" \
$STAGING_ARG \
-d "$DOMAIN" && [ -f "$CERT_PATH" ]; then
echo "Certificate obtained and verified for $DOMAIN at $CERT_PATH"
ISSUED=$((ISSUED + 1))
else
echo "ERROR: Failed to obtain certificate for $DOMAIN"
FAILED="$FAILED $DOMAIN"
fi
done
echo "Summary: $ISSUED issued, $SKIPPED already present"
if [ -n "$FAILED" ]; then
echo "ERROR: certificate request failed for:$FAILED"
exit 1
fi
@@ -0,0 +1,6 @@
---
- name: Reload nginx
ansible.builtin.shell: >
docker exec {{ nginx_proxy_nginx_container }} nginx -t &&
docker exec {{ nginx_proxy_nginx_container }} nginx -s reload
changed_when: true
@@ -0,0 +1,118 @@
---
# Publish an app through nginx-proxy. Usage:
#
# - ansible.builtin.include_role:
# name: nginx-proxy
# tasks_from: add_vhost
# vars:
# vhost_name: paperclip # required, becomes conf.d/<name>.conf
# vhost_domain: "{{ paperclip_domain }}" # required
# vhost_upstream_port: 3100 # required
# vhost_upstream_host: 127.0.0.1 # optional
# vhost_proxy_timeout: 60s # optional (use 3600s for WebSockets)
# vhost_health_path: /health # optional, proxied without access log
#
# Order: marker -> certificate -> vhost file -> nginx -t -> reload.
# The vhost is only written once the certificate exists, and a vhost that
# fails nginx -t is rolled back, so one bad app cannot take the others down.
- name: Validate vhost parameters
ansible.builtin.assert:
that:
- vhost_name is defined
- vhost_domain is defined
- vhost_upstream_port is defined
fail_msg: "add_vhost needs vhost_name, vhost_domain and vhost_upstream_port"
quiet: true
- name: Check that the nginx-proxy containers are running
ansible.builtin.command: "docker inspect -f '{% raw %}{{.State.Running}}{% endraw %}' {{ item }}"
register: nginx_proxy_running
failed_when: nginx_proxy_running.rc != 0 or nginx_proxy_running.stdout | trim != 'true'
changed_when: false
loop:
- "{{ nginx_proxy_nginx_container }}"
- "{{ nginx_proxy_certbot_container }}"
loop_control:
label: "{{ item }}"
- name: "Register cert marker for {{ vhost_domain }}"
ansible.builtin.file:
path: "{{ nginx_proxy_domains_dir }}/{{ vhost_domain }}"
state: touch
modification_time: preserve
access_time: preserve
mode: "0644"
- name: "Check for existing certificate for {{ vhost_domain }}"
ansible.builtin.stat:
path: "{{ nginx_proxy_certs_dir }}/live/{{ vhost_domain }}/fullchain.pem"
register: nginx_proxy_cert_before
# Targeted run: only this domain, so another app's failed issuance
# cannot fail this play.
- name: "Issue certificate for {{ vhost_domain }}"
ansible.builtin.command: >-
docker exec {{ nginx_proxy_certbot_container }}
/scripts/certbot-init.sh {{ vhost_domain }}
register: nginx_proxy_cert_issue
changed_when: "'Certificate obtained' in nginx_proxy_cert_issue.stdout"
when: not nginx_proxy_cert_before.stat.exists
- name: "Confirm certificate exists for {{ vhost_domain }}"
ansible.builtin.stat:
path: "{{ nginx_proxy_certs_dir }}/live/{{ vhost_domain }}/fullchain.pem"
register: nginx_proxy_cert_after
failed_when: not nginx_proxy_cert_after.stat.exists
- name: "Write {{ vhost_name }} vhost, test, and reload"
block:
- name: "Template conf.d/{{ vhost_name }}.conf"
ansible.builtin.template:
src: vhost.conf.j2
dest: "{{ nginx_proxy_conf_dir }}/{{ vhost_name }}.conf"
mode: "0644"
backup: true
register: nginx_proxy_vhost_file
- name: Test nginx config
ansible.builtin.command: "docker exec {{ nginx_proxy_nginx_container }} nginx -t"
changed_when: false
- name: Reload nginx
ansible.builtin.command: "docker exec {{ nginx_proxy_nginx_container }} nginx -s reload"
when: nginx_proxy_vhost_file.changed
- name: Remove superseded backup
ansible.builtin.file:
path: "{{ nginx_proxy_vhost_file.backup_file }}"
state: absent
when: nginx_proxy_vhost_file.backup_file is defined
rescue:
- name: Restore previous vhost
ansible.builtin.copy:
src: "{{ nginx_proxy_vhost_file.backup_file }}"
dest: "{{ nginx_proxy_conf_dir }}/{{ vhost_name }}.conf"
remote_src: true
mode: "0644"
when: nginx_proxy_vhost_file.backup_file | default('') | length > 0
- name: Remove backup after restoring it
ansible.builtin.file:
path: "{{ nginx_proxy_vhost_file.backup_file }}"
state: absent
when: nginx_proxy_vhost_file.backup_file | default('') | length > 0
- name: Remove the new vhost (there was no previous one)
ansible.builtin.file:
path: "{{ nginx_proxy_conf_dir }}/{{ vhost_name }}.conf"
state: absent
when:
- nginx_proxy_vhost_file is defined
- nginx_proxy_vhost_file.backup_file | default('') | length == 0
- name: Fail with context
ansible.builtin.fail:
msg: >-
nginx rejected the {{ vhost_name }} vhost; it was rolled back and
the running config is unchanged.
+70
View File
@@ -0,0 +1,70 @@
---
- name: Assert required variables
ansible.builtin.assert:
that:
- nginx_proxy_certbot_email | length > 0
- nginx_proxy_cloudflare_api_token | length > 0
fail_msg: >-
Set nginx_proxy_certbot_email, and make sure vault_cloudflare_api_token
(or nginx_proxy_cloudflare_api_token) is defined.
quiet: true
tags: [nginx-proxy, preflight]
- name: Create directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ nginx_proxy_base_path }}"
- "{{ nginx_proxy_certs_dir }}"
- "{{ nginx_proxy_conf_dir }}"
- "{{ nginx_proxy_domains_dir }}"
- "{{ nginx_proxy_scripts_dir }}"
tags: [nginx-proxy]
- name: Install certbot script
ansible.builtin.copy:
src: certbot-init.sh
dest: "{{ nginx_proxy_scripts_dir }}/certbot-init.sh"
mode: "0755"
tags: [nginx-proxy]
- name: Template global nginx settings
ansible.builtin.template:
src: 00-global.conf.j2
dest: "{{ nginx_proxy_conf_dir }}/00-global.conf"
mode: "0644"
notify: Reload nginx
tags: [nginx-proxy]
- name: Template compose env file
ansible.builtin.template:
src: env.j2
dest: "{{ nginx_proxy_base_path }}/.env"
mode: "0600"
no_log: true
tags: [nginx-proxy]
- name: Template compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ nginx_proxy_base_path }}/docker-compose.yml"
mode: "0644"
tags: [nginx-proxy]
- name: Deploy nginx and certbot
community.docker.docker_compose_v2:
project_src: "{{ nginx_proxy_base_path }}"
pull: "{{ nginx_proxy_pull_policy }}"
state: present
tags: [nginx-proxy]
- name: Verify nginx config is valid and the container is serving
ansible.builtin.command: "docker exec {{ nginx_proxy_nginx_container }} nginx -t"
register: nginx_proxy_test
until: nginx_proxy_test.rc == 0
retries: 6
delay: 5
changed_when: false
tags: [nginx-proxy, verify]
@@ -0,0 +1,31 @@
# Managed by Ansible (nginx-proxy role)
# Included inside the stock nginx.conf http { } block, so these are http-level.
server_tokens off;
client_max_body_size {{ nginx_proxy_client_max_body_size }};
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
# Catch-all: unknown Host headers get nothing, and unknown SNI is rejected
# before any certificate is presented.
server {
listen 80 default_server;
server_name _;
access_log off;
location = /nginx-health {
return 200 "ok\n";
}
location / {
return 444;
}
}
server {
listen 443 ssl default_server;
server_name _;
ssl_reject_handshake on;
}
@@ -0,0 +1,46 @@
# Managed by Ansible (nginx-proxy role)
services:
certbot:
image: {{ nginx_proxy_certbot_image }}
container_name: {{ nginx_proxy_certbot_container }}
volumes:
- {{ nginx_proxy_certs_dir }}:/etc/letsencrypt
- {{ nginx_proxy_scripts_dir }}:/scripts:ro
- {{ nginx_proxy_domains_dir }}:/etc/cert-domains.d:ro
environment:
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
CERTBOT_EMAIL: ${CERTBOT_EMAIL}
CERTBOT_STAGING: "{{ '1' if nginx_proxy_certbot_staging | bool else '0' }}"
entrypoint: /bin/sh
command:
- -c
- |
while true; do
/scripts/certbot-init.sh || echo "issuance failed; will retry next cycle"
certbot renew --quiet
sleep 12h
done
restart: unless-stopped
nginx:
image: {{ nginx_proxy_nginx_image }}
container_name: {{ nginx_proxy_nginx_container }}
network_mode: host
volumes:
# Directory mounts (not single files) so Ansible's atomic file
# replacement is visible inside the container.
- {{ nginx_proxy_conf_dir }}:/etc/nginx/conf.d:ro
- {{ nginx_proxy_certs_dir }}:/etc/nginx/certs:ro
command:
- /bin/sh
- -c
- |
( while :; do sleep {{ nginx_proxy_reload_interval }}; nginx -s reload; done ) &
exec nginx -g 'daemon off;'
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/nginx-health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
@@ -0,0 +1,3 @@
# Managed by Ansible (nginx-proxy role)
CLOUDFLARE_API_TOKEN={{ nginx_proxy_cloudflare_api_token }}
CERTBOT_EMAIL={{ nginx_proxy_certbot_email }}
@@ -0,0 +1,51 @@
# Managed by Ansible (nginx-proxy role) - vhost: {{ vhost_name }}
server {
listen 80;
server_name {{ vhost_domain }};
return 301 https://{{ vhost_domain }}$request_uri;
}
server {
listen 443 ssl;
{% if nginx_proxy_http2 | bool %}
http2 on;
{% endif %}
server_name {{ vhost_domain }};
ssl_certificate /etc/nginx/certs/live/{{ vhost_domain }}/fullchain.pem;
ssl_certificate_key /etc/nginx/certs/live/{{ vhost_domain }}/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-XSS-Protection "1; mode=block" always;
location / {
proxy_pass http://{{ vhost_upstream_host | default('127.0.0.1') }}:{{ vhost_upstream_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
# WebSocket support
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_connect_timeout 60s;
proxy_send_timeout {{ vhost_proxy_timeout | default('60s') }};
proxy_read_timeout {{ vhost_proxy_timeout | default('60s') }};
proxy_buffering off;
proxy_request_buffering off;
}
{% if vhost_health_path | default('') | length > 0 %}
location {{ vhost_health_path }} {
proxy_pass http://{{ vhost_upstream_host | default('127.0.0.1') }}:{{ vhost_upstream_port }}{{ vhost_health_path }};
access_log off;
}
{% endif %}
}
+81
View File
@@ -0,0 +1,81 @@
# obsidian_headless
Runs Obsidian's official headless Sync client
([`obsidian-headless`](https://obsidian.md/help/sync/headless), command `ob`,
open beta) as a dedicated unprivileged user under a systemd unit. It lays out
the vault folder so that other local users reach only the folders granted to
them.
Written for `spaceship-earth` (WDWL-22). Wong's daemon user (`multica`) may
read and write `inbox/` and nothing else. `everpure/` and `wip/` are `obsync`
only. Entry point: `playbooks/provision-wong-host.yml`.
## What it does
1. Creates the system user `obsync`, with a `0700` home in `/var/lib/obsync`.
That home holds the Obsidian account token and per-vault sync state.
2. Installs the pinned `obsidian-headless` into that user's npm prefix and
links `ob` into `/usr/local/bin`. Node.js is **not** installed here. The
role asserts that the runtime `multica_agent_host` installs is present and
is at least version 22.
3. Lays out the vault folder and its permissions:
| Path | Mode | ACL |
|---|---|---|
| `/srv/obsidian/` | `0710 obsync` | `user:multica:--x` |
| `/srv/obsidian/vault/` | `0710 obsync` | `user:multica:--x`, so `multica` can traverse but not list |
| `vault/everpure/`, `vault/wip/` | `0700 obsync` | none |
| `vault/inbox/` | `0770 obsync` | `user:multica:rwx` and `user:obsync:rwx`, each also as a **default** entry |
On a directory with an ACL, the mode's group bits are the ACL mask. A
`chmod 0700` would therefore void every named entry, which is why the
shared paths use `0710` and `0770`. The owning group is `obsync`'s private
group, so those bits grant nobody anything.
4. Installs `obsidian-sync.service` (`User=obsync`, `UMask=0077`, sandboxed)
and enables it. It is started only once a vault has been set up.
`Condition*=` lines keep it inert until then, including across reboots.
5. Verifies the isolation as the users themselves:
- `multica` writes, reads and deletes in `inbox/`.
- A folder and file created by `obsync` under umask `0077` (the way Sync
creates them) stay readable and writable by `multica`.
- What `multica` creates stays readable and writable by `obsync`. Those
files are owned `multica:multica`, so without the named `user:obsync`
default entry Sync could not read them and would never upload them.
- `multica` is refused both listing the vault root and listing or writing
`everpure/` and `wip/`.
## Do modes and ACLs survive Sync?
Obsidian Sync carries no Unix modes or ACLs. Files are created locally by
`obsync`, so their permissions come from this host, not from the remote:
- In `inbox/`, the default ACL applies and the umask is ignored, so new
content is granted to `multica`.
- Everywhere else, `UMask=0077` applies, so new content is private to
`obsync`. That covers any new top-level folder and `.obsidian/`.
- If Sync deletes and recreates `inbox/` itself, the recreated folder is
private and `multica` loses access. The failure is closed, not open, and
re-running the play restores the grant.
## Manual step: sign in and set up the vault (once per host)
The role never handles the Obsidian account. Run these as `obsync`; `-H`
makes `HOME` point to `/var/lib/obsync`:
```bash
sudo -u obsync -H ob login
sudo -u obsync -H ob sync-list-remote
sudo -u obsync -H ob sync-setup --vault "<vault name>" \
--path /srv/obsidian/vault --device-name spaceship-earth
sudo systemctl start obsidian-sync
journalctl -u obsidian-sync -f
```
Re-running the play after this starts the unit as well. Do not also run
desktop Obsidian Sync on this host: Obsidian warns that two Sync clients on
one device conflict.
## Tags
`obsidian_user`, `obsidian_install`, `obsidian_layout`, `obsidian_service`,
`obsidian_verify`. The play itself is tagged `obsidian`.
@@ -0,0 +1,64 @@
---
# =============================================================================
# obsidian_headless — defaults
#
# Runs Obsidian's official headless Sync client (`ob`, npm obsidian-headless)
# as a dedicated unprivileged user under a systemd unit, and lays out the vault
# folder so that other local users reach only the folders granted to them.
# =============================================================================
# -----------------------------------------------------------------------------
# Service account
# -----------------------------------------------------------------------------
# Holds the Obsidian account token (~/.config/obsidian-headless/auth_token) and
# the per-vault sync state, so its home is private.
obsidian_headless_user: obsync
obsidian_headless_group: obsync
obsidian_headless_home: "/var/lib/{{ obsidian_headless_user }}"
# -----------------------------------------------------------------------------
# Client
# -----------------------------------------------------------------------------
# Pinned. The package is in open beta and versions move quickly.
obsidian_headless_version: "0.0.14"
obsidian_headless_npm_prefix: "{{ obsidian_headless_home }}/.npm-global"
# Node.js is NOT installed by this role. It expects the runtime that
# multica_agent_host already puts on this host. obsidian-headless needs >= 22.
obsidian_headless_node_bin_dir: /usr/local/bin
obsidian_headless_node_min_version: "22.0.0"
# Where `ob` is linked, so `sudo -u obsync -H ob ...` works without a PATH.
obsidian_headless_link_dir: /usr/local/bin
# -----------------------------------------------------------------------------
# Vault
# -----------------------------------------------------------------------------
obsidian_headless_vault_path: /srv/obsidian/vault
# Shown in Sync version history. Applied by the manual `ob sync-setup`.
obsidian_headless_device_name: "{{ inventory_hostname_short }}"
# Top-level folders only obsidian_headless_user may enter (0700, no ACL).
obsidian_headless_private_folders: []
# Top-level folders another local user may read and write. Granted with named
# ACL entries plus matching DEFAULT entries, so files and folders Sync creates
# later inherit the grant. Everything not listed here is private: the unit runs
# with UMask=0077, and the vault root grants listed users traverse (--x) only,
# never list.
# - path: inbox
# user: multica
obsidian_headless_shared_folders: []
# -----------------------------------------------------------------------------
# Service
# -----------------------------------------------------------------------------
obsidian_headless_service_name: obsidian-sync
# Enabled either way; started only once `ob login` and `ob sync-setup` have
# been done by hand. Its Condition*= lines keep it inert until then.
obsidian_headless_service_enabled: true
# -----------------------------------------------------------------------------
# Verification
# -----------------------------------------------------------------------------
# Prove the isolation as the shared users themselves: read/write in their
# folders, denied in every private folder and on listing the vault root.
obsidian_headless_verify: true
@@ -0,0 +1,8 @@
---
# Restarts only a unit that is already running: before the manual sign-in the
# unit is inert, and starting it here would just trip its conditions.
- name: Restart obsidian sync
ansible.builtin.command:
argv: [systemctl, try-restart, "{{ obsidian_headless_service_name }}.service"]
changed_when: true
listen: restart obsidian sync
@@ -0,0 +1,15 @@
---
galaxy_info:
role_name: obsidian_headless
author: JARVIS
description: >-
Obsidian's official headless Sync client as a dedicated user under a
systemd unit, with per-folder isolation for other local users.
license: MIT
min_ansible_version: "2.16"
platforms:
- name: Ubuntu
versions:
- noble
dependencies: []
@@ -0,0 +1,39 @@
---
- name: Install | Read the installed client version
ansible.builtin.slurp:
src: "{{ obsidian_headless_npm_prefix }}/lib/node_modules/obsidian-headless/package.json"
register: _obh_pkg
failed_when: false
- name: Install | Decide whether an install is needed
ansible.builtin.set_fact:
_obh_needs_install: >-
{{ _obh_pkg.content is not defined
or (_obh_pkg.content | b64decode | from_json).version != obsidian_headless_version }}
- name: Install | Install the pinned client as the service user
ansible.builtin.command:
argv:
- "{{ obsidian_headless_node_bin_dir }}/npm"
- install
- --global
- --prefix
- "{{ obsidian_headless_npm_prefix }}"
- --no-fund
- --no-audit
- "obsidian-headless@{{ obsidian_headless_version }}"
become: true
become_user: "{{ obsidian_headless_user }}"
environment:
HOME: "{{ obsidian_headless_home }}"
PATH: "{{ obsidian_headless_node_bin_dir }}:/usr/bin:/bin"
when: _obh_needs_install | bool
changed_when: true
notify: restart obsidian sync
- name: Install | Link ob onto the default PATH
ansible.builtin.file:
src: "{{ obsidian_headless_npm_prefix }}/bin/ob"
dest: "{{ obsidian_headless_link_dir }}/ob"
state: link
force: true
@@ -0,0 +1,92 @@
---
# Permission model
#
# <parent>/ obsync 0710 + user:<shared>:--x traverse, no listing
# <vault>/ obsync 0710 + user:<shared>:--x traverse, no listing
# <vault>/<private>/ obsync 0700 nobody else
# <vault>/<shared>/ obsync 0770 + user:<u>:rwx and user:obsync:rwx,
# each also as a DEFAULT entry, so new
# children inherit both
#
# The user:obsync entry matters for content the SHARED user creates: that is
# owned <u>:<u>, so without a named entry obsync falls into "other" (---)
# and Sync can neither read nor upload it.
#
# The owning group is obsync's private group, so its bits grant nobody. On a
# directory that carries an ACL, the group bits of the mode ARE the ACL mask,
# which is why the modes here are 0710/0770 rather than 0700: a 0700 chmod
# would zero the mask and silently void every named entry.
#
# Anything else in the vault (folders Sync creates later, .obsidian/) is born
# private: the service runs with UMask=0077, and the umask is ignored only
# under a directory that has a default ACL — i.e. only in the shared folders.
- name: Layout | Collect the users granted shared folders
ansible.builtin.set_fact:
_obh_shared_users: "{{ obsidian_headless_shared_folders | map(attribute='user') | unique | list }}"
- name: Layout | Create the vault and its parent
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ obsidian_headless_user }}"
group: "{{ obsidian_headless_group }}"
mode: "{{ '0710' if _obh_shared_users | length > 0 else '0700' }}"
loop:
- "{{ obsidian_headless_vault_path | dirname }}"
- "{{ obsidian_headless_vault_path }}"
- name: Layout | Let shared users traverse (not list) the vault and its parent
ansible.posix.acl:
path: "{{ item.0 }}"
entity: "{{ item.1 }}"
etype: user
permissions: x
state: present
loop: >-
{{ [obsidian_headless_vault_path | dirname, obsidian_headless_vault_path]
| product(_obh_shared_users) | list }}
- name: Layout | Create the private folders
ansible.builtin.file:
path: "{{ obsidian_headless_vault_path }}/{{ item }}"
state: directory
owner: "{{ obsidian_headless_user }}"
group: "{{ obsidian_headless_group }}"
mode: "0700"
loop: "{{ obsidian_headless_private_folders }}"
- name: Layout | Create the shared folders
ansible.builtin.file:
path: "{{ obsidian_headless_vault_path }}/{{ item.path }}"
state: directory
owner: "{{ obsidian_headless_user }}"
group: "{{ obsidian_headless_group }}"
mode: "0770"
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.path }}"
# Each shared folder gets its user AND the service user, both as access and
# default entries.
- name: Layout | Build the shared-folder grants
ansible.builtin.set_fact:
_obh_grants: >-
{{ (obsidian_headless_shared_folders
+ (obsidian_headless_shared_folders
| map(attribute='path')
| map('community.general.dict_kv', 'path') | list
| map('combine', {'user': obsidian_headless_user}) | list))
| unique | list }}
- name: Layout | Grant shared folders (access and default entries)
ansible.posix.acl:
path: "{{ obsidian_headless_vault_path }}/{{ item.0.path }}"
entity: "{{ item.0.user }}"
etype: user
permissions: rwx
default: "{{ item.1 }}"
state: present
loop: "{{ _obh_grants | product([false, true]) | list }}"
loop_control:
label: "{{ item.0.path }} -> {{ item.0.user }}{{ ' (default)' if item.1 else '' }}"
@@ -0,0 +1,27 @@
---
# Not `always`: that would also fire under unrelated tags of the calling
# playbook (e.g. --tags wong_disk). Every tag of this role carries it instead.
- name: Preflight
ansible.builtin.import_tasks: preflight.yml
tags: [obsidian, obsidian_user, obsidian_install, obsidian_layout, obsidian_service, obsidian_verify]
- name: Service account
ansible.builtin.import_tasks: user.yml
tags: [obsidian_user]
- name: Client
ansible.builtin.import_tasks: install.yml
tags: [obsidian_install]
- name: Vault layout
ansible.builtin.import_tasks: layout.yml
tags: [obsidian_layout]
- name: Service
ansible.builtin.import_tasks: service.yml
tags: [obsidian_service]
- name: Verify
ansible.builtin.import_tasks: verify.yml
when: obsidian_headless_verify | bool
tags: [obsidian_verify]
@@ -0,0 +1,33 @@
---
- name: Preflight | Read the Node.js version
ansible.builtin.command:
argv: ["{{ obsidian_headless_node_bin_dir }}/node", "--version"]
register: _obh_node
changed_when: false
failed_when: false
check_mode: false
- name: Preflight | Require Node.js new enough for obsidian-headless
ansible.builtin.assert:
that:
- _obh_node.rc == 0
- (_obh_node.stdout | trim | regex_replace('^v', '')) is version(obsidian_headless_node_min_version, '>=')
fail_msg: >-
{{ obsidian_headless_node_bin_dir }}/node is missing or older than
{{ obsidian_headless_node_min_version }} (found:
{{ _obh_node.stdout | default('nothing') | trim }}). This role uses the
runtime multica_agent_host installs; run provision-agent-host.yml first.
quiet: true
- name: Preflight | Look up the users granted shared folders
ansible.builtin.getent:
database: passwd
key: "{{ item }}"
loop: "{{ obsidian_headless_shared_folders | map(attribute='user') | unique | list }}"
- name: Preflight | Require shared folders to stay disjoint from private ones
ansible.builtin.assert:
that:
- obsidian_headless_shared_folders | map(attribute='path') | intersect(obsidian_headless_private_folders) | length == 0
fail_msg: A folder cannot be both private and shared.
quiet: true
@@ -0,0 +1,31 @@
---
- name: Service | Install the unit
ansible.builtin.template:
src: obsidian-sync.service.j2
dest: "/etc/systemd/system/{{ obsidian_headless_service_name }}.service"
owner: root
group: root
mode: "0644"
notify: restart obsidian sync
- name: Service | Enable it at boot
ansible.builtin.systemd_service:
name: "{{ obsidian_headless_service_name }}"
enabled: "{{ obsidian_headless_service_enabled | bool }}"
daemon_reload: true
# Starting it before the manual sign-in would be a no-op (its conditions fail)
# that reports "changed" on every run. Start it only once it can actually sync.
- name: Service | Check whether the manual sign-in and vault setup are done
ansible.builtin.find:
paths: "{{ obsidian_headless_home }}/.config/obsidian-headless/sync"
file_type: directory
register: _obh_sync_configs
- name: Service | Start it once a vault is set up
ansible.builtin.systemd_service:
name: "{{ obsidian_headless_service_name }}"
state: started
when:
- obsidian_headless_service_enabled | bool
- _obh_sync_configs.matched | default(0) > 0
@@ -0,0 +1,26 @@
---
- name: User | Create the group
ansible.builtin.group:
name: "{{ obsidian_headless_group }}"
system: true
state: present
# nologin: nobody needs a shell as this user. `sudo -u obsync -H ob ...` runs
# the command directly and does not consult the login shell.
- name: User | Create the user
ansible.builtin.user:
name: "{{ obsidian_headless_user }}"
group: "{{ obsidian_headless_group }}"
home: "{{ obsidian_headless_home }}"
shell: /usr/sbin/nologin
system: true
create_home: true
state: present
- name: User | Keep the home private (holds the Obsidian account token)
ansible.builtin.file:
path: "{{ obsidian_headless_home }}"
state: directory
owner: "{{ obsidian_headless_user }}"
group: "{{ obsidian_headless_group }}"
mode: "0700"
@@ -0,0 +1,151 @@
---
# Each check runs AS the user it is about, because file modes and ACLs only
# mean something when exercised. Probes are cleaned up; nothing is left behind.
- name: Verify | Shared users can write, read and delete in their folders
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'f="$1/.obh-verify.$$"; echo ok > "$f" && grep -q ok "$f" && rm -f "$f"'
- sh
- "{{ obsidian_headless_vault_path }}/{{ item.path }}"
become: true
become_user: "{{ item.user }}"
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.user }} -> {{ item.path }}"
changed_when: false
check_mode: false
# The reverse direction: what the shared user writes, Sync must be able to
# read (to upload it) and rewrite (to merge remote edits into it).
- name: Verify | Content a shared user creates stays usable by the service (create)
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'd="$1/.obh-verify-rev"; mkdir -p "$d" && echo ok > "$d/note.md"'
- sh
- "{{ obsidian_headless_vault_path }}/{{ item.path }}"
become: true
become_user: "{{ item.user }}"
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.user }} -> {{ item.path }}"
changed_when: false
check_mode: false
- name: Verify | Content a shared user creates stays usable by the service (use)
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'd="$1/.obh-verify-rev"; grep -q ok "$d/note.md" && echo more >> "$d/note.md" && echo ok > "$d/reply.md"'
- sh
- "{{ obsidian_headless_vault_path }}/{{ item.path }}"
become: true
become_user: "{{ obsidian_headless_user }}"
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.path }}"
register: _obh_reverse
changed_when: false
failed_when: false
check_mode: false
- name: Verify | Remove the reverse probes
ansible.builtin.file:
path: "{{ obsidian_headless_vault_path }}/{{ item.path }}/.obh-verify-rev"
state: absent
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.path }}"
changed_when: false
- name: Verify | The service can read and rewrite shared users' content
ansible.builtin.assert:
that:
- _obh_reverse.results | rejectattr('rc', 'equalto', 0) | list | length == 0
fail_msg: >-
{{ obsidian_headless_user }} cannot read or write content a shared user
created; Sync would never upload it. Check the user:{{ obsidian_headless_user }}
default ACL entry on the shared folder.
quiet: true
# Simulates the service: obsync, umask 0077, creating a folder and a file the
# way Sync does. The shared user must still reach both, via the default ACL.
- name: Verify | Content the service creates in a shared folder inherits the grant (create)
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'umask 0077; d="$1/.obh-verify-dir"; mkdir -p "$d" && echo ok > "$d/note.md"'
- sh
- "{{ obsidian_headless_vault_path }}/{{ item.path }}"
become: true
become_user: "{{ obsidian_headless_user }}"
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.path }}"
changed_when: false
check_mode: false
- name: Verify | Content the service creates in a shared folder inherits the grant (use)
ansible.builtin.command:
argv:
- /bin/sh
- -c
- 'd="$1/.obh-verify-dir"; grep -q ok "$d/note.md" && echo ok > "$d/reply.md"'
- sh
- "{{ obsidian_headless_vault_path }}/{{ item.path }}"
become: true
become_user: "{{ item.user }}"
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.user }} -> {{ item.path }}"
register: _obh_inherit
changed_when: false
failed_when: false
check_mode: false
- name: Verify | Remove the inheritance probes
ansible.builtin.file:
path: "{{ obsidian_headless_vault_path }}/{{ item.path }}/.obh-verify-dir"
state: absent
loop: "{{ obsidian_headless_shared_folders }}"
loop_control:
label: "{{ item.path }}"
changed_when: false
- name: Verify | Inheritance held
ansible.builtin.assert:
that:
- _obh_inherit.results | rejectattr('rc', 'equalto', 0) | list | length == 0
fail_msg: >-
A shared user could not read or write content created by
{{ obsidian_headless_user }} under umask 0077; the default ACL is not
being inherited.
quiet: true
# Every shared user must be refused: listing the vault root, and listing,
# reading into or writing into each private folder.
- name: Verify | Shared users are denied the vault root listing and every private folder
ansible.builtin.command:
argv: >-
{{ ['/bin/sh', '-c', _obh_deny_script, 'sh', obsidian_headless_vault_path]
+ obsidian_headless_private_folders }}
vars:
_obh_deny_script: |
v="$1"; shift
ls "$v" >/dev/null 2>&1 && { echo "can list $v"; exit 1; }
for p in "$@"; do
ls "$v/$p" >/dev/null 2>&1 && { echo "can list $p"; exit 1; }
touch "$v/$p/.obh-verify" 2>/dev/null && { rm -f "$v/$p/.obh-verify"; echo "can write $p"; exit 1; }
done
exit 0
become: true
become_user: "{{ item }}"
loop: "{{ obsidian_headless_shared_folders | map(attribute='user') | unique | list }}"
changed_when: false
check_mode: false
Loaded 100 of 150 files, more files were not shown because too many files have changed in this diff. Show more