Step 29: Operations respect targeting.
Checkpoint, Restore, and Status now skip entries that don't match the machine's identity labels. Status reports non-matching as "skipped". Add accepts Only/Never in AddOptions, propagated through addEntry. 6 tests covering skip/process/skipped-status/restore-skip/add-with. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -139,9 +139,8 @@ func (g *Garden) DeleteBlob(hash string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// addEntry adds a single file or symlink to the manifest. If skipDup is true,
|
// addEntry adds a single file or symlink to the manifest. If skipDup is true,
|
||||||
// already-tracked paths are silently skipped. If encrypt is true, the file
|
// already-tracked paths are silently skipped.
|
||||||
// blob is encrypted before storing. If lock is true, the entry is marked locked.
|
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup bool, o AddOptions) error {
|
||||||
func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup, encrypt, lock bool) error {
|
|
||||||
tilded := toTildePath(abs)
|
tilded := toTildePath(abs)
|
||||||
|
|
||||||
if g.findEntry(tilded) != nil {
|
if g.findEntry(tilded) != nil {
|
||||||
@@ -154,7 +153,9 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup,
|
|||||||
entry := manifest.Entry{
|
entry := manifest.Entry{
|
||||||
Path: tilded,
|
Path: tilded,
|
||||||
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
||||||
Locked: lock,
|
Locked: o.Lock,
|
||||||
|
Only: o.Only,
|
||||||
|
Never: o.Never,
|
||||||
Updated: now,
|
Updated: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +174,7 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup,
|
|||||||
return fmt.Errorf("reading file %s: %w", abs, err)
|
return fmt.Errorf("reading file %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if encrypt {
|
if o.Encrypt {
|
||||||
if g.dek == nil {
|
if g.dek == nil {
|
||||||
return fmt.Errorf("DEK not unlocked; cannot encrypt %s", abs)
|
return fmt.Errorf("DEK not unlocked; cannot encrypt %s", abs)
|
||||||
}
|
}
|
||||||
@@ -200,9 +201,11 @@ func (g *Garden) addEntry(abs string, info os.FileInfo, now time.Time, skipDup,
|
|||||||
|
|
||||||
// AddOptions controls the behavior of Add.
|
// AddOptions controls the behavior of Add.
|
||||||
type AddOptions struct {
|
type AddOptions struct {
|
||||||
Encrypt bool // encrypt file blobs before storing
|
Encrypt bool // encrypt file blobs before storing
|
||||||
Lock bool // mark entries as locked (repo-authoritative)
|
Lock bool // mark entries as locked (repo-authoritative)
|
||||||
DirOnly bool // for directories: track the directory itself, don't recurse
|
DirOnly bool // for directories: track the directory itself, don't recurse
|
||||||
|
Only []string // per-machine targeting: only apply on matching machines
|
||||||
|
Never []string // per-machine targeting: never apply on matching machines
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add tracks new files, directories, or symlinks. Each path is resolved
|
// Add tracks new files, directories, or symlinks. Each path is resolved
|
||||||
@@ -243,6 +246,8 @@ func (g *Garden) Add(paths []string, opts ...AddOptions) error {
|
|||||||
Type: "directory",
|
Type: "directory",
|
||||||
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
Mode: fmt.Sprintf("%04o", info.Mode().Perm()),
|
||||||
Locked: o.Lock,
|
Locked: o.Lock,
|
||||||
|
Only: o.Only,
|
||||||
|
Never: o.Never,
|
||||||
Updated: now,
|
Updated: now,
|
||||||
}
|
}
|
||||||
g.manifest.Files = append(g.manifest.Files, entry)
|
g.manifest.Files = append(g.manifest.Files, entry)
|
||||||
@@ -258,14 +263,14 @@ func (g *Garden) Add(paths []string, opts ...AddOptions) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stat %s: %w", path, err)
|
return fmt.Errorf("stat %s: %w", path, err)
|
||||||
}
|
}
|
||||||
return g.addEntry(path, fi, now, true, o.Encrypt, o.Lock)
|
return g.addEntry(path, fi, now, true, o)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("walking directory %s: %w", abs, err)
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if err := g.addEntry(abs, info, now, false, o.Encrypt, o.Lock); err != nil {
|
if err := g.addEntry(abs, info, now, false, o); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,10 +295,19 @@ type FileStatus struct {
|
|||||||
// the manifest.
|
// the manifest.
|
||||||
func (g *Garden) Checkpoint(message string) error {
|
func (g *Garden) Checkpoint(message string) error {
|
||||||
now := g.clock.Now().UTC()
|
now := g.clock.Now().UTC()
|
||||||
|
labels := g.Identity()
|
||||||
|
|
||||||
for i := range g.manifest.Files {
|
for i := range g.manifest.Files {
|
||||||
entry := &g.manifest.Files[i]
|
entry := &g.manifest.Files[i]
|
||||||
|
|
||||||
|
applies, err := EntryApplies(entry, labels)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !applies {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
abs, err := ExpandTildePath(entry.Path)
|
abs, err := ExpandTildePath(entry.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||||
@@ -378,10 +392,20 @@ func (g *Garden) Checkpoint(message string) error {
|
|||||||
// and returns a status for each.
|
// and returns a status for each.
|
||||||
func (g *Garden) Status() ([]FileStatus, error) {
|
func (g *Garden) Status() ([]FileStatus, error) {
|
||||||
var results []FileStatus
|
var results []FileStatus
|
||||||
|
labels := g.Identity()
|
||||||
|
|
||||||
for i := range g.manifest.Files {
|
for i := range g.manifest.Files {
|
||||||
entry := &g.manifest.Files[i]
|
entry := &g.manifest.Files[i]
|
||||||
|
|
||||||
|
applies, err := EntryApplies(entry, labels)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !applies {
|
||||||
|
results = append(results, FileStatus{Path: entry.Path, State: "skipped"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
abs, err := ExpandTildePath(entry.Path)
|
abs, err := ExpandTildePath(entry.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
return nil, fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||||
@@ -450,9 +474,19 @@ func (g *Garden) Restore(paths []string, force bool, confirm func(path string) b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
labels := g.Identity()
|
||||||
|
|
||||||
for i := range entries {
|
for i := range entries {
|
||||||
entry := &entries[i]
|
entry := &entries[i]
|
||||||
|
|
||||||
|
applies, err := EntryApplies(entry, labels)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !applies {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
abs, err := ExpandTildePath(entry.Path)
|
abs, err := ExpandTildePath(entry.Path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
return fmt.Errorf("expanding path %s: %w", entry.Path, err)
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (g *Garden) MirrorUp(paths []string) error {
|
|||||||
if lstatErr != nil {
|
if lstatErr != nil {
|
||||||
return fmt.Errorf("stat %s: %w", path, lstatErr)
|
return fmt.Errorf("stat %s: %w", path, lstatErr)
|
||||||
}
|
}
|
||||||
return g.addEntry(path, fi, now, true, false, false)
|
return g.addEntry(path, fi, now, true, AddOptions{})
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("walking directory %s: %w", abs, err)
|
return fmt.Errorf("walking directory %s: %w", abs, err)
|
||||||
|
|||||||
190
garden/targeting_ops_test.go
Normal file
190
garden/targeting_ops_test.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package garden
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCheckpointSkipsNonMatching(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("original"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add with only:os:fakeos — won't match this machine.
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{Only: []string{"os:fakeos"}}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origHash := g.manifest.Files[0].Hash
|
||||||
|
|
||||||
|
// Modify file.
|
||||||
|
if err := os.WriteFile(testFile, []byte("modified"), 0o644); err != nil {
|
||||||
|
t.Fatalf("modifying: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checkpoint should skip this entry.
|
||||||
|
if err := g.Checkpoint(""); err != nil {
|
||||||
|
t.Fatalf("Checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.manifest.Files[0].Hash != origHash {
|
||||||
|
t.Error("checkpoint should skip non-matching entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckpointProcessesMatching(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("original"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add with only matching current OS.
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{Only: []string{"os:" + runtime.GOOS}}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
origHash := g.manifest.Files[0].Hash
|
||||||
|
|
||||||
|
if err := os.WriteFile(testFile, []byte("modified"), 0o644); err != nil {
|
||||||
|
t.Fatalf("modifying: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Checkpoint(""); err != nil {
|
||||||
|
t.Fatalf("Checkpoint: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.manifest.Files[0].Hash == origHash {
|
||||||
|
t.Error("checkpoint should process matching entry")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStatusReportsSkipped(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{Only: []string{"os:fakeos"}}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, err := g.Status()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Status: %v", err)
|
||||||
|
}
|
||||||
|
if len(statuses) != 1 || statuses[0].State != "skipped" {
|
||||||
|
t.Errorf("expected skipped, got %v", statuses)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreSkipsNonMatching(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("original"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{Only: []string{"os:fakeos"}}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete file and try to restore — should skip.
|
||||||
|
_ = os.Remove(testFile)
|
||||||
|
if err := g.Restore(nil, true, nil); err != nil {
|
||||||
|
t.Fatalf("Restore: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// File should NOT have been restored.
|
||||||
|
if _, err := os.Stat(testFile); !os.IsNotExist(err) {
|
||||||
|
t.Error("restore should skip non-matching entry — file should not exist")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddWithTargeting(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{
|
||||||
|
Only: []string{"os:linux", "tag:work"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := g.manifest.Files[0]
|
||||||
|
if len(entry.Only) != 2 {
|
||||||
|
t.Fatalf("expected 2 only labels, got %d", len(entry.Only))
|
||||||
|
}
|
||||||
|
if entry.Only[0] != "os:linux" || entry.Only[1] != "tag:work" {
|
||||||
|
t.Errorf("only = %v, want [os:linux tag:work]", entry.Only)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddWithNever(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
repoDir := filepath.Join(root, "repo")
|
||||||
|
|
||||||
|
g, err := Init(repoDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Init: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testFile := filepath.Join(root, "testfile")
|
||||||
|
if err := os.WriteFile(testFile, []byte("data"), 0o644); err != nil {
|
||||||
|
t.Fatalf("writing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := g.Add([]string{testFile}, AddOptions{
|
||||||
|
Never: []string{"arch:arm64"},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("Add: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := g.manifest.Files[0]
|
||||||
|
if len(entry.Never) != 1 || entry.Never[0] != "arch:arm64" {
|
||||||
|
t.Errorf("never = %v, want [arch:arm64]", entry.Never)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user