Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 17 additions & 17 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ members = [
]

[workspace.package]
version = "0.3.4"
version = "0.3.5"
edition = "2024"
license = "MIT"
repository = "https://github.com/profullstack/c0mpute"
Expand Down
2 changes: 1 addition & 1 deletion apps/tui/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@c0mpute/tui",
"version": "0.3.4",
"version": "0.3.5",
"private": true,
"type": "module",
"bin": {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "c0mpute",
"version": "0.3.4",
"version": "0.3.5",
"private": true,
"type": "module",
"scripts": {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/releases/latest.json/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const dynamic = "force-dynamic";
* this in sync with the workspace version in `Cargo.toml` on each release so a
* freshly built CLI reports "already latest" rather than a phantom upgrade.
*/
const FALLBACK_VERSION = "0.3.4";
const FALLBACK_VERSION = "0.3.5";
const FALLBACK_MIN_REQUIRED = "0.0.1";

type Channel = "stable" | "beta" | "nightly";
Expand Down
6 changes: 6 additions & 0 deletions docs/apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ Every gateway routes to apps whether or not it runs any.
snapshot are lost. This is a replicated single-node database, not a
distributed one (no Raft yet; rqlite/dqlite as an app needs node-to-node
links, which come next).
- **Pin your hosts for anything private.** By default any `apps` node may
hold an app, and every holder receives its secrets and app key. `--host
<peer-id>` (repeatable; `hosts = [...]` in `c0mpute.toml`) signs an allowlist
into the record: holders seal secrets only to, and take snapshots only from,
those peers, and nodes off the list never take a copy. Operators can
likewise limit whose apps they run with `[apps] owners`.
- **Operators can read a running app.** The node running the container sees
its memory and its state volume, as with any VPS host. Snapshots at rest on
standbys are encrypted.
Expand Down
6 changes: 6 additions & 0 deletions node/crates/c0mpute-cli/src/apps_mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ fn tools() -> Value {
"memory_mb": {"type": "integer"},
"replicas": {"type": "integer"},
"domains": {"type": "array", "items": {"type": "string"}},
"hosts": {"type": "array", "items": {"type": "string"}, "description": "Peer ids allowed to hold the app; empty = any apps node (each holder gets the app's secrets)"},
"wait_secs": {"type": "integer"}
},
"required": ["name"]
Expand Down Expand Up @@ -113,6 +114,10 @@ async fn call(gateway: &str, name: &str, a: &Value) -> Result<String> {
}
}
}
if let Some(h) = a.get("hosts").and_then(Value::as_array) {
opts.hosts
.extend(h.iter().filter_map(Value::as_str).map(str::to_string));
}
if let Some(d) = a.get("domains").and_then(Value::as_array) {
opts.domains
.extend(d.iter().filter_map(Value::as_str).map(str::to_string));
Expand Down Expand Up @@ -168,6 +173,7 @@ fn base(name: &str, source: deploy::Source, wait: Duration) -> deploy::DeployOpt
millicpus: 500,
replicas: c0mpute_core::apps::record::DEFAULT_REPLICAS,
domains: vec![],
hosts: vec![],
wait,
}
}
Expand Down
7 changes: 7 additions & 0 deletions node/crates/c0mpute-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -749,6 +749,10 @@ enum AppsCmd {
/// `_c0mpute-app.<domain>` = `app=<app_id>`.
#[arg(long = "domain")]
domains: Vec<String>,
/// Peer id allowed to hold and run the app (repeatable). Without it
/// any apps node may, and every holder receives the app's secrets.
#[arg(long = "host")]
hosts: Vec<String>,
/// Give up waiting for a healthy primary after this many seconds.
#[arg(long, default_value_t = 600)]
wait_secs: u64,
Expand Down Expand Up @@ -3859,6 +3863,7 @@ async fn run_apps(cmd: AppsCmd) -> Result<()> {
cpus,
replicas,
domains,
hosts,
wait_secs,
} => {
let wait = std::time::Duration::from_secs(wait_secs);
Expand All @@ -3883,6 +3888,7 @@ async fn run_apps(cmd: AppsCmd) -> Result<()> {
millicpus: 500,
replicas: c0mpute_core::apps::record::DEFAULT_REPLICAS,
domains: vec![],
hosts: vec![],
wait,
},
};
Expand Down Expand Up @@ -3924,6 +3930,7 @@ async fn run_apps(cmd: AppsCmd) -> Result<()> {
opts.replicas = r;
}
opts.domains.extend(domains);
opts.hosts.extend(hosts);
opts.wait = wait;
let rec = deploy::deploy(opts).await?;
println!("{}", rec.app_id);
Expand Down
7 changes: 7 additions & 0 deletions node/crates/c0mpute-core/src/apps/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ pub struct DeployOptions {
pub millicpus: u32,
pub replicas: u8,
pub domains: Vec<String>,
/// Peer ids allowed to hold the app (empty = any apps node).
pub hosts: Vec<String>,
pub wait: Duration,
}

Expand Down Expand Up @@ -256,6 +258,7 @@ pub async fn deploy(opts: DeployOptions) -> Result<AppRecord> {
millicpus: opts.millicpus,
replicas: opts.replicas,
domains: opts.domains.clone(),
hosts: opts.hosts.clone(),
};
let rec = AppRecord::sign(&keypair, spec, seq)?;
if let Some(s) = &secrets {
Expand Down Expand Up @@ -396,6 +399,9 @@ pub struct AppSection {
pub replicas: u8,
#[serde(default)]
pub domains: Vec<String>,
/// Peer ids allowed to hold the app (empty = any apps node).
#[serde(default)]
pub hosts: Vec<String>,
/// A pinned registry image instead of building the repository.
pub image: Option<String>,
#[serde(default)]
Expand Down Expand Up @@ -438,6 +444,7 @@ impl AppSection {
millicpus: (self.cpus * 1000.0).round() as u32,
replicas: self.replicas,
domains: self.domains.clone(),
hosts: self.hosts.clone(),
wait,
}
}
Expand Down
30 changes: 26 additions & 4 deletions node/crates/c0mpute-core/src/apps/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,18 @@ fn load_x25519(dir: Option<&PathBuf>) -> crypto::KeyPair {
kp
}

/// The view as this app sees it: nodes off its host allowlist do not exist
/// for its placement, primary election, snapshots or secrets.
fn scoped(rec: &AppRecord, view: &HashMap<String, NodeView>) -> HashMap<String, NodeView> {
if rec.hosts.is_empty() {
return view.clone();
}
view.iter()
.filter(|(p, _)| rec.allows(p))
.map(|(p, n)| (p.clone(), n.clone()))
.collect()
}

/// Write a file only its owner can read.
pub fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
let tmp = path.with_extension("tmp");
Expand Down Expand Up @@ -567,6 +579,9 @@ impl Apps {
}
// Only the owner or a node that holds the app may hand out secrets.
let from_owner = rec.owner_peer().as_deref() == Some(src);
if !from_owner && !rec.allows(src) {
return;
}
let from_holder = self
.0
.nodes
Expand Down Expand Up @@ -775,8 +790,9 @@ impl Apps {
let view = self.live_view().await;
let mut changed = false;
for rec in records {
changed |= self.step(&rec, &view).await;
self.seal_to_holders(&rec, &view).await;
let v = scoped(&rec, &view);
changed |= self.step(&rec, &v).await;
self.seal_to_holders(&rec, &v).await;
}
// Apps this node holds whose record vanished: nothing to do but wait.
if changed {
Expand All @@ -790,6 +806,11 @@ impl Apps {
async fn step(&self, rec: &AppRecord, view: &HashMap<String, NodeView>) -> bool {
let id = rec.app_id.clone();
let holding = self.0.local.read().await.contains_key(&id);
if holding && !rec.deleted && !rec.allows(&self.0.me) {
info!(app = %id, "apps: no longer on the app's host list; dropping");
self.drop_app(&id).await;
return true;
}
if rec.deleted {
if holding {
info!(app = %id, "apps: deleted by owner; dropping");
Expand Down Expand Up @@ -1469,6 +1490,7 @@ impl Apps {
}

pub async fn info(&self, rec: &AppRecord, view: &HashMap<String, NodeView>) -> AppInfo {
let view = &scoped(rec, view);
let holders = placement::holders(&rec.app_id, view);
let p = placement::primary(&rec.app_id, rec.needs_secrets(), view);
let (primary, epoch, healthy) = match &p {
Expand Down Expand Up @@ -1520,7 +1542,7 @@ impl Apps {
/// Owner side: the last `tail` log lines from the primary.
pub async fn logs(&self, app_id: &str, tail: u32) -> Result<String> {
let rec = self.lookup_record(app_id).await.context("unknown app")?;
let view = self.live_view().await;
let view = scoped(&rec, &self.live_view().await);
let peer = placement::primary(app_id, rec.needs_secrets(), &view)
.peer()
.map(str::to_string)
Expand All @@ -1538,7 +1560,7 @@ impl Apps {
let Some(rec) = self.lookup_record(&req.app_id).await.filter(|r| !r.deleted) else {
return (StatusCode::NOT_FOUND, "no such app").into_response();
};
let view = self.live_view().await;
let view = scoped(&rec, &self.live_view().await);
let Some(peer) = placement::primary(&rec.app_id, rec.needs_secrets(), &view)
.peer()
.map(str::to_string)
Expand Down
Loading
Loading