- 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
146 lines
3.9 KiB
Go
146 lines
3.9 KiB
Go
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
|
|
}
|