Initial commit
This commit is contained in:
48
apps/README.md
Normal file
48
apps/README.md
Normal file
@@ -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.
|
||||
58
apps/bitcoin-core/manifest.yml
Normal file
58
apps/bitcoin-core/manifest.yml
Normal file
@@ -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
|
||||
63
apps/btcpay-server/manifest.yml
Normal file
63
apps/btcpay-server/manifest.yml
Normal file
@@ -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
|
||||
60
apps/core-lightning/manifest.yml
Normal file
60
apps/core-lightning/manifest.yml
Normal file
@@ -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
|
||||
54
apps/did-wallet/manifest.yml
Normal file
54
apps/did-wallet/manifest.yml
Normal file
@@ -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
|
||||
64
apps/lnd/manifest.yml
Normal file
64
apps/lnd/manifest.yml
Normal file
@@ -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
|
||||
54
apps/meshtastic/manifest.yml
Normal file
54
apps/meshtastic/manifest.yml
Normal file
@@ -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
|
||||
52
apps/nostr-rs-relay/manifest.yml
Normal file
52
apps/nostr-rs-relay/manifest.yml
Normal file
@@ -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
|
||||
58
apps/router/manifest.yml
Normal file
58
apps/router/manifest.yml
Normal file
@@ -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]
|
||||
48
apps/strfry/manifest.yml
Normal file
48
apps/strfry/manifest.yml
Normal file
@@ -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
|
||||
52
apps/web5-dwn/manifest.yml
Normal file
52
apps/web5-dwn/manifest.yml
Normal file
@@ -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
|
||||
24
core/container/Cargo.toml
Normal file
24
core/container/Cargo.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "archipelago-container"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
serde_json = "1.0"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.11", features = ["json"] }
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
async-trait = "0.1"
|
||||
futures = "0.3"
|
||||
indexmap = { version = "2.0", features = ["serde"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
|
||||
[lib]
|
||||
name = "archipelago_container"
|
||||
path = "src/lib.rs"
|
||||
255
core/container/src/dependency_resolver.rs
Normal file
255
core/container/src/dependency_resolver.rs
Normal file
@@ -0,0 +1,255 @@
|
||||
use crate::manifest::{AppManifest, Dependency};
|
||||
use indexmap::IndexMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DependencyError {
|
||||
#[error("Circular dependency detected: {0}")]
|
||||
CircularDependency(String),
|
||||
#[error("Missing dependency: {0}")]
|
||||
MissingDependency(String),
|
||||
#[error("Version conflict: {0}")]
|
||||
VersionConflict(String),
|
||||
}
|
||||
|
||||
pub struct DependencyResolver {
|
||||
manifests: IndexMap<String, AppManifest>,
|
||||
}
|
||||
|
||||
impl DependencyResolver {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
manifests: IndexMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_manifest(&mut self, manifest: AppManifest) {
|
||||
self.manifests.insert(manifest.app.id.clone(), manifest);
|
||||
}
|
||||
|
||||
pub fn resolve_dependencies(&self, app_id: &str) -> Result<Vec<String>, DependencyError> {
|
||||
let mut visited = HashSet::new();
|
||||
let mut visiting = HashSet::new();
|
||||
let mut result = Vec::new();
|
||||
|
||||
self.resolve_recursive(app_id, &mut visited, &mut visiting, &mut result)?;
|
||||
|
||||
// Reverse to get installation order (dependencies first)
|
||||
result.reverse();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn resolve_recursive(
|
||||
&self,
|
||||
app_id: &str,
|
||||
visited: &mut HashSet<String>,
|
||||
visiting: &mut HashSet<String>,
|
||||
result: &mut Vec<String>,
|
||||
) -> Result<(), DependencyError> {
|
||||
if visited.contains(app_id) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if visiting.contains(app_id) {
|
||||
return Err(DependencyError::CircularDependency(
|
||||
format!("Circular dependency detected involving: {}", app_id)
|
||||
));
|
||||
}
|
||||
|
||||
visiting.insert(app_id.to_string());
|
||||
|
||||
let manifest = self.manifests.get(app_id)
|
||||
.ok_or_else(|| DependencyError::MissingDependency(
|
||||
format!("App not found: {}", app_id)
|
||||
))?;
|
||||
|
||||
// Resolve all dependencies first
|
||||
for dep in &manifest.app.dependencies {
|
||||
match dep {
|
||||
Dependency::App { app_id: dep_id, version: _ } => {
|
||||
self.resolve_recursive(dep_id, visited, visiting, result)?;
|
||||
}
|
||||
Dependency::Storage { storage: _ } => {
|
||||
// Storage dependencies are checked but don't require other apps
|
||||
}
|
||||
Dependency::Simple(dep_id) => {
|
||||
self.resolve_recursive(dep_id, visited, visiting, result)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visiting.remove(app_id);
|
||||
visited.insert(app_id.to_string());
|
||||
|
||||
if !result.contains(&app_id.to_string()) {
|
||||
result.push(app_id.to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn check_conflicts(&self, app_id: &str) -> Result<(), DependencyError> {
|
||||
let manifest = self.manifests.get(app_id)
|
||||
.ok_or_else(|| DependencyError::MissingDependency(
|
||||
format!("App not found: {}", app_id)
|
||||
))?;
|
||||
|
||||
// Check for port conflicts
|
||||
let mut port_usage: HashMap<u16, String> = HashMap::new();
|
||||
|
||||
for (id, m) in &self.manifests {
|
||||
if id == app_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
for port in &m.app.ports {
|
||||
if let Some(existing) = port_usage.get(&port.host) {
|
||||
return Err(DependencyError::VersionConflict(
|
||||
format!("Port {} already used by {}", port.host, existing)
|
||||
));
|
||||
}
|
||||
port_usage.insert(port.host, id.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Check for new app's ports
|
||||
for port in &manifest.app.ports {
|
||||
if let Some(existing) = port_usage.get(&port.host) {
|
||||
return Err(DependencyError::VersionConflict(
|
||||
format!("Port {} already used by {}", port.host, existing)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn calculate_resources(&self, app_ids: &[String]) -> ResourceRequirements {
|
||||
let mut total = ResourceRequirements {
|
||||
cpu: 0,
|
||||
memory_mb: 0,
|
||||
disk_gb: 0,
|
||||
};
|
||||
|
||||
for app_id in app_ids {
|
||||
if let Some(manifest) = self.manifests.get(app_id) {
|
||||
if let Some(cpu) = manifest.app.resources.cpu_limit {
|
||||
total.cpu += cpu;
|
||||
}
|
||||
|
||||
if let Some(memory) = &manifest.app.resources.memory_limit {
|
||||
// Parse memory string (e.g., "1Gi", "512Mi")
|
||||
if let Ok(mb) = parse_memory(memory) {
|
||||
total.memory_mb += mb;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(disk) = &manifest.app.resources.disk_limit {
|
||||
// Parse disk string (e.g., "10Gi", "500Mi")
|
||||
if let Ok(gb) = parse_disk(disk) {
|
||||
total.disk_gb += gb;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_memory(s: &str) -> Result<u32, ()> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with("gi") {
|
||||
let num: f64 = s.trim_end_matches("gi").parse().map_err(|_| ())?;
|
||||
Ok((num * 1024.0) as u32)
|
||||
} else if s.ends_with("mi") {
|
||||
let num: f64 = s.trim_end_matches("mi").parse().map_err(|_| ())?;
|
||||
Ok(num as u32)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_disk(s: &str) -> Result<u32, ()> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with("gi") {
|
||||
let num: f64 = s.trim_end_matches("gi").parse().map_err(|_| ())?;
|
||||
Ok(num as u32)
|
||||
} else if s.ends_with("ti") {
|
||||
let num: f64 = s.trim_end_matches("ti").parse().map_err(|_| ())?;
|
||||
Ok((num * 1024.0) as u32)
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceRequirements {
|
||||
pub cpu: u32,
|
||||
pub memory_mb: u32,
|
||||
pub disk_gb: u32,
|
||||
}
|
||||
|
||||
impl Default for DependencyResolver {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::manifest::{AppManifest, AppDefinition, ContainerConfig};
|
||||
|
||||
fn create_test_manifest(id: &str, deps: Vec<Dependency>) -> AppManifest {
|
||||
AppManifest {
|
||||
app: AppDefinition {
|
||||
id: id.to_string(),
|
||||
name: format!("Test {}", id),
|
||||
version: "1.0.0".to_string(),
|
||||
description: None,
|
||||
container: ContainerConfig {
|
||||
image: format!("test/{}:latest", id),
|
||||
image_signature: None,
|
||||
pull_policy: "if-not-present".to_string(),
|
||||
},
|
||||
dependencies: deps,
|
||||
resources: Default::default(),
|
||||
security: Default::default(),
|
||||
ports: vec![],
|
||||
volumes: vec![],
|
||||
environment: vec![],
|
||||
health_check: None,
|
||||
devices: vec![],
|
||||
extensions: Default::default(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_simple_dependency() {
|
||||
let mut resolver = DependencyResolver::new();
|
||||
resolver.add_manifest(create_test_manifest("app1", vec![]));
|
||||
resolver.add_manifest(create_test_manifest("app2", vec![
|
||||
Dependency::Simple("app1".to_string())
|
||||
]));
|
||||
|
||||
let deps = resolver.resolve_dependencies("app2").unwrap();
|
||||
assert_eq!(deps, vec!["app1", "app2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_circular_dependency() {
|
||||
let mut resolver = DependencyResolver::new();
|
||||
resolver.add_manifest(create_test_manifest("app1", vec![
|
||||
Dependency::Simple("app2".to_string())
|
||||
]));
|
||||
resolver.add_manifest(create_test_manifest("app2", vec![
|
||||
Dependency::Simple("app1".to_string())
|
||||
]));
|
||||
|
||||
let result = resolver.resolve_dependencies("app1");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
189
core/container/src/health_monitor.rs
Normal file
189
core/container/src/health_monitor.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
use crate::manifest::HealthCheck;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::Duration;
|
||||
use tokio::time::{interval, sleep};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum HealthStatus {
|
||||
Healthy,
|
||||
Unhealthy,
|
||||
Unknown,
|
||||
Starting,
|
||||
}
|
||||
|
||||
pub struct HealthMonitor {
|
||||
container_name: String,
|
||||
health_check: Option<HealthCheck>,
|
||||
}
|
||||
|
||||
impl HealthMonitor {
|
||||
pub fn new(container_name: String, health_check: Option<HealthCheck>) -> Self {
|
||||
Self {
|
||||
container_name,
|
||||
health_check,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_health(&self) -> Result<HealthStatus> {
|
||||
if let Some(ref check) = self.health_check {
|
||||
match check.check_type.as_str() {
|
||||
"http" => self.check_http_health(check).await,
|
||||
"exec" => self.check_exec_health(check).await,
|
||||
_ => {
|
||||
warn!("Unknown health check type: {}", check.check_type);
|
||||
Ok(HealthStatus::Unknown)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No health check defined, assume healthy if container is running
|
||||
Ok(HealthStatus::Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_http_health(&self, check: &HealthCheck) -> Result<HealthStatus> {
|
||||
let endpoint = check.endpoint.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("HTTP health check missing endpoint"))?;
|
||||
|
||||
let url = if let Some(path) = &check.path {
|
||||
format!("{}{}", endpoint, path)
|
||||
} else {
|
||||
endpoint.clone()
|
||||
};
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(5))
|
||||
.build()
|
||||
.context("Failed to create HTTP client")?;
|
||||
|
||||
match client.get(&url).send().await {
|
||||
Ok(response) => {
|
||||
if response.status().is_success() {
|
||||
Ok(HealthStatus::Healthy)
|
||||
} else {
|
||||
Ok(HealthStatus::Unhealthy)
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Health check failed for {}: {}", self.container_name, e);
|
||||
Ok(HealthStatus::Unhealthy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_exec_health(&self, check: &HealthCheck) -> Result<HealthStatus> {
|
||||
// Execute health check command in container
|
||||
let endpoint = check.endpoint.as_ref()
|
||||
.ok_or_else(|| anyhow::anyhow!("Exec health check missing endpoint"))?;
|
||||
|
||||
use tokio::process::Command;
|
||||
|
||||
let output = Command::new("podman")
|
||||
.arg("exec")
|
||||
.arg(&self.container_name)
|
||||
.arg("sh")
|
||||
.arg("-c")
|
||||
.arg(endpoint)
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute health check")?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(HealthStatus::Healthy)
|
||||
} else {
|
||||
Ok(HealthStatus::Unhealthy)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn monitor_health(
|
||||
&self,
|
||||
mut shutdown: tokio::sync::broadcast::Receiver<()>,
|
||||
on_status_change: impl Fn(HealthStatus) + Send + 'static,
|
||||
) -> Result<()> {
|
||||
let check = self.health_check.clone();
|
||||
let interval_duration = if let Some(ref check) = check {
|
||||
parse_duration(&check.interval).unwrap_or(Duration::from_secs(30))
|
||||
} else {
|
||||
Duration::from_secs(30)
|
||||
};
|
||||
|
||||
let mut interval = interval(interval_duration);
|
||||
let mut consecutive_failures = 0;
|
||||
let max_failures = check.as_ref()
|
||||
.map(|c| c.retries)
|
||||
.unwrap_or(3);
|
||||
|
||||
let mut last_status = HealthStatus::Unknown;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
_ = interval.tick() => {
|
||||
match self.check_health().await {
|
||||
Ok(status) => {
|
||||
if status != last_status {
|
||||
info!("Health status changed for {}: {:?} -> {:?}",
|
||||
self.container_name, last_status, status);
|
||||
on_status_change(status.clone());
|
||||
last_status = status.clone();
|
||||
}
|
||||
|
||||
match status {
|
||||
HealthStatus::Healthy => {
|
||||
consecutive_failures = 0;
|
||||
}
|
||||
HealthStatus::Unhealthy => {
|
||||
consecutive_failures += 1;
|
||||
if consecutive_failures >= max_failures {
|
||||
error!("Container {} is unhealthy after {} failures",
|
||||
self.container_name, consecutive_failures);
|
||||
// TODO: Trigger auto-restart or alert
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Health check error for {}: {}", self.container_name, e);
|
||||
consecutive_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown.recv() => {
|
||||
info!("Health monitoring stopped for {}", self.container_name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_duration(s: &str) -> Option<Duration> {
|
||||
let s = s.trim().to_lowercase();
|
||||
if s.ends_with('s') {
|
||||
let secs: u64 = s.trim_end_matches('s').parse().ok()?;
|
||||
Some(Duration::from_secs(secs))
|
||||
} else if s.ends_with('m') {
|
||||
let mins: u64 = s.trim_end_matches('m').parse().ok()?;
|
||||
Some(Duration::from_secs(mins * 60))
|
||||
} else if s.ends_with('h') {
|
||||
let hours: u64 = s.trim_end_matches('h').parse().ok()?;
|
||||
Some(Duration::from_secs(hours * 3600))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration() {
|
||||
assert_eq!(parse_duration("30s"), Some(Duration::from_secs(30)));
|
||||
assert_eq!(parse_duration("5m"), Some(Duration::from_secs(300)));
|
||||
assert_eq!(parse_duration("1h"), Some(Duration::from_secs(3600)));
|
||||
}
|
||||
}
|
||||
9
core/container/src/lib.rs
Normal file
9
core/container/src/lib.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
pub mod manifest;
|
||||
pub mod podman_client;
|
||||
pub mod dependency_resolver;
|
||||
pub mod health_monitor;
|
||||
|
||||
pub use manifest::{AppManifest, Dependency, ResourceLimits, SecurityPolicy, HealthCheck};
|
||||
pub use podman_client::PodmanClient;
|
||||
pub use dependency_resolver::DependencyResolver;
|
||||
pub use health_monitor::HealthMonitor;
|
||||
228
core/container/src/manifest.rs
Normal file
228
core/container/src/manifest.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("Invalid manifest: {0}")]
|
||||
Invalid(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("YAML parse error: {0}")]
|
||||
Yaml(#[from] serde_yaml::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppManifest {
|
||||
pub app: AppDefinition,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppDefinition {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub container: ContainerConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<Dependency>,
|
||||
|
||||
#[serde(default)]
|
||||
pub resources: ResourceLimits,
|
||||
|
||||
#[serde(default)]
|
||||
pub security: SecurityPolicy,
|
||||
|
||||
#[serde(default)]
|
||||
pub ports: Vec<PortMapping>,
|
||||
|
||||
#[serde(default)]
|
||||
pub volumes: Vec<Volume>,
|
||||
|
||||
#[serde(default)]
|
||||
pub environment: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub health_check: Option<HealthCheck>,
|
||||
|
||||
#[serde(default)]
|
||||
pub devices: Vec<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub extensions: HashMap<String, serde_yaml::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ContainerConfig {
|
||||
pub image: String,
|
||||
#[serde(default)]
|
||||
pub image_signature: Option<String>,
|
||||
#[serde(default = "default_pull_policy")]
|
||||
pub pull_policy: String,
|
||||
}
|
||||
|
||||
fn default_pull_policy() -> String {
|
||||
"if-not-present".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum Dependency {
|
||||
Storage { storage: String },
|
||||
App { app_id: String, version: Option<String> },
|
||||
Simple(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct ResourceLimits {
|
||||
#[serde(default)]
|
||||
pub cpu_limit: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub memory_limit: Option<String>,
|
||||
#[serde(default)]
|
||||
pub disk_limit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct SecurityPolicy {
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
#[serde(default = "default_true")]
|
||||
pub readonly_root: bool,
|
||||
#[serde(default = "default_network_policy")]
|
||||
pub network_policy: String,
|
||||
#[serde(default)]
|
||||
pub apparmor_profile: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_network_policy() -> String {
|
||||
"isolated".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PortMapping {
|
||||
pub host: u16,
|
||||
pub container: u16,
|
||||
#[serde(default)]
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
impl From<(u16, u16)> for PortMapping {
|
||||
fn from((host, container): (u16, u16)) -> Self {
|
||||
PortMapping {
|
||||
host,
|
||||
container,
|
||||
protocol: "tcp".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Volume {
|
||||
#[serde(rename = "type")]
|
||||
pub volume_type: String,
|
||||
pub source: String,
|
||||
pub target: String,
|
||||
#[serde(default)]
|
||||
pub options: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HealthCheck {
|
||||
#[serde(rename = "type")]
|
||||
pub check_type: String,
|
||||
pub endpoint: Option<String>,
|
||||
pub path: Option<String>,
|
||||
#[serde(default = "default_interval")]
|
||||
pub interval: String,
|
||||
#[serde(default = "default_timeout")]
|
||||
pub timeout: String,
|
||||
#[serde(default = "default_retries")]
|
||||
pub retries: u32,
|
||||
}
|
||||
|
||||
fn default_interval() -> String {
|
||||
"30s".to_string()
|
||||
}
|
||||
|
||||
fn default_timeout() -> String {
|
||||
"5s".to_string()
|
||||
}
|
||||
|
||||
fn default_retries() -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
impl AppManifest {
|
||||
pub fn from_file(path: &std::path::Path) -> Result<Self, ManifestError> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
Self::from_str(&content)
|
||||
}
|
||||
|
||||
pub fn from_str(content: &str) -> Result<Self, ManifestError> {
|
||||
let manifest: AppManifest = serde_yaml::from_str(content)?;
|
||||
manifest.validate()?;
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), ManifestError> {
|
||||
if self.app.id.is_empty() {
|
||||
return Err(ManifestError::Invalid("app.id cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
if self.app.container.image.is_empty() {
|
||||
return Err(ManifestError::Invalid("container.image cannot be empty".to_string()));
|
||||
}
|
||||
|
||||
// Validate version format (semantic versioning)
|
||||
if !self.app.version.chars().any(|c| c.is_ascii_digit()) {
|
||||
return Err(ManifestError::Invalid("app.version must contain at least one digit".to_string()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_manifest_parse() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: test-app
|
||||
name: Test App
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
"#;
|
||||
|
||||
let manifest = AppManifest::from_str(yaml).unwrap();
|
||||
assert_eq!(manifest.app.id, "test-app");
|
||||
assert_eq!(manifest.app.name, "Test App");
|
||||
assert_eq!(manifest.app.version, "1.0.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_validation() {
|
||||
let yaml = r#"
|
||||
app:
|
||||
id: ""
|
||||
name: Test
|
||||
version: 1.0.0
|
||||
container:
|
||||
image: test/image:latest
|
||||
"#;
|
||||
|
||||
let result = AppManifest::from_str(yaml);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
334
core/container/src/podman_client.rs
Normal file
334
core/container/src/podman_client.rs
Normal file
@@ -0,0 +1,334 @@
|
||||
use crate::manifest::{AppManifest, PortMapping, Volume};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::process::{Command, Stdio};
|
||||
use thiserror::Error;
|
||||
use tokio::process::Command as TokioCommand;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PodmanError {
|
||||
#[error("Podman command failed: {0}")]
|
||||
CommandFailed(String),
|
||||
#[error("Container not found: {0}")]
|
||||
NotFound(String),
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContainerStatus {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub state: ContainerState,
|
||||
pub image: String,
|
||||
pub created: String,
|
||||
pub ports: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ContainerState {
|
||||
Created,
|
||||
Running,
|
||||
Stopped,
|
||||
Exited,
|
||||
Paused,
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl From<&str> for ContainerState {
|
||||
fn from(s: &str) -> Self {
|
||||
match s.to_lowercase().as_str() {
|
||||
"created" => ContainerState::Created,
|
||||
"running" => ContainerState::Running,
|
||||
"stopped" => ContainerState::Stopped,
|
||||
"exited" => ContainerState::Exited,
|
||||
"paused" => ContainerState::Paused,
|
||||
other => ContainerState::Unknown(other.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PodmanClient {
|
||||
user: String,
|
||||
rootless: bool,
|
||||
}
|
||||
|
||||
impl PodmanClient {
|
||||
pub fn new(user: String) -> Self {
|
||||
Self {
|
||||
user,
|
||||
rootless: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn podman_command(&self) -> Command {
|
||||
let mut cmd = Command::new("podman");
|
||||
if self.rootless {
|
||||
// Run as the specified user
|
||||
cmd.env("HOME", format!("/home/{}", self.user));
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
fn podman_async(&self) -> TokioCommand {
|
||||
let mut cmd = TokioCommand::new("podman");
|
||||
if self.rootless {
|
||||
cmd.env("HOME", format!("/home/{}", self.user));
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
pub async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("pull").arg(image);
|
||||
|
||||
if let Some(sig) = signature {
|
||||
// Verify signature with cosign if provided
|
||||
cmd.arg("--signature-policy").arg("default");
|
||||
// TODO: Implement cosign verification
|
||||
log::warn!("Signature verification not yet implemented: {}", sig);
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to execute podman pull")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to pull image: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_container(
|
||||
&self,
|
||||
manifest: &AppManifest,
|
||||
name: &str,
|
||||
) -> Result<String> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("create");
|
||||
|
||||
// Container name
|
||||
cmd.arg("--name").arg(name);
|
||||
|
||||
// Read-only root filesystem
|
||||
if manifest.app.security.readonly_root {
|
||||
cmd.arg("--read-only");
|
||||
}
|
||||
|
||||
// Network policy
|
||||
match manifest.app.security.network_policy.as_str() {
|
||||
"host" => {
|
||||
cmd.arg("--network").arg("host");
|
||||
}
|
||||
"isolated" => {
|
||||
// Create isolated network (default)
|
||||
}
|
||||
_ => {
|
||||
cmd.arg("--network").arg(&manifest.app.security.network_policy);
|
||||
}
|
||||
}
|
||||
|
||||
// Port mappings
|
||||
for port in &manifest.app.ports {
|
||||
cmd.arg("-p").arg(format!("{}:{}", port.host, port.container));
|
||||
}
|
||||
|
||||
// Volumes
|
||||
for volume in &manifest.app.volumes {
|
||||
let mut mount = format!("{}:{}", volume.source, volume.target);
|
||||
if !volume.options.is_empty() {
|
||||
mount.push_str(&format!(":{}", volume.options.join(",")));
|
||||
}
|
||||
cmd.arg("-v").arg(mount);
|
||||
}
|
||||
|
||||
// Devices
|
||||
for device in &manifest.app.devices {
|
||||
cmd.arg("--device").arg(device);
|
||||
}
|
||||
|
||||
// Environment variables
|
||||
for env in &manifest.app.environment {
|
||||
cmd.arg("-e").arg(env);
|
||||
}
|
||||
|
||||
// Resource limits
|
||||
if let Some(cpu) = manifest.app.resources.cpu_limit {
|
||||
cmd.arg("--cpus").arg(cpu.to_string());
|
||||
}
|
||||
|
||||
if let Some(memory) = &manifest.app.resources.memory_limit {
|
||||
cmd.arg("--memory").arg(memory);
|
||||
}
|
||||
|
||||
// Capabilities (drop all, add specified)
|
||||
cmd.arg("--cap-drop").arg("ALL");
|
||||
for cap in &manifest.app.security.capabilities {
|
||||
cmd.arg("--cap-add").arg(cap);
|
||||
}
|
||||
|
||||
// Image
|
||||
cmd.arg(&manifest.app.container.image);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to create container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to create container: {}", stderr));
|
||||
}
|
||||
|
||||
let container_id = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
Ok(container_id)
|
||||
}
|
||||
|
||||
pub async fn start_container(&self, name: &str) -> Result<()> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("start").arg(name);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to start container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to start container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn stop_container(&self, name: &str) -> Result<()> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("stop").arg(name);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to stop container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to stop container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn remove_container(&self, name: &str) -> Result<()> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("rm").arg("-f").arg(name);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to remove container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to remove container: {}", stderr));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_container_status(&self, name: &str) -> Result<ContainerStatus> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("inspect")
|
||||
.arg("--format")
|
||||
.arg("{{.Id}}|{{.Name}}|{{.State.Status}}|{{.Config.Image}}|{{.Created}}|{{.NetworkSettings.Ports}}")
|
||||
.arg(name);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to inspect container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(anyhow::anyhow!("Container not found: {}", name));
|
||||
}
|
||||
|
||||
let info = String::from_utf8_lossy(&output.stdout);
|
||||
let parts: Vec<&str> = info.trim().split('|').collect();
|
||||
|
||||
if parts.len() < 5 {
|
||||
return Err(anyhow::anyhow!("Invalid container inspect output"));
|
||||
}
|
||||
|
||||
Ok(ContainerStatus {
|
||||
id: parts[0].to_string(),
|
||||
name: parts[1].to_string(),
|
||||
state: ContainerState::from(parts[2]),
|
||||
image: parts[3].to_string(),
|
||||
created: parts[4].to_string(),
|
||||
ports: vec![], // TODO: Parse ports from parts[5]
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn get_container_logs(&self, name: &str, lines: u32) -> Result<Vec<String>> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("logs")
|
||||
.arg("--tail")
|
||||
.arg(lines.to_string())
|
||||
.arg(name);
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to get container logs")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to get logs: {}", stderr));
|
||||
}
|
||||
|
||||
let logs = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(logs.lines().map(|s| s.to_string()).collect())
|
||||
}
|
||||
|
||||
pub async fn list_containers(&self) -> Result<Vec<ContainerStatus>> {
|
||||
let mut cmd = self.podman_async();
|
||||
cmd.arg("ps")
|
||||
.arg("-a")
|
||||
.arg("--format")
|
||||
.arg("json");
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.await
|
||||
.context("Failed to list containers")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Failed to list containers: {}", stderr));
|
||||
}
|
||||
|
||||
let json = String::from_utf8_lossy(&output.stdout);
|
||||
let containers: Vec<serde_json::Value> = serde_json::from_str(&json)
|
||||
.context("Failed to parse container list")?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
for container in containers {
|
||||
result.push(ContainerStatus {
|
||||
id: container["Id"].as_str().unwrap_or("").to_string(),
|
||||
name: container["Names"][0].as_str().unwrap_or("").to_string(),
|
||||
state: ContainerState::from(container["State"].as_str().unwrap_or("unknown")),
|
||||
image: container["Image"].as_str().unwrap_or("").to_string(),
|
||||
created: container["Created"].as_str().unwrap_or("").to_string(),
|
||||
ports: vec![],
|
||||
});
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
18
core/parmanode/Cargo.toml
Normal file
18
core/parmanode/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "archipelago-parmanode"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_yaml = "0.9"
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
archipelago-container = { path = "../container" }
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
|
||||
[lib]
|
||||
name = "archipelago_parmanode"
|
||||
path = "src/lib.rs"
|
||||
101
core/parmanode/src/converter.rs
Normal file
101
core/parmanode/src/converter.rs
Normal file
@@ -0,0 +1,101 @@
|
||||
// Parmanode to App Manifest converter
|
||||
// Converts Parmanode module structure to Archipelago app manifest format
|
||||
|
||||
use archipelago_container::AppManifest;
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use tracing::info;
|
||||
|
||||
pub struct ParmanodeConverter;
|
||||
|
||||
impl ParmanodeConverter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Convert a Parmanode module directory to an App Manifest
|
||||
pub async fn convert_to_manifest(&self, module_path: &PathBuf) -> Result<AppManifest> {
|
||||
info!("Converting Parmanode module to manifest: {:?}", module_path);
|
||||
|
||||
// Read Parmanode module metadata if available
|
||||
let module_name = module_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
|
||||
// Try to detect what the module installs
|
||||
let install_script = module_path.join("install.sh");
|
||||
let script_content = if install_script.exists() {
|
||||
fs::read_to_string(&install_script).await.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Infer app details from script content
|
||||
let (app_id, image) = self.infer_from_script(&script_content)?;
|
||||
|
||||
// Create a basic manifest
|
||||
let manifest_yaml = format!(
|
||||
r#"
|
||||
app:
|
||||
id: {}
|
||||
name: {}
|
||||
version: 1.0.0
|
||||
description: Converted from Parmanode module
|
||||
|
||||
container:
|
||||
image: {}
|
||||
pull_policy: if-not-present
|
||||
|
||||
resources:
|
||||
cpu_limit: 1
|
||||
memory_limit: 512Mi
|
||||
disk_limit: 10Gi
|
||||
|
||||
security:
|
||||
capabilities: []
|
||||
readonly_root: true
|
||||
network_policy: isolated
|
||||
"#,
|
||||
app_id, module_name, image
|
||||
);
|
||||
|
||||
AppManifest::from_str(&manifest_yaml)
|
||||
.context("Failed to create manifest from Parmanode module")
|
||||
}
|
||||
|
||||
fn infer_from_script(&self, script_content: &Option<String>) -> Result<(String, String)> {
|
||||
let content = script_content.as_deref().unwrap_or("");
|
||||
|
||||
// Try to detect Bitcoin Core
|
||||
if content.contains("bitcoind") || content.contains("bitcoin-core") {
|
||||
return Ok(("bitcoin-core".to_string(), "bitcoin/bitcoin:latest".to_string()));
|
||||
}
|
||||
|
||||
// Try to detect LND
|
||||
if content.contains("lnd") && !content.contains("lightning") {
|
||||
return Ok(("lnd".to_string(), "lightninglabs/lnd:latest".to_string()));
|
||||
}
|
||||
|
||||
// Try to detect Core Lightning
|
||||
if content.contains("clightning") || content.contains("core-lightning") {
|
||||
return Ok(("core-lightning".to_string(), "elementsproject/lightningd:latest".to_string()));
|
||||
}
|
||||
|
||||
// Try to detect Electrs
|
||||
if content.contains("electrs") {
|
||||
return Ok(("electrs".to_string(), "romanz/electrs:latest".to_string()));
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
Ok(("parmanode-module".to_string(), "alpine:latest".to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ParmanodeConverter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
5
core/parmanode/src/lib.rs
Normal file
5
core/parmanode/src/lib.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
pub mod script_runner;
|
||||
pub mod converter;
|
||||
|
||||
pub use script_runner::ParmanodeScriptRunner;
|
||||
pub use converter::ParmanodeConverter;
|
||||
128
core/parmanode/src/script_runner.rs
Normal file
128
core/parmanode/src/script_runner.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
// Parmanode script runner - executes Parmanode installation scripts in containers
|
||||
// Provides compatibility layer for existing Parmanode modules
|
||||
|
||||
use archipelago_container::{PodmanClient, AppManifest};
|
||||
use anyhow::{Context, Result};
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tokio::fs;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct ParmanodeScriptRunner {
|
||||
podman: PodmanClient,
|
||||
scripts_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ParmanodeScriptRunner {
|
||||
pub fn new(scripts_dir: PathBuf) -> Self {
|
||||
Self {
|
||||
podman: PodmanClient::new("archipelago".to_string()),
|
||||
scripts_dir,
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect if a path contains a Parmanode script
|
||||
pub fn is_parmanode_script(&self, path: &PathBuf) -> bool {
|
||||
// Check for common Parmanode script patterns
|
||||
path.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| {
|
||||
name.ends_with(".sh") && (
|
||||
name.contains("parmanode") ||
|
||||
name.contains("bitcoin") ||
|
||||
name.contains("lightning") ||
|
||||
name.contains("electrs")
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Run a Parmanode script in an isolated container
|
||||
pub async fn run_script(&self, script_path: &PathBuf) -> Result<()> {
|
||||
info!("Running Parmanode script: {:?}", script_path);
|
||||
|
||||
// Create a temporary container manifest for the script
|
||||
let script_name = script_path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or("parmanode-script");
|
||||
|
||||
// Create a minimal container to run the script
|
||||
let container_name = format!("parmanode-{}", script_name);
|
||||
|
||||
// Copy script to a location accessible by containers
|
||||
let script_content = fs::read_to_string(script_path).await
|
||||
.context("Failed to read Parmanode script")?;
|
||||
|
||||
// Create a wrapper script that runs in Alpine
|
||||
let wrapper_script = format!(
|
||||
r#"#!/bin/sh
|
||||
set -e
|
||||
{}
|
||||
"#,
|
||||
script_content
|
||||
);
|
||||
|
||||
// Write wrapper to temp location
|
||||
let temp_script = format!("/tmp/parmanode-{}.sh", script_name);
|
||||
fs::write(&temp_script, wrapper_script).await
|
||||
.context("Failed to write wrapper script")?;
|
||||
|
||||
// Make executable
|
||||
Command::new("chmod")
|
||||
.arg("+x")
|
||||
.arg(&temp_script)
|
||||
.output()
|
||||
.context("Failed to make script executable")?;
|
||||
|
||||
// Run script in a temporary Alpine container
|
||||
let output = Command::new("podman")
|
||||
.arg("run")
|
||||
.arg("--rm")
|
||||
.arg("--volume")
|
||||
.arg(format!("{}:/script.sh:ro", temp_script))
|
||||
.arg("alpine:latest")
|
||||
.arg("sh")
|
||||
.arg("/script.sh")
|
||||
.output()
|
||||
.context("Failed to execute Parmanode script in container")?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Parmanode script failed: {}", stderr));
|
||||
}
|
||||
|
||||
info!("Parmanode script completed successfully");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Install a Parmanode module (runs script and sets up container)
|
||||
pub async fn install_module(&self, module_path: &PathBuf) -> Result<String> {
|
||||
// Find the main installation script
|
||||
let install_script = module_path.join("install.sh");
|
||||
if !install_script.exists() {
|
||||
return Err(anyhow::anyhow!("No install.sh found in Parmanode module"));
|
||||
}
|
||||
|
||||
// Run the installation script
|
||||
self.run_script(&install_script).await?;
|
||||
|
||||
// Try to convert to app manifest for future management
|
||||
let converter = crate::converter::ParmanodeConverter::new();
|
||||
match converter.convert_to_manifest(module_path).await {
|
||||
Ok(manifest) => {
|
||||
info!("Converted Parmanode module to app manifest");
|
||||
// TODO: Save manifest for future use
|
||||
Ok(manifest.app.id)
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("Failed to convert Parmanode module: {}", e);
|
||||
// Return a generic ID
|
||||
Ok(format!("parmanode-{}",
|
||||
module_path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("module")))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
16
core/performance/Cargo.toml
Normal file
16
core/performance/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "archipelago-performance"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
|
||||
[lib]
|
||||
name = "archipelago_performance"
|
||||
path = "src/lib.rs"
|
||||
3
core/performance/src/lib.rs
Normal file
3
core/performance/src/lib.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod resource_manager;
|
||||
|
||||
pub use resource_manager::ResourceManager;
|
||||
89
core/performance/src/resource_manager.rs
Normal file
89
core/performance/src/resource_manager.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
// Resource management and optimization for containers
|
||||
// Handles CPU, memory, and disk I/O limits and optimization
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{info, warn};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResourceLimits {
|
||||
pub cpu_cores: f64,
|
||||
pub memory_mb: u32,
|
||||
pub disk_io_read_mbps: Option<u32>,
|
||||
pub disk_io_write_mbps: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SystemResources {
|
||||
pub total_cpu_cores: u32,
|
||||
pub total_memory_mb: u32,
|
||||
pub available_disk_gb: u32,
|
||||
}
|
||||
|
||||
pub struct ResourceManager {
|
||||
system_resources: SystemResources,
|
||||
allocated_resources: HashMap<String, ResourceLimits>,
|
||||
}
|
||||
|
||||
impl ResourceManager {
|
||||
pub fn new(system_resources: SystemResources) -> Self {
|
||||
Self {
|
||||
system_resources,
|
||||
allocated_resources: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if resources are available for a new container
|
||||
pub fn can_allocate(&self, requested: &ResourceLimits) -> Result<bool> {
|
||||
let mut used_cpu = 0.0;
|
||||
let mut used_memory = 0;
|
||||
|
||||
for limits in self.allocated_resources.values() {
|
||||
used_cpu += limits.cpu_cores;
|
||||
used_memory += limits.memory_mb;
|
||||
}
|
||||
|
||||
let available_cpu = self.system_resources.total_cpu_cores as f64 - used_cpu;
|
||||
let available_memory = self.system_resources.total_memory_mb - used_memory;
|
||||
|
||||
if requested.cpu_cores > available_cpu {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if requested.memory_mb > available_memory {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Allocate resources for a container
|
||||
pub fn allocate(&mut self, container_id: String, limits: ResourceLimits) -> Result<()> {
|
||||
if !self.can_allocate(&limits)? {
|
||||
return Err(anyhow::anyhow!("Insufficient resources"));
|
||||
}
|
||||
|
||||
self.allocated_resources.insert(container_id, limits);
|
||||
info!("Allocated resources for container");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release resources for a container
|
||||
pub fn release(&mut self, container_id: &str) {
|
||||
self.allocated_resources.remove(container_id);
|
||||
info!("Released resources for container: {}", container_id);
|
||||
}
|
||||
|
||||
/// Get current resource usage
|
||||
pub fn get_usage(&self) -> (f64, u32) {
|
||||
let cpu: f64 = self.allocated_resources.values().map(|r| r.cpu_cores).sum();
|
||||
let memory: u32 = self.allocated_resources.values().map(|r| r.memory_mb).sum();
|
||||
(cpu, memory)
|
||||
}
|
||||
|
||||
/// Optimize resource allocation (reduce limits for low-priority containers)
|
||||
pub fn optimize_allocation(&mut self) {
|
||||
// TODO: Implement dynamic resource adjustment based on usage
|
||||
info!("Optimizing resource allocation");
|
||||
}
|
||||
}
|
||||
18
core/security/Cargo.toml
Normal file
18
core/security/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "archipelago-security"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
log = "0.4"
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.0", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[lib]
|
||||
name = "archipelago_security"
|
||||
path = "src/lib.rs"
|
||||
74
core/security/src/container_policies.rs
Normal file
74
core/security/src/container_policies.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
// AppArmor/SELinux policy generator for containers
|
||||
// Creates security profiles for each containerized app
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
|
||||
pub struct ContainerPolicyGenerator {
|
||||
policies_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl ContainerPolicyGenerator {
|
||||
pub fn new(policies_dir: PathBuf) -> Self {
|
||||
Self { policies_dir }
|
||||
}
|
||||
|
||||
/// Generate AppArmor profile for a container
|
||||
pub async fn generate_apparmor_profile(
|
||||
&self,
|
||||
app_id: &str,
|
||||
capabilities: &[String],
|
||||
readonly: bool,
|
||||
) -> Result<PathBuf> {
|
||||
let profile_path = self.policies_dir.join(format!("{}.apparmor", app_id));
|
||||
|
||||
let mut profile = String::from("# AppArmor profile for Archipelago container\n");
|
||||
profile.push_str(&format!("profile archipelago-{} flags=(attach_disconnected,mediate_deleted) {{\n", app_id));
|
||||
|
||||
// Base includes
|
||||
profile.push_str(" #include <abstractions/base>\n");
|
||||
|
||||
// Capabilities
|
||||
if capabilities.is_empty() {
|
||||
profile.push_str(" capability,\n");
|
||||
} else {
|
||||
for cap in capabilities {
|
||||
profile.push_str(&format!(" capability {},\n", cap));
|
||||
}
|
||||
}
|
||||
|
||||
// Filesystem access
|
||||
if readonly {
|
||||
profile.push_str(" deny / rw,\n");
|
||||
profile.push_str(&format!(" /var/lib/archipelago/{} rw,\n", app_id));
|
||||
} else {
|
||||
profile.push_str(" / r,\n");
|
||||
profile.push_str(&format!(" /var/lib/archipelago/{} rw,\n", app_id));
|
||||
}
|
||||
|
||||
// Network
|
||||
profile.push_str(" network,\n");
|
||||
|
||||
profile.push_str("}\n");
|
||||
|
||||
fs::write(&profile_path, profile).await?;
|
||||
Ok(profile_path)
|
||||
}
|
||||
|
||||
/// Apply AppArmor profile to a container
|
||||
pub async fn apply_profile(&self, container_name: &str, profile_path: &PathBuf) -> Result<()> {
|
||||
// Load the profile
|
||||
tokio::process::Command::new("apparmor_parser")
|
||||
.arg("-r")
|
||||
.arg(profile_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
// TODO: Configure Podman to use the profile
|
||||
// This requires Podman configuration changes
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
90
core/security/src/image_verifier.rs
Normal file
90
core/security/src/image_verifier.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
// Container image signature verification using Cosign
|
||||
// Verifies that container images are signed and trusted
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::process::Command;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct ImageVerifier {
|
||||
cosign_public_key: Option<String>, // Public key for verification
|
||||
}
|
||||
|
||||
impl ImageVerifier {
|
||||
pub fn new(cosign_public_key: Option<String>) -> Self {
|
||||
Self { cosign_public_key }
|
||||
}
|
||||
|
||||
/// Verify a container image signature
|
||||
pub async fn verify_image(&self, image: &str, signature: Option<&str>) -> Result<bool> {
|
||||
if signature.is_none() && self.cosign_public_key.is_none() {
|
||||
warn!("No signature provided for image: {}", image);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Check if cosign is available
|
||||
let cosign_available = Command::new("cosign")
|
||||
.arg("version")
|
||||
.output()
|
||||
.is_ok();
|
||||
|
||||
if !cosign_available {
|
||||
warn!("Cosign not available, skipping signature verification");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// If public key is provided, use it for verification
|
||||
if let Some(ref public_key) = self.cosign_public_key {
|
||||
let output = Command::new("cosign")
|
||||
.arg("verify")
|
||||
.arg("--key")
|
||||
.arg(public_key)
|
||||
.arg(image)
|
||||
.output()
|
||||
.context("Failed to run cosign verify")?;
|
||||
|
||||
if output.status.success() {
|
||||
info!("Image signature verified: {}", image);
|
||||
return Ok(true);
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Signature verification failed: {}", stderr));
|
||||
}
|
||||
}
|
||||
|
||||
// If signature URL is provided, verify using that
|
||||
if let Some(sig_url) = signature {
|
||||
if sig_url.starts_with("cosign://") {
|
||||
// Extract signature reference
|
||||
let sig_ref = sig_url.strip_prefix("cosign://").unwrap();
|
||||
let output = Command::new("cosign")
|
||||
.arg("verify")
|
||||
.arg("--signature")
|
||||
.arg(sig_ref)
|
||||
.arg(image)
|
||||
.output()
|
||||
.context("Failed to run cosign verify")?;
|
||||
|
||||
if output.status.success() {
|
||||
info!("Image signature verified: {}", image);
|
||||
return Ok(true);
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(anyhow::anyhow!("Signature verification failed: {}", stderr));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Check if an image has a signature
|
||||
pub async fn has_signature(&self, image: &str) -> bool {
|
||||
// Try to find signature in registry
|
||||
let output = Command::new("cosign")
|
||||
.arg("triangulate")
|
||||
.arg(image)
|
||||
.output();
|
||||
|
||||
output.is_ok() && output.unwrap().status.success()
|
||||
}
|
||||
}
|
||||
7
core/security/src/lib.rs
Normal file
7
core/security/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
pub mod container_policies;
|
||||
pub mod secrets_manager;
|
||||
pub mod image_verifier;
|
||||
|
||||
pub use container_policies::ContainerPolicyGenerator;
|
||||
pub use secrets_manager::SecretsManager;
|
||||
pub use image_verifier::ImageVerifier;
|
||||
98
core/security/src/secrets_manager.rs
Normal file
98
core/security/src/secrets_manager.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
// Encrypted secrets management for containers
|
||||
// Stores secrets securely and injects them at runtime
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::fs;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub struct SecretsManager {
|
||||
secrets_dir: PathBuf,
|
||||
encryption_key: Vec<u8>, // In production, derive from user password
|
||||
}
|
||||
|
||||
impl SecretsManager {
|
||||
pub fn new(secrets_dir: PathBuf, encryption_key: Vec<u8>) -> Self {
|
||||
Self {
|
||||
secrets_dir,
|
||||
encryption_key,
|
||||
}
|
||||
}
|
||||
|
||||
/// Store a secret for an app
|
||||
pub async fn store_secret(
|
||||
&self,
|
||||
app_id: &str,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> Result<String> {
|
||||
let secret_id = Uuid::new_v4().to_string();
|
||||
let secret_path = self.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
|
||||
fs::create_dir_all(secret_path.parent().unwrap()).await?;
|
||||
|
||||
// TODO: Encrypt the secret value
|
||||
// For now, store as plaintext (MUST be encrypted in production)
|
||||
fs::write(&secret_path, value).await
|
||||
.context("Failed to write secret")?;
|
||||
|
||||
// Set restrictive permissions
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut perms = fs::metadata(&secret_path).await?.permissions();
|
||||
perms.set_mode(0o600);
|
||||
fs::set_permissions(&secret_path, perms).await?;
|
||||
}
|
||||
|
||||
Ok(secret_id)
|
||||
}
|
||||
|
||||
/// Retrieve a secret (returns the secret ID path for volume mounting)
|
||||
pub fn get_secret_path(&self, app_id: &str, secret_id: &str) -> PathBuf {
|
||||
self.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id))
|
||||
}
|
||||
|
||||
/// List secrets for an app
|
||||
pub async fn list_secrets(&self, app_id: &str) -> Result<Vec<String>> {
|
||||
let app_secrets_dir = self.secrets_dir.join(app_id);
|
||||
|
||||
if !app_secrets_dir.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut secrets = Vec::new();
|
||||
let mut entries = fs::read_dir(&app_secrets_dir).await?;
|
||||
|
||||
while let Some(entry) = entries.next_entry().await? {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|s| s.to_str()) == Some("secret") {
|
||||
if let Some(secret_id) = path.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.to_string()) {
|
||||
secrets.push(secret_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(secrets)
|
||||
}
|
||||
|
||||
/// Delete a secret
|
||||
pub async fn delete_secret(&self, app_id: &str, secret_id: &str) -> Result<()> {
|
||||
let secret_path = self.secrets_dir
|
||||
.join(app_id)
|
||||
.join(format!("{}.secret", secret_id));
|
||||
|
||||
if secret_path.exists() {
|
||||
fs::remove_file(&secret_path).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
126
docs/app-manifest-spec.md
Normal file
126
docs/app-manifest-spec.md
Normal file
@@ -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
|
||||
165
docs/architecture.md
Normal file
165
docs/architecture.md
Normal file
@@ -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)
|
||||
2
image-recipe/.gitignore
vendored
Normal file
2
image-recipe/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
results/
|
||||
*.deb
|
||||
93
image-recipe/Dockerfile.alpine-base
Normal file
93
image-recipe/Dockerfile.alpine-base
Normal file
@@ -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"]
|
||||
23
image-recipe/README.md
Normal file
23
image-recipe/README.md
Normal file
@@ -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 <dpkg_url>
|
||||
|
||||
# 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.
|
||||
41
image-recipe/build-alpine.sh
Executable file
41
image-recipe/build-alpine.sh
Executable file
@@ -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}"
|
||||
355
image-recipe/build.sh
Executable file
355
image-recipe/build.sh
Executable file
@@ -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
|
||||
24
image-recipe/configs/logrotate.conf
Normal file
24
image-recipe/configs/logrotate.conf
Normal file
@@ -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
|
||||
}
|
||||
24
image-recipe/prepare.sh
Executable file
24
image-recipe/prepare.sh
Executable file
@@ -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
|
||||
2
image-recipe/raspberrypi/img/etc/fstab
Normal file
2
image-recipe/raspberrypi/img/etc/fstab
Normal file
@@ -0,0 +1,2 @@
|
||||
/dev/mmcblk0p1 /boot vfat umask=0077 0 2
|
||||
/dev/mmcblk0p2 / ext4 defaults 0 1
|
||||
129
image-recipe/raspberrypi/img/usr/lib/startos/scripts/init_resize.sh
Executable file
129
image-recipe/raspberrypi/img/usr/lib/startos/scripts/init_resize.sh
Executable file
@@ -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
|
||||
1
image-recipe/raspberrypi/squashfs/boot/cmdline.txt
Normal file
1
image-recipe/raspberrypi/squashfs/boot/cmdline.txt
Normal file
@@ -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
|
||||
86
image-recipe/raspberrypi/squashfs/boot/config.txt
Normal file
86
image-recipe/raspberrypi/squashfs/boot/config.txt
Normal file
@@ -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+
|
||||
@@ -0,0 +1,6 @@
|
||||
os-partitions:
|
||||
boot: /dev/mmcblk0p1
|
||||
root: /dev/mmcblk0p2
|
||||
ethernet-interface: end0
|
||||
wifi-interface: wlan0
|
||||
disable-encryption: true
|
||||
@@ -0,0 +1 @@
|
||||
options cfg80211 ieee80211_regdom=US
|
||||
69
image-recipe/raspberrypi/squashfs/usr/bin/extract-ikconfig
Executable file
69
image-recipe/raspberrypi/squashfs/usr/bin/extract-ikconfig
Executable file
@@ -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 <dick@streefland.net>
|
||||
# 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 <kernel-image>" >&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
|
||||
88
image-recipe/run-local-build.sh
Executable file
88
image-recipe/run-local-build.sh
Executable file
@@ -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 <<END
|
||||
#!/bin/sh
|
||||
|
||||
export IB_SUITE=${SUITE}
|
||||
export IB_TARGET_ARCH=${ARCH}
|
||||
export IB_TARGET_PLATFORM=${PLATFORM}
|
||||
export IB_OS_ENV=${STARTOS_ENV}
|
||||
export VERSION=${VERSION}
|
||||
export VERSION_FULL=${VERSION_FULL}
|
||||
exec ./build.sh
|
||||
END
|
||||
|
||||
prepare_hash=$(sha1sum ${BASEDIR}/image-recipe/prepare.sh | head -c 7)
|
||||
|
||||
mkdir -p ${BASEDIR}/image-recipe/deb
|
||||
cp $DEB_PATH ${BASEDIR}/image-recipe/deb/
|
||||
|
||||
mkdir -p ${BASEDIR}/results
|
||||
set +e
|
||||
debspawn run \
|
||||
-x \
|
||||
--allow=read-kmods,kvm,full-dev \
|
||||
--cachekey="${SUITE}-${prepare_hash}-mkimage" \
|
||||
--init-command="${BASEDIR}/image-recipe/prepare.sh" \
|
||||
--build-dir="${BASEDIR}/image-recipe" \
|
||||
--artifacts-out="${BASEDIR}/results" \
|
||||
--header="StartOS Image Build" \
|
||||
--suite=${SUITE} \
|
||||
${DSNAME} \
|
||||
${imgbuild_fname}
|
||||
|
||||
retval=$?
|
||||
rm $imgbuild_fname
|
||||
if [ $retval -ne 0 ]; then
|
||||
exit $retval
|
||||
fi
|
||||
exit 0
|
||||
118
image-recipe/scripts/harden-alpine.sh
Executable file
118
image-recipe/scripts/harden-alpine.sh
Executable file
@@ -0,0 +1,118 @@
|
||||
#!/bin/bash
|
||||
# Alpine Linux Hardening Script for Archipelago Bitcoin Node OS
|
||||
# This script applies security hardening to the Alpine base image
|
||||
|
||||
set -e
|
||||
|
||||
echo "🔒 Starting Alpine Linux hardening..."
|
||||
|
||||
# Disable unnecessary services
|
||||
systemctl disable bluetooth || true
|
||||
systemctl disable avahi-daemon || true
|
||||
|
||||
# Configure kernel parameters for security
|
||||
cat >> /etc/sysctl.conf <<EOF
|
||||
|
||||
# Archipelago Security Hardening
|
||||
# Disable IP forwarding
|
||||
net.ipv4.ip_forward = 0
|
||||
net.ipv6.conf.all.forwarding = 0
|
||||
|
||||
# Enable SYN flood protection
|
||||
net.ipv4.tcp_syncookies = 1
|
||||
|
||||
# Disable source routing
|
||||
net.ipv4.conf.all.accept_source_route = 0
|
||||
net.ipv4.conf.default.accept_source_route = 0
|
||||
net.ipv6.conf.all.accept_source_route = 0
|
||||
net.ipv6.conf.default.accept_source_route = 0
|
||||
|
||||
# Disable ICMP redirects
|
||||
net.ipv4.conf.all.accept_redirects = 0
|
||||
net.ipv4.conf.default.accept_redirects = 0
|
||||
net.ipv6.conf.all.accept_redirects = 0
|
||||
net.ipv6.conf.default.accept_redirects = 0
|
||||
|
||||
# Disable send redirects
|
||||
net.ipv4.conf.all.send_redirects = 0
|
||||
net.ipv4.conf.default.send_redirects = 0
|
||||
|
||||
# Log martian packets
|
||||
net.ipv4.conf.all.log_martians = 1
|
||||
net.ipv4.conf.default.log_martians = 1
|
||||
|
||||
# Ignore ICMP ping requests (can be enabled if needed)
|
||||
# net.ipv4.icmp_echo_ignore_all = 1
|
||||
|
||||
# Ignore ICMP ping broadcasts
|
||||
net.ipv4.icmp_echo_ignore_broadcasts = 1
|
||||
|
||||
# Ignore bogus ICMP errors
|
||||
net.ipv4.icmp_ignore_bogus_error_responses = 1
|
||||
|
||||
# Enable RFC-recommended source validation
|
||||
net.ipv4.conf.all.rp_filter = 1
|
||||
net.ipv4.conf.default.rp_filter = 1
|
||||
|
||||
# Disable IPv6 if not needed (uncomment if IPv6 not required)
|
||||
# net.ipv6.conf.all.disable_ipv6 = 1
|
||||
# net.ipv6.conf.default.disable_ipv6 = 1
|
||||
EOF
|
||||
|
||||
# Configure SSH (if installed)
|
||||
if [ -f /etc/ssh/sshd_config ]; then
|
||||
sed -i 's/#PermitRootLogin yes/PermitRootLogin no/' /etc/ssh/sshd_config || true
|
||||
sed -i 's/#PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config || true
|
||||
sed -i 's/#PubkeyAuthentication yes/PubkeyAuthentication yes/' /etc/ssh/sshd_config || true
|
||||
fi
|
||||
|
||||
# Set up fail2ban basic configuration
|
||||
if [ -f /etc/fail2ban/jail.conf ]; then
|
||||
cat > /etc/fail2ban/jail.local <<EOF
|
||||
[DEFAULT]
|
||||
bantime = 3600
|
||||
findtime = 600
|
||||
maxretry = 5
|
||||
destemail = root@localhost
|
||||
sendername = Fail2Ban
|
||||
action = %(action_)s
|
||||
|
||||
[sshd]
|
||||
enabled = true
|
||||
port = ssh
|
||||
logpath = %(sshd_log)s
|
||||
backend = %(sshd_backend)s
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Configure automatic security updates
|
||||
cat > /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 <<EOF
|
||||
/var/log/archipelago/*.log {
|
||||
daily
|
||||
rotate 30
|
||||
compress
|
||||
delaycompress
|
||||
missingok
|
||||
notifempty
|
||||
create 0644 root root
|
||||
}
|
||||
EOF
|
||||
|
||||
echo "✅ Alpine Linux hardening complete!"
|
||||
59
image-recipe/scripts/install-podman.sh
Executable file
59
image-recipe/scripts/install-podman.sh
Executable file
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Podman Installation and Configuration Script for Archipelago
|
||||
# Configures Podman for rootless operation
|
||||
|
||||
set -e
|
||||
|
||||
echo "🐳 Configuring Podman for rootless operation..."
|
||||
|
||||
# Ensure archipelago user exists
|
||||
if ! id "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 <<EOF
|
||||
[storage]
|
||||
driver = "overlay"
|
||||
runroot = "/run/user/$(id -u archipelago)/containers"
|
||||
graphroot = "/home/archipelago/.local/share/containers/storage"
|
||||
EOF
|
||||
|
||||
# Configure registries (use Docker Hub and quay.io)
|
||||
mkdir -p /home/archipelago/.config/containers/registries.conf.d
|
||||
cat > /home/archipelago/.config/containers/registries.conf.d/000-shortnames.conf <<EOF
|
||||
[registries.search]
|
||||
registries = ['docker.io', 'quay.io', 'ghcr.io']
|
||||
|
||||
[registries.insecure]
|
||||
registries = []
|
||||
|
||||
[registries.block]
|
||||
registries = []
|
||||
EOF
|
||||
|
||||
# Set up subuid and subgid for rootless containers
|
||||
if ! grep -q "^archipelago:" /etc/subuid; then
|
||||
echo "archipelago:100000:65536" >> /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!"
|
||||
BIN
image-recipe/splash.png
Normal file
BIN
image-recipe/splash.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.6 KiB |
103
neode-ui/src/api/container-client.ts
Normal file
103
neode-ui/src/api/container-client.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// Container management API client
|
||||
// Extends RPC client with container-specific methods
|
||||
|
||||
import { rpcClient } from './rpc-client'
|
||||
|
||||
export interface ContainerStatus {
|
||||
id: string
|
||||
name: string
|
||||
state: 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
|
||||
image: string
|
||||
created: string
|
||||
ports: string[]
|
||||
}
|
||||
|
||||
export interface ContainerAppInfo {
|
||||
id: string
|
||||
name: string
|
||||
version: string
|
||||
status: ContainerStatus
|
||||
health: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||||
}
|
||||
|
||||
export const containerClient = {
|
||||
/**
|
||||
* Install a container app from a manifest file
|
||||
*/
|
||||
async installApp(manifestPath: string): Promise<string> {
|
||||
return rpcClient.call<string>({
|
||||
method: 'container-install',
|
||||
params: { manifest_path: manifestPath },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Start a container
|
||||
*/
|
||||
async startContainer(appId: string): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'container-start',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Stop a container
|
||||
*/
|
||||
async stopContainer(appId: string): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'container-stop',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove a container
|
||||
*/
|
||||
async removeContainer(appId: string): Promise<void> {
|
||||
return rpcClient.call<void>({
|
||||
method: 'container-remove',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get container status
|
||||
*/
|
||||
async getContainerStatus(appId: string): Promise<ContainerStatus> {
|
||||
return rpcClient.call<ContainerStatus>({
|
||||
method: 'container-status',
|
||||
params: { app_id: appId },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get container logs
|
||||
*/
|
||||
async getContainerLogs(appId: string, lines: number = 100): Promise<string[]> {
|
||||
return rpcClient.call<string[]>({
|
||||
method: 'container-logs',
|
||||
params: { app_id: appId, lines },
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* List all containers
|
||||
*/
|
||||
async listContainers(): Promise<ContainerStatus[]> {
|
||||
return rpcClient.call<ContainerStatus[]>({
|
||||
method: 'container-list',
|
||||
params: {},
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Get health status for all containers
|
||||
*/
|
||||
async getHealthStatus(): Promise<Record<string, 'healthy' | 'unhealthy' | 'unknown' | 'starting'>> {
|
||||
return rpcClient.call<Record<string, string>>({
|
||||
method: 'container-health',
|
||||
params: {},
|
||||
})
|
||||
},
|
||||
}
|
||||
116
neode-ui/src/components/ContainerStatus.vue
Normal file
116
neode-ui/src/components/ContainerStatus.vue
Normal file
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Status Indicator -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full transition-colors"
|
||||
:class="statusClass"
|
||||
></div>
|
||||
<div
|
||||
v-if="isRunning"
|
||||
class="absolute inset-0 w-3 h-3 rounded-full animate-ping opacity-75"
|
||||
:class="statusClass"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Status Text -->
|
||||
<span class="text-sm font-medium" :class="textClass">
|
||||
{{ statusText }}
|
||||
</span>
|
||||
|
||||
<!-- Health Badge (if running) -->
|
||||
<span
|
||||
v-if="isRunning && health"
|
||||
class="px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="healthClass"
|
||||
>
|
||||
{{ healthText }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
state: 'created' | 'running' | 'stopped' | 'exited' | 'paused' | 'unknown'
|
||||
health?: 'healthy' | 'unhealthy' | 'unknown' | 'starting'
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
health: 'unknown',
|
||||
})
|
||||
|
||||
const isRunning = computed(() => props.state === 'running')
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case 'running':
|
||||
return 'bg-green-400'
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return 'bg-gray-400'
|
||||
case 'paused':
|
||||
return 'bg-yellow-400'
|
||||
default:
|
||||
return 'bg-red-400'
|
||||
}
|
||||
})
|
||||
|
||||
const textClass = computed(() => {
|
||||
switch (props.state) {
|
||||
case 'running':
|
||||
return 'text-green-400'
|
||||
case 'stopped':
|
||||
case 'exited':
|
||||
return 'text-gray-400'
|
||||
case 'paused':
|
||||
return 'text-yellow-400'
|
||||
default:
|
||||
return 'text-red-400'
|
||||
}
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
switch (props.state) {
|
||||
case 'running':
|
||||
return 'Running'
|
||||
case 'stopped':
|
||||
return 'Stopped'
|
||||
case 'exited':
|
||||
return 'Exited'
|
||||
case 'paused':
|
||||
return 'Paused'
|
||||
case 'created':
|
||||
return 'Created'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
})
|
||||
|
||||
const healthClass = computed(() => {
|
||||
switch (props.health) {
|
||||
case 'healthy':
|
||||
return 'bg-green-500/20 text-green-400 border border-green-500/30'
|
||||
case 'unhealthy':
|
||||
return 'bg-red-500/20 text-red-400 border border-red-500/30'
|
||||
case 'starting':
|
||||
return 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/30'
|
||||
default:
|
||||
return 'bg-gray-500/20 text-gray-400 border border-gray-500/30'
|
||||
}
|
||||
})
|
||||
|
||||
const healthText = computed(() => {
|
||||
switch (props.health) {
|
||||
case 'healthy':
|
||||
return 'Healthy'
|
||||
case 'unhealthy':
|
||||
return 'Unhealthy'
|
||||
case 'starting':
|
||||
return 'Starting'
|
||||
default:
|
||||
return 'Unknown'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
139
neode-ui/src/stores/container.ts
Normal file
139
neode-ui/src/stores/container.ts
Normal file
@@ -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<ContainerStatus[]>([])
|
||||
const healthStatus = ref<Record<string, string>>({})
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(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,
|
||||
}
|
||||
})
|
||||
260
neode-ui/src/views/ContainerAppDetails.vue
Normal file
260
neode-ui/src/views/ContainerAppDetails.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-6">
|
||||
<button
|
||||
@click="$router.back()"
|
||||
class="mb-4 flex items-center gap-2 text-white/70 hover:text-white transition-colors"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ appName }}</h1>
|
||||
<p class="text-white/70">Container details and management</p>
|
||||
</div>
|
||||
<ContainerStatus
|
||||
v-if="container"
|
||||
:state="container.state as any"
|
||||
:health="healthStatus as any"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="glass-card p-6">
|
||||
<div class="flex items-center gap-3 text-red-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="container" class="space-y-6">
|
||||
<!-- Container Info Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Container Information</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Container ID</span>
|
||||
<p class="text-white/90 font-mono text-sm mt-1">{{ container.id }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Image</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ container.image }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">State</span>
|
||||
<p class="text-white/90 text-sm mt-1 capitalize">{{ container.state }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-white/60">Created</span>
|
||||
<p class="text-white/90 text-sm mt-1">{{ formatDate(container.created) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions Card -->
|
||||
<div class="glass-card p-6">
|
||||
<h2 class="text-xl font-semibold text-white mb-4">Actions</h2>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
v-if="container.state !== 'running'"
|
||||
@click="handleStart"
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Start Container
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="handleStop"
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Stop Container
|
||||
</button>
|
||||
<button
|
||||
@click="handleRestart"
|
||||
:disabled="actionLoading || container.state !== 'running'"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
@click="handleRemove"
|
||||
:disabled="actionLoading"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-red-400/90 hover:text-red-400 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logs Card -->
|
||||
<div class="glass-card p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-white">Logs</h2>
|
||||
<button
|
||||
@click="refreshLogs"
|
||||
:disabled="logsLoading"
|
||||
class="px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors disabled:opacity-50"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div class="bg-black/40 rounded-lg p-4 font-mono text-sm text-white/80 max-h-96 overflow-y-auto">
|
||||
<div v-if="logsLoading" class="text-center py-4 text-white/60">
|
||||
Loading logs...
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="text-center py-4 text-white/60">
|
||||
No logs available
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="(log, index) in logs" :key="index" class="mb-1">
|
||||
{{ log }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useContainerStore()
|
||||
|
||||
const appId = computed(() => route.params.id as string)
|
||||
const appName = computed(() => {
|
||||
return appId.value
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
const container = ref<any>(null)
|
||||
const logs = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const logsLoading = ref(false)
|
||||
const actionLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const healthStatus = ref<string>('unknown')
|
||||
|
||||
onMounted(async () => {
|
||||
await loadContainer()
|
||||
await loadLogs()
|
||||
await loadHealthStatus()
|
||||
})
|
||||
|
||||
async function loadContainer() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
try {
|
||||
const status = await store.getContainerStatus(appId.value)
|
||||
container.value = status
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load container'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
logs.value = await store.getContainerLogs(appId.value, 100)
|
||||
} catch (e) {
|
||||
console.error('Failed to load logs:', e)
|
||||
} finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHealthStatus() {
|
||||
await store.fetchHealthStatus()
|
||||
healthStatus.value = store.getHealthStatus(appId.value)
|
||||
}
|
||||
|
||||
async function refreshLogs() {
|
||||
await loadLogs()
|
||||
}
|
||||
|
||||
async function handleStart() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.startContainer(appId.value)
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to start container'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.stopContainer(appId.value)
|
||||
await loadContainer()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to stop container'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRestart() {
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.stopContainer(appId.value)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
await store.startContainer(appId.value)
|
||||
await loadContainer()
|
||||
await loadHealthStatus()
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to restart container'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove() {
|
||||
if (!confirm(`Are you sure you want to remove ${appName.value}? This will delete the container and all its data.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await store.removeContainer(appId.value)
|
||||
router.push('/dashboard/containers')
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to remove container'
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
</script>
|
||||
165
neode-ui/src/views/ContainerApps.vue
Normal file
165
neode-ui/src/views/ContainerApps.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<template>
|
||||
<div class="p-6">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-white mb-2">Container Apps</h1>
|
||||
<p class="text-white/70">Manage containerized applications running on your Archipelago node</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="store.loading" class="flex items-center justify-center py-12">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white/60"></div>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="store.error" class="glass-card p-6 mb-6">
|
||||
<div class="flex items-center gap-3 text-red-400">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>{{ store.error }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Container List -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="container in store.containers"
|
||||
:key="container.id"
|
||||
class="glass-card p-6 hover:bg-white/5 transition-colors cursor-pointer"
|
||||
@click="$router.push(`/dashboard/containers/${extractAppId(container.name)}`)"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-white mb-1">
|
||||
{{ extractAppName(container.name) }}
|
||||
</h3>
|
||||
<p class="text-sm text-white/60">{{ container.image }}</p>
|
||||
</div>
|
||||
<ContainerStatus
|
||||
:state="container.state as any"
|
||||
:health="store.getHealthStatus(extractAppId(container.name)) as any"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 mb-4">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-white/60">Container ID</span>
|
||||
<span class="text-white/80 font-mono text-xs">{{ container.id.substring(0, 12) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
<span class="text-white/60">Created</span>
|
||||
<span class="text-white/80 text-xs">{{ formatDate(container.created) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="container.state !== 'running'"
|
||||
@click.stop="handleStart(extractAppId(container.name))"
|
||||
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click.stop="handleStop(extractAppId(container.name))"
|
||||
class="flex-1 px-4 py-2 glass-button rounded text-sm font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
@click.stop="handleRemove(extractAppId(container.name))"
|
||||
class="px-4 py-2 glass-button rounded text-sm font-medium text-red-400/90 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="store.containers.length === 0" class="col-span-full glass-card p-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">No containers found</h3>
|
||||
<p class="text-white/60 mb-6">Install your first container app to get started</p>
|
||||
<button
|
||||
@click="$router.push('/dashboard/marketplace')"
|
||||
class="px-6 py-3 glass-button rounded-lg font-medium text-white/90 hover:text-white transition-colors"
|
||||
>
|
||||
Browse Marketplace
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useContainerStore } from '@/stores/container'
|
||||
import ContainerStatus from '@/components/ContainerStatus.vue'
|
||||
|
||||
const store = useContainerStore()
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchContainers()
|
||||
await store.fetchHealthStatus()
|
||||
|
||||
// Refresh every 30 seconds
|
||||
setInterval(async () => {
|
||||
await store.fetchContainers()
|
||||
await store.fetchHealthStatus()
|
||||
}, 30000)
|
||||
})
|
||||
|
||||
function extractAppId(containerName: string): string {
|
||||
// Extract app ID from container name like "archipelago-bitcoin-core"
|
||||
return containerName.replace('archipelago-', '')
|
||||
}
|
||||
|
||||
function extractAppName(containerName: string): string {
|
||||
const appId = extractAppId(containerName)
|
||||
// Convert kebab-case to Title Case
|
||||
return appId
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString()
|
||||
} catch {
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStart(appId: string) {
|
||||
try {
|
||||
await store.startContainer(appId)
|
||||
} catch (e) {
|
||||
console.error('Failed to start container:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStop(appId: string) {
|
||||
try {
|
||||
await store.stopContainer(appId)
|
||||
} catch (e) {
|
||||
console.error('Failed to stop container:', e)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(appId: string) {
|
||||
if (!confirm(`Are you sure you want to remove ${appId}? This will delete the container.`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await store.removeContainer(appId)
|
||||
} catch (e) {
|
||||
console.error('Failed to remove container:', e)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
54
scripts/optimize-alpine.sh
Executable file
54
scripts/optimize-alpine.sh
Executable file
@@ -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 <<EOF
|
||||
* soft nofile 65536
|
||||
* hard nofile 65536
|
||||
root soft nofile 65536
|
||||
root hard nofile 65536
|
||||
EOF
|
||||
|
||||
# Optimize network settings for container networking
|
||||
cat >> /etc/sysctl.conf <<EOF
|
||||
|
||||
# Container networking optimizations
|
||||
net.core.somaxconn = 4096
|
||||
net.ipv4.tcp_max_syn_backlog = 4096
|
||||
net.core.netdev_max_backlog = 5000
|
||||
net.ipv4.ip_local_port_range = 1024 65535
|
||||
|
||||
# Container storage optimizations
|
||||
vm.swappiness = 10
|
||||
vm.dirty_ratio = 15
|
||||
vm.dirty_background_ratio = 5
|
||||
EOF
|
||||
|
||||
# Apply sysctl settings
|
||||
sysctl -p >/dev/null 2>&1 || true
|
||||
|
||||
echo "✅ Alpine optimization complete!"
|
||||
38
scripts/parmanode-wrapper.sh
Executable file
38
scripts/parmanode-wrapper.sh
Executable file
@@ -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 <script-path> [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"
|
||||
Reference in New Issue
Block a user