app-platform: type manifest launch interfaces

This commit is contained in:
archipelago
2026-06-11 00:52:16 -04:00
parent 755ba5562d
commit 881478a873
12 changed files with 253 additions and 26 deletions
+3 -3
View File
@@ -8,9 +8,9 @@ pub mod runtime;
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
pub use health_monitor::HealthMonitor;
pub use manifest::{
AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile, HealthCheck,
HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider,
SecurityPolicy, Volume,
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile,
HealthCheck, HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv,
SecretsProvider, SecurityPolicy, Volume,
};
pub use podman_client::{
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
+177
View File
@@ -54,6 +54,9 @@ pub struct AppDefinition {
#[serde(default)]
pub devices: Vec<String>,
#[serde(default)]
pub interfaces: HashMap<String, AppInterface>,
#[serde(flatten)]
pub extensions: HashMap<String, serde_yaml::Value>,
}
@@ -290,6 +293,33 @@ pub struct HealthCheck {
pub retries: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct AppInterface {
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(rename = "type", default = "default_interface_type")]
pub interface_type: String,
pub port: u16,
#[serde(default = "default_http_protocol")]
pub protocol: String,
#[serde(default = "default_root_path")]
pub path: String,
}
fn default_interface_type() -> String {
"ui".to_string()
}
fn default_http_protocol() -> String {
"http".to_string()
}
fn default_root_path() -> String {
"/".to_string()
}
fn default_interval() -> String {
"30s".to_string()
}
@@ -475,6 +505,7 @@ impl AppManifest {
validate_security(&self.app.security)?;
validate_ports(&self.app.ports)?;
validate_interfaces(&self.app.interfaces)?;
validate_environment(&self.app.environment)?;
validate_devices(&self.app.devices)?;
@@ -638,6 +669,66 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
Ok(())
}
fn validate_interfaces(interfaces: &HashMap<String, AppInterface>) -> Result<(), ManifestError> {
let allowed_types = ["ui", "api", "metrics"];
let allowed_protocols = ["http", "https"];
for (key, interface) in interfaces {
if !is_valid_interface_key(key) {
return Err(ManifestError::Invalid(format!(
"interfaces key '{key}' must be lowercase ASCII letters, digits, hyphens, or underscores"
)));
}
if interface.port == 0 {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.port must be non-zero"
)));
}
if !allowed_types.contains(&interface.interface_type.as_str()) {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.type must be one of {}",
allowed_types.join(", ")
)));
}
if !allowed_protocols.contains(&interface.protocol.as_str()) {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.protocol must be one of {}",
allowed_protocols.join(", ")
)));
}
if !interface.path.starts_with('/') || interface.path.chars().any(char::is_control) {
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.path must start with '/' and contain no control characters"
)));
}
if interface
.name
.as_ref()
.is_some_and(|name| name.trim().is_empty())
{
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.name cannot be empty when set"
)));
}
if interface
.description
.as_ref()
.is_some_and(|description| description.trim().is_empty())
{
return Err(ManifestError::Invalid(format!(
"interfaces.{key}.description cannot be empty when set"
)));
}
}
Ok(())
}
fn is_valid_interface_key(key: &str) -> bool {
!key.is_empty()
&& key
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
fn validate_environment(env: &[String]) -> Result<(), ManifestError> {
let mut seen = HashSet::new();
for (i, entry) in env.iter().enumerate() {
@@ -921,6 +1012,92 @@ app:
assert_eq!(manifest.app.version, "1.0.0");
}
#[test]
fn typed_interfaces_parse_with_defaults() {
let yaml = r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
main:
port: 8080
"#;
let manifest = AppManifest::parse(yaml).unwrap();
let main = manifest.app.interfaces.get("main").unwrap();
assert_eq!(main.interface_type, "ui");
assert_eq!(main.port, 8080);
assert_eq!(main.protocol, "http");
assert_eq!(main.path, "/");
}
#[test]
fn invalid_interfaces_are_rejected() {
let cases = [
(
"bad key",
r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
Bad Key:
port: 8080
"#,
"interfaces key",
),
(
"bad protocol",
r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
main:
port: 8080
protocol: ftp
"#,
"interfaces.main.protocol",
),
(
"bad path",
r#"
app:
id: test-app
name: Test App
version: 1.0.0
container:
image: test/image:1.0.0
interfaces:
main:
port: 8080
path: dashboard
"#,
"interfaces.main.path",
),
];
for (name, yaml, expected) in cases {
let err = AppManifest::parse(yaml).unwrap_err();
let ManifestError::Invalid(msg) = err else {
panic!("{name}: expected invalid manifest, got {err:?}");
};
assert!(
msg.contains(expected),
"{name}: expected error containing {expected:?}, got {msg:?}"
);
}
}
#[test]
fn test_manifest_validation() {
let yaml = r#"
+8 -20
View File
@@ -689,26 +689,14 @@ fn manifest_lan_address_for(container_name: &str) -> Option<String> {
}
fn manifest_primary_interface_url(manifest: &AppManifest) -> Option<String> {
let interfaces = manifest.app.extensions.get("interfaces")?.as_mapping()?;
let main = interfaces
.get(&serde_yaml::Value::String("main".to_string()))?
.as_mapping()?;
let port = main
.get(&serde_yaml::Value::String("port".to_string()))?
.as_i64()
.and_then(|port| u16::try_from(port).ok())
.filter(|port| *port > 0)?;
let protocol = main
.get(&serde_yaml::Value::String("protocol".to_string()))
.and_then(|v| v.as_str())
.filter(|protocol| *protocol == "https")
.unwrap_or("http");
let path = main
.get(&serde_yaml::Value::String("path".to_string()))
.and_then(|v| v.as_str())
.filter(|path| path.starts_with('/'))
.unwrap_or("/");
Some(format!("{protocol}://localhost:{port}{path}"))
let main = manifest.app.interfaces.get("main")?;
if main.interface_type != "ui" {
return None;
}
Some(format!(
"{}://localhost:{}{}",
main.protocol, main.port, main.path
))
}
fn manifest_has_http_health(manifest: &AppManifest) -> bool {