1use anyhow::{Result, anyhow};
8use serde::{Deserialize, Serialize};
9
10use crate::cloudflare_provision::{
11 ConnectCredentials, ProvisionedResources, Provisioner, frontend_asset_bucket_name,
12 private_object_storage_bucket_name, public_object_storage_bucket_name,
13 rendered_html_cache_bucket_name,
14};
15
16#[derive(Serialize)]
17struct ConnectInput<'a> {
18 project_id: &'a str,
19 account_id: &'a str,
20 zone_id: &'a str,
21 zone_name: &'a str,
22 frontend_asset_hostname: &'a str,
23 public_object_storage_hostname: &'a str,
24 private_object_storage_bucket: &'a str,
25 public_object_storage_bucket: &'a str,
26 frontend_asset_bucket: &'a str,
27 rendered_html_cache_bucket: &'a str,
28 worker_access_key_id: &'a str,
29 worker_secret: &'a str,
30 frontend_asset_access_key_id: &'a str,
31 frontend_asset_secret: &'a str,
32 purge_token: &'a str,
33}
34
35#[derive(Deserialize)]
36#[serde(tag = "t", rename_all_fields = "camelCase")]
37enum Connect {
38 Ok,
39 CredentialRejected {
40 reason: String,
41 },
42 AlreadyConnected {
43 account_id: String,
44 zone_name: String,
45 },
46 NotLoggedIn,
47 NotFound,
48 InternalError {
49 reason: String,
50 },
51}
52
53pub async fn cloudflare_connect_managed(
55 project_id: &str,
56 account_id: &str,
57 zone_id: &str,
58 api_token: &str,
59) -> Result<()> {
60 println!("provisioning your Cloudflare account (this runs locally)...");
61 let provisioner = Provisioner::new(
62 api_token.to_string(),
63 account_id.to_string(),
64 zone_id.to_string(),
65 );
66 let (resources, credentials, minted) = provisioner.run_managed(project_id).await?;
67 print_resources(&resources);
68 println!(" minted two bucket-scoped R2 tokens and a purge-only token");
69
70 match send_connect(project_id, account_id, zone_id, &resources, &credentials).await {
71 Ok(()) => Ok(()),
72 Err(ConnectFailure::Rejected(error)) => {
76 provisioner.revoke_minted_credentials(&minted).await;
77 Err(error)
78 }
79 Err(ConnectFailure::Indeterminate(error)) => {
83 eprintln!(
84 "warning: could not tell whether fn0 stored the credentials. If it did not, \
85 revoke these in the Cloudflare dashboard: worker {}, frontend assets {}, \
86 cache purge {}.",
87 minted.worker, minted.frontend_asset, minted.purge
88 );
89 Err(error)
90 }
91 }
92}
93
94pub async fn cloudflare_provision(
97 project_id: &str,
98 account_id: &str,
99 zone_id: &str,
100 api_token: &str,
101) -> Result<()> {
102 println!("provisioning your Cloudflare account (this runs locally)...");
103 let provisioner = Provisioner::new(
104 api_token.to_string(),
105 account_id.to_string(),
106 zone_id.to_string(),
107 );
108 let resources = provisioner.run_manual(project_id).await?;
109 print_resources(&resources);
110
111 println!();
112 println!("Now create the three credentials fn0 will keep. None can be minted for");
113 println!("you here, because this token deliberately cannot create tokens.");
114 println!();
115 println!("The first two are separate on purpose: only the worker one is handed to");
116 println!("the fleet, and only the frontend-asset one is used by the GC that deletes.");
117 println!("Scoping them apart is what keeps either from reaching the other's bucket.");
118 println!();
119 println!("1. R2 -> Manage API Tokens -> Create API token");
120 println!(" permission: Object Read & Write");
121 println!(" apply to specific buckets:");
122 println!(" {}", resources.private_object_storage_bucket);
123 println!(" {}", resources.public_object_storage_bucket);
124 println!(" {}", resources.rendered_html_cache_bucket);
125 println!(" the screen shows an Access Key ID and a Secret Access Key; keep both");
126 println!();
127 println!("2. R2 -> Manage API Tokens -> Create API token");
128 println!(" permission: Object Read & Write");
129 println!(" apply to specific buckets:");
130 println!(" {}", resources.frontend_asset_bucket);
131 println!();
132 println!("3. My Profile -> API Tokens -> Create Custom Token");
133 println!(" permission: Zone -> Cache Purge -> Purge");
134 println!(" zone resources: {} only", resources.zone_name);
135 println!();
136 println!("Then finish with:");
137 println!();
138 println!(" forte cloudflare connect \\");
139 println!(" --account-id {account_id} \\");
140 println!(" --zone-id {zone_id} \\");
141 println!(" --zone-name {} \\", resources.zone_name);
142 println!(" --worker-access-key-id <Access Key ID from 1> \\");
143 println!(" --worker-secret <Secret Access Key from 1> \\");
144 println!(" --frontend-asset-access-key-id <Access Key ID from 2> \\");
145 println!(" --frontend-asset-secret <Secret Access Key from 2> \\");
146 println!(" --purge-token <purge token from 3>");
147 println!();
148 println!("You can delete the token you just used once you have done that.");
149 Ok(())
150}
151
152#[allow(clippy::too_many_arguments)]
154pub async fn cloudflare_connect_manual(
155 project_id: &str,
156 account_id: &str,
157 zone_id: &str,
158 zone_name: &str,
159 worker_access_key_id: &str,
160 worker_secret: &str,
161 frontend_asset_access_key_id: &str,
162 frontend_asset_secret: &str,
163 purge_token: &str,
164) -> Result<()> {
165 let frontend_asset_bucket = frontend_asset_bucket_name(project_id);
166 let public_object_storage_bucket = public_object_storage_bucket_name(project_id);
167 let resources = ProvisionedResources {
168 frontend_asset_hostname: format!("{frontend_asset_bucket}.{zone_name}"),
169 public_object_storage_hostname: format!("{public_object_storage_bucket}.{zone_name}"),
170 zone_name: zone_name.to_string(),
171 private_object_storage_bucket: private_object_storage_bucket_name(project_id),
172 public_object_storage_bucket,
173 frontend_asset_bucket,
174 rendered_html_cache_bucket: rendered_html_cache_bucket_name(project_id),
175 };
176 let credentials = ConnectCredentials {
177 worker_access_key_id: worker_access_key_id.to_string(),
178 worker_secret: worker_secret.to_string(),
179 frontend_asset_access_key_id: frontend_asset_access_key_id.to_string(),
180 frontend_asset_secret: frontend_asset_secret.to_string(),
181 purge_token: purge_token.to_string(),
182 };
183 send_connect(project_id, account_id, zone_id, &resources, &credentials)
186 .await
187 .map_err(ConnectFailure::into_error)
188}
189
190fn print_resources(resources: &ProvisionedResources) {
191 println!(" zone: {}", resources.zone_name);
192 println!(" buckets: {}", resources.private_object_storage_bucket);
193 println!(" {}", resources.public_object_storage_bucket);
194 println!(" {}", resources.frontend_asset_bucket);
195 println!(" {}", resources.rendered_html_cache_bucket);
196 println!(" assets: https://{}", resources.frontend_asset_hostname);
197 println!(
198 " public: https://{}",
199 resources.public_object_storage_hostname
200 );
201}
202
203enum ConnectFailure {
207 Rejected(anyhow::Error),
210 Indeterminate(anyhow::Error),
213}
214
215impl ConnectFailure {
216 fn into_error(self) -> anyhow::Error {
217 match self {
218 Self::Rejected(error) | Self::Indeterminate(error) => error,
219 }
220 }
221}
222
223async fn send_connect(
224 project_id: &str,
225 account_id: &str,
226 zone_id: &str,
227 provisioned: &ProvisionedResources,
228 credentials: &ConnectCredentials,
229) -> std::result::Result<(), ConnectFailure> {
230 let creds = crate::credentials::require().map_err(ConnectFailure::Indeterminate)?;
231 let url = format!(
232 "{}/__forte_action/cloudflare_connect",
233 creds.control_url.trim_end_matches('/')
234 );
235 let response = async {
236 reqwest::Client::new()
237 .post(&url)
238 .bearer_auth(&creds.token)
239 .json(&ConnectInput {
240 project_id,
241 account_id,
242 zone_id,
243 zone_name: &provisioned.zone_name,
244 frontend_asset_hostname: &provisioned.frontend_asset_hostname,
245 public_object_storage_hostname: &provisioned.public_object_storage_hostname,
246 private_object_storage_bucket: &provisioned.private_object_storage_bucket,
247 public_object_storage_bucket: &provisioned.public_object_storage_bucket,
248 frontend_asset_bucket: &provisioned.frontend_asset_bucket,
249 rendered_html_cache_bucket: &provisioned.rendered_html_cache_bucket,
250 worker_access_key_id: &credentials.worker_access_key_id,
251 worker_secret: &credentials.worker_secret,
252 frontend_asset_access_key_id: &credentials.frontend_asset_access_key_id,
253 frontend_asset_secret: &credentials.frontend_asset_secret,
254 purge_token: &credentials.purge_token,
255 })
256 .send()
257 .await?
258 .error_for_status()?
259 .json::<Connect>()
260 .await
261 }
262 .await
263 .map_err(|error| ConnectFailure::Indeterminate(error.into()))?;
264
265 match response {
266 Connect::Ok => {
267 println!();
268 println!("connected. the token you used was not sent to fn0 and is no longer");
269 println!("needed — you can delete it in the Cloudflare dashboard.");
270 println!("workers pick this up within a second; no redeploy needed.");
271 Ok(())
272 }
273 Connect::CredentialRejected { reason } => Err(ConnectFailure::Rejected(anyhow!(
274 "fn0 rejected the credentials: {reason}"
275 ))),
276 Connect::AlreadyConnected {
277 account_id,
278 zone_name,
279 } => Err(ConnectFailure::Rejected(anyhow!(
280 "project '{project_id}' is already connected to account {account_id} ({zone_name}). \
281 Reconnecting is not supported yet — it would have to decide whether to rotate \
282 credentials and whether to move objects already written to that account."
283 ))),
284 Connect::NotLoggedIn => Err(ConnectFailure::Rejected(anyhow!(
285 "control rejected token; sign in again."
286 ))),
287 Connect::NotFound => Err(ConnectFailure::Rejected(anyhow!(
288 "project '{project_id}' not found or not owned by you."
289 ))),
290 Connect::InternalError { reason } => Err(ConnectFailure::Rejected(anyhow!(
291 "cloudflare_connect: {reason}"
292 ))),
293 }
294}
295
296#[derive(Serialize)]
297struct StatusInput<'a> {
298 project_id: &'a str,
299}
300
301#[derive(Deserialize)]
302#[serde(tag = "t", rename_all_fields = "camelCase")]
303enum Status {
304 NotConnected,
305 Connected {
306 account_id: String,
307 zone_name: String,
308 frontend_asset_hostname: String,
309 public_object_storage_hostname: String,
310 frontend_asset_bucket: String,
311 public_object_storage_bucket: String,
312 private_object_storage_bucket: String,
313 rendered_html_cache_bucket: String,
314 healthy: bool,
315 problem: Option<String>,
316 },
317 NotLoggedIn,
318 NotFound,
319 InternalError {
320 reason: String,
321 },
322}
323
324pub async fn cloudflare_status(project_id: &str) -> Result<()> {
325 let creds = crate::credentials::require()?;
326 let url = format!(
327 "{}/__forte_action/cloudflare_status",
328 creds.control_url.trim_end_matches('/')
329 );
330 let response = reqwest::Client::new()
331 .post(&url)
332 .bearer_auth(&creds.token)
333 .json(&StatusInput { project_id })
334 .send()
335 .await?
336 .error_for_status()?;
337
338 match response.json::<Status>().await? {
339 Status::NotConnected => {
340 println!("project '{project_id}' has no Cloudflare account connected.");
341 println!("run `forte cloudflare connect` before deploying it.");
342 Ok(())
343 }
344 Status::Connected {
345 account_id,
346 zone_name,
347 frontend_asset_hostname,
348 public_object_storage_hostname,
349 frontend_asset_bucket,
350 public_object_storage_bucket,
351 private_object_storage_bucket,
352 rendered_html_cache_bucket,
353 healthy,
354 problem,
355 } => {
356 println!("account: {account_id}");
357 println!("zone: {zone_name}");
358 println!("assets: {frontend_asset_bucket} -> https://{frontend_asset_hostname}");
359 println!(
360 "public: {public_object_storage_bucket} -> https://{public_object_storage_hostname}"
361 );
362 println!("private: {private_object_storage_bucket}");
363 println!("cache: {rendered_html_cache_bucket}");
364 match (healthy, problem) {
365 (true, _) => println!("status: ok"),
366 (false, Some(problem)) => println!("status: degraded - {problem}"),
367 (false, None) => println!("status: degraded"),
368 }
369 Ok(())
370 }
371 Status::NotLoggedIn => Err(anyhow!("control rejected token; sign in again.")),
372 Status::NotFound => Err(anyhow!(
373 "project '{project_id}' not found or not owned by you."
374 )),
375 Status::InternalError { reason } => Err(anyhow!("cloudflare_status: {reason}")),
376 }
377}