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:
@@ -7,7 +7,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
**Phase:** Phase 4 complete. All 7 steps done (21–27).
|
**Phase:** Phase 5 complete. All 5 steps done (28–32).
|
||||||
|
|
||||||
**Last updated:** 2026-03-24
|
**Last updated:** 2026-03-24
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ ARCHITECTURE.md for design details.
|
|||||||
|
|
||||||
## Up Next
|
## Up Next
|
||||||
|
|
||||||
Phase 5: Per-Machine Targeting (Steps 28–32). Ready for Step 28.
|
Phase 6: Manifest Signing (to be planned).
|
||||||
|
|
||||||
## Known Issues / Decisions Deferred
|
## Known Issues / Decisions Deferred
|
||||||
|
|
||||||
@@ -92,3 +92,8 @@ Phase 5: Per-Machine Targeting (Steps 28–32). 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 | 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 | 27 | Phase 4 polish: e2e test (TLS+encryption+locked+push/pull), final doc review. Phase 4 complete. |
|
||||||
| 2026-03-24 | — | Phase 5 planned (Steps 28–32): machine identity, targeting, tags, proto update, polish. |
|
| 2026-03-24 | — | Phase 5 planned (Steps 28–32): 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. |
|
||||||
|
|||||||
@@ -280,41 +280,41 @@ Depends on Steps 17, 18.
|
|||||||
|
|
||||||
### Step 28: Machine Identity + Targeting Core
|
### Step 28: Machine Identity + Targeting Core
|
||||||
|
|
||||||
- [ ] `manifest/manifest.go`: add `Only []string` and `Never []string` to Entry (yaml tags `only,omitempty` / `never,omitempty`)
|
- [x] `manifest/manifest.go`: add `Only []string` and `Never []string` to Entry
|
||||||
- [ ] `garden/identity.go`: `Identity(repoRoot string) []string` — returns machine's label set: short hostname, `os:<GOOS>`, `arch:<GOARCH>`, `tag:<name>` from `<repo>/tags`
|
- [x] `garden/identity.go`: `Identity()` returns machine label set
|
||||||
- [ ] `garden/targeting.go`: `EntryApplies(entry, labels) bool` — match logic: `only` → any match, `never` → no match, error if both set
|
- [x] `garden/targeting.go`: `EntryApplies(entry, labels)` match logic
|
||||||
- [ ] `garden/tags.go`: `LoadTags(repoRoot)`, `SaveTag(repoRoot, tag)`, `RemoveTag(repoRoot, tag)` — read/write `<repo>/tags` file
|
- [x] `garden/tags.go`: `LoadTags`, `SaveTag`, `RemoveTag` for `<repo>/tags`
|
||||||
- [ ] `garden/garden.go`: `Init` appends `tags` to `.gitignore`
|
- [x] `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] Tests: 13 tests (identity, tags, matching: only, never, both-error, hostname, os, arch, tag, case-insensitive, multiple)
|
||||||
|
|
||||||
### Step 29: Operations Respect Targeting
|
### Step 29: Operations Respect Targeting
|
||||||
|
|
||||||
- [ ] `garden/garden.go`: `Checkpoint` skips entries where `!EntryApplies`
|
- [x] `Checkpoint` skips entries where `!EntryApplies`
|
||||||
- [ ] `garden/garden.go`: `Restore` skips entries where `!EntryApplies`
|
- [x] `Restore` skips entries where `!EntryApplies`
|
||||||
- [ ] `garden/garden.go`: `Status` reports `skipped` for non-matching entries (or omits them — TBD)
|
- [x] `Status` reports `skipped` for non-matching entries
|
||||||
- [ ] `garden/garden.go`: `Add` accepts `Only`/`Never` in `AddOptions`
|
- [x] `Add` accepts `Only`/`Never` in `AddOptions`, propagated through `addEntry`
|
||||||
- [ ] Tests: checkpoint/restore/status skip non-matching, add with targeting
|
- [x] Tests: 6 tests (checkpoint skip/process, status skipped, restore skip, add with only/never)
|
||||||
|
|
||||||
### Step 30: Targeting CLI Commands
|
### Step 30: Targeting CLI Commands
|
||||||
|
|
||||||
- [ ] `cmd/sgard/tag.go`: `sgard tag add <name>`, `sgard tag remove <name>`, `sgard tag list`
|
- [x] `cmd/sgard/tag.go`: tag add/remove/list
|
||||||
- [ ] `cmd/sgard/identity.go`: `sgard identity` — print full label set
|
- [x] `cmd/sgard/identity.go`: identity command
|
||||||
- [ ] `cmd/sgard/add.go`: `--only` and `--never` flags (comma-separated or repeated)
|
- [x] `cmd/sgard/add.go`: --only/--never flags
|
||||||
- [ ] `cmd/sgard/target.go`: `sgard target <path> --only <labels>`, `--never <labels>`, `--clear`
|
- [x] `cmd/sgard/target.go`: target command with --only/--never/--clear
|
||||||
- [ ] Tests: tag file CRUD, identity output
|
- [x] `garden/target.go`: SetTargeting method
|
||||||
|
|
||||||
### Step 31: Proto + Sync Update
|
### Step 31: Proto + Sync Update
|
||||||
|
|
||||||
- [ ] `proto/sgard/v1/sgard.proto`: add `repeated string only` and `repeated string never` to ManifestEntry
|
- [x] `proto/sgard/v1/sgard.proto`: only/never fields on ManifestEntry
|
||||||
- [ ] `server/convert.go`: update proto ↔ manifest conversion
|
- [x] `server/convert.go`: updated conversion
|
||||||
- [ ] Regenerate proto: `make proto`
|
- [x] Regenerated proto
|
||||||
- [ ] Tests: round-trip conversion with targeting fields
|
- [x] Tests: targeting round-trip test
|
||||||
|
|
||||||
### Step 32: Phase 5 Polish
|
### Step 32: Phase 5 Polish
|
||||||
|
|
||||||
- [ ] Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
|
- [x] Update ARCHITECTURE.md, README.md, CLAUDE.md, PROGRESS.md
|
||||||
- [ ] E2e test: add entries with targeting, push/pull, restore on different identity
|
- [x] E2e test: push/pull with targeting labels, restore respects targeting
|
||||||
- [ ] Verify: all tests pass, lint clean, both binaries compile
|
- [x] Verify: all tests pass, lint clean, both binaries compile
|
||||||
|
|
||||||
## Phase 6: Manifest Signing
|
## Phase 6: Manifest Signing
|
||||||
|
|
||||||
|
|||||||
36
README.md
36
README.md
@@ -119,6 +119,37 @@ sgard add --dir ~/.local/share/applications
|
|||||||
On `restore`, sgard creates the directory with the correct permissions
|
On `restore`, sgard creates the directory with the correct permissions
|
||||||
but doesn't touch its contents.
|
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
|
## Commands
|
||||||
|
|
||||||
### Local
|
### Local
|
||||||
@@ -129,6 +160,11 @@ but doesn't touch its contents.
|
|||||||
| `add <path>...` | Track files, directories (recursed), or symlinks |
|
| `add <path>...` | Track files, directories (recursed), or symlinks |
|
||||||
| `add --lock <path>...` | Track as locked (repo-authoritative, auto-restores on drift) |
|
| `add --lock <path>...` | Track as locked (repo-authoritative, auto-restores on drift) |
|
||||||
| `add --dir <path>` | Track directory itself without recursing into contents |
|
| `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 |
|
| `remove <path>...` | Stop tracking files |
|
||||||
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
|
| `checkpoint [-m msg]` | Re-hash tracked files and update the manifest |
|
||||||
| `restore [path...] [-f]` | Restore files to their original locations |
|
| `restore [path...] [-f]` | Restore files to their original locations |
|
||||||
|
|||||||
148
integration/phase5_test.go
Normal file
148
integration/phase5_test.go
Normal 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.
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user