Support for bigger clusters.
This commit is contained in:
1 parent
be278b3c7c
commit
a6d014601d
8 files changed
+1310
-297
No files matched your search
@@ -18,12 +18,12 @@ Instances can run as a **primary** (accepts writes) or as a **read-only replica*
|
|||||||
- **`loose`**: All defined fields are required, but additional unknown fields are accepted.
|
- **`loose`**: All defined fields are required, but additional unknown fields are accepted.
|
||||||
- **Hot-Reloadable Configuration**: Reload `config.json` via an HTTP admin endpoint with atomic swap and rollback on error.
|
- **Hot-Reloadable Configuration**: Reload `config.json` via an HTTP admin endpoint with atomic swap and rollback on error.
|
||||||
- **Persistent Storage**: Validated records are stored with a generated UUID, an ISO-8601 UTC timestamp and a global sequence number (`seq`) in an SQLite database (`messages.db`).
|
- **Persistent Storage**: Validated records are stored with a generated UUID, an ISO-8601 UTC timestamp and a global sequence number (`seq`) in an SQLite database (`messages.db`).
|
||||||
- **Read Replication**:
|
- **Replication & Dynamic Role Switching**:
|
||||||
- Pull-based and cursor-driven: the replica asks for everything after its last `seq`.
|
- Pull-based and cursor-driven: the replica asks for everything after its last `seq`.
|
||||||
- Append-only journal with a global tick (`seq`) that is preserved on replicas.
|
- Append-only journal with a global tick (`seq`) that is preserved on replicas.
|
||||||
- Permanent per-instance ID, used to detect wrong, cloned or restored sources.
|
- Seamless role switch / failover without data loss or split-brain.
|
||||||
- Atomic, idempotent batch apply: safe across crashes, retries and duplicate deliveries.
|
- Automatic CA credential sharing during dynamic join so promoted replicas can continue minting tokens and signing nodes.
|
||||||
- Exponential backoff with jitter, small batches and gzip for flaky or slow links.
|
- Coordinated 1-step switchover API (`/api/admin/switchover`) and fine-grained controls (`/step-down`, `/promote`, `/repoint`).
|
||||||
- Replicas can serve replication to other replicas (relay).
|
- Replicas can serve replication to other replicas (relay).
|
||||||
- **Structured Logging**: Built with `tracing` outputting to standard output.
|
- **Structured Logging**: Built with `tracing` outputting to standard output.
|
||||||
|
|
||||||
@@ -89,7 +89,8 @@ The schema is only enforced on writes to a primary. A replica still loads the fi
|
|||||||
| `APP_CONFIG` | `config.json` | Path to the schema file. |
|
| `APP_CONFIG` | `config.json` | Path to the schema file. |
|
||||||
| `APP_BIND` | `127.0.0.1:3000` | Listen address. Change this for a primary that replicas must reach over a network. |
|
| `APP_BIND` | `127.0.0.1:3000` | Listen address. Change this for a primary that replicas must reach over a network. |
|
||||||
| `APP_ROLE` | `primary` | `primary` or `replica`. |
|
| `APP_ROLE` | `primary` | `primary` or `replica`. |
|
||||||
| `APP_PRIMARY_URL` | *none* | Base URL of the source, e.g. `https://primary.example:3000`. **Required for a replica.** |
|
| `APP_PRIMARY_URL` | *none* | Base URL of the replication source, e.g. `https://primary.example:3000`. **Required for a replica.** |
|
||||||
|
| `APP_JOIN_URL` | `APP_PRIMARY_URL` | CA enrollment URL used during first-boot mTLS join. Defaults to `APP_PRIMARY_URL`. Set this when the replication source differs from the Cluster CA holder (e.g., chain topologies where an intermediate replica serves data but cannot issue certificates). |
|
||||||
| `APP_REPL_TOKEN` | *none* | Shared bearer token for `/api/replication/changes`. **Required for a replica.** On a primary, replication stays disabled if it is unset. |
|
| `APP_REPL_TOKEN` | *none* | Shared bearer token for `/api/replication/changes`. **Required for a replica.** On a primary, replication stays disabled if it is unset. |
|
||||||
| `APP_SYNC_INTERVAL_SECS` | `5` | Replica idle poll interval (jittered) once caught up. |
|
| `APP_SYNC_INTERVAL_SECS` | `5` | Replica idle poll interval (jittered) once caught up. |
|
||||||
| `APP_SYNC_MAX_BACKOFF_SECS` | `60` | Upper bound for retry backoff after failed pulls. |
|
| `APP_SYNC_MAX_BACKOFF_SECS` | `60` | Upper bound for retry backoff after failed pulls. |
|
||||||
@@ -270,6 +271,40 @@ bash repl-test.sh
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Coordinated Graceful Switchover
|
||||||
|
- **Endpoint:** `POST /api/admin/switchover`
|
||||||
|
- **Description:** Initiated on the **active primary** to execute a coordinated, zero-downtime role switch with a replica. The primary quiesces (rejects new writes), waits for the replica to catch up to its exact `head_seq`, issues a promotion call to the replica, and reconfigures itself as a replica following the new primary.
|
||||||
|
- **Request Body:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"replica_url": "https://127.0.0.1:3001",
|
||||||
|
"timeout_secs": 15
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Response (`200 OK`):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Role switchover completed successfully",
|
||||||
|
"old_primary_role": "replica",
|
||||||
|
"new_primary_url": "https://127.0.0.1:3001",
|
||||||
|
"switched_at_seq": 42
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Manual Role Management Endpoints
|
||||||
|
|
||||||
|
- **`POST /api/admin/step-down`**:
|
||||||
|
Demotes a primary to a read-only replica. Optionally takes `{"primary_url": "https://..."}` to immediately begin pulling from a new primary.
|
||||||
|
- **`POST /api/admin/promote`**:
|
||||||
|
Promotes a replica to a primary. Halts any active replication sync loop, increments cluster epoch, and begins accepting writes at `POST /api/records`.
|
||||||
|
- **`POST /api/admin/repoint`**:
|
||||||
|
Updates a replica's source URL (`{"primary_url": "https://...", "reset_source": true}`). Resets the source instance guard and restarts the sync loop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Replication: Pull Changes
|
### Replication: Pull Changes
|
||||||
- **Endpoint:** `GET /api/replication/changes?after=<seq>&limit=<n>`
|
- **Endpoint:** `GET /api/replication/changes?after=<seq>&limit=<n>`
|
||||||
- **Headers:** `Authorization: Bearer <APP_REPL_TOKEN>`
|
- **Headers:** `Authorization: Bearer <APP_REPL_TOKEN>`
|
||||||
@@ -379,7 +414,7 @@ On a fatal mismatch the replica halts, because retrying cannot fix it, and the r
|
|||||||
- **Bootstrapping:** start a replica with an empty database. Don't seed it by copying the primary's `messages.db`, because the copy shares the primary's instance ID and the replica will refuse to sync.
|
- **Bootstrapping:** start a replica with an empty database. Don't seed it by copying the primary's `messages.db`, because the copy shares the primary's instance ID and the replica will refuse to sync.
|
||||||
- **Transport security:** replication uses a bearer token over whatever transport you give it. Put TLS in front of the primary (reverse proxy) for anything beyond a trusted network.
|
- **Transport security:** replication uses a bearer token over whatever transport you give it. Put TLS in front of the primary (reverse proxy) for anything beyond a trusted network.
|
||||||
- **Chaining:** a replica serves `/api/replication/changes` too (if it has a token), so replicas can follow other replicas. Downstream replicas see the upstream replica's instance ID as their source.
|
- **Chaining:** a replica serves `/api/replication/changes` too (if it has a token), so replicas can follow other replicas. Downstream replicas see the upstream replica's instance ID as their source.
|
||||||
- **Promotion:** a replica holds the same `seq` values as its source, so it can be restarted with `APP_ROLE=primary` to take over. Note that its instance ID differs from the old primary's, so other replicas will halt on an ID mismatch until they are reset against the new primary.
|
- **Promotion & Role Switching:** Replicas can be dynamically promoted at runtime without restart via `POST /api/admin/promote` or gracefully swapped via `POST /api/admin/switchover`. Nodes share Cluster CA credentials during initial join, allowing promoted primaries to issue join tokens and admit new nodes. Existing replicas can follow the new primary without resetting their databases via `POST /api/admin/repoint`.
|
||||||
- **Updates and deletes:** the design assumes records are only ever inserted. If updates or deletes are added later, the journal needs separate change entries (with tombstones), since replicas only observe inserts.
|
- **Updates and deletes:** the design assumes records are only ever inserted. If updates or deletes are added later, the journal needs separate change entries (with tombstones), since replicas only observe inserts.
|
||||||
- **Fan-out:** polling is fine for a handful of replicas. For many, consider long-polling or SSE on `/changes`.
|
- **Fan-out:** polling is fine for a handful of replicas. For many, consider long-polling or SSE on `/changes`.
|
||||||
|
|
||||||
|
|||||||
+388
-123
@@ -80,24 +80,36 @@
|
|||||||
border: 1px dashed var(--border);
|
border: 1px dashed var(--border);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
}
|
}
|
||||||
.node-box {
|
.node-box {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 10px 20px;
|
padding: 12px 20px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
background: var(--card-bg);
|
background: var(--card-bg);
|
||||||
min-width: 180px;
|
min-width: 200px;
|
||||||
|
flex: 1;
|
||||||
|
max-width: 320px;
|
||||||
}
|
}
|
||||||
.pipe {
|
.pipe {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 6px;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
min-width: 220px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
.arrow {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
color: var(--accent);
|
||||||
|
letter-spacing: 2px;
|
||||||
|
font-family: monospace;
|
||||||
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
.arrow { font-size: 1.5rem; color: var(--accent); letter-spacing: 2px; }
|
|
||||||
|
|
||||||
/* Inputs & Buttons */
|
/* Inputs & Buttons */
|
||||||
input, textarea, select {
|
input, textarea, select {
|
||||||
@@ -123,11 +135,22 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
}
|
}
|
||||||
button:hover { background: #30363d; border-color: #8b949e; color: #fff; }
|
button:hover:not(:disabled) { background: #30363d; border-color: #8b949e; color: #fff; }
|
||||||
|
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
button.btn-primary { background: #238636; border-color: #2ea043; color: #fff; }
|
button.btn-primary { background: #238636; border-color: #2ea043; color: #fff; }
|
||||||
button.btn-primary:hover { background: #2ea043; }
|
button.btn-primary:hover:not(:disabled) { background: #2ea043; }
|
||||||
button.btn-test { background: #1f6feb; border-color: #388bfd; color: #fff; }
|
button.btn-test { background: #1f6feb; border-color: #388bfd; color: #fff; }
|
||||||
button.btn-test:hover { background: #388bfd; }
|
button.btn-test:hover:not(:disabled) { background: #388bfd; }
|
||||||
|
button.btn-switch { background: #8957e5; border-color: #a371f7; color: #fff; font-weight: 600; }
|
||||||
|
button.btn-switch:hover:not(:disabled) { background: #9a67ea; border-color: #b388ff; }
|
||||||
|
|
||||||
|
.write-box {
|
||||||
|
background: rgba(0, 0, 0, 0.25);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Tables */
|
/* Tables */
|
||||||
.table-container { max-height: 380px; overflow-y: auto; }
|
.table-container { max-height: 380px; overflow-y: auto; }
|
||||||
@@ -160,7 +183,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<h1>⚡ Records API & Replication Monitor</h1>
|
<h1>⚡ Records API & Replication Monitor</h1>
|
||||||
<p style="color: var(--text-muted); font-size: 0.85rem; margin-top: 4px;">
|
<p style="color: var(--text-muted); font-size: 0.85rem; margin-top: 4px;">
|
||||||
Interactive validation and real-time visualization of SQLite append-only journals
|
Interactive validation and real-time visualization of SQLite append-only journals & mTLS failover
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex; align-items:center; gap: 10px;">
|
<div style="display:flex; align-items:center; gap: 10px;">
|
||||||
@@ -175,12 +198,12 @@
|
|||||||
<div class="card" style="padding: 10px 16px;">
|
<div class="card" style="padding: 10px 16px;">
|
||||||
<div style="display:flex; gap: 20px; align-items:center; flex-wrap:wrap; font-size:0.85rem;">
|
<div style="display:flex; gap: 20px; align-items:center; flex-wrap:wrap; font-size:0.85rem;">
|
||||||
<div style="flex:1; min-width:240px;">
|
<div style="flex:1; min-width:240px;">
|
||||||
<label>Primary Base URL:</label>
|
<label>Node 1 Base URL:</label>
|
||||||
<input type="text" id="primaryUrl" value="https://127.0.0.1:3000" style="margin-bottom:0;" />
|
<input type="text" id="node1Url" value="https://127.0.0.1:3000" style="margin-bottom:0;" />
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1; min-width:240px;">
|
<div style="flex:1; min-width:240px;">
|
||||||
<label>Replica Base URL:</label>
|
<label>Node 2 Base URL:</label>
|
||||||
<input type="text" id="replicaUrl" value="https://127.0.0.1:3001" style="margin-bottom:0;" />
|
<input type="text" id="node2Url" value="https://127.0.0.1:3001" style="margin-bottom:0;" />
|
||||||
</div>
|
</div>
|
||||||
<div style="flex:1; min-width:180px;">
|
<div style="flex:1; min-width:180px;">
|
||||||
<label>Replication Bearer Token:</label>
|
<label>Replication Bearer Token:</label>
|
||||||
@@ -196,50 +219,97 @@
|
|||||||
|
|
||||||
<!-- Live Replication Pipeline Banner -->
|
<!-- Live Replication Pipeline Banner -->
|
||||||
<div class="topology">
|
<div class="topology">
|
||||||
<div class="node-box" id="nodePrimaryBox">
|
<!-- Node 1 Box -->
|
||||||
<span class="badge badge-primary">Primary Node</span>
|
<div class="node-box" id="node1Box">
|
||||||
<div style="font-size: 1.2rem; font-weight: bold; margin-top: 5px;" id="primaryHead">Seq: --</div>
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
|
||||||
<div style="font-size: 0.72rem; color: var(--text-muted);" id="primaryId">ID: --</div>
|
<span style="font-size:0.75rem; color:var(--text-muted); font-weight:600;">Node 1 (:3000)</span>
|
||||||
|
<span id="node1RoleBadge" class="badge badge-primary">PRIMARY</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 1.3rem; font-weight: bold; margin-top: 5px;" id="node1Head">Seq: --</div>
|
||||||
|
<div style="font-size: 0.72rem; color: var(--text-muted);" id="node1Id">ID: --</div>
|
||||||
|
<div style="font-size: 0.72rem; color: var(--text-muted);" id="node1SyncState">State: --</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Directional Pipeline & Switchover Control -->
|
||||||
<div class="pipe">
|
<div class="pipe">
|
||||||
<span id="pipeState" class="badge badge-ok">Connected</span>
|
<span id="pipeState" class="badge badge-ok">CONNECTED</span>
|
||||||
<div class="arrow">━━━━ ◄─pull── ━━━━</div>
|
<div id="pipeArrow" class="arrow">━━━━ ◄─pull── ━━━━</div>
|
||||||
<div id="pipeDetails">Lag: <span id="lagRecords">0</span> records</div>
|
<div id="pipeDetails" style="font-size:0.78rem;">Lag: <span id="lagRecords">0</span> records</div>
|
||||||
|
<button id="switchoverBtn" class="btn-switch" style="margin-top:4px;" onclick="performSwitchover()">
|
||||||
|
🔀 Graceful Switchover
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="node-box" id="nodeReplicaBox">
|
<!-- Node 2 Box -->
|
||||||
<span class="badge badge-replica">Replica Node</span>
|
<div class="node-box" id="node2Box">
|
||||||
<div style="font-size: 1.2rem; font-weight: bold; margin-top: 5px;" id="replicaHead">Seq: --</div>
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:4px;">
|
||||||
<div style="font-size: 0.72rem; color: var(--text-muted);" id="replicaId">ID: --</div>
|
<span style="font-size:0.75rem; color:var(--text-muted); font-weight:600;">Node 2 (:3001)</span>
|
||||||
<div style="font-size: 0.72rem; color: var(--text-muted);" id="replicaSyncState">State: --</div>
|
<span id="node2RoleBadge" class="badge badge-replica">REPLICA</span>
|
||||||
|
</div>
|
||||||
|
<div style="font-size: 1.3rem; font-weight: bold; margin-top: 5px;" id="node2Head">Seq: --</div>
|
||||||
|
<div style="font-size: 0.72rem; color: var(--text-muted);" id="node2Id">ID: --</div>
|
||||||
|
<div style="font-size: 0.72rem; color: var(--text-muted);" id="node2SyncState">State: --</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Side-by-Side Journal Tables -->
|
<!-- Main Side-by-Side Journal Tables with Dedicated Write Box Per Instance -->
|
||||||
<div class="grid-2">
|
<div class="grid-2">
|
||||||
<!-- Primary Table -->
|
<!-- Node 1 Column -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>📖 Primary Records (`/api/records`)</span>
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
<span id="primaryCount" class="badge badge-primary">0 records</span>
|
<span>📖 Node 1 Records</span>
|
||||||
|
<span id="node1TableRole" class="badge badge-primary">PRIMARY</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span id="node1Count" class="badge badge-primary">0 records</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Write Box for Node 1 -->
|
||||||
|
<div class="write-box">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
|
||||||
|
<span style="font-size:0.8rem; font-weight:600;">✍️ Insert Record (Node 1)</span>
|
||||||
|
<span style="font-size:0.72rem; color:var(--text-muted);">POST /api/records</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="node1RecordData" rows="2" style="font-family:monospace; margin-bottom:8px;">{ "version": 1, "message": "Written to Node 1" }</textarea>
|
||||||
|
<button class="btn-primary" style="width:100%;" onclick="submitRecordToNode('node1')">
|
||||||
|
Send to Node 1 (:3000)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Node 1 Table -->
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table id="primaryTable">
|
<table id="node1Table">
|
||||||
<thead><tr><th>Seq</th><th>UUID</th><th>Date (UTC)</th><th>Payload</th></tr></thead>
|
<thead><tr><th>Seq</th><th>UUID</th><th>Date (UTC)</th><th>Payload</th></tr></thead>
|
||||||
<tbody><tr><td colspan="4" style="text-align:center; color:var(--text-muted)">Loading...</td></tr></tbody>
|
<tbody><tr><td colspan="4" style="text-align:center; color:var(--text-muted)">Loading...</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Replica Table -->
|
<!-- Node 2 Column -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>📖 Replica Records (`/api/records`)</span>
|
<div style="display:flex; align-items:center; gap:8px;">
|
||||||
<span id="replicaCount" class="badge badge-replica">0 records</span>
|
<span>📖 Node 2 Records</span>
|
||||||
|
<span id="node2TableRole" class="badge badge-replica">REPLICA</span>
|
||||||
</div>
|
</div>
|
||||||
|
<span id="node2Count" class="badge badge-replica">0 records</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Write Box for Node 2 -->
|
||||||
|
<div class="write-box">
|
||||||
|
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:6px;">
|
||||||
|
<span style="font-size:0.8rem; font-weight:600;">✍️ Insert Record (Node 2)</span>
|
||||||
|
<span style="font-size:0.72rem; color:var(--text-muted);">POST /api/records</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="node2RecordData" rows="2" style="font-family:monospace; margin-bottom:8px;">{ "version": 1, "message": "Written to Node 2" }</textarea>
|
||||||
|
<button class="btn-primary" style="width:100%;" onclick="submitRecordToNode('node2')">
|
||||||
|
Send to Node 2 (:3001)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Node 2 Table -->
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<table id="replicaTable">
|
<table id="node2Table">
|
||||||
<thead><tr><th>Seq</th><th>UUID</th><th>Date (UTC)</th><th>Payload</th></tr></thead>
|
<thead><tr><th>Seq</th><th>UUID</th><th>Date (UTC)</th><th>Payload</th></tr></thead>
|
||||||
<tbody><tr><td colspan="4" style="text-align:center; color:var(--text-muted)">Loading...</td></tr></tbody>
|
<tbody><tr><td colspan="4" style="text-align:center; color:var(--text-muted)">Loading...</td></tr></tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -249,24 +319,24 @@
|
|||||||
|
|
||||||
<!-- Action & Validation Panel -->
|
<!-- Action & Validation Panel -->
|
||||||
<div class="grid-3">
|
<div class="grid-3">
|
||||||
<!-- Insert Record Form -->
|
|
||||||
<div class="card">
|
|
||||||
<div class="card-header"><span>✍️ Write Record (Primary)</span></div>
|
|
||||||
<label style="font-size:0.8rem;">JSON Payload (`data` field):</label>
|
|
||||||
<textarea id="recordData" rows="4">{
|
|
||||||
"version": 1,
|
|
||||||
"message": "Testing replication!"
|
|
||||||
}</textarea>
|
|
||||||
<button class="btn-primary" style="width:100%;" onclick="submitRecord()">Send to Primary (POST /api/records)</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Automated Validation Tests -->
|
<!-- Automated Validation Tests -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header"><span>🧪 Automated Tests</span></div>
|
<div class="card-header"><span>🧪 Automated Tests</span></div>
|
||||||
<div style="display:flex; flex-direction:column; gap:8px;">
|
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<button class="btn-test" onclick="runSwitchoverTest()">🔀 Test Graceful Role Switchover</button>
|
||||||
<button class="btn-test" onclick="runReplicaWriteGuardTest()">🛡️ Test Replica Read-Only Guard (Expect 403)</button>
|
<button class="btn-test" onclick="runReplicaWriteGuardTest()">🛡️ Test Replica Read-Only Guard (Expect 403)</button>
|
||||||
<button class="btn-test" onclick="runSchemaValidationTest()">⚠️ Test Invalid Schema Payload (Expect 400)</button>
|
<button class="btn-test" onclick="runSchemaValidationTest()">⚠️ Test Invalid Schema Payload (Expect 400)</button>
|
||||||
<button class="btn-test" onclick="runSyncLatencyTest()">⏱️ Test Insert-to-Replica Sync Latency</button>
|
<button class="btn-test" onclick="runSyncLatencyTest()">⏱️ Test Insert-to-Replica Propagation</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Manual Role Management -->
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header"><span>🎛️ Role Controls</span></div>
|
||||||
|
<div style="display:flex; flex-direction:column; gap:8px;">
|
||||||
|
<button onclick="manualPromote('node1')">👑 Promote Node 1 to Primary</button>
|
||||||
|
<button onclick="manualPromote('node2')">👑 Promote Node 2 to Primary</button>
|
||||||
|
<button onclick="manualStepDown()">⬇️ Step Down Active Primary</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -276,7 +346,7 @@
|
|||||||
<span>⚙️ Schema Management</span>
|
<span>⚙️ Schema Management</span>
|
||||||
<button onclick="fetchSchema()" style="padding:2px 8px; font-size:0.75rem;">Inspect</button>
|
<button onclick="fetchSchema()" style="padding:2px 8px; font-size:0.75rem;">Inspect</button>
|
||||||
</div>
|
</div>
|
||||||
<pre id="schemaView" style="font-size:0.75rem; background:#0d1117; padding:8px; border-radius:4px; max-height:100px; overflow:auto;">Click Inspect to view</pre>
|
<pre id="schemaView" style="font-size:0.75rem; background:#0d1117; padding:8px; border-radius:4px; max-height:85px; overflow:auto;">Click Inspect to view</pre>
|
||||||
<button onclick="reloadSchema()" style="margin-top:8px; width:100%;">Trigger Hot-Reload (`/reload-config`)</button>
|
<button onclick="reloadSchema()" style="margin-top:8px; width:100%;">Trigger Hot-Reload (`/reload-config`)</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -315,8 +385,10 @@
|
|||||||
return nativeFetch(input, init);
|
return nativeFetch(input, init);
|
||||||
};
|
};
|
||||||
|
|
||||||
let primaryHeadSeq = 0;
|
let node1HeadSeq = 0;
|
||||||
let replicaHeadSeq = 0;
|
let node2HeadSeq = 0;
|
||||||
|
let currentNode1Role = 'unknown';
|
||||||
|
let currentNode2Role = 'unknown';
|
||||||
|
|
||||||
function log(msg, type = 'info') {
|
function log(msg, type = 'info') {
|
||||||
const box = document.getElementById('consoleLog');
|
const box = document.getElementById('consoleLog');
|
||||||
@@ -328,74 +400,155 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getUrls() {
|
function getUrls() {
|
||||||
|
const n1 = document.getElementById('node1Url')?.value || 'https://127.0.0.1:3000';
|
||||||
|
const n2 = document.getElementById('node2Url')?.value || 'https://127.0.0.1:3001';
|
||||||
return {
|
return {
|
||||||
primary: document.getElementById('primaryUrl').value.replace(/\/+$/, ''),
|
node1: n1.replace(/\/+$/, ''),
|
||||||
replica: document.getElementById('replicaUrl').value.replace(/\/+$/, ''),
|
node2: n2.replace(/\/+$/, ''),
|
||||||
token: document.getElementById('replToken').value
|
token: document.getElementById('replToken')?.value || 'change-me'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAll() {
|
async function fetchAll() {
|
||||||
const { primary, replica } = getUrls();
|
const { node1, node2 } = getUrls();
|
||||||
|
|
||||||
// 1. Fetch Primary Records
|
let node1Status = null;
|
||||||
|
let node2Status = null;
|
||||||
|
let node1Records = [];
|
||||||
|
let node2Records = [];
|
||||||
|
|
||||||
|
// 1. Query Node 1 Status & Records
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${primary}/api/records`);
|
const sRes = await fetch(`${node1}/api/replication/status`);
|
||||||
if (res.ok) {
|
if (sRes.ok) node1Status = await sRes.json();
|
||||||
const records = await res.json();
|
} catch (_) {}
|
||||||
renderTable('primaryTable', records);
|
|
||||||
document.getElementById('primaryCount').innerText = `${records.length} records`;
|
|
||||||
primaryHeadSeq = records.length > 0 ? records[0].seq : 0;
|
|
||||||
document.getElementById('primaryHead').innerText = `Seq: ${primaryHeadSeq}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('primaryHead').innerText = `Offline`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fetch Replica Replication Status
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${replica}/api/replication/status`);
|
const rRes = await fetch(`${node1}/api/records`);
|
||||||
if (res.ok) {
|
if (rRes.ok) node1Records = await rRes.json();
|
||||||
const status = await res.json();
|
} catch (_) {}
|
||||||
replicaHeadSeq = status.local_head_seq ?? 0;
|
|
||||||
document.getElementById('replicaHead').innerText = `Seq: ${replicaHeadSeq}`;
|
|
||||||
document.getElementById('replicaId').innerText = `ID: ${status.instance_id.slice(0, 8)}...`;
|
|
||||||
document.getElementById('primaryId').innerText = `Source: ${(status.sync?.source_instance_id || 'unlinked').slice(0, 8)}...`;
|
|
||||||
|
|
||||||
const state = status.sync?.state || 'unknown';
|
// 2. Query Node 2 Status & Records
|
||||||
const badge = document.getElementById('pipeState');
|
|
||||||
badge.innerText = state.toUpperCase();
|
|
||||||
badge.className = `badge ${state === 'idle' ? 'badge-ok' : state === 'syncing' ? 'badge-warn' : 'badge-err'}`;
|
|
||||||
|
|
||||||
const lag = status.lag_records ?? Math.max(0, primaryHeadSeq - replicaHeadSeq);
|
|
||||||
document.getElementById('lagRecords').innerText = lag;
|
|
||||||
document.getElementById('replicaSyncState').innerText = `State: ${state}`;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
document.getElementById('replicaHead').innerText = `Offline`;
|
|
||||||
document.getElementById('pipeState').innerText = 'DISCONNECTED';
|
|
||||||
document.getElementById('pipeState').className = 'badge badge-err';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Fetch Replica Records
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${replica}/api/records`);
|
const sRes = await fetch(`${node2}/api/replication/status`);
|
||||||
if (res.ok) {
|
if (sRes.ok) node2Status = await sRes.json();
|
||||||
const records = await res.json();
|
} catch (_) {}
|
||||||
renderTable('replicaTable', records);
|
|
||||||
document.getElementById('replicaCount').innerText = `${records.length} records`;
|
try {
|
||||||
|
const rRes = await fetch(`${node2}/api/records`);
|
||||||
|
if (rRes.ok) node2Records = await rRes.json();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
// Update Node 1 UI elements
|
||||||
|
if (node1Status) {
|
||||||
|
currentNode1Role = node1Status.role || 'replica';
|
||||||
|
node1HeadSeq = node1Status.local_head_seq ?? (node1Records.length > 0 ? node1Records[0].seq : 0);
|
||||||
|
document.getElementById('node1Head').innerText = `Seq: #${node1HeadSeq}`;
|
||||||
|
document.getElementById('node1Id').innerText = `ID: ${node1Status.instance_id.slice(0, 8)}…`;
|
||||||
|
document.getElementById('node1SyncState').innerText = `State: ${node1Status.sync?.state || 'idle'}`;
|
||||||
|
updateRoleBadge('node1RoleBadge', currentNode1Role);
|
||||||
|
updateRoleBadge('node1TableRole', currentNode1Role);
|
||||||
|
} else {
|
||||||
|
currentNode1Role = 'offline';
|
||||||
|
document.getElementById('node1Head').innerText = `Offline`;
|
||||||
|
document.getElementById('node1Id').innerText = `ID: --`;
|
||||||
|
document.getElementById('node1SyncState').innerText = `State: --`;
|
||||||
|
updateRoleBadge('node1RoleBadge', 'offline');
|
||||||
|
updateRoleBadge('node1TableRole', 'offline');
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
renderTable('node1Table', node1Records, Math.max(node1HeadSeq, node2HeadSeq));
|
||||||
|
document.getElementById('node1Count').innerText = `${node1Records.length} records`;
|
||||||
|
|
||||||
|
// Update Node 2 UI elements
|
||||||
|
if (node2Status) {
|
||||||
|
currentNode2Role = node2Status.role || 'replica';
|
||||||
|
node2HeadSeq = node2Status.local_head_seq ?? (node2Records.length > 0 ? node2Records[0].seq : 0);
|
||||||
|
document.getElementById('node2Head').innerText = `Seq: #${node2HeadSeq}`;
|
||||||
|
document.getElementById('node2Id').innerText = `ID: ${node2Status.instance_id.slice(0, 8)}…`;
|
||||||
|
document.getElementById('node2SyncState').innerText = `State: ${node2Status.sync?.state || 'idle'}`;
|
||||||
|
updateRoleBadge('node2RoleBadge', currentNode2Role);
|
||||||
|
updateRoleBadge('node2TableRole', currentNode2Role);
|
||||||
|
} else {
|
||||||
|
currentNode2Role = 'offline';
|
||||||
|
document.getElementById('node2Head').innerText = `Offline`;
|
||||||
|
document.getElementById('node2Id').innerText = `ID: --`;
|
||||||
|
document.getElementById('node2SyncState').innerText = `State: --`;
|
||||||
|
updateRoleBadge('node2RoleBadge', 'offline');
|
||||||
|
updateRoleBadge('node2TableRole', 'offline');
|
||||||
|
}
|
||||||
|
renderTable('node2Table', node2Records, Math.max(node1HeadSeq, node2HeadSeq));
|
||||||
|
document.getElementById('node2Count').innerText = `${node2Records.length} records`;
|
||||||
|
|
||||||
|
// 3. Update the Live Replication Pipeline Banner dynamically
|
||||||
|
updatePipelineBanner(node1Status, node2Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTable(tableId, records) {
|
function updateRoleBadge(badgeId, role) {
|
||||||
|
const el = document.getElementById(badgeId);
|
||||||
|
if (!el) return;
|
||||||
|
if (role === 'primary') {
|
||||||
|
el.className = 'badge badge-primary';
|
||||||
|
el.innerText = 'PRIMARY';
|
||||||
|
} else if (role === 'replica') {
|
||||||
|
el.className = 'badge badge-replica';
|
||||||
|
el.innerText = 'REPLICA';
|
||||||
|
} else {
|
||||||
|
el.className = 'badge badge-err';
|
||||||
|
el.innerText = 'OFFLINE';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePipelineBanner(status1, status2) {
|
||||||
|
const arrowEl = document.getElementById('pipeArrow');
|
||||||
|
const detailsEl = document.getElementById('pipeDetails');
|
||||||
|
const stateBadge = document.getElementById('pipeState');
|
||||||
|
const switchBtn = document.getElementById('switchoverBtn');
|
||||||
|
|
||||||
|
if (currentNode1Role === 'primary' && currentNode2Role === 'replica') {
|
||||||
|
// Node 2 pulls from Node 1
|
||||||
|
arrowEl.innerText = '━━━━ ◄─pull── ━━━━';
|
||||||
|
const lag = status2?.lag_records ?? Math.max(0, node1HeadSeq - node2HeadSeq);
|
||||||
|
detailsEl.innerHTML = `Node 2 pulling from Node 1 (Lag: <strong>${lag}</strong> records)`;
|
||||||
|
const syncState = status2?.sync?.state || 'idle';
|
||||||
|
stateBadge.innerText = syncState.toUpperCase();
|
||||||
|
stateBadge.className = `badge ${syncState === 'idle' ? 'badge-ok' : syncState === 'syncing' ? 'badge-warn' : 'badge-err'}`;
|
||||||
|
switchBtn.innerText = '🔀 Switchover: Node 1 ➔ Node 2';
|
||||||
|
switchBtn.disabled = false;
|
||||||
|
} else if (currentNode2Role === 'primary' && currentNode1Role === 'replica') {
|
||||||
|
// Node 1 pulls from Node 2
|
||||||
|
arrowEl.innerText = '━━━━ ──pull─► ━━━━';
|
||||||
|
const lag = status1?.lag_records ?? Math.max(0, node2HeadSeq - node1HeadSeq);
|
||||||
|
detailsEl.innerHTML = `Node 1 pulling from Node 2 (Lag: <strong>${lag}</strong> records)`;
|
||||||
|
const syncState = status1?.sync?.state || 'idle';
|
||||||
|
stateBadge.innerText = syncState.toUpperCase();
|
||||||
|
stateBadge.className = `badge ${syncState === 'idle' ? 'badge-ok' : syncState === 'syncing' ? 'badge-warn' : 'badge-err'}`;
|
||||||
|
switchBtn.innerText = '🔀 Switchover: Node 2 ➔ Node 1';
|
||||||
|
switchBtn.disabled = false;
|
||||||
|
} else if (currentNode1Role === 'primary' && currentNode2Role === 'primary') {
|
||||||
|
arrowEl.innerText = '⚠️ DUAL PRIMARY ⚠️';
|
||||||
|
detailsEl.innerText = 'Warning: Split-brain condition detected!';
|
||||||
|
stateBadge.innerText = 'SPLIT BRAIN';
|
||||||
|
stateBadge.className = 'badge badge-err';
|
||||||
|
switchBtn.innerText = '🔀 Resolve Split-Brain';
|
||||||
|
switchBtn.disabled = false;
|
||||||
|
} else {
|
||||||
|
arrowEl.innerText = '━━━━ ✖✖✖ ━━━━';
|
||||||
|
detailsEl.innerText = 'No active primary/replica replication stream';
|
||||||
|
stateBadge.innerText = 'DISCONNECTED';
|
||||||
|
stateBadge.className = 'badge badge-err';
|
||||||
|
switchBtn.innerText = '🔀 Graceful Switchover';
|
||||||
|
switchBtn.disabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(tableId, records, globalHead) {
|
||||||
const tbody = document.querySelector(`#${tableId} tbody`);
|
const tbody = document.querySelector(`#${tableId} tbody`);
|
||||||
if (!records || records.length === 0) {
|
if (!records || records.length === 0) {
|
||||||
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color:var(--text-muted)">Database is empty</td></tr>`;
|
tbody.innerHTML = `<tr><td colspan="4" style="text-align:center; color:var(--text-muted)">Database is empty</td></tr>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
tbody.innerHTML = records.map(r => `
|
tbody.innerHTML = records.map(r => `
|
||||||
<tr class="${r.seq === primaryHeadSeq ? 'synced' : ''}">
|
<tr class="${r.seq === globalHead ? 'synced' : ''}">
|
||||||
<td><strong>#${r.seq}</strong></td>
|
<td><strong>#${r.seq}</strong></td>
|
||||||
<td style="font-family:monospace; font-size:0.75rem;" title="${r.uuid}">${r.uuid.slice(0, 8)}…</td>
|
<td style="font-family:monospace; font-size:0.75rem;" title="${r.uuid}">${r.uuid.slice(0, 8)}…</td>
|
||||||
<td style="font-size:0.75rem;">${r.date.replace('T', ' ').replace('Z', '')}</td>
|
<td style="font-size:0.75rem;">${r.date.replace('T', ' ').replace('Z', '')}</td>
|
||||||
@@ -404,18 +557,23 @@
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitRecord() {
|
// Submit a record directly to a specific node
|
||||||
const { primary } = getUrls();
|
async function submitRecordToNode(whichNode) {
|
||||||
|
const { node1, node2 } = getUrls();
|
||||||
|
const targetUrl = whichNode === 'node1' ? node1 : node2;
|
||||||
|
const nodeName = whichNode === 'node1' ? 'Node 1 (:3000)' : 'Node 2 (:3001)';
|
||||||
|
const textarea = document.getElementById(`${whichNode}RecordData`);
|
||||||
|
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(document.getElementById('recordData').value);
|
payload = JSON.parse(textarea.value);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`Invalid JSON in editor: ${err.message}`, 'fail');
|
log(`Invalid JSON in ${nodeName} write box: ${err.message}`, 'fail');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${primary}/api/records`, {
|
const res = await fetch(`${targetUrl}/api/records`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ data: payload })
|
body: JSON.stringify({ data: payload })
|
||||||
@@ -423,43 +581,140 @@
|
|||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
if (res.status === 201) {
|
if (res.status === 201) {
|
||||||
const data = JSON.parse(text);
|
const data = JSON.parse(text);
|
||||||
log(`Write successful on Primary! Created seq #${data.seq} (UUID: ${data.uuid.slice(0, 8)})`, 'pass');
|
log(`Write SUCCESS on ${nodeName}! Created seq #${data.seq} (UUID: ${data.uuid.slice(0, 8)})`, 'pass');
|
||||||
fetchAll();
|
fetchAll();
|
||||||
|
} else if (res.status === 403) {
|
||||||
|
log(`${nodeName} BLOCKED write [HTTP 403 Forbidden] (Read-Only Replica): ${text}`, 'fail');
|
||||||
} else {
|
} else {
|
||||||
log(`Primary rejected write [HTTP ${res.status}]: ${text}`, 'fail');
|
log(`${nodeName} returned [HTTP ${res.status}]: ${text}`, 'fail');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`Network failure writing to Primary: ${e.message}`, 'fail');
|
log(`Network failure writing to ${nodeName}: ${e.message}`, 'fail');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TEST 1: Replica read-only safety guard
|
// Perform graceful switchover from the active Primary
|
||||||
async function runReplicaWriteGuardTest() {
|
async function performSwitchover() {
|
||||||
const { replica } = getUrls();
|
const { node1, node2 } = getUrls();
|
||||||
log(`Starting Replica Write Guard test against ${replica}/api/records...`, 'info');
|
let primaryUrl = null;
|
||||||
|
let replicaUrl = null;
|
||||||
|
let primaryName = '';
|
||||||
|
let replicaName = '';
|
||||||
|
|
||||||
|
if (currentNode1Role === 'primary') {
|
||||||
|
primaryUrl = node1;
|
||||||
|
replicaUrl = node2;
|
||||||
|
primaryName = 'Node 1';
|
||||||
|
replicaName = 'Node 2';
|
||||||
|
} else if (currentNode2Role === 'primary') {
|
||||||
|
primaryUrl = node2;
|
||||||
|
replicaUrl = node1;
|
||||||
|
primaryName = 'Node 2';
|
||||||
|
replicaName = 'Node 1';
|
||||||
|
} else {
|
||||||
|
log('Switchover aborted: No active primary detected', 'fail');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`Initiating graceful switchover: ${primaryName} ➔ ${replicaName}...`, 'info');
|
||||||
|
const btn = document.getElementById('switchoverBtn');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${replica}/api/records`, {
|
const res = await fetch(`${primaryUrl}/api/admin/switchover`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ replica_url: replicaUrl, timeout_secs: 15 })
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
if (res.ok) {
|
||||||
|
log(`PASS: Graceful switchover completed! ${replicaName} is now Primary; ${primaryName} is Replica.`, 'pass');
|
||||||
|
await fetchAll();
|
||||||
|
} else {
|
||||||
|
log(`FAIL: Switchover rejected [HTTP ${res.status}]: ${text}`, 'fail');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
log(`Switchover execution error: ${e.message}`, 'fail');
|
||||||
|
} finally {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual promotion helper
|
||||||
|
async function manualPromote(whichNode) {
|
||||||
|
const { node1, node2 } = getUrls();
|
||||||
|
const targetUrl = whichNode === 'node1' ? node1 : node2;
|
||||||
|
const nodeName = whichNode === 'node1' ? 'Node 1' : 'Node 2';
|
||||||
|
log(`Promoting ${nodeName} to Primary...`, 'info');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${targetUrl}/api/admin/promote`, { method: 'POST' });
|
||||||
|
const text = await res.text();
|
||||||
|
log(`Promote result on ${nodeName} [HTTP ${res.status}]: ${text}`, res.ok ? 'pass' : 'fail');
|
||||||
|
fetchAll();
|
||||||
|
} catch (e) {
|
||||||
|
log(`Promote failed: ${e.message}`, 'fail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual step-down helper
|
||||||
|
async function manualStepDown() {
|
||||||
|
const { node1, node2 } = getUrls();
|
||||||
|
const primaryUrl = (currentNode1Role === 'primary') ? node1 : (currentNode2Role === 'primary' ? node2 : null);
|
||||||
|
if (!primaryUrl) {
|
||||||
|
log('No active primary found to step down', 'fail');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log(`Stepping down active primary (${primaryUrl})...`, 'info');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${primaryUrl}/api/admin/step-down`, { method: 'POST' });
|
||||||
|
const text = await res.text();
|
||||||
|
log(`Step-down result [HTTP ${res.status}]: ${text}`, res.ok ? 'pass' : 'fail');
|
||||||
|
fetchAll();
|
||||||
|
} catch (e) {
|
||||||
|
log(`Step-down failed: ${e.message}`, 'fail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 1: Role Switchover automated test
|
||||||
|
async function runSwitchoverTest() {
|
||||||
|
log('Starting Automated Role Switchover verification test...', 'info');
|
||||||
|
await performSwitchover();
|
||||||
|
}
|
||||||
|
|
||||||
|
// TEST 2: Replica read-only safety guard
|
||||||
|
async function runReplicaWriteGuardTest() {
|
||||||
|
const { node1, node2 } = getUrls();
|
||||||
|
const target = (currentNode2Role === 'replica') ? node2 : (currentNode1Role === 'replica' ? node1 : node2);
|
||||||
|
const targetName = (target === node2) ? 'Node 2 (:3001)' : 'Node 1 (:3000)';
|
||||||
|
|
||||||
|
log(`Testing Write Guard against ${targetName} (${target}/api/records)...`, 'info');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${target}/api/records`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ data: { message: "Direct write forbidden test" } })
|
body: JSON.stringify({ data: { message: "Direct write forbidden test" } })
|
||||||
});
|
});
|
||||||
if (res.status === 403) {
|
if (res.status === 403) {
|
||||||
const msg = await res.text();
|
const msg = await res.text();
|
||||||
log(`PASS: Replica blocked write with HTTP 403 Forbidden! Response: "${msg}"`, 'pass');
|
log(`PASS: ${targetName} blocked write with HTTP 403 Forbidden! Response: "${msg}"`, 'pass');
|
||||||
} else {
|
} else {
|
||||||
log(`FAIL: Replica answered with HTTP ${res.status} instead of expected 403!`, 'fail');
|
log(`FAIL: ${targetName} answered with HTTP ${res.status} instead of expected 403!`, 'fail');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
log(`Test failed due to network error: ${e.message}`, 'fail');
|
log(`Test failed due to network error: ${e.message}`, 'fail');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TEST 2: Schema validation test
|
// TEST 3: Schema validation test
|
||||||
async function runSchemaValidationTest() {
|
async function runSchemaValidationTest() {
|
||||||
const { primary } = getUrls();
|
const { node1, node2 } = getUrls();
|
||||||
log(`Starting Schema Rejection test on Primary (sending wrong types & extra fields)...`, 'info');
|
const primaryUrl = (currentNode1Role === 'primary') ? node1 : (currentNode2Role === 'primary' ? node2 : node1);
|
||||||
|
const primaryName = (primaryUrl === node1) ? 'Node 1' : 'Node 2';
|
||||||
|
|
||||||
|
log(`Starting Schema Rejection test on ${primaryName} (sending invalid payload)...`, 'info');
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${primary}/api/records`, {
|
const res = await fetch(`${primaryUrl}/api/records`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ data: { unknown_xyz_field: 999999, version: "not-an-integer" } })
|
body: JSON.stringify({ data: { unknown_xyz_field: 999999, version: "not-an-integer" } })
|
||||||
@@ -475,15 +730,23 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TEST 3: Sync Latency propagation benchmark
|
// TEST 4: Sync Latency propagation benchmark
|
||||||
async function runSyncLatencyTest() {
|
async function runSyncLatencyTest() {
|
||||||
const { primary, replica } = getUrls();
|
const { node1, node2 } = getUrls();
|
||||||
|
const primaryUrl = (currentNode1Role === 'primary') ? node1 : (currentNode2Role === 'primary' ? node2 : null);
|
||||||
|
const replicaUrl = (currentNode1Role === 'primary') ? node2 : (currentNode2Role === 'primary' ? node1 : null);
|
||||||
|
|
||||||
|
if (!primaryUrl || !replicaUrl) {
|
||||||
|
log('Latency test aborted: Cluster does not have a distinct Primary and Replica', 'fail');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const testMsg = `probe-${Date.now()}`;
|
const testMsg = `probe-${Date.now()}`;
|
||||||
log(`Triggering probe write on Primary with marker: ${testMsg}...`, 'info');
|
log(`Triggering probe write on Primary with marker: ${testMsg}...`, 'info');
|
||||||
|
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${primary}/api/records`, {
|
const res = await fetch(`${primaryUrl}/api/records`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ data: { message: testMsg, msg: testMsg, version: 1 } })
|
body: JSON.stringify({ data: { message: testMsg, msg: testMsg, version: 1 } })
|
||||||
@@ -497,7 +760,7 @@
|
|||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
attempts++;
|
attempts++;
|
||||||
try {
|
try {
|
||||||
const statusRes = await fetch(`${replica}/api/replication/status`);
|
const statusRes = await fetch(`${replicaUrl}/api/replication/status`);
|
||||||
const status = await statusRes.json();
|
const status = await statusRes.json();
|
||||||
if (status.local_head_seq >= targetSeq) {
|
if (status.local_head_seq >= targetSeq) {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
@@ -519,9 +782,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSchema() {
|
async function fetchSchema() {
|
||||||
const { primary } = getUrls();
|
const { node1, node2 } = getUrls();
|
||||||
|
const primaryUrl = (currentNode1Role === 'primary') ? node1 : (currentNode2Role === 'primary' ? node2 : node1);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${primary}/api/admin/config`);
|
const res = await fetch(`${primaryUrl}/api/admin/config`);
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
document.getElementById('schemaView').innerText = JSON.stringify(data, null, 2);
|
document.getElementById('schemaView').innerText = JSON.stringify(data, null, 2);
|
||||||
log(`Fetched active configuration schema.`, 'info');
|
log(`Fetched active configuration schema.`, 'info');
|
||||||
@@ -531,9 +795,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reloadSchema() {
|
async function reloadSchema() {
|
||||||
const { primary } = getUrls();
|
const { node1, node2 } = getUrls();
|
||||||
|
const primaryUrl = (currentNode1Role === 'primary') ? node1 : (currentNode2Role === 'primary' ? node2 : node1);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${primary}/api/admin/reload-config`, { method: 'POST' });
|
const res = await fetch(`${primaryUrl}/api/admin/reload-config`, { method: 'POST' });
|
||||||
const text = await res.text();
|
const text = await res.text();
|
||||||
log(`Reload schema result [HTTP ${res.status}]: ${text}`, res.ok ? 'pass' : 'fail');
|
log(`Reload schema result [HTTP ${res.status}]: ${text}`, res.ok ? 'pass' : 'fail');
|
||||||
fetchSchema();
|
fetchSchema();
|
||||||
|
|||||||
+336
-117
@@ -1,24 +1,69 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# repl-test.sh: Integration harness for Records API & Replication (mTLS)
|
# repl-test.sh: Integration harness for Records API & Replication (mTLS)
|
||||||
# Starts Primary (:3000), Replica (:3001), and Dashboard (:8080).
|
#
|
||||||
|
# Supports N instances in multiple topologies:
|
||||||
|
# star - one primary, all replicas pull directly from it (default)
|
||||||
|
# chain - a linear chain: primary -> r1 -> r2 -> ... -> rN
|
||||||
|
#
|
||||||
|
# Environment variable overrides:
|
||||||
|
# NODES=4 number of total nodes (1 primary + N-1 replicas; default: 2)
|
||||||
|
# BASE_PORT=3000 base port; nodes occupy BASE_PORT .. BASE_PORT+NODES-1
|
||||||
|
# TOPO=star|chain replication topology (default: star)
|
||||||
|
# TOKEN=... shared replication bearer token
|
||||||
|
# START_DASHBOARD=1 launch the web dashboard (default: 1)
|
||||||
|
# SKIP_SWITCHOVER=0 skip the switchover smoke test (default: 0)
|
||||||
|
# NO_WAIT=0 exit immediately after smoke tests instead of waiting
|
||||||
|
#
|
||||||
|
# Examples:
|
||||||
|
# bash repl-test.sh # 2 nodes, star, default ports
|
||||||
|
# NODES=3 bash repl-test.sh # 1 primary + 2 replicas, star
|
||||||
|
# NODES=4 TOPO=chain bash repl-test.sh # 4-node replication chain
|
||||||
|
# NODES=5 SKIP_SWITCHOVER=1 bash repl-test.sh
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# --- Configuration & Overrides ---
|
# ---------------------------------------------------------------------------
|
||||||
|
# Configuration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
BIN="${BIN:-$ROOT_DIR/target/debug/rust_test}"
|
BIN="${BIN:-$ROOT_DIR/target/debug/rust_test}"
|
||||||
WORK="${WORK:-$ROOT_DIR/repl-test}"
|
WORK="${WORK:-$ROOT_DIR/repl-test}"
|
||||||
PRIMARY_PORT="${PRIMARY_PORT:-3000}"
|
BASE_PORT="${BASE_PORT:-3000}"
|
||||||
REPLICA_PORT="${REPLICA_PORT:-3001}"
|
NODES="${NODES:-2}" # total node count (must be >= 2)
|
||||||
|
TOPO="${TOPO:-star}" # star | chain
|
||||||
|
TOKEN="${TOKEN:-change-me}"
|
||||||
DASHBOARD_PORT="${DASHBOARD_PORT:-8080}"
|
DASHBOARD_PORT="${DASHBOARD_PORT:-8080}"
|
||||||
TOKEN="${TOKEN:-change-me}" # Replication bearer token
|
|
||||||
CORS_ORIGIN="${CORS_ORIGIN:-http://127.0.0.1:$DASHBOARD_PORT}"
|
CORS_ORIGIN="${CORS_ORIGIN:-http://127.0.0.1:$DASHBOARD_PORT}"
|
||||||
START_DASHBOARD="${START_DASHBOARD:-1}"
|
START_DASHBOARD="${START_DASHBOARD:-1}"
|
||||||
|
SKIP_SWITCHOVER="${SKIP_SWITCHOVER:-0}"
|
||||||
|
NO_WAIT="${NO_WAIT:-0}"
|
||||||
|
|
||||||
|
if [[ "$NODES" -lt 2 ]]; then
|
||||||
|
echo "Error: NODES must be >= 2." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Node naming and port helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# node_name 0 -> "primary"
|
||||||
|
# node_name 1 -> "replica-1"
|
||||||
|
# node_name 2 -> "replica-2" ...
|
||||||
|
node_name() {
|
||||||
|
local idx=$1
|
||||||
|
if [[ $idx -eq 0 ]]; then echo "primary"; else echo "replica-$idx"; fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# node_port 0 -> BASE_PORT
|
||||||
|
# node_port 1 -> BASE_PORT+1 ...
|
||||||
|
node_port() { echo $(( BASE_PORT + $1 )); }
|
||||||
|
|
||||||
PIDS=()
|
PIDS=()
|
||||||
|
|
||||||
# --- Helper Utilities ---
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper utilities
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
check_port() {
|
check_port() {
|
||||||
local port=$1
|
local port=$1
|
||||||
if command -v lsof >/dev/null 2>&1; then
|
if command -v lsof >/dev/null 2>&1; then
|
||||||
@@ -43,38 +88,119 @@ cleanup() {
|
|||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
# --- Pre-flight Checks ---
|
json_field() {
|
||||||
for port in "$PRIMARY_PORT" "$REPLICA_PORT"; do
|
# Usage: json_field <json_string> <field_name>
|
||||||
|
local json="$1" field="$2"
|
||||||
|
if command -v jq >/dev/null 2>&1; then
|
||||||
|
echo "$json" | jq -r ".${field} // empty"
|
||||||
|
else
|
||||||
|
echo "$json" | grep -o "\"${field}\":\"[^\"]*" | head -1 | cut -d'"' -f4
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
color_echo() {
|
||||||
|
local color=$1; shift
|
||||||
|
local code
|
||||||
|
case "$color" in
|
||||||
|
red) code='\033[1;31m' ;;
|
||||||
|
green) code='\033[1;32m' ;;
|
||||||
|
yellow) code='\033[1;33m' ;;
|
||||||
|
blue) code='\033[1;34m' ;;
|
||||||
|
*) code='\033[0m' ;;
|
||||||
|
esac
|
||||||
|
echo -e "${code}$*\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_up() {
|
||||||
|
local name=$1 port=$2
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
if curl -k -sf "https://127.0.0.1:$port/api/now" >/dev/null 2>&1; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
color_echo red "Error: $name failed to start on :$port" >&2
|
||||||
|
if [[ -f "$WORK/$name/log.txt" ]]; then
|
||||||
|
echo "--- Last 30 lines of $WORK/$name/log.txt ---" >&2
|
||||||
|
tail -n 30 "$WORK/$name/log.txt" >&2
|
||||||
|
fi
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
mint_join_token() {
|
||||||
|
local primary_port=$1
|
||||||
|
local resp
|
||||||
|
resp=$(curl -k -sf -X POST "https://127.0.0.1:$primary_port/api/cluster/tokens" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"ttl_secs": 300}')
|
||||||
|
json_field "$resp" "token"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_replica_caught_up() {
|
||||||
|
local name=$1 port=$2 target_seq=$3
|
||||||
|
for _ in $(seq 1 80); do
|
||||||
|
local out
|
||||||
|
out=$(curl -k -s "https://127.0.0.1:$port/api/replication/status" 2>/dev/null || true)
|
||||||
|
local applied
|
||||||
|
applied=$(json_field "$out" "sync.last_applied_seq" 2>/dev/null || echo "0")
|
||||||
|
# Fallback grep for nested .sync.last_applied_seq
|
||||||
|
if [[ -z "$applied" || "$applied" == "null" ]]; then
|
||||||
|
applied=$(echo "$out" | grep -o '"last_applied_seq":[0-9]*' | head -1 | cut -d: -f2)
|
||||||
|
fi
|
||||||
|
if [[ "${applied:-0}" -ge "$target_seq" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 0.25
|
||||||
|
done
|
||||||
|
color_echo yellow " ⚠ WARNING: $name (:$port) did not reach seq $target_seq in time." >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pre-flight
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
for idx in $(seq 0 $(( NODES - 1 ))); do
|
||||||
|
port=$(node_port "$idx")
|
||||||
if check_port "$port"; then
|
if check_port "$port"; then
|
||||||
echo "Error: Port $port is already in use by another process." >&2
|
color_echo red "Error: Port $port is already in use." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
if [[ "$START_DASHBOARD" -eq 1 ]]; then
|
||||||
if [[ -f "$ROOT_DIR/Cargo.toml" ]]; then
|
if check_port "$DASHBOARD_PORT"; then
|
||||||
echo "Ensuring debug binary is up to date with cargo..."
|
color_echo yellow "Dashboard port :$DASHBOARD_PORT is in use; dashboard will be skipped."
|
||||||
( cd "$ROOT_DIR" && cargo build )
|
START_DASHBOARD=0
|
||||||
elif [[ ! -x "$BIN" ]]; then
|
fi
|
||||||
echo "Error: Binary not found or not executable: $BIN" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Workspace & Schema Setup ---
|
# Build
|
||||||
rm -rf "$WORK"
|
if [[ -f "$ROOT_DIR/Cargo.toml" ]]; then
|
||||||
mkdir -p "$WORK/primary" "$WORK/replica"
|
echo "Ensuring debug binary is up to date..."
|
||||||
|
( cd "$ROOT_DIR" && cargo build )
|
||||||
|
elif [[ ! -x "$BIN" ]]; then
|
||||||
|
color_echo red "Error: Binary not found: $BIN" >&2; exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Schema allows "msg" and "message", requiring integer "version"
|
# ---------------------------------------------------------------------------
|
||||||
for d in primary replica; do
|
# Workspace setup
|
||||||
cat > "$WORK/$d/config.json" <<'EOF'
|
# ---------------------------------------------------------------------------
|
||||||
|
rm -rf "$WORK"
|
||||||
|
for idx in $(seq 0 $(( NODES - 1 ))); do
|
||||||
|
name=$(node_name "$idx")
|
||||||
|
mkdir -p "$WORK/$name"
|
||||||
|
cat > "$WORK/$name/config.json" <<'CFGEOF'
|
||||||
{
|
{
|
||||||
"mode": "loose",
|
"mode": "loose",
|
||||||
"fields": {
|
"fields": {
|
||||||
"version": { "type": "integer" }
|
"version": { "type": "integer" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
EOF
|
CFGEOF
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# start_node <name> <port> [extra env vars...]
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
start_node() {
|
start_node() {
|
||||||
local name=$1 port=$2
|
local name=$1 port=$2
|
||||||
shift 2
|
shift 2
|
||||||
@@ -88,89 +214,104 @@ start_node() {
|
|||||||
PIDS+=("$!")
|
PIDS+=("$!")
|
||||||
}
|
}
|
||||||
|
|
||||||
wait_up() {
|
|
||||||
local name=$1 port=$2
|
|
||||||
for _ in $(seq 1 60); do
|
|
||||||
# Note: -k used since cluster cert is generated on boot
|
|
||||||
if curl -k -sf "https://127.0.0.1:$port/api/now" >/dev/null 2>&1; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
sleep 0.1
|
|
||||||
done
|
|
||||||
echo -e "\n\033[1;31mError: $name failed to start on :$port\033[0m" >&2
|
|
||||||
if [[ -f "$WORK/$name/log.txt" ]]; then
|
|
||||||
echo "--- Last 20 lines of $WORK/$name/log.txt ---" >&2
|
|
||||||
tail -n 20 "$WORK/$name/log.txt" >&2
|
|
||||||
fi
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 1. Launch Primary (Cluster CA & Server Cert generated automatically)
|
# 1. Start Primary
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
echo -e "\033[1;32m==> Starting Primary on :$PRIMARY_PORT (HTTPS/mTLS)...\033[0m"
|
PRIMARY_PORT=$(node_port 0)
|
||||||
|
color_echo green "==> [1/N] Starting Primary on :$PRIMARY_PORT (HTTPS/mTLS)..."
|
||||||
start_node primary "$PRIMARY_PORT" APP_ROLE=primary
|
start_node primary "$PRIMARY_PORT" APP_ROLE=primary
|
||||||
wait_up "primary" "$PRIMARY_PORT"
|
wait_up "primary" "$PRIMARY_PORT"
|
||||||
|
color_echo green " ✔ Primary is up."
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 2. Test Dynamic Discovery: Retrieve Cluster Public CA
|
# 2. Retrieve CA cert (sanity check)
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
echo -e "\n\033[1;34m==> Testing dynamic discovery: Fetching Cluster Public CA...\033[0m"
|
color_echo blue "\n==> Verifying Cluster CA is accessible..."
|
||||||
ca_resp=$(curl -k -sf "https://127.0.0.1:$PRIMARY_PORT/api/cluster/ca")
|
ca_resp=$(curl -k -sf "https://127.0.0.1:$PRIMARY_PORT/api/cluster/ca")
|
||||||
if echo "$ca_resp" | grep -q 'BEGIN CERTIFICATE'; then
|
if echo "$ca_resp" | grep -q 'BEGIN CERTIFICATE'; then
|
||||||
echo -e " \033[1;32m✔ Cluster CA certificate downloaded successfully.\033[0m"
|
color_echo green " ✔ Cluster CA certificate downloaded."
|
||||||
else
|
else
|
||||||
echo -e " \033[1;31m✖ Failed to retrieve Cluster CA.\033[0m"
|
color_echo red " ✖ Failed to retrieve Cluster CA – aborting." >&2; exit 1
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 3. Mint Short-Lived Join Token via Primary
|
# 3. Start replicas according to topology
|
||||||
|
#
|
||||||
|
# star topology: every replica pulls directly from the primary
|
||||||
|
# chain topology: replica-N pulls from replica-(N-1), which pulls from primary
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
echo -e "\n\033[1;34m==> Minting short-lived Join Token from Primary...\033[0m"
|
|
||||||
token_resp=$(curl -k -sf -X POST "https://127.0.0.1:$PRIMARY_PORT/api/cluster/tokens" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"ttl_secs": 120}')
|
|
||||||
|
|
||||||
if command -v jq >/dev/null 2>&1; then
|
# Arrays for later use
|
||||||
JOIN_TOKEN=$(echo "$token_resp" | jq -r '.token')
|
PORTS=() # PORTS[i] = port for node i
|
||||||
else
|
NAMES=() # NAMES[i] = name for node i
|
||||||
JOIN_TOKEN=$(echo "$token_resp" | grep -o '"token":"[^"]*' | cut -d'"' -f4)
|
SOURCES=() # SOURCES[i] = upstream URL for node i (empty for primary)
|
||||||
fi
|
|
||||||
|
|
||||||
echo -e " \033[1;32m✔ Obtained one-time join token: $JOIN_TOKEN\033[0m"
|
PORTS[0]=$PRIMARY_PORT
|
||||||
|
NAMES[0]="primary"
|
||||||
|
SOURCES[0]=""
|
||||||
|
|
||||||
# ==============================================================================
|
for idx in $(seq 1 $(( NODES - 1 ))); do
|
||||||
# 4. Launch Replica (Bootstraps mTLS identity using the Join Token)
|
name=$(node_name "$idx")
|
||||||
# ==============================================================================
|
port=$(node_port "$idx")
|
||||||
echo -e "\n\033[1;32m==> Starting Replica on :$REPLICA_PORT (Dynamic Join Enrollment)...\033[0m"
|
PORTS[$idx]=$port
|
||||||
start_node replica "$REPLICA_PORT" \
|
NAMES[$idx]="$name"
|
||||||
|
|
||||||
|
case "$TOPO" in
|
||||||
|
chain)
|
||||||
|
# Replica pulls from the previous node in the chain
|
||||||
|
upstream_port=$(node_port $(( idx - 1 )))
|
||||||
|
upstream_url="https://127.0.0.1:$upstream_port"
|
||||||
|
upstream_name=$(node_name $(( idx - 1 )))
|
||||||
|
;;
|
||||||
|
star|*)
|
||||||
|
# Replica pulls directly from the primary
|
||||||
|
upstream_port=$PRIMARY_PORT
|
||||||
|
upstream_url="https://127.0.0.1:$PRIMARY_PORT"
|
||||||
|
upstream_name="primary"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
SOURCES[$idx]="$upstream_url"
|
||||||
|
|
||||||
|
# The join token must come from the Cluster CA holder (always the primary)
|
||||||
|
color_echo blue "\n==> Minting join token for $name from primary..."
|
||||||
|
JOIN_TOKEN=$(mint_join_token "$PRIMARY_PORT")
|
||||||
|
color_echo green " ✔ Token: $JOIN_TOKEN"
|
||||||
|
|
||||||
|
color_echo green "\n==> [$idx/$(( NODES - 1 ))] Starting $name on :$port"
|
||||||
|
echo " Topology: $TOPO | upstream: $upstream_name ($upstream_url)"
|
||||||
|
|
||||||
|
# In chain topology the replication upstream differs from the CA issuer (primary).
|
||||||
|
# APP_JOIN_URL overrides enrollment so the new replica can get a cert from the
|
||||||
|
# actual primary even though it pulls data from an intermediate relay node.
|
||||||
|
start_node "$name" "$port" \
|
||||||
APP_ROLE=replica \
|
APP_ROLE=replica \
|
||||||
APP_PRIMARY_URL="https://127.0.0.1:$PRIMARY_PORT" \
|
APP_PRIMARY_URL="$upstream_url" \
|
||||||
|
APP_JOIN_URL="https://127.0.0.1:$PRIMARY_PORT" \
|
||||||
APP_JOIN_TOKEN="$JOIN_TOKEN"
|
APP_JOIN_TOKEN="$JOIN_TOKEN"
|
||||||
wait_up "replica" "$REPLICA_PORT"
|
wait_up "$name" "$port"
|
||||||
|
color_echo green " ✔ $name is up and enrolled."
|
||||||
|
|
||||||
# Verify credentials were saved to replica's SQLite database
|
if [[ -f "$WORK/$name/messages.db" ]]; then
|
||||||
if [[ -f "$WORK/replica/messages.db" ]]; then
|
color_echo green " ✔ mTLS credentials persisted in SQLite."
|
||||||
echo -e " \033[1;32m✔ Replica enrolled and saved mTLS credentials in SQLite.\033[0m"
|
fi
|
||||||
fi
|
done
|
||||||
|
|
||||||
# --- Launch Dashboard (if available) ---
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dashboard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
if [[ "$START_DASHBOARD" -eq 1 && -f "$ROOT_DIR/dashboard.py" ]]; then
|
if [[ "$START_DASHBOARD" -eq 1 && -f "$ROOT_DIR/dashboard.py" ]]; then
|
||||||
if check_port "$DASHBOARD_PORT"; then
|
color_echo green "\n==> Starting Dashboard on :$DASHBOARD_PORT..."
|
||||||
echo -e "\033[1;33mDashboard port :$DASHBOARD_PORT is in use. Skipping dashboard.\033[0m"
|
|
||||||
else
|
|
||||||
echo -e "\033[1;32m==> Starting Dashboard on :$DASHBOARD_PORT\033[0m"
|
|
||||||
( cd "$ROOT_DIR" && exec python3 dashboard.py ) >"$WORK/dashboard.log" 2>&1 &
|
( cd "$ROOT_DIR" && exec python3 dashboard.py ) >"$WORK/dashboard.log" 2>&1 &
|
||||||
PIDS+=("$!")
|
PIDS+=("$!")
|
||||||
fi
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 5. Write Smoke Test Records to Primary
|
# 4. Write smoke-test records to primary
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
echo -e "\n\033[1;34m==> Writing 3 test records to Primary via HTTPS\033[0m"
|
RECORD_COUNT=3
|
||||||
for i in 1 2 3; do
|
color_echo blue "\n==> Writing $RECORD_COUNT test records to Primary..."
|
||||||
|
for i in $(seq 1 "$RECORD_COUNT"); do
|
||||||
curl -k -s -X POST "https://127.0.0.1:$PRIMARY_PORT/api/records" \
|
curl -k -s -X POST "https://127.0.0.1:$PRIMARY_PORT/api/records" \
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d "{\"data\":{\"version\":1,\"msg\":\"hello $i\"}}" >/dev/null
|
-d "{\"data\":{\"version\":1,\"msg\":\"hello $i\"}}" >/dev/null
|
||||||
@@ -178,52 +319,130 @@ for i in 1 2 3; do
|
|||||||
done
|
done
|
||||||
|
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# 6. Await Replica mTLS Sync
|
# 5. Verify every replica has converged
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
echo -e "\n\033[1;34m==> Verifying replica mTLS sync stream...\033[0m"
|
color_echo blue "\n==> Waiting for all replicas to converge to seq $RECORD_COUNT..."
|
||||||
caught_up=0
|
all_ok=1
|
||||||
for _ in $(seq 1 40); do
|
for idx in $(seq 1 $(( NODES - 1 ))); do
|
||||||
status_output=$(curl -k -s "https://127.0.0.1:$REPLICA_PORT/api/replication/status" || true)
|
name="${NAMES[$idx]}"
|
||||||
if echo "$status_output" | grep -q '"last_applied_seq":3'; then
|
port="${PORTS[$idx]}"
|
||||||
caught_up=1
|
echo -n " Checking $name (:$port)... "
|
||||||
break
|
if wait_replica_caught_up "$name" "$port" "$RECORD_COUNT"; then
|
||||||
|
color_echo green "✔ seq $RECORD_COUNT reached"
|
||||||
|
else
|
||||||
|
echo "✖ timeout"
|
||||||
|
all_ok=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ $all_ok -eq 1 ]]; then
|
||||||
|
color_echo green "✔ All replicas are in sync."
|
||||||
|
else
|
||||||
|
color_echo yellow "⚠ Some replicas did not converge in time."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ==============================================================================
|
||||||
|
# 6. Read-only guard check on every replica
|
||||||
|
# ==============================================================================
|
||||||
|
color_echo blue "\n==> Verifying read-only guard on all replicas..."
|
||||||
|
for idx in $(seq 1 $(( NODES - 1 ))); do
|
||||||
|
name="${NAMES[$idx]}"
|
||||||
|
port="${PORTS[$idx]}"
|
||||||
|
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" \
|
||||||
|
-X POST "https://127.0.0.1:$port/api/records" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"data":{"version":1,"msg":"should be rejected"}}')
|
||||||
|
if [[ "$http_code" == "403" ]]; then
|
||||||
|
color_echo green " ✔ $name (:$port) → HTTP 403 Forbidden (correct)"
|
||||||
|
else
|
||||||
|
color_echo red " ✖ $name (:$port) → HTTP $http_code (expected 403)"
|
||||||
fi
|
fi
|
||||||
sleep 0.25
|
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ $caught_up -eq 1 ]]; then
|
# ==============================================================================
|
||||||
echo -e "\033[1;32m✔ SUCCESS: Replica caught up to seq 3 over mTLS!\033[0m"
|
# 7. Graceful switchover (primary -> first replica)
|
||||||
else
|
# Skipped if SKIP_SWITCHOVER=1 or only 1 replica exists (would leave no primary)
|
||||||
echo -e "\033[1;31m✖ WARNING: Replica did not reach seq 3 in time.\033[0m"
|
# ==============================================================================
|
||||||
echo "Replica Status Output: $status_output"
|
if [[ "$SKIP_SWITCHOVER" -eq 0 && "$NODES" -ge 2 ]]; then
|
||||||
fi
|
FIRST_REPLICA_PORT="${PORTS[1]}"
|
||||||
|
FIRST_REPLICA_NAME="${NAMES[1]}"
|
||||||
|
|
||||||
# ==============================================================================
|
color_echo blue "\n==> Initiating Graceful Switchover: primary (:$PRIMARY_PORT) ─► $FIRST_REPLICA_NAME (:$FIRST_REPLICA_PORT)..."
|
||||||
# 7. Verify Read-Only Safety Guard on Replica
|
switch_resp=$(curl -k -sf -X POST "https://127.0.0.1:$PRIMARY_PORT/api/admin/switchover" \
|
||||||
# ==============================================================================
|
|
||||||
echo -e "\n\033[1;34m==> Testing Replica read-only safety guard:\033[0m"
|
|
||||||
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" -X POST "https://127.0.0.1:$REPLICA_PORT/api/records" \
|
|
||||||
-H 'Content-Type: application/json' \
|
-H 'Content-Type: application/json' \
|
||||||
-d '{"data":{"version":1,"msg":"should fail"}}')
|
-d "{\"replica_url\":\"https://127.0.0.1:$FIRST_REPLICA_PORT\",\"timeout_secs\":15}" || true)
|
||||||
|
color_echo green " ✔ Switchover response: $switch_resp"
|
||||||
|
|
||||||
if [[ "$http_code" == "403" ]]; then
|
# Old primary is now replica – verify 403
|
||||||
echo -e " \033[1;32m✔ HTTP 403 Forbidden received as expected.\033[0m"
|
http_code=$(curl -k -s -o /dev/null -w "%{http_code}" \
|
||||||
else
|
-X POST "https://127.0.0.1:$PRIMARY_PORT/api/records" \
|
||||||
echo -e " \033[1;31m✖ Expected HTTP 403, got $http_code.\033[0m"
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"data":{"version":1,"msg":"should fail on demoted primary"}}')
|
||||||
|
if [[ "$http_code" == "403" ]]; then
|
||||||
|
color_echo green " ✔ Demoted primary returned HTTP 403 (read-only)"
|
||||||
|
else
|
||||||
|
color_echo red " ✖ Demoted primary returned HTTP $http_code (expected 403)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Write to new primary
|
||||||
|
NEXT_SEQ=$(( RECORD_COUNT + 1 ))
|
||||||
|
color_echo blue "\n==> Writing record #$NEXT_SEQ to new primary ($FIRST_REPLICA_NAME :$FIRST_REPLICA_PORT)..."
|
||||||
|
write_resp=$(curl -k -sf -X POST "https://127.0.0.1:$FIRST_REPLICA_PORT/api/records" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"data\":{\"version\":1,\"msg\":\"post-switchover write\"}}" || true)
|
||||||
|
color_echo green " ✔ Created: $write_resp"
|
||||||
|
|
||||||
|
# After switchover in star topology, the other replicas still point to the old primary
|
||||||
|
# which now points to the new primary – they will converge via chaining.
|
||||||
|
# In chain topology they chain through naturally.
|
||||||
|
# Verify the demoted primary (now replica) catches up:
|
||||||
|
color_echo blue "\n==> Verifying all nodes converge to seq $NEXT_SEQ post-switchover..."
|
||||||
|
all_ok=1
|
||||||
|
for idx in $(seq 0 $(( NODES - 1 ))); do
|
||||||
|
port="${PORTS[$idx]}"
|
||||||
|
name="${NAMES[$idx]}"
|
||||||
|
if [[ $idx -eq 1 ]]; then continue; fi # new primary already has it
|
||||||
|
echo -n " Checking $name (:$port)... "
|
||||||
|
if wait_replica_caught_up "$name" "$port" "$NEXT_SEQ"; then
|
||||||
|
color_echo green "✔ seq $NEXT_SEQ reached"
|
||||||
|
else
|
||||||
|
echo "✖ timeout"
|
||||||
|
all_ok=0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
if [[ $all_ok -eq 1 ]]; then
|
||||||
|
color_echo green "✔ All nodes converged after switchover."
|
||||||
|
else
|
||||||
|
color_echo yellow "⚠ Some nodes did not converge after switchover."
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Ready Banner ---
|
# ==============================================================================
|
||||||
cat <<EOF
|
# Ready Banner
|
||||||
|
# ==============================================================================
|
||||||
|
echo ""
|
||||||
|
echo "======================================================================"
|
||||||
|
echo " Cluster is healthy • Topology: $TOPO • Nodes: $NODES"
|
||||||
|
echo ""
|
||||||
|
echo " Nodes:"
|
||||||
|
for idx in $(seq 0 $(( NODES - 1 ))); do
|
||||||
|
port="${PORTS[$idx]}"
|
||||||
|
name="${NAMES[$idx]}"
|
||||||
|
if [[ $idx -eq 0 ]]; then
|
||||||
|
echo " [$(( idx + 1 ))] Primary (:$port) https://127.0.0.1:$port"
|
||||||
|
else
|
||||||
|
src="${SOURCES[$idx]}"
|
||||||
|
echo " [$(( idx + 1 ))] Replica (:$port) https://127.0.0.1:$port <─ $src"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo ""
|
||||||
|
if [[ "$START_DASHBOARD" -eq 1 ]]; then
|
||||||
|
echo " Web Dashboard: http://127.0.0.1:$DASHBOARD_PORT"
|
||||||
|
fi
|
||||||
|
echo " Logs Dir: $WORK/"
|
||||||
|
echo ""
|
||||||
|
echo " Press Ctrl+C to stop all instances."
|
||||||
|
echo "======================================================================"
|
||||||
|
|
||||||
======================================================================
|
if [[ "$NO_WAIT" -ne 1 ]]; then
|
||||||
Cluster is healthy and running over mTLS:
|
wait
|
||||||
- Primary API: https://127.0.0.1:$PRIMARY_PORT
|
fi
|
||||||
- Replica API: https://127.0.0.1:$REPLICA_PORT
|
|
||||||
- Web Dashboard: http://127.0.0.1:$DASHBOARD_PORT
|
|
||||||
- Logs Dir: $WORK/
|
|
||||||
|
|
||||||
Press Ctrl+C to stop all instances.
|
|
||||||
======================================================================
|
|
||||||
EOF
|
|
||||||
|
|
||||||
wait
|
|
||||||
+354
-7
@@ -4,7 +4,15 @@ use axum::{
|
|||||||
Json,
|
Json,
|
||||||
};
|
};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use crate::tls::{create_join_token, issue_leaf_cert, JoinClusterRequest, JoinClusterResponse};
|
use crate::{
|
||||||
|
db::{meta_get, meta_set},
|
||||||
|
tls::{
|
||||||
|
create_join_token, create_mtls_client, issue_leaf_cert, JoinClusterRequest,
|
||||||
|
JoinClusterResponse,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
use std::time::Duration;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use rusqlite::params;
|
use rusqlite::params;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
@@ -36,7 +44,7 @@ pub async fn create_token_handler(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
payload: Option<Json<Value>>,
|
payload: Option<Json<Value>>,
|
||||||
) -> Result<Json<Value>, ApiError> {
|
) -> Result<Json<Value>, ApiError> {
|
||||||
if state.role == Role::Replica {
|
if state.is_replica() {
|
||||||
return Err(api_error(StatusCode::FORBIDDEN, "Only Primary can generate join tokens"));
|
return Err(api_error(StatusCode::FORBIDDEN, "Only Primary can generate join tokens"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +69,7 @@ pub async fn join_cluster_handler(
|
|||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(req): Json<JoinClusterRequest>,
|
Json(req): Json<JoinClusterRequest>,
|
||||||
) -> Result<Json<JoinClusterResponse>, ApiError> {
|
) -> Result<Json<JoinClusterResponse>, ApiError> {
|
||||||
if state.role == Role::Replica {
|
if state.is_replica() {
|
||||||
return Err(api_error(StatusCode::FORBIDDEN, "Only Primary can issue certificates"));
|
return Err(api_error(StatusCode::FORBIDDEN, "Only Primary can issue certificates"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +79,9 @@ pub async fn join_cluster_handler(
|
|||||||
.ok_or_else(|| internal_error("CA cert unavailable"))?;
|
.ok_or_else(|| internal_error("CA cert unavailable"))?;
|
||||||
let ca_key_pem = state
|
let ca_key_pem = state
|
||||||
.ca_key_pem
|
.ca_key_pem
|
||||||
.as_ref()
|
.read()
|
||||||
|
.map_err(|_| internal_error("CA key lock poisoned"))?
|
||||||
|
.clone()
|
||||||
.ok_or_else(|| internal_error("CA key unavailable"))?;
|
.ok_or_else(|| internal_error("CA key unavailable"))?;
|
||||||
|
|
||||||
// 1. Validate & burn the token
|
// 1. Validate & burn the token
|
||||||
@@ -106,7 +116,7 @@ pub async fn join_cluster_handler(
|
|||||||
|
|
||||||
let (cert_pem, key_pem) = issue_leaf_cert(
|
let (cert_pem, key_pem) = issue_leaf_cert(
|
||||||
ca_cert_pem,
|
ca_cert_pem,
|
||||||
ca_key_pem,
|
&ca_key_pem,
|
||||||
&req.node_id.to_string(),
|
&req.node_id.to_string(),
|
||||||
sans,
|
sans,
|
||||||
)
|
)
|
||||||
@@ -118,6 +128,7 @@ pub async fn join_cluster_handler(
|
|||||||
ca_pem: ca_cert_pem.to_string(),
|
ca_pem: ca_cert_pem.to_string(),
|
||||||
cert_pem,
|
cert_pem,
|
||||||
key_pem,
|
key_pem,
|
||||||
|
ca_key_pem: Some(ca_key_pem),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,12 +152,13 @@ pub async fn create_record(
|
|||||||
) -> Result<(StatusCode, Json<ApiResponse>), ApiError> {
|
) -> Result<(StatusCode, Json<ApiResponse>), ApiError> {
|
||||||
info!(endpoint = "/api/records", "Create request received");
|
info!(endpoint = "/api/records", "Create request received");
|
||||||
|
|
||||||
if state.role == Role::Replica {
|
if state.is_replica() {
|
||||||
|
let p_url = state.get_primary_url();
|
||||||
return Err(api_error(
|
return Err(api_error(
|
||||||
StatusCode::FORBIDDEN,
|
StatusCode::FORBIDDEN,
|
||||||
format!(
|
format!(
|
||||||
"This instance is a read-only replica; write to {}",
|
"This instance is a read-only replica; write to {}",
|
||||||
state.primary_url.as_deref().map(String::as_str).unwrap_or("the primary")
|
p_url.as_deref().unwrap_or("the primary")
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -285,3 +297,338 @@ pub async fn reload_config(State(state): State<AppState>) -> Result<Json<Value>,
|
|||||||
"fields": field_count
|
"fields": field_count
|
||||||
})))
|
})))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct StepDownRequest {
|
||||||
|
pub primary_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RepointRequest {
|
||||||
|
pub primary_url: String,
|
||||||
|
pub reset_source: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct SwitchoverRequest {
|
||||||
|
pub replica_url: String,
|
||||||
|
pub timeout_secs: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin endpoint: Demote this primary to read-only replica.
|
||||||
|
pub async fn step_down_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
payload: Option<Json<StepDownRequest>>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
info!("Step-down requested");
|
||||||
|
|
||||||
|
// 1. Update role in memory
|
||||||
|
{
|
||||||
|
let mut r = state
|
||||||
|
.role
|
||||||
|
.write()
|
||||||
|
.map_err(|_| internal_error("role lock poisoned"))?;
|
||||||
|
*r = Role::Replica;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Stop any existing replica loop
|
||||||
|
state.stop_replica_sync();
|
||||||
|
|
||||||
|
// 3. Update local head seq as baseline for last_applied_seq
|
||||||
|
let local_head: i64 = {
|
||||||
|
let db = state.db.lock().map_err(|_| internal_error("db poisoned"))?;
|
||||||
|
db.query_row("SELECT COALESCE(MAX(seq), 0) FROM records", [], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.map_err(|e| db_error("get local head", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
{
|
||||||
|
let db = state.db.lock().map_err(|_| internal_error("db poisoned"))?;
|
||||||
|
meta_set(&db, "role", "replica").map_err(|e| db_error("save role", e))?;
|
||||||
|
meta_set(&db, "last_applied_seq", &local_head.to_string())
|
||||||
|
.map_err(|e| db_error("save last_applied_seq", e))?;
|
||||||
|
let _ = db.execute("DELETE FROM meta WHERE key = 'source_instance_id'", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut s) = state.sync.write() {
|
||||||
|
s.state = "idle".into();
|
||||||
|
s.last_applied_seq = local_head;
|
||||||
|
s.source_instance_id = None;
|
||||||
|
s.primary_head_seq = Some(local_head);
|
||||||
|
s.last_error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(Json(req)) = payload {
|
||||||
|
if let Some(p_url) = req.primary_url {
|
||||||
|
let trimmed = p_url.trim_end_matches('/').to_string();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
state.set_primary_url(Some(trimmed.clone()));
|
||||||
|
let db = state.db.lock().map_err(|_| internal_error("db poisoned"))?;
|
||||||
|
let _ = meta_set(&db, "primary_url", &trimmed);
|
||||||
|
drop(db);
|
||||||
|
state.start_replica_sync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(role = "replica", last_applied_seq = local_head, "Node stepped down to replica");
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"role": "replica",
|
||||||
|
"primary_url": state.get_primary_url(),
|
||||||
|
"last_applied_seq": local_head,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin endpoint: Promote this replica to Primary.
|
||||||
|
pub async fn promote_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
info!("Promotion to Primary requested");
|
||||||
|
|
||||||
|
// 1. Stop pulling changes
|
||||||
|
state.stop_replica_sync();
|
||||||
|
|
||||||
|
// 2. Set role to Primary
|
||||||
|
{
|
||||||
|
let mut r = state
|
||||||
|
.role
|
||||||
|
.write()
|
||||||
|
.map_err(|_| internal_error("role lock poisoned"))?;
|
||||||
|
*r = Role::Primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_head: i64 = {
|
||||||
|
let db = state.db.lock().map_err(|_| internal_error("db poisoned"))?;
|
||||||
|
db.query_row("SELECT COALESCE(MAX(seq), 0) FROM records", [], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.map_err(|e| db_error("get local head", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
let epoch = {
|
||||||
|
let db = state.db.lock().map_err(|_| internal_error("db poisoned"))?;
|
||||||
|
meta_set(&db, "role", "primary").map_err(|e| db_error("save role", e))?;
|
||||||
|
let cur_epoch: i64 = meta_get(&db, "cluster_epoch")
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.unwrap_or(1);
|
||||||
|
let next_epoch = cur_epoch + 1;
|
||||||
|
meta_set(&db, "cluster_epoch", &next_epoch.to_string())
|
||||||
|
.map_err(|e| db_error("save epoch", e))?;
|
||||||
|
next_epoch
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Ok(mut s) = state.sync.write() {
|
||||||
|
s.state = "primary".into();
|
||||||
|
s.primary_head_seq = None;
|
||||||
|
s.last_error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(role = "primary", head_seq = local_head, epoch, "Node successfully promoted to Primary");
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"role": "primary",
|
||||||
|
"instance_id": state.instance_id,
|
||||||
|
"head_seq": local_head,
|
||||||
|
"epoch": epoch,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin endpoint: Repoint replica to a new primary URL.
|
||||||
|
pub async fn repoint_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<RepointRequest>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
info!(primary_url = %req.primary_url, "Repoint requested");
|
||||||
|
|
||||||
|
let p_url = req.primary_url.trim_end_matches('/').to_string();
|
||||||
|
state.set_primary_url(Some(p_url.clone()));
|
||||||
|
|
||||||
|
{
|
||||||
|
let db = state.db.lock().map_err(|_| internal_error("db poisoned"))?;
|
||||||
|
meta_set(&db, "primary_url", &p_url).map_err(|e| db_error("save primary_url", e))?;
|
||||||
|
if req.reset_source.unwrap_or(true) {
|
||||||
|
let _ = db.execute("DELETE FROM meta WHERE key = 'source_instance_id'", []);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.reset_source.unwrap_or(true) {
|
||||||
|
if let Ok(mut s) = state.sync.write() {
|
||||||
|
s.source_instance_id = None;
|
||||||
|
s.last_error = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.is_replica() {
|
||||||
|
state.start_replica_sync();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"role": state.get_role().as_str(),
|
||||||
|
"primary_url": p_url,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Admin endpoint: Initiates coordinated graceful switchover from Primary to a chosen Replica.
|
||||||
|
pub async fn switchover_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Json(req): Json<SwitchoverRequest>,
|
||||||
|
) -> Result<Json<Value>, ApiError> {
|
||||||
|
info!(target = %req.replica_url, "Coordinated cluster role switchover initiated");
|
||||||
|
|
||||||
|
if !state.is_primary() {
|
||||||
|
return Err(api_error(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"Switchover can only be initiated on the primary node",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let replica_url = req.replica_url.trim_end_matches('/').to_string();
|
||||||
|
let timeout_secs = req.timeout_secs.unwrap_or(15);
|
||||||
|
|
||||||
|
// Build mTLS client to interact with the replica
|
||||||
|
let ca_pem = state
|
||||||
|
.ca_cert_pem
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| internal_error("ca_cert_pem unavailable"))?;
|
||||||
|
let cert_pem = state
|
||||||
|
.node_cert_pem
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| internal_error("node_cert_pem unavailable"))?;
|
||||||
|
let key_pem = state
|
||||||
|
.node_key_pem
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| internal_error("node_key_pem unavailable"))?;
|
||||||
|
|
||||||
|
let client = create_mtls_client(ca_pem, cert_pem, key_pem)
|
||||||
|
.map_err(|e| internal_error(&format!("failed to create mtls client: {e}")))?;
|
||||||
|
|
||||||
|
// Step 1: Quiesce this primary by demoting to replica (stops accepting new writes)
|
||||||
|
{
|
||||||
|
let mut r = state
|
||||||
|
.role
|
||||||
|
.write()
|
||||||
|
.map_err(|_| internal_error("role lock poisoned"))?;
|
||||||
|
*r = Role::Replica;
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_head: i64 = {
|
||||||
|
let db = state.db.lock().map_err(|_| internal_error("db poisoned"))?;
|
||||||
|
db.query_row("SELECT COALESCE(MAX(seq), 0) FROM records", [], |r| {
|
||||||
|
r.get(0)
|
||||||
|
})
|
||||||
|
.map_err(|e| db_error("get local head", e))?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 2: Poll replica status until last_applied_seq >= local_head
|
||||||
|
info!(local_head, "Waiting for replica to catch up to head_seq...");
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let mut caught_up = false;
|
||||||
|
|
||||||
|
while start.elapsed() < Duration::from_secs(timeout_secs) {
|
||||||
|
let status_url = format!("{replica_url}/api/replication/status");
|
||||||
|
match client.get(&status_url).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => {
|
||||||
|
if let Ok(json) = resp.json::<Value>().await {
|
||||||
|
let repl_applied = json
|
||||||
|
.get("sync")
|
||||||
|
.and_then(|s| s.get("last_applied_seq"))
|
||||||
|
.and_then(|v| v.as_i64())
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if repl_applied >= local_head {
|
||||||
|
caught_up = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(resp) => {
|
||||||
|
warn!(status = %resp.status(), "Replica returned non-success during status check");
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!(error = %e, "Failed to query replica status during switchover");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !caught_up {
|
||||||
|
// Rollback to primary
|
||||||
|
error!("Replica did not catch up within timeout; rolling back role to Primary");
|
||||||
|
let mut r = state
|
||||||
|
.role
|
||||||
|
.write()
|
||||||
|
.map_err(|_| internal_error("role lock poisoned"))?;
|
||||||
|
*r = Role::Primary;
|
||||||
|
return Err(api_error(
|
||||||
|
StatusCode::GATEWAY_TIMEOUT,
|
||||||
|
format!(
|
||||||
|
"Replica at {replica_url} failed to catch up to seq {local_head} within {timeout_secs}s"
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Promote replica to Primary
|
||||||
|
let promote_url = format!("{replica_url}/api/admin/promote");
|
||||||
|
let resp = match client.post(&promote_url).send().await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "Failed to call promote on replica; rolling back");
|
||||||
|
let mut r = state
|
||||||
|
.role
|
||||||
|
.write()
|
||||||
|
.map_err(|_| internal_error("role lock poisoned"))?;
|
||||||
|
*r = Role::Primary;
|
||||||
|
return Err(api_error(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!("Failed to reach replica for promotion: {e}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
error!(status = %resp.status(), "Replica promotion failed; rolling back");
|
||||||
|
let mut r = state
|
||||||
|
.role
|
||||||
|
.write()
|
||||||
|
.map_err(|_| internal_error("role lock poisoned"))?;
|
||||||
|
*r = Role::Primary;
|
||||||
|
return Err(api_error(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
format!("Replica rejected promotion with status {}", resp.status()),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Configure this node as a replica of the new primary
|
||||||
|
{
|
||||||
|
let db = state.db.lock().map_err(|_| internal_error("db poisoned"))?;
|
||||||
|
let _ = meta_set(&db, "role", "replica");
|
||||||
|
let _ = meta_set(&db, "primary_url", &replica_url);
|
||||||
|
let _ = meta_set(&db, "last_applied_seq", &local_head.to_string());
|
||||||
|
let _ = db.execute("DELETE FROM meta WHERE key = 'source_instance_id'", []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut s) = state.sync.write() {
|
||||||
|
s.state = "idle".into();
|
||||||
|
s.last_applied_seq = local_head;
|
||||||
|
s.source_instance_id = None;
|
||||||
|
s.primary_head_seq = Some(local_head);
|
||||||
|
s.last_error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.set_primary_url(Some(replica_url.clone()));
|
||||||
|
state.start_replica_sync();
|
||||||
|
|
||||||
|
info!(new_primary = %replica_url, "Graceful switchover succeeded");
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"message": "Role switchover completed successfully",
|
||||||
|
"old_primary_role": "replica",
|
||||||
|
"new_primary_url": replica_url,
|
||||||
|
"switched_at_seq": local_head,
|
||||||
|
})))
|
||||||
|
}
|
||||||
+33
-26
@@ -28,11 +28,12 @@ use crate::{
|
|||||||
db::{init_db, load_or_create_instance_id, meta_get, meta_set},
|
db::{init_db, load_or_create_instance_id, meta_get, meta_set},
|
||||||
handlers::{
|
handlers::{
|
||||||
create_record, create_token_handler, get_cluster_ca, get_config, get_info,
|
create_record, create_token_handler, get_cluster_ca, get_config, get_info,
|
||||||
join_cluster_handler, list_records, reload_config,
|
join_cluster_handler, list_records, promote_handler, reload_config, repoint_handler,
|
||||||
|
step_down_handler, switchover_handler,
|
||||||
},
|
},
|
||||||
replication::{replica_loop, replication_changes, replication_status, SyncConfig},
|
replication::{replication_changes, replication_status},
|
||||||
schema::load_config,
|
schema::load_config,
|
||||||
state::{AppState, Role, SyncStatus},
|
state::{AppState, Role, SyncConfig, SyncStatus},
|
||||||
tls::{
|
tls::{
|
||||||
bootstrap_replica_join, create_mtls_server_config, generate_ca, issue_leaf_cert,
|
bootstrap_replica_join, create_mtls_server_config, generate_ca, issue_leaf_cert,
|
||||||
JoinToken,
|
JoinToken,
|
||||||
@@ -137,18 +138,24 @@ async fn main() {
|
|||||||
} else {
|
} else {
|
||||||
// Replica: Check if dynamic bootstrap enrollment is needed
|
// Replica: Check if dynamic bootstrap enrollment is needed
|
||||||
if node_cert_pem.is_none() {
|
if node_cert_pem.is_none() {
|
||||||
let p_url = primary_url
|
// APP_JOIN_URL lets the replication source (APP_PRIMARY_URL) differ from the
|
||||||
.as_ref()
|
// CA enrollment target (APP_JOIN_URL). This is required in chain topologies
|
||||||
.expect("APP_PRIMARY_URL required for replica enrollment");
|
// where intermediate replicas serve data but cannot issue certificates.
|
||||||
|
// Falls back to APP_PRIMARY_URL when not set.
|
||||||
|
let join_url = std::env::var("APP_JOIN_URL")
|
||||||
|
.ok()
|
||||||
|
.or_else(|| primary_url.clone())
|
||||||
|
.expect("APP_PRIMARY_URL (or APP_JOIN_URL) required for replica enrollment");
|
||||||
let token = std::env::var("APP_JOIN_TOKEN")
|
let token = std::env::var("APP_JOIN_TOKEN")
|
||||||
.expect("APP_JOIN_TOKEN required for initial replica enrollment");
|
.expect("APP_JOIN_TOKEN required for initial replica enrollment");
|
||||||
|
|
||||||
info!(primary = %p_url, "Bootstrapping mTLS credentials via join token...");
|
info!(join_url = %join_url, "Bootstrapping mTLS credentials via join token...");
|
||||||
bootstrap_replica_join(p_url, &token, instance_id, &db)
|
bootstrap_replica_join(&join_url, &token, instance_id, &db)
|
||||||
.await
|
.await
|
||||||
.expect("Dynamic join failed");
|
.expect("Dynamic join failed");
|
||||||
|
|
||||||
ca_cert_pem = meta_get(&db, "tls_ca_pem").ok().flatten();
|
ca_cert_pem = meta_get(&db, "tls_ca_pem").ok().flatten();
|
||||||
|
ca_key_pem = meta_get(&db, "tls_ca_key_pem").ok().flatten();
|
||||||
node_cert_pem = meta_get(&db, "tls_cert_pem").ok().flatten();
|
node_cert_pem = meta_get(&db, "tls_cert_pem").ok().flatten();
|
||||||
node_key_pem = meta_get(&db, "tls_key_pem").ok().flatten();
|
node_key_pem = meta_get(&db, "tls_key_pem").ok().flatten();
|
||||||
}
|
}
|
||||||
@@ -171,13 +178,19 @@ async fn main() {
|
|||||||
"Instance initialised"
|
"Instance initialised"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let sync_cfg = SyncConfig {
|
||||||
|
poll_interval: Duration::from_secs(env_parse("APP_SYNC_INTERVAL_SECS", 5)),
|
||||||
|
max_backoff: Duration::from_secs(env_parse("APP_SYNC_MAX_BACKOFF_SECS", 60)),
|
||||||
|
batch: env_parse("APP_SYNC_BATCH", 200i64).clamp(1, 1000),
|
||||||
|
};
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
db: Arc::new(Mutex::new(db)),
|
db: Arc::new(Mutex::new(db)),
|
||||||
schema: Arc::new(RwLock::new(schema)),
|
schema: Arc::new(RwLock::new(schema)),
|
||||||
config_path: Arc::new(config_path),
|
config_path: Arc::new(config_path),
|
||||||
instance_id,
|
instance_id,
|
||||||
role,
|
role: Arc::new(RwLock::new(role)),
|
||||||
primary_url: primary_url.clone().map(Arc::new),
|
primary_url: Arc::new(RwLock::new(primary_url)),
|
||||||
repl_token: repl_token.clone().map(Arc::new),
|
repl_token: repl_token.clone().map(Arc::new),
|
||||||
sync: Arc::new(RwLock::new(SyncStatus {
|
sync: Arc::new(RwLock::new(SyncStatus {
|
||||||
state: if role == Role::Replica { "idle" } else { "primary" }.into(),
|
state: if role == Role::Replica { "idle" } else { "primary" }.into(),
|
||||||
@@ -187,27 +200,17 @@ async fn main() {
|
|||||||
last_success: None,
|
last_success: None,
|
||||||
last_error: None,
|
last_error: None,
|
||||||
})),
|
})),
|
||||||
|
sync_cancel: Arc::new(Mutex::new(None)),
|
||||||
|
sync_cfg,
|
||||||
ca_cert_pem: ca_cert_pem.clone().map(Arc::new),
|
ca_cert_pem: ca_cert_pem.clone().map(Arc::new),
|
||||||
ca_key_pem: ca_key_pem.map(Arc::new),
|
ca_key_pem: Arc::new(RwLock::new(ca_key_pem)),
|
||||||
|
node_cert_pem: node_cert_pem.clone().map(Arc::new),
|
||||||
|
node_key_pem: node_key_pem.clone().map(Arc::new),
|
||||||
join_tokens,
|
join_tokens,
|
||||||
};
|
};
|
||||||
|
|
||||||
if role == Role::Replica {
|
if role == Role::Replica {
|
||||||
let sync_cfg = SyncConfig {
|
state.start_replica_sync();
|
||||||
poll_interval: Duration::from_secs(env_parse("APP_SYNC_INTERVAL_SECS", 5)),
|
|
||||||
max_backoff: Duration::from_secs(env_parse("APP_SYNC_MAX_BACKOFF_SECS", 60)),
|
|
||||||
batch: env_parse("APP_SYNC_BATCH", 200i64).clamp(1, 1000),
|
|
||||||
};
|
|
||||||
|
|
||||||
tokio::spawn(replica_loop(
|
|
||||||
state.clone(),
|
|
||||||
primary_url.expect("checked above"),
|
|
||||||
repl_token.unwrap_or_else(|| "".into()),
|
|
||||||
sync_cfg,
|
|
||||||
ca_cert_pem.clone().unwrap(),
|
|
||||||
node_cert_pem.clone().unwrap(),
|
|
||||||
node_key_pem.clone().unwrap(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
@@ -216,6 +219,10 @@ async fn main() {
|
|||||||
.route("/api/records", get(list_records).post(create_record))
|
.route("/api/records", get(list_records).post(create_record))
|
||||||
.route("/api/admin/config", get(get_config))
|
.route("/api/admin/config", get(get_config))
|
||||||
.route("/api/admin/reload-config", post(reload_config))
|
.route("/api/admin/reload-config", post(reload_config))
|
||||||
|
.route("/api/admin/step-down", post(step_down_handler))
|
||||||
|
.route("/api/admin/promote", post(promote_handler))
|
||||||
|
.route("/api/admin/repoint", post(repoint_handler))
|
||||||
|
.route("/api/admin/switchover", post(switchover_handler))
|
||||||
.route("/api/replication/changes", get(replication_changes))
|
.route("/api/replication/changes", get(replication_changes))
|
||||||
.route("/api/replication/status", get(replication_status))
|
.route("/api/replication/status", get(replication_status))
|
||||||
// Dynamic Peer Discovery Routes:
|
// Dynamic Peer Discovery Routes:
|
||||||
|
|||||||
+41
-13
@@ -18,7 +18,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
db::meta_set,
|
db::meta_set,
|
||||||
state::{api_error, db_error, internal_error, ApiError, AppState, SyncStatus},
|
state::{api_error, db_error, internal_error, ApiError, AppState, SyncConfig, SyncStatus},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -43,13 +43,6 @@ pub struct ChangesQuery {
|
|||||||
pub limit: Option<i64>,
|
pub limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct SyncConfig {
|
|
||||||
pub poll_interval: Duration,
|
|
||||||
pub max_backoff: Duration,
|
|
||||||
pub batch: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum SyncError {
|
enum SyncError {
|
||||||
Transient(String),
|
Transient(String),
|
||||||
Fatal(String),
|
Fatal(String),
|
||||||
@@ -165,10 +158,13 @@ pub async fn replication_status(State(state): State<AppState>) -> Result<Json<Va
|
|||||||
.primary_head_seq
|
.primary_head_seq
|
||||||
.map(|head| (head - status.last_applied_seq).max(0));
|
.map(|head| (head - status.last_applied_seq).max(0));
|
||||||
|
|
||||||
|
let current_role = state.get_role();
|
||||||
|
let primary_url = state.get_primary_url();
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"instance_id": state.instance_id,
|
"instance_id": state.instance_id,
|
||||||
"role": state.role.as_str(),
|
"role": current_role.as_str(),
|
||||||
"primary_url": state.primary_url.as_deref(),
|
"primary_url": primary_url,
|
||||||
"local_head_seq": local_head,
|
"local_head_seq": local_head,
|
||||||
"lag_records": lag,
|
"lag_records": lag,
|
||||||
"sync": status,
|
"sync": status,
|
||||||
@@ -335,6 +331,7 @@ pub async fn replica_loop(
|
|||||||
ca_pem: String,
|
ca_pem: String,
|
||||||
cert_pem: String,
|
cert_pem: String,
|
||||||
key_pem: String,
|
key_pem: String,
|
||||||
|
mut cancel_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
) {
|
) {
|
||||||
let client = match create_mtls_client(&ca_pem, &cert_pem, &key_pem) {
|
let client = match create_mtls_client(&ca_pem, &cert_pem, &key_pem) {
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
@@ -348,11 +345,32 @@ pub async fn replica_loop(
|
|||||||
info!(primary = %primary_url, "Replica mTLS sync loop started");
|
info!(primary = %primary_url, "Replica mTLS sync loop started");
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
match sync_once(&client, &state, &primary_url, &token, cfg.batch).await {
|
if *cancel_rx.borrow() {
|
||||||
|
info!("Replica sync loop stopped via cancellation token");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = cancel_rx.changed() => {
|
||||||
|
if *cancel_rx.borrow() {
|
||||||
|
info!("Replica sync loop received cancellation");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res = sync_once(&client, &state, &primary_url, &token, cfg.batch) => {
|
||||||
|
match res {
|
||||||
Ok(more) => {
|
Ok(more) => {
|
||||||
backoff = Duration::from_secs(1);
|
backoff = Duration::from_secs(1);
|
||||||
if !more {
|
if !more {
|
||||||
tokio::time::sleep(jitter(cfg.poll_interval)).await;
|
tokio::select! {
|
||||||
|
_ = cancel_rx.changed() => {
|
||||||
|
if *cancel_rx.borrow() {
|
||||||
|
info!("Replica sync loop cancelled while waiting for next poll");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep(jitter(cfg.poll_interval)) => {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(SyncError::Transient(message)) => {
|
Err(SyncError::Transient(message)) => {
|
||||||
@@ -362,7 +380,15 @@ pub async fn replica_loop(
|
|||||||
s.state = "retrying".into();
|
s.state = "retrying".into();
|
||||||
s.last_error = Some(message);
|
s.last_error = Some(message);
|
||||||
});
|
});
|
||||||
tokio::time::sleep(wait).await;
|
tokio::select! {
|
||||||
|
_ = cancel_rx.changed() => {
|
||||||
|
if *cancel_rx.borrow() {
|
||||||
|
info!("Replica sync loop cancelled during backoff sleep");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = tokio::time::sleep(wait) => {}
|
||||||
|
}
|
||||||
backoff = (backoff * 2).min(cfg.max_backoff);
|
backoff = (backoff * 2).min(cfg.max_backoff);
|
||||||
}
|
}
|
||||||
Err(SyncError::Fatal(message)) => {
|
Err(SyncError::Fatal(message)) => {
|
||||||
@@ -375,4 +401,6 @@ pub async fn replica_loop(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+112
-5
@@ -5,30 +5,137 @@ use serde_json::Value;
|
|||||||
use std::{
|
use std::{
|
||||||
collections::HashMap,
|
collections::HashMap,
|
||||||
sync::{Arc, Mutex, RwLock},
|
sync::{Arc, Mutex, RwLock},
|
||||||
|
time::Duration,
|
||||||
};
|
};
|
||||||
use tracing::error;
|
use tracing::{error, info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::{schema::SchemaConfig, tls::JoinToken};
|
use crate::{schema::SchemaConfig, tls::JoinToken};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SyncConfig {
|
||||||
|
pub poll_interval: Duration,
|
||||||
|
pub max_backoff: Duration,
|
||||||
|
pub batch: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub db: Arc<Mutex<Connection>>,
|
pub db: Arc<Mutex<Connection>>,
|
||||||
pub schema: Arc<RwLock<SchemaConfig>>,
|
pub schema: Arc<RwLock<SchemaConfig>>,
|
||||||
pub config_path: Arc<String>,
|
pub config_path: Arc<String>,
|
||||||
pub instance_id: Uuid,
|
pub instance_id: Uuid,
|
||||||
pub role: Role,
|
pub role: Arc<RwLock<Role>>,
|
||||||
pub primary_url: Option<Arc<String>>,
|
pub primary_url: Arc<RwLock<Option<String>>>,
|
||||||
pub repl_token: Option<Arc<String>>,
|
pub repl_token: Option<Arc<String>>,
|
||||||
pub sync: Arc<RwLock<SyncStatus>>,
|
pub sync: Arc<RwLock<SyncStatus>>,
|
||||||
|
|
||||||
|
// Replication task management:
|
||||||
|
pub sync_cancel: Arc<Mutex<Option<tokio::sync::watch::Sender<bool>>>>,
|
||||||
|
pub sync_cfg: SyncConfig,
|
||||||
|
|
||||||
// mTLS & Discovery additions:
|
// mTLS & Discovery additions:
|
||||||
pub ca_cert_pem: Option<Arc<String>>,
|
pub ca_cert_pem: Option<Arc<String>>,
|
||||||
pub ca_key_pem: Option<Arc<String>>,
|
pub ca_key_pem: Arc<RwLock<Option<String>>>,
|
||||||
|
pub node_cert_pem: Option<Arc<String>>,
|
||||||
|
pub node_key_pem: Option<Arc<String>>,
|
||||||
pub join_tokens: Arc<RwLock<HashMap<String, JoinToken>>>,
|
pub join_tokens: Arc<RwLock<HashMap<String, JoinToken>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
impl AppState {
|
||||||
|
pub fn get_role(&self) -> Role {
|
||||||
|
*self.role.read().unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_primary(&self) -> bool {
|
||||||
|
self.get_role() == Role::Primary
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_replica(&self) -> bool {
|
||||||
|
self.get_role() == Role::Replica
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_primary_url(&self) -> Option<String> {
|
||||||
|
self.primary_url.read().unwrap().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_primary_url(&self, url: Option<String>) {
|
||||||
|
let mut p = self.primary_url.write().unwrap();
|
||||||
|
*p = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stop_replica_sync(&self) {
|
||||||
|
let mut cancel = self.sync_cancel.lock().unwrap();
|
||||||
|
if let Some(tx) = cancel.take() {
|
||||||
|
let _ = tx.send(true);
|
||||||
|
info!("Sent cancellation signal to replica sync loop");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start_replica_sync(&self) {
|
||||||
|
self.stop_replica_sync();
|
||||||
|
|
||||||
|
let primary_url = match self.get_primary_url() {
|
||||||
|
Some(u) if !u.is_empty() => u,
|
||||||
|
_ => {
|
||||||
|
warn!("Cannot start replica sync loop: primary_url is not set");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let token = self
|
||||||
|
.repl_token
|
||||||
|
.as_ref()
|
||||||
|
.map(|t| t.as_str().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let ca_pem = match self.ca_cert_pem.as_ref() {
|
||||||
|
Some(ca) => ca.as_str().to_string(),
|
||||||
|
None => {
|
||||||
|
warn!("Cannot start replica sync loop: ca_cert_pem missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let cert_pem = match self.node_cert_pem.as_ref() {
|
||||||
|
Some(c) => c.as_str().to_string(),
|
||||||
|
None => {
|
||||||
|
warn!("Cannot start replica sync loop: node_cert_pem missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let key_pem = match self.node_key_pem.as_ref() {
|
||||||
|
Some(k) => k.as_str().to_string(),
|
||||||
|
None => {
|
||||||
|
warn!("Cannot start replica sync loop: node_key_pem missing");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (cancel_tx, cancel_rx) = tokio::sync::watch::channel(false);
|
||||||
|
{
|
||||||
|
let mut cancel = self.sync_cancel.lock().unwrap();
|
||||||
|
*cancel = Some(cancel_tx);
|
||||||
|
}
|
||||||
|
|
||||||
|
let state_clone = self.clone();
|
||||||
|
let sync_cfg = self.sync_cfg.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
crate::replication::replica_loop(
|
||||||
|
state_clone,
|
||||||
|
primary_url,
|
||||||
|
token,
|
||||||
|
sync_cfg,
|
||||||
|
ca_pem,
|
||||||
|
cert_pem,
|
||||||
|
key_pem,
|
||||||
|
cancel_rx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub enum Role {
|
pub enum Role {
|
||||||
Primary,
|
Primary,
|
||||||
Replica,
|
Replica,
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ pub struct JoinClusterResponse {
|
|||||||
pub ca_pem: String,
|
pub ca_pem: String,
|
||||||
pub cert_pem: String,
|
pub cert_pem: String,
|
||||||
pub key_pem: String,
|
pub key_pem: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub ca_key_pem: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -213,6 +215,9 @@ pub async fn bootstrap_replica_join(
|
|||||||
meta_set(db, "tls_ca_pem", &payload.ca_pem).map_err(|e| e.to_string())?;
|
meta_set(db, "tls_ca_pem", &payload.ca_pem).map_err(|e| e.to_string())?;
|
||||||
meta_set(db, "tls_cert_pem", &payload.cert_pem).map_err(|e| e.to_string())?;
|
meta_set(db, "tls_cert_pem", &payload.cert_pem).map_err(|e| e.to_string())?;
|
||||||
meta_set(db, "tls_key_pem", &payload.key_pem).map_err(|e| e.to_string())?;
|
meta_set(db, "tls_key_pem", &payload.key_pem).map_err(|e| e.to_string())?;
|
||||||
|
if let Some(ref ca_key) = payload.ca_key_pem {
|
||||||
|
meta_set(db, "tls_ca_key_pem", ca_key).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
info!("Replica successfully joined cluster and persisted mTLS certificates");
|
info!("Replica successfully joined cluster and persisted mTLS certificates");
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
Reference in new issue
Block a user