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