manifestedit

package
v0.41.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 30, 2026 License: Apache-2.0 Imports: 14 Imported by: 0

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

View Source
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

func DocumentBody(content []byte, index int) ([]byte, bool)

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

func DocumentCount(content []byte) int

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

func NewDocument(content []byte, documentIndex int) (*Document, bool)

NewDocument builds a Document for one target document with no known file path. See NewDocumentAt to carry the path for diagnostics.

func NewDocumentAt

func NewDocumentAt(path string, content []byte, documentIndex int) (*Document, bool)

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

type FieldAssignment struct {
	Path  []string
	Value any
}

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

type FileContent struct {
	Path    string
	Content []byte
}

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.

func (Inventory) Location

func (inv Inventory) Location(id Identity) (Location, bool)

Location returns the authoritative location for an identity, if indexed.

func (Inventory) Summary

func (inv Inventory) Summary() Summary

Summary returns bounded counts over the inventory.

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 Location

type Location struct {
	Path          string
	DocumentIndex int
}

Location points at one document inside one file, relative to the scan root.

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.

type Summary

type Summary struct {
	Documents   int
	Editable    int
	NonEditable int
	Encrypted   int
	Duplicates  int
}

Summary is a compact, bounded overview of an inventory. The vision flags that GitTarget status cannot enumerate thousands of manifests, so this seeds the "high-level stats first" direction: a status surface shows these counts and keeps per-resource detail for a separate read path.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL