zc2 0.0.28

P2P compute broker with credit-based billing, WAL, and broker mesh support
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
//! Deployment reconciler: makes the hub's desired "environments" — pre-
//! loaded, warmed images ready to execute a remote function — true on THIS
//! host, reports progress back to the hub, rolls back on failure, and bills
//! served `/serve/:deployment_id` requests by execution time on every
//! verified (2xx) response.
//!
//! Contract with the hub (`zak-dashboard`, being built in parallel):
//!   GET  {api_url}/api/broker/deployments/desired
//!   POST {api_url}/api/broker/deployments/{id}/status
//!
//! Both are authenticated the same way the rest of the broker's dashboard
//! calls are (`X-Broker-Api-Key`), plus `X-Node-Pubkey` identifying which
//! host is reporting — a node only ever reconciles ITS OWN desired set, and
//! the hub uses the pubkey to tell nodes apart the same way it already does
//! for `/api/broker/node/register` (see `node_sync.rs`).

pub mod expose;
pub mod reconcile;
pub mod runtime;
pub mod serve;
pub mod state;

use std::sync::Arc;
use std::time::Duration;

use serde::{Deserialize, Serialize};

// Re-exported so external callers (and doc links) can name these without
// reaching into the submodules directly; `#[allow(unused_imports)]` because
// nothing in THIS crate currently needs the short path — they're still part
// of the module's public surface (used by name in doc comments) and by any
// future caller (e.g. a dashboard-shaped integration test) that wants
// `deploy::Action` rather than `deploy::reconcile::Action`.
#[allow(unused_imports)]
pub use reconcile::{plan, plan_failure, Action};
#[allow(unused_imports)]
pub use runtime::{runtime_available, ContainerRuntime, DockerCli, RunSpec};
#[allow(unused_imports)]
pub use state::DeploymentRecord;
pub use state::LocalState;

/// One port the deployment's container listens on.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Port {
    pub container: u16,
    #[serde(default = "default_protocol")]
    pub protocol: String,
}

fn default_protocol() -> String {
    "http".to_string()
}

/// How to probe a running container for readiness.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Healthcheck {
    #[serde(rename = "type")]
    pub kind: String, // "http" | "tcp"
    pub port: u16,
    #[serde(default)]
    pub path: Option<String>,
    #[serde(default = "default_timeout_s")]
    pub timeout_s: u64,
}

fn default_timeout_s() -> u64 {
    60
}

/// What a running environment may reach: for now this is surfaced only
/// (local state + `zc deployments`), and its `grants` are handed to the
/// container as env vars so the process inside can see them, but nothing on
/// this node enforces them yet — enforcement is a hub/dashboard concern.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ServiceAccount {
    pub name: String,
    #[serde(default)]
    pub grants: Vec<String>,
}

/// The deployment this one is replacing, as the hub describes it. Usually
/// `previous.id == this.id` (a version bump of the same deployment); when it
/// names a DIFFERENT id, the hub is describing a slot handoff — e.g. a
/// blue/green cutover where this deployment takes over from another one
/// entirely — and this node stops that other deployment's container once
/// this one is healthy (see `reconcile::plan`).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PreviousDeployment {
    pub id: String,
    pub image: String,
    #[serde(default)]
    pub cmd: Option<String>,
    #[serde(default)]
    pub ports: Vec<Port>,
    #[serde(default)]
    pub env: std::collections::HashMap<String, String>,
    #[serde(default)]
    pub healthcheck: Option<Healthcheck>,
}

/// One entry of `GET /api/broker/deployments/desired`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Desired {
    pub id: String,
    pub version: u64,
    pub name: String,
    pub image: String,
    #[serde(default)]
    pub cmd: Option<String>,
    #[serde(default)]
    pub ports: Vec<Port>,
    #[serde(default)]
    pub env: std::collections::HashMap<String, String>,
    #[serde(default)]
    pub healthcheck: Option<Healthcheck>,
    /// Credits per second of execution — billed per served request on
    /// `duration_ms` actually spent proxied into the environment (see
    /// `deploy::serve`), not a flat per-call price. The hub has already
    /// resolved this to an effective value for auto-priced deployments.
    pub price_per_second: f64,
    pub desired_state: String, // "running" | "stopped"
    /// What this environment may reach; surfaced only for now (see
    /// `ServiceAccount`'s doc comment) — not enforced on this node.
    #[serde(default)]
    pub service_account: Option<ServiceAccount>,
    /// `true`: keep the environment's container running and ready.
    /// `false`: allowed to stop it when idle and cold-start on the next
    /// `/serve` call — NOT YET IMPLEMENTED, treated as `true` (see
    /// `reconcile::drive`'s `Start` handling).
    #[serde(default = "default_warm")]
    pub warm: bool,
    #[serde(default)]
    pub previous: Option<Box<PreviousDeployment>>,
}

pub(crate) fn default_warm() -> bool {
    true
}

#[derive(Debug, Deserialize)]
struct DesiredResponse {
    deployments: Vec<Desired>,
}

/// `GET {api_url}/api/broker/deployments/desired` for this node.
pub fn fetch_desired(
    api_url: &str,
    api_key: &str,
    node_pubkey: &str,
) -> Result<Vec<Desired>, String> {
    let endpoint = format!(
        "{}/api/broker/deployments/desired",
        api_url.trim_end_matches('/')
    );
    let resp = ureq::get(&endpoint)
        .config()
        .timeout_global(Some(Duration::from_secs(10)))
        .http_status_as_error(false)
        .build()
        .header("X-Broker-Api-Key", api_key)
        .header("X-Node-Pubkey", node_pubkey)
        .call()
        .map_err(|e| e.to_string())?;
    if resp.status().as_u16() != 200 {
        return Err(format!("fetch_desired HTTP {}", resp.status().as_u16()));
    }
    let text = resp
        .into_body()
        .read_to_string()
        .map_err(|e| e.to_string())?;
    let parsed: DesiredResponse = serde_json::from_str(&text).map_err(|e| e.to_string())?;
    Ok(parsed.deployments)
}

/// The body of `POST {api_url}/api/broker/deployments/{id}/status`.
/// `mesh_endpoint` is always present, `null` when the deployment isn't
/// exposed: each report replaces the hub's row, so leaving the key out would
/// not mean "unchanged".
#[allow(clippy::too_many_arguments)]
fn status_payload(
    node_pubkey: &str,
    version: u64,
    phase: &str,
    message: &str,
    attempt: u32,
    container_id: Option<&str>,
    endpoint: Option<&str>,
    mesh_endpoint: Option<&str>,
) -> serde_json::Value {
    serde_json::json!({
        "node_pubkey": node_pubkey,
        "version": version,
        "phase": phase,
        "message": message,
        "attempt": attempt,
        "container_id": container_id,
        "endpoint": endpoint,
        "mesh_endpoint": mesh_endpoint,
    })
}

/// `POST {api_url}/api/broker/deployments/{id}/status`.
#[allow(clippy::too_many_arguments)]
pub fn report_status(
    api_url: &str,
    api_key: &str,
    node_pubkey: &str,
    id: &str,
    version: u64,
    phase: &str,
    message: &str,
    attempt: u32,
    container_id: Option<&str>,
    endpoint: Option<&str>,
    mesh_endpoint: Option<&str>,
) {
    let url = format!(
        "{}/api/broker/deployments/{}/status",
        api_url.trim_end_matches('/'),
        id
    );
    let payload = status_payload(
        node_pubkey,
        version,
        phase,
        message,
        attempt,
        container_id,
        endpoint,
        mesh_endpoint,
    );
    let body = serde_json::to_string(&payload).unwrap_or_default();
    let result = ureq::post(&url)
        .config()
        .timeout_global(Some(Duration::from_secs(10)))
        .http_status_as_error(false)
        .build()
        .header("X-Broker-Api-Key", api_key)
        .header("X-Node-Pubkey", node_pubkey)
        .header("Content-Type", "application/json")
        .send(body.as_str());
    if let Err(e) = result {
        eprintln!("  [DEPLOY] failed to report status for {id}: {e}");
    }
}

/// `reconcile::StatusReporter` backed by the real hub HTTP contract above.
pub struct HttpReporter<'a> {
    pub api_url: &'a str,
    pub api_key: &'a str,
    pub node_pubkey: &'a str,
}

impl reconcile::StatusReporter for HttpReporter<'_> {
    fn report(
        &self,
        id: &str,
        version: u64,
        phase: &str,
        message: &str,
        attempt: u32,
        container_id: Option<&str>,
        endpoint: Option<&str>,
        mesh_endpoint: Option<&str>,
    ) {
        report_status(
            self.api_url,
            self.api_key,
            self.node_pubkey,
            id,
            version,
            phase,
            message,
            attempt,
            container_id,
            endpoint,
            mesh_endpoint,
        );
    }
}

/// One reconcile pass: fetch desired state from the hub and drive local
/// docker state toward it. Called from the broker's periodic tick loop (see
/// `server.rs`, "Periodic worker sync"); a no-op when `api_url`/`api_key`
/// aren't configured — a broker with no dashboard has nothing to reconcile
/// against, same precondition `WORKER_SYNC` and the roster refresh use.
pub fn tick(state: &Arc<crate::broker::BrokerState>) {
    let (Some(api_url), Some(api_key)) = (&state.config.api_url, &state.config.api_key) else {
        return;
    };
    let node_pubkey = state.node_key.public_b64();

    let desired = match fetch_desired(api_url, api_key, &node_pubkey) {
        Ok(d) => d,
        Err(e) => {
            eprintln!("  [DEPLOY] failed to fetch desired deployments: {e}");
            return;
        }
    };
    if desired.is_empty() {
        return;
    }

    let rt = DockerCli;
    if !rt.available() {
        // Nothing this node can do — tell the hub once per tick rather than
        // silently leaving every deployment stuck "pending" from its side.
        for d in &desired {
            if d.desired_state == "running" {
                report_status(
                    api_url,
                    api_key,
                    &node_pubkey,
                    &d.id,
                    d.version,
                    "failed",
                    "no container runtime on this host",
                    1,
                    None,
                    None,
                    None,
                );
            }
        }
        return;
    }

    let mut local = LocalState::load();
    let reporter = HttpReporter {
        api_url,
        api_key,
        node_pubkey: &node_pubkey,
    };
    let mesh_ip = expose::exposure_ip(
        crate::broker::discovery::get_mesh_ip(),
        std::env::var("ZAKURO_MESH_EXPOSE").ok().as_deref(),
    );
    if mesh_ip.is_some() {
        // Who may use the mesh forwarder: the owner's addresses (zc#212).
        expose::refresh_allowlist(api_url, api_key);
    }
    reconcile::drive_and_expose(
        &rt,
        &reporter,
        expose::forwarder(),
        mesh_ip.as_deref(),
        &desired,
        &mut local,
    );
}

/// `zc deployments` — print this node's local reconciler state (from
/// `deployments.json`) plus a `docker ps` view of the containers it created,
/// for debugging a stuck or unexpected deployment without reaching for
/// `sqlite3`/dashboard access.
pub fn print_deployments() {
    let local = LocalState::load();
    if local.deployments.is_empty() {
        println!("No local deployment state (nothing reconciled on this node yet).");
    } else {
        println!(
            "{:<24} {:>7}  {:<12} {:<22} {:<22} {:<6} {:<20} CONTAINER",
            "DEPLOYMENT", "VERSION", "PHASE", "ENDPOINT", "MESH", "WARM", "SERVICE_ACCOUNT"
        );
        let mut ids: Vec<&String> = local.deployments.keys().collect();
        ids.sort();
        for id in ids {
            let rec = &local.deployments[id];
            let service_account = rec
                .service_account
                .as_ref()
                .map(|sa| format!("{}[{}]", sa.name, sa.grants.join(",")))
                .unwrap_or_else(|| "-".to_string());
            println!(
                "{:<24} {:>7}  {:<12} {:<22} {:<22} {:<6} {:<20} {}",
                id,
                rec.version,
                rec.phase,
                rec.endpoint.as_deref().unwrap_or("-"),
                rec.mesh_endpoint.as_deref().unwrap_or("-"),
                rec.warm,
                service_account,
                rec.container_id.as_deref().unwrap_or("-"),
            );
        }
    }

    println!();
    let rt = DockerCli;
    if !rt.available() {
        println!("(docker not available on this host)");
        return;
    }
    match std::process::Command::new("docker")
        .args([
            "ps",
            "-a",
            "--filter",
            "label=zakuro.deployment",
            "--format",
            "table {{.Names}}\t{{.Status}}\t{{.Ports}}",
        ])
        .output()
    {
        Ok(out) if out.status.success() => {
            print!("{}", String::from_utf8_lossy(&out.stdout));
        }
        Ok(out) => {
            eprintln!(
                "docker ps failed: {}",
                String::from_utf8_lossy(&out.stderr).trim()
            );
        }
        Err(e) => eprintln!("docker ps failed: {e}"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn status_payload_carries_the_mesh_endpoint() {
        let payload = status_payload(
            "pk",
            3,
            "healthy",
            "",
            1,
            Some("c1"),
            Some("172.17.0.3:8888"),
            Some("10.13.13.22:8888"),
        );
        assert_eq!(payload["endpoint"], "172.17.0.3:8888");
        assert_eq!(payload["mesh_endpoint"], "10.13.13.22:8888");
    }

    #[test]
    fn status_payload_sends_an_explicit_null_when_not_exposed() {
        let payload = status_payload("pk", 3, "stopped", "", 1, None, None, None);
        assert!(payload.get("mesh_endpoint").is_some_and(|v| v.is_null()));
    }
}