app-platform: derive launch URLs from manifests
This commit is contained in:
parent
182f18ecf3
commit
755ba5562d
@ -8,7 +8,7 @@ use crate::manifest::AppManifest;
|
|||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use hyper::{Body, Request, Uri};
|
use hyper::{Body, Request, Uri};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use tokio::net::UnixStream;
|
use tokio::net::UnixStream;
|
||||||
|
|
||||||
@ -109,31 +109,20 @@ impl PodmanClient {
|
|||||||
|
|
||||||
/// Map container name to its UI launch URL
|
/// Map container name to its UI launch URL
|
||||||
pub fn lan_address_for(name: &str) -> Option<String> {
|
pub fn lan_address_for(name: &str) -> Option<String> {
|
||||||
|
if let Some(url) = manifest_lan_address_for(name) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
|
||||||
let url = match name {
|
let url = match name {
|
||||||
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
|
"bitcoin-knots" | "bitcoin-ui" => "http://localhost:8334",
|
||||||
"lnd" | "archy-lnd-ui" => "http://localhost:18083",
|
"lnd" | "archy-lnd-ui" => "http://localhost:18083",
|
||||||
"homeassistant" => "http://localhost:8123",
|
|
||||||
"archy-mempool-web" | "mempool" => "http://localhost:4080",
|
"archy-mempool-web" | "mempool" => "http://localhost:4080",
|
||||||
"btcpay-server" => "http://localhost:23000",
|
|
||||||
"grafana" => "http://localhost:3000",
|
|
||||||
"searxng" => "http://localhost:8888",
|
|
||||||
"ollama" => "http://localhost:11434",
|
"ollama" => "http://localhost:11434",
|
||||||
"cryptpad" => "http://localhost:3003",
|
"cryptpad" => "http://localhost:3003",
|
||||||
"penpot" => "http://localhost:9001",
|
"penpot" => "http://localhost:9001",
|
||||||
"nextcloud" => "http://localhost:8085",
|
|
||||||
"vaultwarden" => "http://localhost:8082",
|
|
||||||
"gitea" => "http://localhost:3001",
|
|
||||||
"jellyfin" => "http://localhost:8096",
|
|
||||||
"photoprism" => "http://localhost:2342",
|
|
||||||
"immich_server" | "immich" => "http://localhost:2283",
|
"immich_server" | "immich" => "http://localhost:2283",
|
||||||
"filebrowser" => "http://localhost:8083",
|
|
||||||
"nginx-proxy-manager" => "http://localhost:8081",
|
"nginx-proxy-manager" => "http://localhost:8081",
|
||||||
"portainer" => "http://localhost:9000",
|
|
||||||
"uptime-kuma" => "http://localhost:3002",
|
|
||||||
"fedimint" | "fedimintd" => "http://localhost:8175",
|
|
||||||
"fedimint-gateway" => "http://localhost:8176",
|
"fedimint-gateway" => "http://localhost:8176",
|
||||||
"nostr-rs-relay" => "http://localhost:18081",
|
|
||||||
"indeedhub" => "http://localhost:7778",
|
|
||||||
"dwn" => "http://localhost:3100",
|
"dwn" => "http://localhost:3100",
|
||||||
"endurain" => "http://localhost:8080",
|
"endurain" => "http://localhost:8080",
|
||||||
"netbird" => "http://localhost:8087",
|
"netbird" => "http://localhost:8087",
|
||||||
@ -662,6 +651,112 @@ fn parse_port_bindings(bindings: &serde_json::Value) -> Vec<String> {
|
|||||||
ports
|
ports
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn manifest_lan_address_for(container_name: &str) -> Option<String> {
|
||||||
|
for apps_dir in manifest_apps_dirs() {
|
||||||
|
let Ok(entries) = std::fs::read_dir(apps_dir) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path().join("manifest.yml");
|
||||||
|
let Ok(contents) = std::fs::read_to_string(&path) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(manifest) = AppManifest::parse(&contents) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if manifest_runtime_names(&manifest)
|
||||||
|
.iter()
|
||||||
|
.any(|name| name == container_name)
|
||||||
|
{
|
||||||
|
if let Some(url) = manifest_primary_interface_url(&manifest) {
|
||||||
|
return Some(url);
|
||||||
|
}
|
||||||
|
if manifest_has_http_health(&manifest) {
|
||||||
|
if let Some(port) = manifest
|
||||||
|
.app
|
||||||
|
.ports
|
||||||
|
.iter()
|
||||||
|
.find(|port| port.protocol.eq_ignore_ascii_case("tcp"))
|
||||||
|
.map(|port| port.host)
|
||||||
|
{
|
||||||
|
return Some(format!("http://localhost:{port}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
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}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_has_http_health(manifest: &AppManifest) -> bool {
|
||||||
|
manifest
|
||||||
|
.app
|
||||||
|
.health_check
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|health| health.check_type.eq_ignore_ascii_case("http"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_runtime_names(manifest: &AppManifest) -> Vec<String> {
|
||||||
|
let mut names = vec![manifest_container_name(manifest)];
|
||||||
|
match manifest.app.id.as_str() {
|
||||||
|
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => names.push(manifest.app.id.clone()),
|
||||||
|
"fedimint" => names.push("fedimintd".to_string()),
|
||||||
|
"immich" => names.push("immich_server".to_string()),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_container_name(manifest: &AppManifest) -> String {
|
||||||
|
if let Some(v) = manifest.app.extensions.get("container_name") {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
if !s.is_empty() {
|
||||||
|
return s.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match manifest.app.id.as_str() {
|
||||||
|
"bitcoin-ui" | "electrs-ui" | "lnd-ui" => format!("archy-{}", manifest.app.id),
|
||||||
|
id => id.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn manifest_apps_dirs() -> Vec<PathBuf> {
|
||||||
|
let mut dirs = Vec::new();
|
||||||
|
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||||
|
dirs.push(Path::new(&manifest_dir).join("../../apps"));
|
||||||
|
}
|
||||||
|
dirs.extend([
|
||||||
|
Path::new("apps").to_path_buf(),
|
||||||
|
Path::new("/opt/archipelago/apps").to_path_buf(),
|
||||||
|
Path::new("/opt/archipelago/web-ui/archipelago-runtime/apps").to_path_buf(),
|
||||||
|
]);
|
||||||
|
dirs
|
||||||
|
}
|
||||||
|
|
||||||
fn parse_memory_limit(limit: &str) -> Option<i64> {
|
fn parse_memory_limit(limit: &str) -> Option<i64> {
|
||||||
// Supports the Kubernetes-style suffixes used throughout apps/*/manifest.yml
|
// Supports the Kubernetes-style suffixes used throughout apps/*/manifest.yml
|
||||||
// (IEC binary: Ki/Mi/Gi/Ti) as well as the shorter docker-style k/m/g/t.
|
// (IEC binary: Ki/Mi/Gi/Ti) as well as the shorter docker-style k/m/g/t.
|
||||||
@ -757,6 +852,30 @@ mod tests {
|
|||||||
assert_eq!(podman_network_settings(None, "isolated"), ("bridge", None));
|
assert_eq!(podman_network_settings(None, "isolated"), ("bridge", None));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lan_address_uses_manifest_http_port_for_regular_apps() {
|
||||||
|
assert_eq!(
|
||||||
|
PodmanClient::lan_address_for("filebrowser").as_deref(),
|
||||||
|
Some("http://localhost:8083")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lan_address_prefers_manifest_main_interface() {
|
||||||
|
assert_eq!(
|
||||||
|
PodmanClient::lan_address_for("fedimint").as_deref(),
|
||||||
|
Some("http://localhost:8175/")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn lan_address_does_not_expose_tcp_only_service_ports() {
|
||||||
|
assert_eq!(
|
||||||
|
PodmanClient::lan_address_for("bitcoin-knots").as_deref(),
|
||||||
|
Some("http://localhost:8334")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_memory_limit_iec_binary_suffixes() {
|
fn parse_memory_limit_iec_binary_suffixes() {
|
||||||
// Kubernetes-style — this is what apps/*/manifest.yml uses.
|
// Kubernetes-style — this is what apps/*/manifest.yml uses.
|
||||||
|
|||||||
@ -38,6 +38,7 @@ As of the current `1.8-alpha` workstream:
|
|||||||
- Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`.
|
- Derived host facts and secret-file-backed environment variables are represented with `container.derived_env` and `container.secret_env`.
|
||||||
- Catalog metadata generation is implemented by `scripts/generate-app-catalog.py`.
|
- Catalog metadata generation is implemented by `scripts/generate-app-catalog.py`.
|
||||||
- App-session launch ports/titles and new-tab launch behavior now have a generated TypeScript metadata path from manifests, with manual overrides preserved for companion UIs and aliases that do not have manifest-owned metadata yet.
|
- App-session launch ports/titles and new-tab launch behavior now have a generated TypeScript metadata path from manifests, with manual overrides preserved for companion UIs and aliases that do not have manifest-owned metadata yet.
|
||||||
|
- Runtime package listings now derive LAN launch URLs from manifest-owned `interfaces.main` declarations or HTTP app ports before falling back to legacy compatibility aliases.
|
||||||
- Release drift checking is implemented by `scripts/check-app-catalog-drift.py --release --strict`.
|
- Release drift checking is implemented by `scripts/check-app-catalog-drift.py --release --strict`.
|
||||||
- The canonical catalog and the UI public catalog are expected to remain byte-for-byte synced after generation.
|
- The canonical catalog and the UI public catalog are expected to remain byte-for-byte synced after generation.
|
||||||
- Runtime validation has already moved many simple and moderate apps into the manifest/orchestrator path, including Filebrowser, Vaultwarden, Portainer, Uptime Kuma, Grafana, Gitea, Nextcloud, SearXNG, Nostr Relay, PhotoPrism, Jellyfin, Meshtastic, and several Bitcoin-adjacent apps.
|
- Runtime validation has already moved many simple and moderate apps into the manifest/orchestrator path, including Filebrowser, Vaultwarden, Portainer, Uptime Kuma, Grafana, Gitea, Nextcloud, SearXNG, Nostr Relay, PhotoPrism, Jellyfin, Meshtastic, and several Bitcoin-adjacent apps.
|
||||||
|
|||||||
@ -122,11 +122,35 @@ app:
|
|||||||
| `app.health_check` | HTTP or TCP health check settings |
|
| `app.health_check` | HTTP or TCP health check settings |
|
||||||
| `app.devices` | Explicit device paths |
|
| `app.devices` | Explicit device paths |
|
||||||
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and launch hints |
|
| `app.metadata` | Catalog-facing presentation metadata such as icon, category, tier, repo/source, author, feature bullets, and launch hints |
|
||||||
|
| `app.interfaces.main` | Optional primary UI launch surface with `port`, `protocol`, and `path` |
|
||||||
|
|
||||||
Additional extension keys may exist for current integrations, for example Bitcoin, Lightning, or app-specific launch/interface metadata. Treat extension keys as transitional unless they are documented as reusable platform primitives.
|
Additional extension keys may exist for current integrations, for example Bitcoin, Lightning, or app-specific launch/interface metadata. Treat extension keys as transitional unless they are documented as reusable platform primitives.
|
||||||
|
|
||||||
Use `metadata.launch.open_in_new_tab: true` when the app UI is known to reject iframe embedding with headers such as `X-Frame-Options` or restrictive CSP. The frontend app-session metadata is generated from this flag during release work.
|
Use `metadata.launch.open_in_new_tab: true` when the app UI is known to reject iframe embedding with headers such as `X-Frame-Options` or restrictive CSP. The frontend app-session metadata is generated from this flag during release work.
|
||||||
|
|
||||||
|
### Launch Interfaces
|
||||||
|
|
||||||
|
If an app exposes a user-facing web UI, declare its primary launch surface in
|
||||||
|
`interfaces.main`. Runtime package listings prefer this interface over inferred
|
||||||
|
port mappings, which matters for apps that expose non-UI service ports or use a
|
||||||
|
companion wait/proxy UI.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
interfaces:
|
||||||
|
main:
|
||||||
|
name: Web UI
|
||||||
|
description: Primary app interface
|
||||||
|
type: ui
|
||||||
|
port: 8180
|
||||||
|
protocol: http
|
||||||
|
path: /
|
||||||
|
```
|
||||||
|
|
||||||
|
For simple HTTP apps without `interfaces.main`, Archipelago can still infer the
|
||||||
|
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
|
||||||
|
launch URLs.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user