Skip to main content

fn0_deploy/
cloudflare.rs

1//! Connecting a project to the owner's own Cloudflare account.
2//!
3//! The account-wide token is used here and only here. It provisions the
4//! account, mints two narrow credentials, and goes out of scope; fn0 receives
5//! the narrow credentials and never the token that made them.
6
7use 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
53/// One `API Tokens -> Edit` token; the CLI provisions and mints from it.
54pub 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        // fn0 answered, and its answer proves it stored nothing. These two
73        // credentials never expire, so leaving them would hand the account a
74        // live R2 read-write pair for every failed attempt.
75        Err(ConnectFailure::Rejected(error)) => {
76            provisioner.revoke_minted_credentials(&minted).await;
77            Err(error)
78        }
79        // No answer arrived, so whether fn0 stored them is unknown. Revoking
80        // could break a connection that did succeed; naming them lets the user
81        // decide.
82        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
94/// A token that can provision but cannot create tokens. Provisions and stops;
95/// the user makes the two long-lived credentials themselves.
96pub 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/// The second half of the careful path: credentials the user made by hand.
153#[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    // Nothing to revoke on failure: these credentials are the user's own, and
184    // fn0 holds no token that could revoke them anyway.
185    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
203/// Why a connect did not succeed, split by what it says about fn0's state — the
204/// caller has credentials to clean up and may only do so when nothing was
205/// stored.
206enum ConnectFailure {
207    /// fn0 answered. Every answer other than `Ok` is returned before anything
208    /// is written, so the credentials sent are certainly unused.
209    Rejected(anyhow::Error),
210    /// The request or its answer did not complete. fn0 may or may not have
211    /// stored the credentials.
212    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}