From 64cc3bc7fb8546ce204307ff9bb8522a97fcf6af Mon Sep 17 00:00:00 2001 From: zazawowow Date: Sat, 24 Jan 2026 22:01:51 +0000 Subject: [PATCH] Initial commit --- apps/README.md | 48 +++ apps/bitcoin-core/manifest.yml | 58 +++ apps/btcpay-server/manifest.yml | 63 ++++ apps/core-lightning/manifest.yml | 60 +++ apps/did-wallet/manifest.yml | 54 +++ apps/lnd/manifest.yml | 64 ++++ apps/meshtastic/manifest.yml | 54 +++ apps/nostr-rs-relay/manifest.yml | 52 +++ apps/router/manifest.yml | 58 +++ apps/strfry/manifest.yml | 48 +++ apps/web5-dwn/manifest.yml | 52 +++ core/container/Cargo.toml | 24 ++ core/container/src/dependency_resolver.rs | 255 +++++++++++++ core/container/src/health_monitor.rs | 189 ++++++++++ core/container/src/lib.rs | 9 + core/container/src/manifest.rs | 228 +++++++++++ core/container/src/podman_client.rs | 334 ++++++++++++++++ core/parmanode/Cargo.toml | 18 + core/parmanode/src/converter.rs | 101 +++++ core/parmanode/src/lib.rs | 5 + core/parmanode/src/script_runner.rs | 128 +++++++ core/performance/Cargo.toml | 16 + core/performance/src/lib.rs | 3 + core/performance/src/resource_manager.rs | 89 +++++ core/security/Cargo.toml | 18 + core/security/src/container_policies.rs | 74 ++++ core/security/src/image_verifier.rs | 90 +++++ core/security/src/lib.rs | 7 + core/security/src/secrets_manager.rs | 98 +++++ docs/app-manifest-spec.md | 126 +++++++ docs/architecture.md | 165 ++++++++ image-recipe/.gitignore | 2 + image-recipe/Dockerfile.alpine-base | 93 +++++ image-recipe/README.md | 23 ++ image-recipe/build-alpine.sh | 41 ++ image-recipe/build.sh | 355 ++++++++++++++++++ image-recipe/configs/logrotate.conf | 24 ++ image-recipe/prepare.sh | 24 ++ image-recipe/raspberrypi/img/etc/fstab | 2 + .../usr/lib/startos/scripts/init_resize.sh | 129 +++++++ .../raspberrypi/squashfs/boot/cmdline.txt | 1 + .../raspberrypi/squashfs/boot/config.txt | 86 +++++ .../squashfs/etc/embassy/config.yaml | 6 + .../squashfs/etc/modprobe.d/cfg80211.conf | 1 + .../squashfs/usr/bin/extract-ikconfig | 69 ++++ image-recipe/run-local-build.sh | 88 +++++ image-recipe/scripts/harden-alpine.sh | 118 ++++++ image-recipe/scripts/install-podman.sh | 59 +++ image-recipe/splash.png | Bin 0 -> 9834 bytes neode-ui/src/api/container-client.ts | 103 +++++ neode-ui/src/components/ContainerStatus.vue | 116 ++++++ neode-ui/src/stores/container.ts | 139 +++++++ neode-ui/src/views/ContainerAppDetails.vue | 260 +++++++++++++ neode-ui/src/views/ContainerApps.vue | 165 ++++++++ scripts/optimize-alpine.sh | 54 +++ scripts/parmanode-wrapper.sh | 38 ++ 56 files changed, 4584 insertions(+) create mode 100644 apps/README.md create mode 100644 apps/bitcoin-core/manifest.yml create mode 100644 apps/btcpay-server/manifest.yml create mode 100644 apps/core-lightning/manifest.yml create mode 100644 apps/did-wallet/manifest.yml create mode 100644 apps/lnd/manifest.yml create mode 100644 apps/meshtastic/manifest.yml create mode 100644 apps/nostr-rs-relay/manifest.yml create mode 100644 apps/router/manifest.yml create mode 100644 apps/strfry/manifest.yml create mode 100644 apps/web5-dwn/manifest.yml create mode 100644 core/container/Cargo.toml create mode 100644 core/container/src/dependency_resolver.rs create mode 100644 core/container/src/health_monitor.rs create mode 100644 core/container/src/lib.rs create mode 100644 core/container/src/manifest.rs create mode 100644 core/container/src/podman_client.rs create mode 100644 core/parmanode/Cargo.toml create mode 100644 core/parmanode/src/converter.rs create mode 100644 core/parmanode/src/lib.rs create mode 100644 core/parmanode/src/script_runner.rs create mode 100644 core/performance/Cargo.toml create mode 100644 core/performance/src/lib.rs create mode 100644 core/performance/src/resource_manager.rs create mode 100644 core/security/Cargo.toml create mode 100644 core/security/src/container_policies.rs create mode 100644 core/security/src/image_verifier.rs create mode 100644 core/security/src/lib.rs create mode 100644 core/security/src/secrets_manager.rs create mode 100644 docs/app-manifest-spec.md create mode 100644 docs/architecture.md create mode 100644 image-recipe/.gitignore create mode 100644 image-recipe/Dockerfile.alpine-base create mode 100644 image-recipe/README.md create mode 100755 image-recipe/build-alpine.sh create mode 100755 image-recipe/build.sh create mode 100644 image-recipe/configs/logrotate.conf create mode 100755 image-recipe/prepare.sh create mode 100644 image-recipe/raspberrypi/img/etc/fstab create mode 100755 image-recipe/raspberrypi/img/usr/lib/startos/scripts/init_resize.sh create mode 100644 image-recipe/raspberrypi/squashfs/boot/cmdline.txt create mode 100644 image-recipe/raspberrypi/squashfs/boot/config.txt create mode 100644 image-recipe/raspberrypi/squashfs/etc/embassy/config.yaml create mode 100644 image-recipe/raspberrypi/squashfs/etc/modprobe.d/cfg80211.conf create mode 100755 image-recipe/raspberrypi/squashfs/usr/bin/extract-ikconfig create mode 100755 image-recipe/run-local-build.sh create mode 100755 image-recipe/scripts/harden-alpine.sh create mode 100755 image-recipe/scripts/install-podman.sh create mode 100644 image-recipe/splash.png create mode 100644 neode-ui/src/api/container-client.ts create mode 100644 neode-ui/src/components/ContainerStatus.vue create mode 100644 neode-ui/src/stores/container.ts create mode 100644 neode-ui/src/views/ContainerAppDetails.vue create mode 100644 neode-ui/src/views/ContainerApps.vue create mode 100755 scripts/optimize-alpine.sh create mode 100755 scripts/parmanode-wrapper.sh diff --git a/apps/README.md b/apps/README.md new file mode 100644 index 00000000..3494e36c --- /dev/null +++ b/apps/README.md @@ -0,0 +1,48 @@ +# Archipelago App Manifests + +This directory contains app manifest definitions for containerized applications in the Archipelago Bitcoin Node OS. + +## App Categories + +### Bitcoin & Lightning +- `bitcoin-core/` - Bitcoin Core full node +- `lnd/` - Lightning Network Daemon +- `core-lightning/` - Core Lightning (CLN) +- `btcpay-server/` - BTCPay Server payment processor +- `mempool/` - Mempool blockchain explorer + +### Web5 & Decentralized Protocols +- `nostr-rs-relay/` - High-performance Nostr relay (Rust) +- `strfry/` - Nostr relay (C++) +- `web5-dwn/` - Decentralized Web Node +- `did-wallet/` - Web5 wallet with DID support + +### Mesh Networking & Routing +- `meshtastic/` - Meshtastic LoRa mesh networking +- `router/` - Mesh routing and local network management +- `cjdns/` - Encrypted mesh networking (cjdns) + +### Self-Hosted Services +- `homeassistant/` - Home automation +- `grafana/` - Monitoring and dashboards +- `searxng/` - Privacy-respecting search engine +- `onlyoffice/` - Office suite +- `ollama/` - Local AI models +- `penpot/` - Design tool + +### Other +- `fedimint/` - Federated e-cash mint +- `morphos-server/` - MorphOS server +- `a-b/` - A to B protocol + +## Manifest Format + +Each app has a `manifest.yml` file defining: +- Container image and version +- Resource requirements +- Dependencies +- Security policies +- Health checks +- Network configuration + +See `docs/app-manifest-spec.md` for the complete specification. diff --git a/apps/bitcoin-core/manifest.yml b/apps/bitcoin-core/manifest.yml new file mode 100644 index 00000000..4b8b0af5 --- /dev/null +++ b/apps/bitcoin-core/manifest.yml @@ -0,0 +1,58 @@ +app: + id: bitcoin-core + name: Bitcoin Core + version: 26.0.0 + description: Full Bitcoin node implementation. The reference implementation of the Bitcoin protocol. + + container: + image: bitcoin/bitcoin:26.0 + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - storage: 500Gi # Minimum disk space for mainnet + + resources: + cpu_limit: 2 + memory_limit: 2Gi + disk_limit: 500Gi + + security: + capabilities: [] # No special capabilities needed + readonly_root: true + network_policy: isolated + apparmor_profile: bitcoin-core + + ports: + - host: 8332 + container: 8332 + protocol: tcp # RPC + - host: 8333 + container: 8333 + protocol: tcp # P2P + + volumes: + - type: bind + source: /var/lib/archipelago/bitcoin + target: /home/bitcoin/.bitcoin + options: [rw] + + environment: + - NETWORK=mainnet + - RPC_USER=${BITCOIN_RPC_USER} + - RPC_PASSWORD=${BITCOIN_RPC_PASSWORD} + - PRUNE=0 # Full node (set to 550 for pruned) + + health_check: + type: http + endpoint: http://localhost:8332 + path: / + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true + testnet_support: true + pruning_support: true diff --git a/apps/btcpay-server/manifest.yml b/apps/btcpay-server/manifest.yml new file mode 100644 index 00000000..8d2ba432 --- /dev/null +++ b/apps/btcpay-server/manifest.yml @@ -0,0 +1,63 @@ +app: + id: btcpay-server + name: BTCPay Server + version: 1.12.0 + description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries. + + container: + image: btcpayserver/btcpayserver:1.12.0 + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - app_id: bitcoin-core + version: ">=26.0" + - app_id: lnd + version: ">=0.18.0" + + resources: + cpu_limit: 2 + memory_limit: 2Gi + disk_limit: 20Gi + + security: + capabilities: [NET_BIND_SERVICE] + readonly_root: true + network_policy: isolated + apparmor_profile: btcpay + + ports: + - host: 80 + container: 80 + protocol: tcp + - host: 443 + container: 443 + protocol: tcp + + volumes: + - type: bind + source: /var/lib/archipelago/btcpay + target: /datadir + options: [rw] + + environment: + - BTCPAY_NETWORK=mainnet + - BTCPAY_CHAIN=btc + - BTCPAY_BTCEXPLORERURL=http://bitcoin-core:8332 + - BTCPAY_LIGHTNING=type=lnd-rest;server=http://lnd:8080;allowinsecure=true + + health_check: + type: http + endpoint: http://localhost + path: /health + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: read-only + sync_required: true + + lightning_integration: + payment_processing: true + invoice_management: true diff --git a/apps/core-lightning/manifest.yml b/apps/core-lightning/manifest.yml new file mode 100644 index 00000000..a4154611 --- /dev/null +++ b/apps/core-lightning/manifest.yml @@ -0,0 +1,60 @@ +app: + id: core-lightning + name: Core Lightning (CLN) + version: 23.08.2 + description: Lightning Network implementation in C. Lightweight alternative to LND. + + container: + image: elementsproject/lightningd:v23.08.2 + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - app_id: bitcoin-core + version: ">=26.0" + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 5Gi + + security: + capabilities: [NET_BIND_SERVICE] + readonly_root: true + network_policy: isolated + apparmor_profile: core-lightning + + ports: + - host: 9735 + container: 9735 + protocol: tcp # P2P + - host: 9835 + container: 9835 + protocol: tcp # gRPC + + volumes: + - type: bind + source: /var/lib/archipelago/core-lightning + target: /home/clightning/.lightning + options: [rw] + + environment: + - BITCOIND_RPCURL=http://bitcoin-core:8332 + - BITCOIND_RPCUSER=${BITCOIN_RPC_USER} + - BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD} + - NETWORK=bitcoin + + health_check: + type: exec + endpoint: lightning-cli getinfo + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true + + lightning_integration: + channel_management: true + payment_routing: true diff --git a/apps/did-wallet/manifest.yml b/apps/did-wallet/manifest.yml new file mode 100644 index 00000000..ec6c5500 --- /dev/null +++ b/apps/did-wallet/manifest.yml @@ -0,0 +1,54 @@ +app: + id: did-wallet + name: Web5 DID Wallet + version: 1.0.0 + description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets. + + container: + image: tbd/web5-wallet:latest + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - app_id: web5-dwn + version: ">=1.0.0" + - storage: 2Gi + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 2Gi + + security: + capabilities: [] + readonly_root: true + network_policy: isolated + apparmor_profile: did-wallet + + ports: + - host: 8080 + container: 8080 + protocol: tcp # Web UI + + volumes: + - type: bind + source: /var/lib/archipelago/did-wallet + target: /app/wallet + options: [rw] + + environment: + - DWN_ENDPOINT=http://web5-dwn:3000 + - WALLET_STORAGE=/app/wallet + + health_check: + type: http + endpoint: http://localhost:8080 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + web5_integration: + did_support: true + wallet_functionality: true + bitcoin_integration: true diff --git a/apps/lnd/manifest.yml b/apps/lnd/manifest.yml new file mode 100644 index 00000000..402a63b3 --- /dev/null +++ b/apps/lnd/manifest.yml @@ -0,0 +1,64 @@ +app: + id: lnd + name: Lightning Network Daemon + version: 0.18.0 + description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments. + + container: + image: lightninglabs/lnd:v0.18.0 + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - app_id: bitcoin-core + version: ">=26.0" + + resources: + cpu_limit: 2 + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [NET_BIND_SERVICE] + readonly_root: true + network_policy: isolated + apparmor_profile: lnd + + ports: + - host: 9735 + container: 9735 + protocol: tcp # P2P + - host: 10009 + container: 10009 + protocol: tcp # gRPC + - host: 8080 + container: 8080 + protocol: tcp # REST + + volumes: + - type: bind + source: /var/lib/archipelago/lnd + target: /root/.lnd + options: [rw] + + environment: + - BITCOIND_HOST=bitcoin-core + - BITCOIND_RPCUSER=${BITCOIN_RPC_USER} + - BITCOIND_RPCPASS=${BITCOIN_RPC_PASSWORD} + - NETWORK=mainnet + + health_check: + type: http + endpoint: http://localhost:8080 + path: /v1/getinfo + interval: 30s + timeout: 5s + retries: 3 + + bitcoin_integration: + rpc_access: admin + sync_required: true + + lightning_integration: + channel_management: true + payment_routing: true diff --git a/apps/meshtastic/manifest.yml b/apps/meshtastic/manifest.yml new file mode 100644 index 00000000..06de2ead --- /dev/null +++ b/apps/meshtastic/manifest.yml @@ -0,0 +1,54 @@ +app: + id: meshtastic + name: Meshtastic + version: 2.5.0 + description: Open-source mesh networking for LoRa radios. Create decentralized communication networks. + + container: + image: meshtastic/meshtastic:latest + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - storage: 1Gi + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 1Gi + + security: + capabilities: [NET_ADMIN, SYS_ADMIN] # Required for LoRa radio access + readonly_root: false # Needs write access for device management + network_policy: host # Requires host network for radio access + apparmor_profile: meshtastic + + ports: + - 4403:4403 # HTTP API + - 1883:1883 # MQTT (optional) + + devices: + - /dev/ttyUSB0 # LoRa radio device (if connected) + - /dev/ttyACM0 # Alternative device path + + volumes: + - type: bind + source: /var/lib/archipelago/meshtastic + target: /app/data + options: [rw] + + environment: + - MESHTASTIC_PORT=/dev/ttyUSB0 + - MESHTASTIC_SERIAL=true + + health_check: + type: http + endpoint: http://localhost:4403 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + networking: + mesh_enabled: true + local_network_access: true diff --git a/apps/nostr-rs-relay/manifest.yml b/apps/nostr-rs-relay/manifest.yml new file mode 100644 index 00000000..792dfe49 --- /dev/null +++ b/apps/nostr-rs-relay/manifest.yml @@ -0,0 +1,52 @@ +app: + id: nostr-rs-relay + name: Nostr Relay (Rust) + version: 0.8.0 + description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits. + + container: + image: scsibug/nostr-rs-relay:latest + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - storage: 10Gi # For event storage + + resources: + cpu_limit: 2 + memory_limit: 1Gi + disk_limit: 10Gi + + security: + capabilities: [] + readonly_root: true + network_policy: isolated + apparmor_profile: nostr-relay + + ports: + - 8080:8080 # HTTP/WebSocket + + volumes: + - type: bind + source: /var/lib/archipelago/nostr-relay + target: /app/db + options: [rw] + + environment: + - RELAY_NAME=Archipelago Nostr Relay + - RELAY_DESCRIPTION=Self-hosted Nostr relay on Archipelago + - MAX_EVENTS=1000000 + - MAX_SUBSCRIPTIONS=100 + + health_check: + type: http + endpoint: http://localhost:8080 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + nostr_integration: + relay_type: public + monetization_enabled: true # Earn networking profits + event_storage: sqlite diff --git a/apps/router/manifest.yml b/apps/router/manifest.yml new file mode 100644 index 00000000..2fdcd93a --- /dev/null +++ b/apps/router/manifest.yml @@ -0,0 +1,58 @@ +app: + id: router + name: Mesh Router + version: 1.0.0 + description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization. + + container: + image: archipelago/router:latest + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - storage: 500Mi + + resources: + cpu_limit: 2 + memory_limit: 512Mi + disk_limit: 500Mi + + security: + capabilities: [NET_ADMIN, NET_RAW] # Required for network management + readonly_root: true + network_policy: host # Requires host network for routing + apparmor_profile: router + + ports: + - 8080:8080 # Web UI + - 5353:5353 # mDNS/Bonjour + - 1900:1900 # SSDP + + volumes: + - type: bind + source: /var/lib/archipelago/router + target: /app/data + options: [rw] + - type: bind + source: /var/run/dbus + target: /var/run/dbus + options: [ro] + + environment: + - NETWORK_INTERFACE=eth0 + - MESH_ENABLED=true + - DEVICE_DISCOVERY=true + + health_check: + type: http + endpoint: http://localhost:8080 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + networking: + mesh_enabled: true + local_network_access: true + device_discovery: true + routing_protocols: [olsr, babel] diff --git a/apps/strfry/manifest.yml b/apps/strfry/manifest.yml new file mode 100644 index 00000000..8fcd2d88 --- /dev/null +++ b/apps/strfry/manifest.yml @@ -0,0 +1,48 @@ +app: + id: strfry + name: Strfry Nostr Relay + version: 0.9.0 + description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage. + + container: + image: strfry/strfry:latest + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - storage: 5Gi + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 5Gi + + security: + capabilities: [] + readonly_root: true + network_policy: isolated + apparmor_profile: nostr-relay + + ports: + - 8080:8080 # HTTP/WebSocket + + volumes: + - type: bind + source: /var/lib/archipelago/strfry + target: /strfry + options: [rw] + + environment: + - RELAY_NAME=Archipelago Strfry Relay + + health_check: + type: http + endpoint: http://localhost:8080 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + nostr_integration: + relay_type: public + monetization_enabled: true diff --git a/apps/web5-dwn/manifest.yml b/apps/web5-dwn/manifest.yml new file mode 100644 index 00000000..c762aabc --- /dev/null +++ b/apps/web5-dwn/manifest.yml @@ -0,0 +1,52 @@ +app: + id: web5-dwn + name: Decentralized Web Node + version: 1.0.0 + description: Personal data store for Web5. Store and sync your decentralized data across devices. + + container: + image: tbd/web5-dwn:latest + image_signature: cosign://... + pull_policy: verify-signature + + dependencies: + - storage: 5Gi + + resources: + cpu_limit: 1 + memory_limit: 512Mi + disk_limit: 5Gi + + security: + capabilities: [] + readonly_root: true + network_policy: isolated + apparmor_profile: web5-dwn + + ports: + - host: 3000 + container: 3000 + protocol: tcp # HTTP API + + volumes: + - type: bind + source: /var/lib/archipelago/web5-dwn + target: /app/data + options: [rw] + + environment: + - DWN_STORAGE_PATH=/app/data + - DID_METHOD=key + + health_check: + type: http + endpoint: http://localhost:3000 + path: /health + interval: 30s + timeout: 5s + retries: 3 + + web5_integration: + did_support: true + dwn_protocol: true + sync_enabled: true diff --git a/core/container/Cargo.toml b/core/container/Cargo.toml new file mode 100644 index 00000000..972bbfa0 --- /dev/null +++ b/core/container/Cargo.toml @@ -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" diff --git a/core/container/src/dependency_resolver.rs b/core/container/src/dependency_resolver.rs new file mode 100644 index 00000000..0dd0d735 --- /dev/null +++ b/core/container/src/dependency_resolver.rs @@ -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, +} + +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, 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, + visiting: &mut HashSet, + result: &mut Vec, + ) -> 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 = 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 { + 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 { + 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) -> 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()); + } +} diff --git a/core/container/src/health_monitor.rs b/core/container/src/health_monitor.rs new file mode 100644 index 00000000..28d68302 --- /dev/null +++ b/core/container/src/health_monitor.rs @@ -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, +} + +impl HealthMonitor { + pub fn new(container_name: String, health_check: Option) -> Self { + Self { + container_name, + health_check, + } + } + + pub async fn check_health(&self) -> Result { + 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 { + 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 { + // 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 { + 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))); + } +} diff --git a/core/container/src/lib.rs b/core/container/src/lib.rs new file mode 100644 index 00000000..aa0ee5c4 --- /dev/null +++ b/core/container/src/lib.rs @@ -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; diff --git a/core/container/src/manifest.rs b/core/container/src/manifest.rs new file mode 100644 index 00000000..09342fee --- /dev/null +++ b/core/container/src/manifest.rs @@ -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, + + #[serde(default)] + pub container: ContainerConfig, + + #[serde(default)] + pub dependencies: Vec, + + #[serde(default)] + pub resources: ResourceLimits, + + #[serde(default)] + pub security: SecurityPolicy, + + #[serde(default)] + pub ports: Vec, + + #[serde(default)] + pub volumes: Vec, + + #[serde(default)] + pub environment: Vec, + + #[serde(default)] + pub health_check: Option, + + #[serde(default)] + pub devices: Vec, + + #[serde(flatten)] + pub extensions: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ContainerConfig { + pub image: String, + #[serde(default)] + pub image_signature: Option, + #[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 }, + Simple(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ResourceLimits { + #[serde(default)] + pub cpu_limit: Option, + #[serde(default)] + pub memory_limit: Option, + #[serde(default)] + pub disk_limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SecurityPolicy { + #[serde(default)] + pub capabilities: Vec, + #[serde(default = "default_true")] + pub readonly_root: bool, + #[serde(default = "default_network_policy")] + pub network_policy: String, + #[serde(default)] + pub apparmor_profile: Option, +} + +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, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HealthCheck { + #[serde(rename = "type")] + pub check_type: String, + pub endpoint: Option, + pub path: Option, + #[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 { + let content = std::fs::read_to_string(path)?; + Self::from_str(&content) + } + + pub fn from_str(content: &str) -> Result { + 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()); + } +} diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs new file mode 100644 index 00000000..27ba09d9 --- /dev/null +++ b/core/container/src/podman_client.rs @@ -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, +} + +#[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 { + 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 { + 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> { + 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> { + 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::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) + } +} diff --git a/core/parmanode/Cargo.toml b/core/parmanode/Cargo.toml new file mode 100644 index 00000000..62358efc --- /dev/null +++ b/core/parmanode/Cargo.toml @@ -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" diff --git a/core/parmanode/src/converter.rs b/core/parmanode/src/converter.rs new file mode 100644 index 00000000..105f35a2 --- /dev/null +++ b/core/parmanode/src/converter.rs @@ -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 { + 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) -> 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() + } +} diff --git a/core/parmanode/src/lib.rs b/core/parmanode/src/lib.rs new file mode 100644 index 00000000..b6c30b55 --- /dev/null +++ b/core/parmanode/src/lib.rs @@ -0,0 +1,5 @@ +pub mod script_runner; +pub mod converter; + +pub use script_runner::ParmanodeScriptRunner; +pub use converter::ParmanodeConverter; diff --git a/core/parmanode/src/script_runner.rs b/core/parmanode/src/script_runner.rs new file mode 100644 index 00000000..c8b77318 --- /dev/null +++ b/core/parmanode/src/script_runner.rs @@ -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 { + // 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"))) + } + } + } +} diff --git a/core/performance/Cargo.toml b/core/performance/Cargo.toml new file mode 100644 index 00000000..41ee83be --- /dev/null +++ b/core/performance/Cargo.toml @@ -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" diff --git a/core/performance/src/lib.rs b/core/performance/src/lib.rs new file mode 100644 index 00000000..fd4b048e --- /dev/null +++ b/core/performance/src/lib.rs @@ -0,0 +1,3 @@ +pub mod resource_manager; + +pub use resource_manager::ResourceManager; diff --git a/core/performance/src/resource_manager.rs b/core/performance/src/resource_manager.rs new file mode 100644 index 00000000..996df8e5 --- /dev/null +++ b/core/performance/src/resource_manager.rs @@ -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, + pub disk_io_write_mbps: Option, +} + +#[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, +} + +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 { + 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"); + } +} diff --git a/core/security/Cargo.toml b/core/security/Cargo.toml new file mode 100644 index 00000000..bd035489 --- /dev/null +++ b/core/security/Cargo.toml @@ -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" diff --git a/core/security/src/container_policies.rs b/core/security/src/container_policies.rs new file mode 100644 index 00000000..b2336611 --- /dev/null +++ b/core/security/src/container_policies.rs @@ -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 { + 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 \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(()) + } +} diff --git a/core/security/src/image_verifier.rs b/core/security/src/image_verifier.rs new file mode 100644 index 00000000..310557de --- /dev/null +++ b/core/security/src/image_verifier.rs @@ -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, // Public key for verification +} + +impl ImageVerifier { + pub fn new(cosign_public_key: Option) -> Self { + Self { cosign_public_key } + } + + /// Verify a container image signature + pub async fn verify_image(&self, image: &str, signature: Option<&str>) -> Result { + 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() + } +} diff --git a/core/security/src/lib.rs b/core/security/src/lib.rs new file mode 100644 index 00000000..cf11343c --- /dev/null +++ b/core/security/src/lib.rs @@ -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; diff --git a/core/security/src/secrets_manager.rs b/core/security/src/secrets_manager.rs new file mode 100644 index 00000000..d824d652 --- /dev/null +++ b/core/security/src/secrets_manager.rs @@ -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, // In production, derive from user password +} + +impl SecretsManager { + pub fn new(secrets_dir: PathBuf, encryption_key: Vec) -> 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 { + 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> { + 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(()) + } +} diff --git a/docs/app-manifest-spec.md b/docs/app-manifest-spec.md new file mode 100644 index 00000000..57a6e721 --- /dev/null +++ b/docs/app-manifest-spec.md @@ -0,0 +1,126 @@ +# App Manifest Specification + +## Overview + +App manifests define containerized applications in Archipelago. They use YAML format and specify container configuration, dependencies, resources, security policies, and integration metadata. + +## File Location + +App manifests are stored in `apps/{app-id}/manifest.yml` + +## Schema + +### Required Fields + +```yaml +app: + id: string # Unique app identifier (lowercase, kebab-case) + name: string # Human-readable name + version: string # Semantic version (e.g., "1.0.0") + container: + image: string # Container image (e.g., "bitcoin/bitcoin:26.0") +``` + +### Optional Fields + +```yaml +app: + description: string # App description + + container: + image_signature: string # Cosign signature URL (e.g., "cosign://...") + pull_policy: string # "if-not-present" | "always" | "never" + + dependencies: + - storage: string # Minimum disk space (e.g., "500Gi") + - app_id: string # Required app dependency + version: string # Version constraint (e.g., ">=26.0") + - string # Simple app dependency + + resources: + cpu_limit: number # CPU cores (e.g., 2) + memory_limit: string # Memory limit (e.g., "2Gi", "512Mi") + disk_limit: string # Disk limit (e.g., "500Gi") + + security: + capabilities: [string] # Linux capabilities (e.g., ["NET_BIND_SERVICE"]) + readonly_root: boolean # Read-only root filesystem (default: true) + network_policy: string # "isolated" | "host" | network name + apparmor_profile: string # AppArmor profile name + + ports: + - host: number # Host port + container: number # Container port + protocol: string # "tcp" | "udp" (default: "tcp") + + volumes: + - type: string # "bind" | "tmpfs" | "volume" + source: string # Host path + target: string # Container path + options: [string] # Mount options (e.g., ["rw", "noexec"]) + + environment: + - string # Environment variable (e.g., "NETWORK=mainnet") + + devices: + - string # Device path (e.g., "/dev/ttyUSB0") + + health_check: + type: string # "http" | "exec" + endpoint: string # HTTP URL or command + path: string # HTTP path (for http type) + interval: string # Check interval (e.g., "30s") + timeout: string # Timeout (e.g., "5s") + retries: number # Failure retries (default: 3) + + # Integration-specific metadata + bitcoin_integration: + rpc_access: string # "admin" | "read-only" + sync_required: boolean # Requires synced node + testnet_support: boolean + pruning_support: boolean + + lightning_integration: + channel_management: boolean + payment_routing: boolean + + nostr_integration: + relay_type: string # "public" | "private" + monetization_enabled: boolean + event_storage: string # "sqlite" | "postgres" + + web5_integration: + did_support: boolean + dwn_protocol: boolean + sync_enabled: boolean + + networking: + mesh_enabled: boolean + local_network_access: boolean + device_discovery: boolean + routing_protocols: [string] # e.g., ["olsr", "babel"] +``` + +## Examples + +See `apps/` directory for complete examples: +- `apps/bitcoin-core/manifest.yml` +- `apps/lnd/manifest.yml` +- `apps/nostr-rs-relay/manifest.yml` +- `apps/meshtastic/manifest.yml` + +## Validation + +Manifests are validated on installation: +- Required fields present +- Version format valid +- Resource limits reasonable +- Port conflicts detected +- Dependency cycles prevented + +## Versioning + +- Use semantic versioning (MAJOR.MINOR.PATCH) +- Breaking changes increment MAJOR +- New features increment MINOR +- Bug fixes increment PATCH diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 00000000..87383ee5 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,165 @@ +# Archipelago Bitcoin Node OS - Architecture Documentation + +## Overview + +Archipelago is a next-generation Bitcoin Node OS built on Alpine Linux with Podman containerization, combining the modularity of Parmanode with the security and efficiency of a minimal server OS. + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────┐ +│ Alpine Linux Base (130MB) │ +│ - Minimal kernel │ +│ - Hardened security │ +│ - Read-only root filesystem │ +└─────────────────────────────────────────────────────────┘ + │ + ┌───────────────┼───────────────┐ + │ │ │ +┌───────▼──────┐ ┌──────▼──────┐ ┌─────▼──────┐ +│ Podman │ │ Rust Backend│ │ Vue.js UI │ +│ (rootless) │ │ (core/) │ │ (neode-ui/) │ +└───────┬──────┘ └──────┬──────┘ └─────────────┘ + │ │ + └───────┬───────┘ + │ + ┌───────────▼───────────┐ + │ Container Orchestration│ + │ Layer (new) │ + │ - Manifest parser │ + │ - Podman client │ + │ - Dependency resolver │ + │ - Health monitor │ + └───────────┬───────────┘ + │ + ┌───────────▼───────────┐ + │ Containerized Apps │ + │ - Bitcoin Core │ + │ - LND / CLN │ + │ - BTCPay Server │ + │ - Nostr Relays │ + │ - Meshtastic │ + │ - Web5 DWN │ + └───────────────────────┘ +``` + +## Key Components + +### 1. Alpine Linux Base + +- **Size**: ~130MB (vs 1.5GB+ for Umbrel/StartOS) +- **Security**: Hardened kernel, minimal attack surface +- **Multi-arch**: ARM64 (Raspberry Pi) and x86_64 support + +### 2. Container Orchestration Layer + +Located in `core/container/`: +- **manifest.rs**: Parses YAML app manifests +- **podman_client.rs**: Wraps Podman API for container management +- **dependency_resolver.rs**: Resolves app dependencies and conflicts +- **health_monitor.rs**: Monitors container health and auto-restarts + +### 3. Backend API Extensions + +New RPC endpoints in `core/startos/src/container/`: +- `container-install`: Install app from manifest +- `container-start/stop/remove`: Container lifecycle +- `container-status/logs`: Status and debugging +- `container-list`: List all containers +- `container-health`: Health status aggregation + +### 4. Vue.js UI Integration + +New components in `neode-ui/`: +- **ContainerApps.vue**: List of containerized apps +- **ContainerAppDetails.vue**: Detailed app view with logs +- **ContainerStatus.vue**: Status indicator component +- **container-client.ts**: API client for container operations +- **container.ts**: Pinia store for container state + +### 5. App Manifest System + +Standardized YAML format in `apps/`: +- Defines container image, resources, dependencies +- Security policies and health checks +- Bitcoin/Lightning/Web5 integration metadata + +### 6. Parmanode Compatibility + +Located in `core/parmanode/`: +- **script_runner.rs**: Executes Parmanode scripts in containers +- **converter.rs**: Converts Parmanode modules to app manifests +- **parmanode-wrapper.sh**: Shell wrapper for direct script execution + +### 7. Security Modules + +Located in `core/security/`: +- **container_policies.rs**: Generates AppArmor/SELinux profiles +- **secrets_manager.rs**: Encrypted secrets storage +- **image_verifier.rs**: Cosign signature verification + +### 8. Performance Optimization + +Located in `core/performance/`: +- **resource_manager.rs**: CPU/memory/disk allocation +- **optimize-alpine.sh**: OS-level optimizations + +## App Categories + +### Bitcoin & Lightning +- Bitcoin Core (full node) +- LND (Lightning Network Daemon) +- Core Lightning (CLN) +- BTCPay Server +- Mempool (blockchain explorer) + +### Web5 & Decentralized Protocols +- Nostr relays (nostr-rs-relay, strfry) +- Web5 DWN (Decentralized Web Node) +- DID Wallet +- Bitcoin Domain Names + +### Mesh Networking & Routing +- Meshtastic (LoRa mesh networking) +- Router (mesh routing, device discovery) +- Local network management + +### Self-Hosted Services +- Home Assistant +- Grafana +- SearXNG +- OnlyOffice +- Ollama (local AI) +- Penpot + +## Security Model + +1. **OS Level**: Hardened Alpine, read-only root, minimal kernel +2. **Container Level**: Rootless Podman, capability dropping, network isolation +3. **Secrets**: Encrypted storage, runtime injection only +4. **Supply Chain**: Signed images (Cosign), SBOM generation +5. **Network**: Firewall, rate limiting, Tor integration +6. **Audit**: Immutable logs, configuration tracking + +## Networking + +- **Isolated Networks**: Each app on separate bridge network by default +- **Bitcoin Core**: Isolated network, explicit RPC access +- **Lightning Nodes**: Separate network, gRPC/REST exposed +- **Tor Integration**: Optional, default for privacy-sensitive apps +- **Mesh Networking**: Meshtastic and router support for decentralized communication + +## Data Persistence + +- **App Data**: `/var/lib/archipelago/{app-id}/` +- **Secrets**: `/var/lib/archipelago/secrets/{app-id}/` (encrypted) +- **Logs**: `/var/lib/archipelago/logs/{app-id}/` +- **Backups**: `/var/lib/archipelago/backups/` + +## Future Enhancements + +- Time-travel snapshots (ZFS/BTRFS) +- Decentralized app marketplace (IPFS + Nostr) +- Multi-node clustering +- Hardware attestation (TPM 2.0) +- Protocol-agnostic design (multi-chain support) diff --git a/image-recipe/.gitignore b/image-recipe/.gitignore new file mode 100644 index 00000000..6c43811e --- /dev/null +++ b/image-recipe/.gitignore @@ -0,0 +1,2 @@ +results/ +*.deb \ No newline at end of file diff --git a/image-recipe/Dockerfile.alpine-base b/image-recipe/Dockerfile.alpine-base new file mode 100644 index 00000000..b61a3337 --- /dev/null +++ b/image-recipe/Dockerfile.alpine-base @@ -0,0 +1,93 @@ +# Alpine Linux Base Image for Archipelago Bitcoin Node OS +# Multi-arch support: ARM64 (Raspberry Pi) and x86_64 + +ARG ALPINE_VERSION=3.19 +FROM alpine:${ALPINE_VERSION} + +# Install essential packages +RUN apk add --no-cache \ + bash \ + curl \ + wget \ + ca-certificates \ + openssl \ + sudo \ + shadow \ + systemd \ + systemd-openrc \ + dbus \ + udev \ + util-linux \ + e2fsprogs \ + dosfstools \ + parted \ + gptfdisk \ + rsync \ + git \ + vim \ + nano \ + htop \ + iotop \ + net-tools \ + iproute2 \ + iputils \ + tcpdump \ + tzdata \ + logrotate \ + fail2ban \ + ufw \ + && rm -rf /var/cache/apk/* + +# Install Podman and dependencies +RUN apk add --no-cache \ + podman \ + podman-compose \ + crun \ + fuse-overlayfs \ + slirp4netns \ + && rm -rf /var/cache/apk/* + +# Create archipelago user for rootless containers +RUN adduser -D -s /bin/bash archipelago && \ + echo "archipelago ALL=(ALL) NOPASSWD: /usr/bin/podman, /usr/bin/podman-compose" >> /etc/sudoers + +# Configure Podman for rootless operation +RUN mkdir -p /home/archipelago/.config/containers && \ + echo 'driver = "overlay"' > /home/archipelago/.config/containers/storage.conf && \ + echo 'rootless_storage_path = "/home/archipelago/.local/share/containers/storage"' >> /home/archipelago/.config/containers/storage.conf + +# Set up systemd for container management +RUN systemctl enable systemd-resolved && \ + systemctl enable dbus + +# Create necessary directories +RUN mkdir -p \ + /var/lib/archipelago \ + /var/lib/archipelago/apps \ + /var/lib/archipelago/secrets \ + /var/lib/archipelago/logs \ + /var/lib/archipelago/backups \ + /etc/archipelago + +# Copy hardening scripts +COPY scripts/harden-alpine.sh /usr/local/bin/ +COPY scripts/install-podman.sh /usr/local/bin/ +RUN chmod +x /usr/local/bin/harden-alpine.sh /usr/local/bin/install-podman.sh + +# Run hardening script +RUN /usr/local/bin/harden-alpine.sh + +# Set timezone to UTC +RUN ln -sf /usr/share/zoneinfo/UTC /etc/localtime + +# Configure log rotation +COPY configs/logrotate.conf /etc/logrotate.d/archipelago + +# Set up firewall defaults (will be configured on first boot) +RUN ufw --force enable || true + +# Expose common ports (will be managed by firewall rules) +EXPOSE 22 80 443 8332 8333 9735 10009 8080 8443 + +# Default command +CMD ["/bin/bash"] diff --git a/image-recipe/README.md b/image-recipe/README.md new file mode 100644 index 00000000..cbaf8944 --- /dev/null +++ b/image-recipe/README.md @@ -0,0 +1,23 @@ +# StartOS Image Recipes + +Code and `debos` recipes that are used to create the StartOS live and installer +images. + +If you want to build a local image in the exact same environment used to build +official StartOS images, you can use the `run-local-build.sh` helper script: + +```bash +# Prerequisites +sudo apt-get install -y debspawn +sudo mkdir -p /etc/debspawn/ && echo "AllowUnsafePermissions=true" | sudo tee /etc/debspawn/global.toml + +# Get dpkg +mkdir -p overlays/startos/root +wget -O overlays/startos/root/startos_0.3.x-1_amd64.deb + +# Build image +./run-local-build.sh +``` + +In order for the build to work properly, you will need debspawn >= 0.5.1, the +build may fail with prior versions. diff --git a/image-recipe/build-alpine.sh b/image-recipe/build-alpine.sh new file mode 100755 index 00000000..47bfb297 --- /dev/null +++ b/image-recipe/build-alpine.sh @@ -0,0 +1,41 @@ +#!/bin/bash +# Build script for Alpine Linux base image +# Supports multi-arch: ARM64 (aarch64) and x86_64 + +set -e + +ARCH="${ARCH:-$(uname -m)}" +ALPINE_VERSION="${ALPINE_VERSION:-3.19}" +IMAGE_NAME="archipelago/alpine-base" +TAG="${ALPINE_VERSION}-${ARCH}" + +echo "🔨 Building Alpine Linux base image for ${ARCH}..." + +# Map architecture names +case "$ARCH" in + aarch64|arm64) + BUILD_ARCH="arm64" + PLATFORM="linux/arm64" + ;; + x86_64|amd64) + BUILD_ARCH="amd64" + PLATFORM="linux/amd64" + ;; + *) + echo "❌ Unsupported architecture: $ARCH" + exit 1 + ;; +esac + +# Build the image +docker buildx build \ + --platform "$PLATFORM" \ + --file image-recipe/Dockerfile.alpine-base \ + --tag "${IMAGE_NAME}:${TAG}" \ + --tag "${IMAGE_NAME}:latest-${BUILD_ARCH}" \ + --load \ + . + +echo "✅ Alpine base image built successfully!" +echo " Image: ${IMAGE_NAME}:${TAG}" +echo " Platform: ${PLATFORM}" diff --git a/image-recipe/build.sh b/image-recipe/build.sh new file mode 100755 index 00000000..3ff91ca7 --- /dev/null +++ b/image-recipe/build.sh @@ -0,0 +1,355 @@ +#!/bin/bash +set -e + +MAX_IMG_SECTORS=7217792 # 4GB + +echo "==== StartOS Image Build ====" + +echo "Building for architecture: $IB_TARGET_ARCH" + +base_dir="$(dirname "$(readlink -f "$0")")" +prep_results_dir="$base_dir/images-prep" +if systemd-detect-virt -qc; then + RESULTS_DIR="/srv/artifacts" +else + RESULTS_DIR="$base_dir/results" +fi +echo "Saving results in: $RESULTS_DIR" + +IMAGE_BASENAME=startos-${VERSION_FULL}_${IB_TARGET_PLATFORM} + +mkdir -p $prep_results_dir + +cd $prep_results_dir + +QEMU_ARCH=${IB_TARGET_ARCH} +BOOTLOADERS=grub-efi,syslinux +if [ "$QEMU_ARCH" = 'amd64' ]; then + QEMU_ARCH=x86_64 +elif [ "$QEMU_ARCH" = 'arm64' ]; then + QEMU_ARCH=aarch64 + BOOTLOADERS=grub-efi +fi +NON_FREE= +if [[ "${IB_TARGET_PLATFORM}" =~ -nonfree$ ]] || [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then + NON_FREE=1 +fi +IMAGE_TYPE=iso +if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ] || [ "${IB_TARGET_PLATFORM}" = "rockchip64" ]; then + IMAGE_TYPE=img +fi + +ARCHIVE_AREAS="main contrib" +if [ "$NON_FREE" = 1 ]; then + if [ "$IB_SUITE" = "bullseye" ]; then + ARCHIVE_AREAS="$ARCHIVE_AREAS non-free" + elif [ "$IB_SUITE" = "bookworm" ]; then + ARCHIVE_AREAS="$ARCHIVE_AREAS non-free-firmware" + fi +fi + +PLATFORM_CONFIG_EXTRAS= +if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then + PLATFORM_CONFIG_EXTRAS="$PLATFORM_CONFIG_EXTRAS --firmware-binary false" + PLATFORM_CONFIG_EXTRAS="$PLATFORM_CONFIG_EXTRAS --firmware-chroot false" + # BEGIN stupid ugly hack + # The actual name of the package is `raspberrypi-kernel` + # live-build determines thte name of the package for the kernel by combining the `linux-packages` flag, with the `linux-flavours` flag + # the `linux-flavours` flag defaults to the architecture, so there's no way to remove the suffix. + # So we're doing this, cause thank the gods our package name contains a hypen. Cause if it didn't we'd be SOL + PLATFORM_CONFIG_EXTRAS="$PLATFORM_CONFIG_EXTRAS --linux-packages raspberrypi" + PLATFORM_CONFIG_EXTRAS="$PLATFORM_CONFIG_EXTRAS --linux-flavours kernel" + # END stupid ugly hack +elif [ "${IB_TARGET_PLATFORM}" = "rockchip64" ]; then + PLATFORM_CONFIG_EXTRAS="$PLATFORM_CONFIG_EXTRAS --linux-flavours rockchip64" +fi + +cat > /etc/wgetrc << EOF +retry_connrefused = on +tries = 100 +EOF +lb config \ + --iso-application "StartOS v${VERSION_FULL} ${IB_TARGET_ARCH}" \ + --iso-volume "StartOS v${VERSION} ${IB_TARGET_ARCH}" \ + --iso-preparer "START9 LABS; HTTPS://START9.COM" \ + --iso-publisher "START9 LABS; HTTPS://START9.COM" \ + --backports true \ + --bootappend-live "boot=live noautologin" \ + --bootloaders $BOOTLOADERS \ + --mirror-bootstrap "https://deb.debian.org/debian/" \ + --mirror-chroot "https://deb.debian.org/debian/" \ + --mirror-chroot-security "https://security.debian.org/debian-security" \ + -d ${IB_SUITE} \ + -a ${IB_TARGET_ARCH} \ + --bootstrap-qemu-arch ${IB_TARGET_ARCH} \ + --bootstrap-qemu-static /usr/bin/qemu-${QEMU_ARCH}-static \ + --archive-areas "${ARCHIVE_AREAS}" \ + $PLATFORM_CONFIG_EXTRAS + +# Overlays + +mkdir -p config/includes.chroot/deb +cp $base_dir/deb/${IMAGE_BASENAME}.deb config/includes.chroot/deb/ + +if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then + cp -r $base_dir/raspberrypi/squashfs/* config/includes.chroot/ +fi + +mkdir -p config/includes.chroot/etc +echo start > config/includes.chroot/etc/hostname +cat > config/includes.chroot/etc/hosts << EOT +127.0.0.1 localhost start +::1 localhost start ip6-localhost ip6-loopback +ff02::1 ip6-allnodes +ff02::2 ip6-allrouters +EOT + +# Bootloaders + +rm -rf config/bootloaders +cp -r /usr/share/live/build/bootloaders config/bootloaders + +cat > config/bootloaders/syslinux/syslinux.cfg << EOF +include menu.cfg +default vesamenu.c32 +prompt 0 +timeout 50 +EOF + +cat > config/bootloaders/isolinux/isolinux.cfg << EOF +include menu.cfg +default vesamenu.c32 +prompt 0 +timeout 50 +EOF + +rm config/bootloaders/syslinux_common/splash.svg +cp $base_dir/splash.png config/bootloaders/syslinux_common/splash.png +cp $base_dir/splash.png config/bootloaders/isolinux/splash.png +cp $base_dir/splash.png config/bootloaders/grub-pc/splash.png + +sed -i -e '2i set timeout=5' config/bootloaders/grub-pc/config.cfg + +# Archives + +mkdir -p config/archives + +if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then + curl -fsSL https://archive.raspberrypi.org/debian/raspberrypi.gpg.key | gpg --dearmor -o config/archives/raspi.key + echo "deb https://archive.raspberrypi.org/debian/ bullseye main" > config/archives/raspi.list +fi + +if [ "${IB_SUITE}" = "bullseye" ]; then + cat > config/archives/backports.pref <<- EOF + Package: * + Pin: release a=bullseye-backports + Pin-Priority: 500 + EOF +fi + +if [ "${IB_TARGET_PLATFORM}" = "rockchip64" ]; then + curl -fsSL https://apt.armbian.com/armbian.key | gpg --dearmor -o config/archives/armbian.key + echo "deb https://apt.armbian.com/ ${IB_SUITE} main" > config/archives/armbian.list +fi + +curl -fsSL https://deb.torproject.org/torproject.org/A3C4F0F979CAA22CDBA8F512EE8CBC9E886DDD89.asc > config/archives/tor.key +echo "deb [arch=${IB_TARGET_ARCH} signed-by=/etc/apt/trusted.gpg.d/tor.key.gpg] https://deb.torproject.org/torproject.org ${IB_SUITE} main" > config/archives/tor.list + +curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o config/archives/docker.key +echo "deb [arch=${IB_TARGET_ARCH} signed-by=/etc/apt/trusted.gpg.d/docker.key.gpg] https://download.docker.com/linux/debian ${IB_SUITE} stable" > config/archives/docker.list + +echo "deb http://deb.debian.org/debian/ trixie main contrib" > config/archives/trixie.list +cat > config/archives/trixie.pref <<- EOF +Package: * +Pin: release n=trixie +Pin-Priority: 100 + +Package: podman +Pin: release n=trixie +Pin-Priority: 600 +EOF + +# Dependencies + +## Base dependencies +dpkg-deb --fsys-tarfile $base_dir/deb/${IMAGE_BASENAME}.deb | tar --to-stdout -xvf - ./usr/lib/startos/depends > config/package-lists/embassy-depends.list.chroot + +## Firmware +if [ "$NON_FREE" = 1 ]; then + echo 'firmware-iwlwifi firmware-misc-nonfree firmware-brcm80211 firmware-realtek firmware-atheros firmware-libertas firmware-amd-graphics' > config/package-lists/nonfree.list.chroot +fi + +if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then + echo 'raspberrypi-bootloader rpi-update parted' > config/package-lists/bootloader.list.chroot +else + echo 'grub-efi grub2-common' > config/package-lists/bootloader.list.chroot +fi +if [ "${IB_TARGET_ARCH}" = "amd64" ] || [ "${IB_TARGET_ARCH}" = "i386" ]; then + echo 'grub-pc-bin' >> config/package-lists/bootloader.list.chroot +fi + +cat > config/hooks/normal/9000-install-startos.hook.chroot << EOF +#!/bin/bash + +set -e + +apt-get install -y /deb/${IMAGE_BASENAME}.deb +rm -rf /deb + +if [ "${IB_SUITE}" = bookworm ]; then + echo 'deb https://deb.debian.org/debian/ bullseye main' > /etc/apt/sources.list.d/bullseye.list + apt-get update + apt-get install -y postgresql-13 + rm /etc/apt/sources.list.d/bullseye.list + apt-get update +fi + +if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then + for f in /usr/lib/modules/*; do + v=\${f#/usr/lib/modules/} + echo "Configuring raspi kernel '\$v'" + extract-ikconfig "/usr/lib/modules/\$v/kernel/kernel/configs.ko.xz" > /boot/config-\$v + update-initramfs -c -k \$v + done + ln -sf /usr/bin/pi-beep /usr/local/bin/beep +fi + +useradd --shell /bin/bash -G embassy -m start9 +echo start9:embassy | chpasswd +usermod -aG sudo start9 + +echo "start9 ALL=(ALL:ALL) NOPASSWD: ALL" | sudo tee "/etc/sudoers.d/010_start9-nopasswd" + +if [ "${IB_TARGET_PLATFORM}" != "raspberrypi" ]; then + /usr/lib/startos/scripts/enable-kiosk +fi + +if ! [[ "${IB_OS_ENV}" =~ (^|-)dev($|-) ]]; then + passwd -l start9 +fi + +EOF + +SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-$(date '+%s')}" + +lb bootstrap +lb chroot +lb installer +lb binary_chroot +lb chroot_prep install all mode-apt-install-binary mode-archives-chroot +ln -sf /run/systemd/resolve/stub-resolv.conf chroot/chroot/etc/resolv.conf +lb binary_rootfs + +cp $prep_results_dir/binary/live/filesystem.squashfs $RESULTS_DIR/$IMAGE_BASENAME.squashfs + +if [ "${IMAGE_TYPE}" = iso ]; then + + lb binary_manifest + lb binary_package-lists + lb binary_linux-image + lb binary_memtest + lb binary_grub-legacy + lb binary_grub-pc + lb binary_grub_cfg + lb binary_syslinux + lb binary_disk + lb binary_loadlin + lb binary_win32-loader + lb binary_includes + lb binary_grub-efi + lb binary_hooks + lb binary_checksums + find binary -newermt "$(date -d@${SOURCE_DATE_EPOCH} '+%Y-%m-%d %H:%M:%S')" -printf "%y %p\n" -exec touch '{}' -d@${SOURCE_DATE_EPOCH} --no-dereference ';' > binary.modified_timestamps + lb binary_iso + lb binary_onie + lb binary_netboot + lb binary_tar + lb binary_hdd + lb binary_zsync + lb chroot_prep remove all mode-archives-chroot + lb source + + mv $prep_results_dir/live-image-${IB_TARGET_ARCH}.hybrid.iso $RESULTS_DIR/$IMAGE_BASENAME.iso + +elif [ "${IMAGE_TYPE}" = img ]; then + + function partition_for () { + if [[ "$1" =~ [0-9]+$ ]]; then + echo "$1p$2" + else + echo "$1$2" + fi + } + + ROOT_PART_END=$MAX_IMG_SECTORS + TARGET_NAME=$prep_results_dir/${IMAGE_BASENAME}.img + TARGET_SIZE=$[($ROOT_PART_END+1)*512] + truncate -s $TARGET_SIZE $TARGET_NAME + ( + echo o + echo x + echo i + echo "0xcb15ae4d" + echo r + echo n + echo p + echo 1 + echo 2048 + echo 526335 + echo t + echo c + echo n + echo p + echo 2 + echo 526336 + echo $ROOT_PART_END + echo a + echo 1 + echo w + ) | fdisk $TARGET_NAME + OUTPUT_DEVICE=$(losetup --show -fP $TARGET_NAME) + mkfs.ext4 `partition_for ${OUTPUT_DEVICE} 2` + mkfs.vfat `partition_for ${OUTPUT_DEVICE} 1` + + TMPDIR=$(mktemp -d) + + mount `partition_for ${OUTPUT_DEVICE} 2` $TMPDIR + mkdir $TMPDIR/boot + mount `partition_for ${OUTPUT_DEVICE} 1` $TMPDIR/boot + unsquashfs -f -d $TMPDIR $prep_results_dir/binary/live/filesystem.squashfs + + if [ "${IB_TARGET_PLATFORM}" = "raspberrypi" ]; then + sed -i 's| boot=embassy| init=/usr/lib/startos/scripts/init_resize\.sh|' $TMPDIR/boot/cmdline.txt + rsync -a $base_dir/raspberrypi/img/ $TMPDIR/ + fi + + umount $TMPDIR/boot + umount $TMPDIR + + e2fsck -fy `partition_for ${OUTPUT_DEVICE} 2` + resize2fs -M `partition_for ${OUTPUT_DEVICE} 2` + + BLOCK_COUNT=$(dumpe2fs -h `partition_for ${OUTPUT_DEVICE} 2` | awk '/^Block count:/ { print $3 }') + BLOCK_SIZE=$(dumpe2fs -h `partition_for ${OUTPUT_DEVICE} 2` | awk '/^Block size:/ { print $3 }') + SECTOR_LEN=$[$BLOCK_COUNT*$BLOCK_SIZE/512] + + losetup -d $OUTPUT_DEVICE + + ( + echo d + echo 2 + echo n + echo p + echo 2 + echo 526336 + echo +$SECTOR_LEN + echo w + ) | fdisk $TARGET_NAME + + ROOT_PART_END=$[526336+$SECTOR_LEN] + TARGET_SIZE=$[($ROOT_PART_END+1)*512] + truncate -s $TARGET_SIZE $TARGET_NAME + + mv $TARGET_NAME $RESULTS_DIR/$IMAGE_BASENAME.img + +fi diff --git a/image-recipe/configs/logrotate.conf b/image-recipe/configs/logrotate.conf new file mode 100644 index 00000000..a8949630 --- /dev/null +++ b/image-recipe/configs/logrotate.conf @@ -0,0 +1,24 @@ +# Log rotation configuration for Archipelago +/var/log/archipelago/*.log { + daily + rotate 30 + compress + delaycompress + missingok + notifempty + create 0644 root root + sharedscripts + postrotate + /usr/bin/systemctl reload archipelago > /dev/null 2>&1 || true + endscript +} + +/var/lib/archipelago/logs/*.log { + daily + rotate 14 + compress + delaycompress + missingok + notifempty + create 0644 archipelago archipelago +} diff --git a/image-recipe/prepare.sh b/image-recipe/prepare.sh new file mode 100755 index 00000000..1c677960 --- /dev/null +++ b/image-recipe/prepare.sh @@ -0,0 +1,24 @@ +#!/bin/sh +set -e +set -x + +export DEBIAN_FRONTEND=noninteractive +apt-get install -yq \ + live-build \ + procps \ + systemd \ + binfmt-support \ + qemu-utils \ + qemu-user-static \ + qemu-system-x86 \ + qemu-system-aarch64 \ + xorriso \ + isolinux \ + ca-certificates \ + curl \ + gpg \ + fdisk \ + dosfstools \ + e2fsprogs \ + squashfs-tools \ + rsync diff --git a/image-recipe/raspberrypi/img/etc/fstab b/image-recipe/raspberrypi/img/etc/fstab new file mode 100644 index 00000000..816b32bc --- /dev/null +++ b/image-recipe/raspberrypi/img/etc/fstab @@ -0,0 +1,2 @@ +/dev/mmcblk0p1 /boot vfat umask=0077 0 2 +/dev/mmcblk0p2 / ext4 defaults 0 1 diff --git a/image-recipe/raspberrypi/img/usr/lib/startos/scripts/init_resize.sh b/image-recipe/raspberrypi/img/usr/lib/startos/scripts/init_resize.sh new file mode 100755 index 00000000..93164989 --- /dev/null +++ b/image-recipe/raspberrypi/img/usr/lib/startos/scripts/init_resize.sh @@ -0,0 +1,129 @@ +#!/bin/bash + +get_variables () { + ROOT_PART_DEV=$(findmnt / -o source -n) + ROOT_PART_NAME=$(echo "$ROOT_PART_DEV" | cut -d "/" -f 3) + ROOT_DEV_NAME=$(echo /sys/block/*/"${ROOT_PART_NAME}" | cut -d "/" -f 4) + ROOT_DEV="/dev/${ROOT_DEV_NAME}" + ROOT_PART_NUM=$(cat "/sys/block/${ROOT_DEV_NAME}/${ROOT_PART_NAME}/partition") + + BOOT_PART_DEV=$(findmnt /boot -o source -n) + BOOT_PART_NAME=$(echo "$BOOT_PART_DEV" | cut -d "/" -f 3) + BOOT_DEV_NAME=$(echo /sys/block/*/"${BOOT_PART_NAME}" | cut -d "/" -f 4) + BOOT_PART_NUM=$(cat "/sys/block/${BOOT_DEV_NAME}/${BOOT_PART_NAME}/partition") + + OLD_DISKID=$(fdisk -l "$ROOT_DEV" | sed -n 's/Disk identifier: 0x\([^ ]*\)/\1/p') + + ROOT_DEV_SIZE=$(cat "/sys/block/${ROOT_DEV_NAME}/size") + if [ "$ROOT_DEV_SIZE" -le 67108864 ]; then + TARGET_END=$((ROOT_DEV_SIZE - 1)) + else + TARGET_END=$((33554432 - 1)) + DATA_PART_START=33554432 + DATA_PART_END=$((ROOT_DEV_SIZE - 1)) + fi + + PARTITION_TABLE=$(parted -m "$ROOT_DEV" unit s print | tr -d 's') + + LAST_PART_NUM=$(echo "$PARTITION_TABLE" | tail -n 1 | cut -d ":" -f 1) + + ROOT_PART_LINE=$(echo "$PARTITION_TABLE" | grep -e "^${ROOT_PART_NUM}:") + ROOT_PART_START=$(echo "$ROOT_PART_LINE" | cut -d ":" -f 2) + ROOT_PART_END=$(echo "$ROOT_PART_LINE" | cut -d ":" -f 3) +} + +check_variables () { + if [ "$BOOT_DEV_NAME" != "$ROOT_DEV_NAME" ]; then + FAIL_REASON="Boot and root partitions are on different devices" + return 1 + fi + + if [ "$ROOT_PART_NUM" -ne "$LAST_PART_NUM" ]; then + FAIL_REASON="Root partition should be last partition" + return 1 + fi + + if [ "$ROOT_PART_END" -gt "$TARGET_END" ]; then + FAIL_REASON="Root partition runs past the end of device" + return 1 + fi + + if [ ! -b "$ROOT_DEV" ] || [ ! -b "$ROOT_PART_DEV" ] || [ ! -b "$BOOT_PART_DEV" ] ; then + FAIL_REASON="Could not determine partitions" + return 1 + fi +} + +main () { + get_variables + + if ! check_variables; then + return 1 + fi + +# if [ "$ROOT_PART_END" -eq "$TARGET_END" ]; then +# reboot_pi +# fi + + if ! echo Yes | parted -m --align=optimal "$ROOT_DEV" ---pretend-input-tty u s resizepart "$ROOT_PART_NUM" "$TARGET_END" ; then + FAIL_REASON="Root partition resize failed" + return 1 + fi + + if [ -n "$DATA_PART_START" ]; then + if ! parted -ms --align=optimal "$ROOT_DEV" u s mkpart primary "$DATA_PART_START" "$DATA_PART_END"; then + FAIL_REASON="Data partition creation failed" + return 1 + fi + fi + + ( + echo x + echo i + echo "0xcb15ae4d" + echo r + echo w + ) | fdisk $ROOT_DEV + + mount / -o remount,rw + + resize2fs $ROOT_PART_DEV + + if ! systemd-machine-id-setup; then + FAIL_REASON="systemd-machine-id-setup failed" + return 1 + fi + + if ! ssh-keygen -A; then + FAIL_REASON="ssh host key generation failed" + return 1 + fi + + echo start > /etc/hostname + + return 0 +} + +mount -t proc proc /proc +mount -t sysfs sys /sys +mount -t tmpfs tmp /run +mkdir -p /run/systemd +mount /boot +mount / -o remount,ro + +beep + +if main; then + sed -i 's| init=/usr/lib/startos/scripts/init_resize\.sh| boot=embassy|' /boot/cmdline.txt + echo "Resized root filesystem. Rebooting in 5 seconds..." + sleep 5 +else + echo -e "Could not expand filesystem.\n${FAIL_REASON}" + sleep 5 +fi + +sync + +umount /boot + +reboot -f diff --git a/image-recipe/raspberrypi/squashfs/boot/cmdline.txt b/image-recipe/raspberrypi/squashfs/boot/cmdline.txt new file mode 100644 index 00000000..02de3472 --- /dev/null +++ b/image-recipe/raspberrypi/squashfs/boot/cmdline.txt @@ -0,0 +1 @@ +usb-storage.quirks=152d:0562:u,14cd:121c:u,0781:cfcb:u console=serial0,115200 console=tty1 root=PARTUUID=cb15ae4d-02 rootfstype=ext4 fsck.repair=yes rootwait cgroup_enable=cpuset cgroup_memory=1 cgroup_enable=memory quiet boot=embassy \ No newline at end of file diff --git a/image-recipe/raspberrypi/squashfs/boot/config.txt b/image-recipe/raspberrypi/squashfs/boot/config.txt new file mode 100644 index 00000000..17bd5dc4 --- /dev/null +++ b/image-recipe/raspberrypi/squashfs/boot/config.txt @@ -0,0 +1,86 @@ +# For more options and information see +# http://rpf.io/configtxt +# Some settings may impact device functionality. See link above for details + +# uncomment if you get no picture on HDMI for a default "safe" mode +#hdmi_safe=1 + +# uncomment the following to adjust overscan. Use positive numbers if console +# goes off screen, and negative if there is too much border +#overscan_left=16 +#overscan_right=16 +#overscan_top=16 +#overscan_bottom=16 + +# uncomment to force a console size. By default it will be display's size minus +# overscan. +#framebuffer_width=1280 +#framebuffer_height=720 + +# uncomment if hdmi display is not detected and composite is being output +#hdmi_force_hotplug=1 + +# uncomment to force a specific HDMI mode (this will force VGA) +#hdmi_group=1 +#hdmi_mode=1 + +# uncomment to force a HDMI mode rather than DVI. This can make audio work in +# DMT (computer monitor) modes +#hdmi_drive=2 + +# uncomment to increase signal to HDMI, if you have interference, blanking, or +# no display +#config_hdmi_boost=4 + +# uncomment for composite PAL +#sdtv_mode=2 + +#uncomment to overclock the arm. 700 MHz is the default. +#arm_freq=800 + +# Uncomment some or all of these to enable the optional hardware interfaces +#dtparam=i2c_arm=on +#dtparam=i2s=on +#dtparam=spi=on + +# Uncomment this to enable infrared communication. +#dtoverlay=gpio-ir,gpio_pin=17 +#dtoverlay=gpio-ir-tx,gpio_pin=18 + +# Additional overlays and parameters are documented /boot/overlays/README + +# Enable audio (loads snd_bcm2835) +dtparam=audio=on + +# Automatically load overlays for detected cameras +camera_auto_detect=1 + +# Automatically load overlays for detected DSI displays +display_auto_detect=1 + +# Enable DRM VC4 V3D driver +dtoverlay=vc4-kms-v3d +max_framebuffers=2 + +# Run in 64-bit mode +arm_64bit=1 + +# Disable compensation for displays with overscan +disable_overscan=1 + +[cm4] +# Enable host mode on the 2711 built-in XHCI USB controller. +# This line should be removed if the legacy DWC2 controller is required +# (e.g. for USB device mode) or if USB support is not required. +otg_mode=1 + +[all] + +[pi4] +# Run as fast as firmware / board allows +arm_boost=1 + +[all] +gpu_mem=16 +dtoverlay=pwm-2chan,disable-bt +initramfs initrd.img-6.1.21-v8+ diff --git a/image-recipe/raspberrypi/squashfs/etc/embassy/config.yaml b/image-recipe/raspberrypi/squashfs/etc/embassy/config.yaml new file mode 100644 index 00000000..7c81ad51 --- /dev/null +++ b/image-recipe/raspberrypi/squashfs/etc/embassy/config.yaml @@ -0,0 +1,6 @@ +os-partitions: + boot: /dev/mmcblk0p1 + root: /dev/mmcblk0p2 +ethernet-interface: end0 +wifi-interface: wlan0 +disable-encryption: true diff --git a/image-recipe/raspberrypi/squashfs/etc/modprobe.d/cfg80211.conf b/image-recipe/raspberrypi/squashfs/etc/modprobe.d/cfg80211.conf new file mode 100644 index 00000000..d2abd4ef --- /dev/null +++ b/image-recipe/raspberrypi/squashfs/etc/modprobe.d/cfg80211.conf @@ -0,0 +1 @@ +options cfg80211 ieee80211_regdom=US diff --git a/image-recipe/raspberrypi/squashfs/usr/bin/extract-ikconfig b/image-recipe/raspberrypi/squashfs/usr/bin/extract-ikconfig new file mode 100755 index 00000000..8df33e7d --- /dev/null +++ b/image-recipe/raspberrypi/squashfs/usr/bin/extract-ikconfig @@ -0,0 +1,69 @@ +#!/bin/sh +# ---------------------------------------------------------------------- +# extract-ikconfig - Extract the .config file from a kernel image +# +# This will only work when the kernel was compiled with CONFIG_IKCONFIG. +# +# The obscure use of the "tr" filter is to work around older versions of +# "grep" that report the byte offset of the line instead of the pattern. +# +# (c) 2009,2010 Dick Streefland +# Licensed under the terms of the GNU General Public License. +# ---------------------------------------------------------------------- + +cf1='IKCFG_ST\037\213\010' +cf2='0123456789' + +dump_config() +{ + if pos=`tr "$cf1\n$cf2" "\n$cf2=" < "$1" | grep -abo "^$cf2"` + then + pos=${pos%%:*} + tail -c+$(($pos+8)) "$1" | zcat > $tmp1 2> /dev/null + if [ $? != 1 ] + then # exit status must be 0 or 2 (trailing garbage warning) + cat $tmp1 + exit 0 + fi + fi +} + +try_decompress() +{ + for pos in `tr "$1\n$2" "\n$2=" < "$img" | grep -abo "^$2"` + do + pos=${pos%%:*} + tail -c+$pos "$img" | $3 > $tmp2 2> /dev/null + dump_config $tmp2 + done +} + +# Check invocation: +me=${0##*/} +img=$1 +if [ $# -ne 1 -o ! -s "$img" ] +then + echo "Usage: $me " >&2 + exit 2 +fi + +# Prepare temp files: +tmp1=/tmp/ikconfig$$.1 +tmp2=/tmp/ikconfig$$.2 +trap "rm -f $tmp1 $tmp2" 0 + +# Initial attempt for uncompressed images or objects: +dump_config "$img" + +# That didn't work, so retry after decompression. +try_decompress '\037\213\010' xy gunzip +try_decompress '\3757zXZ\000' abcde unxz +try_decompress 'BZh' xy bunzip2 +try_decompress '\135\0\0\0' xxx unlzma +try_decompress '\211\114\132' xy 'lzop -d' +try_decompress '\002\041\114\030' xyy 'lz4 -d -l' +try_decompress '\050\265\057\375' xxx unzstd + +# Bail out: +echo "$me: Cannot find kernel config." >&2 +exit 1 diff --git a/image-recipe/run-local-build.sh b/image-recipe/run-local-build.sh new file mode 100755 index 00000000..7f7ecd3d --- /dev/null +++ b/image-recipe/run-local-build.sh @@ -0,0 +1,88 @@ +#!/bin/bash +set -e + +DEB_PATH="$(realpath $1)" + +cd "$(dirname "${BASH_SOURCE[0]}")"/.. + +BASEDIR="$(pwd -P)" + +VERSION="$(dpkg-deb --fsys-tarfile $DEB_PATH | tar --to-stdout -xvf - ./usr/lib/startos/VERSION.txt)" +GIT_HASH="$(dpkg-deb --fsys-tarfile $DEB_PATH | tar --to-stdout -xvf - ./usr/lib/startos/GIT_HASH.txt)" +if [[ "$GIT_HASH" =~ ^@ ]]; then + GIT_HASH="unknown" +else + GIT_HASH="$(echo -n "$GIT_HASH" | head -c 7)" +fi +STARTOS_ENV="$(dpkg-deb --fsys-tarfile $DEB_PATH | tar --to-stdout -xvf - ./usr/lib/startos/ENVIRONMENT.txt)" +PLATFORM="$(dpkg-deb --fsys-tarfile $DEB_PATH | tar --to-stdout -xvf - ./usr/lib/startos/PLATFORM.txt)" + +if [ -z "$1" ]; then + PLATFORM="$(uname -m)" +fi +if [ "$PLATFORM" = "x86_64" ] || [ "$PLATFORM" = "x86_64-nonfree" ]; then + ARCH=amd64 + QEMU_ARCH=x86_64 +elif [ "$PLATFORM" = "aarch64" ] || [ "$PLATFORM" = "aarch64-nonfree" ] || [ "$PLATFORM" = "raspberrypi" ] || [ "$PLATFORM" = "rockchip64" ]; then + ARCH=arm64 + QEMU_ARCH=aarch64 +else + ARCH="$PLATFORM" + QEMU_ARCH="$PLATFORM" +fi + +SUITE=bookworm + +debspawn list | grep $SUITE || debspawn create $SUITE + +VERSION_FULL="${VERSION}-${GIT_HASH}" +if [ -n "$STARTOS_ENV" ]; then + VERSION_FULL="$VERSION_FULL~${STARTOS_ENV}" +fi + +if [ -z "$DSNAME" ]; then + DSNAME="$SUITE" +fi + +if [ "$QEMU_ARCH" != "$(uname -m)" ]; then + sudo update-binfmts --import qemu-$QEMU_ARCH +fi + +imgbuild_fname="$(mktemp /tmp/exec-mkimage.XXXXXX)" +cat > $imgbuild_fname <> /etc/sysctl.conf < /etc/fail2ban/jail.local < /etc/periodic/daily/archipelago-security-updates <<'EOF' +#!/bin/sh +# Automatic security updates for Archipelago +apk update && apk upgrade -u || true +EOF +chmod +x /etc/periodic/daily/archipelago-security-updates + +# Set restrictive file permissions +chmod 700 /var/lib/archipelago/secrets +chmod 755 /var/lib/archipelago/apps +chmod 755 /var/lib/archipelago/logs + +# Create log directory with proper permissions +mkdir -p /var/log/archipelago +chmod 755 /var/log/archipelago + +# Configure log rotation for archipelago logs +cat > /etc/logrotate.d/archipelago </dev/null; then + echo "Creating archipelago user..." + adduser -D -s /bin/bash archipelago +fi + +# Create Podman configuration directories +mkdir -p /home/archipelago/.config/containers +mkdir -p /home/archipelago/.local/share/containers/storage + +# Configure storage +cat > /home/archipelago/.config/containers/storage.conf < /home/archipelago/.config/containers/registries.conf.d/000-shortnames.conf <> /etc/subuid +fi + +if ! grep -q "^archipelago:" /etc/subgid; then + echo "archipelago:100000:65536" >> /etc/subgid +fi + +# Create systemd user service directory +mkdir -p /home/archipelago/.config/systemd/user + +# Enable lingering for archipelago user (allows user services to run without login) +loginctl enable-linger archipelago || true + +# Set proper permissions +chown -R archipelago:archipelago /home/archipelago/.config +chown -R archipelago:archipelago /home/archipelago/.local + +echo "✅ Podman configuration complete!" diff --git a/image-recipe/splash.png b/image-recipe/splash.png new file mode 100644 index 0000000000000000000000000000000000000000..99ecc129b942dc7844dd53ce5d0032bd256ec034 GIT binary patch literal 9834 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 { + return rpcClient.call({ + method: 'container-install', + params: { manifest_path: manifestPath }, + }) + }, + + /** + * Start a container + */ + async startContainer(appId: string): Promise { + return rpcClient.call({ + method: 'container-start', + params: { app_id: appId }, + }) + }, + + /** + * Stop a container + */ + async stopContainer(appId: string): Promise { + return rpcClient.call({ + method: 'container-stop', + params: { app_id: appId }, + }) + }, + + /** + * Remove a container + */ + async removeContainer(appId: string): Promise { + return rpcClient.call({ + method: 'container-remove', + params: { app_id: appId }, + }) + }, + + /** + * Get container status + */ + async getContainerStatus(appId: string): Promise { + return rpcClient.call({ + method: 'container-status', + params: { app_id: appId }, + }) + }, + + /** + * Get container logs + */ + async getContainerLogs(appId: string, lines: number = 100): Promise { + return rpcClient.call({ + method: 'container-logs', + params: { app_id: appId, lines }, + }) + }, + + /** + * List all containers + */ + async listContainers(): Promise { + return rpcClient.call({ + method: 'container-list', + params: {}, + }) + }, + + /** + * Get health status for all containers + */ + async getHealthStatus(): Promise> { + return rpcClient.call>({ + method: 'container-health', + params: {}, + }) + }, +} diff --git a/neode-ui/src/components/ContainerStatus.vue b/neode-ui/src/components/ContainerStatus.vue new file mode 100644 index 00000000..1ef698e2 --- /dev/null +++ b/neode-ui/src/components/ContainerStatus.vue @@ -0,0 +1,116 @@ + + + diff --git a/neode-ui/src/stores/container.ts b/neode-ui/src/stores/container.ts new file mode 100644 index 00000000..d26df867 --- /dev/null +++ b/neode-ui/src/stores/container.ts @@ -0,0 +1,139 @@ +// Pinia store for container management +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { containerClient, type ContainerStatus, type ContainerAppInfo } from '@/api/container-client' + +export const useContainerStore = defineStore('container', () => { + // State + const containers = ref([]) + const healthStatus = ref>({}) + const loading = ref(false) + const error = ref(null) + + // Getters + const runningContainers = computed(() => + containers.value.filter(c => c.state === 'running') + ) + + const stoppedContainers = computed(() => + containers.value.filter(c => c.state === 'stopped' || c.state === 'exited') + ) + + const getContainerById = computed(() => (id: string) => + containers.value.find(c => c.name.includes(id)) + ) + + const getHealthStatus = computed(() => (appId: string) => + healthStatus.value[appId] || 'unknown' + ) + + // Actions + async function fetchContainers() { + loading.value = true + error.value = null + try { + containers.value = await containerClient.listContainers() + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to fetch containers' + console.error('Failed to fetch containers:', e) + } finally { + loading.value = false + } + } + + async function fetchHealthStatus() { + try { + healthStatus.value = await containerClient.getHealthStatus() + } catch (e) { + console.error('Failed to fetch health status:', e) + } + } + + async function installApp(manifestPath: string) { + loading.value = true + error.value = null + try { + const containerName = await containerClient.installApp(manifestPath) + await fetchContainers() + return containerName + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to install app' + throw e + } finally { + loading.value = false + } + } + + async function startContainer(appId: string) { + loading.value = true + error.value = null + try { + await containerClient.startContainer(appId) + await fetchContainers() + await fetchHealthStatus() + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to start container' + throw e + } finally { + loading.value = false + } + } + + async function stopContainer(appId: string) { + loading.value = true + error.value = null + try { + await containerClient.stopContainer(appId) + await fetchContainers() + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to stop container' + throw e + } finally { + loading.value = false + } + } + + async function removeContainer(appId: string) { + loading.value = true + error.value = null + try { + await containerClient.removeContainer(appId) + await fetchContainers() + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to remove container' + throw e + } finally { + loading.value = false + } + } + + async function getContainerLogs(appId: string, lines: number = 100) { + try { + return await containerClient.getContainerLogs(appId, lines) + } catch (e) { + error.value = e instanceof Error ? e.message : 'Failed to get logs' + throw e + } + } + + return { + // State + containers, + healthStatus, + loading, + error, + // Getters + runningContainers, + stoppedContainers, + getContainerById, + getHealthStatus, + // Actions + fetchContainers, + fetchHealthStatus, + installApp, + startContainer, + stopContainer, + removeContainer, + getContainerLogs, + } +}) diff --git a/neode-ui/src/views/ContainerAppDetails.vue b/neode-ui/src/views/ContainerAppDetails.vue new file mode 100644 index 00000000..170dbd30 --- /dev/null +++ b/neode-ui/src/views/ContainerAppDetails.vue @@ -0,0 +1,260 @@ + + + diff --git a/neode-ui/src/views/ContainerApps.vue b/neode-ui/src/views/ContainerApps.vue new file mode 100644 index 00000000..a2a3f81a --- /dev/null +++ b/neode-ui/src/views/ContainerApps.vue @@ -0,0 +1,165 @@ + + + diff --git a/scripts/optimize-alpine.sh b/scripts/optimize-alpine.sh new file mode 100755 index 00000000..66e0f20f --- /dev/null +++ b/scripts/optimize-alpine.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# Alpine Linux optimization script for Archipelago +# Optimizes system settings for container workloads + +set -e + +echo "⚡ Optimizing Alpine Linux for container workloads..." + +# CPU Governor - set to performance for better container performance +if [ -f /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor ]; then + echo "performance" > /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor 2>/dev/null || true +fi + +# I/O Scheduler - use deadline or none for SSDs +if command -v lsblk >/dev/null 2>&1; then + for disk in $(lsblk -d -o NAME -n); do + if [ -f "/sys/block/$disk/queue/scheduler" ]; then + # Prefer none (for NVMe) or deadline (for SATA SSD) + if grep -q "none" "/sys/block/$disk/queue/scheduler"; then + echo none > "/sys/block/$disk/queue/scheduler" 2>/dev/null || true + elif grep -q "deadline" "/sys/block/$disk/queue/scheduler"; then + echo deadline > "/sys/block/$disk/queue/scheduler" 2>/dev/null || true + fi + fi + done +fi + +# Increase file descriptor limits +cat >> /etc/security/limits.conf <> /etc/sysctl.conf </dev/null 2>&1 || true + +echo "✅ Alpine optimization complete!" diff --git a/scripts/parmanode-wrapper.sh b/scripts/parmanode-wrapper.sh new file mode 100755 index 00000000..13acfbc1 --- /dev/null +++ b/scripts/parmanode-wrapper.sh @@ -0,0 +1,38 @@ +#!/bin/bash +# Parmanode compatibility wrapper +# Allows running Parmanode scripts directly while wrapping them in container isolation + +set -e + +SCRIPT_PATH="$1" +MODULE_NAME="${2:-$(basename "$SCRIPT_PATH" .sh)}" + +if [ -z "$SCRIPT_PATH" ]; then + echo "Usage: $0 [module-name]" + exit 1 +fi + +if [ ! -f "$SCRIPT_PATH" ]; then + echo "Error: Script not found: $SCRIPT_PATH" + exit 1 +fi + +echo "🔧 Running Parmanode script in container: $SCRIPT_PATH" + +# Create temporary container to run the script +CONTAINER_NAME="parmanode-${MODULE_NAME}-$$" + +# Run script in Alpine container with necessary volumes +podman run --rm \ + --name "$CONTAINER_NAME" \ + --volume "$SCRIPT_PATH:/script.sh:ro" \ + --volume "/var/lib/archipelago:/data:rw" \ + --network host \ + alpine:latest \ + sh -c " + apk add --no-cache bash curl wget || true + chmod +x /script.sh + /script.sh + " + +echo "✅ Parmanode script completed"