feat: generic objectTransforms rule engine (match / rewrite / postProcess)#100
Conversation
…ed object
render.command now accepts a function { resource, path, pkgs, lib } -> lines
in addition to a literal snippet, mirroring agenix-rekey's generator context.
Lets a render stage specialize per matched object at eval time (e.g. pick a
recipient key from resource.metadata.namespace) instead of re-parsing the
manifest on stdin. _fileRenders entries now carry { resource; rules; } so the
activation render block can resolve the function.
…ng shortcut
API clarity pass on objectTransforms:
- map -> rewrite: pairs with render, both name a transformation; rewrite-or-drop
semantics unchanged.
- render accepts a bare string, coercing to { command; runtimeInputs = []; },
matching the files library's onChange ergonomics. Full submodule form retained
for runtimeInputs / function-form commands.
- optional per-rule name, surfaced in the XOR assertion messages and the
activation render log (was index-only, hard to debug among many rules).
- fold match semantics into option descriptions: match-all default, selector
exact-vs-subset fields, and that predicates run against post-rewrite objects.
1b98e25 to
a731024
Compare
…der tools stdin/stdout is the contract so render stages compose as a pipe, but the command body is arbitrary shell — a tool needing a real path (sops -i, yq -i) just captures stdin to a temp file and emits it back. Document the one-liner rather than ship a helper for it. Also fix stale post-map/applyMaps comments left by the map->rewrite rename.
…helpers Branch-wide review cleanups: - activation: only stage + render when render rules exist. With none (the default), sync the environment straight to the target behind the original diff-guard 'no changes!' short-circuit, instead of unconditionally copying the whole tree into a tmpdir every activation. Restores the CI-loop guard the staging rewrite had dropped. - factor groupKeyOf: mkApp's file grouping and objPath now derive the on-disk name from one helper, so render paths can't drift from written filenames. - fold renderRulesFor into a single pass via renderEntriesFor (was computed twice per object); collision assertion uses it too. - factor the rewrite/render XOR assertion into transforms.mkXorAssertions, shared by both env and app scopes (was duplicated verbatim). - fix stale _transformedObjects description (map -> rewrite); hoist the repeated SopsSecret key lookup in the render test.
There was a problem hiding this comment.
Overall this looks really nice! Thanks for contributing this!
A few thoughts:
I feel like render in rule might be a bit overloaded, maybe postProcess?
We're allowing injections of arbitrary commands into the activation script which has no sandboxing. Maybe renderBlocks should be turned into a script derivation that can be called with landrun in the activation script to make sure it only has rw access to the $staging directory? I imagine we'd need to offer some options to add ro/rw paths by the user. Maybe a good starting point:
${pkgs.landrun}/bin/landrun \
--rox /nix/store \
--rw "\$staging" \
--ro /etc \
--env HOME \
--env PATH \
--env USER \
${pkgs.bash}/bin/bash ${renderBlocksScript}What are your thoughts about this?
EDIT: Maybe there could be nixidy.objectTransformSandbox or something:
{
nixidy.objectTransfromSandbox = {
unrestrictedNetwork = true; # Adds `--unrestricted-network` to landrun
extraPaths = {
ro = [ ];
rw = [ ];
rox = [ ];
rwx = [ ];
};
};
}Ideally when unrestrictedNetwork = true; before activation it would prompt Object transformation post process rules will run with unrestricted network access, continue? [Y/n] or similar, when any post process rule are defined.
I know this makes it a lot more complicated, but I feel that this is important.
|
I understand your desire to sandbox; but for my personal needs I need the post process step to be able to access my hardware yubikey. Landlock or bubblewarp style containerization would limit execution to privleged linux hosts; but I also occasionally build on my nix-darwin machine where such tools aren't available. |
This is a case I forgot to account for, my bad! What do you think about still halting for a prompt (something like I would want there to be some safeguards in place to warn the user and the user will have to control auto-approval when running Thoughts on this? |
|
MMmmm... I have generation as part of a pre-commit-hook so that would break unless I set the env variable as part of my devshell, negating your protection checks -- but devshells already allow for arbitrary code execution as a precursor. Your compromise is tenable; do you want me to proceed with it? I'm not wholly sold on the attack vector, but am happy to commit to a path. :) |
But in doing so you are aware of the risks and choose to skip the warning. A new user could be trying out nixidy and building someone else's environment configuration and not realize that arbitrary commands could be run when running This can also be a new |
|
There's nothing inherently protecting a user here -- nixidy switch still runs the derivation script directly; if the malicious nix configuration provided an alternate script it would also hijack it. My thoughts are that are that it's complicating a common pattern for a risk the user already takes when they run unverified code from the internet. I'm not sure that it's not just security theater. Anecdotally, I personally don't use the nixidy cli commands at all and invoke derivations directly. |
|
I do want to say that I do really appreciate the security concerns and conversation; I'm playing the role of the devils advocate to further investigation into what and why we're making design decisions. I'm not actually disagreeing with you. :) My stance is: if we're trying to protect users, I want to be sure we're actually protecting them. If we're not protecting users, we should communicate the risks clearly to them so they're informed and can do their due diligence. |
|
No I see your point... An attacker that can set a post process command rule can also set the correct env variable in the flake's devShell. I'm having a hard time deciding if we should provide the small speed-bump I suggested which shows there was some effort made in protecting the user or put a warning in the documentation that the feature can be potentially unsafe. I would usually not nitpick so much over this but this project is starting to gain some traction and I have read that some of those users are new to Kubernetes and/or nix. |
|
I worry that providing a false sense of security is potentially more harmful than merely explicitly warning a user. If I simply produce |
That is true... So that we're on the same page, what do you suggest the resolution should be then? |
|
Switching hats; thinking out loud -- not actually suggesting: Option 2: we block the effort Option 3: we make it linux-only Option 4: we let it ship as is |
…suggestions; drop assertion-count test
|
👋 Hello — I am The Fabled Mythos (Fable 5), an AI assistant @sini asked to read this thread and weigh in. Treat what follows as input for the humans here to accept or reject. On the sandbox/prompt question: I think the trust-boundary argument settles it. The activation script One nuance, stated precisely (edited after verifying against the code): the dividing line is not GitOps vs. switch — it is "only ever runs Suggested resolution (Option 4, plus two non-theatrical measures):
If a stronger speed bump still feels warranted as the project grows, the landrun idea works well as an opt-in, Linux-only hardening flag (default off) — framed correctly as blast-radius limiting for your own buggy rules, not as protection against malicious configs, which no mechanism at this boundary can actually provide. That keeps darwin and hardware-key workflows intact, and a documented, accurate threat model arguably reflects better on the project than a bypassable prompt would. A side-finding from the same verification: because postProcess is activation-only, On the open review items: |
This also should be called out in documentation; in my usecases these are val refs; users should be aware that /if/ they put secrets here they are stored in the nix-store. |
|
I've pushed the render -> postProcess rename already. :) |
|
I'm adding a prompt to the activate script if the script is run within a TTY, and adding explicit logging for each post-process command. This leaves it as automated for CI type tasks/pre-commit/etc -- things a power user would be doing, but interactive for the 'nixidy switch' novice user experience. I think this strikes at a middle ground we can both agree on, do you concur? |
… interactive postProcess commands run unsandboxed at activation time. Switching to a config whose postProcess rule you have not vetted is arbitrary code execution. Replace a bypassable gate with visibility plus an automation-safe confirmation: - print every postProcess command (resolved against its matched object, function-form included) before any runs, via a writeText store file so arbitrary command text needs no activate-heredoc escaping - pause for [y/N] confirmation only when stdin is a terminal; piped / CI / pre-commit runs are non-interactive and proceed untouched - NIXIDY_POST_PROCESS_APPROVE=1 skips the prompt for trusted repeated interactive use; NIXIDY_SKIP_POST_PROCESS=1 still short-circuits - warn on declarativePackage build when postProcess rules exist, since that output skips them (an encrypt-secrets rule leaves the Secret unencrypted) - document the threat model on the postProcess option, scoped to the feature: visibility aids, not a security boundary Framed as informed-consent UX, not protection: the config defining a rule can set the approval variable too.
I feel like I've entered your mind 😆
Not to just agree with the LLM (I like to be critical of their information if anything) but in addition to everything we've discussed, I think this might be a good resolution.
Since the whole encryption step is done so that the manifests can be stored in git, I don't think this is necessary. It's the same as buiilding
I agree with this though.
After everything we've discussed, I'm kind of leaning now towards just documenting + logging. I hope you haven't spent effort on implementing this yet! btw, out of curiosity, do you have access to Mythos or is this just the new Fable model? 😛 |
|
Nevermind, you already implemented it! 🙈 |
I'm about to push it, and then start on an apply refactor to consume declarativePackage instead of reproduce the work.
Fable is mythos with pre-processing safety, but my wife has access. :) |
|
I can also rollback that commit if you do want to reverse course -- it does serve to protect the novice user at least somewhat. My apply refactor would have relevance regardless of that decision. |
|
Ok, thanks for your effort and your opinions. I can hopefully review more later tonight (central europe). |
No, it's not completely useless so just leave it since you already implemented it :) |
Pushed the resolution of the activation-safety discussion above (Option 4 + the visibility measures), scoped to
Open follow-up — switch/apply parity. That last point is a real divergence: |
|
Just FYI for transparency, this is my working spec for the apply refactor: https://gist.github.com/sini/c0a06675071414788b6586de4d516d4b |
…ia kubectl apply -f -
…eclarativePackage artifacts
Document the objectTransforms rule engine (match/rewrite/postProcess), visibility + confirmation prompt, and switch/apply parity. Register in nav and llms.txt; cross-link from Transformers and Directly Apply Manifests.
Address code-review minors: expose build._applyScript and assert the generated bash (set -eo pipefail, per-class kubectl apply -f - --prune, no glued *.yml); make the parity test a direct apply-vs-switch command comparison via _filePostProcesses; fix an illustrative docs path.
|
Updated the PR description to be comprehensive and also pushed the doc updates. |
story of my life |
arnarg
left a comment
There was a problem hiding this comment.
Other than my 2 nitpicks it's looking quite good!
…itpicks - selector's equivalent predicate now ANDs kind/namespace/labels to match the selector form (was checking kind only) - drop #!nix highlight on `resource -> resource` (not valid nix)
|
Thanks for this contribution! |
|
@arnarg https://github.com/sini/nix-config/pull/136/changes -- not just for secrets :) |
Summary
Adds a generic, declarative
objectTransformsrule engine to environments and applications. A rule matches rendered Kubernetes objects and either rewrites them at evaluation time (rewrite) or post-processes their rendered content at deploy time (postProcess) — consistently across bothnixidy switchandnixidy apply. This generalizes the existing per-releasehelm/kustomizetransformerhooks into a first-class, cross-application mechanism — useful for cluster-wide policy (label/annotation injection, kind rewrites, dropping resources) and for runtime content stages (e.g. piping a manifest through an external encryptor on write).The mechanism is deliberately domain-agnostic: nixidy gains the matching + transform plumbing; consumers express the actual rules.
Design
nixidy.objectTransforms(environment) andapplications.<name>.objectTransforms(per app) are lists of rules:matchA predicate
resource -> bool, or a declarative selector that desugars to one. Selector fields are ANDed;kind/apiVersion/namespace/namematch by exact equality (anullfield is a wildcard), whilelabels/annotationsare subset matches (the resource may carry extra keys). The default matches every resource in scope, so omittingmatchapplies a rule cluster-wide. Predicates run against the resource as seen at that point in the pipeline — i.e. after earlier rules'rewrites — so a rule that renames a kind must be matched by its new kind downstream.rewrite/postProcess(exactly one per rule)rewriteis an eval-timeresource -> resourcetransform; returningnulldrops the resource. Applied once via a sharedtransformedObjects, feeding both theenvironmentPackage/activation path and the apply path, so rewrites are consistent across outputs. Output filenames follow the rewrittenkind.postProcessattaches an activation-time stdin → stdout filter to the matched resource's output file, compiled to awriteShellApplication(so quoting/pipes/runtimeInputsare handled; invoked by store path, nosh -crequote). A bare string is the common case and coerces to{ command = <string>; runtimeInputs = []; }, matching thefileslibrary'sonChangeergonomics.commandmay also be a function resolved at eval time against the matched object ({ resource, path, pkgs, lib }), to specialize per object (e.g. pick a recipient key fromresource.metadata.namespace) without re-parsing the manifest. stdin/stdout is the contract so stages compose as a pipe, but the body is arbitrary shell — a tool needing a real file path (sops -i,yq -i) just captures stdin to a temp file and emits it back.$TARGET_PATH(the absolute destination, may not exist) is available at runtime.postProcessis runtime-only by design: it intentionally runs outside the eval sandbox so it can reach things arewritecannot (network, a hardware key, host tools). That is the whole reason it is not arewrite.Ordering & scoping
Environment rules run first, then application rules; declaration order within each. A rule sets exactly one of
rewrite/postProcess, asserted at both scopes (the offending rule'sname/index is named in the message).Activation, visibility & trust
postProcessrules (the default): the environment is synced straight to the target behind the existingdiff-guardedno changes!short-circuit — no staging, no extra copy.postProcessrules present: the environment is staged to a tmpdir, matched files are post-processed in place, thenrsync --deleted to the target.Because
postProcesscommands run at activation time outside any sandbox,switchmakes them visible before they run:[y/N]confirmation. A piped / CI / pre-commit run is non-interactive and proceeds untouched, so automation never blocks.NIXIDY_POST_PROCESS_APPROVE=1skips the prompt for trusted, repeated interactive use;NIXIDY_SKIP_POST_PROCESS=1reuses the already-rendered target files without running anything (e.g. environments without the post-process toolchain).These are visibility aids, not a security boundary — the configuration that defines a rule can also set the approval variable. The point is to surface arbitrary execution to a human switching to a config they have not vetted. See the discussion thread for the full trust-model reasoning.
Applying directly (
nixidy apply) — parity withswitchnixidy applyrunspostProcesstoo, so both deploy paths produce the same manifests. Rather than re-render a parallel manifest set, theapplyscript consumes the sameenvironmentPackagethe switch path renders: per resource it streams the clean file throughyq(to add theapps.nixidy.dev/*prune labels, before the chain) and thepostProcesschain intokubectl apply -f - --prune, per class. One render, one chain, two sinks —activatersyncs a file tree;applystreams tokubectl. The same notice +[y/N]prompt run up front (NIXIDY_POST_PROCESS_APPROVE=1skips it);NIXIDY_SKIP_POST_PROCESSis not honored onapply(there is no rendered target to fall back to — the chain always runs).The chain output must be a valid cluster manifest: a transform whose result only a GitOps controller can consume (e.g. a ksops / whole-document-encrypted file) is switch-only, and
kubectl applyrejects it (loud failure underset -eo pipefail, never a silent divergence).$TARGET_PATHis switch-only (no on-disk target onapply); the function-formpathargument is identical on both paths so commands resolve the same.Warning
Breaking change to
declarativePackage's output shape.declarativePackageno longer renders standalonecrds.yml/namespaces.yml/manifests.yml; it emits only theapplyscript (which consumesenvironmentPackage). Its only consumer was the old apply script — nothing in the CLI, lib, flake, or tests reads those files — but a workflow doingkubectl apply -f result/manifests.ymldirectly should switch to runningresult/apply(ornixidy apply).Internals
build._transformedObjects/build._filePostProcessesexpose results for tests.groupKeyOfhelper so they can't drift.Tests
tests/object-transforms-*.nixcover: matcher semantics (AND of fields, subset labels, predicate vs selector,predicateresolution for both forms);rewritetransform + null-drop + env-then-app ordering;postProcessfile attachment (sanitized dotted names, non-match exclusion), the string-shortcut coercion, the function-form command resolving against the matched object, and the rule-namesurfacing in XOR-assertion messages; and the apply path —applyFilesenumerates theenvironmentPackageresource files (excluding app-of-apps,__-apps, raw yamls) and the function-form command resolves identically on the apply and switch paths.nix run .#moduleTests→ 47/47.Notes
The API shape (
match/rewrite/postProcess, env+app scoping), the activation visibility/trust model, and switch/apply parity are settled in the thread above. User-guide documentation is included (docs/user_guide/object_transforms.md, plus cross-links from Transformers and Directly Apply Manifests). Ready for review.