Compare commits
148 Commits
monitoring
...
better-ind
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
387ae9f494 | ||
|
|
336e1cfefe | ||
|
|
403e199062 | ||
| 29a261575b | |||
| dcf8335240 | |||
|
|
f876b5e67b | ||
| 440c1bce12 | |||
| 024084859e | |||
| 54990cd1a5 | |||
| 06aab1f57f | |||
| 1ab66af718 | |||
|
|
0fff4ef566 | ||
| d95e84d6fc | |||
| a47be890de | |||
| ee8dfa4a93 | |||
| 5d41cc8380 | |||
| cef745b642 | |||
| d9959378a6 | |||
|
|
07f1151e4c | ||
|
|
f7625f0484 | ||
|
|
537da5800f | ||
| 3be2fa246c | |||
| 9452cf5616 | |||
| 9b7456e148 | |||
| 98f3f82ad5 | |||
| 3eca409f8d | |||
| c11a31c7a9 | |||
| 1a6d72dc17 | |||
| df9e21807e | |||
| b1bf4fd4d5 | |||
| f702ecd8c9 | |||
| a19b52e690 | |||
| b73f2e76d0 | |||
| b4534c6ee0 | |||
| 6149249a6c | |||
| d9935e20cb | |||
| 7b0f3b79b1 | |||
| e6612245a5 | |||
| b4f5b91a57 | |||
| d317c0ba76 | |||
| 539b8299ae | |||
| 5a89495c61 | |||
| fb7849c010 | |||
| 6371009c6f | |||
| a4aa685a4f | |||
| 6bf10b093c | |||
| 3eecc2f590 | |||
| 3959c07261 | |||
| e50c01c0b3 | |||
| 286460d59e | |||
| 4baa3ae707 | |||
| 82119076cf | |||
| f2a350fae6 | |||
| 197770a603 | |||
| ab69a2c264 | |||
| e857efa92f | |||
| 2ff3f4afa9 | |||
| 2f6a11ead7 | |||
| 7de9860dcf | |||
| 6e884cff3a | |||
| c74c51090a | |||
| 8ae0d6b548 | |||
| ee02906ce9 | |||
| 284cc6afd7 | |||
| 9bf6aac82e | |||
| 460c8b59e1 | |||
| 8e857bc72a | |||
| e8d55d27e4 | |||
| fea7e9ddb9 | |||
| 7ec89cdac5 | |||
| 55143dcad4 | |||
| 17ad92402d | |||
| 29e74a2712 | |||
| e16f8fa82e | |||
| c21f3084dc | |||
| 2c706225a1 | |||
| acfb93f1a2 | |||
| f437c40428 | |||
| e06548ac44 | |||
| 155e9bac28 | |||
| 7bebc58615 | |||
| 246d6718c3 | |||
| d776042e20 | |||
| 86c681be70 | |||
| b94dd1e595 | |||
| ef5ec4a131 | |||
| a8eb06f686 | |||
| d1678b529e | |||
| 1451260d4d | |||
| 415488ba39 | |||
| bf7a6d590c | |||
| 8d8120bbfd | |||
| 6cf61ae67c | |||
| 8c65aef127 | |||
| 00e71b97f6 | |||
| ee2bba5623 | |||
| 118d34db55 | |||
| 24e466fadd | |||
| 14fc4345c1 | |||
| 8e472e4c65 | |||
| ec17ccc246 | |||
| 5127f44ab3 | |||
| 2ff70db0b1 | |||
| e17ac1af83 | |||
| 31e59937dc | |||
| 12eb4ae31f | |||
| a2be9457b9 | |||
| 0d56fbc09d | |||
| 56dc1e93c1 | |||
| 691540fe64 | |||
| 7e3f1b1830 | |||
| b631e8ccbb | |||
| 60f2f31d6c | |||
| 045954f8d3 | |||
| 27f1a9dbdd | |||
| 7c809bf18a | |||
| 6490e5e82a | |||
| 5e51f7490c | |||
| 97fba07f4e | |||
| 624e4330bb | |||
| e7917843bc | |||
| 7cd541bdd8 | |||
| 270dd49567 | |||
| 0187300473 | |||
| bf16566b4e | |||
| 895fb02f4e | |||
| 88d6af9815 | |||
| 5aa9dc701f | |||
| f4ef895d2e | |||
| 6e7148a945 | |||
| 83453273c6 | |||
| 76ae5eb747 | |||
| 9c51040f3b | |||
| e1a8ee1c15 | |||
| 44b2b092a8 | |||
| 19bd47a545 | |||
| 2b6d2e8606 | |||
| 7fc2b1ebfe | |||
| e80752ea3f | |||
| bae7222d64 | |||
| f7d3da3ac9 | |||
| eb8a8a2e04 | |||
| b4c6848433 | |||
| 0d94c537a0 | |||
| 861f266c4e | |||
| 51724d0e55 | |||
| c2d1cb9b76 | |||
|
|
c84a02c8ec |
5
.cargo/config.toml
Normal file
5
.cargo/config.toml
Normal file
@@ -0,0 +1,5 @@
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-C", "link-arg=/STACK:8000000"]
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
rustflags = ["-C", "link-arg=-Wl,--stack,8000000"]
|
||||
2
.dockerignore
Normal file
2
.dockerignore
Normal file
@@ -0,0 +1,2 @@
|
||||
target/
|
||||
Dockerfile
|
||||
18
.gitea/workflows/check.yml
Normal file
18
.gitea/workflows/check.yml
Normal file
@@ -0,0 +1,18 @@
|
||||
name: Run Check Script
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: docker
|
||||
container:
|
||||
image: hub.nationtech.io/harmony/harmony_composer:latest@sha256:eb0406fcb95c63df9b7c4b19bc50ad7914dd8232ce98e9c9abef628e07c69386
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Run check script
|
||||
run: bash check.sh
|
||||
95
.gitea/workflows/harmony_composer.yaml
Normal file
95
.gitea/workflows/harmony_composer.yaml
Normal file
@@ -0,0 +1,95 @@
|
||||
name: Compile and package harmony_composer
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
package_harmony_composer:
|
||||
container:
|
||||
image: hub.nationtech.io/harmony/harmony_composer:latest@sha256:eb0406fcb95c63df9b7c4b19bc50ad7914dd8232ce98e9c9abef628e07c69386
|
||||
runs-on: dind
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Build for Linux x86_64
|
||||
run: cargo build --release --bin harmony_composer --target x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Build for Windows x86_64 GNU
|
||||
run: cargo build --release --bin harmony_composer --target x86_64-pc-windows-gnu
|
||||
|
||||
- name: Setup log into hub.nationtech.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: hub.nationtech.io
|
||||
username: ${{ secrets.HUB_BOT_USER }}
|
||||
password: ${{ secrets.HUB_BOT_PASSWORD }}
|
||||
|
||||
# TODO: build ARM images and MacOS binaries (or other targets) too
|
||||
|
||||
- name: Update snapshot-latest tag
|
||||
run: |
|
||||
git config user.name "Gitea CI"
|
||||
git config user.email "ci@nationtech.io"
|
||||
git tag -f snapshot-latest
|
||||
git push origin snapshot-latest --force
|
||||
|
||||
- name: Install jq
|
||||
run: apt install -y jq # The current image includes apt lists so we don't have to apt update and rm /var/lib/apt... every time. But if the image is optimized it won't work anymore
|
||||
|
||||
- name: Create or update release
|
||||
run: |
|
||||
# First, check if release exists and delete it if it does
|
||||
RELEASE_ID=$(curl -s -X GET \
|
||||
-H "Authorization: token ${{ secrets.GITEATOKEN }}" \
|
||||
"https://git.nationtech.io/api/v1/repos/nationtech/harmony/releases/tags/snapshot-latest" \
|
||||
| jq -r '.id // empty')
|
||||
|
||||
if [ -n "$RELEASE_ID" ]; then
|
||||
# Delete existing release
|
||||
curl -X DELETE \
|
||||
-H "Authorization: token ${{ secrets.GITEATOKEN }}" \
|
||||
"https://git.nationtech.io/api/v1/repos/nationtech/harmony/releases/$RELEASE_ID"
|
||||
fi
|
||||
|
||||
# Create new release
|
||||
RESPONSE=$(curl -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEATOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tag_name": "snapshot-latest",
|
||||
"name": "Latest Snapshot",
|
||||
"body": "Automated snapshot build from master branch",
|
||||
"draft": false,
|
||||
"prerelease": true
|
||||
}' \
|
||||
"https://git.nationtech.io/api/v1/repos/nationtech/harmony/releases")
|
||||
|
||||
echo "RELEASE_ID=$(echo $RESPONSE | jq -r '.id')" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload Linux binary
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEATOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@target/x86_64-unknown-linux-gnu/release/harmony_composer" \
|
||||
"https://git.nationtech.io/api/v1/repos/nationtech/harmony/releases/${{ env.RELEASE_ID }}/assets?name=harmony_composer"
|
||||
|
||||
- name: Upload Windows binary
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEATOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@target/x86_64-pc-windows-gnu/release/harmony_composer.exe" \
|
||||
"https://git.nationtech.io/api/v1/repos/nationtech/harmony/releases/${{ env.RELEASE_ID }}/assets?name=harmony_composer.exe"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: hub.nationtech.io/harmony/harmony_composer:latest
|
||||
28
.gitignore
vendored
28
.gitignore
vendored
@@ -1,3 +1,25 @@
|
||||
target
|
||||
private_repos
|
||||
log/
|
||||
### General ###
|
||||
private_repos/
|
||||
|
||||
### Harmony ###
|
||||
harmony.log
|
||||
|
||||
### Helm ###
|
||||
# Chart dependencies
|
||||
**/charts/*.tgz
|
||||
|
||||
### Rust ###
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
|
||||
36
CONTRIBUTING.md
Normal file
36
CONTRIBUTING.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Contributing to the Harmony project
|
||||
|
||||
## Write small P-R
|
||||
|
||||
Aim for the smallest piece of work that is mergeable.
|
||||
|
||||
Mergeable means that :
|
||||
|
||||
- it does not break the build
|
||||
- it moves the codebase one step forward
|
||||
|
||||
P-Rs can be many things, they do not have to be complete features.
|
||||
|
||||
### What a P-R **should** be
|
||||
|
||||
- Introduce a new trait : This will be the place to discuss the new trait addition, its design and implementation
|
||||
- A new implementation of a trait : a new concrete implementation of the LoadBalancer trait
|
||||
- A new CI check : something that improves quality, robustness, ci performance
|
||||
- Documentation improvements
|
||||
- Refactoring
|
||||
- Bugfix
|
||||
|
||||
### What a P-R **should not** be
|
||||
|
||||
- Large. Anything over 200 lines (excluding generated lines) should have a very good reason to be this large.
|
||||
- A mix of refactoring, bug fixes and new features.
|
||||
- Introducing multiple new features or ideas at once.
|
||||
- Multiple new implementations of a trait/functionnality at once
|
||||
|
||||
The general idea is to keep P-Rs small and single purpose.
|
||||
|
||||
## Commit message formatting
|
||||
|
||||
We follow conventional commits guidelines.
|
||||
|
||||
https://www.conventionalcommits.org/en/v1.0.0/
|
||||
1653
Cargo.lock
generated
1653
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
62
Cargo.toml
62
Cargo.toml
@@ -11,6 +11,7 @@ members = [
|
||||
"opnsense-config-xml",
|
||||
"harmony_cli",
|
||||
"k3d",
|
||||
"harmony_composer",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
@@ -19,28 +20,39 @@ readme = "README.md"
|
||||
license = "GNU AGPL v3"
|
||||
|
||||
[workspace.dependencies]
|
||||
log = "0.4.22"
|
||||
env_logger = "0.11.5"
|
||||
derive-new = "0.7.0"
|
||||
async-trait = "0.1.82"
|
||||
tokio = { version = "1.40.0", features = ["io-std", "fs", "macros", "rt-multi-thread"] }
|
||||
cidr = "0.2.3"
|
||||
russh = "0.45.0"
|
||||
russh-keys = "0.45.0"
|
||||
rand = "0.8.5"
|
||||
url = "2.5.4"
|
||||
kube = "0.98.0"
|
||||
k8s-openapi = { version = "0.24.0", features = ["v1_30"] }
|
||||
serde_yaml = "0.9.34"
|
||||
serde-value = "0.7.0"
|
||||
http = "1.2.0"
|
||||
inquire = "0.7.5"
|
||||
convert_case = "0.8.0"
|
||||
|
||||
[workspace.dependencies.uuid]
|
||||
version = "1.11.0"
|
||||
features = [
|
||||
"v4", # Lets you generate random UUIDs
|
||||
"fast-rng", # Use a faster (but still sufficiently random) RNG
|
||||
"macro-diagnostics", # Enable better diagnostics for compile-time UUIDs
|
||||
]
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
derive-new = "0.7"
|
||||
async-trait = "0.1"
|
||||
tokio = { version = "1.40", features = [
|
||||
"io-std",
|
||||
"fs",
|
||||
"macros",
|
||||
"rt-multi-thread",
|
||||
] }
|
||||
cidr = { features = ["serde"], version = "0.2" }
|
||||
russh = "0.45"
|
||||
russh-keys = "0.45"
|
||||
rand = "0.8"
|
||||
url = "2.5"
|
||||
kube = { version = "1.1.0", features = [
|
||||
"config",
|
||||
"client",
|
||||
"runtime",
|
||||
"rustls-tls",
|
||||
"ws",
|
||||
"jsonpatch",
|
||||
] }
|
||||
k8s-openapi = { version = "0.25", features = ["v1_30"] }
|
||||
serde_yaml = "0.9"
|
||||
serde-value = "0.7"
|
||||
http = "1.2"
|
||||
inquire = "0.7"
|
||||
convert_case = "0.8"
|
||||
chrono = "0.4"
|
||||
similar = "2"
|
||||
uuid = { version = "1.11", features = ["v4", "fast-rng", "macro-diagnostics"] }
|
||||
pretty_assertions = "1.4.1"
|
||||
bollard = "0.19.1"
|
||||
base64 = "0.22.1"
|
||||
tar = "0.4.44"
|
||||
|
||||
26
Dockerfile
Normal file
26
Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
FROM docker.io/rust:1.87.0 AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cargo build --release --bin harmony_composer
|
||||
|
||||
FROM docker.io/rust:1.87.0
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN rustup target add x86_64-pc-windows-gnu
|
||||
RUN rustup target add x86_64-unknown-linux-gnu
|
||||
RUN rustup component add rustfmt
|
||||
RUN rustup component add clippy
|
||||
|
||||
RUN apt update
|
||||
|
||||
# TODO: Consider adding more supported targets
|
||||
# nodejs for checkout action, docker for building containers, mingw for cross-compiling for windows
|
||||
RUN apt install -y nodejs docker.io mingw-w64
|
||||
|
||||
COPY --from=build /app/target/release/harmony_composer .
|
||||
|
||||
ENTRYPOINT ["/app/harmony_composer"]
|
||||
279
README.md
279
README.md
@@ -1,171 +1,150 @@
|
||||
# Harmony : Open Infrastructure Orchestration
|
||||
# Harmony : Open-source infrastructure orchestration that treats your platform like first-class code
|
||||
|
||||
## Quick demo
|
||||
_By [NationTech](https://nationtech.io)_
|
||||
|
||||
`cargo run -p example-tui`
|
||||
[](https://git.nationtech.io/nationtech/harmony)
|
||||
[](LICENSE)
|
||||
|
||||
This will launch Harmony's minimalist terminal ui which embeds a few demo scores.
|
||||
### Unify
|
||||
|
||||
Usage instructions will be displayed at the bottom of the TUI.
|
||||
- **Project Scaffolding**
|
||||
- **Infrastructure Provisioning**
|
||||
- **Application Deployment**
|
||||
- **Day-2 operations**
|
||||
|
||||
`cargo run --bin example-cli -- --help`
|
||||
All in **one strongly-typed Rust codebase**.
|
||||
|
||||
This is the harmony CLI, a minimal implementation
|
||||
### Deploy anywhere
|
||||
|
||||
The current help text:
|
||||
From a **developer laptop** to a **global production cluster**, a single **source of truth** drives the **full software lifecycle.**
|
||||
|
||||
````
|
||||
Usage: example-cli [OPTIONS]
|
||||
---
|
||||
|
||||
Options:
|
||||
-y, --yes Run score(s) or not
|
||||
-f, --filter <FILTER> Filter query
|
||||
-i, --interactive Run interactive TUI or not
|
||||
-a, --all Run all or nth, defaults to all
|
||||
-n, --number <NUMBER> Run nth matching, zero indexed [default: 0]
|
||||
-l, --list list scores, will also be affected by run filter
|
||||
-h, --help Print help
|
||||
-V, --version Print version```
|
||||
## 1 · The Harmony Philosophy
|
||||
|
||||
## Core architecture
|
||||
Infrastructure is essential, but it shouldn’t be your core business. Harmony is built on three guiding principles that make modern platforms reliable, repeatable, and easy to reason about.
|
||||
|
||||

|
||||
````
|
||||
## Supporting a new field in OPNSense `config.xml`
|
||||
| Principle | What it means for you |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| **Infrastructure as Resilient Code** | Replace sprawling YAML and bash scripts with type-safe Rust. Test, refactor, and version your platform just like application code. |
|
||||
| **Prove It Works — Before You Deploy** | Harmony uses the compiler to verify that your application’s needs match the target environment’s capabilities at **compile-time**, eliminating an entire class of runtime outages. |
|
||||
| **One Unified Model** | Software and infrastructure are a single system. Harmony models them together, enabling deep automation—from bare-metal servers to Kubernetes workloads—with zero context switching. |
|
||||
|
||||
Two steps:
|
||||
- Supporting the field in `opnsense-config-xml`
|
||||
- Enabling Harmony to control the field
|
||||
These principles surface as simple, ergonomic Rust APIs that let teams focus on their product while trusting the platform underneath.
|
||||
|
||||
We'll use the `filename` field in the `dhcpcd` section of the file as an example.
|
||||
---
|
||||
|
||||
### Supporting the field
|
||||
## 2 · Quick Start
|
||||
|
||||
As type checking if enforced, every field from `config.xml` must be known by the code. Each subsection of `config.xml` has its `.rs` file. For the `dhcpcd` section, we'll modify `opnsense-config-xml/src/data/dhcpd.rs`.
|
||||
The snippet below spins up a complete **production-grade LAMP stack** with monitoring. Swap it for your own scores to deploy anything from microservices to machine-learning pipelines.
|
||||
|
||||
When a new field appears in the xml file, an error like this will be thrown and Harmony will panic :
|
||||
```
|
||||
Running `/home/stremblay/nt/dir/harmony/target/debug/example-nanodc`
|
||||
Found unauthorized element filename
|
||||
thread 'main' panicked at opnsense-config-xml/src/data/opnsense.rs:54:14:
|
||||
OPNSense received invalid string, should be full XML: ()
|
||||
|
||||
```
|
||||
|
||||
Define the missing field (`filename`) in the `DhcpInterface` struct of `opnsense-config-xml/src/data/dhcpd.rs`:
|
||||
```
|
||||
pub struct DhcpInterface {
|
||||
...
|
||||
pub filename: Option<String>,
|
||||
```
|
||||
|
||||
Harmony should now be fixed, build and run.
|
||||
|
||||
### Controlling the field
|
||||
|
||||
Define the `xml field setter` in `opnsense-config/src/modules/dhcpd.rs`.
|
||||
```
|
||||
impl<'a> DhcpConfig<'a> {
|
||||
...
|
||||
pub fn set_filename(&mut self, filename: &str) {
|
||||
self.enable_netboot();
|
||||
self.get_lan_dhcpd().filename = Some(filename.to_string());
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
Define the `value setter` in the `DhcpServer trait` in `domain/topology/network.rs`
|
||||
```
|
||||
#[async_trait]
|
||||
pub trait DhcpServer: Send + Sync {
|
||||
...
|
||||
async fn set_filename(&self, filename: &str) -> Result<(), ExecutorError>;
|
||||
...
|
||||
```
|
||||
|
||||
Implement the `value setter` in each `DhcpServer` implementation.
|
||||
`infra/opnsense/dhcp.rs`:
|
||||
```
|
||||
#[async_trait]
|
||||
impl DhcpServer for OPNSenseFirewall {
|
||||
...
|
||||
async fn set_filename(&self, filename: &str) -> Result<(), ExecutorError> {
|
||||
{
|
||||
let mut writable_opnsense = self.opnsense_config.write().await;
|
||||
writable_opnsense.dhcp().set_filename(filename);
|
||||
debug!("OPNsense dhcp server set filename {filename}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
`domain/topology/ha_cluster.rs`
|
||||
```
|
||||
#[async_trait]
|
||||
impl DhcpServer for DummyInfra {
|
||||
...
|
||||
async fn set_filename(&self, _filename: &str) -> Result<(), ExecutorError> {
|
||||
unimplemented!("{}", UNIMPLEMENTED_DUMMY_INFRA)
|
||||
}
|
||||
...
|
||||
```
|
||||
|
||||
Add the new field to the DhcpScore in `modules/dhcp.rs`
|
||||
```
|
||||
pub struct DhcpScore {
|
||||
...
|
||||
pub filename: Option<String>,
|
||||
```
|
||||
|
||||
Define it in its implementation in `modules/okd/dhcp.rs`
|
||||
```
|
||||
impl OKDDhcpScore {
|
||||
...
|
||||
Self {
|
||||
dhcp_score: DhcpScore {
|
||||
...
|
||||
filename: Some("undionly.kpxe".to_string()),
|
||||
```
|
||||
|
||||
Define it in its implementation in `modules/okd/bootstrap_dhcp.rs`
|
||||
```
|
||||
impl OKDDhcpScore {
|
||||
...
|
||||
Self {
|
||||
dhcp_score: DhcpScore::new(
|
||||
...
|
||||
Some("undionly.kpxe".to_string()),
|
||||
```
|
||||
|
||||
Update the interpret (function called by the `execute` fn of the interpret) so it now updates the `filename` field value in `modules/dhcp.rs`
|
||||
```
|
||||
impl DhcpInterpret {
|
||||
...
|
||||
let filename_outcome = match &self.score.filename {
|
||||
Some(filename) => {
|
||||
let dhcp_server = Arc::new(topology.dhcp_server.clone());
|
||||
dhcp_server.set_filename(&filename).await?;
|
||||
Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
format!("Dhcp Interpret Set filename to {filename}"),
|
||||
)
|
||||
}
|
||||
None => Outcome::noop(),
|
||||
```rust
|
||||
use harmony::{
|
||||
data::Version,
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::{
|
||||
lamp::{LAMPConfig, LAMPScore},
|
||||
monitoring::monitoring_alerting::MonitoringAlertingStackScore,
|
||||
},
|
||||
topology::{K8sAnywhereTopology, Url},
|
||||
};
|
||||
|
||||
if next_server_outcome.status == InterpretStatus::NOOP
|
||||
&& boot_filename_outcome.status == InterpretStatus::NOOP
|
||||
&& filename_outcome.status == InterpretStatus::NOOP
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// 1. Describe what you want
|
||||
let lamp_stack = LAMPScore {
|
||||
name: "harmony-lamp-demo".into(),
|
||||
domain: Url::Url(url::Url::parse("https://lampdemo.example.com").unwrap()),
|
||||
php_version: Version::from("8.3.0").unwrap(),
|
||||
config: LAMPConfig {
|
||||
project_root: "./php".into(),
|
||||
database_size: "4Gi".into(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
...
|
||||
// 2. Enhance with extra scores (monitoring, CI/CD, …)
|
||||
let mut monitoring = MonitoringAlertingStackScore::new();
|
||||
monitoring.namespace = Some(lamp_stack.config.namespace.clone());
|
||||
|
||||
Ok(Outcome::new(
|
||||
InterpretStatus::SUCCESS,
|
||||
format!(
|
||||
"Dhcp Interpret Set next boot to [{:?}], boot_filename to [{:?}], filename to [{:?}]",
|
||||
self.score.boot_filename, self.score.boot_filename, self.score.filename
|
||||
)
|
||||
...
|
||||
// 3. Run your scores on the desired topology & inventory
|
||||
harmony_cli::run(
|
||||
Inventory::autoload(), // auto-detect hardware / kube-config
|
||||
K8sAnywhereTopology::from_env(), // local k3d, CI, staging, prod…
|
||||
vec![
|
||||
Box::new(lamp_stack),
|
||||
Box::new(monitoring)
|
||||
],
|
||||
None
|
||||
).await.unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
Run it:
|
||||
|
||||
```bash
|
||||
cargo run
|
||||
```
|
||||
|
||||
Harmony analyses the code, shows an execution plan in a TUI, and applies it once you confirm. Same code, same binary—every environment.
|
||||
|
||||
---
|
||||
|
||||
## 3 · Core Concepts
|
||||
|
||||
| Term | One-liner |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------- |
|
||||
| **Score<T>** | Declarative description of the desired state (e.g., `LAMPScore`). |
|
||||
| **Interpret<T>** | Imperative logic that realises a `Score` on a specific environment. |
|
||||
| **Topology** | An environment (local k3d, AWS, bare-metal) exposing verified _Capabilities_ (Kubernetes, DNS, …). |
|
||||
| **Maestro** | Orchestrator that compiles Scores + Topology, ensuring all capabilities line up **at compile-time**. |
|
||||
| **Inventory** | Optional catalogue of physical assets for bare-metal and edge deployments. |
|
||||
|
||||
A visual overview is in the diagram below.
|
||||
|
||||
[Harmony Core Architecture](docs/diagrams/Harmony_Core_Architecture.drawio.svg)
|
||||
|
||||
---
|
||||
|
||||
## 4 · Install
|
||||
|
||||
Prerequisites:
|
||||
|
||||
- Rust
|
||||
- Docker (if you deploy locally)
|
||||
- `kubectl` / `helm` for Kubernetes-based topologies
|
||||
|
||||
```bash
|
||||
git clone https://git.nationtech.io/nationtech/harmony
|
||||
cd harmony
|
||||
cargo build --release # builds the CLI, TUI and libraries
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5 · Learning More
|
||||
|
||||
- **Architectural Decision Records** – dive into the rationale
|
||||
- [ADR-001 · Why Rust](adr/001-rust.md)
|
||||
- [ADR-003 · Infrastructure Abstractions](adr/003-infrastructure-abstractions.md)
|
||||
- [ADR-006 · Secret Management](adr/006-secret-management.md)
|
||||
- [ADR-011 · Multi-Tenant Cluster](adr/011-multi-tenant-cluster.md)
|
||||
|
||||
- **Extending Harmony** – write new Scores / Interprets, add hardware like OPNsense firewalls, or embed Harmony in your own tooling (`/docs`).
|
||||
|
||||
- **Community** – discussions and roadmap live in [GitLab issues](https://git.nationtech.io/nationtech/harmony/-/issues). PRs, ideas, and feedback are welcome!
|
||||
|
||||
---
|
||||
|
||||
## 6 · License
|
||||
|
||||
Harmony is released under the **GNU AGPL v3**.
|
||||
|
||||
> We choose a strong copyleft license to ensure the project—and every improvement to it—remains open and benefits the entire community. Fork it, enhance it, even out-innovate us; just keep it open.
|
||||
|
||||
See [LICENSE](LICENSE) for the full text.
|
||||
|
||||
---
|
||||
|
||||
_Made with ❤️ & 🦀 by the NationTech and the Harmony community_
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture Decision Record: \<Title\>
|
||||
|
||||
Name: \<Name\>
|
||||
Initial Author: \<Name\>
|
||||
|
||||
Initial Date: \<Date\>
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture Decision Record: Helm and Kustomize Handling
|
||||
|
||||
Name: Taha Hawa
|
||||
Initial Author: Taha Hawa
|
||||
|
||||
Initial Date: 2025-04-15
|
||||
|
||||
|
||||
73
adr/010-monitoring-alerting/architecture.rs
Normal file
73
adr/010-monitoring-alerting/architecture.rs
Normal file
@@ -0,0 +1,73 @@
|
||||
pub trait MonitoringSystem {}
|
||||
|
||||
// 1. Modified AlertReceiver trait:
|
||||
// - Removed the problematic `clone` method.
|
||||
// - Added `box_clone` which returns a Box<dyn AlertReceiver>.
|
||||
pub trait AlertReceiver {
|
||||
type M: MonitoringSystem;
|
||||
fn install(&self, sender: &Self::M) -> Result<(), String>;
|
||||
// This method allows concrete types to clone themselves into a Box<dyn AlertReceiver>
|
||||
fn box_clone(&self) -> Box<dyn AlertReceiver<M = Self::M>>;
|
||||
}
|
||||
#[derive(Clone)]
|
||||
struct Prometheus{}
|
||||
impl MonitoringSystem for Prometheus {}
|
||||
|
||||
#[derive(Clone)] // Keep derive(Clone) for DiscordWebhook itself
|
||||
struct DiscordWebhook{}
|
||||
|
||||
impl AlertReceiver for DiscordWebhook {
|
||||
type M = Prometheus;
|
||||
fn install(&self, sender: &Self::M) -> Result<(), String> {
|
||||
// Placeholder for actual installation logic
|
||||
println!("DiscordWebhook installed for Prometheus monitoring.");
|
||||
Ok(())
|
||||
}
|
||||
// 2. Implement `box_clone` for DiscordWebhook:
|
||||
// This uses the derived `Clone` for DiscordWebhook to create a new boxed instance.
|
||||
fn box_clone(&self) -> Box<dyn AlertReceiver<M = Self::M>> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Implement `std::clone::Clone` for `Box<dyn AlertReceiver<M= M>>`:
|
||||
// This allows `Box<dyn AlertReceiver>` to be cloned.
|
||||
// The `+ 'static` lifetime bound is often necessary for trait objects stored in collections,
|
||||
// ensuring they live long enough.
|
||||
impl<M: MonitoringSystem + 'static> Clone for Box<dyn AlertReceiver<M= M>> {
|
||||
fn clone(&self) -> Self {
|
||||
self.box_clone() // Call the custom `box_clone` method
|
||||
}
|
||||
}
|
||||
|
||||
// MonitoringConfig can now derive Clone because its `receivers` field
|
||||
// (Vec<Box<dyn AlertReceiver<M = M>>>) is now cloneable.
|
||||
#[derive(Clone)]
|
||||
struct MonitoringConfig <M: MonitoringSystem + 'static>{
|
||||
receivers: Vec<Box<dyn AlertReceiver<M = M>>>
|
||||
}
|
||||
|
||||
// Example usage to demonstrate compilation and functionality
|
||||
fn main() {
|
||||
let prometheus_instance = Prometheus{};
|
||||
let discord_webhook_instance = DiscordWebhook{};
|
||||
|
||||
let mut config = MonitoringConfig {
|
||||
receivers: Vec::new()
|
||||
};
|
||||
|
||||
// Create a boxed alert receiver
|
||||
let boxed_receiver: Box<dyn AlertReceiver<M = Prometheus>> = Box::new(discord_webhook_instance);
|
||||
config.receivers.push(boxed_receiver);
|
||||
|
||||
// Clone the config, which will now correctly clone the boxed receiver
|
||||
let cloned_config = config.clone();
|
||||
|
||||
println!("Original config has {} receivers.", config.receivers.len());
|
||||
println!("Cloned config has {} receivers.", cloned_config.receivers.len());
|
||||
|
||||
// Example of using the installed receiver
|
||||
if let Some(receiver) = config.receivers.get(0) {
|
||||
let _ = receiver.install(&prometheus_instance);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
# Architecture Decision Record: Monitoring and Alerting
|
||||
|
||||
Proposed by: Willem Rolleman
|
||||
Initial Author : Willem Rolleman
|
||||
Date : April 28 2025
|
||||
|
||||
## Status
|
||||
|
||||
161
adr/011-multi-tenant-cluster.md
Normal file
161
adr/011-multi-tenant-cluster.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Architecture Decision Record: Multi-Tenancy Strategy for Harmony Managed Clusters
|
||||
|
||||
Initial Author: Jean-Gabriel Gill-Couture
|
||||
|
||||
Initial Date: 2025-05-26
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
Harmony manages production OKD/Kubernetes clusters that serve multiple clients with varying trust levels and operational requirements. We need a multi-tenancy strategy that provides:
|
||||
|
||||
1. **Strong isolation** between client workloads while maintaining operational simplicity
|
||||
2. **Controlled API access** allowing clients self-service capabilities within defined boundaries
|
||||
3. **Security-first approach** protecting both the cluster infrastructure and tenant data
|
||||
4. **Harmony-native implementation** using our Score/Interpret pattern for automated tenant provisioning
|
||||
5. **Scalable management** supporting both small trusted clients and larger enterprise customers
|
||||
|
||||
The official Kubernetes multi-tenancy documentation identifies two primary models: namespace-based isolation and virtual control planes per tenant. Given Harmony's focus on operational simplicity, provider-agnostic abstractions (ADR-003), and hexagonal architecture (ADR-002), we must choose an approach that balances security, usability, and maintainability.
|
||||
|
||||
Our clients represent a hybrid tenancy model:
|
||||
- **Customer multi-tenancy**: Each client operates independently with no cross-tenant trust
|
||||
- **Team multi-tenancy**: Individual clients may have multiple team members requiring coordinated access
|
||||
- **API access requirement**: Unlike pure SaaS scenarios, clients need controlled Kubernetes API access for self-service operations
|
||||
|
||||
The official kubernetes documentation on multi tenancy heavily inspired this ADR : https://kubernetes.io/docs/concepts/security/multi-tenancy/
|
||||
|
||||
## Decision
|
||||
|
||||
Implement **namespace-based multi-tenancy** with the following architecture:
|
||||
|
||||
### 1. Network Security Model
|
||||
- **Private cluster access**: Kubernetes API and OpenShift console accessible only via WireGuard VPN
|
||||
- **No public exposure**: Control plane endpoints remain internal to prevent unauthorized access attempts
|
||||
- **VPN-based authentication**: Initial access control through WireGuard client certificates
|
||||
|
||||
### 2. Tenant Isolation Strategy
|
||||
- **Dedicated namespace per tenant**: Each client receives an isolated namespace with access limited only to the required resources and operations
|
||||
- **Complete network isolation**: NetworkPolicies prevent cross-namespace communication while allowing full egress to public internet
|
||||
- **Resource governance**: ResourceQuotas and LimitRanges enforce CPU, memory, and storage consumption limits
|
||||
- **Storage access control**: Clients can create PersistentVolumeClaims but cannot directly manipulate PersistentVolumes or access other tenants' storage
|
||||
|
||||
### 3. Access Control Framework
|
||||
- **Principle of Least Privilege**: RBAC grants only necessary permissions within tenant namespace scope
|
||||
- **Namespace-scoped**: Clients can create/modify/delete resources within their namespace
|
||||
- **Cluster-level restrictions**: No access to cluster-wide resources, other namespaces, or sensitive cluster operations
|
||||
- **Whitelisted operations**: Controlled self-service capabilities for ingress, secrets, configmaps, and workload management
|
||||
|
||||
### 4. Identity Management Evolution
|
||||
- **Phase 1**: Manual provisioning of VPN access and Kubernetes ServiceAccounts/Users
|
||||
- **Phase 2**: Migration to Keycloak-based identity management (aligning with ADR-006) for centralized authentication and lifecycle management
|
||||
|
||||
### 5. Harmony Integration
|
||||
- **TenantScore implementation**: Declarative tenant provisioning using Harmony's Score/Interpret pattern
|
||||
- **Topology abstraction**: Tenant configuration abstracted from underlying Kubernetes implementation details
|
||||
- **Automated deployment**: Complete tenant setup automated through Harmony's orchestration capabilities
|
||||
|
||||
## Rationale
|
||||
|
||||
### Network Security Through VPN Access
|
||||
- **Defense in depth**: VPN requirement adds critical security layer preventing unauthorized cluster access
|
||||
- **Simplified firewall rules**: No need for complex public endpoint protections or rate limiting
|
||||
- **Audit capability**: VPN access provides clear audit trail of cluster connections
|
||||
- **Aligns with enterprise practices**: Most enterprise customers already use VPN infrastructure
|
||||
|
||||
### Namespace Isolation vs Virtual Control Planes
|
||||
Following Kubernetes official guidance, namespace isolation provides:
|
||||
- **Lower resource overhead**: Virtual control planes require dedicated etcd, API server, and controller manager per tenant
|
||||
- **Operational simplicity**: Single control plane to maintain, upgrade, and monitor
|
||||
- **Cross-tenant service integration**: Enables future controlled cross-tenant communication if required
|
||||
- **Proven stability**: Namespace-based isolation is well-tested and widely deployed
|
||||
- **Cost efficiency**: Significantly lower infrastructure costs compared to dedicated control planes
|
||||
|
||||
### Hybrid Tenancy Model Suitability
|
||||
Our approach addresses both customer and team multi-tenancy requirements:
|
||||
- **Customer isolation**: Strong network and RBAC boundaries prevent cross-tenant interference
|
||||
- **Team collaboration**: Multiple team members can share namespace access through group-based RBAC
|
||||
- **Self-service balance**: Controlled API access enables client autonomy without compromising security
|
||||
|
||||
### Harmony Architecture Alignment
|
||||
- **Provider agnostic**: TenantScore abstracts multi-tenancy concepts, enabling future support for other Kubernetes distributions
|
||||
- **Hexagonal architecture**: Tenant management becomes an infrastructure capability accessed through well-defined ports
|
||||
- **Declarative automation**: Tenant lifecycle fully managed through Harmony's Score execution model
|
||||
|
||||
## Consequences
|
||||
|
||||
### Positive Consequences
|
||||
- **Strong security posture**: VPN + namespace isolation provides robust tenant separation
|
||||
- **Operational efficiency**: Single cluster management with automated tenant provisioning
|
||||
- **Client autonomy**: Self-service capabilities reduce operational support burden
|
||||
- **Scalable architecture**: Can support hundreds of tenants per cluster without architectural changes
|
||||
- **Future flexibility**: Foundation supports evolution to more sophisticated multi-tenancy models
|
||||
- **Cost optimization**: Shared infrastructure maximizes resource utilization
|
||||
|
||||
### Negative Consequences
|
||||
- **VPN operational overhead**: Requires VPN infrastructure management
|
||||
- **Manual provisioning complexity**: Phase 1 manual user management creates administrative burden
|
||||
- **Network policy dependency**: Requires CNI with NetworkPolicy support (OVN-Kubernetes provides this and is the OKD/Openshift default)
|
||||
- **Cluster-wide resource limitations**: Some advanced Kubernetes features require cluster-wide access
|
||||
- **Single point of failure**: Cluster outage affects all tenants simultaneously
|
||||
|
||||
### Migration Challenges
|
||||
- **Legacy client integration**: Existing clients may need VPN client setup and credential migration
|
||||
- **Monitoring complexity**: Per-tenant observability requires careful metric and log segmentation
|
||||
- **Backup considerations**: Tenant data backup must respect isolation boundaries
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
### Alternative 1: Virtual Control Plane Per Tenant
|
||||
**Pros**: Complete control plane isolation, full Kubernetes API access per tenant
|
||||
**Cons**: 3-5x higher resource usage, complex cross-tenant networking, operational complexity scales linearly with tenants
|
||||
|
||||
**Rejected**: Resource overhead incompatible with cost-effective multi-tenancy goals
|
||||
|
||||
### Alternative 2: Dedicated Clusters Per Tenant
|
||||
**Pros**: Maximum isolation, independent upgrade cycles, simplified security model
|
||||
**Cons**: Exponential operational complexity, prohibitive costs, resource waste
|
||||
|
||||
**Rejected**: Operational overhead makes this approach unsustainable for multiple clients
|
||||
|
||||
### Alternative 3: Public API with Advanced Authentication
|
||||
**Pros**: No VPN requirement, potentially simpler client access
|
||||
**Cons**: Larger attack surface, complex rate limiting and DDoS protection, increased security monitoring requirements
|
||||
|
||||
**Rejected**: Risk/benefit analysis favors VPN-based access control
|
||||
|
||||
### Alternative 4: Service Mesh Based Isolation
|
||||
**Pros**: Fine-grained traffic control, encryption, advanced observability
|
||||
**Cons**: Significant operational complexity, performance overhead, steep learning curve
|
||||
|
||||
**Rejected**: Complexity overhead outweighs benefits for current requirements; remains option for future enhancement
|
||||
|
||||
## Additional Notes
|
||||
|
||||
### Implementation Roadmap
|
||||
1. **Phase 1**: Implement VPN access and manual tenant provisioning
|
||||
2. **Phase 2**: Deploy TenantScore automation for namespace, RBAC, and NetworkPolicy management
|
||||
4. **Phase 3**: Work on privilege escalation from pods, audit for weaknesses, enforce security policies on pod runtimes
|
||||
3. **Phase 4**: Integrate Keycloak for centralized identity management
|
||||
4. **Phase 5**: Add advanced monitoring and per-tenant observability
|
||||
|
||||
### TenantScore Structure Preview
|
||||
```rust
|
||||
pub struct TenantScore {
|
||||
pub tenant_config: TenantConfig,
|
||||
pub resource_quotas: ResourceQuotaConfig,
|
||||
pub network_isolation: NetworkIsolationPolicy,
|
||||
pub storage_access: StorageAccessConfig,
|
||||
pub rbac_config: RBACConfig,
|
||||
}
|
||||
```
|
||||
|
||||
### Future Enhancements
|
||||
- **Cross-tenant service mesh**: For approved inter-tenant communication
|
||||
- **Advanced monitoring**: Per-tenant Prometheus/Grafana instances
|
||||
- **Backup automation**: Tenant-scoped backup policies
|
||||
- **Cost allocation**: Detailed per-tenant resource usage tracking
|
||||
|
||||
This ADR establishes the foundation for secure, scalable multi-tenancy in Harmony-managed clusters while maintaining operational simplicity and cost effectiveness. A follow-up ADR will detail the Tenant abstraction and user management mechanisms within the Harmony framework.
|
||||
41
adr/011-tenant/NetworkPolicy.yaml
Normal file
41
adr/011-tenant/NetworkPolicy.yaml
Normal file
@@ -0,0 +1,41 @@
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: tenant-isolation-policy
|
||||
namespace: testtenant
|
||||
spec:
|
||||
podSelector: {} # Selects all pods in the namespace
|
||||
policyTypes:
|
||||
- Ingress
|
||||
- Egress
|
||||
ingress:
|
||||
- from:
|
||||
- podSelector: {} # Allow from all pods in the same namespace
|
||||
egress:
|
||||
- to:
|
||||
- podSelector: {} # Allow to all pods in the same namespace
|
||||
- to:
|
||||
- podSelector: {}
|
||||
namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: openshift-dns # Target the openshift-dns namespace
|
||||
# Note, only opening port 53 is not enough, will have to dig deeper into this one eventually
|
||||
# ports:
|
||||
# - protocol: UDP
|
||||
# port: 53
|
||||
# - protocol: TCP
|
||||
# port: 53
|
||||
# Allow egress to public internet only
|
||||
- to:
|
||||
- ipBlock:
|
||||
cidr: 0.0.0.0/0
|
||||
except:
|
||||
- 10.0.0.0/8 # RFC1918
|
||||
- 172.16.0.0/12 # RFC1918
|
||||
- 192.168.0.0/16 # RFC1918
|
||||
- 169.254.0.0/16 # Link-local
|
||||
- 127.0.0.0/8 # Loopback
|
||||
- 224.0.0.0/4 # Multicast
|
||||
- 240.0.0.0/4 # Reserved
|
||||
- 100.64.0.0/10 # Carrier-grade NAT
|
||||
- 0.0.0.0/8 # Reserved
|
||||
95
adr/011-tenant/TestDeployment.yaml
Normal file
95
adr/011-tenant/TestDeployment.yaml
Normal file
@@ -0,0 +1,95 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: testtenant
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: testtenant2
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: test-web
|
||||
namespace: testtenant
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: test-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: test-web
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginxinc/nginx-unprivileged
|
||||
ports:
|
||||
- containerPort: 80
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: test-web
|
||||
namespace: testtenant
|
||||
spec:
|
||||
selector:
|
||||
app: test-web
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: test-client
|
||||
namespace: testtenant
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: test-client
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: test-client
|
||||
spec:
|
||||
containers:
|
||||
- name: curl
|
||||
image: curlimages/curl:latest
|
||||
command: ["/bin/sh", "-c", "sleep 3600"]
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: test-web
|
||||
namespace: testtenant2
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: test-web
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: test-web
|
||||
spec:
|
||||
containers:
|
||||
- name: nginx
|
||||
image: nginxinc/nginx-unprivileged
|
||||
ports:
|
||||
- containerPort: 80
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: test-web
|
||||
namespace: testtenant2
|
||||
spec:
|
||||
selector:
|
||||
app: test-web
|
||||
ports:
|
||||
- port: 80
|
||||
targetPort: 8080
|
||||
63
adr/012-project-delivery-automation.md
Normal file
63
adr/012-project-delivery-automation.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# Architecture Decision Record: \<Title\>
|
||||
|
||||
Initial Author: Jean-Gabriel Gill-Couture
|
||||
|
||||
Initial Date: 2025-06-04
|
||||
|
||||
Last Updated Date: 2025-06-04
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
As Harmony's goal is to make software delivery easier, we must provide an easy way for developers to express their app's semantics and dependencies with great abstractions, in a similar fashion to what the score.dev project is doing.
|
||||
|
||||
Thus, we started working on ways to package common types of applications such as LAMP, which we started working on with `LAMPScore`.
|
||||
|
||||
Now is time for the next step : we want to pave the way towards complete lifecycle automation. To do this, we will start with a way to execute Harmony's modules easily from anywhere, starting with locally and in CI environments.
|
||||
|
||||
## Decision
|
||||
|
||||
To achieve easy, portable execution of Harmony, we will follow this architecture :
|
||||
|
||||
- Host a basic harmony release that is compiled with the CLI by our gitea/github server
|
||||
- This binary will do the following : check if there is a `harmony` folder in the current path
|
||||
- If yes
|
||||
- Check if cargo is available locally and compile the harmony binary, or compile the harmony binary using a rust docker container, if neither cargo or a container runtime is available, output a message explaining the situation
|
||||
- Run the newly compiled binary. (Ideally using pid handoff like exec does but some research around this should be done. I think handing off the process is to help with OS interaction such as terminal apps, signals, exit codes, process handling, etc but there might be some side effects)
|
||||
- If not
|
||||
- Suggest initializing a project by auto detecting what the project looks like
|
||||
- When the project type cannot be auto detected, provide links to Harmony's documentation on how to set up a project, a link to the examples folder, and a ask the user if he wants to initialize an empty Harmony project in the current folder
|
||||
- harmony/Cargo.toml with dependencies set
|
||||
- harmony/src/main.rs with an example LAMPScore setup and ready to run
|
||||
- This same binary can be used in a CI environment to run the target project's Harmony module. By default, we provide these opinionated steps :
|
||||
1. **An empty check step.** The purpose of this step is to run all tests and checks against the codebase. For complex projects this could involve a very complex pipeline of test environments setup and execution but this is out of scope for now. This is not handled by harmony. For projects with automatic setup, we can fill this step with something like `cargo fmt --check; cargo test; cargo build` but Harmony is not directly involved in the execution of this step.
|
||||
2. **Package and publish.** Once all checks have passed, the production ready container is built and pushed to a registry. This is done by Harmony.
|
||||
3. **Deploy to staging automatically.**
|
||||
4. **Run a sanity check on staging.** As Harmony is responsible for deploying, Harmony should have all the knowledge of how to perform a sanity check on the staging environment. This will, most of the time, be a simple verification of the kubernetes health of all deployed components, and a poke on the public endpoint when there is one.
|
||||
5. **Deploy to production automatically.** Many projects will require manual approval here, this can be easily set up in the CI afterwards, but our opinion is that
|
||||
6. **Run a sanity check on production.** Same check as staging, but on production.
|
||||
|
||||
*Note on providing a base pipeline :* Having a complete pipeline set up automatically will encourage development teams to build upon these by adding tests where they belong. The goal here is to provide an opiniated solution that works for most small and large projects. Of course, many orgnizations will need to add steps such as deploying to sandbox environments, requiring more advanced approvals, more complex publication and coordination with other projects. But this here encompasses the basics required to build and deploy software reliably at any scale.
|
||||
|
||||
### Environment setup
|
||||
|
||||
TBD : For now, environments (tenants) will be set up and configured manually. Harmony will rely on the kubeconfig provided in the environment where it is running to deploy in the namespace.
|
||||
|
||||
For the CD tool such as Argo or Flux they will be activated by default by Harmony when using application level Scores such as LAMPScore in a similar way that the container is automatically built. Then, CI deployment steps will be notifying the CD tool using its API of the new release to deploy.
|
||||
|
||||
## Rationale
|
||||
|
||||
Reasoning behind the decision
|
||||
|
||||
## Consequences
|
||||
|
||||
Pros/Cons of chosen solution
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
Pros/Cons of various proposed solutions considered
|
||||
|
||||
## Additional Notes
|
||||
78
adr/013-monitoring-notifications.md
Normal file
78
adr/013-monitoring-notifications.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Architecture Decision Record: Monitoring Notifications
|
||||
|
||||
Initial Author: Taha Hawa
|
||||
|
||||
Initial Date: 2025-06-26
|
||||
|
||||
Last Updated Date: 2025-06-26
|
||||
|
||||
## Status
|
||||
|
||||
Proposed
|
||||
|
||||
## Context
|
||||
|
||||
We need to send notifications (typically from AlertManager/Prometheus) and we need to receive said notifications on mobile devices for sure in some way, whether it's push messages, SMS, phone call, email, etc or all of the above.
|
||||
|
||||
## Decision
|
||||
|
||||
We should go with https://ntfy.sh except host it ourselves.
|
||||
|
||||
`ntfy` is an open source solution written in Go that has the features we need.
|
||||
|
||||
## Rationale
|
||||
|
||||
`ntfy` has pretty much everything we need (push notifications, email forwarding, receives via webhook), and nothing/not much we don't. Good fit, lightweight.
|
||||
|
||||
## Consequences
|
||||
|
||||
Pros:
|
||||
|
||||
- topics, with ACLs
|
||||
- lightweight
|
||||
- reliable
|
||||
- easy to configure
|
||||
- mobile app
|
||||
- the mobile app can listen via websocket, poll, or receive via Firebase/GCM on Android, or similar on iOS.
|
||||
- Forward to email
|
||||
- Text-to-Speech phone call messages using Twilio integration
|
||||
- Operates based on simple HTTP requests/Webhooks, easily usable via AlertManager
|
||||
|
||||
Cons:
|
||||
|
||||
- No SMS pushes
|
||||
- SQLite DB, makes it harder to HA/scale
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
[AWS SNS](https://aws.amazon.com/sns/):
|
||||
Pros:
|
||||
|
||||
- highly reliable
|
||||
- no hosting needed
|
||||
|
||||
Cons:
|
||||
|
||||
- no control, not self hosted
|
||||
- costs (per usage)
|
||||
|
||||
[Apprise](https://github.com/caronc/apprise):
|
||||
Pros:
|
||||
|
||||
- Way more ways of sending notifications
|
||||
- Can use ntfy as one of the backends/ways of sending
|
||||
|
||||
Cons:
|
||||
|
||||
- Way too overkill for what we need in terms of features
|
||||
|
||||
[Gotify](https://github.com/gotify/server):
|
||||
Pros:
|
||||
|
||||
- simple, lightweight, golang, etc
|
||||
|
||||
Cons:
|
||||
|
||||
- Pushes topics are per-user
|
||||
|
||||
## Additional Notes
|
||||
2
check.sh
Normal file → Executable file
2
check.sh
Normal file → Executable file
@@ -1,5 +1,7 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
cargo check --all-targets --all-features --keep-going
|
||||
cargo fmt --check
|
||||
cargo clippy
|
||||
cargo test
|
||||
|
||||
1
docs/README.md
Normal file
1
docs/README.md
Normal file
@@ -0,0 +1 @@
|
||||
Not much here yet, see the `adr` folder for now. More to come in time!
|
||||
13
docs/cyborg-metaphor.md
Normal file
13
docs/cyborg-metaphor.md
Normal file
@@ -0,0 +1,13 @@
|
||||
## Conceptual metaphor : The Cyborg and the Central Nervous System
|
||||
|
||||
At the heart of Harmony lies a core belief: in modern, decentralized systems, **software and infrastructure are not separate entities.** They are a single, symbiotic organism—a cyborg.
|
||||
|
||||
The software is the electronics, the "mind"; the infrastructure is the biological host, the "body". They live or die, thrive or sink together.
|
||||
|
||||
Traditional approaches attempt to manage this complex organism with fragmented tools: static YAML for configuration, brittle scripts for automation, and separate Infrastructure as Code (IaC) for provisioning. This creates a disjointed system that struggles to scale or heal itself, making it inadequate for the demands of fully automated, enterprise-grade clusters.
|
||||
|
||||
Harmony's goal is to provide the **central nervous system for this cyborg**. We aim to achieve the full automation of complex, decentralized clouds by managing this integrated entity holistically.
|
||||
|
||||
To achieve this, a tool must be both robust and powerful. It must manage the entire lifecycle—deployment, upgrades, failure recovery, and decommissioning—with precision. This requires full control over application packaging and a deep, intrinsic integration between the software and the infrastructure it inhabits.
|
||||
|
||||
This is why Harmony uses a powerful, living language like Rust. It replaces static, lifeless configuration files with a dynamic, breathing codebase. It allows us to express the complex relationships and behaviors of a modern distributed system, enabling the creation of truly automated, resilient, and powerful platforms that can thrive.
|
||||
14
examples/application_monitoring_with_tenant/Cargo.toml
Normal file
14
examples/application_monitoring_with_tenant/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "example-application-monitoring-with-tenant"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
env_logger.workspace = true
|
||||
harmony = { version = "0.1.0", path = "../../harmony" }
|
||||
harmony_cli = { version = "0.1.0", path = "../../harmony_cli" }
|
||||
logging = "0.1.0"
|
||||
tokio.workspace = true
|
||||
url.workspace = true
|
||||
55
examples/application_monitoring_with_tenant/src/main.rs
Normal file
55
examples/application_monitoring_with_tenant/src/main.rs
Normal file
@@ -0,0 +1,55 @@
|
||||
use std::{path::PathBuf, str::FromStr, sync::Arc};
|
||||
|
||||
use harmony::{
|
||||
data::Id,
|
||||
inventory::Inventory,
|
||||
modules::{
|
||||
application::{ApplicationScore, RustWebFramework, RustWebapp, features::Monitoring},
|
||||
monitoring::alert_channel::webhook_receiver::WebhookReceiver,
|
||||
tenant::TenantScore,
|
||||
},
|
||||
topology::{K8sAnywhereTopology, Url, tenant::TenantConfig},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
//TODO there is a bug where the application is deployed into the namespace matching the
|
||||
//application name and the tenant is created in the namesapce matching the tenant name
|
||||
//in order for the application to be deployed in the tenant namespace the application.name and
|
||||
//the TenantConfig.name must match
|
||||
let tenant = TenantScore {
|
||||
config: TenantConfig {
|
||||
id: Id::from_str("test-tenant-id").unwrap(),
|
||||
name: "example-monitoring".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
let application = Arc::new(RustWebapp {
|
||||
name: "example-monitoring".to_string(),
|
||||
domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()),
|
||||
project_root: PathBuf::from("./examples/rust/webapp"),
|
||||
framework: Some(RustWebFramework::Leptos),
|
||||
});
|
||||
|
||||
let webhook_receiver = WebhookReceiver {
|
||||
name: "sample-webhook-receiver".to_string(),
|
||||
url: Url::Url(url::Url::parse("https://webhook-doesnt-exist.com").unwrap()),
|
||||
};
|
||||
|
||||
let app = ApplicationScore {
|
||||
features: vec![Box::new(Monitoring {
|
||||
alert_receiver: vec![Box::new(webhook_receiver)],
|
||||
application: application.clone(),
|
||||
})],
|
||||
application,
|
||||
};
|
||||
|
||||
harmony_cli::run(
|
||||
Inventory::autoload(),
|
||||
K8sAnywhereTopology::from_env(),
|
||||
vec![Box::new(tenant), Box::new(app)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
use harmony::{
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::dummy::{ErrorScore, PanicScore, SuccessScore},
|
||||
topology::LocalhostTopology,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let inventory = Inventory::autoload();
|
||||
let topology = LocalhostTopology::new();
|
||||
let mut maestro = Maestro::initialize(inventory, topology).await.unwrap();
|
||||
|
||||
maestro.register_all(vec![
|
||||
harmony_cli::run(
|
||||
Inventory::autoload(),
|
||||
LocalhostTopology::new(),
|
||||
vec![
|
||||
Box::new(SuccessScore {}),
|
||||
Box::new(ErrorScore {}),
|
||||
Box::new(PanicScore {}),
|
||||
]);
|
||||
harmony_cli::init(maestro, None).await.unwrap();
|
||||
],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -14,8 +14,8 @@ harmony_macros = { path = "../../harmony_macros" }
|
||||
log = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
url = { workspace = true }
|
||||
kube = "0.98.0"
|
||||
k8s-openapi = { version = "0.24.0", features = [ "v1_30" ] }
|
||||
kube = "1.1.0"
|
||||
k8s-openapi = { version = "0.25.0", features = ["v1_30"] }
|
||||
http = "1.2.0"
|
||||
serde_yaml = "0.9.34"
|
||||
inquire.workspace = true
|
||||
|
||||
@@ -125,40 +125,47 @@ spec:
|
||||
name: nginx"#,
|
||||
)
|
||||
.unwrap();
|
||||
return deployment;
|
||||
deployment
|
||||
}
|
||||
fn nginx_deployment_2() -> Deployment {
|
||||
let mut pod_template = PodTemplateSpec::default();
|
||||
pod_template.metadata = Some(ObjectMeta {
|
||||
let pod_template = PodTemplateSpec {
|
||||
metadata: Some(ObjectMeta {
|
||||
labels: Some(BTreeMap::from([(
|
||||
"app".to_string(),
|
||||
"nginx-test".to_string(),
|
||||
)])),
|
||||
..Default::default()
|
||||
});
|
||||
pod_template.spec = Some(PodSpec {
|
||||
}),
|
||||
spec: Some(PodSpec {
|
||||
containers: vec![Container {
|
||||
name: "nginx".to_string(),
|
||||
image: Some("nginx".to_string()),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
let mut spec = DeploymentSpec::default();
|
||||
spec.template = pod_template;
|
||||
spec.selector = LabelSelector {
|
||||
}),
|
||||
};
|
||||
|
||||
let spec = DeploymentSpec {
|
||||
template: pod_template,
|
||||
selector: LabelSelector {
|
||||
match_expressions: None,
|
||||
match_labels: Some(BTreeMap::from([(
|
||||
"app".to_string(),
|
||||
"nginx-test".to_string(),
|
||||
)])),
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut deployment = Deployment::default();
|
||||
deployment.spec = Some(spec);
|
||||
deployment.metadata.name = Some("nginx-test".to_string());
|
||||
|
||||
deployment
|
||||
Deployment {
|
||||
spec: Some(spec),
|
||||
metadata: ObjectMeta {
|
||||
name: Some("nginx-test".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn nginx_deployment() -> Deployment {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use harmony::{
|
||||
data::Version,
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::lamp::{LAMPConfig, LAMPScore},
|
||||
topology::{K8sAnywhereTopology, Url},
|
||||
};
|
||||
@@ -24,23 +23,32 @@ async fn main() {
|
||||
// This config can be extended as needed for more complicated configurations
|
||||
config: LAMPConfig {
|
||||
project_root: "./php".into(),
|
||||
database_size: format!("2Gi").into(),
|
||||
database_size: "4Gi".to_string().into(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
//let monitoring = MonitoringAlertingScore {
|
||||
// alert_receivers: vec![Box::new(DiscordWebhook {
|
||||
// url: Url::Url(url::Url::parse("https://discord.idonotexist.com").unwrap()),
|
||||
// // TODO write url macro
|
||||
// // url: url!("https://discord.idonotexist.com"),
|
||||
// })],
|
||||
// alert_rules: vec![],
|
||||
// scrape_targets: vec![],
|
||||
//};
|
||||
|
||||
// You can choose the type of Topology you want, we suggest starting with the
|
||||
// K8sAnywhereTopology as it is the most automatic one that enables you to easily deploy
|
||||
// locally, to development environment from a CI, to staging, and to production with settings
|
||||
// that automatically adapt to each environment grade.
|
||||
let mut maestro = Maestro::<K8sAnywhereTopology>::initialize(
|
||||
harmony_cli::run(
|
||||
Inventory::autoload(),
|
||||
K8sAnywhereTopology::new(),
|
||||
K8sAnywhereTopology::from_env(),
|
||||
vec![Box::new(lamp_stack)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
maestro.register_all(vec![Box::new(lamp_stack)]);
|
||||
// Here we bootstrap the CLI, this gives some nice features if you need them
|
||||
harmony_cli::init(maestro, None).await.unwrap();
|
||||
}
|
||||
// That's it, end of the infra as code.
|
||||
|
||||
13
examples/monitoring/Cargo.toml
Normal file
13
examples/monitoring/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "example-monitoring"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
harmony = { version = "0.1.0", path = "../../harmony" }
|
||||
harmony_cli = { version = "0.1.0", path = "../../harmony_cli" }
|
||||
harmony_macros = { version = "0.1.0", path = "../../harmony_macros" }
|
||||
tokio.workspace = true
|
||||
url.workspace = true
|
||||
85
examples/monitoring/src/main.rs
Normal file
85
examples/monitoring/src/main.rs
Normal file
@@ -0,0 +1,85 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use harmony::{
|
||||
inventory::Inventory,
|
||||
modules::{
|
||||
monitoring::{
|
||||
alert_channel::discord_alert_channel::DiscordWebhook,
|
||||
alert_rule::prometheus_alert_rule::AlertManagerRuleGroup,
|
||||
kube_prometheus::{
|
||||
helm_prometheus_alert_score::HelmPrometheusAlertingScore,
|
||||
types::{
|
||||
HTTPScheme, MatchExpression, Operator, Selector, ServiceMonitor,
|
||||
ServiceMonitorEndpoint,
|
||||
},
|
||||
},
|
||||
},
|
||||
prometheus::alerts::{
|
||||
infra::dell_server::{
|
||||
alert_global_storage_status_critical, alert_global_storage_status_non_recoverable,
|
||||
global_storage_status_degraded_non_critical,
|
||||
},
|
||||
k8s::pvc::high_pvc_fill_rate_over_two_days,
|
||||
},
|
||||
},
|
||||
topology::{K8sAnywhereTopology, Url},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let discord_receiver = DiscordWebhook {
|
||||
name: "test-discord".to_string(),
|
||||
url: Url::Url(url::Url::parse("https://discord.doesnt.exist.com").unwrap()),
|
||||
};
|
||||
|
||||
let high_pvc_fill_rate_over_two_days_alert = high_pvc_fill_rate_over_two_days();
|
||||
let dell_system_storage_degraded = global_storage_status_degraded_non_critical();
|
||||
let alert_global_storage_status_critical = alert_global_storage_status_critical();
|
||||
let alert_global_storage_status_non_recoverable = alert_global_storage_status_non_recoverable();
|
||||
|
||||
let additional_rules =
|
||||
AlertManagerRuleGroup::new("pvc-alerts", vec![high_pvc_fill_rate_over_two_days_alert]);
|
||||
let additional_rules2 = AlertManagerRuleGroup::new(
|
||||
"dell-server-alerts",
|
||||
vec![
|
||||
dell_system_storage_degraded,
|
||||
alert_global_storage_status_critical,
|
||||
alert_global_storage_status_non_recoverable,
|
||||
],
|
||||
);
|
||||
|
||||
let service_monitor_endpoint = ServiceMonitorEndpoint {
|
||||
port: Some("80".to_string()),
|
||||
path: Some("/metrics".to_string()),
|
||||
scheme: Some(HTTPScheme::HTTP),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let service_monitor = ServiceMonitor {
|
||||
name: "test-service-monitor".to_string(),
|
||||
selector: Selector {
|
||||
match_labels: HashMap::new(),
|
||||
match_expressions: vec![MatchExpression {
|
||||
key: "test".to_string(),
|
||||
operator: Operator::In,
|
||||
values: vec!["test-service".to_string()],
|
||||
}],
|
||||
},
|
||||
endpoints: vec![service_monitor_endpoint],
|
||||
..Default::default()
|
||||
};
|
||||
let alerting_score = HelmPrometheusAlertingScore {
|
||||
receivers: vec![Box::new(discord_receiver)],
|
||||
rules: vec![Box::new(additional_rules), Box::new(additional_rules2)],
|
||||
service_monitors: vec![service_monitor],
|
||||
};
|
||||
|
||||
harmony_cli::run(
|
||||
Inventory::autoload(),
|
||||
K8sAnywhereTopology::from_env(),
|
||||
vec![Box::new(alerting_score)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
13
examples/monitoring_with_tenant/Cargo.toml
Normal file
13
examples/monitoring_with_tenant/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "example-monitoring-with-tenant"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
cidr.workspace = true
|
||||
harmony = { version = "0.1.0", path = "../../harmony" }
|
||||
harmony_cli = { version = "0.1.0", path = "../../harmony_cli" }
|
||||
tokio.workspace = true
|
||||
url.workspace = true
|
||||
89
examples/monitoring_with_tenant/src/main.rs
Normal file
89
examples/monitoring_with_tenant/src/main.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use std::{collections::HashMap, str::FromStr};
|
||||
|
||||
use harmony::{
|
||||
data::Id,
|
||||
inventory::Inventory,
|
||||
modules::{
|
||||
monitoring::{
|
||||
alert_channel::discord_alert_channel::DiscordWebhook,
|
||||
alert_rule::prometheus_alert_rule::AlertManagerRuleGroup,
|
||||
kube_prometheus::{
|
||||
helm_prometheus_alert_score::HelmPrometheusAlertingScore,
|
||||
types::{
|
||||
HTTPScheme, MatchExpression, Operator, Selector, ServiceMonitor,
|
||||
ServiceMonitorEndpoint,
|
||||
},
|
||||
},
|
||||
},
|
||||
prometheus::alerts::k8s::pvc::high_pvc_fill_rate_over_two_days,
|
||||
tenant::TenantScore,
|
||||
},
|
||||
topology::{
|
||||
K8sAnywhereTopology, Url,
|
||||
tenant::{ResourceLimits, TenantConfig, TenantNetworkPolicy},
|
||||
},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let tenant = TenantScore {
|
||||
config: TenantConfig {
|
||||
id: Id::from_str("1234").unwrap(),
|
||||
name: "test-tenant".to_string(),
|
||||
resource_limits: ResourceLimits {
|
||||
cpu_request_cores: 6.0,
|
||||
cpu_limit_cores: 4.0,
|
||||
memory_request_gb: 4.0,
|
||||
memory_limit_gb: 4.0,
|
||||
storage_total_gb: 10.0,
|
||||
},
|
||||
network_policy: TenantNetworkPolicy::default(),
|
||||
},
|
||||
};
|
||||
|
||||
let discord_receiver = DiscordWebhook {
|
||||
name: "test-discord".to_string(),
|
||||
url: Url::Url(url::Url::parse("https://discord.doesnt.exist.com").unwrap()),
|
||||
};
|
||||
|
||||
let high_pvc_fill_rate_over_two_days_alert = high_pvc_fill_rate_over_two_days();
|
||||
|
||||
let additional_rules =
|
||||
AlertManagerRuleGroup::new("pvc-alerts", vec![high_pvc_fill_rate_over_two_days_alert]);
|
||||
|
||||
let service_monitor_endpoint = ServiceMonitorEndpoint {
|
||||
port: Some("80".to_string()),
|
||||
path: Some("/metrics".to_string()),
|
||||
scheme: Some(HTTPScheme::HTTP),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let service_monitor = ServiceMonitor {
|
||||
name: "test-service-monitor".to_string(),
|
||||
selector: Selector {
|
||||
match_labels: HashMap::new(),
|
||||
match_expressions: vec![MatchExpression {
|
||||
key: "test".to_string(),
|
||||
operator: Operator::In,
|
||||
values: vec!["test-service".to_string()],
|
||||
}],
|
||||
},
|
||||
endpoints: vec![service_monitor_endpoint],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let alerting_score = HelmPrometheusAlertingScore {
|
||||
receivers: vec![Box::new(discord_receiver)],
|
||||
rules: vec![Box::new(additional_rules)],
|
||||
service_monitors: vec![service_monitor],
|
||||
};
|
||||
|
||||
harmony_cli::run(
|
||||
Inventory::autoload(),
|
||||
K8sAnywhereTopology::from_env(),
|
||||
vec![Box::new(tenant), Box::new(alerting_score)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use harmony::{
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::{
|
||||
http::HttpScore,
|
||||
http::StaticFilesHttpScore,
|
||||
ipxe::IpxeScore,
|
||||
okd::{
|
||||
bootstrap_dhcp::OKDBootstrapDhcpScore,
|
||||
@@ -126,7 +126,7 @@ async fn main() {
|
||||
harmony::modules::okd::load_balancer::OKDLoadBalancerScore::new(&topology);
|
||||
|
||||
let tftp_score = TftpScore::new(Url::LocalFolder("./data/watchguard/tftpboot".to_string()));
|
||||
let http_score = HttpScore::new(Url::LocalFolder(
|
||||
let http_score = StaticFilesHttpScore::new(Url::LocalFolder(
|
||||
"./data/watchguard/pxe-http-files".to_string(),
|
||||
));
|
||||
let ipxe_score = IpxeScore::new();
|
||||
|
||||
12
examples/ntfy/Cargo.toml
Normal file
12
examples/ntfy/Cargo.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "example-ntfy"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
harmony = { version = "0.1.0", path = "../../harmony" }
|
||||
harmony_cli = { version = "0.1.0", path = "../../harmony_cli" }
|
||||
tokio.workspace = true
|
||||
url.workspace = true
|
||||
18
examples/ntfy/src/main.rs
Normal file
18
examples/ntfy/src/main.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use harmony::{
|
||||
inventory::Inventory, modules::monitoring::ntfy::ntfy::NtfyScore, topology::K8sAnywhereTopology,
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
harmony_cli::run(
|
||||
Inventory::autoload(),
|
||||
K8sAnywhereTopology::from_env(),
|
||||
vec![Box::new(NtfyScore {
|
||||
namespace: "monitoring".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
})],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
@@ -11,7 +11,7 @@ use harmony::{
|
||||
maestro::Maestro,
|
||||
modules::{
|
||||
dummy::{ErrorScore, PanicScore, SuccessScore},
|
||||
http::HttpScore,
|
||||
http::StaticFilesHttpScore,
|
||||
okd::{dhcp::OKDDhcpScore, dns::OKDDnsScore, load_balancer::OKDLoadBalancerScore},
|
||||
opnsense::OPNsenseShellCommandScore,
|
||||
tftp::TftpScore,
|
||||
@@ -81,7 +81,7 @@ async fn main() {
|
||||
let load_balancer_score = OKDLoadBalancerScore::new(&topology);
|
||||
|
||||
let tftp_score = TftpScore::new(Url::LocalFolder("./data/watchguard/tftpboot".to_string()));
|
||||
let http_score = HttpScore::new(Url::LocalFolder(
|
||||
let http_score = StaticFilesHttpScore::new(Url::LocalFolder(
|
||||
"./data/watchguard/pxe-http-files".to_string(),
|
||||
));
|
||||
let mut maestro = Maestro::initialize(inventory, topology).await.unwrap();
|
||||
|
||||
3
examples/rust/.gitignore
vendored
Normal file
3
examples/rust/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
Dockerfile.harmony
|
||||
.harmony_generated
|
||||
harmony
|
||||
15
examples/rust/Cargo.toml
Normal file
15
examples/rust/Cargo.toml
Normal file
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "example-rust"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
harmony = { path = "../../harmony" }
|
||||
harmony_cli = { path = "../../harmony_cli" }
|
||||
harmony_types = { path = "../../harmony_types" }
|
||||
harmony_macros = { path = "../../harmony_macros" }
|
||||
tokio = { workspace = true }
|
||||
log = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
url = { workspace = true }
|
||||
base64.workspace = true
|
||||
58
examples/rust/src/main.rs
Normal file
58
examples/rust/src/main.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
|
||||
use harmony::{
|
||||
inventory::Inventory,
|
||||
modules::{
|
||||
application::{
|
||||
ApplicationScore, RustWebFramework, RustWebapp,
|
||||
features::{ContinuousDelivery, Monitoring},
|
||||
},
|
||||
monitoring::alert_channel::{
|
||||
discord_alert_channel::DiscordWebhook, webhook_receiver::WebhookReceiver,
|
||||
},
|
||||
},
|
||||
topology::{K8sAnywhereTopology, Url},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let application = Arc::new(RustWebapp {
|
||||
name: "harmony-example-rust-webapp".to_string(),
|
||||
domain: Url::Url(url::Url::parse("https://rustapp.harmony.example.com").unwrap()),
|
||||
project_root: PathBuf::from("./webapp"), // Relative from 'harmony-path' param
|
||||
framework: Some(RustWebFramework::Leptos),
|
||||
});
|
||||
|
||||
let discord_receiver = DiscordWebhook {
|
||||
name: "test-discord".to_string(),
|
||||
url: Url::Url(url::Url::parse("https://discord.doesnt.exist.com").unwrap()),
|
||||
};
|
||||
|
||||
let webhook_receiver = WebhookReceiver {
|
||||
name: "sample-webhook-receiver".to_string(),
|
||||
url: Url::Url(url::Url::parse("https://webhook-doesnt-exist.com").unwrap()),
|
||||
};
|
||||
|
||||
let app = ApplicationScore {
|
||||
features: vec![
|
||||
Box::new(ContinuousDelivery {
|
||||
application: application.clone(),
|
||||
}),
|
||||
Box::new(Monitoring {
|
||||
application: application.clone(),
|
||||
alert_receiver: vec![Box::new(discord_receiver), Box::new(webhook_receiver)],
|
||||
}),
|
||||
// TODO add backups, multisite ha, etc
|
||||
],
|
||||
application,
|
||||
};
|
||||
|
||||
harmony_cli::run(
|
||||
Inventory::autoload(),
|
||||
K8sAnywhereTopology::from_env(),
|
||||
vec![Box::new(app)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
14
examples/rust/webapp/.gitignore
vendored
Normal file
14
examples/rust/webapp/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
debug/
|
||||
target/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
|
||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||
*.pdb
|
||||
93
examples/rust/webapp/Cargo.toml
Normal file
93
examples/rust/webapp/Cargo.toml
Normal file
@@ -0,0 +1,93 @@
|
||||
[package]
|
||||
name = "harmony-example-rust-webapp"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[workspace]
|
||||
|
||||
[dependencies]
|
||||
actix-files = { version = "0.6", optional = true }
|
||||
actix-web = { version = "4", optional = true, features = ["macros"] }
|
||||
console_error_panic_hook = "0.1"
|
||||
http = { version = "1.0.0", optional = true }
|
||||
leptos = { version = "0.7.0" }
|
||||
leptos_meta = { version = "0.7.0" }
|
||||
leptos_actix = { version = "0.7.0", optional = true }
|
||||
leptos_router = { version = "0.7.0" }
|
||||
wasm-bindgen = "=0.2.100"
|
||||
|
||||
[features]
|
||||
csr = ["leptos/csr"]
|
||||
hydrate = ["leptos/hydrate"]
|
||||
ssr = [
|
||||
"dep:actix-files",
|
||||
"dep:actix-web",
|
||||
"dep:leptos_actix",
|
||||
"leptos/ssr",
|
||||
"leptos_meta/ssr",
|
||||
"leptos_router/ssr",
|
||||
]
|
||||
|
||||
# Defines a size-optimized profile for the WASM bundle in release mode
|
||||
[profile.wasm-release]
|
||||
inherits = "release"
|
||||
opt-level = 'z'
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
|
||||
[package.metadata.leptos]
|
||||
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
|
||||
output-name = "harmony-example-rust-webapp"
|
||||
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
|
||||
site-root = "target/site"
|
||||
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
|
||||
# Defaults to pkg
|
||||
site-pkg-dir = "pkg"
|
||||
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
|
||||
style-file = "style/main.scss"
|
||||
# Assets source dir. All files found here will be copied and synchronized to site-root.
|
||||
# The assets-dir cannot have a sub directory with the same name/path as site-pkg-dir.
|
||||
#
|
||||
# Optional. Env: LEPTOS_ASSETS_DIR.
|
||||
assets-dir = "assets"
|
||||
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
|
||||
site-addr = "0.0.0.0:3000"
|
||||
# The port to use for automatic reload monitoring
|
||||
reload-port = 3001
|
||||
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
|
||||
# [Windows] for non-WSL use "npx.cmd playwright test"
|
||||
# This binary name can be checked in Powershell with Get-Command npx
|
||||
end2end-cmd = "npx playwright test"
|
||||
end2end-dir = "end2end"
|
||||
# The browserlist query used for optimizing the CSS.
|
||||
browserquery = "defaults"
|
||||
# The environment Leptos will run in, usually either "DEV" or "PROD"
|
||||
env = "DEV"
|
||||
# The features to use when compiling the bin target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --bin-features
|
||||
bin-features = ["ssr"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the bin target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
bin-default-features = false
|
||||
|
||||
# The features to use when compiling the lib target
|
||||
#
|
||||
# Optional. Can be over-ridden with the command line parameter --lib-features
|
||||
lib-features = ["hydrate"]
|
||||
|
||||
# If the --no-default-features flag should be used when compiling the lib target
|
||||
#
|
||||
# Optional. Defaults to false.
|
||||
lib-default-features = false
|
||||
|
||||
# The profile to use for the lib target when compiling for release
|
||||
#
|
||||
# Optional. Defaults to "release".
|
||||
lib-profile-release = "wasm-release"
|
||||
24
examples/rust/webapp/LICENSE
Normal file
24
examples/rust/webapp/LICENSE
Normal file
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org>
|
||||
72
examples/rust/webapp/README.md
Normal file
72
examples/rust/webapp/README.md
Normal file
@@ -0,0 +1,72 @@
|
||||
<picture>
|
||||
<source srcset="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_Solid_White.svg" media="(prefers-color-scheme: dark)">
|
||||
<img src="https://raw.githubusercontent.com/leptos-rs/leptos/main/docs/logos/Leptos_logo_RGB.svg" alt="Leptos Logo">
|
||||
</picture>
|
||||
|
||||
# Leptos Starter Template
|
||||
|
||||
This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool.
|
||||
|
||||
## Creating your template repo
|
||||
|
||||
If you don't have `cargo-leptos` installed you can install it with
|
||||
|
||||
`cargo install cargo-leptos --locked`
|
||||
|
||||
Then run
|
||||
|
||||
`cargo leptos new --git leptos-rs/start-actix`
|
||||
|
||||
to generate a new project template (you will be prompted to enter a project name).
|
||||
|
||||
`cd {projectname}`
|
||||
|
||||
to go to your newly created project.
|
||||
|
||||
Of course, you should explore around the project structure, but the best place to start with your application code is in `src/app.rs`.
|
||||
|
||||
## Running your project
|
||||
|
||||
`cargo leptos watch`
|
||||
By default, you can access your local project at `http://localhost:3000`
|
||||
|
||||
## Installing Additional Tools
|
||||
|
||||
By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools.
|
||||
|
||||
1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly
|
||||
2. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly
|
||||
3. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future)
|
||||
4. `npm install -g sass` - install `dart-sass` (should be optional in future)
|
||||
|
||||
## Executing a Server on a Remote Machine Without the Toolchain
|
||||
After running a `cargo leptos build --release` the minimum files needed are:
|
||||
|
||||
1. The server binary located in `target/server/release`
|
||||
2. The `site` directory and all files within located in `target/site`
|
||||
|
||||
Copy these files to your remote server. The directory structure should be:
|
||||
```text
|
||||
leptos_start
|
||||
site/
|
||||
```
|
||||
Set the following environment variables (updating for your project as needed):
|
||||
```sh
|
||||
export LEPTOS_OUTPUT_NAME="leptos_start"
|
||||
export LEPTOS_SITE_ROOT="site"
|
||||
export LEPTOS_SITE_PKG_DIR="pkg"
|
||||
export LEPTOS_SITE_ADDR="127.0.0.1:3000"
|
||||
export LEPTOS_RELOAD_PORT="3001"
|
||||
```
|
||||
Finally, run the server binary.
|
||||
|
||||
## Notes about CSR and Trunk:
|
||||
Although it is not recommended, you can also run your project without server integration using the feature `csr` and `trunk serve`:
|
||||
|
||||
`trunk serve --open --features csr`
|
||||
|
||||
This may be useful for integrating external tools which require a static site, e.g. `tauri`.
|
||||
|
||||
## Licensing
|
||||
|
||||
This template itself is released under the Unlicense. You should replace the LICENSE for your own application with an appropriate license if you plan to release it publicly.
|
||||
BIN
examples/rust/webapp/assets/favicon.ico
Normal file
BIN
examples/rust/webapp/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
112
examples/rust/webapp/end2end/package-lock.json
generated
Normal file
112
examples/rust/webapp/end2end/package-lock.json
generated
Normal file
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"name": "end2end",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "end2end",
|
||||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@types/node": "^20.12.12",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.44.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.44.1.tgz",
|
||||
"integrity": "sha512-1hZ4TNvD5z9VuhNJ/walIjvMVvYkZKf71axoF/uiAqpntQJXpG64dlXhoDXE3OczPuTuvjf/M5KWFg5VAVUS3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.44.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.12.12",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz",
|
||||
"integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.44.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.44.1.tgz",
|
||||
"integrity": "sha512-qr/0UJ5CFAtloI3avF95Y0L1xQo6r3LQArLIg/z/PoGJ6xa+EwzrwO5lpNr/09STxdHuUoP2mvuELJS+hLdtgg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.44.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.44.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.44.1.tgz",
|
||||
"integrity": "sha512-wh0JWtYTrhv1+OSsLPgFzGzt67Y7BE/ZS3jEqgGBlp2ppp1ZDj8c+9IARNW4dwf1poq5MgHreEM2KV/GuR4cFA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.4.5",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
|
||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
15
examples/rust/webapp/end2end/package.json
Normal file
15
examples/rust/webapp/end2end/package.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "end2end",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@types/node": "^20.12.12",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
104
examples/rust/webapp/end2end/playwright.config.ts
Normal file
104
examples/rust/webapp/end2end/playwright.config.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { devices, defineConfig } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
*/
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
/* Maximum time one test can run for. */
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
/**
|
||||
* Maximum time expect() should wait for the condition to be met.
|
||||
* For example in `await expect(locator).toHaveText();`
|
||||
*/
|
||||
timeout: 5000,
|
||||
},
|
||||
/* Run tests in files in parallel */
|
||||
fullyParallel: true,
|
||||
/* Fail the build on CI if you accidentally left test.only in the source code. */
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: "html",
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
|
||||
actionTimeout: 0,
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
// baseURL: 'http://localhost:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
|
||||
/* Configure projects for major browsers */
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: {
|
||||
...devices["Desktop Chrome"],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "firefox",
|
||||
use: {
|
||||
...devices["Desktop Firefox"],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: "webkit",
|
||||
use: {
|
||||
...devices["Desktop Safari"],
|
||||
},
|
||||
},
|
||||
|
||||
/* Test against mobile viewports. */
|
||||
// {
|
||||
// name: 'Mobile Chrome',
|
||||
// use: {
|
||||
// ...devices['Pixel 5'],
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Mobile Safari',
|
||||
// use: {
|
||||
// ...devices['iPhone 12'],
|
||||
// },
|
||||
// },
|
||||
|
||||
/* Test against branded browsers. */
|
||||
// {
|
||||
// name: 'Microsoft Edge',
|
||||
// use: {
|
||||
// channel: 'msedge',
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// name: 'Google Chrome',
|
||||
// use: {
|
||||
// channel: 'chrome',
|
||||
// },
|
||||
// },
|
||||
],
|
||||
|
||||
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
|
||||
// outputDir: 'test-results/',
|
||||
|
||||
/* Run your local dev server before starting the tests */
|
||||
// webServer: {
|
||||
// command: 'npm run start',
|
||||
// port: 3000,
|
||||
// },
|
||||
});
|
||||
9
examples/rust/webapp/end2end/tests/example.spec.ts
Normal file
9
examples/rust/webapp/end2end/tests/example.spec.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test("homepage has title and links to intro page", async ({ page }) => {
|
||||
await page.goto("http://localhost:3000/");
|
||||
|
||||
await expect(page).toHaveTitle("Welcome to Leptos");
|
||||
|
||||
await expect(page.locator("h1")).toHaveText("Welcome to Leptos!");
|
||||
});
|
||||
109
examples/rust/webapp/end2end/tsconfig.json
Normal file
109
examples/rust/webapp/end2end/tsconfig.json
Normal file
@@ -0,0 +1,109 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||
|
||||
/* Projects */
|
||||
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||
|
||||
/* Language and Environment */
|
||||
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
|
||||
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||
|
||||
/* Modules */
|
||||
"module": "commonjs", /* Specify what module code is generated. */
|
||||
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
|
||||
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
|
||||
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
|
||||
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
|
||||
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
|
||||
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
|
||||
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||
|
||||
/* JavaScript Support */
|
||||
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||
|
||||
/* Emit */
|
||||
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||
// "removeComments": true, /* Disable emitting comments. */
|
||||
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||
|
||||
/* Interop Constraints */
|
||||
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
|
||||
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||
|
||||
/* Type Checking */
|
||||
"strict": true, /* Enable all strict type-checking options. */
|
||||
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||
|
||||
/* Completeness */
|
||||
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||
}
|
||||
}
|
||||
66
examples/rust/webapp/src/app.rs
Normal file
66
examples/rust/webapp/src/app.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use leptos::prelude::*;
|
||||
use leptos_meta::{provide_meta_context, Stylesheet, Title};
|
||||
use leptos_router::{
|
||||
components::{Route, Router, Routes},
|
||||
StaticSegment, WildcardSegment,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
// Provides context that manages stylesheets, titles, meta tags, etc.
|
||||
provide_meta_context();
|
||||
|
||||
view! {
|
||||
// injects a stylesheet into the document <head>
|
||||
// id=leptos means cargo-leptos will hot-reload this stylesheet
|
||||
<Stylesheet id="leptos" href="/pkg/harmony-example-rust-webapp.css"/>
|
||||
|
||||
// sets the document title
|
||||
<Title text="Welcome to Leptos"/>
|
||||
|
||||
// content for this welcome page
|
||||
<Router>
|
||||
<main>
|
||||
<Routes fallback=move || "Not found.">
|
||||
<Route path=StaticSegment("") view=HomePage/>
|
||||
<Route path=WildcardSegment("any") view=NotFound/>
|
||||
</Routes>
|
||||
</main>
|
||||
</Router>
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders the home page of your application.
|
||||
#[component]
|
||||
fn HomePage() -> impl IntoView {
|
||||
// Creates a reactive value to update the button
|
||||
let count = RwSignal::new(0);
|
||||
let on_click = move |_| *count.write() += 1;
|
||||
|
||||
view! {
|
||||
<h1>"Welcome to Leptos!"</h1>
|
||||
<button on:click=on_click>"Click Me: " {count}</button>
|
||||
}
|
||||
}
|
||||
|
||||
/// 404 - Not Found
|
||||
#[component]
|
||||
fn NotFound() -> impl IntoView {
|
||||
// set an HTTP status code 404
|
||||
// this is feature gated because it can only be done during
|
||||
// initial server-side rendering
|
||||
// if you navigate to the 404 page subsequently, the status
|
||||
// code will not be set because there is not a new HTTP request
|
||||
// to the server
|
||||
#[cfg(feature = "ssr")]
|
||||
{
|
||||
// this can be done inline because it's synchronous
|
||||
// if it were async, we'd use a server function
|
||||
let resp = expect_context::<leptos_actix::ResponseOptions>();
|
||||
resp.set_status(actix_web::http::StatusCode::NOT_FOUND);
|
||||
}
|
||||
|
||||
view! {
|
||||
<h1>"Not Found"</h1>
|
||||
}
|
||||
}
|
||||
9
examples/rust/webapp/src/lib.rs
Normal file
9
examples/rust/webapp/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod app;
|
||||
|
||||
#[cfg(feature = "hydrate")]
|
||||
#[wasm_bindgen::prelude::wasm_bindgen]
|
||||
pub fn hydrate() {
|
||||
use app::*;
|
||||
console_error_panic_hook::set_once();
|
||||
leptos::mount::hydrate_body(App);
|
||||
}
|
||||
88
examples/rust/webapp/src/main.rs
Normal file
88
examples/rust/webapp/src/main.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
#[cfg(feature = "ssr")]
|
||||
#[actix_web::main]
|
||||
async fn main() -> std::io::Result<()> {
|
||||
use actix_files::Files;
|
||||
use actix_web::*;
|
||||
use leptos::prelude::*;
|
||||
use leptos::config::get_configuration;
|
||||
use leptos_meta::MetaTags;
|
||||
use leptos_actix::{generate_route_list, LeptosRoutes};
|
||||
use harmony_example_rust_webapp::app::*;
|
||||
|
||||
let conf = get_configuration(None).unwrap();
|
||||
let addr = conf.leptos_options.site_addr;
|
||||
|
||||
HttpServer::new(move || {
|
||||
// Generate the list of routes in your Leptos App
|
||||
let routes = generate_route_list(App);
|
||||
let leptos_options = &conf.leptos_options;
|
||||
let site_root = leptos_options.site_root.clone().to_string();
|
||||
|
||||
println!("listening on http://{}", &addr);
|
||||
|
||||
App::new()
|
||||
// serve JS/WASM/CSS from `pkg`
|
||||
.service(Files::new("/pkg", format!("{site_root}/pkg")))
|
||||
// serve other assets from the `assets` directory
|
||||
.service(Files::new("/assets", &site_root))
|
||||
// serve the favicon from /favicon.ico
|
||||
.service(favicon)
|
||||
.leptos_routes(routes, {
|
||||
let leptos_options = leptos_options.clone();
|
||||
move || {
|
||||
view! {
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<AutoReload options=leptos_options.clone() />
|
||||
<HydrationScripts options=leptos_options.clone()/>
|
||||
<MetaTags/>
|
||||
</head>
|
||||
<body>
|
||||
<App/>
|
||||
</body>
|
||||
</html>
|
||||
}
|
||||
}
|
||||
})
|
||||
.app_data(web::Data::new(leptos_options.to_owned()))
|
||||
//.wrap(middleware::Compress::default())
|
||||
})
|
||||
.bind(&addr)?
|
||||
.run()
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(feature = "ssr")]
|
||||
#[actix_web::get("favicon.ico")]
|
||||
async fn favicon(
|
||||
leptos_options: actix_web::web::Data<leptos::config::LeptosOptions>,
|
||||
) -> actix_web::Result<actix_files::NamedFile> {
|
||||
let leptos_options = leptos_options.into_inner();
|
||||
let site_root = &leptos_options.site_root;
|
||||
Ok(actix_files::NamedFile::open(format!(
|
||||
"{site_root}/favicon.ico"
|
||||
))?)
|
||||
}
|
||||
|
||||
#[cfg(not(any(feature = "ssr", feature = "csr")))]
|
||||
pub fn main() {
|
||||
// no client-side main function
|
||||
// unless we want this to work with e.g., Trunk for pure client-side testing
|
||||
// see lib.rs for hydration function instead
|
||||
// see optional feature `csr` instead
|
||||
}
|
||||
|
||||
#[cfg(all(not(feature = "ssr"), feature = "csr"))]
|
||||
pub fn main() {
|
||||
// a client-side main function is required for using `trunk serve`
|
||||
// prefer using `cargo leptos serve` instead
|
||||
// to run: `trunk serve --open --features csr`
|
||||
use harmony_example_rust_webapp::app::*;
|
||||
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
leptos::mount_to_body(App);
|
||||
}
|
||||
4
examples/rust/webapp/style/main.scss
Normal file
4
examples/rust/webapp/style/main.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
text-align: center;
|
||||
}
|
||||
18
examples/tenant/Cargo.toml
Normal file
18
examples/tenant/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "example-tenant"
|
||||
edition = "2024"
|
||||
version.workspace = true
|
||||
readme.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
harmony = { path = "../../harmony" }
|
||||
harmony_cli = { path = "../../harmony_cli" }
|
||||
harmony_types = { path = "../../harmony_types" }
|
||||
cidr = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
harmony_macros = { path = "../../harmony_macros" }
|
||||
log = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
url = { workspace = true }
|
||||
41
examples/tenant/src/main.rs
Normal file
41
examples/tenant/src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use harmony::{
|
||||
data::Id,
|
||||
inventory::Inventory,
|
||||
modules::tenant::TenantScore,
|
||||
topology::{K8sAnywhereTopology, tenant::TenantConfig},
|
||||
};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let tenant = TenantScore {
|
||||
config: TenantConfig {
|
||||
id: Id::from_str("test-tenant-id").unwrap(),
|
||||
name: "testtenant".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
|
||||
harmony_cli::run(
|
||||
Inventory::autoload(),
|
||||
K8sAnywhereTopology::from_env(),
|
||||
vec![Box::new(tenant)],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// TODO write tests
|
||||
// - Create Tenant with default config mostly, make sure namespace is created
|
||||
// - deploy sample client/server app with nginx unprivileged and a service
|
||||
// - exec in the client pod and validate the following
|
||||
// - can reach internet
|
||||
// - can reach server pod
|
||||
// - can resolve dns queries to internet
|
||||
// - can resolve dns queries to services
|
||||
// - cannot reach services and pods in other namespaces
|
||||
// - Create Tenant with specific cpu/ram/storage requests / limits and make sure they are enforced by trying to
|
||||
// deploy a pod with lower requests/limits (accepted) and higher requests/limits (rejected)
|
||||
// - Create TenantCredentials and make sure they give only access to the correct tenant
|
||||
@@ -10,9 +10,9 @@ publish = false
|
||||
harmony = { path = "../../harmony" }
|
||||
harmony_tui = { path = "../../harmony_tui" }
|
||||
harmony_types = { path = "../../harmony_types" }
|
||||
harmony_macros = { path = "../../harmony_macros" }
|
||||
cidr = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
harmony_macros = { path = "../../harmony_macros" }
|
||||
log = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
url = { workspace = true }
|
||||
|
||||
@@ -6,12 +6,14 @@ readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rand = "0.9"
|
||||
hex = "0.4"
|
||||
libredfish = "0.1.1"
|
||||
reqwest = { version = "0.11", features = ["blocking", "json"] }
|
||||
russh = "0.45.0"
|
||||
rust-ipmi = "0.1.1"
|
||||
semver = "1.0.23"
|
||||
serde = { version = "1.0.209", features = ["derive"] }
|
||||
serde = { version = "1.0.209", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.127"
|
||||
tokio.workspace = true
|
||||
derive-new.workspace = true
|
||||
@@ -25,12 +27,11 @@ harmony_macros = { path = "../harmony_macros" }
|
||||
harmony_types = { path = "../harmony_types" }
|
||||
uuid.workspace = true
|
||||
url.workspace = true
|
||||
kube.workspace = true
|
||||
kube = { workspace = true, features = ["derive"] }
|
||||
k8s-openapi.workspace = true
|
||||
serde_yaml.workspace = true
|
||||
http.workspace = true
|
||||
serde-value.workspace = true
|
||||
inquire.workspace = true
|
||||
helm-wrapper-rs = "0.4.0"
|
||||
non-blank-string-rs = "1.0.4"
|
||||
k3d-rs = { path = "../k3d" }
|
||||
@@ -39,3 +40,30 @@ lazy_static = "1.5.0"
|
||||
dockerfile_builder = "0.1.5"
|
||||
temp-file = "0.1.9"
|
||||
convert_case.workspace = true
|
||||
email_address = "0.2.9"
|
||||
chrono.workspace = true
|
||||
fqdn = { version = "0.4.6", features = [
|
||||
"domain-label-cannot-start-or-end-with-hyphen",
|
||||
"domain-label-length-limited-to-63",
|
||||
"domain-name-without-special-chars",
|
||||
"domain-name-length-limited-to-255",
|
||||
"punycode",
|
||||
"serde",
|
||||
] }
|
||||
temp-dir = "0.1.14"
|
||||
dyn-clone = "1.0.19"
|
||||
similar.workspace = true
|
||||
futures-util = "0.3.31"
|
||||
tokio-util = "0.7.15"
|
||||
strum = { version = "0.27.1", features = ["derive"] }
|
||||
tempfile = "3.20.0"
|
||||
serde_with = "3.14.0"
|
||||
schemars = "0.8.22"
|
||||
kube-derive = "1.1.0"
|
||||
bollard.workspace = true
|
||||
tar.workspace = true
|
||||
base64.workspace = true
|
||||
once_cell = "1.21.3"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions.workspace = true
|
||||
|
||||
@@ -2,7 +2,7 @@ use lazy_static::lazy_static;
|
||||
use std::path::PathBuf;
|
||||
|
||||
lazy_static! {
|
||||
pub static ref HARMONY_CONFIG_DIR: PathBuf = directories::BaseDirs::new()
|
||||
pub static ref HARMONY_DATA_DIR: PathBuf = directories::BaseDirs::new()
|
||||
.unwrap()
|
||||
.data_dir()
|
||||
.join("harmony");
|
||||
@@ -10,4 +10,6 @@ lazy_static! {
|
||||
std::env::var("HARMONY_REGISTRY_URL").unwrap_or_else(|_| "hub.nationtech.io".to_string());
|
||||
pub static ref REGISTRY_PROJECT: String =
|
||||
std::env::var("HARMONY_REGISTRY_PROJECT").unwrap_or_else(|_| "harmony".to_string());
|
||||
pub static ref DRY_RUN: bool =
|
||||
std::env::var("HARMONY_DRY_RUN").is_ok_and(|value| value.parse().unwrap_or(false));
|
||||
}
|
||||
|
||||
@@ -1,12 +1,58 @@
|
||||
use rand::distr::Alphanumeric;
|
||||
use rand::distr::SampleString;
|
||||
use std::str::FromStr;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
/// A unique identifier designed for ease of use.
|
||||
///
|
||||
/// You can pass it any String to use and Id, or you can use the default format with `Id::default()`
|
||||
///
|
||||
/// The default format looks like this
|
||||
///
|
||||
/// `462d4c_g2COgai`
|
||||
///
|
||||
/// The first part is the unix timesamp in hexadecimal which makes Id easily sorted by creation time.
|
||||
/// Second part is a serie of 7 random characters.
|
||||
///
|
||||
/// **It is not meant to be very secure or unique**, it is suitable to generate up to 10 000 items per
|
||||
/// second with a reasonable collision rate of 0,000014 % as calculated by this calculator : https://kevingal.com/apps/collision.html
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Id {
|
||||
value: String,
|
||||
}
|
||||
|
||||
impl Id {
|
||||
pub fn from_string(value: String) -> Self {
|
||||
impl FromStr for Id {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Ok(Id {
|
||||
value: s.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Id {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Id {
|
||||
fn default() -> Self {
|
||||
let start = SystemTime::now();
|
||||
let since_the_epoch = start
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Time went backwards");
|
||||
let timestamp = since_the_epoch.as_secs();
|
||||
|
||||
let hex_timestamp = format!("{:x}", timestamp & 0xffffff);
|
||||
|
||||
let random_part: String = Alphanumeric.sample_string(&mut rand::rng(), 7);
|
||||
|
||||
let value = format!("{}_{}", hex_timestamp, random_part);
|
||||
Self { value }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ impl serde::Serialize for Version {
|
||||
|
||||
impl std::fmt::Display for Version {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
return self.value.fmt(f);
|
||||
self.value.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,10 +35,9 @@ impl PhysicalHost {
|
||||
|
||||
pub fn cluster_mac(&self) -> MacAddress {
|
||||
self.network
|
||||
.get(0)
|
||||
.first()
|
||||
.expect("Cluster physical host should have a network interface")
|
||||
.mac_address
|
||||
.clone()
|
||||
}
|
||||
|
||||
pub fn cpu(mut self, cpu_count: Option<u64>) -> Self {
|
||||
|
||||
69
harmony/src/domain/instrumentation.rs
Normal file
69
harmony/src/domain/instrumentation.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
use log::debug;
|
||||
use once_cell::sync::Lazy;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::{
|
||||
interpret::{InterpretError, Outcome},
|
||||
topology::TopologyStatus,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum HarmonyEvent {
|
||||
HarmonyStarted,
|
||||
HarmonyFinished,
|
||||
InterpretExecutionStarted {
|
||||
execution_id: String,
|
||||
topology: String,
|
||||
interpret: String,
|
||||
score: String,
|
||||
message: String,
|
||||
},
|
||||
InterpretExecutionFinished {
|
||||
execution_id: String,
|
||||
topology: String,
|
||||
interpret: String,
|
||||
score: String,
|
||||
outcome: Result<Outcome, InterpretError>,
|
||||
},
|
||||
TopologyStateChanged {
|
||||
topology: String,
|
||||
status: TopologyStatus,
|
||||
message: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
static HARMONY_EVENT_BUS: Lazy<broadcast::Sender<HarmonyEvent>> = Lazy::new(|| {
|
||||
// TODO: Adjust channel capacity
|
||||
let (tx, _rx) = broadcast::channel(100);
|
||||
tx
|
||||
});
|
||||
|
||||
pub fn instrument(event: HarmonyEvent) -> Result<(), &'static str> {
|
||||
match HARMONY_EVENT_BUS.send(event) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(_) => Err("send error: no subscribers"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn subscribe<F, Fut>(name: &str, mut handler: F)
|
||||
where
|
||||
F: FnMut(HarmonyEvent) -> Fut + Send + 'static,
|
||||
Fut: Future<Output = bool> + Send,
|
||||
{
|
||||
let mut rx = HARMONY_EVENT_BUS.subscribe();
|
||||
debug!("[{name}] Service started. Listening for events...");
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
if !handler(event).await {
|
||||
debug!("[{name}] Handler requested exit.");
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
debug!("[{name}] Lagged behind by {n} messages.");
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use super::{
|
||||
data::{Id, Version},
|
||||
executors::ExecutorError,
|
||||
inventory::Inventory,
|
||||
topology::PreparationError,
|
||||
};
|
||||
|
||||
pub enum InterpretName {
|
||||
@@ -20,6 +21,17 @@ pub enum InterpretName {
|
||||
Panic,
|
||||
OPNSense,
|
||||
K3dInstallation,
|
||||
TenantInterpret,
|
||||
Application,
|
||||
ArgoCD,
|
||||
Alerting,
|
||||
Ntfy,
|
||||
HelmChart,
|
||||
HelmCommand,
|
||||
K8sResource,
|
||||
Lamp,
|
||||
ApplicationMonitoring,
|
||||
K8sPrometheusCrdAlerting,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for InterpretName {
|
||||
@@ -35,6 +47,17 @@ impl std::fmt::Display for InterpretName {
|
||||
InterpretName::Panic => f.write_str("Panic"),
|
||||
InterpretName::OPNSense => f.write_str("OPNSense"),
|
||||
InterpretName::K3dInstallation => f.write_str("K3dInstallation"),
|
||||
InterpretName::TenantInterpret => f.write_str("Tenant"),
|
||||
InterpretName::Application => f.write_str("Application"),
|
||||
InterpretName::ArgoCD => f.write_str("ArgoCD"),
|
||||
InterpretName::Alerting => f.write_str("Alerting"),
|
||||
InterpretName::Ntfy => f.write_str("Ntfy"),
|
||||
InterpretName::HelmChart => f.write_str("HelmChart"),
|
||||
InterpretName::HelmCommand => f.write_str("HelmCommand"),
|
||||
InterpretName::K8sResource => f.write_str("K8sResource"),
|
||||
InterpretName::Lamp => f.write_str("LAMP"),
|
||||
InterpretName::ApplicationMonitoring => f.write_str("ApplicationMonitoring"),
|
||||
InterpretName::K8sPrometheusCrdAlerting => f.write_str("K8sPrometheusCrdAlerting"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -107,6 +130,14 @@ impl std::fmt::Display for InterpretError {
|
||||
}
|
||||
impl Error for InterpretError {}
|
||||
|
||||
impl From<PreparationError> for InterpretError {
|
||||
fn from(value: PreparationError) -> Self {
|
||||
Self {
|
||||
msg: format!("InterpretError : {value}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExecutorError> for InterpretError {
|
||||
fn from(value: ExecutorError) -> Self {
|
||||
Self {
|
||||
@@ -122,3 +153,11 @@ impl From<kube::Error> for InterpretError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for InterpretError {
|
||||
fn from(value: String) -> Self {
|
||||
Self {
|
||||
msg: format!("InterpretError : {value}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,17 @@ pub struct Inventory {
|
||||
}
|
||||
|
||||
impl Inventory {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
location: Location::new("Empty".to_string(), "location".to_string()),
|
||||
switch: vec![],
|
||||
firewall: vec![],
|
||||
worker_host: vec![],
|
||||
storage_host: vec![],
|
||||
control_plane_host: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn autoload() -> Self {
|
||||
Self {
|
||||
location: Location::test_building(),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use log::{info, warn};
|
||||
use log::{debug, warn};
|
||||
|
||||
use crate::topology::TopologyStatus;
|
||||
|
||||
use super::{
|
||||
interpret::{InterpretError, InterpretStatus, Outcome},
|
||||
interpret::{InterpretError, Outcome},
|
||||
inventory::Inventory,
|
||||
score::Score,
|
||||
topology::Topology,
|
||||
topology::{PreparationError, PreparationOutcome, Topology, TopologyState},
|
||||
};
|
||||
|
||||
type ScoreVec<T> = Vec<Box<dyn Score<T>>>;
|
||||
@@ -15,42 +17,55 @@ pub struct Maestro<T: Topology> {
|
||||
inventory: Inventory,
|
||||
topology: T,
|
||||
scores: Arc<RwLock<ScoreVec<T>>>,
|
||||
topology_preparation_result: Mutex<Option<Outcome>>,
|
||||
topology_state: TopologyState,
|
||||
}
|
||||
|
||||
impl<T: Topology> Maestro<T> {
|
||||
pub fn new(inventory: Inventory, topology: T) -> Self {
|
||||
/// Creates a bare maestro without initialization.
|
||||
///
|
||||
/// This should rarely be used. Most of the time Maestro::initialize should be used instead.
|
||||
pub fn new_without_initialization(inventory: Inventory, topology: T) -> Self {
|
||||
let topology_name = topology.name().to_string();
|
||||
|
||||
Self {
|
||||
inventory,
|
||||
topology,
|
||||
scores: Arc::new(RwLock::new(Vec::new())),
|
||||
topology_preparation_result: None.into(),
|
||||
topology_state: TopologyState::new(topology_name),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn initialize(inventory: Inventory, topology: T) -> Result<Self, InterpretError> {
|
||||
let instance = Self::new(inventory, topology);
|
||||
pub async fn initialize(inventory: Inventory, topology: T) -> Result<Self, PreparationError> {
|
||||
let mut instance = Self::new_without_initialization(inventory, topology);
|
||||
instance.prepare_topology().await?;
|
||||
Ok(instance)
|
||||
}
|
||||
|
||||
/// Ensures the associated Topology is ready for operations.
|
||||
/// Delegates the readiness check and potential setup actions to the Topology.
|
||||
pub async fn prepare_topology(&self) -> Result<Outcome, InterpretError> {
|
||||
info!("Ensuring topology '{}' is ready...", self.topology.name());
|
||||
let outcome = self.topology.ensure_ready().await?;
|
||||
info!(
|
||||
"Topology '{}' readiness check complete: {}",
|
||||
self.topology.name(),
|
||||
outcome.status
|
||||
);
|
||||
async fn prepare_topology(&mut self) -> Result<PreparationOutcome, PreparationError> {
|
||||
self.topology_state.prepare();
|
||||
|
||||
self.topology_preparation_result
|
||||
.lock()
|
||||
.unwrap()
|
||||
.replace(outcome.clone());
|
||||
let result = self.topology.ensure_ready().await;
|
||||
|
||||
match result {
|
||||
Ok(outcome) => {
|
||||
match outcome.clone() {
|
||||
PreparationOutcome::Success { details } => {
|
||||
self.topology_state.success(details);
|
||||
}
|
||||
PreparationOutcome::Noop => {
|
||||
self.topology_state.noop();
|
||||
}
|
||||
};
|
||||
Ok(outcome)
|
||||
}
|
||||
Err(err) => {
|
||||
self.topology_state.error(err.to_string());
|
||||
Err(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_all(&mut self, mut scores: ScoreVec<T>) {
|
||||
let mut score_mut = self.scores.write().expect("Should acquire lock");
|
||||
@@ -58,15 +73,7 @@ impl<T: Topology> Maestro<T> {
|
||||
}
|
||||
|
||||
fn is_topology_initialized(&self) -> bool {
|
||||
let result = self.topology_preparation_result.lock().unwrap();
|
||||
if let Some(outcome) = result.as_ref() {
|
||||
match outcome.status {
|
||||
InterpretStatus::SUCCESS => return true,
|
||||
_ => return false,
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
self.topology_state.status == TopologyStatus::Success
|
||||
}
|
||||
|
||||
pub async fn interpret(&self, score: Box<dyn Score<T>>) -> Result<Outcome, InterpretError> {
|
||||
@@ -77,11 +84,9 @@ impl<T: Topology> Maestro<T> {
|
||||
self.topology.name(),
|
||||
);
|
||||
}
|
||||
info!("Running score {score:?}");
|
||||
let interpret = score.create_interpret();
|
||||
info!("Launching interpret {interpret:?}");
|
||||
let result = interpret.execute(&self.inventory, &self.topology).await;
|
||||
info!("Got result {result:?}");
|
||||
debug!("Interpreting score {score:?}");
|
||||
let result = score.interpret(&self.inventory, &self.topology).await;
|
||||
debug!("Got result {result:?}");
|
||||
result
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod data;
|
||||
pub mod executors;
|
||||
pub mod filter;
|
||||
pub mod hardware;
|
||||
pub mod instrumentation;
|
||||
pub mod interpret;
|
||||
pub mod inventory;
|
||||
pub mod maestro;
|
||||
|
||||
@@ -1,22 +1,62 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::Serialize;
|
||||
use serde_value::Value;
|
||||
|
||||
use super::{interpret::Interpret, topology::Topology};
|
||||
use super::{
|
||||
data::Id,
|
||||
instrumentation::{self, HarmonyEvent},
|
||||
interpret::{Interpret, InterpretError, Outcome},
|
||||
inventory::Inventory,
|
||||
topology::Topology,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait Score<T: Topology>:
|
||||
std::fmt::Debug + ScoreToString<T> + Send + Sync + CloneBoxScore<T> + SerializeScore<T>
|
||||
{
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>>;
|
||||
async fn interpret(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &T,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
let id = Id::default();
|
||||
let interpret = self.create_interpret();
|
||||
|
||||
instrumentation::instrument(HarmonyEvent::InterpretExecutionStarted {
|
||||
execution_id: id.clone().to_string(),
|
||||
topology: topology.name().into(),
|
||||
interpret: interpret.get_name().to_string(),
|
||||
score: self.name(),
|
||||
message: format!("{} running...", interpret.get_name()),
|
||||
})
|
||||
.unwrap();
|
||||
let result = interpret.execute(inventory, topology).await;
|
||||
|
||||
instrumentation::instrument(HarmonyEvent::InterpretExecutionFinished {
|
||||
execution_id: id.clone().to_string(),
|
||||
topology: topology.name().into(),
|
||||
interpret: interpret.get_name().to_string(),
|
||||
score: self.name(),
|
||||
outcome: result.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn name(&self) -> String;
|
||||
|
||||
#[doc(hidden)]
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>>;
|
||||
}
|
||||
|
||||
pub trait SerializeScore<T: Topology> {
|
||||
fn serialize(&self) -> Value;
|
||||
}
|
||||
|
||||
impl<'de, S, T> SerializeScore<T> for S
|
||||
impl<S, T> SerializeScore<T> for S
|
||||
where
|
||||
T: Topology,
|
||||
S: Score<T> + Serialize,
|
||||
@@ -24,7 +64,7 @@ where
|
||||
fn serialize(&self) -> Value {
|
||||
// TODO not sure if this is the right place to handle the error or it should bubble
|
||||
// up?
|
||||
serde_value::to_value(&self).expect("Score should serialize successfully")
|
||||
serde_value::to_value(self).expect("Score should serialize successfully")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
59
harmony/src/domain/score_with_dep.rs
Normal file
59
harmony/src/domain/score_with_dep.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
////////////////////
|
||||
/// Working idea
|
||||
///
|
||||
///
|
||||
trait ScoreWithDep<T> {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>>;
|
||||
fn name(&self) -> String;
|
||||
fn get_dependencies(&self) -> Vec<TypeId>; // Force T to impl Installer<TypeId> or something
|
||||
// like that
|
||||
}
|
||||
|
||||
struct PrometheusAlertScore;
|
||||
|
||||
impl <T> ScoreWithDep<T> for PrometheusAlertScore {
|
||||
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn name(&self) -> String {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_dependencies(&self) -> Vec<TypeId> {
|
||||
// We have to find a way to constrait here so at compile time we are only allowed to return
|
||||
// TypeId for types which can be installed by T
|
||||
//
|
||||
// This means, for example that T must implement HelmCommand if the impl <T: HelmCommand> Installable<T> for
|
||||
// KubePrometheus calls for HelmCommand.
|
||||
vec![TypeId::of::<KubePrometheus>()]
|
||||
}
|
||||
}
|
||||
|
||||
trait Installable{}
|
||||
|
||||
struct KubePrometheus;
|
||||
|
||||
impl Installable for KubePrometheus;
|
||||
|
||||
|
||||
struct Maestro<T> {
|
||||
topology: T
|
||||
}
|
||||
|
||||
impl <T>Maestro<T> {
|
||||
fn execute_store(&self, score: ScoreWithDep<T>) {
|
||||
score.get_dependencies().iter().for_each(|dep| {
|
||||
self.topology.ensure_dependency_ready(dep);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct TopologyWithDep {
|
||||
}
|
||||
|
||||
impl TopologyWithDep {
|
||||
fn ensure_dependency_ready(&self, type_id: TypeId) -> Result<(), String> {
|
||||
self.installer
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,6 @@ use harmony_types::net::MacAddress;
|
||||
use log::info;
|
||||
|
||||
use crate::executors::ExecutorError;
|
||||
use crate::interpret::InterpretError;
|
||||
use crate::interpret::Outcome;
|
||||
|
||||
use super::DHCPStaticEntry;
|
||||
use super::DhcpServer;
|
||||
@@ -19,6 +17,8 @@ use super::K8sclient;
|
||||
use super::LoadBalancer;
|
||||
use super::LoadBalancerService;
|
||||
use super::LogicalHost;
|
||||
use super::PreparationError;
|
||||
use super::PreparationOutcome;
|
||||
use super::Router;
|
||||
use super::TftpServer;
|
||||
|
||||
@@ -46,9 +46,9 @@ pub struct HAClusterTopology {
|
||||
#[async_trait]
|
||||
impl Topology for HAClusterTopology {
|
||||
fn name(&self) -> &str {
|
||||
todo!()
|
||||
"HAClusterTopology"
|
||||
}
|
||||
async fn ensure_ready(&self) -> Result<Outcome, InterpretError> {
|
||||
async fn ensure_ready(&self) -> Result<PreparationOutcome, PreparationError> {
|
||||
todo!(
|
||||
"ensure_ready, not entirely sure what it should do here, probably something like verify that the hosts are reachable and all services are up and ready."
|
||||
)
|
||||
@@ -244,10 +244,12 @@ impl Topology for DummyInfra {
|
||||
todo!()
|
||||
}
|
||||
|
||||
async fn ensure_ready(&self) -> Result<Outcome, InterpretError> {
|
||||
async fn ensure_ready(&self) -> Result<PreparationOutcome, PreparationError> {
|
||||
let dummy_msg = "This is a dummy infrastructure that does nothing";
|
||||
info!("{dummy_msg}");
|
||||
Ok(Outcome::success(dummy_msg.to_string()))
|
||||
Ok(PreparationOutcome::Success {
|
||||
details: dummy_msg.into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
14
harmony/src/domain/topology/installable.rs
Normal file
14
harmony/src/domain/topology/installable.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::{interpret::InterpretError, inventory::Inventory};
|
||||
|
||||
#[async_trait]
|
||||
pub trait Installable<T>: Send + Sync {
|
||||
async fn configure(&self, inventory: &Inventory, topology: &T) -> Result<(), InterpretError>;
|
||||
|
||||
async fn ensure_installed(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &T,
|
||||
) -> Result<(), InterpretError>;
|
||||
}
|
||||
@@ -1,18 +1,49 @@
|
||||
use derive_new::new;
|
||||
use k8s_openapi::NamespaceResourceScope;
|
||||
use kube::{
|
||||
Api, Client, Config, Error, Resource,
|
||||
api::PostParams,
|
||||
config::{KubeConfigOptions, Kubeconfig},
|
||||
use k8s_openapi::{
|
||||
ClusterResourceScope, NamespaceResourceScope,
|
||||
api::{apps::v1::Deployment, core::v1::Pod},
|
||||
};
|
||||
use log::error;
|
||||
use serde::de::DeserializeOwned;
|
||||
use kube::{
|
||||
Client, Config, Error, Resource,
|
||||
api::{Api, AttachParams, ListParams, Patch, PatchParams, ResourceExt},
|
||||
config::{KubeConfigOptions, Kubeconfig},
|
||||
core::ErrorResponse,
|
||||
runtime::reflector::Lookup,
|
||||
};
|
||||
use kube::{api::DynamicObject, runtime::conditions};
|
||||
use kube::{
|
||||
api::{ApiResource, GroupVersionKind},
|
||||
runtime::wait::await_condition,
|
||||
};
|
||||
use log::{debug, error, trace};
|
||||
use serde::{Serialize, de::DeserializeOwned};
|
||||
use similar::TextDiff;
|
||||
|
||||
#[derive(new)]
|
||||
#[derive(new, Clone)]
|
||||
pub struct K8sClient {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl Serialize for K8sClient {
|
||||
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for K8sClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
// This is a poor man's debug implementation for now as kube::Client does not provide much
|
||||
// useful information
|
||||
f.write_fmt(format_args!(
|
||||
"K8sClient {{ kube client using default namespace {} }}",
|
||||
self.client.default_namespace()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl K8sClient {
|
||||
pub async fn try_default() -> Result<Self, Error> {
|
||||
Ok(Self {
|
||||
@@ -20,52 +51,264 @@ impl K8sClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn apply_all<
|
||||
K: Resource<Scope = NamespaceResourceScope>
|
||||
+ std::fmt::Debug
|
||||
+ Sync
|
||||
+ DeserializeOwned
|
||||
+ Default
|
||||
+ serde::Serialize
|
||||
+ Clone,
|
||||
>(
|
||||
pub async fn wait_until_deployment_ready(
|
||||
&self,
|
||||
resource: &Vec<K>,
|
||||
) -> Result<Vec<K>, kube::Error>
|
||||
name: String,
|
||||
namespace: Option<&str>,
|
||||
timeout: Option<u64>,
|
||||
) -> Result<(), String> {
|
||||
let api: Api<Deployment>;
|
||||
|
||||
if let Some(ns) = namespace {
|
||||
api = Api::namespaced(self.client.clone(), ns);
|
||||
} else {
|
||||
api = Api::default_namespaced(self.client.clone());
|
||||
}
|
||||
|
||||
let establish = await_condition(api, name.as_str(), conditions::is_deployment_completed());
|
||||
let t = timeout.unwrap_or(300);
|
||||
let res = tokio::time::timeout(std::time::Duration::from_secs(t), establish).await;
|
||||
|
||||
if res.is_ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("timed out while waiting for deployment".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Will execute a command in the first pod found that matches the label `app.kubernetes.io/name={name}`
|
||||
pub async fn exec_app(
|
||||
&self,
|
||||
name: String,
|
||||
namespace: Option<&str>,
|
||||
command: Vec<&str>,
|
||||
) -> Result<(), String> {
|
||||
let api: Api<Pod>;
|
||||
|
||||
if let Some(ns) = namespace {
|
||||
api = Api::namespaced(self.client.clone(), ns);
|
||||
} else {
|
||||
api = Api::default_namespaced(self.client.clone());
|
||||
}
|
||||
let pod_list = api
|
||||
.list(&ListParams::default().labels(format!("app.kubernetes.io/name={name}").as_str()))
|
||||
.await
|
||||
.expect("couldn't get list of pods");
|
||||
|
||||
let res = api
|
||||
.exec(
|
||||
pod_list
|
||||
.items
|
||||
.first()
|
||||
.expect("couldn't get pod")
|
||||
.name()
|
||||
.expect("couldn't get pod name")
|
||||
.into_owned()
|
||||
.as_str(),
|
||||
command,
|
||||
&AttachParams::default(),
|
||||
)
|
||||
.await;
|
||||
|
||||
match res {
|
||||
Err(e) => Err(e.to_string()),
|
||||
Ok(mut process) => {
|
||||
let status = process
|
||||
.take_status()
|
||||
.expect("Couldn't get status")
|
||||
.await
|
||||
.expect("Couldn't unwrap status");
|
||||
|
||||
if let Some(s) = status.status {
|
||||
debug!("Status: {}", s);
|
||||
if s == "Success" { Ok(()) } else { Err(s) }
|
||||
} else {
|
||||
Err("Couldn't get inner status of pod exec".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a resource in namespace
|
||||
///
|
||||
/// See `kubectl apply` for more information on the expected behavior of this function
|
||||
pub async fn apply<K>(&self, resource: &K, namespace: Option<&str>) -> Result<K, Error>
|
||||
where
|
||||
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + serde::Serialize,
|
||||
<K as Resource>::Scope: ApplyStrategy<K>,
|
||||
<K as kube::Resource>::DynamicType: Default,
|
||||
{
|
||||
let mut result = vec![];
|
||||
for r in resource.iter() {
|
||||
let api: Api<K> = Api::all(self.client.clone());
|
||||
result.push(api.create(&PostParams::default(), &r).await?);
|
||||
debug!(
|
||||
"Applying resource {:?} with ns {:?}",
|
||||
resource.meta().name,
|
||||
namespace
|
||||
);
|
||||
trace!(
|
||||
"{:#}",
|
||||
serde_json::to_value(resource).unwrap_or(serde_json::Value::Null)
|
||||
);
|
||||
|
||||
let api: Api<K> =
|
||||
<<K as Resource>::Scope as ApplyStrategy<K>>::get_api(&self.client, namespace);
|
||||
// api.create(&PostParams::default(), &resource).await
|
||||
let patch_params = PatchParams::apply("harmony");
|
||||
let name = resource
|
||||
.meta()
|
||||
.name
|
||||
.as_ref()
|
||||
.expect("K8s Resource should have a name");
|
||||
|
||||
if *crate::config::DRY_RUN {
|
||||
match api.get(name).await {
|
||||
Ok(current) => {
|
||||
trace!("Received current value {current:#?}");
|
||||
// The resource exists, so we calculate and display a diff.
|
||||
println!("\nPerforming dry-run for resource: '{}'", name);
|
||||
let mut current_yaml = serde_yaml::to_value(¤t).unwrap_or_else(|_| {
|
||||
panic!("Could not serialize current value : {current:#?}")
|
||||
});
|
||||
if current_yaml.is_mapping() && current_yaml.get("status").is_some() {
|
||||
let map = current_yaml.as_mapping_mut().unwrap();
|
||||
let removed = map.remove_entry("status");
|
||||
trace!("Removed status {:?}", removed);
|
||||
} else {
|
||||
trace!(
|
||||
"Did not find status entry for current object {}/{}",
|
||||
current.meta().namespace.as_ref().unwrap_or(&"".to_string()),
|
||||
current.meta().name.as_ref().unwrap_or(&"".to_string())
|
||||
);
|
||||
}
|
||||
let current_yaml = serde_yaml::to_string(¤t_yaml)
|
||||
.unwrap_or_else(|_| "Failed to serialize current resource".to_string());
|
||||
let new_yaml = serde_yaml::to_string(resource)
|
||||
.unwrap_or_else(|_| "Failed to serialize new resource".to_string());
|
||||
|
||||
if current_yaml == new_yaml {
|
||||
println!("No changes detected.");
|
||||
// Return the current resource state as there are no changes.
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
println!("Changes detected:");
|
||||
let diff = TextDiff::from_lines(¤t_yaml, &new_yaml);
|
||||
|
||||
// Iterate over the changes and print them in a git-like diff format.
|
||||
for change in diff.iter_all_changes() {
|
||||
let sign = match change.tag() {
|
||||
similar::ChangeTag::Delete => "-",
|
||||
similar::ChangeTag::Insert => "+",
|
||||
similar::ChangeTag::Equal => " ",
|
||||
};
|
||||
print!("{}{}", sign, change);
|
||||
}
|
||||
// In a dry run, we return the new resource state that would have been applied.
|
||||
Ok(resource.clone())
|
||||
}
|
||||
Err(Error::Api(ErrorResponse { code: 404, .. })) => {
|
||||
// The resource does not exist, so the "diff" is the entire new resource.
|
||||
println!("\nPerforming dry-run for new resource: '{}'", name);
|
||||
println!(
|
||||
"Resource does not exist. It would be created with the following content:"
|
||||
);
|
||||
let new_yaml = serde_yaml::to_string(resource)
|
||||
.unwrap_or_else(|_| "Failed to serialize new resource".to_string());
|
||||
|
||||
// Print each line of the new resource with a '+' prefix.
|
||||
for line in new_yaml.lines() {
|
||||
println!("+{}", line);
|
||||
}
|
||||
// In a dry run, we return the new resource state that would have been created.
|
||||
Ok(resource.clone())
|
||||
}
|
||||
Err(e) => {
|
||||
// Another API error occurred.
|
||||
error!("Failed to get resource '{}': {}", name, e);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return api
|
||||
.patch(name, &patch_params, &Patch::Apply(resource))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn apply_many<K>(&self, resource: &[K], ns: Option<&str>) -> Result<Vec<K>, Error>
|
||||
where
|
||||
K: Resource + Clone + std::fmt::Debug + DeserializeOwned + serde::Serialize,
|
||||
<K as Resource>::Scope: ApplyStrategy<K>,
|
||||
<K as kube::Resource>::DynamicType: Default,
|
||||
{
|
||||
let mut result = Vec::new();
|
||||
for r in resource.iter() {
|
||||
result.push(self.apply(r, ns).await?);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub async fn apply_namespaced<K>(
|
||||
pub async fn apply_yaml_many(
|
||||
&self,
|
||||
resource: &Vec<K>,
|
||||
#[allow(clippy::ptr_arg)] yaml: &Vec<serde_yaml::Value>,
|
||||
ns: Option<&str>,
|
||||
) -> Result<Vec<K>, Error>
|
||||
where
|
||||
K: Resource<Scope = NamespaceResourceScope>
|
||||
+ Clone
|
||||
+ std::fmt::Debug
|
||||
+ DeserializeOwned
|
||||
+ serde::Serialize
|
||||
+ Default,
|
||||
<K as kube::Resource>::DynamicType: Default,
|
||||
{
|
||||
let mut resources = Vec::new();
|
||||
for r in resource.iter() {
|
||||
let api: Api<K> = match ns {
|
||||
Some(ns) => Api::namespaced(self.client.clone(), ns),
|
||||
None => Api::default_namespaced(self.client.clone()),
|
||||
};
|
||||
resources.push(api.create(&PostParams::default(), &r).await?);
|
||||
) -> Result<(), Error> {
|
||||
for y in yaml.iter() {
|
||||
self.apply_yaml(y, ns).await?;
|
||||
}
|
||||
Ok(resources)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn apply_yaml(
|
||||
&self,
|
||||
yaml: &serde_yaml::Value,
|
||||
ns: Option<&str>,
|
||||
) -> Result<(), Error> {
|
||||
let obj: DynamicObject = serde_yaml::from_value(yaml.clone()).expect("TODO do not unwrap");
|
||||
let name = obj.metadata.name.as_ref().expect("YAML must have a name");
|
||||
|
||||
let api_version = yaml
|
||||
.get("apiVersion")
|
||||
.expect("couldn't get apiVersion from YAML")
|
||||
.as_str()
|
||||
.expect("couldn't get apiVersion as str");
|
||||
let kind = yaml
|
||||
.get("kind")
|
||||
.expect("couldn't get kind from YAML")
|
||||
.as_str()
|
||||
.expect("couldn't get kind as str");
|
||||
|
||||
let split: Vec<&str> = api_version.splitn(2, "/").collect();
|
||||
let g = split[0];
|
||||
let v = split[1];
|
||||
|
||||
let gvk = GroupVersionKind::gvk(g, v, kind);
|
||||
let api_resource = ApiResource::from_gvk(&gvk);
|
||||
|
||||
let namespace = match ns {
|
||||
Some(n) => n,
|
||||
None => obj
|
||||
.metadata
|
||||
.namespace
|
||||
.as_ref()
|
||||
.expect("YAML must have a namespace"),
|
||||
};
|
||||
|
||||
// 5. Create a dynamic API client for this resource type.
|
||||
let api: Api<DynamicObject> =
|
||||
Api::namespaced_with(self.client.clone(), namespace, &api_resource);
|
||||
|
||||
// 6. Apply the object to the cluster using Server-Side Apply.
|
||||
// This will create the resource if it doesn't exist, or update it if it does.
|
||||
println!(
|
||||
"Applying Argo Application '{}' in namespace '{}'...",
|
||||
name, namespace
|
||||
);
|
||||
let patch_params = PatchParams::apply("harmony"); // Use a unique field manager name
|
||||
let result = api.patch(name, &patch_params, &Patch::Apply(&obj)).await?;
|
||||
|
||||
println!("Successfully applied '{}'.", result.name_any());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn from_kubeconfig(path: &str) -> Option<K8sClient> {
|
||||
@@ -86,3 +329,35 @@ impl K8sClient {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub trait ApplyStrategy<K: Resource> {
|
||||
fn get_api(client: &Client, ns: Option<&str>) -> Api<K>;
|
||||
}
|
||||
|
||||
/// Implementation for all resources that are cluster-scoped.
|
||||
/// It will always use `Api::all` and ignore the namespace parameter.
|
||||
impl<K> ApplyStrategy<K> for ClusterResourceScope
|
||||
where
|
||||
K: Resource<Scope = ClusterResourceScope>,
|
||||
<K as kube::Resource>::DynamicType: Default,
|
||||
{
|
||||
fn get_api(client: &Client, _ns: Option<&str>) -> Api<K> {
|
||||
Api::all(client.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Implementation for all resources that are namespace-scoped.
|
||||
/// It will use `Api::namespaced` if a namespace is provided, otherwise
|
||||
/// it falls back to the default namespace configured in your kubeconfig.
|
||||
impl<K> ApplyStrategy<K> for NamespaceResourceScope
|
||||
where
|
||||
K: Resource<Scope = NamespaceResourceScope>,
|
||||
<K as kube::Resource>::DynamicType: Default,
|
||||
{
|
||||
fn get_api(client: &Client, ns: Option<&str>) -> Api<K> {
|
||||
match ns {
|
||||
Some(ns) => Api::namespaced(client.clone(), ns),
|
||||
None => Api::default_namespaced(client.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,54 @@
|
||||
use std::{process::Command, sync::Arc};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use inquire::Confirm;
|
||||
use log::{info, warn};
|
||||
use log::{debug, info, warn};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use crate::{
|
||||
interpret::{InterpretError, Outcome},
|
||||
executors::ExecutorError,
|
||||
interpret::InterpretStatus,
|
||||
inventory::Inventory,
|
||||
maestro::Maestro,
|
||||
modules::k3d::K3DInstallationScore,
|
||||
topology::LocalhostTopology,
|
||||
modules::{
|
||||
k3d::K3DInstallationScore,
|
||||
monitoring::kube_prometheus::crd::{
|
||||
crd_alertmanager_config::CRDPrometheus,
|
||||
prometheus_operator::prometheus_operator_helm_chart_score,
|
||||
},
|
||||
prometheus::{
|
||||
k8s_prometheus_alerting_score::K8sPrometheusCRDAlertingScore,
|
||||
prometheus::PrometheusApplicationMonitoring,
|
||||
},
|
||||
},
|
||||
score::Score,
|
||||
};
|
||||
|
||||
use super::{HelmCommand, K8sclient, Topology, k8s::K8sClient};
|
||||
use super::{
|
||||
DeploymentTarget, HelmCommand, K8sclient, MultiTargetTopology, PreparationError,
|
||||
PreparationOutcome, Topology,
|
||||
k8s::K8sClient,
|
||||
oberservability::monitoring::AlertReceiver,
|
||||
tenant::{TenantConfig, TenantManager, k8s::K8sTenantManager},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct K8sState {
|
||||
client: Arc<K8sClient>,
|
||||
source: K8sSource,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum K8sSource {
|
||||
LocalK3d,
|
||||
Kubeconfig,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct K8sAnywhereTopology {
|
||||
k8s_state: OnceCell<Option<K8sState>>,
|
||||
k8s_state: Arc<OnceCell<Option<K8sState>>>,
|
||||
tenant_manager: Arc<OnceCell<K8sTenantManager>>,
|
||||
config: Arc<K8sAnywhereConfig>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -47,10 +68,76 @@ impl K8sclient for K8sAnywhereTopology {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl PrometheusApplicationMonitoring<CRDPrometheus> for K8sAnywhereTopology {
|
||||
async fn install_prometheus(
|
||||
&self,
|
||||
sender: &CRDPrometheus,
|
||||
inventory: &Inventory,
|
||||
receivers: Option<Vec<Box<dyn AlertReceiver<CRDPrometheus>>>>,
|
||||
) -> Result<PreparationOutcome, PreparationError> {
|
||||
let po_result = self.ensure_prometheus_operator(sender).await?;
|
||||
|
||||
if po_result == PreparationOutcome::Noop {
|
||||
debug!("Skipping Prometheus CR installation due to missing operator.");
|
||||
return Ok(po_result);
|
||||
}
|
||||
|
||||
let result = self
|
||||
.get_k8s_prometheus_application_score(sender.clone(), receivers)
|
||||
.await
|
||||
.interpret(inventory, self)
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(outcome) => match outcome.status {
|
||||
InterpretStatus::SUCCESS => Ok(PreparationOutcome::Success {
|
||||
details: outcome.message,
|
||||
}),
|
||||
InterpretStatus::NOOP => Ok(PreparationOutcome::Noop),
|
||||
_ => Err(PreparationError::new(outcome.message)),
|
||||
},
|
||||
Err(err) => Err(PreparationError::new(err.to_string())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for K8sAnywhereTopology {
|
||||
fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error>
|
||||
where
|
||||
S: serde::Serializer,
|
||||
{
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
impl K8sAnywhereTopology {
|
||||
pub fn new() -> Self {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
k8s_state: OnceCell::new(),
|
||||
k8s_state: Arc::new(OnceCell::new()),
|
||||
tenant_manager: Arc::new(OnceCell::new()),
|
||||
config: Arc::new(K8sAnywhereConfig::from_env()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_config(config: K8sAnywhereConfig) -> Self {
|
||||
Self {
|
||||
k8s_state: Arc::new(OnceCell::new()),
|
||||
tenant_manager: Arc::new(OnceCell::new()),
|
||||
config: Arc::new(config),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_k8s_prometheus_application_score(
|
||||
&self,
|
||||
sender: CRDPrometheus,
|
||||
receivers: Option<Vec<Box<dyn AlertReceiver<CRDPrometheus>>>>,
|
||||
) -> K8sPrometheusCRDAlertingScore {
|
||||
K8sPrometheusCRDAlertingScore {
|
||||
sender,
|
||||
receivers: receivers.unwrap_or_default(),
|
||||
service_monitors: vec![],
|
||||
prometheus_rules: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,9 +151,8 @@ impl K8sAnywhereTopology {
|
||||
return Err("Failed to run 'helm -version'".to_string());
|
||||
}
|
||||
|
||||
// Print the version output
|
||||
let version_output = String::from_utf8_lossy(&version_result.stdout);
|
||||
println!("Helm version: {}", version_output.trim());
|
||||
debug!("Helm version: {}", version_output.trim());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -83,33 +169,33 @@ impl K8sAnywhereTopology {
|
||||
K3DInstallationScore::default()
|
||||
}
|
||||
|
||||
async fn try_install_k3d(&self) -> Result<(), InterpretError> {
|
||||
let maestro = Maestro::initialize(Inventory::autoload(), LocalhostTopology::new()).await?;
|
||||
let k3d_score = self.get_k3d_installation_score();
|
||||
maestro.interpret(Box::new(k3d_score)).await?;
|
||||
Ok(())
|
||||
}
|
||||
async fn try_install_k3d(&self) -> Result<(), PreparationError> {
|
||||
let result = self
|
||||
.get_k3d_installation_score()
|
||||
.interpret(&Inventory::empty(), self)
|
||||
.await;
|
||||
|
||||
async fn try_get_or_install_k8s_client(&self) -> Result<Option<K8sState>, InterpretError> {
|
||||
let k8s_anywhere_config = K8sAnywhereConfig {
|
||||
kubeconfig: std::env::var("HARMONY_KUBECONFIG")
|
||||
.ok()
|
||||
.map(|v| v.to_string()),
|
||||
use_system_kubeconfig: std::env::var("HARMONY_USE_SYSTEM_KUBECONFIG")
|
||||
.map_or_else(|_| false, |v| v.parse().ok().unwrap_or(false)),
|
||||
autoinstall: std::env::var("HARMONY_AUTOINSTALL")
|
||||
.map_or_else(|_| false, |v| v.parse().ok().unwrap_or(false)),
|
||||
};
|
||||
|
||||
if k8s_anywhere_config.use_system_kubeconfig {
|
||||
match self.try_load_system_kubeconfig().await {
|
||||
Some(_client) => todo!(),
|
||||
None => todo!(),
|
||||
match result {
|
||||
Ok(outcome) => match outcome.status {
|
||||
InterpretStatus::SUCCESS => Ok(()),
|
||||
InterpretStatus::NOOP => Ok(()),
|
||||
_ => Err(PreparationError::new(outcome.message)),
|
||||
},
|
||||
Err(err) => Err(PreparationError::new(err.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(kubeconfig) = k8s_anywhere_config.kubeconfig {
|
||||
match self.try_load_kubeconfig(&kubeconfig).await {
|
||||
async fn try_get_or_install_k8s_client(&self) -> Result<Option<K8sState>, PreparationError> {
|
||||
let k8s_anywhere_config = &self.config;
|
||||
|
||||
// TODO this deserves some refactoring, it is becoming a bit hard to figure out
|
||||
// be careful when making modifications here
|
||||
if k8s_anywhere_config.use_local_k3d {
|
||||
debug!("Using local k3d cluster because of use_local_k3d set to true");
|
||||
} else {
|
||||
if let Some(kubeconfig) = &k8s_anywhere_config.kubeconfig {
|
||||
debug!("Loading kubeconfig {kubeconfig}");
|
||||
match self.try_load_kubeconfig(kubeconfig).await {
|
||||
Some(client) => {
|
||||
return Ok(Some(K8sState {
|
||||
client: Arc::new(client),
|
||||
@@ -118,30 +204,32 @@ impl K8sAnywhereTopology {
|
||||
}));
|
||||
}
|
||||
None => {
|
||||
return Err(InterpretError::new(format!(
|
||||
return Err(PreparationError::new(format!(
|
||||
"Failed to load kubeconfig from {kubeconfig}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if k8s_anywhere_config.use_system_kubeconfig {
|
||||
debug!("Loading system kubeconfig");
|
||||
match self.try_load_system_kubeconfig().await {
|
||||
Some(_client) => todo!(),
|
||||
None => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
info!("No kubernetes configuration found");
|
||||
}
|
||||
|
||||
if !k8s_anywhere_config.autoinstall {
|
||||
let confirmation = Confirm::new( "Harmony autoinstallation is not activated, do you wish to launch autoinstallation? : ")
|
||||
.with_default(false)
|
||||
.prompt()
|
||||
.expect("Unexpected prompt error");
|
||||
|
||||
if !confirmation {
|
||||
warn!(
|
||||
"Installation cancelled, K8sAnywhere could not initialize a valid Kubernetes client"
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Starting K8sAnywhere installation");
|
||||
debug!("Starting K8sAnywhere installation");
|
||||
self.try_install_k3d().await?;
|
||||
let k3d_score = self.get_k3d_installation_score();
|
||||
// I feel like having to rely on the k3d_rs crate here is a smell
|
||||
@@ -154,34 +242,136 @@ impl K8sAnywhereTopology {
|
||||
Ok(client) => K8sState {
|
||||
client: Arc::new(K8sClient::new(client)),
|
||||
source: K8sSource::LocalK3d,
|
||||
message: "Successfully installed K3D cluster and acquired client".to_string(),
|
||||
message: "K8s client ready".to_string(),
|
||||
},
|
||||
Err(_) => todo!(),
|
||||
};
|
||||
|
||||
Ok(Some(state))
|
||||
}
|
||||
|
||||
async fn ensure_k8s_tenant_manager(&self) -> Result<(), String> {
|
||||
if self.tenant_manager.get().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
struct K8sAnywhereConfig {
|
||||
self.tenant_manager
|
||||
.get_or_try_init(async || -> Result<K8sTenantManager, String> {
|
||||
// TOOD: checker si K8s ou K3d/s tenant manager (ref. issue https://git.nationtech.io/NationTech/harmony/issues/94)
|
||||
let k8s_client = self.k8s_client().await?;
|
||||
Ok(K8sTenantManager::new(k8s_client))
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_k8s_tenant_manager(&self) -> Result<&K8sTenantManager, ExecutorError> {
|
||||
match self.tenant_manager.get() {
|
||||
Some(t) => Ok(t),
|
||||
None => Err(ExecutorError::UnexpectedError(
|
||||
"K8sTenantManager not available".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_prometheus_operator(
|
||||
&self,
|
||||
sender: &CRDPrometheus,
|
||||
) -> Result<PreparationOutcome, PreparationError> {
|
||||
let status = Command::new("sh")
|
||||
.args(["-c", "kubectl get crd -A | grep -i prometheuses"])
|
||||
.status()
|
||||
.map_err(|e| PreparationError::new(format!("could not connect to cluster: {}", e)))?;
|
||||
|
||||
if !status.success() {
|
||||
if let Some(Some(k8s_state)) = self.k8s_state.get() {
|
||||
match k8s_state.source {
|
||||
K8sSource::LocalK3d => {
|
||||
debug!("installing prometheus operator");
|
||||
let op_score =
|
||||
prometheus_operator_helm_chart_score(sender.namespace.clone());
|
||||
let result = op_score.interpret(&Inventory::empty(), self).await;
|
||||
|
||||
return match result {
|
||||
Ok(outcome) => match outcome.status {
|
||||
InterpretStatus::SUCCESS => Ok(PreparationOutcome::Success {
|
||||
details: "installed prometheus operator".into(),
|
||||
}),
|
||||
InterpretStatus::NOOP => Ok(PreparationOutcome::Noop),
|
||||
_ => Err(PreparationError::new(
|
||||
"failed to install prometheus operator (unknown error)".into(),
|
||||
)),
|
||||
},
|
||||
Err(err) => Err(PreparationError::new(err.to_string())),
|
||||
};
|
||||
}
|
||||
K8sSource::Kubeconfig => {
|
||||
debug!("unable to install prometheus operator, contact cluster admin");
|
||||
return Ok(PreparationOutcome::Noop);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Unable to detect k8s_state. Skipping Prometheus Operator install.");
|
||||
return Ok(PreparationOutcome::Noop);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Prometheus operator is already present, skipping install");
|
||||
|
||||
Ok(PreparationOutcome::Success {
|
||||
details: "prometheus operator present in cluster".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct K8sAnywhereConfig {
|
||||
/// The path of the KUBECONFIG file that Harmony should use to interact with the Kubernetes
|
||||
/// cluster
|
||||
///
|
||||
/// Default : None
|
||||
kubeconfig: Option<String>,
|
||||
pub kubeconfig: Option<String>,
|
||||
|
||||
/// Whether to use the system KUBECONFIG, either the environment variable or the file in the
|
||||
/// default or configured location
|
||||
///
|
||||
/// Default : false
|
||||
use_system_kubeconfig: bool,
|
||||
pub use_system_kubeconfig: bool,
|
||||
|
||||
/// Whether to install automatically a kubernetes cluster
|
||||
///
|
||||
/// When enabled, autoinstall will setup a K3D cluster on the localhost. https://k3d.io/stable/
|
||||
///
|
||||
/// Default: true
|
||||
autoinstall: bool,
|
||||
pub autoinstall: bool,
|
||||
|
||||
/// Whether to use local k3d cluster.
|
||||
///
|
||||
/// Takes precedence over other options, useful to avoid messing up a remote cluster by mistake
|
||||
///
|
||||
/// default: true
|
||||
pub use_local_k3d: bool,
|
||||
pub harmony_profile: String,
|
||||
}
|
||||
|
||||
impl K8sAnywhereConfig {
|
||||
fn from_env() -> Self {
|
||||
Self {
|
||||
kubeconfig: std::env::var("KUBECONFIG").ok().map(|v| v.to_string()),
|
||||
use_system_kubeconfig: std::env::var("HARMONY_USE_SYSTEM_KUBECONFIG")
|
||||
.map_or_else(|_| false, |v| v.parse().ok().unwrap_or(false)),
|
||||
autoinstall: std::env::var("HARMONY_AUTOINSTALL")
|
||||
.map_or_else(|_| true, |v| v.parse().ok().unwrap_or(false)),
|
||||
// TODO harmony_profile should be managed at a more core level than this
|
||||
harmony_profile: std::env::var("HARMONY_PROFILE").map_or_else(
|
||||
|_| "dev".to_string(),
|
||||
|v| v.parse().ok().unwrap_or("dev".to_string()),
|
||||
),
|
||||
use_local_k3d: std::env::var("HARMONY_USE_LOCAL_K3D")
|
||||
.map_or_else(|_| true, |v| v.parse().ok().unwrap_or(true)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -190,24 +380,57 @@ impl Topology for K8sAnywhereTopology {
|
||||
"K8sAnywhereTopology"
|
||||
}
|
||||
|
||||
async fn ensure_ready(&self) -> Result<Outcome, InterpretError> {
|
||||
async fn ensure_ready(&self) -> Result<PreparationOutcome, PreparationError> {
|
||||
let k8s_state = self
|
||||
.k8s_state
|
||||
.get_or_try_init(|| self.try_get_or_install_k8s_client())
|
||||
.await?;
|
||||
|
||||
let k8s_state: &K8sState = k8s_state.as_ref().ok_or(InterpretError::new(
|
||||
"No K8s client could be found or installed".to_string(),
|
||||
let k8s_state: &K8sState = k8s_state.as_ref().ok_or(PreparationError::new(
|
||||
"no K8s client could be found or installed".to_string(),
|
||||
))?;
|
||||
|
||||
self.ensure_k8s_tenant_manager()
|
||||
.await
|
||||
.map_err(PreparationError::new)?;
|
||||
|
||||
match self.is_helm_available() {
|
||||
Ok(()) => Ok(Outcome::success(format!(
|
||||
"{} + helm available",
|
||||
k8s_state.message.clone()
|
||||
))),
|
||||
Err(e) => Err(InterpretError::new(format!("helm unavailable: {}", e))),
|
||||
Ok(()) => Ok(PreparationOutcome::Success {
|
||||
details: format!("{} + helm available", k8s_state.message.clone()),
|
||||
}),
|
||||
Err(e) => Err(PreparationError::new(format!("helm unavailable: {}", e))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MultiTargetTopology for K8sAnywhereTopology {
|
||||
fn current_target(&self) -> DeploymentTarget {
|
||||
if self.config.use_local_k3d {
|
||||
return DeploymentTarget::LocalDev;
|
||||
}
|
||||
|
||||
match self.config.harmony_profile.to_lowercase().as_str() {
|
||||
"staging" => DeploymentTarget::Staging,
|
||||
"production" => DeploymentTarget::Production,
|
||||
_ => todo!("HARMONY_PROFILE must be set when use_local_k3d is not set"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HelmCommand for K8sAnywhereTopology {}
|
||||
|
||||
#[async_trait]
|
||||
impl TenantManager for K8sAnywhereTopology {
|
||||
async fn provision_tenant(&self, config: &TenantConfig) -> Result<(), ExecutorError> {
|
||||
self.get_k8s_tenant_manager()?
|
||||
.provision_tenant(config)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_tenant_config(&self) -> Option<TenantConfig> {
|
||||
self.get_k8s_tenant_manager()
|
||||
.ok()?
|
||||
.get_tenant_config()
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,12 @@ use serde::Serialize;
|
||||
use super::{IpAddress, LogicalHost};
|
||||
use crate::executors::ExecutorError;
|
||||
|
||||
impl std::fmt::Debug for dyn LoadBalancer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_fmt(format_args!("LoadBalancer {}", self.get_ip()))
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait LoadBalancer: Send + Sync {
|
||||
fn get_ip(&self) -> IpAddress;
|
||||
@@ -32,11 +38,6 @@ pub trait LoadBalancer: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for dyn LoadBalancer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_fmt(format_args!("LoadBalancer {}", self.get_ip()))
|
||||
}
|
||||
}
|
||||
#[derive(Debug, PartialEq, Clone, Serialize)]
|
||||
pub struct LoadBalancerService {
|
||||
pub backend_servers: Vec<BackendServer>,
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use derive_new::new;
|
||||
|
||||
use crate::interpret::{InterpretError, Outcome};
|
||||
|
||||
use super::{HelmCommand, Topology};
|
||||
use super::{HelmCommand, PreparationError, PreparationOutcome, Topology};
|
||||
|
||||
#[derive(new)]
|
||||
pub struct LocalhostTopology;
|
||||
@@ -14,10 +12,10 @@ impl Topology for LocalhostTopology {
|
||||
"LocalHostTopology"
|
||||
}
|
||||
|
||||
async fn ensure_ready(&self) -> Result<Outcome, InterpretError> {
|
||||
Ok(Outcome::success(
|
||||
"Localhost is Chuck Norris, always ready.".to_string(),
|
||||
))
|
||||
async fn ensure_ready(&self) -> Result<PreparationOutcome, PreparationError> {
|
||||
Ok(PreparationOutcome::Success {
|
||||
details: "Localhost is Chuck Norris, always ready.".into(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
mod ha_cluster;
|
||||
mod host_binding;
|
||||
mod http;
|
||||
pub mod installable;
|
||||
mod k8s_anywhere;
|
||||
mod localhost;
|
||||
pub mod oberservability;
|
||||
pub mod tenant;
|
||||
use derive_new::new;
|
||||
pub use k8s_anywhere::*;
|
||||
pub use localhost::*;
|
||||
pub mod k8s;
|
||||
@@ -23,10 +27,13 @@ pub use tftp::*;
|
||||
mod helm_command;
|
||||
pub use helm_command::*;
|
||||
|
||||
use super::{
|
||||
executors::ExecutorError,
|
||||
instrumentation::{self, HarmonyEvent},
|
||||
};
|
||||
use std::error::Error;
|
||||
use std::net::IpAddr;
|
||||
|
||||
use super::interpret::{InterpretError, Outcome};
|
||||
|
||||
/// Represents a logical view of an infrastructure environment providing specific capabilities.
|
||||
///
|
||||
/// A Topology acts as a self-contained "package" responsible for managing access
|
||||
@@ -54,9 +61,139 @@ pub trait Topology: Send + Sync {
|
||||
/// * **Internal Orchestration:** For complex topologies, this method might manage dependencies on other sub-topologies, ensuring *their* `ensure_ready` is called first. Using nested `Maestros` to run setup `Scores` against these sub-topologies is the recommended pattern for non-trivial bootstrapping, allowing reuse of Harmony's core orchestration logic.
|
||||
///
|
||||
/// # Returns
|
||||
/// - `Ok(Outcome)`: Indicates the topology is now ready. The `Outcome` status might be `SUCCESS` if actions were taken, or `NOOP` if it was already ready. The message should provide context.
|
||||
/// - `Err(TopologyError)`: Indicates the topology could not reach a ready state due to configuration issues, discovery failures, bootstrap errors, or unsupported environments.
|
||||
async fn ensure_ready(&self) -> Result<Outcome, InterpretError>;
|
||||
/// - `Ok(PreparationOutcome)`: Indicates the topology is now ready. The `Outcome` status might be `SUCCESS` if actions were taken, or `NOOP` if it was already ready. The message should provide context.
|
||||
/// - `Err(PreparationError)`: Indicates the topology could not reach a ready state due to configuration issues, discovery failures, bootstrap errors, or unsupported environments.
|
||||
async fn ensure_ready(&self) -> Result<PreparationOutcome, PreparationError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PreparationOutcome {
|
||||
Success { details: String },
|
||||
Noop,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, new)]
|
||||
pub struct PreparationError {
|
||||
msg: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PreparationError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.msg)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for PreparationError {}
|
||||
|
||||
impl From<ExecutorError> for PreparationError {
|
||||
fn from(value: ExecutorError) -> Self {
|
||||
Self {
|
||||
msg: format!("InterpretError : {value}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<kube::Error> for PreparationError {
|
||||
fn from(value: kube::Error) -> Self {
|
||||
Self {
|
||||
msg: format!("PreparationError : {value}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for PreparationError {
|
||||
fn from(value: String) -> Self {
|
||||
Self {
|
||||
msg: format!("PreparationError : {value}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum TopologyStatus {
|
||||
Queued,
|
||||
Preparing,
|
||||
Success,
|
||||
Noop,
|
||||
Error,
|
||||
}
|
||||
|
||||
pub struct TopologyState {
|
||||
pub topology: String,
|
||||
pub status: TopologyStatus,
|
||||
}
|
||||
|
||||
impl TopologyState {
|
||||
pub fn new(topology: String) -> Self {
|
||||
let instance = Self {
|
||||
topology,
|
||||
status: TopologyStatus::Queued,
|
||||
};
|
||||
|
||||
instrumentation::instrument(HarmonyEvent::TopologyStateChanged {
|
||||
topology: instance.topology.clone(),
|
||||
status: instance.status.clone(),
|
||||
message: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
instance
|
||||
}
|
||||
|
||||
pub fn prepare(&mut self) {
|
||||
self.status = TopologyStatus::Preparing;
|
||||
|
||||
instrumentation::instrument(HarmonyEvent::TopologyStateChanged {
|
||||
topology: self.topology.clone(),
|
||||
status: self.status.clone(),
|
||||
message: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn success(&mut self, message: String) {
|
||||
self.status = TopologyStatus::Success;
|
||||
|
||||
instrumentation::instrument(HarmonyEvent::TopologyStateChanged {
|
||||
topology: self.topology.clone(),
|
||||
status: self.status.clone(),
|
||||
message: Some(message),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn noop(&mut self) {
|
||||
self.status = TopologyStatus::Noop;
|
||||
|
||||
instrumentation::instrument(HarmonyEvent::TopologyStateChanged {
|
||||
topology: self.topology.clone(),
|
||||
status: self.status.clone(),
|
||||
message: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub fn error(&mut self, message: String) {
|
||||
self.status = TopologyStatus::Error;
|
||||
|
||||
instrumentation::instrument(HarmonyEvent::TopologyStateChanged {
|
||||
topology: self.topology.clone(),
|
||||
status: self.status.clone(),
|
||||
message: Some(message),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum DeploymentTarget {
|
||||
LocalDev,
|
||||
Staging,
|
||||
Production,
|
||||
}
|
||||
|
||||
pub trait MultiTargetTopology: Topology {
|
||||
fn current_target(&self) -> DeploymentTarget;
|
||||
}
|
||||
|
||||
pub type IpAddress = IpAddr;
|
||||
@@ -74,7 +211,7 @@ impl Serialize for Url {
|
||||
{
|
||||
match self {
|
||||
Url::LocalFolder(path) => serializer.serialize_str(path),
|
||||
Url::Url(url) => serializer.serialize_str(&url.as_str()),
|
||||
Url::Url(url) => serializer.serialize_str(url.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1
harmony/src/domain/topology/oberservability/mod.rs
Normal file
1
harmony/src/domain/topology/oberservability/mod.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub mod monitoring;
|
||||
81
harmony/src/domain/topology/oberservability/monitoring.rs
Normal file
81
harmony/src/domain/topology/oberservability/monitoring.rs
Normal file
@@ -0,0 +1,81 @@
|
||||
use std::any::Any;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use log::debug;
|
||||
|
||||
use crate::{
|
||||
data::{Id, Version},
|
||||
interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome},
|
||||
inventory::Inventory,
|
||||
topology::{Topology, installable::Installable},
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
pub trait AlertSender: Send + Sync + std::fmt::Debug {
|
||||
fn name(&self) -> String;
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct AlertingInterpret<S: AlertSender> {
|
||||
pub sender: S,
|
||||
pub receivers: Vec<Box<dyn AlertReceiver<S>>>,
|
||||
pub rules: Vec<Box<dyn AlertRule<S>>>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S: AlertSender + Installable<T>, T: Topology> Interpret<T> for AlertingInterpret<S> {
|
||||
async fn execute(
|
||||
&self,
|
||||
inventory: &Inventory,
|
||||
topology: &T,
|
||||
) -> Result<Outcome, InterpretError> {
|
||||
self.sender.configure(inventory, topology).await?;
|
||||
for receiver in self.receivers.iter() {
|
||||
receiver.install(&self.sender).await?;
|
||||
}
|
||||
for rule in self.rules.iter() {
|
||||
debug!("installing rule: {:#?}", rule);
|
||||
rule.install(&self.sender).await?;
|
||||
}
|
||||
self.sender.ensure_installed(inventory, topology).await?;
|
||||
Ok(Outcome::success(format!(
|
||||
"successfully installed alert sender {}",
|
||||
self.sender.name()
|
||||
)))
|
||||
}
|
||||
|
||||
fn get_name(&self) -> InterpretName {
|
||||
InterpretName::Alerting
|
||||
}
|
||||
|
||||
fn get_version(&self) -> Version {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_status(&self) -> InterpretStatus {
|
||||
todo!()
|
||||
}
|
||||
|
||||
fn get_children(&self) -> Vec<Id> {
|
||||
todo!()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AlertReceiver<S: AlertSender>: std::fmt::Debug + Send + Sync {
|
||||
async fn install(&self, sender: &S) -> Result<Outcome, InterpretError>;
|
||||
fn name(&self) -> String;
|
||||
fn clone_box(&self) -> Box<dyn AlertReceiver<S>>;
|
||||
fn as_any(&self) -> &dyn Any;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait AlertRule<S: AlertSender>: std::fmt::Debug + Send + Sync {
|
||||
async fn install(&self, sender: &S) -> Result<Outcome, InterpretError>;
|
||||
fn clone_box(&self) -> Box<dyn AlertRule<S>>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ScrapeTarget<S: AlertSender> {
|
||||
async fn install(&self, sender: &S) -> Result<(), InterpretError>;
|
||||
}
|
||||
@@ -27,11 +27,11 @@ pub struct UnmanagedRouter {
|
||||
|
||||
impl Router for UnmanagedRouter {
|
||||
fn get_gateway(&self) -> IpAddress {
|
||||
self.gateway.clone()
|
||||
self.gateway
|
||||
}
|
||||
|
||||
fn get_cidr(&self) -> Ipv4Cidr {
|
||||
self.cidr.clone()
|
||||
self.cidr
|
||||
}
|
||||
|
||||
fn get_host(&self) -> LogicalHost {
|
||||
|
||||
446
harmony/src/domain/topology/tenant/k8s.rs
Normal file
446
harmony/src/domain/topology/tenant/k8s.rs
Normal file
@@ -0,0 +1,446 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::{
|
||||
executors::ExecutorError,
|
||||
topology::k8s::{ApplyStrategy, K8sClient},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use k8s_openapi::{
|
||||
api::{
|
||||
core::v1::{LimitRange, Namespace, ResourceQuota},
|
||||
networking::v1::{
|
||||
NetworkPolicy, NetworkPolicyEgressRule, NetworkPolicyIngressRule, NetworkPolicyPort,
|
||||
},
|
||||
},
|
||||
apimachinery::pkg::util::intstr::IntOrString,
|
||||
};
|
||||
use kube::Resource;
|
||||
use log::debug;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::json;
|
||||
use tokio::sync::OnceCell;
|
||||
|
||||
use super::{TenantConfig, TenantManager};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct K8sTenantManager {
|
||||
k8s_client: Arc<K8sClient>,
|
||||
k8s_tenant_config: Arc<OnceCell<TenantConfig>>,
|
||||
}
|
||||
|
||||
impl K8sTenantManager {
|
||||
pub fn new(client: Arc<K8sClient>) -> Self {
|
||||
Self {
|
||||
k8s_client: client,
|
||||
k8s_tenant_config: Arc::new(OnceCell::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl K8sTenantManager {
|
||||
fn get_namespace_name(&self, config: &TenantConfig) -> String {
|
||||
config.name.clone()
|
||||
}
|
||||
|
||||
fn ensure_constraints(&self, _namespace: &Namespace) -> Result<(), ExecutorError> {
|
||||
// TODO: Ensure constraints are applied to namespace (https://git.nationtech.io/NationTech/harmony/issues/98)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn apply_resource<
|
||||
K: Resource + std::fmt::Debug + Sync + DeserializeOwned + Default + serde::Serialize + Clone,
|
||||
>(
|
||||
&self,
|
||||
mut resource: K,
|
||||
config: &TenantConfig,
|
||||
) -> Result<K, ExecutorError>
|
||||
where
|
||||
<K as kube::Resource>::DynamicType: Default,
|
||||
<K as kube::Resource>::Scope: ApplyStrategy<K>,
|
||||
{
|
||||
self.apply_labels(&mut resource, config);
|
||||
self.k8s_client
|
||||
.apply(&resource, Some(&self.get_namespace_name(config)))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ExecutorError::UnexpectedError(format!("Could not create Tenant resource : {e}"))
|
||||
})
|
||||
}
|
||||
|
||||
fn apply_labels<K: Resource>(&self, resource: &mut K, config: &TenantConfig) {
|
||||
let labels = resource.meta_mut().labels.get_or_insert_default();
|
||||
labels.insert(
|
||||
"app.kubernetes.io/managed-by".to_string(),
|
||||
"harmony".to_string(),
|
||||
);
|
||||
labels.insert("harmony/tenant-id".to_string(), config.id.to_string());
|
||||
labels.insert("harmony/tenant-name".to_string(), config.name.clone());
|
||||
}
|
||||
|
||||
fn build_namespace(&self, config: &TenantConfig) -> Result<Namespace, ExecutorError> {
|
||||
let namespace = json!(
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "Namespace",
|
||||
"metadata": {
|
||||
"labels": {
|
||||
"harmony.nationtech.io/tenant.id": config.id.to_string(),
|
||||
"harmony.nationtech.io/tenant.name": config.name,
|
||||
},
|
||||
"name": self.get_namespace_name(config),
|
||||
},
|
||||
}
|
||||
);
|
||||
serde_json::from_value(namespace).map_err(|e| {
|
||||
ExecutorError::ConfigurationError(format!(
|
||||
"Could not build TenantManager Namespace. {}",
|
||||
e
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_resource_quota(&self, config: &TenantConfig) -> Result<ResourceQuota, ExecutorError> {
|
||||
let resource_quota = json!(
|
||||
{
|
||||
"apiVersion": "v1",
|
||||
"kind": "ResourceQuota",
|
||||
"metadata": {
|
||||
"name": format!("{}-quota", config.name),
|
||||
"labels": {
|
||||
"harmony.nationtech.io/tenant.id": config.id.to_string(),
|
||||
"harmony.nationtech.io/tenant.name": config.name,
|
||||
},
|
||||
"namespace": self.get_namespace_name(config),
|
||||
},
|
||||
"spec": {
|
||||
"hard": {
|
||||
"limits.cpu": format!("{:.0}",config.resource_limits.cpu_limit_cores),
|
||||
"limits.memory": format!("{:.3}Gi", config.resource_limits.memory_limit_gb),
|
||||
"requests.cpu": format!("{:.0}",config.resource_limits.cpu_request_cores),
|
||||
"requests.memory": format!("{:.3}Gi", config.resource_limits.memory_request_gb),
|
||||
"requests.storage": format!("{:.3}Gi", config.resource_limits.storage_total_gb),
|
||||
"pods": "20",
|
||||
"services": "10",
|
||||
"configmaps": "60",
|
||||
"secrets": "60",
|
||||
"persistentvolumeclaims": "15",
|
||||
"services.loadbalancers": "2",
|
||||
"services.nodeports": "5",
|
||||
"limits.ephemeral-storage": "10Gi",
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
);
|
||||
serde_json::from_value(resource_quota).map_err(|e| {
|
||||
ExecutorError::ConfigurationError(format!(
|
||||
"Could not build TenantManager ResourceQuota. {}",
|
||||
e
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_limit_range(&self, config: &TenantConfig) -> Result<LimitRange, ExecutorError> {
|
||||
let limit_range = json!({
|
||||
"apiVersion": "v1",
|
||||
"kind": "LimitRange",
|
||||
"metadata": {
|
||||
"name": format!("{}-defaults", config.name),
|
||||
"labels": {
|
||||
"harmony.nationtech.io/tenant.id": config.id.to_string(),
|
||||
"harmony.nationtech.io/tenant.name": config.name,
|
||||
},
|
||||
"namespace": self.get_namespace_name(config),
|
||||
},
|
||||
"spec": {
|
||||
"limits": [
|
||||
{
|
||||
"type": "Container",
|
||||
"default": {
|
||||
"cpu": "500m",
|
||||
"memory": "500Mi"
|
||||
},
|
||||
"defaultRequest": {
|
||||
"cpu": "100m",
|
||||
"memory": "100Mi"
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
serde_json::from_value(limit_range).map_err(|e| {
|
||||
ExecutorError::ConfigurationError(format!(
|
||||
"Could not build TenantManager LimitRange. {}",
|
||||
e
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_network_policy(&self, config: &TenantConfig) -> Result<NetworkPolicy, ExecutorError> {
|
||||
let network_policy = json!({
|
||||
"apiVersion": "networking.k8s.io/v1",
|
||||
"kind": "NetworkPolicy",
|
||||
"metadata": {
|
||||
"name": format!("{}-network-policy", config.name),
|
||||
"namespace": self.get_namespace_name(config),
|
||||
},
|
||||
"spec": {
|
||||
"podSelector": {},
|
||||
"egress": [
|
||||
{
|
||||
"to": [
|
||||
{ "podSelector": {} }
|
||||
]
|
||||
},
|
||||
{
|
||||
"to": [
|
||||
{
|
||||
"podSelector": {},
|
||||
"namespaceSelector": {
|
||||
"matchLabels": {
|
||||
"kubernetes.io/metadata.name": "kube-system"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"to": [
|
||||
{
|
||||
"podSelector": {},
|
||||
"namespaceSelector": {
|
||||
"matchLabels": {
|
||||
"kubernetes.io/metadata.name": "openshift-dns"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"to": [
|
||||
{
|
||||
"ipBlock": {
|
||||
"cidr": "10.43.0.1/32",
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"to": [
|
||||
{
|
||||
//TODO this ip is from the docker network that k3d is running on
|
||||
//since k3d does not deploy kube-api-server as a pod it needs to ahve the ip
|
||||
//address opened up
|
||||
//need to find a way to automatically detect the ip address from the docker
|
||||
//network
|
||||
"ipBlock": {
|
||||
"cidr": "172.18.0.0/16",
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"to": [
|
||||
{
|
||||
"ipBlock": {
|
||||
"cidr": "0.0.0.0/0",
|
||||
"except": [
|
||||
"10.0.0.0/8",
|
||||
"172.16.0.0/12",
|
||||
"192.168.0.0/16",
|
||||
"192.0.0.0/24",
|
||||
"192.0.2.0/24",
|
||||
"192.88.99.0/24",
|
||||
"192.18.0.0/15",
|
||||
"198.51.100.0/24",
|
||||
"169.254.0.0/16",
|
||||
"203.0.113.0/24",
|
||||
"127.0.0.0/8",
|
||||
"224.0.0.0/4",
|
||||
"240.0.0.0/4",
|
||||
"100.64.0.0/10",
|
||||
"233.252.0.0/24",
|
||||
"0.0.0.0/8"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"ingress": [
|
||||
{
|
||||
"from": [
|
||||
{ "podSelector": {} }
|
||||
]
|
||||
}
|
||||
],
|
||||
"policyTypes": [
|
||||
"Ingress",
|
||||
"Egress"
|
||||
]
|
||||
}
|
||||
});
|
||||
let mut network_policy: NetworkPolicy =
|
||||
serde_json::from_value(network_policy).map_err(|e| {
|
||||
ExecutorError::ConfigurationError(format!(
|
||||
"Could not build TenantManager NetworkPolicy. {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
config
|
||||
.network_policy
|
||||
.additional_allowed_cidr_ingress
|
||||
.iter()
|
||||
.try_for_each(|c| -> Result<(), ExecutorError> {
|
||||
let cidr_list: Vec<serde_json::Value> =
|
||||
c.0.iter()
|
||||
.map(|ci| {
|
||||
json!({
|
||||
"ipBlock": {
|
||||
"cidr": ci.to_string(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let ports: Option<Vec<NetworkPolicyPort>> =
|
||||
c.1.as_ref().map(|spec| match &spec.data {
|
||||
super::PortSpecData::SinglePort(port) => vec![NetworkPolicyPort {
|
||||
port: Some(IntOrString::Int((*port).into())),
|
||||
..Default::default()
|
||||
}],
|
||||
super::PortSpecData::PortRange(start, end) => vec![NetworkPolicyPort {
|
||||
port: Some(IntOrString::Int((*start).into())),
|
||||
end_port: Some((*end).into()),
|
||||
protocol: None, // Not currently supported by Harmony
|
||||
}],
|
||||
|
||||
super::PortSpecData::ListOfPorts(items) => items
|
||||
.iter()
|
||||
.map(|i| NetworkPolicyPort {
|
||||
port: Some(IntOrString::Int((*i).into())),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
let rule = serde_json::from_value::<NetworkPolicyIngressRule>(json!({
|
||||
"from": cidr_list,
|
||||
"ports": ports,
|
||||
}))
|
||||
.map_err(|e| {
|
||||
ExecutorError::ConfigurationError(format!(
|
||||
"Could not build TenantManager NetworkPolicyIngressRule. {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
network_policy
|
||||
.spec
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.ingress
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.push(rule);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
config
|
||||
.network_policy
|
||||
.additional_allowed_cidr_egress
|
||||
.iter()
|
||||
.try_for_each(|c| -> Result<(), ExecutorError> {
|
||||
let cidr_list: Vec<serde_json::Value> =
|
||||
c.0.iter()
|
||||
.map(|ci| {
|
||||
json!({
|
||||
"ipBlock": {
|
||||
"cidr": ci.to_string(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let ports: Option<Vec<NetworkPolicyPort>> =
|
||||
c.1.as_ref().map(|spec| match &spec.data {
|
||||
super::PortSpecData::SinglePort(port) => vec![NetworkPolicyPort {
|
||||
port: Some(IntOrString::Int((*port).into())),
|
||||
..Default::default()
|
||||
}],
|
||||
super::PortSpecData::PortRange(start, end) => vec![NetworkPolicyPort {
|
||||
port: Some(IntOrString::Int((*start).into())),
|
||||
end_port: Some((*end).into()),
|
||||
protocol: None, // Not currently supported by Harmony
|
||||
}],
|
||||
|
||||
super::PortSpecData::ListOfPorts(items) => items
|
||||
.iter()
|
||||
.map(|i| NetworkPolicyPort {
|
||||
port: Some(IntOrString::Int((*i).into())),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
let rule = serde_json::from_value::<NetworkPolicyEgressRule>(json!({
|
||||
"to": cidr_list,
|
||||
"ports": ports,
|
||||
}))
|
||||
.map_err(|e| {
|
||||
ExecutorError::ConfigurationError(format!(
|
||||
"Could not build TenantManager NetworkPolicyEgressRule. {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
network_policy
|
||||
.spec
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.egress
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.push(rule);
|
||||
Ok(())
|
||||
})?;
|
||||
|
||||
Ok(network_policy)
|
||||
}
|
||||
fn store_config(&self, config: &TenantConfig) {
|
||||
let _ = self.k8s_tenant_config.set(config.clone());
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TenantManager for K8sTenantManager {
|
||||
async fn provision_tenant(&self, config: &TenantConfig) -> Result<(), ExecutorError> {
|
||||
let namespace = self.build_namespace(config)?;
|
||||
let resource_quota = self.build_resource_quota(config)?;
|
||||
let network_policy = self.build_network_policy(config)?;
|
||||
let resource_limit_range = self.build_limit_range(config)?;
|
||||
|
||||
self.ensure_constraints(&namespace)?;
|
||||
|
||||
debug!("Creating namespace for tenant {}", config.name);
|
||||
self.apply_resource(namespace, config).await?;
|
||||
|
||||
debug!("Creating resource_quota for tenant {}", config.name);
|
||||
self.apply_resource(resource_quota, config).await?;
|
||||
|
||||
debug!("Creating limit_range for tenant {}", config.name);
|
||||
self.apply_resource(resource_limit_range, config).await?;
|
||||
|
||||
debug!("Creating network_policy for tenant {}", config.name);
|
||||
self.apply_resource(network_policy, config).await?;
|
||||
|
||||
debug!(
|
||||
"Success provisionning K8s tenant id {} name {}",
|
||||
config.id, config.name
|
||||
);
|
||||
self.store_config(config);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_tenant_config(&self) -> Option<TenantConfig> {
|
||||
self.k8s_tenant_config.get().cloned()
|
||||
}
|
||||
}
|
||||
20
harmony/src/domain/topology/tenant/manager.rs
Normal file
20
harmony/src/domain/topology/tenant/manager.rs
Normal file
@@ -0,0 +1,20 @@
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::executors::ExecutorError;
|
||||
|
||||
#[async_trait]
|
||||
pub trait TenantManager {
|
||||
/// Creates or update tenant based on the provided configuration.
|
||||
/// This operation should be idempotent; if a tenant with the same `config.id`
|
||||
/// already exists and matches the config, it will succeed without changes.
|
||||
///
|
||||
/// If it exists but differs, it will be updated, or return an error if the update
|
||||
/// action is not supported
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `config`: The desired configuration for the new tenant.
|
||||
async fn provision_tenant(&self, config: &TenantConfig) -> Result<(), ExecutorError>;
|
||||
|
||||
async fn get_tenant_config(&self) -> Option<TenantConfig>;
|
||||
}
|
||||
224
harmony/src/domain/topology/tenant/mod.rs
Normal file
224
harmony/src/domain/topology/tenant/mod.rs
Normal file
@@ -0,0 +1,224 @@
|
||||
pub mod k8s;
|
||||
mod manager;
|
||||
use std::str::FromStr;
|
||||
|
||||
pub use manager::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::data::Id;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] // Assuming serde for Scores
|
||||
pub struct TenantConfig {
|
||||
/// This will be used as the primary unique identifier for management operations and will never
|
||||
/// change for the entire lifetime of the tenant
|
||||
pub id: Id,
|
||||
|
||||
/// A human-readable name for the tenant (e.g., "client-alpha", "project-phoenix").
|
||||
pub name: String,
|
||||
|
||||
/// Desired resource allocations and limits for the tenant.
|
||||
pub resource_limits: ResourceLimits,
|
||||
|
||||
/// High-level network isolation policies for the tenant.
|
||||
pub network_policy: TenantNetworkPolicy,
|
||||
}
|
||||
|
||||
impl Default for TenantConfig {
|
||||
fn default() -> Self {
|
||||
let id = Id::default();
|
||||
Self {
|
||||
name: format!("tenant_{id}"),
|
||||
id,
|
||||
resource_limits: ResourceLimits::default(),
|
||||
network_policy: TenantNetworkPolicy {
|
||||
default_inter_tenant_ingress: InterTenantIngressPolicy::DenyAll,
|
||||
default_internet_egress: InternetEgressPolicy::AllowAll,
|
||||
additional_allowed_cidr_ingress: vec![],
|
||||
additional_allowed_cidr_egress: vec![],
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ResourceLimits {
|
||||
/// Requested/guaranteed CPU cores (e.g., 2.0).
|
||||
pub cpu_request_cores: f32,
|
||||
/// Maximum CPU cores the tenant can burst to (e.g., 4.0).
|
||||
pub cpu_limit_cores: f32,
|
||||
|
||||
/// Requested/guaranteed memory in Gigabytes (e.g., 8.0).
|
||||
pub memory_request_gb: f32,
|
||||
/// Maximum memory in Gigabytes tenant can burst to (e.g., 16.0).
|
||||
pub memory_limit_gb: f32,
|
||||
|
||||
/// Total persistent storage allocation in Gigabytes across all volumes.
|
||||
pub storage_total_gb: f32,
|
||||
}
|
||||
|
||||
impl Default for ResourceLimits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cpu_request_cores: 4.0,
|
||||
cpu_limit_cores: 4.0,
|
||||
memory_request_gb: 4.0,
|
||||
memory_limit_gb: 4.0,
|
||||
storage_total_gb: 20.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct TenantNetworkPolicy {
|
||||
/// Policy for ingress traffic originating from other tenants within the same Harmony-managed environment.
|
||||
pub default_inter_tenant_ingress: InterTenantIngressPolicy,
|
||||
|
||||
/// Policy for egress traffic destined for the public internet.
|
||||
pub default_internet_egress: InternetEgressPolicy,
|
||||
|
||||
pub additional_allowed_cidr_ingress: Vec<(Vec<cidr::Ipv4Cidr>, Option<PortSpec>)>,
|
||||
pub additional_allowed_cidr_egress: Vec<(Vec<cidr::Ipv4Cidr>, Option<PortSpec>)>,
|
||||
}
|
||||
|
||||
impl Default for TenantNetworkPolicy {
|
||||
fn default() -> Self {
|
||||
TenantNetworkPolicy {
|
||||
default_inter_tenant_ingress: InterTenantIngressPolicy::DenyAll,
|
||||
default_internet_egress: InternetEgressPolicy::DenyAll,
|
||||
additional_allowed_cidr_ingress: vec![],
|
||||
additional_allowed_cidr_egress: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum InterTenantIngressPolicy {
|
||||
/// Deny all traffic from other tenants by default.
|
||||
DenyAll,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum InternetEgressPolicy {
|
||||
/// Allow all outbound traffic to the internet.
|
||||
AllowAll,
|
||||
/// Deny all outbound traffic to the internet by default.
|
||||
DenyAll,
|
||||
}
|
||||
|
||||
/// Represents a port specification that can be either a single port, a comma-separated list of ports,
|
||||
/// or a range separated by a dash.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct PortSpec {
|
||||
/// The actual representation of the ports as strings for serialization/deserialization purposes.
|
||||
pub data: PortSpecData,
|
||||
}
|
||||
|
||||
impl PortSpec {
|
||||
/// TODO write short rust doc that shows what types of input are supported
|
||||
fn parse_from_str(spec: &str) -> Result<PortSpec, String> {
|
||||
// Check for single port
|
||||
if let Ok(port) = spec.parse::<u16>() {
|
||||
let spec = PortSpecData::SinglePort(port);
|
||||
return Ok(Self { data: spec });
|
||||
}
|
||||
|
||||
if let Some(range) = spec.find('-') {
|
||||
let start_str = &spec[..range];
|
||||
let end_str = &spec[(range + 1)..];
|
||||
|
||||
if let (Ok(start), Ok(end)) = (start_str.parse::<u16>(), end_str.parse::<u16>()) {
|
||||
let spec = PortSpecData::PortRange(start, end);
|
||||
return Ok(Self { data: spec });
|
||||
}
|
||||
}
|
||||
|
||||
let ports: Vec<&str> = spec.split(',').collect();
|
||||
if !ports.is_empty() && ports.iter().all(|p| p.parse::<u16>().is_ok()) {
|
||||
let maybe_ports = ports.iter().try_fold(vec![], |mut list, &p| {
|
||||
if let Ok(p) = p.parse::<u16>() {
|
||||
list.push(p);
|
||||
return Ok(list);
|
||||
}
|
||||
Err(())
|
||||
});
|
||||
|
||||
if let Ok(ports) = maybe_ports {
|
||||
let spec = PortSpecData::ListOfPorts(ports);
|
||||
return Ok(Self { data: spec });
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("Invalid port spec format {spec}"))
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for PortSpec {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse_from_str(s)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum PortSpecData {
|
||||
SinglePort(u16),
|
||||
PortRange(u16, u16),
|
||||
ListOfPorts(Vec<u16>),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_single_port() {
|
||||
let port_spec = "2144".parse::<PortSpec>().unwrap();
|
||||
match port_spec.data {
|
||||
PortSpecData::SinglePort(port) => assert_eq!(port, 2144),
|
||||
_ => panic!("Expected SinglePort"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_port_range() {
|
||||
let port_spec = "80-90".parse::<PortSpec>().unwrap();
|
||||
match port_spec.data {
|
||||
PortSpecData::PortRange(start, end) => {
|
||||
assert_eq!(start, 80);
|
||||
assert_eq!(end, 90);
|
||||
}
|
||||
_ => panic!("Expected PortRange"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_of_ports() {
|
||||
let port_spec = "2144,3424".parse::<PortSpec>().unwrap();
|
||||
match port_spec.data {
|
||||
PortSpecData::ListOfPorts(ports) => {
|
||||
assert_eq!(ports[0], 2144);
|
||||
assert_eq!(ports[1], 3424);
|
||||
}
|
||||
_ => panic!("Expected ListOfPorts"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_port_spec() {
|
||||
let result = "invalid".parse::<PortSpec>();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_input() {
|
||||
let result = "".parse::<PortSpec>();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_only_coma() {
|
||||
let result = ",".parse::<PortSpec>();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ impl DnsServer for OPNSenseFirewall {
|
||||
}
|
||||
|
||||
fn get_ip(&self) -> IpAddress {
|
||||
OPNSenseFirewall::get_ip(&self)
|
||||
OPNSenseFirewall::get_ip(self)
|
||||
}
|
||||
|
||||
fn get_host(&self) -> LogicalHost {
|
||||
|
||||
@@ -48,7 +48,7 @@ impl HttpServer for OPNSenseFirewall {
|
||||
async fn ensure_initialized(&self) -> Result<(), ExecutorError> {
|
||||
let mut config = self.opnsense_config.write().await;
|
||||
let caddy = config.caddy();
|
||||
if let None = caddy.get_full_config() {
|
||||
if caddy.get_full_config().is_none() {
|
||||
info!("Http config not available in opnsense config, installing package");
|
||||
config.install_package("os-caddy").await.map_err(|e| {
|
||||
ExecutorError::UnexpectedError(format!(
|
||||
|
||||
@@ -121,10 +121,12 @@ pub(crate) fn haproxy_xml_config_to_harmony_loadbalancer(
|
||||
|
||||
LoadBalancerService {
|
||||
backend_servers,
|
||||
listening_port: frontend.bind.parse().expect(&format!(
|
||||
listening_port: frontend.bind.parse().unwrap_or_else(|_| {
|
||||
panic!(
|
||||
"HAProxy frontend address should be a valid SocketAddr, got {}",
|
||||
frontend.bind
|
||||
)),
|
||||
)
|
||||
}),
|
||||
health_check,
|
||||
}
|
||||
})
|
||||
@@ -167,28 +169,28 @@ pub(crate) fn get_health_check_for_backend(
|
||||
None => return None,
|
||||
};
|
||||
|
||||
let haproxy_health_check = match haproxy
|
||||
let haproxy_health_check = haproxy
|
||||
.healthchecks
|
||||
.healthchecks
|
||||
.iter()
|
||||
.find(|h| &h.uuid == health_check_uuid)
|
||||
{
|
||||
Some(health_check) => health_check,
|
||||
None => return None,
|
||||
};
|
||||
.find(|h| &h.uuid == health_check_uuid)?;
|
||||
|
||||
let binding = haproxy_health_check.health_check_type.to_uppercase();
|
||||
let uppercase = binding.as_str();
|
||||
match uppercase {
|
||||
"TCP" => {
|
||||
if let Some(checkport) = haproxy_health_check.checkport.content.as_ref() {
|
||||
if checkport.len() > 0 {
|
||||
return Some(HealthCheck::TCP(Some(checkport.parse().expect(&format!(
|
||||
if !checkport.is_empty() {
|
||||
return Some(HealthCheck::TCP(Some(checkport.parse().unwrap_or_else(
|
||||
|_| {
|
||||
panic!(
|
||||
"HAProxy check port should be a valid port number, got {checkport}"
|
||||
)))));
|
||||
)
|
||||
},
|
||||
))));
|
||||
}
|
||||
}
|
||||
return Some(HealthCheck::TCP(None));
|
||||
Some(HealthCheck::TCP(None))
|
||||
}
|
||||
"HTTP" => {
|
||||
let path: String = haproxy_health_check
|
||||
@@ -355,25 +357,25 @@ mod tests {
|
||||
|
||||
// Create an HAProxy instance with servers
|
||||
let mut haproxy = HAProxy::default();
|
||||
let mut server = HAProxyServer::default();
|
||||
server.uuid = "server1".to_string();
|
||||
server.address = "192.168.1.1".to_string();
|
||||
server.port = 80;
|
||||
|
||||
let server = HAProxyServer {
|
||||
uuid: "server1".to_string(),
|
||||
address: "192.168.1.1".to_string(),
|
||||
port: 80,
|
||||
..Default::default()
|
||||
};
|
||||
haproxy.servers.servers.push(server);
|
||||
let mut server = HAProxyServer::default();
|
||||
server.uuid = "server3".to_string();
|
||||
server.address = "192.168.1.3".to_string();
|
||||
server.port = 8080;
|
||||
|
||||
// Call the function
|
||||
let result = get_servers_for_backend(&backend, &haproxy);
|
||||
|
||||
// Check the result
|
||||
assert_eq!(result, vec![BackendServer {
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![BackendServer {
|
||||
address: "192.168.1.1".to_string(),
|
||||
port: 80,
|
||||
},]);
|
||||
},]
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn test_get_servers_for_backend_no_linked_servers() {
|
||||
@@ -381,10 +383,12 @@ mod tests {
|
||||
let backend = HAProxyBackend::default();
|
||||
// Create an HAProxy instance with servers
|
||||
let mut haproxy = HAProxy::default();
|
||||
let mut server = HAProxyServer::default();
|
||||
server.uuid = "server1".to_string();
|
||||
server.address = "192.168.1.1".to_string();
|
||||
server.port = 80;
|
||||
let server = HAProxyServer {
|
||||
uuid: "server1".to_string(),
|
||||
address: "192.168.1.1".to_string(),
|
||||
port: 80,
|
||||
..Default::default()
|
||||
};
|
||||
haproxy.servers.servers.push(server);
|
||||
// Call the function
|
||||
let result = get_servers_for_backend(&backend, &haproxy);
|
||||
@@ -399,10 +403,12 @@ mod tests {
|
||||
backend.linked_servers.content = Some("server4,server5".to_string());
|
||||
// Create an HAProxy instance with servers
|
||||
let mut haproxy = HAProxy::default();
|
||||
let mut server = HAProxyServer::default();
|
||||
server.uuid = "server1".to_string();
|
||||
server.address = "192.168.1.1".to_string();
|
||||
server.port = 80;
|
||||
let server = HAProxyServer {
|
||||
uuid: "server1".to_string(),
|
||||
address: "192.168.1.1".to_string(),
|
||||
port: 80,
|
||||
..Default::default()
|
||||
};
|
||||
haproxy.servers.servers.push(server);
|
||||
// Call the function
|
||||
let result = get_servers_for_backend(&backend, &haproxy);
|
||||
@@ -413,24 +419,34 @@ mod tests {
|
||||
#[test]
|
||||
fn test_get_servers_for_backend_multiple_linked_servers() {
|
||||
// Create a backend with multiple linked servers
|
||||
#[allow(clippy::field_reassign_with_default)]
|
||||
let mut backend = HAProxyBackend::default();
|
||||
backend.linked_servers.content = Some("server1,server2".to_string());
|
||||
//
|
||||
// Create an HAProxy instance with matching servers
|
||||
let mut haproxy = HAProxy::default();
|
||||
let mut server = HAProxyServer::default();
|
||||
server.uuid = "server1".to_string();
|
||||
server.address = "some-hostname.test.mcd".to_string();
|
||||
server.port = 80;
|
||||
let server = HAProxyServer {
|
||||
uuid: "server1".to_string(),
|
||||
address: "some-hostname.test.mcd".to_string(),
|
||||
port: 80,
|
||||
..Default::default()
|
||||
};
|
||||
haproxy.servers.servers.push(server);
|
||||
let mut server = HAProxyServer::default();
|
||||
server.uuid = "server2".to_string();
|
||||
server.address = "192.168.1.2".to_string();
|
||||
server.port = 8080;
|
||||
|
||||
let server = HAProxyServer {
|
||||
uuid: "server2".to_string(),
|
||||
address: "192.168.1.2".to_string(),
|
||||
port: 8080,
|
||||
..Default::default()
|
||||
};
|
||||
haproxy.servers.servers.push(server);
|
||||
|
||||
// Call the function
|
||||
let result = get_servers_for_backend(&backend, &haproxy);
|
||||
// Check the result
|
||||
assert_eq!(result, vec![
|
||||
assert_eq!(
|
||||
result,
|
||||
vec![
|
||||
BackendServer {
|
||||
address: "some-hostname.test.mcd".to_string(),
|
||||
port: 80,
|
||||
@@ -439,6 +455,7 @@ mod tests {
|
||||
address: "192.168.1.2".to_string(),
|
||||
port: 8080,
|
||||
},
|
||||
]);
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ impl TftpServer for OPNSenseFirewall {
|
||||
async fn ensure_initialized(&self) -> Result<(), ExecutorError> {
|
||||
let mut config = self.opnsense_config.write().await;
|
||||
let tftp = config.tftp();
|
||||
if let None = tftp.get_full_config() {
|
||||
if tftp.get_full_config().is_none() {
|
||||
info!("Tftp config not available in opnsense config, installing package");
|
||||
config.install_package("os-tftp").await.map_err(|e| {
|
||||
ExecutorError::UnexpectedError(format!(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user