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