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;