Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8798110bf3 | |||
| 1508d431c0 | |||
| caf6f0c67b |
43
Cargo.lock
generated
43
Cargo.lock
generated
@@ -1929,6 +1929,26 @@ dependencies = [
|
||||
name = "example"
|
||||
version = "0.0.0"
|
||||
|
||||
[[package]]
|
||||
name = "example-kvm-okd-ha-cluster"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"harmony",
|
||||
"log",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "example_linux_vm"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"harmony",
|
||||
"log",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eyre"
|
||||
version = "0.6.12"
|
||||
@@ -2411,6 +2431,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"serde_with",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"similar",
|
||||
"sqlx",
|
||||
"strum 0.27.2",
|
||||
@@ -2424,6 +2445,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"url",
|
||||
"uuid",
|
||||
"virt",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -7001,6 +7023,27 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "virt"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b191deb9f351999588bbd289fd40d5ca0076fd9784d1a1a0af531ec8684093c9"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"uuid",
|
||||
"virt-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "virt-sys"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8762dc8eb11b230e7ed6c94152910f8382a128eed861af21047a203e3e2ebb00"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -16,9 +16,9 @@ members = [
|
||||
"harmony_secret_derive",
|
||||
"harmony_secret",
|
||||
"adr/agent_discovery/mdns",
|
||||
"brocade",
|
||||
"harmony_agent",
|
||||
"harmony_agent/deploy", "harmony_node_readiness", "harmony-k8s",
|
||||
"brocade",
|
||||
"harmony_agent",
|
||||
"harmony_agent/deploy", "harmony_node_readiness", "harmony-k8s", "examples/kvm_okd_ha_cluster", "examples/example_linux_vm",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
238
adr/017-2-reviewed-staleness-detection-algorithm.md
Normal file
238
adr/017-2-reviewed-staleness-detection-algorithm.md
Normal file
@@ -0,0 +1,238 @@
|
||||
Here are some rough notes on the previous design :
|
||||
|
||||
- We found an issue where there could be primary flapping when network latency is larger than the primary self fencing timeout.
|
||||
- e.g. network latency to get nats ack is 30 seconds (extreme but can happen), and self-fencing happens after 50 seconds. Then at second 50 self-fencing would occur, and then at second 60 ack comes in. At this point we reject the ack as already failed because of timeout. Self fencing happens. But then network latency comes back down to 5 seconds and lets one successful heartbeat through, this means the primary comes back to healthy, and the same thing repeats, so the primary flaps.
|
||||
- At least this does not cause split brain since the replica never times out and wins the leadership write since we validate strict write ordering and we force consensus on writes.
|
||||
|
||||
Also, we were seeing that the implementation became more complex. There is a lot of timers to handle and that becomes hard to reason about for edge cases.
|
||||
|
||||
So, we came up with a slightly different approach, inspired by k8s liveness probes.
|
||||
|
||||
We now want to use a failure and success threshold counter . However, on the replica side, all we can do is use a timer. The timer we can use is time since last primary heartbeat jetstream metadata timestamp. We could also try and mitigate clock skew by measuring time between internal clock and jetstream metadata timestamp when writing our own heartbeat (not for now, but worth thinking about, though I feel like it is useless).
|
||||
|
||||
So the current working design is this :
|
||||
|
||||
configure :
|
||||
- number of consecutive success to mark the node as UP
|
||||
- number of consecutive failures to mark the node as DOWN
|
||||
- note that success/failure must be consecutive. One success in a row of failures is enough to keep service up. This allows for various configuration profiles, from very stict availability to very lenient depending on the number of failure tolerated and success required to keep the service up.
|
||||
- failure_threshold at 100 will let a service fail (or timeout) 99/100 and stay up
|
||||
- success_threshold at 100 will not bring back up a service until it has succeeded 100 heartbeat in a row
|
||||
- failure threshold at 1 will fail the service at the slightest network latency spike/packet loss
|
||||
- success threshold at 1 will bring the service up very quickly and may cause flapping in unstable network conditions
|
||||
|
||||
|
||||
```
|
||||
# heartbeat session log
|
||||
# failure threshold : 3
|
||||
# success threshold : 2
|
||||
|
||||
STATUS UP :
|
||||
t=1 probe : fail f=1 s=0
|
||||
t=2 probe : fail : f=2 s=0
|
||||
t=3 probe : ok f=0 s=1
|
||||
t=4 probe : fail f=1 s=0
|
||||
```
|
||||
|
||||
Scenario :
|
||||
|
||||
failure threshold = 2
|
||||
heartbeat timeout = 1s
|
||||
total before fencing = 2 * 1 = 2s
|
||||
|
||||
staleness detection timer = 2*total before fencing
|
||||
|
||||
can we do this simple multiplication that staleness detection timer (time the replica waits since the last primary heartbeat before promoting itself) is double the time the replica will take before starting the fencing process.
|
||||
|
||||
---
|
||||
|
||||
### Context
|
||||
We are designing a **Staleness-Based Failover Algorithm** for the Harmony Agent. The goal is to manage High Availability (HA) for stateful workloads (like PostgreSQL) across decentralized, variable-quality networks ("Micro Data Centers").
|
||||
|
||||
We are moving away from complex, synchronized clocks in favor of a **Counter-Based Liveness** approach (inspired by Kubernetes probes) for the Primary, and a **Time-Based Watchdog** for the Replica.
|
||||
|
||||
### 1. The Algorithm
|
||||
|
||||
#### The Primary (Self-Health & Fencing)
|
||||
The Primary validates its own "License to Operate" via a heartbeat loop.
|
||||
* **Loop:** Every `heartbeat_interval` (e.g., 1s), it attempts to write a heartbeat to NATS and check the local DB.
|
||||
* **Counters:** It maintains `consecutive_failures` and `consecutive_successes`.
|
||||
* **State Transition:**
|
||||
* **To UNHEALTHY:** If `consecutive_failures >= failure_threshold`, the Primary **Fences Self** (stops DB, releases locks).
|
||||
* **To HEALTHY:** If `consecutive_successes >= success_threshold`, the Primary **Un-fences** (starts DB, acquires locks).
|
||||
* **Reset Logic:** A single success resets the failure counter to 0, and vice versa.
|
||||
|
||||
#### The Replica (Staleness Detection)
|
||||
The Replica acts as a passive watchdog observing the NATS stream.
|
||||
* **Calculation:** It calculates a `MaxStaleness` timeout.
|
||||
$$ \text{MaxStaleness} = (\text{failure\_threshold} \times \text{heartbeat\_interval}) \times \text{SafetyMultiplier} $$
|
||||
*(We use a SafetyMultiplier of 2 to ensure the Primary has definitely fenced itself before we take over).*
|
||||
* **Action:** If `Time.now() - LastPrimaryHeartbeat > MaxStaleness`, the Replica assumes the Primary is dead and **Promotes Self**.
|
||||
|
||||
---
|
||||
|
||||
### 2. Configuration Trade-offs
|
||||
|
||||
The separation of `success` and `failure` thresholds allows us to tune the "personality" of the cluster.
|
||||
|
||||
#### Scenario A: The "Nervous" Cluster (High Sensitivity)
|
||||
* **Config:** `failure_threshold: 1`, `success_threshold: 1`
|
||||
* **Behavior:** Fails over immediately upon a single missed packet or slow disk write.
|
||||
* **Pros:** Maximum availability for perfect networks.
|
||||
* **Cons:** **High Flapping Risk.** In a residential network, a microwave turning on might cause a failover.
|
||||
|
||||
#### Scenario B: The "Tank" Cluster (High Stability)
|
||||
* **Config:** `failure_threshold: 10`, `success_threshold: 1`
|
||||
* **Behavior:** The node must be consistently broken for 10 seconds (assuming 1s interval) to give up.
|
||||
* **Pros:** Extremely stable on bad networks (e.g., Starlink, 4G). Ignores transient spikes.
|
||||
* **Cons:** **Slow Failover.** Users experience 10+ seconds of downtime before the Replica even *thinks* about taking over.
|
||||
|
||||
#### Scenario C: The "Sticky" Cluster (Hysteresis)
|
||||
* **Config:** `failure_threshold: 5`, `success_threshold: 5`
|
||||
* **Behavior:** Hard to kill, hard to bring back.
|
||||
* **Pros:** Prevents "Yo-Yo" effects. If a node fails, it must prove it is *really* stable (5 clean checks in a row) before re-joining the cluster.
|
||||
|
||||
---
|
||||
|
||||
### 3. Failure Modes & Behavior Analysis
|
||||
|
||||
Here is how the algorithm handles specific edge cases:
|
||||
|
||||
#### Case 1: Immediate Outage (Power Cut / Kernel Panic)
|
||||
* **Event:** Primary vanishes instantly. No more writes to NATS.
|
||||
* **Primary:** Does nothing (it's dead).
|
||||
* **Replica:** Sees the `LastPrimaryHeartbeat` timestamp age. Once it crosses `MaxStaleness`, it promotes itself.
|
||||
* **Outcome:** Clean failover after the timeout duration.
|
||||
|
||||
#### Case 2: Network Instability (Packet Loss / Jitter)
|
||||
* **Event:** The Primary fails to write to NATS for 2 cycles due to Wi-Fi interference, then succeeds on the 3rd.
|
||||
* **Config:** `failure_threshold: 5`.
|
||||
* **Primary:**
|
||||
* $t=1$: Fail (Counter=1)
|
||||
* $t=2$: Fail (Counter=2)
|
||||
* $t=3$: Success (Counter resets to 0). **State remains HEALTHY.**
|
||||
* **Replica:** Sees a gap in heartbeats but the timestamp never exceeds `MaxStaleness`.
|
||||
* **Outcome:** No downtime, no failover. The system correctly identified this as noise, not failure.
|
||||
|
||||
#### Case 3: High Latency (The "Slow Death")
|
||||
* **Event:** Primary is under heavy load; heartbeats take 1.5s to complete (interval is 1s).
|
||||
* **Primary:** The `timeout` on the heartbeat logic triggers. `consecutive_failures` rises. Eventually, it hits `failure_threshold` and fences itself to prevent data corruption.
|
||||
* **Replica:** Sees the heartbeats stop (or arrive too late). The timestamp ages out.
|
||||
* **Outcome:** Primary fences self -> Replica waits for safety buffer -> Replica promotes. **Split-brain is avoided** because the Primary killed itself *before* the Replica acted (due to the SafetyMultiplier).
|
||||
|
||||
#### Case 4: Replica Network Partition
|
||||
* **Event:** Replica loses internet connection; Primary is fine.
|
||||
* **Replica:** Sees `LastPrimaryHeartbeat` age out (because it can't reach NATS). It *wants* to promote itself.
|
||||
* **Constraint:** To promote, the Replica must write to NATS. Since it is partitioned, the NATS write fails.
|
||||
* **Outcome:** The Replica remains in Standby (or fails to promote). The Primary continues serving traffic. **Cluster integrity is preserved.**
|
||||
|
||||
|
||||
----
|
||||
|
||||
|
||||
### Context & Use Case
|
||||
We are implementing a High Availability (HA) Failover Strategy for decentralized "Micro Data Centers." The core challenge is managing stateful workloads (PostgreSQL) over unreliable networks.
|
||||
|
||||
We solve this using a **Local Fencing First** approach, backed by **NATS JetStream Strict Ordering** for the final promotion authority.
|
||||
|
||||
In CAP theorem terms, we are developing a CP system, intentionally sacrificing availability. In practical terms, we expect an average of two primary outages per year, with a failover delay of around 2 minutes. This translates to an uptime of over five nines. To be precise, 2 outages * 2 minutes = 4 minutes per year = 99.99924% uptime.
|
||||
|
||||
### The Algorithm: Local Fencing & Remote Promotion
|
||||
|
||||
The safety (data consistency) of the system relies on the time gap between the **Primary giving up (Fencing)** and the **Replica taking over (Promotion)**.
|
||||
|
||||
To avoid clock skew issues between agents and datastore (nats), all timestamps comparisons will be done using jetstream metadata. I.E. a harmony agent will never use `Instant::now()` to get a timestamp, it will use `my_last_heartbeat.metadata.timestamp` (conceptually).
|
||||
|
||||
#### 1. Configuration
|
||||
* `heartbeat_timeout` (e.g., 1s): Max time allowed for a NATS write/DB check.
|
||||
* `failure_threshold` (e.g., 2): Consecutive failures before self-fencing.
|
||||
* `failover_timeout` (e.g., 5s): Time since last NATS update of Primary heartbeat before Replica promotes.
|
||||
* This timeout must be carefully configured to allow enough time for the primary to fence itself (after `heartbeat_timeout * failure_threshold`) BEFORE the replica gets promoted to avoid a split brain with two primaries.
|
||||
* Implementing this will rely on the actual deployment configuration. For example, a CNPG based PostgreSQL cluster might require a longer gap (such as 30s) than other technologies.
|
||||
* Expires when `replica_heartbeat.metadata.timestamp - primary_heartbeat.metadata.timestamp > failover_timeout`
|
||||
|
||||
#### 2. The Primary (Self-Preservation)
|
||||
|
||||
The Primary is aggressive about killing itself.
|
||||
|
||||
* It attempts a heartbeat.
|
||||
* If the network latency > `heartbeat_timeout`, the attempt is **cancelled locally** because the heartbeat did not make it back in time.
|
||||
* This counts as a failure and increments the `consecutive_failures` counter.
|
||||
* If `consecutive_failures` hit the threshold, **FENCING occurs immediately**. The database is stopped.
|
||||
|
||||
This means that the Primary will fence itself after `heartbeat_timeout * failure_threshold`.
|
||||
|
||||
#### 3. The Replica (The Watchdog)
|
||||
|
||||
The Replica is patient.
|
||||
|
||||
* It watches the NATS stream to measure if `replica_heartbeat.metadata.timestamp - primary_heartbeat.metadata.timestamp > failover_timeout`
|
||||
* It only attempts promotion if the `failover_timeout` (5s) has passed.
|
||||
* **Crucial:** Careful configuration of the failover_timeout is required. This is the only way to avoid a split brain in case of a network partition where the Primary cannot write its heartbeats in time anymore.
|
||||
* In short, `failover_timeout` should be tuned to be `heartbeat_timeout * failure_threshold + safety_margin`. This `safety_margin` will vary by use case. For example, a CNPG cluster may need 30 seconds to demote a Primary to Replica when fencing is triggered, so `safety_margin` should be at least 30s in that setup.
|
||||
|
||||
Since we forcibly fail timeouts after `heartbeat_timeout`, we are guaranteed that the primary will have **started** the fencing process after `heartbeat_timeout * failure_threshold`.
|
||||
|
||||
But, in a network split scenario where the failed primary is still accessible by clients but cannot write its heartbeat successfully, there is no way to know if the demotion has actually **completed**.
|
||||
|
||||
For example, in a CNPG cluster, the failed Primary agent will attempt to change the CNPG cluster state to read-only. But if anything fails after that attempt (permission error, k8s api failure, CNPG bug, etc) it is possible that the PostgreSQL instance keeps accepting writes.
|
||||
|
||||
While this is not a theoretical failure of the agent's algorithm, this is a practical failure where data corruption occurs.
|
||||
|
||||
This can be fixed by detecting the demotion failure and escalating the fencing procedure aggressiveness. Harmony being an infrastructure orchestrator, it can easily exert radical measures if given the proper credentials, such as forcibly powering off a server, disconnecting its network in the switch configuration, forcibly kill a pod/container/process, etc.
|
||||
|
||||
However, these details are out of scope of this algorithm, as they simply fall under the "fencing procedure".
|
||||
|
||||
The implementation of the fencing procedure itself is not relevant. This algorithm's responsibility stops at calling the fencing procedure in the appropriate situation.
|
||||
|
||||
#### 4. The Demotion Handshake (Return to Normalcy)
|
||||
|
||||
When the original Primary recovers:
|
||||
|
||||
1. It becomes healthy locally but sees `current_primary = Replica`. It waits.
|
||||
2. The Replica (current leader) detects the Original Primary is back (via NATS heartbeats).
|
||||
3. Replica performs a **Clean Demotion**:
|
||||
* Stops DB.
|
||||
* Writes `current_primary = None` to NATS.
|
||||
4. Original Primary sees `current_primary = None` and can launch the promotion procedure.
|
||||
|
||||
Depending on the implementation, the promotion procedure may require a transition phase. Typically, for a PostgreSQL use case the promoting primary will make sure it has caught up on WAL replication before starting to accept writes.
|
||||
|
||||
---
|
||||
|
||||
### Failure Modes & Behavior Analysis
|
||||
|
||||
#### Case 1: Immediate Outage (Power Cut)
|
||||
|
||||
* **Primary:** Dies instantly. Fencing is implicit (machine is off).
|
||||
* **Replica:** Waits for `failover_timeout` (5s). Sees staleness. Promotes self.
|
||||
* **Outcome:** Clean failover after 5s.
|
||||
|
||||
// TODO detail what happens when the primary comes back up. We will likely have to tie PostgreSQL's lifecycle (liveness/readiness probes) with the agent to ensure it does not come back up as primary.
|
||||
|
||||
#### Case 2: High Network Latency on the Primary (The "Split Brain" Trap)
|
||||
|
||||
* **Scenario:** Network latency spikes to 5s on the Primary, still below `heartbeat_timeout` on the Replica.
|
||||
* **T=0 to T=2 (Primary):** Tries to write. Latency (5s) > Timeout (1s). Fails twice.
|
||||
* **T=2 (Primary):** `consecutive_failures` = 2. **Primary Fences Self.** (Service is DOWN).
|
||||
* **T=2 to T=5 (Cluster):** **Read-Only Phase.** No Primary exists.
|
||||
* **T=5 (Replica):** `failover_timeout` reached. Replica promotes self.
|
||||
* **Outcome:** Safe failover. The "Read-Only Gap" (T=2 to T=5) ensures no Split Brain occurred.
|
||||
|
||||
#### Case 3: Replica Network Lag (False Positive)
|
||||
|
||||
* **Scenario:** Replica has high latency, greater than `failover_timeout`; Primary is fine.
|
||||
* **Replica:** Thinks Primary is dead. Tries to promote by setting `cluster_state.current_primary = replica_id`.
|
||||
* **NATS:** Rejects the write because the Primary is still updating the sequence numbers successfully.
|
||||
* **Outcome:** Promotion denied. Primary stays leader.
|
||||
|
||||
#### Case 4: Network Instability (Flapping)
|
||||
|
||||
* **Scenario:** Intermittent packet loss.
|
||||
* **Primary:** Fails 1 heartbeat, succeeds the next. `consecutive_failures` resets.
|
||||
* **Replica:** Sees a slight delay in updates, but never reaches `failover_timeout`.
|
||||
* **Outcome:** No Fencing, No Promotion. System rides out the noise.
|
||||
|
||||
## Contextual notes
|
||||
|
||||
* Clock skew : Tokio relies on monotonic clocks. This means that `tokio::time::sleep(...)` will not be affected by system clock corrections (such as NTP). But monotonic clocks are known to jump forward in some cases such as VM live migrations. This could mean a false timeout of a single heartbeat. If `failure_threshold = 1`, this can mean a false negative on the nodes' health, and a potentially useless demotion.
|
||||
107
adr/017-3-revised-staleness-inspired-by-kubernetes.md
Normal file
107
adr/017-3-revised-staleness-inspired-by-kubernetes.md
Normal file
@@ -0,0 +1,107 @@
|
||||
### Context & Use Case
|
||||
We are implementing a High Availability (HA) Failover Strategy for decentralized "Micro Data Centers." The core challenge is managing stateful workloads (PostgreSQL) over unreliable networks.
|
||||
|
||||
We solve this using a **Local Fencing First** approach, backed by **NATS JetStream Strict Ordering** for the final promotion authority.
|
||||
|
||||
In CAP theorem terms, we are developing a CP system, intentionally sacrificing availability. In practical terms, we expect an average of two primary outages per year, with a failover delay of around 2 minutes. This translates to an uptime of over five nines. To be precise, 2 outages * 2 minutes = 4 minutes per year = 99.99924% uptime.
|
||||
|
||||
### The Algorithm: Local Fencing & Remote Promotion
|
||||
|
||||
The safety (data consistency) of the system relies on the time gap between the **Primary giving up (Fencing)** and the **Replica taking over (Promotion)**.
|
||||
|
||||
To avoid clock skew issues between agents and datastore (nats), all timestamps comparisons will be done using jetstream metadata. I.E. a harmony agent will never use `Instant::now()` to get a timestamp, it will use `my_last_heartbeat.metadata.timestamp` (conceptually).
|
||||
|
||||
#### 1. Configuration
|
||||
* `heartbeat_timeout` (e.g., 1s): Max time allowed for a NATS write/DB check.
|
||||
* `failure_threshold` (e.g., 2): Consecutive failures before self-fencing.
|
||||
* `failover_timeout` (e.g., 5s): Time since last NATS update of Primary heartbeat before Replica promotes.
|
||||
* This timeout must be carefully configured to allow enough time for the primary to fence itself (after `heartbeat_timeout * failure_threshold`) BEFORE the replica gets promoted to avoid a split brain with two primaries.
|
||||
* Implementing this will rely on the actual deployment configuration. For example, a CNPG based PostgreSQL cluster might require a longer gap (such as 30s) than other technologies.
|
||||
* Expires when `replica_heartbeat.metadata.timestamp - primary_heartbeat.metadata.timestamp > failover_timeout`
|
||||
|
||||
#### 2. The Primary (Self-Preservation)
|
||||
|
||||
The Primary is aggressive about killing itself.
|
||||
|
||||
* It attempts a heartbeat.
|
||||
* If the network latency > `heartbeat_timeout`, the attempt is **cancelled locally** because the heartbeat did not make it back in time.
|
||||
* This counts as a failure and increments the `consecutive_failures` counter.
|
||||
* If `consecutive_failures` hit the threshold, **FENCING occurs immediately**. The database is stopped.
|
||||
|
||||
This means that the Primary will fence itself after `heartbeat_timeout * failure_threshold`.
|
||||
|
||||
#### 3. The Replica (The Watchdog)
|
||||
|
||||
The Replica is patient.
|
||||
|
||||
* It watches the NATS stream to measure if `replica_heartbeat.metadata.timestamp - primary_heartbeat.metadata.timestamp > failover_timeout`
|
||||
* It only attempts promotion if the `failover_timeout` (5s) has passed.
|
||||
* **Crucial:** Careful configuration of the failover_timeout is required. This is the only way to avoid a split brain in case of a network partition where the Primary cannot write its heartbeats in time anymore.
|
||||
* In short, `failover_timeout` should be tuned to be `heartbeat_timeout * failure_threshold + safety_margin`. This `safety_margin` will vary by use case. For example, a CNPG cluster may need 30 seconds to demote a Primary to Replica when fencing is triggered, so `safety_margin` should be at least 30s in that setup.
|
||||
|
||||
Since we forcibly fail timeouts after `heartbeat_timeout`, we are guaranteed that the primary will have **started** the fencing process after `heartbeat_timeout * failure_threshold`.
|
||||
|
||||
But, in a network split scenario where the failed primary is still accessible by clients but cannot write its heartbeat successfully, there is no way to know if the demotion has actually **completed**.
|
||||
|
||||
For example, in a CNPG cluster, the failed Primary agent will attempt to change the CNPG cluster state to read-only. But if anything fails after that attempt (permission error, k8s api failure, CNPG bug, etc) it is possible that the PostgreSQL instance keeps accepting writes.
|
||||
|
||||
While this is not a theoretical failure of the agent's algorithm, this is a practical failure where data corruption occurs.
|
||||
|
||||
This can be fixed by detecting the demotion failure and escalating the fencing procedure aggressiveness. Harmony being an infrastructure orchestrator, it can easily exert radical measures if given the proper credentials, such as forcibly powering off a server, disconnecting its network in the switch configuration, forcibly kill a pod/container/process, etc.
|
||||
|
||||
However, these details are out of scope of this algorithm, as they simply fall under the "fencing procedure".
|
||||
|
||||
The implementation of the fencing procedure itself is not relevant. This algorithm's responsibility stops at calling the fencing procedure in the appropriate situation.
|
||||
|
||||
#### 4. The Demotion Handshake (Return to Normalcy)
|
||||
|
||||
When the original Primary recovers:
|
||||
|
||||
1. It becomes healthy locally but sees `current_primary = Replica`. It waits.
|
||||
2. The Replica (current leader) detects the Original Primary is back (via NATS heartbeats).
|
||||
3. Replica performs a **Clean Demotion**:
|
||||
* Stops DB.
|
||||
* Writes `current_primary = None` to NATS.
|
||||
4. Original Primary sees `current_primary = None` and can launch the promotion procedure.
|
||||
|
||||
Depending on the implementation, the promotion procedure may require a transition phase. Typically, for a PostgreSQL use case the promoting primary will make sure it has caught up on WAL replication before starting to accept writes.
|
||||
|
||||
---
|
||||
|
||||
### Failure Modes & Behavior Analysis
|
||||
|
||||
#### Case 1: Immediate Outage (Power Cut)
|
||||
|
||||
* **Primary:** Dies instantly. Fencing is implicit (machine is off).
|
||||
* **Replica:** Waits for `failover_timeout` (5s). Sees staleness. Promotes self.
|
||||
* **Outcome:** Clean failover after 5s.
|
||||
|
||||
// TODO detail what happens when the primary comes back up. We will likely have to tie PostgreSQL's lifecycle (liveness/readiness probes) with the agent to ensure it does not come back up as primary.
|
||||
|
||||
#### Case 2: High Network Latency on the Primary (The "Split Brain" Trap)
|
||||
|
||||
* **Scenario:** Network latency spikes to 5s on the Primary, still below `heartbeat_timeout` on the Replica.
|
||||
* **T=0 to T=2 (Primary):** Tries to write. Latency (5s) > Timeout (1s). Fails twice.
|
||||
* **T=2 (Primary):** `consecutive_failures` = 2. **Primary Fences Self.** (Service is DOWN).
|
||||
* **T=2 to T=5 (Cluster):** **Read-Only Phase.** No Primary exists.
|
||||
* **T=5 (Replica):** `failover_timeout` reached. Replica promotes self.
|
||||
* **Outcome:** Safe failover. The "Read-Only Gap" (T=2 to T=5) ensures no Split Brain occurred.
|
||||
|
||||
#### Case 3: Replica Network Lag (False Positive)
|
||||
|
||||
* **Scenario:** Replica has high latency, greater than `failover_timeout`; Primary is fine.
|
||||
* **Replica:** Thinks Primary is dead. Tries to promote by setting `cluster_state.current_primary = replica_id`.
|
||||
* **NATS:** Rejects the write because the Primary is still updating the sequence numbers successfully.
|
||||
* **Outcome:** Promotion denied. Primary stays leader.
|
||||
|
||||
#### Case 4: Network Instability (Flapping)
|
||||
|
||||
* **Scenario:** Intermittent packet loss.
|
||||
* **Primary:** Fails 1 heartbeat, succeeds the next. `consecutive_failures` resets.
|
||||
* **Replica:** Sees a slight delay in updates, but never reaches `failover_timeout`.
|
||||
* **Outcome:** No Fencing, No Promotion. System rides out the noise.
|
||||
|
||||
## Contextual notes
|
||||
|
||||
* Clock skew : Tokio relies on monotonic clocks. This means that `tokio::time::sleep(...)` will not be affected by system clock corrections (such as NTP). But monotonic clocks are known to jump forward in some cases such as VM live migrations. This could mean a false timeout of a single heartbeat. If `failure_threshold = 1`, this can mean a false negative on the nodes' health, and a potentially useless demotion.
|
||||
* `heartbeat_timeout == heartbeat_interval` : We intentionally do not provide two separate settings for the timeout before considering a heartbeat failed and the interval between heartbeats. It could make sense in some configurations where low network latency is required to have a small `heartbeat_timeout = 50ms` and larger `hartbeat_interval == 2s`, but we do not have a practical use case for it yet. And having timeout larger than interval does not make sense in any situation we can think of at the moment. So we decided to have a single value for both, which makes the algorithm easier to reason about and implement.
|
||||
95
adr/017-staleness-detection-for-failover.md
Normal file
95
adr/017-staleness-detection-for-failover.md
Normal file
@@ -0,0 +1,95 @@
|
||||
# Architecture Decision Record: Staleness-Based Failover Mechanism & Observability
|
||||
|
||||
**Status:** Proposed
|
||||
**Date:** 2026-01-09
|
||||
**Precedes:** [016-Harmony-Agent-And-Global-Mesh-For-Decentralized-Workload-Management.md](https://git.nationtech.io/NationTech/harmony/raw/branch/master/adr/016-Harmony-Agent-And-Global-Mesh-For-Decentralized-Workload-Management.md)
|
||||
|
||||
## Context
|
||||
|
||||
In ADR 016, we established the **Harmony Agent** and the **Global Orchestration Mesh** (powered by NATS JetStream) as the foundation for our decentralized infrastructure. We defined the high-level need for a `FailoverStrategy` that can support both financial consistency (CP) and AI availability (AP).
|
||||
|
||||
However, a specific implementation challenge remains: **How do we reliably detect node failure without losing the ability to debug the event later?**
|
||||
|
||||
Standard distributed systems often use "Key Expiration" (TTL) for heartbeats. If a key disappears, the node is presumed dead. While simple, this approach is catastrophic for post-mortem analysis. When the key expires, the evidence of *when* and *how* the failure occurred evaporates.
|
||||
|
||||
For NationTech’s vision of **Humane Computing**—where micro datacenters might be heating a family home or running a local business—reliability and diagnosability are paramount. If a cluster fails over, we owe it to the user to provide a clear, historical log of exactly what happened. We cannot build a "wonderful future for computers" on ephemeral, untraceable errors.
|
||||
|
||||
## Decision
|
||||
|
||||
We will implement a **Staleness Detection** mechanism rather than a Key Expiration mechanism. We will leverage NATS JetStream Key-Value (KV) stores with **History Enabled** to create an immutable audit trail of cluster health.
|
||||
|
||||
### 1. The "Black Box" Flight Recorder (NATS Configuration)
|
||||
We will utilize a persistent NATS KV bucket named `harmony_failover`.
|
||||
* **Storage:** File (Persistent).
|
||||
* **History:** Set to `64` (or higher). This allows us to query the last 64 heartbeat entries to visualize the exact degradation of the primary node before failure.
|
||||
* **TTL:** None. Data never disappears; it only becomes "stale."
|
||||
|
||||
### 2. Data Structures
|
||||
We will define two primary schemas to manage the state.
|
||||
|
||||
|
||||
**A. The Rules of Engagement (`cluster_config`)**
|
||||
This persistent key defines the behavior of the mesh. It allows us to tune failover sensitivity dynamically without redeploying the Agent binary.
|
||||
|
||||
```json
|
||||
{
|
||||
"primary_site_id": "site-a-basement",
|
||||
"replica_site_id": "site-b-cloud",
|
||||
"failover_timeout_ms": 5000, // Time before Replica takes over
|
||||
"heartbeat_interval_ms": 1000 // Frequency of Primary updates
|
||||
}
|
||||
```
|
||||
|
||||
> **Note :** The location for this configuration data structure is TBD. See https://git.nationtech.io/NationTech/harmony/issues/206
|
||||
|
||||
**B. The Heartbeat (`primary_heartbeat`)**
|
||||
The Primary writes this; the Replica watches it.
|
||||
|
||||
```json
|
||||
{
|
||||
"site_id": "site-a-basement",
|
||||
"status": "HEALTHY",
|
||||
"counter": 10452,
|
||||
"timestamp": 1704661549000
|
||||
}
|
||||
```
|
||||
|
||||
### 3. The Failover Algorithm
|
||||
|
||||
**The Primary (Site A) Logic:**
|
||||
The Primary's ability to write to the mesh is its "License to Operate."
|
||||
1. **Write Loop:** Attempts to write `primary_heartbeat` every `heartbeat_interval_ms`.
|
||||
2. **Self-Preservation (Fencing):** If the write fails (NATS Ack timeout or NATS unreachable), the Primary **immediately self-demotes**. It assumes it is network-isolated. This prevents Split Brain scenarios where a partitioned Primary continues to accept writes while the Replica promotes itself.
|
||||
|
||||
**The Replica (Site B) Logic:**
|
||||
The Replica acts as the watchdog.
|
||||
1. **Watch:** Subscribes to updates on `primary_heartbeat`.
|
||||
2. **Staleness Check:** Maintains a local timer. Every time a heartbeat arrives, the timer resets.
|
||||
3. **Promotion:** If the timer exceeds `failover_timeout_ms`, the Replica declares the Primary dead and promotes itself to Leader.
|
||||
4. **Yielding:** If the Replica is Leader, but suddenly receives a valid, new heartbeat from the configured `primary_site_id` (indicating the Primary has recovered), the Replica will voluntarily **demote** itself to restore the preferred topology.
|
||||
|
||||
## Rationale
|
||||
|
||||
**Observability as a First-Class Citizen**
|
||||
By keeping the last 64 heartbeats, we can run `nats kv history` to see the exact timeline. Did the Primary stop suddenly (crash)? or did the heartbeats become erratic and slow before stopping (network congestion)? This data is critical for optimizing the "Micro Data Centers" described in our vision, where internet connections in residential areas may vary in quality.
|
||||
|
||||
**Energy Efficiency & Resource Optimization**
|
||||
NationTech aims to "maximize the value of our energy." A "flapping" cluster (constantly failing over and back) wastes immense energy in data re-synchronization and startup costs. By making the `failover_timeout_ms` configurable via `cluster_config`, we can tune a cluster heating a greenhouse to be less sensitive (slower failover is fine) compared to a cluster running a payment gateway.
|
||||
|
||||
**Decentralized Trust**
|
||||
This architecture relies on NATS as the consensus engine. If the Primary is part of the NATS majority, it lives. If it isn't, it dies. This removes ambiguity and allows us to scale to thousands of independent sites without a central "God mode" controller managing every single failover.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive**
|
||||
* **Auditability:** Every failover event leaves a permanent trace in the KV history.
|
||||
* **Safety:** The "Write Ack" check on the Primary provides a strong guarantee against Split Brain in `AbsoluteConsistency` mode.
|
||||
* **Dynamic Tuning:** We can adjust timeouts for specific environments (e.g., high-latency satellite links) by updating a JSON key, requiring no downtime.
|
||||
|
||||
**Negative**
|
||||
* **Storage Overhead:** Keeping history requires marginally more disk space on the NATS servers, though for 64 small JSON payloads, this is negligible.
|
||||
* **Clock Skew:** While we rely on NATS server-side timestamps for ordering, extreme clock skew on the client side could confuse the debug logs (though not the failover logic itself).
|
||||
|
||||
## Alignment with Vision
|
||||
This architecture supports the NationTech goal of a **"Beautifully Integrated Design."** It takes the complex, high-stakes problem of distributed consensus and wraps it in a mechanism that is robust enough for enterprise banking yet flexible enough to manage a basement server heating a swimming pool. It bridges the gap between the reliability of Web2 clouds and the decentralized nature of Web3 infrastructure.
|
||||
|
||||
229
docs/coding-guide.md
Normal file
229
docs/coding-guide.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# Harmony Coding Guide
|
||||
|
||||
Harmony is an infrastructure automation framework. It is **code-first and code-only**: operators write Rust programs to declare and drive infrastructure, rather than YAML files or DSL configs. Good code here means a good operator experience.
|
||||
|
||||
### Concrete context
|
||||
|
||||
We use here the context of the KVM module to explain the coding style. This will make it very easy to understand and should translate quite well to other modules/contexts managed by Harmony like OPNSense and Kubernetes.
|
||||
|
||||
## Core Philosophy
|
||||
|
||||
### High-level functions over raw primitives
|
||||
|
||||
Callers should not need to know about underlying protocols, XML schemas, or API quirks. A function that deploys a VM should accept meaningful parameters like CPU count, memory, and network name — not XML strings.
|
||||
|
||||
```rust
|
||||
// Bad: caller constructs XML and passes it to a thin wrapper
|
||||
let xml = format!(r#"<domain type='kvm'>...</domain>"#, name, memory_kb, ...);
|
||||
executor.create_vm(&xml).await?;
|
||||
|
||||
// Good: caller describes intent, the module handles representation
|
||||
executor.define_vm(&VmConfig::builder("my-vm")
|
||||
.cpu(4)
|
||||
.memory_gb(8)
|
||||
.disk(DiskConfig::new(50))
|
||||
.network(NetworkRef::named("mylan"))
|
||||
.boot_order([BootDevice::Network, BootDevice::Disk])
|
||||
.build())
|
||||
.await?;
|
||||
```
|
||||
|
||||
The module owns the XML, the virsh invocations, the API calls — not the caller.
|
||||
|
||||
### Use the right abstraction layer
|
||||
|
||||
Prefer native library bindings over shelling out to CLI tools. The `virt` crate provides direct libvirt bindings and should be used instead of spawning `virsh` subprocesses.
|
||||
|
||||
- CLI subprocess calls are fragile: stdout/stderr parsing, exit codes, quoting, PATH differences
|
||||
- Native bindings give typed errors, no temp files, no shell escaping
|
||||
- `virt::connect::Connect` opens a connection; `virt::domain::Domain` manages VMs; `virt::network::Network` manages virtual networks
|
||||
|
||||
### Keep functions small and well-named
|
||||
|
||||
Each function should do one thing. If a function is doing two conceptually separate things, split it. Function names should read like plain English: `ensure_network_active`, `define_vm`, `vm_is_running`.
|
||||
|
||||
### Prefer short modules over large files
|
||||
|
||||
Group related types and functions by concept. A module that handles one resource (e.g., network, domain, storage) is better than a single file for everything.
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Use `thiserror` for all error types
|
||||
|
||||
Define error types with `thiserror::Error`. This removes the boilerplate of implementing `Display` and `std::error::Error` by hand, keeps error messages close to their variants, and makes types easy to extend.
|
||||
|
||||
```rust
|
||||
// Bad: hand-rolled Display + std::error::Error
|
||||
#[derive(Debug)]
|
||||
pub enum KVMError {
|
||||
ConnectionError(String),
|
||||
VMNotFound(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for KVMError { ... }
|
||||
impl std::error::Error for KVMError {}
|
||||
|
||||
// Good: derive Display via thiserror
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum KVMError {
|
||||
#[error("connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
#[error("VM not found: {name}")]
|
||||
VmNotFound { name: String },
|
||||
}
|
||||
```
|
||||
|
||||
### Make bubbling errors easy with `?` and `From`
|
||||
|
||||
`?` works on any error type for which there is a `From` impl. Add `From` conversions from lower-level errors into your module's error type so callers can use `?` without boilerplate.
|
||||
|
||||
With `thiserror`, wrapping a foreign error is one line:
|
||||
|
||||
```rust
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum KVMError {
|
||||
#[error("libvirt error: {0}")]
|
||||
Libvirt(#[from] virt::error::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
```
|
||||
|
||||
This means a call that returns `virt::error::Error` can be `?`-propagated into a `Result<_, KVMError>` without any `.map_err(...)`.
|
||||
|
||||
### Typed errors over stringly-typed errors
|
||||
|
||||
Avoid `Box<dyn Error>` or `String` as error return types in library code. Callers need to distinguish errors programmatically — `KVMError::VmAlreadyExists` is actionable, `"VM already exists: foo"` as a `String` is not.
|
||||
|
||||
At binary entry points (e.g., `main`) it is acceptable to convert to `String` or `anyhow::Error` for display.
|
||||
|
||||
---
|
||||
|
||||
## Logging
|
||||
|
||||
### Use the `log` crate macros
|
||||
|
||||
All log output must go through the `log` crate. Never use `println!`, `eprintln!`, or `dbg!` in library code. This makes output compatible with any logging backend (env_logger, tracing, structured logging, etc.).
|
||||
|
||||
```rust
|
||||
// Bad
|
||||
println!("Creating VM: {}", name);
|
||||
|
||||
// Good
|
||||
use log::{info, debug, warn};
|
||||
info!("Creating VM: {name}");
|
||||
debug!("VM XML:\n{xml}");
|
||||
warn!("Network already active, skipping creation");
|
||||
```
|
||||
|
||||
Use the right level:
|
||||
|
||||
| Level | When to use |
|
||||
|---------|-------------|
|
||||
| `error` | Unrecoverable failures (before returning Err) |
|
||||
| `warn` | Recoverable issues, skipped steps |
|
||||
| `info` | High-level progress events visible in normal operation |
|
||||
| `debug` | Detailed operational info useful for debugging |
|
||||
| `trace` | Very granular, per-iteration or per-call data |
|
||||
|
||||
Log before significant operations and after unexpected conditions. Do not log inside tight loops at `info` level.
|
||||
|
||||
---
|
||||
|
||||
## Types and Builders
|
||||
|
||||
### Derive `Serialize` on all public domain types
|
||||
|
||||
All public structs and enums that represent configuration or state should derive `serde::Serialize`. Add `Deserialize` when round-trip serialization is needed.
|
||||
|
||||
### Builder pattern for complex configs
|
||||
|
||||
When a type has more than three fields or optional fields, provide a builder. The builder pattern allows named, incremental construction without positional arguments.
|
||||
|
||||
```rust
|
||||
let config = VmConfig::builder("bootstrap")
|
||||
.cpu(4)
|
||||
.memory_gb(8)
|
||||
.disk(DiskConfig::new(50).labeled("os"))
|
||||
.disk(DiskConfig::new(100).labeled("data"))
|
||||
.network(NetworkRef::named("harmonylan"))
|
||||
.boot_order([BootDevice::Network, BootDevice::Disk])
|
||||
.build();
|
||||
```
|
||||
|
||||
### Avoid `pub` fields on config structs
|
||||
|
||||
Expose data through methods or the builder, not raw field access. This preserves the ability to validate, rename, or change representation without breaking callers.
|
||||
|
||||
---
|
||||
|
||||
## Async
|
||||
|
||||
### Use `tokio` for all async runtime needs
|
||||
|
||||
All async code runs on tokio. Use `tokio::spawn`, `tokio::time`, etc. Use `#[async_trait]` for traits with async methods.
|
||||
|
||||
### No blocking in async context
|
||||
|
||||
Never call blocking I/O (file I/O, network, process spawn) directly in an async function. Use `tokio::fs`, `tokio::process`, or `tokio::task::spawn_blocking` as appropriate.
|
||||
|
||||
---
|
||||
|
||||
## Module Structure
|
||||
|
||||
### Follow the `Score` / `Interpret` pattern
|
||||
|
||||
Modules that represent deployable infrastructure should implement `Score<T: Topology>` and `Interpret<T>`:
|
||||
|
||||
- `Score` is the serializable, clonable configuration declaring *what* to deploy
|
||||
- `Interpret` does the actual work when `execute()` is called
|
||||
|
||||
```rust
|
||||
pub struct KvmScore {
|
||||
network: NetworkConfig,
|
||||
vms: Vec<VmConfig>,
|
||||
}
|
||||
|
||||
impl<T: Topology + KvmHost> Score<T> for KvmScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||
Box::new(KvmInterpret::new(self.clone()))
|
||||
}
|
||||
fn name(&self) -> String { "KvmScore".to_string() }
|
||||
}
|
||||
```
|
||||
|
||||
### Flatten the public API in `mod.rs`
|
||||
|
||||
Internal submodules are implementation detail. Re-export what callers need at the module root:
|
||||
|
||||
```rust
|
||||
// modules/kvm/mod.rs
|
||||
mod connection;
|
||||
mod domain;
|
||||
mod network;
|
||||
mod error;
|
||||
mod xml;
|
||||
|
||||
pub use connection::KvmConnection;
|
||||
pub use domain::{VmConfig, VmConfigBuilder, VmStatus, DiskConfig, BootDevice};
|
||||
pub use error::KvmError;
|
||||
pub use network::NetworkConfig;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Style
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/):
|
||||
|
||||
```
|
||||
feat(kvm): add network isolation support
|
||||
fix(kvm): correct memory unit conversion for libvirt
|
||||
refactor(kvm): replace virsh subprocess calls with virt crate bindings
|
||||
docs: add coding guide
|
||||
```
|
||||
|
||||
Keep pull requests small and single-purpose (under ~200 lines excluding generated code). Do not mix refactoring, bug fixes, and new features in one PR.
|
||||
158
docs/guides/kubernetes-ingress.md
Normal file
158
docs/guides/kubernetes-ingress.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# Ingress Resources in Harmony
|
||||
|
||||
Harmony generates standard Kubernetes `networking.k8s.io/v1` Ingress resources. This ensures your deployments are portable across any Kubernetes distribution (vanilla K8s, OKD/OpenShift, K3s, etc.) without requiring vendor-specific configurations.
|
||||
|
||||
By default, Harmony does **not** set `spec.ingressClassName`. This allows the cluster's default ingress controller to automatically claim the resource, which is the correct approach for most single-controller clusters.
|
||||
|
||||
---
|
||||
|
||||
## TLS Configurations
|
||||
|
||||
There are two portable TLS modes for Ingress resources. Use only these in your Harmony deployments.
|
||||
|
||||
### 1. Plain HTTP (No TLS)
|
||||
|
||||
Omit the `tls` block entirely. The Ingress serves traffic over plain HTTP. Use this for local development or when TLS is terminated elsewhere (e.g., by a service mesh or external load balancer).
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: my-app
|
||||
namespace: my-ns
|
||||
spec:
|
||||
rules:
|
||||
- host: app.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: my-app
|
||||
port:
|
||||
number: 8080
|
||||
```
|
||||
|
||||
### 2. HTTPS with a Named TLS Secret
|
||||
|
||||
Provide a `tls` block with both `hosts` and a `secretName`. The ingress controller will use that Secret for TLS termination. The Secret must be a `kubernetes.io/tls` type in the same namespace as the Ingress.
|
||||
|
||||
There are two ways to provide this Secret.
|
||||
|
||||
#### Option A: Manual Secret
|
||||
|
||||
Create the TLS Secret yourself before deploying the Ingress. This is suitable when certificates are issued outside the cluster or managed by another system.
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: my-app
|
||||
namespace: my-ns
|
||||
spec:
|
||||
rules:
|
||||
- host: app.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: my-app
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- hosts:
|
||||
- app.example.com
|
||||
secretName: app-example-com-tls
|
||||
```
|
||||
|
||||
#### Option B: Automated via cert-manager (Recommended)
|
||||
|
||||
Add the `cert-manager.io/cluster-issuer` annotation to the Ingress. cert-manager will automatically perform the ACME challenge, generate the certificate, store it in the named Secret, and handle renewal. You do not create the Secret yourself.
|
||||
|
||||
```yaml
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: my-app
|
||||
namespace: my-ns
|
||||
annotations:
|
||||
cert-manager.io/cluster-issuer: letsencrypt-prod
|
||||
spec:
|
||||
rules:
|
||||
- host: app.example.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: my-app
|
||||
port:
|
||||
number: 8080
|
||||
tls:
|
||||
- hosts:
|
||||
- app.example.com
|
||||
secretName: app-example-com-tls
|
||||
```
|
||||
|
||||
If you use a namespace-scoped `Issuer` instead of a `ClusterIssuer`, replace the annotation with `cert-manager.io/issuer: <name>`.
|
||||
|
||||
---
|
||||
|
||||
## Do Not Use: TLS Without `secretName`
|
||||
|
||||
Avoid TLS entries that omit `secretName`:
|
||||
|
||||
```yaml
|
||||
# ⚠️ Non-portable — do not use
|
||||
tls:
|
||||
- hosts:
|
||||
- app.example.com
|
||||
```
|
||||
|
||||
Behavior for this pattern is **controller-specific and not portable**. On OKD/OpenShift, the ingress-to-route translation rejects it as incomplete. On other controllers, it may silently serve a self-signed fallback or fail in unpredictable ways. Harmony does not support this pattern.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites for cert-manager
|
||||
|
||||
To use automated certificates (Option B above):
|
||||
|
||||
1. **cert-manager** must be installed on the cluster.
|
||||
2. A `ClusterIssuer` or `Issuer` must exist. A typical Let's Encrypt production issuer:
|
||||
|
||||
```yaml
|
||||
apiVersion: cert-manager.io/v1
|
||||
kind: ClusterIssuer
|
||||
metadata:
|
||||
name: letsencrypt-prod
|
||||
spec:
|
||||
acme:
|
||||
server: https://acme-v02.api.letsencrypt.org/directory
|
||||
email: team@example.com
|
||||
privateKeySecretRef:
|
||||
name: letsencrypt-prod-account-key
|
||||
solvers:
|
||||
- http01:
|
||||
ingress: {}
|
||||
```
|
||||
|
||||
3. **DNS must already resolve** to the cluster's ingress endpoint before the Ingress is created. The HTTP01 challenge requires this routing to be active.
|
||||
|
||||
For wildcard certificates (e.g. `*.example.com`), HTTP01 cannot be used — configure a DNS01 solver with credentials for your DNS provider instead.
|
||||
|
||||
---
|
||||
|
||||
## OKD / OpenShift Notes
|
||||
|
||||
On OKD, standard Ingress resources are automatically translated into OpenShift `Route` objects. The default TLS termination mode is `edge`, which is correct for most HTTP applications. To control this explicitly, add:
|
||||
|
||||
```yaml
|
||||
annotations:
|
||||
route.openshift.io/termination: edge # or passthrough / reencrypt
|
||||
```
|
||||
|
||||
This annotation is ignored on non-OpenShift clusters and is safe to include unconditionally.
|
||||
16
docs/one_liners.md
Normal file
16
docs/one_liners.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# Handy one liners for infrastructure management
|
||||
|
||||
### Delete all evicted pods from a cluster
|
||||
|
||||
```sh
|
||||
kubectl get po -A | grep Evic | awk '{ print "-n " $1 " " $2 }' | xargs -L 1 kubectl delete po
|
||||
```
|
||||
> Pods are evicted when the node they are running on lacks the ressources to keep them going. The most common case is when ephemeral storage becomes too full because of something like a log file getting too big.
|
||||
>
|
||||
> It could also happen because of memory or cpu pressure due to unpredictable workloads.
|
||||
>
|
||||
> This means it is generally ok to delete them.
|
||||
>
|
||||
> However, in a perfectly configured deployment and cluster, pods should rarely, if ever, get evicted. For example, a log file getting too big should be reconfigured not to use too much space, or the deployment should be configured to reserve the correct amount of ephemeral storage space.
|
||||
>
|
||||
> Note that deleting evicted pods do not solve the underlying issue, make sure to understand why the pod was evicted in the first place and put the proper solution in place.
|
||||
15
examples/example_linux_vm/Cargo.toml
Normal file
15
examples/example_linux_vm/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "example_linux_vm"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "example_linux_vm"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
harmony = { path = "../../harmony" }
|
||||
tokio.workspace = true
|
||||
log.workspace = true
|
||||
env_logger.workspace = true
|
||||
43
examples/example_linux_vm/README.md
Normal file
43
examples/example_linux_vm/README.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# Example: Linux VM from ISO
|
||||
|
||||
This example deploys a simple Linux virtual machine from an ISO URL.
|
||||
|
||||
## What it creates
|
||||
|
||||
- One isolated virtual network (`linuxvm-net`, 192.168.101.0/24)
|
||||
- One Ubuntu Server VM with the ISO attached as a CD-ROM
|
||||
- The VM is configured to boot from the CD-ROM first, allowing installation
|
||||
- After installation, the VM can be rebooted to boot from disk
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A running KVM hypervisor (local or remote)
|
||||
- `HARMONY_KVM_URI` environment variable pointing to the hypervisor (defaults to `qemu:///system`)
|
||||
- `HARMONY_KVM_IMAGE_DIR` environment variable for storing VM images (defaults to harmony data dir)
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
cargo run -p example_linux_vm
|
||||
```
|
||||
|
||||
## After deployment
|
||||
|
||||
Once the VM is running, you can connect to its console:
|
||||
|
||||
```bash
|
||||
virsh -c qemu:///system console linux-vm
|
||||
```
|
||||
|
||||
To access the VM via SSH after installation, you'll need to configure a bridged network or port forwarding.
|
||||
|
||||
## Clean up
|
||||
|
||||
To remove the VM and network:
|
||||
|
||||
```bash
|
||||
virsh -c qemu:///system destroy linux-vm
|
||||
virsh -c qemu:///system undefine linux-vm
|
||||
virsh -c qemu:///system net-destroy linuxvm-net
|
||||
virsh -c qemu:///system net-undefine linuxvm-net
|
||||
```
|
||||
63
examples/example_linux_vm/src/main.rs
Normal file
63
examples/example_linux_vm/src/main.rs
Normal file
@@ -0,0 +1,63 @@
|
||||
use harmony::modules::kvm::config::init_executor;
|
||||
use harmony::modules::kvm::{BootDevice, NetworkConfig, NetworkRef, VmConfig};
|
||||
use log::info;
|
||||
|
||||
const NETWORK_NAME: &str = "linuxvm-net";
|
||||
const NETWORK_GATEWAY: &str = "192.168.101.1";
|
||||
const NETWORK_PREFIX: u8 = 24;
|
||||
|
||||
const UBUNTU_ISO_URL: &str =
|
||||
"https://releases.ubuntu.com/24.04/ubuntu-24.04.3-live-server-amd64.iso";
|
||||
|
||||
pub async fn deploy_linux_vm() -> Result<(), String> {
|
||||
let executor = init_executor().map_err(|e| format!("KVM initialization failed: {e}"))?;
|
||||
|
||||
let network = NetworkConfig::builder(NETWORK_NAME)
|
||||
.bridge("virbr101")
|
||||
.subnet(NETWORK_GATEWAY, NETWORK_PREFIX)
|
||||
.build();
|
||||
|
||||
info!("Ensuring network '{NETWORK_NAME}' ({NETWORK_GATEWAY}/{NETWORK_PREFIX}) exists");
|
||||
executor
|
||||
.ensure_network(network)
|
||||
.await
|
||||
.map_err(|e| format!("Network setup failed: {e}"))?;
|
||||
|
||||
let vm = linux_vm();
|
||||
info!("Defining Linux VM '{}'", vm.name);
|
||||
executor
|
||||
.ensure_vm(vm.clone())
|
||||
.await
|
||||
.map_err(|e| format!("Linux VM setup failed: {e}"))?;
|
||||
|
||||
info!("Starting VM '{}'", vm.name);
|
||||
executor
|
||||
.start_vm(&vm.name)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start VM: {e}"))?;
|
||||
|
||||
info!(
|
||||
"Linux VM '{}' is running. \
|
||||
Connect to the console using: virsh -c qemu:///system console {}",
|
||||
vm.name, vm.name
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn linux_vm() -> VmConfig {
|
||||
VmConfig::builder("linux-vm")
|
||||
.vcpus(2)
|
||||
.memory_gb(4)
|
||||
.disk(20)
|
||||
.network(NetworkRef::named(NETWORK_NAME))
|
||||
.cdrom(UBUNTU_ISO_URL)
|
||||
.boot_order([BootDevice::Cdrom, BootDevice::Disk])
|
||||
.build()
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), String> {
|
||||
env_logger::init();
|
||||
deploy_linux_vm().await
|
||||
}
|
||||
15
examples/kvm_okd_ha_cluster/Cargo.toml
Normal file
15
examples/kvm_okd_ha_cluster/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "example-kvm-okd-ha-cluster"
|
||||
version.workspace = true
|
||||
edition = "2024"
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "kvm_okd_ha_cluster"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
harmony = { path = "../../harmony" }
|
||||
tokio.workspace = true
|
||||
log.workspace = true
|
||||
env_logger.workspace = true
|
||||
100
examples/kvm_okd_ha_cluster/README.md
Normal file
100
examples/kvm_okd_ha_cluster/README.md
Normal file
@@ -0,0 +1,100 @@
|
||||
# OKD HA Cluster on KVM
|
||||
|
||||
Deploys a complete OKD high-availability cluster on a KVM hypervisor using
|
||||
Harmony's KVM module. All infrastructure is defined in Rust — no YAML, no
|
||||
shell scripts, no hand-crafted XML.
|
||||
|
||||
## What it creates
|
||||
|
||||
| Resource | Details |
|
||||
|-------------------|------------------------------------------|
|
||||
| Virtual network | `harmonylan` — 192.168.100.0/24, NAT |
|
||||
| OPNsense VM | 2 vCPU / 4 GiB RAM — gateway + PXE |
|
||||
| Control plane ×3 | 4 vCPU / 16 GiB RAM — `cp0` … `cp2` |
|
||||
| Worker ×3 | 8 vCPU / 32 GiB RAM — `worker0` … `worker2` |
|
||||
|
||||
## Architecture
|
||||
|
||||
All VMs share the same `harmonylan` virtual network. OPNsense sits on both
|
||||
that network and the host bridge, acting as the gateway and PXE server.
|
||||
|
||||
```
|
||||
Host network (bridge)
|
||||
│
|
||||
┌───────┴──────────┐
|
||||
│ OPNsense │ 192.168.100.1
|
||||
│ gateway + PXE │
|
||||
└───────┬──────────┘
|
||||
│
|
||||
│ harmonylan (192.168.100.0/24)
|
||||
├─────────────┬──────────────────┬──────────────────┐
|
||||
│ │ │ │
|
||||
┌───────┴──┐ ┌──────┴───┐ ┌──────────┴─┐ ┌──────────┴─┐
|
||||
│ cp0 │ │ cp1 │ │ cp2 │ │ worker0 │
|
||||
│ .10 │ │ .11 │ │ .12 │ │ .20 │
|
||||
└──────────┘ └──────────┘ └────────────┘ └──────┬─────┘
|
||||
│
|
||||
┌───────┴────┐
|
||||
│ worker1 │
|
||||
│ .21 │
|
||||
└───────┬────┘
|
||||
│
|
||||
┌───────┴────┐
|
||||
│ worker2 │
|
||||
│ .22 │
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
All nodes PXE boot from the network interface. OPNsense serves the OKD
|
||||
bootstrap images via TFTP/iPXE and handles DHCP for the whole subnet.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Linux host with KVM/QEMU and libvirt installed
|
||||
- `libvirt-dev` headers (for building the `virt` crate)
|
||||
- A `default` storage pool configured in libvirt
|
||||
- Sufficient disk space (~550 GiB for all VM images)
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cargo run --bin kvm_okd_ha_cluster
|
||||
```
|
||||
|
||||
Set `RUST_LOG=info` (or `debug`) to control verbosity.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Environment variable | Default | Description |
|
||||
|-------------------------|--------------------|-------------------------------------|
|
||||
| `HARMONY_KVM_URI` | `qemu:///system` | Libvirt connection URI |
|
||||
| `HARMONY_KVM_IMAGE_DIR` | harmony data dir | Directory for qcow2 disk images |
|
||||
|
||||
For a remote KVM host over SSH:
|
||||
|
||||
```bash
|
||||
export HARMONY_KVM_URI="qemu+ssh://user@myhost/system"
|
||||
```
|
||||
|
||||
## What happens after `cargo run`
|
||||
|
||||
The program defines all resources in libvirt but does not start any VMs.
|
||||
Next steps:
|
||||
|
||||
1. Start OPNsense: `virsh start opnsense-harmony`
|
||||
2. Connect to the OPNsense web UI at `https://192.168.100.1`
|
||||
3. Configure DHCP, TFTP, and the iPXE menu for OKD
|
||||
4. Start the control plane and worker nodes — they will PXE boot and begin
|
||||
the OKD installation automatically
|
||||
|
||||
## Cleanup
|
||||
|
||||
```bash
|
||||
for vm in opnsense-harmony cp0-harmony cp1-harmony cp2-harmony \
|
||||
worker0-harmony worker1-harmony worker2-harmony; do
|
||||
virsh destroy "$vm" 2>/dev/null || true
|
||||
virsh undefine "$vm" --remove-all-storage 2>/dev/null || true
|
||||
done
|
||||
virsh net-destroy harmonylan 2>/dev/null || true
|
||||
virsh net-undefine harmonylan 2>/dev/null || true
|
||||
```
|
||||
132
examples/kvm_okd_ha_cluster/src/lib.rs
Normal file
132
examples/kvm_okd_ha_cluster/src/lib.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
use harmony::modules::kvm::{
|
||||
BootDevice, NetworkConfig, NetworkRef, VmConfig, config::init_executor,
|
||||
};
|
||||
use log::info;
|
||||
|
||||
const NETWORK_NAME: &str = "harmonylan";
|
||||
const NETWORK_GATEWAY: &str = "192.168.100.1";
|
||||
const NETWORK_PREFIX: u8 = 24;
|
||||
|
||||
const OPNSENSE_IP: &str = "192.168.100.1";
|
||||
|
||||
/// Deploys a full OKD HA cluster on a local or remote KVM hypervisor.
|
||||
///
|
||||
/// # What it creates
|
||||
///
|
||||
/// - One isolated virtual network (`harmonylan`, 192.168.100.0/24)
|
||||
/// - One OPNsense VM acting as the cluster gateway and PXE server
|
||||
/// - Three OKD control-plane nodes
|
||||
/// - Three OKD worker nodes
|
||||
///
|
||||
/// All nodes are configured to PXE boot from the network so that OPNsense
|
||||
/// can drive unattended OKD installation via TFTP/iPXE.
|
||||
///
|
||||
/// # Configuration
|
||||
///
|
||||
/// | Environment variable | Default | Description |
|
||||
/// |---------------------------|-----------------------|-----------------------------------|
|
||||
/// | `HARMONY_KVM_URI` | `qemu:///system` | Libvirt connection URI |
|
||||
/// | `HARMONY_KVM_IMAGE_DIR` | harmony data dir | Directory for qcow2 disk images |
|
||||
pub async fn deploy_okd_ha_cluster() -> Result<(), String> {
|
||||
let executor = init_executor().map_err(|e| format!("KVM initialisation failed: {e}"))?;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Network
|
||||
// -------------------------------------------------------------------------
|
||||
let network = NetworkConfig::builder(NETWORK_NAME)
|
||||
.bridge("virbr100")
|
||||
.subnet(NETWORK_GATEWAY, NETWORK_PREFIX)
|
||||
.build();
|
||||
|
||||
info!("Ensuring network '{NETWORK_NAME}' ({NETWORK_GATEWAY}/{NETWORK_PREFIX}) exists");
|
||||
executor
|
||||
.ensure_network(network)
|
||||
.await
|
||||
.map_err(|e| format!("Network setup failed: {e}"))?;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// OPNsense gateway / PXE server
|
||||
// -------------------------------------------------------------------------
|
||||
let opnsense = opnsense_vm();
|
||||
info!("Defining OPNsense VM '{}'", opnsense.name);
|
||||
executor
|
||||
.ensure_vm(opnsense)
|
||||
.await
|
||||
.map_err(|e| format!("OPNsense VM setup failed: {e}"))?;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Control plane nodes
|
||||
// -------------------------------------------------------------------------
|
||||
for i in 0u8..3 {
|
||||
let vm = control_plane_vm(i);
|
||||
info!("Defining control plane VM '{}'", vm.name);
|
||||
executor
|
||||
.ensure_vm(vm)
|
||||
.await
|
||||
.map_err(|e| format!("Control plane VM setup failed: {e}"))?;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Worker nodes
|
||||
// -------------------------------------------------------------------------
|
||||
for i in 0u8..3 {
|
||||
let vm = worker_vm(i);
|
||||
info!("Defining worker VM '{}'", vm.name);
|
||||
executor
|
||||
.ensure_vm(vm)
|
||||
.await
|
||||
.map_err(|e| format!("Worker VM setup failed: {e}"))?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"OKD HA cluster infrastructure ready. \
|
||||
Connect OPNsense at https://{OPNSENSE_IP} to configure DHCP, TFTP, and PXE \
|
||||
before starting the nodes."
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// VM definitions
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
/// OPNsense firewall — gateway and PXE server for the cluster.
|
||||
///
|
||||
/// Connected to both the host bridge (WAN) and `harmonylan` (LAN). It manages
|
||||
/// DHCP, TFTP, and the PXE menu that drives OKD installation on all other VMs.
|
||||
fn opnsense_vm() -> VmConfig {
|
||||
VmConfig::builder("opnsense-harmony")
|
||||
.vcpus(2)
|
||||
.memory_gb(4)
|
||||
.disk(20) // OS disk: vda
|
||||
.network(NetworkRef::named(NETWORK_NAME))
|
||||
.boot_order([BootDevice::Cdrom, BootDevice::Disk])
|
||||
.build()
|
||||
}
|
||||
|
||||
/// One OKD control-plane node. Indexed 0..2 → `cp0-harmony` … `cp2-harmony`.
|
||||
///
|
||||
/// Boots from network so OPNsense can serve the OKD bootstrap image via PXE.
|
||||
fn control_plane_vm(index: u8) -> VmConfig {
|
||||
VmConfig::builder(format!("cp{index}-harmony"))
|
||||
.vcpus(4)
|
||||
.memory_gb(16)
|
||||
.disk(120) // OS + etcd: vda
|
||||
.network(NetworkRef::named(NETWORK_NAME))
|
||||
.boot_order([BootDevice::Network, BootDevice::Disk])
|
||||
.build()
|
||||
}
|
||||
|
||||
/// One OKD worker node. Indexed 0..2 → `worker0-harmony` … `worker2-harmony`.
|
||||
///
|
||||
/// Boots from network for automated OKD installation.
|
||||
fn worker_vm(index: u8) -> VmConfig {
|
||||
VmConfig::builder(format!("worker{index}-harmony"))
|
||||
.vcpus(8)
|
||||
.memory_gb(32)
|
||||
.disk(120) // OS: vda
|
||||
.disk(200) // Persistent storage (ODF/Rook): vdb
|
||||
.network(NetworkRef::named(NETWORK_NAME))
|
||||
.boot_order([BootDevice::Network, BootDevice::Disk])
|
||||
.build()
|
||||
}
|
||||
7
examples/kvm_okd_ha_cluster/src/main.rs
Normal file
7
examples/kvm_okd_ha_cluster/src/main.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
use example_kvm_okd_ha_cluster::deploy_okd_ha_cluster;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), String> {
|
||||
env_logger::init();
|
||||
deploy_okd_ha_cluster().await
|
||||
}
|
||||
14
examples/penpot/Cargo.toml
Normal file
14
examples/penpot/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "example-penpot"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
harmony = { path = "../../harmony" }
|
||||
harmony_cli = { path = "../../harmony_cli" }
|
||||
harmony_macros = { path = "../../harmony_macros" }
|
||||
harmony_types = { path = "../../harmony_types" }
|
||||
tokio.workspace = true
|
||||
url.workspace = true
|
||||
41
examples/penpot/src/main.rs
Normal file
41
examples/penpot/src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
|
||||
use harmony::{
|
||||
inventory::Inventory,
|
||||
modules::helm::chart::{HelmChartScore, HelmRepository, NonBlankString},
|
||||
topology::K8sAnywhereTopology,
|
||||
};
|
||||
use harmony_macros::hurl;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// let mut chart_values = HashMap::new();
|
||||
// chart_values.insert(
|
||||
// NonBlankString::from_str("persistence.assets.enabled").unwrap(),
|
||||
// "true".into(),
|
||||
// );
|
||||
// let penpot_chart = HelmChartScore {
|
||||
// namespace: Some(NonBlankString::from_str("penpot").unwrap()),
|
||||
// release_name: NonBlankString::from_str("penpot").unwrap(),
|
||||
// chart_name: NonBlankString::from_str("penpot/penpot").unwrap(),
|
||||
// chart_version: None,
|
||||
// values_overrides: Some(chart_values),
|
||||
// values_yaml: None,
|
||||
// create_namespace: true,
|
||||
// install_only: true,
|
||||
// repository: Some(HelmRepository::new(
|
||||
// "penpot".to_string(),
|
||||
// hurl!("http://helm.penpot.app"),
|
||||
// true,
|
||||
// )),
|
||||
// };
|
||||
//
|
||||
// harmony_cli::run(
|
||||
// Inventory::autoload(),
|
||||
// K8sAnywhereTopology::from_env(),
|
||||
// vec![Box::new(penpot_chart)],
|
||||
// None,
|
||||
// )
|
||||
// .await
|
||||
// .unwrap();
|
||||
}
|
||||
Binary file not shown.
@@ -78,11 +78,13 @@ harmony_inventory_agent = { path = "../harmony_inventory_agent" }
|
||||
harmony_secret_derive = { path = "../harmony_secret_derive" }
|
||||
harmony_secret = { path = "../harmony_secret" }
|
||||
askama.workspace = true
|
||||
sha2 = "0.10"
|
||||
sqlx.workspace = true
|
||||
inquire.workspace = true
|
||||
brocade = { path = "../brocade" }
|
||||
option-ext = "0.2.0"
|
||||
rand.workspace = true
|
||||
virt = "0.4.3"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions.workspace = true
|
||||
|
||||
54
harmony/src/modules/kvm/config.rs
Normal file
54
harmony/src/modules/kvm/config.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use log::{debug, info};
|
||||
|
||||
use crate::domain::config::HARMONY_DATA_DIR;
|
||||
|
||||
use super::error::KvmError;
|
||||
use super::executor::KvmExecutor;
|
||||
|
||||
const DEFAULT_IMAGE_DIR: &str = "/var/lib/libvirt/images";
|
||||
|
||||
/// Creates a [`KvmExecutor`] from environment variables.
|
||||
///
|
||||
/// | Variable | Description |
|
||||
/// |---------------------------|----------------------------------------------------|
|
||||
/// | `HARMONY_KVM_URI` | Full libvirt URI. Defaults to `qemu:///system`. |
|
||||
/// | `HARMONY_KVM_IMAGE_DIR` | Directory for VM disk images. Defaults to `/var/lib/libvirt/images`. |
|
||||
///
|
||||
/// For backwards compatibility, `HARMONY_KVM_CONNECTION` is also accepted as
|
||||
/// an alias for `HARMONY_KVM_URI`.
|
||||
pub fn init_executor() -> Result<KvmExecutor, KvmError> {
|
||||
let uri = std::env::var("HARMONY_KVM_URI")
|
||||
.or_else(|_| std::env::var("HARMONY_KVM_CONNECTION"))
|
||||
.unwrap_or_else(|_| "qemu:///system".to_string());
|
||||
|
||||
let image_dir = std::env::var("HARMONY_KVM_IMAGE_DIR").unwrap_or_else(|_| {
|
||||
// Fall back to the harmony data dir if available, else the system default.
|
||||
let data_dir = HARMONY_DATA_DIR.join("kvm").join("images");
|
||||
let path = data_dir.to_string_lossy().to_string();
|
||||
debug!("HARMONY_KVM_IMAGE_DIR not set; using {path}");
|
||||
path
|
||||
});
|
||||
|
||||
if uri.starts_with("qemu+ssh://") {
|
||||
validate_ssh_uri(&uri)?;
|
||||
}
|
||||
|
||||
info!("KVM executor initialised: uri={uri}, image_dir={image_dir}");
|
||||
Ok(KvmExecutor::new(uri, image_dir))
|
||||
}
|
||||
|
||||
/// Validates that an SSH URI looks structurally correct and returns an error
|
||||
/// with a helpful message when it does not.
|
||||
fn validate_ssh_uri(uri: &str) -> Result<(), KvmError> {
|
||||
// Expected form: qemu+ssh://user@host/system
|
||||
let without_scheme = uri
|
||||
.strip_prefix("qemu+ssh://")
|
||||
.ok_or_else(|| KvmError::InvalidUri(uri.to_string()))?;
|
||||
|
||||
if !without_scheme.contains('@') || !without_scheme.contains('/') {
|
||||
return Err(KvmError::InvalidUri(format!(
|
||||
"expected qemu+ssh://user@host/system, got: {uri}"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
39
harmony/src/modules/kvm/error.rs
Normal file
39
harmony/src/modules/kvm/error.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::io::Error as IoError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum KvmError {
|
||||
#[error("connection failed to '{uri}': {source}")]
|
||||
ConnectionFailed {
|
||||
uri: String,
|
||||
#[source]
|
||||
source: virt::error::Error,
|
||||
},
|
||||
|
||||
#[error("invalid connection URI: {0}")]
|
||||
InvalidUri(String),
|
||||
|
||||
#[error("VM '{name}' already exists")]
|
||||
VmAlreadyExists { name: String },
|
||||
|
||||
#[error("VM '{name}' not found")]
|
||||
VmNotFound { name: String },
|
||||
|
||||
#[error("network '{name}' already exists")]
|
||||
NetworkAlreadyExists { name: String },
|
||||
|
||||
#[error("network '{name}' not found")]
|
||||
NetworkNotFound { name: String },
|
||||
|
||||
#[error("storage pool '{name}' not found")]
|
||||
StoragePoolNotFound { name: String },
|
||||
|
||||
#[error("ISO download failed: {0}")]
|
||||
IsoDownload(String),
|
||||
|
||||
#[error("libvirt error: {0}")]
|
||||
Libvirt(#[from] virt::error::Error),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] IoError),
|
||||
}
|
||||
367
harmony/src/modules/kvm/executor.rs
Normal file
367
harmony/src/modules/kvm/executor.rs
Normal file
@@ -0,0 +1,367 @@
|
||||
use log::{debug, info, warn};
|
||||
use virt::connect::Connect;
|
||||
use virt::domain::Domain;
|
||||
use virt::network::Network;
|
||||
use virt::storage_pool::StoragePool;
|
||||
use virt::storage_vol::StorageVol;
|
||||
use virt::sys;
|
||||
|
||||
use super::error::KvmError;
|
||||
use super::types::{CdromConfig, NetworkConfig, VmConfig, VmStatus};
|
||||
use super::xml;
|
||||
|
||||
/// A handle to a libvirt hypervisor.
|
||||
///
|
||||
/// Wraps a [`virt::connect::Connect`] and provides high-level operations for
|
||||
/// virtual machines, networks, and storage volumes. All methods that call
|
||||
/// libvirt are dispatched to a blocking thread via
|
||||
/// [`tokio::task::spawn_blocking`] to avoid blocking the async executor.
|
||||
#[derive(Clone)]
|
||||
pub struct KvmExecutor {
|
||||
/// Libvirt connection URI (e.g. `qemu:///system`).
|
||||
uri: String,
|
||||
/// Path used as the base image directory for new VM disks.
|
||||
image_dir: String,
|
||||
}
|
||||
|
||||
impl KvmExecutor {
|
||||
/// Creates an executor that will open a libvirt connection on each
|
||||
/// blocking call. Connection is not held across calls to keep `Clone`
|
||||
/// and `Send` simple.
|
||||
pub fn new(uri: impl Into<String>, image_dir: impl Into<String>) -> Self {
|
||||
Self {
|
||||
uri: uri.into(),
|
||||
image_dir: image_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn open_connection(&self) -> Result<Connect, KvmError> {
|
||||
let uri = self.uri.clone();
|
||||
Connect::open(Some(&uri)).map_err(|e| KvmError::ConnectionFailed {
|
||||
uri: uri.clone(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Networks
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Ensures the given network exists and is active.
|
||||
///
|
||||
/// If the network already exists, it is started if not already active.
|
||||
/// If it does not exist, it is defined and started.
|
||||
pub async fn ensure_network(&self, cfg: NetworkConfig) -> Result<(), KvmError> {
|
||||
let executor = self.clone();
|
||||
tokio::task::spawn_blocking(move || executor.ensure_network_blocking(&cfg))
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
fn ensure_network_blocking(&self, cfg: &NetworkConfig) -> Result<(), KvmError> {
|
||||
let conn = self.open_connection()?;
|
||||
match Network::lookup_by_name(&conn, &cfg.name) {
|
||||
Ok(net) => {
|
||||
if !net.is_active()? {
|
||||
info!("Network '{}' exists but is inactive; starting it", cfg.name);
|
||||
net.create()?;
|
||||
} else {
|
||||
debug!("Network '{}' already active", cfg.name);
|
||||
}
|
||||
if !net.get_autostart()? {
|
||||
net.set_autostart(true)?;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
info!("Defining network '{}'", cfg.name);
|
||||
let xml = xml::network_xml(cfg);
|
||||
debug!("Network XML:\n{xml}");
|
||||
let net = Network::define_xml(&conn, &xml)?;
|
||||
net.create()?;
|
||||
net.set_autostart(true)?;
|
||||
info!("Network '{}' created and active", cfg.name);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops and removes a network. No-ops if the network does not exist.
|
||||
pub async fn delete_network(&self, name: &str) -> Result<(), KvmError> {
|
||||
let executor = self.clone();
|
||||
let name = name.to_string();
|
||||
tokio::task::spawn_blocking(move || executor.delete_network_blocking(&name))
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
fn delete_network_blocking(&self, name: &str) -> Result<(), KvmError> {
|
||||
let conn = self.open_connection()?;
|
||||
match Network::lookup_by_name(&conn, name) {
|
||||
Ok(net) => {
|
||||
if net.is_active()? {
|
||||
info!("Destroying network '{name}'");
|
||||
net.destroy()?;
|
||||
}
|
||||
net.undefine()?;
|
||||
info!("Network '{name}' removed");
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("delete_network: network '{name}' not found, skipping");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Domains (VMs)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// Returns `true` if a domain with `name` is known to libvirt.
|
||||
pub async fn vm_exists(&self, name: &str) -> Result<bool, KvmError> {
|
||||
let executor = self.clone();
|
||||
let name = name.to_string();
|
||||
tokio::task::spawn_blocking(move || executor.vm_exists_blocking(&name))
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
fn vm_exists_blocking(&self, name: &str) -> Result<bool, KvmError> {
|
||||
let conn = self.open_connection()?;
|
||||
match Domain::lookup_by_name(&conn, name) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Defines a VM from `config`, creating storage volumes as needed.
|
||||
///
|
||||
/// Fails if the VM already exists. Use [`KvmExecutor::ensure_vm`] for
|
||||
/// idempotent behaviour.
|
||||
pub async fn define_vm(&self, config: VmConfig) -> Result<(), KvmError> {
|
||||
let executor = self.clone();
|
||||
tokio::task::spawn_blocking(move || executor.define_vm_blocking(&config))
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
fn define_vm_blocking(&self, config: &VmConfig) -> Result<(), KvmError> {
|
||||
let conn = self.open_connection()?;
|
||||
|
||||
if Domain::lookup_by_name(&conn, &config.name).is_ok() {
|
||||
return Err(KvmError::VmAlreadyExists {
|
||||
name: config.name.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
self.create_volumes_blocking(&conn, config)?;
|
||||
|
||||
let xml = xml::domain_xml(config, &self.image_dir);
|
||||
debug!("Defining domain '{}' with XML:\n{xml}", config.name);
|
||||
Domain::define_xml(&conn, &xml)?;
|
||||
info!("VM '{}' defined", config.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Idempotent: defines the VM if it does not already exist.
|
||||
pub async fn ensure_vm(&self, config: VmConfig) -> Result<(), KvmError> {
|
||||
let executor = self.clone();
|
||||
tokio::task::spawn_blocking(move || executor.ensure_vm_blocking(&config))
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
fn ensure_vm_blocking(&self, config: &VmConfig) -> Result<(), KvmError> {
|
||||
let conn = self.open_connection()?;
|
||||
if Domain::lookup_by_name(&conn, &config.name).is_ok() {
|
||||
debug!("VM '{}' already defined, skipping", config.name);
|
||||
return Ok(());
|
||||
}
|
||||
self.create_volumes_blocking(&conn, config)?;
|
||||
let xml = xml::domain_xml(config, &self.image_dir);
|
||||
debug!("Defining domain '{}' with XML:\n{xml}", config.name);
|
||||
Domain::define_xml(&conn, &xml)?;
|
||||
info!("VM '{}' defined", config.name);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts a defined VM.
|
||||
pub async fn start_vm(&self, name: &str) -> Result<(), KvmError> {
|
||||
let executor = self.clone();
|
||||
let name = name.to_string();
|
||||
tokio::task::spawn_blocking(move || executor.start_vm_blocking(&name))
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
fn start_vm_blocking(&self, name: &str) -> Result<(), KvmError> {
|
||||
let conn = self.open_connection()?;
|
||||
let dom = Domain::lookup_by_name(&conn, name).map_err(|_| KvmError::VmNotFound {
|
||||
name: name.to_string(),
|
||||
})?;
|
||||
dom.create()?;
|
||||
info!("VM '{name}' started");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gracefully shuts down a VM.
|
||||
pub async fn shutdown_vm(&self, name: &str) -> Result<(), KvmError> {
|
||||
let executor = self.clone();
|
||||
let name = name.to_string();
|
||||
tokio::task::spawn_blocking(move || executor.shutdown_vm_blocking(&name))
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
fn shutdown_vm_blocking(&self, name: &str) -> Result<(), KvmError> {
|
||||
let conn = self.open_connection()?;
|
||||
let dom = Domain::lookup_by_name(&conn, name).map_err(|_| KvmError::VmNotFound {
|
||||
name: name.to_string(),
|
||||
})?;
|
||||
dom.shutdown()?;
|
||||
info!("VM '{name}' shutdown requested");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Forcibly powers off a VM without a graceful shutdown.
|
||||
pub async fn destroy_vm(&self, name: &str) -> Result<(), KvmError> {
|
||||
let executor = self.clone();
|
||||
let name = name.to_string();
|
||||
tokio::task::spawn_blocking(move || executor.destroy_vm_blocking(&name))
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
fn destroy_vm_blocking(&self, name: &str) -> Result<(), KvmError> {
|
||||
let conn = self.open_connection()?;
|
||||
let dom = Domain::lookup_by_name(&conn, name).map_err(|_| KvmError::VmNotFound {
|
||||
name: name.to_string(),
|
||||
})?;
|
||||
dom.destroy()?;
|
||||
info!("VM '{name}' forcibly destroyed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Undefines (removes) a VM. The VM must not be running.
|
||||
pub async fn undefine_vm(&self, name: &str) -> Result<(), KvmError> {
|
||||
let executor = self.clone();
|
||||
let name = name.to_string();
|
||||
tokio::task::spawn_blocking(move || executor.undefine_vm_blocking(&name))
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
fn undefine_vm_blocking(&self, name: &str) -> Result<(), KvmError> {
|
||||
let conn = self.open_connection()?;
|
||||
match Domain::lookup_by_name(&conn, name) {
|
||||
Ok(dom) => {
|
||||
dom.undefine()?;
|
||||
info!("VM '{name}' undefined");
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => {
|
||||
warn!("undefine_vm: VM '{name}' not found, skipping");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the current [`VmStatus`] of a VM.
|
||||
pub async fn vm_status(&self, name: &str) -> Result<VmStatus, KvmError> {
|
||||
let executor = self.clone();
|
||||
let name = name.to_string();
|
||||
tokio::task::spawn_blocking(move || executor.vm_status_blocking(&name))
|
||||
.await
|
||||
.expect("blocking task panicked")
|
||||
}
|
||||
|
||||
fn vm_status_blocking(&self, name: &str) -> Result<VmStatus, KvmError> {
|
||||
let conn = self.open_connection()?;
|
||||
let dom = Domain::lookup_by_name(&conn, name).map_err(|_| KvmError::VmNotFound {
|
||||
name: name.to_string(),
|
||||
})?;
|
||||
let (state, _reason) = dom.get_state()?;
|
||||
let status = match state {
|
||||
sys::VIR_DOMAIN_RUNNING | sys::VIR_DOMAIN_BLOCKED => VmStatus::Running,
|
||||
sys::VIR_DOMAIN_PAUSED => VmStatus::Paused,
|
||||
sys::VIR_DOMAIN_SHUTDOWN | sys::VIR_DOMAIN_SHUTOFF => VmStatus::Shutoff,
|
||||
sys::VIR_DOMAIN_CRASHED => VmStatus::Crashed,
|
||||
sys::VIR_DOMAIN_PMSUSPENDED => VmStatus::PMSuspended,
|
||||
_ => VmStatus::Other,
|
||||
};
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Storage
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
fn create_volumes_blocking(&self, conn: &Connect, config: &VmConfig) -> Result<(), KvmError> {
|
||||
for disk in &config.disks {
|
||||
let pool = StoragePool::lookup_by_name(conn, &disk.pool).map_err(|_| {
|
||||
KvmError::StoragePoolNotFound {
|
||||
name: disk.pool.clone(),
|
||||
}
|
||||
})?;
|
||||
|
||||
let vol_name = format!("{}-{}", config.name, disk.device);
|
||||
match StorageVol::lookup_by_name(&pool, &format!("{vol_name}.qcow2")) {
|
||||
Ok(_) => {
|
||||
debug!(
|
||||
"Volume '{vol_name}.qcow2' already exists in pool '{}'",
|
||||
disk.pool
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
info!(
|
||||
"Creating volume '{vol_name}.qcow2' ({} GiB) in pool '{}'",
|
||||
disk.size_gb, disk.pool
|
||||
);
|
||||
let xml = xml::volume_xml(&vol_name, disk.size_gb);
|
||||
StorageVol::create_xml(&pool, &xml, 0)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for cdrom in &config.cdroms {
|
||||
self.prepare_iso_blocking(cdrom)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_iso_blocking(&self, cdrom: &CdromConfig) -> Result<(), KvmError> {
|
||||
let source = &cdrom.source;
|
||||
|
||||
if source.starts_with("http://") || source.starts_with("https://") {
|
||||
let file_name = source.split('/').last().unwrap_or("downloaded.iso");
|
||||
let target_path = format!("{}/{}", self.image_dir, file_name);
|
||||
|
||||
if std::path::Path::new(&target_path).exists() {
|
||||
info!("ISO '{}' already downloaded, skipping", file_name);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Downloading ISO '{}' to '{}'", file_name, target_path);
|
||||
self.download_iso_blocking(source, &target_path)?;
|
||||
info!("ISO '{}' downloaded successfully", file_name);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_iso_blocking(&self, url: &str, target_path: &str) -> Result<(), KvmError> {
|
||||
let response =
|
||||
reqwest::blocking::get(url).map_err(|e| KvmError::IsoDownload(e.to_string()))?;
|
||||
|
||||
let mut file = std::fs::File::create(target_path)?;
|
||||
|
||||
let content = response
|
||||
.bytes()
|
||||
.map_err(|e| KvmError::IsoDownload(e.to_string()))?;
|
||||
|
||||
std::io::copy(&mut content.as_ref(), &mut file)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
13
harmony/src/modules/kvm/mod.rs
Normal file
13
harmony/src/modules/kvm/mod.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
mod xml;
|
||||
|
||||
pub mod config;
|
||||
pub mod error;
|
||||
pub mod executor;
|
||||
pub mod types;
|
||||
|
||||
pub use error::KvmError;
|
||||
pub use executor::KvmExecutor;
|
||||
pub use types::{
|
||||
BootDevice, CdromConfig, DiskConfig, ForwardMode, NetworkConfig, NetworkConfigBuilder,
|
||||
NetworkRef, VmConfig, VmConfigBuilder, VmStatus,
|
||||
};
|
||||
318
harmony/src/modules/kvm/types.rs
Normal file
318
harmony/src/modules/kvm/types.rs
Normal file
@@ -0,0 +1,318 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Specifies how a KVM host is accessed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum KvmConnectionUri {
|
||||
/// Local hypervisor via UNIX socket. Equivalent to `qemu:///system`.
|
||||
Local,
|
||||
/// Remote hypervisor over SSH. Equivalent to `qemu+ssh://user@host/system`.
|
||||
RemoteSsh { host: String, username: String },
|
||||
}
|
||||
|
||||
impl KvmConnectionUri {
|
||||
/// Returns the libvirt URI string for this connection.
|
||||
pub fn as_uri(&self) -> String {
|
||||
match self {
|
||||
KvmConnectionUri::Local => "qemu:///system".to_string(),
|
||||
KvmConnectionUri::RemoteSsh { host, username } => {
|
||||
format!("qemu+ssh://{username}@{host}/system")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for a virtual disk attached to a VM.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiskConfig {
|
||||
/// Disk size in gigabytes.
|
||||
pub size_gb: u32,
|
||||
/// Target device name in the guest (e.g. `vda`, `vdb`).
|
||||
pub device: String,
|
||||
/// Storage pool to allocate the volume from. Defaults to `"default"`.
|
||||
pub pool: String,
|
||||
}
|
||||
|
||||
/// Configuration for a CD-ROM/ISO device attached to a VM.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CdromConfig {
|
||||
/// Path or URL to the ISO image. If it starts with `http` or `https`, it will be downloaded.
|
||||
pub source: String,
|
||||
/// Target device name in the guest (e.g. `hda`, `hdb`). Defaults to `hda`.
|
||||
pub device: String,
|
||||
}
|
||||
|
||||
impl DiskConfig {
|
||||
/// Creates a new disk config with sequential virtio device naming.
|
||||
///
|
||||
/// `index` maps 0 → `vda`, 1 → `vdb`, etc.
|
||||
pub fn new(size_gb: u32, index: u8) -> Self {
|
||||
let device = format!("vd{}", (b'a' + index) as char);
|
||||
Self {
|
||||
size_gb,
|
||||
device,
|
||||
pool: "default".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Override the storage pool.
|
||||
pub fn from_pool(mut self, pool: impl Into<String>) -> Self {
|
||||
self.pool = pool.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A reference to a libvirt virtual network by name.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkRef {
|
||||
/// Libvirt network name (e.g. `"harmonylan"`).
|
||||
pub name: String,
|
||||
/// Optional fixed MAC address for this interface. When `None`, libvirt
|
||||
/// assigns one automatically.
|
||||
pub mac: Option<String>,
|
||||
}
|
||||
|
||||
impl NetworkRef {
|
||||
pub fn named(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
mac: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_mac(mut self, mac: impl Into<String>) -> Self {
|
||||
self.mac = Some(mac.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Boot device priority entry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum BootDevice {
|
||||
/// Boot from first hard disk (vda)
|
||||
Disk,
|
||||
/// Boot from network (PXE)
|
||||
Network,
|
||||
/// Boot from CD-ROM/ISO
|
||||
Cdrom,
|
||||
}
|
||||
|
||||
impl BootDevice {
|
||||
pub(crate) fn as_xml_dev(&self) -> &'static str {
|
||||
match self {
|
||||
BootDevice::Disk => "hd",
|
||||
BootDevice::Network => "network",
|
||||
BootDevice::Cdrom => "cdrom",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Full configuration for a KVM virtual machine.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VmConfig {
|
||||
/// VM name, must be unique on the host.
|
||||
pub name: String,
|
||||
/// Number of virtual CPUs.
|
||||
pub vcpus: u32,
|
||||
/// Memory in mebibytes (MiB).
|
||||
pub memory_mib: u64,
|
||||
/// Disks to attach, in order.
|
||||
pub disks: Vec<DiskConfig>,
|
||||
/// Network interfaces to attach, in order.
|
||||
pub networks: Vec<NetworkRef>,
|
||||
/// CD-ROM/ISO devices to attach.
|
||||
pub cdroms: Vec<CdromConfig>,
|
||||
/// Boot order. First entry has highest priority.
|
||||
pub boot_order: Vec<BootDevice>,
|
||||
}
|
||||
|
||||
impl VmConfig {
|
||||
pub fn builder(name: impl Into<String>) -> VmConfigBuilder {
|
||||
VmConfigBuilder::new(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for [`VmConfig`].
|
||||
#[derive(Debug)]
|
||||
pub struct VmConfigBuilder {
|
||||
name: String,
|
||||
vcpus: u32,
|
||||
memory_mib: u64,
|
||||
disks: Vec<DiskConfig>,
|
||||
networks: Vec<NetworkRef>,
|
||||
cdroms: Vec<CdromConfig>,
|
||||
boot_order: Vec<BootDevice>,
|
||||
}
|
||||
|
||||
impl VmConfigBuilder {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
vcpus: 2,
|
||||
memory_mib: 4096,
|
||||
disks: vec![],
|
||||
networks: vec![],
|
||||
cdroms: vec![],
|
||||
boot_order: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn vcpus(mut self, vcpus: u32) -> Self {
|
||||
self.vcpus = vcpus;
|
||||
self
|
||||
}
|
||||
|
||||
/// Convenience shorthand: sets memory in whole gigabytes.
|
||||
pub fn memory_gb(mut self, gb: u32) -> Self {
|
||||
self.memory_mib = gb as u64 * 1024;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn memory_mib(mut self, mib: u64) -> Self {
|
||||
self.memory_mib = mib;
|
||||
self
|
||||
}
|
||||
|
||||
/// Appends a disk. Devices are named sequentially: `vda`, `vdb`, …
|
||||
pub fn disk(mut self, size_gb: u32) -> Self {
|
||||
let idx = self.disks.len() as u8;
|
||||
self.disks.push(DiskConfig::new(size_gb, idx));
|
||||
self
|
||||
}
|
||||
|
||||
/// Appends a disk with an explicit pool override.
|
||||
pub fn disk_from_pool(mut self, size_gb: u32, pool: impl Into<String>) -> Self {
|
||||
let idx = self.disks.len() as u8;
|
||||
self.disks
|
||||
.push(DiskConfig::new(size_gb, idx).from_pool(pool));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn network(mut self, net: NetworkRef) -> Self {
|
||||
self.networks.push(net);
|
||||
self
|
||||
}
|
||||
|
||||
/// Attaches a CD-ROM with the given ISO source.
|
||||
///
|
||||
/// The source can be a local path or an HTTP/HTTPS URL that will be
|
||||
/// downloaded to the image directory.
|
||||
pub fn cdrom(mut self, source: impl Into<String>) -> Self {
|
||||
self.cdroms.push(CdromConfig {
|
||||
source: source.into(),
|
||||
device: "hda".to_string(),
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
pub fn boot_order(mut self, order: impl IntoIterator<Item = BootDevice>) -> Self {
|
||||
self.boot_order = order.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> VmConfig {
|
||||
VmConfig {
|
||||
name: self.name,
|
||||
vcpus: self.vcpus,
|
||||
memory_mib: self.memory_mib,
|
||||
disks: self.disks,
|
||||
networks: self.networks,
|
||||
cdroms: self.cdroms,
|
||||
boot_order: self.boot_order,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for an isolated virtual network.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NetworkConfig {
|
||||
/// Libvirt network name.
|
||||
pub name: String,
|
||||
/// Bridge device name (e.g. `"virbr100"`).
|
||||
pub bridge: String,
|
||||
/// Gateway IP address of the network.
|
||||
pub gateway_ip: String,
|
||||
/// Network prefix length (e.g. `24`).
|
||||
pub prefix_len: u8,
|
||||
/// Forward mode. When `None`, the network is fully isolated.
|
||||
pub forward_mode: Option<ForwardMode>,
|
||||
}
|
||||
|
||||
/// Libvirt network forward mode.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
||||
pub enum ForwardMode {
|
||||
Nat,
|
||||
Route,
|
||||
}
|
||||
|
||||
impl NetworkConfig {
|
||||
pub fn builder(name: impl Into<String>) -> NetworkConfigBuilder {
|
||||
NetworkConfigBuilder::new(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builder for [`NetworkConfig`].
|
||||
#[derive(Debug)]
|
||||
pub struct NetworkConfigBuilder {
|
||||
name: String,
|
||||
bridge: Option<String>,
|
||||
gateway_ip: String,
|
||||
prefix_len: u8,
|
||||
forward_mode: Option<ForwardMode>,
|
||||
}
|
||||
|
||||
impl NetworkConfigBuilder {
|
||||
pub fn new(name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
bridge: None,
|
||||
gateway_ip: "192.168.100.1".to_string(),
|
||||
prefix_len: 24,
|
||||
forward_mode: Some(ForwardMode::Nat),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bridge(mut self, bridge: impl Into<String>) -> Self {
|
||||
self.bridge = Some(bridge.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the gateway IP and prefix length (e.g. `"192.168.100.1"`, `24`).
|
||||
pub fn subnet(mut self, gateway_ip: impl Into<String>, prefix_len: u8) -> Self {
|
||||
self.gateway_ip = gateway_ip.into();
|
||||
self.prefix_len = prefix_len;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn isolated(mut self) -> Self {
|
||||
self.forward_mode = None;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn forward(mut self, mode: ForwardMode) -> Self {
|
||||
self.forward_mode = Some(mode);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn build(self) -> NetworkConfig {
|
||||
NetworkConfig {
|
||||
bridge: self
|
||||
.bridge
|
||||
.unwrap_or_else(|| format!("virbr-{}", self.name.replace('-', ""))),
|
||||
name: self.name,
|
||||
gateway_ip: self.gateway_ip,
|
||||
prefix_len: self.prefix_len,
|
||||
forward_mode: self.forward_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current state of a VM as returned by libvirt.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
pub enum VmStatus {
|
||||
Running,
|
||||
Paused,
|
||||
Shutoff,
|
||||
Crashed,
|
||||
PMSuspended,
|
||||
Other,
|
||||
}
|
||||
194
harmony/src/modules/kvm/xml.rs
Normal file
194
harmony/src/modules/kvm/xml.rs
Normal file
@@ -0,0 +1,194 @@
|
||||
use super::types::{CdromConfig, DiskConfig, ForwardMode, NetworkConfig, VmConfig};
|
||||
|
||||
/// Renders the libvirt domain XML for a VM definition.
|
||||
///
|
||||
/// The caller passes the image directory where qcow2 volumes are stored.
|
||||
pub fn domain_xml(vm: &VmConfig, image_dir: &str) -> String {
|
||||
let memory_kib = vm.memory_mib * 1024;
|
||||
|
||||
let os_boot = vm
|
||||
.boot_order
|
||||
.iter()
|
||||
.map(|b| format!(" <boot dev='{}'/>\n", b.as_xml_dev()))
|
||||
.collect::<String>();
|
||||
|
||||
let devices = {
|
||||
let disks = disk_devices(vm, image_dir);
|
||||
let cdroms = cdrom_devices(vm);
|
||||
let nics = nic_devices(vm);
|
||||
format!("{disks}{cdroms}{nics}")
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<domain type='kvm'>
|
||||
<name>{name}</name>
|
||||
<memory unit='KiB'>{memory_kib}</memory>
|
||||
<vcpu>{vcpus}</vcpu>
|
||||
<os>
|
||||
<type arch='x86_64' machine='q35'>hvm</type>
|
||||
{os_boot} </os>
|
||||
<features>
|
||||
<acpi/>
|
||||
<apic/>
|
||||
</features>
|
||||
<cpu mode='host-model'/>
|
||||
<devices>
|
||||
<emulator>/usr/bin/qemu-system-x86_64</emulator>
|
||||
{devices} <serial type='pty'>
|
||||
<target port='0'/>
|
||||
</serial>
|
||||
<console type='pty'>
|
||||
<target type='serial' port='0'/>
|
||||
</console>
|
||||
</devices>
|
||||
</domain>"#,
|
||||
name = vm.name,
|
||||
memory_kib = memory_kib,
|
||||
vcpus = vm.vcpus,
|
||||
os_boot = os_boot,
|
||||
devices = devices,
|
||||
)
|
||||
}
|
||||
|
||||
fn disk_devices(vm: &VmConfig, image_dir: &str) -> String {
|
||||
vm.disks
|
||||
.iter()
|
||||
.map(|d| format_disk(vm, d, image_dir))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn cdrom_devices(vm: &VmConfig) -> String {
|
||||
vm.cdroms.iter().map(|c| format_cdrom(c)).collect()
|
||||
}
|
||||
|
||||
fn format_disk(vm: &VmConfig, disk: &DiskConfig, image_dir: &str) -> String {
|
||||
let path = format!("{image_dir}/{}-{}.qcow2", vm.name, disk.device);
|
||||
format!(
|
||||
r#" <disk type='file' device='disk'>
|
||||
<driver name='qemu' type='qcow2'/>
|
||||
<source file='{path}'/>
|
||||
<target dev='{dev}' bus='virtio'/>
|
||||
</disk>
|
||||
"#,
|
||||
path = path,
|
||||
dev = disk.device,
|
||||
)
|
||||
}
|
||||
|
||||
fn format_cdrom(cdrom: &CdromConfig) -> String {
|
||||
let source = &cdrom.source;
|
||||
let dev = &cdrom.device;
|
||||
let device_type = if source.starts_with("http://") || source.starts_with("https://") {
|
||||
"cdrom"
|
||||
} else {
|
||||
"cdrom"
|
||||
};
|
||||
format!(
|
||||
r#" <disk type='file' device='{device_type}'>
|
||||
<driver name='qemu' type='raw'/>
|
||||
<source file='{source}'/>
|
||||
<target dev='{dev}' bus='ide'/>
|
||||
</disk>
|
||||
"#,
|
||||
source = source,
|
||||
dev = dev,
|
||||
device_type = device_type,
|
||||
)
|
||||
}
|
||||
|
||||
fn nic_devices(vm: &VmConfig) -> String {
|
||||
vm.networks
|
||||
.iter()
|
||||
.map(|net| {
|
||||
let mac_line = net
|
||||
.mac
|
||||
.as_deref()
|
||||
.map(|m| format!("\n <mac address='{m}'/>"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
r#" <interface type='network'>
|
||||
<source network='{network}'/>{mac}
|
||||
<model type='virtio'/>
|
||||
</interface>
|
||||
"#,
|
||||
network = net.name,
|
||||
mac = mac_line,
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Renders the libvirt network XML for a virtual network definition.
|
||||
pub fn network_xml(cfg: &NetworkConfig) -> String {
|
||||
let forward = match cfg.forward_mode {
|
||||
Some(ForwardMode::Nat) => " <forward mode='nat'/>\n",
|
||||
Some(ForwardMode::Route) => " <forward mode='route'/>\n",
|
||||
None => "",
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"<network>
|
||||
<name>{name}</name>
|
||||
<bridge name='{bridge}' stp='on' delay='0'/>
|
||||
{forward} <ip address='{gateway}' prefix='{prefix}'/>
|
||||
</network>"#,
|
||||
name = cfg.name,
|
||||
bridge = cfg.bridge,
|
||||
forward = forward,
|
||||
gateway = cfg.gateway_ip,
|
||||
prefix = cfg.prefix_len,
|
||||
)
|
||||
}
|
||||
|
||||
/// Renders the libvirt storage volume XML for a qcow2 disk.
|
||||
pub fn volume_xml(name: &str, size_gb: u32) -> String {
|
||||
let capacity_bytes: u64 = size_gb as u64 * 1024 * 1024 * 1024;
|
||||
format!(
|
||||
r#"<volume>
|
||||
<name>{name}.qcow2</name>
|
||||
<capacity unit='bytes'>{capacity}</capacity>
|
||||
<target>
|
||||
<format type='qcow2'/>
|
||||
</target>
|
||||
</volume>"#,
|
||||
name = name,
|
||||
capacity = capacity_bytes,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::modules::kvm::types::{BootDevice, NetworkRef, VmConfig};
|
||||
|
||||
#[test]
|
||||
fn domain_xml_contains_vm_name() {
|
||||
let vm = VmConfig::builder("test-vm")
|
||||
.vcpus(2)
|
||||
.memory_gb(4)
|
||||
.disk(20)
|
||||
.network(NetworkRef::named("mynet"))
|
||||
.boot_order([BootDevice::Network, BootDevice::Disk])
|
||||
.build();
|
||||
|
||||
let xml = domain_xml(&vm, "/var/lib/libvirt/images");
|
||||
assert!(xml.contains("<name>test-vm</name>"));
|
||||
assert!(xml.contains("source network='mynet'"));
|
||||
assert!(xml.contains("boot dev='network'"));
|
||||
assert!(xml.contains("boot dev='hd'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_xml_isolated_has_no_forward() {
|
||||
use crate::modules::kvm::types::NetworkConfig;
|
||||
|
||||
let cfg = NetworkConfig::builder("testnet")
|
||||
.subnet("10.0.0.1", 24)
|
||||
.isolated()
|
||||
.build();
|
||||
|
||||
let xml = network_xml(&cfg);
|
||||
assert!(!xml.contains("<forward"));
|
||||
assert!(xml.contains("10.0.0.1"));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ pub mod http;
|
||||
pub mod inventory;
|
||||
pub mod k3d;
|
||||
pub mod k8s;
|
||||
pub mod kvm;
|
||||
pub mod lamp;
|
||||
pub mod load_balancer;
|
||||
pub mod monitoring;
|
||||
|
||||
17
opencode.json
Normal file
17
opencode.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"provider": {
|
||||
"ollama": {
|
||||
"npm": "@ai-sdk/openai-compatible",
|
||||
"name": "Ollama sto1",
|
||||
"options": {
|
||||
"baseURL": "http://192.168.55.132:11434/v1"
|
||||
},
|
||||
"models": {
|
||||
"qwen3-coder-next:q4_K_M": {
|
||||
"name": "qwen3-coder-next:q4_K_M"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user