feat: three-state sync planner with warning semantics
- Plan is a pure function (inventory, manifest, disk facts, injected path mapping) emitting exactly six actions: deploy, update, skip+warn, leave+warn, remove, keep — deterministic order, no filesystem access - full 3x3 matrix (disk unmodified/user-modified/never-managed x upstream added/changed/removed) table-tested plus missing-disk restore cases, a mixed fixture and a purity/determinism guarantee test - executor applies plans item by item (atomic deploy/remove per item), rewrites the manifest once, appends timestamped SKIP/LEAVE lines with item name and reason to the sync log - sync now plans before mutating: unmanaged occupants block new deploys, upstream deletions remove only unmodified copies, local modifications always win and are warned about
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
package client
|
||||
|
||||
import "sort"
|
||||
|
||||
// Action is one step of a sync plan.
|
||||
type Action string
|
||||
|
||||
// The six sync actions. skip warns about managed items with local
|
||||
// modifications; leave warns about self-created items agent-lib never
|
||||
// managed; remove only ever touches unmodified managed copies.
|
||||
const (
|
||||
ActDeploy Action = "deploy"
|
||||
ActUpdate Action = "update"
|
||||
ActSkip Action = "skip+warn"
|
||||
ActLeave Action = "leave+warn"
|
||||
ActRemove Action = "remove"
|
||||
ActKeep Action = "keep"
|
||||
)
|
||||
|
||||
// PlannedItem is one deployable inventory item with its repo-side hash.
|
||||
type PlannedItem struct {
|
||||
Type string
|
||||
Name string
|
||||
Origin string
|
||||
RelPath string
|
||||
Hash string
|
||||
}
|
||||
|
||||
// DiskFact is the observed on-disk state of one deployed path.
|
||||
type DiskFact struct {
|
||||
Exists bool
|
||||
Hash string
|
||||
}
|
||||
|
||||
// PlanEntry is one decided action with everything the executor needs.
|
||||
type PlanEntry struct {
|
||||
Action Action
|
||||
Type string
|
||||
Name string
|
||||
Origin string
|
||||
RelPath string
|
||||
DstPath string
|
||||
NewHash string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Plan is a pure function from (inventory, manifest, on-disk facts) to an
|
||||
// action plan. pathFor injects the target path mapping so the planner never
|
||||
// touches the filesystem or the environment. The plan is sorted by
|
||||
// (type, name) for deterministic execution and reporting.
|
||||
func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pathFor func(typ, name string) string) []PlanEntry {
|
||||
invByKey := map[string]PlannedItem{}
|
||||
for _, it := range items {
|
||||
invByKey[manifestKey(it.Type, it.Name)] = it
|
||||
}
|
||||
|
||||
var plan []PlanEntry
|
||||
emit := func(a Action, it PlannedItem, reason string) {
|
||||
e := PlanEntry{Action: a, Reason: reason, NewHash: it.Hash}
|
||||
e.Type, e.Name, e.Origin, e.RelPath = it.Type, it.Name, it.Origin, it.RelPath
|
||||
e.DstPath = pathFor(e.Type, e.Name)
|
||||
plan = append(plan, e)
|
||||
}
|
||||
|
||||
keys := map[string]bool{}
|
||||
for key := range invByKey {
|
||||
keys[key] = true
|
||||
}
|
||||
for key := range manifest.Items {
|
||||
keys[key] = true
|
||||
}
|
||||
for key, fact := range disk {
|
||||
if fact.Exists {
|
||||
keys[key] = true
|
||||
}
|
||||
}
|
||||
sorted := make([]string, 0, len(keys))
|
||||
for key := range keys {
|
||||
sorted = append(sorted, key)
|
||||
}
|
||||
sort.Strings(sorted)
|
||||
|
||||
for _, key := range sorted {
|
||||
it, inInv := invByKey[key]
|
||||
rec := manifest.Items[key]
|
||||
fact := disk[key]
|
||||
|
||||
switch {
|
||||
case inInv && rec == nil:
|
||||
if fact.Exists {
|
||||
emit(ActSkip, it, "path occupied by a local item agent-lib never deployed; deploy blocked")
|
||||
} else {
|
||||
emit(ActDeploy, it, "")
|
||||
}
|
||||
case inInv && it.Hash == rec.Hash:
|
||||
decideAgainstDisk(it, rec, fact, emit, "unchanged")
|
||||
case inInv:
|
||||
decideAgainstDisk(it, rec, fact, emit, "changed")
|
||||
case rec != nil && !fact.Exists:
|
||||
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "already gone from disk; dropping manifest record")
|
||||
case rec != nil && fact.Hash == rec.Hash:
|
||||
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "upstream deleted")
|
||||
case rec != nil:
|
||||
emit(ActSkip, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin},
|
||||
"upstream deleted but the local copy was modified; keeping it")
|
||||
default:
|
||||
emit(ActLeave, PlannedItem{Type: typeOfKey(key), Name: nameOfKey(key)},
|
||||
"unmanaged local item; left untouched")
|
||||
}
|
||||
}
|
||||
return plan
|
||||
}
|
||||
|
||||
func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit func(Action, PlannedItem, string), upstreamState string) {
|
||||
switch {
|
||||
case !fact.Exists:
|
||||
emit(ActUpdate, it, "managed item missing on disk; restoring")
|
||||
case fact.Hash == rec.Hash:
|
||||
if upstreamState == "unchanged" {
|
||||
emit(ActKeep, it, "")
|
||||
} else {
|
||||
emit(ActUpdate, it, "")
|
||||
}
|
||||
default:
|
||||
emit(ActSkip, it, "locally modified; keeping the local version (upstream "+upstreamState+")")
|
||||
}
|
||||
}
|
||||
|
||||
func typeOfKey(key string) string {
|
||||
for i := len(key) - 1; i >= 0; i-- {
|
||||
if key[i] == '/' {
|
||||
return key[:i]
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
func nameOfKey(key string) string {
|
||||
for i := len(key) - 1; i >= 0; i-- {
|
||||
if key[i] == '/' {
|
||||
return key[i+1:]
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
Reference in New Issue
Block a user