Compare commits

...

1 Commits

Author SHA1 Message Date
3cf4fe4855 feat: rustfs 2026-03-21 11:36:50 -04:00
11 changed files with 626 additions and 0 deletions

10
Cargo.lock generated
View File

@@ -2953,6 +2953,16 @@ dependencies = [
"url",
]
[[package]]
name = "example-rustfs"
version = "0.1.0"
dependencies = [
"harmony",
"harmony_cli",
"harmony_types",
"tokio",
]
[[package]]
name = "example-tenant"
version = "0.1.0"

View File

@@ -14,6 +14,7 @@ If you're new to Harmony, start here:
See how to use Harmony to solve real-world problems.
- [**PostgreSQL on Local K3D**](./use-cases/postgresql-on-local-k3d.md): Deploy a production-grade PostgreSQL cluster on a local K3D cluster. The fastest way to get started.
- [**RustFS on Local K3D**](./use-cases/rustfs-on-local-k3d.md): Deploy a RustFS S3-compatible object store on a local K3D cluster.
- [**OKD on Bare Metal**](./use-cases/okd-on-bare-metal.md): A detailed walkthrough of bootstrapping a high-availability OKD cluster from physical hardware.
## 3. Component Catalogs

View File

@@ -0,0 +1,151 @@
# Use Case: RustFS (S3-Compatible Store) on Local K3D
Deploy a RustFS object store on a local Kubernetes cluster (K3D) using Harmony. RustFS is a Rust-based S3-compatible storage server, a modern alternative to MinIO for local development.
## What you'll have at the end
A fully operational S3-compatible object store with:
- 1 standalone instance with 1 GiB of storage
- S3 API endpoint on port 9000
- Web console on port 9001
- Ingress-based access at `http://rustfs.local`
- Default credentials: `rustfsadmin` / `rustfsadmin`
## Prerequisites
- Rust 2024 edition
- Docker running locally
- ~5 minutes
## The Score
The entire deployment is expressed in ~20 lines of Rust:
```rust
use harmony::{
inventory::Inventory,
modules::rustfs::{K8sRustFsScore, RustFsConfig},
topology::K8sAnywhereTopology,
};
#[tokio::main]
async fn main() {
let rustfs = K8sRustFsScore {
config: RustFsConfig {
release_name: "harmony-rustfs".to_string(),
namespace: "harmony-rustfs".to_string(),
..Default::default()
},
};
harmony_cli::run(
Inventory::autoload(),
K8sAnywhereTopology::from_env(),
vec![Box::new(rustfs)],
None,
)
.await
.unwrap();
}
```
## What Harmony does
When you run this, Harmony:
1. **Connects to K8sAnywhereTopology** — auto-provisions a K3D cluster if none exists
2. **Creates a namespace**`harmony-rustfs` (or your custom namespace)
3. **Creates credentials secret** — stores the access/secret keys securely
4. **Deploys via Helm** — installs the RustFS chart in standalone mode
5. **Configures Ingress** — sets up routing at `rustfs.local`
## Running it
```bash
cargo run -p example-rustfs
```
## Verifying the deployment
```bash
# Check pods
kubectl get pods -n harmony-rustfs
# Check ingress
kubectl get ingress -n harmony-rustfs
# Access the S3 API
# Add rustfs.local to your /etc/hosts
echo "127.0.0.1 rustfs.local" | sudo tee -a /etc/hosts
# Use the AWS CLI or any S3 client
AWS_ACCESS_KEY_ID=rustfsadmin \
AWS_SECRET_ACCESS_KEY=rustfsadmin \
aws s3 ls --endpoint-url http://rustfs.local:9000
# Or via the web console
open http://rustfs.local:9001
```
## Customizing the deployment
The `RustFsConfig` struct supports:
| Field | Default | Description |
|-------|---------|-------------|
| `release_name` | `rustfs` | Helm release name |
| `namespace` | `harmony-rustfs` | Kubernetes namespace |
| `storage_size` | `1Gi` | Data storage size |
| `mode` | `Standalone` | Deployment mode (standalone only for now) |
| `access_key` | `None` | S3 access key (default: `rustfsadmin`) |
| `secret_key` | `None` | S3 secret key (default: `rustfsadmin`) |
| `ingress_class` | `None` | Ingress class to use (default: `nginx`) |
Example with custom credentials:
```rust
let rustfs = K8sRustFsScore {
config: RustFsConfig {
release_name: "my-rustfs".to_string(),
namespace: "storage".to_string(),
access_key: Some("myaccess".to_string()),
secret_key: Some("mysecret".to_string()),
ingress_class: Some("traefik".to_string()),
..Default::default()
},
};
```
## Architecture
The RustFS module follows the same pattern as PostgreSQL:
```
┌─────────────────────────────────────────────────────────────┐
│ K8sRustFsScore (user-facing) │
│ └── K8sRustFsInterpret │
│ ├── ensure_namespace() │
│ ├── ensure_secret() → K8sResourceScore │
│ └── HelmChartScore → HelmChartInterpret │
│ └── Installs rustfs/rustfs chart │
└─────────────────────────────────────────────────────────────┘
```
## Future: Unified S3 Capability
This is the first step toward a unified S3 capability that will work with:
- **RustFS** — local development (this example)
- **Ceph RGW** — production S3 via Rook/Ceph
- **AWS S3** — cloud-native S3
The pattern will be:
```rust
// Future: unified S3 interface
trait S3Store: Send + Sync {
async fn deploy_bucket(&self, config: &BucketConfig) -> Result<(), String>;
async fn get_endpoint(&self) -> Result<S3Endpoint, String>;
}
```
See the [Scores Catalog](../catalogs/scores.md) for related components.

View File

@@ -7,6 +7,7 @@ This directory contains runnable examples demonstrating Harmony's capabilities.
| Example | Description | Local K3D | Existing Cluster | Hardware Needed |
|---------|-------------|:---------:|:----------------:|:---------------:|
| `postgresql` | Deploy a PostgreSQL cluster | ✅ | ✅ | — |
| `rustfs` | Deploy a RustFS S3-compatible store | ✅ | ✅ | — |
| `ntfy` | Deploy ntfy notification server | ✅ | ✅ | — |
| `tenant` | Create a multi-tenant namespace | ✅ | ✅ | — |
| `cert_manager` | Provision TLS certificates | ✅ | ✅ | — |
@@ -52,6 +53,7 @@ This directory contains runnable examples demonstrating Harmony's capabilities.
- **`postgresql`** — Deploy a PostgreSQL cluster via CloudNativePG
- **`multisite_postgres`** — Multi-site PostgreSQL with failover
- **`public_postgres`** — Public-facing PostgreSQL (⚠️ uses NationTech DNS)
- **`rustfs`** — Deploy a RustFS S3-compatible object store
### Kubernetes Utilities
- **`node_health`** — Check node health in a cluster

View File

@@ -0,0 +1,13 @@
[package]
name = "example-rustfs"
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" }
tokio = { workspace = true }

View File

@@ -0,0 +1,25 @@
use harmony::{
inventory::Inventory,
modules::rustfs::{K8sRustFsScore, RustFsConfig},
topology::K8sAnywhereTopology,
};
#[tokio::main]
async fn main() {
let rustfs = K8sRustFsScore {
config: RustFsConfig {
release_name: "harmony-rustfs".to_string(),
namespace: "harmony-rustfs".to_string(),
..Default::default()
},
};
harmony_cli::run(
Inventory::autoload(),
K8sAnywhereTopology::from_env(),
vec![Box::new(rustfs)],
None,
)
.await
.unwrap();
}

View File

@@ -21,6 +21,7 @@ pub mod openbao;
pub mod opnsense;
pub mod postgresql;
pub mod prometheus;
pub mod rustfs;
pub mod storage;
pub mod tenant;
pub mod tftp;

View File

@@ -0,0 +1,47 @@
use async_trait::async_trait;
use harmony_types::storage::StorageSize;
use serde::Serialize;
#[derive(Clone, Debug, Serialize)]
pub struct RustFsConfig {
pub release_name: String,
pub namespace: String,
pub storage_size: StorageSize,
pub mode: RustFsMode,
pub access_key: Option<String>,
pub secret_key: Option<String>,
pub ingress_class: Option<String>,
}
impl Default for RustFsConfig {
fn default() -> Self {
Self {
release_name: "rustfs".to_string(),
namespace: "harmony-rustfs".to_string(),
storage_size: StorageSize::gi(1),
mode: RustFsMode::Standalone,
access_key: None,
secret_key: None,
ingress_class: None,
}
}
}
#[derive(Clone, Debug, Serialize)]
pub enum RustFsMode {
Standalone,
}
#[async_trait]
pub trait RustFs: Send + Sync {
async fn deploy(&self, config: &RustFsConfig) -> Result<String, String>;
async fn get_endpoint(&self, config: &RustFsConfig) -> Result<RustFsEndpoint, String>;
}
#[derive(Clone, Debug)]
pub struct RustFsEndpoint {
pub s3_endpoint: String,
pub console_endpoint: String,
pub access_key: String,
pub secret_key: String,
}

View File

@@ -0,0 +1,6 @@
pub mod capability;
mod score;
mod score_k8s;
pub use capability::*;
pub use score::*;
pub use score_k8s::*;

View File

@@ -0,0 +1,85 @@
use async_trait::async_trait;
use harmony_types::id::Id;
use serde::Serialize;
use crate::data::Version;
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
use crate::inventory::Inventory;
use crate::modules::rustfs::capability::{RustFs, RustFsConfig};
use crate::score::Score;
use crate::topology::Topology;
#[derive(Debug, Clone, Serialize)]
pub struct RustFsScore {
pub config: RustFsConfig,
}
impl Default for RustFsScore {
fn default() -> Self {
Self {
config: RustFsConfig::default(),
}
}
}
impl RustFsScore {
pub fn new(namespace: &str) -> Self {
Self {
config: RustFsConfig {
namespace: namespace.to_string(),
..Default::default()
},
}
}
}
impl<T: Topology + RustFs + Send + Sync> Score<T> for RustFsScore {
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(RustFsInterpret {
config: self.config.clone(),
})
}
fn name(&self) -> String {
format!(
"RustFsScore({}:{})",
self.config.namespace, self.config.release_name
)
}
}
#[derive(Debug, Clone)]
struct RustFsInterpret {
config: RustFsConfig,
}
#[async_trait]
impl<T: Topology + RustFs + Send + Sync> Interpret<T> for RustFsInterpret {
fn get_name(&self) -> InterpretName {
InterpretName::Custom("RustFsInterpret")
}
fn get_version(&self) -> Version {
todo!()
}
fn get_status(&self) -> InterpretStatus {
todo!()
}
fn get_children(&self) -> Vec<Id> {
todo!()
}
async fn execute(&self, _inventory: &Inventory, topo: &T) -> Result<Outcome, InterpretError> {
let release_name = topo
.deploy(&self.config)
.await
.map_err(|e| InterpretError::new(e))?;
Ok(Outcome::success(format!(
"RustFS '{}' deployed in namespace '{}'",
release_name, self.config.namespace
)))
}
}

View File

@@ -0,0 +1,285 @@
use std::str::FromStr;
use crate::data::Version;
use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome};
use crate::inventory::Inventory;
use crate::modules::helm::chart::{HelmChartScore, HelmRepository};
use crate::modules::k8s::resource::K8sResourceScore;
use crate::modules::rustfs::capability::{RustFs, RustFsConfig, RustFsEndpoint, RustFsMode};
use crate::score::Score;
use crate::topology::{HelmCommand, K8sclient, Topology};
use async_trait::async_trait;
use harmony_types::id::Id;
use harmony_types::net::Url;
use k8s_openapi::api::core::v1::Secret;
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use k8s_openapi::ByteString;
use log::info;
use non_blank_string_rs::NonBlankString;
use serde::Serialize;
#[derive(Debug, Clone, Serialize)]
pub struct K8sRustFsScore {
pub config: RustFsConfig,
}
impl Default for K8sRustFsScore {
fn default() -> Self {
Self {
config: RustFsConfig::default(),
}
}
}
impl K8sRustFsScore {
pub fn new(namespace: &str) -> Self {
Self {
config: RustFsConfig {
namespace: namespace.to_string(),
..Default::default()
},
}
}
}
impl<T: Topology + K8sclient + HelmCommand + 'static> Score<T> for K8sRustFsScore {
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
Box::new(K8sRustFsInterpret {
config: self.config.clone(),
})
}
fn name(&self) -> String {
format!("K8sRustFsScore({})", self.config.namespace)
}
}
#[derive(Debug)]
pub struct K8sRustFsInterpret {
config: RustFsConfig,
}
impl K8sRustFsInterpret {
async fn ensure_namespace<T: Topology + K8sclient>(
&self,
topology: &T,
) -> Result<(), InterpretError> {
let k8s_client = topology
.k8s_client()
.await
.map_err(|e| InterpretError::new(format!("Failed to get k8s client: {}", e)))?;
let namespace_name = &self.config.namespace;
if k8s_client
.namespace_exists(namespace_name)
.await
.map_err(|e| {
InterpretError::new(format!(
"Failed to check namespace '{}': {}",
namespace_name, e
))
})?
{
info!("Namespace '{}' already exists", namespace_name);
return Ok(());
}
info!("Creating namespace '{}'", namespace_name);
k8s_client
.create_namespace(namespace_name)
.await
.map_err(|e| {
InterpretError::new(format!(
"Failed to create namespace '{}': {}",
namespace_name, e
))
})?;
k8s_client
.wait_for_namespace(namespace_name, Some(std::time::Duration::from_secs(30)))
.await
.map_err(|e| {
InterpretError::new(format!("Namespace '{}' not ready: {}", namespace_name, e))
})?;
info!("Namespace '{}' is ready", namespace_name);
Ok(())
}
async fn ensure_secret<T: Topology + K8sclient>(
&self,
topology: &T,
) -> Result<(), InterpretError> {
let access_key = self.config.access_key.as_deref().unwrap_or("rustfsadmin");
let secret_key = self.config.secret_key.as_deref().unwrap_or("rustfsadmin");
let k8s_client = topology
.k8s_client()
.await
.map_err(|e| InterpretError::new(format!("Failed to get k8s client: {}", e)))?;
let namespace_name = &self.config.namespace;
let secret_name = format!("{}-credentials", self.config.release_name);
let secret_exists = k8s_client
.get_secret_json_value(&secret_name, Some(namespace_name))
.await
.is_ok();
if secret_exists {
info!(
"Secret '{}' already exists in namespace '{}'",
secret_name, namespace_name
);
return Ok(());
}
info!("Creating secret '{}' in namespace '{}'", secret_name, namespace_name);
let mut data = std::collections::BTreeMap::new();
data.insert(
"access_key".to_string(),
ByteString(access_key.as_bytes().to_vec()),
);
data.insert(
"secret_key".to_string(),
ByteString(secret_key.as_bytes().to_vec()),
);
let secret = Secret {
metadata: ObjectMeta {
name: Some(secret_name.clone()),
namespace: Some(namespace_name.clone()),
..ObjectMeta::default()
},
data: Some(data),
string_data: None,
type_: Some("Opaque".to_string()),
..Secret::default()
};
K8sResourceScore::single(secret, Some(namespace_name.clone()))
.create_interpret()
.execute(&Inventory::empty(), topology)
.await?;
Ok(())
}
fn to_values_yaml(&self) -> String {
let storage_size = self.config.storage_size.to_string();
let ingress_class = self.config.ingress_class.as_deref().unwrap_or("nginx");
let mode_yaml = match self.config.mode {
RustFsMode::Standalone => {
"mode:\n standalone:\n enabled: true\n distributed:\n enabled: false"
}
};
format!(
r#"{mode_yaml}
storageclass:
name: local-path
dataStorageSize: {storage_size}
logStorageSize: 256Mi
ingress:
enabled: true
className: {ingress_class}
hosts:
- host: rustfs.local
paths:
- path: /
pathType: Prefix
secret:
existingSecret: {release_name}-credentials
"#,
release_name = self.config.release_name
)
}
}
#[async_trait]
impl<T: Topology + K8sclient + HelmCommand + 'static> Interpret<T> for K8sRustFsInterpret {
async fn execute(
&self,
_inventory: &Inventory,
topology: &T,
) -> Result<Outcome, InterpretError> {
self.ensure_namespace(topology).await?;
self.ensure_secret(topology).await?;
let helm_score = HelmChartScore {
namespace: Some(NonBlankString::from_str(&self.config.namespace).unwrap()),
release_name: NonBlankString::from_str(&self.config.release_name).unwrap(),
chart_name: NonBlankString::from_str("rustfs/rustfs").unwrap(),
chart_version: None,
values_overrides: None,
values_yaml: Some(self.to_values_yaml()),
create_namespace: false,
install_only: false,
repository: Some(HelmRepository::new(
"rustfs".to_string(),
Url::Url(url::Url::parse("https://charts.rustfs.com").unwrap()),
true,
)),
};
helm_score
.create_interpret()
.execute(&Inventory::empty(), topology)
.await
}
fn get_name(&self) -> InterpretName {
InterpretName::Custom("K8sRustFsInterpret")
}
fn get_version(&self) -> Version {
todo!()
}
fn get_status(&self) -> InterpretStatus {
todo!()
}
fn get_children(&self) -> Vec<Id> {
todo!()
}
}
pub struct K8sAnywhereRustFs;
impl K8sAnywhereRustFs {
pub fn new() -> Self {
Self
}
}
impl Default for K8sAnywhereRustFs {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl RustFs for K8sAnywhereRustFs {
async fn deploy(&self, config: &RustFsConfig) -> Result<String, String> {
Ok(config.release_name.clone())
}
async fn get_endpoint(&self, config: &RustFsConfig) -> Result<RustFsEndpoint, String> {
Ok(RustFsEndpoint {
s3_endpoint: "http://rustfs.local:9000".to_string(),
console_endpoint: "http://rustfs.local:9001".to_string(),
access_key: config
.access_key
.clone()
.unwrap_or_else(|| "rustfsadmin".to_string()),
secret_key: config
.secret_key
.clone()
.unwrap_or_else(|| "rustfsadmin".to_string()),
})
}
}