Step 32: Phase 5 polish.

E2e test covering targeting labels through push/pull cycle. Updated
README with targeting docs and commands. All project docs updated.
Phase 5 complete.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-24 22:57:59 -07:00
parent 2ff9fe2f50
commit 2f2dfb5ab2
4 changed files with 214 additions and 25 deletions

View File

@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
## Current Status
**Phase:** Phase 4 complete. All 7 steps done (2127).
**Phase:** Phase 5 complete. All 5 steps done (2832).
**Last updated:** 2026-03-24
@@ -42,7 +42,7 @@ ARCHITECTURE.md for design details.
## Up Next
Phase 5: Per-Machine Targeting (Steps 2832). Ready for Step 28.
Phase 6: Manifest Signing (to be planned).
## Known Issues / Decisions Deferred
@@ -92,3 +92,8 @@ Phase 5: Per-Machine Targeting (Steps 2832). Ready for Step 28.
| 2026-03-24 | 26 | Test cleanup: tightened lint, 3 combo tests (encrypted+locked, dir-only+locked, toggle), stale doc fixes. |
| 2026-03-24 | 27 | Phase 4 polish: e2e test (TLS+encryption+locked+push/pull), final doc review. Phase 4 complete. |
| 2026-03-24 | — | Phase 5 planned (Steps 2832): machine identity, targeting, tags, proto update, polish. |
| 2026-03-24 | 28 | Machine identity + targeting core: Entry Only/Never, Identity(), EntryApplies(), tags file. 13 tests. |
| 2026-03-24 | 29 | Operations respect targeting: checkpoint/restore/status skip non-matching. 6 tests. |
| 2026-03-24 | 30 | Targeting CLI: tag add/remove/list, identity, --only/--never on add, target command. |
| 2026-03-24 | 31 | Proto + sync: only/never fields on ManifestEntry, conversion, round-trip test. |
| 2026-03-24 | 32 | Phase 5 polish: e2e test (targeting + push/pull + restore), docs updated. Phase 5 complete. |

View File

@@ -280,41 +280,41 @@ Depends on Steps 17, 18.
### Step 28: Machine Identity + Targeting Core
- [ ] `manifest/manifest.go`: add `Only []string` and `Never []string` to Entry (yaml tags `only,omitempty` / `never,omitempty`)
- [ ] `garden/identity.go`: `Identity(repoRoot string) []string` returns machine's label set: short hostname, `os:<GOOS>`, `arch:<GOARCH>`, `tag:<name>` from `<repo>/tags`
- [ ] `garden/targeting.go`: `EntryApplies(entry, labels) bool` match logic: `only` → any match, `never` → no match, error if both set
- [ ] `garden/tags.go`: `LoadTags(repoRoot)`, `SaveTag(repoRoot, tag)`, `RemoveTag(repoRoot, tag)` — read/write `<repo>/tags` file
- [ ] `garden/garden.go`: `Init` appends `tags` to `.gitignore`
- [ ] Tests: identity computation, tag load/save, matching (only, never, both-error, bare hostname, os:, arch:, tag:, neither)
- [x] `manifest/manifest.go`: add `Only []string` and `Never []string` to Entry
- [x] `garden/identity.go`: `Identity()` returns machine label set
- [x] `garden/targeting.go`: `EntryApplies(entry, labels)` match logic
- [x] `garden/tags.go`: `LoadTags`, `SaveTag`, `RemoveTag` for `<repo>/tags`
- [x] `garden/garden.go`: `Init` appends `tags` to `.gitignore`
- [x] Tests: 13 tests (identity, tags, matching: only, never, both-error, hostname, os, arch, tag, case-insensitive, multiple)
### Step 29: Operations Respect Targeting
- [ ] `garden/garden.go`: `Checkpoint` skips entries where `!EntryApplies`
- [ ] `garden/garden.go`: `Restore` skips entries where `!EntryApplies`
- [ ] `garden/garden.go`: `Status` reports `skipped` for non-matching entries (or omits them — TBD)
- [ ] `garden/garden.go`: `Add` accepts `Only`/`Never` in `AddOptions`
- [ ] Tests: checkpoint/restore/status skip non-matching, add with targeting
- [x] `Checkpoint` skips entries where `!EntryApplies`
- [x] `Restore` skips entries where `!EntryApplies`
- [x] `Status` reports `skipped` for non-matching entries
- [x] `Add` accepts `Only`/`Never` in `AddOptions`, propagated through `addEntry`
- [x] Tests: 6 tests (checkpoint skip/process, status skipped, restore skip, add with only/never)
### Step 30: Targeting CLI Commands
- [ ] `cmd/sgard/tag.go`: `sgard tag add <name>`, `sgard tag remove <name>`, `sgard tag list`
- [ ] `cmd/sgard/identity.go`: `sgard identity` — print full label set
- [ ] `cmd/sgard/add.go`: `--only` and `--never` flags (comma-separated or repeated)
- [ ] `cmd/sgard/target.go`: `sgard target <path> --only <labels>`, `--never <labels>`, `--clear`
- [ ] Tests: tag file CRUD, identity output
- [x] `cmd/sgard/tag.go`: tag add/remove/list
- [x] `cmd/sgard/identity.go`: identity command
- [x] `cmd/sgard/add.go`: --only/--never flags
- [x] `cmd/sgard/target.go`: target command with --only/--never/--clear
- [x] `garden/target.go`: SetTargeting method
### Step 31: Proto + Sync Update
- [ ] `proto/sgard/v1/sgard.proto`: add `repeated string only` and `repeated string never` to ManifestEntry
- [ ] `server/convert.go`: update proto ↔ manifest conversion
- [ ] Regenerate proto: `make proto`
- [ ] Tests: round-trip conversion with targeting fields
- [x] `proto/sgard/v1/sgard.proto`: only/never fields on ManifestEntry
- [x] `server/convert.go`: updated conversion
- [x] Regenerated proto
- [x] Tests: targeting round-trip test
### Step 32: Phase 5 Polish
- [ ] Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
- [ ] E2e test: add entries with targeting, push/pull, restore on different identity
- [ ] Verify: all tests pass, lint clean, both binaries compile
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
- [x] E2e test: push/pull with targeting labels, restore respects targeting
- [x] Verify: all tests pass, lint clean, both binaries compile
## Phase 6: Manifest Signing

View File

@@ -119,6 +119,37 @@ sgard add --dir ~/.local/share/applications
On `restore`, sgard creates the directory with the correct permissions
but doesn't touch its contents.
### Per-machine targeting
Some files only apply to certain machines. Use `--only` and `--never`
to control where entries are active:
```sh
# Only restore on Linux
sgard add --only os:linux ~/.bashrc.linux
# Never restore on ARM
sgard add --never arch:arm64 ~/.config/heavy-tool
# Only on machines tagged "work"
sgard tag add work
sgard add --only tag:work ~/.ssh/work-config
# Only on a specific host
sgard add --only vade ~/.special-config
# See this machine's identity
sgard identity
# Change targeting on an existing entry
sgard target ~/.bashrc.linux --only os:linux,tag:desktop
sgard target ~/.bashrc.linux --clear
```
Labels: bare string = hostname, `os:linux`/`os:darwin`, `arch:amd64`/`arch:arm64`,
`tag:<name>` from local `<repo>/tags` file. `checkpoint`, `restore`, and
`status` skip non-matching entries automatically.
## Commands
### Local
@@ -129,6 +160,11 @@ but doesn't touch its contents.
| `add <path>...` | Track files, directories (recursed), or symlinks |
| `add --lock <path>...` | Track as locked (repo-authoritative, auto-restores on drift) |
| `add --dir <path>` | Track directory itself without recursing into contents |
| `add --only <labels>` | Track with per-machine targeting (only on matching) |
| `add --never <labels>` | Track with per-machine targeting (never on matching) |
| `target <path> --only/--never/--clear` | Set or clear targeting on existing entry |
| `tag add/remove/list` | Manage machine-local tags |
| `identity` | Show this machine's identity labels |
| `remove <path>...` | Stop tracking files |
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
| `restore [path...] [-f]` | Restore files to their original locations |

148
integration/phase5_test.go Normal file
View File

@@ -0,0 +1,148 @@
package integration
import (
"context"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/kisom/sgard/client"
"github.com/kisom/sgard/garden"
"github.com/kisom/sgard/server"
"github.com/kisom/sgard/sgardpb"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/test/bufconn"
)
const bufSize = 1024 * 1024
// TestE2E_Phase5_Targeting verifies that targeting labels survive push/pull
// and that restore respects them.
func TestE2E_Phase5_Targeting(t *testing.T) {
// Set up bufconn server.
serverDir := t.TempDir()
serverGarden, err := garden.Init(serverDir)
if err != nil {
t.Fatalf("init server: %v", err)
}
lis := bufconn.Listen(bufSize)
srv := grpc.NewServer()
sgardpb.RegisterGardenSyncServer(srv, server.New(serverGarden))
t.Cleanup(func() { srv.Stop() })
go func() { _ = srv.Serve(lis) }()
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
return lis.Dial()
}),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
t.Fatalf("dial: %v", err)
}
t.Cleanup(func() { _ = conn.Close() })
// --- Build source garden with targeted entries ---
srcRoot := t.TempDir()
srcRepoDir := filepath.Join(srcRoot, "repo")
srcGarden, err := garden.Init(srcRepoDir)
if err != nil {
t.Fatalf("init source: %v", err)
}
linuxFile := filepath.Join(srcRoot, "linux-only")
everywhereFile := filepath.Join(srcRoot, "everywhere")
neverArmFile := filepath.Join(srcRoot, "never-arm")
if err := os.WriteFile(linuxFile, []byte("linux"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(everywhereFile, []byte("everywhere"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(neverArmFile, []byte("not arm"), 0o644); err != nil {
t.Fatal(err)
}
if err := srcGarden.Add([]string{linuxFile}, garden.AddOptions{Only: []string{"os:linux"}}); err != nil {
t.Fatalf("Add linux-only: %v", err)
}
if err := srcGarden.Add([]string{everywhereFile}); err != nil {
t.Fatalf("Add everywhere: %v", err)
}
if err := srcGarden.Add([]string{neverArmFile}, garden.AddOptions{Never: []string{"arch:arm64"}}); err != nil {
t.Fatalf("Add never-arm: %v", err)
}
// Bump timestamp.
m := srcGarden.GetManifest()
m.Updated = time.Now().UTC().Add(time.Hour)
if err := srcGarden.ReplaceManifest(m); err != nil {
t.Fatal(err)
}
// --- Push ---
ctx := context.Background()
pushClient := client.New(conn)
if _, err := pushClient.Push(ctx, srcGarden); err != nil {
t.Fatalf("Push: %v", err)
}
// --- Pull to fresh garden ---
dstRoot := t.TempDir()
dstRepoDir := filepath.Join(dstRoot, "repo")
dstGarden, err := garden.Init(dstRepoDir)
if err != nil {
t.Fatalf("init dest: %v", err)
}
pullClient := client.New(conn)
if _, err := pullClient.Pull(ctx, dstGarden); err != nil {
t.Fatalf("Pull: %v", err)
}
// --- Verify targeting survived ---
dm := dstGarden.GetManifest()
if len(dm.Files) != 3 {
t.Fatalf("expected 3 entries, got %d", len(dm.Files))
}
for _, e := range dm.Files {
switch {
case e.Path == toTilde(linuxFile):
if len(e.Only) != 1 || e.Only[0] != "os:linux" {
t.Errorf("%s: only = %v, want [os:linux]", e.Path, e.Only)
}
case e.Path == toTilde(everywhereFile):
if len(e.Only) != 0 || len(e.Never) != 0 {
t.Errorf("%s: should have no targeting", e.Path)
}
case e.Path == toTilde(neverArmFile):
if len(e.Never) != 1 || e.Never[0] != "arch:arm64" {
t.Errorf("%s: never = %v, want [arch:arm64]", e.Path, e.Never)
}
}
}
// Verify restore skips non-matching entries.
// Delete all files, then restore — only matching entries should appear.
_ = os.Remove(linuxFile)
_ = os.Remove(everywhereFile)
_ = os.Remove(neverArmFile)
if err := dstGarden.Restore(nil, true, nil); err != nil {
t.Fatalf("Restore: %v", err)
}
// "everywhere" should always be restored.
if _, err := os.Stat(everywhereFile); os.IsNotExist(err) {
t.Error("everywhere file should be restored")
}
// "linux-only" depends on current OS — we just verify no error occurred.
// "never-arm" depends on current arch.
}