Author SHA1 Message Date
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
140 changed files with 8836 additions and 370 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.**
+423 -364
View File
@@ -1,365 +1,424 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
66393233316132396639356564316439343234383066633231646134313361666463656536323732 31393939323063366635653130356131666436323762303533333634333166396337383133616565
6630616536646439613533363430306466306233643730350a343364633233333335643833326163 6134333735373036313732303663376130653536633031320a306634646132386335633431336263
64393933613963313533623733316339396236363663343635346663323366663166363839663837 32346162326338643665386432393464303165613930306636336136616239633137336563356564
3331363062653239380a326566623264623837326636383939346430666537613361333638366630 6536633364623763370a636563646435363930343838383166336438656365373535326666386432
63353062393335316663633739313532366363623739653631366539323435336361353331386230 30353534376235626238616662356634653132396138333163343736333631636466343035393337
39366634643964336233353961316630616462663166316266613037623363346335373638656365 65613837643733353664633262323262616331636533306561623364303437613136336238373938
38353733396636386133373836346336383231663661346137373164386338623733393566373563 64353862616166303339613430646534356237623733613035303066623437666261306130326366
35653933343036633365643535303934326537356136666539316137363433643266346630386439 66323331336636316566363532316463346162313239646431613865646462383432316363393661
38613332646238366536333536343031356532656336613530663830613264346339353034323362 38373837353764633435303335373639643431366137373930373464336463376136306263636163
66613530626361323535653232313730373463373332313561616631393461353730653464343063 61616632326530643531383264323565646466633963663732653561333430343162393035386330
38616338363938346161616636316232313838616463326432353639613837343162646363343232 36323566666264646631323939333461656237643736656430346434613566636663663165343665
33323064363139376566343866626364373662393138353666646234373461666163363139313631 35353239303433376635646237653739396533626538633332326462393838356262646430383138
64343261326566363265323463663538343034306136326234386664333837333937333136653563 38633266623464643463326563613232333361336162356561623265643130346537326238386134
61333531353434633339383661636363363535316366353330313566323133616438373161303135 31653061376334663534643130323032626232636565396466643664326263643730306439663337
35353630613037316466353832333033393030636331386438393133366333653832393731366363 63323630363863356561316139343766376132343832663266343232386133383135353861363231
62373638303737393162303461646239653865653834613662666636373364633165383062643831 63643763356361396166643262353866613235383033353562363761363539313334316535653462
34386232376361323638353361666530366432356331353963303930326535663536373339333062 32613139666234646238613837636462653532613631333332386339636334616535326333626431
32396266373430343339636635366434313635313766363863336464633961666332353834626163 61616432336135363536316137616364306337623966646366376233633437363631306662383565
61653637316163636465343630353431313863653033643237356434313564366361373435376662 66626433316135353339643235633933626563363334356234313333356237666665393931373837
63643737353830663236643862613533623237373531646136383763303766336139303632666235 66353535343339343631333662383238343930633630333133656236616335663439393865356564
64623834323966363363663730626437323432623966663537346162656265363562643836633731 36633334643739333035646139666630396239626230613264353237383830653461613634303532
65663631633462663764393132326165346639353033633035636432613039336164303538396632 36303730643632393731373037306261613037373630643137646166316136376633353865396335
66396264393865306666643636353638613661313230313337383634663839363439656533333932 34663233633431323233633038343961626538626161336533316339343539643764643531663666
36633432306131396539386539633063653230363932376264323537396434353364643432653661 34353234346362346263656165393538346435663465386164336465313739656334383339633661
32643162363066636432336363323534316436613838646562313538326566666239633234646236 31613562653062623064353666666564326339626463323435653763366330393463363935313834
30346534636533623365326564613561363362333364363037646561656635623935653466613565 38646534343261636362633464373034373432643438346364306163656164663762373133366239
65646361313436356261643762313339333864356338386136306162386262636464393130303963 35393231323337663633613936653534356361623865636535346263396665373266616231326362
38646464316432326431326661343632396235626234366133353461623862316662326432356234 39393061653563333562313639303031366236336464643864613939373265353961363739353130
37626366383861373831633639616465663564643866356664623066386535646163336134356534 65323761346363336663353763633833373633666661353032363961393834366631616439316161
33366664616232353863626465626364313530353335306565336665663866303736323162393362 66316438316135363038333831303563363137336439313933393333626261616535373932316231
63626261653161663664363833313461653034326330653835393737616135646462366665383935 32333131356330383239336432663761626265313266636233373363343031343339396362316535
30363639306330636634386433646231363530633061336364313338653632323831393630383934 66373234666536316533616635663837363361313862386364343932336430373934613934383062
37316362326338313733646332336263386239626539383330353362616132333161613464313066 64393835303235303835363633393430386439313536653835373164663064383936363335393535
34663434326662326233363432306433363666356132383866346336336261636435366332666135 32346265346337643865303030393234306565386137326637353430353633643939666639636162
34616231613638363339356333616536643266636363643131653330396162306264303566396461 33616537333266396535643536346530613637663464626238666464346661653235623064323763
64363763376365356533636430643866333361363062376237653237663731663934306265646630 37303839643137376438376237323162663636613431306130373562323739343636303936623434
33393637656335643366383564373966343265393630333835303731316339373133633462383364 39643764363065363231613065366232343638343461326635303862663834386633376262613862
33383435383331303264313334393532373932333334343862326635346135613932356337373034 37623662346139623734303239346366633231623937666330326631313637326466363833613534
62316262326331313135376465343336373266663338396533666431616462613932663861646238 36636639323563386161313038313835653061613530343538643665663439383361633365656662
62653563623535633738383033326235383666646333653731316233376231623661306462303732 62643732633634626139643630386531303634393432636235386333653732336234663738346562
32643064373236613336396233323435393939386530323331336138353364663762356538316562 32363832326365383137333566623635376335666134396362343064346131643331666666343463
33376530623664623733386133333433303031373337313366386236376539613964316135343865 38633837623337333061373164393330303161373632386139656336323837383637666138663832
33363963366165333238356663663435386439336366646138313034343636653463323938633136 38323763343237646138636561363436613063323962393362303630623036353537353061643463
39363966376238306662303265643034306136663661393738633436393432303139313132616534 37313863623062653036663733633731623831626130373265326231346363316538643562313036
66323432313635386162333838323136623634653264643438303264636430633232323434666532 37623566643636633938353235653364616266653861613362383464346237313031663063646466
32616664663063653735316237643539633133356661333132323238376333356464313262653836 31643463363138343037646164316433646232616430353338656237623436376534366635306663
39303566316332663737323437633031353330333365383837636336643763313433313937396531 61333166363537386235663563323834336130396166393133313036653631383035336465333435
38363536343438663966663436613132663661613134383431633765383164373762343435316161 62336433613235616234376133366563383630613161383830396165646134323139313437613236
62303631646235343063383230343232383336356562303563373933346530393333316634316437 38656531333137653966326462613534373830616664326362313064613434363338343564616230
32316330306163396434663031393965663163666537353031613365353437666466333464626238 35316461656135336563366637323933363763383165393535383566306332366530623735383034
35313739646535356665323734393965303064306132626261363062363438383164346261393463 39663434336266386535616430656566353137313231353839653565663430363638336530613462
30643438623363323161323230306230386332363635386234666639623566643536626637616533 33326232613964636231386237643063633335373234346165333336393234666233623038363165
37396136643930633262333331656363376433333234343630306535313262306235663263663362 65336139343634303462313335636232363637356437313161393739313966616364333335616261
32653434363035613732363136303363393939323337613661333439393637646262383039386661 30366536623663306535656431383534393530666461626562353262396562323638663036393934
62326163323562333339323636363565623664396164383332633666386130613766393138346134 32313361333439376563353130653537346438363830346138643438613433323731663631363732
33343338393536316431353439353062663164643634396363353131303038353965393466383030 63613738363637613230376337373137656363393261326461313233343466393766623433383338
36656465383938353936346361393963356630666630373236626237303064303062383638373730 33333533623838353630366566636661353730313737366333653537623637636138616233316630
35633866646535313432353338623462323235346433653431313031363163393666626432363238 31363362646565383534343535393266396465323634653262653931623931613565313932363961
39623361316132626230633336636163623466313666346631656134343762656566353432353264 36623161373665393466306161323862303830616333316563653165393736343638613434323766
30353436356237653231363564626134633039363035313232616333336436393638396233626638 30353331633039393461393062323335316662366537333035666536313036346266313839613031
32663230396539323761313838313466376165646430346634383332346134653662393161363337 38396232666165633437343032663565386133633631643863653539396361623232643532393665
62646161343665383364306665333164666231386531626465373366623761643161656462303733 66646334386565383338396461326366393833633037343238306635383663306635356465613932
37653438616233353432626466623163316565353764323762613635333832343634323665356336 61326663363634373737336265643836396238373761356665316464626138643063613632663461
35353162326233333836396337356466636131383838313436626336663132346339623261366465 65646366336633383765373034373834313731356330666363363535343934663366326465616130
30623261303933396562353331636638376135663330643638643536346261626632626139386535 64323533366235313064306536306238666366613839653730373864363861373231633164313034
66653332366361336636666437643165656239613031303638333232303836383132616636633938 37313361613666343531663965366239363735643465323763373362623961303063303536663264
31343034643037623731643931316463303639656266323231313666356336333133323135363330 63366638313332356164616435346335356536336266646263346538393136323537663464643265
63373365303131353161303630633738353536393631353034666139383435303461316131646138 64313730396638353465626330343836313863636666386334313432666230613462363137303732
61333731356538366366613831303565613365633965323235366166313534653965366433656533 64316234306565396464633662653831393934356637613139633362666661383530363032656465
62666136313662366638356237343734336333313034396465346632336262306531633535643238 62366639383433393861316132323634373366376539636462353663303466383233393830646338
35333831366532386235316565303936616264373337356134643066396531383533353336303131 62383837633533326665626161666530346336616436646535663930386462313165653436306633
31393837623564386535323532653733393734393164373235396566333565356237356438313762 34636238306636373136303866353832303265613831316236653832396466643934356464613331
32623765326639386262393639376461326163333237313232386138643130643231626466643663 36653466363561373065363934653434616661323438353166383564343731316662626264313034
39643061393566353434333136366335393536376234366266376265333234643536633035653933 63626638663333303938613636666239316635643931626538633465613465663235383137633839
36316132663539306465343039323935356361373439346437386234386464623962643464643562 61633761336665636164616162313838333937353761363635623033363964616137643035333735
61336434613834336161633237383361303930313464613666313834356330343138633735386530 61656639656263373535356331373633376463326134363031393765623332326136343163346533
36616233323366323961653965613438346136373738366266316134356266623664313539636235 35373732636131303561393462656637386631343736336636323139326234356238336238633637
37313033373466383134346361646562366531333338386330653736626530396238356639303131 61306462373633386136636636383137356437653565346130316262373436663838333065626239
38363738396236386461316433316261326435646130383336316234363461393237623633633336 31643264346363346237643064646263663931353663613036346663623031623064363637356537
30386630376565646337383738663939663462623232316635346635653830306664653336343033 30626336316531313339363937313938393864633733303738393766346537636232326232313833
64316430396664393532313766326437636636626232613036666666656430323136356436333564 63373563613935653965653737383438643566623532303939336231333639623834323639316466
36303334303562393832336433343438396430373833623137363736386665343866313064353063 38653635643032643431326462383634343235663630316264356431396561363932636136656563
32303634393131326464656535633734386462646339663533666430336265653965333538633866 37373466356538366636396130306135636637386632393763333365626339643735616332393236
61623666643839653239373335633735373738363736313665323365613635313766656635613832 65303032343831363332646564633861383865663731343632303263636136353962313232333938
33616461643539636165383233636533626230343138663630323731626139393230383464313430 61333633633935623365393032656133323434626161633438373766333064633161393434366235
61333438393337316239376435313337313437333931623238616133666138363235386533633437 66303566383132313538303637623836623163376665383933336533666530613738303262633333
35356163363231656536353934643539643562343732626630383565623730626533313230656164 35356164653063633039363039663934306333313163363034656463646464656436653438323362
35333735666135343364663233626163363930383262363266303265303638396239636361366534 39383638373337306535313739376230316166386537383736386163663861653939633338656538
31333863333565356135613232393165353266343632633532343061663331633337343538376265 38646431623831353537393835313130623336636535313765633830303164353932336632373131
39316630613439356262396634316361356436336634396337353339616536356336653930613966 36353566373662663437613766623434636165616562353938336631666665343661656363313538
34656439653366363562636639346430623561303463356337363830373966366632303337663564 35376633373535616163623838616561363932643366363235383335373632626131313063646265
35663632313265323365636238303364366230353039353561616636633664643233343430336237 63653236326135616361383339623663373331343735356334363761333766376333346332663733
63373264643935616331616632633065366638363833306337633563653065363464343137623533 61643231643465663933623735336532326431623764396136633863353135633432356362633364
36373231363739373335346464623533393336613634333636613937366136326464336332346166 37633138306266653339643763326330373637663831366465383035613463336336326533333038
61376263623835646163353134643963663964373732313833346163323138633230393537636664 36643961656336626537333161656264383334323063643462336331653663333636376663653065
30366234303334656130336630346130656237306161376566336534653630616439323764373665 66613231643363653163633330373039616138306261393734303536333763313530373738313266
61383338326163336164353265326163646165623235626137623237306666333832306461613630 64356332396137383966393238313033393934306264373234633132613231393132336665663930
66373331356465346261643466323662393661623433383265376666623932343861323139383531 35356364666334313937323931636433636164383562396433316164316232363262363132653435
64633536373362643935633734366235396433333237306166646164363930613862613365303663 66656239613163353866306631323961623731623932383265306339366134346666653664343639
38343833336137353634313362666665306666393635663633353934363832343739616331386130 37623766386538323763353531613530313665326661356566373035323833333033393364343439
66336561633039326434313833303465366638303961626138333165623331386230616130626639 64343133366334346532316439316433346238396639623633373534323338356263643263386332
34613962366230333065633761333335613636363533656461626632343631666563383738623330 39346339346631333733613064393964326234383434626462316330633031346262616338643737
30333834346233653938633330663166616331376436356533366461336264643264336139343262 33353866666532333233313362653765393664323937666435393435373638643039306366633539
35393665656230663232366133393037643536366234343537326631623332373131323739363638 36336132383032623864376563623634623461303564326639366361353961366265336563356436
39653162646366316639313631393631666261623230313538613666393732626438393763646330 38616538303831653533323332363863343666383537633864356665613139356535666136623364
36346661313131313630343432616365666633353762623261613039623331396330623939626132 34646563303636643133386537313038313066303735383437363531333739353166333635333762
34626333386538326434356432623965666662663437646237373537326534653634346239653634 37356532376132393862666236303332666262326135663735643663346234376334643966323339
31373038303639333037613637393862356263323066666630313262366633313932396465633337 35626437376162366638373235366366363166666438363964346361336361623234353337623062
66653930303934616236323064613761353935613835356561313334323762633064306661346666 39363234663765343866353964393438316361343532623766393964373838323363333633323134
37653262343865386236343634316336386630393739626437333065323433613531393738313432 37356263303564303534656338323537303965333138326636303138636534323461633837646462
31376233353463373237653164386363633334366332356538343966663939656165323465333030 62373839396138623739346466383830346535663534313265393761376135653465666630626232
39656532363363333432626638626438396539336461326338353732376235316133616666316261 39316337306161356239633435663166336139626232653261323463386363616636353138633133
37353063343366376433653961333233306461303133376661303332386230346231383837396133 33303134646130643261333630643038343637636132323930363638623266376263653561306634
37323137343066383966343535633363643233663530613566313330336232366638396165373631 66386363656233386639356462643234353464323239336464646236383537306539356662373865
30626531363033313833303836366434613736396339643032663066333865306535323739666162 32653865383932636230656662333530373663643363656166636535663862616330386163613337
64626133616433653864376662623464343131303938303237316264393765303035663833376464 39623864633435313735386163666164646335353562306162346339326231366566613735353033
32663264383236303766323935306463643138396237373338653238633464616238306132633735 32373762313636343761313235306234343639613463616263386130636165393636383662643739
31626538653262326533326266336633623532623935383266373533363466313033393235663538 65303537393437373866316535383066363732366130646533663062623361623565653138383433
66653038646233303665343634383666343363383238326533366136363838303332323230316662 35313335346236353363323434653434333432316531393433306435336238666561613161643531
35383235646638653539633961663036663933306463626335356631646662636230356261363261 64393732333932623266643938396132393235333030373133386638643831633138613835366530
65633261353830373865636630353932323937666331353635373736376436333361613330366633 64633933353465646461356465633239316631666264353530363938343732656233323339633061
30663939356165393132636131663966373433623063356265353131306532643066306630656363 63636364323762633639306336613532333935633132633239333161343933383030663539623732
66636636353262633437663264613266613663656137386231306231646264363661613035343538 31616365323265373461343961313436613436633061356630656433646361373464373962303534
37353633643065643236376537336238663137623735613038623766393231643131653436333262 63643039313565316461386230333964366537346162303838633133323339323636356430373264
31626463646432613563393665346532386161366435396364663239386236616233356131323536 63333364383363613637333463383238303734323130623437333262613763663535303563376631
33633936623762666534633862363466353736386137636363633733623366346337613365636439 35616133366439313362653763643135383061613362306533353638633666396266636337323031
66663035313430386464623833646135333062313830396637323961386135363461326539623432 32653435653639663335333137373964393431633261373436653036393366633639646639643734
32653865623530313637393561343465636430373162333162646631643235653931333830326266 35343639616637653464663964383961636565613363366561353838663464646636393164626162
36376631316165343631326165623838306239623764363262376634663236393933343838376663 63613763386262633936646466316531353164323036613539343938633830626361376135666337
39663834306165313330393739363133396436376437643232346336386531356638343063376465 30373038656339373665613833383439363131323562343435656633353266323762323764633336
61613037623137306666383231376539656361326132396662613061376134376266633764336266 66313561333066626634643531613930316664373434363365626337393833393835656262343662
64336334313335643635303632666431383637306334376462643630646339396435313830313363 66306138383962326366353832306164663561353661323538653861353338633935636630346135
33383162316261663035393962306234613865613366353465373035656434366261383133653331 65343964323839616334363430323563343463333832623766633364616136393934666238303130
63643235616362663663343330303765363263366130393837613939323264373937333162636639 30343662303736626639613034316336313133633630633638356339323831386238373832666232
31643438666338646135663538343231643235646364623761653064633566656663383465626133 33396162666134366439626564333634313666376138323039376636346436343031633361663130
31373935646266303565303539376162623132316438623565316537306337636630313861623937 66646364646235353531336132636339613138336332616165376138313764346630333233613332
34313832636533623033616139373965303839356530353935643363613464356364343162336466 65363432393532646663333465363231363535343961326631393565336363333362613730353733
66376130653162666661313139613530306666633432346639656466653364376435636461626362 39343862636131346438386165396334313230663461336264396666323962643561653139323664
32653633303561346233643463373534653434323134353434373839373937626663336464303866 66323436663539613832346265633962616531363731376261316463373035626434313731623864
33363264623038313835396231373132396163363662626264346461333539326365326165323066 30366561333161303934386562643835373336313530633436326334626537323365663138663366
62333139383334333334353031616430323339623066363232313937323465356266323934313761 33626534363365376633626564326266666431626364623832373865343035393963383838346362
66623835653961303830383030643537393130653935313265333062393034336562633535323263 66353835393439383966376365393966636131306464616636643035383466303064306635343036
65616237373232336534393834653162363461336262653862666637326266663966356665363036 64653738303232356437623937343963313837653931336339396561303864393739356135663664
35383437326465663635303664643236633435303862633965346133376536316233386333313634 36656163653266613330396236633566366331363236643861373338336562323066653766346232
33643565326633386565653961646463383866646636303537643436623734393234633938333933 62386339343161383732353838353764326264376432313865313534343734326538353364623535
65366164633165623333623362393639656661326332306538663738356364373734316563653038 36376535623834303038396139343739353131376338363738646564363931653634323639353331
34646531616662386232613034366332656262343164333531353037363036646262623663666236 37393237623864316433313139376538356538393966663065616131613931616638633536613930
38613238666136363431623664633863636365396236666532383930336636353031396232656435 63613635643565386464303065623966623038376338636663303435313764393838626563313335
61346238643431653231623861373964383931336535363262373437353532393165316562386134 65353636356330613132333338373764353766353363333838333839633635643935376462393030
36363263666135646237383666373833373737396330616163376439663736663937666161313831 30306533656139666534666162366465306163393063633632303330356165643135656433313336
63663531656635663339306365656663636633343733636165386230376332616331313638386538 62653237393834356334613038343736336162343337343666333935653635623834393139313230
32386466323232363533613334333333346161376430373436373961316564343061326164306138 35376233653937326630393939373365643338353765356239646262633632643161626161366465
33616263666262323430303730626266396535626439623364376239346564323730323534323938 64303630333433303631396135343931306363393864663162663538313263313333356262326464
33346364393033353865393864326361643734353234613563393138363334383536396535393166 36383962616163356166636137323230623262343636336364376366323238386663303336653738
34623163616336653436393639313965353237633566313039303137326234383230323235363234 61393138396333623738333766636563623834353737333134303733336165303235303635373361
37626161356166356365366164363863636563316332393638616535376466343537373966643839 31633035626334313336373733646364643333373063613063636434383037323931383466633164
32613930643533336264626136626465303339376632323034386161663661376466616233633065 32396233633233646630333037393635663733373664626665653533303535633034636163643937
66313739346162363838346663623266383130383736656334323430623463666439386532643630 37613537616137303261366533656536376437353839303439373939373764323563313363396134
33666639613830386136363535363830333234653961663739343537306634616531616263623762 31326632663664626364373133346363623239313166653737646335633833393838356131666233
64666230373830636238353062666330623061613663376638343763626264363130313464383661 65613662303338306638623462373961633338346130333463633465373739323436306232333731
38326530333362616163363735323861376366333665623536383566653837306131623732373639 61666237326631323434633063373338663961663837653038326233636666336662303737363935
31316661353332633630326162663738636562336666326637353764323431613666303038373532 38613663363430323135346566306664633930656434653833323539323266373931656131623933
62343661336338306561356235396636343130633365303466613637633363613862663233633731 64613763643664633263353162366231646262643164653437316336343438386665633562366334
66623530353132666261316637303763363830623734346262333633646238613131346564303734 39383261383265323239306266323839326566303666623462666236616266626439356266303134
62336434353432326239333232383833633962313537626430663130393733623162626131656366 61306530326265623132656662653335393962396135356561396231393831653331313765306161
64333535623138326239336165666562376663663334323036323539653734333835386331653438 34353064663032343438316163633333366266363839653662326238336631623863663937316266
63353861666239396437346361306634613462386335376137333963333838616138633730393865 63303936663666326539633138616263326266613534303162346531396333313930366466373161
62353539376136316564666136646639363635663736636439393462633165646632623664383663 62363064653737383264323235393935656534313830653137383834323363663237356230383832
31613137306461616361323832393036323933626531363536336261356636303531633239333362 65363139633961663063633936323339363839663361383361613864343035333135633038626234
32663134363263383039646162643539663737333861386437326337616362343963373532346238 65633662343264616637386463656261316631303536386236393635636430383563383232373938
37346137363933623839373838353939386630303461346438666534616434333031373730393537 34656437343633333438666461343665383066626230376233373236663561666135663433623137
30313134643963623564356266656430613430626238613266316335336265613132616562626261 30343639306333663530646635366166626432336530373363333131383338363966303639363765
35386435313933626634616463616166646466363939313639646264346464363337656339323366 62623537343732396264393933396366376634626239363761663366636433336630393036343162
36366665363739356564363232313762323565323134616134666337336534353464373637373130 64653339633131363035666532613433613735313835616433316665386133623762396363626136
63613265366436313131356332316531633732356461383064383031613337343363646432373936 64653333613561613362333531343961343237383564386239376438613935393066383138613637
33633339386632653032663837346130623636356464326637303338376132623734333932396232 38356232396432623063373138383231383565633264356562303031663538616333363038363264
61333033386265356630316134383066343164613130666664643732643362666561346132656266 32303030633037353130326365386332623634393139343061653136326133653632613330373836
31623633333039633837383264363937623435643061393935393762346430396335373864633634 61363166393766373232633637373265393539316134303961336561373534343137346434663462
33336136353332663366313334353739303539633364663231636539333132303966383432376262 31663232363638373939626432316232646338633863323031646635623735303762313030626664
61356563323232613433653262623663336634626532653465306638316633663564633862666666 34323361333431646636386132363237366636633332343434323765323235316134386636616231
30366133616336326661626238653933383164336366333438626235636631336165386664343736 31333564383739663537663131623066313336333630316336303130326531643539346236326131
62663961346664656333306435323833366632346366356238653731653937626333653630623334 39393034326661653763343761653162666561656564356232383436323864623838363730616566
64326662346138386433333232643262333835326263343239353264373038613634356436396630 34366464353765313663646430656539663764363562356336373634646232383338343737326662
38323931643361663238623766323930666130356339363564366661663033303831363138343737 38393964643261376562336432393863643733656438313462383161613730656235666333643233
66633535326131396236653261303836613364306537633637323031663166316338323533323731 31333633346333663563363730376164363363356264396334393065626165353834623139396235
30326235323066396663613531653061643661336631613835626266626436386662353465383065 32353734316137366131383935313136313337346436663735353331393030366330623937383132
64396562343966303362636136616438353661626466636635323961613438646634336563636534 33366132653534306661633765393833656534316533373163323333613839616165356636373131
38343566396530643961356434643933636235643561353232643062303232323437666261363061 33656534613830656532333533303730323539326439663665363061636431303431373662346462
62636530396466303466653333633930376465366561376363316137323263333561343334383364 36323265343939643936393066303065633637313437613832303062366334343139643864313264
39313863643062643766396564363137386231373136346138396162376264653538303464633161 63653665316638376433373861663630316439636130626333336361316165383535316466626565
61663363623937356138356430666461623130323466623162653863393736326264393836336637 30306662666537623963646432323262386132386130303335653634643436343334333130306261
34663931646566333535666664653237643732316663323230383239393763376266356135326438 63343666363536616236346637633632353839356636623066386238353833623565363932313766
35656266353865623663373366373830613361373664346632363031356265313364623866643438 61653161353835373762333034613563663633353634663562306634373938366130613831336339
61363866353934636337616239633330623734666138396166313864333939663563636138653930 61656265363662333536303633373233623436616238383463333764643362646532393630646664
35653137363033326432373661613434313137623163356134613265393238346438306165313639 34653765613362376238363163326433623963653334306231343135636230396562303662383738
36666662393165353565633531663536613037623230373063316639663632643139353235303462 66366531656562333533653638316336323365343063366332666436366161343730313433633362
31393263656265656131613164363035343233626433656135353331613532363236616439363731 61393461396162316231613265393932343866663537663333623035626635316466326664666237
62666432356363323937666435326638323437346136366131613636653430306131623966356263 37386661346634323438363938656339636434383931613931613262386532646466393333616637
39303739306233303862636535633431363630393432613663633836396566653039383735303336 35336563633164323535326530393134383634363461313366626139616433393935376164646661
62623331623034636363636661653236386337326666656532343737336262336462613762326531 30376638373565633533343730623665303438363361363065373166383662616134653066333132
34303066303834386366636430343161653665343362363038396562626133636135656538306435 65636233373934393336383631313932326266366362393036383434633333333333613937636464
36393364333066346238396362663664643236373532336263656233386663323663623137343462 32316462373532313934303961616138303862386537333439313065393630386637303161393330
64386337333130316434663564613665666238623132343437656637653035373738313735366630 39346137326338386634313838306462616263353065616639323532336531383238666662313664
66383639616166393265616434623463326437313530326130376339313662303836636664366232 38326163653663353332346631376461306663353631363661346337663266323166643062633132
32366634633030333130316435616233396231663937343732313066373834326464623139363663 30393863303130643534313035353631636631336465613738663361626630656561386236326661
31323931626364303230666162316436653065366137663631376265383063316534343736373261 38643630393963333036393534373839616236646466646433313034353163376634393038643838
31613637316235386539343766323439653062633137663730343236343661346162653366656332 65356636356265373339393765613164343834383762353234373831613264356134643739666337
32663932313063383561636266373766633535656131386133386135663863396261306530326632 36333162353239313437643336636438656266646536663939333765633862616530386538643935
63653936626236316539613262386231616433393064323461626536363831666461316131383837 61323864323632626331383461613534336534666533376566323936363031343537363432396431
30646266646266393666396362326238613231303335336532303836363264323233343534636635 61643339643038353432366261663633623631396463373735626433623032626333653939323736
66313538643033343262373463363866346566353263303966323933383963363463393761383865 64633336616536393736636361636238393439386165623039373537326462633332343039303339
64663932343830643531643466303438343161396133666463353762393737613036646166333265 37376339623837323461353739346266623334363439656133326130336563396338363963306433
66376231613232666164663964636134653061633330383863373836306366393838393235656331 61656531636232326539336566616536613836353633316666643633653131343161626332346334
35613231306263373230326634623262326333356263353961633836396531633431383163633361 65363136393539393039653134353734333964613533323061643563373332303935653837356264
30356534666466653734333437383964346564346165326664633738653338313263633837316531 38396136313265333063643937663461633438333039303234653662616666313732393038303937
36613034323433643839333264323864613033313137663131623265643364333664646235666232 37336133333030643133333539613538373461356131653262306630323734663532333932383163
33393039313666323266643362323337316465306564303230646561303434666630616137633831 39643838366263613062316331373537616137343035666130333263663361333730306233663432
34346439616634343337306636643733316464376631616266376437636439396337306637333432 62313539663138326239353932336364383337636564616466633830353030323664303961383865
39306333363035393436316434656436353738303861633933376531383862316466373736323639 37366635653939626238376238613730663333343664393966663461633866366632636630333139
35336137373866336631386436646231653366366435363932376434303063613961353261343661 65636361363339396337663937656533393639363336333930643530346361313832663864633239
33383638393165336438376662306431333837356435626137356130323836396335636166306662 37643439306631353263306664393938633139653633386466306563663333396663393266616438
36386161353739353637353861306666383966323339303262616239633930373633323937356632 31663963626564626461643038653363386636646537626561633464396535323666336264336130
65383032613031666665623631613430666662656336663931646533636230303261646530623765 63653238366565336431663365373436323938363764366137353034663666383537346264343136
64643939326435643539373564336531623236653731636636346361363064333963376566616530 30613734386432356536353431653562363738363762333632626630303738366437313431656365
62326639663632666634326233363635383830643163373938646165656163643864336436373466 66653830663236323865303131373335376630333234646232353731633764643935653631613363
36353832306632386230373832333234643638313238626333303963383962343265366137656136 62343063623133333162343066366232303233353565373739396466376239373163393065323132
37333261343161633562346638323632616566646162633133663466346535656463393932386135 32643636396532363531356530336563626164383164306161633166353262633433393731346631
62653332313066363965386335356430326539316366633537356364666230326237306236393563 31383730316130303137643438316332353132666436333232646435313161663333396537366163
35323363633936353034323232353366373566666332323737653237323135646665626139393436 36633063383238353665613333643262383365396439366265653739386264353436653137343239
65333762653536656161386532363765336538653763666236343166653933626666633130393033 61316465313835643565343733643330326236303463633032303133343662663834653462343837
31643531646633623663313237353333313136663863663430306131316165663765653732663164 62333334613066383537623838313565353430323737376237666164326537656430373032633637
36326531626365326330643064336230313466343731376437316563303339336333326636633066 32626264353461376536376137653433613035346264363332393462353562653537626262656663
61386135626430616661313236623030316362373338643233326365646531633265626238383830 65366237373363383738666134623638613138613962613936353933313061376162396532653635
65346132643537366537626132666165616138656139626132396639376230333262643766386363 31663932343936313831313636316234616130336361653533666431313334323235326565363131
39383034663034326165643636613237623234613666333532383733623462303331326238636461 33343630643764616230376434626234343463393634633534663361373465396566313862633834
34383139383466356139333934353837613964326538336463643832623062633034613762363061 39353964376434633130656535386239373963653832643838313833623839353866386531323062
61346361656363366136353336326433326266336564316366393565626262303637316564356566 63633665383935663965303830623563613734313034653139356435663733393836353834373435
39376661383763373436306238393666653561306538333638306233356139346634653363346164 32333636363937303039323065653134623438383730326262313065633265363866363435623336
31303835383062376638626266303237323832623735653066353936376339633637333562333561 33623536623432376365663264386539303363663463313035356164323038353264363862353964
36646462653834316131323166366661386161646538346464386232306239363030366363633663 38316235373434393964616233346430356463383230393730343437666437343663393263376262
65306439616339626635326531636435356134376561303235393337373564373937623636643432 32393861363264633130656266633261623130613362663537373332626230316161646539323264
61393535643038626562366331353831663338333838383066323632383633346564396566653330 66613533653633633262343134326434336166333634323034623738383539373230616237363565
34386563393832313537623061666466366661333934613766366165366330353835323637643635 65393765613134393465376435626263323633396238663630373564336136386366326631373530
38613363396663356564646132613536653033616337386566623662333832383938303138316662 61633738376236343863363031343835303931363936343364656136376330303566633962323232
34303339323166383863363831636233323335313565393933636435396666313337663037323432 37313266393463373065636466363665383665663362303137613138643530393137613761653633
63333139333165646262666339343736383966346133356138326437386334626461636530336432 36373363336430336366363136323030386537343962623361373265393132636130373464663335
61356631366163366561303636333230643732316261376365386463333565623533663966336365 33396437663064363634363132363038663438396233303364646266393931383862306464366336
39333365393766306536366231366435363030353263393534653534373064636361333532323735 66616131643662393862303166326165396135323765616366333330316563396161366561363132
61313163323831653362356333386638343566356261353534303738613730373632363534666337 63373635613663366232363238386331316461616636663439386434666165306330366337356534
62313339613138646361356431616236613435393233343732626263653332663265393934616134 65643662336138303335623765386465643561613039663536363734616264333431643337323437
35313165613766643938393839373261633439396661623961353934373130623865353639633038 35666533646432336165343761303137633130303164386166356337656337356437613862336132
34353631346433663131653965326337663561613330323562666336656237633163356562323931 30636235643532356236623762363239313163613432636466346538343062316435643735393331
62393833663233333538383063303937386365306135343962623333663435663431396438666362 66623331626566303535623631366135316238346165386430363062383138373132656633633939
61386266353838653532393233353939363738306634666537313761313835333864633764666262 38643765323636393932646363313265333637643535333762633939336162623435346664383231
30333032623766633334383031636633636539336237613235376466356430663938653565626235 35346664393165643239366436346233343566646438363533313964323831303566666264356337
64373537656566306136633630366130363630633462656330623633393735386630343437336436 66663638633064643962346136616333303064356331343031623863353365613661353333656362
65343635366531646130616534623136636666323139326462306533653532643962656530336633 37393062356661383430353365643964343462666436643236386164313932656234383039633537
35616537303932343539336638333730663639396330653761346136363431346536666138336462 30653833626331373930666239623639306433633038643963656634616337366230623265626539
66396565613166623934316532383835316137303134363466306163356233356530323231666464 66323638616330393135356134373735613933373735306330363437373237346532663239343636
30353933306530323734306564626234343864373964333264353366326265316333343330356532 37393937646365343065613233333331353763656535373739663263636233363230636166363530
38363837646635633461653562303264353633343461633339376665616331613733666663353130 38333065386138306165303664393064643836346264373939383535323365383363666436356639
65396363613731366234326466323738663563646166653237613364323734616465643764633537 32323364643937343139633435623734303839383566656539376263356634653838616337386466
35373865353532383566363632366564353536643739663761303565333138383638653665663664 66366632346331323039663863393666376637626137393037623161323931346565343935343863
65643366316461613630366437623736353739356538336237613431306363663234373265623962 38643864643739356331316162633233666162373430623337383734336564633463643338666637
34653565373335653563356135313835643266356261623037336536613733323733363933376538 35656265656461633537643739323833373838653130613831343336356331373737616465646564
36626134396563623733656534363331626262643339633932373035626134343531623634666463 64353734333262303038663631663231326636633532343164643338333237333331316165623032
61623036313334616639633930393562663631653565656136666537393731333430663062643362 34386137386534633432303638653366333962646532336130646139656136656533666533336233
63633663396562343965313261373965356163393538666466303661363531393266316462626166 62646261636630663161313966323862363761333135663864646339393835316437613862383434
38616536653665366462383064373766396438616665346666376232653031323566313164383164 63643436616432653662613531303361616263343365613437303838613332643637383439646664
36643231646439663637333165376439333432383532316661333766363136636236326338386537 32333761666237306232633832363362626239326139363363333530666630383736623839303065
33306130353634346136356234363438383865313136393839663066333935623565333730613538 33383562353733393830646661313632656361623037663262303665393532306335643465353436
63323831663866363831383930303434333936646564316435303931396362303534386335343330 63313834333732366530373366666335353630663633373333343434626664383137646538336666
65383635346662626363626365666166636361633365643735303762393832316436646139303835 33306666383838616435363037333062396461363634383236336130666635666539366462616238
63633733656361643233323332613632653837663262306661626438316262653931333061336366 31316265303961323666633065633961313963663962383837666565366361333836656364346137
35353939353835333361623261613738383734656132613139393264393038373765343131333330 65633030356432343935343139366138666137373734343361616132626630313833323461666265
37663962313566366463623437323965326365623437363038633661313461383634626661666236 61383565666138393937396265306634323939366334663635663566343932656633366333386434
32633335323861643037383261393164393933353531636134323765353962633732396230636331 38303530333034346139366432396663386631663931623761633961626539386234623837376262
32613865373739366530303538313566346434633933393330346637346136373036306666336164 38326235316330643635383934303030663634633834613164623332393365333966316131316161
35373732346334353432616561623031663331346431383235306537386466623339356366663335 35353965396337356463626661333232656236653930373531316163346465333862623734343438
36333733626433653465336431666530626665373564336339626163633131353330656437643638 65643837613962656162343866336634353436346337643761336531346164353765323238636262
31616563616665343635356231633135663665326131636664373338323736393364353636613762 35663432396165306464353266323532343363333232653865616563613030356236323036396266
66636135633766323866376235633535613735613465303239343036663438333331626431623435 64376162323436646532626363363933386433333033363864356334323839613134613935383764
61323537386434323638666537643236623632626430666263376534643336613635663762643736 30626435633531633361366332623861626363366531393861383134636230643032336663666131
30323237353265613062373265643562373637383337326264653639306263373865333262376665 37643630313138623433316562643435323762653438333335323566353264323033373834316631
62646331373931373762303461366163393839633135393964313937616437323865653735383630 66346332373366313234323237343931393437646130386261656565663065636132333035666164
32653936336534666565373437666130396265363561333635316461663766346336623865376133 35663762373131643164323031653365333339326264626234623537326638663432666235343730
35373665303433326265623531613038636166643130616637653165376263643634376439613765 61623961333439633265316431366434356161613166326162383732386166383036313838386539
35363031373630333966656466616235616337306335363132386335613462363664653634633864 64636664366130306365333762646133336465313033306563353633626561356336313966383931
61303635653932353730663666386263633662633736313461643932386161313762663761313336 32613863653662316136376330373335316437376233323930356434663732376631643963383938
62653665313033656537643936373465633932626166366430643763313030393838393039323230 61346262333339613736386333323534396130313935663632663432623162643738313238613438
62633334383938343433306262393536653930653030393033306661666264313630643564333166 34303163333731626664303337393965313237333936663664323565366434643436623539633139
35383237633932656331653030363434313534613637373465663264643061303538653666653861 62363431656634663636646465396238323165376131396635663764356532656534373065663461
35373936643037333866636131373338363062663035323531626431633362663364396365353139 34383765333933383561646430633235306533303134633939336561623265626431613833346161
66383737666437353764333231303662393630643933376161366430376530613365363830373534 63616662616239393566396332313430393563313063383836623331393965623065643535333734
38376633333936626430393163323830346166643537326430616236393733653761363235356363 63323431633533373632663939343830663530353533373730323939363931353438373338313466
35333131663032383861336262653936376565646662313965303265623763613330653461333835 64326461393564383961326463326335376238353034656531643732633461353862636466646264
63613563323135633438383931343731656333303362316533376339376636623037376431336366 64613635333334363237353235393539363766643838396330663437373639323432653165366339
61393236383364356162633062666265653534326363363862666539623761623065386537616563 30353739383739633564313635653763346631343934636437346362373363316339396437323733
62666561316437303763376635346536666437373361386666643139643737663333323933613661 62343733303834323736383834306232333932626166626536356338663862613339353531363935
38326566663932333930616435626133616531306461356466326437623235613233393434626563 38653761663066363265383139373536353638623330333239636361646564303039343930623638
34373966633834373430386132353163366465626262353863353335323830393266393562393133 30313864353833386263613633333335323932643462643133343461626365383238623639306631
65383632653438646435343333386261653066613663623232373564666465613136353039313036 32333538613432393662383434393831306236656236303234656235353637393137326665306661
61646462626433646330396664363938376530376438646262343231393262383733636233636333 38343961356631653631323261386630343263636537323434393736313333323334336639356263
35633862336566636439653464613564333162613836343636316334316665383164353131373431 34653237313930636237396264653336396132343364616339393061306435663632636433373065
63623030306564346562346237333934616134346536303365396533626262333937396432393830 37383139363666393634323636386166666331346564333336386463626433376137646561353730
38313763393463646437666137353835373735646365373934363936346564326362376565353133 33633661616238623363366532336363386137333363346463303435353130643132313665643264
36653362333432326133393837316331666663663263396461363239306239363733633137396633 37613430633733353837626238303833343463363662303831303734336165383432663562613433
38393865613431653337313665313762653635656531353465623436343132303064303564393066 38613564643230326163333863633961323239376136323833326361653862643664666233306564
63616639353962366666616261393766643364333634346630616436376565313236316539633537 39653738343431616563333563666132323639366461343039363436613236646131343332363737
66653239636561393433383639646462616433653166613130373134376535633937353366383230 63633036646232646566623632383636303861646131373330623436333338346163316232363339
61613335663434333835653236343633633038346335333861356637353965396632393833646635 31346163623032353861343234333337623536386366643037393265316533356261313136313237
36653034356234663831333764303338663464316362646339376338393236336161616263363538 61663032363466626239336435303137636366626339623730653334313862306539633933336438
63356631613239326161343031643936623366643432663732346438333265666535623664396333 65663436623033336263383438353838353037363266636438346266363363633630386438396464
62303239363339626566613439396234303536333333653433393666383635643235376165666234 35346630336231393137366535333261313662663564653963316364653035613030323461643433
30336236393962666335353233666463346530323531316438373130303933383465316638646461 33623934666562646537623936666361386633393364343363323532306134623461366334393439
62643066376666363236333231386237376466633932313836323163363061313333633434663763 32643164306237393031663537636535396635623861383163343538633335383466643733653338
64323365353336333736363436376232653436633739613437343538633632356665656364616637 38306161643436346230383763313138393435356435336466303831613261356336366234366632
62313666346436656564663335636635393632353430666236313863613464626434323939383538 64323362373833336664323936323361616562653531373962396533623937386238333965333463
65386265343434303632313739353239323565333734656566356164643430613538333234383566 63366633373732643939306462356163366464333364613738393732326130303262626537323966
37646431316363316139646435333732313339623666613738663039613239613738393565333330 38313837643431393232633531663165373565343739363032336538363435613336616663613463
33376432373933656435303737653762666464363865633831373330393435633332636261336139 30333739366165613165636561373965356363346231646462626235636431623233663038373761
34336236373535636165353262323966363164633135613534353661316364616637663465363864 61363863316137393033306634343034666466346631393465646565646333343463363362363066
39306163346365643339643937396165666366663339336438373031613937636464383531613962 38366139386634313533323932666335336131393766666538386632336264303531306264336431
34373433663533626665656364623634373335313033646165303764396563356235343033383138 61303838616139376332613838376331336266316566613738643537393162623032346431666137
62326361353630393938643764616636313461633734646661386536356235656665393864386465 64666339393332376636646533636531303266633530633436326563396462313838616232353162
37666262346561656436343036646330363664306135333464663265306165353039396665336664 38363535396461326131323936336661656364663965313462326534323461313839313264333231
62346631366330653762646538323565613864383534636532633033643533323736373931643130 34633163626336316666386133336133306666353632333033353533376462616265356438373733
32343435333333613734626234363132373734353035326232366264336161383631353133663230 66666233333830393437333635326239373239303032633765303735646162386237306438646366
32636533333866343763336439373336356237303636376334333433376630353338333261333037 65333534666637653165653362613130626432373731656636396662616533333635303964373263
31666537363666653238383939663464346662636133346561326335346163363061393830616237 65646531633731323438636165303938356265383030653066393031383438303236323763623963
36643430626534653331653665316535303139343763663965363164636238366533303038653935 33383130616364383438663133646233623864376131323765623261353834633331353265303731
32356432316633373137316237663331336463363431393033323635646564366639346230353363 36306462396530356433356537393335323965663236646637306139363861353139626666333032
38346663363039363962323137383366303862356530353238656563306131643236626536656334 34306438383737656136313539623837663232306137333465613938353161356530646666643539
38303462306562366532346163323061393437353063326539393466616439346564383036303235 62333038306665383362393137613962323064653135376137626638613133363535646561353063
35633731393661323962633631373061303930323638326565636162316436646337383266626561 39386165333663336439386132303334306330373730666536373236333438636663626530663239
32326430363031396530396238353862333133363731623736376239626561626165663337373261 36663039616662636334663864323635636439616465623438393630383832353366323861613162
39353461343461643238646635633562653865323336366634613264616662323232653861663038 35333666313236653433343836623931333765613837343835363762663563363266303561623735
64396330626633303031333334343335393039623135353266383561313231643433393963326637 64353937623133306263393738303666323066353964373033383065316463383462326437653034
39313530636361373831306234383166346266656261663830636631333564356536323565336266 35663831653336663231636165633433663530313138373035313462393531653339303365646233
38306561376366626236306633613564386166616630613032633163613837313462343662653261 61303765396464653037306630623738306366383735386135316266356165376637396630643361
63353437663436303634633336636532646439636465663362346138313665336334313039613631 36656237663565303433313332386534356534653463313363626264313132373266383662343233
65326135383831613531323265353831313562346161663265366434623236636635333038366536 33326264636365303630653834646539653865663465326437623461363937653232386166373233
33646631663662393331323162343438626666366636613438383665633136326439376166373462 66353164383130633832666438336261666537623164363263306433633337666335656134353765
33363864613136643461663436396362643066633437376631623031613366656238396165313832 39373134623061653134636364643935373233396237386334653561313833343762323832313564
31313131653263666334393664343239306235373862313339373563643137393633343663613936 37356238656430623763396439383965323136323066346636313465633336393931373931633131
61356564636238363136623031336638333566633766636362303938653531306131396665303033 36363262336636316533396465613736663662306232323739363336666233386565373837326562
63353362636463636236643464343562383161343432383766396330623764393837613435396162 65643661626264303434613837623761333934633438326666386538303264643334383833616631
31303162356437663932663964663239623764666366663061313535346438373334636263653531 36646564383436346234303336666666313165396461373938316634323830383135343830666339
34643133356638653031373036343162653135663734623035353033633561366266623566383233 63303737613439633335376163616432623366333933643135363664626437636433323731663166
36356434643538643430383532393762333535636639353361353763333363313131646264336332 33303535643634633764643832633263376338313732376130336331653032663137613031333963
34373331343930633962623963666365306132356334646636626461316236343839383266363635 39643561383633383932653066363461663331383962373631636632356336396134653330373961
65623434336239313330343437646333353362303232346638623161616133636636626236643465 62643433363830306337663262383261386131626263303762663061353936623034316432613963
36303636363965363765656533386534633839346363363738386532386531326538643134363132 65333738326264313235303730303165386463353965613264383631666463326434656235323630
66613235373362633166343565323766306335336365333439323764623964393263623236623832 33656265313363306565626662303139353938306237346166663534353936633235383735656366
34346132383136303038363764333039626234616132386464666633663536656230666133363533 39646330656533353030353537636362316261353966373739636336633334633262343336623762
38306665643361666539636666316432623430623939663636343164386438313765633031313534 31383637376566616464316132346639626464366262343032363739336330616363373437346334
65393633323837326166343936326263343833646331326464376138633461613532303135393036 37326133633234373231346632613463383530323661386364376634336135363438376239663263
65353830373065393038343039323937303634346665393135383639303162396565646232663736 66363238643833333461626437356165346435386363356230346562376566386538633638376362
39376434646634353330383933303164653431373433346335666131386165343035303964626665 39313834633264613839396233653162313735313230346666393161643966626230383066623863
35383035653631346638326637326235393833623264323030373238646335346332353362393230 33613365323835343039336631336138656566306161643132333562373630383533613336373435
61616664613562383639306564376661306665396138613066326631616531623132633966633832 37383532356336303738343565326265336631393336666136626334313539383730653462373963
65363637376264336635633132633332373634383864626564623966356464373864393832323738 38346666663834333564646461356636336133316439303062643534303837633661303032393633
37616338646633326636323461376137663632376262363738303336616463326238333465343533 35636231333561343761626431373966623966346466633563626538336430646236613466303331
31316132386530326539 35363736366630363166396433653438643563326464636466633866353263633534313365353334
37383963363035303735666330323435623531303366663866643334366461313336303334616337
32343362336366623566356361646638633237306531396264643437356133653931396433383262
34303039353836373738623339393136386538643738326264323334623962323838636464663832
63656234636364386663333733663332393639613335666237653739316138383536366662393061
38393137383331386362386565303135656231336163363839323332613530336435623365393361
34343839373963666366633564343531326264323538393231373032663935343935396661313139
61626662636235653335653761666137313430353533383033363762396636366463363635386437
32383835653833643866633265666661363837396337616236306636333633616337336233353966
39613232653635373534373039636437393630303063623033366563366664633233323565363732
33336132653261643835336430613263323832333065663131343830316438306139653736326232
65313764386231623434643738383536323232373463393239323865313061366364656536626261
37353831366538653130356330303630313536336661353738303230336435333663393365656239
31386561636437303432613765353932306635366234383631346261646461346338366165633033
63363066363966373430376634633733323233313466303033663463323932376461366634323831
64656431316533613930376337323736633361386565376238336165323364316264666235316636
65666334363638336263626332346436366262626537356163356362666539616636623336343265
64616265333138333334636633613466656434333961316539326137656565346430646564313030
32653430303463613532313734666264336631333134626339613535346664306534343530343139
66663737343038313563623039633261393364343235656563663366383039393933353262323462
64376433386533353132313739393934306566613632613933343036633338353435353631643262
61356534373562333466366163333338303637623732373063646465656536313435333034666536
38303335376632616136363334356331313032333765626331663139643335383039333561663264
61353735306131656635333062613365343131383531393838376531626631396233613130646236
65396561633464626137313737323164646236303866366538616661323331653536613936353734
35616661383063313837376433343836353064643937386138626636373263326662346663343138
64363164646561613230323436373333623036316231643364366565313534383665666563306430
32383539633661376366316261333730316531376133376166646136623737626664656461303336
39653031636233646239626536306337623631353539373762663230393361313862633233633634
30663561313761613064376232613166663433316635623134386262386231386262613161333864
62626333343062363565663863383038363031333632336664323161383239306466643464623233
38623034623031343130613139383631656231343134373039333237343065313139313766326465
38383135306539363261386531363034653637663235353836343331376337356436393132633933
36313166643338353963616466623764363166663239356139346333373761653636386435663363
65303934393339353265613332366233373665376562383562663166303838363233393563353730
34663937313864346230306330323230666234373364666135306461363265373031343865313339
32356130613261323435656266323231633164376132353065336331663562666132396635653964
61343633323532653238396131373466393561386434356536383134623534303333333336303738
66353039643466353266366436663636363438383964353635663664316463373238326164333433
38623335653366623961303161316261653038373837643733363534343663393065316130663035
63623931383263316333333863623836623635353862396439623264613234306538633466366131
61373237323337656162303366336363386139613635616135323035343764333166346337353034
65626535623034343232333735326438303438646263303261326664656339633762326338336137
39653730393164316530633635306333613333623064336635373732313431306631383737326338
33303931626431643465316438666432613564616238343237383165366262303237613961383630
36643439626633306263316437653866343362623536303032343137366535356165313430393264
33316339323838663062623566646365663734663339653638636363363861313361326166663630
34633433326633613035663639386434663430626464623761623931353639306232386533363538
38666636613432303739313030353830343535636633353664666661643531356138613731363563
32616239343063386536383462633332303136343333306131616563326438653136313830303038
32613961366634303562653436626132643338336235383638653961303262383565626632376363
65613865663638653739356365383731333664363732643834356339623138613838343631303433
65326262373963646361306432303336376638383061303164643030393633333737363166366339
34616637363261376462356230376166373630313862643732616431303161653965373032336366
64656138336237386362643638316465373464353862666236336532343436633534363966333031
39623933306631633366366138303237366638623435323766383562626233396436616261663738
63363632323335336366343264396662383562366430323866653635663962393136373732646331
33333963373261613161623733626138613037653136643735323331653338373830343633303263
30356261323766333161633738376238336666353733383666626634313437326462383335616138
656664303532663862373935343637336266
+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 %}
@@ -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
@@ -0,0 +1,10 @@
{
"vllm": {
"type": "api",
"key": "{{ vault_vllm_api_key }}"
},
"fantasmic": {
"type": "api",
"key": "{{ vault_fantasmic_api_key }}"
}
}
Loaded 100 of 140 files, more files were not shown because too many files have changed in this diff. Show more