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//!
7//! Everything in this module is a step of `forte cloud init`. The command owns
8//! the prompting and the printing; these functions own the Cloudflare calls, so
9//! the CLI never handles a token or decides what to mint.
10
11use anyhow::{Result, anyhow};
12use serde::{Deserialize, Serialize};
13
14use crate::cloudflare_provision::{
15    ConnectCredentials, ProvisionedResources, Provisioner, frontend_asset_bucket_name,
16    private_object_storage_bucket_name, public_object_storage_bucket_name,
17};
18
19/// What the user chose and typed, carried between the steps of one setup.
20pub struct CloudSetup<'a> {
21    pub project_id: &'a str,
22    pub account_id: &'a str,
23    pub zone_id: &'a str,
24    /// Discarded when the command exits. fn0 never receives it.
25    pub api_token: &'a str,
26    /// `true` when the token carries only `API Tokens -> Edit`, so every
27    /// privileged call has to be made through a token minted from it.
28    pub mint_from_setup_token: bool,
29    pub domain: &'a str,
30}
31
32impl CloudSetup<'_> {
33    /// The one origin the project's own pages are served from, and so the only
34    /// origin allowed to read its buckets from a browser.
35    pub fn app_origin(&self) -> String {
36        format!("https://{}", self.domain)
37    }
38
39    fn provisioner(&self) -> Provisioner {
40        Provisioner::new(
41            self.api_token.to_string(),
42            self.account_id.to_string(),
43            self.zone_id.to_string(),
44        )
45    }
46}
47
48#[derive(Serialize)]
49struct ConnectInput<'a> {
50    project_id: &'a str,
51    account_id: &'a str,
52    zone_id: &'a str,
53    zone_name: &'a str,
54    frontend_asset_hostname: &'a str,
55    public_object_storage_hostname: &'a str,
56    private_object_storage_bucket: &'a str,
57    public_object_storage_bucket: &'a str,
58    frontend_asset_bucket: &'a str,
59    worker_access_key_id: &'a str,
60    worker_secret: &'a str,
61    frontend_asset_access_key_id: &'a str,
62    frontend_asset_secret: &'a str,
63    purge_token: &'a str,
64}
65
66#[derive(Deserialize)]
67#[serde(tag = "t", rename_all_fields = "camelCase")]
68enum Connect {
69    Ok,
70    CredentialRejected {
71        reason: String,
72    },
73    AlreadyConnected {
74        account_id: String,
75        zone_name: String,
76    },
77    NotLoggedIn,
78    NotFound,
79    InternalError {
80        reason: String,
81    },
82}
83
84/// The convenient path: one `API Tokens -> Edit` token. Provisions the account,
85/// mints the three credentials fn0 keeps, and hands them over.
86pub async fn provision_and_connect(setup: &CloudSetup<'_>) -> Result<ProvisionedResources> {
87    let provisioner = setup.provisioner();
88    let (resources, credentials, minted) = provisioner
89        .run_managed(setup.project_id, &setup.app_origin(), setup.domain)
90        .await?;
91
92    match send_connect(setup, &resources, &credentials).await {
93        Ok(()) => Ok(resources),
94        // fn0 answered, and its answer proves it stored nothing. These two
95        // credentials never expire, so leaving them would hand the account a
96        // live R2 read-write pair for every failed attempt.
97        Err(ConnectFailure::Rejected(error)) => {
98            provisioner.revoke_minted_credentials(&minted).await;
99            Err(error)
100        }
101        // No answer arrived, so whether fn0 stored them is unknown. Revoking
102        // could break a connection that did succeed; naming them lets the user
103        // decide.
104        Err(ConnectFailure::Indeterminate(error)) => {
105            eprintln!(
106                "warning: could not tell whether fn0 stored the credentials. If it did not, \
107                 revoke these in the Cloudflare dashboard: worker {}, frontend assets {}, \
108                 cache purge {}.",
109                minted.worker, minted.frontend_asset, minted.purge
110            );
111            Err(error)
112        }
113    }
114}
115
116/// The careful path, first half: a token that can provision but cannot create
117/// tokens. Provisions and stops, so the caller can tell the user which three
118/// credentials to make by hand.
119pub async fn provision_only(setup: &CloudSetup<'_>) -> Result<ProvisionedResources> {
120    setup
121        .provisioner()
122        .run_manual(setup.project_id, &setup.app_origin(), setup.domain)
123        .await
124}
125
126/// The careful path, second half: credentials the user made themselves.
127pub async fn connect_with_own_credentials(
128    setup: &CloudSetup<'_>,
129    resources: &ProvisionedResources,
130    credentials: &ConnectCredentials,
131) -> Result<()> {
132    // Nothing to revoke on failure: these credentials are the user's own, and
133    // fn0 holds no token that could revoke them anyway.
134    send_connect(setup, resources, credentials)
135        .await
136        .map_err(ConnectFailure::into_error)
137}
138
139/// Names the buckets a project's credentials have to be scoped to, before those
140/// buckets exist. The careful path prints them so the user can pick the right
141/// scopes in the dashboard.
142pub fn expected_resources(project_id: &str, zone_name: &str) -> ProvisionedResources {
143    let frontend_asset_bucket = frontend_asset_bucket_name(project_id);
144    let public_object_storage_bucket = public_object_storage_bucket_name(project_id);
145    ProvisionedResources {
146        frontend_asset_hostname: format!("{frontend_asset_bucket}.{zone_name}"),
147        public_object_storage_hostname: format!("{public_object_storage_bucket}.{zone_name}"),
148        zone_name: zone_name.to_string(),
149        private_object_storage_bucket: private_object_storage_bucket_name(project_id),
150        public_object_storage_bucket,
151        frontend_asset_bucket,
152    }
153}
154
155/// Why a connect did not succeed, split by what it says about fn0's state — the
156/// caller has credentials to clean up and may only do so when nothing was
157/// stored.
158enum ConnectFailure {
159    /// fn0 answered. Every answer other than `Ok` is returned before anything
160    /// is written, so the credentials sent are certainly unused.
161    Rejected(anyhow::Error),
162    /// The request or its answer did not complete. fn0 may or may not have
163    /// stored the credentials.
164    Indeterminate(anyhow::Error),
165}
166
167impl ConnectFailure {
168    fn into_error(self) -> anyhow::Error {
169        match self {
170            Self::Rejected(error) | Self::Indeterminate(error) => error,
171        }
172    }
173}
174
175async fn send_connect(
176    setup: &CloudSetup<'_>,
177    provisioned: &ProvisionedResources,
178    credentials: &ConnectCredentials,
179) -> std::result::Result<(), ConnectFailure> {
180    let creds = crate::credentials::require().map_err(ConnectFailure::Indeterminate)?;
181    let url = format!(
182        "{}/__forte_action/cloudflare_connect",
183        creds.control_url.trim_end_matches('/')
184    );
185    let project_id = setup.project_id;
186    let response = async {
187        reqwest::Client::new()
188            .post(&url)
189            .bearer_auth(&creds.token)
190            .json(&ConnectInput {
191                project_id,
192                account_id: setup.account_id,
193                zone_id: setup.zone_id,
194                zone_name: &provisioned.zone_name,
195                frontend_asset_hostname: &provisioned.frontend_asset_hostname,
196                public_object_storage_hostname: &provisioned.public_object_storage_hostname,
197                private_object_storage_bucket: &provisioned.private_object_storage_bucket,
198                public_object_storage_bucket: &provisioned.public_object_storage_bucket,
199                frontend_asset_bucket: &provisioned.frontend_asset_bucket,
200                worker_access_key_id: &credentials.worker_access_key_id,
201                worker_secret: &credentials.worker_secret,
202                frontend_asset_access_key_id: &credentials.frontend_asset_access_key_id,
203                frontend_asset_secret: &credentials.frontend_asset_secret,
204                purge_token: &credentials.purge_token,
205            })
206            .send()
207            .await?
208            .error_for_status()?
209            .json::<Connect>()
210            .await
211    }
212    .await
213    .map_err(|error| ConnectFailure::Indeterminate(error.into()))?;
214
215    match response {
216        Connect::Ok => Ok(()),
217        Connect::CredentialRejected { reason } => Err(ConnectFailure::Rejected(anyhow!(
218            "fn0 rejected the credentials: {reason}"
219        ))),
220        Connect::AlreadyConnected {
221            account_id,
222            zone_name,
223        } => Err(ConnectFailure::Rejected(anyhow!(
224            "project '{project_id}' is already connected to account {account_id} ({zone_name}). \
225             Reconnecting is not supported yet — it would have to decide whether to rotate \
226             credentials and whether to move objects already written to that account."
227        ))),
228        Connect::NotLoggedIn => Err(ConnectFailure::Rejected(anyhow!(
229            "control rejected token; sign in again."
230        ))),
231        Connect::NotFound => Err(ConnectFailure::Rejected(anyhow!(
232            "project '{project_id}' not found or not owned by you."
233        ))),
234        Connect::InternalError { reason } => Err(ConnectFailure::Rejected(anyhow!(
235            "cloudflare_connect: {reason}"
236        ))),
237    }
238}
239
240#[derive(Serialize)]
241struct StatusInput<'a> {
242    project_id: &'a str,
243}
244
245#[derive(Deserialize)]
246#[serde(tag = "t", rename_all_fields = "camelCase")]
247enum Status {
248    NotConnected,
249    Connected { zone_name: String },
250    NotLoggedIn,
251    NotFound,
252    InternalError { reason: String },
253}
254
255/// Whether a project has an account behind it. `forte deploy` refuses on this,
256/// because a project without one has nowhere to put its bundle or its assets.
257pub enum CloudflareConnection {
258    Connected { zone_name: String },
259    NotConnected,
260    NotFound,
261}
262
263pub async fn fetch_cloudflare_connection(project_id: &str) -> Result<CloudflareConnection> {
264    let creds = crate::credentials::require()?;
265    let url = format!(
266        "{}/__forte_action/cloudflare_status",
267        creds.control_url.trim_end_matches('/')
268    );
269    let response = reqwest::Client::new()
270        .post(&url)
271        .bearer_auth(&creds.token)
272        .json(&StatusInput { project_id })
273        .send()
274        .await?
275        .error_for_status()?;
276
277    match response.json::<Status>().await? {
278        Status::Connected { zone_name } => Ok(CloudflareConnection::Connected { zone_name }),
279        Status::NotConnected => Ok(CloudflareConnection::NotConnected),
280        Status::NotFound => Ok(CloudflareConnection::NotFound),
281        Status::NotLoggedIn => Err(anyhow!("control rejected token; sign in again.")),
282        Status::InternalError { reason } => Err(anyhow!("cloudflare_status: {reason}")),
283    }
284}