- 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>
294 lines
10 KiB
HTML
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 & 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 & 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 & 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-<pair></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 & 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>
|