use crate::cli::commands::{
CloudflareCmd, CloudflareContainersBuildCmd, CloudflareContainersCmd,
CloudflareContainersSubCommand, CloudflareDoctorCmd, CloudflareEnvCmd, CloudflareEnvSubCommand,
CloudflareInitCmd, CloudflareJobWorkflow, CloudflareJobsCmd, CloudflareJobsSubCommand,
CloudflareRollout, CloudflareSubCommand, CloudflareTunnelCmd, CloudflareTunnelCreateCmd,
CloudflareTunnelDeleteCmd, CloudflareTunnelInfoCmd, CloudflareTunnelQuickStartCmd,
CloudflareTunnelRunCmd, CloudflareTunnelSubCommand, CloudflareWorkflowsCmd,
CloudflareWorkflowsSubCommand,
};
use crate::commands::version::check_domain_for_cloudflare_release;
use crate::commands::workers::project::{
ensure_selected_workers_configured, parse_multiline_env_file, resolve_worker_targets,
write_worker_configs_to_project,
};
use crate::commands::workers::wrangler::{self, WranglerOutput};
use crate::strategies::{WorkerConfig, WorkerContainerConfig, XbpConfig};
use crate::utils::{collapse_project_path, find_xbp_config_upwards, parse_config_with_auto_heal};
use chrono::{DateTime, Utc};
use colored::Colorize;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use uuid::Uuid;
const CLOUDFLARE_JOB_DIR: &str = ".xbp/jobs/cloudflare";
#[derive(Debug, Clone)]
struct ResolvedCloudflareApp {
project_root: PathBuf,
config_path: PathBuf,
worker_root: PathBuf,
app_name: String,
worker: WorkerConfig,
container: Option<WorkerContainerConfig>,
wrangler_config: PathBuf,
}
impl ResolvedCloudflareApp {
fn require_container(&self) -> Result<&WorkerContainerConfig, String> {
self.container.as_ref().ok_or_else(|| {
format!(
"Worker app `{}` does not define `workers[].container` in {}. Run `xbp cloudflare init --app {} --worker-root {} --container-port <port> --write` first (only required for Container-backed Workers).",
self.app_name,
self.config_path.display(),
self.app_name,
self.worker_root.display()
)
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CloudflareWorkflowPayload {
app: Option<String>,
token: Option<String>,
account_id: Option<String>,
workflow: CloudflareJobWorkflow,
rollout: CloudflareRollout,
version: Option<String>,
domain: Option<String>,
dry_run: bool,
skip_deploy: bool,
allow_unchanged_container_image: bool,
prune_old_images: bool,
keep_image_tag_count: Option<usize>,
#[serde(default)]
local_build: bool,
#[serde(default)]
local_build_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
enum CloudflareJobStatus {
Queued,
Running,
Succeeded,
Failed,
Cancelled,
}
impl CloudflareJobStatus {
fn as_str(&self) -> &'static str {
match self {
Self::Queued => "queued",
Self::Running => "running",
Self::Succeeded => "succeeded",
Self::Failed => "failed",
Self::Cancelled => "cancelled",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CloudflareJobRecord {
id: String,
status: CloudflareJobStatus,
payload: CloudflareWorkflowPayload,
created_at: DateTime<Utc>,
updated_at: DateTime<Utc>,
started_at: Option<DateTime<Utc>>,
finished_at: Option<DateTime<Utc>>,
error: Option<String>,
logs: Vec<String>,
}
pub async fn run_cloudflare(cmd: CloudflareCmd, _debug: bool) -> Result<(), String> {
match cmd.command {
CloudflareSubCommand::Doctor(doctor_cmd) => {
let app = resolve_cloudflare_app(cmd.root.as_deref(), cmd.app.as_deref())?;
run_doctor(&app, &doctor_cmd).await
}
CloudflareSubCommand::Builds(builds_cmd) => {
run_builds_command(cmd.root.as_deref(), cmd.app.as_deref(), builds_cmd).await
}
CloudflareSubCommand::Init(init_cmd) => {
run_init_contract(cmd.root.as_deref(), cmd.app.as_deref(), init_cmd)
}
CloudflareSubCommand::Deploy(deploy_cmd) => {
let app = resolve_cloudflare_app(cmd.root.as_deref(), cmd.app.as_deref())?;
let payload = CloudflareWorkflowPayload {
app: Some(app.app_name.clone()),
token: cmd.token,
account_id: cmd.account_id,
workflow: CloudflareJobWorkflow::Deploy,
rollout: deploy_cmd.rollout,
version: None,
domain: None,
dry_run: deploy_cmd.dry_run,
skip_deploy: deploy_cmd.skip_deploy || deploy_cmd.local_build_only,
allow_unchanged_container_image: deploy_cmd.allow_unchanged_container_image
|| !deploy_cmd.require_new_container_image,
prune_old_images: deploy_cmd.prune_old_images,
keep_image_tag_count: deploy_cmd.keep_image_tag_count,
local_build: deploy_cmd.local_build || deploy_cmd.local_build_only,
local_build_only: deploy_cmd.local_build_only,
};
let result = run_deploy_workflow(&app, &payload).await;
notify_cloudflare_deploy_discord(&app, "deploy", payload.dry_run, &result).await;
if !payload.dry_run {
sync_cloudflare_deploy_activity(&app, "cloudflare-deploy", &payload, &result).await;
}
result
}
CloudflareSubCommand::Release(release_cmd) => {
let mut app = resolve_cloudflare_app(cmd.root.as_deref(), cmd.app.as_deref())?;
let payload = CloudflareWorkflowPayload {
app: Some(app.app_name.clone()),
token: cmd.token,
account_id: cmd.account_id,
workflow: CloudflareJobWorkflow::Release,
rollout: release_cmd.rollout,
version: Some(release_cmd.version),
domain: release_cmd.domain,
dry_run: false,
skip_deploy: release_cmd.skip_deploy || release_cmd.local_build_only,
allow_unchanged_container_image: release_cmd.allow_unchanged_container_image
|| !release_cmd.require_new_container_image,
prune_old_images: release_cmd.prune_old_images,
keep_image_tag_count: release_cmd.keep_image_tag_count,
local_build: release_cmd.local_build || release_cmd.local_build_only,
local_build_only: release_cmd.local_build_only,
};
let version = payload.version.clone().unwrap_or_default();
let result = run_release_workflow(&mut app, &payload).await;
notify_cloudflare_deploy_discord(
&app,
&format!("release {version}"),
false,
&result,
)
.await;
sync_cloudflare_deploy_activity(&app, "cloudflare-release", &payload, &result).await;
result
}
CloudflareSubCommand::Containers(containers_cmd) => {
let app = resolve_cloudflare_app(cmd.root.as_deref(), cmd.app.as_deref())?;
let action = cloudflare_containers_action_label(&containers_cmd);
let mutates = cloudflare_containers_mutates(&containers_cmd);
let result = run_containers(&app, containers_cmd);
if mutates {
notify_cloudflare_containers_discord(&app, &action, &result).await;
sync_cloudflare_containers_activity(&app, &action, &result).await;
}
result
}
CloudflareSubCommand::Tunnel(tunnel_cmd) => {
let project_root = resolve_project_root(cmd.root.as_deref())?;
run_tunnel(&project_root, tunnel_cmd)
}
CloudflareSubCommand::Workflows(workflows_cmd) => {
let project_root = resolve_project_root(cmd.root.as_deref())?;
run_workflows(&project_root, workflows_cmd)
}
CloudflareSubCommand::Env(env_cmd) => {
let worker_root = resolve_worker_root(cmd.root.as_deref(), cmd.app.as_deref())?;
run_cloudflare_env(&worker_root, env_cmd)
}
CloudflareSubCommand::Jobs(jobs_cmd) => {
run_jobs(cmd.root.as_deref(), cmd.app, jobs_cmd).await
}
}
}
fn run_tunnel(project_root: &Path, cmd: CloudflareTunnelCmd) -> Result<(), String> {
let args = match cmd.command {
CloudflareTunnelSubCommand::Create(CloudflareTunnelCreateCmd { name }) => {
vec!["tunnel".to_string(), "create".to_string(), name]
}
CloudflareTunnelSubCommand::Delete(CloudflareTunnelDeleteCmd { tunnel, force }) => {
let mut args = vec!["tunnel".to_string(), "delete".to_string(), tunnel];
if force {
args.push("--force".to_string());
}
args
}
CloudflareTunnelSubCommand::Info(CloudflareTunnelInfoCmd { tunnel }) => {
vec!["tunnel".to_string(), "info".to_string(), tunnel]
}
CloudflareTunnelSubCommand::List(_) => {
vec!["tunnel".to_string(), "list".to_string()]
}
CloudflareTunnelSubCommand::Run(CloudflareTunnelRunCmd {
tunnel,
token,
log_level,
}) => {
let mut args = vec!["tunnel".to_string(), "run".to_string()];
if let Some(tunnel) = tunnel {
args.push(tunnel);
}
if let Some(token) = token {
args.push("--token".to_string());
args.push(token);
}
if let Some(log_level) = log_level {
args.push("--log-level".to_string());
args.push(log_level);
}
args
}
CloudflareTunnelSubCommand::QuickStart(CloudflareTunnelQuickStartCmd { url }) => {
vec!["tunnel".to_string(), "quick-start".to_string(), url]
}
};
wrangler::run_wrangler(project_root, &args)
}
fn run_workflows(project_root: &Path, cmd: CloudflareWorkflowsCmd) -> Result<(), String> {
let CloudflareWorkflowsSubCommand::External(mut workflow_args) = cmd.command;
workflow_args.insert(0, "workflows".to_string());
wrangler::run_wrangler(project_root, &workflow_args)
}
fn run_cloudflare_env(project_root: &Path, cmd: CloudflareEnvCmd) -> Result<(), String> {
let args = match cmd.command {
CloudflareEnvSubCommand::External(args) => args,
};
wrangler::run_wrangler(project_root, &args)
}
async fn run_doctor(app: &ResolvedCloudflareApp, cmd: &CloudflareDoctorCmd) -> Result<(), String> {
doctor_header("Cloudflare doctor");
doctor_section("App");
doctor_kv("app", &app.app_name);
doctor_kv("project root", ".");
doctor_kv(
"xbp config",
&doctor_rel_path(&app.project_root, &app.config_path),
);
doctor_kv(
"worker root",
&doctor_rel_path(&app.project_root, &app.worker_root),
);
doctor_kv(
"wrangler config",
&doctor_rel_path(&app.project_root, &app.wrangler_config),
);
if let Some(script) = app.worker.script_name.as_deref() {
doctor_kv("script name", script);
} else {
doctor_kv("script name", "—");
}
match app.container.as_ref() {
Some(_) => doctor_kv("container contract", "configured"),
None => doctor_kv("container contract", "none (plain Worker)"),
}
doctor_section("Local contract");
let diagnostics = collect_local_diagnostics(app)?;
if !diagnostics.warnings.is_empty() {
for warning in &diagnostics.warnings {
doctor_warn(warning);
}
}
if !diagnostics.missing_required.is_empty() {
for item in &diagnostics.missing_required {
doctor_err(item);
}
return Err(format!(
"Cloudflare worker contract is incomplete ({} issue{}).",
diagnostics.missing_required.len(),
if diagnostics.missing_required.len() == 1 {
""
} else {
"s"
}
));
}
if app.container.is_some() {
doctor_ok("local container contract");
} else {
doctor_ok("local worker contract");
}
if cmd.offline {
doctor_section("Wrangler");
doctor_skip("wrangler checks skipped (--offline)");
doctor_footer_ok();
return Ok(());
}
doctor_section("Auth");
let auth = wrangler::wrangler_cloudflare_auth_summary(&app.worker_root);
if auth.token_present {
doctor_ok(&format!("token {}", auth.token_preview));
} else {
doctor_err(&format!("token {}", auth.token_preview));
}
doctor_kv("token source", &auth.token_source);
if let Ok(cfg) = crate::config::SshConfig::load() {
if let Some(t) = cfg.cloudflare_user_api_token() {
doctor_kv(
"user slot",
&crate::config::format_cloudflare_token_preview(t),
);
} else {
doctor_kv("user slot", "unset");
}
if let Some(t) = cfg.cloudflare_account_api_token() {
doctor_kv(
"account slot",
&crate::config::format_cloudflare_token_preview(t),
);
} else {
doctor_kv("account slot", "unset");
}
if let Some(t) = cfg.cloudflare_primary_api_token() {
doctor_kv(
"primary slot",
&crate::config::format_cloudflare_token_preview(t),
);
}
}
let has_user_slot = crate::config::SshConfig::load()
.ok()
.and_then(|c| c.cloudflare_user_api_token().map(str::to_string))
.is_some();
if auth.token_present && auth.token_preview.contains("account-owned") && !has_user_slot {
doctor_warn(
"resolved credential is **account-owned** (`cfat_`) and no user slot is set. \
Wrangler deploy is OK, but Workers Builds only supports *user* tokens (`cfut_`). \
Store both: `xbp config cloudflare set-key --kind account` and \
`xbp config cloudflare set-key --kind user` (My Profile → API Tokens).",
);
} else if auth.token_present && auth.token_preview.contains("account-owned") && has_user_slot {
doctor_ok(
"user slot is set — Builds will prefer `cfut_` even if primary/env is account-owned",
);
}
if crate::config::cloudflare_env_token_shadows_config() {
doctor_warn(
"process env CLOUDFLARE_API_TOKEN differs from global config — env wins for API calls; \
Builds 12006 is often a stale env token or an account-owned (`cfat_`) secret. Unset it \
or update env to a *user* token (`cfut_`) matching `xbp config cloudflare set-key`.",
);
doctor_kv(
"hint",
"PowerShell: Remove-Item Env:CLOUDFLARE_API_TOKEN; then re-run doctor",
);
}
if auth.account_set {
doctor_ok("account id set");
} else {
doctor_err("account id missing");
}
doctor_kv("account source", &auth.account_source);
doctor_section("Workers Builds build command");
match sync_workers_builds_for_app(app, false).await {
Ok(()) => {}
Err(e) => {
use crate::commands::workers::builds_config::{
explain_builds_api_error, is_builds_token_scope_error,
};
let explained = explain_builds_api_error(&e);
if is_builds_token_scope_error(&explained) || is_builds_token_scope_error(&e) {
doctor_warn(&explained);
doctor_kv(
"deploy impact",
"none for wrangler deploy — only remote Builds trigger sync is skipped",
);
} else {
doctor_warn(&format!("Builds sync: {explained}"));
}
}
}
doctor_section("Workers Builds auto-fix");
match auto_fix_workers_builds_if_needed(app).await {
Ok(Some(msg)) => doctor_ok(&msg),
Ok(None) => doctor_ok("no known Builds failure to remediate (local workspace healed if needed)"),
Err(e) => {
use crate::commands::workers::builds_config::{
explain_builds_api_error, is_builds_token_scope_error,
};
let explained = explain_builds_api_error(&e);
if is_builds_token_scope_error(&explained) || is_builds_token_scope_error(&e) {
doctor_warn(
"auto-fix skipped remote Builds (account-owned `cfat_` token and/or missing \
Workers Builds Configuration — need a user `cfut_` token); local heals still applied when possible",
);
} else {
doctor_warn(&format!("Builds fix: {explained}"));
}
}
}
doctor_section("Wrangler checks");
run_wrangler_checked(app, vec!["--version".to_string()], "wrangler --version")?;
run_wrangler_checked(app, vec!["whoami".to_string()], "wrangler whoami")?;
run_wrangler_checked(app, wrangler_check_args(app), "wrangler check")?;
run_doctor_deploy_dry_run(app)?;
doctor_footer_ok();
Ok(())
}
async fn sync_workers_builds_for_app(
app: &ResolvedCloudflareApp,
dry_run: bool,
) -> Result<(), String> {
use crate::commands::workers::builds_config::{
print_build_command_sync_report, sync_local_wrangler_build_command,
sync_workers_builds_build_command_trying_tokens,
};
if let Ok(Some(msg)) = sync_local_wrangler_build_command(&app.worker_root) {
doctor_ok(&msg);
}
let script_name = resolve_builds_script_name(app)?;
let root_directory = monorepo_relative_root(&app.project_root, &app.worker_root);
let report = sync_workers_builds_build_command_trying_tokens(
&script_name,
&app.worker_root,
root_directory.as_deref(),
dry_run,
)
.await?;
print_build_command_sync_report(&report);
for u in &report.updated_triggers {
doctor_ok(u);
}
Ok(())
}
fn resolve_builds_script_name(app: &ResolvedCloudflareApp) -> Result<String, String> {
app.worker
.script_name
.clone()
.filter(|s| !s.trim().is_empty())
.or_else(|| Some(app.app_name.clone()))
.or_else(|| {
app.worker_root
.file_name()
.and_then(|s| s.to_str())
.map(str::to_string)
})
.ok_or_else(|| "cannot resolve worker script name for Builds sync".to_string())
}
fn monorepo_relative_root(project_root: &Path, worker_root: &Path) -> Option<String> {
let rel = worker_root.strip_prefix(project_root).ok()?;
let s = rel.to_string_lossy().replace('\\', "/");
if s.is_empty() || s == "." {
Some("/".into())
} else {
Some(s)
}
}
async fn run_builds_command(
root: Option<&Path>,
app_flag: Option<&str>,
cmd: crate::cli::commands::CloudflareBuildsCmd,
) -> Result<(), String> {
use crate::cli::commands::CloudflareBuildsSubCommand;
use crate::commands::workers::builds_config::{
print_build_command_sync_report, sync_local_wrangler_build_command,
sync_workers_builds_build_command_trying_tokens,
};
match cmd.command {
CloudflareBuildsSubCommand::Sync {
app,
root: root_override,
dry_run,
} => {
let app_name = app.or_else(|| app_flag.map(str::to_string));
let project_root = root_override.as_deref().or(root);
let resolved = resolve_cloudflare_app(project_root, app_name.as_deref())?;
let _ = crate::utils::ensure_pnpm_workspace_packages(&resolved.worker_root);
if let Ok(Some(msg)) = sync_local_wrangler_build_command(&resolved.worker_root) {
println!("{} {msg}", "local".green());
}
let script_name = resolve_builds_script_name(&resolved)?;
let root_directory =
monorepo_relative_root(&resolved.project_root, &resolved.worker_root);
let report = sync_workers_builds_build_command_trying_tokens(
&script_name,
&resolved.worker_root,
root_directory.as_deref(),
dry_run,
)
.await?;
print_build_command_sync_report(&report);
if report.skipped.is_some()
&& report.updated_triggers.is_empty()
&& report.notes.iter().any(|n| {
n.contains("failed") || crate::commands::workers::builds_config::is_builds_token_scope_error(n)
})
{
return Err("Workers Builds sync incomplete — see notes above".into());
}
Ok(())
}
CloudflareBuildsSubCommand::Fix {
app,
root: root_override,
dry_run,
log,
no_remote,
commit,
} => {
use crate::commands::workers::builds_fix::{
fix_workers_builds_failure, print_builds_fix_report, BuildsFixOptions,
};
let app_name = app.or_else(|| app_flag.map(str::to_string));
let project_root = root_override.as_deref().or(root);
let resolved = resolve_cloudflare_app(project_root, app_name.as_deref())?;
let script_name = resolve_builds_script_name(&resolved)?;
let root_directory =
monorepo_relative_root(&resolved.project_root, &resolved.worker_root);
if !no_remote && !dry_run {
let _ = sync_workers_builds_build_command_trying_tokens(
&script_name,
&resolved.worker_root,
root_directory.as_deref(),
false,
)
.await;
}
let report = fix_workers_builds_failure(
None, &script_name,
&resolved.worker_root,
root_directory.as_deref(),
BuildsFixOptions {
dry_run,
no_remote: true, log_path: log,
commit,
always_heal: false,
},
)
.await?;
print_builds_fix_report(&report);
if !report.classification.is_known()
&& report.healed_workspace_files.is_empty()
&& report
.sync
.as_ref()
.map(|s| s.updated_triggers.is_empty())
.unwrap_or(true)
{
return Err(
"Workers Builds fix: no known failure pattern and nothing remediable".into(),
);
}
if let Some(sync) = &report.sync {
if sync.skipped.is_some()
&& sync.updated_triggers.is_empty()
&& sync.notes.iter().any(|n| n.contains("failed"))
{
return Err("Workers Builds fix incomplete — see notes above".into());
}
}
Ok(())
}
}
}
async fn auto_fix_workers_builds_if_needed(app: &ResolvedCloudflareApp) -> Result<Option<String>, String> {
use crate::commands::workers::builds_config::sync_workers_builds_build_command_trying_tokens;
use crate::commands::workers::builds_fix::{
fix_workers_builds_failure, print_builds_fix_report, BuildsFixOptions,
};
let script_name = match resolve_builds_script_name(app) {
Ok(s) => s,
Err(_) => return Ok(None),
};
let root_directory = monorepo_relative_root(&app.project_root, &app.worker_root);
let local = fix_workers_builds_failure(
None,
&script_name,
&app.worker_root,
root_directory.as_deref(),
BuildsFixOptions {
dry_run: false,
no_remote: true,
log_path: None,
commit: false,
always_heal: true,
},
)
.await?;
print_builds_fix_report(&local);
let sync_report = sync_workers_builds_build_command_trying_tokens(
&script_name,
&app.worker_root,
root_directory.as_deref(),
false,
)
.await
.ok();
if let Some(ref report) = sync_report {
crate::commands::workers::builds_config::print_build_command_sync_report(report);
}
let healed = !local.healed_workspace_files.is_empty();
let updated = sync_report
.as_ref()
.map(|s| !s.updated_triggers.is_empty())
.unwrap_or(false);
if healed || updated {
Ok(Some(format!(
"remediated (healed_files={} triggers_updated={})",
local.healed_workspace_files.len(),
sync_report
.as_ref()
.map(|s| s.updated_triggers.len())
.unwrap_or(0)
)))
} else {
Ok(None)
}
}
fn run_doctor_deploy_dry_run(app: &ResolvedCloudflareApp) -> Result<(), String> {
let rollout = app
.container
.as_ref()
.and_then(|container| container.default_rollout.as_deref());
if let Some(built_config) = built_wrangler_config_path(&app.worker_root) {
doctor_kv(
"deploy config",
&doctor_rel_path(&app.project_root, &built_config),
);
let mut args = wrangler_deploy_args(app, rollout, true);
replace_wrangler_config_arg(&mut args, &app.worker_root, &built_config);
return run_wrangler_checked_or_explain_entry(app, args, "wrangler deploy --dry-run (built)");
}
if worker_requires_vite_worker_build(&app.worker_root) {
doctor_skip("wrangler deploy --dry-run");
doctor_kv(
"reason",
"Vite/TanStack Start worker needs a production build first",
);
doctor_kv(
"hint",
&format!(
"pnpm --dir {} run build → dist/server/wrangler.json",
doctor_rel_path(&app.project_root, &app.worker_root)
),
);
doctor_kv(
"note",
"source wrangler.jsonc cannot resolve #tanstack-* virtual modules",
);
return Ok(());
}
if crate::commands::workers::open_next_deploy::is_opennext_worker(&app.worker_root) {
let gaps =
crate::commands::workers::open_next_deploy::opennext_build_gaps(&app.worker_root);
if !gaps.is_empty() {
doctor_skip("wrangler deploy --dry-run");
doctor_kv(
"reason",
&format!(
"OpenNext build incomplete ({})",
gaps.summary()
),
);
doctor_kv(
"hint",
"deploy runs OpenNext install/build then wrangler; or: pnpm run build:worker",
);
if gaps.missing_assets {
doctor_kv(
"note",
"wrangler requires assets.directory on disk; stale/partial .open-next is not enough",
);
}
return Ok(());
}
}
if let Ok(config) = load_wrangler_config_value(&app.wrangler_config) {
if let Some(report) = diagnose_wrangler_entry_point(app, &config) {
if !report.ok {
doctor_skip("wrangler deploy --dry-run");
doctor_kv("reason", "Wrangler config has no deployable entry-point");
for line in &report.hints {
doctor_kv("hint", line);
}
return Err(report.summary);
}
}
}
run_wrangler_checked_or_explain_entry(
app,
wrangler_deploy_args(app, rollout, true),
"wrangler deploy --dry-run",
)
}
fn run_wrangler_checked_or_explain_entry(
app: &ResolvedCloudflareApp,
args: Vec<String>,
label: &str,
) -> Result<(), String> {
match run_wrangler_checked(app, args, label) {
Ok(_) => Ok(()),
Err(err)
if looks_like_missing_assets_directory_error(&err)
&& crate::commands::workers::open_next_deploy::is_opennext_worker(
&app.worker_root,
) =>
{
let gaps =
crate::commands::workers::open_next_deploy::opennext_build_gaps(&app.worker_root);
Err(format!(
"cf_incomplete_opennext_build: wrangler assets.directory missing under worker root `{}` ({}). \
Rebuild with `pnpm run build:worker` (or run `xbp cloudflare deploy` which builds first).\n\nwrangler: {err}",
doctor_rel_path(&app.project_root, &app.worker_root),
gaps.summary()
))
}
Err(err) if looks_like_missing_entry_point_error(&err) => {
let mut msg = explain_missing_entry_point(app);
if !err.trim().is_empty() {
msg = format!("{msg}\n\nwrangler: {err}");
}
Err(msg)
}
Err(err) => Err(err),
}
}
#[derive(Debug, Clone)]
struct EntryPointReport {
ok: bool,
summary: String,
hints: Vec<String>,
has_main: bool,
has_assets: bool,
}
fn diagnose_wrangler_entry_point(
app: &ResolvedCloudflareApp,
config: &Value,
) -> Option<EntryPointReport> {
let main = config
.get("main")
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let assets_dir = config
.get("assets")
.and_then(|a| a.get("directory"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|s| !s.is_empty());
let has_main = main.is_some();
let has_assets = assets_dir.is_some();
if has_main || has_assets {
let mut hints = Vec::new();
if let Some(m) = main {
let path = app.worker_root.join(m);
let looks_built = m.contains(".open-next")
|| m.contains("dist/")
|| m.contains("dist\\")
|| m.ends_with(".open-next/worker.js");
if !looks_built && !path.exists() {
hints.push(format!(
"`main` points to missing file `{}` (relative to worker root)",
m
));
}
}
if let Some(dir) = assets_dir {
let path = app.worker_root.join(dir);
let looks_built = dir.contains(".open-next") || dir.contains("dist");
if !looks_built && !path.is_dir() {
hints.push(format!(
"`assets.directory` points to missing dir `{}`",
dir
));
}
}
if hints.is_empty() {
return Some(EntryPointReport {
ok: true,
summary: String::new(),
hints,
has_main,
has_assets,
});
}
return Some(EntryPointReport {
ok: false,
summary: format!(
"Wrangler entry-point path(s) missing under {}",
doctor_rel_path(&app.project_root, &app.worker_root)
),
hints,
has_main,
has_assets,
});
}
let mut hints = vec![
format!(
"Add `main = \"src/index.ts\"` (or assets.directory) to {}",
doctor_rel_path(&app.project_root, &app.wrangler_config)
),
"This often means workers[].root points at a monorepo root stub, not a real Worker app."
.into(),
];
let siblings = suggest_sibling_worker_apps(app);
if !siblings.is_empty() {
hints.push(format!(
"Try a deployable app instead: {}",
siblings
.iter()
.map(|s| format!("`xbp cloudflare doctor --app {s}`"))
.collect::<Vec<_>>()
.join(" · ")
));
}
hints.push(
"In this monorepo, `athena` service is k8s/docker; CF Workers live under apps/cloudflare-* / services/athena-auth."
.into(),
);
Some(EntryPointReport {
ok: false,
summary: format!(
"Wrangler config has no `main` or `assets.directory` (worker root `{}`). \
wrangler deploy fails with: Missing entry-point to Worker script or to assets directory.",
doctor_rel_path(&app.project_root, &app.worker_root)
),
hints,
has_main: false,
has_assets: false,
})
}
fn validate_wrangler_entry_point(
app: &ResolvedCloudflareApp,
config: &Value,
diagnostics: &mut LocalDiagnostics,
) {
let Some(report) = diagnose_wrangler_entry_point(app, config) else {
return;
};
if report.ok {
return;
}
diagnostics.missing_required.push(report.summary.clone());
for hint in report.hints {
diagnostics.warnings.push(hint);
}
}
fn looks_like_missing_entry_point_error(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
lower.contains("missing entry-point")
|| lower.contains("missing entry point")
|| (lower.contains("entry-point") && lower.contains("assets directory"))
}
fn looks_like_missing_assets_directory_error(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
(lower.contains("assets.directory") || lower.contains("assets directory"))
&& (lower.contains("does not exist")
|| lower.contains("doesn't exist")
|| lower.contains("not exist"))
}
fn explain_missing_entry_point(app: &ResolvedCloudflareApp) -> String {
if let Ok(config) = load_wrangler_config_value(&app.wrangler_config) {
if let Some(report) = diagnose_wrangler_entry_point(app, &config) {
if !report.ok {
let mut parts = vec![report.summary];
parts.extend(report.hints);
return parts.join("\n ");
}
}
}
format!(
"Wrangler reported missing entry-point for worker root `{}` (config {}). \
Set `main` or `assets.directory` in wrangler config, or use a workers[] app that points at a real Worker package.",
doctor_rel_path(&app.project_root, &app.worker_root),
doctor_rel_path(&app.project_root, &app.wrangler_config)
)
}
fn suggest_sibling_worker_apps(app: &ResolvedCloudflareApp) -> Vec<String> {
let Ok(content) = fs::read_to_string(&app.config_path) else {
return Vec::new();
};
let kind = app
.config_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("toml");
let Ok((config, _)) = parse_config_with_auto_heal::<XbpConfig>(&content, kind) else {
return Vec::new();
};
let Some(workers) = config.workers.as_ref() else {
return Vec::new();
};
let mut out = Vec::new();
for w in workers {
if w.name == app.app_name {
continue;
}
let root_rel = w.root.trim_start_matches("./");
let root = if root_rel.is_empty() || root_rel == "." {
app.project_root.clone()
} else {
app.project_root.join(root_rel)
};
if wrangler_root_looks_deployable(&root) {
out.push(w.name.clone());
}
if out.len() >= 4 {
break;
}
}
out
}
fn wrangler_root_looks_deployable(worker_root: &Path) -> bool {
for name in ["wrangler.toml", "wrangler.jsonc", "wrangler.json"] {
let path = worker_root.join(name);
if !path.is_file() {
continue;
}
let Ok(config) = load_wrangler_config_value(&path) else {
continue;
};
let has_main = config
.get("main")
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty());
let has_assets = config
.get("assets")
.and_then(|a| a.get("directory"))
.and_then(Value::as_str)
.is_some_and(|s| !s.trim().is_empty());
if has_main || has_assets {
return true;
}
}
false
}
fn built_wrangler_config_path(worker_root: &Path) -> Option<PathBuf> {
let path = worker_root
.join("dist")
.join("server")
.join("wrangler.json");
path.is_file().then_some(path)
}
fn worker_requires_vite_worker_build(worker_root: &Path) -> bool {
if worker_root
.join("dist")
.join("server")
.join("wrangler.json")
.is_file()
{
return false;
}
let package_json = worker_root.join("package.json");
let Ok(content) = fs::read_to_string(&package_json) else {
return false;
};
let lower = content.to_ascii_lowercase();
let has_vite_worker = lower.contains("@cloudflare/vite-plugin")
|| lower.contains("@tanstack/react-start")
|| lower.contains("@tanstack/start");
if !has_vite_worker {
return false;
}
let has_source_entry = worker_root.join("src").join("entry-worker.ts").is_file()
|| worker_root.join("src").join("entry-worker.js").is_file()
|| worker_root.join("vite.config.ts").is_file()
|| worker_root.join("vite.config.mts").is_file()
|| worker_root.join("vite.config.js").is_file();
has_source_entry
}
fn replace_wrangler_config_arg(args: &mut Vec<String>, worker_root: &Path, config: &Path) {
if let Some(index) = args.iter().position(|arg| arg == "--config") {
if let Some(slot) = args.get_mut(index + 1) {
*slot = wrangler::render_path_arg(worker_root, config);
return;
}
}
args.push("--config".to_string());
args.push(wrangler::render_path_arg(worker_root, config));
}
fn doctor_header(title: &str) {
println!(
"{} {}",
"â—†".bright_blue().bold(),
title.bright_blue().bold()
);
println!("{}", "─".repeat(52).bright_black());
}
fn doctor_section(title: &str) {
println!();
println!("{}", title.bright_white().bold());
}
fn doctor_kv(label: &str, value: &str) {
println!(" {:<20} {}", label.bright_black(), value.cyan());
}
fn doctor_ok(label: &str) {
println!(" {} {}", "✓".bright_green().bold(), label.bright_white());
}
fn doctor_skip(label: &str) {
println!(" {} {}", "â—‹".bright_black().bold(), label.dimmed());
}
fn doctor_warn(message: &str) {
let prefix = format!(" {} ", "âš ".bright_yellow().bold());
let body = wrap_doctor_text(message, 88);
for (index, line) in body.lines().enumerate() {
if index == 0 {
println!("{}{}", prefix, line.yellow());
} else {
println!(" {}", line.yellow());
}
}
}
fn doctor_err(message: &str) {
let prefix = format!(" {} ", "✗".bright_red().bold());
let body = wrap_doctor_text(message, 88);
for (index, line) in body.lines().enumerate() {
if index == 0 {
println!("{}{}", prefix, line.red());
} else {
println!(" {}", line.red());
}
}
}
fn doctor_footer_ok() {
println!();
println!("{}", "─".repeat(52).bright_black());
println!(
" {} {}",
"✓".bright_green().bold(),
"Cloudflare doctor: ok".bright_green().bold()
);
}
fn doctor_rel_path(project_root: &Path, path: &Path) -> String {
let collapsed = collapse_project_path(project_root, &path.to_string_lossy());
let normalized = collapsed.replace('\\', "/");
if normalized == "./" || normalized.is_empty() {
".".to_string()
} else {
normalized.trim_start_matches("./").to_string()
}
}
fn wrap_doctor_text(text: &str, width: usize) -> String {
if text.chars().count() <= width {
return text.to_string();
}
let mut lines = Vec::new();
let mut current = String::new();
for word in text.split_whitespace() {
if current.is_empty() {
current.push_str(word);
continue;
}
if current.chars().count() + 1 + word.chars().count() > width {
lines.push(std::mem::take(&mut current));
current.push_str(word);
} else {
current.push(' ');
current.push_str(word);
}
}
if !current.is_empty() {
lines.push(current);
}
lines.join("\n")
}
fn run_init_contract(
root_override: Option<&Path>,
app_override: Option<&str>,
cmd: CloudflareInitCmd,
) -> Result<(), String> {
let current_dir =
env::current_dir().map_err(|error| format!("Failed to read current directory: {error}"))?;
let start = root_override.unwrap_or(current_dir.as_path());
let found = find_xbp_config_upwards(start).ok_or_else(|| {
format!(
"Could not find .xbp/xbp.yaml from {}. Run `xbp setup` or pass `--root`.",
start.display()
)
})?;
let content = fs::read_to_string(&found.config_path)
.map_err(|error| format!("Failed to read {}: {error}", found.config_path.display()))?;
let (mut config, healed_content) =
parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
.map_err(|error| format!("Failed to parse {}: {error}", found.config_path.display()))?;
if let Some(healed_content) = healed_content {
fs::write(&found.config_path, healed_content)
.map_err(|error| format!("Failed to write {}: {error}", found.config_path.display()))?;
}
let app_name = app_override
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
cmd.worker_root
.file_name()
.and_then(|value| value.to_str())
.map(ToOwned::to_owned)
})
.ok_or_else(|| "Pass --app or a worker root with a final path component.".to_string())?;
let worker_root = if cmd.worker_root.is_absolute() {
cmd.worker_root.clone()
} else {
found.project_root.join(&cmd.worker_root)
};
let worker_root_string = worker_root.to_string_lossy().to_string();
let workers = config.workers.get_or_insert_with(Vec::new);
let index = workers
.iter()
.position(|worker| {
worker.name == app_name
|| worker.root == worker_root_string
|| found.project_root.join(&worker.root) == worker_root
})
.unwrap_or_else(|| {
workers.push(WorkerConfig {
name: app_name.clone(),
root: worker_root_string.clone(),
script_name: None,
service: None,
deploy: None,
container: None,
containers: None,
durable_objects: None,
});
workers.len() - 1
});
let dockerfile = cmd.dockerfile.to_string_lossy().replace('\\', "/");
let container = WorkerContainerConfig {
class_name: cmd.class_name,
binding: cmd.binding,
dockerfile: Some(dockerfile),
port: Some(cmd.container_port),
application_id: cmd.application_id,
healthcheck_path: Some(normalize_health_path(&cmd.health_path)),
required_secrets: cmd.required_secrets,
version_var: cmd.version_var,
instance_name_template: None,
default_rollout: Some("immediate".to_string()),
health_urls: Vec::new(),
expected_image_name: None,
image_tag_keep_count: None,
allow_unchanged_container_image: false,
image: None,
build_context: None,
instance_type: None,
max_instances: None,
rollout_active_grace_period: None,
rollout_step_percentage: Vec::new(),
regions: Vec::new(),
};
workers[index].root = worker_root_string;
workers[index].container = Some(container);
if !cmd.write {
println!(
"{}",
serde_yaml::to_string(&workers[index]).map_err(|error| error.to_string())?
);
println!("Run again with --write to persist this worker container contract.");
return Ok(());
}
write_worker_configs_to_project(&found.project_root, &found.config_path, &mut config)?;
println!(
"Wrote Cloudflare container contract for `{}` to {}",
app_name,
found.config_path.display()
);
Ok(())
}
pub async fn deploy_app_by_name(
root_override: Option<&Path>,
app_name: &str,
dry_run: bool,
rollout: Option<&str>,
skip_deploy: bool,
allow_unchanged_container_image: bool,
prune_old_images: bool,
) -> Result<Vec<String>, String> {
deploy_app_by_name_with_options(
root_override,
app_name,
dry_run,
rollout,
skip_deploy,
allow_unchanged_container_image,
prune_old_images,
false,
false,
)
.await
}
pub async fn deploy_app_by_name_with_options(
root_override: Option<&Path>,
app_name: &str,
dry_run: bool,
rollout: Option<&str>,
skip_deploy: bool,
allow_unchanged_container_image: bool,
prune_old_images: bool,
local_build: bool,
local_build_only: bool,
) -> Result<Vec<String>, String> {
let app = resolve_cloudflare_app(root_override, Some(app_name))?;
let rollout = match rollout.map(str::trim).filter(|s| !s.is_empty()) {
Some("gradual") => CloudflareRollout::Gradual,
Some("none") | Some("off") => CloudflareRollout::None,
Some("immediate") | None => CloudflareRollout::Immediate,
Some(other) => {
return Err(format!(
"unsupported cloudflare rollout `{other}` (use immediate|gradual|none)"
));
}
};
let payload = CloudflareWorkflowPayload {
app: Some(app.app_name.clone()),
token: None,
account_id: None,
workflow: CloudflareJobWorkflow::Deploy,
rollout,
version: None,
domain: None,
dry_run,
skip_deploy: skip_deploy || local_build_only,
allow_unchanged_container_image,
prune_old_images,
keep_image_tag_count: None,
local_build: local_build || local_build_only,
local_build_only,
};
run_deploy_workflow(&app, &payload).await?;
Ok(vec![format!(
"cloudflare deploy completed app={} dry_run={dry_run} local_build={}",
app.app_name,
payload.local_build
)])
}
async fn notify_cloudflare_deploy_discord(
app: &ResolvedCloudflareApp,
mode: &str,
dry_run: bool,
result: &Result<(), String>,
) {
if dry_run {
return;
}
let status = match result {
Ok(()) => crate::commands::discord_notify::DiscordStatus::Success,
Err(_) => crate::commands::discord_notify::DiscordStatus::Failure,
};
let detail = match result {
Ok(()) => {
if app.container.is_some() {
format!(
"Cloudflare deploy finished for `{}` (Worker + Containers)",
app.app_name
)
} else {
format!("Cloudflare Worker deploy finished for `{}`", app.app_name)
}
}
Err(e) => e.clone(),
};
let xbp = load_project_xbp_for_discord(&app.project_root);
crate::commands::discord_notify::notify_discord_for_project(
&app.project_root,
xbp.as_ref(),
None,
crate::commands::discord_notify::cloudflare_deploy_notification(
&[app.app_name.clone()],
mode,
status,
&detail,
app.container.is_some(),
),
)
.await;
}
async fn sync_cloudflare_deploy_activity(
app: &ResolvedCloudflareApp,
mode: &str,
payload: &CloudflareWorkflowPayload,
result: &Result<(), String>,
) {
use crate::commands::cli_session::{report_ops_deploy_activity, OpsDeployActivityReport};
let kind = if app.container.is_some() {
"worker+container"
} else {
"worker"
};
let version_note = payload
.version
.as_deref()
.filter(|v| !v.is_empty())
.map(|v| format!(" version={v}"))
.unwrap_or_default();
let summary = match result {
Ok(()) => format!(
"Cloudflare {kind} `{mode}` for `{}`{version_note} (rollout={:?})",
app.app_name, payload.rollout
),
Err(e) => format!(
"Cloudflare {kind} `{mode}` for `{}`{version_note} failed: {e}",
app.app_name
),
};
let project_name = load_project_xbp_for_discord(&app.project_root).map(|c| c.project_name);
let health_urls = app
.container
.as_ref()
.map(|c| c.health_urls.join(" "))
.filter(|s| !s.is_empty());
report_ops_deploy_activity(OpsDeployActivityReport {
project_root: app.project_root.clone(),
project_name,
status_ok: result.is_ok(),
mode: mode.to_string(),
target: app.app_name.clone(),
env: if app.container.is_some() {
"cloudflare-containers".into()
} else {
"cloudflare-workers".into()
},
services: vec![app.app_name.clone()],
summary,
logs: health_urls.or_else(|| result.as_ref().err().cloned()),
error: result.as_ref().err().cloned(),
kube_context: None,
namespace: Some(if app.container.is_some() {
"cloudflare-containers".into()
} else {
"cloudflare-workers".into()
}),
plan_hash: None,
})
.await;
}
async fn sync_cloudflare_containers_activity(
app: &ResolvedCloudflareApp,
action: &str,
result: &Result<(), String>,
) {
use crate::commands::cli_session::{report_ops_deploy_activity, OpsDeployActivityReport};
let summary = match result {
Ok(()) => format!(
"Cloudflare container `{action}` for `{}`",
app.app_name
),
Err(e) => format!(
"Cloudflare container `{action}` for `{}` failed: {e}",
app.app_name
),
};
let project_name = load_project_xbp_for_discord(&app.project_root).map(|c| c.project_name);
report_ops_deploy_activity(OpsDeployActivityReport {
project_root: app.project_root.clone(),
project_name,
status_ok: result.is_ok(),
mode: format!("cloudflare-containers-{action}"),
target: app.app_name.clone(),
env: "cloudflare-containers".into(),
services: vec![app.app_name.clone()],
summary,
logs: result.as_ref().err().cloned(),
error: result.as_ref().err().cloned(),
kube_context: None,
namespace: Some("cloudflare-containers".into()),
plan_hash: None,
})
.await;
}
async fn notify_cloudflare_containers_discord(
app: &ResolvedCloudflareApp,
action: &str,
result: &Result<(), String>,
) {
let status = match result {
Ok(()) => crate::commands::discord_notify::DiscordStatus::Success,
Err(_) => crate::commands::discord_notify::DiscordStatus::Failure,
};
let detail = match result {
Ok(()) => format!("Containers `{action}` completed for `{}`", app.app_name),
Err(e) => e.clone(),
};
let xbp = load_project_xbp_for_discord(&app.project_root);
crate::commands::discord_notify::notify_discord_for_project(
&app.project_root,
xbp.as_ref(),
None,
crate::commands::discord_notify::cloudflare_containers_notification(
&app.app_name,
action,
status,
&detail,
),
)
.await;
}
fn load_project_xbp_for_discord(project_root: &Path) -> Option<XbpConfig> {
let found = find_xbp_config_upwards(project_root)?;
let content = fs::read_to_string(&found.config_path).ok()?;
parse_config_with_auto_heal::<XbpConfig>(&content, found.kind)
.ok()
.map(|(cfg, _)| cfg)
}
fn cloudflare_containers_action_label(cmd: &CloudflareContainersCmd) -> String {
match &cmd.command {
CloudflareContainersSubCommand::Build(_) => "build".into(),
CloudflareContainersSubCommand::List(_) => "list".into(),
CloudflareContainersSubCommand::Info(_) => "info".into(),
CloudflareContainersSubCommand::Instances(_) => "instances".into(),
CloudflareContainersSubCommand::Push(_) => "push".into(),
CloudflareContainersSubCommand::Ssh(_) => "ssh".into(),
}
}
fn cloudflare_containers_mutates(cmd: &CloudflareContainersCmd) -> bool {
matches!(
cmd.command,
CloudflareContainersSubCommand::Push(_) | CloudflareContainersSubCommand::Build(_)
)
}
async fn run_deploy_workflow(
app: &ResolvedCloudflareApp,
payload: &CloudflareWorkflowPayload,
) -> Result<(), String> {
let has_container = app.container.is_some();
let provider = if has_container {
"cloudflare-containers"
} else {
"cloudflare-worker"
};
let result = run_deploy_workflow_inner(app, payload, has_container).await;
if !payload.dry_run && !payload.local_build_only {
match &result {
Ok(()) => {
crate::commands::deploy_engine::history_ext::record_cloudflare_cli_deploy(
&app.project_root,
&app.app_name,
provider,
true,
"cloudflare deploy succeeded",
None,
);
}
Err(e) => {
crate::commands::deploy_engine::history_ext::record_cloudflare_cli_deploy(
&app.project_root,
&app.app_name,
provider,
false,
e,
Some(e.as_str()),
);
}
}
}
result
}
async fn run_deploy_workflow_inner(
app: &ResolvedCloudflareApp,
payload: &CloudflareWorkflowPayload,
has_container: bool,
) -> Result<(), String> {
if has_container {
validate_payload_for_workflow(payload)?;
}
if payload.local_build_only {
if !has_container {
return Err(
"local-build-only requires a workers[].container contract (Dockerfile)".into(),
);
}
let tag = build_container_image_local(app, None, &[], false, None)?;
println!(
"{} local container image ready: {tag} (no Cloudflare push)",
"✓".green().bold()
);
return Ok(());
}
let is_opennext = !has_container
&& crate::commands::workers::open_next_deploy::is_opennext_worker(&app.worker_root);
if is_opennext && !payload.skip_deploy {
if payload.dry_run {
run_doctor(app, &CloudflareDoctorCmd { offline: false }).await?;
if crate::commands::workers::open_next_deploy::opennext_build_ready_for_wrangler(
&app.worker_root,
) {
let args = wrangler_deploy_args(app, None, true);
run_wrangler_streaming(app, args, "wrangler deploy --dry-run")?;
} else {
let gaps = crate::commands::workers::open_next_deploy::opennext_build_gaps(
&app.worker_root,
);
println!(
"{} OpenNext dry-run: skipped wrangler ({}). Use full deploy to build, or `pnpm run build:worker` then re-run.",
"·".dimmed(),
gaps.summary()
);
}
return Ok(());
}
run_doctor(app, &CloudflareDoctorCmd { offline: false }).await?;
let opts = crate::commands::workers::open_next_deploy::OpenNextDeployOptions::from_worker_deploy(
app.worker.deploy.as_ref(),
);
crate::commands::workers::open_next_deploy::run_opennext_deploy(&app.worker_root, &opts)?;
return Ok(());
}
run_doctor(app, &CloudflareDoctorCmd { offline: false }).await?;
regenerate_worker_types(app)?;
if let Err(err) = run_project_build_if_present(app) {
if crate::commands::workers::builds_fix::looks_like_fixable_builds_error(&err) {
eprintln!(
"{} build failed with known pattern — auto-fixing and retrying once",
"xbp".bright_magenta()
);
let _ = auto_fix_workers_builds_if_needed(app).await;
run_project_build_if_present(app)?;
} else {
return Err(err);
}
}
if payload.local_build && has_container {
let tag = build_container_image_local(app, None, &[], false, None)?;
println!(
"{} pre-built container image locally: {tag}",
"→".cyan()
);
}
let before_info = if payload.dry_run || !has_container || payload.skip_deploy {
None
} else {
read_container_app_info(app)?
};
if payload.skip_deploy {
println!("wrangler deploy: skipped (--skip-deploy)");
} else {
let rollout = if has_container {
Some(payload.rollout.as_str())
} else {
None
};
let args = wrangler_deploy_args(app, rollout, payload.dry_run);
run_wrangler_streaming(app, args, "wrangler deploy")?;
}
if !payload.dry_run && has_container && !payload.skip_deploy {
verify_container_state(
app,
ContainerVerificationContext {
before_info,
expected_version: payload.version.clone(),
skip_deploy: payload.skip_deploy,
allow_unchanged_image: payload.allow_unchanged_container_image,
prune_old_images: payload.prune_old_images,
keep_image_tag_count: payload.keep_image_tag_count,
},
)
.await?;
}
Ok(())
}
async fn run_release_workflow(
app: &mut ResolvedCloudflareApp,
payload: &CloudflareWorkflowPayload,
) -> Result<(), String> {
let _ = app.require_container()?;
validate_payload_for_workflow(payload)?;
let version = payload
.version
.as_deref()
.ok_or_else(|| "Release jobs require --version.".to_string())?;
if let Some(domain) = payload.domain.as_deref() {
check_domain_for_cloudflare_release(
Some(&app.project_root),
domain,
version,
&app.app_name,
)?;
}
sync_version_var(app, version)?;
run_deploy_workflow(app, payload).await?;
Ok(())
}
fn run_containers(app: &ResolvedCloudflareApp, cmd: CloudflareContainersCmd) -> Result<(), String> {
let _ = app.require_container()?;
if let CloudflareContainersSubCommand::Build(build_cmd) = &cmd.command {
let tag = build_container_image_local(
app,
build_cmd.tag.as_deref(),
&build_cmd.build_arg,
build_cmd.no_cache,
build_cmd.with_env_file.as_deref(),
)?;
println!(
"{} built local container image `{tag}` (no Cloudflare registry push)",
"✓".green().bold()
);
println!(
" re-run `xbp cloudflare deploy --app {} --local-build` to build then deploy, or plain deploy to let Wrangler build+push",
app.app_name
);
return Ok(());
}
let args = match cmd.command {
CloudflareContainersSubCommand::Build(_) => unreachable!("handled above"),
CloudflareContainersSubCommand::List(list_cmd) => {
let mut args = vec!["containers".to_string(), "list".to_string()];
if list_cmd.json {
args.push("--json".to_string());
}
args
}
CloudflareContainersSubCommand::Info(info_cmd) => {
let mut args = vec![
"containers".to_string(),
"info".to_string(),
resolve_application_id(app, info_cmd.application_id.as_deref())?,
];
if info_cmd.json {
args.push("--json".to_string());
}
args
}
CloudflareContainersSubCommand::Instances(instances_cmd) => {
let mut args = vec![
"containers".to_string(),
"instances".to_string(),
resolve_application_id(app, instances_cmd.application_id.as_deref())?,
];
if instances_cmd.json {
args.push("--json".to_string());
}
args
}
CloudflareContainersSubCommand::Push(push_cmd) => {
vec!["containers".to_string(), "push".to_string(), push_cmd.image]
}
CloudflareContainersSubCommand::Ssh(ssh_cmd) => {
let mut args = vec![
"containers".to_string(),
"ssh".to_string(),
ssh_cmd.instance_id,
];
args.extend(ssh_cmd.args);
args
}
};
wrangler::run_wrangler(&app.worker_root, &args)
}
async fn run_jobs(
root_override: Option<&Path>,
app_override: Option<String>,
cmd: CloudflareJobsCmd,
) -> Result<(), String> {
match cmd.command {
CloudflareJobsSubCommand::Enqueue(enqueue_cmd) => {
let project_root = resolve_project_root(root_override)?;
let payload = CloudflareWorkflowPayload {
app: app_override,
token: None,
account_id: None,
workflow: enqueue_cmd.workflow,
rollout: enqueue_cmd.rollout,
version: enqueue_cmd.version,
domain: None,
dry_run: enqueue_cmd.dry_run,
skip_deploy: enqueue_cmd.skip_deploy,
allow_unchanged_container_image: enqueue_cmd.allow_unchanged_container_image
|| !enqueue_cmd.require_new_container_image,
prune_old_images: enqueue_cmd.prune_old_images,
keep_image_tag_count: enqueue_cmd.keep_image_tag_count,
local_build: false,
local_build_only: false,
};
validate_payload_for_workflow(&payload)?;
let job = enqueue_job(&project_root, payload)?;
println!(
"{}",
serde_json::to_string_pretty(&job).map_err(|error| error.to_string())?
);
Ok(())
}
CloudflareJobsSubCommand::Run(run_cmd) => {
let project_root = resolve_project_root(root_override)?;
run_queued_jobs(&project_root, run_cmd.once).await
}
CloudflareJobsSubCommand::Status(status_cmd) => {
let project_root = resolve_project_root(root_override)?;
let job = read_job(&project_root, &status_cmd.job_id)?;
println!(
"{}",
serde_json::to_string_pretty(&job).map_err(|error| error.to_string())?
);
Ok(())
}
CloudflareJobsSubCommand::List(_) => {
let project_root = resolve_project_root(root_override)?;
for job in list_jobs(&project_root)? {
println!(
"{}\t{}\t{}\t{}",
job.id,
job.status.as_str(),
job.payload.workflow.as_str(),
job.updated_at
);
}
Ok(())
}
CloudflareJobsSubCommand::Logs(logs_cmd) => {
let project_root = resolve_project_root(root_override)?;
let job = read_job(&project_root, &logs_cmd.job_id)?;
for line in job.logs {
println!("{line}");
}
Ok(())
}
}
}
async fn run_queued_jobs(project_root: &Path, once: bool) -> Result<(), String> {
let mut jobs = list_jobs(project_root)?
.into_iter()
.filter(|job| job.status == CloudflareJobStatus::Queued)
.collect::<Vec<_>>();
jobs.sort_by_key(|job| job.created_at);
if jobs.is_empty() {
println!("No queued Cloudflare jobs.");
return Ok(());
}
for mut job in jobs {
job.status = CloudflareJobStatus::Running;
job.started_at = Some(Utc::now());
job.updated_at = Utc::now();
job.logs.push("job started".to_string());
write_job(project_root, &job)?;
let result = run_job_payload(project_root, &job.payload).await;
job.updated_at = Utc::now();
job.finished_at = Some(Utc::now());
match result {
Ok(()) => {
job.status = CloudflareJobStatus::Succeeded;
job.logs.push("job succeeded".to_string());
}
Err(error) => {
job.status = CloudflareJobStatus::Failed;
job.error = Some(error.clone());
job.logs.push(format!("job failed: {error}"));
}
}
write_job(project_root, &job)?;
if once {
break;
}
}
Ok(())
}
async fn run_job_payload(
project_root: &Path,
payload: &CloudflareWorkflowPayload,
) -> Result<(), String> {
let app = resolve_cloudflare_app(Some(project_root), payload.app.as_deref())?;
match payload.workflow {
CloudflareJobWorkflow::Doctor => {
run_doctor(&app, &CloudflareDoctorCmd { offline: false }).await
}
CloudflareJobWorkflow::Deploy => {
let result = run_deploy_workflow(&app, payload).await;
notify_cloudflare_deploy_discord(&app, "job:deploy", payload.dry_run, &result).await;
result
}
CloudflareJobWorkflow::Release => {
let mut app = app;
let version = payload.version.clone().unwrap_or_default();
let result = run_release_workflow(&mut app, payload).await;
notify_cloudflare_deploy_discord(
&app,
&format!("job:release {version}"),
payload.dry_run,
&result,
)
.await;
result
}
}
}
pub fn preflight_cf_worker_for_deploy(
project_root: &Path,
app_name: &str,
) -> Result<Vec<String>, String> {
let mut notes = Vec::new();
let app = resolve_cloudflare_app(Some(project_root), Some(app_name)).map_err(|e| {
if e.contains("was not found") || e.contains("Known workers") {
format!("cf_worker_not_found: {e}")
} else {
e
}
})?;
notes.push(format!(
"resolved app={} script={} root={}",
app.app_name,
app.worker
.script_name
.as_deref()
.unwrap_or(app.app_name.as_str()),
doctor_rel_path(&app.project_root, &app.worker_root)
));
let diagnostics = collect_local_diagnostics(&app)?;
for warning in &diagnostics.warnings {
notes.push(format!("warn: {warning}"));
}
let mut hard = Vec::new();
let is_opennext =
crate::commands::workers::open_next_deploy::is_opennext_worker(&app.worker_root);
for issue in &diagnostics.missing_required {
let lower = issue.to_ascii_lowercase();
let open_next_entry = is_opennext
&& (lower.contains(".open-next")
|| lower.contains("worker.js")
|| lower.contains("assets.directory")
|| lower.contains("assets directory")
|| lower.contains("entry-point")
|| lower.contains("entry point"));
if open_next_entry {
notes.push(format!(
"note: {issue} (OpenNext build runs during deploy — not a hard preflight fail)"
));
continue;
}
hard.push(issue.clone());
}
if !hard.is_empty() {
let mut msg = format!(
"cf_contract_incomplete: Cloudflare worker contract is incomplete ({} issue{}).",
hard.len(),
if hard.len() == 1 { "" } else { "s" }
);
for item in &hard {
msg.push_str("\n - ");
msg.push_str(item);
}
msg.push_str(&format!(
"\nRun `xbp cloudflare doctor --app {}` for details.",
app.app_name
));
return Err(msg);
}
if app.container.is_some() {
notes.push("local container contract ok".into());
} else {
notes.push("local worker contract ok".into());
}
Ok(notes)
}
pub fn preflight_cf_worker_candidates(
project_root: &Path,
candidates: &[String],
) -> Result<(String, Vec<String>), String> {
let mut last_err = String::from("no worker app candidates provided");
let mut tried = Vec::new();
for raw in candidates {
let name = raw.trim();
if name.is_empty() {
continue;
}
if tried.iter().any(|t: &String| t.eq_ignore_ascii_case(name)) {
continue;
}
tried.push(name.to_string());
match preflight_cf_worker_for_deploy(project_root, name) {
Ok(notes) => return Ok((name.to_string(), notes)),
Err(e) => last_err = e,
}
}
if tried.is_empty() {
return Err(last_err);
}
Err(format!(
"{last_err}\nTried app names: {}",
tried.join(", ")
))
}
fn resolve_cloudflare_app(
root_override: Option<&Path>,
app_override: Option<&str>,
) -> Result<ResolvedCloudflareApp, String> {
let current_dir =
env::current_dir().map_err(|error| format!("Failed to read current directory: {error}"))?;
let mut resolution = resolve_worker_targets(¤t_dir, root_override, app_override, false)?;
let inserted = ensure_selected_workers_configured(&mut resolution)?;
if inserted > 0 {
println!(
"Auto-configured {inserted} Worker entr{} in {} from on-disk Wrangler project(s).",
if inserted == 1 { "y" } else { "ies" },
resolution.config_path.display()
);
}
let selected = resolution
.selected
.first()
.ok_or_else(|| "No Worker app was selected.".to_string())?
.clone();
let mut worker = selected.config.clone().ok_or_else(|| {
format!(
"Worker app `{}` was discovered on disk but could not be auto-configured in {}.",
selected.label,
resolution.config_path.display()
)
})?;
let wrangler_config = resolve_wrangler_config_file(&selected.root)?;
if worker.container.is_none() {
if let Some(inferred) = infer_container_contract_from_wrangler(&wrangler_config) {
worker.container = Some(inferred.clone());
if let Some(workers) = resolution.config.workers.as_mut() {
if let Some(entry) = workers.iter_mut().find(|entry| {
entry.name == worker.name
|| entry.root == worker.root
|| entry.script_name == worker.script_name
}) {
entry.container = Some(inferred);
write_worker_configs_to_project(
&resolution.project_root,
&resolution.config_path,
&mut resolution.config,
)?;
println!(
"Inferred container contract for `{}` from Wrangler and wrote it to {}.",
worker.name,
resolution.config_path.display()
);
}
}
}
}
if let Some(port) = fill_missing_container_port(&mut worker, &wrangler_config) {
if let Some(workers) = resolution.config.workers.as_mut() {
if let Some(entry) = workers.iter_mut().find(|entry| {
entry.name == worker.name
|| entry.root == worker.root
|| entry.script_name == worker.script_name
}) {
if let Some(c) = entry.container.as_mut() {
if c.port.is_none() {
c.port = Some(port);
let _ = write_worker_configs_to_project(
&resolution.project_root,
&resolution.config_path,
&mut resolution.config,
);
}
}
}
}
}
let app_name = if selected.label.eq_ignore_ascii_case("worker") {
let script = selected.base_script_name.as_str();
if !script.is_empty() && !script.eq_ignore_ascii_case("worker") {
script.to_string()
} else {
worker
.script_name
.clone()
.filter(|s| !s.trim().is_empty() && !s.eq_ignore_ascii_case("worker"))
.unwrap_or_else(|| selected.label.clone())
}
} else {
selected.label.clone()
};
worker.name = app_name.clone();
Ok(ResolvedCloudflareApp {
project_root: resolution.project_root,
config_path: resolution.config_path,
worker_root: selected.root.clone(),
app_name,
container: worker.container.clone(),
worker,
wrangler_config,
})
}
fn fill_missing_container_port(
worker: &mut WorkerConfig,
wrangler_config: &Path,
) -> Option<u16> {
let container = worker.container.as_mut()?;
if container.port.is_some() {
return None;
}
let port = infer_port_from_wrangler(wrangler_config)?;
container.port = Some(port);
Some(port)
}
fn infer_port_from_wrangler(wrangler_config: &Path) -> Option<u16> {
let config = load_wrangler_config_value(wrangler_config).ok()?;
if let Some(port) = config
.get("containers")
.and_then(Value::as_array)
.and_then(|arr| arr.first())
.and_then(|c| c.get("image_vars"))
.and_then(|vars| vars.get("PORT"))
.and_then(json_port)
{
return Some(port);
}
if let Some(port) = config
.get("vars")
.and_then(|v| v.get("PORT"))
.and_then(json_port)
{
return Some(port);
}
None
}
fn json_port(value: &Value) -> Option<u16> {
value
.as_u64()
.or_else(|| value.as_str().and_then(|t| t.parse::<u64>().ok()))
.and_then(|p| u16::try_from(p).ok())
}
fn infer_container_contract_from_wrangler(wrangler_config: &Path) -> Option<WorkerContainerConfig> {
let config = load_wrangler_config_value(wrangler_config).ok()?;
let containers = config.get("containers")?.as_array()?;
let first = containers.first()?;
let class_name = first
.get("class_name")
.and_then(Value::as_str)
.map(str::to_string)?;
let binding = config
.get("durable_objects")
.and_then(|value| value.get("bindings"))
.and_then(Value::as_array)
.and_then(|bindings| {
bindings.iter().find_map(|binding| {
let binding_class = binding.get("class_name").and_then(Value::as_str)?;
if binding_class == class_name {
binding
.get("name")
.and_then(Value::as_str)
.map(str::to_string)
} else {
None
}
})
});
let port = first
.get("image_vars")
.and_then(|vars| vars.get("PORT"))
.and_then(|port| {
port.as_u64()
.or_else(|| port.as_str().and_then(|text| text.parse::<u64>().ok()))
})
.and_then(|port| u16::try_from(port).ok());
let dockerfile = first
.get("image")
.and_then(Value::as_str)
.filter(|image| image.starts_with("./") || image.starts_with("../") || !image.contains('/'))
.map(str::to_string)
.or_else(|| Some("Dockerfile".to_string()));
Some(WorkerContainerConfig {
class_name: Some(class_name),
binding,
dockerfile,
port,
application_id: None,
healthcheck_path: Some("/health".to_string()),
required_secrets: Vec::new(),
version_var: None,
instance_name_template: None,
default_rollout: Some("immediate".to_string()),
health_urls: Vec::new(),
expected_image_name: None,
image_tag_keep_count: None,
allow_unchanged_container_image: false,
image: None,
build_context: None,
instance_type: None,
max_instances: None,
rollout_active_grace_period: None,
rollout_step_percentage: Vec::new(),
regions: Vec::new(),
})
}
fn resolve_project_root(root_override: Option<&Path>) -> Result<PathBuf, String> {
use crate::utils::find_repository_xbp_config;
let current_dir =
env::current_dir().map_err(|error| format!("Failed to read current directory: {error}"))?;
let start = match root_override {
Some(path) => crate::commands::workers::project::resolve_xbp_start_path(path, ¤t_dir),
None => current_dir.clone(),
};
find_repository_xbp_config(&start)
.or_else(|| find_xbp_config_upwards(&start))
.map(|found| found.project_root)
.ok_or_else(|| {
format!(
"Could not find .xbp/xbp.yaml from {}. \
Use `--root .` (monorepo) + `--app athena-auth`, or a relative path like \
`--root services/athena-auth` (avoid a leading `/` unless it is a real absolute path).",
start.display()
)
})
}
fn resolve_worker_root(
root_override: Option<&Path>,
app_override: Option<&str>,
) -> Result<PathBuf, String> {
let current_dir =
env::current_dir().map_err(|error| format!("Failed to read current directory: {error}"))?;
resolve_worker_targets(¤t_dir, root_override, app_override, false)?
.selected
.first()
.map(|app| app.root.clone())
.ok_or_else(|| "No Worker app was selected.".to_string())
}
fn resolve_wrangler_config_file(worker_root: &Path) -> Result<PathBuf, String> {
for name in ["wrangler.jsonc", "wrangler.json", "wrangler.toml"] {
let path = worker_root.join(name);
if path.exists() {
return Ok(path);
}
}
Err(format!(
"No Wrangler config found under {}. Expected wrangler.jsonc, wrangler.json, or wrangler.toml.",
worker_root.display()
))
}
#[derive(Debug, Default)]
struct LocalDiagnostics {
missing_required: Vec<String>,
warnings: Vec<String>,
}
fn collect_local_diagnostics(app: &ResolvedCloudflareApp) -> Result<LocalDiagnostics, String> {
let mut diagnostics = LocalDiagnostics::default();
let local_env = load_worker_env(&app.worker_root)?;
let wrangler_config = load_wrangler_config_value(&app.wrangler_config)?;
let require_local_secrets = app
.worker
.deploy
.as_ref()
.and_then(|d| d.require_local_secrets)
.unwrap_or(false);
validate_required_wrangler_secrets(
app,
&wrangler_config,
&local_env,
require_local_secrets,
&mut diagnostics,
);
if crate::commands::workers::open_next_deploy::is_opennext_worker(&app.worker_root) {
let opts =
crate::commands::workers::open_next_deploy::OpenNextDeployOptions::from_worker_deploy(
app.worker.deploy.as_ref(),
);
let missing = crate::commands::workers::open_next_deploy::missing_build_required_env(
&app.worker_root,
&opts,
&local_env,
);
for key in missing {
diagnostics.missing_required.push(format!(
"Required for build: `{key}` is missing locally (.env.local / .dev.vars / process env). Fix with `xbp secrets pull --key {key}` or export it before deploy."
));
}
}
audit_wrangler_vars_for_secrets(&wrangler_config, &mut diagnostics);
validate_wrangler_entry_point(app, &wrangler_config, &mut diagnostics);
let Some(container) = app.container.as_ref() else {
return Ok(diagnostics);
};
if container.class_name.as_deref().is_none_or(str::is_empty) {
diagnostics
.missing_required
.push("workers[].container.class_name is required".to_string());
}
if container.binding.as_deref().is_none_or(str::is_empty) {
diagnostics
.missing_required
.push("workers[].container.binding is required".to_string());
}
if container.port.is_none() {
diagnostics
.missing_required
.push("workers[].container.port is required".to_string());
}
let dockerfile = resolve_dockerfile_path(app);
if !dockerfile.exists() {
diagnostics
.missing_required
.push(format!("Dockerfile not found: {}", dockerfile.display()));
}
for secret_name in &container.required_secrets {
let present = local_env
.get(secret_name)
.is_some_and(|value| !value.trim().is_empty())
|| env::var(secret_name).is_ok_and(|value| !value.trim().is_empty());
if !present {
diagnostics.missing_required.push(format!(
"Required secret `{secret_name}` is missing locally"
));
}
}
validate_wrangler_container_shape(app, &wrangler_config, &mut diagnostics);
validate_port_alignment(app, &wrangler_config, &mut diagnostics);
Ok(diagnostics)
}
fn validate_required_wrangler_secrets(
app: &ResolvedCloudflareApp,
config: &Value,
local_env: &HashMap<String, String>,
require_local_secrets: bool,
diagnostics: &mut LocalDiagnostics,
) {
let Some(required) = config
.get("secrets")
.and_then(|secrets| secrets.get("required"))
.and_then(Value::as_array)
else {
return;
};
for secret_name in required.iter().filter_map(Value::as_str) {
let present = local_env
.get(secret_name)
.is_some_and(|value| !value.trim().is_empty())
|| env::var(secret_name).is_ok_and(|value| !value.trim().is_empty());
if present {
continue;
}
let build_required =
crate::commands::workers::open_next_deploy::is_build_required_secret_name(
&app.worker_root,
secret_name,
);
if build_required || require_local_secrets {
diagnostics.missing_required.push(format!(
"Required for build: `{secret_name}` is missing locally (.env.local / .dev.vars). Run `xbp secrets pull --key {secret_name}` or export it before deploy."
));
} else {
diagnostics.warnings.push(format!(
"Wrangler required secret `{secret_name}` is missing locally (.env.local / .dev.vars). Deploy still proceeds if it is already set on the Worker (`wrangler secret put {secret_name}`)."
));
}
}
}
fn audit_wrangler_vars_for_secrets(config: &Value, diagnostics: &mut LocalDiagnostics) {
let Some(vars) = config.get("vars").and_then(Value::as_object) else {
return;
};
for key in vars.keys() {
if crate::utils::secret_like_key(key) {
diagnostics.warnings.push(format!(
"wrangler `vars` contains secret-looking key `{key}`. Move real secrets to `wrangler secret put {key}` and keep only documented dummy/local values in .env.local/.env.example."
));
}
}
}
fn validate_wrangler_container_shape(
app: &ResolvedCloudflareApp,
config: &Value,
diagnostics: &mut LocalDiagnostics,
) {
let Some(container) = app.container.as_ref() else {
return;
};
let class_name = container.class_name.as_deref().unwrap_or_default();
let binding = container.binding.as_deref().unwrap_or_default();
let has_container = config
.get("containers")
.and_then(Value::as_array)
.is_some_and(|containers| {
containers
.iter()
.any(|item| item.get("class_name").and_then(Value::as_str) == Some(class_name))
});
if !class_name.is_empty() && !has_container {
diagnostics.missing_required.push(format!(
"wrangler config `containers[]` does not include class_name `{class_name}`"
));
}
let has_do_binding = config
.get("durable_objects")
.and_then(|value| value.get("bindings"))
.and_then(Value::as_array)
.is_some_and(|bindings| {
bindings.iter().any(|item| {
item.get("name").and_then(Value::as_str) == Some(binding)
&& item.get("class_name").and_then(Value::as_str) == Some(class_name)
})
});
if !binding.is_empty() && !class_name.is_empty() && !has_do_binding {
diagnostics.missing_required.push(format!(
"wrangler config `durable_objects.bindings[]` does not map `{binding}` to `{class_name}`"
));
}
}
fn validate_port_alignment(
app: &ResolvedCloudflareApp,
config: &Value,
diagnostics: &mut LocalDiagnostics,
) {
let Some(app_container) = app.container.as_ref() else {
return;
};
let Some(expected_port) = app_container.port else {
return;
};
let Some(containers) = config.get("containers").and_then(Value::as_array) else {
return;
};
for container in containers {
if container.get("class_name").and_then(Value::as_str)
!= app_container.class_name.as_deref()
{
continue;
}
let configured_port = container
.get("image_vars")
.and_then(|value| value.get("PORT"))
.and_then(|value| {
value
.as_str()
.and_then(|text| text.parse::<u16>().ok())
.or_else(|| value.as_u64().and_then(|value| u16::try_from(value).ok()))
})
.or_else(|| {
container
.get("port")
.and_then(Value::as_u64)
.and_then(|value| u16::try_from(value).ok())
});
if let Some(configured_port) = configured_port {
if configured_port != expected_port {
diagnostics.warnings.push(format!(
"container port mismatch: xbp.yaml has {expected_port}, wrangler config has {configured_port}"
));
}
}
}
}
fn load_worker_env(worker_root: &Path) -> Result<HashMap<String, String>, String> {
let mut vars = HashMap::new();
for name in [".env", ".env.local", ".dev.vars"] {
let path = worker_root.join(name);
if !path.exists() {
continue;
}
vars.extend(parse_multiline_env_file(&path)?);
}
Ok(vars)
}
fn load_wrangler_config_value(path: &Path) -> Result<Value, String> {
let content = fs::read_to_string(path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
match path.extension().and_then(|value| value.to_str()) {
Some("toml") => {
let value: toml::Value = toml::from_str(&content)
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))?;
serde_json::to_value(value)
.map_err(|error| format!("Failed to normalize {}: {error}", path.display()))
}
_ => {
let stripped = strip_json_comments(&content);
let normalized = strip_jsonc_trailing_commas(&stripped);
serde_json::from_str(&normalized)
.map_err(|error| format!("Failed to parse {} as JSONC: {error}", path.display()))
}
}
}
fn strip_json_comments(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut in_string = false;
let mut escaped = false;
while let Some(ch) = chars.next() {
if in_string {
output.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
output.push(ch);
continue;
}
if ch == '/' {
match chars.peek().copied() {
Some('/') => {
let _ = chars.next();
for next in chars.by_ref() {
if next == '\n' {
output.push('\n');
break;
}
}
continue;
}
Some('*') => {
let _ = chars.next();
let mut last = '\0';
for next in chars.by_ref() {
if last == '*' && next == '/' {
break;
}
last = next;
}
continue;
}
_ => {}
}
}
output.push(ch);
}
output
}
fn strip_jsonc_trailing_commas(input: &str) -> String {
let mut output = String::with_capacity(input.len());
let chars = input.chars().collect::<Vec<_>>();
let mut in_string = false;
let mut escaped = false;
for (index, ch) in chars.iter().copied().enumerate() {
if in_string {
output.push(ch);
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_string = false;
}
continue;
}
if ch == '"' {
in_string = true;
output.push(ch);
continue;
}
if ch == ',' {
let next = chars[index + 1..]
.iter()
.copied()
.find(|next| !next.is_whitespace());
if matches!(next, Some('}' | ']')) {
continue;
}
}
output.push(ch);
}
output
}
fn wrangler_config_args(app: &ResolvedCloudflareApp) -> Vec<String> {
vec![
"--config".to_string(),
wrangler::render_path_arg(&app.worker_root, &app.wrangler_config),
]
}
fn wrangler_check_args(app: &ResolvedCloudflareApp) -> Vec<String> {
let mut args = vec!["check".to_string()];
args.extend(wrangler_config_args(app));
args
}
fn wrangler_deploy_args(
app: &ResolvedCloudflareApp,
rollout: Option<&str>,
dry_run: bool,
) -> Vec<String> {
let mut args = vec!["deploy".to_string()];
args.extend(wrangler_config_args(app));
if dry_run {
args.push("--dry-run".to_string());
} else {
let effective = rollout
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.or_else(|| {
app.container
.as_ref()
.map(|_| "immediate".to_string())
});
if let Some(rollout) = effective {
args.push("--containers-rollout".to_string());
args.push(rollout);
}
}
args
}
fn wrangler_types_args(app: &ResolvedCloudflareApp) -> Vec<String> {
let output = if app.worker_root.join("worker").is_dir() {
app.worker_root
.join("worker")
.join("worker-configuration.d.ts")
} else {
app.worker_root.join("worker-configuration.d.ts")
};
let mut args = vec![
"types".to_string(),
wrangler::render_path_arg(&app.worker_root, &output),
];
args.extend(wrangler_config_args(app));
args
}
fn run_wrangler_checked(
app: &ResolvedCloudflareApp,
args: Vec<String>,
label: &str,
) -> Result<WranglerOutput, String> {
let output = wrangler::run_wrangler_capture(&app.worker_root, &args)?;
if output.exit_code != 0 {
doctor_err(&format!("{label} failed"));
let detail = mask_secrets(&wrangler::format_wrangler_failure(&args, &output));
let summary = print_wrangler_failure_summary(&detail);
return Err(summary);
}
doctor_ok(label);
Ok(output)
}
fn print_wrangler_failure_summary(detail: &str) -> String {
let plain = strip_ansi_codes(detail);
let mut headlines: Vec<String> = Vec::new();
for line in plain.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let lower = trimmed.to_ascii_lowercase();
let is_signal = lower.contains("could not resolve")
|| lower.contains("build failed with")
|| lower.contains("missing entry-point")
|| lower.contains("missing entry point")
|| (lower.contains("error") && lower.contains("could not"))
|| trimmed.starts_with("X [ERROR]")
|| trimmed.starts_with("✘");
if !is_signal {
continue;
}
if lower.contains("alias") && lower.contains("wrangler configuration") {
continue;
}
if lower.contains("for more guidance see") {
continue;
}
doctor_err(trimmed);
if headlines.len() < 5 {
headlines.push(trimmed.to_string());
}
}
let is_tanstack = plain.to_ascii_lowercase().contains("tanstack")
|| plain.contains("#tanstack-")
|| plain.contains("tanstack-start-");
if is_tanstack {
doctor_kv(
"hint",
"Do not add wrangler aliases — run a Vite build, then dry-run dist/server/wrangler.json",
);
doctor_kv("fix", "pnpm --dir apps/web run build");
doctor_kv("then", "xbp cloudflare doctor --app web");
}
if headlines.is_empty() {
if is_tanstack {
return "wrangler deploy failed: TanStack Start virtual modules are unresolved. Build with Vite first (dist/server/wrangler.json), do not add wrangler aliases.".to_string();
}
return "wrangler command failed (see summary above).".to_string();
}
let mut message = format!("wrangler failed: {}", headlines.join("; "));
if is_tanstack {
message.push_str(
" — fix: pnpm build → wrangler -c dist/server/wrangler.json (not source wrangler.jsonc aliases)",
);
}
message
}
fn strip_ansi_codes(input: &str) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\u{1b}' {
if chars.peek() == Some(&'[') {
chars.next();
for next in chars.by_ref() {
if next.is_ascii_alphabetic() {
break;
}
}
}
continue;
}
out.push(ch);
}
out
}
fn run_wrangler_streaming(
app: &ResolvedCloudflareApp,
args: Vec<String>,
label: &str,
) -> Result<(), String> {
println!("{label}: running");
wrangler::run_wrangler_stream(&app.worker_root, &args, |line| {
println!("{}", mask_secrets(line));
Ok(())
})
}
fn regenerate_worker_types(app: &ResolvedCloudflareApp) -> Result<(), String> {
run_wrangler_streaming(app, wrangler_types_args(app), "wrangler types")
}
fn run_project_build_if_present(app: &ResolvedCloudflareApp) -> Result<(), String> {
let package_json = app.worker_root.join("package.json");
if !package_json.exists() {
return Ok(());
}
let content = fs::read_to_string(&package_json)
.map_err(|error| format!("Failed to read {}: {error}", package_json.display()))?;
let value: Value = serde_json::from_str(&content)
.map_err(|error| format!("Failed to parse {}: {error}", package_json.display()))?;
let scripts = value.get("scripts").and_then(Value::as_object);
let script = scripts.and_then(|scripts| {
if scripts.contains_key("build:worker") {
Some("build:worker")
} else if scripts.contains_key("build") {
Some("build")
} else {
None
}
});
let Some(script) = script else {
return Ok(());
};
let cmd = crate::utils::suggested_build_command(&app.worker_root, script);
println!("build: {cmd}");
crate::utils::run_package_json_script(&app.worker_root, script)
}
#[derive(Debug, Clone)]
struct ContainerVerificationContext {
before_info: Option<ContainerAppInfo>,
expected_version: Option<String>,
skip_deploy: bool,
allow_unchanged_image: bool,
prune_old_images: bool,
keep_image_tag_count: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ContainerAppInfo {
image: Option<String>,
image_name: Option<String>,
image_tag: Option<String>,
app_version: Option<i64>,
instance_type: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ContainerInstance {
name: String,
state: String,
version: Option<i64>,
}
async fn verify_container_state(
app: &ResolvedCloudflareApp,
context: ContainerVerificationContext,
) -> Result<(), String> {
let after_info: Option<ContainerAppInfo> = read_container_app_info(app)?;
if let Some(after_info) = after_info.as_ref() {
verify_container_app_info(app, context.before_info.as_ref(), after_info, &context)?;
let instances: Vec<ContainerInstance> = read_container_instances(app)?;
verify_container_instances(app, after_info, &instances, &context.expected_version)?;
report_or_prune_old_images(app, after_info, &context)?;
} else {
println!("Container app verification skipped: no application_id configured.");
}
verify_health(app, context.expected_version.as_deref()).await?;
Ok(())
}
fn build_container_image_local(
app: &ResolvedCloudflareApp,
tag_override: Option<&str>,
extra_build_args: &[String],
no_cache: bool,
env_file: Option<&Path>,
) -> Result<String, String> {
if !crate::utils::command_exists("docker") {
return Err(
"docker CLI not found — install Docker Desktop to build Cloudflare container images locally"
.into(),
);
}
let container = app.require_container()?;
let (dockerfile, context) = resolve_container_build_paths(app, container)?;
if !dockerfile.is_file() {
return Err(format!(
"container Dockerfile not found: {} (worker root {})",
dockerfile.display(),
app.worker_root.display()
));
}
if !context.is_dir() {
return Err(format!(
"container build context is not a directory: {}",
context.display()
));
}
let tag = tag_override
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(|| {
let base = container
.expected_image_name
.as_deref()
.filter(|s| !s.trim().is_empty())
.unwrap_or(app.app_name.as_str());
format!("{base}:local")
});
let mut args: Vec<String> = vec![
"build".into(),
"-t".into(),
tag.clone(),
"-f".into(),
dockerfile.display().to_string(),
"--progress=plain".into(),
];
if no_cache {
args.push("--no-cache".into());
}
for ba in extra_build_args {
let ba = ba.trim();
if ba.is_empty() {
continue;
}
args.push("--build-arg".into());
args.push(ba.to_string());
}
if let Some(env_path) = env_file {
let map = xbp_deploy::parse_dotenv_file(env_path)
.map_err(|e| format!("read {}: {e}", env_path.display()))?;
for (k, v) in map {
if v.trim().is_empty() || (v.starts_with("${") && v.ends_with('}')) {
continue;
}
args.push("--build-arg".into());
args.push(format!("{k}={v}"));
}
}
args.push(context.display().to_string());
println!(
"{} docker build -t {tag} -f {} {}",
"→".cyan(),
dockerfile.display(),
context.display()
);
let status = std::process::Command::new("docker")
.args(&args)
.current_dir(&app.worker_root)
.status()
.map_err(|e| format!("failed to start docker: {e}"))?;
if !status.success() {
return Err(format!(
"docker build failed for `{tag}` (exit {:?}). Fix Dockerfile/context, then retry.",
status.code()
));
}
Ok(tag)
}
fn resolve_container_build_paths(
app: &ResolvedCloudflareApp,
container: &WorkerContainerConfig,
) -> Result<(PathBuf, PathBuf), String> {
let context = container
.build_context
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|c| {
let p = PathBuf::from(c);
if p.is_absolute() {
p
} else {
app.worker_root.join(p)
}
})
.unwrap_or_else(|| app.worker_root.clone());
let dockerfile_rel = container
.dockerfile
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| {
container
.image
.as_deref()
.map(str::trim)
.filter(|s| {
!s.is_empty()
&& (s.ends_with("Dockerfile")
|| s.contains("Dockerfile")
|| s.starts_with("./")
|| s.starts_with("../"))
})
.map(|s| s.trim_start_matches("./").to_string())
})
.or_else(|| {
for name in ["wrangler.jsonc", "wrangler.json", "wrangler.toml"] {
let path = app.worker_root.join(name);
if !path.is_file() {
continue;
}
if let Ok(cfg) = load_wrangler_config_value(&path) {
if let Some(img) = cfg
.get("containers")
.and_then(|c| c.as_array())
.and_then(|a| a.first())
.and_then(|c| c.get("image"))
.and_then(|v| v.as_str())
{
let s = img.trim();
if !s.is_empty()
&& (s.contains("Dockerfile")
|| s.starts_with("./")
|| s.starts_with("../")
|| !s.contains('/'))
{
return Some(s.trim_start_matches("./").to_string());
}
}
}
}
None
})
.unwrap_or_else(|| "Dockerfile".to_string());
let dockerfile = {
let p = PathBuf::from(&dockerfile_rel);
if p.is_absolute() {
p
} else {
let in_ctx = context.join(&p);
if in_ctx.is_file() {
in_ctx
} else {
app.worker_root.join(&p)
}
}
};
Ok((dockerfile, context))
}
fn read_container_app_info(
app: &ResolvedCloudflareApp,
) -> Result<Option<ContainerAppInfo>, String> {
let Some(application_id) = app
.container
.as_ref()
.and_then(|container| container.application_id.as_deref())
else {
return Ok(None);
};
let mut args: Vec<String> = vec![
"containers".to_string(),
"info".to_string(),
application_id.to_string(),
];
args.extend(wrangler_config_args(app));
let output: WranglerOutput = run_wrangler_checked(app, args, "wrangler containers info")?;
let value: Value = serde_json::from_str(output.stdout.trim()).map_err(|error| {
format!(
"wrangler containers info did not return JSON that XBP can verify: {error}. Upgrade Wrangler or run `wrangler containers info {application_id}` and confirm it emits JSON."
)
})?;
Ok(Some(parse_container_app_info(&value)))
}
fn read_container_instances(app: &ResolvedCloudflareApp) -> Result<Vec<ContainerInstance>, String> {
let application_id: String = resolve_application_id(app, None)?;
let mut args: Vec<String> = vec![
"containers".to_string(),
"instances".to_string(),
application_id,
];
args.extend(wrangler_config_args(app));
args.extend([
"--per-page".to_string(),
"100".to_string(),
"--json".to_string(),
]);
let output: WranglerOutput =
run_wrangler_checked(app, args, "wrangler containers instances --json")?;
let value: Value = serde_json::from_str(output.stdout.trim()).map_err(|error| {
format!("wrangler containers instances --json returned invalid JSON: {error}")
})?;
parse_container_instances(&value)
}
fn verify_container_app_info(
app: &ResolvedCloudflareApp,
before_info: Option<&ContainerAppInfo>,
after_info: &ContainerAppInfo,
context: &ContainerVerificationContext,
) -> Result<(), String> {
let container: &WorkerContainerConfig = app.require_container()?;
if let Some(expected_image_name) = container.expected_image_name.as_deref() {
let actual_matches: bool = after_info.image_name.as_deref() == Some(expected_image_name)
|| after_info.image.as_deref().is_some_and(|image| {
image == expected_image_name
|| image.starts_with(&format!("{expected_image_name}:"))
|| image.contains(&format!("/{expected_image_name}:"))
});
if !actual_matches {
return Err(format!(
"Container image mismatch: expected image name `{expected_image_name}`, observed `{}`.",
after_info.image.as_deref().unwrap_or("<missing>")
));
}
}
let allow_unchanged: bool =
context.allow_unchanged_image || container.allow_unchanged_container_image;
if !context.skip_deploy {
if let (Some(before), Some(after)) = (
before_info.and_then(|info| info.image.as_deref()),
after_info.image.as_deref(),
) {
if before == after {
if allow_unchanged {
println!(
" · container image unchanged (`{after}`) — treating as config-only deploy (pass --require-new-container-image to fail)"
);
} else {
return Err(format!(
"Container image did not change after deploy (`{after}`). Re-build the container, or omit --require-new-container-image for config-only deploys (default)."
));
}
}
}
if let (Some(before), Some(after)) = (
before_info.and_then(|info| info.app_version),
after_info.app_version,
) {
if after <= before {
if allow_unchanged {
println!(
" · container app version did not advance (before {before}, after {after}) — allowed for config-only deploy"
);
} else {
return Err(format!(
"Container app version did not advance after deploy (before {before}, after {after})."
));
}
}
}
}
if let Some(expected_instance_type) = expected_instance_type(app)? {
match after_info.instance_type.as_deref() {
None => {
println!(
" ! containers info omitted instance type (expected `{expected_instance_type}`) — skipping type check"
);
}
Some(actual_instance_type) if actual_instance_type != expected_instance_type => {
return Err(format!(
"Container instance type mismatch: expected `{expected_instance_type}`, observed `{actual_instance_type}`."
));
}
Some(_) => {}
}
}
println!("Container app verification: ok");
Ok(())
}
fn verify_container_instances(
app: &ResolvedCloudflareApp,
info: &ContainerAppInfo,
instances: &[ContainerInstance],
expected_version: &Option<String>,
) -> Result<(), String> {
let container: &WorkerContainerConfig = app.require_container()?;
let expected_name: Option<String> = container
.instance_name_template
.as_deref()
.zip(expected_version.as_deref())
.map(|(template, version)| render_instance_name_template(template, version));
if let Some(expected_name) = expected_name.as_deref() {
let has_expected = instances
.iter()
.any(|instance| instance.name == expected_name && instance_is_active(instance));
if !has_expected {
return Err(format!(
"Expected active container instance `{expected_name}` was not found."
));
}
}
let prefix: Option<String> = container
.instance_name_template
.as_deref()
.and_then(instance_name_template_prefix);
for instance in instances {
let matches_release_instance = prefix
.as_deref()
.is_some_and(|prefix| instance.name.starts_with(prefix));
if !matches_release_instance {
continue;
}
let is_expected: bool = expected_name
.as_deref()
.is_some_and(|expected| instance.name == expected);
if is_expected {
continue;
}
if instance_is_active(instance) {
if instance.version == info.app_version {
println!(
"warning: active stale-looking instance `{}` has current app version {:?}",
instance.name, instance.version
);
} else {
return Err(format!(
"Stale active container instance `{}` has version {:?}; expected active version {:?}.",
instance.name, instance.version, info.app_version
));
}
} else {
println!(
"info: inactive stale container instance `{}` in state `{}`",
instance.name, instance.state
);
}
}
println!("Container instance verification: ok");
Ok(())
}
async fn verify_health(
app: &ResolvedCloudflareApp,
expected_version: Option<&str>,
) -> Result<(), String> {
let urls: Vec<String> = resolve_health_urls(app)?;
if urls.is_empty() {
println!("Live health verification skipped: no health URL could be inferred.");
return Ok(());
}
let client: reqwest::Client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(20))
.build()
.map_err(|error| format!("Failed to build HTTP client: {error}"))?;
for url in urls {
let response: reqwest::Response = client
.get(&url)
.send()
.await
.map_err(|error| format!("Health check failed for {url}: {error}"))?;
let status: reqwest::StatusCode = response.status();
let text: String = response
.text()
.await
.map_err(|error| format!("Failed to read health response from {url}: {error}"))?;
if !status.is_success() {
return Err(format!("Health check failed for {url}: HTTP {status}"));
}
if let Some(expected_version) = expected_version {
let value: Value = serde_json::from_str(&text)
.map_err(|error| format!("Health check {url} did not return JSON: {error}"))?;
let observed: &str = observed_health_version(&value).ok_or_else(|| {
format!("Health check {url} JSON did not include a string `version` field.")
})?;
if observed != expected_version {
return Err(format!(
"Health check {url} reported version `{observed}`, expected `{expected_version}`."
));
}
}
println!("Health check: {url} ok");
}
Ok(())
}
fn observed_health_version(value: &Value) -> Option<&str> {
value
.get("version")
.and_then(Value::as_str)
.or_else(|| value.get("appVersion").and_then(Value::as_str))
}
fn resolve_health_urls(app: &ResolvedCloudflareApp) -> Result<Vec<String>, String> {
let configured: Vec<String> = app
.container
.as_ref()
.map(|container| {
container
.health_urls
.iter()
.map(|url| url.trim())
.filter(|url| !url.is_empty())
.map(ToOwned::to_owned)
.collect::<Vec<_>>()
})
.unwrap_or_default();
if !configured.is_empty() {
return Ok(configured);
}
Ok(resolve_inferred_health_url(app)?.into_iter().collect())
}
fn resolve_inferred_health_url(app: &ResolvedCloudflareApp) -> Result<Option<String>, String> {
let path: String = app
.container
.as_ref()
.and_then(|container| container.healthcheck_path.as_deref())
.map(normalize_health_path)
.unwrap_or_else(|| "/health".to_string());
let config: Value = load_wrangler_config_value(&app.wrangler_config)?;
if let Some(pattern) = config
.get("routes")
.and_then(Value::as_array)
.and_then(|routes| routes.first())
.and_then(|route| {
route
.get("pattern")
.and_then(Value::as_str)
.or_else(|| route.as_str())
})
{
let host: &str = pattern
.trim()
.trim_start_matches("https://")
.trim_start_matches("http://")
.trim_end_matches("/*")
.trim_end_matches('/');
if !host.is_empty() {
return Ok(Some(format!("https://{host}{path}")));
}
}
if config
.get("workers_dev")
.and_then(Value::as_bool)
.unwrap_or(true)
{
let worker_name: &str = config
.get("name")
.and_then(Value::as_str)
.unwrap_or(&app.worker.name);
return Ok(Some(format!("https://{worker_name}.workers.dev{path}")));
}
Ok(None)
}
fn parse_container_app_info(value: &Value) -> ContainerAppInfo {
let image: Option<String> = find_string_key(value, &["image", "current_image", "image_ref"]);
let image_name: Option<String> = find_string_key(value, &["image_name", "imageName"]);
let image_tag: Option<String> = image
.as_deref()
.and_then(|image| image.rsplit_once(':').map(|(_, tag)| tag.to_string()))
.or_else(|| find_string_key(value, &["image_tag", "tag"]));
ContainerAppInfo {
image,
image_name,
image_tag,
app_version: find_i64_key(value, &["version", "app_version"]),
instance_type: find_string_key(value, &["instance_type", "instanceType"])
.or_else(|| instance_type_from_resource_shape(value)),
}
}
fn parse_container_instances(value: &Value) -> Result<Vec<ContainerInstance>, String> {
let array: &Vec<Value> = value
.as_array()
.or_else(|| value.get("instances").and_then(Value::as_array))
.or_else(|| value.get("items").and_then(Value::as_array))
.or_else(|| value.get("result").and_then(Value::as_array))
.or_else(|| value.get("value").and_then(Value::as_array))
.ok_or_else(|| "wrangler containers instances JSON was not an array.".to_string())?;
Ok(array
.iter()
.filter_map(|item| {
let name = find_string_key(item, &["name", "id"])?;
let state = find_string_key(item, &["state", "status"]).unwrap_or_default();
Some(ContainerInstance {
name,
state,
version: find_i64_key(item, &["version"]),
})
})
.collect())
}
fn expected_instance_type(app: &ResolvedCloudflareApp) -> Result<Option<String>, String> {
let config: Value = load_wrangler_config_value(&app.wrangler_config)?;
let Some(container) = find_wrangler_container(app, &config) else {
return Ok(None);
};
if let Some(instance_type) = container.get("instance_type").and_then(Value::as_str) {
return Ok(Some(instance_type.to_string()));
}
Ok(instance_type_from_resource_shape(container))
}
fn find_wrangler_container<'a>(
app: &ResolvedCloudflareApp,
config: &'a Value,
) -> Option<&'a Value> {
let class_name: &str = app.container.as_ref()?.class_name.as_deref()?;
config
.get("containers")
.and_then(Value::as_array)?
.iter()
.find(|container| container.get("class_name").and_then(Value::as_str) == Some(class_name))
}
fn instance_type_from_resource_shape(value: &Value) -> Option<String> {
let vcpu: Option<f64> = find_f64_key(value, &["vcpu", "vcpu_count", "cpu"]);
let memory_mib: Option<i64> = find_i64_key(value, &["memory_mib", "memoryMiB", "memory"]);
let disk_mb: Option<i64> = find_i64_key(value, &["disk_mb", "diskMB"]).or_else(|| {
value
.get("disk")
.and_then(|disk| find_i64_key(disk, &["size_mb", "sizeMB"]))
});
match (vcpu, memory_mib, disk_mb) {
(Some(vcpu), Some(memory), Some(disk)) => Some(match (vcpu, memory, disk) {
(vcpu, 256, 2000) if approx_eq(vcpu, 0.0625) => "lite".to_string(),
(vcpu, 1024, 4000) if approx_eq(vcpu, 0.25) => "basic".to_string(),
(vcpu, 4096, 8000) if approx_eq(vcpu, 0.5) => "standard-1".to_string(),
(vcpu, 6144, 12000) if approx_eq(vcpu, 1.0) => "standard-2".to_string(),
(vcpu, 8192, 16000) if approx_eq(vcpu, 2.0) => "standard-3".to_string(),
(vcpu, 12288, 20000) if approx_eq(vcpu, 4.0) => "standard-4".to_string(),
_ => format!("custom(vcpu={vcpu},memory_mib={memory},disk_mb={disk})"),
}),
_ => None,
}
}
fn approx_eq(left: f64, right: f64) -> bool {
(left - right).abs() < f64::EPSILON
}
fn find_string_key(value: &Value, keys: &[&str]) -> Option<String> {
match value {
Value::Object(map) => {
for key in keys {
if let Some(text) = map.get(*key).and_then(Value::as_str) {
return Some(text.to_string());
}
}
map.values().find_map(|item| find_string_key(item, keys))
}
Value::Array(items) => items.iter().find_map(|item| find_string_key(item, keys)),
_ => None,
}
}
fn find_i64_key(value: &Value, keys: &[&str]) -> Option<i64> {
match value {
Value::Object(map) => {
for key in keys {
if let Some(number) = map.get(*key).and_then(Value::as_i64) {
return Some(number);
}
if let Some(number) = map
.get(*key)
.and_then(Value::as_str)
.and_then(|text| text.parse::<i64>().ok())
{
return Some(number);
}
}
map.values().find_map(|item| find_i64_key(item, keys))
}
Value::Array(items) => items.iter().find_map(|item| find_i64_key(item, keys)),
_ => None,
}
}
fn find_f64_key(value: &Value, keys: &[&str]) -> Option<f64> {
match value {
Value::Object(map) => {
for key in keys {
if let Some(number) = map.get(*key).and_then(Value::as_f64) {
return Some(number);
}
if let Some(number) = map
.get(*key)
.and_then(Value::as_str)
.and_then(|text| text.parse::<f64>().ok())
{
return Some(number);
}
}
map.values().find_map(|item| find_f64_key(item, keys))
}
Value::Array(items) => items.iter().find_map(|item| find_f64_key(item, keys)),
_ => None,
}
}
fn render_instance_name_template(template: &str, version: &str) -> String {
let raw_segment = version
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'-'
}
})
.collect::<String>();
let segment: String = raw_segment
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-");
template.replace("${VERSION}", &segment)
}
fn instance_name_template_prefix(template: &str) -> Option<String> {
template
.split_once("${VERSION}")
.map(|(prefix, _)| prefix.to_string())
.filter(|prefix| !prefix.is_empty())
}
fn instance_is_active(instance: &ContainerInstance) -> bool {
!matches!(
instance.state.to_ascii_lowercase().as_str(),
"stopped" | "inactive" | "terminated" | "failed" | "exited"
)
}
fn report_or_prune_old_images(
app: &ResolvedCloudflareApp,
after_info: &ContainerAppInfo,
context: &ContainerVerificationContext,
) -> Result<(), String> {
let Some(container) = app.container.as_ref() else {
return Ok(());
};
let Some(expected_image_name) = container.expected_image_name.as_deref() else {
return Ok(());
};
let Some(active_tag) = after_info.image_tag.as_deref() else {
return Ok(());
};
let keep_count = context
.keep_image_tag_count
.or(container.image_tag_keep_count)
.unwrap_or(20);
let mut args = vec![
"containers".to_string(),
"images".to_string(),
"list".to_string(),
"--filter".to_string(),
format!("^{expected_image_name}$"),
"--json".to_string(),
];
args.extend(wrangler_config_args(app));
let output = run_wrangler_checked(app, args, "wrangler containers images list --json");
let Ok(output) = output else {
println!("warning: image cleanup report skipped; Wrangler could not list images.");
return Ok(());
};
let value: Value = serde_json::from_str(output.stdout.trim()).map_err(|error| {
format!("wrangler containers images list --json returned invalid JSON: {error}")
})?;
let tags = parse_image_tags(&value);
let to_delete = select_image_tags_to_delete(&tags, active_tag, keep_count);
if to_delete.is_empty() {
return Ok(());
}
if !context.prune_old_images {
println!(
"Image cleanup available: {} old tags can be pruned with --prune-old-images.",
to_delete.len()
);
return Ok(());
}
for tag in to_delete {
let mut args = vec![
"containers".to_string(),
"images".to_string(),
"delete".to_string(),
format!("{expected_image_name}:{tag}"),
];
args.extend(wrangler_config_args(app));
run_wrangler_checked(app, args, "wrangler containers images delete")?;
}
Ok(())
}
fn parse_image_tags(value: &Value) -> Vec<String> {
match value {
Value::Array(items) => items
.iter()
.filter_map(|item| {
item.as_str()
.map(ToOwned::to_owned)
.or_else(|| {
item.get("tag")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
})
.or_else(|| {
item.get("name")
.and_then(Value::as_str)
.and_then(|name| name.rsplit_once(':').map(|(_, tag)| tag.to_string()))
})
})
.collect(),
Value::Object(map) => map
.get("images")
.or_else(|| map.get("items"))
.or_else(|| map.get("result"))
.map(parse_image_tags)
.unwrap_or_default(),
_ => Vec::new(),
}
}
fn select_image_tags_to_delete(
tags: &[String],
active_tag: &str,
keep_count: usize,
) -> Vec<String> {
let mut kept = HashSet::from([active_tag.to_string()]);
for tag in tags.iter().take(keep_count) {
kept.insert(tag.clone());
}
tags.iter()
.filter(|tag| !kept.contains(*tag))
.cloned()
.collect()
}
fn sync_version_var(app: &mut ResolvedCloudflareApp, version: &str) -> Result<(), String> {
let version_var: String = app
.require_container()?
.version_var
.as_deref()
.ok_or_else(|| "workers[].container.version_var is required for release.".to_string())?
.to_string();
let version_var: &str = version_var.as_str();
let content = fs::read_to_string(&app.wrangler_config)
.map_err(|error| format!("Failed to read {}: {error}", app.wrangler_config.display()))?;
let pattern: regex::Regex = regex::Regex::new(&format!(
r#""{}"\s*:\s*"[^"]*""#,
regex::escape(version_var)
))
.map_err(|error| error.to_string())?;
if !pattern.is_match(&content) {
return Err(format!(
"Could not find version var `{version_var}` in {}. Add it under `vars` before release.",
app.wrangler_config.display()
));
}
let replacement: String = format!("\"{version_var}\": \"{version}\"");
let updated: String = pattern.replace(&content, replacement).to_string();
fs::write(&app.wrangler_config, updated)
.map_err(|error| format!("Failed to write {}: {error}", app.wrangler_config.display()))?;
println!("Updated {version_var} in {}", app.wrangler_config.display());
Ok(())
}
fn resolve_dockerfile_path(app: &ResolvedCloudflareApp) -> PathBuf {
let dockerfile = app
.container
.as_ref()
.and_then(|container| container.dockerfile.as_deref())
.unwrap_or("Dockerfile");
let path = PathBuf::from(dockerfile);
if path.is_absolute() {
path
} else {
app.worker_root.join(path)
}
}
fn resolve_application_id(
app: &ResolvedCloudflareApp,
override_id: Option<&str>,
) -> Result<String, String> {
override_id
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or_else(|| {
app.container
.as_ref()
.and_then(|container| container.application_id.clone())
})
.ok_or_else(|| {
format!(
"No container application id configured for `{}`. Set workers[].container.application_id or pass --application-id.",
app.app_name
)
})
}
fn normalize_health_path(path: &str) -> String {
let trimmed = path.trim();
if trimmed.is_empty() {
return "/health".to_string();
}
if trimmed.starts_with('/') {
trimmed.to_string()
} else {
format!("/{trimmed}")
}
}
fn mask_secrets(input: &str) -> String {
let mut output = input.to_string();
for (key, value) in env::vars() {
let key_upper = key.to_ascii_uppercase();
let looks_secret = key_upper.contains("SECRET")
|| key_upper.contains("TOKEN")
|| key_upper.contains("PRIVATE_KEY")
|| key_upper.contains("PASSWORD");
if looks_secret && value.len() >= 8 {
output = output.replace(&value, "***");
}
}
output
}
fn validate_payload_for_workflow(payload: &CloudflareWorkflowPayload) -> Result<(), String> {
if payload.workflow == CloudflareJobWorkflow::Release && payload.version.is_none() {
return Err("Release jobs require --version.".to_string());
}
if payload.dry_run && payload.skip_deploy {
return Err("--dry-run and --skip-deploy are mutually exclusive.".to_string());
}
Ok(())
}
fn enqueue_job(
project_root: &Path,
payload: CloudflareWorkflowPayload,
) -> Result<CloudflareJobRecord, String> {
let now = Utc::now();
let job = CloudflareJobRecord {
id: Uuid::new_v4().to_string(),
status: CloudflareJobStatus::Queued,
payload,
created_at: now,
updated_at: now,
started_at: None,
finished_at: None,
error: None,
logs: vec!["job queued".to_string()],
};
write_job(project_root, &job)?;
Ok(job)
}
fn job_dir(project_root: &Path) -> PathBuf {
project_root.join(CLOUDFLARE_JOB_DIR)
}
fn job_path(project_root: &Path, job_id: &str) -> PathBuf {
job_dir(project_root).join(format!("{job_id}.json"))
}
fn write_job(project_root: &Path, job: &CloudflareJobRecord) -> Result<(), String> {
let dir = job_dir(project_root);
fs::create_dir_all(&dir)
.map_err(|error| format!("Failed to create {}: {error}", dir.display()))?;
let path = job_path(project_root, &job.id);
let tmp_path = dir.join(format!("{}.{}.tmp", job.id, Uuid::new_v4()));
let content = format!(
"{}\n",
serde_json::to_string_pretty(job)
.map_err(|error| format!("Failed to encode job {}: {error}", job.id))?
);
fs::write(&tmp_path, content)
.map_err(|error| format!("Failed to write {}: {error}", tmp_path.display()))?;
match fs::rename(&tmp_path, &path) {
Ok(()) => Ok(()),
Err(first_error) => {
if path.exists() {
fs::remove_file(&path).map_err(|error| {
format!(
"Failed to replace {}: {first_error}; remove failed: {error}",
path.display()
)
})?;
fs::rename(&tmp_path, &path)
.map_err(|error| format!("Failed to replace {}: {error}", path.display()))
} else {
Err(format!("Failed to write {}: {first_error}", path.display()))
}
}
}
}
fn read_job(project_root: &Path, job_id: &str) -> Result<CloudflareJobRecord, String> {
let path = job_path(project_root, job_id);
let content = fs::read_to_string(&path)
.map_err(|error| format!("Failed to read {}: {error}", path.display()))?;
serde_json::from_str(&content)
.map_err(|error| format!("Failed to parse {}: {error}", path.display()))
}
fn list_jobs(project_root: &Path) -> Result<Vec<CloudflareJobRecord>, String> {
let dir = job_dir(project_root);
if !dir.exists() {
return Ok(Vec::new());
}
let mut jobs = Vec::new();
for entry in
fs::read_dir(&dir).map_err(|error| format!("Failed to read {}: {error}", dir.display()))?
{
let entry = entry.map_err(|error| format!("Failed to read job entry: {error}"))?;
if entry.path().extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let content = fs::read_to_string(entry.path())
.map_err(|error| format!("Failed to read {}: {error}", entry.path().display()))?;
let job: CloudflareJobRecord = serde_json::from_str(&content)
.map_err(|error| format!("Failed to parse {}: {error}", entry.path().display()))?;
jobs.push(job);
}
jobs.sort_by_key(|job| job.created_at);
Ok(jobs)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let dir = env::temp_dir().join(format!("xbp-cloudflare-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("temp dir");
dir
}
fn test_app(root: &Path) -> ResolvedCloudflareApp {
ResolvedCloudflareApp {
project_root: root.to_path_buf(),
config_path: root.join(".xbp").join("xbp.yaml"),
worker_root: root.join("apps").join("auth"),
app_name: "auth".to_string(),
worker: WorkerConfig {
name: "auth".to_string(),
root: root.join("apps").join("auth").to_string_lossy().to_string(),
script_name: Some("auth-worker".to_string()),
service: None,
deploy: None,
container: None,
containers: None,
durable_objects: None,
},
container: Some(WorkerContainerConfig {
class_name: Some("AuthContainer".to_string()),
binding: Some("AUTH_CONTAINER".to_string()),
dockerfile: Some("Dockerfile".to_string()),
port: Some(8787),
application_id: Some("app_123".to_string()),
healthcheck_path: Some("/health".to_string()),
required_secrets: vec!["AUTH_SECRET".to_string()],
version_var: Some("AUTH_VERSION".to_string()),
instance_name_template: None,
default_rollout: Some("immediate".to_string()),
health_urls: Vec::new(),
expected_image_name: None,
image_tag_keep_count: None,
allow_unchanged_container_image: false,
image: None,
build_context: None,
instance_type: None,
max_instances: None,
rollout_active_grace_period: None,
rollout_step_percentage: Vec::new(),
regions: Vec::new(),
}),
wrangler_config: root.join("apps").join("auth").join("wrangler.jsonc"),
}
}
#[test]
fn strip_json_comments_preserves_urls_inside_strings() {
let input = r#"{
// comment
"url": "https://example.com/a//b",
"value": 1 /* block */
}"#;
let value: Value = serde_json::from_str(&strip_json_comments(input)).expect("json");
assert_eq!(value["url"], "https://example.com/a//b");
assert_eq!(value["value"], 1);
}
#[test]
fn load_wrangler_jsonc_accepts_trailing_commas_and_nested_environments() {
let root: PathBuf = temp_dir("jsonc-environment");
let path: PathBuf = root.join("wrangler.jsonc");
fs::write(
&path,
r#"{
"$schema": "./node_modules/wrangler/config-schema.json",
// Top-level configuration
"name": "my-worker",
"main": "src/index.js",
"compatibility_date": "2026-07-12",
"workers_dev": false,
"route": {
"pattern": "example.org/*",
"zone_name": "example.org",
},
"kv_namespaces": [
{
"binding": "MY_NAMESPACE",
"id": "kv-id",
},
],
"env": {
"staging": {
"name": "my-worker-staging",
"route": {
"pattern": "staging.example.org/*",
"zone_name": "example.org",
},
"kv_namespaces": [
{
"binding": "MY_NAMESPACE",
"id": "staging-kv-id",
},
],
},
},
}"#,
)
.expect("wrangler jsonc");
let config = load_wrangler_config_value(&path).expect("parse JSONC");
assert_eq!(
config["env"]["staging"]["route"]["pattern"],
"staging.example.org/*"
);
assert_eq!(
config["env"]["staging"]["kv_namespaces"][0]["id"],
"staging-kv-id"
);
let _ = fs::remove_dir_all(root);
}
#[test]
fn wrangler_deploy_args_include_rollout_or_dry_run() {
let root = temp_dir("args");
fs::create_dir_all(root.join("apps/auth")).expect("worker dir");
let app = test_app(&root);
let dry = wrangler_deploy_args(&app, Some("gradual"), true);
assert!(dry.contains(&"--dry-run".to_string()));
assert!(!dry.contains(&"--containers-rollout".to_string()));
let live = wrangler_deploy_args(&app, Some("gradual"), false);
assert!(live
.windows(2)
.any(|pair| pair == ["--containers-rollout", "gradual"]));
if app.container.is_some() {
let auto = wrangler_deploy_args(&app, None, false);
assert!(auto
.windows(2)
.any(|pair| pair == ["--containers-rollout", "immediate"]));
}
let _ = fs::remove_dir_all(root);
}
#[test]
fn diagnoses_observability_only_wrangler_as_missing_entry() {
let root = temp_dir("entry-missing");
let worker = root.join(".");
let _ = &worker;
fs::write(
root.join("wrangler.toml"),
r#"[observability]
enabled = false
"#,
)
.expect("wrangler");
let mut app = test_app(&root);
app.worker_root = root.clone();
app.wrangler_config = root.join("wrangler.toml");
app.app_name = "athena".into();
app.container = None;
let config = load_wrangler_config_value(&app.wrangler_config).expect("parse");
let report = diagnose_wrangler_entry_point(&app, &config).expect("report");
assert!(!report.ok, "expected missing entry");
assert!(
report.summary.to_ascii_lowercase().contains("main")
|| report.summary.to_ascii_lowercase().contains("entry"),
"{}",
report.summary
);
assert!(looks_like_missing_entry_point_error(
"X [ERROR] Missing entry-point to Worker script or to assets directory"
));
let _ = fs::remove_dir_all(root);
}
#[test]
fn diagnoses_main_present_as_ok() {
let root = temp_dir("entry-ok");
fs::create_dir_all(root.join("src")).expect("src");
fs::write(root.join("src/index.ts"), "export default {}\n").expect("main");
fs::write(
root.join("wrangler.toml"),
r#"name = "w"
main = "src/index.ts"
compatibility_date = "2024-01-01"
"#,
)
.expect("wrangler");
let mut app = test_app(&root);
app.worker_root = root.clone();
app.wrangler_config = root.join("wrangler.toml");
app.container = None;
let config = load_wrangler_config_value(&app.wrangler_config).expect("parse");
let report = diagnose_wrangler_entry_point(&app, &config).expect("report");
assert!(report.ok, "{:?}", report);
let _ = fs::remove_dir_all(root);
}
#[test]
fn detects_wrangler_missing_assets_directory_error() {
let err = r#"X [ERROR] The directory specified by the "assets.directory" field in your configuration
file does not exist:"#;
assert!(looks_like_missing_assets_directory_error(err));
assert!(!looks_like_missing_entry_point_error(err));
}
#[test]
fn detects_vite_tanstack_worker_without_build_output() {
let root = temp_dir("vite-detect");
let worker = root.join("apps/web");
fs::create_dir_all(worker.join("src")).expect("src");
fs::write(
worker.join("package.json"),
r#"{"dependencies":{"@tanstack/react-start":"1.0.0","@cloudflare/vite-plugin":"1.0.0"}}"#,
)
.expect("package");
fs::write(worker.join("src/entry-worker.ts"), "// entry\n").expect("entry");
assert!(worker_requires_vite_worker_build(&worker));
assert!(built_wrangler_config_path(&worker).is_none());
fs::create_dir_all(worker.join("dist/server")).expect("dist");
fs::write(worker.join("dist/server/wrangler.json"), "{}\n").expect("built config");
assert!(built_wrangler_config_path(&worker).is_some());
assert!(!worker_requires_vite_worker_build(&worker));
let _ = fs::remove_dir_all(root);
}
#[test]
fn diagnostics_detect_missing_secret_without_printing_value() {
let root = temp_dir("diagnostics");
let worker_root = root.join("apps/auth");
fs::create_dir_all(&worker_root).expect("worker dir");
fs::write(worker_root.join("Dockerfile"), "FROM node:22\n").expect("dockerfile");
fs::write(
worker_root.join("wrangler.jsonc"),
r#"{
"name": "auth-worker",
"containers": [{"class_name": "AuthContainer", "image_vars": {"PORT": "8787"}}],
"durable_objects": {"bindings": [{"name": "AUTH_CONTAINER", "class_name": "AuthContainer"}]}
}"#,
)
.expect("wrangler");
let app = test_app(&root);
let diagnostics = collect_local_diagnostics(&app).expect("diagnostics");
assert!(diagnostics
.missing_required
.iter()
.any(|item| item.contains("AUTH_SECRET")));
assert!(!diagnostics
.missing_required
.iter()
.any(|item| item.contains("secret-value")));
let _ = fs::remove_dir_all(root);
}
#[test]
fn local_jobs_round_trip_status_transitions() {
let root = temp_dir("jobs");
let payload = CloudflareWorkflowPayload {
app: Some("auth".to_string()),
token: None,
account_id: None,
workflow: CloudflareJobWorkflow::Deploy,
rollout: CloudflareRollout::Immediate,
version: None,
domain: None,
dry_run: true,
skip_deploy: false,
allow_unchanged_container_image: false,
prune_old_images: false,
keep_image_tag_count: None,
local_build: false,
local_build_only: false,
};
let mut job = enqueue_job(&root, payload).expect("enqueue");
assert_eq!(job.status, CloudflareJobStatus::Queued);
job.status = CloudflareJobStatus::Running;
write_job(&root, &job).expect("write running");
let loaded = read_job(&root, &job.id).expect("read");
assert_eq!(loaded.status, CloudflareJobStatus::Running);
let jobs = list_jobs(&root).expect("list");
assert_eq!(jobs.len(), 1);
let _ = fs::remove_dir_all(root);
}
#[test]
fn fake_container_instances_json_parses() {
let output = json!([
{"id": "abc", "status": "running"},
{"id": "def", "status": "stopped"}
]);
let running = output
.as_array()
.expect("array")
.iter()
.filter(|item| item.get("status").and_then(Value::as_str) == Some("running"))
.count();
assert_eq!(running, 1);
}
#[test]
fn renders_release_derived_instance_name() {
assert_eq!(
render_instance_name_template("athena-auth-${VERSION}", "1.14.2-canary.1"),
"athena-auth-1-14-2-canary-1"
);
}
#[test]
fn maps_named_and_resource_shaped_instance_types() {
let named = json!({"configuration": {"instance_type": "basic"}});
let parsed = parse_container_app_info(&named);
assert_eq!(parsed.instance_type.as_deref(), Some("basic"));
let shaped = json!({"vcpu": 0.25, "memory_mib": 1024, "disk": {"size_mb": 4000}});
assert_eq!(
instance_type_from_resource_shape(&shaped).as_deref(),
Some("basic")
);
}
#[test]
fn detects_unchanged_container_image_without_override() {
let root = temp_dir("unchanged-image");
fs::create_dir_all(root.join("apps/auth")).expect("worker dir");
fs::write(
root.join("apps/auth/wrangler.jsonc"),
r#"{"containers":[{"class_name":"AuthContainer","instance_type":"basic"}]}"#,
)
.expect("wrangler");
let mut app = test_app(&root);
app.container
.as_mut()
.expect("container")
.expected_image_name = Some("demo-auth".to_string());
let before = ContainerAppInfo {
image: Some("registry/demo-auth:old".to_string()),
image_name: None,
image_tag: Some("old".to_string()),
app_version: Some(1),
instance_type: Some("basic".to_string()),
};
let err = verify_container_app_info(
&app,
Some(&before),
&before,
&ContainerVerificationContext {
before_info: None,
expected_version: None,
skip_deploy: false,
allow_unchanged_image: false,
prune_old_images: false,
keep_image_tag_count: None,
},
)
.expect_err("unchanged image should fail");
assert!(err.contains("did not change"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn allows_unchanged_container_image_with_override() {
let root = temp_dir("unchanged-image-allowed");
fs::create_dir_all(root.join("apps/auth")).expect("worker dir");
fs::write(
root.join("apps/auth/wrangler.jsonc"),
r#"{"containers":[{"class_name":"AuthContainer","instance_type":"basic"}]}"#,
)
.expect("wrangler");
let mut app = test_app(&root);
app.container
.as_mut()
.expect("container")
.expected_image_name = Some("demo-auth".to_string());
let before = ContainerAppInfo {
image: Some("registry/demo-auth:old".to_string()),
image_name: None,
image_tag: Some("old".to_string()),
app_version: Some(1),
instance_type: Some("basic".to_string()),
};
verify_container_app_info(
&app,
Some(&before),
&before,
&ContainerVerificationContext {
before_info: None,
expected_version: None,
skip_deploy: false,
allow_unchanged_image: true,
prune_old_images: false,
keep_image_tag_count: None,
},
)
.expect("override should allow unchanged image");
let _ = fs::remove_dir_all(root);
}
#[test]
fn stale_active_wrong_version_instance_fails() {
let root = temp_dir("stale-instance");
let mut app = test_app(&root);
app.container
.as_mut()
.expect("container")
.instance_name_template = Some("auth-${VERSION}".to_string());
let info = ContainerAppInfo {
image: Some("registry/demo-auth:new".to_string()),
image_name: None,
image_tag: Some("new".to_string()),
app_version: Some(5),
instance_type: Some("basic".to_string()),
};
let instances = vec![
ContainerInstance {
name: "auth-1-2-3".to_string(),
state: "running".to_string(),
version: Some(5),
},
ContainerInstance {
name: "auth-1-2-2".to_string(),
state: "running".to_string(),
version: Some(4),
},
];
let err = verify_container_instances(&app, &info, &instances, &Some("1.2.3".to_string()))
.expect_err("wrong-version stale active instance should fail");
assert!(err.contains("Stale active"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn same_version_stale_active_instance_warns_only() {
let root = temp_dir("same-version-stale");
let mut app = test_app(&root);
app.container
.as_mut()
.expect("container")
.instance_name_template = Some("auth-${VERSION}".to_string());
let info = ContainerAppInfo {
image: Some("registry/demo-auth:new".to_string()),
image_name: None,
image_tag: Some("new".to_string()),
app_version: Some(5),
instance_type: Some("basic".to_string()),
};
let instances = vec![
ContainerInstance {
name: "auth-1-2-3".to_string(),
state: "running".to_string(),
version: Some(5),
},
ContainerInstance {
name: "auth-1-2-2".to_string(),
state: "running".to_string(),
version: Some(5),
},
];
verify_container_instances(&app, &info, &instances, &Some("1.2.3".to_string()))
.expect("same-version stale active instance should warn only");
let _ = fs::remove_dir_all(root);
}
#[test]
fn parses_health_json_version_for_multiple_hosts() {
let bodies = [json!({"version": "1.2.3"}), json!({"appVersion": "1.2.3"})];
assert!(bodies
.iter()
.all(|body| observed_health_version(body) == Some("1.2.3")));
}
#[test]
fn selects_old_image_tags_to_prune_with_retention() {
let tags = vec![
"new".to_string(),
"1-2-3".to_string(),
"1-2-2".to_string(),
"old".to_string(),
];
assert_eq!(
select_image_tags_to_delete(&tags, "new", 2),
vec!["1-2-2".to_string(), "old".to_string()]
);
}
#[test]
fn detects_secret_like_wrangler_vars_without_value_leakage() {
let mut diagnostics = LocalDiagnostics::default();
let config = json!({"vars": {"API_TOKEN": "super-secret-token", "PUBLIC_URL": "https://example.com"}});
audit_wrangler_vars_for_secrets(&config, &mut diagnostics);
let rendered = diagnostics.warnings.join("\n");
assert!(rendered.contains("API_TOKEN"));
assert!(!rendered.contains("super-secret-token"));
assert!(!rendered.contains("PUBLIC_URL"));
}
}