app-platform: type manifest launch interfaces
This commit is contained in:
parent
755ba5562d
commit
881478a873
@ -49,6 +49,15 @@ app:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Home Assistant dashboard
|
||||||
|
type: ui
|
||||||
|
port: 8123
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
icon: /assets/img/app-icons/homeassistant.png
|
icon: /assets/img/app-icons/homeassistant.png
|
||||||
category: home
|
category: home
|
||||||
|
|||||||
@ -45,6 +45,15 @@ app:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Jellyfin media dashboard
|
||||||
|
type: ui
|
||||||
|
port: 8096
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
icon: /assets/img/app-icons/jellyfin.webp
|
icon: /assets/img/app-icons/jellyfin.webp
|
||||||
category: data
|
category: data
|
||||||
|
|||||||
@ -41,6 +41,15 @@ app:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Nextcloud file and collaboration dashboard
|
||||||
|
type: ui
|
||||||
|
port: 8085
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
icon: /assets/img/app-icons/nextcloud.webp
|
icon: /assets/img/app-icons/nextcloud.webp
|
||||||
category: data
|
category: data
|
||||||
|
|||||||
@ -42,6 +42,15 @@ app:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: PhotoPrism photo library
|
||||||
|
type: ui
|
||||||
|
port: 2342
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
icon: /assets/img/app-icons/photoprism.svg
|
icon: /assets/img/app-icons/photoprism.svg
|
||||||
category: data
|
category: data
|
||||||
|
|||||||
@ -41,6 +41,15 @@ app:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Vaultwarden web vault
|
||||||
|
type: ui
|
||||||
|
port: 8082
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
|
||||||
metadata:
|
metadata:
|
||||||
icon: /assets/img/app-icons/vaultwarden.webp
|
icon: /assets/img/app-icons/vaultwarden.webp
|
||||||
category: data
|
category: data
|
||||||
|
|||||||
@ -8,9 +8,9 @@ pub mod runtime;
|
|||||||
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
pub use bitcoin_simulator::{BitcoinSimulationMode, BitcoinSimulator};
|
||||||
pub use health_monitor::HealthMonitor;
|
pub use health_monitor::HealthMonitor;
|
||||||
pub use manifest::{
|
pub use manifest::{
|
||||||
AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile, HealthCheck,
|
AppInterface, AppManifest, BuildConfig, ContainerConfig, Dependency, DerivedEnv, GeneratedFile,
|
||||||
HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv, SecretsProvider,
|
HealthCheck, HostFacts, ManifestError, ResolvedSource, ResourceLimits, SecretEnv,
|
||||||
SecurityPolicy, Volume,
|
SecretsProvider, SecurityPolicy, Volume,
|
||||||
};
|
};
|
||||||
pub use podman_client::{
|
pub use podman_client::{
|
||||||
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
|
image_uses_insecure_registry, ContainerState, ContainerStatus, PodmanClient,
|
||||||
|
|||||||
@ -54,6 +54,9 @@ pub struct AppDefinition {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub devices: Vec<String>,
|
pub devices: Vec<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub interfaces: HashMap<String, AppInterface>,
|
||||||
|
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub extensions: HashMap<String, serde_yaml::Value>,
|
pub extensions: HashMap<String, serde_yaml::Value>,
|
||||||
}
|
}
|
||||||
@ -290,6 +293,33 @@ pub struct HealthCheck {
|
|||||||
pub retries: u32,
|
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 {
|
fn default_interval() -> String {
|
||||||
"30s".to_string()
|
"30s".to_string()
|
||||||
}
|
}
|
||||||
@ -475,6 +505,7 @@ impl AppManifest {
|
|||||||
|
|
||||||
validate_security(&self.app.security)?;
|
validate_security(&self.app.security)?;
|
||||||
validate_ports(&self.app.ports)?;
|
validate_ports(&self.app.ports)?;
|
||||||
|
validate_interfaces(&self.app.interfaces)?;
|
||||||
validate_environment(&self.app.environment)?;
|
validate_environment(&self.app.environment)?;
|
||||||
validate_devices(&self.app.devices)?;
|
validate_devices(&self.app.devices)?;
|
||||||
|
|
||||||
@ -638,6 +669,66 @@ fn validate_ports(ports: &[PortMapping]) -> Result<(), ManifestError> {
|
|||||||
Ok(())
|
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> {
|
fn validate_environment(env: &[String]) -> Result<(), ManifestError> {
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
for (i, entry) in env.iter().enumerate() {
|
for (i, entry) in env.iter().enumerate() {
|
||||||
@ -921,6 +1012,92 @@ app:
|
|||||||
assert_eq!(manifest.app.version, "1.0.0");
|
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]
|
#[test]
|
||||||
fn test_manifest_validation() {
|
fn test_manifest_validation() {
|
||||||
let yaml = r#"
|
let yaml = r#"
|
||||||
|
|||||||
@ -689,26 +689,14 @@ fn manifest_lan_address_for(container_name: &str) -> Option<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn manifest_primary_interface_url(manifest: &AppManifest) -> Option<String> {
|
fn manifest_primary_interface_url(manifest: &AppManifest) -> Option<String> {
|
||||||
let interfaces = manifest.app.extensions.get("interfaces")?.as_mapping()?;
|
let main = manifest.app.interfaces.get("main")?;
|
||||||
let main = interfaces
|
if main.interface_type != "ui" {
|
||||||
.get(&serde_yaml::Value::String("main".to_string()))?
|
return None;
|
||||||
.as_mapping()?;
|
}
|
||||||
let port = main
|
Some(format!(
|
||||||
.get(&serde_yaml::Value::String("port".to_string()))?
|
"{}://localhost:{}{}",
|
||||||
.as_i64()
|
main.protocol, main.port, main.path
|
||||||
.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}"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn manifest_has_http_health(manifest: &AppManifest) -> bool {
|
fn manifest_has_http_health(manifest: &AppManifest) -> bool {
|
||||||
|
|||||||
@ -151,6 +151,11 @@ launch URL from the first declared TCP host port when the app has an HTTP health
|
|||||||
check. TCP-only service ports, such as Bitcoin RPC/P2P, are not treated as UI
|
check. TCP-only service ports, such as Bitcoin RPC/P2P, are not treated as UI
|
||||||
launch URLs.
|
launch URLs.
|
||||||
|
|
||||||
|
Interface keys must use lowercase ASCII letters, digits, hyphens, or
|
||||||
|
underscores. Supported interface types are `ui`, `api`, and `metrics`; only
|
||||||
|
`type: ui` is treated as a launchable app surface. Supported protocols are
|
||||||
|
`http` and `https`, and `path` must start with `/`.
|
||||||
|
|
||||||
## Security Requirements
|
## Security Requirements
|
||||||
|
|
||||||
These are enforced by the marketplace/catalog pipeline and the node. Non-compliant apps are flagged.
|
These are enforced by the marketplace/catalog pipeline and the node. Non-compliant apps are flagged.
|
||||||
|
|||||||
@ -33,7 +33,17 @@ describe('appSessionConfig', () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(resolveAppUrl('meshtastic')).toBe('http://192.168.1.228:4403')
|
expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8083')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not treat service-only tcp ports as web launch surfaces', () => {
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { hostname: '192.168.1.228' },
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resolveAppUrl('meshtastic')).toBe('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps NetBird on the unified dashboard proxy port', () => {
|
it('keeps NetBird on the unified dashboard proxy port', () => {
|
||||||
|
|||||||
@ -7,7 +7,6 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
|||||||
"botfights": 9100,
|
"botfights": 9100,
|
||||||
"btcpay-server": 23000,
|
"btcpay-server": 23000,
|
||||||
"did-wallet": 8083,
|
"did-wallet": 8083,
|
||||||
"electrumx": 50001,
|
|
||||||
"fedimint": 8175,
|
"fedimint": 8175,
|
||||||
"filebrowser": 8083,
|
"filebrowser": 8083,
|
||||||
"gitea": 3001,
|
"gitea": 3001,
|
||||||
@ -18,7 +17,6 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
|
|||||||
"lnd-ui": 18083,
|
"lnd-ui": 18083,
|
||||||
"mempool": 4080,
|
"mempool": 4080,
|
||||||
"mempool-api": 8999,
|
"mempool-api": 8999,
|
||||||
"meshtastic": 4403,
|
|
||||||
"morphos-server": 8086,
|
"morphos-server": 8086,
|
||||||
"nextcloud": 8085,
|
"nextcloud": 8085,
|
||||||
"nostr-rs-relay": 18081,
|
"nostr-rs-relay": 18081,
|
||||||
|
|||||||
@ -68,6 +68,10 @@ def manifest_launch_port(app: dict[str, Any]) -> int | None:
|
|||||||
if isinstance(port, str) and port.isdigit():
|
if isinstance(port, str) and port.isdigit():
|
||||||
return int(port)
|
return int(port)
|
||||||
|
|
||||||
|
health_check = app.get("health_check")
|
||||||
|
if not isinstance(health_check, dict) or str(health_check.get("type", "")).lower() != "http":
|
||||||
|
return None
|
||||||
|
|
||||||
ports = app.get("ports")
|
ports = app.get("ports")
|
||||||
if not isinstance(ports, list):
|
if not isinstance(ports, list):
|
||||||
return None
|
return None
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user