Skip to main content

cloud/provider/
mod.rs

1//! @yah:relay(R409, "Envoy — providers and internal verb catalog (W144)")
2//! @yah:at(2026-06-02T20:58:35Z)
3//! @yah:status(open)
4//! @arch:see(.yah/docs/working/W144-envoy-providers-and-tiering.md)
5//!
6//!
7//!
8//!
9
10use anyhow::Result;
11use async_trait::async_trait;
12
13// R374-F3: `s3_sign` moved to the `local-driver` crate; yubaba's pond MinIO
14// slot uses the same SigV4 helpers. Cloud's hetzner / r2_publish / pond_publish
15// callers now import from `local_driver::s3_sign`.
16
17pub mod cloudflare;
18pub use cloudflare::{
19    CfAccountInfo, CloudflareClient, CreateR2BucketResult, CreateTokenResult, CreateTunnelResult,
20    GrantScope, R2BucketInfo, R2CustomDomain, TokenGrant, TunnelConnState, TunnelDnsRecord,
21    TunnelDriftRow, TunnelDriftState, WorkerDeployResult, MESOFACT_STATIC_GRANTS,
22};
23
24pub mod hetzner;
25pub use hetzner::HetznerDriver;
26
27pub mod cloudflare_envoy;
28pub use cloudflare_envoy::CloudflareEnvoy;
29
30pub mod hetzner_envoy;
31pub use hetzner_envoy::HetznerEnvoy;
32
33pub mod digitalocean;
34pub use digitalocean::{DigitalOceanClient, DigitalOceanEnvoy, DoCreateDropletSpec};
35
36// R594-F5: `floating_ip.*` envoy verb — raft `ingress_owner` follow-placement
37// for sovereign-tier public ingress (W267 §Tier 1). `floating_ip` holds the
38// provider-abstracted trait + idempotent reconcile core; the three
39// `*_floating_ip` modules are the Hetzner/OVH/Vultr adapters.
40pub mod floating_ip;
41pub use floating_ip::{
42    on_ingress_owner_changed, reconcile_assignment, FloatingIpAssignOutcome, FloatingIpProvider,
43    FloatingIpState, FloatingIpTarget,
44};
45// R859-F2: the registry R594-F5 left out (nothing mapped `machine.provider` to
46// an adapter, so the verbs were described but unreachable), plus the pure
47// planner that decides what an `ingress_owner` transition should command.
48pub use floating_ip::{
49    floating_ip_provider_for, plan_ingress_owner_effect, provider_has_floating_ip_adapter,
50    resolve_ingress_owner, IngressOwnerEffect, OwnerLiveness, QuorumHealth,
51};
52
53pub mod hetzner_floating_ip;
54pub use hetzner_floating_ip::HetznerFloatingIp;
55
56pub mod ovh_floating_ip;
57pub use ovh_floating_ip::OvhFloatingIp;
58
59pub mod vultr_floating_ip;
60pub use vultr_floating_ip::VultrFloatingIp;
61
62#[cfg(feature = "local-docker")]
63pub mod local_docker;
64#[cfg(feature = "local-docker")]
65pub use local_docker::LocalDockerProvider;
66
67#[cfg(feature = "local-docker")]
68pub mod local_docker_envoy;
69#[cfg(feature = "local-docker")]
70pub use local_docker_envoy::LocalDockerEnvoy;
71
72/// Logical project scope. Hetzner Cloud tokens are already project-scoped, so
73/// this is a no-op placeholder there.
74#[derive(Debug, Clone, PartialEq, Eq, Hash)]
75pub struct ProjectId(pub String);
76
77/// Opaque server identifier returned by the provider.
78#[derive(Debug, Clone, PartialEq, Eq, Hash)]
79pub struct ServerId(pub String);
80
81/// Reference to a created object-storage bucket.
82#[derive(Debug, Clone, PartialEq, Eq)]
83pub struct BucketRef {
84    pub name: String,
85    /// S3-compat base endpoint for this bucket's region.
86    pub endpoint: String,
87}
88
89/// Observed server lifecycle status (mirrors Hetzner Cloud's status field).
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub enum ServerStatus {
92    Initializing,
93    Starting,
94    Running,
95    Stopping,
96    Off,
97    Deleting,
98    Unknown(String),
99}
100
101impl ServerStatus {
102    pub fn is_running(&self) -> bool {
103        matches!(self, ServerStatus::Running)
104    }
105}
106
107/// Snapshot of a live server returned by [`MachineProvider::find_server_by_name`].
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct ServerSummary {
110    pub id: ServerId,
111    /// `server_type.name` from the Hetzner API (e.g. `"cpx22"`).
112    pub server_type: String,
113    pub status: ServerStatus,
114    /// Primary public IPv4 address, if available (`public_net.ipv4.ip`).
115    pub public_ipv4: Option<String>,
116    /// Provider-native location slug (e.g. Hetzner `"hil"` / `"ash"` / `"fsn1"`).
117    /// Used by the idempotent-provision reconciler (R330-F15) to detect
118    /// declared-vs-reality location drift without a separate API call.
119    pub location: String,
120}
121
122/// Parameters for a new machine.
123#[derive(Debug, Clone)]
124pub struct ServerSpec {
125    pub name: String,
126    pub server_type: String,
127    /// Cloud image slug, e.g. `"debian-12"`.
128    pub image: String,
129    pub location: Location,
130    /// Provider-side SSH-key IDs to authorize for `root` at create time.
131    /// Empty means "no key" — Hetzner then emails a random root password,
132    /// which the cloud-crate currently throws away. For machines that
133    /// expect mesh-only access via yah-yubaba once cloud-init finishes,
134    /// pre-mesh SSH is still useful for bootstrap deploys (the
135    /// `yah-agentd` round-trip in R032-T3) and recovery.
136    pub ssh_keys: Vec<u64>,
137}
138
139/// Phase-1 cloud regions.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Location {
142    /// Hillsboro, Oregon, USA — Hetzner Cloud: `"hil"`
143    Pdx,
144    /// Ashburn, Virginia, USA — Hetzner Cloud: `"ash"`
145    Iad,
146    /// Falkenstein, Germany — Hetzner Cloud: `"fsn1"`
147    Fsn,
148}
149
150impl Location {
151    /// Hetzner Cloud API location slug.
152    pub fn hetzner_cloud_id(&self) -> &'static str {
153        match self {
154            Location::Pdx => "hil",
155            Location::Iad => "ash",
156            Location::Fsn => "fsn1",
157        }
158    }
159
160    /// Hetzner Object Storage S3-compat base endpoint.
161    ///
162    /// VERIFY before A6 that Hillsboro (PDX) and Ashburn (IAD) Object Storage
163    /// are GA. Falkenstein (FSN) is confirmed GA.
164    pub fn hetzner_storage_endpoint(&self) -> &'static str {
165        match self {
166            Location::Fsn => "https://fsn1.your-objectstorage.com",
167            Location::Pdx => "https://hil.your-objectstorage.com",
168            Location::Iad => "https://ash.your-objectstorage.com",
169        }
170    }
171
172    /// Region label used for AWS Sig V4 signing against Hetzner Object Storage.
173    pub fn hetzner_storage_region(&self) -> &'static str {
174        match self {
175            Location::Fsn => "fsn1",
176            Location::Pdx => "hil",
177            Location::Iad => "ash",
178        }
179    }
180}
181
182impl TryFrom<&str> for Location {
183    type Error = anyhow::Error;
184    fn try_from(s: &str) -> Result<Self> {
185        // Wire codes are the coarse region tags from W144 D5; the
186        // Hetzner-native city codes stay as a one-way internal shorthand for
187        // logs, config files, and bucket endpoints — they are not accepted
188        // from the public verb surface.
189        match s {
190            "na-west" | "pdx" | "hil" => Ok(Location::Pdx),
191            "na-east" | "iad" | "ash" => Ok(Location::Iad),
192            "eu-central" | "fsn" | "fsn1" => Ok(Location::Fsn),
193            other => Err(anyhow::anyhow!("unknown location: {other}")),
194        }
195    }
196}
197
198/// Canned S3 ACL policies for bucket-level access control.
199#[derive(Debug, Clone, PartialEq, Eq)]
200pub enum BucketAcl {
201    /// No public access; presigned URLs still work (signed-only access pattern).
202    Private,
203    /// Anonymous GET/HEAD allowed; objects served publicly.
204    PublicRead,
205}
206
207impl BucketAcl {
208    /// S3 canned ACL string for the `x-amz-acl` header.
209    pub fn as_canned(&self) -> &'static str {
210        match self {
211            BucketAcl::Private => "private",
212            BucketAcl::PublicRead => "public-read",
213        }
214    }
215}
216
217/// Abstracts over cloud providers for the machine + bucket lifecycle.
218#[async_trait]
219pub trait MachineProvider: Send + Sync {
220    /// Return or create a logical project scope.
221    ///
222    /// Hetzner Cloud tokens are already project-scoped — this returns a
223    /// no-op `ProjectId(name)` without calling the API.
224    async fn ensure_project(&self, name: &str) -> Result<ProjectId>;
225
226    /// Provision a new server with the given cloud-init `user_data` string.
227    async fn create_server(
228        &self,
229        project: &ProjectId,
230        spec: &ServerSpec,
231        user_data: &str,
232    ) -> Result<ServerId>;
233
234    /// Create an object-storage bucket in the given location.
235    ///
236    /// Uses Hetzner Object Storage S3-compat API (separate from Cloud API).
237    /// Requires `HETZNER_S3_ACCESS_KEY` + `HETZNER_S3_SECRET_KEY` — run
238    /// `yah cloud secrets` for the canonical contract.
239    async fn create_bucket(&self, name: &str, location: Location) -> Result<BucketRef>;
240
241    /// Fetch the current lifecycle status of a server.
242    async fn server_status(&self, id: &ServerId) -> Result<ServerStatus>;
243
244    /// Look up a server by its declared name. `Ok(None)` means the API
245    /// responded but no server with that name exists; `Err(_)` means the
246    /// API call itself failed.
247    async fn find_server_by_name(&self, name: &str) -> Result<Option<ServerSummary>>;
248
249    /// Probe whether a bucket exists in `location`. `Ok(true)` = HEAD 200,
250    /// `Ok(false)` = HEAD 404. Auth failures (403) propagate as `Err` so
251    /// the caller can distinguish "missing" from "can't tell".
252    async fn bucket_exists(&self, name: &str, location: Location) -> Result<bool>;
253
254    /// Irreversibly destroy a server. Returns `Ok(())` if already deleted.
255    async fn destroy_server(&self, id: &ServerId) -> Result<()>;
256
257    /// Irreversibly delete an object-storage bucket. Lists and deletes every
258    /// object first (S3 won't delete a non-empty bucket), then deletes the
259    /// bucket itself. Returns `Ok(())` if the bucket was already gone (404
260    /// on the final DELETE). Auth/transport failures propagate as `Err`.
261    async fn delete_bucket(&self, name: &str, location: Location) -> Result<()>;
262
263    /// Set the canned ACL on an existing bucket via `PUT /<bucket>?acl`.
264    ///
265    /// [`BucketAcl::Private`] covers both "private" and "signed-only" semantics —
266    /// presigned URLs work regardless of ACL. [`BucketAcl::PublicRead`] enables
267    /// anonymous GET/HEAD. The call is idempotent: applying the same ACL twice
268    /// succeeds without error.
269    async fn set_bucket_acl(&self, name: &str, location: Location, acl: BucketAcl) -> Result<()>;
270}