Compare commits
27
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15aeb8e222 | ||
|
|
04a0937910 | ||
|
|
01cd7b8d1e | ||
|
|
5559cd425d | ||
|
|
c396c89efb | ||
|
|
7450892a68 | ||
|
|
4f868d4f12 | ||
|
|
f69199b429 | ||
|
|
ebe4681438 | ||
|
|
b1ce761291 | ||
|
|
39e2b87941 | ||
|
|
63c5f7eed7 | ||
|
|
f2b4e2b93a | ||
|
|
8b81c090f6 | ||
|
|
ffc8feddff | ||
|
|
d1a3461dba | ||
|
|
db9face9f3 | ||
|
|
8ef6dac5ad | ||
|
|
881e2e24a8 | ||
|
|
5b48536c32 | ||
|
|
d368c92055 | ||
|
|
6112b6daea | ||
|
|
f701ad823f | ||
|
|
8778a3bbb0 | ||
|
|
f864be59cf | ||
|
|
23381f87e2 | ||
|
|
49989b1757 |
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
@@ -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
|
||||||
@@ -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 }}"
|
||||||
@@ -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 }}"
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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: []
|
||||||
@@ -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
|
||||||
@@ -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 }}"
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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' }}
|
||||||
@@ -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.
|
||||||
@@ -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 %}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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
Reference in new issue
Block a user