Compare commits

..

4 Commits

Author SHA1 Message Date
6ca8663422 wip: Roadmap for config 2026-03-22 16:57:36 -04:00
f6ce0c6d4f chore: Harmony short term roadmap 2026-03-22 11:43:43 -04:00
8a1eca21f7 Merge branch 'feat/harmony_assets' into feature/kvm-module 2026-03-22 11:26:04 -04:00
ccc26e07eb feat: harmony_asset crate to manage assets, local, s3, http urls, etc
Some checks failed
Run Check Script / check (pull_request) Failing after 17s
2026-03-21 11:10:51 -04:00
25 changed files with 11389 additions and 113 deletions

9174
Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -25,6 +25,7 @@ members = [
"harmony_agent/deploy",
"harmony_node_readiness",
"harmony-k8s",
"harmony_assets",
]
[workspace.package]
@@ -39,6 +40,7 @@ derive-new = "0.7"
async-trait = "0.1"
tokio = { version = "1.40", features = [
"io-std",
"io-util",
"fs",
"macros",
"rt-multi-thread",
@@ -75,6 +77,7 @@ base64 = "0.22.1"
tar = "0.4.44"
lazy_static = "1.5.0"
directories = "6.0.0"
futures-util = "0.3"
thiserror = "2.0.14"
serde = { version = "1.0.209", features = ["derive", "rc"] }
serde_json = "1.0.127"
@@ -88,3 +91,4 @@ reqwest = { version = "0.12", features = [
"json",
], default-features = false }
assertor = "0.0.4"
tokio-test = "0.4"

29
ROADMAP.md Normal file
View File

@@ -0,0 +1,29 @@
# Harmony Roadmap
Six phases to take Harmony from working prototype to production-ready open-source project.
| # | Phase | Status | Depends On | Detail |
|---|-------|--------|------------|--------|
| 1 | [Harden `harmony_config`](ROADMAP/01-config-crate.md) | Not started | — | Test every source, add SQLite backend, wire Zitadel + OpenBao, validate zero-setup UX |
| 2 | [Migrate to `harmony_config`](ROADMAP/02-refactor-harmony-config.md) | Not started | 1 | Replace all 19 `SecretManager` call sites, deprecate direct `harmony_secret` usage |
| 3 | [Complete `harmony_assets`](ROADMAP/03-assets-crate.md) | Not started | 1, 2 | Test, refactor k3d and OKD to use it, implement `Url::Url`, remove LFS |
| 4 | [Publish to GitHub](ROADMAP/04-publish-github.md) | Not started | 3 | Clean history, set up GitHub as community hub, CI on self-hosted runners |
| 5 | [E2E tests: PostgreSQL & RustFS](ROADMAP/05-e2e-tests-simple.md) | Not started | 1 | k3d-based test harness, two passing E2E tests, CI job |
| 6 | [E2E tests: OKD HA on KVM](ROADMAP/06-e2e-tests-kvm.md) | Not started | 5 | KVM test infrastructure, full OKD installation test, nightly CI |
## Current State (as of branch `feature/kvm-module`)
- `harmony_config` crate exists with `EnvSource`, `LocalFileSource`, `PromptSource`, `StoreSource`. 12 unit tests. **Zero consumers** in workspace — everything still uses `harmony_secret::SecretManager` directly (19 call sites).
- `harmony_assets` crate exists with `Asset`, `LocalCache`, `LocalStore`, `S3Store`. **No tests. Zero consumers.** The `k3d` crate has its own `DownloadableAsset` with identical functionality and full test coverage.
- `harmony_secret` has `LocalFileSecretStore`, `OpenbaoSecretStore` (token/userpass only), `InfisicalSecretStore`. Works but no Zitadel OIDC integration.
- KVM module exists on this branch with `KvmExecutor`, VM lifecycle, ISO download, two examples (`example_linux_vm`, `kvm_okd_ha_cluster`).
- RustFS module exists on `feat/rustfs` branch (2 commits ahead of master).
- 39 example crates, **zero E2E tests**. Unit tests pass across workspace (~240 tests).
- CI runs `cargo check`, `fmt`, `clippy`, `test` on Gitea. No E2E job.
## Guiding Principles
- **Zero-setup first**: A new user clones, runs `cargo run`, gets prompted for config, values persist to local SQLite. No env vars, no external services required.
- **Progressive disclosure**: Local SQLite → OpenBao → Zitadel SSO. Each layer is opt-in.
- **Test what ships**: Every example that works should have an E2E test proving it works.
- **Community over infrastructure**: GitHub for engagement, self-hosted runners for CI.

177
ROADMAP/01-config-crate.md Normal file
View File

@@ -0,0 +1,177 @@
# Phase 1: Harden `harmony_config`, Validate UX, Zero-Setup Starting Point
## Goal
Make `harmony_config` production-ready with a seamless first-run experience: clone, run, get prompted, values persist locally. Then progressively add team-scale backends (OpenBao, Zitadel SSO) without changing any calling code.
## Current State
`harmony_config` exists with:
- `Config` trait + `#[derive(Config)]` macro
- `ConfigManager` with ordered source chain
- Four `ConfigSource` implementations:
- `EnvSource` — reads `HARMONY_CONFIG_{KEY}` env vars
- `LocalFileSource` — reads/writes `{key}.json` files from a directory
- `PromptSource`**stub** (returns `None` / no-ops on set)
- `StoreSource<S: SecretStore>` — wraps any `harmony_secret::SecretStore` backend
- 12 unit tests (mock source, env, local file)
- Global `CONFIG_MANAGER` static with `init()`, `get()`, `get_or_prompt()`, `set()`
- **Zero workspace consumers** — nothing calls `harmony_config` yet
## Tasks
### 1.1 Add `SqliteSource` as the default zero-setup backend
Replace `LocalFileSource` (JSON files scattered in a directory) with a single SQLite database as the default local backend. `sqlx` with SQLite is already a workspace dependency.
```rust
// harmony_config/src/source/sqlite.rs
pub struct SqliteSource {
pool: SqlitePool,
}
impl SqliteSource {
/// Opens or creates the database at the given path.
/// Creates the `config` table if it doesn't exist.
pub async fn open(path: PathBuf) -> Result<Self, ConfigError>
/// Uses the default Harmony data directory:
/// ~/.local/share/harmony/config.db (Linux)
pub async fn default() -> Result<Self, ConfigError>
}
#[async_trait]
impl ConfigSource for SqliteSource {
async fn get(&self, key: &str) -> Result<Option<serde_json::Value>, ConfigError>
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError>
}
```
Schema:
```sql
CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
**Tests**:
- `test_sqlite_set_and_get` — round-trip a `TestConfig` struct
- `test_sqlite_get_returns_none_when_missing` — key not in DB
- `test_sqlite_overwrites_on_set` — set twice, get returns latest
- `test_sqlite_concurrent_access` — two tasks writing different keys simultaneously
- All tests use `tempfile::NamedTempFile` for the DB path
### 1.1.1 Add Config example to show exact DX and confirm functionality
Create `harmony_config/examples` that show how to use config crate with various backends.
Show how to use the derive macros, how to store secrets in a local backend or a zitadel + openbao backend, how to fetch them from environment variables, etc. Explicitely outline the dependencies for examples with dependencies in a comment at the top. Explain how to configure zitadel + openbao for this backend. The local backend should have zero dependency, zero setup, storing its config/secrets with sane defaults.
Also show that a Config with default values will not prompt for values with defaults.
### 1.2 Make `PromptSource` functional
Currently `PromptSource::get()` returns `None` and `set()` is a no-op. Wire it to `interactive_parse::InteractiveParseObj`:
```rust
#[async_trait]
impl ConfigSource for PromptSource {
async fn get(&self, _key: &str) -> Result<Option<serde_json::Value>, ConfigError> {
// PromptSource never "has" a value — it's always a fallback.
// The actual prompting happens in ConfigManager::get_or_prompt().
Ok(None)
}
async fn set(&self, _key: &str, _value: &serde_json::Value) -> Result<(), ConfigError> {
// Prompt source doesn't persist. Other sources in the chain do.
Ok(())
}
}
```
The prompting logic is already in `ConfigManager::get_or_prompt()` via `T::parse_to_obj()`. The `PromptSource` struct exists mainly to hold the `PROMPT_MUTEX` and potentially a custom writer for TUI integration later.
**Key fix**: Ensure `get_or_prompt()` persists the prompted value to the **first writable source** (SQLite), not to all sources. Current code tries all sources — this is wrong for prompt-then-persist because you don't want to write prompted values to env vars.
```rust
pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> {
match self.get::<T>().await {
Ok(config) => Ok(config),
Err(ConfigError::NotFound { .. }) => {
let config = T::parse_to_obj()
.map_err(|e| ConfigError::PromptError(e.to_string()))?;
let value = serde_json::to_value(&config)
.map_err(|e| ConfigError::Serialization { key: T::KEY.to_string(), source: e })?;
// Persist to the first source that accepts writes (skip EnvSource)
for source in &self.sources {
if source.set(T::KEY, &value).await.is_ok() {
break;
}
}
Ok(config)
}
Err(e) => Err(e),
}
}
```
**Tests**:
- `test_get_or_prompt_persists_to_first_writable_source` — mock source chain where first source is read-only, second is writable. Verify prompted value lands in second source.
### 1.3 Integration test: full resolution chain
Test the complete priority chain: env > sqlite > prompt.
```rust
#[tokio::test]
async fn test_full_resolution_chain() {
// 1. No env var, no SQLite entry → prompting would happen
// (test with mock/pre-seeded source instead of real stdin)
// 2. Set in SQLite → get() returns SQLite value
// 3. Set env var → get() returns env value (overrides SQLite)
// 4. Remove env var → get() falls back to SQLite
}
#[tokio::test]
async fn test_branch_switching_scenario() {
// Simulate: struct shape changes between branches.
// Old value in SQLite doesn't match new struct.
// get() should return Deserialization error.
// get_or_prompt() should re-prompt and overwrite.
}
```
### 1.4 Validate Zitadel + OpenBao integration path
This is not about building the full OIDC flow yet. It's about validating that the architecture supports it by adding `StoreSource<OpenbaoSecretStore>` to the source chain.
**Validate**:
- `ConfigManager::new(vec![EnvSource, SqliteSource, StoreSource<Openbao>])` compiles and works
- When OpenBao is unreachable, the chain falls through to SQLite gracefully (no panic)
- When OpenBao has the value, it's returned and SQLite is not queried
**Document** the target Zitadel OIDC flow as an ADR (RFC 8628 device authorization grant), but don't implement it yet. The `StoreSource` wrapping OpenBao with JWT auth is the integration point — Zitadel provides the JWT, OpenBao validates it.
### 1.5 UX validation checklist
Before this phase is done, manually verify:
- [ ] `cargo run --example postgresql` with no env vars → prompts for nothing (postgresql doesn't use secrets yet, but the config system initializes cleanly)
- [ ] An example that uses `SecretManager` today (e.g., `brocade_snmp_server`) → when migrated to `harmony_config`, first run prompts, second run reads from SQLite
- [ ] Setting `HARMONY_CONFIG_BrocadeSwitchAuth='{"host":"...","user":"...","password":"..."}'` → skips prompt, uses env value
- [ ] Deleting `~/.local/share/harmony/config.db` → re-prompts on next run
## Deliverables
- [ ] `SqliteSource` implementation with tests
- [ ] Functional `PromptSource` (or validated that current `get_or_prompt` flow is correct)
- [ ] Fix `get_or_prompt` to persist to first writable source, not all sources
- [ ] Integration tests for full resolution chain
- [ ] Branch-switching deserialization failure test
- [ ] `StoreSource<OpenbaoSecretStore>` integration validated (compiles, graceful fallback)
- [ ] ADR for Zitadel OIDC target architecture

View File

@@ -0,0 +1,112 @@
# Phase 2: Migrate Workspace to `harmony_config`
## Goal
Replace every direct `harmony_secret::SecretManager` call with `harmony_config` equivalents. After this phase, modules and examples depend only on `harmony_config`. `harmony_secret` becomes an internal implementation detail behind `StoreSource`.
## Current State
19 call sites use `SecretManager::get_or_prompt::<T>()` across:
| Location | Secret Types | Call Sites |
|----------|-------------|------------|
| `harmony/src/modules/brocade/brocade_snmp.rs` | `BrocadeSnmpAuth`, `BrocadeSwitchAuth` | 2 |
| `harmony/src/modules/nats/score_nats_k8s.rs` | `NatsAdmin` | 1 |
| `harmony/src/modules/okd/bootstrap_02_bootstrap.rs` | `RedhatSecret`, `SshKeyPair` | 2 |
| `harmony/src/modules/application/features/monitoring.rs` | `NtfyAuth` | 1 |
| `brocade/examples/main.rs` | `BrocadeSwitchAuth` | 1 |
| `examples/okd_installation/src/main.rs` + `topology.rs` | `SshKeyPair`, `BrocadeSwitchAuth`, `OPNSenseFirewallConfig` | 3 |
| `examples/okd_pxe/src/main.rs` + `topology.rs` | `SshKeyPair`, `BrocadeSwitchAuth`, `OPNSenseFirewallCredentials` | 3 |
| `examples/opnsense/src/main.rs` | `OPNSenseFirewallCredentials` | 1 |
| `examples/sttest/src/main.rs` + `topology.rs` | `SshKeyPair`, `OPNSenseFirewallConfig` | 2 |
| `examples/opnsense_node_exporter/` | (has dep but unclear usage) | ~1 |
| `examples/okd_cluster_alerts/` | (has dep but unclear usage) | ~1 |
| `examples/brocade_snmp_server/` | (has dep but unclear usage) | ~1 |
## Tasks
### 2.1 Bootstrap `harmony_config` in CLI and TUI entry points
Add `harmony_config::init()` as the first thing that happens in `harmony_cli::run()` and `harmony_tui::run()`.
```rust
// harmony_cli/src/lib.rs — inside run()
pub async fn run<T: Topology + Send + Sync + 'static>(
inventory: Inventory,
topology: T,
scores: Vec<Box<dyn Score<T>>>,
args_struct: Option<Args>,
) -> Result<(), Box<dyn std::error::Error>> {
// Initialize config system with default source chain
let sqlite = Arc::new(SqliteSource::default().await?);
let env = Arc::new(EnvSource);
harmony_config::init(vec![env, sqlite]).await;
// ... rest of run()
}
```
This replaces the implicit `SecretManager` lazy initialization that currently happens on first `get_or_prompt` call.
### 2.2 Migrate each secret type from `Secret` to `Config`
For each secret struct, change:
```rust
// Before
use harmony_secret::Secret;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, InteractiveParse, Secret)]
struct BrocadeSwitchAuth { ... }
// After
use harmony_config::Config;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, InteractiveParse, Config)]
struct BrocadeSwitchAuth { ... }
```
At each call site, change:
```rust
// Before
let config = SecretManager::get_or_prompt::<BrocadeSwitchAuth>().await.unwrap();
// After
let config = harmony_config::get_or_prompt::<BrocadeSwitchAuth>().await.unwrap();
```
### 2.3 Migration order (low risk to high risk)
1. **`brocade/examples/main.rs`** — 1 call site, isolated example, easy to test manually
2. **`examples/opnsense/src/main.rs`** — 1 call site, isolated
3. **`harmony/src/modules/brocade/brocade_snmp.rs`** — 2 call sites, core module but straightforward
4. **`harmony/src/modules/nats/score_nats_k8s.rs`** — 1 call site
5. **`harmony/src/modules/application/features/monitoring.rs`** — 1 call site
6. **`examples/sttest/`** — 2 call sites, has both main.rs and topology.rs patterns
7. **`examples/okd_installation/`** — 3 call sites, complex topology setup
8. **`examples/okd_pxe/`** — 3 call sites, similar to okd_installation
9. **`harmony/src/modules/okd/bootstrap_02_bootstrap.rs`** — 2 call sites, critical OKD bootstrap path
### 2.4 Remove `harmony_secret` from direct dependencies
After all call sites are migrated:
1. Remove `harmony_secret` from `Cargo.toml` of: `harmony`, `brocade`, and all examples that had it
2. `harmony_config` keeps `harmony_secret` as a dependency (for `StoreSource`)
3. The `Secret` trait and `SecretManager` remain in `harmony_secret` but are not used directly anymore
### 2.5 Backward compatibility for existing local secrets
Users who already have secrets stored via `LocalFileSecretStore` (JSON files in `~/.local/share/harmony/secrets/`) need a migration path:
- On first run after upgrade, if SQLite has no entry for a key but the old JSON file exists, read from JSON and write to SQLite
- Or: add `LocalFileSource` as a fallback source at the end of the chain (read-only) for one release cycle
- Log a deprecation warning when reading from old JSON files
## Deliverables
- [ ] `harmony_config::init()` called in `harmony_cli::run()` and `harmony_tui::run()`
- [ ] All 19 call sites migrated from `SecretManager` to `harmony_config`
- [ ] `harmony_secret` removed from direct dependencies of `harmony`, `brocade`, and all examples
- [ ] Backward compatibility for existing local JSON secrets
- [ ] All existing unit tests still pass
- [ ] Manual verification: one migrated example works end-to-end (prompt → persist → read)

141
ROADMAP/03-assets-crate.md Normal file
View File

@@ -0,0 +1,141 @@
# Phase 3: Complete `harmony_assets`, Refactor Consumers
## Goal
Make `harmony_assets` the single way to manage downloadable binaries and images across Harmony. Eliminate `k3d::DownloadableAsset` duplication, implement `Url::Url` in OPNsense infra, remove LFS-tracked files from git.
## Current State
- `harmony_assets` exists with `Asset`, `LocalCache`, `LocalStore`, `S3Store` (behind feature flag). CLI with `upload`, `download`, `checksum`, `verify` commands. **No tests. Zero consumers.**
- `k3d/src/downloadable_asset.rs` has the same functionality with full test coverage (httptest mock server, checksum verification, cache hit, 404 handling, checksum failure).
- `Url::Url` variant in `harmony_types/src/net.rs` exists but is `todo!()` in OPNsense TFTP and HTTP infra layers.
- OKD modules hardcode `./data/...` paths (`bootstrap_02_bootstrap.rs:84-88`, `ipxe.rs:73`).
- `data/` directory contains ~3GB of LFS-tracked files (OKD binaries, PXE images, SCOS images).
## Tasks
### 3.1 Port k3d tests to `harmony_assets`
The k3d crate has 5 well-written tests in `downloadable_asset.rs`. Port them to test `harmony_assets::LocalStore`:
```rust
// harmony_assets/tests/local_store.rs (or in src/ as unit tests)
#[tokio::test]
async fn test_fetch_downloads_and_verifies_checksum() {
// Start httptest server serving a known file
// Create Asset with URL pointing to mock server
// Fetch via LocalStore
// Assert file exists at expected cache path
// Assert checksum matches
}
#[tokio::test]
async fn test_fetch_returns_cached_file_when_present() {
// Pre-populate cache with correct file
// Fetch — assert no HTTP request made (mock server not hit)
}
#[tokio::test]
async fn test_fetch_fails_on_404() { ... }
#[tokio::test]
async fn test_fetch_fails_on_checksum_mismatch() { ... }
#[tokio::test]
async fn test_fetch_with_progress_callback() {
// Assert progress callback is called with (bytes_received, total_size)
}
```
Add `httptest` to `[dev-dependencies]` of `harmony_assets`.
### 3.2 Refactor `k3d` to use `harmony_assets`
Replace `k3d/src/downloadable_asset.rs` with calls to `harmony_assets`:
```rust
// k3d/src/lib.rs — in download_latest_release()
use harmony_assets::{Asset, LocalCache, LocalStore, ChecksumAlgo};
let asset = Asset::new(
binary_url,
checksum,
ChecksumAlgo::SHA256,
K3D_BIN_FILE_NAME.to_string(),
);
let cache = LocalCache::new(self.base_dir.clone());
let store = LocalStore::new();
let path = store.fetch(&asset, &cache, None).await
.map_err(|e| format!("Failed to download k3d: {}", e))?;
```
Delete `k3d/src/downloadable_asset.rs`. Update k3d's `Cargo.toml` to depend on `harmony_assets`.
### 3.3 Define asset metadata as config structs
Following `plan.md` Phase 2, create typed config for OKD assets using `harmony_config`:
```rust
// harmony/src/modules/okd/config.rs
#[derive(Config, Serialize, Deserialize, JsonSchema, InteractiveParse)]
struct OkdInstallerConfig {
pub openshift_install_url: String,
pub openshift_install_sha256: String,
pub scos_kernel_url: String,
pub scos_kernel_sha256: String,
pub scos_initramfs_url: String,
pub scos_initramfs_sha256: String,
pub scos_rootfs_url: String,
pub scos_rootfs_sha256: String,
}
```
First run prompts for URLs/checksums (or uses compiled-in defaults). Values persist to SQLite. Can be overridden via env vars or OpenBao.
### 3.4 Implement `Url::Url` in OPNsense infra layer
In `harmony/src/infra/opnsense/http.rs` and `tftp.rs`, implement the `Url::Url(url)` match arm:
```rust
// Instead of SCP-ing files to OPNsense:
// SSH into OPNsense, run: fetch -o /usr/local/http/{path} {url}
// (FreeBSD-native HTTP client, no extra deps on OPNsense)
```
This eliminates the manual `scp` workaround and the `inquire::Confirm` prompts in `ipxe.rs:126` and `bootstrap_02_bootstrap.rs:230`.
### 3.5 Refactor OKD modules to use assets + config
In `bootstrap_02_bootstrap.rs`:
- `openshift-install`: Resolve `OkdInstallerConfig` from `harmony_config`, download via `harmony_assets`, invoke from cache.
- SCOS images: Pass `Url::Url(scos_kernel_url)` etc. to `StaticFilesHttpScore`. OPNsense fetches from S3 directly.
- Remove `oc` and `kubectl` from `data/okd/bin/` (never used by code).
In `ipxe.rs`:
- Replace the folder-to-serve SCP workaround with individual `Url::Url` entries.
- Remove the `inquire::Confirm` SCP prompts.
### 3.6 Upload assets to S3
- Upload all current `data/` binaries to Ceph S3 bucket with path scheme: `harmony-assets/okd/v{version}/openshift-install`, `harmony-assets/pxe/centos-stream-9/install.img`, etc.
- Set public-read ACL or configure presigned URL generation.
- Record S3 URLs and SHA256 checksums as defaults in the config structs.
### 3.7 Remove LFS, clean git
- Remove all LFS-tracked files from the repo.
- Update `.gitattributes` to remove LFS filters.
- Keep `data/` in `.gitignore` (it becomes a local cache directory).
- Optionally use `git filter-repo` or BFG to strip LFS objects from history (required before Phase 4 GitHub publish).
## Deliverables
- [ ] `harmony_assets` has tests ported from k3d pattern (5+ tests with httptest)
- [ ] `k3d::DownloadableAsset` replaced by `harmony_assets` usage
- [ ] `OkdInstallerConfig` struct using `harmony_config`
- [ ] `Url::Url` implemented in OPNsense HTTP and TFTP infra
- [ ] OKD bootstrap refactored to use lazy-download pattern
- [ ] Assets uploaded to S3 with documented URLs/checksums
- [ ] LFS removed, git history cleaned
- [ ] Repo size small enough for GitHub (~code + templates only)

View File

@@ -0,0 +1,110 @@
# Phase 4: Publish to GitHub
## Goal
Make Harmony publicly available on GitHub as the primary community hub for issues, pull requests, and discussions. CI runs on self-hosted runners.
## Prerequisites
- Phase 3 complete: LFS removed, git history cleaned, repo is small
- README polished with quick-start, architecture overview, examples
- All existing tests pass
## Tasks
### 4.1 Clean git history
```bash
# Option A: git filter-repo (preferred)
git filter-repo --strip-blobs-bigger-than 10M
# Option B: BFG Repo Cleaner
bfg --strip-blobs-bigger-than 10M
git reflog expire --expire=now --all
git gc --prune=now --aggressive
```
Verify final repo size is reasonable (target: <50MB including all code, docs, templates).
### 4.2 Create GitHub repository
- Create `NationTech/harmony` (or chosen org/name) on GitHub
- Push cleaned repo as initial commit
- Set default branch to `main` (rename from `master` if desired)
### 4.3 Set up CI on self-hosted runners
GitHub is the community hub, but CI runs on your own infrastructure. Options:
**Option A: GitHub Actions with self-hosted runners**
- Register your Gitea runner machines as GitHub Actions self-hosted runners
- Port `.gitea/workflows/check.yml` to `.github/workflows/check.yml`
- Same Docker image (`hub.nationtech.io/harmony/harmony_composer:latest`), same commands
- Pro: native GitHub PR checks, no external service needed
- Con: runners need outbound access to GitHub API
**Option B: External CI (Woodpecker, Drone, Jenkins)**
- Use any CI that supports webhooks from GitHub
- Report status back to GitHub via commit status API / checks API
- Pro: fully self-hosted, no GitHub dependency for builds
- Con: extra integration work
**Option C: Keep Gitea CI, mirror from GitHub**
- GitHub repo has a webhook that triggers Gitea CI on push
- Gitea reports back to GitHub via commit status API
- Pro: no migration of CI config
- Con: fragile webhook chain
**Recommendation**: Option A. GitHub Actions self-hosted runners are straightforward and give the best contributor UX (native PR checks). The workflow files are nearly identical to Gitea workflows.
```yaml
# .github/workflows/check.yml
name: Check
on: [push, pull_request]
jobs:
check:
runs-on: self-hosted
container:
image: hub.nationtech.io/harmony/harmony_composer:latest
steps:
- uses: actions/checkout@v4
- run: bash build/check.sh
```
### 4.4 Polish documentation
- **README.md**: Quick-start (clone run get prompted see result), architecture diagram (Score Interpret Topology), link to docs and examples
- **CONTRIBUTING.md**: Already exists. Review for GitHub-specific guidance (fork workflow, PR template)
- **docs/**: Already comprehensive. Verify links work on GitHub rendering
- **Examples**: Ensure each example has a one-line description in its `Cargo.toml` and a comment block in `main.rs`
### 4.5 License and legal
- Verify workspace `license` field in root `Cargo.toml` is set correctly
- Add `LICENSE` file at repo root if not present
- Scan for any proprietary dependencies or hardcoded internal URLs
### 4.6 GitHub repository configuration
- Branch protection on `main`: require PR review, require CI to pass
- Issue templates: bug report, feature request
- PR template: checklist (tests pass, docs updated, etc.)
- Topics/tags: `rust`, `infrastructure-as-code`, `kubernetes`, `orchestration`, `bare-metal`
- Repository description: "Infrastructure orchestration framework. Declare what you want (Score), describe your infrastructure (Topology), let Harmony figure out how."
### 4.7 Gitea as internal mirror
- Set up Gitea to mirror from GitHub (pull mirror)
- Internal CI can continue running on Gitea for private/experimental branches
- Public contributions flow through GitHub
## Deliverables
- [ ] Git history cleaned, repo size <50MB
- [ ] Public GitHub repository created
- [ ] CI running on self-hosted runners with GitHub Actions
- [ ] Branch protection enabled
- [ ] README polished with quick-start guide
- [ ] Issue and PR templates created
- [ ] LICENSE file present
- [ ] Gitea configured as mirror

View File

@@ -0,0 +1,255 @@
# Phase 5: E2E Tests for PostgreSQL & RustFS
## Goal
Establish an automated E2E test pipeline that proves working examples actually work. Start with the two simplest k8s-based examples: PostgreSQL and RustFS.
## Prerequisites
- Phase 1 complete (config crate works, bootstrap is clean)
- `feat/rustfs` branch merged
## Architecture
### Test harness: `tests/e2e/`
A dedicated workspace member crate at `tests/e2e/` that contains:
1. **Shared k3d utilities** — create/destroy clusters, wait for readiness
2. **Per-example test modules** — each example gets a `#[tokio::test]` function
3. **Assertion helpers** — wait for pods, check CRDs exist, verify services
```
tests/
e2e/
Cargo.toml
src/
lib.rs # Shared test utilities
k3d.rs # k3d cluster lifecycle
k8s_assert.rs # K8s assertion helpers
tests/
postgresql.rs # PostgreSQL E2E test
rustfs.rs # RustFS E2E test
```
### k3d cluster lifecycle
```rust
// tests/e2e/src/k3d.rs
use k3d_rs::K3d;
pub struct TestCluster {
pub name: String,
pub k3d: K3d,
pub client: kube::Client,
reuse: bool,
}
impl TestCluster {
/// Creates a k3d cluster for testing.
/// If HARMONY_E2E_REUSE_CLUSTER=1, reuses existing cluster.
pub async fn ensure(name: &str) -> Result<Self, String> {
let reuse = std::env::var("HARMONY_E2E_REUSE_CLUSTER")
.map(|v| v == "1")
.unwrap_or(false);
let base_dir = PathBuf::from("/tmp/harmony-e2e");
let k3d = K3d::new(base_dir, Some(name.to_string()));
let client = k3d.ensure_installed().await?;
Ok(Self { name: name.to_string(), k3d, client, reuse })
}
/// Returns the kubeconfig path for this cluster.
pub fn kubeconfig_path(&self) -> String { ... }
}
impl Drop for TestCluster {
fn drop(&mut self) {
if !self.reuse {
// Best-effort cleanup
let _ = self.k3d.run_k3d_command(["cluster", "delete", &self.name]);
}
}
}
```
### K8s assertion helpers
```rust
// tests/e2e/src/k8s_assert.rs
/// Wait until a pod matching the label selector is Running in the namespace.
/// Times out after `timeout` duration.
pub async fn wait_for_pod_running(
client: &kube::Client,
namespace: &str,
label_selector: &str,
timeout: Duration,
) -> Result<(), String>
/// Assert a CRD instance exists.
pub async fn assert_resource_exists<K: kube::Resource>(
client: &kube::Client,
name: &str,
namespace: Option<&str>,
) -> Result<(), String>
/// Install a Helm chart. Returns when all pods in the release are running.
pub async fn helm_install(
release_name: &str,
chart: &str,
namespace: &str,
repo_url: Option<&str>,
timeout: Duration,
) -> Result<(), String>
```
## Tasks
### 5.1 Create the `tests/e2e/` crate
Add to workspace `Cargo.toml`:
```toml
[workspace]
members = [
# ... existing members
"tests/e2e",
]
```
`tests/e2e/Cargo.toml`:
```toml
[package]
name = "harmony-e2e-tests"
edition = "2024"
publish = false
[dependencies]
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_types = { path = "../../harmony_types" }
k3d_rs = { path = "../../k3d", package = "k3d_rs" }
kube = { workspace = true }
k8s-openapi = { workspace = true }
tokio = { workspace = true }
log = { workspace = true }
env_logger = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
```
### 5.2 PostgreSQL E2E test
```rust
// tests/e2e/tests/postgresql.rs
use harmony::modules::postgresql::{PostgreSQLScore, capability::PostgreSQLConfig};
use harmony::topology::K8sAnywhereTopology;
use harmony::inventory::Inventory;
use harmony::maestro::Maestro;
#[tokio::test]
async fn test_postgresql_deploys_on_k3d() {
let cluster = TestCluster::ensure("harmony-e2e-pg").await.unwrap();
// Install CNPG operator via Helm
// (K8sAnywhereTopology::ensure_ready() now handles this since
// commit e1183ef "K8s postgresql score now ensures cnpg is installed")
// But we may need the Helm chart for non-OKD:
helm_install(
"cnpg",
"cloudnative-pg",
"cnpg-system",
Some("https://cloudnative-pg.github.io/charts"),
Duration::from_secs(120),
).await.unwrap();
// Configure topology pointing to test cluster
let config = K8sAnywhereConfig {
kubeconfig: Some(cluster.kubeconfig_path()),
use_local_k3d: false,
autoinstall: false,
use_system_kubeconfig: false,
harmony_profile: "dev".to_string(),
k8s_context: None,
};
let topology = K8sAnywhereTopology::with_config(config);
// Create and run the score
let score = PostgreSQLScore {
config: PostgreSQLConfig {
cluster_name: "e2e-test-pg".to_string(),
namespace: "e2e-pg-test".to_string(),
..Default::default()
},
};
let mut maestro = Maestro::initialize(Inventory::autoload(), topology).await.unwrap();
maestro.register_all(vec![Box::new(score)]);
let scores = maestro.scores().read().unwrap().first().unwrap().clone_box();
let result = maestro.interpret(scores).await;
assert!(result.is_ok(), "PostgreSQL score failed: {:?}", result.err());
// Assert: CNPG Cluster resource exists
// (the Cluster CRD is applied — pod readiness may take longer)
let client = cluster.client.clone();
// ... assert Cluster CRD exists in e2e-pg-test namespace
}
```
### 5.3 RustFS E2E test
Similar structure. Details depend on what the RustFS score deploys (likely a Helm chart or k8s resources for MinIO/RustFS).
```rust
#[tokio::test]
async fn test_rustfs_deploys_on_k3d() {
let cluster = TestCluster::ensure("harmony-e2e-rustfs").await.unwrap();
// ... similar pattern: configure topology, create score, interpret, assert
}
```
### 5.4 CI job for E2E tests
New workflow file (Gitea or GitHub Actions):
```yaml
# .gitea/workflows/e2e.yml (or .github/workflows/e2e.yml)
name: E2E Tests
on:
push:
branches: [master, main]
# Don't run on every PR — too slow. Run on label or manual trigger.
workflow_dispatch:
jobs:
e2e:
runs-on: self-hosted # Must have Docker available for k3d
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Install k3d
run: curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
- name: Run E2E tests
run: cargo test -p harmony-e2e-tests -- --test-threads=1
env:
RUST_LOG: info
```
Note `--test-threads=1`: E2E tests create k3d clusters and should not run in parallel (port conflicts, resource contention).
## Deliverables
- [ ] `tests/e2e/` crate added to workspace
- [ ] Shared test utilities: `TestCluster`, `wait_for_pod_running`, `helm_install`
- [ ] PostgreSQL E2E test passing
- [ ] RustFS E2E test passing (after `feat/rustfs` merge)
- [ ] CI job running E2E tests on push to main
- [ ] `HARMONY_E2E_REUSE_CLUSTER=1` for fast local iteration

214
ROADMAP/06-e2e-tests-kvm.md Normal file
View File

@@ -0,0 +1,214 @@
# Phase 6: E2E Tests for OKD HA Cluster on KVM
## Goal
Prove the full OKD bare-metal installation flow works end-to-end using KVM virtual machines. This is the ultimate validation of Harmony's core value proposition: declare an OKD cluster, point it at infrastructure, watch it materialize.
## Prerequisites
- Phase 5 complete (test harness exists, k3d tests passing)
- `feature/kvm-module` merged to main
- A CI runner with libvirt/KVM access and nested virtualization support
## Architecture
The KVM branch already has a `kvm_okd_ha_cluster` example that creates:
```
Host bridge (WAN)
|
+--------------------+
| OPNsense | 192.168.100.1
| gateway + PXE |
+--------+-----------+
|
harmonylan (192.168.100.0/24)
+---------+---------+---------+---------+
| | | | |
+----+---+ +---+---+ +---+---+ +---+---+ +--+----+
| cp0 | | cp1 | | cp2 | |worker0| |worker1|
| .10 | | .11 | | .12 | | .20 | | .21 |
+--------+ +-------+ +-------+ +-------+ +---+---+
|
+-----+----+
| worker2 |
| .22 |
+----------+
```
The test needs to orchestrate this entire setup, wait for OKD to converge, and assert the cluster is healthy.
## Tasks
### 6.1 Start with `example_linux_vm` — the simplest KVM test
Before tackling the full OKD stack, validate the KVM module itself with the simplest possible test:
```rust
// tests/e2e/tests/kvm_linux_vm.rs
#[tokio::test]
#[ignore] // Requires libvirt access — run with: cargo test -- --ignored
async fn test_linux_vm_boots_from_iso() {
let executor = KvmExecutor::from_env().unwrap();
// Create isolated network
let network = NetworkConfig {
name: "e2e-test-net".to_string(),
bridge: "virbr200".to_string(),
// ...
};
executor.ensure_network(&network).await.unwrap();
// Define and start VM
let vm_config = VmConfig::builder("e2e-linux-test")
.vcpus(1)
.memory_gb(1)
.disk(5)
.network(NetworkRef::named("e2e-test-net"))
.cdrom("https://releases.ubuntu.com/24.04/ubuntu-24.04-live-server-amd64.iso")
.boot_order([BootDevice::Cdrom, BootDevice::Disk])
.build();
executor.ensure_vm(&vm_config).await.unwrap();
executor.start_vm("e2e-linux-test").await.unwrap();
// Assert VM is running
let status = executor.vm_status("e2e-linux-test").await.unwrap();
assert_eq!(status, VmStatus::Running);
// Cleanup
executor.destroy_vm("e2e-linux-test").await.unwrap();
executor.undefine_vm("e2e-linux-test").await.unwrap();
executor.delete_network("e2e-test-net").await.unwrap();
}
```
This test validates:
- ISO download works (via `harmony_assets` if refactored, or built-in KVM module download)
- libvirt XML generation is correct
- VM lifecycle (define → start → status → destroy → undefine)
- Network creation/deletion
### 6.2 OKD HA Cluster E2E test
The full integration test. This is long-running (30-60 minutes) and should only run nightly or on-demand.
```rust
// tests/e2e/tests/kvm_okd_ha.rs
#[tokio::test]
#[ignore] // Requires KVM + significant resources. Run nightly.
async fn test_okd_ha_cluster_on_kvm() {
// 1. Create virtual infrastructure
// - OPNsense gateway VM
// - 3 control plane VMs
// - 3 worker VMs
// - Virtual network (harmonylan)
// 2. Run OKD installation scores
// (the kvm_okd_ha_cluster example, but as a test)
// 3. Wait for OKD API server to become reachable
// - Poll https://api.okd.harmonylan:6443 until it responds
// - Timeout: 30 minutes
// 4. Assert cluster health
// - All nodes in Ready state
// - ClusterVersion reports Available=True
// - Sample workload (nginx) deploys and pod reaches Running
// 5. Cleanup
// - Destroy all VMs
// - Delete virtual networks
// - Clean up disk images
}
```
### 6.3 CI runner requirements
The KVM E2E test needs a runner with:
- **Hardware**: 32GB+ RAM, 8+ CPU cores, 100GB+ disk
- **Software**: libvirt, QEMU/KVM, `virsh`, nested virtualization enabled
- **Network**: Outbound internet access (to download ISOs, OKD images)
- **Permissions**: User in `libvirt` group, or root access
Options:
- **Dedicated bare-metal machine** registered as a self-hosted GitHub Actions runner
- **Cloud VM with nested virt** (e.g., GCP n2-standard-8 with `--enable-nested-virtualization`)
- **Manual trigger only** — developer runs locally, CI just tracks pass/fail
### 6.4 Nightly CI job
```yaml
# .github/workflows/e2e-kvm.yml
name: E2E KVM Tests
on:
schedule:
- cron: '0 2 * * *' # 2 AM daily
workflow_dispatch: # Manual trigger
jobs:
kvm-tests:
runs-on: [self-hosted, kvm] # Label for KVM-capable runners
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- name: Run KVM E2E tests
run: cargo test -p harmony-e2e-tests -- --ignored --test-threads=1
env:
RUST_LOG: info
HARMONY_KVM_URI: qemu:///system
- name: Cleanup VMs on failure
if: failure()
run: |
virsh list --all --name | grep e2e | xargs -I {} virsh destroy {} || true
virsh list --all --name | grep e2e | xargs -I {} virsh undefine {} --remove-all-storage || true
```
### 6.5 Test resource management
KVM tests create real resources that must be cleaned up even on failure. Implement a test fixture pattern:
```rust
struct KvmTestFixture {
executor: KvmExecutor,
vms: Vec<String>,
networks: Vec<String>,
}
impl KvmTestFixture {
fn track_vm(&mut self, name: &str) { self.vms.push(name.to_string()); }
fn track_network(&mut self, name: &str) { self.networks.push(name.to_string()); }
}
impl Drop for KvmTestFixture {
fn drop(&mut self) {
// Best-effort cleanup of all tracked resources
for vm in &self.vms {
let _ = std::process::Command::new("virsh")
.args(["destroy", vm]).output();
let _ = std::process::Command::new("virsh")
.args(["undefine", vm, "--remove-all-storage"]).output();
}
for net in &self.networks {
let _ = std::process::Command::new("virsh")
.args(["net-destroy", net]).output();
let _ = std::process::Command::new("virsh")
.args(["net-undefine", net]).output();
}
}
}
```
## Deliverables
- [ ] `test_linux_vm_boots_from_iso` — passing KVM smoke test
- [ ] `test_okd_ha_cluster_on_kvm` — full OKD installation test
- [ ] `KvmTestFixture` with resource cleanup on test failure
- [ ] Nightly CI job on KVM-capable runner
- [ ] Force-cleanup script for leaked VMs/networks
- [ ] Documentation: how to set up a KVM runner for E2E tests

56
harmony_assets/Cargo.toml Normal file
View File

@@ -0,0 +1,56 @@
[package]
name = "harmony_assets"
edition = "2024"
version.workspace = true
readme.workspace = true
license.workspace = true
[lib]
name = "harmony_assets"
[[bin]]
name = "harmony_assets"
path = "src/cli/mod.rs"
required-features = ["cli"]
[features]
default = ["blake3"]
sha256 = ["dep:sha2"]
blake3 = ["dep:blake3"]
s3 = [
"dep:aws-sdk-s3",
"dep:aws-config",
]
cli = [
"dep:clap",
"dep:indicatif",
"dep:inquire",
]
reqwest = ["dep:reqwest"]
[dependencies]
log.workspace = true
tokio.workspace = true
thiserror.workspace = true
directories.workspace = true
sha2 = { version = "0.10", optional = true }
blake3 = { version = "1.5", optional = true }
reqwest = { version = "0.12", optional = true, default-features = false, features = ["stream", "rustls-tls"] }
futures-util.workspace = true
async-trait.workspace = true
url.workspace = true
# CLI only
clap = { version = "4.5", features = ["derive"], optional = true }
indicatif = { version = "0.18", optional = true }
inquire = { version = "0.7", optional = true }
# S3 only
aws-sdk-s3 = { version = "1", optional = true }
aws-config = { version = "1", optional = true }
[dev-dependencies]
tempfile.workspace = true
httptest = "0.16"
pretty_assertions.workspace = true
tokio-test.workspace = true

View File

@@ -0,0 +1,80 @@
use crate::hash::ChecksumAlgo;
use std::path::PathBuf;
use url::Url;
#[derive(Debug, Clone)]
pub struct Asset {
pub url: Url,
pub checksum: String,
pub checksum_algo: ChecksumAlgo,
pub file_name: String,
pub size: Option<u64>,
}
impl Asset {
pub fn new(url: Url, checksum: String, checksum_algo: ChecksumAlgo, file_name: String) -> Self {
Self {
url,
checksum,
checksum_algo,
file_name,
size: None,
}
}
pub fn with_size(mut self, size: u64) -> Self {
self.size = Some(size);
self
}
pub fn formatted_checksum(&self) -> String {
crate::hash::format_checksum(&self.checksum, self.checksum_algo.clone())
}
}
#[derive(Debug, Clone)]
pub struct LocalCache {
pub base_dir: PathBuf,
}
impl LocalCache {
pub fn new(base_dir: PathBuf) -> Self {
Self { base_dir }
}
pub fn path_for(&self, asset: &Asset) -> PathBuf {
let prefix = &asset.checksum[..16.min(asset.checksum.len())];
self.base_dir.join(prefix).join(&asset.file_name)
}
pub fn cache_key_dir(&self, asset: &Asset) -> PathBuf {
let prefix = &asset.checksum[..16.min(asset.checksum.len())];
self.base_dir.join(prefix)
}
pub async fn ensure_dir(&self, asset: &Asset) -> Result<(), crate::errors::AssetError> {
let dir = self.cache_key_dir(asset);
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| crate::errors::AssetError::IoError(e))?;
Ok(())
}
}
impl Default for LocalCache {
fn default() -> Self {
let base_dir = directories::ProjectDirs::from("io", "NationTech", "Harmony")
.map(|dirs| dirs.cache_dir().join("assets"))
.unwrap_or_else(|| PathBuf::from("/tmp/harmony_assets"));
Self::new(base_dir)
}
}
#[derive(Debug, Clone)]
pub struct StoredAsset {
pub url: Url,
pub checksum: String,
pub checksum_algo: ChecksumAlgo,
pub size: u64,
pub key: String,
}

View File

@@ -0,0 +1,25 @@
use clap::Parser;
#[derive(Parser, Debug)]
pub struct ChecksumArgs {
pub path: String,
#[arg(short, long, default_value = "blake3")]
pub algo: String,
}
pub async fn execute(args: ChecksumArgs) -> Result<(), Box<dyn std::error::Error>> {
use harmony_assets::{ChecksumAlgo, checksum_for_path};
let path = std::path::Path::new(&args.path);
if !path.exists() {
eprintln!("Error: File not found: {}", args.path);
std::process::exit(1);
}
let algo = ChecksumAlgo::from_str(&args.algo)?;
let checksum = checksum_for_path(path, algo.clone()).await?;
println!("{}:{} {}", algo.name(), checksum, args.path);
Ok(())
}

View File

@@ -0,0 +1,82 @@
use clap::Parser;
#[derive(Parser, Debug)]
pub struct DownloadArgs {
pub url: String,
pub checksum: String,
#[arg(short, long)]
pub output: Option<String>,
#[arg(short, long, default_value = "blake3")]
pub algo: String,
}
pub async fn execute(args: DownloadArgs) -> Result<(), Box<dyn std::error::Error>> {
use harmony_assets::{
Asset, AssetStore, ChecksumAlgo, LocalCache, LocalStore, verify_checksum,
};
use indicatif::{ProgressBar, ProgressStyle};
use url::Url;
let url = Url::parse(&args.url).map_err(|e| format!("Invalid URL: {}", e))?;
let file_name = args
.output
.or_else(|| {
std::path::Path::new(&args.url)
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
})
.unwrap_or_else(|| "download".to_string());
let algo = ChecksumAlgo::from_str(&args.algo)?;
let asset = Asset::new(url, args.checksum.clone(), algo.clone(), file_name);
let cache = LocalCache::default();
println!("Downloading: {}", asset.url);
println!("Checksum: {}:{}", algo.name(), args.checksum);
println!("Cache dir: {:?}", cache.base_dir);
let total_size = asset.size.unwrap_or(0);
let pb = if total_size > 0 {
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40}] {bytes}/{total_bytes} ({bytes_per_sec})")?
.progress_chars("=>-"),
);
Some(pb)
} else {
None
};
let progress_fn: Box<dyn Fn(u64, Option<u64>) + Send> = Box::new({
let pb = pb.clone();
move |bytes, _total| {
if let Some(ref pb) = pb {
pb.set_position(bytes);
}
}
});
let store = LocalStore::default();
let result = store.fetch(&asset, &cache, Some(progress_fn)).await;
if let Some(pb) = pb {
pb.finish();
}
match result {
Ok(path) => {
verify_checksum(&path, &args.checksum, algo).await?;
println!("\nDownloaded to: {:?}", path);
println!("Checksum verified OK");
Ok(())
}
Err(e) => {
eprintln!("Download failed: {}", e);
std::process::exit(1);
}
}
}

View File

@@ -0,0 +1,49 @@
pub mod checksum;
pub mod download;
pub mod upload;
pub mod verify;
use clap::{Parser, Subcommand};
#[derive(Parser, Debug)]
#[command(
name = "harmony_assets",
version,
about = "Asset management CLI for downloading, uploading, and verifying large binary assets"
)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand, Debug)]
pub enum Commands {
Upload(upload::UploadArgs),
Download(download::DownloadArgs),
Checksum(checksum::ChecksumArgs),
Verify(verify::VerifyArgs),
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
log::info!("Starting harmony_assets CLI");
let cli = Cli::parse();
match cli.command {
Commands::Upload(args) => {
upload::execute(args).await?;
}
Commands::Download(args) => {
download::execute(args).await?;
}
Commands::Checksum(args) => {
checksum::execute(args).await?;
}
Commands::Verify(args) => {
verify::execute(args).await?;
}
}
Ok(())
}

View File

@@ -0,0 +1,166 @@
use clap::Parser;
use harmony_assets::{S3Config, S3Store, checksum_for_path_with_progress};
use indicatif::{ProgressBar, ProgressStyle};
use std::path::Path;
#[derive(Parser, Debug)]
pub struct UploadArgs {
pub source: String,
pub key: Option<String>,
#[arg(short, long)]
pub content_type: Option<String>,
#[arg(short, long, default_value_t = true)]
pub public_read: bool,
#[arg(short, long)]
pub endpoint: Option<String>,
#[arg(short, long)]
pub bucket: Option<String>,
#[arg(short, long)]
pub region: Option<String>,
#[arg(short, long)]
pub access_key_id: Option<String>,
#[arg(short, long)]
pub secret_access_key: Option<String>,
#[arg(short, long, default_value = "blake3")]
pub algo: String,
#[arg(short, long, default_value_t = false)]
pub yes: bool,
}
pub async fn execute(args: UploadArgs) -> Result<(), Box<dyn std::error::Error>> {
let source_path = Path::new(&args.source);
if !source_path.exists() {
eprintln!("Error: File not found: {}", args.source);
std::process::exit(1);
}
let key = args.key.unwrap_or_else(|| {
source_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("upload")
.to_string()
});
let metadata = tokio::fs::metadata(source_path)
.await
.map_err(|e| format!("Failed to read file metadata: {}", e))?;
let total_size = metadata.len();
let endpoint = args
.endpoint
.or_else(|| std::env::var("S3_ENDPOINT").ok())
.unwrap_or_default();
let bucket = args
.bucket
.or_else(|| std::env::var("S3_BUCKET").ok())
.unwrap_or_else(|| {
inquire::Text::new("S3 Bucket name:")
.with_default("harmony-assets")
.prompt()
.unwrap()
});
let region = args
.region
.or_else(|| std::env::var("S3_REGION").ok())
.unwrap_or_else(|| {
inquire::Text::new("S3 Region:")
.with_default("us-east-1")
.prompt()
.unwrap()
});
let access_key_id = args
.access_key_id
.or_else(|| std::env::var("AWS_ACCESS_KEY_ID").ok());
let secret_access_key = args
.secret_access_key
.or_else(|| std::env::var("AWS_SECRET_ACCESS_KEY").ok());
let config = S3Config {
endpoint: if endpoint.is_empty() {
None
} else {
Some(endpoint)
},
bucket: bucket.clone(),
region: region.clone(),
access_key_id,
secret_access_key,
public_read: args.public_read,
};
println!("Upload Configuration:");
println!(" Source: {}", args.source);
println!(" S3 Key: {}", key);
println!(" Bucket: {}", bucket);
println!(" Region: {}", region);
println!(
" Size: {} bytes ({} MB)",
total_size,
total_size as f64 / 1024.0 / 1024.0
);
println!();
if !args.yes {
let confirm = inquire::Confirm::new("Proceed with upload?")
.with_default(true)
.prompt()?;
if !confirm {
println!("Upload cancelled.");
return Ok(());
}
}
let store = S3Store::new(config)
.await
.map_err(|e| format!("Failed to initialize S3 client: {}", e))?;
println!("Computing checksum while uploading...\n");
let pb = ProgressBar::new(total_size);
pb.set_style(
ProgressStyle::default_bar()
.template("{spinner:.green} [{elapsed_precise}] [{bar:40}] {bytes}/{total_bytes} ({bytes_per_sec})")?
.progress_chars("=>-"),
);
{
let algo = harmony_assets::ChecksumAlgo::from_str(&args.algo)?;
let rt = tokio::runtime::Handle::current();
let pb_clone = pb.clone();
let _checksum = rt.block_on(checksum_for_path_with_progress(
source_path,
algo,
|read, _total| {
pb_clone.set_position(read);
},
))?;
}
pb.set_position(total_size);
let result = store
.store(source_path, &key, args.content_type.as_deref())
.await;
pb.finish();
match result {
Ok(asset) => {
println!("\nUpload complete!");
println!(" URL: {}", asset.url);
println!(
" Checksum: {}:{}",
asset.checksum_algo.name(),
asset.checksum
);
println!(" Size: {} bytes", asset.size);
println!(" Key: {}", asset.key);
Ok(())
}
Err(e) => {
eprintln!("Upload failed: {}", e);
std::process::exit(1);
}
}
}

View File

@@ -0,0 +1,32 @@
use clap::Parser;
#[derive(Parser, Debug)]
pub struct VerifyArgs {
pub path: String,
pub expected: String,
#[arg(short, long, default_value = "blake3")]
pub algo: String,
}
pub async fn execute(args: VerifyArgs) -> Result<(), Box<dyn std::error::Error>> {
use harmony_assets::{ChecksumAlgo, verify_checksum};
let path = std::path::Path::new(&args.path);
if !path.exists() {
eprintln!("Error: File not found: {}", args.path);
std::process::exit(1);
}
let algo = ChecksumAlgo::from_str(&args.algo)?;
match verify_checksum(path, &args.expected, algo).await {
Ok(()) => {
println!("Checksum verified OK");
Ok(())
}
Err(e) => {
eprintln!("Verification FAILED: {}", e);
std::process::exit(1);
}
}
}

View File

@@ -0,0 +1,37 @@
use std::path::PathBuf;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AssetError {
#[error("File not found: {0}")]
FileNotFound(PathBuf),
#[error("Checksum mismatch for '{path}': expected {expected}, got {actual}")]
ChecksumMismatch {
path: PathBuf,
expected: String,
actual: String,
},
#[error("Checksum algorithm not available: {0}. Enable the corresponding feature flag.")]
ChecksumAlgoNotAvailable(String),
#[error("Download failed: {0}")]
DownloadFailed(String),
#[error("S3 error: {0}")]
S3Error(String),
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
#[cfg(feature = "reqwest")]
#[error("HTTP error: {0}")]
HttpError(#[from] reqwest::Error),
#[error("Store error: {0}")]
StoreError(String),
#[error("Configuration error: {0}")]
ConfigError(String),
}

233
harmony_assets/src/hash.rs Normal file
View File

@@ -0,0 +1,233 @@
use crate::errors::AssetError;
use std::path::Path;
#[cfg(feature = "blake3")]
use blake3::Hasher as B3Hasher;
#[cfg(feature = "sha256")]
use sha2::{Digest, Sha256};
#[derive(Debug, Clone)]
pub enum ChecksumAlgo {
BLAKE3,
SHA256,
}
impl Default for ChecksumAlgo {
fn default() -> Self {
#[cfg(feature = "blake3")]
return ChecksumAlgo::BLAKE3;
#[cfg(not(feature = "blake3"))]
return ChecksumAlgo::SHA256;
}
}
impl ChecksumAlgo {
pub fn name(&self) -> &'static str {
match self {
ChecksumAlgo::BLAKE3 => "blake3",
ChecksumAlgo::SHA256 => "sha256",
}
}
pub fn from_str(s: &str) -> Result<Self, AssetError> {
match s.to_lowercase().as_str() {
"blake3" | "b3" => Ok(ChecksumAlgo::BLAKE3),
"sha256" | "sha-256" => Ok(ChecksumAlgo::SHA256),
_ => Err(AssetError::ChecksumAlgoNotAvailable(s.to_string())),
}
}
}
impl std::fmt::Display for ChecksumAlgo {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
pub async fn checksum_for_file<R>(reader: R, algo: ChecksumAlgo) -> Result<String, AssetError>
where
R: tokio::io::AsyncRead + Unpin,
{
match algo {
#[cfg(feature = "blake3")]
ChecksumAlgo::BLAKE3 => {
let mut hasher = B3Hasher::new();
let mut reader = reader;
let mut buf = vec![0u8; 65536];
loop {
let n = tokio::io::AsyncReadExt::read(&mut reader, &mut buf).await?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(hasher.finalize().to_hex().to_string())
}
#[cfg(not(feature = "blake3"))]
ChecksumAlgo::BLAKE3 => Err(AssetError::ChecksumAlgoNotAvailable("blake3".to_string())),
#[cfg(feature = "sha256")]
ChecksumAlgo::SHA256 => {
let mut hasher = Sha256::new();
let mut reader = reader;
let mut buf = vec![0u8; 65536];
loop {
let n = tokio::io::AsyncReadExt::read(&mut reader, &mut buf).await?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
Ok(format!("{:x}", hasher.finalize()))
}
#[cfg(not(feature = "sha256"))]
ChecksumAlgo::SHA256 => Err(AssetError::ChecksumAlgoNotAvailable("sha256".to_string())),
}
}
pub async fn checksum_for_path(path: &Path, algo: ChecksumAlgo) -> Result<String, AssetError> {
let file = tokio::fs::File::open(path)
.await
.map_err(|e| AssetError::IoError(e))?;
let reader = tokio::io::BufReader::with_capacity(65536, file);
checksum_for_file(reader, algo).await
}
pub async fn checksum_for_path_with_progress<F>(
path: &Path,
algo: ChecksumAlgo,
mut progress: F,
) -> Result<String, AssetError>
where
F: FnMut(u64, Option<u64>) + Send,
{
let file = tokio::fs::File::open(path)
.await
.map_err(|e| AssetError::IoError(e))?;
let metadata = file.metadata().await.map_err(|e| AssetError::IoError(e))?;
let total = Some(metadata.len());
let reader = tokio::io::BufReader::with_capacity(65536, file);
match algo {
#[cfg(feature = "blake3")]
ChecksumAlgo::BLAKE3 => {
let mut hasher = B3Hasher::new();
let mut reader = reader;
let mut buf = vec![0u8; 65536];
let mut read: u64 = 0;
loop {
let n = tokio::io::AsyncReadExt::read(&mut reader, &mut buf).await?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
read += n as u64;
progress(read, total);
}
Ok(hasher.finalize().to_hex().to_string())
}
#[cfg(not(feature = "blake3"))]
ChecksumAlgo::BLAKE3 => Err(AssetError::ChecksumAlgoNotAvailable("blake3".to_string())),
#[cfg(feature = "sha256")]
ChecksumAlgo::SHA256 => {
let mut hasher = Sha256::new();
let mut reader = reader;
let mut buf = vec![0u8; 65536];
let mut read: u64 = 0;
loop {
let n = tokio::io::AsyncReadExt::read(&mut reader, &mut buf).await?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
read += n as u64;
progress(read, total);
}
Ok(format!("{:x}", hasher.finalize()))
}
#[cfg(not(feature = "sha256"))]
ChecksumAlgo::SHA256 => Err(AssetError::ChecksumAlgoNotAvailable("sha256".to_string())),
}
}
pub async fn verify_checksum(
path: &Path,
expected: &str,
algo: ChecksumAlgo,
) -> Result<(), AssetError> {
let actual = checksum_for_path(path, algo).await?;
let expected_clean = expected
.trim_start_matches("blake3:")
.trim_start_matches("sha256:")
.trim_start_matches("b3:")
.trim_start_matches("sha-256:");
if actual != expected_clean {
return Err(AssetError::ChecksumMismatch {
path: path.to_path_buf(),
expected: expected_clean.to_string(),
actual,
});
}
Ok(())
}
pub fn format_checksum(checksum: &str, algo: ChecksumAlgo) -> String {
format!("{}:{}", algo.name(), checksum)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::NamedTempFile;
async fn create_temp_file(content: &[u8]) -> NamedTempFile {
let mut file = NamedTempFile::new().unwrap();
file.write_all(content).unwrap();
file.flush().unwrap();
file
}
#[tokio::test]
async fn test_checksum_blake3() {
let file = create_temp_file(b"hello world").await;
let checksum = checksum_for_path(file.path(), ChecksumAlgo::BLAKE3)
.await
.unwrap();
assert_eq!(
checksum,
"d74981efa70a0c880b8d8c1985d075dbcbf679b99a5f9914e5aaf96b831a9e24"
);
}
#[tokio::test]
async fn test_verify_checksum_success() {
let file = create_temp_file(b"hello world").await;
let checksum = checksum_for_path(file.path(), ChecksumAlgo::BLAKE3)
.await
.unwrap();
let result = verify_checksum(file.path(), &checksum, ChecksumAlgo::BLAKE3).await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_verify_checksum_failure() {
let file = create_temp_file(b"hello world").await;
let result = verify_checksum(
file.path(),
"blake3:0000000000000000000000000000000000000000000000000000000000000000",
ChecksumAlgo::BLAKE3,
)
.await;
assert!(matches!(result, Err(AssetError::ChecksumMismatch { .. })));
}
#[tokio::test]
async fn test_checksum_with_prefix() {
let file = create_temp_file(b"hello world").await;
let checksum = checksum_for_path(file.path(), ChecksumAlgo::BLAKE3)
.await
.unwrap();
let formatted = format_checksum(&checksum, ChecksumAlgo::BLAKE3);
assert!(formatted.starts_with("blake3:"));
}
}

14
harmony_assets/src/lib.rs Normal file
View File

@@ -0,0 +1,14 @@
pub mod asset;
pub mod errors;
pub mod hash;
pub mod store;
pub use asset::{Asset, LocalCache, StoredAsset};
pub use errors::AssetError;
pub use hash::{ChecksumAlgo, checksum_for_path, checksum_for_path_with_progress, verify_checksum};
pub use store::AssetStore;
#[cfg(feature = "s3")]
pub use store::{S3Config, S3Store};
pub use store::local::LocalStore;

View File

@@ -0,0 +1,137 @@
use crate::asset::{Asset, LocalCache};
use crate::errors::AssetError;
use crate::store::AssetStore;
use async_trait::async_trait;
use std::path::PathBuf;
use url::Url;
#[cfg(feature = "reqwest")]
use crate::hash::verify_checksum;
#[derive(Debug, Clone)]
pub struct LocalStore {
base_dir: PathBuf,
}
impl LocalStore {
pub fn new(base_dir: PathBuf) -> Self {
Self { base_dir }
}
pub fn with_cache(cache: LocalCache) -> Self {
Self {
base_dir: cache.base_dir.clone(),
}
}
pub fn base_dir(&self) -> &PathBuf {
&self.base_dir
}
}
impl Default for LocalStore {
fn default() -> Self {
Self::new(LocalCache::default().base_dir)
}
}
#[async_trait]
impl AssetStore for LocalStore {
#[cfg(feature = "reqwest")]
async fn fetch(
&self,
asset: &Asset,
cache: &LocalCache,
progress: Option<Box<dyn Fn(u64, Option<u64>) + Send>>,
) -> Result<PathBuf, AssetError> {
use futures_util::StreamExt;
let dest_path = cache.path_for(asset);
if dest_path.exists() {
let verification =
verify_checksum(&dest_path, &asset.checksum, asset.checksum_algo.clone()).await;
if verification.is_ok() {
log::debug!("Asset already cached at {:?}", dest_path);
return Ok(dest_path);
} else {
log::warn!("Cached file failed checksum verification, re-downloading");
tokio::fs::remove_file(&dest_path)
.await
.map_err(|e| AssetError::IoError(e))?;
}
}
cache.ensure_dir(asset).await?;
log::info!("Downloading asset from {}", asset.url);
let client = reqwest::Client::new();
let response = client
.get(asset.url.as_str())
.send()
.await
.map_err(|e| AssetError::DownloadFailed(e.to_string()))?;
if !response.status().is_success() {
return Err(AssetError::DownloadFailed(format!(
"HTTP {}: {}",
response.status(),
asset.url
)));
}
let total_size = response.content_length();
let mut file = tokio::fs::File::create(&dest_path)
.await
.map_err(|e| AssetError::IoError(e))?;
let mut stream = response.bytes_stream();
let mut downloaded: u64 = 0;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| AssetError::DownloadFailed(e.to_string()))?;
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
.await
.map_err(|e| AssetError::IoError(e))?;
downloaded += chunk.len() as u64;
if let Some(ref p) = progress {
p(downloaded, total_size);
}
}
tokio::io::AsyncWriteExt::flush(&mut file)
.await
.map_err(|e| AssetError::IoError(e))?;
drop(file);
verify_checksum(&dest_path, &asset.checksum, asset.checksum_algo.clone()).await?;
log::info!("Asset downloaded and verified: {:?}", dest_path);
Ok(dest_path)
}
#[cfg(not(feature = "reqwest"))]
async fn fetch(
&self,
_asset: &Asset,
_cache: &LocalCache,
_progress: Option<Box<dyn Fn(u64, Option<u64>) + Send>>,
) -> Result<PathBuf, AssetError> {
Err(AssetError::DownloadFailed(
"HTTP downloads not available. Enable the 'reqwest' feature.".to_string(),
))
}
async fn exists(&self, key: &str) -> Result<bool, AssetError> {
let path = self.base_dir.join(key);
Ok(path.exists())
}
fn url_for(&self, key: &str) -> Result<Url, AssetError> {
let path = self.base_dir.join(key);
Url::from_file_path(&path)
.map_err(|_| AssetError::StoreError("Could not convert path to file URL".to_string()))
}
}

View File

@@ -0,0 +1,27 @@
use crate::asset::{Asset, LocalCache};
use crate::errors::AssetError;
use async_trait::async_trait;
use std::path::PathBuf;
use url::Url;
pub mod local;
#[cfg(feature = "s3")]
pub mod s3;
#[async_trait]
pub trait AssetStore: Send + Sync {
async fn fetch(
&self,
asset: &Asset,
cache: &LocalCache,
progress: Option<Box<dyn Fn(u64, Option<u64>) + Send>>,
) -> Result<PathBuf, AssetError>;
async fn exists(&self, key: &str) -> Result<bool, AssetError>;
fn url_for(&self, key: &str) -> Result<Url, AssetError>;
}
#[cfg(feature = "s3")]
pub use s3::{S3Config, S3Store};

View File

@@ -0,0 +1,235 @@
use crate::asset::StoredAsset;
use crate::errors::AssetError;
use crate::hash::ChecksumAlgo;
use async_trait::async_trait;
use aws_sdk_s3::Client as S3Client;
use aws_sdk_s3::primitives::ByteStream;
use aws_sdk_s3::types::ObjectCannedAcl;
use std::path::Path;
use url::Url;
#[derive(Debug, Clone)]
pub struct S3Config {
pub endpoint: Option<String>,
pub bucket: String,
pub region: String,
pub access_key_id: Option<String>,
pub secret_access_key: Option<String>,
pub public_read: bool,
}
impl Default for S3Config {
fn default() -> Self {
Self {
endpoint: None,
bucket: String::new(),
region: String::from("us-east-1"),
access_key_id: None,
secret_access_key: None,
public_read: true,
}
}
}
#[derive(Debug, Clone)]
pub struct S3Store {
client: S3Client,
config: S3Config,
}
impl S3Store {
pub async fn new(config: S3Config) -> Result<Self, AssetError> {
let mut cfg_builder = aws_config::defaults(aws_config::BehaviorVersion::latest());
if let Some(ref endpoint) = config.endpoint {
cfg_builder = cfg_builder.endpoint_url(endpoint);
}
let cfg = cfg_builder.load().await;
let client = S3Client::new(&cfg);
Ok(Self { client, config })
}
pub fn config(&self) -> &S3Config {
&self.config
}
fn public_url(&self, key: &str) -> Result<Url, AssetError> {
let url_str = if let Some(ref endpoint) = self.config.endpoint {
format!(
"{}/{}/{}",
endpoint.trim_end_matches('/'),
self.config.bucket,
key
)
} else {
format!(
"https://{}.s3.{}.amazonaws.com/{}",
self.config.bucket, self.config.region, key
)
};
Url::parse(&url_str).map_err(|e| AssetError::S3Error(e.to_string()))
}
pub async fn store(
&self,
source: &Path,
key: &str,
content_type: Option<&str>,
) -> Result<StoredAsset, AssetError> {
let metadata = tokio::fs::metadata(source)
.await
.map_err(|e| AssetError::IoError(e))?;
let size = metadata.len();
let checksum = crate::checksum_for_path(source, ChecksumAlgo::default())
.await
.map_err(|e| AssetError::StoreError(e.to_string()))?;
let body = ByteStream::from_path(source).await.map_err(|e| {
AssetError::IoError(std::io::Error::new(
std::io::ErrorKind::Other,
e.to_string(),
))
})?;
let mut put_builder = self
.client
.put_object()
.bucket(&self.config.bucket)
.key(key)
.body(body)
.content_length(size as i64)
.metadata("checksum", &checksum);
if self.config.public_read {
put_builder = put_builder.acl(ObjectCannedAcl::PublicRead);
}
if let Some(ct) = content_type {
put_builder = put_builder.content_type(ct);
}
put_builder
.send()
.await
.map_err(|e| AssetError::S3Error(e.to_string()))?;
Ok(StoredAsset {
url: self.public_url(key)?,
checksum,
checksum_algo: ChecksumAlgo::default(),
size,
key: key.to_string(),
})
}
}
use crate::store::AssetStore;
use crate::{Asset, LocalCache};
#[async_trait]
impl AssetStore for S3Store {
async fn fetch(
&self,
asset: &Asset,
cache: &LocalCache,
progress: Option<Box<dyn Fn(u64, Option<u64>) + Send>>,
) -> Result<std::path::PathBuf, AssetError> {
let dest_path = cache.path_for(asset);
if dest_path.exists() {
let verification =
crate::verify_checksum(&dest_path, &asset.checksum, asset.checksum_algo.clone())
.await;
if verification.is_ok() {
log::debug!("Asset already cached at {:?}", dest_path);
return Ok(dest_path);
}
}
cache.ensure_dir(asset).await?;
log::info!(
"Downloading asset from s3://{}/{}",
self.config.bucket,
asset.url
);
let key = extract_s3_key(&asset.url, &self.config.bucket)?;
let obj = self
.client
.get_object()
.bucket(&self.config.bucket)
.key(&key)
.send()
.await
.map_err(|e| AssetError::S3Error(e.to_string()))?;
let total_size = obj.content_length.unwrap_or(0) as u64;
let mut file = tokio::fs::File::create(&dest_path)
.await
.map_err(|e| AssetError::IoError(e))?;
let mut stream = obj.body;
let mut downloaded: u64 = 0;
while let Some(chunk_result) = stream.next().await {
let chunk = chunk_result.map_err(|e| AssetError::S3Error(e.to_string()))?;
tokio::io::AsyncWriteExt::write_all(&mut file, &chunk)
.await
.map_err(|e| AssetError::IoError(e))?;
downloaded += chunk.len() as u64;
if let Some(ref p) = progress {
p(downloaded, Some(total_size));
}
}
tokio::io::AsyncWriteExt::flush(&mut file)
.await
.map_err(|e| AssetError::IoError(e))?;
drop(file);
crate::verify_checksum(&dest_path, &asset.checksum, asset.checksum_algo.clone()).await?;
Ok(dest_path)
}
async fn exists(&self, key: &str) -> Result<bool, AssetError> {
match self
.client
.head_object()
.bucket(&self.config.bucket)
.key(key)
.send()
.await
{
Ok(_) => Ok(true),
Err(e) => {
let err_str = e.to_string();
if err_str.contains("NoSuchKey") || err_str.contains("NotFound") {
Ok(false)
} else {
Err(AssetError::S3Error(err_str))
}
}
}
}
fn url_for(&self, key: &str) -> Result<Url, AssetError> {
self.public_url(key)
}
}
fn extract_s3_key(url: &Url, bucket: &str) -> Result<String, AssetError> {
let path = url.path().trim_start_matches('/');
if let Some(stripped) = path.strip_prefix(&format!("{}/", bucket)) {
Ok(stripped.to_string())
} else if path == bucket {
Ok(String::new())
} else {
Ok(path.to_string())
}
}

View File

@@ -216,15 +216,6 @@ mod tests {
const KEY: &'static str = "TestConfig";
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq)]
struct AnotherTestConfig {
value: String,
}
impl Config for AnotherTestConfig {
const KEY: &'static str = "AnotherTestConfig";
}
struct MockSource {
data: std::sync::Mutex<std::collections::HashMap<String, serde_json::Value>>,
get_count: AtomicUsize,

View File

@@ -1,11 +1,8 @@
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::Mutex;
use crate::{ConfigError, ConfigSource};
static PROMPT_MUTEX: Mutex<()> = Mutex::const_new(());
pub struct PromptSource {
#[allow(dead_code)]
writer: Option<Arc<dyn std::io::Write + Send + Sync>>,
@@ -40,11 +37,3 @@ impl ConfigSource for PromptSource {
Ok(())
}
}
pub async fn with_prompt_lock<F, T>(f: F) -> Result<T, ConfigError>
where
F: std::future::Future<Output = Result<T, ConfigError>>,
{
let _guard = PROMPT_MUTEX.lock().await;
f.await
}

93
plan.md
View File

@@ -1,93 +0,0 @@
Final Plan: S3-Backed Asset Management for Harmony
Context Summary
Harmony is an infrastructure-as-code framework where Scores (desired state) are interpreted against Topologies (infrastructure capabilities). The existing Url enum (harmony_types/src/net.rs:96) already has LocalFolder(String) and Url(url::Url) variants, but the Url variant is unimplemented (todo!()) in both OPNsense TFTP and HTTP infra layers. Configuration in Harmony follows a "schema in Git, state in the store" pattern via harmony_config -- compile-time structs with values resolved from environment, secret store, or interactive prompt.
Findings
1. openshift-install is the only OKD binary actually invoked from Rust code (bootstrap_02_bootstrap.rs:139,162). oc and kubectl in data/okd/bin/ are never used by any code path.
2. The Url::Url variant is the designed extension point. The architecture explicitly anticipated remote URL sources but left them as todo!().
3. The k3d crate has a working lazy-download pattern (DownloadableAsset with SHA256 checksum verification, local caching, and HTTP download). This should be generalized.
4. The manual SCP workaround (ipxe.rs:126, bootstrap_02_bootstrap.rs:230) exists because russh is too slow for large file transfers. The S3 approach eliminates this entirely -- the OPNsense box pulls from S3 over HTTP instead.
5. All data/ paths are hardcoded as ./data/... in bootstrap_02_bootstrap.rs:84-88 and ipxe.rs:73.
---
Phase 1: Create a shared DownloadableAsset crate
Goal: Generalize the k3d download pattern into a reusable crate.
- Extract k3d/src/downloadable_asset.rs into a new shared crate (e.g., harmony_asset or add to harmony_types)
- The struct stays simple: { url, file_name, checksum, local_cache_path }
- Behavior: check local cache first (by checksum), download if missing, verify checksum after download
- The k3d crate becomes a consumer of this shared code
This is a straightforward refactor of ~160 lines of existing, tested code.
Phase 2: Define asset metadata as compile-time configuration
Goal: Replace hardcoded ./data/... paths with typed configuration, following the harmony_config pattern.
Create config structs for each asset group:
```rust
#[derive(Config, Serialize, Deserialize, JsonSchema, InteractiveParse)]
struct OkdInstallerConfig {
pub openshift_install_url: String,
pub openshift_install_sha256: String,
pub scos_kernel_url: String,
pub scos_kernel_sha256: String,
pub scos_initramfs_url: String,
pub scos_initramfs_sha256: String,
pub scos_rootfs_url: String,
pub scos_rootfs_sha256: String,
}
#[derive(Config, Serialize, Deserialize, JsonSchema, InteractiveParse)]
struct PxeAssetsConfig {
pub centos_install_img_url: String,
pub centos_install_img_sha256: String,
// ... etc
}
```
These structs live in the OKD module. On first run, harmony_config::get_or_prompt will prompt for the S3 URLs and checksums; after that, the values are persisted in the config store (OpenBao or local file). This means:
- No manifest file to maintain separately
- URLs/checksums can be updated per-team/per-environment without code changes
- Defaults can be compiled in for convenience
Phase 3: Implement Url::Url in OPNsense infra layer
Goal: Make the OPNsense TFTP/HTTP server pull files from remote URLs.
In harmony/src/infra/opnsense/http.rs and tftp.rs, implement the Url::Url(url) match arm:
- SSH into the OPNsense box
- Run fetch -o /usr/local/http/{path} {url} (FreeBSD/OPNsense native) or curl -o ...
- This completely replaces the manual SCP workaround for internet-connected environments
For serve_files with a folder of remote assets: the Score would pass individual Url::Url entries rather than a single Url::LocalFolder. This may require the trait to accept a list of URLs or an iterator pattern.
Phase 4: Refactor OKD modules
Goal: Wire up the new patterns in the OKD bootstrap flow.
In bootstrap_02_bootstrap.rs:
- openshift-install: Use the lazy-download pattern (like k3d). On execute(), resolve OkdInstallerConfig from harmony_config, download openshift-install to a local cache, invoke it.
- SCOS images: Pass Url::Url(scos_kernel_url) etc. to the StaticFilesHttpScore, which triggers the OPNsense box to fetch them from S3 directly. No more SCP.
- Remove oc and kubectl from data/okd/bin/ (they are unused).
In ipxe.rs:
- TFTP boot files (ipxe.efi, undionly.kpxe): These are small (~1MB). Either keep them in git (they're not the size problem) or move to S3 and lazy-download.
- HTTP files folder: Replace the folder_to_serve: None / SCP workaround with individual Url::Url entries for each asset.
- Remove the inquire::Confirm SCP prompts.
Phase 5: Upload assets to Ceph S3
Goal: Populate the S3 bucket and configure defaults.
- Upload all current data/ binaries to your Ceph S3 with a clear path scheme: harmony-assets/okd/v{version}/openshift-install, harmony-assets/pxe/centos-stream-9/install.img, etc.
- Set public-read ACL (or document presigned URL generation)
- Record the S3 URLs and SHA256 checksums
- These become the default values for the config structs (can be hardcoded as defaults or documented)
Phase 6: Remove LFS, clean up git history, publish to GitHub
Goal: Make the repo publishable.
- Remove all LFS-tracked files from the repo
- Update .gitattributes to remove LFS filters
- Keep data/ in .gitignore (it becomes a local cache directory)
- Optionally use git filter-repo or BFG to clean LFS objects from history
- The repo is now small enough for GitHub (code + templates + small configs only)
- Document the setup: "after clone, run the program and it will prompt for asset URLs or download from defaults"
---
Risks and Mitigations
Risk Mitigation
OPNsense can't reach S3 (network issues) Url::LocalFolder remains as fallback; populate local data/ manually for air-gapped
S3 bucket permissions misconfigured Test with curl from OPNsense before wiring into code
Large download times during bootstrap Progress reporting in the fetch/curl command; files are cached after first download
Breaking change to existing workflows Phase the rollout; keep LocalFolder working throughout
What About Upstream URL Resilience?
You mentioned upstream repos sometimes get cleaned up. The S3 bucket is your durable mirror. The config structs could optionally include upstream_url as a fallback source, but the primary retrieval should always be from your S3. Periodically re-uploading new versions to S3 (when upstream releases new images) is a manual but infrequent operation.
---
Order of Execution
I'd suggest this order:
1. Phase 5 first (upload to S3) -- this is independent of code and gives you the URLs to work with
2. Phase 1 (shared DownloadableAsset crate) -- small, testable refactor
3. Phase 2 (config structs) -- define the schema
4. Phase 3 (Url::Url implementation) -- the core infra change
5. Phase 4 (OKD module refactor) -- wire it all together
6. Phase 6 (LFS removal + GitHub) -- final cleanup
Does this plan align with your vision? Any aspect you'd like me to adjust or elaborate on before implementation?