98 changed files with 380 additions and 5987 deletions

No files matched your search

@@ -1,702 +0,0 @@
# 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)
@@ -1,257 +0,0 @@
# 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.**
+364 -417
View File
@@ -1,418 +1,365 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
39633233363564393465633065323730343130653362373436356334323930393036656638303033 66393233316132396639356564316439343234383066633231646134313361666463656536323732
6333636536643835303936633039633138336434663565320a643961663732373630333238633466 6630616536646439613533363430306466306233643730350a343364633233333335643833326163
62633236666331326165653637353038313163643162323763313635306230333338613164316337 64393933613963313533623733316339396236363663343635346663323366663166363839663837
6133396638336465350a306163643138653639646133346561323333313332336639393764343364 3331363062653239380a326566623264623837326636383939346430666537613361333638366630
30666535323264363661653236333839643631376337303563383739656238343732396364366437 63353062393335316663633739313532366363623739653631366539323435336361353331386230
33333437643930656564376236333736336465356134653239393036313830333462363633653366 39366634643964336233353961316630616462663166316266613037623363346335373638656365
62623863613261323135633262633066383363313731646361326232366661356261316633323164 38353733396636386133373836346336383231663661346137373164386338623733393566373563
34653661383366643531353939303333363832323731623830393262373538613631613631616463 35653933343036633365643535303934326537356136666539316137363433643266346630386439
66396163613965343833356432643534393539643961373263323533316664386665383330326665 38613332646238366536333536343031356532656336613530663830613264346339353034323362
35393537363734333961393931386161386234323264316466303361663139323431303034393035 66613530626361323535653232313730373463373332313561616631393461353730653464343063
33353432363162353261646231313136643533363563343537666565306638353564646235623835 38616338363938346161616636316232313838616463326432353639613837343162646363343232
33653663393838303364363762613663646139326135313438663838323064326265303733346164 33323064363139376566343866626364373662393138353666646234373461666163363139313631
62386130613134653739353930623330663135633632346531306535643332303939356263636232 64343261326566363265323463663538343034306136326234386664333837333937333136653563
34313939333263313133373635396138643838396235626662343664316632663930313038643863 61333531353434633339383661636363363535316366353330313566323133616438373161303135
65653630373437373631633032333663353866636630306235363035623837656661323735356361 35353630613037316466353832333033393030636331386438393133366333653832393731366363
33343961373235613262613363636565396234613865303937323432656433343836373831343162 62373638303737393162303461646239653865653834613662666636373364633165383062643831
37356234363662333764656530333139653434336137326631386366306162646433333239316133 34386232376361323638353361666530366432356331353963303930326535663536373339333062
35303136633664336433386331316662313330643734383139633938636462346539623835653738 32396266373430343339636635366434313635313766363863336464633961666332353834626163
63373439623866363863633963633265373339613130343966656637376632396139646266306666 61653637316163636465343630353431313863653033643237356434313564366361373435376662
62316430343338626331326433366266396131666264633234323233646639643236323937396431 63643737353830663236643862613533623237373531646136383763303766336139303632666235
62323738376664303237653563323164396166313266613937383431653766616234653762383163 64623834323966363363663730626437323432623966663537346162656265363562643836633731
65666138353262663536623036623439363463306136363930343633383436333565363232376162 65663631633462663764393132326165346639353033633035636432613039336164303538396632
35343035306230666432666131643634383263663838626466633133653830623330656164313465 66396264393865306666643636353638613661313230313337383634663839363439656533333932
32363031313266396532313563343963613133396232353832636464663462636138623637646135 36633432306131396539386539633063653230363932376264323537396434353364643432653661
64356364363862653465303837663261333933623266333736343938643065393031343535633762 32643162363066636432336363323534316436613838646562313538326566666239633234646236
62363436323931373563323863643066653237393339653363656133316365346262306463336364 30346534636533623365326564613561363362333364363037646561656635623935653466613565
66303732366437633461373065363730336235313732623131633366633834376563663734653063 65646361313436356261643762313339333864356338386136306162386262636464393130303963
31656638623933376666393232613238323239623836313430356639613131303761346535336338 38646464316432326431326661343632396235626234366133353461623862316662326432356234
65613363366331613865343163623863636666303336646636663465373065393731306634663239 37626366383861373831633639616465663564643866356664623066386535646163336134356534
32653666343631666235383863336338326438646137656562646431623436323037313438343839 33366664616232353863626465626364313530353335306565336665663866303736323162393362
34663062396564306138356465623032396239396562643933306365613033333065323630393665 63626261653161663664363833313461653034326330653835393737616135646462366665383935
66346262303965353539663463626261633332373833346430346235376636323633623864353133 30363639306330636634386433646231363530633061336364313338653632323831393630383934
34666265373936376339646363383834393166376434653233306632373966336232316339336465 37316362326338313733646332336263386239626539383330353362616132333161613464313066
63313866653732386234666531663030313364626534343235333463376334663061316461613331 34663434326662326233363432306433363666356132383866346336336261636435366332666135
64303962393930373036643561393762666334666538626233333937653038633734626430623837 34616231613638363339356333616536643266636363643131653330396162306264303566396461
33303463386363386562333061353963303538366362313131656433383263666631666566633765 64363763376365356533636430643866333361363062376237653237663731663934306265646630
65613238393736646637346333323933326664663862326563363566613361343539646264626561 33393637656335643366383564373966343265393630333835303731316339373133633462383364
37323034323136376433343433316266386135333765643036373565326539643066336533343431 33383435383331303264313334393532373932333334343862326635346135613932356337373034
34316264393261373130333930383932343864386534376633336239303066356666363438356632 62316262326331313135376465343336373266663338396533666431616462613932663861646238
63313637616465386363346162653064613236383166353432636530336437376665643830356134 62653563623535633738383033326235383666646333653731316233376231623661306462303732
35366239326637373366383962376165643932336632373330326533366535666434343161373264 32643064373236613336396233323435393939386530323331336138353364663762356538316562
30323130626236356666663230613837373666666334646366616339313661613238666138353832 33376530623664623733386133333433303031373337313366386236376539613964316135343865
37633066303139313961643935383632646662613766303366663335306263646439383534373639 33363963366165333238356663663435386439336366646138313034343636653463323938633136
33643733626531376565623431666637323736393361373566626666633232383832656333333566 39363966376238306662303265643034306136663661393738633436393432303139313132616534
65616662383561626161363061646334633033386534633533393032386532643939373736356532 66323432313635386162333838323136623634653264643438303264636430633232323434666532
31633430613338343762656630303139316634323635653763386366633366366336383064613333 32616664663063653735316237643539633133356661333132323238376333356464313262653836
32373164633066643064316535333364366461666332663165346462386563373864663565613736 39303566316332663737323437633031353330333365383837636336643763313433313937396531
64636131346133623166656235306230353733373263313166383761303364366434656666303732 38363536343438663966663436613132663661613134383431633765383164373762343435316161
34313234663139616466633562383663316630666261336463653236393832303363316438326635 62303631646235343063383230343232383336356562303563373933346530393333316634316437
32343135396333613936383239633938393261393435653239643763373065623531336633313130 32316330306163396434663031393965663163666537353031613365353437666466333464626238
64323761646666636464623734656236653439646433303934633536333862653930303133336264 35313739646535356665323734393965303064306132626261363062363438383164346261393463
66333361323735386338643933336636303263613739666432626137393061343833636537396539 30643438623363323161323230306230386332363635386234666639623566643536626637616533
62626534663636323764376437393237663839356534306438306431663937323364316431323264 37396136643930633262333331656363376433333234343630306535313262306235663263663362
38353234303364653833393733383736636139336237386137313937656137636562303036666135 32653434363035613732363136303363393939323337613661333439393637646262383039386661
62626530383037613439646261346130353430303131303936393230333662386533356631326533 62326163323562333339323636363565623664396164383332633666386130613766393138346134
65643931313036623766316137353130363535383834323363623334376234393265326362653838 33343338393536316431353439353062663164643634396363353131303038353965393466383030
34353665666537343232626137383166383761643232313539346461393937353737373935323462 36656465383938353936346361393963356630666630373236626237303064303062383638373730
38303631383465623865323230333330383730333536346366616332343539313237333538333431 35633866646535313432353338623462323235346433653431313031363163393666626432363238
65636235343938316635633732393430323932626339323236323663663932306463373039343363 39623361316132626230633336636163623466313666346631656134343762656566353432353264
66363235366539386430333365613338646166313133346239346537313666396435343064643537 30353436356237653231363564626134633039363035313232616333336436393638396233626638
30363236396563346165353032373463386630646134356634646465353136643635373164643263 32663230396539323761313838313466376165646430346634383332346134653662393161363337
31396333353232323937346530303334623163316538353937396337636165356163396639373731 62646161343665383364306665333164666231386531626465373366623761643161656462303733
64363036346434623366376261316632343238306564646234383433343137396462303466383635 37653438616233353432626466623163316565353764323762613635333832343634323665356336
38353161383933336264393332386233643666656132633038323936353630313635313365353663 35353162326233333836396337356466636131383838313436626336663132346339623261366465
30353734363435373265616333653737383838376463646466633732333537346565666433656137 30623261303933396562353331636638376135663330643638643536346261626632626139386535
62333234653264626532323064386639643136336264346232663537393065386666623463336433 66653332366361336636666437643165656239613031303638333232303836383132616636633938
38613966376535303961343361363930313664633030353538306633656335326430653534346161 31343034643037623731643931316463303639656266323231313666356336333133323135363330
31613166366231386239366631623839316361303630663063336637303832373463613735356364 63373365303131353161303630633738353536393631353034666139383435303461316131646138
39323238656232383632643630386261323764643861353063616130323939383732343863356633 61333731356538366366613831303565613365633965323235366166313534653965366433656533
33303965363565313535386463613030373634653763313430643335393032663839636164303630 62666136313662366638356237343734336333313034396465346632336262306531633535643238
38653930383661386331613763316365653538386266396430633638633638653134633365343937 35333831366532386235316565303936616264373337356134643066396531383533353336303131
30313565663139366139623065623730356363313731663034663362313335346561323130396333 31393837623564386535323532653733393734393164373235396566333565356237356438313762
37343165333434613235363964323564306330393738333635303763633466366664616138613862 32623765326639386262393639376461326163333237313232386138643130643231626466643663
61623464656630343361353433313836316230653435356661326366366131386637363334326336 39643061393566353434333136366335393536376234366266376265333234643536633035653933
34313334386464363261343831356163613834353639323562333930363563343963633833623832 36316132663539306465343039323935356361373439346437386234386464623962643464643562
37323964336565383062373739346261306139376637306461336265316336666439353536313063 61336434613834336161633237383361303930313464613666313834356330343138633735386530
31653432373235666235396239363261316333373332333234376635323261306234383836383161 36616233323366323961653965613438346136373738366266316134356266623664313539636235
33303136363335353539663833306166643737333238643066313633323031613237666264666365 37313033373466383134346361646562366531333338386330653736626530396238356639303131
63333766616631366639396235633466383062623462363638363866346462306535623839626231 38363738396236386461316433316261326435646130383336316234363461393237623633633336
63666633393137316637616634653139316439626432366236663738316166643335333534626164 30386630376565646337383738663939663462623232316635346635653830306664653336343033
62623861356134663637663665313934313833636165643732376438316563383539663037373432 64316430396664393532313766326437636636626232613036666666656430323136356436333564
30326233373936346433323934663633363166376463623135386238393334396466316265613965 36303334303562393832336433343438396430373833623137363736386665343866313064353063
33623362653436613835386535393038363064373233623266643466633732366632346330343438 32303634393131326464656535633734386462646339663533666430336265653965333538633866
65383938363063666263616330356661343732313435383137313435656337353063613230656462 61623666643839653239373335633735373738363736313665323365613635313766656635613832
63383465343932303832313335346230313164333362303935343836363764313933623831306130 33616461643539636165383233636533626230343138663630323731626139393230383464313430
36333339373230386162323337386661303663616530383863333332396439313833303461643662 61333438393337316239376435313337313437333931623238616133666138363235386533633437
37336231343065623635376564393934316333626566623765353265613737646131653934343631 35356163363231656536353934643539643562343732626630383565623730626533313230656164
32613463636130333561653066343839303332396239656534303733643639343337363437656662 35333735666135343364663233626163363930383262363266303265303638396239636361366534
63303732326235643830303038663131613330303863353231313561376231666135353464303864 31333863333565356135613232393165353266343632633532343061663331633337343538376265
66633332613139386136303264366464356434633434656536393664313130646234393634326432 39316630613439356262396634316361356436336634396337353339616536356336653930613966
63663938373932386564363834373134356464633662383130386337633564643930613734383631 34656439653366363562636639346430623561303463356337363830373966366632303337663564
38643239663733393336643834643438303737636635396630333565656264653866366166623332 35663632313265323365636238303364366230353039353561616636633664643233343430336237
35653430323365313834643862393065323531633230613630343738333934383161613264356265 63373264643935616331616632633065366638363833306337633563653065363464343137623533
66626561386138396639353934653465373162323838303233636565306534633236343066623638 36373231363739373335346464623533393336613634333636613937366136326464336332346166
38653132376663666136383235663337343762646137643935626365343162363439303061393563 61376263623835646163353134643963663964373732313833346163323138633230393537636664
32633366623834613739303139333038303762316663663136656636663663386362613264663238 30366234303334656130336630346130656237306161376566336534653630616439323764373665
35323038326239643062666336333932383065383461373933626339383434363736323536313533 61383338326163336164353265326163646165623235626137623237306666333832306461613630
65363565306239363935666234653438393039303663623636376262666164393334363839393665 66373331356465346261643466323662393661623433383265376666623932343861323139383531
62326533613639643938613838396565376334333035333962626135356265353639653633373438 64633536373362643935633734366235396433333237306166646164363930613862613365303663
37316463303838633335306333346465386461373766356533313863333534326231353864303531 38343833336137353634313362666665306666393635663633353934363832343739616331386130
32663461623731653138646336663266303065306562643961393936373937653664323032626339 66336561633039326434313833303465366638303961626138333165623331386230616130626639
39373539363664316432333931633866646333346363336466393963656637366562336162613465 34613962366230333065633761333335613636363533656461626632343631666563383738623330
32663230613335393839633633633063636635373363653234663564663264353036393434663963 30333834346233653938633330663166616331376436356533366461336264643264336139343262
66653631663062666338326133636236303862356662666533326236613965323462303839623331 35393665656230663232366133393037643536366234343537326631623332373131323739363638
35626238323837343738356264313266383135626239363731353639633837323132623635633435 39653162646366316639313631393631666261623230313538613666393732626438393763646330
61386663366533326130626332613562633965633837346237633334303564363136633733323361 36346661313131313630343432616365666633353762623261613039623331396330623939626132
32353361396166396334386630353738643438643537303034623830303939326436363239343664 34626333386538326434356432623965666662663437646237373537326534653634346239653634
35633731396161623934633864313863323336326535313637653935376436363162363565666634 31373038303639333037613637393862356263323066666630313262366633313932396465633337
33613435326539343865653862613263383236313130383234623766333637646464613365346532 66653930303934616236323064613761353935613835356561313334323762633064306661346666
34653035393862643039623763323138333334623866316437613435666530396134306665316334 37653262343865386236343634316336386630393739626437333065323433613531393738313432
31373364653865323765646535303166373638636134326538343563363732636337653931373234 31376233353463373237653164386363633334366332356538343966663939656165323465333030
35303433656137333635383334376237616261393334623835353866323333393630373765613835 39656532363363333432626638626438396539336461326338353732376235316133616666316261
33363636323664383136353863666635646563393764633531616135313935333539333664343664 37353063343366376433653961333233306461303133376661303332386230346231383837396133
62633465356563353433633564303531633236656631326331323638643666643636396162316532 37323137343066383966343535633363643233663530613566313330336232366638396165373631
36313839363164663336326639313066316331303365386130666662366638353834306662303336 30626531363033313833303836366434613736396339643032663066333865306535323739666162
34393538346338376330333332383231333630353334646635313037313736613139383832663566 64626133616433653864376662623464343131303938303237316264393765303035663833376464
30653137386339623136356663613431303564633966383633643830393636383734306238333831 32663264383236303766323935306463643138396237373338653238633464616238306132633735
64363933386532346264666638316161393435653661356162306262323131643535636134396439 31626538653262326533326266336633623532623935383266373533363466313033393235663538
64656538313938623938323638393734343464653362636532386663303762326234373930363866 66653038646233303665343634383666343363383238326533366136363838303332323230316662
32306235363837396339383461396430373964396533616434373937386266396439303964356534 35383235646638653539633961663036663933306463626335356631646662636230356261363261
65663262343461343864633934636536366335326162393830633232633832383166366430313934 65633261353830373865636630353932323937666331353635373736376436333361613330366633
64643262313163323531326163323431386532386530326332326130353230653263616139303566 30663939356165393132636131663966373433623063356265353131306532643066306630656363
64386535326262326463663734303061613435653964303764386537636132646430626530313331 66636636353262633437663264613266613663656137386231306231646264363661613035343538
38323563303738353630653631333535376239393730633535353934316131333039353931616439 37353633643065643236376537336238663137623735613038623766393231643131653436333262
35343531353332303932353636643763643035376161643536333365666466616234326237633430 31626463646432613563393665346532386161366435396364663239386236616233356131323536
62666464613334633537306431396534306237313032306632323266613134363231313331313139 33633936623762666534633862363466353736386137636363633733623366346337613365636439
63316661303830653035376261633539386533373235653839333562656135636433333263303030 66663035313430386464623833646135333062313830396637323961386135363461326539623432
65333136653939616231363835616134373038633634656366363266386534343233393730386436 32653865623530313637393561343465636430373162333162646631643235653931333830326266
61366536333730646562343333313234663037616461643864613334313464326661366133613966 36376631316165343631326165623838306239623764363262376634663236393933343838376663
32343831376266376636646439323566373033643939663261356432623865313833316238363363 39663834306165313330393739363133396436376437643232346336386531356638343063376465
34353765396233336263373161323433396437363463626464386463346330653861656162346639 61613037623137306666383231376539656361326132396662613061376134376266633764336266
32653738353738396630383431346535616165306638666137616664336235623834616239613131 64336334313335643635303632666431383637306334376462643630646339396435313830313363
33643866396530643062633537376565306538313437623462363963363364303738376662323237 33383162316261663035393962306234613865613366353465373035656434366261383133653331
37313438633138666434646633333262643333633930396439333565663930613263363638366265 63643235616362663663343330303765363263366130393837613939323264373937333162636639
61376165353939633165646330656631323765313430323630303335643362393763663134333333 31643438666338646135663538343231643235646364623761653064633566656663383465626133
64383338343939616362363930626235303532666538653561656162396566383865363463373233 31373935646266303565303539376162623132316438623565316537306337636630313861623937
62363363623938383234343639656631343831616564633136363332393131356531343137396534 34313832636533623033616139373965303839356530353935643363613464356364343162336466
65626639613663323139393061353636313036396163646434613431323864303130663531343964 66376130653162666661313139613530306666633432346639656466653364376435636461626362
35363339336436336532303763333564363962313637653438626235353339333438376433396565 32653633303561346233643463373534653434323134353434373839373937626663336464303866
32646536383036353864356134623564653436326232643330613736386162656161336165363463 33363264623038313835396231373132396163363662626264346461333539326365326165323066
37646138323937343839373736656332643430386630383463393637643463396231303730656530 62333139383334333334353031616430323339623066363232313937323465356266323934313761
61633563633338663635353235663931303964333839663333323062646266646466356434343432 66623835653961303830383030643537393130653935313265333062393034336562633535323263
38303762336132333132373532623637353731306161316564373935303366666530376639613166 65616237373232336534393834653162363461336262653862666637326266663966356665363036
66383533663634333162373436313031643533336436373563356364323236333632303663393761 35383437326465663635303664643236633435303862633965346133376536316233386333313634
32343631643566626433306532343764373263666537346538303561323465646237396639363065 33643565326633386565653961646463383866646636303537643436623734393234633938333933
64646334633033653962303437396430646332306562623763323733303930623763393537646532 65366164633165623333623362393639656661326332306538663738356364373734316563653038
38336439636134666264663339346230326435383365616433363535613235626439623037666234 34646531616662386232613034366332656262343164333531353037363036646262623663666236
33393434306662333966316364303261643137356136326637346539363538353465313662393534 38613238666136363431623664633863636365396236666532383930336636353031396232656435
34306132613636633936616339636632623733616432323564393565336633366464306661306130 61346238643431653231623861373964383931336535363262373437353532393165316562386134
32373465656131346138343537373463663937636337303765366337376666346136643139356234 36363263666135646237383666373833373737396330616163376439663736663937666161313831
37376436313834366538636136303965383330663437343363633861396634373764373863613734 63663531656635663339306365656663636633343733636165386230376332616331313638386538
39363837613363663337323137616139633935643437353838383735383561346365333565316336 32386466323232363533613334333333346161376430373436373961316564343061326164306138
37373231343665343838663235656132613331663638323331363763373539323239306232656338 33616263666262323430303730626266396535626439623364376239346564323730323534323938
33643632333863333064666332663437613961393530616330643139663736313733306339346535 33346364393033353865393864326361643734353234613563393138363334383536396535393166
31386639363338656462663566313836313066623661653139306662636331373038396636386362 34623163616336653436393639313965353237633566313039303137326234383230323235363234
63623431336139316233396432396166633762363662616432616634363639616530663931366230 37626161356166356365366164363863636563316332393638616535376466343537373966643839
37396434343039303232363633626532383235376538626438356639386661373330663838663134 32613930643533336264626136626465303339376632323034386161663661376466616233633065
65666437363139663932383035303036343633303766343836363435313739363839373938643562 66313739346162363838346663623266383130383736656334323430623463666439386532643630
33393733303165373364643961326634396265323962313433653634326436336564313338323631 33666639613830386136363535363830333234653961663739343537306634616531616263623762
63666636646638626639323631323335653961363964616461393632316363623162653665333532 64666230373830636238353062666330623061613663376638343763626264363130313464383661
34346532313436356235343539343330656235376261326262646364393866343562323663363232 38326530333362616163363735323861376366333665623536383566653837306131623732373639
32636539343063323734663732303430306261366361383461363963346633316632623531363530 31316661353332633630326162663738636562336666326637353764323431613666303038373532
34343633666136323238336530653335643730326439666637663266363563636466306531393431 62343661336338306561356235396636343130633365303466613637633363613862663233633731
33306662653537643835656637636466383965356663373530386431383837373438373436383461 66623530353132666261316637303763363830623734346262333633646238613131346564303734
66653735376535656637626231613437336663333132386135666432656638636138383939386665 62336434353432326239333232383833633962313537626430663130393733623162626131656366
35346636313439613335653635653861303231663239613563333438303738356439343933326332 64333535623138326239336165666562376663663334323036323539653734333835386331653438
35653764613061356465666361323134323331326137353863666364333332326133343039353164 63353861666239396437346361306634613462386335376137333963333838616138633730393865
35663264326432366330353230643762623933346363623039656137356135613063366265626334 62353539376136316564666136646639363635663736636439393462633165646632623664383663
64666539373230376335623365373866613761373637393066343536386134633131393339343439 31613137306461616361323832393036323933626531363536336261356636303531633239333362
39353938643233636562353131336262616439623461363531383064616564616462383833363338 32663134363263383039646162643539663737333861386437326337616362343963373532346238
31396436303330336532333661333263346233626532653437633766306230643032633035636132 37346137363933623839373838353939386630303461346438666534616434333031373730393537
65343938643630303063336664393532363131333532323566306338343837323431373062393265 30313134643963623564356266656430613430626238613266316335336265613132616562626261
31653964376339626632313734366665613563383266376533353664363764366639393563313164 35386435313933626634616463616166646466363939313639646264346464363337656339323366
63646661373461323039636338313165306435323530633535353331343166636565373630333530 36366665363739356564363232313762323565323134616134666337336534353464373637373130
63343262656538616232313633616166623534643834313138326634356334636635363935343030 63613265366436313131356332316531633732356461383064383031613337343363646432373936
61373533373863633630616135626335376136336261646465666135383130633032656465623938 33633339386632653032663837346130623636356464326637303338376132623734333932396232
32306433303937363133623537313535633164346536626465366663623430383639386662396665 61333033386265356630316134383066343164613130666664643732643362666561346132656266
65393038383136306331323035373432666565373934653763336336323131353732356164366262 31623633333039633837383264363937623435643061393935393762346430396335373864633634
64396137353838303934363033393363666632323137663762626163303963386461373166323734 33336136353332663366313334353739303539633364663231636539333132303966383432376262
30336135396262323736313037653133623237336532663765323034383766363932666562343335 61356563323232613433653262623663336634626532653465306638316633663564633862666666
36623731326232653363663937626136386139663630376564333230313632646334353664303137 30366133616336326661626238653933383164336366333438626235636631336165386664343736
62363261316161356164313433666562303965646362626331323538663865386530393864373932 62663961346664656333306435323833366632346366356238653731653937626333653630623334
63326161656561393665396461313938316430353131643035353933326430653439643634316435 64326662346138386433333232643262333835326263343239353264373038613634356436396630
61626437376565363365303061346536386134356335646137626162663739656631343334653866 38323931643361663238623766323930666130356339363564366661663033303831363138343737
31323564663165376266656233316266363232333336356137353665613961303065373735353735 66633535326131396236653261303836613364306537633637323031663166316338323533323731
30663339343631633931376562316262356462656132666661383064333838323061616361383264 30326235323066396663613531653061643661336631613835626266626436386662353465383065
64666664616231363661636337323839613830363366373530313963613132643836353062646561 64396562343966303362636136616438353661626466636635323961613438646634336563636534
32396565313631623639666433663037336338313033373938653336366539653166376237326536 38343566396530643961356434643933636235643561353232643062303232323437666261363061
35323134313962613962663630376663333334373431383731383038323965333736356462303566 62636530396466303466653333633930376465366561376363316137323263333561343334383364
32633164663734366262383264376539313438343530303364623566383564376361343231386564 39313863643062643766396564363137386231373136346138396162376264653538303464633161
39316366373964333837656536393961613466353436333839396331326463356636623738613563 61663363623937356138356430666461623130323466623162653863393736326264393836336637
66333030376364383535633637383439383237636539623930623966356461366436323933646266 34663931646566333535666664653237643732316663323230383239393763376266356135326438
63363264643238333432346432363339323461646533333266666630663161653031626361366364 35656266353865623663373366373830613361373664346632363031356265313364623866643438
62613433333235356438326439306132376234326437316437373537346363303537626632373537 61363866353934636337616239633330623734666138396166313864333939663563636138653930
33346534306363323133353163663932633632626561313566383865393461316163616532666463 35653137363033326432373661613434313137623163356134613265393238346438306165313639
36333966626638336437666338356437643862303332353663303537613933643338396432663336 36666662393165353565633531663536613037623230373063316639663632643139353235303462
65613565353536396132373663663730386664323732616533303037363362663335656236383838 31393263656265656131613164363035343233626433656135353331613532363236616439363731
32666463323463363736316534393538373938303062396564333435383834646664346261383535 62666432356363323937666435326638323437346136366131613636653430306131623966356263
65653931623934363330366361303030306132616637636366313333303664656363623061346638 39303739306233303862636535633431363630393432613663633836396566653039383735303336
38626261313765303832333562383763363935656632343735613935356333346237313436326562 62623331623034636363636661653236386337326666656532343737336262336462613762326531
65353230353537373737326239636465656566346635303134363739333139666362333964393465 34303066303834386366636430343161653665343362363038396562626133636135656538306435
35663837633132663461383036353065656334303730636661363236646462353238343737633635 36393364333066346238396362663664643236373532336263656233386663323663623137343462
38663762656234376233623264343762303434353366363262353232386131303231373832336431 64386337333130316434663564613665666238623132343437656637653035373738313735366630
35636434346531376461393330363837633564613363373235623837373130336465383933353039 66383639616166393265616434623463326437313530326130376339313662303836636664366232
31363732656164333037316438393532336435663035346139373439326565653363343336306638 32366634633030333130316435616233396231663937343732313066373834326464623139363663
30383635656336643934623132383433333962323438303733396131333761643730343464633539 31323931626364303230666162316436653065366137663631376265383063316534343736373261
32306664393738353334333465363966613261333532393130366437353433643439316135613861 31613637316235386539343766323439653062633137663730343236343661346162653366656332
63303863313133333339323665343539343930356637356262316138613733613465343031376538 32663932313063383561636266373766633535656131386133386135663863396261306530326632
30633537396262636262663033656430313831616635363239646334356334323731626530303034 63653936626236316539613262386231616433393064323461626536363831666461316131383837
61343362383935343835316561333231323261643838616435313032313636613933363837343336 30646266646266393666396362326238613231303335336532303836363264323233343534636635
34613131316239653861626434366338343232326531396530373165663931396136373536366339 66313538643033343262373463363866346566353263303966323933383963363463393761383865
64306363393061333333663435666130366630633838333533306163313237653462306461303030 64663932343830643531643466303438343161396133666463353762393737613036646166333265
61353830326337333563356165663062316165373939333938373637636164646536616639366634 66376231613232666164663964636134653061633330383863373836306366393838393235656331
65353035353836356264346234653431383565373637383035323234316637356530346332643635 35613231306263373230326634623262326333356263353961633836396531633431383163633361
37326161333838643166313664616331623532356235323564356261663734613464666133313230 30356534666466653734333437383964346564346165326664633738653338313263633837316531
35393763353639323666313430666164323836633138373961666162316433396364393563626265 36613034323433643839333264323864613033313137663131623265643364333664646235666232
64373565393965313134613365653062323764346432653630643662663930353738353038643661 33393039313666323266643362323337316465306564303230646561303434666630616137633831
34376363633338353132363061363964346162663438626638373032326536363733396634303461 34346439616634343337306636643733316464376631616266376437636439396337306637333432
39306432646362666336393437396330643864353437376235313966393837393435373437313065 39306333363035393436316434656436353738303861633933376531383862316466373736323639
33303439343736626436613663353739656264313639626633323265643966643566623765653739 35336137373866336631386436646231653366366435363932376434303063613961353261343661
31336564393138356265623636306266363861323961636131336331653632626439613461383735 33383638393165336438376662306431333837356435626137356130323836396335636166306662
61616265646365636133393031333733363537366262326632363962333433656366313965303164 36386161353739353637353861306666383966323339303262616239633930373633323937356632
66663335636363343739313838633235323561343833346332353532663065363439303936303266 65383032613031666665623631613430666662656336663931646533636230303261646530623765
34356331633961383264393765316236376130323233346163306536386436366636636139363035 64643939326435643539373564336531623236653731636636346361363064333963376566616530
34623938366266343164633532646161633264316334313930383565623766396165663664393537 62326639663632666634326233363635383830643163373938646165656163643864336436373466
32373266656134333136393965366538316366313735653535626635663462623036323832373964 36353832306632386230373832333234643638313238626333303963383962343265366137656136
61343236616264373563386265656364333230396462653833373338356233393962663965346636 37333261343161633562346638323632616566646162633133663466346535656463393932386135
39313261353263373364373034653338383632643430323335306632353331633939383137396164 62653332313066363965386335356430326539316366633537356364666230326237306236393563
65313166633632366465663466633733663939303039316265313539303564613038313136623861 35323363633936353034323232353366373566666332323737653237323135646665626139393436
37383837653262633365333063646331663562356162333937383538623662633134613364653033 65333762653536656161386532363765336538653763666236343166653933626666633130393033
32613537633639656361336236323039383533303661366432376665323432386464393534633066 31643531646633623663313237353333313136663863663430306131316165663765653732663164
32616666613938373066313866306330393061396263386563306661643831303266633465396364 36326531626365326330643064336230313466343731376437316563303339336333326636633066
30613961636333373462386531373264386332303063663264393861363263666638643836396234 61386135626430616661313236623030316362373338643233326365646531633265626238383830
66613235643365316233353262663739313831373561633339396165386530313538653538633039 65346132643537366537626132666165616138656139626132396639376230333262643766386363
36353364656136306161636135663464343033306161653365653166643762343035336166316164 39383034663034326165643636613237623234613666333532383733623462303331326238636461
66346363646238636438353036353631663135356538663762323030353965386635306630643131 34383139383466356139333934353837613964326538336463643832623062633034613762363061
36666330663063626334386431663635646561643464616636633735626132383961663265626631 61346361656363366136353336326433326266336564316366393565626262303637316564356566
61653365373863313832366538363864333537643336623633343334633333323561373638613961 39376661383763373436306238393666653561306538333638306233356139346634653363346164
34396433366665333531373031336362306364633537646565656630323334643038383535383036 31303835383062376638626266303237323832623735653066353936376339633637333562333561
36633431633930313065663337366533323463333162326435626264393162333162656236306536 36646462653834316131323166366661386161646538346464386232306239363030366363633663
62323032616162313666303265636636323163303234656230313061363862343736373630626435 65306439616339626635326531636435356134376561303235393337373564373937623636643432
37616662393737656132326537336534623633613133343833313135336330303536393364336363 61393535643038626562366331353831663338333838383066323632383633346564396566653330
34383136376363306264306237653236326166323032383939616563353432323565666263646164 34386563393832313537623061666466366661333934613766366165366330353835323637643635
38346663373335636131366236313134623435633766653266383661363532353931323831363065 38613363396663356564646132613536653033616337386566623662333832383938303138316662
34663732643435623436323236316365313861346162613636323138303831353062616263626436 34303339323166383863363831636233323335313565393933636435396666313337663037323432
37356230643833323062336465306366613637633834303762323838656132333130646462653766 63333139333165646262666339343736383966346133356138326437386334626461636530336432
32393532636463363634636665643439366531303162306133626138653839316433343532646530 61356631366163366561303636333230643732316261376365386463333565623533663966336365
36383066303539386136643436393764613937313933396335376262323064663363396133373739 39333365393766306536366231366435363030353263393534653534373064636361333532323735
66643332333135333437623266626563356337366665336632333035323537343762376132373331 61313163323831653362356333386638343566356261353534303738613730373632363534666337
66303630353430313961356234616438363436323938656466636264636364313431396165623530 62313339613138646361356431616236613435393233343732626263653332663265393934616134
33306334656338623237313333386135313961633666363031376334653433366330303262363832 35313165613766643938393839373261633439396661623961353934373130623865353639633038
32333139643434623031653137393134343961353261366464383537643237623166396431356430 34353631346433663131653965326337663561613330323562666336656237633163356562323931
64356435356566336461663766393537303737393139653132363737343238366361316463306136 62393833663233333538383063303937386365306135343962623333663435663431396438666362
38313365333863303734336162666130393531356531623865356431653237343432613238633566 61386266353838653532393233353939363738306634666537313761313835333864633764666262
35323166623935336361653530373466653063383339396466356334663066393539356432616362 30333032623766633334383031636633636539336237613235376466356430663938653565626235
65643536316137326533363930333966666632393334333536326431633962353362313532383033 64373537656566306136633630366130363630633462656330623633393735386630343437336436
63376566643166353136353733353862643033363035353534643062303133633064366332623938 65343635366531646130616534623136636666323139326462306533653532643962656530336633
33363862306263663737616364633462363736666530613562643865393466656131663663663166 35616537303932343539336638333730663639396330653761346136363431346536666138336462
33313839623739386565323133323263613939623838396365313265363466666235316263313535 66396565613166623934316532383835316137303134363466306163356233356530323231666464
64393936396332376635343362396564346634336634336663383566376365353836383739333665 30353933306530323734306564626234343864373964333264353366326265316333343330356532
30653038346161623536633361363961623931326265666566316565636662633032663639373361 38363837646635633461653562303264353633343461633339376665616331613733666663353130
62653665366338656532373734663138353238356535353036336331383635306236656130613633 65396363613731366234326466323738663563646166653237613364323734616465643764633537
34323962316636333664616664313163643061623836316432366664623164303365613161653466 35373865353532383566363632366564353536643739663761303565333138383638653665663664
39333538343435383066343336313564386136363363326231353838373437643136616336393561 65643366316461613630366437623736353739356538336237613431306363663234373265623962
62626430393735326366353839393166313030376435303039643739373337653635316466643236 34653565373335653563356135313835643266356261623037336536613733323733363933376538
64646237303064306663396439316431393931626431616630396538626639636432326561653562 36626134396563623733656534363331626262643339633932373035626134343531623634666463
61363338623863363731356566306231366336363739623735313134613933373238303765386234 61623036313334616639633930393562663631653565656136666537393731333430663062643362
61363034313139626636643533643738663266343235333465663632343533646131393130666632 63633663396562343965313261373965356163393538666466303661363531393266316462626166
31366562643166616430613030333832303636616231646239646435616530343032376663653031 38616536653665366462383064373766396438616665346666376232653031323566313164383164
65636561353734656562626463643361633362313861373233346633636438653335323761373165 36643231646439663637333165376439333432383532316661333766363136636236326338386537
31613130306437346630313466393735643434376133336565656566323132653962636630313431 33306130353634346136356234363438383865313136393839663066333935623565333730613538
30386136643462306261383135336362356161666435323132626637346365396436653835363733 63323831663866363831383930303434333936646564316435303931396362303534386335343330
33393732633464626437366439303931623962313433626533363136653939316633326432356565 65383635346662626363626365666166636361633365643735303762393832316436646139303835
62393332656238303763363732623163653663373536393265343763373365316337323564353064 63633733656361643233323332613632653837663262306661626438316262653931333061336366
31616634653837396438303938343562613761363766616561643032643065346238383734376433 35353939353835333361623261613738383734656132613139393264393038373765343131333330
34613931366331366138346636376566646539303430353665626263643465313133323939613361 37663962313566366463623437323965326365623437363038633661313461383634626661666236
30613063656665666439646362656136656439643934623730383736353031363633653036643161 32633335323861643037383261393164393933353531636134323765353962633732396230636331
39366163663035663135343462336564383862336531656138313431316263653064306334633062 32613865373739366530303538313566346434633933393330346637346136373036306666336164
39386538616361623265663536636130383362633934313764373538643636353237323133303266 35373732346334353432616561623031663331346431383235306537386466623339356366663335
65613765313838363236656334663635636264356331666134643063636334633131353734623632 36333733626433653465336431666530626665373564336339626163633131353330656437643638
38316637306163363839366536333365663337383632376230373137386535303666343362316663 31616563616665343635356231633135663665326131636664373338323736393364353636613762
62663333313634313361373463313566363063363665323565633935303738316135393264663935 66636135633766323866376235633535613735613465303239343036663438333331626431623435
63643662333430633535663130343930373763366430306337656162383537376564653439343430 61323537386434323638666537643236623632626430666263376534643336613635663762643736
35656430366365636165386333663566313034646564636261333361373030663633373861323065 30323237353265613062373265643562373637383337326264653639306263373865333262376665
33363463303135336436613563633135353531623339616530353538356130656263383265353735 62646331373931373762303461366163393839633135393964313937616437323865653735383630
66383964313665623133303130323335393339663331323431363438613637636665623634313862 32653936336534666565373437666130396265363561333635316461663766346336623865376133
66336533626266343831363732613331653431306462313830343830336339663636396566343134 35373665303433326265623531613038636166643130616637653165376263643634376439613765
34396137396461376433653962613361323334393766303330633237623963653033363532613761 35363031373630333966656466616235616337306335363132386335613462363664653634633864
37316139336234366633343064313965393965353461383433366630633935336233383962616662 61303635653932353730663666386263633662633736313461643932386161313762663761313336
30356234663661343232633435346532313737376234623336366161303937623964656331633935 62653665313033656537643936373465633932626166366430643763313030393838393039323230
62333537303536333062613132646237373031663766623730666330666364313634366362363437 62633334383938343433306262393536653930653030393033306661666264313630643564333166
34643634303366633236363665643934323864303732613461326238323637393733393964663562 35383237633932656331653030363434313534613637373465663264643061303538653666653861
30386664636139313335333238373632353936646432326436666665326365646565383766316362 35373936643037333866636131373338363062663035323531626431633362663364396365353139
62376463626338643937663163336166663465353936626332303739393762313235323535366636 66383737666437353764333231303662393630643933376161366430376530613365363830373534
63666438636230383031333838613333633734623134363833383035623564613637633562323263 38376633333936626430393163323830346166643537326430616236393733653761363235356363
64356662376532333261316537656234636338393738613730656530613232373662363431393630 35333131663032383861336262653936376565646662313965303265623763613330653461333835
32323230346332613038373530643563353162366366636338623566373065373637623638333533 63613563323135633438383931343731656333303362316533376339376636623037376431336366
34633333663832393438616266653563623338626431636634303234333636613033333831316264 61393236383364356162633062666265653534326363363862666539623761623065386537616563
36333664366262343261373163373763373036653866663539343337323761633431313435323465 62666561316437303763376635346536666437373361386666643139643737663333323933613661
35386539636338613666393064303363303263323565613837623866633031303834363837626531 38326566663932333930616435626133616531306461356466326437623235613233393434626563
31363838326235643163353731333938386264323730323036333066326463326537613530373832 34373966633834373430386132353163366465626262353863353335323830393266393562393133
65303663613361336565643366316238336135396334303432393936336635306434653930643631 65383632653438646435343333386261653066613663623232373564666465613136353039313036
65393438643630313330383061643031343663393265646664356636313035313961383634316466 61646462626433646330396664363938376530376438646262343231393262383733636233636333
36633139373564623466386264303664373763386534326234323337633732633232346136366133 35633862336566636439653464613564333162613836343636316334316665383164353131373431
61363939336531633265396136623031396534363031393164646661383339653232393463636439 63623030306564346562346237333934616134346536303365396533626262333937396432393830
63306438613837313136646232333434323430643130313937376230613531383461306262366636 38313763393463646437666137353835373735646365373934363936346564326362376565353133
61633137643164653739343663393335373065386339393531343930373161376434356666306132 36653362333432326133393837316331666663663263396461363239306239363733633137396633
35383763613966353662333362396265326532303238656133316332363335346437306164653230 38393865613431653337313665313762653635656531353465623436343132303064303564393066
38333263323064653533373365336166613431303231373062376663343261663135623937373163 63616639353962366666616261393766643364333634346630616436376565313236316539633537
63613239343034303263306138376430613335643862303633343637373430336131623037373537 66653239636561393433383639646462616433653166613130373134376535633937353366383230
64323838306233396435656339306132623266363961643166303733373537333135303665336239 61613335663434333835653236343633633038346335333861356637353965396632393833646635
62623439623865363736343631613432633264363733353834386335333330313933383433346137 36653034356234663831333764303338663464316362646339376338393236336161616263363538
32386432393765356133353736306430376665626665663964313037323136326164653435383332 63356631613239326161343031643936623366643432663732346438333265666535623664396333
61346361313063333137356662383533623734633435646638363537303037393638316439316561 62303239363339626566613439396234303536333333653433393666383635643235376165666234
62376361363532623266383635323330633033333536636262386239353732633237663139393839 30336236393962666335353233666463346530323531316438373130303933383465316638646461
33633537353136376163613334393430663366636533616265633133373563653962393238373862 62643066376666363236333231386237376466633932313836323163363061313333633434663763
61376639663631323231376631353964643162616538353835363937663661313632333333373933 64323365353336333736363436376232653436633739613437343538633632356665656364616637
62323539643630303062333063386163656530623061613664376436623732386666336162396533 62313666346436656564663335636635393632353430666236313863613464626434323939383538
36343563643032356261363234663264303762663439363664353966313561623761336138313165 65386265343434303632313739353239323565333734656566356164643430613538333234383566
37633630663131313565346539396234363565646530386266666232306262663961663636326536 37646431316363316139646435333732313339623666613738663039613239613738393565333330
64653638643935636365313561313964653764363766623433353365376161623333356164346232 33376432373933656435303737653762666464363865633831373330393435633332636261336139
36646630666333383234343931343161646463633134316466616436336634336661356161303764 34336236373535636165353262323966363164633135613534353661316364616637663465363864
62646362333139373761333035386666663561643837623665656630393432303839666666303339 39306163346365643339643937396165666366663339336438373031613937636464383531613962
31646461613838353033343665396239616538356164333436353133653134313738343530313366 34373433663533626665656364623634373335313033646165303764396563356235343033383138
33646530373561633834383865393961353032656339633732646533363535343630613937323461 62326361353630393938643764616636313461633734646661386536356235656665393864386465
65363262643937373638633838343266323966623039616333656538363362333962353066323363 37666262346561656436343036646330363664306135333464663265306165353039396665336664
36326138323132333837343130363735623861616334343130363064303030306361336161303832 62346631366330653762646538323565613864383534636532633033643533323736373931643130
32343534623662393566653131363866343637373930643434326338333362646233383231636235 32343435333333613734626234363132373734353035326232366264336161383631353133663230
33636433346464363766376634636533366163333064313534313831636238376639393533616566 32636533333866343763336439373336356237303636376334333433376630353338333261333037
66333633613261336361366234643966663536653736323235663030626234386166663033376234 31666537363666653238383939663464346662636133346561326335346163363061393830616237
32623435383837336330616235313165636537623462626230346535663662386337366532363733 36643430626534653331653665316535303139343763663965363164636238366533303038653935
39306631626262623630393137653763326234366139626461643931613736653932316466396163 32356432316633373137316237663331336463363431393033323635646564366639346230353363
66643038343336326533366438356562333561616138383330643235663633336262303266613630 38346663363039363962323137383366303862356530353238656563306131643236626536656334
61346237313730303666346639366439313865663430643536336338313130656438613831383633 38303462306562366532346163323061393437353063326539393466616439346564383036303235
30613862363961373732333863636134623431626361396562393437613533383738613761363835 35633731393661323962633631373061303930323638326565636162316436646337383266626561
30363831363963623133653038383031323364346237326164313232363936656132353732373463 32326430363031396530396238353862333133363731623736376239626561626165663337373261
39383737303034346163623737353537646433366539663963313566363032343836383134636131 39353461343461643238646635633562653865323336366634613264616662323232653861663038
66363664336435376563343765623737366632383431656166303639366661633565623331613530 64396330626633303031333334343335393039623135353266383561313231643433393963326637
39396131613161663436333736666536656364663361626633333637316130646266346165323632 39313530636361373831306234383166346266656261663830636631333564356536323565336266
35623062353732616535373166336564383166663861613239373836646637616333613639613862 38306561376366626236306633613564386166616630613032633163613837313462343662653261
66396434333039393135623733653938336233613235363134636362666363303337383335303136 63353437663436303634633336636532646439636465663362346138313665336334313039613631
66313061323938643432623833363930303236633462363663323362626332363565656135363464 65326135383831613531323265353831313562346161663265366434623236636635333038366536
63376266653132333565616363383564353135656431393062393333626336663738366331383737 33646631663662393331323162343438626666366636613438383665633136326439376166373462
30626239366666366263353662333166303063386364366530653731353135313761343461646238 33363864613136643461663436396362643066633437376631623031613366656238396165313832
39353531656232616530633330393130663862623964363565326233643431343432313039333234 31313131653263666334393664343239306235373862313339373563643137393633343663613936
63336232336535373032383933393763623538353061396534373632323165666134333465303066 61356564636238363136623031336638333566633766636362303938653531306131396665303033
39333366303231363863333133643062333230316361623635656663323737643965663563376564 63353362636463636236643464343562383161343432383766396330623764393837613435396162
34343134306333386663356339616461646639303835333333623363383364666436643436343533 31303162356437663932663964663239623764666366663061313535346438373334636263653531
38373364363765343265663138616439316633623865343939353465353466613065653366336230 34643133356638653031373036343162653135663734623035353033633561366266623566383233
39636664303335316538326136356361633366373761333635613139633665616433386162623965 36356434643538643430383532393762333535636639353361353763333363313131646264336332
32636266303031336162626132363931363163313235363865353334616137643934353637643633 34373331343930633962623963666365306132356334646636626461316236343839383266363635
33626137376132353735303737343433323462343830633731396565383037313232313862396330 65623434336239313330343437646333353362303232346638623161616133636636626236643465
65383564386564353634636430393562623833373533626632333530336266633038663333663063 36303636363965363765656533386534633839346363363738386532386531326538643134363132
32313534666162666134373533313730663730333933613762303238323065363236363166326361 66613235373362633166343565323766306335336365333439323764623964393263623236623832
39633938373861366133316261303665356661393830366131343138623138616233623961386233 34346132383136303038363764333039626234616132386464666633663536656230666133363533
65356634333833313061313134333364306238623637333733666366373762383732663037643338 38306665643361666539636666316432623430623939663636343164386438313765633031313534
33333263356262633366346130386337396263646333636134333935343835386366306663663962 65393633323837326166343936326263343833646331326464376138633461613532303135393036
62306232653735656365643533653635366232326638386563393365376464653532656136656266 65353830373065393038343039323937303634346665393135383639303162396565646232663736
62393830656339393033343638643334313765383766336338363331386135373864383939643166 39376434646634353330383933303164653431373433346335666131386165343035303964626665
66316437666434643266326235663632363161663338336434313134643533383634633936336665 35383035653631346638326637326235393833623264323030373238646335346332353362393230
62653638346633336331333364623363376538373562663437373933323932663533333733633036 61616664613562383639306564376661306665396138613066326631616531623132633966633832
39386333653636646133366461633237656166396664623136633863653339633463346135393566 65363637376264336635633132633332373634383864626564623966356464373864393832323738
39316161613532386432306364333662313261346631636430363061376332633431356132663066 37616338646633326636323461376137663632376262363738303336616463326238333465343533
65643137303961383539663733653863346131396232346263393934326239383735353565376533 31316132386530326539
33356438336463323531326561326264396635623734663835363039613162613762393662363562
32646634643934393263616335633933386564373162303766376661323864306230376533633539
38363032353639633735356266616330396333373665373035643334336566636534383831323862
33623866386539376131393531653562643430663765316539613438663733366566366533393761
31303836636533656535383165323232353065663338646337323530363133393263653930666334
62323836353563366637383138383864323438353339653434306532336164306238633437646235
64366536393039323339616130663939393562363935633435373932396462323533363838356663
34343138353130383734363234366661386638653834643661353031353031343562613461393966
31386463366635353965386462653461653866393864613063636564313638623734663864333334
36386239336431316338643761363566636137333161663833623633376262623830383961613931
33666136653631663131626631643266656430356364343138353663326435656663373035333934
33636339386639666532333763393735386231376666323733333030353239373333383264323130
35303437616433393231653739653230326263616233373166316237346138336364353339653339
33353132636262323030643465313231393763613861343734363031386563646337336562336463
37303232663235313335336435333032626432633531306638313561646236616230306135313639
63633938326266656138383635653339393238303964386663366638643734633866333432373737
34636433613566366132613864643135356136336162643066633035663533666237376437333762
64376262633064333635623438353732376466366338323636643662323234326139316534646533
33666432303636373963666334376263636634323532643837316361376338616430346464656465
33373336653539303163336239613038386230363839353532336335303564306363666665333530
63393863363232653138356234323161323931353932326137653430363836323863393035356230
61633161623761303335646435383934663135633734346433643639383932643662393264626430
38303735343033303765316365353834633461386636653662393538643339346230643335656438
66386164326535646335356661383735366134613939636130383034633734313739333635376233
35623130353239323237386337623962616536643463376632613631646436353630366137343838
30376136616261303235643437613864613031346564363465646533363033303566613061363330
63306530323938383737313438333139363862366331336665623139626135353236316664643661
38343165383537643136623661663730383635376263346131303332646464376263303464373734
62366331316166633631663163306237343862306365326339653363336638633830646233363665
61393461326535653736363834346163343736666665623838643937306539376266393761633263
39316163356262633137313261353463373166633735663035663135326437383534616239343661
64633762613833653365353234363539643533353239316238636537323064353337316361353630
39343463643236656431623861333734313838346430356630656131383763386534373635666331
34333137346266353265376431623935336366623461666237666334616634613162623761346161
35366334313837333939306563316236313839623236373234623635656138656165313662656632
31343961363864346438333932656264363932646132366136663339363930386435666639663135
61313037303762626537393330366131616532646666613338393032353837643835353436383539
30323064656161636534653664336232613565626162383035636131393539386633643436653633
37343339663531396533323432643464393431623338343537646138653236366331366261663631
65386666313531323031353762363437653561653534316463666539636439383361373861646331
66626434326566653534393964643938393463303930613734336161356431363230363261383139
38356663323539636533663237306566623062333036666636663637306332626139333738386365
38363635386439646363626631366430393636613763646539303939643764643966353461343466
39363331343334653637656561383638626637646230346665393366313366633366653839663839
39333263313566626135326633653238626566633536353461376462613462303830353638393161
36366433656261313436663466366433663138613561386336383262646663313836633564353266
61623139323961346564623939303339366233643231373031626165646364376138383935626166
38373833613033633630313965336438626363353031346630376532393932353961363732623631
35613038366664663337653230653134373861666430343666623165306130306137383163396235
66633035353534386262663336646361303236623365613736383938333261653462386636376531
37616539376637386462373063316535636365626634613462623232333435313733326261373230
66373061336231663230386133613665396263646563376534313830316439623363653165613666
62373063633863656566643333636461353339396635396331633734623266306430
-12
View File
@@ -1,12 +0,0 @@
---
# 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
@@ -1,8 +0,0 @@
---
# 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
@@ -1,26 +0,0 @@
---
# Paperclip Docker deployment
paperclip_domain: cranium-command.wdw-labs.cloud
paperclip_base_path: /opt/paperclip
paperclip_port: 3100
certbot_email: ryan.blundon@protonmail.com
# vLLM inference endpoint (local)
vllm_endpoint: "http://127.0.0.1:8000"
hermes_model:
provider: custom
default: Gemma-4-26B-A4B-Hermes-VLM-MLX # from http://fantasmic.wdw-labs.cloud:8000/v1/models
base_url: http://fantasmic.wdw-labs.cloud:8000/v1
api_key: "{{ vault_fantasmic_api_key }}"
# # DNS entries (if using your existing dns-manager role)
# dns_entries:
# - name: illuminations
# domain: wdw-labs.cloud
# type: A
# value: "{{ ansible_host }}"
# - name: muppet-vision
# domain: wdw-labs.cloud
# type: A
# value: "{{ ansible_host }}"
-244
View File
@@ -1,244 +0,0 @@
---
# ------------------------------------------------------------------------------
# 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 }}"
-22
View File
@@ -63,14 +63,6 @@ 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:
@@ -86,20 +78,6 @@ 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:
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:
@@ -1,40 +0,0 @@
---
- 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 }}"
@@ -1,7 +0,0 @@
---
# 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
@@ -1,6 +0,0 @@
---
- name: Deploy shared nginx reverse proxy and certbot
hosts: illuminations
become: true
roles:
- nginx-proxy
-6
View File
@@ -1,6 +0,0 @@
---
- name: Deploy Multica + Caddy
hosts: multica
become: true
roles:
- role: multica_docker
@@ -1,82 +0,0 @@
---
- name: Deploy Paperclip stack (behind the shared nginx-proxy)
hosts: illuminations
become: true
vars_files:
- "{{ playbook_dir }}/../group_vars/all/vars"
- "{{ playbook_dir }}/../group_vars/all/vault"
roles:
- docker-host
- nginx-proxy # must come before paperclip-docker (it publishes its vhost through it)
- paperclip-docker
post_tasks:
# CLI-based on purpose: community.docker.docker_container_info needs the
# Docker SDK for Python, which the paperclip-docker role no longer installs.
- name: Verify stack containers are running
ansible.builtin.command: "docker inspect -f '{% raw %}{{.State.Running}}{% endraw %}' {{ item }}"
register: container_status
loop:
- paperclip-db
- paperclip
- nginx
- certbot
changed_when: false
failed_when: container_status.rc != 0 or container_status.stdout | trim != 'true'
loop_control:
label: "{{ item }}"
- name: Verify Docker is available
ansible.builtin.command: docker --version
register: docker_version
changed_when: false
- name: Show Docker version
ansible.builtin.debug:
msg: "Docker version: {{ docker_version.stdout }}"
- name: Verify docker-compose plugin is available
ansible.builtin.command: docker compose version
register: compose_version
changed_when: false
ignore_errors: true
- name: Show docker-compose version
ansible.builtin.debug:
msg: "Docker Compose version: {{ compose_version.stdout | default('docker-compose plugin not found') }}"
- name: Display deployment summary
ansible.builtin.debug:
msg: |
Paperclip stack deployed successfully!
Access points:
- HTTPS: https://{{ paperclip_domain }}
- HTTP (redirects to HTTPS): http://{{ paperclip_domain }}
Services:
- Paperclip: {{ paperclip_host }}:{{ paperclip_port }}
- nginx (shared reverse proxy): 0.0.0.0:443 / 0.0.0.0:80, config in {{ nginx_proxy_conf_dir }}
- certbot: Running certificate renewal checks
Data location:
- {{ paperclip_base_path }}/data
Certificates:
- {{ nginx_proxy_certs_dir }}/live/{{ paperclip_domain }}/
vLLM endpoint:
- {{ vllm_endpoint }}
OpenCode configuration:
- Config: {{ paperclip_base_path }}/.config/opencode/opencode.jsonc
- Model: Gemma-4-26B (AWQ)
- Endpoint: {{ vllm_endpoint }}/v1
Next steps:
1. Verify DNS resolution for {{ paperclip_domain }}
2. Visit https://{{ paperclip_domain }} to access Paperclip
3. OpenCode should auto-initialize with vLLM at {{ vllm_endpoint }}
4. Run day1_deploy_hermes_docker.yml (publishes its dashboard through the same nginx)
5. Configure agents and skills
-11
View File
@@ -1,11 +0,0 @@
# 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
-35
View File
@@ -1,35 +0,0 @@
# 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`.
@@ -1,39 +0,0 @@
# roles/claude_code/defaults/main.yml
---
# How to install Claude Code:
# apt - signed Anthropic apt repo, system-wide, upgraded via apt (recommended for servers)
# native - Anthropic's installer script, per-user in ~/.local/bin, self-updating
claude_code_install_method: apt
# ---- apt method -------------------------------------------------------------
# Release channel: "stable" (~1 week behind, skips bad releases) or "latest"
claude_code_apt_channel: stable
# "present" installs once; "latest" upgrades on every run
claude_code_package_state: present
claude_code_apt_key_url: https://downloads.claude.ai/keys/claude-code.asc
claude_code_apt_key_path: /etc/apt/keyrings/claude-code.asc
# Fingerprint published in Anthropic's docs; the role refuses to continue if it differs
claude_code_apt_key_fingerprint: "31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE"
claude_code_apt_repo_base: https://downloads.claude.ai/claude-code/apt
# ---- native method ----------------------------------------------------------
# User the native installer runs as
claude_code_user: "{{ ansible_user_id }}"
# "latest", "stable", or a specific version such as "2.1.89"
claude_code_native_version: latest
# ---- common -----------------------------------------------------------------
claude_code_prereq_packages:
- curl
- ca-certificates
- gnupg
# Optional: content for ~/.claude/settings.json of claude_code_settings_user.
# Empty dict = don't touch the file. NOTE: when set, the file is overwritten.
# Example: { "autoUpdatesChannel": "stable", "env": { "DISABLE_AUTOUPDATER": "1" } }
claude_code_settings: {}
claude_code_settings_user: "{{ claude_code_user }}"
# Run `claude --version` at the end and fail if it doesn't work
claude_code_verify: true
-15
View File
@@ -1,15 +0,0 @@
# roles/claude_code/meta/main.yml
---
galaxy_info:
role_name: claude_code
author: ryan
description: Install Claude Code on Ubuntu/Debian servers (signed apt repo or native installer)
license: MIT
min_ansible_version: "2.14"
platforms:
- name: Ubuntu
versions: [focal, jammy, noble]
- name: Debian
versions: [buster, bullseye, bookworm]
galaxy_tags: [claude, ai, cli]
dependencies: []
-70
View File
@@ -1,70 +0,0 @@
# roles/claude_code/tasks/apt.yml
---
- name: Create apt keyrings directory
ansible.builtin.file:
path: /etc/apt/keyrings
state: directory
mode: "0755"
owner: root
group: root
become: true
- name: Download Anthropic release signing key
ansible.builtin.get_url:
url: "{{ claude_code_apt_key_url }}"
dest: "{{ claude_code_apt_key_path }}"
mode: "0644"
owner: root
group: root
become: true
- name: Read key fingerprint
ansible.builtin.shell:
cmd: |
set -o pipefail
gpg --show-keys --with-colons {{ claude_code_apt_key_path | quote }} | awk -F':' '/^fpr/ {print $10; exit}'
executable: /bin/bash
register: claude_code_key_fpr
changed_when: false
check_mode: false
become: true
- name: Refuse to continue if the key fingerprint does not match
ansible.builtin.assert:
that:
- claude_code_key_fpr.stdout | trim | upper == claude_code_apt_key_fingerprint | upper
fail_msg: >-
Signing key fingerprint mismatch: got '{{ claude_code_key_fpr.stdout | trim }}',
expected '{{ claude_code_apt_key_fingerprint }}'. Removing the key file.
quiet: true
register: claude_code_key_check
ignore_errors: true
- name: Remove untrusted key file
ansible.builtin.file:
path: "{{ claude_code_apt_key_path }}"
state: absent
become: true
when: claude_code_key_check is failed
- name: Stop if key check failed
ansible.builtin.fail:
msg: "{{ claude_code_key_check.msg }}"
when: claude_code_key_check is failed
- name: Configure Claude Code apt repository
ansible.builtin.apt_repository:
repo: >-
deb [signed-by={{ claude_code_apt_key_path }}]
{{ claude_code_apt_repo_base }}/{{ claude_code_apt_channel }}
{{ claude_code_apt_channel }} main
filename: claude-code
state: present
update_cache: true
become: true
- name: Install claude-code package
ansible.builtin.apt:
name: claude-code
state: "{{ claude_code_package_state }}"
become: true
-44
View File
@@ -1,44 +0,0 @@
# 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
@@ -1,20 +0,0 @@
# 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
@@ -1,22 +0,0 @@
# 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
@@ -1,17 +0,0 @@
# 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 }}"
@@ -1,91 +0,0 @@
---
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'))"
@@ -1,40 +0,0 @@
"""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)
@@ -1,5 +0,0 @@
---
# Config.yaml is read at start, so a plain restart is enough.
# Compose/env changes are handled by docker compose itself (it recreates).
- name: Restart hermes
ansible.builtin.command: "docker restart {{ hermes_container_name }}"
-148
View File
@@ -1,148 +0,0 @@
---
- 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]
@@ -1,31 +0,0 @@
# 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 }}"
@@ -1,8 +0,0 @@
# 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 %}
@@ -1,66 +0,0 @@
---
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'))"
@@ -1,39 +0,0 @@
"""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)
@@ -1,5 +0,0 @@
---
# 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 }}"
@@ -1,49 +0,0 @@
# ---- 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;
}
}
@@ -1,108 +0,0 @@
---
- 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]
@@ -1,30 +0,0 @@
# 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 }}"
@@ -1,8 +0,0 @@
# 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 %}
@@ -65,6 +65,11 @@ baseline_packages_extra: []
# provisions on every host. Service users (e.g. 'cast' for podman # provisions on every host. Service users (e.g. 'cast' for podman
# workloads) are created by their respective application roles, not here. # workloads) are created by their respective application roles, not here.
baseline_users: baseline_users:
- name: wed
comment: "Default admin user"
shell: /bin/bash
groups: "{{ baseline_sudo_group }}"
sudo_nopasswd: true
- name: jarvis - name: jarvis
comment: "JARVIS automation account" comment: "JARVIS automation account"
shell: /bin/bash shell: /bin/bash
@@ -72,6 +77,11 @@ baseline_users:
sudo_nopasswd: true sudo_nopasswd: true
ssh_authorized_keys: ssh_authorized_keys:
- "{{ jarvis_ssh_public_key }}" - "{{ jarvis_ssh_public_key }}"
- name: rblundon
comment: "Ryan Blundon"
shell: /bin/bash
groups: "{{ baseline_sudo_group }}"
sudo_nopasswd: true
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# SSH hardening # SSH hardening
-61
View File
@@ -1,61 +0,0 @@
# 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"`
@@ -1,70 +0,0 @@
---
# ───────────────────────── 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
@@ -1,13 +0,0 @@
---
- 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
@@ -1,14 +0,0 @@
---
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: []
@@ -1,51 +0,0 @@
---
- 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
@@ -1,16 +0,0 @@
---
- 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]
@@ -1,147 +0,0 @@
---
- 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
@@ -1,88 +0,0 @@
---
- 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.
@@ -1,32 +0,0 @@
---
- 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 }}"
@@ -1,73 +0,0 @@
# 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 %}
@@ -1,6 +0,0 @@
# 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
@@ -1 +0,0 @@
CLOUDFLARE_API_TOKEN={{ caddy_cloudflare_api_token }}
@@ -1,26 +0,0 @@
# 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:
@@ -1,32 +0,0 @@
---
# Fixed by Multica's docker-compose.selfhost.yml (bound to 127.0.0.1)
_multica_frontend_port: 3000
_multica_backend_port: 8080
_multica_compose_file: docker-compose.selfhost.yml
_multica_app_host: "{{ multica_domain if multica_layout == 'single' else multica_app_domain }}"
_multica_api_host: "{{ multica_domain if multica_layout == 'single' else multica_api_domain }}"
_multica_port_suffix: "{{ '' if caddy_https_port | int == 443 else ':' ~ caddy_https_port }}"
_multica_app_url: "https://{{ _multica_app_host }}{{ _multica_port_suffix }}"
_multica_api_url: "https://{{ _multica_api_host }}{{ _multica_port_suffix }}"
_multica_env_core:
JWT_SECRET: "{{ multica_jwt_secret }}"
POSTGRES_PASSWORD: "{{ multica_postgres_password }}"
MULTICA_VCS_SECRET_KEY: "{{ multica_vcs_secret_key }}"
MULTICA_IMAGE_TAG: "{{ multica_image_tag }}"
FRONTEND_ORIGIN: "{{ _multica_app_url }}"
MULTICA_APP_URL: "{{ _multica_app_url }}"
# Split layout only: daemons/CLI talk to the API hostname
_multica_env_split: "{{ {'MULTICA_PUBLIC_URL': _multica_api_url} if multica_layout == 'split' else {} }}"
_multica_env_telemetry: "{{ {'DO_NOT_TRACK': 'true'} if multica_disable_telemetry | bool else {} }}"
_multica_env_resend: "{{ {'RESEND_API_KEY': multica_resend_api_key} if (multica_resend_api_key | length) > 0 else {} }}"
_multica_env: >-
{{ _multica_env_core
| combine(_multica_env_split, _multica_env_telemetry, _multica_env_resend, multica_extra_env) }}
# Health URL used by post-deploy verification
_multica_public_health_url: >-
{{ _multica_app_url ~ '/health' if multica_layout == 'single' else _multica_api_url ~ '/readyz' }}
-28
View File
@@ -1,28 +0,0 @@
# 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;`).
@@ -1,27 +0,0 @@
---
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
@@ -1,92 +0,0 @@
#!/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
@@ -1,6 +0,0 @@
---
- 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
@@ -1,118 +0,0 @@
---
# Publish an app through nginx-proxy. Usage:
#
# - ansible.builtin.include_role:
# name: nginx-proxy
# tasks_from: add_vhost
# vars:
# vhost_name: paperclip # required, becomes conf.d/<name>.conf
# vhost_domain: "{{ paperclip_domain }}" # required
# vhost_upstream_port: 3100 # required
# vhost_upstream_host: 127.0.0.1 # optional
# vhost_proxy_timeout: 60s # optional (use 3600s for WebSockets)
# vhost_health_path: /health # optional, proxied without access log
#
# Order: marker -> certificate -> vhost file -> nginx -t -> reload.
# The vhost is only written once the certificate exists, and a vhost that
# fails nginx -t is rolled back, so one bad app cannot take the others down.
- name: Validate vhost parameters
ansible.builtin.assert:
that:
- vhost_name is defined
- vhost_domain is defined
- vhost_upstream_port is defined
fail_msg: "add_vhost needs vhost_name, vhost_domain and vhost_upstream_port"
quiet: true
- name: Check that the nginx-proxy containers are running
ansible.builtin.command: "docker inspect -f '{% raw %}{{.State.Running}}{% endraw %}' {{ item }}"
register: nginx_proxy_running
failed_when: nginx_proxy_running.rc != 0 or nginx_proxy_running.stdout | trim != 'true'
changed_when: false
loop:
- "{{ nginx_proxy_nginx_container }}"
- "{{ nginx_proxy_certbot_container }}"
loop_control:
label: "{{ item }}"
- name: "Register cert marker for {{ vhost_domain }}"
ansible.builtin.file:
path: "{{ nginx_proxy_domains_dir }}/{{ vhost_domain }}"
state: touch
modification_time: preserve
access_time: preserve
mode: "0644"
- name: "Check for existing certificate for {{ vhost_domain }}"
ansible.builtin.stat:
path: "{{ nginx_proxy_certs_dir }}/live/{{ vhost_domain }}/fullchain.pem"
register: nginx_proxy_cert_before
# Targeted run: only this domain, so another app's failed issuance
# cannot fail this play.
- name: "Issue certificate for {{ vhost_domain }}"
ansible.builtin.command: >-
docker exec {{ nginx_proxy_certbot_container }}
/scripts/certbot-init.sh {{ vhost_domain }}
register: nginx_proxy_cert_issue
changed_when: "'Certificate obtained' in nginx_proxy_cert_issue.stdout"
when: not nginx_proxy_cert_before.stat.exists
- name: "Confirm certificate exists for {{ vhost_domain }}"
ansible.builtin.stat:
path: "{{ nginx_proxy_certs_dir }}/live/{{ vhost_domain }}/fullchain.pem"
register: nginx_proxy_cert_after
failed_when: not nginx_proxy_cert_after.stat.exists
- name: "Write {{ vhost_name }} vhost, test, and reload"
block:
- name: "Template conf.d/{{ vhost_name }}.conf"
ansible.builtin.template:
src: vhost.conf.j2
dest: "{{ nginx_proxy_conf_dir }}/{{ vhost_name }}.conf"
mode: "0644"
backup: true
register: nginx_proxy_vhost_file
- name: Test nginx config
ansible.builtin.command: "docker exec {{ nginx_proxy_nginx_container }} nginx -t"
changed_when: false
- name: Reload nginx
ansible.builtin.command: "docker exec {{ nginx_proxy_nginx_container }} nginx -s reload"
when: nginx_proxy_vhost_file.changed
- name: Remove superseded backup
ansible.builtin.file:
path: "{{ nginx_proxy_vhost_file.backup_file }}"
state: absent
when: nginx_proxy_vhost_file.backup_file is defined
rescue:
- name: Restore previous vhost
ansible.builtin.copy:
src: "{{ nginx_proxy_vhost_file.backup_file }}"
dest: "{{ nginx_proxy_conf_dir }}/{{ vhost_name }}.conf"
remote_src: true
mode: "0644"
when: nginx_proxy_vhost_file.backup_file | default('') | length > 0
- name: Remove backup after restoring it
ansible.builtin.file:
path: "{{ nginx_proxy_vhost_file.backup_file }}"
state: absent
when: nginx_proxy_vhost_file.backup_file | default('') | length > 0
- name: Remove the new vhost (there was no previous one)
ansible.builtin.file:
path: "{{ nginx_proxy_conf_dir }}/{{ vhost_name }}.conf"
state: absent
when:
- nginx_proxy_vhost_file is defined
- nginx_proxy_vhost_file.backup_file | default('') | length == 0
- name: Fail with context
ansible.builtin.fail:
msg: >-
nginx rejected the {{ vhost_name }} vhost; it was rolled back and
the running config is unchanged.
-70
View File
@@ -1,70 +0,0 @@
---
- 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]
@@ -1,31 +0,0 @@
# 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;
}
@@ -1,46 +0,0 @@
# 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
@@ -1,3 +0,0 @@
# Managed by Ansible (nginx-proxy role)
CLOUDFLARE_API_TOKEN={{ nginx_proxy_cloudflare_api_token }}
CERTBOT_EMAIL={{ nginx_proxy_certbot_email }}
@@ -1,51 +0,0 @@
# 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 %}
}
@@ -1,191 +0,0 @@
# Installation Guide - Paperclip Docker Role
## File Placement
Copy the files from this directory to your ansible repo as follows:
### Role Files
Create the role directory structure:
```bash
mkdir -p roles/paperclip-docker/{defaults,handlers,tasks,templates}
```
Then copy:
| From | To |
|------|-----|
| `defaults_main.yml` | `roles/paperclip-docker/defaults/main.yml` |
| `handlers_main.yml` | `roles/paperclip-docker/handlers/main.yml` |
| `tasks_main.yml` | `roles/paperclip-docker/tasks/main.yml` |
| `docker-compose.yml.j2` | `roles/paperclip-docker/templates/docker-compose.yml.j2` |
| `nginx.conf.j2` | `roles/paperclip-docker/templates/nginx.conf.j2` |
| `certbot-init.sh.j2` | `roles/paperclip-docker/templates/certbot-init.sh.j2` |
| `README.md` | `roles/paperclip-docker/README.md` |
### Host Variables
Create the host directory:
```bash
mkdir -p host_vars/illuminations.wdw-labs.cloud
```
Then copy:
| From | To |
|------|-----|
| `host_vars_illuminations.yml` | `host_vars/illuminations.wdw-labs.cloud/vars.yml` |
### Playbook
Copy:
| From | To |
|------|-----|
| `deploy_paperclip_stack.yml` | `playbooks/deploy_paperclip_stack.yml` |
## Complete Installation Command
```bash
# From your ansible repo root
# Create directories
mkdir -p roles/paperclip-docker/{defaults,handlers,tasks,templates}
mkdir -p host_vars/illuminations.wdw-labs.cloud
# Copy files (adjust path to where you downloaded them)
cp /path/to/defaults_main.yml roles/paperclip-docker/defaults/main.yml
cp /path/to/handlers_main.yml roles/paperclip-docker/handlers/main.yml
cp /path/to/tasks_main.yml roles/paperclip-docker/tasks/main.yml
cp /path/to/docker-compose.yml.j2 roles/paperclip-docker/templates/
cp /path/to/nginx.conf.j2 roles/paperclip-docker/templates/
cp /path/to/certbot-init.sh.j2 roles/paperclip-docker/templates/
cp /path/to/README.md roles/paperclip-docker/
cp /path/to/host_vars_illuminations.yml host_vars/illuminations.wdw-labs.cloud/vars.yml
cp /path/to/deploy_paperclip_stack.yml playbooks/
# Verify structure
tree roles/paperclip-docker
tree host_vars/illuminations.wdw-labs.cloud
ls -la playbooks/deploy_paperclip_stack.yml
```
## Final Directory Structure
After installation, your ansible repo should have:
```
.
├── roles/
│ └── paperclip-docker/
│ ├── README.md
│ ├── defaults/
│ │ └── main.yml
│ ├── handlers/
│ │ └── main.yml
│ ├── tasks/
│ │ └── main.yml
│ └── templates/
│ ├── certbot-init.sh.j2
│ ├── docker-compose.yml.j2
│ └── nginx.conf.j2
├── playbooks/
│ ├── deploy_paperclip_stack.yml
│ └── (other existing playbooks)
├── host_vars/
│ ├── illuminations.wdw-labs.cloud/
│ │ └── vars.yml
│ └── (other existing host vars)
├── group_vars/
│ └── (existing group vars)
├── inventory.yml
├── ansible.cfg
└── (other existing files)
```
## Next Steps
1. **Copy the files** as described above
2. **Review variables** in `host_vars/illuminations.wdw-labs.cloud/vars.yml` - update if needed
3. **Verify vault** contains `vault_cloudflare_api_token`
4. **Test connectivity**:
```bash
ansible -i inventory.yml illuminations.wdw-labs.cloud -m ping
```
5. **Run the playbook**:
```bash
ansible-playbook playbooks/deploy_paperclip_stack.yml -i inventory.yml -K
```
(The `-K` prompts for become password)
**Note:** The playbook automatically calls the `docker-host` role first to ensure Docker is installed. No manual Docker setup needed.
## Verification After Installation
```bash
# Verify the role exists
ls -la roles/paperclip-docker/
# Verify host vars exist
cat host_vars/illuminations.wdw-labs.cloud/vars.yml
# Verify playbook exists
cat playbooks/deploy_paperclip_stack.yml
# Test syntax
ansible-playbook playbooks/deploy_paperclip_stack.yml --syntax-check
# Dry run
ansible-playbook playbooks/deploy_paperclip_stack.yml -i inventory.yml --check
```
## Troubleshooting Installation
### Role not found error
```
ERROR! the role 'paperclip-docker' was not found
```
**Solution:** Ensure `roles/paperclip-docker/` exists with `tasks/main.yml` inside.
### Template not found error
```
ERROR! Unable to find template paperclip-docker/docker-compose.yml.j2
```
**Solution:** Ensure templates are in `roles/paperclip-docker/templates/` with `.j2` extension.
### Vault variable not found
```
fatal: [illuminations]: FAILED! => error: Undefined variable: vault_cloudflare_api_token
```
**Solution:** Ensure `vault_cloudflare_api_token` is defined in your vault file:
```bash
ansible-vault view group_vars/all/vault
```
### Host not in inventory
```
ERROR! Unable to parse illuminations.wdw-labs.cloud as an inventory address
```
**Solution:** Ensure your `inventory.yml` contains the host:
```yaml
all:
hosts:
illuminations.wdw-labs.cloud:
ansible_host: <ip_address>
```
## Files Reference
All files are self-contained and ready to use. No modifications needed unless:
- You want to change the domain (edit `host_vars/illuminations.wdw-labs.cloud/vars.yml`)
- You want to change ports (edit `roles/paperclip-docker/defaults/main.yml`)
- You want to change the certbot email (edit host_vars)
For complete documentation, see `roles/paperclip-docker/README.md` after installation.
-136
View File
@@ -1,136 +0,0 @@
---
- 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]
@@ -1,35 +0,0 @@
---
# 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.
@@ -1,12 +0,0 @@
---
# 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))
@@ -1,156 +0,0 @@
---
- 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]
@@ -1,54 +0,0 @@
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
@@ -1,10 +0,0 @@
{
"vllm": {
"type": "api",
"key": "{{ vault_vllm_api_key }}"
},
"fantasmic": {
"type": "api",
"key": "{{ vault_fantasmic_api_key }}"
}
}
@@ -1,40 +0,0 @@
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"illuminations": {
"npm": "@ai-sdk/openai-compatible",
"name": "illuminations",
"models": {
"gemma-4-26b": {
"id": "Gemma-4-26B-A4B-it-AWQ",
"name": "Gemma 4 26B (Local AWQ)"
}
},
"options": {
"baseURL": "{{ illuminations_endpoint }}/v1",
"headers": {
"Authorization": "Bearer {env:ILLUMINATIONS_API_KEY}"
}
}
}
},
"provider": {
"fantasmic": {
"npm": "@ai-sdk/openai-compatible",
"name": "fantasmic",
"models": {
"gemma-4-26b-hermes": {
"id": "Gemma-4-26B-A4B-Hermes-VLM-MLX",
"name": "Gemma 4 26B (Hermes)"
}
},
"options": {
"baseURL": "{{ fantasmic_endpoint }}/v1",
"headers": {
"Authorization": "Bearer {{ vault_fantasmic_api_key }}"
}
}
}
},
"model": "vllm/gemma-4-26b"
}
@@ -1,39 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: body-wars
namespace: argocd
annotations:
argocd.argoproj.io/sync-wave: "9"
spec:
project: default
source:
repoURL: https://helm-charts.open-webui.com
chart: open-webui
targetRevision: 16.3.0
helm:
values: |
ingress:
enabled: true
className: nginx
hosts:
- host: body-wars.local.mk-labs.cloud
paths:
- path: /
pathType: Prefix
tls:
- secretName: body-wars-tls
hosts:
- body-wars.local.mk-labs.cloud
openwebui:
backend:
url: http://astro-orbiter:8000/v1
destination:
server: https://kubernetes.default.svc
namespace: open-webui
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
@@ -88,5 +88,5 @@ spec:
# Kubernetes-deployed consumer. # Kubernetes-deployed consumer.
- secretKey: HINDSIGHT_API_LLM_API_KEY - secretKey: HINDSIGHT_API_LLM_API_KEY
remoteRef: remoteRef:
key: illuminations key: vllm
property: api-key property: api-key
+2 -6
View File
@@ -25,7 +25,7 @@
# so version: "0.9.1" pins the API image to 0.9.1. # so version: "0.9.1" pins the API image to 0.9.1.
# ============================================================================ # ============================================================================
version: "0.10.2" version: "0.9.1"
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# External PostgreSQL (chart's bundled postgresql is disabled). # External PostgreSQL (chart's bundled postgresql is disabled).
@@ -88,7 +88,7 @@ api:
# key -- only the served model name changed. Gemma 4 does NOT emit a # key -- only the served model name changed. Gemma 4 does NOT emit a
# reasoning trace by default (confirmed live) -- simpler completion # reasoning trace by default (confirmed live) -- simpler completion
# parsing than DeepSeek-R1's always-on <think> blocks. # parsing than DeepSeek-R1's always-on <think> blocks.
HINDSIGHT_API_LLM_BASE_URL: "http://illuminations.wdw-labs.cloud:8000/v1" HINDSIGHT_API_LLM_BASE_URL: "http://astro-orbiter:8000/v1"
HINDSIGHT_API_LLM_PROVIDER: "openai" HINDSIGHT_API_LLM_PROVIDER: "openai"
HINDSIGHT_API_LLM_MODEL: "Gemma-4-26B-A4B-it-AWQ" HINDSIGHT_API_LLM_MODEL: "Gemma-4-26B-A4B-it-AWQ"
# vLLM's max_model_len is now 65536 (up from DeepSeek's 32768, up from # vLLM's max_model_len is now 65536 (up from DeepSeek's 32768, up from
@@ -148,7 +148,6 @@ api:
# retain path explicitly; the global timeout covers reflect/consolidation. # retain path explicitly; the global timeout covers reflect/consolidation.
HINDSIGHT_API_LLM_TIMEOUT: "600" HINDSIGHT_API_LLM_TIMEOUT: "600"
HINDSIGHT_API_RETAIN_LLM_TIMEOUT: "600" HINDSIGHT_API_RETAIN_LLM_TIMEOUT: "600"
HINDSIGHT_API_WORKER_ID: "hindsight-api-64b6644c8b-pv2sv"
# ---------------------------------------------------------------------------- # ----------------------------------------------------------------------------
# Ingress via the chart's native template. # Ingress via the chart's native template.
@@ -176,9 +175,6 @@ ingress:
- path: /v1 - path: /v1
pathType: Prefix pathType: Prefix
service: api service: api
- path: /version
pathType: Prefix
service: api
- path: /mcp - path: /mcp
pathType: Prefix pathType: Prefix
service: api service: api
@@ -1,84 +0,0 @@
# ============================================================================
# ArgoCD Application: LiteLLM proxy (skyliner)
# Wave: 8 (same tier as hindsight / openviking)
# Deployment method: GitOps (Gitea -> ArgoCD)
# ============================================================================
#
# Multi-source: upstream OCI Helm chart (pinned) + local values + manifests
# from repo (namespace, ExternalSecrets, HTTPRoute, ExternalName Services,
# ServiceMonitor). Follows the openviking / hindsight pattern.
#
# Chart: oci://ghcr.io/berriai/litellm-helm (version == appVersion)
# Includes bundled Bitnami PostgreSQL (db.deployStandalone: true).
#
# NOTE (ArgoCD + OCI): repoURL has NO "oci://" prefix. If the OCI registry is not
# already usable by ArgoCD, register it once (public repo, no creds needed):
# argocd repo add ghcr.io/berriai --type helm --name berriai-oci --enable-oci
#
# Platform changes required BEFORE this syncs (see DEPLOYMENT GATE below).
# ============================================================================
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: litellm
namespace: argocd
labels:
app.kubernetes.io/name: litellm
app.kubernetes.io/part-of: mk-labs
annotations:
argocd.argoproj.io/sync-wave: "8"
description: |
LiteLLM OpenAI-compatible gateway for Paperclip/OpenCode and Hermes agents.
Host: skyliner.wdw-labs.cloud (fastpass-gateway, HTTPRoute). Admin UI at /ui.
Backends: illuminations vLLM (Gemma-4-26B); Mac Studio (fantasmic) and
Mac Mini (wishes) as placeholders.
spec:
project: default
sources:
# Source 1: upstream OCI Helm chart, pinned
- repoURL: ghcr.io/berriai
chart: litellm-helm
targetRevision: 1.106.1
helm:
releaseName: litellm
valueFiles:
- $values/cluster/applications/litellm/values.yaml
# Source 2: homelab repo — values ref + manifests
- repoURL: https://gitea.mk-labs.cloud/rblundon/homelab.git
targetRevision: main
path: cluster/applications/litellm
ref: values
directory:
# Not Kubernetes manifests
exclude: "{application.yaml,values.yaml}"
destination:
server: https://kubernetes.default.svc
namespace: litellm
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
# ============================================================================
# DEPLOYMENT GATE: DO NOT MERGE/SYNC UNTIL
# ============================================================================
# 1. 1Password vault wdw-labs has item "litellm" (master-key, salt-key, db-password,
# db-admin-password) and items "illuminations", "fantasmic",
# "wishes" (field "api-key"); the onepassword-connect token can read
# wdw-labs and the ClusterSecretStore lists it (platform/onepassword-connect).
# (master-key must start with "sk-"; salt-key is PERMANENT — never rotate it
# after models/keys are stored, or stored credentials become undecryptable).
# 2. Platform changes are live: cert-manager dnsZones (wdw-labs.cloud), gateway
# listener + skyliner Certificate, ReferenceGrant for litellm, and external-dns
# (Technitium) can write to the wdw-labs.cloud zone (zone exists + TSIG key
# authorized for it).
# 3. DNS names fantasmic.wdw-labs.cloud / wishes.wdw-labs.cloud resolve from pods.
# 4. Verified with `helm template` (see README / hand-off notes).
# ============================================================================
@@ -1,26 +0,0 @@
# ExternalName Services for Mac inference endpoints (DNS names, not raw IPs).
# Referenced from proxy_config api_base in values.yaml.
---
apiVersion: v1
kind: Service
metadata:
name: fantasmic
namespace: litellm
labels:
app.kubernetes.io/component: llm-inference
app.kubernetes.io/name: mac-studio
spec:
type: ExternalName
externalName: fantasmic.wdw-labs.cloud # Mac Studio (Tier 1 Hermes / Gemma-4 VLM)
---
apiVersion: v1
kind: Service
metadata:
name: wishes
namespace: litellm
labels:
app.kubernetes.io/component: llm-inference
app.kubernetes.io/name: mac-mini
spec:
type: ExternalName
externalName: wishes.wdw-labs.cloud # Mac Mini M4 (Tier 3 / oMLX)
@@ -1,82 +0,0 @@
# ExternalSecrets - LiteLLM
# Store: onepassword-connect ClusterSecretStore (vaults: mk-labs, wdw-labs)
#
# 1Password, vault wdw-labs:
# Item "litellm" (CONCEALED fields):
# master-key LiteLLM admin/master key (must start with "sk-")
# salt-key LITELLM_SALT_KEY — encrypts stored provider creds. NEVER ROTATE.
# db-password Postgres password for user "litellm" (use alphanumerics,
# e.g. `openssl rand -hex 24`; it is embedded in a URL)
# db-admin-password Postgres "postgres" superuser password
# Items "illuminations", "fantasmic", "wishes" — field "api-key"
#
# Wave -1: secrets exist before the Deployment / migration job / Postgres.
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: litellm-credentials
namespace: litellm
annotations:
argocd.argoproj.io/sync-wave: "-1"
spec:
refreshInterval: "1h"
secretStoreRef:
kind: ClusterSecretStore
name: onepassword-connect
target:
name: litellm-credentials
creationPolicy: Owner
template:
engineVersion: v2
data:
# Chart masterkeySecretName/Key
master-key: "{{ .MASTER_KEY }}"
# Chart db.secret + bundled Postgres existingSecret
username: litellm
password: "{{ .DB_PASSWORD }}"
postgres-password: "{{ .DB_ADMIN_PASSWORD }}"
data:
- secretKey: MASTER_KEY
remoteRef: { key: litellm, property: master-key }
- secretKey: DB_PASSWORD
remoteRef: { key: litellm, property: db-password }
- secretKey: DB_ADMIN_PASSWORD
remoteRef: { key: litellm, property: db-admin-password }
---
# Exported wholesale as env vars via chart `environmentSecrets`
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: litellm-env
namespace: litellm
annotations:
argocd.argoproj.io/sync-wave: "-1"
spec:
refreshInterval: "1h"
secretStoreRef:
kind: ClusterSecretStore
name: onepassword-connect
target:
name: litellm-env
creationPolicy: Owner
template:
engineVersion: v2
data:
LITELLM_SALT_KEY: "{{ .SALT_KEY }}"
# Full URL for the proxy Deployment (see values.yaml environmentSecrets note)
DATABASE_URL: "postgresql://litellm:{{ .DB_PASSWORD | urlquery }}@litellm-postgresql:5432/litellm"
ILLUMINATIONS_API_KEY: "{{ .ILLUMINATIONS_API_KEY }}"
FANTASMIC_API_KEY: "{{ .FANTASMIC_API_KEY }}"
WISHES_API_KEY: "{{ .WISHES_API_KEY }}"
data:
- secretKey: DB_PASSWORD
remoteRef: { key: litellm, property: db-password }
- secretKey: SALT_KEY
remoteRef: { key: litellm, property: salt-key }
- secretKey: ILLUMINATIONS_API_KEY
remoteRef: { key: illuminations, property: api-key }
- secretKey: FANTASMIC_API_KEY
remoteRef: { key: fantasmic, property: api-key }
- secretKey: WISHES_API_KEY
remoteRef: { key: wishes, property: api-key }
@@ -1,31 +0,0 @@
# HTTPRoute — LiteLLM (skyliner) via Cilium fastpass-gateway
# Certificate + dedicated listener live in cluster/platform/gateway
# (certificate-skyliner.yaml, gateway.yaml listener "https-skyliner").
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: skyliner
namespace: litellm
annotations:
external-dns.alpha.kubernetes.io/hostname: skyliner.wdw-labs.cloud
external-dns.alpha.kubernetes.io/target: "10.1.71.90"
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: fastpass-gateway
namespace: gateway
sectionName: https-skyliner
hostnames:
- skyliner.wdw-labs.cloud
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- group: ""
kind: Service
name: litellm
port: 4000
weight: 1
@@ -1,4 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: litellm
-170
View File
@@ -1,170 +0,0 @@
# ============================================================================
# LiteLLM Helm values — overrides for oci://ghcr.io/berriai/litellm-helm 1.106.1
# Cluster: fastpass (Talos). Host: skyliner.wdw-labs.cloud. Wave 8.
# Upstream defaults for reference: values.upstream.yaml (same dir; not applied).
# ============================================================================
replicaCount: 1
image:
repository: ghcr.io/berriai/litellm
# Empty tag => chart appVersion (1.106.1). Version is pinned via chart version
# in application.yaml; bump both together.
tag: ""
pullPolicy: IfNotPresent
# Exposure is via Gateway API HTTPRoute (httproute.yaml), not the chart ingress.
ingress:
enabled: false
service:
type: ClusterIP
port: 4000
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 2Gi
# --- Secrets -----------------------------------------------------------------
# Master key from ExternalSecret litellm-credentials (key: master-key) -> PROXY_MASTER_KEY
masterkeySecretName: litellm-credentials
masterkeySecretKey: master-key
# All keys of litellm-env exported as env vars (LITELLM_SALT_KEY, ILLUMINATIONS_/FANTASMIC_/WISHES_API_KEY)
# (includes DATABASE_URL: the Deployment template ignores db.useExisting and would
# otherwise use the chart's placeholder DB password; envFrom supplies the real URL.
# Not extraEnvVars: the chart also injects those into the migration Job -> duplicate env.)
environmentSecrets:
- litellm-env
# --- Database: bundled Bitnami PostgreSQL ------------------------------------
db:
# Bundled Postgres is still deployed (deployStandalone), but the app and the
# migration job are pointed at it as an "existing" DB so credentials come from
# litellm-credentials (ExternalSecret) instead of the chart's placeholder secret.
deployStandalone: true
useExisting: true
endpoint: litellm-postgresql
database: litellm
secret:
name: litellm-credentials
usernameKey: username
passwordKey: password
postgresql:
architecture: standalone
auth:
username: litellm
database: litellm
existingSecret: litellm-credentials
secretKeys:
adminPasswordKey: postgres-password
userPasswordKey: password
primary:
persistence:
enabled: true
storageClass: px-fa-direct-access # Portworx direct access to FlashArray (as openviking)
size: 10Gi
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 1Gi
redis:
enabled: false
# Schema migrations. The chart's default is an ArgoCD PreSync hook, which runs
# BEFORE Postgres/ExternalSecrets exist and would block first install. Instead run
# it as a Sync-phase hook ordered by wave: ExternalSecrets (-1) -> Postgres (0)
# -> migration Job (1) -> Deployment (2).
migrationJob:
enabled: true
hooks:
argocd:
enabled: false
helm:
enabled: false
jobAnnotations:
argocd.argoproj.io/hook: Sync
argocd.argoproj.io/hook-delete-policy: BeforeHookCreation
argocd.argoproj.io/sync-wave: "1"
deploymentAnnotations:
argocd.argoproj.io/sync-wave: "2"
logLevel: INFO
# --- Prometheus ----------------------------------------------------------------
serviceMonitor:
enabled: true
labels:
release: monitoring # kube-prometheus-stack selector (matches Harbor/external-dns)
interval: 30s
scrapeTimeout: 10s
namespaceSelector:
matchNames:
- litellm
# --- LiteLLM proxy config (rendered to ConfigMap) ------------------------------
proxy_config:
model_list:
# --- illuminations (RTX 3090, vLLM) --------------------------------------
- model_name: Gemma-4-26B-A4B-it-AWQ
litellm_params:
model: openai/Gemma-4-26B-A4B-it-AWQ
api_base: http://illuminations.wdw-labs.cloud:8000/v1
api_key: os.environ/ILLUMINATIONS_API_KEY
# --- Mac Studio (fantasmic, oMLX) — Tier 1 Hermes ------------------------
- model_name: Gemma-4-26B-A4B-Hermes-VLM-MLX
litellm_params:
model: openai/Gemma-4-26B-A4B-Hermes-VLM-MLX
api_base: http://fantasmic.litellm.svc.cluster.local:8000/v1
api_key: os.environ/FANTASMIC_API_KEY
- model_name: Qwen3.8-27B-4bit
litellm_params:
model: openai/mlx-community--Qwen3.8-27B-4bit
api_base: http://fantasmic.litellm.svc.cluster.local:8000/v1
api_key: os.environ/FANTASMIC_API_KEY
# Also served, not exposed: incoai--Qwen3.8-27B-DFlash2, ukisai-Swift-Qwen3.8-27b-MTPLX
# --- Mac Mini M4 (wishes, oMLX) — Tier 3 aux tasks -----------------------
- model_name: Qwen2.5-7B-Instruct-4bit
litellm_params:
model: openai/Qwen2.5-7B-Instruct-4bit
api_base: http://wishes.litellm.svc.cluster.local:8000/v1
api_key: os.environ/WISHES_API_KEY
- model_name: Phi-3.5-mini-instruct-4bit
litellm_params:
model: openai/Phi-3.5-mini-instruct-4bit
api_base: http://wishes.litellm.svc.cluster.local:8000/v1
api_key: os.environ/WISHES_API_KEY
- model_name: Phi-3.5-mini-instruct-bf16
litellm_params:
model: openai/Phi-3.5-mini-instruct-bf16
api_base: http://wishes.litellm.svc.cluster.local:8000/v1
api_key: os.environ/WISHES_API_KEY
- model_name: Phi-3.5-vision-instruct-4bit
litellm_params:
model: openai/Phi-3.5-vision-instruct-4bit
api_base: http://wishes.litellm.svc.cluster.local:8000/v1
api_key: os.environ/WISHES_API_KEY
- model_name: gemma-2-2b-it-4bit
litellm_params:
model: openai/gemma-2-2b-it-4bit
api_base: http://wishes.litellm.svc.cluster.local:8000/v1
api_key: os.environ/WISHES_API_KEY
litellm_settings:
drop_params: true
callbacks: ["prometheus"] # exposes /metrics for the ServiceMonitor
general_settings:
master_key: os.environ/PROXY_MASTER_KEY
# Allow adding models / virtual keys from the Admin UI (/ui), stored in Postgres
store_model_in_db: true
@@ -1,24 +0,0 @@
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: obsidian-vault
namespace: argocd
spec:
project: default
source:
repoURL: https://gitea.mk-labs.cloud/rblundon/homelab.git
targetRevision: main
path: cluster/applications/obsidian-vault
directory:
# Exclude the Application manifest itself (already in argocd)
exclude: "application.yaml"
destination:
server: https://kubernetes.default.svc
namespace: obsidian
syncPolicy:
automated:
prune: true
selfHeal: true
syncOptions:
- ServerSideApply=true
- PruneLast=true
@@ -1,94 +0,0 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: obsidian-vault
labels:
app.kubernetes.io/name: obsidian-vault
annotations:
argocd.argoproj.io/sync-wave: "8"
spec:
replicas: 1 # exactly one sync "device" per vault copy
strategy:
type: Recreate # never two sync clients on the same folder
selector:
matchLabels:
app.kubernetes.io/name: obsidian-vault
template:
metadata:
labels:
app.kubernetes.io/name: obsidian-vault
spec:
automountServiceAccountToken: false
securityContext:
runAsNonRoot: true
runAsUser: 10000
runAsGroup: 10000
fsGroup: 10000
fsGroupChangePolicy: OnRootMismatch
seccompProfile:
type: RuntimeDefault
containers:
- name: ob
image: ghcr.io/CHANGE_ME/obsidian-headless:0.0.13 # build from ./image
env:
- name: SYNC_MODE # mirror-remote = download only, revert local changes
value: mirror-remote
- name: DEBUG # "1" = idle instead of syncing, then kubectl exec in
value: "0"
- name: VAULT_PATH
value: /vault
- name: DEVICE_NAME # shows up in Obsidian's sync version history
value: k8s-obsidian
- name: EXCLUDED_FOLDERS # comma-separated folders that must never reach the cluster
value: ""
- name: HOME
value: /state
- name: XDG_CONFIG_HOME
value: /state/config
- name: VAULT_NAME
valueFrom:
secretKeyRef:
name: obsidian-vault
key: vault_name
- name: E2EE_PASSWORD
valueFrom:
secretKeyRef:
name: obsidian-vault
key: e2ee_password
optional: true
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
volumeMounts:
- name: vault
mountPath: /vault
- name: state
mountPath: /state
- name: auth
mountPath: /secrets
readOnly: true
- name: tmp
mountPath: /tmp
volumes:
- name: vault
persistentVolumeClaim:
claimName: obsidian-vault
- name: state
persistentVolumeClaim:
claimName: obsidian-sync-state
- name: auth
secret:
secretName: obsidian-sync
items:
- key: auth_token
path: auth_token
- name: tmp
emptyDir: {}
@@ -1,9 +0,0 @@
# No official image exists. Pin the npm version: `npm view obsidian-headless version`
FROM node:22-bookworm-slim
ARG OBSIDIAN_HEADLESS_VERSION=0.0.13
RUN npm install -g obsidian-headless@${OBSIDIAN_HEADLESS_VERSION} \
&& useradd -u 10000 -m -d /state ob
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod 0755 /usr/local/bin/entrypoint.sh
USER 10000
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
@@ -1,35 +0,0 @@
#!/bin/sh
set -eu
: "${VAULT_PATH:=/vault}"
: "${SYNC_MODE:=mirror-remote}"
: "${DEVICE_NAME:=k8s-obsidian}"
: "${VAULT_NAME:?VAULT_NAME is required}"
CFG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/obsidian-headless"
mkdir -p "$CFG_DIR"
install -m 0600 /secrets/auth_token "$CFG_DIR/auth_token"
# Debug mode: token is in place, but don't sync. Exec in and run `ob ...` by hand.
if [ "${DEBUG:-0}" = "1" ]; then
echo "DEBUG=1: idling. kubectl exec -it deploy/obsidian-sync -- sh"
exec sleep infinity
fi
# Link the folder to the remote vault once. UNTESTED assumption: sync-status exits
# non-zero when the folder isn't configured yet; verify on your obsidian-headless version.
if ! ob sync-status --path "$VAULT_PATH" >/dev/null 2>&1; then
set -- --vault "$VAULT_NAME" --path "$VAULT_PATH" --device-name "$DEVICE_NAME"
if [ -n "${E2EE_PASSWORD:-}" ]; then
set -- "$@" --password "$E2EE_PASSWORD"
fi
ob sync-setup "$@"
fi
# --configs "" = don't sync .obsidian app/plugin settings from this device
ob sync-config --path "$VAULT_PATH" \
--mode "$SYNC_MODE" \
--conflict-strategy merge \
--configs "" \
--excluded-folders "${EXCLUDED_FOLDERS:-}"
exec ob sync --path "$VAULT_PATH" --continuous
@@ -1,8 +0,0 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: obsidian
resources:
- namespace.yaml
- pvcs.yaml
- deployment.yaml
- networkpolicy.yaml
@@ -1,9 +0,0 @@
apiVersion: v1
kind: Namespace
metadata:
name: obsidian
labels:
# The sync pod is fully compliant with the "restricted" Pod Security Standard.
pod-security.kubernetes.io/enforce: restricted
annotations:
argocd.argoproj.io/sync-wave: "-1"
@@ -1,33 +0,0 @@
# Requires a CNI that enforces NetworkPolicy. Nothing may call the pod; it may only
# resolve DNS and reach the Obsidian Sync servers over HTTPS/WebSocket.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: obsidian-vault
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: obsidian-vault
policyTypes: ["Ingress", "Egress"]
ingress: []
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
- 169.254.169.254/32
ports:
- { protocol: TCP, port: 443 }
@@ -1,18 +0,0 @@
# DO NOT COMMIT real values. Create out-of-band in namespace "obsidian":
#
# ob login # once, on a trusted machine
# kubectl create namespace obsidian # if Argo hasn't created it yet
# kubectl -n obsidian create secret generic obsidian-sync \
# --from-file=auth_token=<path to auth_token in your obsidian-headless config dir> \
# --from-literal=vault_name='<exact remote vault name>' \
# --from-literal=e2ee_password='<only if the vault uses E2EE>'
apiVersion: v1
kind: Secret
metadata:
name: obsidian-sync
namespace: obsidian
type: Opaque
stringData:
auth_token: "REPLACE"
vault_name: "REPLACE"
# e2ee_password: "REPLACE"
@@ -1,29 +0,0 @@
# Vault copy. One writer (the sync pod), so RWO is enough for now.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: obsidian-vault
annotations:
argocd.argoproj.io/sync-wave: "0"
argocd.argoproj.io/sync-options: Prune=false
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: px-fa-direct-access
resources:
requests:
storage: 10Gi # size to your vault + attachments, with headroom
---
# Client config + state.db, so restarts don't trigger a full re-index.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: obsidian-sync-state
annotations:
argocd.argoproj.io/sync-wave: "0"
argocd.argoproj.io/sync-options: Prune=false
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: px-fa-direct-access
resources:
requests:
storage: 1Gi
@@ -22,7 +22,7 @@ spec:
# vLLM API key from 1Password (mk-labs vault, vllm item, api-key field) # vLLM API key from 1Password (mk-labs vault, vllm item, api-key field)
- secretKey: vllm_api_key - secretKey: vllm_api_key
remoteRef: remoteRef:
key: illuminations key: vllm
property: api-key property: api-key
# WebUI JWT signing secret - TEMPORARILY DISABLED # WebUI JWT signing secret - TEMPORARILY DISABLED
# Uncomment and configure once open-webui item exists in 1Password: # Uncomment and configure once open-webui item exists in 1Password:
@@ -25,7 +25,6 @@ spec:
selector: selector:
dnsZones: dnsZones:
- mk-labs.cloud - mk-labs.cloud
- wdw-labs.cloud
--- ---
# Production issuer — switch to this once staging certs are working # Production issuer — switch to this once staging certs are working
@@ -48,4 +47,3 @@ spec:
selector: selector:
dnsZones: dnsZones:
- mk-labs.cloud - mk-labs.cloud
- wdw-labs.cloud
+2 -8
View File
@@ -16,6 +16,8 @@ env:
value: "10.1.71.32" value: "10.1.71.32"
- name: EXTERNAL_DNS_RFC2136_PORT - name: EXTERNAL_DNS_RFC2136_PORT
value: "53" value: "53"
- name: EXTERNAL_DNS_RFC2136_ZONE
value: "local.mk-labs.cloud"
- name: EXTERNAL_DNS_RFC2136_TSIG_AXFR - name: EXTERNAL_DNS_RFC2136_TSIG_AXFR
value: "true" value: "true"
# TSIG secret key name — must match what's configured in Technitium # TSIG secret key name — must match what's configured in Technitium
@@ -32,14 +34,6 @@ env:
# Only manage records in this zone # Only manage records in this zone
domainFilters: domainFilters:
- local.mk-labs.cloud - local.mk-labs.cloud
- wdw-labs.cloud
# Multiple Technitium zones (the env var form only supports one zone).
# wdw-labs.cloud zone must exist in Technitium and the "external-dns" TSIG key
# must be authorised for dynamic updates + zone transfer on it (added for skyliner).
extraArgs:
- --rfc2136-zone=local.mk-labs.cloud
- --rfc2136-zone=wdw-labs.cloud
# Sources: ingress, service, and Gateway API HTTPRoutes # Sources: ingress, service, and Gateway API HTTPRoutes
sources: sources:
@@ -1,17 +0,0 @@
# ------------------------------------------------------------------------------
# Certificate for skyliner.wdw-labs.cloud (LiteLLM)
# Single-host cert; referenced by listener "https-skyliner" in gateway.yaml.
# Requires letsencrypt-prod dnsZones to include wdw-labs.cloud (cert-manager).
# ------------------------------------------------------------------------------
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: skyliner-wdw-labs-tls
namespace: gateway
spec:
secretName: skyliner-wdw-labs-tls
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- skyliner.wdw-labs.cloud
-15
View File
@@ -36,18 +36,3 @@ spec:
allowedRoutes: allowedRoutes:
namespaces: namespaces:
from: All from: All
# Dedicated listener for LiteLLM (skyliner) — wdw-labs.cloud is not covered
# by the wildcard cert above. Cert: certificate-skyliner.yaml
- name: https-skyliner
port: 443
protocol: HTTPS
hostname: skyliner.wdw-labs.cloud
tls:
mode: Terminate
certificateRefs:
- group: ""
kind: Secret
name: skyliner-wdw-labs-tls
allowedRoutes:
namespaces:
from: All
@@ -16,7 +16,6 @@
# - monitoring (Grafana) # - monitoring (Grafana)
# - authentik (Authentik SSO — future) # - authentik (Authentik SSO — future)
# - gitea (Gitea — future) # - gitea (Gitea — future)
# - litellm (LiteLLM / skyliner)
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
apiVersion: gateway.networking.k8s.io/v1beta1 apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant kind: ReferenceGrant
@@ -46,9 +45,6 @@ spec:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: HTTPRoute kind: HTTPRoute
namespace: firecrawl namespace: firecrawl
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: litellm
to: to:
- group: gateway.networking.k8s.io - group: gateway.networking.k8s.io
kind: Gateway kind: Gateway
@@ -24,7 +24,6 @@ spec:
connectHost: http://onepassword-connect.onepassword-connect.svc.cluster.local:8080 connectHost: http://onepassword-connect.onepassword-connect.svc.cluster.local:8080
vaults: vaults:
mk-labs: 1 # vault name in 1Password : priority mk-labs: 1 # vault name in 1Password : priority
wdw-labs: 2 # WDW Labs hosts/agents (LiteLLM, host api-keys); Connect token needs read access
auth: auth:
secretRef: secretRef:
connectTokenSecretRef: connectTokenSecretRef:
-223
View File
@@ -1,223 +0,0 @@
# wiki-llm intake pipeline (n8n + Hermes Kanban)
Detects new raw files in the Obsidian vault, queues one Hermes Kanban card per file, tracks every file in an n8n Data Table, and alerts on failure. The Hermes librarian does the actual cataloging and writes wiki pages directly into the vault per the llm-wiki skill.
> **Status:** design complete, partly implemented. `kanban-gateway.sh` (tested against a stub `hermes`) and `wiki-llm-watcher.py` (tested against a mock webhook) are written. The n8n workflows are still to build. See [Open items](#open-items).
## Architecture
```mermaid
flowchart LR
subgraph MAC["Mac (vault + Hermes)"]
V["Obsidian vault<br/>raw/"] --> W["Watcher<br/>(launchd, polls 15-30s)"]
GW["kanban-gateway<br/>(SSH forced command)"] --> K["hermes kanban<br/>~/.hermes/kanban.db"]
K --> D["Dispatcher<br/>(hermes gateway)"] --> L["Librarian profile<br/>(llm-wiki skill)"]
L --> V
end
subgraph N8N["n8n"]
IN["Intake workflow"]
ST["Status workflow<br/>(Schedule Trigger)"]
DT[("Data Table<br/>processed-files log")]
NT["Slack / email<br/>failure alert"]
end
W -- "POST one file per call" --> IN
IN <--> DT
IN -- "SSH: create" --> GW
ST -- "SSH: list / show" --> GW
ST <--> DT
ST --> NT
```
| Piece | Where | Job |
|---|---|---|
| Watcher | Mac, launchd | Finds new paths in `raw/`, waits until each file is stable, POSTs one webhook call per file |
| Intake workflow | n8n | Validates, dedupes against the Data Table, creates the Kanban card over SSH |
| `kanban-gateway.sh` | Mac, SSH forced command | The only thing n8n's key can run: `create`, `list`, `show` |
| Hermes Kanban + dispatcher | Mac | Durable queue, claiming, retries, stale-task reclaim, circuit breaker |
| Librarian profile | Mac | Does the work in the vault workspace, writes wiki pages per the llm-wiki skill |
| Status workflow | n8n | Polls the board, updates the Data Table, sends failure notifications |
| Data Table | n8n | Authoritative processed-files log |
## Key decisions
- **Watcher on the vault host, not a network mount.** Filesystem events and Local File Trigger don't work reliably over NFS/SMB, and failures there are silent.
- **Polling loop is the source of truth.** FSEvents coalesces and reorders events, and clippers and sync tools are noisy, so the watcher lists `raw/` every 15 to 30 seconds and compares against its seen-state. Events may wake it early but never decide anything.
- **Files stay in `raw/`.** "Processed" is tracked in the Data Table, not by moving files.
- **Dedupe key is the relative path, first-seen wins.** The librarian edits frontmatter after cataloging, so the content hash changes. The hash is recorded as metadata at first-seen only. A second check rejects a first-seen hash that already exists under a different path (`status=duplicate`).
- **Kanban is the queue.** The board already provides claiming, retries, stale reclaim, and a circuit breaker, so n8n has no Worker workflow.
- **SSH instead of the dashboard REST API.** The dashboard plugin routes under `/api/plugins/kanban/` are unauthenticated by design. This pipeline does not use or expose them.
- **Instruction files can live in `raw/`.** `AGENTS.md`, `CLAUDE.md`, `.hermes.md`, `README.md`, and `SCHEMA.md` are ignored by name at any depth (`WIKI_IGNORE_NAMES`), and the Intake `Validate` node should reject the same names. Hermes loads `AGENTS.md` into the librarian's context; see "Processing rules (AGENTS.md)" below.
- **Output stays outside `raw/`.** The llm-wiki skill's folder layout must keep wiki output out of `raw/`, so Hermes can't feed itself.
## Mac setup
### 1. Install the gateway script
1. Copy `kanban-gateway.sh` to `~/bin/kanban-gateway` and `chmod 755` it.
2. Edit the top block: `HERMES_BIN` (absolute path), `VAULT_ROOT`, `LIBRARIAN_PROFILE`, `SKILL_NAME`. The profile and skill names in the file are placeholders.
3. Generate a dedicated key for n8n: `ssh-keygen -t ed25519 -f n8n_kanban -C n8n-wiki-llm`.
4. Add one line to `~/.ssh/authorized_keys` on the Mac, using the absolute script path (optionally add `from="<n8n IP>"` before `restrict`):
```
restrict,command="/Users/<you>/bin/kanban-gateway" ssh-ed25519 AAAA... n8n-wiki-llm
```
5. Enable Remote Login for your user (System Settings, General, Sharing). On an MDM-managed Mac this may need IT approval.
6. Smoke test from the n8n host: `ssh -i n8n_kanban <user>@<mac> list`. A bare `list` should return JSON.
The SSH user must be the user that owns the Hermes profiles and `~/.hermes/kanban.db`. Kanban is single-host and per-user.
### 2. Gateway script protocol
| Command sent over SSH | Effect |
|---|---|
| `create <base64 relative path>` | Creates a card whose body lists every `AGENTS.md` from the vault root down to the file's folder (shallow to deep), so folder rules are read deterministically. Assignee = librarian profile, `--workspace dir:<vault>`, `--idempotency-key <relative path>`, `--skill <skill>`, `--json` |
| `list [status]` | `hermes kanban list --json`, optionally filtered. Status is one of `triage todo ready running blocked review done archived` |
| `show <task id>` | `hermes kanban show <id> --json`. The id must match `t_[A-Za-z0-9]+` |
The path travels base64-encoded and is never passed through a shell or `eval`, so filenames containing spaces, quotes, or `$()` are inert. The script also rejects control characters, absolute paths, `..` segments, anything outside `raw/`, and symlinks that resolve out of `raw/`.
Exit codes: `0` ok, `2` rejected input, `3` file not found.
### 3. Run the dispatcher
`hermes gateway start` must be running on the Mac. Without it, cards stay `ready`. The default dispatch tick is 60 seconds. Cap concurrency with `kanban.max_in_progress` in `~/.hermes/config.yaml` so a burst drains at a pace your local models can handle.
### 4. Install the watcher
1. Copy `wiki-llm-watcher.py` to `~/bin/` (`chmod 755`). It uses only the Python 3 standard library.
2. Copy `wiki-llm-watcher.env.example` to `~/.config/wiki-llm-watcher/env`, edit it, and `chmod 600` it. It holds the webhook URL (the Webhook node's **production** URL) and secret.
3. **Baseline first:** `python3 ~/bin/wiki-llm-watcher.py --baseline` marks everything already in `raw/` as seen, so the first run does not post your whole backlog. Skip this only if you want the backlog processed.
4. Dry run: `python3 ~/bin/wiki-llm-watcher.py --dry-run --once` prints the payloads it would send and posts nothing.
5. Install `com.wiki-llm.watcher.plist` into `~/Library/LaunchAgents/` (replace `YOURNAME`), then `launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.wiki-llm.watcher.plist`. Logs go to `~/Library/Logs/wiki-llm-watcher.log`.
6. To reprocess one file on purpose: `python3 ~/bin/wiki-llm-watcher.py --forget raw/path/to/file.md`.
macOS privacy controls (TCC) can block a LaunchAgent from reading `~/Documents`, `~/Desktop`, or iCloud Drive. If the log shows permission errors, grant the Python binary access under Privacy & Security, or keep the vault outside those folders.
Webhook response handling in the watcher: any 2xx marks the file posted (including n8n's `duplicate` response). `400/409/413/422` marks it `rejected` locally and stops retrying. `401/403/404` and an unreachable n8n pause posting for 60 seconds without marking anything. Other errors retry per file with exponential backoff.
## n8n workflows
### Intake (Webhook trigger)
1. **Webhook node**, header-auth credential. Body: `{path, sha256, size, mtime, source}`.
2. **Code node:** validate `path` against an allowlist regex (for example `^raw/[\w .,\-/]+\.md$`, widened to the extensions you accept). Reject mismatches as `status=rejected`. Compute `rel_b64 = Buffer.from(path, 'utf8').toString('base64')` and `file_key = path`.
3. **Data Table lookup** by `file_key`. If a row exists, respond `duplicate` and stop. Otherwise insert with `status=queued`. Respond to the webhook after this insert, so a 2xx means "durably queued".
4. **SSH node** (Execute Command, Private Key credential): `create {{ $json.rel_b64 }}`. Retry On Fail: 3 tries with a few seconds' backoff. On Error: Continue (using error output).
5. **If node:** check `code` is `0` explicitly. Don't rely on the node throwing. Exit `3` means the file is gone, so set `rejected`. Anything else, set `create_failed`, record `last_error`, and notify.
6. **Code node:** parse `stdout` as JSON and extract the task id. Verify the key name against a real `hermes kanban create --json` run.
7. **Data Table update:** `kanban_task_id`, `status=created`, `created_at`.
An SSH timeout can happen after the card was created. The kanban idempotency key means a retry returns the existing card, not a duplicate.
### Status (Schedule Trigger, every 1 to 2 minutes)
1. **SSH node:** `list blocked` and `list done` (or plain `list`).
2. **Code node:** match cards to Data Table rows by `kanban_task_id`.
3. **Data Table update:** `running`, `done` (set `processed_at`), `blocked`.
4. **If node:** for `blocked` where `notified_at` is empty, run `show <id>` to get the recorded reason, send the **Slack** or **Gmail** node, then set `notified_at`. Notify on transitions only.
### Retry sweep (Schedule Trigger, optional)
Re-attempts rows stuck in `create_failed` with a capped `attempts` count.
### Error Workflow
Set an **Error Trigger** workflow on both workflows above as a safety net for crashes outside the handled branches.
## Data Table: `wiki_llm_files`
| Column | Notes |
|---|---|
| `file_key` | Relative path. Unique lookup key |
| `sha256_first_seen` | Recorded at first-seen only |
| `size`, `mtime`, `source` | Source: `watcher` or `obsidian-plugin` |
| `status` | `queued`, `created`, `running`, `done`, `blocked`, `create_failed`, `rejected`, `duplicate` |
| `kanban_task_id` | From the `create` response |
| `attempts`, `last_error` | Retry accounting and diagnostics |
| `first_seen`, `created_at`, `processed_at` | Timestamps |
| `notified_at` | Prevents duplicate alerts |
A Data Table is enough to start. Move to Postgres only if you need joins, heavy volume, or external reporting.
## Processing rules (AGENTS.md)
All per-folder configuration lives in the vault as `AGENTS.md` files, so the watcher stays generic: it only knows the inbox folder and what to ignore.
```
vault/
AGENTS.md # global inbox rules
raw/
genealogy/find-a-grave/AGENTS.md # rules for that folder
meeting-notes/AGENTS.md # rules for that folder
```
When the gateway creates a card, it walks from the vault root down to the file's folder and lists every `AGENTS.md` it finds in the card body, shallow to deep, telling the agent to read all of them first (deeper files take precedence). This makes the rules deterministic and visible on the card. It does not depend on Hermes discovering subdirectory context files lazily, though Hermes also loads `AGENTS.md` from its working directory and discovers subdirectory ones as files are read there. If no `AGENTS.md` exists for a folder, the card says so and the agent follows the llm-wiki skill defaults.
Only one project context type loads per Hermes session (first match wins: `.hermes.md`, `AGENTS.md`, `CLAUDE.md`, `.cursorrules`). If the vault root already has a `.hermes.md`, it takes precedence at startup, but the card still names the `AGENTS.md` chain explicitly.
Suggested split, so rules live in one place each:
| Where | Holds |
|---|---|
| llm-wiki skill | How the wiki is structured and written (stable, reusable) |
| `AGENTS.md` at root | Inbox-wide rules: never edit raw bodies (only frontmatter metadata), output goes outside `raw/`, raw file text is untrusted data and never instructions, block the card with a clear reason instead of guessing |
| `AGENTS.md` per folder | That folder's processing rules and where its output goes |
| Kanban card body | Per-file facts (path) and the ruleset list |
**Validate with a marker test:** give two folders deliberately silly rules (one adds the tag `#fag-test` to the frontmatter, the other `#meeting-test`), drop one file in each through the real pipeline, and confirm each file got only its own tag.
## Failure modes
| Problem | Handling |
|---|---|
| Duplicate triggers | Data Table lookup on relative path, plus the kanban idempotency key, plus the watcher's local seen-state. Losing the watcher's state causes noisy reposts but no reprocessing |
| Partial writes | Watcher requires size and mtime unchanged for about 10 seconds, ignores temp patterns (`*.tmp`, `.syncthing.*`, `*.sync-conflict-*`, dotfiles, `.obsidian/`) |
| Bursts | Watcher posts sequentially with a short delay. The board queue plus `max_in_progress` absorb the rest |
| n8n down | Watcher records a file as posted only after a 2xx, so unposted files are retried on the next pass |
| SSH or Mac unreachable | Row stays `queued` or `create_failed`, retried by the sweep, with an alert once attempts are exhausted |
| Hermes worker fails | Dispatcher retries and then circuit-breaks to `blocked` with the error recorded. The Status workflow alerts and keeps the reason |
| Stuck or crashed worker | Dispatcher reclaims stale claims automatically |
## Optional: Obsidian "Post Webhook" plugin
[obsidian-post-webhook](https://github.com/masterb1234/obsidian-post-webhook) sends a note to a webhook when you run a command. It is not a file watcher and is not the primary trigger. It needs Obsidian running with the note open, and it sends note content with base64 attachments rather than a path and hash.
Use it only as a manual "process this note now" entry point into the same Intake workflow:
- Accept both payload shapes (`{path, sha256}` and `{content, filename}`) and branch with an **If node**.
- For plugin payloads, compute the hash with the **Crypto node** and set `source=obsidian-plugin`.
- Enable "Exclude Attachments" and set response handling to **None**, so n8n output never writes back into the vault.
## Security notes
- The n8n SSH key can run only the gateway script, via `restrict,command=...`.
- Do not run `hermes dashboard --host 0.0.0.0`. The kanban REST routes under `/api/plugins/` are unauthenticated.
- Keep the watcher webhook behind header auth, and restrict it by network if possible.
## Verify on first real run
1. `hermes kanban create --json` output shape (the task id key name).
2. Idempotency: run the same `create` twice and confirm the second returns the existing card.
3. `--workspace dir:<vault>` resolves correctly in `hermes kanban show <id>`.
4. `--skill` matches a skill installed on the librarian profile (`hermes skills list`).
5. `hermes gateway start` is running and the dispatcher picks the card up.
6. A deliberately failing card reaches `blocked` and triggers exactly one alert.
## Open items
- [x] Mac watcher script and launchd plist (poll loop, stability check, seen-state, one POST per file)
- [ ] Build Intake, Status, and Retry sweep workflows, and create the Data Table
- [ ] Choose the notification channel (Slack or email)
- [ ] Confirm expected file volume (sets watcher delay and `max_in_progress`)
- [ ] Decide whether to add a Hermes plugin hook (`kanban_task_blocked`) that POSTs to an n8n webhook for lower-latency alerts. Polling stays as the backstop either way
## Files
| File | Purpose |
|---|---|
| `kanban-gateway.sh` | SSH forced-command wrapper (tested with a stub `hermes`) |
| `wiki-llm-watcher.py` | Mac watcher (tested with a mock webhook) |
| `wiki-llm-watcher.env.example` | Watcher configuration template |
| `com.wiki-llm.watcher.plist` | launchd LaunchAgent for the watcher |
| `README.md` | This document |
@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.wiki-llm.watcher</string>
<key>ProgramArguments</key>
<array>
<string>/usr/bin/python3</string>
<string>/Users/rblundon/bin/wiki-llm-watcher.py</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>WIKI_ENV_FILE</key>
<string>/Users/rblundon/.config/wiki-llm-watcher/env</string>
<key>PYTHONUNBUFFERED</key>
<string>1</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>ThrottleInterval</key>
<integer>30</integer>
<key>StandardOutPath</key>
<string>/Users/rblundon/Library/Logs/wiki-llm-watcher.log</string>
<key>StandardErrorPath</key>
<string>/Users/rblundon/Library/Logs/wiki-llm-watcher.log</string>
</dict>
</plist>
@@ -1,128 +0,0 @@
#!/bin/bash
# kanban-gateway: SSH forced-command wrapper for n8n -> Hermes kanban.
#
# Install on the Mac as the user that owns ~/.hermes, e.g. ~/bin/kanban-gateway (chmod 755).
# Wire it up in ~/.ssh/authorized_keys with a forced command (see the line in the chat).
#
# Verbs n8n may call (sent as the SSH command string):
# create <base64 of path relative to the vault, e.g. raw/clippings/foo.md>
# list [status] status in: triage todo ready running blocked review done archived
# show <task id> id like t_ab12cd34
#
# Nothing from the SSH command is ever passed through a shell or eval; the path
# travels base64-encoded so spaces/quotes/$()/newlines in filenames are inert.
set -euo pipefail
# ---- EDIT THESE ------------------------------------------------------------
HERMES_BIN="${HERMES_BIN:-$HOME/.local/bin/hermes}" # absolute path; non-interactive SSH has a minimal PATH
VAULT_ROOT="${VAULT_ROOT:-$HOME/Documents/wiki-llm}" # absolute path to the vault root
RAW_DIR="raw" # inbox folder inside the vault
LIBRARIAN_PROFILE="${LIBRARIAN_PROFILE:-librarian}" # assignee profile name
SKILL_NAME="${SKILL_NAME:-llm-wiki}" # skill installed on that profile ('' to skip pinning)
# ----------------------------------------------------------------------------
export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin:$HOME/.local/bin"
fail() { echo "kanban-gateway: $1" >&2; exit "${2:-2}"; }
cmd_line="${SSH_ORIGINAL_COMMAND:-}"
[[ "$cmd_line" != *$'\n'* ]] || fail "newline in command"
# n8n's SSH node prefixes the command with "cd <dir> ; " (or "&&") when its Working Directory
# field is set. The directory is never used or executed here; only that exact prefix shape is
# stripped, and whatever remains must still be one of the allowed verbs below.
cd_re='^cd[[:space:]]+("[^"]*"|'"'"'[^'"'"']*'"'"'|[^[:space:];&|`$()<>\\]+)[[:space:]]*(;|&&)[[:space:]]*'
if [[ "$cmd_line" =~ $cd_re ]]; then
cmd_line="${cmd_line#"${BASH_REMATCH[0]}"}"
fi
[[ -n "$cmd_line" ]] || fail "no command supplied"
# Split only on spaces; the verb and a single argument.
verb="${cmd_line%% *}"
arg=""
if [[ "$cmd_line" == *" "* ]]; then arg="${cmd_line#* }"; fi
decode_b64() {
# macOS older = -D, newer/GNU = -d
printf '%s' "$1" | { base64 -d 2>/dev/null || base64 -D; }
}
case "$verb" in
create)
[[ "$arg" =~ ^[A-Za-z0-9+/=]+$ ]] || fail "create expects base64 argument"
rel="$(decode_b64 "$arg")" || fail "bad base64"
[[ -n "$rel" ]] || fail "empty path"
# Reject control characters (incl. newlines), absolute paths, and traversal.
if printf '%s' "$rel" | LC_ALL=C grep -q '[[:cntrl:]]'; then fail "control characters in path"; fi
[[ "$rel" != /* ]] || fail "absolute path rejected"
case "/$rel/" in */../*) fail "path traversal rejected" ;; esac
[[ "$rel" == "$RAW_DIR"/* ]] || fail "path must be under $RAW_DIR/"
vault_real="$(cd "$VAULT_ROOT" && pwd -P)" || fail "VAULT_ROOT not found"
abs="$vault_real/$rel"
[[ -f "$abs" ]] || fail "file not found: $rel" 3
# Resolve symlinks in the directory part and confirm it is still inside raw/.
dir_real="$(cd "$(dirname "$abs")" && pwd -P)" || fail "cannot resolve directory"
case "$dir_real/" in
"$vault_real/$RAW_DIR/"*) ;;
*) fail "resolved path escapes $RAW_DIR/" ;;
esac
# Folder-scoped rulesets live IN the vault as AGENTS.md files. Collect every AGENTS.md from the
# vault root down to the file's folder (shallow -> deep) and name them on the card, so the
# agent reads them deterministically instead of relying on lazy discovery.
rules=()
if [[ -f "$vault_real/AGENTS.md" && ! -L "$vault_real/AGENTS.md" ]]; then rules+=("AGENTS.md"); fi
cur=""
IFS='/' read -r -a parts <<< "$(dirname "$rel")"
for part in "${parts[@]}"; do
cur="${cur:+$cur/}$part"
if [[ -f "$vault_real/$cur/AGENTS.md" && ! -L "$vault_real/$cur/AGENTS.md" ]]; then
rules+=("$cur/AGENTS.md")
fi
done
if (( ${#rules[@]} > 0 )); then
rules_text="Rulesets for this file's folder. Read ALL of them before doing anything else, in this order (later ones are more specific and take precedence):"
for r in "${rules[@]}"; do rules_text+=$'\n'"- $r"; done
else
rules_text="No AGENTS.md rulesets found for this folder; follow the llm-wiki skill defaults."
fi
title="Catalog: $(basename "$rel")"
body="Catalog this new raw file per the llm-wiki skill.
Relative path: $rel
Absolute path: $abs
$rules_text"
args=( kanban create "$title"
--body "$body"
--assignee "$LIBRARIAN_PROFILE"
--workspace "dir:$vault_real"
--idempotency-key "$rel"
--json )
if [[ -n "$SKILL_NAME" ]]; then args+=( --skill "$SKILL_NAME" ); fi
exec "$HERMES_BIN" "${args[@]}"
;;
list)
if [[ -z "$arg" ]]; then
exec "$HERMES_BIN" kanban list --json
fi
case "$arg" in
triage|todo|ready|running|blocked|review|done|archived)
exec "$HERMES_BIN" kanban list --status "$arg" --json ;;
*) fail "invalid status" ;;
esac
;;
show)
[[ "$arg" =~ ^t_[A-Za-z0-9]+$ ]] || fail "invalid task id"
exec "$HERMES_BIN" kanban show "$arg" --json
;;
*)
fail "verb not allowed: $verb"
;;
esac
@@ -1,23 +0,0 @@
# Copy to ~/.config/wiki-llm-watcher/env and run: chmod 600 ~/.config/wiki-llm-watcher/env
# Lines are KEY=VALUE. No export, no shell expansion (use absolute paths).
# Absolute path to the vault root, and the inbox folder inside it
WIKI_VAULT_ROOT=/Users/YOURNAME/Documents/wiki-llm
WIKI_RAW_DIR=raw
# n8n Webhook node PRODUCTION URL (the workflow must be Active; the "test" URL only works
# while you are listening in the editor)
WIKI_WEBHOOK_URL=https://n8n.example.com/webhook/wiki-llm-intake
# Header-auth credential configured on the Webhook node
WIKI_AUTH_HEADER=X-Webhook-Secret
WIKI_AUTH_SECRET=change-me
# Optional tuning (defaults shown)
# WIKI_POLL_SECONDS=20 # how often to scan raw/
# WIKI_STABLE_SECONDS=10 # file must be unchanged this long before it is posted
# WIKI_POST_DELAY=0.5 # pause between POSTs during a burst
# WIKI_TIMEOUT=30 # HTTP timeout per POST
# WIKI_IGNORE_NAMES=AGENTS.md,CLAUDE.md,.hermes.md,README.md,SCHEMA.md # file names never posted, at any depth (case-insensitive)
# WIKI_EXTENSIONS=.md # comma-separated, or * for all (keep in sync with n8n's path allowlist)
# WIKI_STATE_DB=~/.local/state/wiki-llm-watcher/seen.db
@@ -1,363 +0,0 @@
#!/usr/bin/env python3
"""wiki-llm-watcher: poll the vault's raw/ inbox and POST one webhook call per new file.
Design (see README.md):
* Polling is the source of truth. No filesystem events are trusted.
* A file is posted only after (size, mtime) has been unchanged for WIKI_STABLE_SECONDS
and across at least one full scan, so half-written clips/syncs are skipped.
* Files are posted sequentially, one webhook call each, with a short delay between calls.
* A file is recorded as posted in the local SQLite seen-state ONLY after n8n returns 2xx.
On restart, anything not recorded is retried, so nothing is lost while n8n or the
watcher is down. (n8n dedupes against its Data Table, so reposts are harmless.)
* Detection is by NEW PATH only. Later edits (the librarian rewrites frontmatter) are ignored.
* Standard library only. Python 3.8+.
Usage:
wiki-llm-watcher.py --baseline mark everything currently in raw/ as seen (no POSTs)
wiki-llm-watcher.py --dry-run --once show what WOULD be posted, post nothing
wiki-llm-watcher.py run forever (this is what launchd runs)
wiki-llm-watcher.py --once one stabilised pass, then exit
wiki-llm-watcher.py --forget raw/x.md drop a path from seen-state so it is posted again
Configuration is by environment variables, optionally loaded from the KEY=VALUE file named by
WIKI_ENV_FILE (keep that file chmod 600; it holds the webhook secret).
"""
import argparse
import hashlib
import json
import logging
import os
import signal
import socket
import sqlite3
import sys
import time
import unicodedata
import urllib.error
import urllib.request
from datetime import datetime, timezone
log = logging.getLogger("wiki-llm-watcher")
STOP = False
# ----------------------------------------------------------------------------- config
def load_env_file(path):
with open(path, "r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
value = value[1:-1]
os.environ.setdefault(key, value)
def get_config():
env_file = os.environ.get("WIKI_ENV_FILE")
if env_file:
load_env_file(os.path.expanduser(env_file))
cfg = {
"vault_root": os.path.expanduser(os.environ.get("WIKI_VAULT_ROOT", "")),
"raw_dir": os.environ.get("WIKI_RAW_DIR", "raw"),
"url": os.environ.get("WIKI_WEBHOOK_URL", ""),
"auth_header": os.environ.get("WIKI_AUTH_HEADER", "X-Webhook-Secret"),
"auth_secret": os.environ.get("WIKI_AUTH_SECRET", ""),
"poll": float(os.environ.get("WIKI_POLL_SECONDS", "20")),
"stable": float(os.environ.get("WIKI_STABLE_SECONDS", "10")),
"post_delay": float(os.environ.get("WIKI_POST_DELAY", "0.5")),
"timeout": float(os.environ.get("WIKI_TIMEOUT", "30")),
"ignore_names": {n.strip().lower() for n in os.environ.get(
"WIKI_IGNORE_NAMES", "AGENTS.md,CLAUDE.md,.hermes.md,README.md,SCHEMA.md").split(",") if n.strip()},
"extensions": [e.strip().lower() for e in os.environ.get("WIKI_EXTENSIONS", ".md").split(",") if e.strip()],
"state_db": os.path.expanduser(
os.environ.get("WIKI_STATE_DB", "~/.local/state/wiki-llm-watcher/seen.db")
),
}
return cfg
def validate_config(cfg, need_url):
problems = []
if not cfg["vault_root"] or not os.path.isdir(cfg["vault_root"]):
problems.append("WIKI_VAULT_ROOT is not set or not a directory")
elif not os.path.isdir(os.path.join(cfg["vault_root"], cfg["raw_dir"])):
problems.append("raw dir not found: %s" % os.path.join(cfg["vault_root"], cfg["raw_dir"]))
if need_url:
if not cfg["url"].startswith(("http://", "https://")):
problems.append("WIKI_WEBHOOK_URL must be an http(s) URL")
if not cfg["auth_secret"]:
problems.append("WIKI_AUTH_SECRET is empty")
return problems
# ----------------------------------------------------------------------------- state
def open_state(path):
os.makedirs(os.path.dirname(path), exist_ok=True)
db = sqlite3.connect(path)
db.execute(
"CREATE TABLE IF NOT EXISTS files ("
" path TEXT PRIMARY KEY, sha256 TEXT, size INTEGER, mtime TEXT,"
" status TEXT NOT NULL, updated_at TEXT NOT NULL)"
)
db.commit()
return db
def now_iso():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def mark(db, rel, status, sha=None, size=None, mtime=None):
db.execute(
"INSERT INTO files(path, sha256, size, mtime, status, updated_at) VALUES(?,?,?,?,?,?)"
" ON CONFLICT(path) DO UPDATE SET sha256=excluded.sha256, size=excluded.size,"
" mtime=excluded.mtime, status=excluded.status, updated_at=excluded.updated_at",
(rel, sha, size, mtime, status, now_iso()),
)
db.commit()
def known_paths(db):
return {row[0] for row in db.execute("SELECT path FROM files")}
# ----------------------------------------------------------------------------- scanning
IGNORED_SUFFIXES = (".tmp", ".part", ".partial", ".crdownload", ".download", ".swp", ".swx", "~")
def is_ignored(name, extensions, ignore_names=frozenset()):
low = name.lower()
if low in ignore_names: # instruction files that live in raw/ (AGENTS.md etc.) are never ingested
return True
if name.startswith("."): # dotfiles, .syncthing.*, .DS_Store, iCloud placeholders (.foo.icloud)
return True
if low.endswith(IGNORED_SUFFIXES) or low.endswith(".icloud"):
return True
if ".sync-conflict-" in low:
return True
if "*" in extensions:
return False
return not any(low.endswith(ext) for ext in extensions)
def scan_files(cfg):
"""Yield (relative_path_nfc, absolute_path) for every candidate file under raw/."""
root = os.path.realpath(cfg["vault_root"])
raw = os.path.join(root, cfg["raw_dir"])
for dirpath, dirnames, filenames in os.walk(raw, followlinks=False):
dirnames[:] = sorted(d for d in dirnames if not d.startswith("."))
for name in sorted(filenames):
if is_ignored(name, cfg["extensions"], cfg["ignore_names"]):
continue
full = os.path.join(dirpath, name)
if os.path.islink(full):
continue
rel = os.path.relpath(full, root).replace(os.sep, "/")
yield unicodedata.normalize("NFC", rel), full
def sha256_of(path):
h = hashlib.sha256()
with open(path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
# ----------------------------------------------------------------------------- HTTP
def post_json(cfg, payload):
"""Return (status_code, body_snippet). Raises URLError/OSError when unreachable."""
req = urllib.request.Request(
cfg["url"],
data=json.dumps(payload).encode("utf-8"),
method="POST",
headers={"Content-Type": "application/json", cfg["auth_header"]: cfg["auth_secret"]},
)
try:
with urllib.request.urlopen(req, timeout=cfg["timeout"]) as resp:
return resp.status, resp.read(500).decode("utf-8", "replace")
except urllib.error.HTTPError as err:
return err.code, err.read(500).decode("utf-8", "replace")
# ----------------------------------------------------------------------------- core pass
class Watcher:
def __init__(self, cfg, db, dry_run=False):
self.cfg = cfg
self.db = db
self.dry_run = dry_run
self.observed = {} # rel -> [(size, mtime_ns), first_seen_monotonic, seen_in_scans]
self.retry = {} # rel -> (fail_count, not_before_monotonic)
self.cooldown_until = 0.0 # set when n8n is unreachable or config is wrong
def observe(self):
"""One scan: update stability tracking, return list of (rel, full, stat) eligible now."""
now = time.monotonic()
seen = known_paths(self.db)
present = set()
eligible = []
for rel, full in scan_files(self.cfg):
if rel in seen:
continue
try:
st = os.stat(full)
except OSError:
continue # vanished between listing and stat
present.add(rel)
sig = (st.st_size, st.st_mtime_ns)
rec = self.observed.get(rel)
if rec is None or rec[0] != sig:
self.observed[rel] = [sig, now, 1]
continue
rec[2] += 1
if rec[2] >= 2 and (now - rec[1]) >= self.cfg["stable"]:
nb = self.retry.get(rel)
if nb and now < nb[1]:
continue
eligible.append((rel, full, st))
for gone in set(self.observed) - present:
del self.observed[gone]
eligible.sort(key=lambda item: item[2].st_mtime_ns)
return eligible
def post_pass(self):
eligible = self.observe()
if eligible and time.monotonic() < self.cooldown_until:
return # n8n was unreachable / misconfigured a moment ago; don't spam the log
if eligible:
log.info("%d stable new file(s) to post", len(eligible))
for rel, full, st in eligible:
if STOP:
return
try:
digest = sha256_of(full)
st2 = os.stat(full)
except OSError as exc:
log.warning("skip %s (unreadable: %s)", rel, exc)
continue
if (st2.st_size, st2.st_mtime_ns) != (st.st_size, st.st_mtime_ns):
log.info("changed while hashing, will re-check: %s", rel)
self.observed.pop(rel, None)
continue
mtime_iso = datetime.fromtimestamp(st.st_mtime, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
payload = {
"path": rel,
"sha256": digest,
"size": st.st_size,
"mtime": mtime_iso,
"source": "watcher",
"host": socket.gethostname(),
}
if self.dry_run:
log.info("[dry-run] would POST %s", json.dumps(payload, ensure_ascii=False))
continue
try:
code, body = post_json(self.cfg, payload)
except (urllib.error.URLError, OSError) as exc:
log.error("n8n unreachable (%s); pausing posts for 60s", exc)
self.cooldown_until = time.monotonic() + 60
return # stop this pass; do not hammer a down endpoint
if 200 <= code < 300:
mark(self.db, rel, "posted", digest, st.st_size, mtime_iso)
self.retry.pop(rel, None)
self.observed.pop(rel, None)
log.info("posted %s (HTTP %d)", rel, code)
elif code in (400, 409, 413, 422):
mark(self.db, rel, "rejected", digest, st.st_size, mtime_iso)
self.observed.pop(rel, None)
log.warning("rejected by n8n, not retrying: %s (HTTP %d: %s). Use --forget to retry.",
rel, code, body[:200].replace("\n", " "))
elif code in (401, 403, 404):
log.error("config problem (HTTP %d) - check URL (production, workflow active?) and secret. "
"Pausing posts for 60s.", code)
self.cooldown_until = time.monotonic() + 60
return
else:
fails = self.retry.get(rel, (0, 0))[0] + 1
delay = min(30 * (2 ** (fails - 1)), 900)
self.retry[rel] = (fails, time.monotonic() + delay)
log.warning("HTTP %d for %s, retry in %ds (attempt %d)", code, rel, delay, fails)
time.sleep(self.cfg["post_delay"])
def do_baseline(cfg, db):
count = 0
for rel, full in scan_files(cfg):
try:
st = os.stat(full)
except OSError:
continue
mark(db, rel, "baseline", None, st.st_size,
datetime.fromtimestamp(st.st_mtime, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
count += 1
log.info("baseline: marked %d existing file(s) as seen; nothing was posted", count)
def sleep_interruptible(seconds):
end = time.monotonic() + seconds
while not STOP and time.monotonic() < end:
time.sleep(min(1.0, end - time.monotonic()))
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--baseline", action="store_true", help="mark existing raw/ files as seen without posting")
parser.add_argument("--once", action="store_true", help="run one stabilised pass and exit")
parser.add_argument("--dry-run", action="store_true", help="log what would be posted; post nothing, record nothing")
parser.add_argument("--forget", metavar="REL_PATH", help="remove a path from seen-state so it is posted again")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", stream=sys.stdout)
cfg = get_config()
problems = validate_config(cfg, need_url=not (args.baseline or args.forget or args.dry_run))
if problems:
for p in problems:
log.error(p)
return 1
db = open_state(cfg["state_db"])
if args.forget:
rel = unicodedata.normalize("NFC", args.forget)
cur = db.execute("DELETE FROM files WHERE path = ?", (rel,))
db.commit()
log.info("forgot %d row(s) for %s", cur.rowcount, rel)
return 0
if args.baseline:
do_baseline(cfg, db)
return 0
def handle_signal(signum, _frame):
global STOP
STOP = True
log.info("signal %d received, stopping", signum)
signal.signal(signal.SIGTERM, handle_signal)
signal.signal(signal.SIGINT, handle_signal)
watcher = Watcher(cfg, db, dry_run=args.dry_run)
log.info("watching %s (poll %.0fs, stable %.0fs, ext %s)",
os.path.join(cfg["vault_root"], cfg["raw_dir"]), cfg["poll"], cfg["stable"],
",".join(cfg["extensions"]))
if args.once:
watcher.observe() # prime stability tracking
sleep_interruptible(max(cfg["stable"], 1.0))
watcher.observe() # second observation (counts as the 'unchanged across scans' check)
watcher.post_pass()
return 0
while not STOP:
try:
watcher.post_pass()
except Exception: # keep the daemon alive; launchd would restart us anyway
log.exception("unexpected error in pass")
sleep_interruptible(cfg["poll"])
return 0
if __name__ == "__main__":
sys.exit(main())