Skip to content

feat(presentation): pluggable GitOps backend (ArgoCD + minimal Flux)#104

Draft
sini wants to merge 3 commits into
arnarg:mainfrom
sini:refactor/presentation-backend
Draft

feat(presentation): pluggable GitOps backend (ArgoCD + minimal Flux)#104
sini wants to merge 3 commits into
arnarg:mainfrom
sini:refactor/presentation-backend

Conversation

@sini

@sini sini commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Stage three of the "pure functional core / imperative shell" direction (#102 is the file layout seam; the generators split made compile pure). This makes presentation a pure function and opens it to pluggable, user-provided GitOps backends.

Design writeup: https://gist.github.com/sini/d2a1b7876977e01cfb0712ee8d89dcfe

What changes

The enum + mkIf (backend == "x")-guarded dispatch becomes a registry of pure backend records:

  • nixidy.presentation.backends : attrsOf { perAppOptions ? []; typeImports ? []; present ? (ctx: {}); bootstrapFile ? null; }. Built-ins register argocd/flux; a user adds an entry.
  • nixidy.presentation.backend : str. Selects by key, with an unknown-key assertion.
  • modules/presentation/default.nix resolves the selected record and wires its four contributions. Synthesis is a pure present :: ctx -> attrsOf appConfig (no module config access, no per-backend guards); every mkIf (backend == ...) collapses into one backends.${backend} lookup.

present being a function that is called (not a module that is imported) is what lets a user backend work without touching nixidy: it sidesteps the "a module's imports can't read config" wall. (Context in the thread above.)

ArgoCD stays first-class

Options relocated behind nixidy.presentation.argocd.*; the old nixidy.appOfApps.* / nixidy.defaults.* paths value-forward via mkRenamedOptionModule aliases, so existing configs are unchanged (a pre-existing app-of-apps test that sets the old paths still passes end-to-end). Default-backend rendered output is byte-identical.

Flux backend (minimal, illustrative)

A minimal Flux backend proves the seam is not ArgoCD-shaped. It is intentionally a proof, not production (no HelmRelease/OCI parity; bootstrapManifest.enable is not wired for it). A real Flux backend and the OCI-artifact delivery story are deliberately scoped out: that is a delivery axis, orthogonal to presentation, and a clean follow-up.

User backends

A backend is a record assigned from ordinary config. tests/presentation-user-backend.nix registers a custom backend entirely in user config, selects it, renders end-to-end, and asserts isolation from argocd/flux.

Tests

Unknown-backend dispatch assertion; direct unit tests of each backend's pure present on a fixture ctx; the user-backend extensibility proof; plus argocd alias, flux synthesis, and bootstrap-manifest coverage. moduleTests 56/56, libTests 13/13, staticCheck clean.

Commits

The first commit is #102 (the D4 build/ layout seam) and drops out once #102 lands; review the two feat(presentation) / test(presentation) commits.

@sini

sini commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

I provided only a minimal impl as a POC for framing discussion.

@arnarg

arnarg commented Jun 12, 2026

Copy link
Copy Markdown
Owner

I've been sitting and thinking about this today and I think I'm for the idea of supporting a pluggable "presentation" backend as long as the original ArgoCD experience doesn't suffer (apart from moving the options a bit, but the aliases help).

I have used Flux a bit at $work but never liked it as much as ArgoCD, how do you propose the environmentPackage layout should be? Won't there need to be a similar app-of-apps setup?

I think the consensus in the Flux community is that the rendered manifest pattern like nixidy uses (commit manifests to git) is an anti-pattern. I think they're more in favour of pushing manifests in OCI artifacts.

I think it would make sense to have the standard flux backend be as close to the argocd one (but make it make sense for flux usage) but also offer a user provided backend if the user wants. Maybe someone could implement a new activation script that builds an OCI artifact and pushes it instead.

Maybe nixidy.presentation.backend could have type with types; either (enum ["argocd" "flux"]) (oneOf [package path (functionTo attrs)]) (I'm not 100% confident this works, but you get the idea).

What do you think?

@sini

sini commented Jun 12, 2026

Copy link
Copy Markdown
Contributor Author

Yeah; that's in line with my thinking. The flux impl in this draft isn't intended to ship; just show what the restructure would look like for ArgoCD. ArgoCD of course remains the first class flagship, we just re-architect the backend model a bit to support (ideally, any) alternative gitops toolchains and more closely match a MVC-style rendering pattern for future enhancements. :)

Restructure modules/build.nix into modules/build/ as a functional-core /
imperative-shell pipeline. No public API change; all module and lib tests
green.

- layout.nix: pure core — each application -> [FileSpec] (one per output
  file), exposed as the internal `config.build.layout` seam so file-layout
  policy is plain data that tests assert directly (no derivation built).
- render.nix: FileSpec -> shell fragment (the yq/cp realization).
- emit-environment.nix: environmentPackage / activationPackage /
  bootstrapPackage / extrasPackage, each a fold over the seam.
- apply.nix: declarativePackage (apply script) + the activation
  post-process stream; shares chainOf/resolveCommand across both paths.
- default.nix: the module — options.build.*, the assertion, and wiring.

One intentional behavior change: the per-app postProcess collision
assertion is strengthened from "no two post-processed objects share a file"
to "a post-processed object's group is single-object". This closes a latent
divergence where the switch (per-object) and apply (group-head) paths could
post-process different sets in a multi-document file — the case the old
message already called "undefined". apply == activation now holds by
construction; the previously-undefined case becomes an explicit build error,
and every case the tests exercise is unaffected.

New tests: layout-filespec.nix asserts the seam directly;
layout-postprocess-multidoc-assert.nix covers the strengthened assertion.
@sini
sini force-pushed the refactor/presentation-backend branch from 79dde2b to 9ce529d Compare July 17, 2026 17:58
@sini

sini commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks, that lines up, and digging into the module system turned up a constraint that I think points at a cleaner shape than the type I floated.

Why backend = either enum (oneOf [package path (functionTo attrs)]) doesn't quite work as an option. A backend contributes top-level config: the synthetic app(s) carrying the controller objects, the per-app options list, the CRD type registration. Those have to come from a module in the top-level imports, and top-level imports can't read config.nixidy.presentation.backend (it's the fixpoint those imports produce). That's exactly why the current draft pre-imports both backends and guards each with mkIf (backend == "x"). A backend stored in a config option can't drive a top-level import, so path/package can't resolve that way.

But your functionTo attrs instinct is the right one; it just wants to be a pure function, not a module. If synthesis is a pure present :: ctx -> objects whose result is assigned (not a module that's imported), the import-timing problem disappears: you call it and merge its output. So:

  • nixidy.presentation.backends : attrsOf { perAppOptions ? []; typeImports ? []; present ? (ctx: {}); bootstrapFile ? null; }, a registry. Built-ins register argocd/flux; a user adds an entry.
  • nixidy.presentation.backend : str, selects by key, with an assertion for unknown keys.
  • One central dispatch resolves the record and wires its four contributions; every mkIf (backend == ...) collapses into the single backends.${backend} lookup.

Registration is unconditional and free (present is lazy, only called for the selected key). ArgoCD stays exactly as first-class as today: same options, the appOfApps/defaults aliases keep it non-breaking, default-backend output byte-identical. Only synthesis moves from a guarded module into a pure function. It also finishes a direction the earlier work started: #102 makes file layout a pure function, the generators split makes compile pure, and present is the presentation-side equivalent.

On Flux + the app-of-apps question. Agreed a real Flux backend wants its own bootstrap shape (root Kustomization / GitRepository, not an app-of-apps Application); that's just what its present returns. The minimal one in the draft only proves the seam isn't ArgoCD-shaped. I'd keep the standard flux backend close to argocd in spirit but idiomatic for flux, as you said, and the registry is what lets someone drop in their own backend without touching nixidy.

On OCI artifacts, I'd scope that out of this change, deliberately. "Commit rendered manifests" vs "build and push an OCI artifact" is a delivery concern, orthogonal to presentation (what controller objects describe the apps). Pure present keeps presentation delivery-agnostic by construction: it returns objects, it doesn't care how they ship. So OCI stays a clean follow-up: an OCIRepository-emitting flux variant is another registry entry, and the build-and-push is a separate activation seam over the current apply/activation path. Happy to design that next; I just don't want to conflate the two axes in one PR.

Full design writeup here: https://gist.github.com/sini/d2a1b7876977e01cfb0712ee8d89dcfe

I'll push the registry version onto #104. Does the attrsOf { present; perAppOptions; typeImports; bootstrapFile } record shape look right to you as the backend contract?

sini added 2 commits July 17, 2026 12:30
Replace the enum + mkIf-guarded presentation dispatch with a registry of
pure backend records. nixidy.presentation.backends is an attrsOf record
{ perAppOptions; typeImports; present; bootstrapFile }; nixidy.presentation.backend
is a str key selecting one, with an unknown-key assertion. modules/presentation/default.nix
resolves the selected record and wires its four contributions; synthesis is a
pure present :: ctx -> attrsOf appConfig (no module config access, no per-backend
guards). ArgoCD stays first-class (options relocated behind .argocd.* with
value-forwarding aliases; default-backend output byte-identical); a minimal Flux
backend proves the seam. A user backend is a record assigned from ordinary config,
no nixidy edit required.
…ckend

Adds: dispatch unknown-backend assertion; direct unit tests of each backend's
pure present function on a fixture ctx; an end-to-end user-provided backend
proving third-party extensibility with isolation from argocd/flux; plus the
argocd option-alias, flux synthesis, and bootstrap-manifest coverage.
@sini
sini force-pushed the refactor/presentation-backend branch from 9ce529d to d7f0306 Compare July 17, 2026 19:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants