Compare commits
2 Commits
feat/named
...
feat/arm-c
| Author | SHA1 | Date | |
|---|---|---|---|
| abb57b4059 | |||
| 0b451c6f35 |
@@ -3,3 +3,6 @@ rustflags = ["-C", "link-arg=/STACK:8000000"]
|
||||
|
||||
[target.x86_64-pc-windows-gnu]
|
||||
rustflags = ["-C", "link-arg=-Wl,--stack,8000000"]
|
||||
|
||||
[target.aarch64-unknown-linux-gnu]
|
||||
linker = "aarch64-linux-gnu-gcc"
|
||||
|
||||
78
.gitea/workflows/arm-agents.yaml
Normal file
78
.gitea/workflows/arm-agents.yaml
Normal file
@@ -0,0 +1,78 @@
|
||||
name: Build ARM agent binaries
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
- 'snapshot-*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build_arm_agents:
|
||||
container:
|
||||
image: hub.nationtech.io/harmony/harmony_composer:latest
|
||||
runs-on: docker
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Install ARM cross-compilation toolchain
|
||||
run: |
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq gcc-aarch64-linux-gnu
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
- name: Build agent crates for aarch64
|
||||
run: |
|
||||
cargo build --release --target aarch64-unknown-linux-gnu \
|
||||
-p harmony_agent \
|
||||
-p harmony_inventory_agent
|
||||
|
||||
- name: Install jq
|
||||
run: apt-get install -y -qq jq
|
||||
|
||||
- name: Get or create release
|
||||
run: |
|
||||
TAG_NAME="${GITHUB_REF_NAME}"
|
||||
|
||||
# Try to get existing release
|
||||
RELEASE_ID=$(curl -s -X GET \
|
||||
-H "Authorization: token ${{ secrets.GITEATOKEN }}" \
|
||||
"https://git.nationtech.io/api/v1/repos/nationtech/harmony/releases/tags/${TAG_NAME}" \
|
||||
| jq -r '.id // empty')
|
||||
|
||||
if [ -z "$RELEASE_ID" ]; then
|
||||
# Create new release
|
||||
RESPONSE=$(curl -s -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEATOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"tag_name\": \"${TAG_NAME}\",
|
||||
\"name\": \"${TAG_NAME}\",
|
||||
\"body\": \"Release ${TAG_NAME}\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": true
|
||||
}" \
|
||||
"https://git.nationtech.io/api/v1/repos/nationtech/harmony/releases")
|
||||
RELEASE_ID=$(echo "$RESPONSE" | jq -r '.id')
|
||||
fi
|
||||
|
||||
echo "RELEASE_ID=$RELEASE_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Upload harmony_agent ARM binary
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEATOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@target/aarch64-unknown-linux-gnu/release/harmony_agent" \
|
||||
"https://git.nationtech.io/api/v1/repos/nationtech/harmony/releases/${{ env.RELEASE_ID }}/assets?name=harmony_agent-aarch64-linux"
|
||||
|
||||
- name: Upload harmony_inventory_agent ARM binary
|
||||
run: |
|
||||
curl -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITEATOKEN }}" \
|
||||
-H "Content-Type: application/octet-stream" \
|
||||
--data-binary "@target/aarch64-unknown-linux-gnu/release/harmony_inventory_agent" \
|
||||
"https://git.nationtech.io/api/v1/repos/nationtech/harmony/releases/${{ env.RELEASE_ID }}/assets?name=harmony_inventory_agent-aarch64-linux"
|
||||
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3640,7 +3640,6 @@ dependencies = [
|
||||
"cidr",
|
||||
"env_logger",
|
||||
"getrandom 0.3.4",
|
||||
"harmony",
|
||||
"harmony_macros",
|
||||
"harmony_types",
|
||||
"log",
|
||||
|
||||
43
build/cross-arm.sh
Executable file
43
build/cross-arm.sh
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Cross-compile agent crates for aarch64 (Raspberry Pi)
|
||||
#
|
||||
# Prerequisites (Debian/Ubuntu):
|
||||
# sudo apt install gcc-aarch64-linux-gnu
|
||||
# rustup target add aarch64-unknown-linux-gnu
|
||||
#
|
||||
# Prerequisites (Arch Linux):
|
||||
# sudo pacman -S aarch64-linux-gnu-gcc
|
||||
# rustup target add aarch64-unknown-linux-gnu
|
||||
|
||||
TARGET="aarch64-unknown-linux-gnu"
|
||||
|
||||
echo "=== Cross-compiling for $TARGET ==="
|
||||
|
||||
# Check prerequisites
|
||||
if ! rustup target list --installed | grep -q "$TARGET"; then
|
||||
echo "ERROR: Rust target $TARGET not installed. Run: rustup target add $TARGET"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v aarch64-linux-gnu-gcc > /dev/null 2>&1; then
|
||||
echo "ERROR: aarch64-linux-gnu-gcc not found. Install the cross-compilation toolchain."
|
||||
echo " Debian/Ubuntu: sudo apt install gcc-aarch64-linux-gnu"
|
||||
echo " Arch Linux: sudo pacman -S aarch64-linux-gnu-gcc"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- Building harmony_agent ---"
|
||||
cargo build --release --target "$TARGET" -p harmony_agent
|
||||
|
||||
echo "--- Building harmony_inventory_agent ---"
|
||||
cargo build --release --target "$TARGET" -p harmony_inventory_agent
|
||||
|
||||
echo ""
|
||||
echo "=== Build complete ==="
|
||||
echo "Binaries:"
|
||||
echo " target/$TARGET/release/harmony_agent"
|
||||
echo " target/$TARGET/release/harmony_inventory_agent"
|
||||
@@ -60,14 +60,9 @@ impl FirewallPairTopology {
|
||||
///
|
||||
/// Credentials are loaded via `SecretManager::get_or_prompt`.
|
||||
pub async fn opnsense_from_config() -> Self {
|
||||
// TODO: both firewalls share the same credentials. Named config instances
|
||||
// are now available in harmony_config (ConfigManager::get_named /
|
||||
// get_or_prompt_named). To use per-device credentials here, add
|
||||
// harmony_config as a dependency and impl Config for OPNSenseApiCredentials
|
||||
// and OPNSenseFirewallCredentials, then replace the calls below with:
|
||||
// let api_creds = ConfigManager::get_or_prompt_named::<OPNSenseApiCredentials>("fw-primary").await?;
|
||||
// let backup_api = ConfigManager::get_or_prompt_named::<OPNSenseApiCredentials>("fw-backup").await?;
|
||||
// See ROADMAP/11-named-config-instances.md for details.
|
||||
// TODO: both firewalls share the same credentials. Once named config
|
||||
// instances are available (ROADMAP/11), use per-device credentials:
|
||||
// ConfigManager::get_named::<OPNSenseApiCredentials>("fw-primary")
|
||||
let ssh_creds = SecretManager::get_or_prompt::<OPNSenseFirewallCredentials>()
|
||||
.await
|
||||
.expect("Failed to get SSH credentials");
|
||||
|
||||
@@ -6,7 +6,6 @@ readme.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
harmony = { path = "../harmony" }
|
||||
# harmony_cli = { path = "../harmony_cli" }
|
||||
harmony_types = { path = "../harmony_types" }
|
||||
harmony_macros = { path = "../harmony_macros" }
|
||||
|
||||
@@ -72,11 +72,6 @@ pub trait ConfigSource: Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a composite key for a named config instance: `{base_key}/{name}`.
|
||||
fn named_key(base_key: &str, name: &str) -> String {
|
||||
format!("{}/{}", base_key, name)
|
||||
}
|
||||
|
||||
pub struct ConfigManager {
|
||||
sources: Vec<Arc<dyn ConfigSource>>,
|
||||
}
|
||||
@@ -87,62 +82,24 @@ impl ConfigManager {
|
||||
}
|
||||
|
||||
pub async fn get<T: Config>(&self) -> Result<T, ConfigError> {
|
||||
self.get_by_key(T::KEY).await
|
||||
}
|
||||
|
||||
/// Retrieve a named instance of a config type.
|
||||
///
|
||||
/// The storage key becomes `{T::KEY}/{name}`, allowing multiple instances
|
||||
/// of the same config type (e.g., separate credentials for primary and
|
||||
/// backup firewalls).
|
||||
pub async fn get_named<T: Config>(&self, name: &str) -> Result<T, ConfigError> {
|
||||
let key = named_key(T::KEY, name);
|
||||
self.get_by_key(&key).await
|
||||
}
|
||||
|
||||
pub async fn get_or_prompt<T: Config>(&self) -> Result<T, ConfigError> {
|
||||
self.get_or_prompt_by_key(T::KEY).await
|
||||
}
|
||||
|
||||
/// Retrieve a named instance, falling back to interactive prompt if not
|
||||
/// found in any source. The prompt will display the instance name for
|
||||
/// clarity.
|
||||
pub async fn get_or_prompt_named<T: Config>(&self, name: &str) -> Result<T, ConfigError> {
|
||||
let key = named_key(T::KEY, name);
|
||||
self.get_or_prompt_by_key(&key).await
|
||||
}
|
||||
|
||||
pub async fn set<T: Config>(&self, config: &T) -> Result<(), ConfigError> {
|
||||
self.set_by_key(T::KEY, config).await
|
||||
}
|
||||
|
||||
/// Store a named instance of a config type.
|
||||
pub async fn set_named<T: Config>(&self, name: &str, config: &T) -> Result<(), ConfigError> {
|
||||
let key = named_key(T::KEY, name);
|
||||
self.set_by_key(&key, config).await
|
||||
}
|
||||
|
||||
// ── Internal helpers ──────────────────────────────────────────────
|
||||
|
||||
async fn get_by_key<T: Config>(&self, key: &str) -> Result<T, ConfigError> {
|
||||
for source in &self.sources {
|
||||
if let Some(value) = source.get(key).await? {
|
||||
if let Some(value) = source.get(T::KEY).await? {
|
||||
let config: T =
|
||||
serde_json::from_value(value).map_err(|e| ConfigError::Deserialization {
|
||||
key: key.to_string(),
|
||||
key: T::KEY.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
debug!("Retrieved config for key {} from source", key);
|
||||
debug!("Retrieved config for key {} from source", T::KEY);
|
||||
return Ok(config);
|
||||
}
|
||||
}
|
||||
Err(ConfigError::NotFound {
|
||||
key: key.to_string(),
|
||||
key: T::KEY.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn get_or_prompt_by_key<T: Config>(&self, key: &str) -> Result<T, ConfigError> {
|
||||
match self.get_by_key::<T>(key).await {
|
||||
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 =
|
||||
@@ -150,7 +107,7 @@ impl ConfigManager {
|
||||
|
||||
let value =
|
||||
serde_json::to_value(&config).map_err(|e| ConfigError::Serialization {
|
||||
key: key.to_string(),
|
||||
key: T::KEY.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
@@ -158,7 +115,7 @@ impl ConfigManager {
|
||||
if !source.should_persist() {
|
||||
continue;
|
||||
}
|
||||
if source.set(key, &value).await.is_ok() {
|
||||
if source.set(T::KEY, &value).await.is_ok() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -169,14 +126,14 @@ impl ConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
async fn set_by_key<T: Config>(&self, key: &str, config: &T) -> Result<(), ConfigError> {
|
||||
pub async fn set<T: Config>(&self, config: &T) -> Result<(), ConfigError> {
|
||||
let value = serde_json::to_value(config).map_err(|e| ConfigError::Serialization {
|
||||
key: key.to_string(),
|
||||
key: T::KEY.to_string(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
for source in &self.sources {
|
||||
source.set(key, &value).await?;
|
||||
source.set(T::KEY, &value).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -217,33 +174,6 @@ pub async fn set<T: Config>(config: &T) -> Result<(), ConfigError> {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_named<T: Config>(name: &str) -> Result<T, ConfigError> {
|
||||
let manager = CONFIG_MANAGER.lock().await;
|
||||
manager
|
||||
.as_ref()
|
||||
.ok_or(ConfigError::NoSources)?
|
||||
.get_named::<T>(name)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_or_prompt_named<T: Config>(name: &str) -> Result<T, ConfigError> {
|
||||
let manager = CONFIG_MANAGER.lock().await;
|
||||
manager
|
||||
.as_ref()
|
||||
.ok_or(ConfigError::NoSources)?
|
||||
.get_or_prompt_named::<T>(name)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_named<T: Config>(name: &str, config: &T) -> Result<(), ConfigError> {
|
||||
let manager = CONFIG_MANAGER.lock().await;
|
||||
manager
|
||||
.as_ref()
|
||||
.ok_or(ConfigError::NoSources)?
|
||||
.set_named::<T>(name, config)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn default_config_dir() -> Option<PathBuf> {
|
||||
ProjectDirs::from("io", "NationTech", "Harmony").map(|dirs| dirs.data_dir().join("config"))
|
||||
}
|
||||
@@ -887,155 +817,4 @@ mod tests {
|
||||
assert_eq!(result.name, "from_sqlite");
|
||||
assert_eq!(result.count, 99);
|
||||
}
|
||||
|
||||
// ── Named config instance tests ───────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_named_get_returns_value_for_named_key() {
|
||||
let primary = TestConfig {
|
||||
name: "primary".to_string(),
|
||||
count: 1,
|
||||
};
|
||||
let mut data = std::collections::HashMap::new();
|
||||
data.insert(
|
||||
"TestConfig/primary".to_string(),
|
||||
serde_json::to_value(&primary).unwrap(),
|
||||
);
|
||||
|
||||
let source = Arc::new(MockSource::with_data(data));
|
||||
let manager = ConfigManager::new(vec![source]);
|
||||
|
||||
let result: TestConfig = manager.get_named("primary").await.unwrap();
|
||||
assert_eq!(result, primary);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_named_and_unnamed_keys_do_not_collide() {
|
||||
let unnamed = TestConfig {
|
||||
name: "unnamed".to_string(),
|
||||
count: 0,
|
||||
};
|
||||
let named_primary = TestConfig {
|
||||
name: "primary".to_string(),
|
||||
count: 1,
|
||||
};
|
||||
let named_backup = TestConfig {
|
||||
name: "backup".to_string(),
|
||||
count: 2,
|
||||
};
|
||||
|
||||
let mut data = std::collections::HashMap::new();
|
||||
data.insert(
|
||||
"TestConfig".to_string(),
|
||||
serde_json::to_value(&unnamed).unwrap(),
|
||||
);
|
||||
data.insert(
|
||||
"TestConfig/primary".to_string(),
|
||||
serde_json::to_value(&named_primary).unwrap(),
|
||||
);
|
||||
data.insert(
|
||||
"TestConfig/backup".to_string(),
|
||||
serde_json::to_value(&named_backup).unwrap(),
|
||||
);
|
||||
|
||||
let source = Arc::new(MockSource::with_data(data));
|
||||
let manager = ConfigManager::new(vec![source]);
|
||||
|
||||
let r_unnamed: TestConfig = manager.get().await.unwrap();
|
||||
let r_primary: TestConfig = manager.get_named("primary").await.unwrap();
|
||||
let r_backup: TestConfig = manager.get_named("backup").await.unwrap();
|
||||
|
||||
assert_eq!(r_unnamed, unnamed);
|
||||
assert_eq!(r_primary, named_primary);
|
||||
assert_eq!(r_backup, named_backup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_named_set_and_get_roundtrip() {
|
||||
let source = Arc::new(MockSource::new());
|
||||
let manager = ConfigManager::new(vec![source.clone()]);
|
||||
|
||||
let config = TestConfig {
|
||||
name: "instance_a".to_string(),
|
||||
count: 42,
|
||||
};
|
||||
|
||||
manager.set_named("instance_a", &config).await.unwrap();
|
||||
|
||||
let result: TestConfig = manager.get_named("instance_a").await.unwrap();
|
||||
assert_eq!(result, config);
|
||||
|
||||
// Unnamed get should NOT find the named value
|
||||
let unnamed: Result<TestConfig, ConfigError> = manager.get().await;
|
||||
assert!(matches!(unnamed, Err(ConfigError::NotFound { .. })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_named_resolution_through_source_chain() {
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
let temp_file = NamedTempFile::new().unwrap();
|
||||
let sqlite = SqliteSource::open(temp_file.path().to_path_buf())
|
||||
.await
|
||||
.unwrap();
|
||||
let sqlite = Arc::new(sqlite);
|
||||
|
||||
// Empty first source, config in sqlite
|
||||
let source1 = Arc::new(MockSource::new());
|
||||
let manager = ConfigManager::new(vec![source1.clone(), sqlite.clone()]);
|
||||
|
||||
let config = TestConfig {
|
||||
name: "from_sqlite_named".to_string(),
|
||||
count: 77,
|
||||
};
|
||||
sqlite
|
||||
.set(
|
||||
"TestConfig/my-instance",
|
||||
&serde_json::to_value(&config).unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let result: TestConfig = manager.get_named("my-instance").await.unwrap();
|
||||
assert_eq!(result, config);
|
||||
|
||||
// First source was checked but had nothing
|
||||
assert_eq!(source1.get_call_count(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_named_env_var_format() {
|
||||
let _lock = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
|
||||
let config = TestConfig {
|
||||
name: "from_env_named".to_string(),
|
||||
count: 55,
|
||||
};
|
||||
|
||||
// Named key "TestConfig/fw-primary" should map to env var
|
||||
// HARMONY_CONFIG_TestConfig_fw_primary
|
||||
let env_key = "HARMONY_CONFIG_TestConfig_fw_primary";
|
||||
unsafe {
|
||||
std::env::set_var(env_key, serde_json::to_string(&config).unwrap());
|
||||
}
|
||||
|
||||
let env_source = Arc::new(EnvSource);
|
||||
let manager = ConfigManager::new(vec![env_source]);
|
||||
|
||||
let result: TestConfig = manager.get_named("fw-primary").await.unwrap();
|
||||
assert_eq!(result, config);
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var(env_key);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_named_not_found() {
|
||||
let source = Arc::new(MockSource::new());
|
||||
let manager = ConfigManager::new(vec![source]);
|
||||
|
||||
let result: Result<TestConfig, ConfigError> = manager.get_named("nonexistent").await;
|
||||
assert!(matches!(result, Err(ConfigError::NotFound { ref key }) if key == "TestConfig/nonexistent"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@ use async_trait::async_trait;
|
||||
pub struct EnvSource;
|
||||
|
||||
fn env_key_for(config_key: &str) -> String {
|
||||
// Replace `/` and `-` with `_` so named keys like "MyConfig/fw-primary"
|
||||
// become valid env var names: HARMONY_CONFIG_MyConfig_fw_primary
|
||||
let sanitized = config_key.replace(['/', '-'], "_");
|
||||
format!("HARMONY_CONFIG_{}", sanitized)
|
||||
format!("HARMONY_CONFIG_{}", config_key)
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
|
||||
@@ -46,10 +46,9 @@ impl ConfigSource for LocalFileSource {
|
||||
}
|
||||
|
||||
async fn set(&self, key: &str, value: &serde_json::Value) -> Result<(), ConfigError> {
|
||||
fs::create_dir_all(&self.base_path).await?;
|
||||
|
||||
let path = self.file_path_for(key);
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let contents =
|
||||
serde_json::to_string_pretty(value).map_err(|e| ConfigError::Serialization {
|
||||
key: key.to_string(),
|
||||
|
||||
Reference in New Issue
Block a user