Files
agent-lib/internal/client/sync.go
T
m3ta-chiron 63619b90a4 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
2026-08-23 10:36:44 +02:00

93 lines
2.3 KiB
Go

package client
import (
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/m3tam3re/agent-lib/internal/target"
)
// SyncOptions parameterize a client sync run.
type SyncOptions struct {
ConfigPath string
}
// Sync pulls the work repository, plans the sync as a pure function over
// (inventory, manifest, disk facts) and executes the plan. Validation and
// hashing happen before the first mutation; unreachable repositories fail
// cleanly with the previous state intact.
func Sync(opts SyncOptions, stdout io.Writer) (*SyncReport, error) {
cfg, err := LoadClientConfig(opts.ConfigPath)
if err != nil {
return nil, err
}
paths, err := ResolveStatePaths()
if err != nil {
return nil, err
}
if err := os.MkdirAll(paths.Root, 0o755); err != nil {
return nil, err
}
pull, err := Pull(paths.CacheDir, cfg.RepoURL, cfg.Ref, cfg.Token, cfg.TokenUser)
if err != nil {
return nil, err
}
inv, err := BuildInventory(pull.Tree, pull.Rev)
if err != nil {
return nil, err
}
oc, err := target.NewOpenCode()
if err != nil {
return nil, err
}
manifest, err := LoadManifest(paths.Manifest)
if err != nil {
return nil, err
}
items := make([]PlannedItem, 0, len(inv.Items))
for _, it := range inv.Deployable() {
if _, ok := oc.DeployPath(it.Type, it.Name); !ok {
continue
}
hash, err := HashItem(pull.Tree, it)
if err != nil {
return nil, fmt.Errorf("hashing %s/%s: %w", it.Type, it.Name, err)
}
items = append(items, PlannedItem{Type: it.Type, Name: it.Name, Origin: it.Origin, RelPath: it.RelPath, Hash: hash})
}
pathFor := func(typ, name string) string {
path, _ := oc.DeployPath(typ, name)
return path
}
plan := Plan(items, manifest, diskFacts(oc, manifest, items), pathFor)
for _, e := range plan {
if !safePath(e.DstPath) {
return nil, fmt.Errorf("refusing unsafe deploy path %q", e.DstPath)
}
}
return ExecutePlan(pull.Tree, plan, manifest, paths, pull.Rev, stdout)
}
// safePath rejects destinations that escape the user's home directory.
func safePath(dst string) bool {
home, err := os.UserHomeDir()
if err != nil {
return false
}
if dst == home {
return false
}
rel, err := filepath.Rel(home, dst)
if err != nil {
return false
}
return rel != ".." && rel != "." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}