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

nordisk/monetize

crates/monetize-cloud/src/lib.rs

//! **`Cloud` — where monetize pays for disk, CPU and the boxes features need.**
//!
//! The customer pays for every resource used to serve them, and features cost
//! compute. An order's delta becomes resources; this trait is how a
//! resource is priced, bought, grown and destroyed at the provider that hosts the product.
//!
//! # The provision step of the transaction
//!
//! The transaction in `monetize` ensures a resource ([`cloud::Change::Ensure`]) between `reserve` and
//! `capture`. `ensure` has exactly two honest answers:
//!
//! * `OnHand` — the pool already holds enough (spare disk, a provider that gave us extra).
//!   Nothing is bought, the money is captured anyway, we keep it.
//! * `Provisioned` — something was bought; here is what and what it costs us.
//!
//! and one failure, which releases the reservation. There is no "bought but not sure"
//! state: an impl that cannot tell must query the provider until it can.
//!
//! # Providers, in order
//!
//! | feature | impl | where |
//! |---|---|---|
//! | `server`   | **the iron this box already has** — the free cloud; never buys: `OnHand` from a declared pool walled by the product's own measurement; see [`server`] | **here**, open, in `default` |
//! | `sandbox`  | **fake iron at real prices** — the rehearsal cloud; provisions nothing, prices off a declared table or a linked provider's list; see [`sandbox`] | **here**, open, never in `default` |
//! | `upcloud`  | UpCloud API 1.3, zone `se-sto1` first — the one gunnar.rs runs on | `monetize-cloud-impl`, private |
//! | `scaleway` | Scaleway; second country; stub until the account exists | `monetize-cloud-impl`, private |
//!
//! The private ones plug in through [`factory`] — the server never names them.
//!
//! Pure Rust, and nothing is shelled out to: HTTPS on `rustls`, no provider CLI.
//!
//! # Rule 2, at the provider, by construction
//!
//! Every disk and server a provider creates carries two labels, [`LABEL_REF`] (the
//! order's reference) and [`LABEL_RES`] (the [`fingerprint`] of the resource), and
//! `ensure` searches for that pair before it buys. A byte with no order cannot
//! exist because nothing creates one without stamping the order on it. The
//! constants and the fingerprint live here so every provider — real or rehearsal —
//! stamps the same two.

use std::fmt;

/// Label key carrying the order's reference on every created resource
/// (2–32 printable ASCII, not `_`-led: UpCloud's rule, and the strictest).
pub const LABEL_REF: &str = "monetize_ref";
/// Label key carrying the resource's [`fingerprint`].
pub const LABEL_RES: &str = "monetize_res";

/// A stable name for a `Resource`, short enough for a label value:
/// `disk:<server>:<gib>` | `server:<plan>:<zone>:<role>` | `compute:<server>:<plan>`.
pub fn fingerprint(resource: &Resource) -> String {
    match resource {
        Resource::Disk { server, gib } => format!("disk:{}:{gib}", server.0),
        Resource::Server { plan, zone, role } => {
            let role = match role {
                ServerRole::Primary => "primary",
                ServerRole::Replica => "replica",
            };
            format!("server:{plan}:{}:{role}", zone.0)
        }
        Resource::Compute { server, plan } => format!("compute:{}:{plan}", server.0),
    }
}

/// A resource the transaction can ensure. Units are the provider's smallest sellable step.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Resource {
    /// Block storage attached to a named server, in GiB.
    Disk { server: ServerId, gib: u64 },
    /// A whole box: the read replica in the second country, a customer's private
    /// replica, a standby.
    Server { plan: String, zone: Zone, role: ServerRole },
    /// CPU/RAM step on an existing server (a resize).
    Compute { server: ServerId, plan: String },
}

#[derive(Clone, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)]
pub struct ServerId(pub String);

#[derive(Clone, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)]
pub struct Zone(pub String);

#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum ServerRole {
    Primary,
    /// gunnar's `Role::Standby` / browse replica.
    Replica,
}

/// What a resource costs us, per month, in the provider's currency minor units.
/// Read from the provider's price API, never from a constant.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Price {
    pub minor_per_month: u64,
    pub currency: monetize_pay::Currency,
}

#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Ensured {
    /// Enough was already in the pool. Nothing bought.
    OnHand { pool_free_after: u64 },
    /// Bought. `cost` is the provider's price for it, for the ledger.
    Provisioned { id: ResourceId, cost: Price },
}

/// **What the product measured about the room for a resource, handed to the
/// provider that would buy it.** The transaction reads it off
/// `monetize_product::Absorb` and passes it to the ensure ([`cloud::Change::Ensure`]'s `room`),
/// so a cloud learns whether buying is wanted without learning which product
/// asked or how it measured.
///
/// Only `Resource::Disk` is decided by it today: the product's capacity answer
/// is about its data set, and a whole server or a plan change is not something
/// that answer speaks to.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum Room {
    /// **The product serves it from what exists. Buy nothing.** A provider
    /// with a pool ledger may record the grant; a provider that buys must not.
    OnHand,
    /// **The product is short, and said bought iron will be used.** Buy it
    /// (idempotently, as `ensure` always has) without second-guessing the
    /// measurement with a pool figure that never looked at the disk.
    Buy,
}

#[derive(Clone, PartialEq, Eq, Hash, Debug, serde::Serialize, serde::Deserialize)]
pub struct ResourceId {
    pub provider: String,
    pub id: String,
}

#[derive(Debug)]
pub enum CloudError {
    /// The provider refused (quota, out of stock in zone, bad plan name). The
    /// transaction releases the reservation.
    Refused(String),
    /// **A spend cap refused** ([`cap::SpendCap`]): a refusal like
    /// [`CloudError::Refused`] (the reservation is released) that an estate job
    /// WAITS on in its queue instead of failing, and never shows a buyer. Read
    /// by this kind, never by its words (stinky upcloud #21, 5.4).
    OverCap(String),
    /// Network / provider outage. Retryable; the impl must make `ensure` idempotent
    /// on `Resource` so a retry does not buy twice.
    Unavailable(String),
    UnknownResource(ResourceId),
    /// **The ACCOUNT is out of something** (CODE RED audit 5, 2026-10-18): money (`402`,
    /// `INSUFFICIENT_CREDITS`), a quota (`403 *_LIMIT_REACHED`) or the zone's capacity (`409
    /// SERVER_RESOURCES_UNAVAILABLE`). A refusal like [`CloudError::Refused`] (nothing bought,
    /// the reservation goes back) that no retry of the call cures: the operator acts.
    Exhausted(String),
}

impl fmt::Display for CloudError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            CloudError::Refused(r) | CloudError::OverCap(r) => write!(f, "provider refused: {r}"),
            CloudError::Unavailable(r) => write!(f, "provider unavailable: {r}"),
            CloudError::UnknownResource(r) => write!(f, "unknown resource {}:{}", r.provider, r.id),
            CloudError::Exhausted(r) => write!(f, "provider account exhausted: {r}"),
        }
    }
}
impl std::error::Error for CloudError {}

impl CloudError {
    /// A refusal of any kind ([`CloudError::Refused`], [`CloudError::OverCap`] or
    /// [`CloudError::Exhausted`]): nothing was bought, the reservation goes back.
    pub fn is_refusal(&self) -> bool {
        matches!(self, CloudError::Refused(_) | CloudError::OverCap(_) | CloudError::Exhausted(_))
    }
}

/// **A release by rows, as a function**: the account listed (`inventory`), every row
/// under one of `references` given back through `destroy`, the box's own iron spared, a
/// failure named and the walk continued. What a [`Cloud`] whose give-back is its own
/// per-resource destroy (UpCloud) answers [`cloud::Give::References`] with.
///
/// `references` is a filter and never a wildcard: an empty slice matches nothing. A row
/// the provider no longer has IS released (a retried purge must not report the first
/// run's work as a failure); any other failure is named and the walk continues, because
/// these are the rows the ledger may NOT know.
pub fn release_for_by_rows(inventory: Inventory, references: &[String], destroy: impl Fn(&ResourceId) -> Result<(), CloudError>) -> Reclaimed {
    let mut out = Reclaimed::default();
    for row in inventory.owned {
        // The box's own iron, whatever label it carries: by KIND, never a string compare.
        if row.kind == OwnedKind::Dataset {
            out.spared_datasets = out.spared_datasets.saturating_add(1);
            continue;
        }
        let Some(reference) = row.reference.as_deref() else {
            continue;
        };
        if !references.iter().any(|want| want == reference) {
            out.skipped_other_tenants = out.skipped_other_tenants.saturating_add(1);
            continue;
        }
        match destroy(&row.id) {
            Ok(()) | Err(CloudError::UnknownResource(_)) => out.destroyed.push(row.id),
            Err(err) => out.failures.push(format!("{}:{} is still at the provider: {err}", row.id.provider, row.id.id)),
        }
    }
    out
}

/// **An estate priced row by row**: every server at its plan and every disk at its size
/// through `price`, every public IPv4 address at `ipv4`. What a cloud guest built before
/// the estate ops crossed is priced with, by the host that forwards to it.
pub fn price_estate_by_rows(plan: &lay::EstatePlan, price: impl Fn(&Resource) -> Result<Price, CloudError>, ipv4: Option<Price>) -> Result<Price, CloudError> {
    let mut total: Option<Price> = None;
    for m in &plan.machines {
        let mut rows = vec![Resource::Server { plan: m.plan.clone(), zone: plan.zone.clone(), role: ServerRole::Primary }];
        rows.extend(m.disks.iter().map(|d| Resource::Disk { server: ServerId(m.hostname.clone()), gib: d.gib }));
        let mut prices: Vec<Price> = Vec::new();
        for r in rows {
            prices.push(price(&r)?);
        }
        if let Some(ip) = ipv4 {
            prices.push(Price { minor_per_month: ip.minor_per_month.saturating_mul(m.public_ipv4()), currency: ip.currency });
        }
        for one in prices {
            total = Some(match total {
                None => one,
                Some(t) if t.currency == one.currency => Price { minor_per_month: t.minor_per_month.saturating_add(one.minor_per_month), currency: t.currency },
                Some(t) => return Err(CloudError::Refused(format!("the estate is priced in both {:?} and {:?}; there is no conversion here", t.currency, one.currency))),
            });
        }
    }
    total.ok_or_else(|| CloudError::Refused("an estate with no machines has no price".into()))
}

/// What a give-back ([`cloud::Give`]) gave back, and what it did not.
#[derive(Clone, Default, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Reclaimed {
    /// The ids handed back, in the order they were released. **Empty is a
    /// normal answer**: a tenant served entirely from the pool owns no
    /// provider-side resource at all.
    pub destroyed: Vec<ResourceId>,
    /// One line per row the provider still holds, naming it and why.
    /// Non-empty means the release was PARTIAL and somebody has to go and
    /// finish it — these are resources still on the bill.
    pub failures: Vec<String>,
    /// Rows carrying a `monetize_ref` that was not one of `references`: other
    /// tenants'. **Counted rather than ignored**, because a release that
    /// scoped itself wrongly and a release that genuinely found nothing look
    /// identical without this number.
    pub skipped_other_tenants: u64,
    /// Rows spared because they are the box's own data set
    /// ([`OwnedKind::Dataset`]). Reported for the same reason: a silent skip
    /// would be one line between an operator and the deployment.
    pub spared_datasets: u64,
}

/// A shared cloud is a cloud, and so is a boxed one: every op forwards, so the pointer is
/// invisible to whatever is asked.
macro_rules! forward_cloud {
    ($ptr:ident) => {
        impl<C: Cloud + ?Sized> Cloud for $ptr<C> {
            fn manifest(&self) -> cloud::CloudManifest {
                (**self).manifest()
            }
            fn catalog(&self, zone: &Zone) -> Result<cloud::Catalog, CloudError> {
                (**self).catalog(zone)
            }
            fn price(&self, what: &cloud::Priced<'_>) -> Result<Price, CloudError> {
                (**self).price(what)
            }
            fn lay(&self, plan: &lay::EstatePlan, journal: &grow::Journal) -> Result<lay::LaidEstate, CloudError> {
                (**self).lay(plan, journal)
            }
            fn change(&self, change: &cloud::Change, journal: &grow::Journal) -> Result<cloud::Changed, CloudError> {
                (**self).change(change, journal)
            }
            fn boot(&self, boot: &cloud::Boot, journal: &grow::Journal) -> Result<cloud::Booted, CloudError> {
                (**self).boot(boot, journal)
            }
            fn firewall(&self, laid: &lay::LaidEstate) -> Result<(), CloudError> {
                (**self).firewall(laid)
            }
            fn destroy(&self, what: &cloud::Give<'_>) -> Result<Reclaimed, CloudError> {
                (**self).destroy(what)
            }
            fn inventory(&self, what: &cloud::Listing<'_>) -> Result<cloud::Listed, CloudError> {
                (**self).inventory(what)
            }
        }
    };
}

use std::sync::Arc;
forward_cloud!(Arc);
forward_cloud!(Box);
/// A borrowed cloud is a cloud: `&C` answers as `C`.
type Ref<'a, C> = &'a C;
impl<C: Cloud + ?Sized> Cloud for Ref<'_, C> {
    fn manifest(&self) -> cloud::CloudManifest {
        (**self).manifest()
    }
    fn catalog(&self, zone: &Zone) -> Result<cloud::Catalog, CloudError> {
        (**self).catalog(zone)
    }
    fn price(&self, what: &cloud::Priced<'_>) -> Result<Price, CloudError> {
        (**self).price(what)
    }
    fn lay(&self, plan: &lay::EstatePlan, journal: &grow::Journal) -> Result<lay::LaidEstate, CloudError> {
        (**self).lay(plan, journal)
    }
    fn change(&self, change: &cloud::Change, journal: &grow::Journal) -> Result<cloud::Changed, CloudError> {
        (**self).change(change, journal)
    }
    fn boot(&self, boot: &cloud::Boot, journal: &grow::Journal) -> Result<cloud::Booted, CloudError> {
        (**self).boot(boot, journal)
    }
    fn firewall(&self, laid: &lay::LaidEstate) -> Result<(), CloudError> {
        (**self).firewall(laid)
    }
    fn destroy(&self, what: &cloud::Give<'_>) -> Result<Reclaimed, CloudError> {
        (**self).destroy(what)
    }
    fn inventory(&self, what: &cloud::Listing<'_>) -> Result<cloud::Listed, CloudError> {
        (**self).inventory(what)
    }
}

#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum ResourceStatus {
    Active,
    Provisioning,
    Gone,
    /// **It stands, powered off** (UpCloud `stopped`; CODE RED audit 4, 2026-10-18): a box
    /// that is down without being gone — started from its disk, never laid again.
    Stopped,
}

/// What a provider-side resource IS, as the inventory reports it. Coarser than
/// [`Resource`] on purpose: a plan change is not a thing that exists at the
/// provider, a disk and a box are.
#[derive(Clone, Copy, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub enum OwnedKind {
    Disk,
    Server,
    /// **A product's own data set — the box itself.** Listed by the `server`
    /// provider so the console shows the iron a self-hoster already has. Never
    /// bought, so never an orphan and never matched to an order; see
    /// `monetize::inventory::reconcile`.
    Dataset,
}

impl OwnedKind {
    pub fn name(&self) -> &'static str {
        match self {
            OwnedKind::Disk => "disk",
            OwnedKind::Server => "server",
            OwnedKind::Dataset => "dataset",
        }
    }
}

/// **One thing monetize owns at the provider, as the provider reports it.**
///
/// The provider is the truth for what exists; this is its word, not the ledger's.
/// `monetize::inventory::reconcile` sets the two against each other.
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Owned {
    pub id: ResourceId,
    pub kind: OwnedKind,
    /// Disk size, or the OS disk of a server; 0 when the provider does not say.
    pub gib: u64,
    pub zone: String,
    /// The server a disk is attached to, when the provider says. `None` is
    /// "detached or not reported", and a detached disk is the classic orphan.
    pub attached_to: Option<String>,
    /// The `monetize_ref` label — the order it was bought for. A provider lists
    /// only what carries one, so this is `Some` for every real provider; it is an
    /// `Option` because the type does not get to assume that.
    pub reference: Option<String>,
    /// What it costs per month by the provider's own list, in `currency` minor
    /// units. `None` means "could not be priced now", never zero.
    pub monthly_minor: Option<u64>,
    /// The currency the provider prices in — what `monthly_minor` is a number
    /// OF. Known even when the row could not be priced: a provider prices its
    /// whole account in one currency, and a number without one is ambiguous.
    pub currency: monetize_pay::Currency,
    /// `upcloud` for real iron, `sandbox` for play iron, `server` for the box
    /// itself. Travels with the row so the console never has to guess from the id.
    pub provenance: String,
    /// **What it is within its order** — the `monetize_res` label, when the provider has
    /// one: `lay:<role>:disk<i>`, `lay:<role>:server`, `lay:<role>:release-clone`
    /// ([`lay::CLONE_LABEL`], 2026-10-09). How a release clone is told from a disk.
    #[serde(default)]
    pub resource: Option<String>,
    /// **A provider-side BACKUP of one of the account's disks** (MAP-3, owner 2026-10-27: 16
    /// UpCloud backups of live estates read "orphan — we pay for this and no order owns it").
    /// `Some` on a row that is a backup, with its origin; such a row is never an owned disk
    /// and never an orphan while its origin stands. `None` on every other row, and on every
    /// row of a provider older than the field.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub backup: Option<BackupOf>,
}

/// **What a backup is a backup OF**, as the provider says it.
#[derive(Clone, PartialEq, Eq, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct BackupOf {
    /// The storage it was taken of (`storage/<uuid>`): the provider's `origin`.
    #[serde(default)]
    pub origin: String,
    /// Whether that storage still stands at the provider. A backup whose origin is gone
    /// belongs to nothing that is there: an orphan, by name.
    #[serde(default)]
    pub origin_standing: bool,
    /// The name (hostname) of the server the origin disk is attached to; empty when it is
    /// detached, gone, or the provider did not say.
    #[serde(default)]
    pub server: String,
    /// When the provider took it, as the provider spells it (ISO 8601 UTC); empty when not said.
    #[serde(default)]
    pub created: String,
    /// The provider's own title of the backup (`Simple backup of <disk title>`).
    #[serde(default)]
    pub title: String,
}

impl Owned {
    /// A release-medium clone ([`lay::CLONE_LABEL`]): what the orphan sweep may give back.
    pub fn is_release_clone(&self) -> bool {
        self.kind == OwnedKind::Disk && self.backup.is_none() && self.resource.as_deref().is_some_and(|r| r.ends_with(&format!(":{}", lay::CLONE_LABEL)))
    }
    /// A provider-side backup ([`Owned::backup`]).
    pub fn is_backup(&self) -> bool {
        self.backup.is_some()
    }
}

/// The answer to *what do we actually own out there?*
#[derive(Clone, PartialEq, Eq, Debug, serde::Serialize, serde::Deserialize)]
pub struct Inventory {
    /// Everything at the provider that carries a `monetize_ref` label.
    pub owned: Vec<Owned>,
    /// Resources in the account that carry NO `monetize_ref`: not monetize's,
    /// never reported as ours — but counted, so an operator can see the account
    /// is not empty. Measured by hand on 2026-09-05: 11 storages in the UpCloud
    /// account, all `gunnar-*`, zero monetize labels.
    pub foreign: u64,
    /// The currency every `monthly_minor` in `owned` is in — the provider's own,
    /// from its price source, never a constant of ours. Travels with the list so
    /// a sum over the rows has a unit.
    pub currency: monetize_pay::Currency,
}

// ── providers, each behind its feature; the trait above is always there ──────

/// **The cloud trait (A24 §2.5): nine ops**, the kinds each takes, and [`cloud::ask`],
/// the one place an answer's arm is decoded.
pub mod cloud;
pub use cloud::{ask, Cloud};

/// **The `Cloud` conformance suite**: lay → inventory → change → destroy leaves
/// nothing, run by every implementation against its mock.
pub mod conformance;

/// How a cloud gets into a server without the server knowing its name.
pub mod factory;

/// A ceiling any provider can be wrapped in, so a rehearsal on a real account
/// cannot run away. Always available: it is not a provider, it is a guard.
pub mod cap;

/// **Growing a data set** — the vendor-neutral half of the OUTSIDE actor that
/// owns detach → resize → re-attach and can always put the volumes back
/// (`DATA-SET-GROWTH-FLOW.md` T6). Types, the durable journal, the contract.
pub mod grow;

/// **An estate's DNS records** — the writer seam (Cloudflare lives in the
/// private impl and registers here), the record, the custodian's credential.
pub mod dns;

/// **Laying a whole estate** — the servers and disks of a private gunnar
/// (`private-gunnar-ops/PRIVATE-GUNNAR.md`): the plan, what was laid, the
/// company-name rule, the contract.
pub mod lay;

/// The clock and the wait a provider polls with: the process's own when
/// linked, the host's when it runs as a guest.
pub mod pace;

/// The pool ledger — sold GiB per server and the `(reference, resource)` grants.
/// Always available: `upcloud` keeps one as a file, `sandbox` embeds one.
pub mod pool;

/// The iron this box already has: `OnHand` from a declared pool, walled by the
/// product's own measurement, never buys. The free cloud, in `default`.
#[cfg(feature = "server")]
pub mod server;

/// Fake iron at real prices: a deployable rehearsal cloud that provisions
/// nothing and whose only network call is the price GET.
#[cfg(feature = "sandbox")]
pub mod sandbox;