- 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
93 lines
2.3 KiB
Go
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))
|
|
}
|