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,163 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/m3tam3re/agent-lib/internal/deploy"
|
||||
"github.com/m3tam3re/agent-lib/internal/gitsource"
|
||||
"github.com/m3tam3re/agent-lib/internal/lockfile"
|
||||
"github.com/m3tam3re/agent-lib/internal/target"
|
||||
)
|
||||
|
||||
// SyncReport summarizes one planner-driven sync run.
|
||||
type SyncReport struct {
|
||||
Revision string
|
||||
Deployed int
|
||||
Updated int
|
||||
Kept int
|
||||
Removed int
|
||||
Skipped int
|
||||
Left int
|
||||
Manifest string
|
||||
}
|
||||
|
||||
// ExecutePlan applies a plan item by item: deploys and removes are atomic
|
||||
// per item, every skip+warn and leave+warn is appended to the warning log
|
||||
// with item name and reason, and the manifest is rewritten once at the end.
|
||||
func ExecutePlan(tree *gitsource.GitTree, plan []PlanEntry, manifest *Manifest, paths StatePaths, rev string, stdout io.Writer) (*SyncReport, error) {
|
||||
report := &SyncReport{Revision: rev, Manifest: paths.Manifest}
|
||||
var warnings []string
|
||||
|
||||
for _, e := range plan {
|
||||
key := manifestKey(e.Type, e.Name)
|
||||
switch e.Action {
|
||||
case ActDeploy, ActUpdate:
|
||||
if err := deployEntry(tree, e); err != nil {
|
||||
return nil, fmt.Errorf("%s %s/%s: %w", e.Action, e.Type, e.Name, err)
|
||||
}
|
||||
manifest.Items[key] = &ManifestItem{
|
||||
Name: e.Name, Type: e.Type, Origin: e.Origin, Revision: rev, Hash: e.NewHash,
|
||||
}
|
||||
if e.Action == ActDeploy {
|
||||
report.Deployed++
|
||||
} else {
|
||||
report.Updated++
|
||||
}
|
||||
case ActKeep:
|
||||
report.Kept++
|
||||
case ActRemove:
|
||||
if err := os.RemoveAll(e.DstPath); err != nil {
|
||||
return nil, fmt.Errorf("removing %s/%s: %w", e.Type, e.Name, err)
|
||||
}
|
||||
delete(manifest.Items, key)
|
||||
report.Removed++
|
||||
case ActSkip:
|
||||
warnings = append(warnings, fmt.Sprintf("SKIP %s/%s: %s", e.Type, e.Name, e.Reason))
|
||||
report.Skipped++
|
||||
case ActLeave:
|
||||
warnings = append(warnings, fmt.Sprintf("LEAVE %s/%s: %s", e.Type, e.Name, e.Reason))
|
||||
report.Left++
|
||||
}
|
||||
}
|
||||
|
||||
manifest.Version = ManifestVersion
|
||||
manifest.Revision = rev
|
||||
if err := manifest.Save(paths.Manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
if err := appendWarnings(paths.LogFile, warnings); err != nil {
|
||||
fmt.Fprintf(stdout, "warning: could not write sync log: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
printPlannedSyncReport(stdout, report)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func deployEntry(tree *gitsource.GitTree, e PlanEntry) error {
|
||||
if e.Type == lockfile.TypeSkill {
|
||||
return deploy.Dir(tree, e.RelPath, e.DstPath)
|
||||
}
|
||||
return deploy.File(tree, e.RelPath, e.DstPath)
|
||||
}
|
||||
|
||||
// appendWarnings adds timestamped warning lines to the sync log.
|
||||
func appendWarnings(logPath string, warnings []string) error {
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
stamp := time.Now().UTC().Format(time.RFC3339)
|
||||
for _, w := range warnings {
|
||||
if _, err := fmt.Fprintf(f, "%s %s\n", stamp, w); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// diskFacts observes every deployed path the planner can care about:
|
||||
// manifest items, current inventory items, and unmanaged occupants of the
|
||||
// target directories.
|
||||
func diskFacts(oc *target.OpenCode, manifest *Manifest, items []PlannedItem) map[string]DiskFact {
|
||||
facts := map[string]DiskFact{}
|
||||
observe := func(typ, name string) {
|
||||
key := manifestKey(typ, name)
|
||||
if _, seen := facts[key]; seen {
|
||||
return
|
||||
}
|
||||
path, ok := oc.DeployPath(typ, name)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
facts[key] = DiskFact{}
|
||||
return
|
||||
}
|
||||
hash, err := HashDiskPath(typ, path)
|
||||
if err != nil {
|
||||
hash = ""
|
||||
}
|
||||
facts[key] = DiskFact{Exists: true, Hash: hash}
|
||||
}
|
||||
|
||||
for _, rec := range manifest.Items {
|
||||
observe(rec.Type, rec.Name)
|
||||
}
|
||||
for _, it := range items {
|
||||
observe(it.Type, it.Name)
|
||||
}
|
||||
for key := range oc.ScanTargetDirs() {
|
||||
if _, seen := facts[key]; !seen {
|
||||
facts[key] = DiskFact{Exists: true}
|
||||
}
|
||||
}
|
||||
return facts
|
||||
}
|
||||
|
||||
func printPlannedSyncReport(w io.Writer, r *SyncReport) {
|
||||
fmt.Fprintf(w, "synced to revision %s\n", shortRev(r.Revision))
|
||||
fmt.Fprintf(w, " deployed: %d\n", r.Deployed)
|
||||
fmt.Fprintf(w, " updated: %d\n", r.Updated)
|
||||
fmt.Fprintf(w, " kept: %d\n", r.Kept)
|
||||
fmt.Fprintf(w, " removed: %d\n", r.Removed)
|
||||
if r.Skipped > 0 {
|
||||
fmt.Fprintf(w, " skipped (local modifications kept): %d\n", r.Skipped)
|
||||
}
|
||||
if r.Left > 0 {
|
||||
fmt.Fprintf(w, " unmanaged items left untouched: %d\n", r.Left)
|
||||
}
|
||||
fmt.Fprintf(w, " manifest: %s\n", r.Manifest)
|
||||
}
|
||||
|
||||
func shortRev(rev string) string {
|
||||
if len(rev) > 12 {
|
||||
return rev[:12]
|
||||
}
|
||||
return rev
|
||||
}
|
||||
Reference in New Issue
Block a user