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            // Field type change would require delete-then-add, which is
355            // data-losing on the field — refuse rather than silently drop.
356            for fd in &d.field_diffs {
357                if let DiffOp::Modified { from, to } = fd {
358                    return Err(anyhow!(
359                        "modifying field '{}' on catalog '{}' (type {} → {}) \
360                         is not supported by braze-sync; the change would be \
361                         data-losing on the field. Drop the field manually \
362                         in the Braze dashboard and re-run `braze-sync apply`",
363                        to.name,
364                        d.name,
365                        from.field_type.as_str(),
366                        to.field_type.as_str(),
367                    ));
368                }
369            }
370        }
371    }
372    Ok(())
373}
374
375async fn apply_content_block(
376    client: &BrazeClient,
377    d: &ContentBlockDiff,
378    id_index: Option<&ContentBlockIdIndex>,
379    archive_orphans: bool,
380    today: chrono::NaiveDate,
381) -> anyhow::Result<usize> {
382    // Orphans only mutate remote state when --archive-orphans is set;
383    // otherwise the plan-print is the entire effect.
384    if d.orphan {
385        if !archive_orphans {
386            return Ok(0);
387        }
388        let id_index = id_index.ok_or_else(|| {
389            anyhow!("internal: content_block id index missing for orphan apply path")
390        })?;
391        let id = id_index.get(&d.name).ok_or_else(|| {
392            anyhow!(
393                "internal: orphan '{}' missing from id index — list/diff drift",
394                d.name
395            )
396        })?;
397        let archived = orphan::archive_name(today, &d.name);
398        if archived == d.name {
399            return Ok(0);
400        }
401        // Update endpoint requires the full body, not a partial. Safe
402        // re: state — `get_content_block` defaults state to Active
403        // (Braze /info has no state field) and `update_content_block`
404        // omits state from the wire body, so this rename can never
405        // toggle the remote state as a side effect. If either of those
406        // invariants ever changes, the orphan path needs revisiting.
407        let mut cb = client
408            .get_content_block(id)
409            .await
410            .with_context(|| format!("fetching content block '{}' for archive rename", d.name))?;
411        cb.name = archived;
412        tracing::info!(
413            content_block = %d.name,
414            new_name = %cb.name,
415            "archiving orphan content block"
416        );
417        client.update_content_block(id, &cb).await?;
418        return Ok(1);
419    }
420
421    match &d.op {
422        DiffOp::Added(cb) => {
423            tracing::info!(content_block = %cb.name, "creating content block");
424            let _ = client.create_content_block(cb).await?;
425            Ok(1)
426        }
427        DiffOp::Modified { to, .. } => {
428            let id_index = id_index.ok_or_else(|| {
429                anyhow!("internal: content_block id index missing for modified apply path")
430            })?;
431            let id = id_index.get(&to.name).ok_or_else(|| {
432                anyhow!(
433                    "internal: modified content block '{}' missing from id index",
434                    to.name
435                )
436            })?;
437            tracing::info!(content_block = %to.name, "updating content block");
438            client.update_content_block(id, to).await?;
439            Ok(1)
440        }
441        // The diff layer routes remote-only blocks through the orphan
442        // flag, never as a Removed op.
443        DiffOp::Removed(_) => {
444            unreachable!("diff layer routes content block removals through orphan")
445        }
446        DiffOp::Unchanged => Ok(0),
447    }
448}
449
450async fn apply_catalog_schema(
451    client: &BrazeClient,
452    d: &CatalogSchemaDiff,
453) -> anyhow::Result<usize> {
454    // `POST /catalogs` carries the full schema, so the per-field loop
455    // below is skipped for `Added` to avoid duplicate field POSTs.
456    if let DiffOp::Added(cat) = &d.op {
457        tracing::info!(catalog = %d.name, "creating new catalog");
458        client
459            .create_catalog(cat)
460            .await
461            .with_context(|| format!("creating catalog '{}'", d.name))?;
462        return Ok(1);
463    }
464
465    // Top-level delete short-circuits the field loop: `DELETE /catalogs/{name}`
466    // drops the schema and all items in one call, so per-field DELETEs would
467    // be redundant (and would 404 once the catalog is gone).
468    if let DiffOp::Removed(_) = &d.op {
469        tracing::info!(catalog = %d.name, "deleting catalog");
470        client
471            .delete_catalog(&d.name)
472            .await
473            .with_context(|| format!("deleting catalog '{}'", d.name))?;
474        return Ok(1);
475    }
476
477    let mut count = 0;
478    for fd in &d.field_diffs {
479        match fd {
480            DiffOp::Added(f) => {
481                tracing::info!(
482                    catalog = %d.name,
483                    field = %f.name,
484                    field_type = f.field_type.as_str(),
485                    "adding catalog field"
486                );
487                client.add_catalog_field(&d.name, f).await?;
488                count += 1;
489            }
490            DiffOp::Removed(f) => {
491                tracing::info!(
492                    catalog = %d.name,
493                    field = %f.name,
494                    "deleting catalog field"
495                );
496                client.delete_catalog_field(&d.name, &f.name).await?;
497                count += 1;
498            }
499            DiffOp::Modified { .. } => {
500                return Err(anyhow!(
501                    "internal: Modified field op should have been rejected \
502                     by check_for_unsupported_ops"
503                ));
504            }
505            DiffOp::Unchanged => {}
506        }
507    }
508    Ok(count)
509}
510
511async fn apply_email_template(
512    client: &BrazeClient,
513    d: &EmailTemplateDiff,
514    id_index: Option<&EmailTemplateIdIndex>,
515    archive_orphans: bool,
516    today: chrono::NaiveDate,
517) -> anyhow::Result<usize> {
518    if d.orphan {
519        if !archive_orphans {
520            return Ok(0);
521        }
522        let id_index = id_index.ok_or_else(|| {
523            anyhow!("internal: email_template id index missing for orphan apply path")
524        })?;
525        let id = id_index.get(&d.name).ok_or_else(|| {
526            anyhow!(
527                "internal: orphan '{}' missing from id index — list/diff drift",
528                d.name
529            )
530        })?;
531        let archived = orphan::archive_name(today, &d.name);
532        if archived == d.name {
533            return Ok(0);
534        }
535        let mut et = client
536            .get_email_template(id)
537            .await
538            .with_context(|| format!("fetching email template '{}' for archive rename", d.name))?;
539        et.name = archived;
540        tracing::info!(
541            email_template = %d.name,
542            new_name = %et.name,
543            "archiving orphan email template"
544        );
545        client.update_email_template(id, &et).await?;
546        return Ok(1);
547    }
548
549    match &d.op {
550        DiffOp::Added(et) => {
551            tracing::info!(email_template = %et.name, "creating email template");
552            let _ = client.create_email_template(et).await?;
553            Ok(1)
554        }
555        DiffOp::Modified { to, .. } => {
556            let id_index = id_index.ok_or_else(|| {
557                anyhow!("internal: email_template id index missing for modified apply path")
558            })?;
559            let id = id_index.get(&to.name).ok_or_else(|| {
560                anyhow!(
561                    "internal: modified email template '{}' missing from id index",
562                    to.name
563                )
564            })?;
565            tracing::info!(email_template = %to.name, "updating email template");
566            client.update_email_template(id, to).await?;
567            Ok(1)
568        }
569        DiffOp::Removed(_) => {
570            unreachable!("diff layer routes email template removals through orphan")
571        }
572        DiffOp::Unchanged => Ok(0),
573    }
574}
575
576/// Batch custom attribute deprecation toggles by direction so we issue
577/// at most two API calls. Each batch is reported to stderr on success so
578/// the user can tell what was committed if a later batch fails.
579async fn apply_custom_attribute_batch(
580    client: &BrazeClient,
581    to_deprecate: &[&str],
582    to_reactivate: &[&str],
583) -> anyhow::Result<usize> {
584    let mut applied = 0;
585    for (names, blocklisted, verb) in [
586        (to_deprecate, true, "deprecating"),
587        (to_reactivate, false, "reactivating"),
588    ] {
589        if names.is_empty() {
590            continue;
591        }
592        tracing::info!(attributes = ?names, "{verb} custom attributes");
593        client
594            .set_custom_attribute_blocklist(names, blocklisted)
595            .await
596            .with_context(|| format!("{verb} custom attributes"))?;
597        let n = names.len();
598        let past = if blocklisted {
599            "deprecated"
600        } else {
601            "reactivated"
602        };
603        eprintln!("  ✓ {past} {n} custom attribute(s)");
604        applied += n;
605    }
606
607    Ok(applied)
608}