1use serde_json::Value;
11
12use crate::cloudflare_manager::{
13 repo_url, CloudflareApiError, CloudflareEnvVar, CloudflareManager, PLAIN_TEXT,
14};
15use crate::providers::api_base::provider_api_host;
16use crate::providers::deploy::{
17 present, Deployment, DeploymentDetail, DeploymentMeta, DomainVerification, ProjectLink,
18 ProjectSetting, ProviderDomain, ProviderEnvVar, ProviderLogLine, ProviderProject,
19};
20use crate::providers::project_resolution::LinkFile;
21
22pub const CLOUDFLARE_PROVIDER_ID: &str = "cloudflare";
23
24pub const CLOUDFLARE_LINK_FILE: Option<&LinkFile> = None;
27
28const SETTINGS: [(&str, &str); 4] = [
35 ("buildCommand", "Build command"),
36 ("outputDirectory", "Output directory"),
37 ("rootDirectory", "Root directory"),
38 ("productionBranch", "Production branch"),
39];
40
41fn inspector_url(account: &str, project: &str, deployment: &str) -> String {
49 format!(
50 "https://dash.cloudflare.com/{account}/pages/view/{}/{}",
51 urlencoding::encode(project),
52 urlencoding::encode(deployment)
53 )
54}
55
56fn state_of(status: Option<&str>) -> &'static str {
65 match status {
66 Some("success") => "ready",
67 Some("failure") => "error",
68 Some("canceled") => "canceled",
69 Some(_) => "building",
70 None => "queued",
71 }
72}
73
74pub fn project_from_raw(raw: &Value) -> ProviderProject {
77 let build = raw.get("build_config");
78 let field = |owner: Option<&Value>, key: &str| -> Value {
79 owner
80 .and_then(|value| value.get(key))
81 .cloned()
82 .unwrap_or(Value::Null)
83 };
84 let values = [
85 field(build, "build_command"),
86 field(build, "destination_dir"),
87 field(build, "root_dir"),
88 field(Some(raw), "production_branch"),
89 ];
90
91 ProviderProject {
92 id: raw.get("name").cloned(),
93 name: raw.get("name").cloned(),
94 framework: Value::Null,
97 updated_at: raw
98 .get("created_on")
99 .and_then(Value::as_str)
100 .and_then(epoch_ms)
101 .map(Value::from),
102 link: link_from_raw(raw.get("source")),
103 settings: SETTINGS
104 .iter()
105 .zip(values)
106 .map(|((key, label), value)| ProjectSetting { key, label, value })
107 .collect(),
108 }
109}
110
111fn link_from_raw(raw: Option<&Value>) -> Option<ProjectLink> {
112 let source = raw?;
113 let kind = source
114 .get("type")
115 .and_then(Value::as_str)
116 .filter(|kind| !kind.is_empty())?;
117 let config = source.get("config");
118 Some(ProjectLink {
119 kind: kind.to_string(),
120 org: config.and_then(|config| config.get("owner")).cloned(),
121 repo: config.and_then(|config| config.get("repo_name")).cloned(),
122 production_branch: config
123 .and_then(|config| config.get("production_branch"))
124 .cloned(),
125 })
126}
127
128fn project_repo_url(raw: &Value) -> Option<String> {
130 let config = raw.get("source")?.get("config")?;
131 let owner = config.get("owner")?.as_str()?;
132 let repo = config.get("repo_name")?.as_str()?;
133 Some(format!("{owner}/{repo}").to_lowercase())
134}
135
136fn states_of(raw: &Value) -> (&'static str, String) {
139 if raw.get("is_skipped").and_then(Value::as_bool) == Some(true) {
142 return ("canceled", "skipped".to_string());
143 }
144 let stage = raw.get("latest_stage");
145 let stage_name = stage
146 .and_then(|stage| stage.get("name"))
147 .and_then(Value::as_str)
148 .unwrap_or("queued");
149 let status = stage
150 .and_then(|stage| stage.get("status"))
151 .and_then(Value::as_str);
152 (
153 state_of(status),
154 format!("{stage_name}:{}", status.unwrap_or("idle")),
155 )
156}
157
158pub fn deployment_from_raw(
163 raw: &Value,
164 account: &str,
165 project: &str,
166 canonical: Option<&str>,
167) -> Deployment {
168 let (state, raw_state) = states_of(raw);
169
170 let id = raw
171 .get("id")
172 .and_then(Value::as_str)
173 .unwrap_or_default()
174 .to_string();
175
176 Deployment {
177 is_current_production: canonical.is_some_and(|canonical| canonical == id),
182 name: Some(Value::from(
186 raw.get("project_name")
187 .and_then(Value::as_str)
188 .unwrap_or_default(),
189 )),
190 url: Some(
193 raw.get("url")
194 .and_then(Value::as_str)
195 .map(hostname)
196 .map_or(Value::Null, Value::from),
197 ),
198 state: state.to_string(),
199 raw_state,
200 target: Value::from(
204 raw.get("environment")
205 .and_then(Value::as_str)
206 .unwrap_or("preview"),
207 ),
208 created_at: raw
209 .get("created_on")
210 .and_then(Value::as_str)
211 .and_then(epoch_ms)
212 .map(Value::from),
213 ready_at: (state == "ready")
216 .then(|| {
217 raw.get("modified_on")
218 .and_then(Value::as_str)
219 .and_then(epoch_ms)
220 })
221 .flatten()
222 .map(Value::from),
223 creator: None,
224 meta: meta_from_raw(raw.get("deployment_trigger")),
225 inspector_url: Some(Value::from(inspector_url(account, project, &id))),
226 id: Value::from(id),
227 }
228}
229
230fn meta_from_raw(raw: Option<&Value>) -> DeploymentMeta {
233 let metadata = raw.and_then(|trigger| trigger.get("metadata"));
234 let pick = |key: &str| metadata.and_then(|metadata| metadata.get(key)).cloned();
235 DeploymentMeta {
236 branch: pick("branch"),
237 sha: pick("commit_hash"),
238 commit_message: pick("commit_message"),
239 commit_author: None,
240 }
241}
242
243pub fn detail_from_raw(
244 raw: &Value,
245 account: &str,
246 project: &str,
247 canonical: Option<&str>,
248) -> DeploymentDetail {
249 let deployment = deployment_from_raw(raw, account, project, canonical);
250 let error_message = (deployment.state == "error").then(|| {
255 let stage = raw
256 .get("latest_stage")
257 .and_then(|stage| stage.get("name"))
258 .and_then(Value::as_str)
259 .unwrap_or("build");
260 Value::from(format!("The {stage} stage failed."))
261 });
262 DeploymentDetail {
263 deployment,
264 aliases: Value::Array(
265 raw.get("aliases")
266 .and_then(Value::as_array)
267 .map(|aliases| {
268 aliases
269 .iter()
270 .filter_map(Value::as_str)
271 .map(|alias| Value::from(hostname(alias)))
272 .collect()
273 })
274 .unwrap_or_default(),
275 ),
276 building_at: None,
279 error_message,
280 }
281}
282
283fn hostname(url: &str) -> String {
286 url.trim()
287 .trim_start_matches("https://")
288 .trim_start_matches("http://")
289 .trim_end_matches('/')
290 .to_string()
291}
292
293fn epoch_ms(value: &str) -> Option<i64> {
296 chrono::DateTime::parse_from_rfc3339(value)
297 .ok()
298 .map(|parsed| parsed.timestamp_millis())
299}
300
301pub fn env_from_merged(variable: &CloudflareEnvVar) -> ProviderEnvVar {
306 ProviderEnvVar {
307 id: Some(Value::String(variable.key.clone())),
308 key: Some(Value::String(variable.key.clone())),
309 environments: variable
310 .environments
311 .iter()
312 .map(|environment| Value::String(environment.clone()))
313 .collect(),
314 kind: Value::String(
315 if variable.kind == PLAIN_TEXT {
316 "plain"
317 } else {
318 "encrypted"
319 }
320 .into(),
321 ),
322 git_branch: None,
325 comment: None,
326 created_at: None,
327 updated_at: None,
328 }
329}
330
331fn domain_from_raw(raw: &Value) -> ProviderDomain {
338 let status = raw
339 .get("status")
340 .and_then(Value::as_str)
341 .unwrap_or("pending");
342 let validation = raw.get("validation_data").filter(|value| !value.is_null());
343 let txt_name = validation
344 .and_then(|data| data.get("txt_name"))
345 .and_then(Value::as_str)
346 .filter(|name| !name.is_empty());
347
348 let verification = match txt_name.filter(|_| status != "active") {
349 Some(txt_name) => vec![DomainVerification {
350 kind: Value::String("TXT".into()),
351 domain: Value::String(txt_name.to_string()),
352 value: Value::String(
353 validation
354 .and_then(|data| data.get("txt_value"))
355 .and_then(Value::as_str)
356 .unwrap_or_default()
357 .to_string(),
358 ),
359 reason: Some(
362 validation
363 .and_then(|data| present(data.get("error_message")))
364 .or_else(|| {
365 present(
366 raw.get("verification_data")
367 .and_then(|data| data.get("error_message")),
368 )
369 })
370 .unwrap_or_else(|| Value::String(status.to_string())),
371 ),
372 }],
373 None => Vec::new(),
374 };
375
376 ProviderDomain {
377 name: raw.get("name").cloned(),
378 apex_name: None,
381 verified: status == "active",
382 redirect: None,
383 git_branch: None,
384 created_at: raw
385 .get("created_on")
386 .and_then(Value::as_str)
387 .and_then(epoch_ms)
388 .map(Value::from),
389 updated_at: None,
390 verification,
391 }
392}
393
394pub struct CloudflareDeployProvider {
396 manager: CloudflareManager,
397}
398
399impl CloudflareDeployProvider {
400 pub fn new(manager: CloudflareManager) -> Self {
401 Self { manager }
402 }
403
404 fn account(&self) -> String {
405 self.manager.account_id().unwrap_or_default().to_string()
406 }
407
408 pub async fn viewer(&self) -> Result<Value, CloudflareApiError> {
411 self.manager.viewer().await
412 }
413
414 pub async fn list_scopes(&self) -> Result<Vec<Value>, CloudflareApiError> {
420 Ok(self
421 .manager
422 .list_accounts()
423 .await?
424 .iter()
425 .map(|raw| {
426 let id = raw.get("id").filter(|value| !value.is_null()).cloned();
427 let name = raw
428 .get("name")
429 .filter(|value| !value.is_null())
430 .cloned()
431 .or_else(|| id.clone());
432 let mut scope = serde_json::Map::new();
433 for (key, value) in [("id", id.clone()), ("slug", id), ("name", name)] {
434 if let Some(value) = value {
435 scope.insert(key.into(), value);
436 }
437 }
438 Value::Object(scope)
439 })
440 .collect())
441 }
442
443 pub async fn list_env(&self, project: &str) -> Result<Vec<ProviderEnvVar>, CloudflareApiError> {
444 Ok(self
445 .manager
446 .list_env(project)
447 .await?
448 .iter()
449 .map(env_from_merged)
450 .collect())
451 }
452
453 pub async fn get_env_value(
454 &self,
455 project: &str,
456 key: &str,
457 ) -> Result<String, CloudflareApiError> {
458 self.manager.env_value(project, key).await
459 }
460
461 pub async fn list_domains(
474 &self,
475 project: &str,
476 ) -> Result<Vec<ProviderDomain>, CloudflareApiError> {
477 let (custom, project_raw) = tokio::join!(
478 self.manager.list_domains_raw(project),
479 self.manager.get_project_raw(project)
480 );
481 let mut domains: Vec<ProviderDomain> = custom?.iter().map(domain_from_raw).collect();
482 let Ok(project_raw) = project_raw else {
483 return Ok(domains);
484 };
485 let Some(subdomain) = project_raw
486 .get("subdomain")
487 .and_then(Value::as_str)
488 .filter(|subdomain| !subdomain.is_empty())
489 else {
490 return Ok(domains);
491 };
492 if domains
493 .iter()
494 .any(|domain| domain.name.as_ref().and_then(Value::as_str) == Some(subdomain))
495 {
496 return Ok(domains);
497 }
498 domains.push(ProviderDomain {
499 name: Some(Value::String(subdomain.to_string())),
500 apex_name: None,
501 verified: true,
503 redirect: None,
504 git_branch: None,
505 created_at: project_raw
506 .get("created_on")
507 .and_then(Value::as_str)
508 .and_then(epoch_ms)
509 .map(Value::from),
510 updated_at: None,
511 verification: Vec::new(),
512 });
513 Ok(domains)
514 }
515
516 pub async fn list_projects(
519 &self,
520 search: Option<&str>,
521 ) -> Result<Vec<ProviderProject>, CloudflareApiError> {
522 let projects = self.manager.list_projects_raw().await?;
523 let needle = search.map(str::to_lowercase);
524 Ok(projects
525 .iter()
526 .filter(|raw| match &needle {
527 None => true,
528 Some(needle) => raw
529 .get("name")
530 .and_then(Value::as_str)
531 .is_some_and(|name| name.to_lowercase().contains(needle)),
532 })
533 .map(project_from_raw)
534 .collect())
535 }
536
537 pub async fn get_project(&self, name: &str) -> Result<ProviderProject, CloudflareApiError> {
538 Ok(project_from_raw(&self.manager.get_project_raw(name).await?))
539 }
540
541 pub async fn find_by_repo_url(
544 &self,
545 repo: &str,
546 ) -> Result<Option<ProviderProject>, CloudflareApiError> {
547 Ok(self
548 .manager
549 .list_projects_raw()
550 .await?
551 .iter()
552 .find(|raw| project_repo_url(raw).as_deref() == Some(repo))
553 .map(project_from_raw))
554 }
555
556 pub async fn list_deployments(
557 &self,
558 project: &str,
559 target: Option<&str>,
560 limit: u32,
561 ) -> Result<Vec<Deployment>, CloudflareApiError> {
562 let (deployments, canonical) = tokio::join!(
566 self.manager
567 .list_deployments_raw(project, target, limit as usize),
568 self.canonical_deployment(project)
569 );
570 let deployments = deployments?;
571 let account = self.account();
572 Ok(deployments
573 .iter()
574 .map(|raw| deployment_from_raw(raw, &account, project, canonical.as_deref()))
575 .collect())
576 }
577
578 pub async fn get_deployment(
579 &self,
580 project: &str,
581 deployment: &str,
582 ) -> Result<DeploymentDetail, CloudflareApiError> {
583 let (raw, canonical) = tokio::join!(
584 self.manager.get_deployment_raw(project, deployment),
585 self.canonical_deployment(project)
586 );
587 Ok(detail_from_raw(
588 &raw?,
589 &self.account(),
590 project,
591 canonical.as_deref(),
592 ))
593 }
594
595 pub async fn build_logs(
596 &self,
597 project: &str,
598 deployment: &str,
599 ) -> Result<Vec<ProviderLogLine>, CloudflareApiError> {
600 Ok(self
601 .manager
602 .build_logs_raw(project, deployment)
603 .await?
604 .iter()
605 .enumerate()
609 .filter_map(|(index, entry)| {
610 let text = entry.get("line").and_then(Value::as_str)?.trim_end();
615 if text.is_empty() {
616 return None;
617 }
618 let stamp = entry.get("ts").and_then(Value::as_str);
619 Some(ProviderLogLine::build(
620 match stamp {
623 Some(ts) => format!("{ts}-{index}"),
624 None => format!("{index}-{index}"),
625 },
626 stamp.and_then(epoch_ms).unwrap_or(0),
627 "stdout".to_string(),
630 text.to_string(),
631 ))
632 })
633 .collect())
634 }
635
636 async fn canonical_deployment(&self, project: &str) -> Option<String> {
639 self.manager
640 .get_project_raw(project)
641 .await
642 .ok()?
643 .get("canonical_deployment")?
644 .get("id")?
645 .as_str()
646 .map(str::to_string)
647 }
648}
649
650pub fn cloudflare_repo_url(remote: &str) -> Option<String> {
652 repo_url(remote)
653}
654
655pub fn manifest() -> Value {
661 serde_json::json!({
662 "id": "cloudflare",
663 "name": "Cloudflare",
664 "kind": "deploy",
665 "strings": {
666 "en": {
667 "scope.label": "Cloudflare account",
668 "action.redeploy": "Retry build",
669 "action.redeploy.done": "Build retried.",
670 "action.rollback": "Roll back",
671 "action.rollback.done": "Rolled back production.",
672 "action.rollback.confirmTitle": "Roll production back?",
673 "action.rollback.confirm": "Production traffic switches back to this older deployment immediately."
674 },
675 "zh": {
676 "scope.label": "Cloudflare 账户",
677 "action.redeploy": "重试构建",
678 "action.redeploy.done": "已重试构建。",
679 "action.rollback": "回滚",
680 "action.rollback.done": "已回滚生产环境。",
681 "action.rollback.confirmTitle": "回滚生产环境?",
682 "action.rollback.confirm": "生产流量将立即切回这个较旧的部署。"
683 }
684 },
685 "authSources": [
686 "cli",
687 "stored"
688 ],
689 "capabilities": [
690 "projects",
691 "deployments",
692 "buildLogs",
693 "env",
694 "domains"
695 ],
696 "requiresScope": true,
697 "actions": [
698 "redeploy",
699 "rollback"
700 ],
701 "productionAffecting": [
702 "rollback"
703 ],
704 "api": {
713 "hosts": [
714 provider_api_host(&crate::cloudflare_manager::api_base())
715 ]
716 }
717 })
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use serde_json::json;
724
725 #[test]
726 fn a_projects_id_is_its_name() {
727 let project = project_from_raw(&json!({"id": "prj_opaque", "name": "app"}));
728 assert_eq!(project.id, Some(json!("app")));
729 assert_eq!(project.name, Some(json!("app")));
730 }
731
732 #[test]
735 fn every_setting_is_reported_even_when_the_project_has_none() {
736 let project = project_from_raw(&json!({"name": "app"}));
737 assert_eq!(project.settings.len(), 4);
738 assert!(project
739 .settings
740 .iter()
741 .all(|setting| setting.value.is_null()));
742 }
743
744 #[test]
745 fn a_skipped_deployment_is_canceled_and_says_so_plainly() {
746 let deployment = deployment_from_raw(
747 &json!({"id": "d", "is_skipped": true, "latest_stage": {"name": "deploy", "status": "success"}}),
748 "acc",
749 "app",
750 None,
751 );
752 assert_eq!(deployment.state, "canceled");
753 assert_eq!(deployment.raw_state, "skipped");
754 }
755
756 #[test]
758 fn a_missing_stage_reads_as_queued_and_idle() {
759 let deployment = deployment_from_raw(&json!({"id": "d"}), "acc", "app", None);
760 assert_eq!(deployment.state, "queued");
761 assert_eq!(deployment.raw_state, "queued:idle");
762 }
763
764 #[test]
767 fn current_production_is_the_canonical_deployment() {
768 let raw = json!({"id": "d1", "environment": "preview"});
769 assert!(deployment_from_raw(&raw, "acc", "app", Some("d1")).is_current_production);
770 assert!(!deployment_from_raw(&raw, "acc", "app", Some("d2")).is_current_production);
771 assert!(!deployment_from_raw(&raw, "acc", "app", None).is_current_production);
772 }
773
774 #[test]
775 fn only_a_finished_build_has_a_ready_moment() {
776 let ready = json!({
777 "id": "d", "latest_stage": {"name": "deploy", "status": "success"},
778 "modified_on": "2026-02-01T10:05:00Z"
779 });
780 assert!(deployment_from_raw(&ready, "acc", "app", None)
781 .ready_at
782 .is_some());
783 let failed = json!({
784 "id": "d", "latest_stage": {"name": "deploy", "status": "failure"},
785 "modified_on": "2026-02-01T10:05:00Z"
786 });
787 assert!(deployment_from_raw(&failed, "acc", "app", None)
788 .ready_at
789 .is_none());
790 }
791
792 #[test]
793 fn urls_and_aliases_are_reported_as_bare_hostnames() {
794 let detail = detail_from_raw(
795 &json!({"id": "d", "url": "https://d.pages.dev", "aliases": ["https://a.pages.dev", "b.pages.dev"]}),
796 "acc",
797 "app",
798 None,
799 );
800 assert_eq!(detail.deployment.url, Some(json!("d.pages.dev")));
801 assert_eq!(detail.aliases, json!(["a.pages.dev", "b.pages.dev"]));
802 }
803}