1use anyhow::{Result, anyhow};
8use serde::{Deserialize, Serialize};
9
10use crate::cloudflare_provision::{
11 ASSET_BUCKET, DataPlaneCredentials, PAGE_BUCKET, ProvisionedResources, Provisioner,
12 object_bucket_name,
13};
14
15#[derive(Serialize)]
16struct ConnectInput<'a> {
17 project_id: &'a str,
18 account_id: &'a str,
19 zone_id: &'a str,
20 zone_name: &'a str,
21 static_hostname: &'a str,
22 object_bucket: &'a str,
23 asset_bucket: &'a str,
24 page_bucket: &'a str,
25 dataplane_access_key_id: &'a str,
26 dataplane_secret: &'a str,
27 purge_token: &'a str,
28}
29
30#[derive(Deserialize)]
31#[serde(tag = "t", rename_all_fields = "camelCase")]
32enum Connect {
33 Ok,
34 CredentialRejected {
35 reason: String,
36 },
37 AlreadyConnected {
38 account_id: String,
39 zone_name: String,
40 },
41 NotLoggedIn,
42 NotFound,
43 InternalError {
44 reason: String,
45 },
46}
47
48pub async fn cloudflare_connect_managed(
50 project_id: &str,
51 account_id: &str,
52 zone_id: &str,
53 api_token: &str,
54) -> Result<()> {
55 println!("provisioning your Cloudflare account (this runs locally)...");
56 let provisioner = Provisioner::new(
57 api_token.to_string(),
58 account_id.to_string(),
59 zone_id.to_string(),
60 );
61 let (resources, credentials) = provisioner.run_managed(project_id).await?;
62 print_resources(&resources);
63 println!(" minted a bucket-scoped R2 token and a purge-only token");
64 send_connect(project_id, account_id, zone_id, &resources, &credentials).await
65}
66
67pub async fn cloudflare_provision(
70 project_id: &str,
71 account_id: &str,
72 zone_id: &str,
73 api_token: &str,
74) -> Result<()> {
75 println!("provisioning your Cloudflare account (this runs locally)...");
76 let provisioner = Provisioner::new(
77 api_token.to_string(),
78 account_id.to_string(),
79 zone_id.to_string(),
80 );
81 let resources = provisioner.run_manual(project_id).await?;
82 print_resources(&resources);
83
84 println!();
85 println!("Now create the two credentials fn0 will keep. Neither can be minted for");
86 println!("you here, because this token deliberately cannot create tokens.");
87 println!();
88 println!("1. R2 -> Manage API Tokens -> Create API token");
89 println!(" permission: Object Read & Write");
90 println!(" apply to specific buckets:");
91 println!(" {}", resources.object_bucket);
92 println!(" {}", resources.asset_bucket);
93 println!(" {}", resources.page_bucket);
94 println!(" the screen shows an Access Key ID and a Secret Access Key; keep both");
95 println!();
96 println!("2. My Profile -> API Tokens -> Create Custom Token");
97 println!(" permission: Zone -> Cache Purge -> Purge");
98 println!(" zone resources: {} only", resources.zone_name);
99 println!();
100 println!("Then finish with:");
101 println!();
102 println!(" forte cloudflare connect \\");
103 println!(" --account-id {account_id} \\");
104 println!(" --zone-id {zone_id} \\");
105 println!(" --zone-name {} \\", resources.zone_name);
106 println!(" --dataplane-access-key-id <Access Key ID> \\");
107 println!(" --dataplane-secret <Secret Access Key> \\");
108 println!(" --purge-token <purge token>");
109 println!();
110 println!("You can delete the token you just used once you have done that.");
111 Ok(())
112}
113
114pub async fn cloudflare_connect_manual(
116 project_id: &str,
117 account_id: &str,
118 zone_id: &str,
119 zone_name: &str,
120 dataplane_access_key_id: &str,
121 dataplane_secret: &str,
122 purge_token: &str,
123) -> Result<()> {
124 let resources = ProvisionedResources {
125 zone_name: zone_name.to_string(),
126 static_hostname: format!("static.{zone_name}"),
127 object_bucket: object_bucket_name(project_id),
128 asset_bucket: ASSET_BUCKET.to_string(),
129 page_bucket: PAGE_BUCKET.to_string(),
130 };
131 let credentials = DataPlaneCredentials {
132 dataplane_access_key_id: dataplane_access_key_id.to_string(),
133 dataplane_secret: dataplane_secret.to_string(),
134 purge_token: purge_token.to_string(),
135 };
136 send_connect(project_id, account_id, zone_id, &resources, &credentials).await
137}
138
139fn print_resources(resources: &ProvisionedResources) {
140 println!(" zone: {}", resources.zone_name);
141 println!(
142 " buckets: {}, {}, {}",
143 resources.object_bucket, resources.asset_bucket, resources.page_bucket
144 );
145 println!(" assets: https://{}", resources.static_hostname);
146}
147
148async fn send_connect(
149 project_id: &str,
150 account_id: &str,
151 zone_id: &str,
152 provisioned: &ProvisionedResources,
153 credentials: &DataPlaneCredentials,
154) -> Result<()> {
155 let creds = crate::credentials::require()?;
156 let url = format!(
157 "{}/__forte_action/cloudflare_connect",
158 creds.control_url.trim_end_matches('/')
159 );
160 let response = reqwest::Client::new()
161 .post(&url)
162 .bearer_auth(&creds.token)
163 .json(&ConnectInput {
164 project_id,
165 account_id,
166 zone_id,
167 zone_name: &provisioned.zone_name,
168 static_hostname: &provisioned.static_hostname,
169 object_bucket: &provisioned.object_bucket,
170 asset_bucket: &provisioned.asset_bucket,
171 page_bucket: &provisioned.page_bucket,
172 dataplane_access_key_id: &credentials.dataplane_access_key_id,
173 dataplane_secret: &credentials.dataplane_secret,
174 purge_token: &credentials.purge_token,
175 })
176 .send()
177 .await?
178 .error_for_status()?;
179
180 match response.json::<Connect>().await? {
181 Connect::Ok => {
182 println!();
183 println!("connected. the token you used was not sent to fn0 and is no longer");
184 println!("needed — you can delete it in the Cloudflare dashboard.");
185 println!("workers pick this up within a second; no redeploy needed.");
186 Ok(())
187 }
188 Connect::CredentialRejected { reason } => {
189 Err(anyhow!("fn0 rejected the credentials: {reason}"))
190 }
191 Connect::AlreadyConnected {
192 account_id,
193 zone_name,
194 } => Err(anyhow!(
195 "project '{project_id}' is already connected to account {account_id} ({zone_name}). \
196 Reconnecting is not supported yet — it would have to decide whether to rotate \
197 credentials and whether to move objects already written to that account."
198 )),
199 Connect::NotLoggedIn => Err(anyhow!("control rejected token; sign in again.")),
200 Connect::NotFound => Err(anyhow!(
201 "project '{project_id}' not found or not owned by you."
202 )),
203 Connect::InternalError { reason } => Err(anyhow!("cloudflare_connect: {reason}")),
204 }
205}
206
207#[derive(Serialize)]
208struct StatusInput<'a> {
209 project_id: &'a str,
210}
211
212#[derive(Deserialize)]
213#[serde(tag = "t", rename_all_fields = "camelCase")]
214enum Status {
215 Platform,
216 Connected {
217 account_id: String,
218 zone_name: String,
219 static_hostname: String,
220 asset_bucket: String,
221 page_bucket: String,
222 healthy: bool,
223 problem: Option<String>,
224 },
225 NotLoggedIn,
226 NotFound,
227 InternalError {
228 reason: String,
229 },
230}
231
232pub async fn cloudflare_status(project_id: &str) -> Result<()> {
233 let creds = crate::credentials::require()?;
234 let url = format!(
235 "{}/__forte_action/cloudflare_status",
236 creds.control_url.trim_end_matches('/')
237 );
238 let response = reqwest::Client::new()
239 .post(&url)
240 .bearer_auth(&creds.token)
241 .json(&StatusInput { project_id })
242 .send()
243 .await?
244 .error_for_status()?;
245
246 match response.json::<Status>().await? {
247 Status::Platform => {
248 println!("project '{project_id}' runs on the fn0 platform Cloudflare account.");
249 println!("run `forte cloudflare connect` to move it onto your own.");
250 Ok(())
251 }
252 Status::Connected {
253 account_id,
254 zone_name,
255 static_hostname,
256 asset_bucket,
257 page_bucket,
258 healthy,
259 problem,
260 } => {
261 println!("account: {account_id}");
262 println!("zone: {zone_name}");
263 println!("assets: {asset_bucket} -> https://{static_hostname}");
264 println!("pages: {page_bucket}");
265 match (healthy, problem) {
266 (true, _) => println!("status: ok"),
267 (false, Some(problem)) => println!("status: degraded - {problem}"),
268 (false, None) => println!("status: degraded"),
269 }
270 Ok(())
271 }
272 Status::NotLoggedIn => Err(anyhow!("control rejected token; sign in again.")),
273 Status::NotFound => Err(anyhow!(
274 "project '{project_id}' not found or not owned by you."
275 )),
276 Status::InternalError { reason } => Err(anyhow!("cloudflare_status: {reason}")),
277 }
278}