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