- 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
164 lines
4.4 KiB
Go
164 lines
4.4 KiB
Go
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
|
|
}
|