Initial commit
This commit is contained in:
24
core/container/Cargo.toml
Normal file
24
core/container/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "archipelago-container"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
indexmap = { version = "2.0", features = ["serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
|
||||
[lib]
|
||||
name = "archipelago_container"
|
||||
path = "src/lib.rs"
|
||||
255
core/container/src/dependency_resolver.rs
Normal file
255
core/container/src/dependency_resolver.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
use crate::manifest::{AppManifest, Dependency};
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DependencyError {
|
||||
#[error("Circular dependency detected: {0}")]
|
||||
CircularDependency(String),
|
||||
#[error("Missing dependency: {0}")]
|
||||
MissingDependency(String),
|
||||
#[error("Version conflict: {0}")]
|
||||
VersionConflict(String),
|
||||
}
|
||||
|
||||
pub struct DependencyResolver {
|
||||
manifests: IndexMap<String, AppManifest>,
|
||||
}
|
||||
|
||||
impl DependencyResolver {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
manifests: IndexMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_manifest(&mut self, manifest: AppManifest) {
|
||||
self.manifests.insert(manifest.app.id.clone(), manifest);
|
||||
}
|
||||
|
||||
pub fn resolve_dependencies(&self, app_id: &str) -> Result<Vec<String>, DependencyError> {
|
||||
let mut visited = HashSet::new();
|
||||
let mut visiting = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
self.resolve_recursive(app_id, &mut visited, &mut visiting, &mut result)?;
|
||||
|
||||
// Reverse to get installation order (dependencies first)
|
||||
result.reverse();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn resolve_recursive(
|
||||
&self,
|
||||
app_id: &str,
|
||||
visited: &mut HashSet<String>,
|
||||
visiting: &mut HashSet<String>,
|
||||
result: &mut Vec<String>,
|
||||
) -> Result<(), DependencyError> {
|
||||
if visited.contains(app_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if visiting.contains(app_id) {
|
||||
return Err(DependencyError::CircularDependency(
|
||||
format!("Circular dependency detected involving: {}", app_id)
|
||||
));
|
||||
}
|
||||
|
||||
visiting.insert(app_id.to_string());
|
||||
|
||||
let manifest = self.manifests.get(app_id)
|
||||
.ok_or_else(|| DependencyError::MissingDependency(
|
||||
format!("App not found: {}", app_id)
|
||||
))?;
|
||||
|
||||
// Resolve all dependencies first
|
||||
for dep in &manifest.app.dependencies {
|
||||
match dep {
|
||||
Dependency::App { app_id: dep_id, version: _ } => {
|
||||
self.resolve_recursive(dep_id, visited, visiting, result)?;
|
||||
}
|
||||
Dependency::Storage { storage: _ } => {
|
||||
// Storage dependencies are checked but don't require other apps
|
||||
}
|
||||
Dependency::Simple(dep_id) => {
|
||||
self.resolve_recursive(dep_id, visited, visiting, result)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visiting.remove(app_id);
|
||||
visited.insert(app_id.to_string());
|
||||
|
||||
if !result.contains(&app_id.to_string()) {
|
||||
result.push(app_id.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_conflicts(&self, app_id: &str) -> Result<(), DependencyError> {
|
||||
let manifest = self.manifests.get(app_id)
|
||||
.ok_or_else(|| DependencyError::MissingDependency(
|
||||
format!("App not found: {}", app_id)
|
||||
))?;
|
||||
|
||||
// Check for port conflicts
|
||||
let mut port_usage: HashMap<u16, String> = HashMap::new();
|
||||
|
||||
for (id, m) in &self.manifests {
|
||||
if id == app_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
for port in &m.app.ports {
|
||||
if let Some(existing) = port_usage.get(&port.host) {
|
||||
return Err(DependencyError::VersionConflict(
|
||||
format!("Port {} already used by {}", port.host, existing)
|
||||
));
|
||||
}
|
||||
port_usage.insert(port.host, id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new app's ports
|
||||
for port in &manifest.app.ports {
|
||||
if let Some(existing) = port_usage.get(&port.host) {
|
||||
return Err(DependencyError::VersionConflict(
|
||||
format!("Port {} already used by {}", port.host, existing)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn calculate_resources(&self, app_ids: &[String]) -> ResourceRequirements {
|
||||
let mut total = ResourceRequirements {
|
||||
cpu: 0,
|
||||
memory_mb: 0,
|
||||
disk_gb: 0,
|
||||
};
|
||||
|
||||
for app_id in app_ids {
|
||||
if let Some(manifest) = self.manifests.get(app_id) {
|
||||
if let Some(cpu) = manifest.app.resources.cpu_limit {
|
||||
total.cpu += cpu;
|
||||
}
|
||||
|
||||
if let Some(memory) = &manifest.app.resources.memory_limit {
|
||||
// Parse memory string (e.g., "1Gi", "512Mi")
|
||||
if let Ok(mb) = parse_memory(memory) {
|
||||
total.memory_mb += mb;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(disk) = &manifest.app.resources.disk_limit {
|
||||
// Parse disk string (e.g., "10Gi", "500Mi")
|
||||
if let Ok(gb) = parse_disk(disk) {
|
||||
total.disk_gb += gb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_memory(s: &str) -> Result<u32, ()> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with("gi") {
|
||||
let num: f64 = s.trim_end_matches("gi").parse().map_err(|_| ())?;
|
||||
Ok((num * 1024.0) as u32)
|
||||
} else if s.ends_with("mi") {
|
||||
let num: f64 = s.trim_end_matches("mi").parse().map_err(|_| ())?;
|
||||
Ok(num as u32)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_disk(s: &str) -> Result<u32, ()> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with("gi") {
|
||||
let num: f64 = s.trim_end_matches("gi").parse().map_err(|_| ())?;
|
||||
Ok(num as u32)
|
||||
} else if s.ends_with("ti") {
|
||||
let num: f64 = s.trim_end_matches("ti").parse().map_err(|_| ())?;
|
||||
Ok((num * 1024.0) as u32)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceRequirements {
|
||||
pub cpu: u32,
|
||||
pub memory_mb: u32,
|
||||
pub disk_gb: u32,
|
||||
}
|
||||
|
||||
impl Default for DependencyResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::{AppManifest, AppDefinition, ContainerConfig};
|
||||
|
||||
fn create_test_manifest(id: &str, deps: Vec<Dependency>) -> AppManifest {
|
||||
AppManifest {
|
||||
app: AppDefinition {
|
||||
id: id.to_string(),
|
||||
name: format!("Test {}", id),
|
||||
version: "1.0.0".to_string(),
|
||||
description: None,
|
||||
container: ContainerConfig {
|
||||
image: format!("test/{}:latest", id),
|
||||
image_signature: None,
|
||||
pull_policy: "if-not-present".to_string(),
|
||||
},
|
||||
dependencies: deps,
|
||||
resources: Default::default(),
|
||||
security: Default::default(),
|
||||
ports: vec![],
|
||||
volumes: vec![],
|
||||
environment: vec![],
|
||||
health_check: None,
|
||||
devices: vec![],
|
||||
extensions: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_dependency() {
|
||||
let mut resolver = DependencyResolver::new();
|
||||
resolver.add_manifest(create_test_manifest("app1", vec![]));
|
||||
resolver.add_manifest(create_test_manifest("app2", vec![
|
||||
Dependency::Simple("app1".to_string())
|
||||
]));
|
||||
|
||||
let deps = resolver.resolve_dependencies("app2").unwrap();
|
||||
assert_eq!(deps, vec!["app1", "app2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_dependency() {
|
||||
let mut resolver = DependencyResolver::new();
|
||||
resolver.add_manifest(create_test_manifest("app1", vec![
|
||||
Dependency::Simple("app2".to_string())
|
||||
]));
|
||||
resolver.add_manifest(create_test_manifest("app2", vec![
|
||||
Dependency::Simple("app1".to_string())
|
||||
]));
|
||||
|
||||
let result = resolver.resolve_dependencies("app1");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
189
core/container/src/health_monitor.rs
Normal file
189
core/container/src/health_monitor.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use crate::manifest::HealthCheck;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::time::{interval, sleep};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum HealthStatus {
|
||||
Healthy,
|
||||
Unhealthy,
|
||||
Unknown,
|
||||
Starting,
|
||||
}
|
||||
|
||||
pub struct HealthMonitor {
|
||||
container_name: String,
|
||||
health_check: Option<HealthCheck>,
|
||||
}
|
||||
|
||||
impl HealthMonitor {
|
||||
pub fn new(container_name: String, health_check: Option<HealthCheck>) -> Self {
|
||||
Self {
|
||||
container_name,
|
||||
health_check,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_health(&self) -> Result<HealthStatus> {
|
||||
if let Some(ref check) = self.health_check {
|
||||
match check.check_type.as_str() {
|
||||
"http" => self.check_http_health(check).await,
|
||||
"exec" => self.check_exec_health(check).await,
|
||||
_ => {
|
||||
warn!("Unknown health check type: {}", check.check_type);
|
||||
Ok(HealthStatus::Unknown)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No health check defined, assume healthy if container is running
|
||||
Ok(HealthStatus::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_http_health(&self, check: &HealthCheck) -> Result<HealthStatus> {
|
||||
let endpoint = check.endpoint.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("HTTP health check missing endpoint"))?;
|
||||
|
||||
let url = if let Some(path) = &check.path {
|
||||
format!("{}{}", endpoint, path)
|
||||
} else {
|
||||
endpoint.clone()
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
Ok(HealthStatus::Healthy)
|
||||
} else {
|
||||
Ok(HealthStatus::Unhealthy)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Health check failed for {}: {}", self.container_name, e);
|
||||
Ok(HealthStatus::Unhealthy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_exec_health(&self, check: &HealthCheck) -> Result<HealthStatus> {
|
||||
// Execute health check command in container
|
||||
let endpoint = check.endpoint.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Exec health check missing endpoint"))?;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
let output = Command::new("podman")
|
||||
.arg("exec")
|
||||
.arg(&self.container_name)
|
||||
.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(endpoint)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute health check")?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(HealthStatus::Healthy)
|
||||
} else {
|
||||
Ok(HealthStatus::Unhealthy)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn monitor_health(
|
||||
&self,
|
||||
mut shutdown: tokio::sync::broadcast::Receiver<()>,
|
||||
on_status_change: impl Fn(HealthStatus) + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let check = self.health_check.clone();
|
||||
let interval_duration = if let Some(ref check) = check {
|
||||
parse_duration(&check.interval).unwrap_or(Duration::from_secs(30))
|
||||
} else {
|
||||
Duration::from_secs(30)
|
||||
};
|
||||
|
||||
let mut interval = interval(interval_duration);
|
||||
let mut consecutive_failures = 0;
|
||||
let max_failures = check.as_ref()
|
||||
.map(|c| c.retries)
|
||||
.unwrap_or(3);
|
||||
|
||||
let mut last_status = HealthStatus::Unknown;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
match self.check_health().await {
|
||||
Ok(status) => {
|
||||
if status != last_status {
|
||||
info!("Health status changed for {}: {:?} -> {:?}",
|
||||
self.container_name, last_status, status);
|
||||
on_status_change(status.clone());
|
||||
last_status = status.clone();
|
||||
}
|
||||
|
||||
match status {
|
||||
HealthStatus::Healthy => {
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
HealthStatus::Unhealthy => {
|
||||
consecutive_failures += 1;
|
||||
if consecutive_failures >= max_failures {
|
||||
error!("Container {} is unhealthy after {} failures",
|
||||
self.container_name, consecutive_failures);
|
||||
// TODO: Trigger auto-restart or alert
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Health check error for {}: {}", self.container_name, e);
|
||||
consecutive_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown.recv() => {
|
||||
info!("Health monitoring stopped for {}", self.container_name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_duration(s: &str) -> Option<Duration> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with('s') {
|
||||
let secs: u64 = s.trim_end_matches('s').parse().ok()?;
|
||||
Some(Duration::from_secs(secs))
|
||||
} else if s.ends_with('m') {
|
||||
let mins: u64 = s.trim_end_matches('m').parse().ok()?;
|
||||
Some(Duration::from_secs(mins * 60))
|
||||
} else if s.ends_with('h') {
|
||||
let hours: u64 = s.trim_end_matches('h').parse().ok()?;
|
||||
Some(Duration::from_secs(hours * 3600))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration() {
|
||||
assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
|
||||
assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
|
||||
assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600)));
|
||||
}
|
||||
}
|
||||
9
core/container/src/lib.rs
Normal file
9
core/container/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod manifest;
|
||||
pub mod podman_client;
|
||||
pub mod dependency_resolver;
|
||||
pub mod health_monitor;
|
||||
|
||||
pub use manifest::{AppManifest, Dependency, ResourceLimits, SecurityPolicy, HealthCheck};
|
||||
pub use podman_client::PodmanClient;
|
||||
pub use dependency_resolver::DependencyResolver;
|
||||
pub use health_monitor::HealthMonitor;
|
||||
228
core/container/src/manifest.rs
Normal file
228
core/container/src/manifest.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("Invalid manifest: {0}")]
|
||||
Invalid(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("YAML parse error: {0}")]
|
||||
Yaml(#[from] serde_yaml::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppManifest {
|
||||
pub app: AppDefinition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppDefinition {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub container: ContainerConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<Dependency>,
|
||||
|
||||
#[serde(default)]
|
||||
pub resources: ResourceLimits,
|
||||
|
||||
#[serde(default)]
|
||||
pub security: SecurityPolicy,
|
||||
|
||||
#[serde(default)]
|
||||
pub ports: Vec<PortMapping>,
|
||||
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<Volume>,
|
||||
|
||||
#[serde(default)]
|
||||
pub environment: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub health_check: Option<HealthCheck>,
|
||||
|
||||
#[serde(default)]
|
||||
pub devices: Vec<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extensions: HashMap<String, serde_yaml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ContainerConfig {
|
||||
pub image: String,
|
||||
#[serde(default)]
|
||||
pub image_signature: Option<String>,
|
||||
#[serde(default = "default_pull_policy")]
|
||||
pub pull_policy: String,
|
||||
}
|
||||
|
||||
fn default_pull_policy() -> String {
|
||||
"if-not-present".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Dependency {
|
||||
Storage { storage: String },
|
||||
App { app_id: String, version: Option<String> },
|
||||
Simple(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ResourceLimits {
|
||||
#[serde(default)]
|
||||
pub cpu_limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub memory_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
pub disk_limit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SecurityPolicy {
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub readonly_root: bool,
|
||||
#[serde(default = "default_network_policy")]
|
||||
pub network_policy: String,
|
||||
#[serde(default)]
|
||||
pub apparmor_profile: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_network_policy() -> String {
|
||||
"isolated".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PortMapping {
|
||||
pub host: u16,
|
||||
pub container: u16,
|
||||
#[serde(default)]
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
impl From<(u16, u16)> for PortMapping {
|
||||
fn from((host, container): (u16, u16)) -> Self {
|
||||
PortMapping {
|
||||
host,
|
||||
container,
|
||||
protocol: "tcp".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Volume {
|
||||
#[serde(rename = "type")]
|
||||
pub volume_type: String,
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
#[serde(default)]
|
||||
pub options: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthCheck {
|
||||
#[serde(rename = "type")]
|
||||
pub check_type: String,
|
||||
pub endpoint: Option<String>,
|
||||
pub path: Option<String>,
|
||||
#[serde(default = "default_interval")]
|
||||
pub interval: String,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout: String,
|
||||
#[serde(default = "default_retries")]
|
||||
pub retries: u32,
|
||||
}
|
||||
|
||||
fn default_interval() -> String {
|
||||
"30s".to_string()
|
||||
}
|
||||
|
||||
fn default_timeout() -> String {
|
||||
"5s".to_string()
|
||||
}
|
||||
|
||||
fn default_retries() -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
impl AppManifest {
|
||||
pub fn from_file(path: &std::path::Path) -> Result<Self, ManifestError> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
Self::from_str(&content)
|
||||
}
|
||||
|
||||
pub fn from_str(content: &str) -> Result<Self, ManifestError> {
|
||||
let manifest: AppManifest = serde_yaml::from_str(content)?;
|
||||
manifest.validate()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ManifestError> {
|
||||
if self.app.id.is_empty() {
|
||||
return Err(ManifestError::Invalid("app.id cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
if self.app.container.image.is_empty() {
|
||||
return Err(ManifestError::Invalid("container.image cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
// Validate version format (semantic versioning)
|
||||
if !self.app.version.chars().any(|c| c.is_ascii_digit()) {
|
||||
return Err(ManifestError::Invalid("app.version must contain at least one digit".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_manifest_parse() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: test-app
|
||||
name: Test App
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
"#;
|
||||
|
||||
let manifest = AppManifest::from_str(yaml).unwrap();
|
||||
assert_eq!(manifest.app.id, "test-app");
|
||||
assert_eq!(manifest.app.name, "Test App");
|
||||
assert_eq!(manifest.app.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_validation() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: ""
|
||||
name: Test
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
"#;
|
||||
|
||||
let result = AppManifest::from_str(yaml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
334
core/container/src/podman_client.rs
Normal file
334
core/container/src/podman_client.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
use crate::manifest::{AppManifest, PortMapping, Volume};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::process::{Command, Stdio};
|
||||
use thiserror::Error;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PodmanError {
|
||||
#[error("Podman command failed: {0}")]
|
||||
CommandFailed(String),
|
||||
#[error("Container not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContainerStatus {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub state: ContainerState,
|
||||
pub image: String,
|
||||
pub created: String,
|
||||
pub ports: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ContainerState {
|
||||
Created,
|
||||
Running,
|
||||
Stopped,
|
||||
Exited,
|
||||
Paused,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<&str> for ContainerState {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"created" => ContainerState::Created,
|
||||
"running" => ContainerState::Running,
|
||||
"stopped" => ContainerState::Stopped,
|
||||
"exited" => ContainerState::Exited,
|
||||
"paused" => ContainerState::Paused,
|
||||
other => ContainerState::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PodmanClient {
|
||||
user: String,
|
||||
rootless: bool,
|
||||
}
|
||||
|
||||
impl PodmanClient {
|
||||
pub fn new(user: String) -> Self {
|
||||
Self {
|
||||
user,
|
||||
rootless: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn podman_command(&self) -> Command {
|
||||
let mut cmd = Command::new("podman");
|
||||
if self.rootless {
|
||||
// Run as the specified user
|
||||
cmd.env("HOME", format!("/home/{}", self.user));
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
fn podman_async(&self) -> TokioCommand {
|
||||
let mut cmd = TokioCommand::new("podman");
|
||||
if self.rootless {
|
||||
cmd.env("HOME", format!("/home/{}", self.user));
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
pub async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("pull").arg(image);
|
||||
|
||||
if let Some(sig) = signature {
|
||||
// Verify signature with cosign if provided
|
||||
cmd.arg("--signature-policy").arg("default");
|
||||
// TODO: Implement cosign verification
|
||||
log::warn!("Signature verification not yet implemented: {}", sig);
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute podman pull")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_container(
|
||||
&self,
|
||||
manifest: &AppManifest,
|
||||
name: &str,
|
||||
) -> Result<String> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("create");
|
||||
|
||||
// Container name
|
||||
cmd.arg("--name").arg(name);
|
||||
|
||||
// Read-only root filesystem
|
||||
if manifest.app.security.readonly_root {
|
||||
cmd.arg("--read-only");
|
||||
}
|
||||
|
||||
// Network policy
|
||||
match manifest.app.security.network_policy.as_str() {
|
||||
"host" => {
|
||||
cmd.arg("--network").arg("host");
|
||||
}
|
||||
"isolated" => {
|
||||
// Create isolated network (default)
|
||||
}
|
||||
_ => {
|
||||
cmd.arg("--network").arg(&manifest.app.security.network_policy);
|
||||
}
|
||||
}
|
||||
|
||||
// Port mappings
|
||||
for port in &manifest.app.ports {
|
||||
cmd.arg("-p").arg(format!("{}:{}", port.host, port.container));
|
||||
}
|
||||
|
||||
// Volumes
|
||||
for volume in &manifest.app.volumes {
|
||||
let mut mount = format!("{}:{}", volume.source, volume.target);
|
||||
if !volume.options.is_empty() {
|
||||
mount.push_str(&format!(":{}", volume.options.join(",")));
|
||||
}
|
||||
cmd.arg("-v").arg(mount);
|
||||
}
|
||||
|
||||
// Devices
|
||||
for device in &manifest.app.devices {
|
||||
cmd.arg("--device").arg(device);
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
for env in &manifest.app.environment {
|
||||
cmd.arg("-e").arg(env);
|
||||
}
|
||||
|
||||
// Resource limits
|
||||
if let Some(cpu) = manifest.app.resources.cpu_limit {
|
||||
cmd.arg("--cpus").arg(cpu.to_string());
|
||||
}
|
||||
|
||||
if let Some(memory) = &manifest.app.resources.memory_limit {
|
||||
cmd.arg("--memory").arg(memory);
|
||||
}
|
||||
|
||||
// Capabilities (drop all, add specified)
|
||||
cmd.arg("--cap-drop").arg("ALL");
|
||||
for cap in &manifest.app.security.capabilities {
|
||||
cmd.arg("--cap-add").arg(cap);
|
||||
}
|
||||
|
||||
// Image
|
||||
cmd.arg(&manifest.app.container.image);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to create container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
|
||||
}
|
||||
|
||||
let container_id = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(container_id)
|
||||
}
|
||||
|
||||
pub async fn start_container(&self, name: &str) -> Result<()> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("start").arg(name);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_container(&self, name: &str) -> Result<()> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("stop").arg(name);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to stop container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_container(&self, name: &str) -> Result<()> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("rm").arg("-f").arg(name);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to remove container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to remove container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("inspect")
|
||||
.arg("--format")
|
||||
.arg("{{.Id}}|{{.Name}}|{{.State.Status}}|{{.Config.Image}}|{{.Created}}|{{.NetworkSettings.Ports}}")
|
||||
.arg(name);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to inspect container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow::anyhow!("Container not found: {}", name));
|
||||
}
|
||||
|
||||
let info = String::from_utf8_lossy(&output.stdout);
|
||||
let parts: Vec<&str> = info.trim().split('|').collect();
|
||||
|
||||
if parts.len() < 5 {
|
||||
return Err(anyhow::anyhow!("Invalid container inspect output"));
|
||||
}
|
||||
|
||||
Ok(ContainerStatus {
|
||||
id: parts[0].to_string(),
|
||||
name: parts[1].to_string(),
|
||||
state: ContainerState::from(parts[2]),
|
||||
image: parts[3].to_string(),
|
||||
created: parts[4].to_string(),
|
||||
ports: vec![], // TODO: Parse ports from parts[5]
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("logs")
|
||||
.arg("--tail")
|
||||
.arg(lines.to_string())
|
||||
.arg(name);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to get logs: {}", stderr));
|
||||
}
|
||||
|
||||
let logs = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(logs.lines().map(|s| s.to_string()).collect())
|
||||
}
|
||||
|
||||
pub async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("ps")
|
||||
.arg("-a")
|
||||
.arg("--format")
|
||||
.arg("json");
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to list containers: {}", stderr));
|
||||
}
|
||||
|
||||
let json = String::from_utf8_lossy(&output.stdout);
|
||||
let containers: Vec<serde_json::Value> = serde_json::from_str(&json)
|
||||
.context("Failed to parse container list")?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for container in containers {
|
||||
result.push(ContainerStatus {
|
||||
id: container["Id"].as_str().unwrap_or("").to_string(),
|
||||
name: container["Names"][0].as_str().unwrap_or("").to_string(),
|
||||
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
|
||||
image: container["Image"].as_str().unwrap_or("").to_string(),
|
||||
created: container["Created"].as_str().unwrap_or("").to_string(),
|
||||
ports: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
18
core/parmanode/Cargo.toml
Normal file
18
core/parmanode/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "archipelago-parmanode"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
archipelago-container = { path = "../container" }
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
|
||||
[lib]
|
||||
name = "archipelago_parmanode"
|
||||
path = "src/lib.rs"
|
||||
101
core/parmanode/src/converter.rs
Normal file
101
core/parmanode/src/converter.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
// Parmanode to App Manifest converter
|
||||
// Converts Parmanode module structure to Archipelago app manifest format
|
||||
|
||||
use archipelago_container::AppManifest;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tracing::info;
|
||||
|
||||
pub struct ParmanodeConverter;
|
||||
|
||||
impl ParmanodeConverter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Convert a Parmanode module directory to an App Manifest
|
||||
pub async fn convert_to_manifest(&self, module_path: &PathBuf) -> Result<AppManifest> {
|
||||
info!("Converting Parmanode module to manifest: {:?}", module_path);
|
||||
|
||||
// Read Parmanode module metadata if available
|
||||
let module_name = module_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// Try to detect what the module installs
|
||||
let install_script = module_path.join("install.sh");
|
||||
let script_content = if install_script.exists() {
|
||||
fs::read_to_string(&install_script).await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Infer app details from script content
|
||||
let (app_id, image) = self.infer_from_script(&script_content)?;
|
||||
|
||||
// Create a basic manifest
|
||||
let manifest_yaml = format!(
|
||||
r#"
|
||||
app:
|
||||
id: {}
|
||||
name: {}
|
||||
version: 1.0.0
|
||||
description: Converted from Parmanode module
|
||||
|
||||
container:
|
||||
image: {}
|
||||
pull_policy: if-not-present
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
network_policy: isolated
|
||||
"#,
|
||||
app_id, module_name, image
|
||||
);
|
||||
|
||||
AppManifest::from_str(&manifest_yaml)
|
||||
.context("Failed to create manifest from Parmanode module")
|
||||
}
|
||||
|
||||
fn infer_from_script(&self, script_content: &Option<String>) -> Result<(String, String)> {
|
||||
let content = script_content.as_deref().unwrap_or("");
|
||||
|
||||
// Try to detect Bitcoin Core
|
||||
if content.contains("bitcoind") || content.contains("bitcoin-core") {
|
||||
return Ok(("bitcoin-core".to_string(), "bitcoin/bitcoin:latest".to_string()));
|
||||
}
|
||||
|
||||
// Try to detect LND
|
||||
if content.contains("lnd") && !content.contains("lightning") {
|
||||
return Ok(("lnd".to_string(), "lightninglabs/lnd:latest".to_string()));
|
||||
}
|
||||
|
||||
// Try to detect Core Lightning
|
||||
if content.contains("clightning") || content.contains("core-lightning") {
|
||||
return Ok(("core-lightning".to_string(), "elementsproject/lightningd:latest".to_string()));
|
||||
}
|
||||
|
||||
// Try to detect Electrs
|
||||
if content.contains("electrs") {
|
||||
return Ok(("electrs".to_string(), "romanz/electrs:latest".to_string()));
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
Ok(("parmanode-module".to_string(), "alpine:latest".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ParmanodeConverter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
5
core/parmanode/src/lib.rs
Normal file
5
core/parmanode/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod script_runner;
|
||||
pub mod converter;
|
||||
|
||||
pub use script_runner::ParmanodeScriptRunner;
|
||||
pub use converter::ParmanodeConverter;
|
||||
128
core/parmanode/src/script_runner.rs
Normal file
128
core/parmanode/src/script_runner.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
// Parmanode script runner - executes Parmanode installation scripts in containers
|
||||
// Provides compatibility layer for existing Parmanode modules
|
||||
|
||||
use archipelago_container::{PodmanClient, AppManifest};
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct ParmanodeScriptRunner {
|
||||
podman: PodmanClient,
|
||||
scripts_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ParmanodeScriptRunner {
|
||||
pub fn new(scripts_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
podman: PodmanClient::new("archipelago".to_string()),
|
||||
scripts_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect if a path contains a Parmanode script
|
||||
pub fn is_parmanode_script(&self, path: &PathBuf) -> bool {
|
||||
// Check for common Parmanode script patterns
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| {
|
||||
name.ends_with(".sh") && (
|
||||
name.contains("parmanode") ||
|
||||
name.contains("bitcoin") ||
|
||||
name.contains("lightning") ||
|
||||
name.contains("electrs")
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Run a Parmanode script in an isolated container
|
||||
pub async fn run_script(&self, script_path: &PathBuf) -> Result<()> {
|
||||
info!("Running Parmanode script: {:?}", script_path);
|
||||
|
||||
// Create a temporary container manifest for the script
|
||||
let script_name = script_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("parmanode-script");
|
||||
|
||||
// Create a minimal container to run the script
|
||||
let container_name = format!("parmanode-{}", script_name);
|
||||
|
||||
// Copy script to a location accessible by containers
|
||||
let script_content = fs::read_to_string(script_path).await
|
||||
.context("Failed to read Parmanode script")?;
|
||||
|
||||
// Create a wrapper script that runs in Alpine
|
||||
let wrapper_script = format!(
|
||||
r#"#!/bin/sh
|
||||
set -e
|
||||
{}
|
||||
"#,
|
||||
script_content
|
||||
);
|
||||
|
||||
// Write wrapper to temp location
|
||||
let temp_script = format!("/tmp/parmanode-{}.sh", script_name);
|
||||
fs::write(&temp_script, wrapper_script).await
|
||||
.context("Failed to write wrapper script")?;
|
||||
|
||||
// Make executable
|
||||
Command::new("chmod")
|
||||
.arg("+x")
|
||||
.arg(&temp_script)
|
||||
.output()
|
||||
.context("Failed to make script executable")?;
|
||||
|
||||
// Run script in a temporary Alpine container
|
||||
let output = Command::new("podman")
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("--volume")
|
||||
.arg(format!("{}:/script.sh:ro", temp_script))
|
||||
.arg("alpine:latest")
|
||||
.arg("sh")
|
||||
.arg("/script.sh")
|
||||
.output()
|
||||
.context("Failed to execute Parmanode script in container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Parmanode script failed: {}", stderr));
|
||||
}
|
||||
|
||||
info!("Parmanode script completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install a Parmanode module (runs script and sets up container)
|
||||
pub async fn install_module(&self, module_path: &PathBuf) -> Result<String> {
|
||||
// Find the main installation script
|
||||
let install_script = module_path.join("install.sh");
|
||||
if !install_script.exists() {
|
||||
return Err(anyhow::anyhow!("No install.sh found in Parmanode module"));
|
||||
}
|
||||
|
||||
// Run the installation script
|
||||
self.run_script(&install_script).await?;
|
||||
|
||||
// Try to convert to app manifest for future management
|
||||
let converter = crate::converter::ParmanodeConverter::new();
|
||||
match converter.convert_to_manifest(module_path).await {
|
||||
Ok(manifest) => {
|
||||
info!("Converted Parmanode module to app manifest");
|
||||
// TODO: Save manifest for future use
|
||||
Ok(manifest.app.id)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to convert Parmanode module: {}", e);
|
||||
// Return a generic ID
|
||||
Ok(format!("parmanode-{}",
|
||||
module_path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("module")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
core/performance/Cargo.toml
Normal file
16
core/performance/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "archipelago-performance"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
|
||||
[lib]
|
||||
name = "archipelago_performance"
|
||||
path = "src/lib.rs"
|
||||
3
core/performance/src/lib.rs
Normal file
3
core/performance/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod resource_manager;
|
||||
|
||||
pub use resource_manager::ResourceManager;
|
||||
89
core/performance/src/resource_manager.rs
Normal file
89
core/performance/src/resource_manager.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
// Resource management and optimization for containers
|
||||
// Handles CPU, memory, and disk I/O limits and optimization
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceLimits {
|
||||
pub cpu_cores: f64,
|
||||
pub memory_mb: u32,
|
||||
pub disk_io_read_mbps: Option<u32>,
|
||||
pub disk_io_write_mbps: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemResources {
|
||||
pub total_cpu_cores: u32,
|
||||
pub total_memory_mb: u32,
|
||||
pub available_disk_gb: u32,
|
||||
}
|
||||
|
||||
pub struct ResourceManager {
|
||||
system_resources: SystemResources,
|
||||
allocated_resources: HashMap<String, ResourceLimits>,
|
||||
}
|
||||
|
||||
impl ResourceManager {
|
||||
pub fn new(system_resources: SystemResources) -> Self {
|
||||
Self {
|
||||
system_resources,
|
||||
allocated_resources: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if resources are available for a new container
|
||||
pub fn can_allocate(&self, requested: &ResourceLimits) -> Result<bool> {
|
||||
let mut used_cpu = 0.0;
|
||||
let mut used_memory = 0;
|
||||
|
||||
for limits in self.allocated_resources.values() {
|
||||
used_cpu += limits.cpu_cores;
|
||||
used_memory += limits.memory_mb;
|
||||
}
|
||||
|
||||
let available_cpu = self.system_resources.total_cpu_cores as f64 - used_cpu;
|
||||
let available_memory = self.system_resources.total_memory_mb - used_memory;
|
||||
|
||||
if requested.cpu_cores > available_cpu {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if requested.memory_mb > available_memory {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Allocate resources for a container
|
||||
pub fn allocate(&mut self, container_id: String, limits: ResourceLimits) -> Result<()> {
|
||||
if !self.can_allocate(&limits)? {
|
||||
return Err(anyhow::anyhow!("Insufficient resources"));
|
||||
}
|
||||
|
||||
self.allocated_resources.insert(container_id, limits);
|
||||
info!("Allocated resources for container");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release resources for a container
|
||||
pub fn release(&mut self, container_id: &str) {
|
||||
self.allocated_resources.remove(container_id);
|
||||
info!("Released resources for container: {}", container_id);
|
||||
}
|
||||
|
||||
/// Get current resource usage
|
||||
pub fn get_usage(&self) -> (f64, u32) {
|
||||
let cpu: f64 = self.allocated_resources.values().map(|r| r.cpu_cores).sum();
|
||||
let memory: u32 = self.allocated_resources.values().map(|r| r.memory_mb).sum();
|
||||
(cpu, memory)
|
||||
}
|
||||
|
||||
/// Optimize resource allocation (reduce limits for low-priority containers)
|
||||
pub fn optimize_allocation(&mut self) {
|
||||
// TODO: Implement dynamic resource adjustment based on usage
|
||||
info!("Optimizing resource allocation");
|
||||
}
|
||||
}
|
||||
18
core/security/Cargo.toml
Normal file
18
core/security/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "archipelago-security"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[lib]
|
||||
name = "archipelago_security"
|
||||
path = "src/lib.rs"
|
||||
74
core/security/src/container_policies.rs
Normal file
74
core/security/src/container_policies.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
// AppArmor/SELinux policy generator for containers
|
||||
// Creates security profiles for each containerized app
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
pub struct ContainerPolicyGenerator {
|
||||
policies_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ContainerPolicyGenerator {
|
||||
pub fn new(policies_dir: PathBuf) -> Self {
|
||||
Self { policies_dir }
|
||||
}
|
||||
|
||||
/// Generate AppArmor profile for a container
|
||||
pub async fn generate_apparmor_profile(
|
||||
&self,
|
||||
app_id: &str,
|
||||
capabilities: &[String],
|
||||
readonly: bool,
|
||||
) -> Result<PathBuf> {
|
||||
let profile_path = self.policies_dir.join(format!("{}.apparmor", app_id));
|
||||
|
||||
let mut profile = String::from("# AppArmor profile for Archipelago container\n");
|
||||
profile.push_str(&format!("profile archipelago-{} flags=(attach_disconnected,mediate_deleted) {{\n", app_id));
|
||||
|
||||
// Base includes
|
||||
profile.push_str(" #include <abstractions/base>\n");
|
||||
|
||||
// Capabilities
|
||||
if capabilities.is_empty() {
|
||||
profile.push_str(" capability,\n");
|
||||
} else {
|
||||
for cap in capabilities {
|
||||
profile.push_str(&format!(" capability {},\n", cap));
|
||||
}
|
||||
}
|
||||
|
||||
// Filesystem access
|
||||
if readonly {
|
||||
profile.push_str(" deny / rw,\n");
|
||||
profile.push_str(&format!(" /var/lib/archipelago/{} rw,\n", app_id));
|
||||
} else {
|
||||
profile.push_str(" / r,\n");
|
||||
profile.push_str(&format!(" /var/lib/archipelago/{} rw,\n", app_id));
|
||||
}
|
||||
|
||||
// Network
|
||||
profile.push_str(" network,\n");
|
||||
|
||||
profile.push_str("}\n");
|
||||
|
||||
fs::write(&profile_path, profile).await?;
|
||||
Ok(profile_path)
|
||||
}
|
||||
|
||||
/// Apply AppArmor profile to a container
|
||||
pub async fn apply_profile(&self, container_name: &str, profile_path: &PathBuf) -> Result<()> {
|
||||
// Load the profile
|
||||
tokio::process::Command::new("apparmor_parser")
|
||||
.arg("-r")
|
||||
.arg(profile_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
// TODO: Configure Podman to use the profile
|
||||
// This requires Podman configuration changes
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
90
core/security/src/image_verifier.rs
Normal file
90
core/security/src/image_verifier.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
// Container image signature verification using Cosign
|
||||
// Verifies that container images are signed and trusted
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::process::Command;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct ImageVerifier {
|
||||
cosign_public_key: Option<String>, // Public key for verification
|
||||
}
|
||||
|
||||
impl ImageVerifier {
|
||||
pub fn new(cosign_public_key: Option<String>) -> Self {
|
||||
Self { cosign_public_key }
|
||||
}
|
||||
|
||||
/// Verify a container image signature
|
||||
pub async fn verify_image(&self, image: &str, signature: Option<&str>) -> Result<bool> {
|
||||
if signature.is_none() && self.cosign_public_key.is_none() {
|
||||
warn!("No signature provided for image: {}", image);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check if cosign is available
|
||||
let cosign_available = Command::new("cosign")
|
||||
.arg("version")
|
||||
.output()
|
||||
.is_ok();
|
||||
|
||||
if !cosign_available {
|
||||
warn!("Cosign not available, skipping signature verification");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// If public key is provided, use it for verification
|
||||
if let Some(ref public_key) = self.cosign_public_key {
|
||||
let output = Command::new("cosign")
|
||||
.arg("verify")
|
||||
.arg("--key")
|
||||
.arg(public_key)
|
||||
.arg(image)
|
||||
.output()
|
||||
.context("Failed to run cosign verify")?;
|
||||
|
||||
if output.status.success() {
|
||||
info!("Image signature verified: {}", image);
|
||||
return Ok(true);
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Signature verification failed: {}", stderr));
|
||||
}
|
||||
}
|
||||
|
||||
// If signature URL is provided, verify using that
|
||||
if let Some(sig_url) = signature {
|
||||
if sig_url.starts_with("cosign://") {
|
||||
// Extract signature reference
|
||||
let sig_ref = sig_url.strip_prefix("cosign://").unwrap();
|
||||
let output = Command::new("cosign")
|
||||
.arg("verify")
|
||||
.arg("--signature")
|
||||
.arg(sig_ref)
|
||||
.arg(image)
|
||||
.output()
|
||||
.context("Failed to run cosign verify")?;
|
||||
|
||||
if output.status.success() {
|
||||
info!("Image signature verified: {}", image);
|
||||
return Ok(true);
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Signature verification failed: {}", stderr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check if an image has a signature
|
||||
pub async fn has_signature(&self, image: &str) -> bool {
|
||||
// Try to find signature in registry
|
||||
let output = Command::new("cosign")
|
||||
.arg("triangulate")
|
||||
.arg(image)
|
||||
.output();
|
||||
|
||||
output.is_ok() && output.unwrap().status.success()
|
||||
}
|
||||
}
|
||||
7
core/security/src/lib.rs
Normal file
7
core/security/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod container_policies;
|
||||
pub mod secrets_manager;
|
||||
pub mod image_verifier;
|
||||
|
||||
pub use container_policies::ContainerPolicyGenerator;
|
||||
pub use secrets_manager::SecretsManager;
|
||||
pub use image_verifier::ImageVerifier;
|
||||
98
core/security/src/secrets_manager.rs
Normal file
98
core/security/src/secrets_manager.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
// Encrypted secrets management for containers
|
||||
// Stores secrets securely and injects them at runtime
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SecretsManager {
|
||||
secrets_dir: PathBuf,
|
||||
encryption_key: Vec<u8>, // In production, derive from user password
|
||||
}
|
||||
|
||||
impl SecretsManager {
|
||||
pub fn new(secrets_dir: PathBuf, encryption_key: Vec<u8>) -> Self {
|
||||
Self {
|
||||
secrets_dir,
|
||||
encryption_key,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a secret for an app
|
||||
pub async fn store_secret(
|
||||
&self,
|
||||
app_id: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<String> {
|
||||
let secret_id = Uuid::new_v4().to_string();
|
||||
let secret_path = self.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
|
||||
fs::create_dir_all(secret_path.parent().unwrap()).await?;
|
||||
|
||||
// TODO: Encrypt the secret value
|
||||
// For now, store as plaintext (MUST be encrypted in production)
|
||||
fs::write(&secret_path, value).await
|
||||
.context("Failed to write secret")?;
|
||||
|
||||
// Set restrictive permissions
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&secret_path).await?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(&secret_path, perms).await?;
|
||||
}
|
||||
|
||||
Ok(secret_id)
|
||||
}
|
||||
|
||||
/// Retrieve a secret (returns the secret ID path for volume mounting)
|
||||
pub fn get_secret_path(&self, app_id: &str, secret_id: &str) -> PathBuf {
|
||||
self.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id))
|
||||
}
|
||||
|
||||
/// List secrets for an app
|
||||
pub async fn list_secrets(&self, app_id: &str) -> Result<Vec<String>> {
|
||||
let app_secrets_dir = self.secrets_dir.join(app_id);
|
||||
|
||||
if !app_secrets_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut secrets = Vec::new();
|
||||
let mut entries = fs::read_dir(&app_secrets_dir).await?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("secret") {
|
||||
if let Some(secret_id) = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string()) {
|
||||
secrets.push(secret_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
/// Delete a secret
|
||||
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
|
||||
let secret_path = self.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
|
||||
if secret_path.exists() {
|
||||
fs::remove_file(&secret_path).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user