Skip to main content

braze_sync/cli/
apply.rs

1//! `braze-sync apply` — the only command that mutates remote state.
2//!
3//! Recomputes the diff via the same code path as `diff`, prints it,
4//! then short-circuits unless `--confirm` is set. Pre-validates
5//! unsupported/destructive ops before any API call, then walks the
6//! plan and aborts on the first write failure. Braze has no
7//! cross-resource transaction, so a mid-loop failure can still leave
8//! earlier writes applied — the pre-validation prevents *known-bad*
9//! plans from firing any writes at all, but it does not promise
10//! cross-write atomicity.
11
12use crate::braze::BrazeClient;
13use crate::config::ResolvedConfig;
14use crate::diff::catalog::CatalogSchemaDiff;
15use crate::diff::content_block::{ContentBlockDiff, ContentBlockIdIndex};
16use crate::diff::custom_attribute::CustomAttributeOp;
17use crate::diff::email_template::{EmailTemplateDiff, EmailTemplateIdIndex};
18use crate::diff::orphan;
19use crate::diff::{DiffOp, DiffSummary, ResourceDiff};
20use crate::error::Error;
21use crate::format::OutputFormat;
22use crate::resource::ResourceKind;
23use anyhow::{anyhow, Context as _};
24use clap::Args;
25use std::path::Path;
26
27use super::diff::{
28    compute_catalog_schema_diffs, compute_content_block_plan, compute_custom_attribute_diffs,
29    compute_email_template_plan, compute_tag_diffs,
30};
31use super::{selected_kinds, warn_if_name_excluded};
32
33#[derive(Args, Debug)]
34pub struct ApplyArgs {
35    /// Limit apply to a specific resource kind.
36    #[arg(long, value_enum)]
37    pub resource: Option<ResourceKind>,
38
39    /// When `--resource` is given, optionally restrict to a single named
40    /// resource. Requires `--resource`.
41    #[arg(long, requires = "resource")]
42    pub name: Option<String>,
43
44    /// Actually apply changes. Without this, runs in dry-run mode and
45    /// makes zero write calls to Braze. This is the default for safety.
46    #[arg(long)]
47    pub confirm: bool,
48
49    /// Permit destructive operations (field deletes, etc.). Required in
50    /// addition to `--confirm` for any change that would lose data on
51    /// the Braze side.
52    #[arg(long)]
53    pub allow_destructive: bool,
54
55    /// Archive orphan Content Blocks / Email Templates by prefixing the
56    /// remote name with `[ARCHIVED-YYYY-MM-DD]`. Inert for resource
57    /// kinds that have no orphan concept (e.g. catalog schema).
58    #[arg(long)]
59    pub archive_orphans: bool,
60}
61
62pub async fn run(
63    args: &ApplyArgs,
64    resolved: ResolvedConfig,
65    config_dir: &Path,
66    format: OutputFormat,
67) -> anyhow::Result<()> {
68    let catalogs_root = config_dir.join(&resolved.resources.catalog_schema.path);
69    let content_blocks_root = config_dir.join(&resolved.resources.content_block.path);
70    let email_templates_root = config_dir.join(&resolved.resources.email_template.path);
71    let custom_attributes_path = config_dir.join(&resolved.resources.custom_attribute.path);
72    let tags_path = config_dir.join(&resolved.resources.tag.path);
73    let client = BrazeClient::from_resolved(&resolved);
74    let kinds = selected_kinds(args.resource, &resolved.resources);
75
76    let mut summary = DiffSummary::default();
77    let mut content_block_id_index: Option<ContentBlockIdIndex> = None;
78    let mut email_template_id_index: Option<EmailTemplateIdIndex> = None;
79    for kind in kinds {
80        if warn_if_name_excluded(kind, args.name.as_deref(), resolved.excludes_for(kind)) {
81            continue;
82        }
83        match kind {
84            ResourceKind::CatalogSchema => {
85                let diffs = compute_catalog_schema_diffs(
86                    &client,
87                    &catalogs_root,
88                    args.name.as_deref(),
89                    resolved.excludes_for(ResourceKind::CatalogSchema),
90                )
91                .await
92                .context("computing catalog_schema plan")?;
93                summary.diffs.extend(diffs);
94            }
95            ResourceKind::ContentBlock => {
96                let (diffs, idx) = compute_content_block_plan(
97                    &client,
98                    &content_blocks_root,
99                    args.name.as_deref(),
100                    resolved.excludes_for(ResourceKind::ContentBlock),
101                )
102                .await
103                .context("computing content_block plan")?;
104                summary.diffs.extend(diffs);
105                content_block_id_index = Some(idx);
106            }
107            ResourceKind::EmailTemplate => {
108                let (diffs, idx) = compute_email_template_plan(
109                    &client,
110                    &email_templates_root,
111                    args.name.as_deref(),
112                    resolved.excludes_for(ResourceKind::EmailTemplate),
113                )
114                .await
115                .context("computing email_template plan")?;
116                summary.diffs.extend(diffs);
117                email_template_id_index = Some(idx);
118            }
119            ResourceKind::CustomAttribute => {
120                let diffs = compute_custom_attribute_diffs(
121                    &client,
122                    &custom_attributes_path,
123                    args.name.as_deref(),
124                    resolved.excludes_for(ResourceKind::CustomAttribute),
125                )
126                .await
127                .context("computing custom_attribute plan")?;
128                summary.diffs.extend(diffs);
129            }
130            ResourceKind::Tag => {
131                let diffs = compute_tag_diffs(
132                    config_dir,
133                    &resolved,
134                    &tags_path,
135                    args.name.as_deref(),
136                    resolved.excludes_for(ResourceKind::Tag),
137                )
138                .context("computing tag plan")?;
139                summary.diffs.extend(diffs);
140            }
141        }
142    }
143
144    // Tag pre-flight: refuse to fire any mutation if a referenced tag is
145    // missing from the registry. Without this, Braze rejects the first
146    // tag-bearing create/update with HTTP 400 and the rest of the plan
147    // halts mid-pipeline. Runs even when --kind is restricted to a non-tag
148    // kind so tag drift cannot sneak past a per-kind apply.
149    enforce_tag_preflight(config_dir, &resolved, &summary)?;
150
151    let mode_label = if args.confirm {
152        "Plan:"
153    } else {
154        "Plan (dry-run, pass --confirm to apply):"
155    };
156    eprintln!("{mode_label}");
157    print!("{}", format.formatter().format(&summary));
158
159    if summary.actionable_count() == 0 {
160        if summary.changed_count() > 0 {
161            eprintln!(
162                "No actionable changes to apply \
163                 (informational drift above can be reconciled with `export`)."
164            );
165        } else {
166            eprintln!("No changes to apply.");
167        }
168        return Ok(());
169    }
170
171    if !args.confirm {
172        eprintln!("DRY RUN — pass --confirm to apply these changes.");
173        return Ok(());
174    }
175
176    if summary.destructive_count() > 0 && !args.allow_destructive {
177        return Err(Error::DestructiveBlocked.into());
178    }
179
180    check_for_unsupported_ops(&summary)?;
181
182    // One canonical archive timestamp per run, even across multiple
183    // orphans. UTC (not Local) so two operators running the same archive
184    // on the same wall-clock day from different timezones produce the
185    // same `[ARCHIVED-YYYY-MM-DD]` prefix — determinism across a team
186    // matters more than matching the operator's local calendar.
187    let today = chrono::Utc::now().date_naive();
188
189    let mut applied = 0;
190    let mut ca_deprecate: Vec<&str> = Vec::new();
191    let mut ca_reactivate: Vec<&str> = Vec::new();
192    for diff in &summary.diffs {
193        match diff {
194            ResourceDiff::CatalogSchema(d) => {
195                applied += apply_catalog_schema(&client, d).await?;
196            }
197            ResourceDiff::ContentBlock(d) => {
198                applied += apply_content_block(
199                    &client,
200                    d,
201                    content_block_id_index.as_ref(),
202                    args.archive_orphans,
203                    today,
204                )
205                .await?;
206            }
207            ResourceDiff::EmailTemplate(d) => {
208                applied += apply_email_template(
209                    &client,
210                    d,
211                    email_template_id_index.as_ref(),
212                    args.archive_orphans,
213                    today,
214                )
215                .await?;
216            }
217            ResourceDiff::CustomAttribute(d) => {
218                if let CustomAttributeOp::DeprecationToggled { to, .. } = &d.op {
219                    if *to {
220                        ca_deprecate.push(&d.name);
221                    } else {
222                        ca_reactivate.push(&d.name);
223                    }
224                }
225            }
226            // Tags have no remote mutation API. The diff is informational;
227            // pre-flight (above) is what protects apply from missing tags.
228            ResourceDiff::Tag(_) => {}
229        }
230    }
231
232    if !ca_deprecate.is_empty() || !ca_reactivate.is_empty() {
233        applied += apply_custom_attribute_batch(&client, &ca_deprecate, &ca_reactivate).await?;
234    }
235
236    eprintln!("✓ Applied {applied} change(s).");
237    Ok(())
238}
239
240/// Reject ops the API can't actually perform. Runs before any write
241/// call so a statically-known-bad plan cannot fire a partial apply;
242/// runtime write failures can still leave earlier writes in place.
243///
244/// ContentBlock diffs are deliberately not inspected here: every shape
245/// the diff layer can produce (`Added` → create, `Modified` → update,
246/// `orphan` → archive-or-noop) maps to a supported API call, so there
247/// is nothing to statically reject. If a future diff shape is added
248/// (e.g. content-type change with no in-place update), re-evaluate.
249/// Block apply when any resource that would be mutated references a tag
250/// not declared in `tags/registry.yaml`. Braze has no public REST API for
251/// workspace tags, so we cannot create them; the only reliable way to
252/// avoid the cascading "Tags could not be found" failure (a single tagged
253/// content_block create that 400s blocks every queued create after it) is
254/// to refuse the apply entirely until the operator either (a) adds the
255/// tag to the registry after creating it in the Braze dashboard, or
256/// (b) removes the tag from the offending resource.
257///
258/// No-op when the `tag` resource is disabled in config — this is opt-in
259/// safety, not a forced gate.
260fn enforce_tag_preflight(
261    config_dir: &Path,
262    resolved: &ResolvedConfig,
263    summary: &DiffSummary,
264) -> anyhow::Result<()> {
265    if !resolved.resources.is_enabled(ResourceKind::Tag) {
266        return Ok(());
267    }
268    let tags_path = config_dir.join(&resolved.resources.tag.path);
269    let registry = match crate::fs::tag_io::load_registry(&tags_path)? {
270        Some(r) => r,
271        // No registry file → operator hasn't opted in to tag tracking yet;
272        // skip the gate rather than blocking on a config-only state.
273        None => return Ok(()),
274    };
275    let excludes = resolved.excludes_for(ResourceKind::Tag);
276
277    let mut missing: std::collections::BTreeMap<String, Vec<String>> =
278        std::collections::BTreeMap::new();
279    for diff in &summary.diffs {
280        // Only inspect resources that would actually fire a write.
281        let (resource_label, tags) = match diff {
282            ResourceDiff::ContentBlock(d) => match &d.op {
283                DiffOp::Added(cb) => (format!("content_block '{}'", cb.name), &cb.tags),
284                DiffOp::Modified { to, .. } => (format!("content_block '{}'", to.name), &to.tags),
285                _ => continue,
286            },
287            ResourceDiff::EmailTemplate(d) => match &d.op {
288                DiffOp::Added(et) => (format!("email_template '{}'", et.name), &et.tags),
289                DiffOp::Modified { to, .. } => (format!("email_template '{}'", to.name), &to.tags),
290                _ => continue,
291            },
292            _ => continue,
293        };
294        for t in tags {
295            if crate::config::is_excluded(t, excludes) {
296                continue;
297            }
298            if !registry.contains(t) {
299                missing
300                    .entry(t.clone())
301                    .or_default()
302                    .push(resource_label.clone());
303            }
304        }
305    }
306
307    if missing.is_empty() {
308        return Ok(());
309    }
310
311    let mut msg = String::from(
312        "tag pre-flight: refusing to apply — the following tags are referenced \
313         by resources that would be created/updated, but are not declared in \
314         tags/registry.yaml:\n",
315    );
316    for (tag, refs) in &missing {
317        msg.push_str(&format!("  • '{tag}' — referenced by:\n"));
318        for r in refs {
319            msg.push_str(&format!("      - {r}\n"));
320        }
321    }
322    msg.push_str(
323        "\nFix: create each missing tag in the Braze dashboard \
324         (Settings → Tags), then add it to tags/registry.yaml and re-run apply. \
325         Braze does not expose a tag creation API.",
326    );
327    Err(anyhow!(msg))
328}
329
330fn check_for_unsupported_ops(summary: &DiffSummary) -> anyhow::Result<()> {
331    for diff in &summary.diffs {
332        if let ResourceDiff::CatalogSchema(d) = diff {
333            match &d.op {
334                DiffOp::Added(_) => {}
335                DiffOp::Removed(_) => {
336                    return Err(anyhow!(
337                        "deleting catalog '{}' (top-level) is not supported by braze-sync; \
338                         only field-level changes can be applied",
339                        d.name
340                    ));
341                }
342                _ => {}
343            }
344            // Field type change would require delete-then-add, which is
345            // data-losing on the field — refuse rather than silently drop.
346            for fd in &d.field_diffs {
347                if let DiffOp::Modified { from, to } = fd {
348                    return Err(anyhow!(
349                        "modifying field '{}' on catalog '{}' (type {} → {}) \
350                         is not supported by braze-sync; the change would be \
351                         data-losing on the field. Drop the field manually \
352                         in the Braze dashboard and re-run `braze-sync apply`",
353                        to.name,
354                        d.name,
355                        from.field_type.as_str(),
356                        to.field_type.as_str(),
357                    ));
358                }
359            }
360        }
361    }
362    Ok(())
363}
364
365async fn apply_content_block(
366    client: &BrazeClient,
367    d: &ContentBlockDiff,
368    id_index: Option<&ContentBlockIdIndex>,
369    archive_orphans: bool,
370    today: chrono::NaiveDate,
371) -> anyhow::Result<usize> {
372    // Orphans only mutate remote state when --archive-orphans is set;
373    // otherwise the plan-print is the entire effect.
374    if d.orphan {
375        if !archive_orphans {
376            return Ok(0);
377        }
378        let id_index = id_index.ok_or_else(|| {
379            anyhow!("internal: content_block id index missing for orphan apply path")
380        })?;
381        let id = id_index.get(&d.name).ok_or_else(|| {
382            anyhow!(
383                "internal: orphan '{}' missing from id index — list/diff drift",
384                d.name
385            )
386        })?;
387        let archived = orphan::archive_name(today, &d.name);
388        if archived == d.name {
389            return Ok(0);
390        }
391        // Update endpoint requires the full body, not a partial. Safe
392        // re: state — `get_content_block` defaults state to Active
393        // (Braze /info has no state field) and `update_content_block`
394        // omits state from the wire body, so this rename can never
395        // toggle the remote state as a side effect. If either of those
396        // invariants ever changes, the orphan path needs revisiting.
397        let mut cb = client
398            .get_content_block(id)
399            .await
400            .with_context(|| format!("fetching content block '{}' for archive rename", d.name))?;
401        cb.name = archived;
402        tracing::info!(
403            content_block = %d.name,
404            new_name = %cb.name,
405            "archiving orphan content block"
406        );
407        client.update_content_block(id, &cb).await?;
408        return Ok(1);
409    }
410
411    match &d.op {
412        DiffOp::Added(cb) => {
413            tracing::info!(content_block = %cb.name, "creating content block");
414            let _ = client.create_content_block(cb).await?;
415            Ok(1)
416        }
417        DiffOp::Modified { to, .. } => {
418            let id_index = id_index.ok_or_else(|| {
419                anyhow!("internal: content_block id index missing for modified apply path")
420            })?;
421            let id = id_index.get(&to.name).ok_or_else(|| {
422                anyhow!(
423                    "internal: modified content block '{}' missing from id index",
424                    to.name
425                )
426            })?;
427            tracing::info!(content_block = %to.name, "updating content block");
428            client.update_content_block(id, to).await?;
429            Ok(1)
430        }
431        // The diff layer routes remote-only blocks through the orphan
432        // flag, never as a Removed op.
433        DiffOp::Removed(_) => {
434            unreachable!("diff layer routes content block removals through orphan")
435        }
436        DiffOp::Unchanged => Ok(0),
437    }
438}
439
440async fn apply_catalog_schema(
441    client: &BrazeClient,
442    d: &CatalogSchemaDiff,
443) -> anyhow::Result<usize> {
444    // `POST /catalogs` carries the full schema, so the per-field loop
445    // below is skipped for `Added` to avoid duplicate field POSTs.
446    if let DiffOp::Added(cat) = &d.op {
447        tracing::info!(catalog = %d.name, "creating new catalog");
448        client
449            .create_catalog(cat)
450            .await
451            .with_context(|| format!("creating catalog '{}'", d.name))?;
452        return Ok(1);
453    }
454
455    let mut count = 0;
456    for fd in &d.field_diffs {
457        match fd {
458            DiffOp::Added(f) => {
459                tracing::info!(
460                    catalog = %d.name,
461                    field = %f.name,
462                    field_type = f.field_type.as_str(),
463                    "adding catalog field"
464                );
465                client.add_catalog_field(&d.name, f).await?;
466                count += 1;
467            }
468            DiffOp::Removed(f) => {
469                tracing::info!(
470                    catalog = %d.name,
471                    field = %f.name,
472                    "deleting catalog field"
473                );
474                client.delete_catalog_field(&d.name, &f.name).await?;
475                count += 1;
476            }
477            DiffOp::Modified { .. } => {
478                return Err(anyhow!(
479                    "internal: Modified field op should have been rejected \
480                     by check_for_unsupported_ops"
481                ));
482            }
483            DiffOp::Unchanged => {}
484        }
485    }
486    Ok(count)
487}
488
489async fn apply_email_template(
490    client: &BrazeClient,
491    d: &EmailTemplateDiff,
492    id_index: Option<&EmailTemplateIdIndex>,
493    archive_orphans: bool,
494    today: chrono::NaiveDate,
495) -> anyhow::Result<usize> {
496    if d.orphan {
497        if !archive_orphans {
498            return Ok(0);
499        }
500        let id_index = id_index.ok_or_else(|| {
501            anyhow!("internal: email_template id index missing for orphan apply path")
502        })?;
503        let id = id_index.get(&d.name).ok_or_else(|| {
504            anyhow!(
505                "internal: orphan '{}' missing from id index — list/diff drift",
506                d.name
507            )
508        })?;
509        let archived = orphan::archive_name(today, &d.name);
510        if archived == d.name {
511            return Ok(0);
512        }
513        let mut et = client
514            .get_email_template(id)
515            .await
516            .with_context(|| format!("fetching email template '{}' for archive rename", d.name))?;
517        et.name = archived;
518        tracing::info!(
519            email_template = %d.name,
520            new_name = %et.name,
521            "archiving orphan email template"
522        );
523        client.update_email_template(id, &et).await?;
524        return Ok(1);
525    }
526
527    match &d.op {
528        DiffOp::Added(et) => {
529            tracing::info!(email_template = %et.name, "creating email template");
530            let _ = client.create_email_template(et).await?;
531            Ok(1)
532        }
533        DiffOp::Modified { to, .. } => {
534            let id_index = id_index.ok_or_else(|| {
535                anyhow!("internal: email_template id index missing for modified apply path")
536            })?;
537            let id = id_index.get(&to.name).ok_or_else(|| {
538                anyhow!(
539                    "internal: modified email template '{}' missing from id index",
540                    to.name
541                )
542            })?;
543            tracing::info!(email_template = %to.name, "updating email template");
544            client.update_email_template(id, to).await?;
545            Ok(1)
546        }
547        DiffOp::Removed(_) => {
548            unreachable!("diff layer routes email template removals through orphan")
549        }
550        DiffOp::Unchanged => Ok(0),
551    }
552}
553
554/// Batch custom attribute deprecation toggles by direction so we issue
555/// at most two API calls. Each batch is reported to stderr on success so
556/// the user can tell what was committed if a later batch fails.
557async fn apply_custom_attribute_batch(
558    client: &BrazeClient,
559    to_deprecate: &[&str],
560    to_reactivate: &[&str],
561) -> anyhow::Result<usize> {
562    let mut applied = 0;
563    for (names, blocklisted, verb) in [
564        (to_deprecate, true, "deprecating"),
565        (to_reactivate, false, "reactivating"),
566    ] {
567        if names.is_empty() {
568            continue;
569        }
570        tracing::info!(attributes = ?names, "{verb} custom attributes");
571        client
572            .set_custom_attribute_blocklist(names, blocklisted)
573            .await
574            .with_context(|| format!("{verb} custom attributes"))?;
575        let n = names.len();
576        let past = if blocklisted {
577            "deprecated"
578        } else {
579            "reactivated"
580        };
581        eprintln!("  ✓ {past} {n} custom attribute(s)");
582        applied += n;
583    }
584
585    Ok(applied)
586}