Author SHA1 Message Date
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
Hermes Agent service account 49989b1757 Migrate Body Wars to Helm chart 2026-09-16 21:25:30 -05:00
142 changed files with 8841 additions and 372 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.**
+426 -364
View File
@@ -1,365 +1,427 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
66393233316132396639356564316439343234383066633231646134313361666463656536323732 31356235343830393937633039386366623238373864353438353332653038616330623365343135
6630616536646439613533363430306466306233643730350a343364633233333335643833326163 3961393463613335663138383233323964393832626466620a393763616364363030323833386565
64393933613963313533623733316339396236363663343635346663323366663166363839663837 30666333366332313262313836613962363537353065646361316230386434366534666338336636
3331363062653239380a326566623264623837326636383939346430666537613361333638366630 3930363231393333610a613039646430663233613163376131333637383866656564333765633565
63353062393335316663633739313532366363623739653631366539323435336361353331386230 32333338316539363034316132313631643632316337356164373765373736366639623362343738
39366634643964336233353961316630616462663166316266613037623363346335373638656365 63326164623535326461323234333933656132313836643139316339333831323331393030323233
38353733396636386133373836346336383231663661346137373164386338623733393566373563 34643932626464353861616534326130373362646437613131326464313737623965366361616266
35653933343036633365643535303934326537356136666539316137363433643266346630386439 37393934333761303163346465366464393964313831393266303434313061356333373430346339
38613332646238366536333536343031356532656336613530663830613264346339353034323362 33393331393461303163306639386239656139613833386235363335333837316666323637346462
66613530626361323535653232313730373463373332313561616631393461353730653464343063 35346634383936333631303338356433323865623030663336323663373962623531313562626666
38616338363938346161616636316232313838616463326432353639613837343162646363343232 33393063613137646631666363386161636462343262313935386535386361346464396133613736
33323064363139376566343866626364373662393138353666646234373461666163363139313631 31383464613130353034396264643766646161343664363435383639656234623035343833653832
64343261326566363265323463663538343034306136326234386664333837333937333136653563 63356130646136396463636533616334643638333239636437663030393064326362393736373233
61333531353434633339383661636363363535316366353330313566323133616438373161303135 34396166653932326632376535373762663633303261303536396334303937316431346133353830
35353630613037316466353832333033393030636331386438393133366333653832393731366363 37626337313861306164366130643131383133323337643637666535316132373238373066306339
62373638303737393162303461646239653865653834613662666636373364633165383062643831 38343865613937663366376630333365633039333430653762313864303737633661356232373133
34386232376361323638353361666530366432356331353963303930326535663536373339333062 64303934623937313863323431396638383932383836613830336235656530613234316238353364
32396266373430343339636635366434313635313766363863336464633961666332353834626163 32306561343362643431623239333638316239323164386634623736313261313936616239396431
61653637316163636465343630353431313863653033643237356434313564366361373435376662 64373934313663313139316331623436663832393131373562393436326265656562363137636461
63643737353830663236643862613533623237373531646136383763303766336139303632666235 33613864356464366239306639653264363565306331636137396466336163336631353163383430
64623834323966363363663730626437323432623966663537346162656265363562643836633731 30303034383136356465643238663034623036363562353037343832616136363539656432346639
65663631633462663764393132326165346639353033633035636432613039336164303538396632 34636536383533333538346237653731633331643137303966316138333633373564383034383438
66396264393865306666643636353638613661313230313337383634663839363439656533333932 34666465323032356333613935363161306431666533353061626536633839396234613662623062
36633432306131396539386539633063653230363932376264323537396434353364643432653661 63313338346138653765393537356639373035643935373735323166376336626166396562316535
32643162363066636432336363323534316436613838646562313538326566666239633234646236 39343236356665363038326132313437303236646161333638393939653033623664306563613730
30346534636533623365326564613561363362333364363037646561656635623935653466613565 34383538656139646436333765623861303739613939633962316139386463643666366464303935
65646361313436356261643762313339333864356338386136306162386262636464393130303963 32366239656366393832376436623534653539376661363737343865663633316138663061643866
38646464316432326431326661343632396235626234366133353461623862316662326432356234 65393930386162393663303135396237623633656230303036646431633236376465633835336364
37626366383861373831633639616465663564643866356664623066386535646163336134356534 63643332616433303766396561386433363836343836663430636530616135653966336131383533
33366664616232353863626465626364313530353335306565336665663866303736323162393362 64613933306165303564303835616132613839636432653137356131343732343736313237356436
63626261653161663664363833313461653034326330653835393737616135646462366665383935 35663033353163346363366635333231366432316434306362616265653832313963303338343538
30363639306330636634386433646231363530633061336364313338653632323831393630383934 61363966306162323932393037626335613133363936393166626433653463363661343732306465
37316362326338313733646332336263386239626539383330353362616132333161613464313066 32373839653865326333613165353461613531383666323938653034623936303562363331643363
34663434326662326233363432306433363666356132383866346336336261636435366332666135 37326164323935626239396239646465616464613332636662353539363233343032616636613237
34616231613638363339356333616536643266636363643131653330396162306264303566396461 62316663643134663038626535663262336266646565336236643234663561313264626238313136
64363763376365356533636430643866333361363062376237653237663731663934306265646630 65383364643537393436666465653437653334663934333765626233303835643732373334663639
33393637656335643366383564373966343265393630333835303731316339373133633462383364 62643064383535366630306666383365353963306563323533653664643132383832346435323465
33383435383331303264313334393532373932333334343862326635346135613932356337373034 30326466623265326437383063353833383131393166313231633032333137326563633734636263
62316262326331313135376465343336373266663338396533666431616462613932663861646238 61623466613436393237656435623335623133343032333535373430633836346566353039646238
62653563623535633738383033326235383666646333653731316233376231623661306462303732 34363338343065323765313466306130643462303061336265623833343139353336383234366362
32643064373236613336396233323435393939386530323331336138353364663762356538316562 30383465376635356462313533643961613437333930396134626463383464323661396431643234
33376530623664623733386133333433303031373337313366386236376539613964316135343865 30353737396262653336376137646137353362353066366634613034313938613234323931333365
33363963366165333238356663663435386439336366646138313034343636653463323938633136 39323733613864346132633461323563396161643738333634316564353934376537353663386636
39363966376238306662303265643034306136663661393738633436393432303139313132616534 36373335343439643432396266386665396266643063616331666438363066643162356265373732
66323432313635386162333838323136623634653264643438303264636430633232323434666532 31616537656266363866656133316663383634343735616266653161613139636164653566303666
32616664663063653735316237643539633133356661333132323238376333356464313262653836 35396566653136343038636239626561303966346636333635363836366538616233386164316633
39303566316332663737323437633031353330333365383837636336643763313433313937396531 30626135303434636335623366383566366532313937313133313664313462316438323964376130
38363536343438663966663436613132663661613134383431633765383164373762343435316161 61363166306232363737616432316666623166313962366462656464626537323538316337626635
62303631646235343063383230343232383336356562303563373933346530393333316634316437 64366533383264356430343066373664646534396538306335333861356237346164373235643239
32316330306163396434663031393965663163666537353031613365353437666466333464626238 31633264396338303061376633376564356231336338376135386639393031656437363662613861
35313739646535356665323734393965303064306132626261363062363438383164346261393463 33373934313862396238353863663530633337383633623565623937623734616664626534383061
30643438623363323161323230306230386332363635386234666639623566643536626637616533 34353862393537633932393338653265353633643166383564363766636539636334643534333736
37396136643930633262333331656363376433333234343630306535313262306235663263663362 62666332313730666661303462656162303338326433393934313536326365663235346263313330
32653434363035613732363136303363393939323337613661333439393637646262383039386661 34623539383031653538333762396361336235643936386366323235346162326635393062646263
62326163323562333339323636363565623664396164383332633666386130613766393138346134 36343638613134353966363261613632313963363236326364366133353838616636323138316561
33343338393536316431353439353062663164643634396363353131303038353965393466383030 63356237663864333334343461396364656231333939636264343966343266346430363965633764
36656465383938353936346361393963356630666630373236626237303064303062383638373730 38636562626366636438343538666632643964393463353836323536333961613961613166373963
35633866646535313432353338623462323235346433653431313031363163393666626432363238 62356336396536613238353531616237616334616231363836616561333866643033666336336439
39623361316132626230633336636163623466313666346631656134343762656566353432353264 65646362393933306534326137396561346338336263383134383263373836323034653932646463
30353436356237653231363564626134633039363035313232616333336436393638396233626638 62326364336438623137623862386438396164643432333135346462363761333437653532373361
32663230396539323761313838313466376165646430346634383332346134653662393161363337 32306161313238363365633131663334376135613336363937313263663537313733313762656134
62646161343665383364306665333164666231386531626465373366623761643161656462303733 33613838326137343061306431363237373661333163373739326339303732623131393633646565
37653438616233353432626466623163316565353764323762613635333832343634323665356336 38663963663338623362653364343337383636323730306431313830316261666264326464303235
35353162326233333836396337356466636131383838313436626336663132346339623261366465 64313662326434646366386166396464653666323735323931343436653763363535396636613938
30623261303933396562353331636638376135663330643638643536346261626632626139386535 37386435303139663965623963383861353030626530653061636132326233373165613231316232
66653332366361336636666437643165656239613031303638333232303836383132616636633938 38616539303630316565366161383664316337333665323631663430363764313736643934623638
31343034643037623731643931316463303639656266323231313666356336333133323135363330 35346264666461623533623963306238613530633034333662653561353030646437346537623065
63373365303131353161303630633738353536393631353034666139383435303461316131646138 37376263366630666430383833373966353730396239626133333330306232643037643231336433
61333731356538366366613831303565613365633965323235366166313534653965366433656533 62653332623461653565376531386464353237383665323538363061633831353534656366333933
62666136313662366638356237343734336333313034396465346632336262306531633535643238 37373235376564613665303462393634623836663835383738356138323136316432663637353434
35333831366532386235316565303936616264373337356134643066396531383533353336303131 36336338623364383334363565643334613631386330633466396336336539383762383162363631
31393837623564386535323532653733393734393164373235396566333565356237356438313762 32393230623938623433333632663365313165306165643433643131366237366131616263333465
32623765326639386262393639376461326163333237313232386138643130643231626466643663 38333739636234326139393361386131383232373362343636653862356535613230343232333365
39643061393566353434333136366335393536376234366266376265333234643536633035653933 30316261623036373531383238373164313961386532393663643736363131623533373366613565
36316132663539306465343039323935356361373439346437386234386464623962643464643562 37653133383930663037363232353032616133653763306564383434366233613066373331363162
61336434613834336161633237383361303930313464613666313834356330343138633735386530 61366530303731333235313537366436363237303561336232663363396562313735613461363331
36616233323366323961653965613438346136373738366266316134356266623664313539636235 30336561643035336430643666643565653538616536646263303265663739653132353233343834
37313033373466383134346361646562366531333338386330653736626530396238356639303131 36613332313430313138636464393139316466373738313330383762623963376363663839386366
38363738396236386461316433316261326435646130383336316234363461393237623633633336 36636265383436643661653465383561306663303461633335373331323733663936373334353164
30386630376565646337383738663939663462623232316635346635653830306664653336343033 35353138306233626363616636633236643130373432636161646563333937623334636163653637
64316430396664393532313766326437636636626232613036666666656430323136356436333564 31613431363963396266303637363833303635376239356337613935636165326265303166383062
36303334303562393832336433343438396430373833623137363736386665343866313064353063 32326165636331356466363766643766363266666530363739383535623863393135663331623737
32303634393131326464656535633734386462646339663533666430336265653965333538633866 61303261313563643665626435343362656632363263656235316230646135616163346630363335
61623666643839653239373335633735373738363736313665323365613635313766656635613832 36633564383761346163383166326562313464316362613531633565646365326538356565386630
33616461643539636165383233636533626230343138663630323731626139393230383464313430 35396334353834343733643931376261336435373362386339353138386435663961623064343438
61333438393337316239376435313337313437333931623238616133666138363235386533633437 34633639383037653437653461346563373733663536313862386262393933623434313163313136
35356163363231656536353934643539643562343732626630383565623730626533313230656164 64333531656462353966353362646636353634646433333361633166653338373938326639343732
35333735666135343364663233626163363930383262363266303265303638396239636361366534 30313163613532633163663965393434393735383730653430663762336262393564303263643935
31333863333565356135613232393165353266343632633532343061663331633337343538376265 32313564393835353762363662373631643430393938643265393462643864633465643636306664
39316630613439356262396634316361356436336634396337353339616536356336653930613966 32363936303737363934373536383136613636323538363565306563653438343961373566363463
34656439653366363562636639346430623561303463356337363830373966366632303337663564 34323338636161316537383036643638366430303964376435356164333666313833303264666464
35663632313265323365636238303364366230353039353561616636633664643233343430336237 64383766356630313130626235663364653730666638363336353938323735356434336539383132
63373264643935616331616632633065366638363833306337633563653065363464343137623533 61633334383661613039373136613735306662633734346538663838653132343066313166333834
36373231363739373335346464623533393336613634333636613937366136326464336332346166 33386533306466613037383636636432363435616266373664636631623264396538626366616164
61376263623835646163353134643963663964373732313833346163323138633230393537636664 63363831616562653034666231386365666639343330303964313165616462623865663266326531
30366234303334656130336630346130656237306161376566336534653630616439323764373665 38306337376133613932656631393532613431323964643532386234303335643731373265643536
61383338326163336164353265326163646165623235626137623237306666333832306461613630 39336233363530313533663163616462333466656364666262313132636633613863343766656465
66373331356465346261643466323662393661623433383265376666623932343861323139383531 38393339386335623234646437393061366230643432636163306432633365356330306234396530
64633536373362643935633734366235396433333237306166646164363930613862613365303663 37346466333961383538356236303630353131376330613437656536393766383062323235643465
38343833336137353634313362666665306666393635663633353934363832343739616331386130 38663034393633383661363632386431343364333664396530323066306365366539366336333265
66336561633039326434313833303465366638303961626138333165623331386230616130626639 34343865633834333564343237336661613437363333373032393661633034643161623461306362
34613962366230333065633761333335613636363533656461626632343631666563383738623330 33613663613966636235333664313731333064613033613639656232626365366461373535376532
30333834346233653938633330663166616331376436356533366461336264643264336139343262 37613365393237333037653730323432386162616165663335653565666565656438373337376232
35393665656230663232366133393037643536366234343537326631623332373131323739363638 35663933646636336535653431613337313562386137363137616561346435636562656337653265
39653162646366316639313631393631666261623230313538613666393732626438393763646330 62396430663033373234366535633838306335306263616561323438343736326664653436663432
36346661313131313630343432616365666633353762623261613039623331396330623939626132 61396237383030316436306130663039623963343362326465333933373061643165353465383761
34626333386538326434356432623965666662663437646237373537326534653634346239653634 66336433356538313761616663363438316165653763306431353139656361346631336538343936
31373038303639333037613637393862356263323066666630313262366633313932396465633337 37643363363662626362643333613431363032323938396365373337643235353333393232373862
66653930303934616236323064613761353935613835356561313334323762633064306661346666 38316664383838623836343938663231623237306566353036633265316361636565366134646562
37653262343865386236343634316336386630393739626437333065323433613531393738313432 61383161313664633736613463393035363637633836636634363231326230613437663438343362
31376233353463373237653164386363633334366332356538343966663939656165323465333030 64313362653131316433303561366536373436383865333138376663353035343731363934633537
39656532363363333432626638626438396539336461326338353732376235316133616666316261 37663832316265356535643561623435336563663362663766306662326564383865373565333134
37353063343366376433653961333233306461303133376661303332386230346231383837396133 37343035363763373236656239376665663536366131306439643331663731656234386432653665
37323137343066383966343535633363643233663530613566313330336232366638396165373631 30393632653939393564363634373334623865626231653639356633653136363630306539356361
30626531363033313833303836366434613736396339643032663066333865306535323739666162 34396238393339616333393734633835626463353932616262383263336439366262303966333666
64626133616433653864376662623464343131303938303237316264393765303035663833376464 39666536363537633030393730343037623265643438313166333064303635663430323435383438
32663264383236303766323935306463643138396237373338653238633464616238306132633735 31313364336662363365303262653566303830633930666639656234373037366238643431626562
31626538653262326533326266336633623532623935383266373533363466313033393235663538 31373137663434366564373833656233636165636636653738383035393936343063303761353265
66653038646233303665343634383666343363383238326533366136363838303332323230316662 66393938643238393338343930363233326463643237633137393566316438393266613731346162
35383235646638653539633961663036663933306463626335356631646662636230356261363261 39393630316164613139393436316533333632366638663437366266323837633937613539643434
65633261353830373865636630353932323937666331353635373736376436333361613330366633 38316132306333633666633433623565323463636664336536616266306262653064363931316165
30663939356165393132636131663966373433623063356265353131306532643066306630656363 34383233343964326363363235343165653339653162326161323932373631353665353164653731
66636636353262633437663264613266613663656137386231306231646264363661613035343538 34333665313065306439393431336632356536646334376562646232353938313836313735616238
37353633643065643236376537336238663137623735613038623766393231643131653436333262 63623765313564353463653133333536343937663165396463303531616365616363383938313939
31626463646432613563393665346532386161366435396364663239386236616233356131323536 37373530363830386338393661306261616432363737303236613232616232643666653464613937
33633936623762666534633862363466353736386137636363633733623366346337613365636439 35313631616166386330653730653062646234333432336135316235356166343139316538353337
66663035313430386464623833646135333062313830396637323961386135363461326539623432 38663265633166383563623561656632386364663365326662323036316436386639663761323861
32653865623530313637393561343465636430373162333162646631643235653931333830326266 33313937343632626561663233323862636335373930366233326163623831363765643236666463
36376631316165343631326165623838306239623764363262376634663236393933343838376663 65386234646363313039646266636435366462383266396337383439623437306166663536363966
39663834306165313330393739363133396436376437643232346336386531356638343063376465 34333762366161373531633036356139646338363031366561363863393830353336343337363664
61613037623137306666383231376539656361326132396662613061376134376266633764336266 32366263343464663338306562636664333035616165363538333764616139663063326232326664
64336334313335643635303632666431383637306334376462643630646339396435313830313363 63623264303965353632303230653636313162373061396436643064353632323731633435623232
33383162316261663035393962306234613865613366353465373035656434366261383133653331 63313263616339613065313138613032626635646236353462306263376638643966313332393939
63643235616362663663343330303765363263366130393837613939323264373937333162636639 36643637653261663830313564316364643832383330353165613666333733646138633432633763
31643438666338646135663538343231643235646364623761653064633566656663383465626133 61633431396432313863343466306266666133646266336536646635666631613633346430633539
31373935646266303565303539376162623132316438623565316537306337636630313861623937 63313964353937386636626532303532353133366237383566393061326265316163613665326131
34313832636533623033616139373965303839356530353935643363613464356364343162336466 38613232366132313762393436646265326361323736643338623035323336326539616432386630
66376130653162666661313139613530306666633432346639656466653364376435636461626362 65346333643936343835376364626539303265313239376338653835343232646362656366646264
32653633303561346233643463373534653434323134353434373839373937626663336464303866 61626463363665363737323331356361616364626565333561303836313966323461663438333933
33363264623038313835396231373132396163363662626264346461333539326365326165323066 65643533346362626137363762363663383761353364363164393062383830383931653262383564
62333139383334333334353031616430323339623066363232313937323465356266323934313761 35333532646661623731663938656339323739663964666238653963323232666664313237363137
66623835653961303830383030643537393130653935313265333062393034336562633535323263 38626438313265336263626365643763393532613831343763393130343766663335343431613038
65616237373232336534393834653162363461336262653862666637326266663966356665363036 31393938303162346236303635306239613335656338643362376539346335373130323935393133
35383437326465663635303664643236633435303862633965346133376536316233386333313634 62356533393363626139323632663865373837306337393433353963396231643937373762653837
33643565326633386565653961646463383866646636303537643436623734393234633938333933 38306331656262343832333136333536383035323532383766383538653831386531353936363936
65366164633165623333623362393639656661326332306538663738356364373734316563653038 66623762663266663135373165303233646262653465633930373832383364386263383831366461
34646531616662386232613034366332656262343164333531353037363036646262623663666236 32643533386438613533326566336538633632313331396535326562336263646334336362346634
38613238666136363431623664633863636365396236666532383930336636353031396232656435 30366562623663313638356233633433306564303065316337323862663533366366353230373434
61346238643431653231623861373964383931336535363262373437353532393165316562386134 64656437626135633932616230333339373934613666383565626535663939313930373230313130
36363263666135646237383666373833373737396330616163376439663736663937666161313831 39333434643834356634383133383533396264663831383736326162323431393464613736616539
63663531656635663339306365656663636633343733636165386230376332616331313638386538 38326533623734353937373135386664613635636264383130346263326462303531316232386131
32386466323232363533613334333333346161376430373436373961316564343061326164306138 30663833633738363533333662376461313331636230346538313764633135303331613039316363
33616263666262323430303730626266396535626439623364376239346564323730323534323938 34623937386336646339373366663563626430343866303239363661313634636333616232336438
33346364393033353865393864326361643734353234613563393138363334383536396535393166 66616132363337393334646333623737336633303331393030326561613835613164303965393932
34623163616336653436393639313965353237633566313039303137326234383230323235363234 65313665303932373331656434353634343561353236643932623464393566333037386334363066
37626161356166356365366164363863636563316332393638616535376466343537373966643839 39373163313838663163323666636337663161343664646661616438393333363365363065353732
32613930643533336264626136626465303339376632323034386161663661376466616233633065 61666666666563626432383639393038633233663561303561353238613832373633653436316366
66313739346162363838346663623266383130383736656334323430623463666439386532643630 65333030623565336266646563333336373339616231366334316336666463633964323630373634
33666639613830386136363535363830333234653961663739343537306634616531616263623762 34346131626466316163366362326265613363333933336463623961306637353639346537316336
64666230373830636238353062666330623061613663376638343763626264363130313464383661 63383561353339393963396339663738333765663934383038313631363733356562373935333765
38326530333362616163363735323861376366333665623536383566653837306131623732373639 38663735636562336564636235306135666530303762623331663931366163363734316539333734
31316661353332633630326162663738636562336666326637353764323431613666303038373532 33303638643166303165653062396231613862313165373564626432313961643835383038316437
62343661336338306561356235396636343130633365303466613637633363613862663233633731 35653639656362633631336635363035633635623035653439353561343739626637656566333065
66623530353132666261316637303763363830623734346262333633646238613131346564303734 61626536366535376237366664646534383534323331633035326632656132646364363133636133
62336434353432326239333232383833633962313537626430663130393733623162626131656366 65643065383337356136633534383033396563353034383462386230623261636337303537646463
64333535623138326239336165666562376663663334323036323539653734333835386331653438 38356538356434393864303563336563343566306638353138656561663930316230353433386362
63353861666239396437346361306634613462386335376137333963333838616138633730393865 61643561383739366362666633616365343235393363623035316263313738633662323961613931
62353539376136316564666136646639363635663736636439393462633165646632623664383663 35393234373730616133316462363661663866323466333839656462636133613936643137653833
31613137306461616361323832393036323933626531363536336261356636303531633239333362 62333030343861356365616339363134353463656162616636303430356136363638313861313637
32663134363263383039646162643539663737333861386437326337616362343963373532346238 37613731663639633865353332653835316631663364646139623631613138633162366432333532
37346137363933623839373838353939386630303461346438666534616434333031373730393537 30306432653133333637623631626165633537623265313237323532303566383334633337306564
30313134643963623564356266656430613430626238613266316335336265613132616562626261 31643163633934633161316663336334316237353735313063376436393166313033313665366265
35386435313933626634616463616166646466363939313639646264346464363337656339323366 35363864386265386139353735663238366663396366653164613833383336653230383763316134
36366665363739356564363232313762323565323134616134666337336534353464373637373130 37386633376538366633343963303339323338303263343862396631613733336261356536396264
63613265366436313131356332316531633732356461383064383031613337343363646432373936 37623236383565323331306531316563356238316237636631613736316437643965613634666437
33633339386632653032663837346130623636356464326637303338376132623734333932396232 32366661346466353261336130363233613036633863303161303931346462633763353038363136
61333033386265356630316134383066343164613130666664643732643362666561346132656266 36363762353231373933616265643039666237333334326134396531643463336238303862313465
31623633333039633837383264363937623435643061393935393762346430396335373864633634 62346431336663633661303034376165643539323061653363613635343761326363616139313933
33336136353332663366313334353739303539633364663231636539333132303966383432376262 64613430353539376131653439346337353733663830306333373966393162366235393364336532
61356563323232613433653262623663336634626532653465306638316633663564633862666666 38613936303965646231323138643464636133393037363538373562396230363434386230363934
30366133616336326661626238653933383164336366333438626235636631336165386664343736 30656361323137326335316536663231376536646565643363323462363165633139346535313733
62663961346664656333306435323833366632346366356238653731653937626333653630623334 61666434643537363135613862656462653732306665623037363638613832323530653836643137
64326662346138386433333232643262333835326263343239353264373038613634356436396630 37323233316131343039643137333532653165316137303032373635346539656635363265396565
38323931643361663238623766323930666130356339363564366661663033303831363138343737 34663263396265383532306264376430313237616438383639313863663664393066643565623636
66633535326131396236653261303836613364306537633637323031663166316338323533323731 30376136373833653462646561396238356638336432376630306465333133623538636333623961
30326235323066396663613531653061643661336631613835626266626436386662353465383065 33316263373862643166623030316239383433313365313439386534336532653436633034643938
64396562343966303362636136616438353661626466636635323961613438646634336563636534 34613737333933303934653635636239363332376436303063643566663536353266303363626539
38343566396530643961356434643933636235643561353232643062303232323437666261363061 64393737383037383336373663383163303538343438306361656164356563653236383563633735
62636530396466303466653333633930376465366561376363316137323263333561343334383364 61376362303866646130666234616362323830643238353433613831353635373636306363623262
39313863643062643766396564363137386231373136346138396162376264653538303464633161 65393266313430653133316639326330383936303566613534316366613637623465643739323234
61663363623937356138356430666461623130323466623162653863393736326264393836336637 36646138353135666265646137653138656262303864653839333839633336643466343066653865
34663931646566333535666664653237643732316663323230383239393763376266356135326438 31323538353566663638316136653438353835613137653364393032383935396134333030623831
35656266353865623663373366373830613361373664346632363031356265313364623866643438 66333431326362303038343032316436356237373932316531313437613762636238663364356535
61363866353934636337616239633330623734666138396166313864333939663563636138653930 30393931613231303936366436316438396535303561393538633465623064623039386430393230
35653137363033326432373661613434313137623163356134613265393238346438306165313639 31336630663839336364336332623165613763303331383830663161613966623530323266396531
36666662393165353565633531663536613037623230373063316639663632643139353235303462 39393931363365366334643231363732646631626533653636373364393463333661653833653964
31393263656265656131613164363035343233626433656135353331613532363236616439363731 35656361623463396664306134313365333664616436313761313837363038343632373032356163
62666432356363323937666435326638323437346136366131613636653430306131623966356263 66653030393665626264663761313266383362393565633864636436326238343836383066363438
39303739306233303862636535633431363630393432613663633836396566653039383735303336 66656164373936663732333565313466366631336464316335663361663563363765333938653437
62623331623034636363636661653236386337326666656532343737336262336462613762326531 63346563356234333936666437396462613262633834333938343635613761396632633462633837
34303066303834386366636430343161653665343362363038396562626133636135656538306435 64303563376431643930343366626336656238616162366566613166623637656533653263383832
36393364333066346238396362663664643236373532336263656233386663323663623137343462 66643465626636303136383235383739393265313964373238393563613438346132383566353061
64386337333130316434663564613665666238623132343437656637653035373738313735366630 39393334643839613538343966383736646664373363373234376639636637326639383734313562
66383639616166393265616434623463326437313530326130376339313662303836636664366232 32306163326563366534303862386638643534636162353235626438373237343135376339663164
32366634633030333130316435616233396231663937343732313066373834326464623139363663 37313836313863646539396231353864623733396163636136313864613030326534663430633062
31323931626364303230666162316436653065366137663631376265383063316534343736373261 62373238313435346637623334623631343339323131643363383863323530333236313632653364
31613637316235386539343766323439653062633137663730343236343661346162653366656332 38346165396336623838623630336264313565396436646536393237646163333338346338653438
32663932313063383561636266373766633535656131386133386135663863396261306530326632 34396537373535616339626164356635333837623234316630373333646333343034376336383739
63653936626236316539613262386231616433393064323461626536363831666461316131383837 62346366383134626162376134643738623965653963303334353936613665313061653661616438
30646266646266393666396362326238613231303335336532303836363264323233343534636635 37643032393033313265396536353433323735613766326164323461623238316335623131343932
66313538643033343262373463363866346566353263303966323933383963363463393761383865 31313763303235366436613939366634646434326233303637653235323539626264306238353733
64663932343830643531643466303438343161396133666463353762393737613036646166333265 63383666636464313064623736316466323539376365376134663763343530333839313165643432
66376231613232666164663964636134653061633330383863373836306366393838393235656331 36663238333231306134313866303332623134323631646330373563656431363537323861353632
35613231306263373230326634623262326333356263353961633836396531633431383163633361 30306334366131633865633366353666306238326537633461323965303437656538643136386262
30356534666466653734333437383964346564346165326664633738653338313263633837316531 61386162626633343266666338363563663965643761636462663333373539396634313766323139
36613034323433643839333264323864613033313137663131623265643364333664646235666232 65653564333531373561393239343036616238363162633462353962393336396530383430353265
33393039313666323266643362323337316465306564303230646561303434666630616137633831 38373539613339313964356166666630376130313830356137326465653166633135346262613839
34346439616634343337306636643733316464376631616266376437636439396337306637333432 34623038396635373936333535343330623338663235653137393335346536343331383034666139
39306333363035393436316434656436353738303861633933376531383862316466373736323639 61333965386132666439616639623165663034383633303136393730396232336139306664663233
35336137373866336631386436646231653366366435363932376434303063613961353261343661 61613731646538633265323439666535346636333531326230366238653331303830646131343430
33383638393165336438376662306431333837356435626137356130323836396335636166306662 61383735386361356262326632663666383162316232393338363633323463613732623064623232
36386161353739353637353861306666383966323339303262616239633930373633323937356632 39623231643734363937623134336632623562356531333635633031636536383362343634373461
65383032613031666665623631613430666662656336663931646533636230303261646530623765 35633665383130636336363364616466346462663962376164336331663165316661393934396136
64643939326435643539373564336531623236653731636636346361363064333963376566616530 36623836636438373761376338303534353832653433396266376638636231616631356262323736
62326639663632666634326233363635383830643163373938646165656163643864336436373466 36383466643661363062373062663039333566633763316334626334373663633062376335663461
36353832306632386230373832333234643638313238626333303963383962343265366137656136 61316564303432333730333638303566313132646636643639626131343165663063333838333635
37333261343161633562346638323632616566646162633133663466346535656463393932386135 36663139643730333630633766383132396636666632366164346537363732353830613939663262
62653332313066363965386335356430326539316366633537356364666230326237306236393563 66623433633434646432313138643639366265396333626564623562323865616261663064363630
35323363633936353034323232353366373566666332323737653237323135646665626139393436 66313134356536646633366262356331336134366265316166633831653434646232333730356661
65333762653536656161386532363765336538653763666236343166653933626666633130393033 66663431653363666639333632366261613764393665393535373934616433653834646361373438
31643531646633623663313237353333313136663863663430306131316165663765653732663164 36363164363932663463623161373736353233393934633537613932333732376436623031303962
36326531626365326330643064336230313466343731376437316563303339336333326636633066 30643830353665616636663066306536336236373064376530306135646234326131613130306433
61386135626430616661313236623030316362373338643233326365646531633265626238383830 62653463306332346530336130623633393636393762633230323063646237626130353335336563
65346132643537366537626132666165616138656139626132396639376230333262643766386363 65646538313634333535333564663337623233613238303638616263383764653230343661343065
39383034663034326165643636613237623234613666333532383733623462303331326238636461 30626533643465333733366430316162316134323431643166316631623965323334316263396131
34383139383466356139333934353837613964326538336463643832623062633034613762363061 64303932663464383131386239336166353363333664353031646462363862663165376565356562
61346361656363366136353336326433326266336564316366393565626262303637316564356566 65346539633036343031396662623233613739656633653430663737613662303331396230353038
39376661383763373436306238393666653561306538333638306233356139346634653363346164 62333333383462653138653162626333326466653631643634636135663232393437343332333964
31303835383062376638626266303237323832623735653066353936376339633637333562333561 38623062656633313762353438313839373935373964393938396536633233613161666166626139
36646462653834316131323166366661386161646538346464386232306239363030366363633663 66323363323039323666303934373631343737313866343665643131386431373336333630346261
65306439616339626635326531636435356134376561303235393337373564373937623636643432 65646666303633623166643635326165303838646532393663666266303536343634633533346664
61393535643038626562366331353831663338333838383066323632383633346564396566653330 31386264656165376264653266663332316536303665323239613630386233373565353763323739
34386563393832313537623061666466366661333934613766366165366330353835323637643635 31373232623735366563303731626163356537393034313962653263333831363632343561316337
38613363396663356564646132613536653033616337386566623662333832383938303138316662 33663430333836633061643566303638316133323361343931303331353234613065366133363931
34303339323166383863363831636233323335313565393933636435396666313337663037323432 33316564313939383137653330353935396532386563623939363336383537386639376262376561
63333139333165646262666339343736383966346133356138326437386334626461636530336432 31323837346166396332373735316230653531343166393539386364646461376265373564636637
61356631366163366561303636333230643732316261376365386463333565623533663966336365 65663430333564393861343261316562386564393836303834633638346638383633353164303438
39333365393766306536366231366435363030353263393534653534373064636361333532323735 34343463353538663431306332636166623066376135323261613735393837363666666465323866
61313163323831653362356333386638343566356261353534303738613730373632363534666337 63356530346532656131383835363133353938636630333231353964373765636162613062363937
62313339613138646361356431616236613435393233343732626263653332663265393934616134 32393461356530383531363131326164356562383762643832353037393931316666353331396437
35313165613766643938393839373261633439396661623961353934373130623865353639633038 37616230383338613931646135306539636335636238373065346236333966393631346266343737
34353631346433663131653965326337663561613330323562666336656237633163356562323931 37383135613133383763306531326436323233393231376165633538653036393833373538653030
62393833663233333538383063303937386365306135343962623333663435663431396438666362 61336331313430326638373138376661633731306165653661313766663134383163653731393934
61386266353838653532393233353939363738306634666537313761313835333864633764666262 63643438353663646539623935356635303236616231646431356262383233643333393531376534
30333032623766633334383031636633636539336237613235376466356430663938653565626235 65663437633761646564333366626335336534626362353964316236643066306439366565373735
64373537656566306136633630366130363630633462656330623633393735386630343437336436 30373965366137643363653533366430303965636631383664303239623938616135303030373962
65343635366531646130616534623136636666323139326462306533653532643962656530336633 65346635663562306566303236663563623036366465353739626564393839643530353033323737
35616537303932343539336638333730663639396330653761346136363431346536666138336462 66643665303132346566333866663736376637393966366263636462396637663461646434376566
66396565613166623934316532383835316137303134363466306163356233356530323231666464 61353464313337333938303866663036646566623032393036646431343739646436356632353361
30353933306530323734306564626234343864373964333264353366326265316333343330356532 39366262376235663139666631643734376461613862613838653062313466663266646139323831
38363837646635633461653562303264353633343461633339376665616331613733666663353130 37313961383638353139353635633133636561306361323065323733633532333235323031653930
65396363613731366234326466323738663563646166653237613364323734616465643764633537 39306534343635646135646337353161303737396266343135363938383664346338373730366531
35373865353532383566363632366564353536643739663761303565333138383638653665663664 32396533323264666433616330373232623330383762323263333432383066313838663931333838
65643366316461613630366437623736353739356538336237613431306363663234373265623962 30636230333235653238356633353030663136343462353530643531376234336161656361633438
34653565373335653563356135313835643266356261623037336536613733323733363933376538 64336361636437636236373532346131393431373537633663396534363764653563393064313665
36626134396563623733656534363331626262643339633932373035626134343531623634666463 36616266333938643061613361653532356133663431333435343634373564656531383035353632
61623036313334616639633930393562663631653565656136666537393731333430663062643362 38383863616133363937633937643062623831356232346238326236313536393738346263313530
63633663396562343965313261373965356163393538666466303661363531393266316462626166 35303865666530393330343936356131326164653765623031323535363266393265333232333464
38616536653665366462383064373766396438616665346666376232653031323566313164383164 36356536613264353962323665333238666638373637306431343566656532383735363863313366
36643231646439663637333165376439333432383532316661333766363136636236326338386537 61666465316437383934653534363766356635666364383036366664356534333465366530633365
33306130353634346136356234363438383865313136393839663066333935623565333730613538 62353134313939373666623966666335663031616563373931386564383039613833323434326238
63323831663866363831383930303434333936646564316435303931396362303534386335343330 66373132323762653031363763353831353336396137376364383138366264396134343262306133
65383635346662626363626365666166636361633365643735303762393832316436646139303835 34393865653666663035633637373661366463303335353031373431386161616437353161326638
63633733656361643233323332613632653837663262306661626438316262653931333061336366 32633339356434633363313030636464376535383534346436663634396636376630613634653635
35353939353835333361623261613738383734656132613139393264393038373765343131333330 61383734313234396666303136393633363536326265643863656562356239326536353632363764
37663962313566366463623437323965326365623437363038633661313461383634626661666236 32383663306139373532643037303365346336643331336331663630333136323437663930333366
32633335323861643037383261393164393933353531636134323765353962633732396230636331 34366162356435316436313663633936323931383832373465323537653436623237323666333737
32613865373739366530303538313566346434633933393330346637346136373036306666336164 35303962396266336633623563663064613934613837356563636330323431346334326234663433
35373732346334353432616561623031663331346431383235306537386466623339356366663335 31313665653836616631653661626162613032313737373939323232386635393533363962373966
36333733626433653465336431666530626665373564336339626163633131353330656437643638 61396639366131323131623436633439363234333032636137646230656463306265333334393736
31616563616665343635356231633135663665326131636664373338323736393364353636613762 32323262643066306631316336356461633764646664653339626162366366353566373565653635
66636135633766323866376235633535613735613465303239343036663438333331626431623435 63363438396264666565383164363639303637646330333064363762393731613133633934666131
61323537386434323638666537643236623632626430666263376534643336613635663762643736 65323932373235373863623434373439343661396137656564396663653336396633393331663032
30323237353265613062373265643562373637383337326264653639306263373865333262376665 65363432373338313137356666623338303064393232636535356262383038323166393235653338
62646331373931373762303461366163393839633135393964313937616437323865653735383630 63613536626365666437376638633665626531366330313230663135353538303232356366326161
32653936336534666565373437666130396265363561333635316461663766346336623865376133 64303863643030316564643262303664653463653463393461313538343464313638653461643966
35373665303433326265623531613038636166643130616637653165376263643634376439613765 61653733343339613864666665373033353062363165376530323362663763306132336230396533
35363031373630333966656466616235616337306335363132386335613462363664653634633864 64373762626263626638643763336635616364323561633536326165326466393736333836376462
61303635653932353730663666386263633662633736313461643932386161313762663761313336 37303365396539376538663866633761313136326563623365373739373439393630376262373138
62653665313033656537643936373465633932626166366430643763313030393838393039323230 35663064636561626434313035346131633530326431653664373036356464313435333333386438
62633334383938343433306262393536653930653030393033306661666264313630643564333166 34396166643435663734363434316436386637643836313438333731323261303737653932666664
35383237633932656331653030363434313534613637373465663264643061303538653666653861 62313761383832373235353239313830326635633430313762656265313838623163366637633736
35373936643037333866636131373338363062663035323531626431633362663364396365353139 30316230353338626561393763663932663666613331653834346131343434663232623434353838
66383737666437353764333231303662393630643933376161366430376530613365363830373534 66396262383937353530366163313532343731353338633065393632353264343136386538616334
38376633333936626430393163323830346166643537326430616236393733653761363235356363 31383365666534303564616364343464313233643932326539313738386131646535316638396133
35333131663032383861336262653936376565646662313965303265623763613330653461333835 35636339396339323765336137626438643631653963333362356438663531626634633336303733
63613563323135633438383931343731656333303362316533376339376636623037376431336366 34626138336238323063623536333837336666613630626462636661353962303330323134326231
61393236383364356162633062666265653534326363363862666539623761623065386537616563 66396665383033386634373766383461616334383130356337323763353130653732333136303166
62666561316437303763376635346536666437373361386666643139643737663333323933613661 32303364616239616439336639633233666230396334666531643464653433333532313737363835
38326566663932333930616435626133616531306461356466326437623235613233393434626563 63623737383830646334643039643739636235333963643163336464373133333731633765393764
34373966633834373430386132353163366465626262353863353335323830393266393562393133 66616236346430323464393739326364333735663937643737356631393766623734636262336266
65383632653438646435343333386261653066613663623232373564666465613136353039313036 31376535613266613362313363666262383837663533316636323262316364633461656262363762
61646462626433646330396664363938376530376438646262343231393262383733636233636333 39613635343165326164646636343431623230353561363566666636656237373363646230363435
35633862336566636439653464613564333162613836343636316334316665383164353131373431 64343435633039336337313562353638363664373137396366303839303766316230323861396331
63623030306564346562346237333934616134346536303365396533626262333937396432393830 36346230353163336538303961626261383031366330613636323263346139356339616537393236
38313763393463646437666137353835373735646365373934363936346564326362376565353133 30306537663861323931323536373833643330313338363435383461383330656266613439333262
36653362333432326133393837316331666663663263396461363239306239363733633137396633 39643930313537386538323436636134643639656265336137666663666637363135643137333063
38393865613431653337313665313762653635656531353465623436343132303064303564393066 63346133313066643566643432356537383532326163356437386363353965303737656261656132
63616639353962366666616261393766643364333634346630616436376565313236316539633537 63386338643565383033353832663634633736323432336561383763356239303936333038396639
66653239636561393433383639646462616433653166613130373134376535633937353366383230 33616364346165323962313033666335323233343462353434663832386561376339666366393161
61613335663434333835653236343633633038346335333861356637353965396632393833646635 62656532636238613264643833336264616634623236643534363565303531656131646263613830
36653034356234663831333764303338663464316362646339376338393236336161616263363538 30323161623864386130316431313435383637316337333032633035303564636534326634646565
63356631613239326161343031643936623366643432663732346438333265666535623664396333 32333465303061626536303231386434363033636665366339376432376432383134353938663331
62303239363339626566613439396234303536333333653433393666383635643235376165666234 31323633386631383038376438313761323839646139616564383734366534376134366330336166
30336236393962666335353233666463346530323531316438373130303933383465316638646461 32323430663335653335376637313065383731383936663765303664356661636366616136656535
62643066376666363236333231386237376466633932313836323163363061313333633434663763 65346637313766326233376232646137313839616339636161613066653133613230623838326662
64323365353336333736363436376232653436633739613437343538633632356665656364616637 62313037393231643966353638316164333563343264616563633365333437356133373336386561
62313666346436656564663335636635393632353430666236313863613464626434323939383538 65323665356463326537326239396339613839306131656538323066656134333833656539623462
65386265343434303632313739353239323565333734656566356164643430613538333234383566 61386263646434636236643534636464363731656566383831656635336437363962306263633233
37646431316363316139646435333732313339623666613738663039613239613738393565333330 36646263636638396461313138306563656565613336356132393532626161343863633239636438
33376432373933656435303737653762666464363865633831373330393435633332636261336139 30393663323562623764663638646431336265303764356233323566613934306132333735623962
34336236373535636165353262323966363164633135613534353661316364616637663465363864 36626462343039313765333535633835303664393664366635393264373139346639636537313038
39306163346365643339643937396165666366663339336438373031613937636464383531613962 30383133376361303336363239653931653436663630666665383161333138346239616338313338
34373433663533626665656364623634373335313033646165303764396563356235343033383138 33343138666263623534396239633462383936646564313833373435346432323532336466373937
62326361353630393938643764616636313461633734646661386536356235656665393864386465 64386537653332646439643764643563393738323565346630616432316164663161313337366531
37666262346561656436343036646330363664306135333464663265306165353039396665336664 38396564386635323466386339613765326638333532623639303136656331633038336539393062
62346631366330653762646538323565613864383534636532633033643533323736373931643130 66613035306632373562666466306336353333333039346134383263313834666434313237376530
32343435333333613734626234363132373734353035326232366264336161383631353133663230 64373539383166363864366531323333323565366465383937333732613437366264396532616433
32636533333866343763336439373336356237303636376334333433376630353338333261333037 37623438653438656162626666343033346638636564653061396562343564626535616233353334
31666537363666653238383939663464346662636133346561326335346163363061393830616237 63373565383039383737333534663637666231323132313162373436666362383963303162373835
36643430626534653331653665316535303139343763663965363164636238366533303038653935 62656434313463336462643764333735653166306265636638326131613736393230326634333135
32356432316633373137316237663331336463363431393033323635646564366639346230353363 36666633626235343630306637386164313562323439663037386166303430393065386238383434
38346663363039363962323137383366303862356530353238656563306131643236626536656334 35396664336331336561633232333934343832626230343935393433326430653632393562366532
38303462306562366532346163323061393437353063326539393466616439346564383036303235 37346235343762336430653733353235643831633230646135333761613832336234646262623865
35633731393661323962633631373061303930323638326565636162316436646337383266626561 34386230623338613764303536343533623432336332396665626131363364643465666639636664
32326430363031396530396238353862333133363731623736376239626561626165663337373261 32376461393532313839393766623463386537643733616161313330623739626533643562303262
39353461343461643238646635633562653865323336366634613264616662323232653861663038 37356339353836303235343762393838343363623331646464646163363363663638626433636230
64396330626633303031333334343335393039623135353266383561313231643433393963326637 38323038363931353830633737383865353934373238313634613961373566336639326131653335
39313530636361373831306234383166346266656261663830636631333564356536323565336266 32623238313339396261333531373437336266363964386237366663336164323566313864396337
38306561376366626236306633613564386166616630613032633163613837313462343662653261 35636566616462353565383731386236356230303333336431666633613264373331663430346434
63353437663436303634633336636532646439636465663362346138313665336334313039613631 36663166363430373534346236363332346562653463373261346331653032613436616637623936
65326135383831613531323265353831313562346161663265366434623236636635333038366536 63393663666230663835623835633936656538323739356231353563623939383463353330326464
33646631663662393331323162343438626666366636613438383665633136326439376166373462 38373531653537376637633436616335633866656163303937636438643162653231393963356537
33363864613136643461663436396362643066633437376631623031613366656238396165313832 63306630313635363637656665373337363232336135346230643765376631343734303766633764
31313131653263666334393664343239306235373862313339373563643137393633343663613936 66356236643338356631373832323863353866336333316333626232396463303466643434646232
61356564636238363136623031336638333566633766636362303938653531306131396665303033 65623838353539353962346661383865626532613930363330326334316565343330343538636434
63353362636463636236643464343562383161343432383766396330623764393837613435396162 33626433623361313430313361323966353732303035626366323835383631343634316634623562
31303162356437663932663964663239623764666366663061313535346438373334636263653531 39313166376365373638303338326263616566643566636361633332373862623037336163636366
34643133356638653031373036343162653135663734623035353033633561366266623566383233 31626365663862303563626130393266316237643266383139643864613134373734663238633639
36356434643538643430383532393762333535636639353361353763333363313131646264336332 33613761383431383463383037313139393833633761633635356538313635383166386639326362
34373331343930633962623963666365306132356334646636626461316236343839383266363635 36346662626139353837376565633633363964343665356632646232393433326437353532613365
65623434336239313330343437646333353362303232346638623161616133636636626236643465 34323666633131653233393062366637336630303662306634363262666439353964653436363534
36303636363965363765656533386534633839346363363738386532386531326538643134363132 64656238363036343164656434343262393936306230333639393932663233363062666264656361
66613235373362633166343565323766306335336365333439323764623964393263623236623832 64363233303636646138323662623532333063646339306635633039653931333865356535396665
34346132383136303038363764333039626234616132386464666633663536656230666133363533 38346362366165336339646162623739653433353966613762373534306634643530316538356433
38306665643361666539636666316432623430623939663636343164386438313765633031313534 65393034366562663466313432396431636462383239326438643437316361343561393234306338
65393633323837326166343936326263343833646331326464376138633461613532303135393036 37333664666132343263343763333137316564363963306663363265376263393964386630646636
65353830373065393038343039323937303634346665393135383639303162396565646232663736 66316334323439663630373131643330636266633032376236383564643138356337353566363966
39376434646634353330383933303164653431373433346335666131386165343035303964626665 31323662613461353336396637366636366336353763666264626431653935663332363930303633
35383035653631346638326637326235393833623264323030373238646335346332353362393230 64643862393963613936323838653661646464353662613430336535363833663161323764303036
61616664613562383639306564376661306665396138613066326631616531623132633966633832 34396130633438323566366566303932363937653536653638303931646535353239393738323337
65363637376264336635633132633332373634383864626564623966356464373864393832323738 31316237356531373034646565653138396536326339376135613935366130333061636135613164
37616338646633326636323461376137663632376262363738303336616463326238333465343533 62386134626234623734333339393763393536623964343032316666366632633863643839353530
31316132386530326539 63366631646535323133353235613338383864613265613562653861653138333066396134653833
30313237646466333139323037376564646161613131393232333233633863396536363038396165
66643163386337333666626261336537353461666331353162346330616332616330653936616439
62356635323036323363653566313764396566346533646437323932366134303830356331303730
63363933396337653239343163366236386162333032356465326237623563386234656339643536
38393636376536616638303637393537393465656563353232623435323730353561303830363434
64346532303834363338643838346163313963303638346239373936303335373039323636343534
62623563343361613166633465373966343534633864303564393766656465393964396237353464
30666532393332323162313464663931356335333263643332373431343036636536316435303765
66633161653136653038366162353538623563653237666131383163333636373035643062626236
36303837376161363762353964363239656565396666356462633864343833373237303362316161
35323931616535633166646163616535363863616237363238316264363733636230323864363530
32353533666339656464303234366562616332633434393736663631656434373730313165313062
63373939313332346235386566613537343339363365623836313835343165386133643837336161
31666139393664363765646233323965653831333435376565653132313861613565393833613463
34623338313765386331643235303361363033393438353432383963663133306465613530323434
64306364326261303433623062313261613532303333656136363536353066313630363633353938
63623566613262646531616332376333633934666337376638616231653939646661316264376531
37346436323339633839393665383735653465313538366262366538623462306439363730386666
66383736623636333139346434663933303766333130323333383536316333363165633762336235
34613638626161326165346662613433316164323533376265353261303535336431323065623964
32636265653736363763316430636133376539376361616631643965353732656336333835356536
38373933623063316135353762663735376533663065336233363133613833373464633365346339
36613530663735663735353264373966643134306464343431633963366337616239623135663361
61303532323564306266656537636264663739356235393535633139646131346662353437306138
63313239623832376362353632326563633562663265623534643032306562343535613035656162
31353862366233663535396134643433306338333638613737623161376334653631316236383532
66316364313637633732363935636266393364636635373061353132383866306330333734616562
64633132336634643034633834623536383631363933653463663364643262623565393461613735
31343962333461393431643233396561376365666337313961616338313736373439323433393061
34636665323331653731346566656164316163666131396431333063656634373433306262666437
62363939326131663035633264613861376131623332666533653666373262613035636437346461
66336362393935633862323631393163373633306334366434346434333664313732636337313165
31356132646265346537363761393462393630653239396535383164323739636632306531626531
63643536366464383831333663376136643435636361666266333035616530333832336163636436
32613437333336323332376238623336373465323937353130643232656238643334313332303465
32306539626632323062343230666636653566376530633263373964623364363234316264346462
31656665336165643831333039376163623332633634643961316635396533356531323134356162
36363731373337376265303261313737663235306164666532303539656565343462333034366530
66643165646461303235653435613266383034333664323165646238616236663339633361383565
31616438623866633664323037343238383663343936643636333361633666336638333262316531
36663139313466303736336331326666666638333938613031353439313765343530626537393933
38383863643337363038386135326463363736373434646434343035613063343738383965366434
63613038353065353538323130323163383763383030666335383066373536336166393666333763
37663861353636633464396532666434313363323838346235643364393039366566313264333538
30353962613263623838656336353134373564383864383130356664376531626137353064643562
33393261346662346538666130303138623765383166626537636363313138646536623436643666
39626461383038633461306431363364636364623333663766396365653732333433346664316139
39333961366433633238386335343934383134663133633030386133386433633261383332306163
34373536346335616265666666386364646461653433386239653730656635663665643233323630
39616336386238333830363465636437323762633833633938323731653538613636653239373333
30636464343262356165313932356666663765376136663065666433616639613539323237303238
61386637373130626265373336326531653631343339613938376136346262626362653237633164
61616636626563613738613964376561376262343837396430653438623066663937653032326530
33396334653131356337376638356664313333343366373139623766393736326438376130646564
63333230306139303238323235353061616666643764333864646365356135376638643038313037
66316636643165333739356530663962346135323431386535356161393239616231326432393439
38326163363938373330386262386664313761613631356430633861346631623765663062643865
61353937383533633638313138316466313863656136393666353730303434613031313731333534
31306361613234626332386339653162393734623536613638343164663963326532623133663732
37343336326133626132383465306266623132643861376564636461383132306561613635373135
65386432316133393134386334653633666330623039336335396433623530633765383732333635
6431363665353131633562626461643631353938376163613361
+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: hosts:
astro-orbiter: 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: hermes_server:
hosts: hosts:
carousel-of-progress: carousel-of-progress:
@@ -78,6 +86,29 @@ honcho_server:
ansible_user: wed ansible_user: wed
ansible_become: true 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: jmri_server:
hosts: hosts:
main-street-station: 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,66 @@
---
# 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
# --- 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') }}"
# 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 }}"
@@ -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 # 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]
+404
View File
@@ -0,0 +1,404 @@
# 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 |
| `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. |
### 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` |
| Autostart | `multica daemon autostart status` |
| Daemon start | `multica daemon status --output json` reports `running` |
A binary, config or credential change notifies a `multica daemon restart`
handler, which is flushed *before* verification reads the daemon's state back.
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.
## 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` | 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,148 @@
---
# =============================================================================
# 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)"
# -----------------------------------------------------------------------------
# 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,9 @@
---
- name: Restart multica daemon
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'restart'] }}"
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,44 @@
---
# `multica auth status` prints the server and user when authenticated and exits
# non-zero otherwise, so it is the idempotency guard for login.
- 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
- 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('')) }}
# 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,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,73 @@
---
# 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)
changed_when: true
- 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
- name: Daemon | Start it if it is not already running
ansible.builtin.command:
argv: "{{ _multica_cmd + ['daemon', 'start'] }}"
become: true
become_user: "{{ multica_user }}"
environment: "{{ _multica_user_env }}"
when: >-
_multica_daemon_status.rc != 0
or ((_multica_daemon_status.stdout | from_json).status | default('')) != 'running'
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,45 @@
---
- 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: 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,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,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 %}
}
+78
View File
@@ -0,0 +1,78 @@
# 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`, plus the same 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`.
- `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,75 @@
---
# 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 the same as a
# DEFAULT entry, so new children inherit
#
# 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 }}"
- 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: "{{ obsidian_headless_shared_folders | 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,96 @@
---
# 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
# 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
@@ -0,0 +1,34 @@
# {{ ansible_managed }}
[Unit]
Description=Obsidian Headless Sync ({{ obsidian_headless_vault_path }})
Documentation=https://obsidian.md/help/sync/headless
Wants=network-online.target
After=network-online.target
# Inert until `ob login` and `ob sync-setup` have been run by hand.
ConditionPathExists={{ obsidian_headless_home }}/.config/obsidian-headless/auth_token
ConditionDirectoryNotEmpty={{ obsidian_headless_home }}/.config/obsidian-headless/sync
[Service]
Type=simple
User={{ obsidian_headless_user }}
Group={{ obsidian_headless_group }}
# Everything Sync creates is private unless it lands under a folder with a
# default ACL (the shared folders), where the umask does not apply.
UMask=0077
Environment=HOME={{ obsidian_headless_home }}
Environment=PATH={{ obsidian_headless_node_bin_dir }}:/usr/bin:/bin
WorkingDirectory={{ obsidian_headless_vault_path }}
ExecStart={{ obsidian_headless_link_dir }}/ob sync --continuous --path {{ obsidian_headless_vault_path }}
Restart=on-failure
RestartSec=30
# 2: missing encryption key, 3: no sync configuration. Both need a human.
RestartPreventExitStatus=2 3
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths={{ obsidian_headless_vault_path }} {{ obsidian_headless_home }}
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,191 @@
# Installation Guide - Paperclip Docker Role
## File Placement
Copy the files from this directory to your ansible repo as follows:
### Role Files
Create the role directory structure:
```bash
mkdir -p roles/paperclip-docker/{defaults,handlers,tasks,templates}
```
Then copy:
| From | To |
|------|-----|
| `defaults_main.yml` | `roles/paperclip-docker/defaults/main.yml` |
| `handlers_main.yml` | `roles/paperclip-docker/handlers/main.yml` |
| `tasks_main.yml` | `roles/paperclip-docker/tasks/main.yml` |
| `docker-compose.yml.j2` | `roles/paperclip-docker/templates/docker-compose.yml.j2` |
| `nginx.conf.j2` | `roles/paperclip-docker/templates/nginx.conf.j2` |
| `certbot-init.sh.j2` | `roles/paperclip-docker/templates/certbot-init.sh.j2` |
| `README.md` | `roles/paperclip-docker/README.md` |
### Host Variables
Create the host directory:
```bash
mkdir -p host_vars/illuminations.wdw-labs.cloud
```
Then copy:
| From | To |
|------|-----|
| `host_vars_illuminations.yml` | `host_vars/illuminations.wdw-labs.cloud/vars.yml` |
### Playbook
Copy:
| From | To |
|------|-----|
| `deploy_paperclip_stack.yml` | `playbooks/deploy_paperclip_stack.yml` |
## Complete Installation Command
```bash
# From your ansible repo root
# Create directories
mkdir -p roles/paperclip-docker/{defaults,handlers,tasks,templates}
mkdir -p host_vars/illuminations.wdw-labs.cloud
# Copy files (adjust path to where you downloaded them)
cp /path/to/defaults_main.yml roles/paperclip-docker/defaults/main.yml
cp /path/to/handlers_main.yml roles/paperclip-docker/handlers/main.yml
cp /path/to/tasks_main.yml roles/paperclip-docker/tasks/main.yml
cp /path/to/docker-compose.yml.j2 roles/paperclip-docker/templates/
cp /path/to/nginx.conf.j2 roles/paperclip-docker/templates/
cp /path/to/certbot-init.sh.j2 roles/paperclip-docker/templates/
cp /path/to/README.md roles/paperclip-docker/
cp /path/to/host_vars_illuminations.yml host_vars/illuminations.wdw-labs.cloud/vars.yml
cp /path/to/deploy_paperclip_stack.yml playbooks/
# Verify structure
tree roles/paperclip-docker
tree host_vars/illuminations.wdw-labs.cloud
ls -la playbooks/deploy_paperclip_stack.yml
```
## Final Directory Structure
After installation, your ansible repo should have:
```
.
├── roles/
│ └── paperclip-docker/
│ ├── README.md
│ ├── defaults/
│ │ └── main.yml
│ ├── handlers/
│ │ └── main.yml
│ ├── tasks/
│ │ └── main.yml
│ └── templates/
│ ├── certbot-init.sh.j2
│ ├── docker-compose.yml.j2
│ └── nginx.conf.j2
├── playbooks/
│ ├── deploy_paperclip_stack.yml
│ └── (other existing playbooks)
├── host_vars/
│ ├── illuminations.wdw-labs.cloud/
│ │ └── vars.yml
│ └── (other existing host vars)
├── group_vars/
│ └── (existing group vars)
├── inventory.yml
├── ansible.cfg
└── (other existing files)
```
## Next Steps
1. **Copy the files** as described above
2. **Review variables** in `host_vars/illuminations.wdw-labs.cloud/vars.yml` - update if needed
3. **Verify vault** contains `vault_cloudflare_api_token`
4. **Test connectivity**:
```bash
ansible -i inventory.yml illuminations.wdw-labs.cloud -m ping
```
5. **Run the playbook**:
```bash
ansible-playbook playbooks/deploy_paperclip_stack.yml -i inventory.yml -K
```
(The `-K` prompts for become password)
**Note:** The playbook automatically calls the `docker-host` role first to ensure Docker is installed. No manual Docker setup needed.
## Verification After Installation
```bash
# Verify the role exists
ls -la roles/paperclip-docker/
# Verify host vars exist
cat host_vars/illuminations.wdw-labs.cloud/vars.yml
# Verify playbook exists
cat playbooks/deploy_paperclip_stack.yml
# Test syntax
ansible-playbook playbooks/deploy_paperclip_stack.yml --syntax-check
# Dry run
ansible-playbook playbooks/deploy_paperclip_stack.yml -i inventory.yml --check
```
## Troubleshooting Installation
### Role not found error
```
ERROR! the role 'paperclip-docker' was not found
```
**Solution:** Ensure `roles/paperclip-docker/` exists with `tasks/main.yml` inside.
### Template not found error
```
ERROR! Unable to find template paperclip-docker/docker-compose.yml.j2
```
**Solution:** Ensure templates are in `roles/paperclip-docker/templates/` with `.j2` extension.
### Vault variable not found
```
fatal: [illuminations]: FAILED! => error: Undefined variable: vault_cloudflare_api_token
```
**Solution:** Ensure `vault_cloudflare_api_token` is defined in your vault file:
```bash
ansible-vault view group_vars/all/vault
```
### Host not in inventory
```
ERROR! Unable to parse illuminations.wdw-labs.cloud as an inventory address
```
**Solution:** Ensure your `inventory.yml` contains the host:
```yaml
all:
hosts:
illuminations.wdw-labs.cloud:
ansible_host: <ip_address>
```
## Files Reference
All files are self-contained and ready to use. No modifications needed unless:
- You want to change the domain (edit `host_vars/illuminations.wdw-labs.cloud/vars.yml`)
- You want to change ports (edit `roles/paperclip-docker/defaults/main.yml`)
- You want to change the certbot email (edit host_vars)
For complete documentation, see `roles/paperclip-docker/README.md` after installation.
+136
View File
@@ -0,0 +1,136 @@
---
- name: Install Docker SDK for Python
apt:
name: python3-docker
state: present
become: yes
- name: Download latest docker-compose from GitHub
shell: |
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
become: yes
- name: Verify docker-compose installation
shell: /usr/local/bin/docker-compose --version
register: compose_version
become: yes
- name: Show docker-compose version
debug:
msg: "{{ compose_version.stdout }}"
- name: Create paperclip directories
file:
path: "{{ item }}"
state: directory
mode: '0755'
owner: root
group: root
loop:
- "{{ paperclip_base_path }}"
- "{{ paperclip_data_dir }}"
- "{{ paperclip_certs_dir }}"
- "{{ paperclip_base_path }}/pgdata"
become: yes
- name: Generate BETTER_AUTH_SECRET
shell: openssl rand -hex 24
register: better_auth_secret
changed_when: false
no_log: yes
- name: Generate PAPERCLIP_AGENT_JWT_SECRET
shell: openssl rand -hex 24
register: paperclip_jwt_secret
changed_when: false
no_log: yes
- name: Write secrets and configuration to .env
copy:
content: |
# Authentication secrets (auto-generated during deployment)
BETTER_AUTH_SECRET={{ better_auth_secret.stdout }}
PAPERCLIP_AGENT_JWT_SECRET={{ paperclip_jwt_secret.stdout }}
# Infrastructure configuration
CLOUDFLARE_API_TOKEN={{ vault_cloudflare_api_token }}
CERTBOT_EMAIL={{ certbot_email }}
PAPERCLIP_DOMAIN={{ paperclip_domain }}
VLLM_ENDPOINT={{ vllm_endpoint }}
dest: "{{ paperclip_base_path }}/.env"
mode: '0600'
owner: root
group: root
become: yes
no_log: yes
- name: Template docker-compose.yml
template:
src: docker-compose.yml.j2
dest: "{{ paperclip_base_path }}/docker-compose.yml"
mode: '0644'
owner: root
group: root
become: yes
notify: Restart paperclip stack
- name: Template nginx config
template:
src: nginx.conf.j2
dest: "{{ paperclip_base_path }}/nginx.conf"
mode: '0644'
owner: root
group: root
become: yes
notify: Reload nginx
- name: Template certbot init script
template:
src: certbot-init.sh.j2
dest: "{{ paperclip_base_path }}/certbot-init.sh"
mode: '0755'
owner: root
group: root
become: yes
- name: Stop and remove old containers
shell: |
cd {{ paperclip_base_path }}
/usr/local/bin/docker-compose down -v --remove-orphans 2>/dev/null || true
become: yes
ignore_errors: yes
- name: Deploy docker compose stack
shell: |
cd {{ paperclip_base_path }}
/usr/local/bin/docker-compose up -d
become: yes
register: compose_result
- name: Wait for Paperclip to be ready
uri:
url: "http://localhost:{{ paperclip_port }}/health"
status_code: 200
register: result
until: result.status == 200
retries: 30
delay: 5
ignore_errors: yes
- name: Wait for nginx to be ready
uri:
url: "https://{{ paperclip_domain }}/"
validate_certs: no
status_code: [200, 301, 302]
register: result
until: result.status in [200, 301, 302]
retries: 30
delay: 5
ignore_errors: yes
- name: Verify Paperclip is accessible via https
debug:
msg: "Paperclip is accessible at https://{{ paperclip_domain }}"
when: result.status is defined and result.status in [200, 301, 302]
@@ -0,0 +1,35 @@
---
# Paperclip Official image
paperclip_image: ghcr.io/paperclipai/paperclip
paperclip_version: "2026.1001.0" # no "v" prefix; also accepts stable, beta, nightly, canary, sha-<short>
# Paperclip deployment paths and configuration
paperclip_base_path: /opt/paperclip
paperclip_data_dir: "{{ paperclip_base_path }}/data"
paperclip_port: 3100
paperclip_domain: cranium-command.wdw-labs.cloud
paperclip_host: cranium-command.wdw-labs.cloud
# paperclip_listen_host: 127.0.0.1 # Bootstrap local deployment
paperclip_listen_host: 0.0.0.0 # Switch to this host after CEO is functional on local model
# Paperclip allowed hostnames (space or comma-separated)
# Includes: domain name, localhost (for internal/local access), and client IP
paperclip_allowed_hostnames: "{{ paperclip_domain }} localhost 192.168.10.50"
# Deployment mode: authenticated (requires login) or local_trusted (no auth, trusted network)
# paperclip_deployment_mode: "local_trusted" # Bootstrap local deployment
paperclip_deployment_mode: "authenticated" # Switch to this model after CEO is functional on local model
# TLS / reverse proxy are handled by the nginx-proxy role (/opt/nginx).
# Set nginx_proxy_certbot_email in host_vars; run day1_deploy_nginx_proxy.yml first.
# Inference endpoints (vLLM or remote OpenAI-compatible)
illuminations_endpoint: "http://illuminations:8000"
fantasmic_endpoint: "http://fantasmic.wdw-labs.cloud:8000"
wishes_endpoint: "http://wishes.wdw-labs.cloud:8000"
# NOTE: Secrets come from the ansible vault and are written to .env (mode 0600):
# vault_better_auth_secret, vault_paperclip_agent_jwt_secret,
# vault_illuminations_api_key, vault_fantasmic_api_key
# Everything in .env is passed into the Paperclip container (env_file), so only
# put values here that Paperclip itself should be able to read.
@@ -0,0 +1,12 @@
---
# Recreate (not just restart) so .env / env_file changes are actually applied.
# Skipped when the deploy task already created or recreated containers this run.
- name: Restart paperclip stack
community.docker.docker_compose_v2:
project_src: "{{ paperclip_base_path }}"
services:
- paperclip
state: present
recreate: always
become: true
when: not (paperclip_deploy.changed | default(false))
@@ -0,0 +1,156 @@
---
- name: Create paperclip directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: '0755'
owner: root
group: root
loop:
- "{{ paperclip_base_path }}"
# The postgres image chowns its own data dir on start; do not chown it here.
- "{{ paperclip_base_path }}/pgdata"
become: true
tags: [paperclip, files]
# Container runs as UID 1000. Deliberately NOT recursive: recurse + mode would
# chmod every file the app writes under these paths on each run. A fresh
# deploy has no root-owned content to repair; if you ever need to, run a
# one-off `chown -R 1000:1000 <dir>` by hand.
- name: Create container-owned directories (UID 1000)
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: '0755'
owner: 1000
group: 1000
loop:
- "{{ paperclip_data_dir }}"
- "{{ paperclip_base_path }}/.config/opencode"
- "{{ paperclip_base_path }}/.local/share/opencode"
become: true
tags: [paperclip, files]
# - name: Deploy OpenCode auth.json with vLLM credentials
# template:
# src: opencode-auth.json.j2
# dest: "{{ paperclip_base_path }}/.local/share/opencode/auth.json"
# mode: '0600'
# owner: 1000
# group: 1000
# become: true
# no_log: true
# This file is both the compose interpolation source and the container's
# env_file, so everything here is visible inside the Paperclip container.
# Infrastructure secrets (Cloudflare token, certbot email) live in
# /opt/nginx/.env instead.
- name: Write secrets and configuration to .env
ansible.builtin.copy:
content: |
# Authentication secrets (from vault)
BETTER_AUTH_SECRET={{ vault_better_auth_secret }}
PAPERCLIP_AGENT_JWT_SECRET={{ vault_paperclip_agent_jwt_secret }}
# Inference endpoints
ILLUMINATIONS_ENDPOINT={{ illuminations_endpoint }}
ILLUMINATIONS_API_KEY={{ vault_illuminations_api_key }}
FANTASMIC_ENDPOINT={{ fantasmic_endpoint }}
FANTASMIC_API_KEY={{ vault_fantasmic_api_key }}
dest: "{{ paperclip_base_path }}/.env"
mode: '0600'
owner: root
group: root
become: true
no_log: true
notify: Restart paperclip stack
tags: [paperclip, files]
# No notify: the deploy task below recreates whatever the compose file changed.
- name: Template docker-compose.yml
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ paperclip_base_path }}/docker-compose.yml"
mode: '0644'
owner: root
group: root
become: true
tags: [paperclip, files]
# - name: Template OpenCode config
# ansible.builtin.template:
# src: opencode.jsonc.j2
# dest: "{{ paperclip_base_path }}/.config/opencode/opencode.jsonc"
# mode: '0644'
# owner: 1000
# group: 1000
# become: true
# notify: Restart paperclip stack
# tags: [paperclip, files]
# Idempotent: only changed services are recreated. remove_orphans also cleans
# up the old paperclip-nginx / paperclip-certbot containers if they still exist.
- name: Deploy paperclip stack
community.docker.docker_compose_v2:
project_src: "{{ paperclip_base_path }}"
pull: missing
remove_orphans: true
state: present
register: paperclip_deploy
become: true
tags: [paperclip, deploy]
- name: Wait for Paperclip to be ready
ansible.builtin.uri:
url: "http://localhost:{{ paperclip_port }}/health"
status_code: 200
register: paperclip_health
until: paperclip_health.status == 200
retries: 30
delay: 5
ignore_errors: true
tags: [paperclip, deploy, verify]
- name: Warn if Paperclip never became healthy
ansible.builtin.debug:
msg: "WARNING: Paperclip did not report healthy on port {{ paperclip_port }}. Check: docker logs paperclip"
when: paperclip_health.status | default(0) != 200
tags: [paperclip, verify]
# Requires day1_deploy_nginx_proxy.yml to have run first.
- name: Publish Paperclip through nginx-proxy
ansible.builtin.include_role:
name: nginx-proxy
tasks_from: add_vhost
apply:
become: true
tags: [paperclip, nginx]
vars:
vhost_name: paperclip
vhost_domain: "{{ paperclip_domain }}"
vhost_upstream_port: "{{ paperclip_port }}"
vhost_health_path: /health
tags: [paperclip, nginx]
# --resolve keeps this independent of DNS: it tests nginx, the cert and the
# upstream. DNS is a separate (manual) prerequisite.
- name: Verify HTTPS through nginx
ansible.builtin.command: >-
curl -sk -o /dev/null -w '%{http_code}'
--resolve {{ paperclip_domain }}:443:127.0.0.1
https://{{ paperclip_domain }}/
register: paperclip_https
until: paperclip_https.stdout in ['200', '301', '302']
retries: 6
delay: 5
changed_when: false
failed_when: false
tags: [paperclip, verify]
- name: Report HTTPS result
ansible.builtin.debug:
msg: >-
{{ 'Paperclip is serving at https://' ~ paperclip_domain
if paperclip_https.stdout in ['200', '301', '302']
else 'WARNING: https://' ~ paperclip_domain ~ ' returned ' ~ (paperclip_https.stdout | default('no response')) ~ ' (502 usually means Paperclip is not up yet)' }}
tags: [paperclip, verify]
@@ -0,0 +1,54 @@
services:
db:
image: postgres:16-alpine
container_name: paperclip-db
network_mode: host
# network_mode: host would otherwise expose Postgres on every interface
command: ["postgres", "-c", "listen_addresses=127.0.0.1"]
environment:
POSTGRES_USER: paperclip
POSTGRES_PASSWORD: paperclip
POSTGRES_DB: paperclip
volumes:
- {{ paperclip_base_path }}/pgdata:/var/lib/postgresql/data
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U paperclip -d paperclip"]
interval: 10s
timeout: 5s
retries: 5
paperclip:
image: {{ paperclip_image }}:{{ paperclip_version }}
container_name: paperclip
network_mode: host
env_file:
- {{ paperclip_base_path }}/.env
environment:
HOST: {{ paperclip_listen_host }}
PAPERCLIP_HOME: /paperclip
PAPERCLIP_PORT: {{ paperclip_port }}
PAPERCLIP_PUBLIC_URL: "https://{{ paperclip_domain }}"
PAPERCLIP_DEPLOYMENT_MODE: "{{ paperclip_deployment_mode }}"
# PAPERCLIP_DEPLOYMENT_EXPOSURE: "private"
PAPERCLIP_ALLOWED_HOSTNAMES: "{{ paperclip_allowed_hostnames }}"
DATABASE_URL: "postgresql://paperclip:paperclip@localhost:5432/paperclip"
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
PAPERCLIP_AGENT_JWT_SECRET: ${PAPERCLIP_AGENT_JWT_SECRET}
VLLM_ENDPOINT: ${VLLM_ENDPOINT}
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
volumes:
- {{ paperclip_data_dir }}:/paperclip
- {{ paperclip_base_path }}/.config/opencode:/paperclip/.config/opencode
- {{ paperclip_base_path }}/.local/share/opencode:/paperclip/.local/share/opencode
depends_on:
db:
condition: service_healthy
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:{{ paperclip_port }}/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more