1use 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
19pub struct CloudSetup<'a> {
21 pub project_id: &'a str,
22 pub account_id: &'a str,
23 pub zone_id: &'a str,
24 pub api_token: &'a str,
26 pub mint_from_setup_token: bool,
29 pub domain: &'a str,
30}
31
32impl CloudSetup<'_> {
33 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
84pub 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 Err(ConnectFailure::Rejected(error)) => {
98 provisioner.revoke_minted_credentials(&minted).await;
99 Err(error)
100 }
101 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
116pub 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
126pub async fn connect_with_own_credentials(
128 setup: &CloudSetup<'_>,
129 resources: &ProvisionedResources,
130 credentials: &ConnectCredentials,
131) -> Result<()> {
132 send_connect(setup, resources, credentials)
135 .await
136 .map_err(ConnectFailure::into_error)
137}
138
139pub 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
155enum ConnectFailure {
159 Rejected(anyhow::Error),
162 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
255pub 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}