feat(client): sync --force to escape the locally-modified skip
Managed items with local modifications were kept (skip + warn) with no escape hatch other than deleting the deployed file by hand — after an upstream change the deployed content silently stayed old, which reads exactly like a stale cache. --force turns the two skip cases into update (repository version wins) and remove (upstream deleted + modified). Unmanaged items and never- managed path occupancies stay untouched even with --force; the default behavior is unchanged.
This commit is contained in:
1 parent
2017329015
commit
1ab97bff43
6 files changed
+164
-14
No files matched your search
@@ -47,8 +47,11 @@ type PlanEntry struct {
|
||||
// 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 {
|
||||
// (type, name) for deterministic execution and reporting. With force set,
|
||||
// locally modified managed items are overwritten with the upstream version
|
||||
// (and removed after upstream deletion) instead of being skipped; items
|
||||
// agent-lib never managed are never touched, even with force.
|
||||
func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pathFor func(typ, name string) string, force bool) []PlanEntry {
|
||||
invByKey := map[string]PlannedItem{}
|
||||
for _, it := range items {
|
||||
invByKey[manifestKey(it.Type, it.Name)] = it
|
||||
@@ -93,13 +96,15 @@ func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pat
|
||||
emit(ActDeploy, it, "")
|
||||
}
|
||||
case inInv && it.Hash == rec.Hash:
|
||||
decideAgainstDisk(it, rec, fact, emit, "unchanged")
|
||||
decideAgainstDisk(it, rec, fact, emit, "unchanged", force)
|
||||
case inInv:
|
||||
decideAgainstDisk(it, rec, fact, emit, "changed")
|
||||
decideAgainstDisk(it, rec, fact, emit, "changed", force)
|
||||
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 && force:
|
||||
emit(ActRemove, PlannedItem{Type: rec.Type, Name: rec.Name, Origin: rec.Origin}, "upstream deleted; forced removal of the locally modified copy")
|
||||
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")
|
||||
@@ -111,7 +116,7 @@ func Plan(items []PlannedItem, manifest *Manifest, disk map[string]DiskFact, pat
|
||||
return plan
|
||||
}
|
||||
|
||||
func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit func(Action, PlannedItem, string), upstreamState string) {
|
||||
func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit func(Action, PlannedItem, string), upstreamState string, force bool) {
|
||||
switch {
|
||||
case !fact.Exists:
|
||||
emit(ActUpdate, it, "managed item missing on disk; restoring")
|
||||
@@ -121,6 +126,8 @@ func decideAgainstDisk(it PlannedItem, rec *ManifestItem, fact DiskFact, emit fu
|
||||
} else {
|
||||
emit(ActUpdate, it, "")
|
||||
}
|
||||
case force:
|
||||
emit(ActUpdate, it, "forced: locally modified copy overwritten")
|
||||
default:
|
||||
emit(ActSkip, it, "locally modified; keeping the local version (upstream "+upstreamState+")")
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ func TestPlanMatrix(t *testing.T) {
|
||||
diskFacts[key] = DiskFact{Exists: true, Hash: hashMod}
|
||||
}
|
||||
|
||||
plan := Plan(items, manifest, diskFacts, testPathFor)
|
||||
plan := Plan(items, manifest, diskFacts, testPathFor, false)
|
||||
if tc.disk == diskNone && tc.up == upAbsent {
|
||||
if len(plan) != 0 {
|
||||
t.Fatalf("a key present nowhere must produce no entry, got %+v", plan)
|
||||
@@ -123,6 +123,54 @@ func TestPlanMatrix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlanForceMatrix asserts how --force changes the skip cases: locally
|
||||
// modified managed items become updates (or removes after upstream
|
||||
// deletion), while never-managed occupancies stay skipped even with force.
|
||||
func TestPlanForceMatrix(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
up string // "changed", "unchanged", "gone"
|
||||
want Action
|
||||
}{
|
||||
{"modified/changed", "changed", ActUpdate},
|
||||
{"modified/unchanged", "unchanged", ActUpdate},
|
||||
{"modified/gone", "gone", ActRemove},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
key := manifestKey("skills", "alpha")
|
||||
var items []PlannedItem
|
||||
manifest := NewManifest()
|
||||
manifest.Items[key] = &ManifestItem{Name: "alpha", Type: "skills", Hash: hashV1}
|
||||
if tc.up != "gone" {
|
||||
hash := hashV2
|
||||
if tc.up == "unchanged" {
|
||||
hash = hashV1
|
||||
}
|
||||
items = append(items, PlannedItem{Type: "skills", Name: "alpha", RelPath: "skills/alpha", Hash: hash})
|
||||
}
|
||||
disk := map[string]DiskFact{key: {Exists: true, Hash: hashMod}}
|
||||
|
||||
plan := Plan(items, manifest, disk, testPathFor, true)
|
||||
if len(plan) != 1 {
|
||||
t.Fatalf("want exactly 1 entry, got %d: %+v", len(plan), plan)
|
||||
}
|
||||
if plan[0].Action != tc.want {
|
||||
t.Errorf("action = %s, want %s (reason: %s)", plan[0].Action, tc.want, plan[0].Reason)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("never-managed occupancy stays skipped", func(t *testing.T) {
|
||||
items := []PlannedItem{{Type: "skills", Name: "alpha", RelPath: "skills/alpha", Hash: hashV1}}
|
||||
disk := map[string]DiskFact{manifestKey("skills", "alpha"): {Exists: true, Hash: hashMod}}
|
||||
plan := Plan(items, NewManifest(), disk, testPathFor, true)
|
||||
if len(plan) != 1 || plan[0].Action != ActSkip {
|
||||
t.Fatalf("force must not deploy over a never-managed item: %+v", plan)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestPlanMixedFixture combines several states in one plan and asserts the
|
||||
// full deterministic output.
|
||||
func TestPlanMixedFixture(t *testing.T) {
|
||||
@@ -151,7 +199,7 @@ func TestPlanMixedFixture(t *testing.T) {
|
||||
"agents/helper": {Exists: true, Hash: hashV1}, // unmodified × unchanged
|
||||
}
|
||||
|
||||
plan := Plan(items, manifest, disk, testPathFor)
|
||||
plan := Plan(items, manifest, disk, testPathFor, false)
|
||||
got := map[string]Action{}
|
||||
for _, e := range plan {
|
||||
got[e.Type+"/"+e.Name] = e.Action
|
||||
@@ -195,8 +243,8 @@ func TestPlanPureGuarantee(t *testing.T) {
|
||||
manifest := manifestWith(map[string]*ManifestItem{"skills/b": {Name: "b", Type: "skills", Hash: hashV1}})
|
||||
disk := map[string]DiskFact{"skills/b": {Exists: true, Hash: hashV1}}
|
||||
|
||||
p1 := Plan(items, manifest, disk, testPathFor)
|
||||
p2 := Plan(items, manifest, disk, testPathFor)
|
||||
p1 := Plan(items, manifest, disk, testPathFor, false)
|
||||
p2 := Plan(items, manifest, disk, testPathFor, false)
|
||||
if len(p1) != len(p2) {
|
||||
t.Fatal("same inputs must yield same plan length")
|
||||
}
|
||||
|
||||
@@ -12,11 +12,13 @@ import (
|
||||
|
||||
// SyncOptions parameterize a client sync run. RepoURL (with optional Ref)
|
||||
// is the inline --repo override: it works without any config file and beats
|
||||
// a repo_url discovered in the config chain.
|
||||
// a repo_url discovered in the config chain. Force switches the planner
|
||||
// from skip+warn to overwrite for locally modified managed items.
|
||||
type SyncOptions struct {
|
||||
ConfigPath string
|
||||
RepoURL string
|
||||
Ref string
|
||||
Force bool
|
||||
}
|
||||
|
||||
// prepared is everything sync and status share: the pulled repository, the
|
||||
@@ -80,7 +82,7 @@ func prepare(opts SyncOptions) (*prepared, error) {
|
||||
path, _ := oc.DeployPath(typ, name)
|
||||
return path
|
||||
}
|
||||
plan := Plan(items, manifest, diskFacts(oc, manifest, items), pathFor)
|
||||
plan := Plan(items, manifest, diskFacts(oc, manifest, items), pathFor, opts.Force)
|
||||
for _, e := range plan {
|
||||
if !safePath(e.DstPath) {
|
||||
return nil, fmt.Errorf("refusing unsafe deploy path %q", e.DstPath)
|
||||
|
||||
Reference in new issue
Block a user