use anyhow::{Result, anyhow};
use serde::{Deserialize, Serialize};
use crate::cloudflare_provision::{
ConnectCredentials, ProvisionedResources, Provisioner, frontend_asset_bucket_name,
private_object_storage_bucket_name, public_object_storage_bucket_name,
};
pub struct CloudSetup<'a> {
pub project_id: &'a str,
pub account_id: &'a str,
pub zone_id: &'a str,
pub api_token: &'a str,
pub mint_from_setup_token: bool,
pub domain: &'a str,
}
impl CloudSetup<'_> {
pub fn app_origin(&self) -> String {
format!("https://{}", self.domain)
}
fn provisioner(&self) -> Provisioner {
Provisioner::new(
self.api_token.to_string(),
self.account_id.to_string(),
self.zone_id.to_string(),
)
}
}
#[derive(Serialize)]
struct ConnectInput<'a> {
project_id: &'a str,
account_id: &'a str,
zone_id: &'a str,
zone_name: &'a str,
frontend_asset_hostname: &'a str,
public_object_storage_hostname: &'a str,
private_object_storage_bucket: &'a str,
public_object_storage_bucket: &'a str,
frontend_asset_bucket: &'a str,
worker_access_key_id: &'a str,
worker_secret: &'a str,
frontend_asset_access_key_id: &'a str,
frontend_asset_secret: &'a str,
purge_token: &'a str,
}
#[derive(Deserialize)]
#[serde(tag = "t", rename_all_fields = "camelCase")]
enum Connect {
Ok,
CredentialRejected {
reason: String,
},
AlreadyConnected {
account_id: String,
zone_name: String,
},
NotLoggedIn,
NotFound,
InternalError {
reason: String,
},
}
pub async fn provision_and_connect(setup: &CloudSetup<'_>) -> Result<ProvisionedResources> {
let provisioner = setup.provisioner();
let (resources, credentials, minted) = provisioner
.run_managed(setup.project_id, &setup.app_origin(), setup.domain)
.await?;
match send_connect(setup, &resources, &credentials).await {
Ok(()) => Ok(resources),
Err(ConnectFailure::Rejected(error)) => {
provisioner.revoke_minted_credentials(&minted).await;
Err(error)
}
Err(ConnectFailure::Indeterminate(error)) => {
eprintln!(
"warning: could not tell whether fn0 stored the credentials. If it did not, \
revoke these in the Cloudflare dashboard: worker {}, frontend assets {}, \
cache purge {}.",
minted.worker, minted.frontend_asset, minted.purge
);
Err(error)
}
}
}
pub async fn provision_only(setup: &CloudSetup<'_>) -> Result<ProvisionedResources> {
setup
.provisioner()
.run_manual(setup.project_id, &setup.app_origin(), setup.domain)
.await
}
pub async fn connect_with_own_credentials(
setup: &CloudSetup<'_>,
resources: &ProvisionedResources,
credentials: &ConnectCredentials,
) -> Result<()> {
send_connect(setup, resources, credentials)
.await
.map_err(ConnectFailure::into_error)
}
pub fn expected_resources(project_id: &str, zone_name: &str) -> ProvisionedResources {
let frontend_asset_bucket = frontend_asset_bucket_name(project_id);
let public_object_storage_bucket = public_object_storage_bucket_name(project_id);
ProvisionedResources {
frontend_asset_hostname: format!("{frontend_asset_bucket}.{zone_name}"),
public_object_storage_hostname: format!("{public_object_storage_bucket}.{zone_name}"),
zone_name: zone_name.to_string(),
private_object_storage_bucket: private_object_storage_bucket_name(project_id),
public_object_storage_bucket,
frontend_asset_bucket,
}
}
enum ConnectFailure {
Rejected(anyhow::Error),
Indeterminate(anyhow::Error),
}
impl ConnectFailure {
fn into_error(self) -> anyhow::Error {
match self {
Self::Rejected(error) | Self::Indeterminate(error) => error,
}
}
}
async fn send_connect(
setup: &CloudSetup<'_>,
provisioned: &ProvisionedResources,
credentials: &ConnectCredentials,
) -> std::result::Result<(), ConnectFailure> {
let creds = crate::credentials::require().map_err(ConnectFailure::Indeterminate)?;
let url = format!(
"{}/__forte_action/cloudflare_connect",
creds.control_url.trim_end_matches('/')
);
let project_id = setup.project_id;
let response = async {
reqwest::Client::new()
.post(&url)
.bearer_auth(&creds.token)
.json(&ConnectInput {
project_id,
account_id: setup.account_id,
zone_id: setup.zone_id,
zone_name: &provisioned.zone_name,
frontend_asset_hostname: &provisioned.frontend_asset_hostname,
public_object_storage_hostname: &provisioned.public_object_storage_hostname,
private_object_storage_bucket: &provisioned.private_object_storage_bucket,
public_object_storage_bucket: &provisioned.public_object_storage_bucket,
frontend_asset_bucket: &provisioned.frontend_asset_bucket,
worker_access_key_id: &credentials.worker_access_key_id,
worker_secret: &credentials.worker_secret,
frontend_asset_access_key_id: &credentials.frontend_asset_access_key_id,
frontend_asset_secret: &credentials.frontend_asset_secret,
purge_token: &credentials.purge_token,
})
.send()
.await?
.error_for_status()?
.json::<Connect>()
.await
}
.await
.map_err(|error| ConnectFailure::Indeterminate(error.into()))?;
match response {
Connect::Ok => Ok(()),
Connect::CredentialRejected { reason } => Err(ConnectFailure::Rejected(anyhow!(
"fn0 rejected the credentials: {reason}"
))),
Connect::AlreadyConnected {
account_id,
zone_name,
} => Err(ConnectFailure::Rejected(anyhow!(
"project '{project_id}' is already connected to account {account_id} ({zone_name}). \
Reconnecting is not supported yet — it would have to decide whether to rotate \
credentials and whether to move objects already written to that account."
))),
Connect::NotLoggedIn => Err(ConnectFailure::Rejected(anyhow!(
"control rejected token; sign in again."
))),
Connect::NotFound => Err(ConnectFailure::Rejected(anyhow!(
"project '{project_id}' not found or not owned by you."
))),
Connect::InternalError { reason } => Err(ConnectFailure::Rejected(anyhow!(
"cloudflare_connect: {reason}"
))),
}
}
#[derive(Serialize)]
struct StatusInput<'a> {
project_id: &'a str,
}
#[derive(Deserialize)]
#[serde(tag = "t", rename_all_fields = "camelCase")]
enum Status {
NotConnected,
Connected { zone_name: String },
NotLoggedIn,
NotFound,
InternalError { reason: String },
}
pub enum CloudflareConnection {
Connected { zone_name: String },
NotConnected,
NotFound,
}
pub async fn fetch_cloudflare_connection(project_id: &str) -> Result<CloudflareConnection> {
let creds = crate::credentials::require()?;
let url = format!(
"{}/__forte_action/cloudflare_status",
creds.control_url.trim_end_matches('/')
);
let response = reqwest::Client::new()
.post(&url)
.bearer_auth(&creds.token)
.json(&StatusInput { project_id })
.send()
.await?
.error_for_status()?;
match response.json::<Status>().await? {
Status::Connected { zone_name } => Ok(CloudflareConnection::Connected { zone_name }),
Status::NotConnected => Ok(CloudflareConnection::NotConnected),
Status::NotFound => Ok(CloudflareConnection::NotFound),
Status::NotLoggedIn => Err(anyhow!("control rejected token; sign in again.")),
Status::InternalError { reason } => Err(anyhow!("cloudflare_status: {reason}")),
}
}