Motivation
Building CD for ERP for Factory Games as a real-world Fallout-as-CD dogfood (ErpForFactoryGames#265), we hand-rolled the same shape every CD target eventually needs:
- Fetch current state from the remote.
- Diff against desired state.
- Render the plan (dry-run, structured + tabular).
- Optionally apply.
Concretely: ResourcePlan, FieldChange, PlanAction, PlanRenderer, plus per-resource reconcilers (TunnelReconciler, DnsRecordReconciler, IngressReconciler) — ~250 LOC of pure scaffolding before any Cloudflare-specific code. The next CD target (K8s manifests, Docker compose, IAM policies, on-disk config) will hand-roll a sibling because the primitives aren't upstream.
Proposal
A Fallout.Reconcile namespace with the generic primitives:
public abstract record PlanAction { Create, Update, Delete, NoChange }
public sealed record FieldChange(string Field, string? Before, string? After);
public sealed record ResourcePlan<T>(string ResourceKind, string Identifier, PlanAction Action, IReadOnlyList<FieldChange> Changes, T Current, T Desired);
public interface IReconciler<T> {
Task<IReadOnlyList<ResourcePlan<T>>> PlanAsync(T desired, CancellationToken ct);
Task ApplyAsync(IReadOnlyList<ResourcePlan<T>> plans, CancellationToken ct);
}
public static class PlanRenderer {
public static void RenderTable(IEnumerable<ResourcePlan> plans, IAnsiConsole console);
public static string RenderJson(IEnumerable<ResourcePlan> plans);
}
A target composes reconcilers with the existing [Parameter] DryRun machinery — ./build.sh Provision --dry-run becomes one-liner glue instead of 250 LOC of scaffolding.
Why this fits Fallout (not a separate library)
Open questions
- Diff fidelity across resource types. Cloudflare is easy (clean GET API). K8s, IAM, Terraform-state have defaults, server-side mutation, ordering quirks. The API needs an escape hatch (custom
IDiffStrategy<T>?) without becoming Cloudflare-only.
T vs untyped state. Typed records are ergonomic for known shapes (DNS records, tunnels) but awkward for opaque server state (K8s objects, IAM docs). Generic over T, or over JsonNode / IDictionary<string, object?> with optional typed views?
- Apply ordering. Plans apply in production order today. Real CD needs ordering hints (tunnel before DNS for cloudflared). First-class
DependsOn, or leave it to caller-side composition?
Related
Reference implementation (to be replaced)
ErpForFactoryGames/src/Deploy/Erp.Deploy/Reconcile/ — the hand-rolled version we'd delete once this lands upstream.
Motivation
Building CD for ERP for Factory Games as a real-world Fallout-as-CD dogfood (ErpForFactoryGames#265), we hand-rolled the same shape every CD target eventually needs:
Concretely:
ResourcePlan,FieldChange,PlanAction,PlanRenderer, plus per-resource reconcilers (TunnelReconciler,DnsRecordReconciler,IngressReconciler) — ~250 LOC of pure scaffolding before any Cloudflare-specific code. The next CD target (K8s manifests, Docker compose, IAM policies, on-disk config) will hand-roll a sibling because the primitives aren't upstream.Proposal
A
Fallout.Reconcilenamespace with the generic primitives:A target composes reconcilers with the existing
[Parameter] DryRunmachinery —./build.sh Provision --dry-runbecomes one-liner glue instead of 250 LOC of scaffolding.Why this fits Fallout (not a separate library)
[Parameter]/[Secret]flow for dry-run, credentials, output sinks.IAnsiConsoleso output stays consistent.Doctor-style preflight (Doctor target / [Diagnose] primitive — operator-side preflight surface #251) — both are "tell me what would happen" surfaces.Open questions
IDiffStrategy<T>?) without becoming Cloudflare-only.Tvs untyped state. Typed records are ergonomic for known shapes (DNS records, tunnels) but awkward for opaque server state (K8s objects, IAM docs). Generic overT, or overJsonNode/IDictionary<string, object?>with optional typed views?DependsOn, or leave it to caller-side composition?Related
Reference implementation (to be replaced)
ErpForFactoryGames/src/Deploy/Erp.Deploy/Reconcile/— the hand-rolled version we'd delete once this lands upstream.