homelab/services/ha-sync/internal/ui/templates/about.html
Dan V deb6c38d7b chore: commit homelab setup — deployment, services, orchestration, skill
- Add .gitignore: exclude compiled binaries, build artifacts, and Helm
  values files containing real secrets (authentik, prometheus)
- Add all Kubernetes deployment manifests (deployment/)
- Add services source code: ha-sync, device-inventory, games-console,
  paperclip, parts-inventory
- Add Ansible orchestration: playbooks, roles, inventory, cloud-init
- Add hardware specs, execution plans, scripts, HOMELAB.md
- Add skills/homelab/SKILL.md + skills/install.sh to preserve Copilot skill
- Remove previously-tracked inventory-cli binary from git index

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-09 08:10:32 +02:00

294 lines
10 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HA Sync — How It Works</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d1117;
--surface: #161b22;
--border: #30363d;
--text: #c9d1d9;
--muted: #8b949e;
--accent: #58a6ff;
--success: #3fb950;
--warning: #d29922;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
font-size: 14px;
min-height: 100vh;
}
header {
padding: 18px 28px;
border-bottom: 1px solid var(--border);
background: var(--surface);
display: flex;
align-items: center;
gap: 16px;
}
header a.back {
color: var(--muted);
text-decoration: none;
font-size: 13px;
}
header a.back:hover { color: var(--accent); }
header h1 {
font-size: 18px;
font-weight: 600;
color: var(--accent);
}
main {
max-width: 820px;
margin: 0 auto;
padding: 36px 28px 64px;
}
h2 {
font-size: 16px;
font-weight: 600;
color: #e6edf3;
margin: 32px 0 12px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
h2:first-of-type { margin-top: 0; }
p {
color: var(--text);
line-height: 1.65;
margin-bottom: 10px;
}
ul, ol {
padding-left: 22px;
margin-bottom: 10px;
}
li {
line-height: 1.65;
margin-bottom: 4px;
}
code {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 6px;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
color: #e6edf3;
}
.callout {
background: var(--surface);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: 6px;
padding: 12px 16px;
margin: 16px 0;
font-size: 13px;
line-height: 1.6;
color: var(--muted);
}
.callout strong { color: var(--text); }
.callout.warn { border-left-color: var(--warning); }
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
margin: 12px 0 20px;
}
th {
background: var(--surface);
color: var(--muted);
font-weight: 600;
text-align: left;
padding: 8px 12px;
border: 1px solid var(--border);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
td {
padding: 8px 12px;
border: 1px solid var(--border);
vertical-align: top;
}
td code { font-size: 11px; }
.flow {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin: 16px 0;
font-size: 13px;
}
.flow-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 14px;
color: #e6edf3;
font-weight: 500;
white-space: nowrap;
}
.flow-arrow { color: var(--muted); font-size: 16px; }
</style>
</head>
<body>
<header>
<a class="back" href="/">← Dashboard</a>
<h1>How HA Sync Works</h1>
</header>
<main>
<h2>Overview</h2>
<p>
HA Sync is a homelab service that keeps six NFS-exported data folders in sync between two
physical servers — a <strong>Dell OptiPlex 7070</strong> (<code>192.168.2.100</code>) and an
<strong>HP ProLiant DL360 G7</strong> (<code>192.168.2.193</code>) — so that either machine
can take over if the other goes down.
</p>
<p>
Sync runs as Kubernetes CronJobs every 15 minutes. Each folder pair has two jobs:
one copying Dell → HP, and one copying HP → Dell (bidirectional, last-writer-wins).
</p>
<div class="callout warn">
<strong>Dry-run mode is on by default.</strong> CronJobs run with <code>--dry-run</code>
until you remove that flag. The dashboard shows what <em>would</em> be synced —
no files are actually moved until you enable real mode.
</div>
<h2>Sync Pairs</h2>
<table>
<thead>
<tr><th>Pair</th><th>Dell path</th><th>HP path</th><th>Description</th></tr>
</thead>
<tbody>
<tr><td><code>media</code></td> <td><code>/data/media</code></td> <td><code>/data/media</code></td> <td>Movies, TV shows, music</td></tr>
<tr><td><code>photos</code></td> <td><code>/data/photos</code></td> <td><code>/data/photos</code></td> <td>Personal photo library</td></tr>
<tr><td><code>owncloud</code></td> <td><code>/data/owncloud</code></td> <td><code>/data/owncloud</code></td> <td>OwnCloud user data</td></tr>
<tr><td><code>games</code></td> <td><code>/data/games</code></td> <td><code>/data/games</code></td> <td>Game storage</td></tr>
<tr><td><code>infra</code></td> <td><code>/data/infra</code></td> <td><code>/data/infra</code></td> <td>Infrastructure configs &amp; DB data</td></tr>
<tr><td><code>ai</code></td> <td><code>/data/ai</code></td> <td><code>/data/ai</code></td> <td>AI model weights &amp; datasets</td></tr>
</tbody>
</table>
<h2>How a Sync Run Works</h2>
<div class="flow">
<div class="flow-box">Acquire K8s Lease</div>
<span class="flow-arrow"></span>
<div class="flow-box">Walk src &amp; dest trees</div>
<span class="flow-arrow"></span>
<div class="flow-box">Compare mtime + size</div>
<span class="flow-arrow"></span>
<div class="flow-box">Copy / Delete (worker pool)</div>
<span class="flow-arrow"></span>
<div class="flow-box">Write results to MySQL</div>
<span class="flow-arrow"></span>
<div class="flow-box">Release Lease</div>
</div>
<ol>
<li><strong>Lease acquisition</strong> — the CronJob pod acquires a Kubernetes <code>Lease</code>
object (<code>coordination.k8s.io/v1</code>) named <code>ha-sync-&lt;pair&gt;</code>.
If another pod for the same pair is already running, it exits immediately.
The lease is heartbeated every <code>TTL/3</code> seconds and auto-expires on crash.</li>
<li><strong>Tree walk</strong> — source and destination directories are walked in parallel.
Each file's path, size, and modification time are collected into a hash map.</li>
<li><strong>Comparison</strong> — files are compared by mtime + size.
If they differ by less than 2 seconds (configurable), they are considered equal and skipped.
On a mtime/size mismatch an MD5 comparison is triggered to avoid false positives.</li>
<li><strong>Copy / delete</strong> — a configurable worker pool (default 4) processes
the operation queue. <code>os.Chtimes()</code> preserves the source mtime on every copy,
which prevents the reverse-direction job from re-copying the same file.</li>
<li><strong>Opslog flush</strong> — each operation is appended to a local JSONL file
(<code>/var/log/ha-sync/</code>, backed by NFS). After all ops complete, the file is
bulk-inserted into MySQL and deleted. If MySQL is down, the file is retried on the
next run.</li>
</ol>
<h2>Loop Prevention</h2>
<p>
Because sync is bidirectional, a naïve implementation would copy a file from A→B, then
copy it back B→A on the next run, forever. HA Sync avoids this by preserving the source
file's <strong>mtime</strong> on every copy. On the next run the comparison sees equal
mtimes and skips the file.
</p>
<p>
In a write conflict (both sides modified the same file between runs), the
<strong>newest mtime wins</strong> — the more recently modified copy is treated as
the source of truth and overwrites the other.
</p>
<h2>Dry-Run &amp; Idempotency</h2>
<p>
Running with <code>--dry-run</code> computes all would-be operations and saves them to
the <code>sync_iterations</code> / <code>sync_operations</code> tables with
<code>dry_run = 1</code>, but makes no file changes. The dashboard marks these rows
with a <span style="background:rgba(88,166,255,.15);color:#58a6ff;padding:1px 7px;border-radius:10px;font-size:11px;font-weight:600">DRY</span> badge.
</p>
<p>
If you trigger a second dry-run before anything changes on disk, the service detects
that the new would-be op set is identical to the previous one, skips writing new DB rows,
and prints <em>"no changes since last dry-run"</em>.
</p>
<h2>Enabling Real Sync</h2>
<p>
When you are satisfied with the dry-run output, remove <code>--dry-run</code> from
the CronJob args:
</p>
<div class="callout">
<strong>Patch a single pair:</strong><br>
<code>kubectl -n infrastructure edit cronjob ha-sync-media-dell-to-hp</code><br>
Remove <code>--dry-run</code> from <code>.spec.jobTemplate.spec.template.spec.containers[0].args</code>
</div>
<div class="callout">
<strong>Enable delete propagation</strong> (after initial full sync only):<br>
Add <code>--delete-missing</code> to the args of the <em>primary</em> direction CronJob.
Do not enable it on both directions simultaneously.
</div>
<h2>Infrastructure</h2>
<table>
<thead><tr><th>Component</th><th>Detail</th></tr></thead>
<tbody>
<tr><td>Language</td><td>Go 1.22, single static binary</td></tr>
<tr><td>Locking</td><td>Kubernetes <code>Lease</code> (<code>coordination.k8s.io/v1</code>), no MySQL dependency for locks</td></tr>
<tr><td>Database</td><td>MySQL 9 (<code>general-purpose-db</code> StatefulSet, <code>general_db</code> schema)</td></tr>
<tr><td>Storage</td><td>NFS PersistentVolumes, RWX — both servers export <code>/data/*</code></td></tr>
<tr><td>Schedule</td><td>Dell→HP every 15 min; HP→Dell at :07, :22, :37, :52 (staggered)</td></tr>
<tr><td>Workers</td><td>4 concurrent copy goroutines per run (configurable)</td></tr>
<tr><td>Log retention</td><td>Opslog JSONL files kept for 10 days before purge</td></tr>
</tbody>
</table>
</main>
</body>
</html>