Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

guests/toy-cloud/src/lib.rs

//! **A toy cloud guest** — fake iron at a declared price. It buys nothing
//! anywhere; what it proves is the cloud ABI end to end: settings travel
//! with every call, the pool ledger lives in the host's durable state (so a
//! retried `ensure` answers the SAME id, across calls, replacements and
//! restarts), a wait happens on the host, and every answer keeps its kind.
//!
//! Settings (the `[clouds.toycloud]` block minus the host's keys):
//! `disk_minor_per_gib_month` (default 23), `tag` (stamped into every id, so a
//! test can tell which build or block answered), `refuse_verify = true` (the
//! start-path proof fails, as a dead credential would), `lay_takes_ms` (a lay
//! waits this long on the host first, as UpCloud's does for its servers — what
//! proves a lay never holds the guest's other calls back, A32), `ensure_takes_ms`
//! (a purchase that waits first — what proves a sold estate's growth runs
//! beside the everyday calls, not behind them), `destroy_takes_ms` (a destroy that
//! waits first, in short sleeps, as UpCloud's stop-then-delete of a STARTED server
//! does — what proves a destroy is not cut off at the everyday call deadline, F4),
//! `ensure_blocks = true` (a purchase that waits on the host in short sleeps and never
//! ends by itself — only the call's deadline ends it, whatever the box's load does to
//! the epoch ticker: what proves a purchase is bounded by the block's deadline).
//!
//! It also LAYS an estate (2026-10-08): two plans in its list, and a lay that
//! journals each server through the host's door and answers toy ids — what
//! proves the estate ops cross the ABI journaled, as a growth does. What it laid
//! (each server and its disks, under the estate's reference) is kept in the host's
//! state beside the pool ledger and listed by `inventory`, so a second lay adopts
//! it, a plan change and a power cycle find it, and the give-back takes it all: the
//! `Cloud` conformance suite runs over this guest through the host's adapter.

use std::collections::BTreeMap;
use std::time::Duration;

use monetize_cloud::pace::Pace;
use monetize_cloud::pool::{Grant, Ledger};
use monetize_cloud::{
    CloudError, Ensured, Inventory, Owned, OwnedKind, Price, Resource, ResourceId, ResourceStatus,
    Zone,
};
use monetize_cloud_guest::{
    kinds, CallContext, CloudGuest, HostLedger, HostPace, HostRef, Manifest,
};
use monetize_pay::Currency;

pub const NAME: &str = "toycloud";

#[derive(Default)]
pub struct ToyCloud;

impl CloudGuest for ToyCloud {
    fn manifest(&self) -> Manifest {
        Manifest::of_kind(kinds::CLOUD, NAME, &["state"])
    }
    fn open(
        &self,
        host: HostRef,
        ctx: &CallContext,
    ) -> Result<Box<dyn monetize_cloud::Cloud>, CloudError> {
        let per_gib = ctx
            .settings
            .get("disk_minor_per_gib_month")
            .and_then(|v| v.as_u64())
            .unwrap_or(23);
        let tag = ctx
            .settings
            .get("tag")
            .and_then(|v| v.as_str())
            .unwrap_or("v1")
            .to_string();
        let lay_takes_ms = ctx
            .settings
            .get("lay_takes_ms")
            .and_then(|v| v.as_u64())
            .unwrap_or(0);
        let ensure_takes_ms = ctx
            .settings
            .get("ensure_takes_ms")
            .and_then(|v| v.as_u64())
            .unwrap_or(0);
        let destroy_takes_ms = ctx
            .settings
            .get("destroy_takes_ms")
            .and_then(|v| v.as_u64())
            .unwrap_or(0);
        let ensure_blocks = ctx
            .settings
            .get("ensure_blocks")
            .and_then(|v| v.as_bool())
            .unwrap_or(false);
        Ok(Box::new(Toy {
            host,
            per_gib,
            tag,
            lay_takes_ms,
            ensure_takes_ms,
            ensure_blocks,
            destroy_takes_ms,
            zones: vec![Zone("toy-1".into())],
        }))
    }
    fn verify(&self, host: HostRef, ctx: &CallContext) -> Result<(), CloudError> {
        if ctx
            .settings
            .get("refuse_verify")
            .and_then(|v| v.as_bool())
            .unwrap_or(false)
        {
            return Err(CloudError::Refused(
                "the toy's credential is dead (refuse_verify = true)".into(),
            ));
        }
        self.open(host, ctx).map(|_| ())
    }
}

monetize_cloud_guest::export_cloud!(ToyCloud, grants = [state]);

struct Toy {
    host: HostRef,
    per_gib: u64,
    tag: String,
    lay_takes_ms: u64,
    ensure_takes_ms: u64,
    ensure_blocks: bool,
    destroy_takes_ms: u64,
    zones: Vec<Zone>,
}

impl Toy {
    fn ledger(&self) -> Result<Ledger, CloudError> {
        Ledger::with_store(Box::new(HostLedger {
            host: self.host,
            key: "pool".into(),
        }))
    }
    /// What the estate ops laid, apart from the pool: a lay runs on the estate's own lane
    /// beside the shop's purchases, and one state key per writer loses no update.
    fn laid(&self) -> Result<Ledger, CloudError> {
        Ledger::with_store(Box::new(HostLedger {
            host: self.host,
            key: "laid".into(),
        }))
    }
    /// A laid server, by id, or the provider's own refusal of an id it never laid.
    fn known_server(&self, server: &ResourceId) -> Result<(), CloudError> {
        if self.laid()?.keys_for_id(&server.id).is_empty() {
            return Err(CloudError::UnknownResource(server.clone()));
        }
        Ok(())
    }
    fn price_of(&self, r: &Resource) -> Price {
        let minor = match r {
            Resource::Disk { gib, .. } => gib * self.per_gib,
            Resource::Server { .. } => 1_000,
            Resource::Compute { .. } => 500,
        };
        Price {
            minor_per_month: minor,
            currency: Currency::Eur,
        }
    }
}

#[allow(dead_code)]
impl Toy {
    pub fn provider(&self) -> &'static str {
        NAME
    }
    pub fn zones(&self) -> &[Zone] {
        &self.zones
    }
    pub fn price_resource(&self, r: &Resource) -> Result<Price, CloudError> {
        Ok(self.price_of(r))
    }
    pub fn ensure(&self, reference: &str, r: &Resource) -> Result<Ensured, CloudError> {
        if self.ensure_takes_ms > 0 {
            HostPace(self.host).sleep(Duration::from_millis(self.ensure_takes_ms));
        }
        // Blocked until the host cuts the call off: each short sleep returns into this loop,
        // whose back edge is where the host's epoch deadline interrupts it. No answer comes.
        if self.ensure_blocks {
            loop {
                HostPace(self.host).sleep(Duration::from_millis(50));
            }
        }
        if reference == "unavailable" {
            return Err(CloudError::Unavailable("the toy provider is down".into()));
        }
        let fp = monetize_cloud::fingerprint(r);
        let key = Ledger::key(reference, &fp);
        let mut ledger = self.ledger()?;
        if let Some(Grant::Provisioned { id, cost, .. }) = ledger.grant(&key) {
            return Ok(Ensured::Provisioned {
                id: ResourceId {
                    provider: NAME.into(),
                    id: id.clone(),
                },
                cost: *cost,
            });
        }
        // One wait on the host, the way a real provider waits for `online`.
        let before = HostPace(self.host).now_ms();
        HostPace(self.host).sleep(Duration::from_millis(5));
        let _ = before;
        let cost = self.price_of(r);
        let id = format!("{}:{}:{fp}", self.tag, ledger.data().grants.len() + 1);
        let (server, gib) = match r {
            Resource::Disk { server, gib } => (Some(server.0.clone()), *gib),
            _ => (None, 0),
        };
        ledger.record(
            &key,
            Grant::Provisioned {
                id: id.clone(),
                cost,
                server,
                gib,
            },
        )?;
        Ok(Ensured::Provisioned {
            id: ResourceId {
                provider: NAME.into(),
                id,
            },
            cost,
        })
    }
    pub fn destroy_id(&self, id: &ResourceId) -> Result<(), CloudError> {
        // The stop's wait, polled the way the upcloud guest polls `stopped`: short sleeps.
        let mut waited = 0;
        while waited < self.destroy_takes_ms {
            let step = (self.destroy_takes_ms - waited).min(100);
            HostPace(self.host).sleep(Duration::from_millis(step));
            waited += step;
        }
        let mut found = false;
        for mut ledger in [self.ledger()?, self.laid()?] {
            for k in ledger.keys_for_id(&id.id) {
                ledger.forget(&k)?;
                found = true;
            }
        }
        if !found {
            return Err(CloudError::UnknownResource(id.clone()));
        }
        Ok(())
    }
    pub fn status(&self, id: &ResourceId) -> Result<ResourceStatus, CloudError> {
        let held = !self.ledger()?.keys_for_id(&id.id).is_empty()
            || !self.laid()?.keys_for_id(&id.id).is_empty();
        Ok(if held {
            ResourceStatus::Active
        } else {
            ResourceStatus::Gone
        })
    }
    pub fn list_owned(&self) -> Result<Inventory, CloudError> {
        let (pool, laid) = (self.ledger()?, self.laid()?);
        let owned = pool
            .data()
            .grants
            .iter()
            .chain(laid.data().grants.iter())
            .filter_map(|(key, g)| match g {
                Grant::Provisioned {
                    id,
                    cost,
                    gib,
                    server,
                } => Some(Owned {
                    id: ResourceId {
                        provider: NAME.into(),
                        id: id.clone(),
                    },
                    kind: if *gib > 0 {
                        OwnedKind::Disk
                    } else {
                        OwnedKind::Server
                    },
                    gib: *gib,
                    zone: "toy-1".into(),
                    attached_to: server.clone(),
                    reference: key.split('|').next().map(str::to_string),
                    monthly_minor: Some(cost.minor_per_month),
                    currency: Currency::Eur,
                    provenance: NAME.into(),
                    resource: None,
                    backup: None,
                }),
                _ => None,
            })
            .collect();
        Ok(Inventory {
            owned,
            foreign: 0,
            currency: Currency::Eur,
        })
    }
    pub fn ceilings(&self) -> BTreeMap<String, u64> {
        [("cpu_millicores".to_string(), 4_000u64)]
            .into_iter()
            .collect()
    }
    pub fn plan_catalog(
        &self,
        zone: &Zone,
    ) -> Result<Vec<monetize_cloud::lay::PlanSpec>, CloudError> {
        Ok(vec![
            monetize_cloud::lay::PlanSpec {
                name: format!("TOY-1xCPU-2GB-{}", zone.0),
                cores: 1,
                memory_mib: 2048,
                price: Price {
                    minor_per_month: 1_000,
                    currency: Currency::Eur,
                },
            },
            monetize_cloud::lay::PlanSpec {
                name: format!("TOY-2xCPU-4GB-{}", zone.0),
                cores: 2,
                memory_mib: 4096,
                price: Price {
                    minor_per_month: 2_000,
                    currency: Currency::Eur,
                },
            },
        ])
    }
    /// A laid server moves to a plan the toy sells; nothing is bought.
    pub fn change_plan(
        &self,
        server: &ResourceId,
        plan: &str,
        _delta: Price,
        _journal: &monetize_cloud::grow::Journal,
    ) -> Result<(), CloudError> {
        self.known_server(server)?;
        let zone = self.zones[0].clone();
        if !self.plan_catalog(&zone)?.iter().any(|p| p.name == plan) {
            return Err(CloudError::Refused(format!("the toy sells no plan {plan}")));
        }
        Ok(())
    }
    /// A laid server is powered off or on.
    pub fn set_power(&self, server: &ResourceId, _on: bool) -> Result<(), CloudError> {
        self.known_server(server)
    }
    /// The rules of every laid server are written again (the toy holds no rules).
    pub fn set_estate_firewall(
        &self,
        laid: &monetize_cloud::lay::LaidEstate,
    ) -> Result<(), CloudError> {
        for m in &laid.machines {
            self.known_server(&ResourceId {
                provider: NAME.into(),
                id: m.server.clone(),
            })?;
        }
        Ok(())
    }
    /// One intent and one confirmation per server, through the host's door;
    /// toy ids stamped with the block's `tag`.
    pub fn lay_estate(
        &self,
        plan: &monetize_cloud::lay::EstatePlan,
        journal: &monetize_cloud::grow::Journal,
    ) -> Result<monetize_cloud::lay::LaidEstate, CloudError> {
        use monetize_cloud::grow::{GrowRecord, Mark};
        use monetize_cloud::lay::{step, LaidDisk, LaidEstate, LaidMachine};
        plan.check()?;
        if self.lay_takes_ms > 0 {
            HostPace(self.host).sleep(Duration::from_millis(self.lay_takes_ms));
        }
        let now = self.host.clock_unix_ms();
        let mut machines = Vec::new();
        for (i, m) in plan.machines.iter().enumerate() {
            let id = format!("{}:{}:{}", self.tag, plan.reference, m.role);
            for (mark, detail) in [(Mark::Intent, String::new()), (Mark::Confirmed, id.clone())] {
                let mut rec = GrowRecord::set_wide(now, now, step::SERVER, mark, detail);
                rec.volume = m.hostname.clone();
                journal.append(rec)?;
            }
            // Kept, under the estate's reference: a second lay finds the same ids (adopts).
            let mut laid = self.laid()?;
            let server_key = Ledger::key(&plan.reference, &format!("server:{}", m.role));
            if laid.grant(&server_key).is_none() {
                laid.record(
                    &server_key,
                    Grant::Provisioned {
                        id: id.clone(),
                        cost: Price {
                            minor_per_month: 1_000,
                            currency: Currency::Eur,
                        },
                        server: None,
                        gib: 0,
                    },
                )?;
            }
            let mut disks = Vec::new();
            for (j, d) in m.disks.iter().enumerate() {
                let disk = format!("{id}:disk{j}");
                let key = Ledger::key(&plan.reference, &format!("disk:{}:{j}", m.role));
                if laid.grant(&key).is_none() {
                    let cost = Price {
                        minor_per_month: d.gib * self.per_gib,
                        currency: Currency::Eur,
                    };
                    laid.record(
                        &key,
                        Grant::Provisioned {
                            id: disk.clone(),
                            cost,
                            server: Some(id.clone()),
                            gib: d.gib,
                        },
                    )?;
                }
                disks.push(LaidDisk {
                    title: d.title.clone(),
                    id: disk,
                    gib: d.gib,
                    tier: d.tier.clone(),
                    address: format!("virtio:{j}"),
                });
            }
            machines.push(LaidMachine {
                role: m.role.clone(),
                hostname: m.hostname.clone(),
                title: m.title.clone(),
                plan: m.plan.clone(),
                server: id,
                public_ipv4: format!("192.0.2.{}", i + 1),
                utility_ipv4: format!("10.0.0.{}", i + 1),
                disks,
                // The toy lays no release medium.
                medium_clone: String::new(),
            });
        }
        Ok(LaidEstate {
            reference: plan.reference.clone(),
            company: plan.company.clone(),
            zone: plan.zone.clone(),
            machines,
            narrate_from: plan.narrate_from.clone(),
            shape: plan.shape.clone(),
        })
    }
}
#[allow(unused_variables, unreachable_code)]
impl monetize_cloud::cloud::Cloud for Toy {
    fn manifest(&self) -> monetize_cloud::cloud::CloudManifest {
        monetize_cloud::cloud::CloudManifest {
            provider: self.provider().to_string(),
            zones: self.zones().to_vec(),
            settings: Vec::new(),
            grow_refused: Some(monetize_cloud::grow::unsupported(self.provider()).to_string()),
        }
    }
    fn catalog(
        &self,
        zone: &monetize_cloud::Zone,
    ) -> Result<monetize_cloud::cloud::Catalog, monetize_cloud::CloudError> {
        Ok(monetize_cloud::cloud::Catalog {
            plans: self.plan_catalog(zone)?,
            ipv4: None,
        })
    }
    fn price(
        &self,
        what: &monetize_cloud::cloud::Priced<'_>,
    ) -> Result<monetize_cloud::Price, monetize_cloud::CloudError> {
        match what {
            monetize_cloud::cloud::Priced::Resource(r) => self.price_resource(r),
            monetize_cloud::cloud::Priced::Plan(p) => {
                monetize_cloud::price_estate_by_rows(p, |r| self.price_resource(r), None)
            }
            monetize_cloud::cloud::Priced::Grow(set) => {
                Err(monetize_cloud::grow::unsupported(self.provider()))
            }
        }
    }
    fn lay(
        &self,
        plan: &monetize_cloud::lay::EstatePlan,
        journal: &monetize_cloud::grow::Journal,
    ) -> Result<monetize_cloud::lay::LaidEstate, monetize_cloud::CloudError> {
        self.lay_estate(plan, journal)
    }
    fn change(
        &self,
        change: &monetize_cloud::cloud::Change,
        journal: &monetize_cloud::grow::Journal,
    ) -> Result<monetize_cloud::cloud::Changed, monetize_cloud::CloudError> {
        Ok(match change {
            monetize_cloud::cloud::Change::Plan {
                server,
                plan,
                delta,
            } => {
                self.change_plan(server, plan, *delta, journal)?;
                monetize_cloud::cloud::Changed::Done
            }
            monetize_cloud::cloud::Change::Grow { set, estate } => {
                return Err(monetize_cloud::grow::unsupported(self.provider()))
            }
            monetize_cloud::cloud::Change::Settle { set } => {
                return Err(monetize_cloud::grow::unsupported(self.provider()))
            }
            monetize_cloud::cloud::Change::ForgetMedia { laid } => {
                monetize_cloud::cloud::Changed::Forgotten(Vec::new())
            }
            monetize_cloud::cloud::Change::Ensure {
                reference,
                resource,
                room,
            } => monetize_cloud::cloud::Changed::Ensured(self.ensure(reference, resource)?),
            monetize_cloud::cloud::Change::Retire { reference, machine } => {
                monetize_cloud::cloud::Changed::Retired(monetize_cloud::cloud::retire_by_rows(
                    self, reference, machine,
                )?)
            }
            monetize_cloud::cloud::Change::LabelAdopted { .. } => {
                return Err(monetize_cloud::lay::unsupported_verb(
                    self.provider(),
                    "label_adopted",
                ))
            }
        })
    }
    fn boot(
        &self,
        boot: &monetize_cloud::cloud::Boot,
        journal: &monetize_cloud::grow::Journal,
    ) -> Result<monetize_cloud::cloud::Booted, monetize_cloud::CloudError> {
        Ok(match boot {
            monetize_cloud::cloud::Boot::Keep(k) => {
                return Err(monetize_cloud::lay::unsupported_verb(
                    self.provider(),
                    "keep_boot",
                ))
            }
            monetize_cloud::cloud::Boot::Power { server, on } => {
                self.set_power(server, *on)?;
                monetize_cloud::cloud::Booted::Powered
            }
            monetize_cloud::cloud::Boot::Restore(r) => {
                return Err(monetize_cloud::lay::unsupported_verb(
                    self.provider(),
                    "restore_from_backup",
                ))
            }
        })
    }
    fn firewall(
        &self,
        laid: &monetize_cloud::lay::LaidEstate,
    ) -> Result<(), monetize_cloud::CloudError> {
        self.set_estate_firewall(laid)
    }
    fn destroy(
        &self,
        what: &monetize_cloud::cloud::Give<'_>,
    ) -> Result<monetize_cloud::Reclaimed, monetize_cloud::CloudError> {
        match what {
            monetize_cloud::cloud::Give::References(refs) => Ok(
                monetize_cloud::release_for_by_rows(self.list_owned()?, refs, |id| {
                    self.destroy_id(id)
                }),
            ),
            monetize_cloud::cloud::Give::Resource(id) => {
                self.destroy_id(id)?;
                Ok(monetize_cloud::Reclaimed {
                    destroyed: vec![(*id).clone()],
                    ..monetize_cloud::Reclaimed::default()
                })
            }
        }
    }
    fn inventory(
        &self,
        what: &monetize_cloud::cloud::Listing<'_>,
    ) -> Result<monetize_cloud::cloud::Listed, monetize_cloud::CloudError> {
        Ok(match what {
            monetize_cloud::cloud::Listing::Account => {
                monetize_cloud::cloud::Listed::Account(self.list_owned()?)
            }
            monetize_cloud::cloud::Listing::Set(set) => {
                return Err(monetize_cloud::grow::unsupported(self.provider()))
            }
            monetize_cloud::cloud::Listing::Status(id) => {
                monetize_cloud::cloud::Listed::Status(self.status(id)?)
            }
            monetize_cloud::cloud::Listing::Under(refs) => monetize_cloud::cloud::Listed::Under(
                monetize_cloud::cloud::under(self.list_owned()?, refs),
            ),
            monetize_cloud::cloud::Listing::Room { delta, room } => {
                monetize_cloud::cloud::Listed::Room(monetize_cloud::cloud::RoomLeft {
                    serve: Ok(()),
                    left_bytes: None,
                    ceilings: self.ceilings(),
                })
            }
            monetize_cloud::cloud::Listing::Admits(_plan) => {
                monetize_cloud::cloud::Listed::Admitted
            }
        })
    }
}