feat: Add paperclip-docker Ansible role for RTX 3090
Deploy Paperclip AI with PostgreSQL, nginx reverse proxy, and Cloudflare SSL/TLS on illuminations.wdw-labs.cloud. Configured for local_trusted mode (LAN-only, no auth) with host networking and auto-generated secrets. Stack: PostgreSQL 16 + Paperclip + nginx + certbot Hostname: muppet-vision.wdw-labs.cloud vLLM endpoint: localhost:8000 (DeepSeek-R1-32B Q4)
This commit is contained in:
1 parent
8778a3bbb0
commit
f701ad823f
13 files changed
+1093
-124
No files matched your search
@@ -0,0 +1,20 @@
|
|||||||
|
---
|
||||||
|
# Paperclip Docker deployment
|
||||||
|
paperclip_domain: muppet-vision.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"
|
||||||
|
|
||||||
|
# # DNS entries (if using your existing dns-manager role)
|
||||||
|
# dns_entries:
|
||||||
|
# - name: illuminations
|
||||||
|
# domain: wdw-labs.cloud
|
||||||
|
# type: A
|
||||||
|
# value: "{{ ansible_host }}"
|
||||||
|
# - name: muppet-vision
|
||||||
|
# domain: wdw-labs.cloud
|
||||||
|
# type: A
|
||||||
|
# value: "{{ ansible_host }}"
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
---
|
||||||
|
# ------------------------------------------------------------------------------
|
||||||
|
# FILE: ansible/host_vars/astro_orbiter/vars.yml
|
||||||
|
# HOST: astro-orbiter (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
|
||||||
|
paperclip_domain: muppet-vision.wdw-labs.cloud
|
||||||
|
paperclip_base_path: /opt/paperclip
|
||||||
|
paperclip_port: 3100
|
||||||
|
certbot_email: ryan.blundon@protonmail.com
|
||||||
@@ -80,8 +80,8 @@ honcho_server:
|
|||||||
|
|
||||||
paperclip_server:
|
paperclip_server:
|
||||||
hosts:
|
hosts:
|
||||||
muppet-vision:
|
illuminations:
|
||||||
ansible_host: 10.1.71.136
|
ansible_host: 10.1.71.130
|
||||||
ansible_user: wed
|
ansible_user: wed
|
||||||
ansible_become: true
|
ansible_become: true
|
||||||
|
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
---
|
|
||||||
- name: Deploy Paperclip AI via Podman Quadlets
|
|
||||||
hosts: paperclip_server
|
|
||||||
become: false
|
|
||||||
vars:
|
|
||||||
deploy_dir: "/home/{{ ansible_user }}/paperclip-deploy"
|
|
||||||
quadlet_dir: "/home/{{ ansible_user }}/.config/containers/systemd"
|
|
||||||
db_user: "paperclip"
|
|
||||||
db_password: "paperclip_password_change_me"
|
|
||||||
db_name: "paperclip"
|
|
||||||
better_auth_secret: "super_secret_better_auth_key_change_me"
|
|
||||||
paperclip_public_url: "http://muppet-vision.local:3100"
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
- name: "Ensure deployment and quadlet directories exist"
|
|
||||||
file:
|
|
||||||
path: "{{ item }}"
|
|
||||||
state: directory
|
|
||||||
mode: '0755'
|
|
||||||
loop:
|
|
||||||
- "{{ deploy_dir }}"
|
|
||||||
- "{{ quadlet_dir }}"
|
|
||||||
|
|
||||||
- name: "Clone Paperclip repository"
|
|
||||||
git:
|
|
||||||
repo: 'https://github.com/paperclipai/paperclip.git'
|
|
||||||
dest: "{{ deploy_dir }}/source"
|
|
||||||
version: master
|
|
||||||
force: yes
|
|
||||||
|
|
||||||
- name: "Build paperclip-local image"
|
|
||||||
shell: |
|
|
||||||
podman build -t paperclip-local {{ deploy_dir }}/source
|
|
||||||
args:
|
|
||||||
chdir: "{{ deploy_dir }}"
|
|
||||||
|
|
||||||
- name: "Create environment file (paperclip.env)"
|
|
||||||
copy:
|
|
||||||
dest: "{{ quadlet_dir }}/paperclip.env"
|
|
||||||
content: |
|
|
||||||
POSTGRES_USER={{ db_user }}
|
|
||||||
POSTGRES_PASSWORD={{ db_password }}
|
|
||||||
POSTGRES_DB={{ db_name }}
|
|
||||||
BETTER_AUTH_SECRET={{ better_auth_secret }}
|
|
||||||
PAPERCLIP_PUBLIC_URL={{ paperclip_public_url }}
|
|
||||||
mode: '0600'
|
|
||||||
|
|
||||||
- name: "Deploy Quadlet: paperclip.pod"
|
|
||||||
copy:
|
|
||||||
dest: "{{ quadlet_dir }}/paperclip.pod"
|
|
||||||
content: |
|
|
||||||
[Unit]
|
|
||||||
Description=Paperclip Pod
|
|
||||||
|
|
||||||
[Pod]
|
|
||||||
PodName=paperclip
|
|
||||||
PublishPort=3100:3100
|
|
||||||
mode: '0644'
|
|
||||||
|
|
||||||
- name: "Deploy Quadlet: paperclip-db.container"
|
|
||||||
copy:
|
|
||||||
dest: "{{ quadlet_dir }}/paperclip-db.container"
|
|
||||||
content: |
|
|
||||||
[Unit]
|
|
||||||
Description=PostgreSQL for Paperclip
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=docker.io/library/postgres:17-alpine
|
|
||||||
ContainerName=paperclip-db
|
|
||||||
Pod=paperclip.pod
|
|
||||||
Volume=paperclip-pgdata:/var/lib/postgresql/data
|
|
||||||
EnvironmentFile=%h/.config/containers/systemd/paperclip.env
|
|
||||||
HealthCmd=pg_isready -U $POSTGRES_USER -d $POSTGRES_DB -h localhost || exit 1
|
|
||||||
HealthInterval=15s
|
|
||||||
HealthTimeout=5s
|
|
||||||
HealthRetries=5
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=on-failure
|
|
||||||
TimeoutStartSec=60
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
mode: '0644'
|
|
||||||
|
|
||||||
- name: "Deploy Quadlet: paperclip.container"
|
|
||||||
copy:
|
|
||||||
dest: "{{ quadlet_dir }}/paperclip.container"
|
|
||||||
content: |
|
|
||||||
[Unit]
|
|
||||||
Description=Paperclip AI Agent Orchestrator
|
|
||||||
Requires=paperclip-db.service
|
|
||||||
After=paperclip-db.service
|
|
||||||
|
|
||||||
[Container]
|
|
||||||
Image=paperclip-local
|
|
||||||
ContainerName=paperclip
|
|
||||||
Pod=paperclip.pod
|
|
||||||
Volume=%h/.local/share/paperclip:/paperclip:Z
|
|
||||||
Environment=HOST=0.0.0.0
|
|
||||||
Environment=PAPERCLIP_HOME=/paperclip
|
|
||||||
Environment=PAPERCLIP_DEPLOYMENT_MODE=authenticated
|
|
||||||
Environment=PAPERCLIP_DEPLOYMENT_EXPOSURE=private
|
|
||||||
Environment=PAPERCLIP_PUBLIC_URL={{ paperclip_public_url }}
|
|
||||||
EnvironmentFile=%h/.config/containers/systemd/paperclip.env
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Restart=on-failure
|
|
||||||
TimeoutStartSec=120
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=default.target
|
|
||||||
mode: '0644'
|
|
||||||
|
|
||||||
- name: "Reload systemd daemon"
|
|
||||||
shell: systemctl --user daemon-reload
|
|
||||||
|
|
||||||
- name: "Start Paperclip services"
|
|
||||||
systemd:
|
|
||||||
name: paperclip.service
|
|
||||||
state: started
|
|
||||||
scope: user
|
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
- name: Deploy Paperclip stack with nginx and certbot
|
||||||
|
hosts: illuminations
|
||||||
|
become: true
|
||||||
|
vars_files:
|
||||||
|
- "{{ playbook_dir }}/../group_vars/all/vars"
|
||||||
|
- "{{ playbook_dir }}/../group_vars/all/vault"
|
||||||
|
|
||||||
|
roles:
|
||||||
|
- docker-host
|
||||||
|
- paperclip-docker
|
||||||
|
|
||||||
|
post_tasks:
|
||||||
|
- name: Verify Paperclip stack is running
|
||||||
|
community.docker.docker_container_info:
|
||||||
|
name: "{{ item }}"
|
||||||
|
register: container_status
|
||||||
|
loop:
|
||||||
|
- paperclip
|
||||||
|
- paperclip-nginx
|
||||||
|
- paperclip-certbot
|
||||||
|
failed_when: container_status.containers | length == 0
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Verify Docker is available
|
||||||
|
command: docker --version
|
||||||
|
register: docker_version
|
||||||
|
changed_when: false
|
||||||
|
|
||||||
|
- name: Show Docker version
|
||||||
|
debug:
|
||||||
|
msg: "Docker version: {{ docker_version.stdout }}"
|
||||||
|
|
||||||
|
- name: Verify docker-compose plugin is available
|
||||||
|
command: docker compose version
|
||||||
|
register: compose_version
|
||||||
|
changed_when: false
|
||||||
|
ignore_errors: true
|
||||||
|
|
||||||
|
- name: Show docker-compose version
|
||||||
|
debug:
|
||||||
|
msg: "Docker Compose version: {{ compose_version.stdout | default('docker-compose plugin not found') }}"
|
||||||
|
|
||||||
|
- name: Display deployment summary
|
||||||
|
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 (reverse proxy): 0.0.0.0:443 / 0.0.0.0:80
|
||||||
|
- certbot: Running certificate renewal checks
|
||||||
|
|
||||||
|
Data location:
|
||||||
|
- {{ paperclip_base_path }}/data
|
||||||
|
|
||||||
|
Certificates:
|
||||||
|
- {{ paperclip_certs_dir }}/live/{{ paperclip_domain }}/
|
||||||
|
|
||||||
|
vLLM endpoint:
|
||||||
|
- {{ vllm_endpoint }}
|
||||||
|
|
||||||
|
Next steps:
|
||||||
|
1. Verify DNS resolution for {{ paperclip_domain }}
|
||||||
|
2. Visit https://{{ paperclip_domain }} to access Paperclip
|
||||||
|
3. Configure Paperclip with OpenCode adapter (should auto-detect vLLM at {{ vllm_endpoint }})
|
||||||
|
4. Set up Hermes Gateway on Mac Studio
|
||||||
|
5. Configure agents and skills
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
# Installation Guide - Paperclip Docker Role
|
||||||
|
|
||||||
|
## File Placement
|
||||||
|
|
||||||
|
Copy the files from this directory to your ansible repo as follows:
|
||||||
|
|
||||||
|
### Role Files
|
||||||
|
|
||||||
|
Create the role directory structure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p roles/paperclip-docker/{defaults,handlers,tasks,templates}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then copy:
|
||||||
|
|
||||||
|
| From | To |
|
||||||
|
|------|-----|
|
||||||
|
| `defaults_main.yml` | `roles/paperclip-docker/defaults/main.yml` |
|
||||||
|
| `handlers_main.yml` | `roles/paperclip-docker/handlers/main.yml` |
|
||||||
|
| `tasks_main.yml` | `roles/paperclip-docker/tasks/main.yml` |
|
||||||
|
| `docker-compose.yml.j2` | `roles/paperclip-docker/templates/docker-compose.yml.j2` |
|
||||||
|
| `nginx.conf.j2` | `roles/paperclip-docker/templates/nginx.conf.j2` |
|
||||||
|
| `certbot-init.sh.j2` | `roles/paperclip-docker/templates/certbot-init.sh.j2` |
|
||||||
|
| `README.md` | `roles/paperclip-docker/README.md` |
|
||||||
|
|
||||||
|
### Host Variables
|
||||||
|
|
||||||
|
Create the host directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p host_vars/illuminations.wdw-labs.cloud
|
||||||
|
```
|
||||||
|
|
||||||
|
Then copy:
|
||||||
|
|
||||||
|
| From | To |
|
||||||
|
|------|-----|
|
||||||
|
| `host_vars_illuminations.yml` | `host_vars/illuminations.wdw-labs.cloud/vars.yml` |
|
||||||
|
|
||||||
|
### Playbook
|
||||||
|
|
||||||
|
Copy:
|
||||||
|
|
||||||
|
| From | To |
|
||||||
|
|------|-----|
|
||||||
|
| `deploy_paperclip_stack.yml` | `playbooks/deploy_paperclip_stack.yml` |
|
||||||
|
|
||||||
|
## Complete Installation Command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From your ansible repo root
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
mkdir -p roles/paperclip-docker/{defaults,handlers,tasks,templates}
|
||||||
|
mkdir -p host_vars/illuminations.wdw-labs.cloud
|
||||||
|
|
||||||
|
# Copy files (adjust path to where you downloaded them)
|
||||||
|
cp /path/to/defaults_main.yml roles/paperclip-docker/defaults/main.yml
|
||||||
|
cp /path/to/handlers_main.yml roles/paperclip-docker/handlers/main.yml
|
||||||
|
cp /path/to/tasks_main.yml roles/paperclip-docker/tasks/main.yml
|
||||||
|
cp /path/to/docker-compose.yml.j2 roles/paperclip-docker/templates/
|
||||||
|
cp /path/to/nginx.conf.j2 roles/paperclip-docker/templates/
|
||||||
|
cp /path/to/certbot-init.sh.j2 roles/paperclip-docker/templates/
|
||||||
|
cp /path/to/README.md roles/paperclip-docker/
|
||||||
|
cp /path/to/host_vars_illuminations.yml host_vars/illuminations.wdw-labs.cloud/vars.yml
|
||||||
|
cp /path/to/deploy_paperclip_stack.yml playbooks/
|
||||||
|
|
||||||
|
# Verify structure
|
||||||
|
tree roles/paperclip-docker
|
||||||
|
tree host_vars/illuminations.wdw-labs.cloud
|
||||||
|
ls -la playbooks/deploy_paperclip_stack.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Final Directory Structure
|
||||||
|
|
||||||
|
After installation, your ansible repo should have:
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── roles/
|
||||||
|
│ └── paperclip-docker/
|
||||||
|
│ ├── README.md
|
||||||
|
│ ├── defaults/
|
||||||
|
│ │ └── main.yml
|
||||||
|
│ ├── handlers/
|
||||||
|
│ │ └── main.yml
|
||||||
|
│ ├── tasks/
|
||||||
|
│ │ └── main.yml
|
||||||
|
│ └── templates/
|
||||||
|
│ ├── certbot-init.sh.j2
|
||||||
|
│ ├── docker-compose.yml.j2
|
||||||
|
│ └── nginx.conf.j2
|
||||||
|
├── playbooks/
|
||||||
|
│ ├── deploy_paperclip_stack.yml
|
||||||
|
│ └── (other existing playbooks)
|
||||||
|
├── host_vars/
|
||||||
|
│ ├── illuminations.wdw-labs.cloud/
|
||||||
|
│ │ └── vars.yml
|
||||||
|
│ └── (other existing host vars)
|
||||||
|
├── group_vars/
|
||||||
|
│ └── (existing group vars)
|
||||||
|
├── inventory.yml
|
||||||
|
├── ansible.cfg
|
||||||
|
└── (other existing files)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Copy the files** as described above
|
||||||
|
2. **Review variables** in `host_vars/illuminations.wdw-labs.cloud/vars.yml` - update if needed
|
||||||
|
3. **Verify vault** contains `vault_cloudflare_api_token`
|
||||||
|
4. **Test connectivity**:
|
||||||
|
```bash
|
||||||
|
ansible -i inventory.yml illuminations.wdw-labs.cloud -m ping
|
||||||
|
```
|
||||||
|
5. **Run the playbook**:
|
||||||
|
```bash
|
||||||
|
ansible-playbook playbooks/deploy_paperclip_stack.yml -i inventory.yml -K
|
||||||
|
```
|
||||||
|
(The `-K` prompts for become password)
|
||||||
|
|
||||||
|
**Note:** The playbook automatically calls the `docker-host` role first to ensure Docker is installed. No manual Docker setup needed.
|
||||||
|
|
||||||
|
## Verification After Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Verify the role exists
|
||||||
|
ls -la roles/paperclip-docker/
|
||||||
|
|
||||||
|
# Verify host vars exist
|
||||||
|
cat host_vars/illuminations.wdw-labs.cloud/vars.yml
|
||||||
|
|
||||||
|
# Verify playbook exists
|
||||||
|
cat playbooks/deploy_paperclip_stack.yml
|
||||||
|
|
||||||
|
# Test syntax
|
||||||
|
ansible-playbook playbooks/deploy_paperclip_stack.yml --syntax-check
|
||||||
|
|
||||||
|
# Dry run
|
||||||
|
ansible-playbook playbooks/deploy_paperclip_stack.yml -i inventory.yml --check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting Installation
|
||||||
|
|
||||||
|
### Role not found error
|
||||||
|
```
|
||||||
|
ERROR! the role 'paperclip-docker' was not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Ensure `roles/paperclip-docker/` exists with `tasks/main.yml` inside.
|
||||||
|
|
||||||
|
### Template not found error
|
||||||
|
```
|
||||||
|
ERROR! Unable to find template paperclip-docker/docker-compose.yml.j2
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Ensure templates are in `roles/paperclip-docker/templates/` with `.j2` extension.
|
||||||
|
|
||||||
|
### Vault variable not found
|
||||||
|
```
|
||||||
|
fatal: [illuminations]: FAILED! => error: Undefined variable: vault_cloudflare_api_token
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Ensure `vault_cloudflare_api_token` is defined in your vault file:
|
||||||
|
```bash
|
||||||
|
ansible-vault view group_vars/all/vault
|
||||||
|
```
|
||||||
|
|
||||||
|
### Host not in inventory
|
||||||
|
```
|
||||||
|
ERROR! Unable to parse illuminations.wdw-labs.cloud as an inventory address
|
||||||
|
```
|
||||||
|
|
||||||
|
**Solution:** Ensure your `inventory.yml` contains the host:
|
||||||
|
```yaml
|
||||||
|
all:
|
||||||
|
hosts:
|
||||||
|
illuminations.wdw-labs.cloud:
|
||||||
|
ansible_host: <ip_address>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files Reference
|
||||||
|
|
||||||
|
All files are self-contained and ready to use. No modifications needed unless:
|
||||||
|
|
||||||
|
- You want to change the domain (edit `host_vars/illuminations.wdw-labs.cloud/vars.yml`)
|
||||||
|
- You want to change ports (edit `roles/paperclip-docker/defaults/main.yml`)
|
||||||
|
- You want to change the certbot email (edit host_vars)
|
||||||
|
|
||||||
|
For complete documentation, see `roles/paperclip-docker/README.md` after installation.
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
---
|
||||||
|
- name: Install Docker SDK for Python
|
||||||
|
apt:
|
||||||
|
name: python3-docker
|
||||||
|
state: present
|
||||||
|
become: yes
|
||||||
|
|
||||||
|
- name: Download latest docker-compose from GitHub
|
||||||
|
shell: |
|
||||||
|
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||||
|
chmod +x /usr/local/bin/docker-compose
|
||||||
|
become: yes
|
||||||
|
|
||||||
|
- name: Verify docker-compose installation
|
||||||
|
shell: /usr/local/bin/docker-compose --version
|
||||||
|
register: compose_version
|
||||||
|
become: yes
|
||||||
|
|
||||||
|
- name: Show docker-compose version
|
||||||
|
debug:
|
||||||
|
msg: "{{ compose_version.stdout }}"
|
||||||
|
|
||||||
|
- name: Create paperclip directories
|
||||||
|
file:
|
||||||
|
path: "{{ item }}"
|
||||||
|
state: directory
|
||||||
|
mode: '0755'
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
loop:
|
||||||
|
- "{{ paperclip_base_path }}"
|
||||||
|
- "{{ paperclip_data_dir }}"
|
||||||
|
- "{{ paperclip_certs_dir }}"
|
||||||
|
- "{{ paperclip_base_path }}/pgdata"
|
||||||
|
become: yes
|
||||||
|
|
||||||
|
- name: Generate BETTER_AUTH_SECRET
|
||||||
|
shell: openssl rand -hex 24
|
||||||
|
register: better_auth_secret
|
||||||
|
changed_when: false
|
||||||
|
no_log: yes
|
||||||
|
|
||||||
|
- name: Generate PAPERCLIP_AGENT_JWT_SECRET
|
||||||
|
shell: openssl rand -hex 24
|
||||||
|
register: paperclip_jwt_secret
|
||||||
|
changed_when: false
|
||||||
|
no_log: yes
|
||||||
|
|
||||||
|
- name: Write secrets and configuration to .env
|
||||||
|
copy:
|
||||||
|
content: |
|
||||||
|
# Authentication secrets (auto-generated during deployment)
|
||||||
|
BETTER_AUTH_SECRET={{ better_auth_secret.stdout }}
|
||||||
|
PAPERCLIP_AGENT_JWT_SECRET={{ paperclip_jwt_secret.stdout }}
|
||||||
|
|
||||||
|
# Infrastructure configuration
|
||||||
|
CLOUDFLARE_API_TOKEN={{ vault_cloudflare_api_token }}
|
||||||
|
CERTBOT_EMAIL={{ certbot_email }}
|
||||||
|
PAPERCLIP_DOMAIN={{ paperclip_domain }}
|
||||||
|
VLLM_ENDPOINT={{ vllm_endpoint }}
|
||||||
|
dest: "{{ paperclip_base_path }}/.env"
|
||||||
|
mode: '0600'
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
become: yes
|
||||||
|
no_log: yes
|
||||||
|
|
||||||
|
- name: Template docker-compose.yml
|
||||||
|
template:
|
||||||
|
src: docker-compose.yml.j2
|
||||||
|
dest: "{{ paperclip_base_path }}/docker-compose.yml"
|
||||||
|
mode: '0644'
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
become: yes
|
||||||
|
notify: Restart paperclip stack
|
||||||
|
|
||||||
|
- name: Template nginx config
|
||||||
|
template:
|
||||||
|
src: nginx.conf.j2
|
||||||
|
dest: "{{ paperclip_base_path }}/nginx.conf"
|
||||||
|
mode: '0644'
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
become: yes
|
||||||
|
notify: Reload nginx
|
||||||
|
|
||||||
|
- name: Template certbot init script
|
||||||
|
template:
|
||||||
|
src: certbot-init.sh.j2
|
||||||
|
dest: "{{ paperclip_base_path }}/certbot-init.sh"
|
||||||
|
mode: '0755'
|
||||||
|
owner: root
|
||||||
|
group: root
|
||||||
|
become: yes
|
||||||
|
|
||||||
|
- name: Stop and remove old containers
|
||||||
|
shell: |
|
||||||
|
cd {{ paperclip_base_path }}
|
||||||
|
/usr/local/bin/docker-compose down -v --remove-orphans 2>/dev/null || true
|
||||||
|
become: yes
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Deploy docker compose stack
|
||||||
|
shell: |
|
||||||
|
cd {{ paperclip_base_path }}
|
||||||
|
/usr/local/bin/docker-compose up -d
|
||||||
|
become: yes
|
||||||
|
register: compose_result
|
||||||
|
|
||||||
|
- name: Wait for Paperclip to be ready
|
||||||
|
uri:
|
||||||
|
url: "http://localhost:{{ paperclip_port }}/health"
|
||||||
|
status_code: 200
|
||||||
|
register: result
|
||||||
|
until: result.status == 200
|
||||||
|
retries: 30
|
||||||
|
delay: 5
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Wait for nginx to be ready
|
||||||
|
uri:
|
||||||
|
url: "https://{{ paperclip_domain }}/"
|
||||||
|
validate_certs: no
|
||||||
|
status_code: [200, 301, 302]
|
||||||
|
register: result
|
||||||
|
until: result.status in [200, 301, 302]
|
||||||
|
retries: 30
|
||||||
|
delay: 5
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Verify Paperclip is accessible via https
|
||||||
|
debug:
|
||||||
|
msg: "Paperclip is accessible at https://{{ paperclip_domain }}"
|
||||||
|
when: result.status is defined and result.status in [200, 301, 302]
|
||||||
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
# Paperclip deployment paths and configuration
|
||||||
|
paperclip_base_path: /opt/paperclip
|
||||||
|
paperclip_data_dir: "{{ paperclip_base_path }}/data"
|
||||||
|
paperclip_certs_dir: "{{ paperclip_base_path }}/certs"
|
||||||
|
paperclip_port: 3100
|
||||||
|
paperclip_domain: muppet-vision.wdw-labs.cloud
|
||||||
|
paperclip_host: 127.0.0.1
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|
||||||
|
# Reverse proxy configuration
|
||||||
|
nginx_port: 443
|
||||||
|
nginx_http_port: 80
|
||||||
|
|
||||||
|
# HTTPS/SSL configuration
|
||||||
|
certbot_email: admin@wdw-labs.cloud
|
||||||
|
certbot_provider: cloudflare
|
||||||
|
|
||||||
|
# Inference endpoint (vLLM or remote OpenAI-compatible)
|
||||||
|
vllm_endpoint: "http://127.0.0.1:8000"
|
||||||
|
|
||||||
|
# NOTE: Authentication secrets (BETTER_AUTH_SECRET, PAPERCLIP_AGENT_JWT_SECRET)
|
||||||
|
# are auto-generated during deployment and stored in .env (mode 0600)
|
||||||
|
# No need to specify them here — they're created with: openssl rand -hex 24
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
- name: Restart paperclip stack
|
||||||
|
shell: |
|
||||||
|
cd {{ paperclip_base_path }}
|
||||||
|
/usr/local/bin/docker-compose restart
|
||||||
|
become: yes
|
||||||
|
|
||||||
|
- name: Reload nginx
|
||||||
|
shell: |
|
||||||
|
docker exec paperclip-nginx nginx -s reload
|
||||||
|
become: yes
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Reload certbot
|
||||||
|
shell: |
|
||||||
|
docker restart paperclip-certbot
|
||||||
|
become: yes
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
- 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: Fix directory ownership for container user (UID 1000)
|
||||||
|
shell: |
|
||||||
|
chown -R 1000:1000 {{ paperclip_data_dir }}
|
||||||
|
chown -R 1000:1000 {{ 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 --remove-orphans 2>/dev/null || true
|
||||||
|
become: yes
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Deploy docker compose stack
|
||||||
|
shell: |
|
||||||
|
cd {{ paperclip_base_path }}
|
||||||
|
/usr/local/bin/docker-compose up -d
|
||||||
|
become: yes
|
||||||
|
register: compose_result
|
||||||
|
|
||||||
|
- name: Wait for Paperclip to be ready
|
||||||
|
uri:
|
||||||
|
url: "http://localhost:{{ paperclip_port }}/health"
|
||||||
|
status_code: 200
|
||||||
|
register: result
|
||||||
|
until: result.status == 200
|
||||||
|
retries: 30
|
||||||
|
delay: 5
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Wait for nginx to be ready
|
||||||
|
uri:
|
||||||
|
url: "https://{{ paperclip_domain }}/"
|
||||||
|
validate_certs: no
|
||||||
|
status_code: [200, 301, 302]
|
||||||
|
register: result
|
||||||
|
until: result.status in [200, 301, 302]
|
||||||
|
retries: 30
|
||||||
|
delay: 5
|
||||||
|
ignore_errors: yes
|
||||||
|
|
||||||
|
- name: Verify Paperclip is accessible via https
|
||||||
|
debug:
|
||||||
|
msg: "Paperclip is accessible at https://{{ paperclip_domain }}"
|
||||||
|
when: result.status is defined and result.status in [200, 301, 302]
|
||||||
|
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DOMAIN="${PAPERCLIP_DOMAIN}"
|
||||||
|
CERT_PATH="/etc/letsencrypt/live/${DOMAIN}/fullchain.pem"
|
||||||
|
EMAIL="${CERTBOT_EMAIL}"
|
||||||
|
TOKEN="${CLOUDFLARE_API_TOKEN}"
|
||||||
|
|
||||||
|
# Verify environment variables
|
||||||
|
if [ -z "$DOMAIN" ]; then
|
||||||
|
echo "ERROR: PAPERCLIP_DOMAIN not set"
|
||||||
|
exit 1
|
||||||
|
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
|
||||||
|
|
||||||
|
# Check if certificate already exists
|
||||||
|
if [ -f "$CERT_PATH" ]; then
|
||||||
|
echo "Certificate for $DOMAIN already exists at $CERT_PATH"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Requesting certificate for $DOMAIN via Cloudflare DNS challenge..."
|
||||||
|
|
||||||
|
# Create a temporary credentials file (more reliable than env var with certbot)
|
||||||
|
CREDS_FILE="/tmp/cloudflare.ini"
|
||||||
|
cat > "$CREDS_FILE" << EOF
|
||||||
|
dns_cloudflare_api_token = ${TOKEN}
|
||||||
|
EOF
|
||||||
|
chmod 600 "$CREDS_FILE"
|
||||||
|
|
||||||
|
# Request certificate with Cloudflare DNS plugin
|
||||||
|
if certbot certonly \
|
||||||
|
--dns-cloudflare \
|
||||||
|
--dns-cloudflare-credentials "$CREDS_FILE" \
|
||||||
|
--agree-tos \
|
||||||
|
--no-eff-email \
|
||||||
|
--email "$EMAIL" \
|
||||||
|
--non-interactive \
|
||||||
|
-d "$DOMAIN"; then
|
||||||
|
echo "Certificate obtained successfully for $DOMAIN"
|
||||||
|
else
|
||||||
|
echo "ERROR: Failed to obtain certificate for $DOMAIN"
|
||||||
|
rm -f "$CREDS_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up credentials file
|
||||||
|
rm -f "$CREDS_FILE"
|
||||||
|
|
||||||
|
# Verify certificate
|
||||||
|
if [ -f "$CERT_PATH" ]; then
|
||||||
|
echo "Certificate verified at $CERT_PATH"
|
||||||
|
else
|
||||||
|
echo "ERROR: Certificate not found at $CERT_PATH after successful certbot run"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: paperclip-db
|
||||||
|
network_mode: host
|
||||||
|
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: reeoss/paperclipai-paperclip:latest
|
||||||
|
container_name: paperclip
|
||||||
|
network_mode: host
|
||||||
|
env_file:
|
||||||
|
- {{ paperclip_base_path }}/.env
|
||||||
|
environment:
|
||||||
|
HOST: "127.0.0.1"
|
||||||
|
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
|
||||||
|
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
|
||||||
|
|
||||||
|
certbot:
|
||||||
|
image: certbot/dns-cloudflare:latest
|
||||||
|
container_name: paperclip-certbot
|
||||||
|
volumes:
|
||||||
|
- {{ paperclip_certs_dir }}:/etc/letsencrypt
|
||||||
|
- {{ paperclip_base_path }}/certbot-init.sh:/certbot-init.sh:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
environment:
|
||||||
|
CLOUDFLARE_API_TOKEN: ${CLOUDFLARE_API_TOKEN}
|
||||||
|
CERTBOT_EMAIL: ${CERTBOT_EMAIL}
|
||||||
|
PAPERCLIP_DOMAIN: ${PAPERCLIP_DOMAIN}
|
||||||
|
entrypoint: /bin/sh
|
||||||
|
command:
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
/certbot-init.sh && while true; do certbot renew --quiet; sleep 12h; done
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
nginx:
|
||||||
|
image: nginx:latest
|
||||||
|
container_name: paperclip-nginx
|
||||||
|
network_mode: host
|
||||||
|
volumes:
|
||||||
|
- {{ paperclip_base_path }}/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||||
|
- {{ paperclip_certs_dir }}:/etc/nginx/certs:ro
|
||||||
|
depends_on:
|
||||||
|
- certbot
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:80/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
paperclip_data:
|
||||||
|
driver: local
|
||||||
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/nginx/error.log warn;
|
||||||
|
pid /var/run/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||||
|
'$status $body_bytes_sent "$http_referer" '
|
||||||
|
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||||
|
|
||||||
|
access_log /var/log/nginx/access.log main;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
tcp_nodelay on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
types_hash_max_size 2048;
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
# Redirect HTTP to HTTPS
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name {{ paperclip_domain }};
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS server
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name {{ paperclip_domain }};
|
||||||
|
|
||||||
|
# SSL certificates from certbot
|
||||||
|
ssl_certificate /etc/nginx/certs/live/{{ paperclip_domain }}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/live/{{ paperclip_domain }}/privkey.pem;
|
||||||
|
|
||||||
|
# SSL configuration
|
||||||
|
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;
|
||||||
|
|
||||||
|
# Security headers
|
||||||
|
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://127.0.0.1:{{ paperclip_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";
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
proxy_connect_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
|
||||||
|
# Buffering
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_request_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Health check endpoint (no auth)
|
||||||
|
location /health {
|
||||||
|
proxy_pass http://127.0.0.1:{{ paperclip_port }}/health;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user