fix: app-deploy safety — no secret helm dump, jwt mount, ingress, smoke #347

Open
johnride wants to merge 5 commits from fix/app-deploy-safety into master
43 changed files with 150 additions and 320 deletions

17
Cargo.lock generated
View File

@@ -1313,7 +1313,7 @@ version = "0.1.0"
dependencies = [
"async-trait",
"env_logger",
"harmony_secret",
"harmony_config",
"harmony_types",
"log",
"regex",
@@ -3181,9 +3181,8 @@ dependencies = [
"env_logger",
"harmony",
"harmony_cli",
"harmony_config",
"harmony_macros",
"harmony_secret",
"harmony_secret_derive",
"harmony_types",
"log",
"schemars 0.8.22",
@@ -3240,8 +3239,8 @@ dependencies = [
"env_logger",
"harmony",
"harmony_cli",
"harmony_config",
"harmony_macros",
"harmony_secret",
"harmony_types",
"log",
"schemars 0.8.22",
@@ -3320,9 +3319,8 @@ dependencies = [
"env_logger",
"harmony",
"harmony_cli",
"harmony_config",
"harmony_macros",
"harmony_secret",
"harmony_secret_derive",
"harmony_types",
"log",
"schemars 0.8.22",
@@ -3465,7 +3463,7 @@ dependencies = [
"harmony",
"harmony-fleet-deploy",
"harmony_cli",
"harmony_secret",
"harmony_config",
"harmony_types",
"log",
"reqwest 0.12.28",
@@ -6445,9 +6443,8 @@ dependencies = [
"cidr",
"harmony",
"harmony_cli",
"harmony_config",
"harmony_macros",
"harmony_secret",
"harmony_secret_derive",
"harmony_types",
"schemars 0.8.22",
"serde",
@@ -8896,8 +8893,8 @@ dependencies = [
"cidr",
"harmony",
"harmony_cli",
"harmony_config",
"harmony_macros",
"harmony_secret",
"tokio",
]

View File

@@ -1,4 +1,4 @@
FROM docker.io/rust:1.94 AS build
FROM docker.io/rust:1.97 AS build
WORKDIR /app

View File

@@ -14,6 +14,6 @@ tokio.workspace = true
log.workspace = true
env_logger.workspace = true
regex = "1.11.3"
harmony_secret = { path = "../harmony_secret" }
harmony_config = { path = "../harmony_config" }
serde.workspace = true
schemars = "0.8"

View File

@@ -1,12 +1,13 @@
use std::net::{IpAddr, Ipv4Addr};
use brocade::{BrocadeOptions, ssh};
use harmony_secret::{Secret, SecretManager};
use harmony_config::Config;
use harmony_types::switch::PortLocation;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Secret, Clone, Debug, JsonSchema, Serialize, Deserialize)]
#[derive(Config, Clone, Debug, JsonSchema, Serialize, Deserialize)]
#[config(secret)]
struct BrocadeSwitchAuth {
username: String,
password: String,
@@ -21,7 +22,7 @@ async fn main() {
// let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 4, 11)); // brocade @ st
let switch_addresses = vec![ip];
let config = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
let config = harmony_config::get_or_prompt::<BrocadeSwitchAuth>()
.await
.unwrap();

View File

@@ -1,3 +1,4 @@
#![allow(deprecated)]
use std::{path::PathBuf, str::FromStr, sync::Arc};
use harmony::{

View File

@@ -12,7 +12,7 @@ path = "src/main.rs"
harmony = { path = "../../harmony" }
harmony-fleet-deploy = { path = "../../fleet/harmony-fleet-deploy" }
harmony_cli = { path = "../../harmony_cli" }
harmony_secret = { path = "../../harmony_secret" }
harmony_config = { path = "../../harmony_config" }
harmony_types = { path = "../../harmony_types" }
tokio.workspace = true
log.workspace = true

View File

@@ -23,7 +23,7 @@
//! - SSH server enabled
//! - An admin user with sudo. Passwordless sudo is detected and
//! used silently; otherwise the example prompts for a sudo
//! password via `SecretManager` and caches it for next runs.
//! password via `harmony_config` and caches it for next runs.
//! - Your driver-machine SSH public key in that user's
//! `~/.ssh/authorized_keys`
//!
@@ -39,7 +39,6 @@ use harmony::config::secret::SudoPassword;
use harmony::inventory::Inventory;
use harmony::modules::linux::{LinuxHostTopology, SshCredentials, ensure_ansible_venv, ssh_exec};
use harmony_fleet_deploy::{FleetDeviceAuth, FleetDeviceSetupConfig, FleetDeviceSetupScore};
use harmony_secret::SecretManager;
use harmony_types::id::Id;
use log::info;
use std::path::PathBuf;
@@ -144,8 +143,8 @@ async fn main() -> Result<()> {
};
// If the Pi doesn't have passwordless sudo, fetch the password
// through SecretManager (same flow other scores use for SSH keys
// etc. — see harmony_secret/src/lib.rs:145). First run prompts;
// through harmony_config (same flow other scores use for SSH keys
// etc.). First run prompts;
// subsequent runs reuse the cached value. Probe with `sudo -n`
// first so we don't prompt the operator for a password they
// don't need.
@@ -154,7 +153,7 @@ async fn main() -> Result<()> {
.map_err(|e| anyhow::anyhow!("sudo probe: {e}"))?;
if probe.rc != 0 {
info!("device requires a sudo password — fetching from secret store");
let secret = SecretManager::get_or_prompt::<SudoPassword>()
let secret = harmony_config::get_or_prompt::<SudoPassword>()
.await
.map_err(|e| anyhow::anyhow!("get sudo password: {e}"))?;
creds.sudo_password = Some(secret.password);

View File

@@ -11,8 +11,7 @@ harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_macros = { path = "../../harmony_macros" }
harmony_types = { path = "../../harmony_types" }
harmony_secret = { path = "../../harmony_secret" }
harmony_secret_derive = { path = "../../harmony_secret_derive" }
harmony_config = { path = "../../harmony_config" }
tokio.workspace = true
cidr.workspace = true
serde = { workspace = true }

View File

@@ -9,8 +9,8 @@ use harmony::{
inventory::Inventory,
topology::{HAClusterTopology, LogicalHost, UnmanagedRouter},
};
use harmony_config::Config;
use harmony_macros::{ip, ipv4};
use harmony_secret::{Secret, SecretManager};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::{
@@ -18,7 +18,8 @@ use std::{
sync::{Arc, OnceLock},
};
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[derive(Config, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[config(secret)]
struct OkdAddNodeFirewallConfig {
username: String,
password: String,
@@ -41,7 +42,7 @@ pub async fn get_topology() -> HAClusterTopology {
.expect("Failed to connect to switch"),
);
let config = SecretManager::get_or_prompt::<OkdAddNodeFirewallConfig>()
let config = harmony_config::get_or_prompt::<OkdAddNodeFirewallConfig>()
.await
.unwrap();
let api_creds = harmony::config::secret::OPNSenseApiCredentials {

View File

@@ -10,8 +10,7 @@ publish = false
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_types = { path = "../../harmony_types" }
harmony_secret = { path = "../../harmony_secret" }
harmony_secret_derive = { path = "../../harmony_secret_derive" }
harmony_config = { path = "../../harmony_config" }
cidr = { workspace = true }
tokio = { workspace = true }
harmony_macros = { path = "../../harmony_macros" }

View File

@@ -14,14 +14,13 @@ use harmony::{
score::Score,
topology::HAClusterTopology,
};
use harmony_secret::SecretManager;
#[tokio::main]
async fn main() {
let inventory = get_inventory();
let topology = get_topology().await;
let ssh_key = SecretManager::get_or_prompt::<SshKeyPair>().await.unwrap();
let ssh_key = harmony_config::get_or_prompt::<SshKeyPair>().await.unwrap();
let mut scores: Vec<Box<dyn Score<HAClusterTopology>>> = vec![Box::new(OKDIpxeScore {
kickstart_filename: "inventory.kickstart".to_string(),

View File

@@ -12,7 +12,6 @@ use harmony::{
topology::{HAClusterTopology, LogicalHost, UnmanagedRouter},
};
use harmony_macros::{ip, ipv4};
use harmony_secret::SecretManager;
use std::{
net::IpAddr,
sync::{Arc, OnceLock},
@@ -24,7 +23,7 @@ pub async fn get_topology() -> HAClusterTopology {
name: String::from("opnsense-1"),
};
let switch_auth = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
let switch_auth = harmony_config::get_or_prompt::<BrocadeSwitchAuth>()
.await
.expect("Failed to get credentials");
@@ -43,10 +42,10 @@ pub async fn get_topology() -> HAClusterTopology {
let switch_client = Arc::new(switch_client);
let ssh_creds = SecretManager::get_or_prompt::<OPNSenseFirewallCredentials>()
let ssh_creds = harmony_config::get_or_prompt::<OPNSenseFirewallCredentials>()
.await
.unwrap();
let api_creds = SecretManager::get_or_prompt::<OPNSenseApiCredentials>()
let api_creds = harmony_config::get_or_prompt::<OPNSenseApiCredentials>()
.await
.unwrap();

View File

@@ -10,8 +10,7 @@ publish = false
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_types = { path = "../../harmony_types" }
harmony_secret = { path = "../../harmony_secret" }
harmony_secret_derive = { path = "../../harmony_secret_derive" }
harmony_config = { path = "../../harmony_config" }
cidr = { workspace = true }
tokio = { workspace = true }
harmony_macros = { path = "../../harmony_macros" }

View File

@@ -6,7 +6,6 @@ use harmony::{
data::{FileContent, FilePath},
modules::okd::ipxe::OKDIpxeScore,
};
use harmony_secret::SecretManager;
#[tokio::main]
async fn main() {
@@ -15,7 +14,7 @@ async fn main() {
let kickstart_filename = "inventory.kickstart".to_string();
let harmony_inventory_agent = "harmony_inventory_agent".to_string();
let ssh_key = SecretManager::get_or_prompt::<SshKeyPair>().await.unwrap();
let ssh_key = harmony_config::get_or_prompt::<SshKeyPair>().await.unwrap();
let ipxe_score = OKDIpxeScore {
kickstart_filename,

View File

@@ -12,7 +12,6 @@ use harmony::{
topology::{HAClusterTopology, LogicalHost, UnmanagedRouter},
};
use harmony_macros::{ip, ipv4};
use harmony_secret::SecretManager;
use std::{
net::IpAddr,
sync::{Arc, OnceLock},
@@ -24,7 +23,7 @@ pub async fn get_topology() -> HAClusterTopology {
name: String::from("opnsense-1"),
};
let switch_auth = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
let switch_auth = harmony_config::get_or_prompt::<BrocadeSwitchAuth>()
.await
.expect("Failed to get credentials");
@@ -43,11 +42,11 @@ pub async fn get_topology() -> HAClusterTopology {
let switch_client = Arc::new(switch_client);
let ssh_creds = SecretManager::get_or_prompt::<OPNSenseFirewallCredentials>()
let ssh_creds = harmony_config::get_or_prompt::<OPNSenseFirewallCredentials>()
.await
.unwrap();
let api_creds =
SecretManager::get_or_prompt::<harmony::config::secret::OPNSenseApiCredentials>()
harmony_config::get_or_prompt::<harmony::config::secret::OPNSenseApiCredentials>()
.await
.unwrap();

View File

@@ -16,7 +16,7 @@ harmony_macros = { path = "../../harmony_macros" }
log = { workspace = true }
env_logger = { workspace = true }
url = { workspace = true }
harmony_secret = { path = "../../harmony_secret" }
harmony_config = { path = "../../harmony_config" }
brocade = { path = "../../brocade" }
serde = { workspace = true }
schemars = "0.8"

View File

@@ -6,9 +6,6 @@ use harmony::{
topology::LogicalHost,
};
use harmony_macros::{ip, ipv4};
use harmony_secret::{Secret, SecretManager};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[tokio::main]
async fn main() {
@@ -17,10 +14,10 @@ async fn main() {
name: String::from("opnsense-1"),
};
let ssh_creds = SecretManager::get_or_prompt::<OPNSenseFirewallCredentials>()
let ssh_creds = harmony_config::get_or_prompt::<OPNSenseFirewallCredentials>()
.await
.expect("Failed to get SSH credentials");
let api_creds = SecretManager::get_or_prompt::<OPNSenseApiCredentials>()
let api_creds = harmony_config::get_or_prompt::<OPNSenseApiCredentials>()
.await
.expect("Failed to get API credentials");
@@ -67,9 +64,3 @@ async fn main() {
.await
.unwrap();
}
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug)]
pub struct BrocadeSwitchAuth {
pub username: String,
pub password: String,
}

View File

@@ -1,3 +1,4 @@
#![allow(deprecated)]
use std::{path::PathBuf, sync::Arc};
use harmony::{

View File

@@ -1,3 +1,4 @@
#![allow(deprecated)]
use std::{path::PathBuf, sync::Arc};
use harmony::{

View File

@@ -10,6 +10,6 @@ publish = false
harmony = { path = "../../harmony" }
harmony_cli = { path = "../../harmony_cli" }
harmony_macros = { path = "../../harmony_macros" }
harmony_secret = { path = "../../harmony_secret" }
harmony_config = { path = "../../harmony_config" }
cidr = { workspace = true }
tokio = { workspace = true }

View File

@@ -16,7 +16,6 @@ use harmony::{
topology::HAClusterTopology,
};
use harmony_macros::cidrv4;
use harmony_secret::SecretManager;
#[tokio::main]
async fn main() {
@@ -25,7 +24,7 @@ async fn main() {
let inventory = get_inventory();
let topology = get_topology().await;
let ssh_key = SecretManager::get_or_prompt::<SshKeyPair>().await.unwrap();
let ssh_key = harmony_config::get_or_prompt::<SshKeyPair>().await.unwrap();
// Discovery runs as a CIDR scan across the sttest LAN on the
// harmony_inventory_agent's default port. Shared between the install

View File

@@ -7,7 +7,6 @@ use harmony::{
topology::{HAClusterTopology, LogicalHost, UnmanagedRouter},
};
use harmony_macros::{ip, ipv4};
use harmony_secret::SecretManager;
use std::{
net::IpAddr,
sync::{Arc, OnceLock},
@@ -22,10 +21,10 @@ pub async fn get_opnsense() -> Arc<harmony::infra::opnsense::OPNSenseFirewall> {
name: String::from("fw0"),
};
let ssh_creds = SecretManager::get_or_prompt::<OPNSenseFirewallCredentials>()
let ssh_creds = harmony_config::get_or_prompt::<OPNSenseFirewallCredentials>()
.await
.unwrap();
let api_credentials = SecretManager::get_or_prompt::<OPNSenseApiCredentials>()
let api_credentials = harmony_config::get_or_prompt::<OPNSenseApiCredentials>()
.await
.unwrap();

View File

@@ -1,3 +1,4 @@
#![allow(deprecated)]
use harmony::{
inventory::Inventory,
modules::{

View File

@@ -1,3 +1,4 @@
#![allow(deprecated)]
use harmony::{
inventory::Inventory,
modules::{

View File

@@ -33,7 +33,7 @@ impl K8sClient {
.take(6);
let attempt = Mutex::new(0u32);
let d = Retry::spawn(retry_strategy, || async {
let d = Retry::start(retry_strategy, || async {
let mut n = attempt.lock().await;
*n += 1;
debug!("Running Kubernetes API discovery (attempt {})", *n);

View File

@@ -1,27 +1,31 @@
use harmony_secret_derive::Secret;
use harmony_config::Config;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[derive(Config, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[config(secret)]
pub struct OPNSenseFirewallCredentials {
pub username: String,
pub password: String,
}
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[derive(Config, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[config(secret)]
pub struct OPNSenseApiCredentials {
pub key: String,
pub secret: String,
}
// TODO we need a better way to handle multiple "instances" of the same secret structure.
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[derive(Config, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[config(secret)]
pub struct SshKeyPair {
pub private: String,
pub public: String,
}
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[derive(Config, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[config(secret)]
pub struct RedhatSecret {
pub pull_secret: String,
}
@@ -33,7 +37,8 @@ pub struct RedhatSecret {
/// Not used for SSH login itself — `LinuxHostTopology` still requires
/// a `private_key_path` for that. SSH password auth is a possible
/// future extension; see the TODO on `SshCredentials`.
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[derive(Config, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
#[config(secret)]
pub struct SudoPassword {
pub password: String,
}

View File

@@ -35,8 +35,6 @@ use crate::topology::{
Topology,
};
use harmony_secret::SecretManager;
// ── FirewallPairTopology ───────────────────────────────────────────
/// An OPNsense HA firewall pair managed via CARP.
@@ -58,16 +56,16 @@ impl FirewallPairTopology {
/// - `OPNSENSE_BACKUP_IP` — IP address of the backup firewall
/// - `OPNSENSE_API_PORT` — API/web GUI port (default: 443)
///
/// Credentials are loaded via `SecretManager::get_or_prompt`.
/// Credentials are loaded via `harmony_config::get_or_prompt`.
pub async fn opnsense_from_config() -> Self {
// TODO: both firewalls share the same credentials. Once named config
// instances are available (ROADMAP/11), use per-device credentials:
// ConfigClient::get_named::<OPNSenseApiCredentials>("fw-primary")
let ssh_creds = SecretManager::get_or_prompt::<OPNSenseFirewallCredentials>()
let ssh_creds = harmony_config::get_or_prompt::<OPNSenseFirewallCredentials>()
.await
.expect("Failed to get SSH credentials");
let api_creds = SecretManager::get_or_prompt::<OPNSenseApiCredentials>()
let api_creds = harmony_config::get_or_prompt::<OPNSenseApiCredentials>()
.await
.expect("Failed to get API credentials");

View File

@@ -9,6 +9,7 @@
use harmony_types::firewall::LaggProtocol;
/// NetworkManager bond `mode=` value for a `LaggProtocol`.
#[cfg(test)]
pub(crate) fn nm_bond_mode(proto: &LaggProtocol) -> &'static str {
match proto {
LaggProtocol::Lacp => "802.3ad",

View File

@@ -27,8 +27,7 @@ use crate::{
};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose};
use harmony_secret::SecretManager;
use harmony_secret_derive::Secret;
use harmony_config::Config;
use harmony_types::net::Url;
use kube::api::ObjectMeta;
use log::{debug, info};
@@ -100,7 +99,7 @@ impl<
.await
.map_err(|e| e.to_string())?;
let config = SecretManager::get_or_prompt::<NtfyAuth>().await.unwrap();
let config = harmony_config::get_or_prompt::<NtfyAuth>().await.unwrap();
let ntfy_default_auth_header = format!(
"Basic {}",
@@ -143,7 +142,8 @@ impl<
}
}
#[derive(Secret, Serialize, Deserialize, JsonSchema, Clone, Debug)]
#[derive(Config, Serialize, Deserialize, JsonSchema, Clone, Debug)]
#[config(secret)]
struct NtfyAuth {
username: String,
password: String,

View File

@@ -2,7 +2,7 @@ use std::net::IpAddr;
use async_trait::async_trait;
use brocade::BrocadeOptions;
use harmony_secret::{Secret, SecretManager};
use harmony_config::Config;
use harmony_types::id::Id;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -38,7 +38,8 @@ pub struct BrocadeEnableSnmpInterpret {
score: BrocadeEnableSnmpScore,
}
#[derive(Secret, Clone, Debug, JsonSchema, Serialize, Deserialize)]
#[derive(Config, Clone, Debug, JsonSchema, Serialize, Deserialize)]
#[config(secret)]
pub struct BrocadeSwitchAuth {
pub username: String,
pub password: String,
@@ -50,7 +51,8 @@ impl BrocadeSwitchAuth {
}
}
#[derive(Secret, Clone, Debug, JsonSchema, Serialize, Deserialize)]
#[derive(Config, Clone, Debug, JsonSchema, Serialize, Deserialize)]
#[config(secret)]
pub struct BrocadeSnmpAuth {
pub username: String,
pub auth_password: String,
@@ -66,11 +68,11 @@ impl<T: Topology> Interpret<T> for BrocadeEnableSnmpInterpret {
) -> Result<Outcome, InterpretError> {
let switch_addresses = &self.score.switch_ips;
let snmp_auth = SecretManager::get_or_prompt::<BrocadeSnmpAuth>()
let snmp_auth = harmony_config::get_or_prompt::<BrocadeSnmpAuth>()
.await
.unwrap();
let config = SecretManager::get_or_prompt::<BrocadeSwitchAuth>()
let config = harmony_config::get_or_prompt::<BrocadeSwitchAuth>()
.await
.unwrap();

View File

@@ -78,7 +78,7 @@ where
.max_delay(Duration::from_millis(1000))
.take(10);
Retry::spawn(strategy, || async {
Retry::start(strategy, || async {
log::debug!("Attempting CA cert fetch");
let res = self

View File

@@ -1,8 +1,8 @@
use std::collections::BTreeMap;
use async_trait::async_trait;
use harmony_config::Config;
use harmony_k8s::KubernetesDistribution;
use harmony_secret::{Secret, SecretManager};
use harmony_types::id::Id;
use k8s_openapi::{ByteString, api::core::v1::Secret as K8sSecret};
use kube::api::ObjectMeta;
@@ -344,7 +344,7 @@ impl NatsK8sInterpret {
peers: Option<Vec<NatsCluster>>,
namespace: String,
) -> Result<Outcome, InterpretError> {
let admin = SecretManager::get_or_prompt::<NatsAdmin>().await.unwrap();
let admin = harmony_config::get_or_prompt::<NatsAdmin>().await.unwrap();
let admin_user = admin.user.clone();
let admin_password = admin.password.clone();
@@ -582,7 +582,8 @@ config:
}
}
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug, PartialEq, Clone)]
#[derive(Config, Serialize, Deserialize, JsonSchema, Debug, PartialEq, Clone)]
#[config(secret)]
struct NatsAdmin {
user: String,
password: String,

View File

@@ -27,7 +27,7 @@ use harmony_k8s::{
K8sClient,
csr::{KUBE_APISERVER_CLIENT_KUBELET_SIGNER, KUBELET_SERVING_SIGNER},
};
use harmony_secret::SecretManager;
use harmony_types::{id::Id, ssh::SshCredentials};
use k8s_openapi::api::core::v1::{Node, Secret};
use kube::api::ListParams;
@@ -349,7 +349,9 @@ impl Interpret<HAClusterTopology> for OKDAddNodeInterpret {
.map_err(|e| InterpretError::new(format!("User prompt failed: {e}")))?;
if rebooted {
let ssh_key = SecretManager::get_or_prompt::<SshKeyPair>().await?;
let ssh_key = harmony_config::get_or_prompt::<SshKeyPair>()
.await
.map_err(|e| InterpretError::new(e.to_string()))?;
let creds = SshCredentials::SshKey {
username: "root".to_string(),
private_pem: ssh_key.private,

View File

@@ -18,7 +18,7 @@ use crate::{
};
use async_trait::async_trait;
use derive_new::new;
use harmony_secret::SecretManager;
use harmony_types::id::Id;
use harmony_types::net::Url;
use log::{debug, info};
@@ -115,8 +115,12 @@ impl OKDSetup02BootstrapInterpret {
);
}
let redhat_secret = SecretManager::get_or_prompt::<RedhatSecret>().await?;
let ssh_key = SecretManager::get_or_prompt::<SshKeyPair>().await?;
let redhat_secret = harmony_config::get_or_prompt::<RedhatSecret>()
.await
.map_err(|e| InterpretError::new(e.to_string()))?;
let ssh_key = harmony_config::get_or_prompt::<SshKeyPair>()
.await
.map_err(|e| InterpretError::new(e.to_string()))?;
let install_config_yaml = InstallConfigYaml {
cluster_name: &topology.get_cluster_name(),

View File

@@ -19,7 +19,6 @@
//! regardless of whether the stable name is a regular file or a symlink.
use async_trait::async_trait;
use harmony_secret::SecretManager;
use harmony_types::{id::Id, ssh::SshCredentials};
use log::{debug, info};
use serde::Serialize;
@@ -71,7 +70,9 @@ impl Interpret<HAClusterTopology> for OKDOsArtifactsInterpret {
topology: &HAClusterTopology,
) -> Result<Outcome, InterpretError> {
let fw_ip = topology.firewall.get_ip();
let fw_creds = SecretManager::get_or_prompt::<OPNSenseFirewallCredentials>().await?;
let fw_creds = harmony_config::get_or_prompt::<OPNSenseFirewallCredentials>()
.await
.map_err(|e| InterpretError::new(e.to_string()))?;
let ssh_creds = SshCredentials::Password {
username: fw_creds.username,
password: fw_creds.password,

View File

@@ -34,7 +34,7 @@ use std::str::FromStr;
use async_trait::async_trait;
use harmony_config::{Config, ConfigError, StateClient};
use harmony_types::id::Id;
use log::{debug, error, info, trace, warn};
use log::{debug, error, info, warn};
use non_blank_string_rs::NonBlankString;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -1078,8 +1078,6 @@ login:
}
};
trace!("[Zitadel] Helm values YAML:\n{values_yaml}");
// --- Step 6: Deploy Helm chart ------------------------------------
let chart_name =

View File

@@ -1,3 +1,4 @@
#![allow(deprecated)]
use harmony::{
inventory::Inventory,
modules::{

View File

@@ -361,9 +361,9 @@ impl Interpret<K8sAnywhereTopology> for K8sAnywhereApplicationInterpret {
))
})?;
}
smoke_check_routes(&self.score.application.routes, &self.score.endpoints).await?;
}
smoke_check_routes(&self.score.application.routes, &self.score.endpoints).await?;
Ok(Outcome::success_with_details(
format!("deployed application '{}'", self.score.application.name),
vec![format!(
@@ -499,7 +499,7 @@ fn lower(
Ok(LoweredApplication {
deployments,
services,
ingress: ingress(application, endpoints),
ingress: ingress(application, endpoints)?,
})
}
@@ -803,14 +803,23 @@ fn k8s_service(service: &Service) -> K8sService {
fn ingress(
application: &Application,
endpoints: &BTreeMap<String, ResolvedEndpoint>,
) -> Option<Ingress> {
) -> Result<Option<Ingress>, AppError> {
if application.routes.is_empty() {
return None;
return Ok(None);
}
let mut hosts = Vec::<String>::new();
let mut paths = BTreeMap::<String, Vec<HTTPIngressPath>>::new();
for route in &application.routes {
let host = endpoints.get(route.endpoint.name())?.host.clone();
let host = endpoints
.get(route.endpoint.name())
.ok_or_else(|| {
AppError::InvalidComposition(format!(
"unknown public endpoint '{}'",
route.endpoint.name()
))
})?
.host
.clone();
if !paths.contains_key(&host) {
hosts.push(host.clone());
}
@@ -842,7 +851,7 @@ fn ingress(
.into_iter()
.collect();
let managed_tls = !tls_hosts.is_empty();
Some(Ingress {
Ok(Some(Ingress {
metadata: ObjectMeta {
name: Some(application.name.clone()),
annotations: managed_tls.then(|| {
@@ -874,7 +883,7 @@ fn ingress(
..Default::default()
}),
..Default::default()
})
}))
}
/// Default window where startup probe failures do not restart the container.

View File

@@ -24,6 +24,7 @@ pub struct BackendAuth {
zitadel_pat: String,
openbao_url: String,
openbao_token: String,
openbao_jwt_mount: String,
}
struct RoleRecord {
@@ -61,9 +62,15 @@ impl BackendAuth {
zitadel_pat,
openbao_url: openbao_url.trim_end_matches('/').into(),
openbao_token,
openbao_jwt_mount: "jwt".into(),
})
}
pub fn with_openbao_jwt_mount(mut self, mount: impl Into<String>) -> Self {
self.openbao_jwt_mount = mount.into();
self
}
pub fn zitadel_url(&self) -> &str {
&self.zitadel_url
}
@@ -527,6 +534,7 @@ impl BackendAuth {
zitadel_pat: zitadel_pat.unwrap_or_else(|| self.zitadel_pat.clone()),
openbao_url: self.openbao_url.clone(),
openbao_token: openbao_token.unwrap_or_else(|| self.openbao_token.clone()),
openbao_jwt_mount: self.openbao_jwt_mount.clone(),
}
}
@@ -591,8 +599,9 @@ impl BackendAuth {
async fn roles(&self) -> Result<Vec<RoleRecord>, AuthError> {
let mut roles = Vec::new();
for name in self.role_names("jwt").await? {
if let Some(raw) = self.json(&format!("auth/jwt/role/{name}")).await? {
let mount = &self.openbao_jwt_mount;
for name in self.role_names(mount).await? {
if let Some(raw) = self.json(&format!("auth/{mount}/role/{name}")).await? {
let subject_id = role_subject(&raw);
if !subject_id.is_empty() {
roles.push(RoleRecord {
@@ -739,7 +748,7 @@ impl BackendAuth {
raw.remove("policies");
self.openbao(
Method::POST,
&format!("auth/jwt/role/{}", role.role.name),
&format!("auth/{}/role/{}", self.openbao_jwt_mount, role.role.name),
Some(Value::Object(raw)),
)
.await?

View File

@@ -380,7 +380,8 @@ async fn run(cli: &Cli) -> Result<Output, AuthError> {
config.openbao_url.clone(),
config.openbao_token.clone(),
)
.map_err(AuthError::Backend)?;
.map_err(AuthError::Backend)?
.with_openbao_jwt_mount(cli.openbao_jwt_mount.clone());
match &cli.command {
Command::Context { .. } => unreachable!("context configuration returned before dispatch"),

View File

@@ -501,31 +501,39 @@ pub async fn init_client(client: Arc<ConfigClient>) {
*manager = Some(client);
}
pub async fn get<T: Config>() -> Result<T, ConfigError> {
/// Global client: explicit [`init_client`], else lazy `HARMONY_SECRET_NAMESPACE`
/// (or `HARMONY_CONFIG_NAMESPACE`) via [`ConfigClient::for_namespace`].
async fn global_client() -> Result<Arc<ConfigClient>, ConfigError> {
{
let manager = CONFIG_CLIENT.lock().await;
manager
.as_ref()
.ok_or(ConfigError::NoSources)?
.get::<T>()
.await
if let Some(client) = manager.as_ref() {
return Ok(client.clone());
}
}
let namespace = std::env::var("HARMONY_SECRET_NAMESPACE")
.or_else(|_| std::env::var("HARMONY_CONFIG_NAMESPACE"))
.map_err(|_| {
ConfigError::EnvError(
"HARMONY_SECRET_NAMESPACE or HARMONY_CONFIG_NAMESPACE must be set \
(project namespace for config/secrets)"
.into(),
)
})?;
let client = Arc::new(ConfigClient::for_namespace(&namespace).await);
init_client(client.clone()).await;
Ok(client)
}
pub async fn get<T: Config>() -> Result<T, ConfigError> {
global_client().await?.get::<T>().await
}
pub async fn get_or_prompt<T: Config>() -> Result<T, ConfigError> {
let manager = CONFIG_CLIENT.lock().await;
manager
.as_ref()
.ok_or(ConfigError::NoSources)?
.get_or_prompt::<T>()
.await
global_client().await?.get_or_prompt::<T>().await
}
pub async fn set<T: Config>(config: &T) -> Result<(), ConfigError> {
let manager = CONFIG_CLIENT.lock().await;
manager
.as_ref()
.ok_or(ConfigError::NoSources)?
.set::<T>(config)
.await
global_client().await?.set::<T>(config).await
}
pub fn default_config_dir() -> Option<PathBuf> {

View File

@@ -1,6 +1,6 @@
use crate::{ConfigClass, ConfigError, ConfigSource};
use async_trait::async_trait;
use log::{debug, info};
use log::debug;
pub struct EnvSource;

View File

@@ -3,46 +3,22 @@ mod deployment_grants;
mod openbao_policy;
pub mod store;
use crate::config::SECRET_NAMESPACE;
use async_trait::async_trait;
use config::HARMONY_SSO_CLIENT_ID;
use config::HARMONY_SSO_URL;
use config::INFISICAL_CLIENT_ID;
use config::INFISICAL_CLIENT_SECRET;
use config::INFISICAL_ENVIRONMENT;
use config::INFISICAL_PROJECT_ID;
use config::INFISICAL_URL;
use config::OPENBAO_AUTH_MOUNT;
use config::OPENBAO_KV_MOUNT;
use config::OPENBAO_PASSWORD;
use config::OPENBAO_SKIP_TLS;
use config::OPENBAO_TOKEN;
use config::OPENBAO_URL;
use config::OPENBAO_USERNAME;
use config::SECRET_STORE;
use interactive_parse::InteractiveParseObj;
use log::debug;
use log::info;
use schemars::JsonSchema;
use serde::{Serialize, de::DeserializeOwned};
use std::fmt;
use store::InfisicalSecretStore;
use store::LocalFileSecretStore;
pub use store::{OpenbaoSecretStore, OpenbaoStoreOptions, ZitadelJwtBearerConfig};
use thiserror::Error;
use tokio::sync::OnceCell;
pub use deployment_grants::OpenBaoDeploymentSecretGrants;
pub use harmony_secret_derive::Secret;
pub use openbao_policy::{HARMONY_STATE_SUBPATH, OpenBaoPolicyManager, render_tenant_policy};
// The Secret trait remains the same.
// pub trait Secret: Serialize + DeserializeOwned + Sized {
pub trait Secret: Serialize + DeserializeOwned + JsonSchema + InteractiveParseObj + Sized {
const KEY: &'static str;
}
// The error enum remains the same.
#[derive(Debug, Error)]
pub enum SecretStoreError {
#[error("Secret not found for key '{key}' in namespace '{namespace}'")]
@@ -61,7 +37,6 @@ pub enum SecretStoreError {
Store(#[from] Box<dyn std::error::Error + Send + Sync>),
}
// The trait is now async!
#[async_trait]
pub trait SecretStore: fmt::Debug + Send + Sync {
async fn get_raw(&self, namespace: &str, key: &str) -> Result<Vec<u8>, SecretStoreError>;
@@ -72,173 +47,3 @@ pub trait SecretStore: fmt::Debug + Send + Sync {
value: &[u8],
) -> Result<(), SecretStoreError>;
}
// Use OnceCell for async-friendly, one-time initialization.
static SECRET_MANAGER: OnceCell<SecretManager> = OnceCell::const_new();
/// Initializes and returns a reference to the global SecretManager.
async fn get_secret_manager() -> &'static SecretManager {
SECRET_MANAGER.get_or_init(init_secret_manager).await
}
/// The async initialization function for the SecretManager.
async fn init_secret_manager() -> SecretManager {
let default_secret_store = "infisical".to_string();
let store_type = SECRET_STORE.as_ref().unwrap_or(&default_secret_store);
let store: Box<dyn SecretStore> = match store_type.as_str() {
"file" => Box::new(LocalFileSecretStore),
"openbao" | "vault" => {
let store = OpenbaoSecretStore::new(OpenbaoStoreOptions {
base_url: OPENBAO_URL.clone().expect("Openbao/Vault URL must be set, see harmony_secret config for ways to provide it. You can try with OPENBAO_URL or VAULT_ADDR"),
kv_mount: OPENBAO_KV_MOUNT.clone(),
auth_mount: OPENBAO_AUTH_MOUNT.clone(),
skip_tls: *OPENBAO_SKIP_TLS,
token: OPENBAO_TOKEN.clone(),
username: OPENBAO_USERNAME.clone(),
password: OPENBAO_PASSWORD.clone(),
zitadel_sso_url: HARMONY_SSO_URL.clone(),
zitadel_client_id: HARMONY_SSO_CLIENT_ID.clone(),
jwt_role: None,
jwt_auth_mount: None,
zitadel_jwt_bearer: None,
})
.await
.expect("Failed to initialize Openbao/Vault secret store");
Box::new(store)
}
"infisical" => {
let store = InfisicalSecretStore::new(
INFISICAL_URL.clone().expect("Infisical url must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_URL"),
INFISICAL_PROJECT_ID.clone().expect("Infisical project id must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_PROJECT_ID"),
INFISICAL_ENVIRONMENT.clone().expect("Infisical environment must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_ENVIRONMENT"),
INFISICAL_CLIENT_ID.clone().expect("Infisical client id must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_CLIENT_ID"),
INFISICAL_CLIENT_SECRET.clone().expect("Infisical client secret must be set, see harmony_secret config for ways to provide it. You can try with HARMONY_SECRET_INFISICAL_CLIENT_SECRET"),
)
.await
.expect("Failed to initialize Infisical secret store");
Box::new(store)
}
other => panic!(
"HARMONY_SECRET_STORE='{other}' is not a recognized store type. \
Valid values: 'file', 'openbao' (or 'vault'), 'infisical'."
),
};
SecretManager::new(SECRET_NAMESPACE.clone(), store)
}
/// Manages the lifecycle of secrets, providing a simple static API.
#[deprecated(
note = "Use harmony_config::ConfigClient instead; it unifies config + secrets over the same stores."
)]
#[derive(Debug)]
pub struct SecretManager {
namespace: String,
store: Box<dyn SecretStore>,
}
impl SecretManager {
fn new(namespace: String, store: Box<dyn SecretStore>) -> Self {
Self { namespace, store }
}
/// Retrieves and deserializes a secret.
pub async fn get<T: Secret>() -> Result<T, SecretStoreError> {
let manager = get_secret_manager().await;
debug!("Getting secret ns {} key {}", &manager.namespace, T::KEY);
let raw_value = manager.store.get_raw(&manager.namespace, T::KEY).await?;
serde_json::from_slice(&raw_value).map_err(|e| SecretStoreError::Deserialization {
key: T::KEY.to_string(),
source: e,
})
}
pub async fn get_or_prompt<T: Secret>() -> Result<T, SecretStoreError> {
let secret = Self::get::<T>().await;
let manager = get_secret_manager().await;
let prompted = secret.is_err();
let secret = secret.or_else(|e| -> Result<T, SecretStoreError> {
debug!("Could not get secret : {e}");
let ns = &manager.namespace;
let key = T::KEY;
debug!("Prompting interactively for secret {ns} {key}");
info!("Secret not found for {} {}, fill the fields :", ns, key);
T::parse_to_obj().map_err(|e| {
SecretStoreError::Store(
format!("Failed to interactively parse secret {ns} {key}: {e}").into(),
)
})
})?;
if prompted {
Self::set(&secret).await?;
}
Ok(secret)
}
/// Serializes and stores a secret.
pub async fn set<T: Secret>(secret: &T) -> Result<(), SecretStoreError> {
let manager = get_secret_manager().await;
let raw_value =
serde_json::to_vec(secret).map_err(|e| SecretStoreError::Serialization {
key: T::KEY.to_string(),
source: e,
})?;
manager
.store
.set_raw(&manager.namespace, T::KEY, &raw_value)
.await
}
}
// The integration test below requires a live OpenBao instance and is
// deliberately gated behind a custom cfg flag so it doesn't run in the
// regular `cargo test` flow. Enable with
// `RUSTFLAGS="--cfg secrete2etest" cargo test`.
#[cfg(test)]
#[cfg(secrete2etest)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
struct TestUserMeta {
labels: Vec<String>,
}
#[derive(Secret, Serialize, Deserialize, JsonSchema, Debug, PartialEq)]
struct TestSecret {
user: String,
password: String,
metadata: TestUserMeta,
}
#[tokio::test]
async fn set_and_retrieve_secret() {
let secret = TestSecret {
user: String::from("user"),
password: String::from("password"),
metadata: TestUserMeta {
labels: vec![
String::from("label1"),
String::from("label2"),
String::from(
"some longet label with \" special @#%$)(udiojcia[]]] \"'asdij'' characters Nдs はにほへとちり าฟันพัฒนา yağız şoföre ç <20> <20> <20> <20> <20> <20> <20> <20> <20> <20> <20> <20> <20> 👩‍👩‍👧‍👦 /span> 👩‍👧‍👦 and why not emojis ",
),
],
},
};
SecretManager::set(&secret).await.unwrap();
let value = SecretManager::get::<TestSecret>().await.unwrap();
assert_eq!(value, secret);
}
}