Documentation
¶
Overview ¶
Package manifestedit is an isolated proof of concept for the manifest-inventory "file-agnostic placement" feature. It indexes Kubernetes resources from YAML content and edits a single document in place while preserving the formatting of everything it did not change.
It is intentionally throw-away: the package proves whether gopkg.in/yaml.v3 node editing is good enough before any of this is wired into the real writer. See internal/git/manifestedit/DECISION.md.
Index ¶
- Constants
- func AppendKustomizationOverride(path string, content []byte, section, name, field, value string) (EditResult, []Diagnostic)
- func AppendKustomizationPatch(path string, content []byte, patchPath string) (EditResult, []Diagnostic)
- func AppendKustomizationResource(path string, content []byte, entry string) (EditResult, []Diagnostic)
- func Apply(c Comparison, d Decision) (EditResult, []Diagnostic)
- func CountByLevel(diags []Diagnostic) map[DiagnosticLevel]int
- func DeleteDocument(content []byte, documentIndex int) (DeleteResult, []Diagnostic)
- func DocumentBody(content []byte, index int) ([]byte, bool)
- func DocumentCount(content []byte) int
- func IndexDir(root string) (Inventory, []Diagnostic)
- func IndexFile(path string, content []byte) (Inventory, []Diagnostic)
- func IndexFiles(files []FileContent) (Inventory, []Diagnostic)
- func OwnsAssignedPaths(assignments []FieldAssignment) func(FieldPath) bool
- func PartialDesired(id Identity, assignments []FieldAssignment) (*unstructured.Unstructured, error)
- func PatchDocument(content []byte, documentIndex int, desired *unstructured.Unstructured, ...) (EditResult, []Diagnostic)
- func PatchFields(content []byte, documentIndex int, id Identity, assignments []FieldAssignment, ...) (EditResult, []Diagnostic)
- func PatchKustomization(path string, content []byte, edits []KustomizationEdit) (EditResult, []Diagnostic)
- func RemoveKustomizationResource(path string, content []byte, entry string) (EditResult, []Diagnostic)
- type Comparison
- type Decision
- type DecisionAction
- type DeleteResult
- type DiagReason
- type Diagnostic
- type DiagnosticLevel
- type Document
- type DocumentRecord
- type EditMode
- type EditOptions
- type EditResult
- type FieldAssignment
- type FieldPath
- type FileContent
- type Identity
- type Inventory
- type KustomizationEdit
- type ListMatchStrategy
- type Location
- type SnapshotRef
- type Summary
Constants ¶
const ( KustomizationSectionImages = "images" KustomizationSectionReplicas = "replicas" )
Kustomization override sections the editor accepts. The editor is the mechanism half of the images/replicas edit-through (docs/design/support-boundary/finished/images-and-replicas-edit-through.md): it updates the scalar value of a field that ALREADY EXISTS on an entry that ALREADY EXISTS, and nothing else — it never adds or removes entries, keys, or files.
Variables ¶
This section is empty.
Functions ¶
func AppendKustomizationOverride ¶ added in v0.37.0
func AppendKustomizationOverride( path string, content []byte, section, name, field, value string, ) (EditResult, []Diagnostic)
AppendKustomizationOverride AUTHORS a new images:/replicas: override entry in a kustomization.yaml — the mechanism half of "edit a specific environment and the override is added for you" (docs/design/support-boundary/render-root-scoping.md §4). Unlike PatchKustomization, which only updates a scalar on an entry that already exists, this creates the entry (and the section sequence, if the kustomization has none yet) so an overlay can override a value its base supplies WITHOUT touching the read-only base.
It writes exactly one field beside name:, matching how the writer inverts one changed image component or replica count at a time:
images: { name: <image name>, newName|newTag|digest: <value> }
replicas: { name: <resource name>, count: <value> }
It is idempotent and never duplicates: an entry named name that already sets field to value is a no-op (EditNoChange), so a resync re-observing the same live state does not append a second entry. All-or-nothing like its siblings: a multi-document file, unparseable YAML, or a non-mapping document skips the whole call with a diagnostic. Every proposal it produces is put to kustomize by the re-render oracle before it can become a commit, so an entry that would over-reach (an images: name shared by another object the overlay did not mean to move) is refused there, not written.
func AppendKustomizationPatch ¶ added in v0.37.0
func AppendKustomizationPatch(path string, content []byte, patchPath string) (EditResult, []Diagnostic)
AppendKustomizationPatch adds a path-based `patches:` entry (`{ path: <patchPath> }`) to a kustomization.yaml, creating the patches: sequence if it has none — the mechanism half of authoring a `$patch: delete` for an object an overlay inherits from its base (docs/design/support-boundary/render-root-scoping.md §4). The patch document itself is written separately; this only names it so kustomize applies it.
It is idempotent (an entry that already names patchPath is EditNoChange) and all-or-nothing like its siblings. The re-render oracle verifies the object actually disappears from the render before the flush can commit, so a patch that does not match is refused there, not committed.
func AppendKustomizationResource ¶
func AppendKustomizationResource(path string, content []byte, entry string) (EditResult, []Diagnostic)
AppendKustomizationResource adds one entry to an existing kustomization.yaml's resources: sequence — the mechanism half of the "add to the right kustomize file" (docs/spec/gittarget-new-file-placement-rules.md): a new sibling file placed inside a kustomize-governed directory must also be named in that directory's resources: list, or kustomize never renders it.
It is idempotent: if entry already appears in the sequence, the call is a no-op (EditNoChange), never a duplicate append. All-or-nothing like PatchKustomization: a multi-document file, unparseable YAML, or a document with no existing resources: sequence skips the whole call with a diagnostic — the writer never invents a resources: key that is not already there, mirroring the edit-through's "never creates a kustomization file" boundary one level down (never creates a resources: section either).
func Apply ¶
func Apply(c Comparison, d Decision) (EditResult, []Diagnostic)
Apply is authoritative: it re-parses c.Git, validates the snapshot, performs the edit, and returns what actually happened. There is no separate file argument — c.Git is the single source of truth for the bytes. The returned EditResult.Mode is the truth about what happened, not the Decision: a Patch intent may legitimately land on Replace if a node turns out ambiguous, or on a soft Skip if the snapshot drifted.
func CountByLevel ¶
func CountByLevel(diags []Diagnostic) map[DiagnosticLevel]int
CountByLevel groups diagnostics by severity, for a bounded status summary instead of listing every diagnostic.
func DeleteDocument ¶
func DeleteDocument(content []byte, documentIndex int) (DeleteResult, []Diagnostic)
DeleteDocument removes one document from a file, leaving every surviving document's content byte-for-byte intact. Removing the only document reports FileEmpty so the caller can delete the file. This serves both resource deletion and pruning a duplicate loser.
It is a thin wrapper over Decide + Apply with Desired == nil. Deletion is the content-agnostic cell of the comparison: it never decrypts or merges, so an encrypted document, a disallowed-construct document, or a duplicate loser can always be pruned. No renderer is needed.
When the first document is removed, the new first document's leading "---" separator is dropped so the file does not start with a stray separator. Only the separator is affected; the document content is unchanged. We deliberately prefer a clean leading document over preserving a now-pointless separator.
func DocumentBody ¶
DocumentBody returns the exact bytes of the document at index within content, carved by the same byte-faithful splitter every edit path uses. ok is false when the index is out of range. Callers use it to parse one document's raw object without re-implementing document splitting.
func DocumentCount ¶
DocumentCount reports how many non-empty YAML documents a file holds. It is block-scalar aware (it reuses the byte-faithful splitter), and ignores empty documents such as a trailing "---". Callers use it to refuse a single-document wholesale write that would silently drop the other documents in a shared file.
func IndexDir ¶
func IndexDir(root string) (Inventory, []Diagnostic)
IndexDir recursively scans a folder for YAML manifests and builds an inventory. Paths in the inventory are relative to root. Symlinks are never followed: a symlinked file or directory is skipped, which avoids escaping the scan root and symlink cycles.
func IndexFile ¶
func IndexFile(path string, content []byte) (Inventory, []Diagnostic)
IndexFile builds an inventory from a single file's content.
func IndexFiles ¶
func IndexFiles(files []FileContent) (Inventory, []Diagnostic)
IndexFiles builds an inventory from several files. Scan order is deterministic (lexicographic path, then document index) so duplicate resolution is stable.
func OwnsAssignedPaths ¶
func OwnsAssignedPaths(assignments []FieldAssignment) func(FieldPath) bool
OwnsAssignedPaths returns the Comparison.Options.Owns predicate for a field patch: it owns exactly the assigned paths and their descendants. Because the merge consults ownership only to decide whether a Git field absent from desired should be deleted, owning just the assigned subtrees means the patch can replace an assigned field (including pruning sub-keys of a map-valued assignment) while never deleting any field outside an assignment. A scalar assignment has no descendants, so it cannot delete anything at all — it only overwrites its leaf.
This predicate is always derived from the assignments, never caller-supplied: a field patch's ownership is a property of the patch, not a tunable. That is the one sanctioned non-nil Owns (the field-ownership spike forbids ownership as configuration); here it is scoped to a single edit and not exposed as a knob.
func PartialDesired ¶
func PartialDesired(id Identity, assignments []FieldAssignment) (*unstructured.Unstructured, error)
PartialDesired builds the Comparison.Desired for a field patch: an object carrying only the parent identity plus each assignment's value at its path. Identity is included so the whole-object merge does not see apiVersion/kind/ metadata as "absent from desired" — those are not owned by the patch, so they would be left regardless, but carrying them keeps Decide's identity snapshot meaningful and the no-op comparison honest.
Assignments must have non-empty, disjoint paths; an empty path or a path that descends through a value an earlier assignment set as a scalar is a programming error and returns one.
func PatchDocument ¶
func PatchDocument( content []byte, documentIndex int, desired *unstructured.Unstructured, opts EditOptions, ) (EditResult, []Diagnostic)
PatchDocument updates one document inside a file to match the desired object, touching only what changed and leaving every other document byte-for-byte identical. It is a thin wrapper over Decide + Apply.
The desired object must already be the clean Git projection: this package is mechanism, not policy, so it never sanitizes internally. The caller passes the projected object and injects the canonical renderer (opts.Render), used for the whole-document replace fallback.
func PatchFields ¶
func PatchFields( content []byte, documentIndex int, id Identity, assignments []FieldAssignment, opts EditOptions, ) (EditResult, []Diagnostic)
PatchFields applies a field patch to one document inside a file: it sets the assigned paths on the document for id and leaves every other field and document byte-for-byte identical. It is the field-patch analog of PatchDocument, wiring PartialDesired and OwnsAssignedPaths through the same Decide + Apply path, so it inherits the snapshot guard, the encrypted-document refusal, and formatting preservation. opts.Owns is always overwritten with the patch's own ownership; the caller injects only opts.Render (for the replace fallback) and opts.ListMatch.
A skip (the document is missing, encrypted, or non-editable) surfaces as an EditSkipped result with a diagnostic, exactly like PatchDocument — the caller decides whether that is "no parent in Git, drop" or "unsafe, drop".
func PatchKustomization ¶
func PatchKustomization(path string, content []byte, edits []KustomizationEdit) (EditResult, []Diagnostic)
PatchKustomization applies the edits to a single-document kustomization file, preserving comments, key order, and framing exactly as the manifest patch path does. All-or-nothing: any edit that cannot be applied (multi-document file, unparseable YAML, missing section/entry/field, a name mismatch at the pinned index) skips the whole call and returns the original content with a diagnostic — the writer must never guess inside a build directive.
func RemoveKustomizationResource ¶
func RemoveKustomizationResource(path string, content []byte, entry string) (EditResult, []Diagnostic)
RemoveKustomizationResource drops one entry from an existing kustomization.yaml's resources: sequence. It is AppendKustomizationResource's counterpart, and it exists for exactly the reason that one does, read backwards: a file named in resources: that no longer exists is a file kustomize refuses to build over —
accumulating resources ... '/scan/apps/api.yaml' doesn't exist
so deleting a managed document without removing its entry leaves a repository no GitOps controller can deploy. Deleting the manifest is only half the delete.
It is idempotent (an entry that is not there is EditNoChange) and all-or-nothing in the same way as its sibling: a multi-document file, unparseable YAML, or a document with no resources: sequence skips the whole call with a diagnostic rather than inventing structure. An emptied sequence is left as an empty sequence — removing the key is not this function's call to make.
Types ¶
type Comparison ¶
type Comparison struct {
// Git is required: a Comparison always describes an existing document. A nil
// Git is not a valid comparison — creating a brand-new resource is a placement
// decision owned upstream, not a content edit.
Git *Document
// Desired is the clean object Git should contain. Nil means "absent" and
// models deletion as just another cell of the same comparison.
Desired *unstructured.Unstructured
// Options injects the renderer and the (future) list-match and ownership
// strategies.
Options EditOptions
}
Comparison is the two-version comparison: an existing Git document against the desired object Git should contain.
type Decision ¶
type Decision struct {
Action DecisionAction
Reason string
Snapshot SnapshotRef
// contains filtered or unexported fields
}
Decision is the result of the pure preflight. It states an intent; the merge happens only in Apply, whose EditResult.Mode is authoritative.
func Decide ¶
func Decide(c Comparison) Decision
Decide is a pure preflight: it inspects and compares, never mutating Git. It runs only cheap, non-mutating checks — parseable? disallowed construct? encrypted? non-mapping root? object-level equality — and never runs the structural merge, so a decision can never silently change Git.
type DecisionAction ¶
type DecisionAction string
DecisionAction is the intent Decide states before any merge runs.
const ( // ActionNoChange means Git already matches the desired projection. ActionNoChange DecisionAction = "no-change" // ActionPatch means a field-level in-place edit is expected. ActionPatch DecisionAction = "patch" // ActionReplace means the document must be re-rendered canonically. ActionReplace DecisionAction = "replace" // ActionDelete means the document should be removed. ActionDelete DecisionAction = "delete" // ActionSkip means the document is left untouched, with a diagnostic. ActionSkip DecisionAction = "skip" )
type DeleteResult ¶
type DeleteResult struct {
// Content is the file content after removal. It is nil when FileEmpty is true.
Content []byte
// FileEmpty is true when the removed document was the only one, so the caller
// should delete the file rather than write empty content.
FileEmpty bool
Mode EditMode
}
DeleteResult is the outcome of removing one document from a file.
type DiagReason ¶
type DiagReason string
DiagReason is a structured, machine-readable cause for a diagnostic. It lets callers classify a document from a code rather than by parsing the human-readable Message — which the manifest materialization design explicitly forbids. The zero value is the empty reason, used for diagnostics that carry no structured classification (e.g. edit-time skips).
const ( // ReasonInvalidYAML marks a document that does not parse as YAML. ReasonInvalidYAML DiagReason = "invalid-yaml" // ReasonEmptyDocument marks an empty or comment-only document. ReasonEmptyDocument DiagReason = "empty-document" // ReasonNotKRM marks valid YAML that is not a Kubernetes manifest. ReasonNotKRM DiagReason = "not-krm" // ReasonNonEditable marks a manifest the editor refuses to edit in place // (anchors, aliases, merge keys, unusual tags, duplicate keys). ReasonNonEditable DiagReason = "non-editable" // ReasonMissingSopsKey marks a .sops.yaml file lacking a sops stanza. ReasonMissingSopsKey DiagReason = "missing-sops-key" // ReasonDuplicateIdentity marks a document whose manifest identity duplicates // an earlier occurrence. ReasonDuplicateIdentity DiagReason = "duplicate-identity" )
type Diagnostic ¶
type Diagnostic struct {
Level DiagnosticLevel `json:"level"`
// Reason is the structured cause, set for index-time classification so callers
// never parse Message. It is empty for diagnostics with no structured code.
Reason DiagReason `json:"reason,omitempty"`
Message string `json:"message"`
Path string `json:"path"`
DocumentIndex int `json:"documentIndex"`
}
Diagnostic explains an inventory or edit decision.
type DiagnosticLevel ¶
type DiagnosticLevel string
DiagnosticLevel classifies how serious a diagnostic is.
const ( // DiagInfo is informational and never blocks editing. DiagInfo DiagnosticLevel = "info" // DiagWarning marks something skipped or ignored but not fatal to the file. DiagWarning DiagnosticLevel = "warning" // DiagError marks content that cannot be edited safely. DiagError DiagnosticLevel = "error" )
type Document ¶
type Document struct {
// Path is the file location relative to the scan root, carried so Apply's
// diagnostics can name the file (e.g. "apps/deploy.yaml doc 0"), not just the
// document index. It is informational: it does not affect the edit.
Path string
// Content is the whole file, so Apply can splice the edited document back
// among its untouched siblings.
Content []byte
// DocumentIndex is the target document's position within the file.
DocumentIndex int
// Identity is the manifest identity of the target document, as written.
Identity Identity
}
Document is immutable data describing one target document inside a file: the whole file content, the target document index, and the manifest identity. It deliberately carries no parsed node tree — Decide and Apply each parse internally, so nothing one mutates can affect the other. This is what lets Decide stay non-mutating.
func NewDocument ¶
NewDocument builds a Document for one target document with no known file path. See NewDocumentAt to carry the path for diagnostics.
func NewDocumentAt ¶
NewDocumentAt builds a Document for one target document at a known path, deriving its identity from the content. ok is false when the index is out of range; the Document is still returned (with a zero Identity) so callers can hand it to Decide, which reports the out-of-range condition as a skip.
type DocumentRecord ¶
type DocumentRecord struct {
Identity Identity
Location Location
// Editable is false when the document uses constructs the POC refuses to edit
// (anchors, aliases, merge keys) or when it lost a duplicate-identity contest.
Editable bool
// Reason explains a non-editable record.
Reason string
// Encrypted is true for a SOPS-managed document with cleartext identity.
Encrypted bool
}
DocumentRecord is one indexed Kubernetes document.
type EditMode ¶
type EditMode string
EditMode describes what PatchDocument did.
const ( // EditNoChange means the document already matched the clean desired projection. EditNoChange EditMode = "no-change" // EditPatched means only the changed nodes were updated in place. EditPatched EditMode = "patched" // EditWholeReplace means the whole document body was re-rendered as a fallback. EditWholeReplace EditMode = "whole-replace" // EditSkipped means the document was left untouched because editing was unsafe. EditSkipped EditMode = "skipped" )
const EditDeleted EditMode = "deleted"
EditDeleted means a document was removed from the file.
type EditOptions ¶
type EditOptions struct {
// Render is the canonical renderer for whole-document replacement and new
// files — the house output format, so it is policy: injected, not owned here.
// Nil is allowed only when no canonical output is needed (pure patch, no-op,
// delete); a path that needs it with no Render fails loudly with a diagnostic.
Render func(*unstructured.Unstructured) ([]byte, error)
// ListMatch aligns sequences (default: by index).
ListMatch ListMatchStrategy
// Owns is a DORMANT mechanism seam, not a product feature. It reports whether a
// field path is owned by the reverser; an absent field is deleted only when
// owned. The product decision is API-first, whole-object truth: production
// MUST leave this nil (own everything), so a field absent from the desired
// projection is deleted from Git. See
// docs/spec/manifestedit-field-ownership-spike.md. Do not grow configuration
// on top of this; it exists only to keep the deletion decision explicit and to
// keep the merge testable.
Owns func(path FieldPath) bool
}
EditOptions carries the injected strategies. They are the one seam where later strategies plug in, so the core merge stays small and pure.
type EditResult ¶
type EditResult struct {
// Content is the full file content after the edit.
Content []byte
Mode EditMode
}
EditResult is the outcome of editing one document.
type FieldAssignment ¶
FieldAssignment is one (path, value) assignment of a field patch: set the node at Path to Value, leaving every other field in the document untouched. Path is from the document root, e.g. {"spec","replicas"}. Value is a JSON-native unstructured value (string, int64, float64, bool, nil, map[string]interface{}, []interface{}) — exactly what decoding an audit body as unstructured yields.
type FieldPath ¶
type FieldPath []string
FieldPath is a path to a node within a document, used by the ownership predicate. The root object is the empty path; "spec", "replicas" addresses spec.replicas.
type FileContent ¶
FileContent pairs a path with its raw bytes for multi-file indexing.
type Identity ¶
type Identity struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Namespace string `json:"namespace"`
Name string `json:"name"`
}
Identity is the manifest (content) identity of a Kubernetes object: the GVK plus name and, for namespaced objects, namespace, exactly as written in YAML. It is deliberately not the API-side resource identity (GVR); mapping a GVK to a GVR needs a live RESTMapper and is out of scope for this POC.
type Inventory ¶
type Inventory struct {
// Records are all indexed documents in stable scan order (path, then index).
Records []DocumentRecord
// contains filtered or unexported fields
}
Inventory is the mapping from resource identity to its authoritative location, plus the full list of records and any duplicate losers that must be deleted.
func (Inventory) Duplicates ¶
func (inv Inventory) Duplicates() []DocumentRecord
Duplicates returns the records that lost the first-occurrence-wins contest.
type KustomizationEdit ¶
type KustomizationEdit struct {
// Section is KustomizationSectionImages or KustomizationSectionReplicas.
Section string
// EntryIndex is the entry's position within the section sequence.
EntryIndex int
// EntryName is the entry's name: value, verified against EntryIndex.
EntryName string
// Field is the scalar key to update: newName/newTag/digest, or count.
Field string
// Value is the new scalar value; for count it is a decimal integer.
Value string
}
KustomizationEdit sets one existing scalar field on one existing entry of a kustomization.yaml override section. EntryIndex pins the exact entry (two entries may share a name and kustomize applies them in order); EntryName is re-verified against it so a drifted file is skipped, never mis-edited.
type ListMatchStrategy ¶
type ListMatchStrategy struct {
// KeyField, when set, matches list items by that field instead of by index.
KeyField string
}
ListMatchStrategy aligns desired and Git sequence items. The zero value matches by index (today's behavior). A keyed strategy names the field to match on; the GVK->field choice is made above this layer, never baked into the YAML merge.
type SnapshotRef ¶
type SnapshotRef struct {
// Identity is the observed manifest identity of the target document.
Identity Identity
// DocumentIndex is the observed target document index.
DocumentIndex int
// BodyHash fingerprints the target document body only — sibling documents can
// change without invalidating the target edit.
BodyHash string
}
SnapshotRef is the identity and content fingerprint that Decide observed. Apply re-parses the document and validates against this, refusing if the file drifted in between.