Documentation
¶
Overview ¶
Package reconcile is the pure half of the change-plan engine (P7-LIVE-01).
Purpose: one in-memory model of "what a command is about to change" that every mutating command (build, config set, doctor --fix, plugin and bundle install/remove, db hasura apply-ref) renders the same way. The package holds the Plan type (contract:cli.change-plan v1), the artifact diff, the destructive/confirmation classification, the env class, the canonical JSON and plan_id, and a deterministic human renderer.
Constraints: stdlib plus internal/observability only. No filesystem, docker, generator or command wiring lives here; P7-LIVE-03 adds the build edge. Nothing in a Plan's JSON depends on time or map iteration order.
Index ¶
- Constants
- func CanonicalJSON(p Plan) ([]byte, error)
- func Confirm(p Plan, opt ApplyOptions, v15 bool, stderr io.Writer) error
- func GeneratedStatePath(projectDir string) string
- func HandEditedPaths(p Plan) []string
- func IsEnvPath(p string) bool
- func IsGenerated(data []byte) bool
- func MarshalPlan(p Plan) ([]byte, error)
- func NormalizeEnv(env string) string
- func PlanID(p Plan) (string, error)
- func RenderHuman(w io.Writer, p Plan) error
- func Unified(path string, a, b []byte) string
- type Action
- type AppliedBy
- type ApplyOptions
- type Artifact
- type ArtifactKind
- type ArtifactSet
- type Command
- type ContainerAction
- type ContainerItem
- type ContainerRuntime
- type Containers
- type Effect
- type EffectKind
- type EnvClassName
- type File
- type GeneratedHostEntry
- type GeneratedState
- type Plan
- type Request
- type RunningContainer
- type Trigger
- type TriggerKind
Constants ¶
const ( PluginsPrefix = "@plugins/" AbsPrefix = "@abs/" )
PluginsPrefix marks a file under the plugin directory (a compose fragment the build rewrites in place); AbsPrefix any other file outside the project.
const ConfirmFlagHint = "--yes"
ConfirmFlagHint is the flag a non-interactive caller adds to proceed.
const FrontingPrefix = "@fronting/"
FrontingPrefix marks a path that belongs to the fronting stack rather than the project (contract: `@fronting/<rel>`).
const GeneratedStateDisplayPath = ".nself/state/generated.json"
GeneratedStateDisplayPath is the record's location as a plan display path (project-relative, "/" separators): the artifact path the plan reports for the record itself.
const MaxDiffLines = 20000
MaxDiffLines is the per-side line cap. When either side of a changed artifact is longer, Diff reports the change with DiffLines = -1 and Unified returns no text, which bounds the cost of a plan over huge files.
const SchemaVersion = "1"
SchemaVersion is the change-plan contract version carried in every Plan.
Variables ¶
This section is empty.
Functions ¶
func CanonicalJSON ¶
CanonicalJSON returns the canonical encoding of a plan (EPIC D9).
Inputs: a Plan (finalised or not). Outputs: compact json.Marshal bytes of a copy whose PlanID is "" and whose nil slices are empty, with HTML escaping off ("<", ">", "&" are written literally). Field order is the struct order, which is the contract's key order; the type has no maps, so the bytes depend only on the values. The input is not modified.
func GeneratedStatePath ¶
GeneratedStatePath is the record's location in a project.
func HandEditedPaths ¶
HandEditedPaths returns the paths of artifacts a human changed that this plan would overwrite or remove, in plan order. These are the files that need --force in any env (EPIC D4). Callers use it to build the refusal text.
func IsEnvPath ¶
IsEnvPath reports whether a path is env-kind: a basename starting with ".env" (.env, .env.dev, .env.computed, .env.secrets) or .nself/compose.env. Env-kind artifacts are always redacted in diffs and plans.
func IsGenerated ¶
IsGenerated reports whether the first 512 bytes of data carry the nself generated-file marker, matched case-insensitively.
func MarshalPlan ¶
MarshalPlan renders a plan as the contract's JSON: 2-space indent, keys in contract order, trailing newline. This is the `data` of the v1 envelope and the form golden files pin. Nil slices render as [] (see CanonicalJSON).
func NormalizeEnv ¶
NormalizeEnv returns the normalised ENV name used in Plan.Env.
Inputs: a raw ENV value. Outputs: trimmed, lower-cased, with the aliases the config package folds (development/develop/devel to dev, production to prod, stage to staging). Unknown names pass through lower-cased; empty stays empty. Mirrors internal/config normalizeEnv without importing it (this package stays free of config and filesystem edges).
func PlanID ¶
PlanID returns the plan_id of a plan: lowercase hex sha256 of its CanonicalJSON. Two computations over the same plan are equal, and any change to an artifact, effect, container item or header field changes the value.
func RenderHuman ¶
RenderHuman writes the deterministic human form of a plan.
Inputs: a writer and a (normally finalised) Plan. Outputs: a header `Plan <first 12 of plan_id> (<command>, env <env>/<class>)`, then one line per artifact, effect and container item under Artifacts, Effects and Containers headings (a heading is omitted when empty), or `(no changes)` for an empty plan. A destructive plan lists its reasons, and a plan that needs confirmation says so. The output depends only on the plan's values: no time, no map order, no terminal width. Unified diffs are not part of it (`--diff` prints those). The error is the writer's.
func Unified ¶
Unified returns a unified diff of one artifact for `--diff` output.
Inputs: the artifact path and the before/after bytes (nil for an absent side). Outputs: "--- a/<path>", "+++ b/<path>" and hunks with three lines of context, or "" when the sides are equal or either exceeds MaxDiffLines. Env-kind paths (IsEnvPath) show KEY names only: every KEY=value line prints as KEY=[REDACTED], and the continuation lines of a multi-line or quoted value print as [REDACTED] (see redactEnvLines), before the line is written, so no value reaches the text. When the change is too large for an edit script (over maxTraceDistance changed lines, or maxEditDistance) the text is the two header lines and a one-line notice.
Types ¶
type ApplyOptions ¶
type ApplyOptions struct {
// Yes confirms a prod-class plan without a prompt.
Yes bool
// Force allows overwriting or removing a hand-edited generated file.
Force bool
// PlanID, when set, must equal the recomputed plan's id (E450 otherwise).
PlanID string
// BeforeWrite runs after the plan id and the confirmation have passed and
// before the write-mode build; an error stops the apply. Nil does nothing.
BeforeWrite func() error
// Interactive asks the operator a yes/no question and reports the answer.
// Nil means the session is not interactive: nothing can be asked.
Interactive func(prompt string) bool
}
ApplyOptions are the operator's answers to a plan.
type Artifact ¶
type Artifact struct {
Kind ArtifactKind `json:"kind"`
Path string `json:"path"`
Action Action `json:"action"`
Generated bool `json:"generated"`
HandEdited bool `json:"hand_edited"`
DiffLines int `json:"diff_lines"`
Redacted bool `json:"redacted"`
}
Artifact is one changed file. Unchanged files never appear. Path is project-relative with "/" separators, or "@fronting/<rel>" for the fronting stack. DiffLines is inserted plus deleted lines, or -1 when a side exceeds MaxDiffLines.
func Diff ¶
func Diff(before, after ArtifactSet, handEdited func(path string) bool) []Artifact
Diff compares two artifact sets and returns the changed items.
Inputs: before (what is on disk) and after (what the pipeline rendered); handEdited reports whether a path was changed by a human since nself last wrote it (nil means never). Outputs: add, change and remove items sorted by path; unchanged files are omitted. Only file bytes are compared: a permission-only difference is not a change here. generated is true when either side carries the nself marker; hand_edited is only set on change and remove (an add has nothing to overwrite); redacted is true for env-kind paths. DiffLines is inserted plus deleted lines (a change of one line is 2), the whole line count for add and remove, and -1 (for any action) over MaxDiffLines on a side. A change whose edit distance passes maxEditDistance is also -1: the count is not computed past that bound. The action of an add or remove over the cap stays add/remove: it describes what happens to the file, and -1 only says the size is not reported.
type ArtifactKind ¶
type ArtifactKind string
ArtifactKind is the `artifacts[].kind` enum.
const ( KindCompose ArtifactKind = "compose" KindNginx ArtifactKind = "nginx" KindSSL ArtifactKind = "ssl" KindEnv ArtifactKind = "env" KindHasura ArtifactKind = "hasura" KindMonitoring ArtifactKind = "monitoring" KindPostgresInit ArtifactKind = "postgres-init" KindState ArtifactKind = "state" KindLock ArtifactKind = "lock" KindOther ArtifactKind = "other" )
ArtifactKind values.
func Kind ¶
func Kind(p string) ArtifactKind
Kind classifies a path per the change-plan contract.
Inputs: an artifact path. Outputs: compose (docker-compose*.yml|yaml at the root), nginx (nginx/**), ssl (ssl/**), env (.env* basenames and .nself/compose.env), hasura, monitoring, postgres-init (postgres/init/**), lock (nself.lock.yaml), state (.nself/**), else other. A "@fronting/" prefix is ignored for classification, so the fronting stack's nginx tree is still nginx. Env is checked before state so .nself/compose.env is env.
type ArtifactSet ¶
ArtifactSet maps a normalised path (project-relative, "/" separators, or "@fronting/<rel>") to its file. Diff compares two sets. A nil set is empty.
type Command ¶
type Command string
Command is the `command` enum of the change-plan contract.
const ( CmdBuild Command = "build" CmdConfigSet Command = "config-set" CmdDoctorFix Command = "doctor-fix" CmdPluginInstall Command = "plugin-install" CmdPluginRemove Command = "plugin-remove" CmdBundleInstall Command = "bundle-install" CmdBundleRemove Command = "bundle-remove" CmdDBHasuraApplyRef Command = "db-hasura-apply-ref" )
Command values, exactly as written in the contract.
type ContainerAction ¶
type ContainerAction string
ContainerAction is the `containers.items[].action` enum. ContainerNone items are dropped by Finalize ("none omitted").
const ( ContainerRecreate ContainerAction = "recreate" ContainerNone ContainerAction = "none" ContainerUnknown ContainerAction = "unknown" )
ContainerAction values.
type ContainerItem ¶
type ContainerItem struct {
Service string `json:"service"`
Action ContainerAction `json:"action"`
Stateful bool `json:"stateful"`
AppliedBy AppliedBy `json:"applied_by"`
}
ContainerItem is the impact on one compose service.
type ContainerRuntime ¶
type ContainerRuntime interface {
// Running lists the project's containers with their config-hash labels. An
// error means the daemon could not be asked.
Running(ctx context.Context, project string) ([]RunningContainer, error)
// ConfigHashes returns service -> config hash for the given compose files,
// env files and project directory.
ConfigHashes(ctx context.Context, files, envFiles []string, projectDir string) (map[string]string, error)
}
ContainerRuntime is the docker half of container impact. The default implementation shells out through internal/docker; tests inject a fake.
type Containers ¶
type Containers struct {
Known bool `json:"known"`
Items []ContainerItem `json:"items"`
}
Containers is the running-container impact. Known is false when the running state could not be read; Items is never nil after Finalize.
type Effect ¶
type Effect struct {
Kind EffectKind `json:"kind"`
Target string `json:"target"`
Detail string `json:"detail"`
}
Effect is a host-level side effect. Detail never carries secret values.
type EffectKind ¶
type EffectKind string
EffectKind is the `effects[].kind` enum.
const ( EffectHosts EffectKind = "hosts" EffectTrustStore EffectKind = "trust-store" EffectCertificates EffectKind = "certificates" EffectPluginFragment EffectKind = "plugin-fragment" EffectPluginInstall EffectKind = "plugin-install" EffectPluginRemove EffectKind = "plugin-remove" EffectSecretsPersist EffectKind = "secrets-persist" EffectNginxSitesBkp EffectKind = "nginx-sites-backup" EffectOrphanRemove EffectKind = "orphan-remove" EffectBuildLock EffectKind = "build-lock" )
EffectKind values.
type EnvClassName ¶
type EnvClassName string
EnvClassName is the `env_class` enum of the change-plan contract.
const ( ClassProd EnvClassName = "prod" ClassDev EnvClassName = "dev" )
EnvClassName values.
func EnvClass ¶
func EnvClass(env string) EnvClassName
EnvClass classes an environment name per EPIC D3.
Inputs: a raw or normalised ENV (also the --env of a remote db command). Outputs: ClassProd when the normalised name is prod or staging, else ClassDev. Empty and unknown names are dev: a running stack does not make an env prod-class, so local changes never teach --yes by reflex.
type GeneratedHostEntry ¶
GeneratedHostEntry is one host-level entry nself created (an /etc/hosts block, a trust-store entry).
type GeneratedState ¶
type GeneratedState struct {
SchemaVersion string `json:"schema_version"`
Files map[string]string `json:"files"`
Host []GeneratedHostEntry `json:"host"`
}
GeneratedState is what nself last wrote in a project: the sha256 of every file it wrote, keyed by the plan's display path (project-relative for project files, "@fronting/<rel>", "@plugins/<rel>" or "@abs/<path>" for files outside the project), and the host-level entries it created by effect kind and target.
func LoadGeneratedState ¶
func LoadGeneratedState(projectDir string) (*GeneratedState, error)
LoadGeneratedState reads the record. A missing record is not an error: it returns (nil, nil), the no-state-yet case of D5. A record that exists but cannot be parsed is an error: detection would silently answer "never hand-edited" over an unknown file, so the operator is told to remove it (the next successful apply re-records).
func PlannedRecord ¶
func PlannedRecord(state *GeneratedState, pb *nbuild.PlannedBuild) (*GeneratedState, []byte, error)
PlannedRecord returns what a successful apply of pb leaves in the record, and the exact bytes of it: a copy of the current record with pb folded in (RecordPlanned), serialised the way Save writes it. Pure, so the plan can report the record as an artifact (kind state) whose bytes are exactly what the apply then writes — plan == apply stays exact. The record never holds an entry for itself: the folded set is the build's render, not the record, so there is no self-reference to resolve.
func (*GeneratedState) HandEdited ¶
func (s *GeneratedState) HandEdited(path string, current []byte) bool
HandEdited reports whether the file at path (a plan display path) was changed by a human since nself last wrote it: the record holds the sha256 of the bytes it wrote and current hashes differently. A nil record, a missing entry or a file that cannot be read is never hand-edited — detection fails open here so an unreadable file cannot block a build; the overwrite itself is what Confirm guards.
func (*GeneratedState) HandEditedFn ¶
func (s *GeneratedState) HandEditedFn(projectDir string) func(path string) bool
HandEditedFn returns the Request.HandEdited callback over this record: it resolves each display path to its location on disk and reads the file's current bytes. A nil record (no state yet) answers false for everything: the first run on an existing project records and never blocks.
func (*GeneratedState) RecordPlanned ¶
func (s *GeneratedState) RecordPlanned(pb *nbuild.PlannedBuild)
RecordPlanned folds one successful apply into the record: every file the write was held to is (re)recorded with the sha256 of those exact bytes, every removal drops its entry, and the host effects nself creates (hosts blocks, trust-store entries) are recorded by kind and target. Other effect kinds act outside the project tree or are bookkeeping and stay unrecorded.
func (*GeneratedState) Save ¶
func (s *GeneratedState) Save(projectDir string) error
Save writes the record deterministically and atomically: file keys are sorted by encoding/json, host entries are sorted and deduped here, the body goes to a temp file in the state directory and one rename puts it in place, so a reader (or a crash) never sees a partial record.
type Plan ¶
type Plan struct {
SchemaVersion string `json:"schema_version"`
PlanID string `json:"plan_id"`
Command Command `json:"command"`
Trigger Trigger `json:"trigger"`
Env string `json:"env"`
EnvClass EnvClassName `json:"env_class"`
Artifacts []Artifact `json:"artifacts"`
Effects []Effect `json:"effects"`
Containers Containers `json:"containers"`
Destructive bool `json:"destructive"`
DestructiveReasons []string `json:"destructive_reasons"`
RequiresConfirmation bool `json:"requires_confirmation"`
Empty bool `json:"empty"`
}
Plan implements contract:cli.change-plan v1. The struct field order is the contract's key order and is also the canonical-JSON order; there are no maps in the type, so encoding is deterministic.
func ApplyBuild ¶
func ApplyBuild(ctx context.Context, req Request, opt ApplyOptions) (*Plan, *nbuild.BuildResult, error)
ApplyBuild is Apply that also returns the write-mode build result.
One locked render (EPIC ruling B): the project lock is taken first and held to the end; the project is rendered once in plan mode and that render is the plan; the plan id is checked; the operator confirms; and the write build is then held to exactly that render (nbuild.BuildOptions.Expect), so a value edited while the prompt waited cannot reach the disk. A re-render right before the write refuses (E450) a change made during the prompt before anything is written. The compat switch is read once, first: a project file cannot toggle it mid-command.
func Compute ¶
Compute computes the change plan of req. (EPIC D6 names it reconcile.Plan; that name is the Plan type of P7-LIVE-01, so the function is Compute.)
func (*Plan) Finalize ¶
Finalize normalises the plan and computes every derived field.
Inputs: a Plan whose Command, Trigger, Env, Artifacts, Effects and Containers are filled in by the caller. Outputs: the same Plan with SchemaVersion set, Env normalised, EnvClass derived, arrays non-nil and sorted (artifacts by path; effects by kind, target; containers by service; reasons lexically), container items with action "none" dropped, Empty, Destructive, DestructiveReasons and RequiresConfirmation computed, and PlanID set last. Finalize is idempotent. The error is a JSON encoding failure, which cannot occur for well-formed plans.
type Request ¶
type Request struct {
// ProjectDir is the project root (the directory holding .env).
ProjectDir string
// Command and Trigger label the plan. Zero values mean `build` and a build
// trigger with no subject.
Command Command
Trigger Trigger
// Build is passed to the build pipeline. Plan forces Mode to ModePlan and
// Apply to ModeWrite; every other field is used as given.
Build nbuild.BuildOptions
// Containers asks for container impact (the running stack compared with
// what the next start would create).
Containers bool
// RemoveOrphans records an orphan-remove effect for every running container
// of the project that the planned compose no longer defines.
RemoveOrphans bool
// Extra lists effects the caller knows about that the build pipeline does
// not record (for example the plugin lifecycle step's expired-plugin
// removals). They join the plan's effects as given.
Extra []Effect
// HandEdited reports whether a human changed a generated file since nself
// last wrote it (P7-LIVE-04 supplies it). Nil means never.
HandEdited func(path string) bool
// Runtime answers the docker questions of container impact. Nil uses the
// docker CLI through internal/docker.
Runtime ContainerRuntime
// Seed, when set, drives every secret the build generates (plan and write
// use the same stream). Apply draws a random one when it is empty, so the
// bytes it writes are exactly the bytes it planned.
Seed []byte
// DiffOut, when set, receives the unified diff of every changed artifact
// (env-kind artifacts show key names only). Compute writes it; nothing else does.
DiffOut io.Writer
// Stderr receives notices (for example "container state unknown"). Nil
// discards them.
Stderr io.Writer
}
Request describes one change.
type RunningContainer ¶
RunningContainer is one container of the project.
type Trigger ¶
type Trigger struct {
Kind TriggerKind `json:"kind"`
Subject string `json:"subject"`
}
Trigger records what started the plan; Subject is a key, plugin or bundle name, git ref, or "".
type TriggerKind ¶
type TriggerKind string
TriggerKind is the `trigger.kind` enum.
const ( TriggerBuild TriggerKind = "build" TriggerConfig TriggerKind = "config" TriggerPlugin TriggerKind = "plugin" TriggerBundle TriggerKind = "bundle" TriggerDrift TriggerKind = "drift" TriggerMetadata TriggerKind = "metadata" )
TriggerKind values.